llm-simple-router 0.5.6 → 0.6.1

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 (141) hide show
  1. package/dist/admin/groups.js +17 -68
  2. package/dist/admin/logs.js +2 -0
  3. package/dist/admin/mappings.js +17 -13
  4. package/dist/admin/metrics.js +3 -2
  5. package/dist/admin/providers.js +22 -70
  6. package/dist/admin/routes.js +2 -0
  7. package/dist/admin/schedules.d.ts +7 -0
  8. package/dist/admin/schedules.js +210 -0
  9. package/dist/db/index.d.ts +4 -2
  10. package/dist/db/index.js +32 -2
  11. package/dist/db/logs.d.ts +1 -0
  12. package/dist/db/logs.js +13 -4
  13. package/dist/db/mappings.d.ts +0 -20
  14. package/dist/db/mappings.js +17 -34
  15. package/dist/db/metrics.d.ts +10 -1
  16. package/dist/db/metrics.js +27 -28
  17. package/dist/db/migrations/011_create_mapping_groups.sql +8 -4
  18. package/dist/db/migrations/024_add_mapping_groups_is_active.sql +3 -0
  19. package/dist/db/migrations/026_create_schedules_simplify_mappings.sql +64 -0
  20. package/dist/db/migrations/{026_metrics_independent.sql → 027_metrics_independent.sql} +1 -1
  21. package/dist/db/migrations/028_ensure_strategy_column.sql +11 -0
  22. package/dist/db/migrations/029_convert_old_rule_format.sql +7 -0
  23. package/dist/db/migrations/030_add_input_tokens_estimated.sql +6 -0
  24. package/dist/db/migrations/031_add_tps_breakdown.sql +13 -0
  25. package/dist/db/migrations/032_add_non_thinking_tps.sql +3 -0
  26. package/dist/db/schedules.d.ts +31 -0
  27. package/dist/db/schedules.js +40 -0
  28. package/dist/db/stats.js +1 -1
  29. package/dist/metrics/metrics-extractor.d.ts +20 -0
  30. package/dist/metrics/metrics-extractor.js +105 -13
  31. package/dist/monitor/request-tracker.d.ts +5 -0
  32. package/dist/monitor/request-tracker.js +33 -0
  33. package/dist/monitor/types.d.ts +8 -0
  34. package/dist/proxy/mapping-resolver.d.ts +2 -2
  35. package/dist/proxy/mapping-resolver.js +144 -27
  36. package/dist/proxy/orchestrator.d.ts +3 -1
  37. package/dist/proxy/orchestrator.js +5 -1
  38. package/dist/proxy/overflow.js +1 -16
  39. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +0 -2
  40. package/dist/proxy/proxy-handler.js +20 -10
  41. package/dist/proxy/proxy-logging.js +12 -2
  42. package/dist/proxy/resilience.js +22 -1
  43. package/dist/proxy/scope.d.ts +2 -1
  44. package/dist/proxy/scope.js +2 -2
  45. package/dist/proxy/semaphore.d.ts +5 -1
  46. package/dist/proxy/semaphore.js +4 -2
  47. package/dist/proxy/strategy/types.d.ts +10 -8
  48. package/dist/proxy/strategy/types.js +1 -6
  49. package/dist/proxy/stream-proxy.js +40 -3
  50. package/dist/proxy/transport-fn.js +10 -0
  51. package/dist/proxy/types.d.ts +4 -0
  52. package/dist/utils/token-counter.d.ts +7 -0
  53. package/dist/utils/token-counter.js +81 -0
  54. package/frontend-dist/assets/{CardContent-ByybpNZM.js → CardContent-BtAcFNMy.js} +1 -1
  55. package/frontend-dist/assets/{CardTitle-Cv39_iQu.js → CardTitle-Bmwf1S5Y.js} +1 -1
  56. package/frontend-dist/assets/CascadingModelSelect-CicfrqcY.js +1 -0
  57. package/frontend-dist/assets/Checkbox-B1o39YuC.js +1 -0
  58. package/frontend-dist/assets/{CollapsibleTrigger-BPzLViBo.js → CollapsibleTrigger-2jySTCeh.js} +1 -1
  59. package/frontend-dist/assets/{Collection-Dafpcl-w.js → Collection-ChUVejsh.js} +1 -1
  60. package/frontend-dist/assets/Dashboard-DkJauxYu.js +3 -0
  61. package/frontend-dist/assets/{DialogTitle-BMNhmnin.js → DialogTitle-D0erB-Fr.js} +1 -1
  62. package/frontend-dist/assets/{Input-BkzqSK7i.js → Input-BDbKynVD.js} +1 -1
  63. package/frontend-dist/assets/{Label-DwqBcp6d.js → Label-CrHq5hrg.js} +1 -1
  64. package/frontend-dist/assets/{Login-B7sSST00.js → Login-D2YdqYnu.js} +1 -1
  65. package/frontend-dist/assets/Logs-DgeOPIkd.js +1 -0
  66. package/frontend-dist/assets/ModelMappings-De_UjiND.js +1 -0
  67. package/frontend-dist/assets/Monitor-BgRMReMF.js +1 -0
  68. package/frontend-dist/assets/PopoverTrigger-BVsxIE2L.js +1 -0
  69. package/frontend-dist/assets/PopperContent-B23SzU9H.js +1 -0
  70. package/frontend-dist/assets/Providers-DQypvsEg.js +1 -0
  71. package/frontend-dist/assets/ProxyEnhancement-Cijb2FID.js +5 -0
  72. package/frontend-dist/assets/RetryRules-CSseSPoO.js +1 -0
  73. package/frontend-dist/assets/{RouterKeys-D8rXsmpq.js → RouterKeys-ccwqoMCX.js} +1 -1
  74. package/frontend-dist/assets/{RovingFocusItem-Bo0dNNmj.js → RovingFocusItem-rwA4uA9N.js} +1 -1
  75. package/frontend-dist/assets/Schedules-8YYNjLNo.js +1 -0
  76. package/frontend-dist/assets/SelectValue-TvIOOalu.js +1 -0
  77. package/frontend-dist/assets/{Settings-YiR7zqua.js → Settings-D1WDm5lQ.js} +1 -1
  78. package/frontend-dist/assets/{Setup-D7EXZ1Nv.js → Setup-Bw-RIF9G.js} +1 -1
  79. package/frontend-dist/assets/{Switch-CA_wdlEs.js → Switch-D9wFEsMF.js} +1 -1
  80. package/frontend-dist/assets/{TableHeader-BuObvzlS.js → TableHeader-HOR173Xk.js} +1 -1
  81. package/frontend-dist/assets/{TabsTrigger-DZIFRVA_.js → TabsTrigger-BOsmgFYE.js} +1 -1
  82. package/frontend-dist/assets/{Teleport-FxAUQAZT.js → Teleport-BGbwtNTD.js} +1 -1
  83. package/frontend-dist/assets/{TooltipTrigger-D9nCGsBG.js → TooltipTrigger-DPzNY2Sp.js} +1 -1
  84. package/frontend-dist/assets/UnifiedRequestDialog-BjEigSaR.css +1 -0
  85. package/frontend-dist/assets/UnifiedRequestDialog-CevmD2P2.js +3 -0
  86. package/frontend-dist/assets/VisuallyHidden-ChauvWtH.js +1 -0
  87. package/frontend-dist/assets/{VisuallyHiddenInput-CmDbYWUO.js → VisuallyHiddenInput-BcDuL0V8.js} +1 -1
  88. package/frontend-dist/assets/{alert-dialog-BvbdNhnK.js → alert-dialog-DgtxmV7t.js} +1 -1
  89. package/frontend-dist/assets/arrow-down-BuK6B6yc.js +1 -0
  90. package/frontend-dist/assets/{badge-CcCt1-ig.js → badge-NUBqZBxu.js} +1 -1
  91. package/frontend-dist/assets/{button-DeOsxcjG.js → button-BLX8zWc1.js} +2 -2
  92. package/frontend-dist/assets/check-CsZv9cnK.js +1 -0
  93. package/frontend-dist/assets/constants-D_0jiLjw.js +1 -0
  94. package/frontend-dist/assets/copy-BMWzukd1.js +1 -0
  95. package/frontend-dist/assets/{dialog-CPO2KcC1.js → dialog-Dsvgfiw-.js} +1 -1
  96. package/frontend-dist/assets/{file-text-C-6LFEhP.js → file-text-CqJ33eWr.js} +1 -1
  97. package/frontend-dist/assets/{format-K3VR67cG.js → format-Dln15Luw.js} +1 -1
  98. package/frontend-dist/assets/index-BEKWug0p.js +1 -0
  99. package/frontend-dist/assets/index-_Icfkt3I.css +1 -0
  100. package/frontend-dist/assets/{lib-DkM_rWnj.js → lib-DQotd1d8.js} +1 -1
  101. package/frontend-dist/assets/loader-circle-CnEL8ILi.js +1 -0
  102. package/frontend-dist/assets/ohash.D__AXeF1-D5e5Wyzx.js +1 -0
  103. package/frontend-dist/assets/{useClipboard-NBCgpr6Z.js → useClipboard-BDAhyrgL.js} +1 -1
  104. package/frontend-dist/assets/useFocusGuards-CHAbXhQp.js +1 -0
  105. package/frontend-dist/assets/useFormControl-CCVkIi3o.js +1 -0
  106. package/frontend-dist/assets/{useLogRetention-B_u8u74J.js → useLogRetention-X-CkHhJ7.js} +1 -1
  107. package/frontend-dist/assets/useNonce-DyF1ycZV.js +1 -0
  108. package/frontend-dist/assets/x-DMAovOe-.js +1 -0
  109. package/frontend-dist/index.html +22 -20
  110. package/package.json +1 -1
  111. package/dist/proxy/strategy/failover.d.ts +0 -4
  112. package/dist/proxy/strategy/failover.js +0 -8
  113. package/dist/proxy/strategy/random.d.ts +0 -4
  114. package/dist/proxy/strategy/random.js +0 -11
  115. package/dist/proxy/strategy/round-robin.d.ts +0 -5
  116. package/dist/proxy/strategy/round-robin.js +0 -16
  117. package/dist/proxy/strategy/scheduled.d.ts +0 -4
  118. package/dist/proxy/strategy/scheduled.js +0 -55
  119. package/dist/proxy/strategy/targets-rule.d.ts +0 -8
  120. package/dist/proxy/strategy/targets-rule.js +0 -19
  121. package/frontend-dist/assets/Checkbox-F8_Gy_s5.js +0 -1
  122. package/frontend-dist/assets/Dashboard-BTwf4ZtI.js +0 -3
  123. package/frontend-dist/assets/Logs-DJc0hZ8C.js +0 -1
  124. package/frontend-dist/assets/ModelMappings-BavaEbnL.js +0 -1
  125. package/frontend-dist/assets/Monitor-B4hCGdS-.js +0 -1
  126. package/frontend-dist/assets/PopoverTrigger-vgVugQwU.js +0 -1
  127. package/frontend-dist/assets/PopperContent-tf2A4fsa.js +0 -1
  128. package/frontend-dist/assets/Providers-BmrsbthR.js +0 -1
  129. package/frontend-dist/assets/ProxyEnhancement-BQ02PeEF.js +0 -5
  130. package/frontend-dist/assets/RetryRules-H460Dyek.js +0 -1
  131. package/frontend-dist/assets/SelectValue-BU16UnrX.js +0 -1
  132. package/frontend-dist/assets/UnifiedRequestDialog-B2nt8nLl.css +0 -1
  133. package/frontend-dist/assets/UnifiedRequestDialog-BPv5B17F.js +0 -3
  134. package/frontend-dist/assets/VisuallyHidden-clBSgYdG.js +0 -1
  135. package/frontend-dist/assets/chevron-down-D_DCDFPY.js +0 -1
  136. package/frontend-dist/assets/index-DHONWydQ.css +0 -1
  137. package/frontend-dist/assets/index-DW58MMV6.js +0 -1
  138. package/frontend-dist/assets/loader-circle-BS4uI1Z4.js +0 -1
  139. package/frontend-dist/assets/ohash.D__AXeF1-CBYQgVou.js +0 -1
  140. package/frontend-dist/assets/useNonce-D1dqoOZO.js +0 -1
  141. package/frontend-dist/assets/x-DVLhwc3Q.js +0 -1
@@ -1,17 +1,13 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, } from "../db/index.js";
3
- import { STRATEGY_NAMES } from "../proxy/strategy/types.js";
4
3
  import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_CONFLICT, HTTP_NOT_FOUND } from "./constants.js";
5
4
  import { API_CODE, apiError } from "./api-response.js";
6
- const MIN_FAILOVER_TARGETS = 2;
7
5
  const CreateGroupSchema = Type.Object({
8
6
  client_model: Type.String({ minLength: 1 }),
9
- strategy: Type.String({ minLength: 1 }),
10
7
  rule: Type.String(),
11
8
  });
12
9
  const UpdateGroupSchema = Type.Object({
13
10
  client_model: Type.Optional(Type.String({ minLength: 1 })),
14
- strategy: Type.Optional(Type.String({ minLength: 1 })),
15
11
  rule: Type.Optional(Type.String()),
16
12
  });
17
13
  function validateOverflow(db, target, label) {
@@ -31,11 +27,7 @@ function validateOverflow(db, target, label) {
31
27
  }
32
28
  return undefined;
33
29
  }
34
- async function validateRule(db, strategy, ruleJson) {
35
- const VALID_STRATEGIES = new Set(Object.values(STRATEGY_NAMES));
36
- if (!VALID_STRATEGIES.has(strategy)) {
37
- return `Unknown strategy '${strategy}'. Valid: ${[...VALID_STRATEGIES].join(", ")}`;
38
- }
30
+ function validateRule(db, ruleJson) {
39
31
  let rule;
40
32
  try {
41
33
  rule = JSON.parse(ruleJson);
@@ -43,59 +35,24 @@ async function validateRule(db, strategy, ruleJson) {
43
35
  catch {
44
36
  return "Invalid rule JSON";
45
37
  }
46
- if (strategy === STRATEGY_NAMES.SCHEDULED) {
47
- const r = rule;
48
- if (!r.default || !r.default.backend_model || !r.default.provider_id) {
49
- return "rule.default.backend_model and rule.default.provider_id are required";
38
+ if (typeof rule !== "object" || rule === null)
39
+ return "Invalid rule";
40
+ const r = rule;
41
+ if (!Array.isArray(r.targets) || r.targets.length === 0) {
42
+ return "rule.targets must be a non-empty array";
43
+ }
44
+ for (let i = 0; i < r.targets.length; i++) {
45
+ const t = r.targets[i];
46
+ if (!t.backend_model || !t.provider_id) {
47
+ return `targets[${i}] missing backend_model or provider_id`;
50
48
  }
51
- const defaultProvider = getProviderById(db, r.default.provider_id);
52
- if (!defaultProvider) {
53
- return `provider_id '${r.default.provider_id}' not found`;
49
+ const p = getProviderById(db, t.provider_id);
50
+ if (!p) {
51
+ return `targets[${i}] provider_id '${t.provider_id}' not found`;
54
52
  }
55
- const overflowErr = validateOverflow(db, r.default, "rule.default");
53
+ const overflowErr = validateOverflow(db, t, `targets[${i}]`);
56
54
  if (overflowErr)
57
55
  return overflowErr;
58
- if (r.windows !== undefined && !Array.isArray(r.windows)) {
59
- return "rule.windows must be an array";
60
- }
61
- if (Array.isArray(r.windows)) {
62
- for (let i = 0; i < r.windows.length; i++) {
63
- const w = r.windows[i];
64
- if (!w.start || !w.end || !w.target || !w.target.backend_model || !w.target.provider_id) {
65
- return `window[${i}] missing start/end/target.backend_model/target.provider_id`;
66
- }
67
- const p = getProviderById(db, w.target.provider_id);
68
- if (!p) {
69
- return `window[${i}] provider_id '${w.target.provider_id}' not found`;
70
- }
71
- const wOverflowErr = validateOverflow(db, w.target, `window[${i}]`);
72
- if (wOverflowErr)
73
- return wOverflowErr;
74
- }
75
- }
76
- }
77
- if (strategy === STRATEGY_NAMES.ROUND_ROBIN || strategy === STRATEGY_NAMES.RANDOM || strategy === STRATEGY_NAMES.FAILOVER) {
78
- const r = rule;
79
- if (!Array.isArray(r.targets) || r.targets.length === 0) {
80
- return "rule.targets must be a non-empty array";
81
- }
82
- const minTargets = strategy === STRATEGY_NAMES.FAILOVER ? MIN_FAILOVER_TARGETS : 1;
83
- if (r.targets.length < minTargets) {
84
- return `strategy '${strategy}' requires at least ${minTargets} target(s)`;
85
- }
86
- for (let i = 0; i < r.targets.length; i++) {
87
- const t = r.targets[i];
88
- if (!t.backend_model || !t.provider_id) {
89
- return `targets[${i}] missing backend_model or provider_id`;
90
- }
91
- const p = getProviderById(db, t.provider_id);
92
- if (!p) {
93
- return `targets[${i}] provider_id '${t.provider_id}' not found`;
94
- }
95
- const overflowErr = validateOverflow(db, t, `targets[${i}]`);
96
- if (overflowErr)
97
- return overflowErr;
98
- }
99
56
  }
100
57
  return undefined;
101
58
  }
@@ -107,14 +64,13 @@ export const adminGroupRoutes = (app, options, done) => {
107
64
  });
108
65
  app.post("/admin/api/mapping-groups", { schema: { body: CreateGroupSchema } }, async (request, reply) => {
109
66
  const body = request.body;
110
- const validationError = await validateRule(db, body.strategy, body.rule);
67
+ const validationError = validateRule(db, body.rule);
111
68
  if (validationError) {
112
69
  return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, validationError));
113
70
  }
114
71
  try {
115
72
  const id = createMappingGroup(db, {
116
73
  client_model: body.client_model,
117
- strategy: body.strategy,
118
74
  rule: body.rule,
119
75
  });
120
76
  return reply.code(HTTP_CREATED).send({ id });
@@ -132,13 +88,10 @@ export const adminGroupRoutes = (app, options, done) => {
132
88
  const fields = {};
133
89
  if (body.client_model !== undefined)
134
90
  fields.client_model = body.client_model;
135
- if (body.strategy !== undefined)
136
- fields.strategy = body.strategy;
137
91
  if (body.rule !== undefined)
138
92
  fields.rule = body.rule;
139
- const strategy = body.strategy ?? findGroupStrategy(db, id);
140
93
  const ruleJson = body.rule ?? findGroupRule(db, id);
141
- const validationError = await validateRule(db, strategy, ruleJson);
94
+ const validationError = validateRule(db, ruleJson);
142
95
  if (validationError) {
143
96
  return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, validationError));
144
97
  }
@@ -172,10 +125,6 @@ export const adminGroupRoutes = (app, options, done) => {
172
125
  });
173
126
  done();
174
127
  };
175
- function findGroupStrategy(db, id) {
176
- const g = getMappingGroupById(db, id);
177
- return g?.strategy ?? STRATEGY_NAMES.SCHEDULED;
178
- }
179
128
  function findGroupRule(db, id) {
180
129
  const g = getMappingGroupById(db, id);
181
130
  return g?.rule ?? "{}";
@@ -11,6 +11,7 @@ const LogQuerySchema = Type.Object({
11
11
  provider_id: Type.Optional(Type.String()),
12
12
  start_time: Type.Optional(Type.String()),
13
13
  end_time: Type.Optional(Type.String()),
14
+ status_code: Type.Optional(Type.String()),
14
15
  view: Type.Optional(Type.Literal("grouped")),
15
16
  });
16
17
  const DeleteLogsBeforeSchema = Type.Object({
@@ -33,6 +34,7 @@ export const adminLogRoutes = (app, options, done) => {
33
34
  provider_id: query.provider_id || undefined,
34
35
  start_time: query.start_time || undefined,
35
36
  end_time: query.end_time || undefined,
37
+ status_code: query.status_code || undefined,
36
38
  };
37
39
  const result = view === "grouped"
38
40
  ? getRequestLogsGrouped(db, listOptions)
@@ -1,6 +1,5 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, getMappingGroup, } from "../db/index.js";
3
- import { STRATEGY_NAMES } from "../proxy/strategy/types.js";
4
3
  import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT } from "./constants.js";
5
4
  import { API_CODE, apiError } from "./api-response.js";
6
5
  const CreateMappingSchema = Type.Object({
@@ -21,14 +20,14 @@ function toLegacy(group) {
21
20
  catch {
22
21
  return null;
23
22
  }
24
- const defaultTarget = rule?.default;
25
- if (!defaultTarget)
23
+ const targets = rule?.targets;
24
+ if (!targets || targets.length === 0)
26
25
  return null;
27
26
  return {
28
27
  id: group.id,
29
28
  client_model: group.client_model,
30
- backend_model: defaultTarget.backend_model ?? "",
31
- provider_id: defaultTarget.provider_id ?? "",
29
+ backend_model: targets[0].backend_model ?? "",
30
+ provider_id: targets[0].provider_id ?? "",
32
31
  is_active: 1,
33
32
  created_at: group.created_at,
34
33
  };
@@ -52,10 +51,8 @@ export const adminMappingRoutes = (app, options, done) => {
52
51
  try {
53
52
  const id = createMappingGroup(db, {
54
53
  client_model: body.client_model,
55
- strategy: STRATEGY_NAMES.SCHEDULED,
56
54
  rule: JSON.stringify({
57
- default: { backend_model: body.backend_model, provider_id: body.provider_id },
58
- windows: [],
55
+ targets: [{ backend_model: body.backend_model, provider_id: body.provider_id }],
59
56
  }),
60
57
  });
61
58
  return reply.code(HTTP_CREATED).send({ id });
@@ -79,19 +76,26 @@ export const adminMappingRoutes = (app, options, done) => {
79
76
  rule = JSON.parse(group.rule);
80
77
  }
81
78
  catch {
82
- rule = { default: {}, windows: [] };
79
+ rule = { targets: [{}] };
83
80
  }
84
- const defaultTarget = { ...(rule.default || {}) };
81
+ const targets = rule.targets || [];
82
+ const firstTarget = { ...(targets[0] || {}) };
85
83
  if (body.backend_model !== undefined)
86
- defaultTarget.backend_model = body.backend_model;
84
+ firstTarget.backend_model = body.backend_model;
87
85
  if (body.provider_id !== undefined) {
88
86
  const provider = getProviderById(db, body.provider_id);
89
87
  if (!provider) {
90
88
  return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.NOT_FOUND, "provider_id not found"));
91
89
  }
92
- defaultTarget.provider_id = body.provider_id;
90
+ firstTarget.provider_id = body.provider_id;
93
91
  }
94
- rule.default = defaultTarget;
92
+ if (targets.length > 0) {
93
+ targets[0] = firstTarget;
94
+ }
95
+ else {
96
+ targets.push(firstTarget);
97
+ }
98
+ rule.targets = targets;
95
99
  const fields = {
96
100
  rule: JSON.stringify(rule),
97
101
  };
@@ -10,8 +10,9 @@ const DashboardPeriodEnum = Type.Union([
10
10
  ]);
11
11
  const PeriodEnum = Type.Union([LegacyPeriodEnum, DashboardPeriodEnum]);
12
12
  const MetricEnum = Type.Union([
13
- Type.Literal("ttft"), Type.Literal("tps"), Type.Literal("tokens"),
14
- Type.Literal("cache_rate"), Type.Literal("request_count"),
13
+ Type.Literal("ttft"), Type.Literal("tps"), Type.Literal("text_tps"),
14
+ Type.Literal("thinking_tps"), Type.Literal("tool_use_tps"), Type.Literal("total_tps"),
15
+ Type.Literal("tokens"), Type.Literal("cache_rate"), Type.Literal("request_count"),
15
16
  Type.Literal("input_tokens"), Type.Literal("output_tokens"),
16
17
  Type.Literal("cache_hit_tokens"),
17
18
  ]);
@@ -1,5 +1,5 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups, getAllModelMappings, updateMappingGroup, PROVIDER_CONCURRENCY_DEFAULTS } from "../db/index.js";
2
+ import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups, updateMappingGroup, PROVIDER_CONCURRENCY_DEFAULTS } from "../db/index.js";
3
3
  import { encrypt, decrypt } from "../utils/crypto.js";
4
4
  import { getSetting } from "../db/settings.js";
5
5
  import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT, HTTP_BAD_REQUEST } from "./constants.js";
@@ -22,55 +22,29 @@ function cascadeProviderDisable(db, providerId) {
22
22
  }
23
23
  let modified = false;
24
24
  let shouldDisable = false;
25
- if (g.strategy === "scheduled") {
26
- const def = rule.default;
27
- if (def) {
28
- if (def.provider_id === providerId) {
29
- shouldDisable = true;
25
+ // 归一化旧格式 { default, windows } → { targets }(向后兼容 migration 026 前数据)
26
+ // eslint-disable-next-line taste/no-deprecated-rule-format
27
+ if (!Array.isArray(rule.targets) && typeof rule.default === "object" && rule.default !== null) {
28
+ // eslint-disable-next-line taste/no-deprecated-rule-format
29
+ rule.targets = [rule.default];
30
+ }
31
+ const targets = rule.targets;
32
+ if (Array.isArray(targets)) {
33
+ const filtered = targets.filter((t) => {
34
+ if (t.provider_id === providerId) {
30
35
  modified = true;
36
+ return false;
31
37
  }
32
- if (def.overflow_provider_id === providerId) {
33
- delete def.overflow_provider_id;
34
- delete def.overflow_model;
38
+ if (t.overflow_provider_id === providerId) {
39
+ delete t.overflow_provider_id;
40
+ delete t.overflow_model;
35
41
  modified = true;
36
42
  }
37
- }
38
- const windows = rule.windows;
39
- if (Array.isArray(windows)) {
40
- const filtered = windows.filter((w) => {
41
- if (w.target?.provider_id === providerId) {
42
- modified = true;
43
- return false;
44
- }
45
- if (w.target?.overflow_provider_id === providerId) {
46
- delete w.target.overflow_provider_id;
47
- delete w.target.overflow_model;
48
- modified = true;
49
- }
50
- return true;
51
- });
52
- rule.windows = filtered;
53
- }
54
- }
55
- else {
56
- const targets = rule.targets;
57
- if (Array.isArray(targets)) {
58
- const filtered = targets.filter((t) => {
59
- if (t.provider_id === providerId) {
60
- modified = true;
61
- return false;
62
- }
63
- if (t.overflow_provider_id === providerId) {
64
- delete t.overflow_provider_id;
65
- delete t.overflow_model;
66
- modified = true;
67
- }
68
- return true;
69
- });
70
- rule.targets = filtered;
71
- if (filtered.length === 0 && modified) {
72
- shouldDisable = true;
73
- }
43
+ return true;
44
+ });
45
+ rule.targets = filtered;
46
+ if (filtered.length === 0 && modified) {
47
+ shouldDisable = true;
74
48
  }
75
49
  }
76
50
  if (modified) {
@@ -256,22 +230,6 @@ export const adminProviderRoutes = (app, options, done) => {
256
230
  const refs = [];
257
231
  try {
258
232
  const rule = JSON.parse(g.rule);
259
- if (rule.default?.provider_id === id) {
260
- refs.push(`默认模型 (${rule.default.backend_model})`);
261
- }
262
- if (rule.default?.overflow_provider_id === id) {
263
- refs.push(`默认溢出模型 (${rule.default.overflow_model || "-"})`);
264
- }
265
- if (Array.isArray(rule.windows)) {
266
- for (const w of rule.windows) {
267
- if (w.target?.provider_id === id) {
268
- refs.push(`时间窗口 ${w.start}-${w.end} (${w.target.backend_model})`);
269
- }
270
- if (w.target?.overflow_provider_id === id) {
271
- refs.push(`时间窗口 ${w.start}-${w.end} 溢出 (${w.target.overflow_model || "-"})`);
272
- }
273
- }
274
- }
275
233
  if (Array.isArray(rule.targets)) {
276
234
  for (let i = 0; i < rule.targets.length; i++) {
277
235
  const t = rule.targets[i];
@@ -291,12 +249,6 @@ export const adminProviderRoutes = (app, options, done) => {
291
249
  references.push(`映射分组「${g.client_model}」: ${ref}`);
292
250
  }
293
251
  }
294
- const mappings = getAllModelMappings(db);
295
- for (const m of mappings) {
296
- if (m.provider_id === id) {
297
- references.push(`旧版映射「${m.client_model}」→ ${m.backend_model}`);
298
- }
299
- }
300
252
  return reply.send({ references });
301
253
  });
302
254
  app.delete("/admin/api/providers/:id", async (request, reply) => {
@@ -309,8 +261,8 @@ export const adminProviderRoutes = (app, options, done) => {
309
261
  for (const g of groups) {
310
262
  try {
311
263
  const rule = JSON.parse(g.rule);
312
- const targets = [rule.default, ...(rule.windows || [])].filter(Boolean);
313
- if (targets.some((t) => t.provider_id === id)) {
264
+ const targets = Array.isArray(rule.targets) ? rule.targets : [];
265
+ if (targets.some((t) => t?.provider_id === id)) {
314
266
  return reply.code(HTTP_CONFLICT).send(apiError(API_CODE.CONFLICT_REFERENCED, `Provider is referenced by mapping group '${g.client_model}'`));
315
267
  }
316
268
  }
@@ -15,6 +15,7 @@ import { adminRecommendedRoutes } from "./recommended.js";
15
15
  import { adminUsageRoutes } from "./usage.js";
16
16
  import { adminUpgradeRoutes } from "./upgrade.js";
17
17
  import { adminImportExportRoutes } from "./settings-import-export.js";
18
+ import { adminScheduleRoutes } from "./schedules.js";
18
19
  export const adminRoutes = (app, options, done) => {
19
20
  // Setup 路由不需要 auth
20
21
  app.register(adminSetupRoutes, { db: options.db });
@@ -23,6 +24,7 @@ export const adminRoutes = (app, options, done) => {
23
24
  app.register(adminProviderRoutes, { db: options.db, semaphoreManager: options.semaphoreManager, tracker: options.tracker });
24
25
  app.register(adminMappingRoutes, { db: options.db });
25
26
  app.register(adminGroupRoutes, { db: options.db });
27
+ app.register(adminScheduleRoutes, { db: options.db });
26
28
  app.register(adminRetryRuleRoutes, { db: options.db, matcher: options.matcher });
27
29
  app.register(adminLogRoutes, { db: options.db });
28
30
  app.register(adminRouterKeyRoutes, { db: options.db });
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface ScheduleRoutesOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminScheduleRoutes: FastifyPluginCallback<ScheduleRoutesOptions>;
7
+ export {};
@@ -0,0 +1,210 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getSchedulesByGroup, getAllSchedules, getScheduleById, createSchedule, updateSchedule, deleteSchedule, } from "../db/index.js";
3
+ import { getMappingGroupById, getProviderById } from "../db/index.js";
4
+ import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND } from "./constants.js";
5
+ import { API_CODE, apiError } from "./api-response.js";
6
+ const CreateScheduleSchema = Type.Object({
7
+ mapping_group_id: Type.String({ minLength: 1 }),
8
+ name: Type.String({ minLength: 1 }),
9
+ week: Type.String(),
10
+ start_hour: Type.Number({ minimum: 0, maximum: 23 }),
11
+ end_hour: Type.Number({ minimum: 1, maximum: 24 }),
12
+ mapping_rule: Type.String(),
13
+ concurrency_rule: Type.Optional(Type.String()),
14
+ });
15
+ const UpdateScheduleSchema = Type.Object({
16
+ name: Type.Optional(Type.String({ minLength: 1 })),
17
+ enabled: Type.Optional(Type.Number()),
18
+ week: Type.Optional(Type.String()),
19
+ start_hour: Type.Optional(Type.Number({ minimum: 0, maximum: 23 })),
20
+ end_hour: Type.Optional(Type.Number({ minimum: 1, maximum: 24 })),
21
+ mapping_rule: Type.Optional(Type.String()),
22
+ concurrency_rule: Type.Optional(Type.String()),
23
+ });
24
+ function validateMappingRule(db, ruleJson) {
25
+ let rule;
26
+ try {
27
+ rule = JSON.parse(ruleJson);
28
+ }
29
+ catch {
30
+ return "Invalid mapping_rule JSON";
31
+ }
32
+ if (typeof rule !== "object" || rule === null)
33
+ return "Invalid mapping_rule";
34
+ const r = rule;
35
+ if (!Array.isArray(r.targets) || r.targets.length === 0) {
36
+ return "mapping_rule.targets must be a non-empty array";
37
+ }
38
+ for (let i = 0; i < r.targets.length; i++) {
39
+ const t = r.targets[i];
40
+ if (!t.backend_model || !t.provider_id) {
41
+ return `targets[${i}] missing backend_model or provider_id`;
42
+ }
43
+ const p = getProviderById(db, t.provider_id);
44
+ if (!p)
45
+ return `targets[${i}] provider_id '${t.provider_id}' not found`;
46
+ const hasOverflowProvider = !!t.overflow_provider_id;
47
+ const hasOverflowModel = !!t.overflow_model;
48
+ if (hasOverflowProvider && !hasOverflowModel) {
49
+ return `targets[${i}]: overflow_provider_id requires overflow_model`;
50
+ }
51
+ if (hasOverflowModel && !hasOverflowProvider) {
52
+ return `targets[${i}]: overflow_model requires overflow_provider_id`;
53
+ }
54
+ if (hasOverflowProvider) {
55
+ const op = getProviderById(db, t.overflow_provider_id);
56
+ if (!op)
57
+ return `targets[${i}]: overflow_provider_id '${t.overflow_provider_id}' not found`;
58
+ }
59
+ }
60
+ return undefined;
61
+ }
62
+ /** 解析 week JSON 为数字数组,失败返回 null */
63
+ function parseWeekSafe(weekJson) {
64
+ try {
65
+ const arr = JSON.parse(weekJson);
66
+ if (!Array.isArray(arr) || !arr.every((d) => typeof d === "number" && d >= 0 && d <= 6))
67
+ return null;
68
+ return arr;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** 检查同组 schedules 是否与 [startHour, endHour) 时段重叠(按星期交集判断) */
75
+ function checkOverlap(db, groupId, excludeId, weekDays, startHour, endHour) {
76
+ const existing = getSchedulesByGroup(db, groupId);
77
+ for (const s of existing) {
78
+ if (excludeId && s.id === excludeId)
79
+ continue;
80
+ const sWeek = parseWeekSafe(s.week);
81
+ if (!sWeek)
82
+ continue;
83
+ // 星期有交集 AND 时段有交集 才算重叠
84
+ const weekOverlap = weekDays.some(d => sWeek.includes(d));
85
+ const timeOverlap = startHour < s.end_hour && endHour > s.start_hour;
86
+ if (weekOverlap && timeOverlap) {
87
+ const days = weekDays.map(d => ["周日", "周一", "周二", "周三", "周四", "周五", "周六"][d]).join("、");
88
+ return `时段与「${s.name}」重叠 (${days} ${formatHour(startHour)}-${formatHour(endHour)} vs ${formatHour(s.start_hour)}-${formatHour(s.end_hour)})`;
89
+ }
90
+ }
91
+ return undefined;
92
+ }
93
+ function formatHour(h) {
94
+ return String(h).padStart(2, "0") + ":00";
95
+ }
96
+ export const adminScheduleRoutes = (app, options, done) => {
97
+ const { db } = options;
98
+ app.get("/admin/api/schedules", async (_request, reply) => {
99
+ const schedules = getAllSchedules(db);
100
+ return reply.send(schedules);
101
+ });
102
+ app.get("/admin/api/schedules/group/:groupId", async (request, reply) => {
103
+ const { groupId } = request.params;
104
+ const group = getMappingGroupById(db, groupId);
105
+ if (!group)
106
+ return reply.code(HTTP_NOT_FOUND).send(apiError(API_CODE.NOT_FOUND, "Mapping group not found"));
107
+ const schedules = getSchedulesByGroup(db, groupId);
108
+ return reply.send(schedules);
109
+ });
110
+ app.post("/admin/api/schedules", { schema: { body: CreateScheduleSchema } }, async (request, reply) => {
111
+ const body = request.body;
112
+ const group = getMappingGroupById(db, body.mapping_group_id);
113
+ if (!group)
114
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "mapping_group_id not found"));
115
+ const ruleErr = validateMappingRule(db, body.mapping_rule);
116
+ if (ruleErr)
117
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, ruleErr));
118
+ if (body.concurrency_rule) {
119
+ try {
120
+ JSON.parse(body.concurrency_rule);
121
+ }
122
+ catch {
123
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "Invalid concurrency_rule JSON"));
124
+ }
125
+ }
126
+ if (body.start_hour >= body.end_hour) {
127
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "start_hour must be < end_hour"));
128
+ }
129
+ const weekDays = parseWeekSafe(body.week);
130
+ if (!weekDays) {
131
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "week must be array of 0-6"));
132
+ }
133
+ const overlapErr = checkOverlap(db, body.mapping_group_id, undefined, weekDays, body.start_hour, body.end_hour);
134
+ if (overlapErr) {
135
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, overlapErr));
136
+ }
137
+ const id = createSchedule(db, {
138
+ mapping_group_id: body.mapping_group_id,
139
+ name: body.name,
140
+ week: body.week,
141
+ start_hour: body.start_hour,
142
+ end_hour: body.end_hour,
143
+ mapping_rule: body.mapping_rule,
144
+ concurrency_rule: body.concurrency_rule,
145
+ });
146
+ return reply.code(HTTP_CREATED).send({ id });
147
+ });
148
+ app.put("/admin/api/schedules/:id", { schema: { body: UpdateScheduleSchema } }, async (request, reply) => {
149
+ const { id } = request.params;
150
+ const body = request.body;
151
+ const existing = getScheduleById(db, id);
152
+ if (!existing)
153
+ return reply.code(HTTP_NOT_FOUND).send(apiError(API_CODE.NOT_FOUND, "Schedule not found"));
154
+ const mappingRule = body.mapping_rule ?? existing.mapping_rule;
155
+ const ruleErr = validateMappingRule(db, mappingRule);
156
+ if (ruleErr)
157
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, ruleErr));
158
+ if (body.concurrency_rule) {
159
+ try {
160
+ JSON.parse(body.concurrency_rule);
161
+ }
162
+ catch {
163
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "Invalid concurrency_rule JSON"));
164
+ }
165
+ }
166
+ const startH = body.start_hour ?? existing.start_hour;
167
+ const endH = body.end_hour ?? existing.end_hour;
168
+ if (startH >= endH) {
169
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "start_hour must be < end_hour"));
170
+ }
171
+ // 时段重叠校验(仅在 week 或 start/end 有变更时)
172
+ if (body.week !== undefined || body.start_hour !== undefined || body.end_hour !== undefined) {
173
+ const weekDays = parseWeekSafe(body.week ?? existing.week);
174
+ if (!weekDays) {
175
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, "week must be array of 0-6"));
176
+ }
177
+ const overlapErr = checkOverlap(db, existing.mapping_group_id, id, weekDays, startH, endH);
178
+ if (overlapErr) {
179
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, overlapErr));
180
+ }
181
+ }
182
+ const fields = {};
183
+ const UPDATE_FIELDS = ["name", "enabled", "week", "start_hour", "end_hour", "mapping_rule", "concurrency_rule"];
184
+ const bodyObj = body;
185
+ for (const key of UPDATE_FIELDS) {
186
+ if (bodyObj[key] !== undefined)
187
+ fields[key] = bodyObj[key];
188
+ }
189
+ updateSchedule(db, id, fields);
190
+ return reply.send({ success: true });
191
+ });
192
+ app.delete("/admin/api/schedules/:id", async (request, reply) => {
193
+ const { id } = request.params;
194
+ const existing = getScheduleById(db, id);
195
+ if (!existing)
196
+ return reply.code(HTTP_NOT_FOUND).send(apiError(API_CODE.NOT_FOUND, "Schedule not found"));
197
+ deleteSchedule(db, id);
198
+ return reply.send({ success: true });
199
+ });
200
+ app.post("/admin/api/schedules/:id/toggle", async (request, reply) => {
201
+ const { id } = request.params;
202
+ const existing = getScheduleById(db, id);
203
+ if (!existing)
204
+ return reply.code(HTTP_NOT_FOUND).send(apiError(API_CODE.NOT_FOUND, "Schedule not found"));
205
+ const newEnabled = existing.enabled ? 0 : 1;
206
+ updateSchedule(db, id, { enabled: newEnabled });
207
+ return reply.send({ success: true, enabled: newEnabled });
208
+ });
209
+ done();
210
+ };
@@ -2,8 +2,8 @@ import Database from "better-sqlite3";
2
2
  export declare function initDatabase(dbPath: string): Database.Database;
3
3
  export { getActiveProviders, getAllProviders, getProviderById, getActiveProviderByName, getActiveProvidersWithModels, createProvider, updateProvider, deleteProvider, PROVIDER_CONCURRENCY_DEFAULTS, } from "./providers.js";
4
4
  export type { Provider } from "./providers.js";
5
- export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
6
- export type { ModelMapping, MappingGroup, ProviderModelEntry } from "./mappings.js";
5
+ export { getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
6
+ export type { MappingGroup, ProviderModelEntry } from "./mappings.js";
7
7
  export { getActiveRetryRules, getAllRetryRules, getRetryRuleById, createRetryRule, updateRetryRule, deleteRetryRule, } from "./retry-rules.js";
8
8
  export type { RetryRule } from "./retry-rules.js";
9
9
  export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, updateLogClientStatus, backfillMetricsFromRequestMetrics, estimateLogTableSize, deleteOldestLogs, getLogCount, } from "./logs.js";
@@ -22,5 +22,7 @@ export { insertWindow, getLatestWindow, getWindowsInRange, getWindowUsage, } fro
22
22
  export type { UsageWindow, WindowUsage } from "./usage-windows.js";
23
23
  export { getModelContextWindowOverride, getModelInfoForProvider, setModelInfoForProvider, deleteAllModelInfoForProvider, getAllModelInfo, } from "./model-info.js";
24
24
  export type { ProviderModelInfo } from "./model-info.js";
25
+ export { getSchedulesByGroup, getActiveSchedulesForGroup, getScheduleById, getAllSchedules, createSchedule, updateSchedule, deleteSchedule, deleteSchedulesByGroup, } from "./schedules.js";
26
+ export type { Schedule } from "./schedules.js";
25
27
  export { collectDbSizeInfo, runSizeBasedCleanup, scheduleDbSizeMonitor, } from "./db-size-monitor.js";
26
28
  export type { DbSizeInfo, SizeThresholds, DbSizeMonitorHandle } from "./db-size-monitor.js";