llm-simple-router 0.9.16 → 0.9.18

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.
@@ -52,7 +52,7 @@ const QuickSetupSchema = Type.Object({
52
52
  transform_rules: Type.Optional(QuickSetupTransformSchema),
53
53
  });
54
54
  export const adminQuickSetupRoutes = (app, options, done) => {
55
- const { db, stateRegistry, tracker, adaptiveController } = options;
55
+ const { db, tracker, adaptiveController } = options;
56
56
  app.post("/admin/api/quick-setup", { schema: { body: QuickSetupSchema } }, async (request, reply) => {
57
57
  const body = request.body;
58
58
  // 1. Validate provider name
@@ -60,10 +60,11 @@ function validateMappingRule(db, ruleJson) {
60
60
  return undefined;
61
61
  }
62
62
  /** 解析 week JSON 为数字数组,失败返回 null */
63
+ const MAX_WEEK_DAY = 6;
63
64
  function parseWeekSafe(weekJson) {
64
65
  try {
65
66
  const arr = JSON.parse(weekJson);
66
- if (!Array.isArray(arr) || !arr.every((d) => typeof d === "number" && d >= 0 && d <= 6))
67
+ if (!Array.isArray(arr) || !arr.every((d) => typeof d === "number" && d >= 0 && d <= MAX_WEEK_DAY))
67
68
  return null;
68
69
  return arr;
69
70
  }
@@ -90,8 +91,9 @@ function checkOverlap(db, groupId, excludeId, weekDays, startHour, endHour) {
90
91
  }
91
92
  return undefined;
92
93
  }
94
+ const HOUR_PAD_WIDTH = 2;
93
95
  function formatHour(h) {
94
- return String(h).padStart(2, "0") + ":00";
96
+ return String(h).padStart(HOUR_PAD_WIDTH, "0") + ":00";
95
97
  }
96
98
  export const adminScheduleRoutes = (app, options, done) => {
97
99
  const { db } = options;
@@ -77,7 +77,7 @@ function calcDirSize(dirPath) {
77
77
  try {
78
78
  total += statSync(fullPath).size;
79
79
  }
80
- catch { /* 文件可能刚被删除 */ }
80
+ catch { /* 文件可能刚被删除 */ } // eslint-disable-line taste/no-silent-catch
81
81
  }
82
82
  }
83
83
  return total;
@@ -84,6 +84,10 @@ export const OVERFLOW_THRESHOLD = 1000000;
84
84
  export function lookupContextWindow(modelName) {
85
85
  return MODEL_CONTEXT_WINDOWS[modelName] ?? DEFAULT_CONTEXT_WINDOW;
86
86
  }
87
+ /** 标准化 patch 名称:连字符 → 下划线 */
88
+ function normalizePatchName(name) {
89
+ return name.replace(/-/g, "_");
90
+ }
87
91
  export function parseModels(raw) {
88
92
  if (!raw)
89
93
  return [];
@@ -100,7 +104,7 @@ export function parseModels(raw) {
100
104
  return null;
101
105
  return {
102
106
  name: obj.name,
103
- patches: obj.patches ?? [],
107
+ patches: (obj.patches ?? []).map(normalizePatchName),
104
108
  };
105
109
  }).filter((e) => e !== null);
106
110
  }
@@ -17,9 +17,11 @@ const PERIOD_OFFSET = {
17
17
  "7d": "-7 days",
18
18
  "30d": "-30 days",
19
19
  };
20
- // 精确 10 个数据点:总秒数 / 10,最小 60 秒避免过细
20
+ // 精确 DATA_POINT_COUNT 个数据点:总秒数 / DATA_POINT_COUNT,最小 MIN_BUCKET_SEC 秒避免过细
21
+ const MIN_BUCKET_SEC = 60;
22
+ const DATA_POINT_COUNT = 10;
21
23
  function calcBucketSec(totalSec) {
22
- return Math.max(60, Math.round(totalSec / 10));
24
+ return Math.max(MIN_BUCKET_SEC, Math.round(totalSec / DATA_POINT_COUNT));
23
25
  }
24
26
  // 预设周期总秒数(与 PERIOD_OFFSET 对应)
25
27
  const PERIOD_TOTAL_SEC = {
@@ -6,7 +6,7 @@ function parseJsonColumns(row) {
6
6
  try {
7
7
  result[col] = JSON.parse(result[col]);
8
8
  }
9
- catch {
9
+ catch { // eslint-disable-line taste/no-silent-catch
10
10
  console.error(`[transform-rules] Failed to parse JSON column "${col}", keeping raw value`);
11
11
  }
12
12
  }
@@ -105,7 +105,7 @@ export class SSEMetricsTransform extends Transform {
105
105
  return delta.content;
106
106
  }
107
107
  }
108
- catch { /* 非 JSON 数据行,跳过 */ }
108
+ catch { /* 非 JSON 数据行,跳过 */ } // eslint-disable-line taste/no-silent-catch
109
109
  return undefined;
110
110
  }
111
111
  /** 节流逻辑:首次或距上次回调超过 throttleMs 时触发 */
@@ -267,7 +267,7 @@ async function executeFailoverLoop(ctx) {
267
267
  pluginRegistry.applyAfterResponse(respCtx);
268
268
  transformed = JSON.stringify(respCtx.response);
269
269
  }
270
- catch { /* response hooks best-effort */ }
270
+ catch { /* response hooks best-effort */ } // eslint-disable-line taste/no-silent-catch
271
271
  }
272
272
  return transformed;
273
273
  }
@@ -67,7 +67,7 @@ function filterExcluded(targets, excludeTargets) {
67
67
  return targets.filter(t => !excludeTargets.some(e => e.backend_model === t.backend_model && e.provider_id === t.provider_id));
68
68
  }
69
69
  // ---------- Schedule matching ----------
70
- const ALL_WEEK_DAYS = [0, 1, 2, 3, 4, 5, 6];
70
+ const ALL_WEEK_DAYS = [0, 1, 2, 3, 4, 5, 6]; // eslint-disable-line no-magic-numbers
71
71
  /** 将 week JSON 字符串解析为 dayOfWeek 数字集合 (0=Sun ~ 6=Sat) */
72
72
  function parseWeekDays(weekJson) {
73
73
  try {
@@ -52,7 +52,7 @@ export class PluginRegistry {
52
52
  try {
53
53
  p.beforeRequestTransform?.(ctx);
54
54
  }
55
- catch (err) {
55
+ catch (err) { // eslint-disable-line taste/no-silent-catch
56
56
  console.error(`[plugin-registry] Plugin "${p.name}" beforeRequestTransform error:`, err);
57
57
  }
58
58
  }
@@ -62,7 +62,7 @@ export class PluginRegistry {
62
62
  try {
63
63
  p.afterRequestTransform?.(ctx);
64
64
  }
65
- catch (err) {
65
+ catch (err) { // eslint-disable-line taste/no-silent-catch
66
66
  console.error(`[plugin-registry] Plugin "${p.name}" afterRequestTransform error:`, err);
67
67
  }
68
68
  }
@@ -72,7 +72,7 @@ export class PluginRegistry {
72
72
  try {
73
73
  p.beforeResponseTransform?.(ctx);
74
74
  }
75
- catch (err) {
75
+ catch (err) { // eslint-disable-line taste/no-silent-catch
76
76
  console.error(`[plugin-registry] Plugin "${p.name}" beforeResponseTransform error:`, err);
77
77
  }
78
78
  }
@@ -82,7 +82,7 @@ export class PluginRegistry {
82
82
  try {
83
83
  p.afterResponseTransform?.(ctx);
84
84
  }
85
- catch (err) {
85
+ catch (err) { // eslint-disable-line taste/no-silent-catch
86
86
  console.error(`[plugin-registry] Plugin "${p.name}" afterResponseTransform error:`, err);
87
87
  }
88
88
  }
@@ -121,7 +121,7 @@ export class PluginRegistry {
121
121
  }
122
122
  }
123
123
  },
124
- afterResponseTransform(_ctx) {
124
+ afterResponseTransform() {
125
125
  // field_overrides only applies to request direction;
126
126
  // response should reflect actual upstream data, not override rules
127
127
  },
@@ -4,8 +4,9 @@ const EFFORT_BUDGET = { low: 1024, medium: 8192, high: 32768 };
4
4
  const DEFAULT_BUDGET = 8192;
5
5
  // ---------- Helpers ----------
6
6
  /** Strip "toolu_" prefix from a tool_use_id to recover the original call_id. */
7
+ const TOOLU_PREFIX_LEN = "toolu_".length;
7
8
  function stripTooluPrefix(id) {
8
- return id.startsWith("toolu_") ? id.slice(6) : id;
9
+ return id.startsWith("toolu_") ? id.slice(TOOLU_PREFIX_LEN) : id;
9
10
  }
10
11
  /** Merge consecutive same-role messages to satisfy Anthropic strict alternation. */
11
12
  function mergeConsecutiveMessages(msgs) {
@@ -76,9 +76,10 @@ export function responsesToAnthropicResponse(bodyStr) {
76
76
  });
77
77
  }
78
78
  // ---------- Anthropic → Responses ----------
79
+ const TOOLU_PREFIX_LEN = "toolu_".length;
79
80
  /** Strip "toolu_" prefix from a tool_use_id to recover the original call_id. */
80
81
  function stripTooluPrefix(id) {
81
- return id.startsWith("toolu_") ? id.slice(6) : id;
82
+ return id.startsWith("toolu_") ? id.slice(TOOLU_PREFIX_LEN) : id;
82
83
  }
83
84
  export function anthropicToResponsesResponse(bodyStr) {
84
85
  const ant = JSON.parse(bodyStr);
@@ -1,10 +1,13 @@
1
1
  import { randomBytes } from "crypto";
2
2
  import { BaseSSETransform } from "./stream-transform-base.js";
3
- import { generateRespId } from "./id-utils.js";
3
+ import { generateRespId, MS_PER_SECOND } from "./id-utils.js";
4
4
  import { RESPONSES_SSE_EVENTS } from "./types-responses.js";
5
5
  function randomHex(bytes) {
6
6
  return randomBytes(bytes).toString("hex");
7
7
  }
8
+ const ID_HEX_LENGTH = 12;
9
+ const SHORT_HEX_LENGTH = 8;
10
+ const TOOLU_PREFIX_LEN = "toolu_".length;
8
11
  export class AnthropicToResponsesTransform extends BaseSSETransform {
9
12
  state = "init";
10
13
  responseId = generateRespId();
@@ -19,7 +22,7 @@ export class AnthropicToResponsesTransform extends BaseSSETransform {
19
22
  currentItemId = "";
20
23
  currentSummaryPartId = "";
21
24
  currentContentPartIndex = 0;
22
- createdAt = Math.floor(Date.now() / 1000);
25
+ createdAt = Math.floor(Date.now() / MS_PER_SECOND);
23
26
  nextSeq() {
24
27
  return this.sequenceNumber++;
25
28
  }
@@ -68,8 +71,8 @@ export class AnthropicToResponsesTransform extends BaseSSETransform {
68
71
  const blockType = block?.type;
69
72
  if (blockType === "thinking") {
70
73
  this.state = "thinking";
71
- this.currentItemId = `rs_${randomHex(12)}`;
72
- this.currentSummaryPartId = `sp_${randomHex(8)}`;
74
+ this.currentItemId = `rs_${randomHex(ID_HEX_LENGTH)}`;
75
+ this.currentSummaryPartId = `sp_${randomHex(SHORT_HEX_LENGTH)}`;
73
76
  this.pushResponsesSSE(RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED, {
74
77
  type: RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED,
75
78
  output_index: this.outputIndex,
@@ -86,7 +89,7 @@ export class AnthropicToResponsesTransform extends BaseSSETransform {
86
89
  }
87
90
  else if (blockType === "text") {
88
91
  this.state = "text";
89
- this.currentItemId = `msg_${randomHex(12)}`;
92
+ this.currentItemId = `msg_${randomHex(ID_HEX_LENGTH)}`;
90
93
  this.currentContentPartIndex = 0;
91
94
  this.pushResponsesSSE(RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED, {
92
95
  type: RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED,
@@ -107,8 +110,8 @@ export class AnthropicToResponsesTransform extends BaseSSETransform {
107
110
  const toolId = block.id;
108
111
  // Convert toolu_ prefix to fc_ prefix
109
112
  this.activeToolCallId = toolId.startsWith("toolu_")
110
- ? `fc_${toolId.slice(6)}`
111
- : `fc_${randomHex(12)}`;
113
+ ? `fc_${toolId.slice(TOOLU_PREFIX_LEN)}`
114
+ : `fc_${randomHex(ID_HEX_LENGTH)}`;
112
115
  const callId = this.activeToolCallId;
113
116
  this.currentItemId = callId;
114
117
  this.pushResponsesSSE(RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED, {
@@ -288,7 +291,7 @@ export class AnthropicToResponsesTransform extends BaseSSETransform {
288
291
  }
289
292
  emitCompleted() {
290
293
  const status = this.pendingStatus ?? "completed";
291
- const completedAt = Math.floor(Date.now() / 1000);
294
+ const completedAt = Math.floor(Date.now() / MS_PER_SECOND);
292
295
  const response = {
293
296
  id: this.responseId,
294
297
  object: "response",
@@ -1,10 +1,11 @@
1
1
  import { randomBytes } from "crypto";
2
2
  import { BaseSSETransform } from "./stream-transform-base.js";
3
- import { generateRespId } from "./id-utils.js";
3
+ import { generateRespId, MS_PER_SECOND } from "./id-utils.js";
4
4
  import { RESPONSES_SSE_EVENTS } from "./types-responses.js";
5
5
  function randomHex(bytes) {
6
6
  return randomBytes(bytes).toString("hex");
7
7
  }
8
+ const ID_HEX_LENGTH = 12;
8
9
  /**
9
10
  * Bridge transform: Chat Completions SSE → Responses API SSE.
10
11
  *
@@ -29,7 +30,7 @@ export class ChatToResponsesBridgeTransform extends BaseSSETransform {
29
30
  currentFunctionCallId = "";
30
31
  currentFunctionCallName = "";
31
32
  currentReasoningItemId = "";
32
- createdAt = Math.floor(Date.now() / 1000);
33
+ createdAt = Math.floor(Date.now() / MS_PER_SECOND);
33
34
  nextSeq() {
34
35
  return this.sequenceNumber++;
35
36
  }
@@ -173,7 +174,7 @@ export class ChatToResponsesBridgeTransform extends BaseSSETransform {
173
174
  }
174
175
  }
175
176
  emitCompleted() {
176
- const completedAt = Math.floor(Date.now() / 1000);
177
+ const completedAt = Math.floor(Date.now() / MS_PER_SECOND);
177
178
  const response = {
178
179
  id: this.responseId,
179
180
  object: "response",
@@ -235,7 +236,7 @@ export class ChatToResponsesBridgeTransform extends BaseSSETransform {
235
236
  this.closeCurrentMessageItem();
236
237
  if (!this.hasReasoningItemStarted) {
237
238
  this.hasReasoningItemStarted = true;
238
- this.currentReasoningItemId = `rs_${randomHex(12)}`;
239
+ this.currentReasoningItemId = `rs_${randomHex(ID_HEX_LENGTH)}`;
239
240
  this.pushResponsesSSE(RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED, {
240
241
  type: RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED,
241
242
  output_index: this.outputIndex,
@@ -266,7 +267,7 @@ export class ChatToResponsesBridgeTransform extends BaseSSETransform {
266
267
  if (!this.hasMessageItemStarted) {
267
268
  this.hasMessageItemStarted = true;
268
269
  this.contentIndex = 0;
269
- this.currentMessageItemId = `msg_${randomHex(12)}`;
270
+ this.currentMessageItemId = `msg_${randomHex(ID_HEX_LENGTH)}`;
270
271
  this.pushResponsesSSE(RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED, {
271
272
  type: RESPONSES_SSE_EVENTS.OUTPUT_ITEM_ADDED,
272
273
  output_index: this.outputIndex,
@@ -162,7 +162,7 @@ class StreamProxy {
162
162
  try {
163
163
  this.reply.raw.write(chunk);
164
164
  }
165
- catch {
165
+ catch { // eslint-disable-line taste/no-silent-catch
166
166
  // 客户端已断开,写已销毁的 socket 会抛出异常,可安全忽略
167
167
  }
168
168
  });
@@ -224,7 +224,7 @@ class StreamProxy {
224
224
  try {
225
225
  this.reply.raw.end();
226
226
  }
227
- catch {
227
+ catch { // eslint-disable-line taste/no-silent-catch
228
228
  // reply 可能已 destroyed,安全忽略
229
229
  }
230
230
  });
@@ -270,7 +270,7 @@ class StreamProxy {
270
270
  try {
271
271
  this.reply.raw.end();
272
272
  }
273
- catch {
273
+ catch { // eslint-disable-line taste/no-silent-catch
274
274
  // reply 可能已 destroyed,安全忽略
275
275
  }
276
276
  }
@@ -1,6 +1,5 @@
1
1
  import http from 'node:http';
2
2
  import https from 'node:https';
3
- import path from 'node:path';
4
3
  import { getInstalledVersion } from './version.js';
5
4
  import { getConfigVersions } from '../config/recommended.js';
6
5
  const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/llm-simple-router';
@@ -49,7 +48,6 @@ export async function fetchJson(url, redirects = 0) {
49
48
  export function createUpgradeChecker(options) {
50
49
  const npmRegistryUrl = options?.npmRegistryUrl ?? DEFAULT_NPM_REGISTRY;
51
50
  const configBaseUrl = options?.configBaseUrl ?? DEFAULT_GITHUB_CONFIG_BASE;
52
- const configDir = options?.configDir ?? path.resolve(process.cwd(), 'config');
53
51
  let npmStatus = {
54
52
  hasUpdate: false,
55
53
  currentVersion: getInstalledVersion(),
@@ -5,6 +5,11 @@ const WINDOW_HOURS = 5;
5
5
  const MS_PER_HOUR = 3600_000;
6
6
  // 与 usage-windows 的默认窗口时长对齐
7
7
  const WINDOW_DURATION_MS = WINDOW_HOURS * MS_PER_HOUR;
8
+ const DAYS_TO_SUNDAY = 6;
9
+ const END_OF_DAY_HOUR = 23;
10
+ const END_OF_DAY_MINUTE = 59;
11
+ const END_OF_DAY_SECOND = 59;
12
+ const END_OF_DAY_MS = 999;
8
13
  export function resolveTimeRange(period, db, routerKeyId, providerId) {
9
14
  const now = new Date();
10
15
  switch (period) {
@@ -23,14 +28,14 @@ export function resolveTimeRange(period, db, routerKeyId, providerId) {
23
28
  const monday = getMonday(now);
24
29
  monday.setHours(0, 0, 0, 0);
25
30
  const sunday = new Date(monday);
26
- sunday.setDate(sunday.getDate() + 6);
27
- sunday.setHours(23, 59, 59, 999);
31
+ sunday.setDate(sunday.getDate() + DAYS_TO_SUNDAY);
32
+ sunday.setHours(END_OF_DAY_HOUR, END_OF_DAY_MINUTE, END_OF_DAY_SECOND, END_OF_DAY_MS);
28
33
  return { startTime: toSqliteDatetime(monday), endTime: toSqliteDatetime(sunday) };
29
34
  }
30
35
  case "monthly": {
31
36
  const first = new Date(now.getFullYear(), now.getMonth(), 1);
32
37
  const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
33
- last.setHours(23, 59, 59, 999);
38
+ last.setHours(END_OF_DAY_HOUR, END_OF_DAY_MINUTE, END_OF_DAY_SECOND, END_OF_DAY_MS);
34
39
  return { startTime: toSqliteDatetime(first), endTime: toSqliteDatetime(last) };
35
40
  }
36
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-simple-router",
3
- "version": "0.9.16",
3
+ "version": "0.9.18",
4
4
  "description": "LLM API proxy router with OpenAI/Anthropic support, model mapping, retry strategies, and admin dashboard",
5
5
  "license": "MIT",
6
6
  "author": "ZZzzswszzZZ",