llm-simple-router 0.4.1 → 0.4.2

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 (96) hide show
  1. package/dist/admin/logs.js +2 -2
  2. package/dist/admin/routes.js +2 -0
  3. package/dist/admin/settings.d.ts +7 -0
  4. package/dist/admin/settings.js +16 -0
  5. package/dist/db/index.d.ts +1 -1
  6. package/dist/db/index.js +19 -3
  7. package/dist/db/log-cleaner.d.ts +10 -0
  8. package/dist/db/log-cleaner.js +42 -0
  9. package/dist/db/logs.d.ts +27 -8
  10. package/dist/db/logs.js +40 -6
  11. package/dist/db/migrations/020_drop_log_redundancy.sql +8 -0
  12. package/dist/db/migrations/021_merge_metrics_columns.sql +28 -0
  13. package/dist/db/settings.d.ts +2 -0
  14. package/dist/db/settings.js +7 -0
  15. package/dist/index.js +13 -1
  16. package/dist/monitor/types.d.ts +3 -0
  17. package/dist/proxy/log-helpers.d.ts +0 -2
  18. package/dist/proxy/log-helpers.js +3 -5
  19. package/dist/proxy/orchestrator.d.ts +2 -0
  20. package/dist/proxy/orchestrator.js +1 -0
  21. package/dist/proxy/proxy-handler.js +29 -4
  22. package/dist/proxy/proxy-logging.d.ts +0 -1
  23. package/dist/proxy/proxy-logging.js +8 -10
  24. package/frontend-dist/assets/{CardContent-fmM_iiuR.js → CardContent-3ytnac7B.js} +1 -1
  25. package/frontend-dist/assets/CardTitle-BHZE8Rty.js +1 -0
  26. package/frontend-dist/assets/Checkbox-CMYgDuxw.js +1 -0
  27. package/frontend-dist/assets/CollapsibleTrigger-DooxvEnx.js +1 -0
  28. package/frontend-dist/assets/Collection-GDvpW_uY.js +3 -0
  29. package/frontend-dist/assets/Dashboard-BJslVTg8.js +3 -0
  30. package/frontend-dist/assets/DialogTitle-lj6NAA5R.js +1 -0
  31. package/frontend-dist/assets/{Input-BhvZ-Up7.js → Input-JApdUstN.js} +1 -1
  32. package/frontend-dist/assets/Label-IbQFgxLe.js +1 -0
  33. package/frontend-dist/assets/Login-BjuVvrPV.js +1 -0
  34. package/frontend-dist/assets/Logs-J08HyZWA.js +1 -0
  35. package/frontend-dist/assets/ModelMappings-DWVmxMy6.js +1 -0
  36. package/frontend-dist/assets/Monitor-BTEW0evp.js +1 -0
  37. package/frontend-dist/assets/{PopperContent-CHNw_qb6.js → PopperContent-ZhhkKJo0.js} +1 -1
  38. package/frontend-dist/assets/{Providers-C9ZAqHxO.js → Providers-BqLSKXuv.js} +1 -1
  39. package/frontend-dist/assets/ProxyEnhancement-TAHOKnxW.js +5 -0
  40. package/frontend-dist/assets/RetryRules-Cn6KHzgB.js +1 -0
  41. package/frontend-dist/assets/RouterKeys-CBgWAJ6-.js +1 -0
  42. package/frontend-dist/assets/SelectValue-DS4Z8y0u.js +1 -0
  43. package/frontend-dist/assets/Setup-QKmeMDtB.js +1 -0
  44. package/frontend-dist/assets/Switch-BYebebrY.js +1 -0
  45. package/frontend-dist/assets/TableHeader-B2A48qgy.js +1 -0
  46. package/frontend-dist/assets/TabsContent-BcNBY5CB.js +1 -0
  47. package/frontend-dist/assets/TabsTrigger-8W_mNsGI.js +1 -0
  48. package/frontend-dist/assets/UnifiedRequestDialog-BmEamR1L.js +3 -0
  49. package/frontend-dist/assets/UnifiedRequestDialog-Dk3IIDDx.css +1 -0
  50. package/frontend-dist/assets/VisuallyHidden-DPKPka_x.js +1 -0
  51. package/frontend-dist/assets/{VisuallyHiddenInput-cjeTgyDe.js → VisuallyHiddenInput-Bnglr6yR.js} +1 -1
  52. package/frontend-dist/assets/{alert-dialog-BoGRIC1Q.js → alert-dialog-BzyDZnoE.js} +1 -1
  53. package/frontend-dist/assets/{badge-DIO8W_W9.js → badge-BTjuxlp4.js} +1 -1
  54. package/frontend-dist/assets/{button-qxGNBunr.js → button-BKJB3nEQ.js} +2 -2
  55. package/frontend-dist/assets/{createLucideIcon-jHUFhqKn.js → createLucideIcon-igIAnu_Y.js} +1 -1
  56. package/frontend-dist/assets/dialog-C0B-Xn-S.js +1 -0
  57. package/frontend-dist/assets/file-text-Ci7Mgh3F.js +1 -0
  58. package/frontend-dist/assets/index-BrDOp_gc.js +1 -0
  59. package/frontend-dist/assets/index-DMdVJThL.css +1 -0
  60. package/frontend-dist/assets/lib-BGW4QyKP.js +1 -0
  61. package/frontend-dist/assets/{ohash.D__AXeF1-nmJ7gFbh.js → ohash.D__AXeF1-CsY_LBk-.js} +1 -1
  62. package/frontend-dist/assets/{useClipboard-CmLp2YGk.js → useClipboard-wnGQAe3I.js} +1 -1
  63. package/frontend-dist/assets/{useForwardExpose-awoGXQkg.js → useForwardExpose-bqtcPo63.js} +1 -1
  64. package/frontend-dist/assets/useNonce-DN0Hrw3l.js +1 -0
  65. package/frontend-dist/assets/x-Cy_v5hrA.js +1 -0
  66. package/frontend-dist/index.html +8 -7
  67. package/package.json +1 -1
  68. package/frontend-dist/assets/CardHeader-BzzFzZ1B.js +0 -1
  69. package/frontend-dist/assets/CardTitle-09d7O-11.js +0 -1
  70. package/frontend-dist/assets/Checkbox-DH8iqXQd.js +0 -1
  71. package/frontend-dist/assets/CollapsibleTrigger-DCRRORrU.js +0 -1
  72. package/frontend-dist/assets/Collection-DY9-Yue9.js +0 -3
  73. package/frontend-dist/assets/Dashboard-BEzoZuSm.js +0 -3
  74. package/frontend-dist/assets/DialogTitle-BeMGJzYO.js +0 -1
  75. package/frontend-dist/assets/Label-DjtouWZ7.js +0 -1
  76. package/frontend-dist/assets/LogDetailDialog-BjRsy_FR.js +0 -3
  77. package/frontend-dist/assets/Login-hOCPB-34.js +0 -1
  78. package/frontend-dist/assets/Logs-C5c3BJsg.js +0 -1
  79. package/frontend-dist/assets/ModelMappings-CDjxwyyz.js +0 -1
  80. package/frontend-dist/assets/Monitor-CPAvIREG.js +0 -1
  81. package/frontend-dist/assets/ProxyEnhancement-Ct5WbiB7.js +0 -5
  82. package/frontend-dist/assets/RetryRules-CbgyrP6w.js +0 -1
  83. package/frontend-dist/assets/RouterKeys-zmqgFEKp.js +0 -1
  84. package/frontend-dist/assets/SelectValue-CP4Sh7LP.js +0 -1
  85. package/frontend-dist/assets/Setup-BXDEPt4o.js +0 -1
  86. package/frontend-dist/assets/Switch-DF6awXqs.js +0 -1
  87. package/frontend-dist/assets/TableHeader-BKE_yVML.js +0 -1
  88. package/frontend-dist/assets/TabsTrigger-D8R7lxaI.js +0 -1
  89. package/frontend-dist/assets/TooltipTrigger-BjQXeFem.js +0 -1
  90. package/frontend-dist/assets/VisuallyHidden-B_NnkONE.js +0 -1
  91. package/frontend-dist/assets/dialog-D8pIXeSs.js +0 -1
  92. package/frontend-dist/assets/index-C_disqMY.js +0 -1
  93. package/frontend-dist/assets/index-DDp6SHfg.css +0 -1
  94. package/frontend-dist/assets/lib-DjpgwSRA.js +0 -1
  95. package/frontend-dist/assets/useNonce-_2e-GL-A.js +0 -1
  96. package/frontend-dist/assets/x-B0G-wIAB.js +0 -1
@@ -44,7 +44,7 @@ export const adminLogRoutes = (app, options, done) => {
44
44
  if (!log) {
45
45
  return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Log not found" } });
46
46
  }
47
- return reply.send({ data: log });
47
+ return reply.send(log);
48
48
  });
49
49
  app.get("/admin/api/logs/:id/children", async (request, reply) => {
50
50
  const params = request.params;
@@ -53,7 +53,7 @@ export const adminLogRoutes = (app, options, done) => {
53
53
  return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Log not found" } });
54
54
  }
55
55
  const rows = getRequestLogChildren(db, params.id);
56
- return reply.send({ data: rows });
56
+ return reply.send(rows);
57
57
  });
58
58
  app.delete("/admin/api/logs/before", { schema: { body: DeleteLogsBeforeSchema } }, async (request, reply) => {
59
59
  const body = request.body;
@@ -10,6 +10,7 @@ import { adminProxyEnhancementRoutes } from "./proxy-enhancement.js";
10
10
  import { adminRouterKeyRoutes } from "./router-keys.js";
11
11
  import { adminSetupRoutes } from "./setup.js";
12
12
  import { adminMonitorRoutes } from "./monitor.js";
13
+ import { adminSettingsRoutes } from "./settings.js";
13
14
  import { adminRecommendedRoutes } from "./recommended.js";
14
15
  import { adminUsageRoutes } from "./usage.js";
15
16
  export const adminRoutes = (app, options, done) => {
@@ -27,6 +28,7 @@ export const adminRoutes = (app, options, done) => {
27
28
  app.register(adminMetricsRoutes, { db: options.db });
28
29
  app.register(adminProxyEnhancementRoutes, { db: options.db });
29
30
  app.register(adminMonitorRoutes, { tracker: options.tracker });
31
+ app.register(adminSettingsRoutes, { db: options.db });
30
32
  app.register(adminRecommendedRoutes, { db: options.db });
31
33
  app.register(adminUsageRoutes, { db: options.db });
32
34
  done();
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface SettingsOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminSettingsRoutes: FastifyPluginCallback<SettingsOptions>;
7
+ export {};
@@ -0,0 +1,16 @@
1
+ import { getLogRetentionDays, setLogRetentionDays } from "../db/settings.js";
2
+ export const adminSettingsRoutes = (app, options, done) => {
3
+ const { db } = options;
4
+ app.get("/admin/api/settings/log-retention", async () => {
5
+ return { days: getLogRetentionDays(db) };
6
+ });
7
+ app.put("/admin/api/settings/log-retention", async (request) => {
8
+ const { days } = request.body;
9
+ if (!Number.isInteger(days) || days < 0 || days > 90) {
10
+ throw { statusCode: 400, message: "days must be integer 0-90" };
11
+ }
12
+ setLogRetentionDays(db, days);
13
+ return { days };
14
+ });
15
+ done();
16
+ };
@@ -6,7 +6,7 @@ export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMa
6
6
  export type { ModelMapping, MappingGroup, ProviderModelEntry } from "./mappings.js";
7
7
  export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "./retry-rules.js";
8
8
  export type { RetryRule } from "./retry-rules.js";
9
- export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, } from "./logs.js";
9
+ export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, backfillMetricsFromRequestMetrics, } from "./logs.js";
10
10
  export type { RequestLog, RequestLogGroupedRow, RequestLogListRow } from "./logs.js";
11
11
  export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
12
12
  export type { RouterKey } from "./router-keys.js";
package/dist/db/index.js CHANGED
@@ -5,11 +5,17 @@ import { fileURLToPath } from "url";
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = dirname(__filename);
7
7
  const MIGRATIONS_DIR = join(__dirname, "migrations");
8
+ const MIGRATION_RENAMES = {
9
+ "019_drop_log_redundancy.sql": "020_drop_log_redundancy.sql",
10
+ "020_merge_metrics_columns.sql": "021_merge_metrics_columns.sql",
11
+ };
8
12
  export function initDatabase(dbPath) {
9
13
  if (dbPath !== ":memory:") {
10
14
  mkdirSync(dirname(dbPath), { recursive: true });
11
15
  }
12
16
  const db = new Database(dbPath);
17
+ db.pragma("journal_mode = WAL");
18
+ db.pragma("foreign_keys = ON");
13
19
  db.exec(`
14
20
  CREATE TABLE IF NOT EXISTS migrations (
15
21
  name TEXT PRIMARY KEY,
@@ -17,6 +23,14 @@ export function initDatabase(dbPath) {
17
23
  );
18
24
  `);
19
25
  const applied = new Set(db.prepare("SELECT name FROM migrations").all().map((r) => r.name));
26
+ // 将已应用的旧文件名更新为新文件名,避免重命名后重复执行
27
+ for (const [oldName, newName] of Object.entries(MIGRATION_RENAMES)) {
28
+ if (applied.has(oldName) && !applied.has(newName)) {
29
+ db.prepare("UPDATE migrations SET name = ? WHERE name = ?").run(newName, oldName);
30
+ applied.delete(oldName);
31
+ applied.add(newName);
32
+ }
33
+ }
20
34
  const files = readdirSync(MIGRATIONS_DIR)
21
35
  .filter((f) => f.endsWith(".sql"))
22
36
  .sort();
@@ -25,13 +39,15 @@ export function initDatabase(dbPath) {
25
39
  continue;
26
40
  try {
27
41
  const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf-8");
28
- db.exec(sql);
42
+ db.transaction(() => {
43
+ db.exec(sql);
44
+ db.prepare("INSERT INTO migrations (name, applied_at) VALUES (?, ?)").run(file, new Date().toISOString());
45
+ })();
29
46
  }
30
47
  catch (err) {
31
48
  console.error(`Failed to apply migration ${file}:`, err);
32
49
  throw err;
33
50
  }
34
- db.prepare("INSERT INTO migrations (name, applied_at) VALUES (?, ?)").run(file, new Date().toISOString());
35
51
  }
36
52
  return db;
37
53
  }
@@ -39,7 +55,7 @@ export function initDatabase(dbPath) {
39
55
  export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, PROVIDER_CONCURRENCY_DEFAULTS, } from "./providers.js";
40
56
  export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
41
57
  export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "./retry-rules.js";
42
- export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, } from "./logs.js";
58
+ export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, backfillMetricsFromRequestMetrics, } from "./logs.js";
43
59
  export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
44
60
  export { getMetricsSummary, getMetricsTimeseries, insertMetrics } from "./metrics.js";
45
61
  export { getStats } from "./stats.js";
@@ -0,0 +1,10 @@
1
+ import Database from "better-sqlite3";
2
+ export interface LogCleanupHandle {
3
+ stop: () => void;
4
+ }
5
+ /** 运行一次清理,返回删除条数 */
6
+ export declare function runLogCleanup(db: Database.Database): number;
7
+ /** 启动定时清理,返回 handle 用于停止 */
8
+ export declare function scheduleLogCleanup(db: Database.Database, log: {
9
+ info: (msg: string) => void;
10
+ }): LogCleanupHandle;
@@ -0,0 +1,42 @@
1
+ import { deleteLogsBefore } from "./logs.js";
2
+ import { getLogRetentionDays } from "./settings.js";
3
+ const MS_PER_DAY = 86_400_000;
4
+ const CLEANUP_INTERVAL_MS = 3_600_000; // 1 小时
5
+ /** 运行一次清理,返回删除条数 */
6
+ export function runLogCleanup(db) {
7
+ const days = getLogRetentionDays(db);
8
+ if (days <= 0)
9
+ return 0;
10
+ const cutoff = new Date(Date.now() - days * MS_PER_DAY).toISOString();
11
+ return deleteLogsBefore(db, cutoff);
12
+ }
13
+ /** 启动定时清理,返回 handle 用于停止 */
14
+ export function scheduleLogCleanup(db, log) {
15
+ let cleaning = false;
16
+ let timer = null;
17
+ const doCleanup = () => {
18
+ if (cleaning)
19
+ return;
20
+ cleaning = true;
21
+ try {
22
+ const deleted = runLogCleanup(db);
23
+ if (deleted > 0)
24
+ log.info(`Log cleanup: deleted ${deleted} records`);
25
+ }
26
+ finally {
27
+ cleaning = false;
28
+ }
29
+ };
30
+ // 启动时立即执行一次
31
+ doCleanup();
32
+ // 定时执行
33
+ timer = setInterval(doCleanup, CLEANUP_INTERVAL_MS);
34
+ return {
35
+ stop: () => {
36
+ if (timer) {
37
+ clearInterval(timer);
38
+ timer = null;
39
+ }
40
+ },
41
+ };
42
+ }
package/dist/db/logs.d.ts CHANGED
@@ -9,21 +9,27 @@ export interface RequestLog {
9
9
  is_stream: number;
10
10
  error_message: string | null;
11
11
  created_at: string;
12
- request_body: string | null;
13
- response_body: string | null;
14
12
  client_request: string | null;
15
13
  upstream_request: string | null;
16
14
  upstream_response: string | null;
17
- client_response: string | null;
18
15
  is_retry: number;
19
16
  is_failover: number;
20
17
  original_request_id: string | null;
21
18
  original_model: string | null;
19
+ input_tokens: number | null;
20
+ output_tokens: number | null;
21
+ cache_read_tokens: number | null;
22
+ ttft_ms: number | null;
23
+ tokens_per_second: number | null;
24
+ stop_reason: string | null;
25
+ backend_model: string | null;
26
+ metrics_complete: number;
27
+ stream_text_content: string | null;
22
28
  }
23
- /** 列表查询扩展字段:JOIN request_metrics + providers 获得 */
29
+ /** 列表查询扩展字段:JOIN providers 获得 provider_name */
24
30
  export interface RequestLogListRow extends RequestLog {
25
- backend_model: string | null;
26
31
  provider_name: string | null;
32
+ child_count?: number;
27
33
  }
28
34
  export interface RequestLogInsert {
29
35
  id: string;
@@ -35,12 +41,9 @@ export interface RequestLogInsert {
35
41
  is_stream: number;
36
42
  error_message: string | null;
37
43
  created_at: string;
38
- request_body?: string | null;
39
- response_body?: string | null;
40
44
  client_request?: string | null;
41
45
  upstream_request?: string | null;
42
46
  upstream_response?: string | null;
43
- client_response?: string | null;
44
47
  is_retry?: number;
45
48
  is_failover?: number;
46
49
  original_request_id?: string | null;
@@ -62,6 +65,21 @@ export declare function getRequestLogs(db: Database.Database, options: {
62
65
  total: number;
63
66
  };
64
67
  export declare function getRequestLogById(db: Database.Database, id: string): RequestLog | undefined;
68
+ type MetricsUpdate = {
69
+ input_tokens?: number | null;
70
+ output_tokens?: number | null;
71
+ cache_read_tokens?: number | null;
72
+ ttft_ms?: number | null;
73
+ tokens_per_second?: number | null;
74
+ stop_reason?: string | null;
75
+ is_complete?: number;
76
+ };
77
+ /** 双写:collectTransportMetrics 写 request_metrics 的同时,更新 request_logs 的冗余列 */
78
+ export declare function updateLogMetrics(db: Database.Database, logId: string, m: MetricsUpdate): void;
79
+ /** 流式请求完成后,将 tracker 中累积的文本内容写入 request_logs */
80
+ export declare function updateLogStreamContent(db: Database.Database, logId: string, textContent: string): void;
81
+ /** 启动时回填:从 request_metrics 补齐 metrics_complete = 0 但实际有指标的行 */
82
+ export declare function backfillMetricsFromRequestMetrics(db: Database.Database): number;
65
83
  export declare function deleteLogsBefore(db: Database.Database, beforeDate: string): number;
66
84
  /** 查询某条日志的子请求(retry/failover 关联),上限 100 条 */
67
85
  export declare function getRequestLogChildren(db: Database.Database, parentId: string): RequestLogListRow[];
@@ -82,3 +100,4 @@ export declare function getRequestLogsGrouped(db: Database.Database, options: {
82
100
  data: RequestLogGroupedRow[];
83
101
  total: number;
84
102
  };
103
+ export {};
package/dist/db/logs.js CHANGED
@@ -1,14 +1,17 @@
1
1
  // --- request_logs ---
2
- /** 三处日志列表查询共享的 SELECT 列 + JOIN 子句 */
2
+ /** 日志列表查询共享的 SELECT 列 + JOIN 子句(metrics 已冗余到 request_logs,无需 JOIN request_metrics) */
3
3
  const LOG_LIST_SELECT = `rl.id, rl.api_type, rl.model, rl.provider_id, rl.status_code, rl.latency_ms,
4
4
  rl.is_stream, rl.error_message, rl.created_at, rl.is_retry, rl.is_failover, rl.original_request_id, rl.original_model,
5
5
  CASE WHEN rl.provider_id = 'router' THEN rl.upstream_request ELSE NULL END AS upstream_request,
6
- rm.backend_model, COALESCE(p.name, rl.provider_id) AS provider_name`;
7
- const LOG_LIST_JOIN = `LEFT JOIN request_metrics rm ON rm.request_log_id = rl.id
8
- LEFT JOIN providers p ON p.id = rl.provider_id`;
6
+ rl.input_tokens, rl.output_tokens, rl.cache_read_tokens, rl.ttft_ms, rl.tokens_per_second, rl.stop_reason,
7
+ rl.backend_model, rl.metrics_complete,
8
+ COALESCE(p.name, rl.provider_id) AS provider_name`;
9
+ const LOG_LIST_JOIN = `LEFT JOIN providers p ON p.id = rl.provider_id`;
9
10
  export function insertRequestLog(db, log) {
10
- 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, is_failover, original_request_id, router_key_id, original_model)
11
- 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.is_failover ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null);
11
+ db.prepare(`INSERT INTO request_logs (id, api_type, model, provider_id, status_code, latency_ms,
12
+ is_stream, error_message, created_at, client_request, upstream_request, upstream_response,
13
+ is_retry, is_failover, original_request_id, router_key_id, original_model)
14
+ 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.client_request ?? null, log.upstream_request ?? null, log.upstream_response ?? null, log.is_retry ?? 0, log.is_failover ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null);
12
15
  }
13
16
  function buildLogWhereClause(options, baseCondition) {
14
17
  let where = baseCondition;
@@ -54,6 +57,37 @@ export function getRequestLogs(db, options) {
54
57
  export function getRequestLogById(db, id) {
55
58
  return db.prepare("SELECT * FROM request_logs WHERE id = ?").get(id);
56
59
  }
60
+ /** 双写:collectTransportMetrics 写 request_metrics 的同时,更新 request_logs 的冗余列 */
61
+ export function updateLogMetrics(db, logId, m) {
62
+ db.prepare(`UPDATE request_logs SET
63
+ input_tokens = ?, output_tokens = ?, cache_read_tokens = ?,
64
+ ttft_ms = ?, tokens_per_second = ?, stop_reason = ?,
65
+ backend_model = (SELECT backend_model FROM request_metrics WHERE request_log_id = ?),
66
+ metrics_complete = ?
67
+ WHERE id = ?`).run(m.input_tokens ?? null, m.output_tokens ?? null, m.cache_read_tokens ?? null, m.ttft_ms ?? null, m.tokens_per_second ?? null, m.stop_reason ?? null, logId, m.is_complete ?? 1, logId);
68
+ }
69
+ /** 流式请求完成后,将 tracker 中累积的文本内容写入 request_logs */
70
+ export function updateLogStreamContent(db, logId, textContent) {
71
+ db.prepare("UPDATE request_logs SET stream_text_content = ? WHERE id = ?").run(textContent, logId);
72
+ }
73
+ /** 启动时回填:从 request_metrics 补齐 metrics_complete = 0 但实际有指标的行 */
74
+ export function backfillMetricsFromRequestMetrics(db) {
75
+ return db.prepare(`
76
+ UPDATE request_logs
77
+ SET
78
+ input_tokens = rm.input_tokens,
79
+ output_tokens = rm.output_tokens,
80
+ cache_read_tokens = rm.cache_read_tokens,
81
+ ttft_ms = rm.ttft_ms,
82
+ tokens_per_second = rm.tokens_per_second,
83
+ stop_reason = rm.stop_reason,
84
+ backend_model = rm.backend_model,
85
+ metrics_complete = rm.is_complete
86
+ FROM request_metrics rm
87
+ WHERE rm.request_log_id = request_logs.id
88
+ AND request_logs.metrics_complete = 0
89
+ `).run().changes;
90
+ }
57
91
  export function deleteLogsBefore(db, beforeDate) {
58
92
  return db.prepare("DELETE FROM request_logs WHERE created_at < ?").run(beforeDate).changes;
59
93
  }
@@ -0,0 +1,8 @@
1
+ -- 019_drop_log_redundancy.sql
2
+ -- 删除冗余大文本字段:request_body = client_request.body, response_body = upstream_response.body, client_response ≈ upstream_response
3
+ ALTER TABLE request_logs DROP COLUMN request_body;
4
+ ALTER TABLE request_logs DROP COLUMN response_body;
5
+ ALTER TABLE request_logs DROP COLUMN client_response;
6
+
7
+ -- 日志自动清理保留天数(默认 3 天,0 = 不自动清理)
8
+ INSERT OR IGNORE INTO settings (key, value) VALUES ('log_retention_days', '3');
@@ -0,0 +1,28 @@
1
+ -- V1 双写:将 request_metrics 列冗余到 request_logs,消除日志查询的 JOIN
2
+ -- request_metrics 表保留不动,聚合查询仍查它
3
+
4
+ ALTER TABLE request_logs ADD COLUMN input_tokens INTEGER;
5
+ ALTER TABLE request_logs ADD COLUMN output_tokens INTEGER;
6
+ ALTER TABLE request_logs ADD COLUMN cache_read_tokens INTEGER;
7
+ ALTER TABLE request_logs ADD COLUMN ttft_ms INTEGER;
8
+ ALTER TABLE request_logs ADD COLUMN tokens_per_second REAL;
9
+ ALTER TABLE request_logs ADD COLUMN stop_reason TEXT;
10
+ ALTER TABLE request_logs ADD COLUMN backend_model TEXT;
11
+ ALTER TABLE request_logs ADD COLUMN metrics_complete INTEGER NOT NULL DEFAULT 0;
12
+
13
+ -- 流式请求的累积文本内容(从 tracker.appendStreamChunk 中提取的纯文本)
14
+ ALTER TABLE request_logs ADD COLUMN stream_text_content TEXT;
15
+
16
+ -- 回填历史数据:把已有的 request_metrics 写入 request_logs 新列
17
+ UPDATE request_logs
18
+ SET
19
+ input_tokens = rm.input_tokens,
20
+ output_tokens = rm.output_tokens,
21
+ cache_read_tokens = rm.cache_read_tokens,
22
+ ttft_ms = rm.ttft_ms,
23
+ tokens_per_second = rm.tokens_per_second,
24
+ stop_reason = rm.stop_reason,
25
+ backend_model = rm.backend_model,
26
+ metrics_complete = rm.is_complete
27
+ FROM request_metrics rm
28
+ WHERE rm.request_log_id = request_logs.id;
@@ -2,3 +2,5 @@ import Database from "better-sqlite3";
2
2
  export declare function getSetting(db: Database.Database, key: string): string | null;
3
3
  export declare function setSetting(db: Database.Database, key: string, value: string): void;
4
4
  export declare function isInitialized(db: Database.Database): boolean;
5
+ export declare function getLogRetentionDays(db: Database.Database): number;
6
+ export declare function setLogRetentionDays(db: Database.Database, days: number): void;
@@ -8,3 +8,10 @@ export function setSetting(db, key, value) {
8
8
  export function isInitialized(db) {
9
9
  return getSetting(db, "initialized") === "true";
10
10
  }
11
+ export function getLogRetentionDays(db) {
12
+ const val = getSetting(db, "log_retention_days");
13
+ return val ? parseInt(val, 10) : 3;
14
+ }
15
+ export function setLogRetentionDays(db, days) {
16
+ setSetting(db, "log_retention_days", String(days));
17
+ }
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ function getProxyApiType(url) {
21
21
  const __filename = fileURLToPath(import.meta.url);
22
22
  const __dirname = path.dirname(__filename);
23
23
  import { getConfig } from "./config.js";
24
- import { initDatabase, getAllProviders } from "./db/index.js";
24
+ import { initDatabase, getAllProviders, backfillMetricsFromRequestMetrics } from "./db/index.js";
25
25
  import { loadRecommendedConfig } from "./config/recommended.js";
26
26
  import { authMiddleware } from "./middleware/auth.js";
27
27
  import { openaiProxy } from "./proxy/openai.js";
@@ -32,16 +32,19 @@ import { ProviderSemaphoreManager } from "./proxy/semaphore.js";
32
32
  import { RequestTracker } from "./monitor/request-tracker.js";
33
33
  import { modelState } from "./proxy/model-state.js";
34
34
  import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
35
+ import { scheduleLogCleanup } from "./db/log-cleaner.js";
35
36
  import fastifyStatic from "@fastify/static";
36
37
  export async function buildApp(options) {
37
38
  const config = options?.config ?? getBaseConfig();
38
39
  // 允许外部传入已初始化的 DB(测试用),否则自行创建
39
40
  let db;
41
+ let shouldBackfill = false;
40
42
  if (options?.db) {
41
43
  db = options.db;
42
44
  }
43
45
  else {
44
46
  db = initDatabase(config.DB_PATH);
47
+ shouldBackfill = true;
45
48
  }
46
49
  const isDev = process.env.NODE_ENV !== "production";
47
50
  const app = Fastify({
@@ -103,6 +106,13 @@ export async function buildApp(options) {
103
106
  return reply.code(status).send({ error: { message: fastifyError.message } });
104
107
  });
105
108
  loadRecommendedConfig();
109
+ // 启动时回填:补齐回退老版本期间缺失的 metrics 冗余列
110
+ if (shouldBackfill) {
111
+ const backfilled = backfillMetricsFromRequestMetrics(db);
112
+ if (backfilled > 0) {
113
+ app.log.info({ backfilled }, "Backfilled metrics from request_metrics");
114
+ }
115
+ }
106
116
  // 注入 DB 到 modelState 单例,启用会话级持久化
107
117
  modelState.init(db);
108
118
  const matcher = new RetryRuleMatcher();
@@ -173,11 +183,13 @@ export async function buildApp(options) {
173
183
  app.get("/health", async () => {
174
184
  return { status: "ok" };
175
185
  });
186
+ const logCleanup = scheduleLogCleanup(db, app.log);
176
187
  return {
177
188
  app,
178
189
  db,
179
190
  usageWindowTracker,
180
191
  close: async () => {
192
+ logCleanup.stop();
181
193
  tracker.stopPushInterval();
182
194
  await app.close();
183
195
  db.close();
@@ -27,6 +27,7 @@ export interface ActiveRequest {
27
27
  streamMetrics?: StreamMetricsSnapshot;
28
28
  streamContent?: StreamContentSnapshot;
29
29
  clientIp?: string;
30
+ sessionId?: string;
30
31
  completedAt?: number;
31
32
  }
32
33
  export interface AttemptSnapshot {
@@ -38,7 +39,9 @@ export interface AttemptSnapshot {
38
39
  export interface StreamMetricsSnapshot {
39
40
  inputTokens: number | null;
40
41
  outputTokens: number | null;
42
+ cacheReadTokens: number | null;
41
43
  ttftMs: number | null;
44
+ tokensPerSecond: number | null;
42
45
  stopReason: string | null;
43
46
  isComplete: boolean;
44
47
  }
@@ -17,13 +17,11 @@ export interface RequestLogParams extends LogRetryMeta {
17
17
  provider: Provider;
18
18
  isStream: boolean;
19
19
  startTime: number;
20
- reqBody: string;
21
20
  clientReq: string;
22
21
  upstreamReq: string;
23
22
  status: number;
24
23
  respBody: string | null;
25
24
  upHdrs: Record<string, string>;
26
- cliHdrs: Record<string, string>;
27
25
  routerKeyId?: string | null;
28
26
  originalModel?: string | null;
29
27
  }
@@ -1,15 +1,14 @@
1
1
  import { insertRequestLog } from "../db/index.js";
2
2
  /** 插入成功请求日志,供 openai/anthropic 插件共享 */
3
3
  export function insertSuccessLog(db, params) {
4
- const { id: logId, apiType, model, provider, isStream, startTime, reqBody, clientReq, upstreamReq, status, respBody, upHdrs, cliHdrs, isRetry = false, isFailover = false, originalRequestId = null, routerKeyId = null, originalModel = null } = params;
4
+ const { id: logId, apiType, model, provider, isStream, startTime, clientReq, upstreamReq, status, respBody, upHdrs, isRetry = false, isFailover = false, originalRequestId = null, routerKeyId = null, originalModel = null } = params;
5
5
  insertRequestLog(db, {
6
6
  id: logId, api_type: apiType, model, provider_id: provider.id,
7
7
  status_code: status, latency_ms: Date.now() - startTime,
8
8
  is_stream: isStream ? 1 : 0, error_message: null,
9
- created_at: new Date().toISOString(), request_body: reqBody,
10
- response_body: respBody, client_request: clientReq, upstream_request: upstreamReq,
9
+ created_at: new Date().toISOString(),
10
+ client_request: clientReq, upstream_request: upstreamReq,
11
11
  upstream_response: JSON.stringify({ statusCode: status, headers: upHdrs, body: respBody }),
12
- client_response: JSON.stringify({ statusCode: status, headers: cliHdrs, body: respBody }),
13
12
  is_retry: isRetry ? 1 : 0, is_failover: isFailover ? 1 : 0, original_request_id: originalRequestId,
14
13
  router_key_id: routerKeyId, original_model: originalModel,
15
14
  });
@@ -27,7 +26,6 @@ export function insertRejectedLog(params) {
27
26
  is_stream: isStream ? 1 : 0,
28
27
  error_message: errorMessage,
29
28
  created_at: new Date().toISOString(),
30
- request_body: JSON.stringify(originalBody),
31
29
  client_request: JSON.stringify({ headers: clientHeaders, body: originalBody }),
32
30
  is_failover: isFailover ? 1 : 0,
33
31
  original_request_id: originalRequestId,
@@ -21,6 +21,8 @@ export interface OrchestratorConfig {
21
21
  isStream: boolean;
22
22
  /** 外部生成的 tracker ID,用于 tracker.appendStreamChunk / tracker.update 等回调匹配 */
23
23
  trackerId?: string;
24
+ /** Claude Code 的 session ID,从 x-claude-code-session-id 请求头获取 */
25
+ sessionId?: string;
24
26
  }
25
27
  export interface HandleContext {
26
28
  streamTimeoutMs?: number;
@@ -49,6 +49,7 @@ export class ProxyOrchestrator {
49
49
  retryCount: 0,
50
50
  attempts: [],
51
51
  clientIp: request.ip,
52
+ sessionId: config.sessionId,
52
53
  };
53
54
  }
54
55
  createAbortSignal(request) {
@@ -9,6 +9,8 @@ import { logResilienceResult, collectTransportMetrics, handleIntercept, sanitize
9
9
  import { buildUpstreamHeaders } from "./proxy-core.js";
10
10
  import { UPSTREAM_SUCCESS, ProviderSwitchNeeded } from "./types.js";
11
11
  import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
12
+ import { MetricsExtractor } from "../metrics/metrics-extractor.js";
13
+ import { updateLogStreamContent } from "../db/index.js";
12
14
  import { callNonStream, callStream } from "./transport.js";
13
15
  import { insertRejectedLog } from "./log-helpers.js";
14
16
  const HTTP_ERROR_THRESHOLD = 400;
@@ -120,7 +122,9 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
120
122
  deps.tracker?.update(logId, {
121
123
  streamMetrics: {
122
124
  inputTokens: m.input_tokens, outputTokens: m.output_tokens,
123
- ttftMs: m.ttft_ms, stopReason: m.stop_reason, isComplete: m.is_complete === 1,
125
+ cacheReadTokens: m.cache_read_tokens,
126
+ ttftMs: m.ttft_ms, tokensPerSecond: m.tokens_per_second,
127
+ stopReason: m.stop_reason, isComplete: m.is_complete === 1,
124
128
  },
125
129
  });
126
130
  },
@@ -134,6 +138,20 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
134
138
  return callStream(provider, apiKey, body, cliHdrs, reply, deps.streamTimeoutMs, upstreamPath, buildUpstreamHeaders, metricsTransform, checkEarlyError);
135
139
  }
136
140
  const result = await callNonStream(provider, apiKey, body, cliHdrs, upstreamPath, buildUpstreamHeaders);
141
+ // 非流式请求:从响应体提取指标并更新 tracker
142
+ if (result.kind === "success") {
143
+ const mr = MetricsExtractor.fromNonStreamResponse(apiType, result.body);
144
+ if (mr) {
145
+ deps.tracker?.update(logId, {
146
+ streamMetrics: {
147
+ inputTokens: mr.input_tokens, outputTokens: mr.output_tokens,
148
+ cacheReadTokens: mr.cache_read_tokens,
149
+ ttftMs: mr.ttft_ms, tokensPerSecond: mr.tokens_per_second,
150
+ stopReason: mr.stop_reason, isComplete: mr.is_complete === 1,
151
+ },
152
+ });
153
+ }
154
+ }
137
155
  // 非流式响应注入模型信息标签(模型映射场景)
138
156
  if (originalModel && result.kind === "success" && result.statusCode === UPSTREAM_SUCCESS) {
139
157
  try {
@@ -150,13 +168,20 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
150
168
  return result;
151
169
  };
152
170
  try {
153
- const resilienceResult = await deps.orchestrator.handle(request, reply, apiType, { resolved, provider, clientModel: effectiveModel, isStream, trackerId: logId }, { retryMaxAttempts: deps.retryMaxAttempts, retryBaseDelayMs: deps.retryBaseDelayMs, isFailover, ruleMatcher: deps.matcher, transportFn });
171
+ const resilienceResult = await deps.orchestrator.handle(request, reply, apiType, { resolved, provider, clientModel: effectiveModel, isStream, trackerId: logId, sessionId }, { retryMaxAttempts: deps.retryMaxAttempts, retryBaseDelayMs: deps.retryBaseDelayMs, isFailover, ruleMatcher: deps.matcher, transportFn });
154
172
  const lastLogId = logResilienceResult(deps.db, {
155
173
  apiType, model: effectiveModel, providerId: provider.id, isStream,
156
- reqBodyStr, clientReq, upstreamReqBase, logId, routerKeyId, originalModel,
174
+ clientReq, upstreamReqBase, logId, routerKeyId, originalModel,
157
175
  failover: { isFailoverIteration, rootLogId: rootLogId },
158
176
  }, resilienceResult.attempts, resilienceResult.result, startTime);
159
177
  collectTransportMetrics(deps.db, apiType, resilienceResult.result, isStream, lastLogId, provider.id, resolved.backend_model, request);
178
+ // 流式请求:将 tracker 中累积的文本内容持久化到日志
179
+ if (isStream && deps.tracker) {
180
+ const req = deps.tracker.get(lastLogId);
181
+ const text = req?.streamContent?.textContent;
182
+ if (text)
183
+ updateLogStreamContent(deps.db, lastLogId, text);
184
+ }
160
185
  // Failover: 单 provider 内重试已耗尽但仍失败,尝试下一个 target
161
186
  if (isFailover && !reply.raw.headersSent) {
162
187
  const tr = resilienceResult.result;
@@ -212,7 +237,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
212
237
  id: logId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
213
238
  status_code: 502, latency_ms: Date.now() - startTime, is_stream: isStream ? 1 : 0,
214
239
  error_message: errMsg || "Upstream connection failed", created_at: new Date().toISOString(),
215
- request_body: reqBodyStr, client_request: clientReq, upstream_request: upstreamReqBase,
240
+ client_request: clientReq, upstream_request: upstreamReqBase,
216
241
  is_failover: isFailoverIteration ? 1 : 0, original_request_id: isFailoverIteration ? rootLogId : null,
217
242
  router_key_id: routerKeyId, original_model: originalModel,
218
243
  });
@@ -17,7 +17,6 @@ export declare function logResilienceResult(db: Database.Database, params: {
17
17
  model: string;
18
18
  providerId: string;
19
19
  isStream: boolean;
20
- reqBodyStr: string;
21
20
  clientReq: string;
22
21
  upstreamReqBase: string;
23
22
  logId: string;