@gehennawu/dsh-service 1.9.0 → 1.9.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.
package/index.js CHANGED
@@ -205,6 +205,9 @@ const MAX_QUOTA_PROVIDERS = 256
205
205
  const MAX_QUOTA_PROVIDER_NAME = 128
206
206
  const MAX_QUOTA_RESET_CARDS = 500
207
207
  const MAX_QUOTA_RESET_CARDS_PER_PROVIDER = 10
208
+ // 账号级重置卡(v1.9.1):CLIProxyAPI 一行多账号,重置卡可按「provider + account」细分。
209
+ // account 是账号标识(邮箱/名称),沿用与卡片名同级的截断与计数约束。
210
+ const MAX_QUOTA_RESET_CARD_ACCOUNT = 128
208
211
  // 子代理路由(v0.27)三态常量:inherit=不干预 / follow=读父会话最近请求路由注入 / custom=固定路由。
209
212
  const SUBAGENT_ROUTE_VERSION = 1
210
213
  const SUBAGENT_ROUTE_FILE = 'dsh-service-subagent-route.json'
@@ -1157,8 +1160,45 @@ function normalizeQuotaAllowedHosts(raw) {
1157
1160
  return out
1158
1161
  }
1159
1162
 
1163
+ /**
1164
+ * 重置卡到期时刻(毫秒):
1165
+ * - 带时刻的 ISO 串(`2026-09-30T08:00` / 含秒、时区)→ Date.parse 的真实时刻;
1166
+ * - 只到日的串(`2026-09-30`)→ **该自然日的 23:59:59.999**。裸日期被 Date.parse 当作当日
1167
+ * 零点 UTC,会让「9-30 到期」的卡在 9-30 零点刚过就判过期,与用户「当天还有效」的直觉相反。
1168
+ * - 缺失/无法解析 → null(永不过期,只手动移除)。
1169
+ */
1170
+ function resetCardExpiryMs(expiresAt) {
1171
+ if (typeof expiresAt !== 'string') return null
1172
+ const raw = expiresAt.trim()
1173
+ if (raw === '') return null
1174
+ const parsed = Date.parse(raw)
1175
+ if (!Number.isFinite(parsed)) return null
1176
+ // 纯日期形态(YYYY-MM-DD,无 T/时刻):顺延到当日末尾。
1177
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return parsed + 24 * 60 * 60 * 1000 - 1
1178
+ return parsed
1179
+ }
1180
+
1181
+ /** 已过期(到期时刻严格早于 now)的重置卡:宿主读配置时自动剔除。 */
1182
+ function isResetCardExpired(card, now = Date.now()) {
1183
+ const at = resetCardExpiryMs(card?.expiresAt)
1184
+ return at !== null && at < now
1185
+ }
1186
+
1187
+ /**
1188
+ * 自动移除已过期的重置卡。读路径(refreshQuotaConfigCache)在加载后调用:
1189
+ * 有剔除才落盘,避免每次读都写文件。返回值同时告诉调用方「是否发生过变更」。
1190
+ */
1191
+ function pruneExpiredResetCards(config, now = Date.now()) {
1192
+ if (!Array.isArray(config?.resetCards) || config.resetCards.length === 0) return false
1193
+ const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now))
1194
+ if (kept.length === config.resetCards.length) return false
1195
+ config.resetCards = kept
1196
+ return true
1197
+ }
1198
+
1160
1199
  // 重置卡条目(v0.19 手填过渡方案:官方无 API Key 可查的端点):provider 必填,label/expiresAt 可选;
1161
1200
  // id 缺失时按原始位置合成稳定 id(老数据兼容),写入口生成的 id 原样保留。
1201
+ // v1.9.1 起另可带 account(CLIProxyAPI 多账号细分),缺省 = 整 provider 共享的卡,老数据零迁移。
1162
1202
  function normalizeResetCards(raw) {
1163
1203
  if (!Array.isArray(raw)) return []
1164
1204
  const cards = []
@@ -1171,6 +1211,7 @@ function normalizeResetCards(raw) {
1171
1211
  const providerCount = perProvider.get(provider) ?? 0
1172
1212
  if (providerCount >= MAX_QUOTA_RESET_CARDS_PER_PROVIDER) continue
1173
1213
  const normalized = { id: typeof card.id === 'string' && card.id.trim() !== '' ? card.id.trim().slice(0, 64) : `legacy-${index}`, provider }
1214
+ if (typeof card.account === 'string' && card.account.trim() !== '') normalized.account = card.account.trim().slice(0, MAX_QUOTA_RESET_CARD_ACCOUNT)
1174
1215
  if (typeof card.label === 'string' && card.label.trim() !== '') normalized.label = card.label.trim().slice(0, 40)
1175
1216
  if (typeof card.expiresAt === 'string' && card.expiresAt.trim() !== '') normalized.expiresAt = card.expiresAt.trim().slice(0, 32)
1176
1217
  cards.push(normalized)
@@ -2683,6 +2724,41 @@ async function collectHealth(ctx) {
2683
2724
  }
2684
2725
  }
2685
2726
 
2727
+ /**
2728
+ * 概览重置卡到期提示的窗口(设计定稿参数,只写这一处):到期时刻距今 **≤24 小时** 即提示。
2729
+ * 用剩余时长而不是「当天」日历窗:纯日期卡本就顺延到当日 23:59:59.999、当天窗 ≈24h,
2730
+ * 但精确时刻卡(datetime-local)在当天窗下只剩 0:00 到到期时刻的一截(可能仅几十分钟),
2731
+ * 错过即被读路径自动清理、再无补看机会;剩余时长窗让两种录入方式都保证 ≥24h 曝光。
2732
+ */
2733
+ const QUOTA_RESET_CARD_EXPIRING_WINDOW_MS = 24 * 60 * 60 * 1000
2734
+
2735
+ /**
2736
+ * 剩余 ≤24h 到期的重置卡清单(概览 info 提示的数据源)。读路径的 prune 正常时不会剩下
2737
+ * 过期卡,这里再防御性排除一次(at < now 与 isResetCardExpired 同口径)——宁可少提示,
2738
+ * 绝不把已过期的卡当「即将到期」展示。返回 [{ provider, label }];无命中返回空数组。
2739
+ */
2740
+ async function collectResetCardsExpiringSoon(refreshQuotaConfigCache, now = Date.now()) {
2741
+ let config
2742
+ try {
2743
+ config = await refreshQuotaConfigCache()
2744
+ } catch (_) {
2745
+ return []
2746
+ }
2747
+ const cards = Array.isArray(config?.resetCards) ? config.resetCards : []
2748
+ if (cards.length === 0) return []
2749
+ const horizon = now + QUOTA_RESET_CARD_EXPIRING_WINDOW_MS
2750
+ const hits = []
2751
+ for (const card of cards) {
2752
+ const at = resetCardExpiryMs(card?.expiresAt)
2753
+ if (at === null || at < now || at > horizon) continue
2754
+ hits.push({
2755
+ provider: typeof card.provider === 'string' ? card.provider : '',
2756
+ label: typeof card.label === 'string' ? card.label : '',
2757
+ })
2758
+ }
2759
+ return hits
2760
+ }
2761
+
2686
2762
  function collectActiveWork(ctx) {
2687
2763
  const agentsService = ctx.get('agents')
2688
2764
  const jobsService = ctx.get('jobs')
@@ -4878,9 +4954,23 @@ function apply(ctx) {
4878
4954
  let quotaConfigLastCheckedAt = 0
4879
4955
  let quotaConfigMtimeMs = 0
4880
4956
  const quotaConfigPath = join(dshHome, QUOTA_CONFIG_FILE)
4957
+ // 过期重置卡自动移除:剔除内存快照里的过期条目,有变更才落盘(避免每次读都写文件)。
4958
+ // 读失败不影响本次结果——内存已剔除,下次读再试。TTL 快路径、TTL 到期后 mtime 未变、
4959
+ // 重新加载三条路都要过这关:mtime 未变就永远不会重新加载,缺了这一路,「停在概览页
4960
+ // 只靠 health 轮询」时运行期到点的卡在内存里清不掉。
4961
+ const pruneExpiredQuotaResetCards = async () => {
4962
+ if (!pruneExpiredResetCards(quotaConfig)) return
4963
+ try {
4964
+ await saveQuotaConfig(dshHome, quotaConfig)
4965
+ quotaConfigMtimeMs = (await stat(quotaConfigPath)).mtimeMs
4966
+ } catch (_) {}
4967
+ }
4881
4968
  const refreshQuotaConfigCache = async (force = false) => {
4882
4969
  const now = Date.now()
4883
- if (!force && quotaConfigLoaded && now - quotaConfigLastCheckedAt < QUOTA_CONFIG_STAT_TTL_MS) return quotaConfig
4970
+ if (!force && quotaConfigLoaded && now - quotaConfigLastCheckedAt < QUOTA_CONFIG_STAT_TTL_MS) {
4971
+ await pruneExpiredQuotaResetCards()
4972
+ return quotaConfig
4973
+ }
4884
4974
  if (quotaConfigLoadPromise !== undefined) return quotaConfigLoadPromise
4885
4975
  quotaConfigLoadPromise = Promise.resolve().then(async () => {
4886
4976
  quotaConfigLastCheckedAt = now
@@ -4900,6 +4990,8 @@ function apply(ctx) {
4900
4990
  quotaConfigLoaded = true
4901
4991
  quotaConfigMtimeMs = mtimeMs
4902
4992
  }
4993
+ // 慢路径无论是否重新加载都 prune:mtime 未变时不重新加载,prune 是这条路上唯一的清理点。
4994
+ await pruneExpiredQuotaResetCards()
4903
4995
  return quotaConfig
4904
4996
  }).finally(() => { quotaConfigLoadPromise = undefined })
4905
4997
  return quotaConfigLoadPromise
@@ -5162,7 +5254,13 @@ function apply(ctx) {
5162
5254
 
5163
5255
  } },
5164
5256
  'health': { handle: async (payload, rpcEndpoint) => {
5165
- return { ok: true, value: await collectHealth(ctx) }
5257
+ const value = await collectHealth(ctx)
5258
+ // 剩余 24 小时内到期的重置卡(额度功能开启时才算):概览「可行动项」的 info 提示数据源。
5259
+ // 走 health 而非 quota,是因为概览不打开额度页也该看到提醒,而 health 是面板的常驻轮询。
5260
+ if (featureEnabled('quotaLookup')) {
5261
+ value.resetCardsExpiringSoon = await collectResetCardsExpiringSoon(refreshQuotaConfigCache)
5262
+ }
5263
+ return { ok: true, value }
5166
5264
 
5167
5265
  } },
5168
5266
  'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
@@ -5312,7 +5410,7 @@ function apply(ctx) {
5312
5410
  coreRoutes,
5313
5411
  createSkillsRoutes({ ctx, describeJobs, dshHome, makeDescribeJobLogger, registerSkillCall, serializeSkillsIndexWrite, skillFileCache, skillsActiveControllers, skillsBatchRef, skillsIndexRef, SKILL_DESCRIPTION_MAX_CHARS, SKILL_USAGE_MAX_CHARS, bodyHashOf, describeSkillDraft, evaluateSkillFile, fixLegacySkillInvocationKeys, listSkillModels, locateSkillFrontmatter, mutateSkillEntryById, name, normalizeSkillDescribeLang, publicSkillEntry, rpcTechnicalFailure, sanitizeSkillDraftText, scanSkillEntries, selectSkillBatchCandidates, setSkillInvocationKey }),
5314
5412
  createSessionsRoutes({ ctx, dshHome, sessionBytesCache, sessionDeletePlans, sessionTitleCache, sessionTitlesReady, sessionViewCache, SESSIONS_BYTES_MAX_IDS, SESSIONS_DELETE_PLAN_TTL_MS, SESSIONS_VIEW_PAGE_SIZE, listSessionsForManage, loadDeletedSessions, name, resolveSessionBytesForIds, resolveSessionForDelete, rpcFailure, rpcTechnicalFailure, saveDeletedSessions, searchSessionsContent, sessionExists, sessionIsLive, viewSessionPage }),
5315
- createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
5413
+ createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
5316
5414
  createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
5317
5415
  createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, setBackupProgress, withBackupLock, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
5318
5416
  ])
@@ -5377,6 +5475,9 @@ export {
5377
5475
  normalizeXiaomiTokenPlanUsage,
5378
5476
  normalizeZaiCodingUsage,
5379
5477
  parseQuotaConfigText,
5478
+ isResetCardExpired,
5479
+ pruneExpiredResetCards,
5480
+ resetCardExpiryMs,
5380
5481
  parseSessionFileAddress,
5381
5482
  parseSkillFrontmatterData,
5382
5483
  parseSubagentRouteText,
@@ -5457,6 +5558,9 @@ export default {
5457
5558
  normalizeXiaomiTokenPlanUsage,
5458
5559
  normalizeZaiCodingUsage,
5459
5560
  parseQuotaConfigText,
5561
+ isResetCardExpired,
5562
+ pruneExpiredResetCards,
5563
+ resetCardExpiryMs,
5460
5564
  parseSessionFileAddress,
5461
5565
  parseSkillFrontmatterData,
5462
5566
  parseSubagentRouteText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
package/plugin-compat.js CHANGED
@@ -50,11 +50,66 @@ function packageNameOf(moduleName) {
50
50
  return pkg
51
51
  }
52
52
 
53
+ function isSemverIdentifier(value) {
54
+ if (value.length === 0) return false
55
+ return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
56
+ }
57
+
58
+ function isNumericSemverIdentifier(value) {
59
+ return value.length > 0 && [...value].every((char) => char >= '0' && char <= '9')
60
+ }
61
+
62
+ export function parseSemver(value) {
63
+ if (typeof value !== 'string') return null
64
+ const trimmed = value.trim()
65
+ const buildParts = trimmed.split('+')
66
+ if (buildParts.length > 2 || (buildParts.length === 2 && !buildParts[1].split('.').every((part) => isSemverIdentifier(part)))) return null
67
+ const versionPart = buildParts[0]
68
+ const dashIndex = versionPart.indexOf('-')
69
+ const corePart = dashIndex === -1 ? versionPart : versionPart.slice(0, dashIndex)
70
+ const prereleasePart = dashIndex === -1 ? '' : versionPart.slice(dashIndex + 1)
71
+ if (dashIndex !== -1 && prereleasePart.length === 0) return null
72
+ const core = corePart.split('.')
73
+ if (core.length !== 3 || !core.every((part) => isNumericSemverIdentifier(part) && (part.length === 1 || !part.startsWith('0')))) return null
74
+ const prerelease = prereleasePart === '' ? [] : prereleasePart.split('.')
75
+ if (!prerelease.every((part) => isSemverIdentifier(part) && !(isNumericSemverIdentifier(part) && part.length > 1 && part.startsWith('0')))) return null
76
+ return { major: Number(core[0]), minor: Number(core[1]), patch: Number(core[2]), prerelease }
77
+ }
78
+
79
+ export function compareSemver(left, right) {
80
+ const a = parseSemver(left)
81
+ const b = parseSemver(right)
82
+ if (!a || !b) return 0
83
+ for (const key of ['major', 'minor', 'patch']) {
84
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1
85
+ }
86
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
87
+ if (a.prerelease.length === 0) return 1
88
+ if (b.prerelease.length === 0) return -1
89
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
90
+ for (let index = 0; index < length; index += 1) {
91
+ if (index >= a.prerelease.length) return -1
92
+ if (index >= b.prerelease.length) return 1
93
+ const leftPart = a.prerelease[index]
94
+ const rightPart = b.prerelease[index]
95
+ if (leftPart === rightPart) continue
96
+ const leftNumeric = /^[0-9]+$/.test(leftPart)
97
+ const rightNumeric = /^[0-9]+$/.test(rightPart)
98
+ if (leftNumeric && rightNumeric) return Number(leftPart) > Number(rightPart) ? 1 : -1
99
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
100
+ return leftPart > rightPart ? 1 : -1
101
+ }
102
+ return 0
103
+ }
104
+
53
105
  /**
54
106
  * 已核实破坏面清单(每条 = 一个已被 DSH alpha 移除/迁移的旧标识)。
55
107
  * 客户端按 `plugin.compat.break.<id>` 取词典文案;layer 决定扫描目标(manifest=package.json
56
108
  * 依赖键与 dsh 字段,code=入口文件非注释文本)。
57
109
  *
110
+ * since: 该破坏面首次生效的 DSH 版本。
111
+ * kind: 破坏类型(package-removed / slot-retired / method-removed / hash-migrated / event-removed / attribute-removed)。
112
+ *
58
113
  * `severity: 'info'`(可选,默认 warning)把该条降为提示档:命中的插件仍逐条列出,但走
59
114
  * 蓝色提示行、不把检查项拉成 warning(也就不进概览可行动项)。只用于「旧标识仍被注册但
60
115
  * 插件照常运行、对使用者无功能损失」的退役接口——如为旧宿主保留的退役槽位注册。
@@ -64,41 +119,57 @@ export const COMPAT_BREAKS = Object.freeze([
64
119
  id: 'client-runtime',
65
120
  layer: 'manifest',
66
121
  match: '@deepseek-ai/dsh-client-runtime',
122
+ since: '0.1.2-alpha.2',
123
+ kind: 'package-removed',
67
124
  },
68
125
  {
69
126
  id: 'sqlite-persistence',
70
127
  layer: 'manifest',
71
128
  match: '@deepseek-ai/dsh-session-persistence-sqlite',
129
+ since: '0.1.2-alpha.3',
130
+ kind: 'package-removed',
72
131
  },
73
132
  {
74
133
  id: 'code-runtime',
75
134
  layer: 'manifest',
76
135
  match: '@deepseek-ai/dsh-code-runtime',
136
+ since: '0.1.6-alpha.1',
137
+ kind: 'package-removed',
77
138
  },
78
139
  {
79
140
  id: 'e2b-runtime',
80
141
  layer: 'manifest',
81
142
  match: '@deepseek-ai/dsh-e2b',
143
+ since: '0.1.6-alpha.1',
144
+ kind: 'package-removed',
82
145
  },
83
146
  {
84
147
  id: 'session-start-event',
85
148
  layer: 'code',
86
149
  match: 'agent/session-start',
150
+ since: '0.1.6-alpha.1',
151
+ kind: 'event-removed',
87
152
  },
88
153
  {
89
154
  id: 'chat-hash',
90
155
  layer: 'code',
91
156
  match: 'Md3f7G_',
157
+ since: '0.1.2-alpha.2',
158
+ kind: 'hash-migrated',
92
159
  },
93
160
  {
94
161
  id: 'stats-hash',
95
162
  layer: 'code',
96
163
  match: 'FJxK0a_',
164
+ since: '0.1.2-alpha.2',
165
+ kind: 'hash-migrated',
97
166
  },
98
167
  {
99
168
  id: 'time-hover-root',
100
169
  layer: 'code',
101
170
  match: 'data-time-hover-root',
171
+ since: '0.1.2-alpha.2',
172
+ kind: 'attribute-removed',
102
173
  },
103
174
  {
104
175
  // 0.1.6-alpha.2 退役设置页槽位:第三方注册它则配置卡片在新插件页不再渲染。
@@ -109,6 +180,8 @@ export const COMPAT_BREAKS = Object.freeze([
109
180
  id: 'settings-plugin-item',
110
181
  layer: 'code',
111
182
  match: 'settings.plugin.item',
183
+ since: '0.1.6-alpha.2',
184
+ kind: 'slot-retired',
112
185
  selfExempt: true,
113
186
  severity: 'info',
114
187
  },
@@ -121,6 +194,8 @@ export const COMPAT_BREAKS = Object.freeze([
121
194
  layer: 'code',
122
195
  match: 'sessions.open',
123
196
  call: true,
197
+ since: '0.1.6-alpha.2',
198
+ kind: 'method-removed',
124
199
  },
125
200
  ])
126
201
 
@@ -369,6 +444,19 @@ export function manifestCallRefs(text, manifestBreaks) {
369
444
  return found
370
445
  }
371
446
 
447
+ export function enrichBreak(breakId, dshVersion) {
448
+ const meta = COMPAT_BREAKS.find((b) => b.id === breakId)
449
+ if (!meta) return { id: breakId, since: 'unknown', kind: 'unknown', severity: 'warning', active: true }
450
+ const active = dshVersion && dshVersion !== 'unknown' ? compareSemver(dshVersion, meta.since) >= 0 : true
451
+ return {
452
+ id: meta.id,
453
+ since: meta.since,
454
+ kind: meta.kind,
455
+ severity: meta.severity || 'warning',
456
+ active,
457
+ }
458
+ }
459
+
372
460
  /**
373
461
  * 收集启用插件的兼容性扫描结果(顺序 = loader 条目序,稳定可测)。
374
462
  * @param ctx 宿主插件上下文(ctx.get('loader'))
@@ -378,6 +466,9 @@ export function manifestCallRefs(text, manifestBreaks) {
378
466
  export async function collectPluginCompat(ctx, options = {}) {
379
467
  const loader = ctx.get('loader')
380
468
  if (loader === undefined) return { available: false, scanned: 0, issues: [], soft: [], declaredOnly: [], unknown: [] }
469
+ // 真机 ctx 是服务代理:未提供服务的属性直读(ctx.dshVersion 之类)会抛
470
+ // cannot get property "<prop>" without inject,让整个检查被降级 unavailable。
471
+ // 本函数只允许 ctx.get('loader') 与 ctx.baseUrl(Context 实体字段,恒存在)。
381
472
  let requireFn = options.requireFn
382
473
  if (typeof requireFn !== 'function') {
383
474
  const baseUrl = typeof loader.ctx?.baseUrl === 'string' && loader.ctx.baseUrl.length > 0
package/quota-routes.js CHANGED
@@ -16,6 +16,7 @@ export function createQuotaRoutes({
16
16
  MAX_QUOTA_PROVIDER_NAME,
17
17
  MAX_QUOTA_RESET_CARDS,
18
18
  MAX_QUOTA_RESET_CARDS_PER_PROVIDER,
19
+ MAX_QUOTA_RESET_CARD_ACCOUNT,
19
20
  QUOTA_ADAPTER_BY_KIND,
20
21
  name,
21
22
  quotaCredentialConfigured,
@@ -183,20 +184,28 @@ export function createQuotaRoutes({
183
184
  // 手录重置卡(v0.19 过渡方案;v0.20 免次数、每 provider 可多条)的面板写入口:
184
185
  // provider 过宿主清单白名单;{remove:true,id} 删除宿主下发 id 对应的那一条,
185
186
  // 其余载荷为追加一条(label/expiresAt 截断限长),单 provider 上限 10 条防配置膨胀。
187
+ // v1.9.1:可带 account(CLIProxyAPI 多账号细分)——同一 provider 下按账号分别记事。
188
+ // 与 label 同等对待:只是随卡存下的展示标识,截断限长,绝不进命令/路径/URL,
189
+ // 因而无需(也不能)对着上游清单做白名单校验——那会在写路径上引入一次外呼。
186
190
  const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
187
191
  if (!readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).some((candidate) => candidate.name === providerName)) {
188
192
  return { ok: false, error: 'unknown-provider' }
189
193
  }
194
+ const accountName = typeof payload?.account === 'string' && payload.account.trim() !== ''
195
+ ? payload.account.trim().slice(0, MAX_QUOTA_RESET_CARD_ACCOUNT)
196
+ : ''
190
197
  return await serializeQuotaConfigWrite(async (config) => {
191
198
  const allCards = Array.isArray(config.resetCards) ? config.resetCards : []
192
199
  if (payload?.remove === true) {
193
200
  const cardId = typeof payload?.id === 'string' ? payload.id : ''
201
+ // 删除按 id 精确定位(同 provider 下 id 唯一),account 不参与匹配。
194
202
  config.resetCards = allCards.filter((card) => !(card.provider === providerName && card.id === cardId))
195
203
  } else {
196
204
  if (allCards.length >= MAX_QUOTA_RESET_CARDS || allCards.filter((card) => card.provider === providerName).length >= MAX_QUOTA_RESET_CARDS_PER_PROVIDER) {
197
205
  return { save: false, value: { ok: false, error: 'too-many-cards' } }
198
206
  }
199
207
  const card = { id: `rc-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`, provider: providerName }
208
+ if (accountName !== '') card.account = accountName
200
209
  if (typeof payload?.label === 'string' && payload.label.trim() !== '') card.label = payload.label.trim().slice(0, 40)
201
210
  if (typeof payload?.expiresAt === 'string' && payload.expiresAt.trim() !== '') card.expiresAt = payload.expiresAt.trim().slice(0, 32)
202
211
  config.resetCards = [...allCards, card]