@gehennawu/dsh-service 2.0.10 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/quota-adapters.js CHANGED
@@ -190,6 +190,7 @@ function defineQuotaAdapter(definition, metadata = {}) {
190
190
  defaultBaseURL: typeof metadata.defaultBaseURL === 'string' && metadata.defaultBaseURL !== '' ? metadata.defaultBaseURL : undefined,
191
191
  usageUrl: metadata.usageUrl,
192
192
  usageBasePathname: typeof metadata.usageBasePathname === 'function' ? metadata.usageBasePathname : undefined,
193
+ realmCaps: metadata.realmCaps,
193
194
  }))
194
195
  return adapter
195
196
  }
@@ -364,6 +365,7 @@ function createComposedAdapter(options) {
364
365
  routeIds: options.routeIds,
365
366
  defaultBaseURL: options.defaultBaseURL,
366
367
  usageUrl: options.usageUrl,
368
+ realmCaps: options.realmCaps,
367
369
  })
368
370
  }
369
371
 
@@ -1301,6 +1303,355 @@ function normalizeClinePassUsage(payload) {
1301
1303
  return { windows }
1302
1304
  }
1303
1305
 
1306
+ // ─── WorkBuddy2API 面板(workbuddy)用量 API ───────────────────────────────
1307
+ // 查询面是网关自己的**面板用量 API**(不是 OpenAI 标准 /usage,/status 是账号池状态也不查):
1308
+ // GET <渠道 baseURL 同源>/panel/api/usage?hours=24
1309
+ // Authorization: Bearer <网关 API Key>
1310
+ // 返回 totals(请求/错误/输入输出总 Token/积分/缓存/平均延迟)+ by_realm + by_account + by_model
1311
+ // + series(小时桶)。`hours` 同时收窄全部汇总(2026-10-18 真 key 实测:hours=1 时 totals、
1312
+ // by_realm、by_account、by_model 同步变成 1 小时口径),缺省即 24h,这里显式钉住。
1313
+ //
1314
+ // **本渠道只能手动适配**:渠道 baseURL 是中转地址,主机名不构成服务身份(同一个中转域名可以
1315
+ // 代理任何上游),自动识别会把「恰好指向该主机的任意渠道」错误认领——故 Adapter 不声明
1316
+ // hosts/routeIds,用户在额度页「手动适配」里显式选择本类型。端点取所选渠道 baseURL 的
1317
+ // **同源**(中转网关与面板 API 同域:推理是 `…/v1`,面板在根路径 `/panel/api/usage`),
1318
+ // 换中转域名无需改代码;baseURL 为空/非法时如实报错,绝不回退到写死的主机。
1319
+ //
1320
+ // 面板用量只回答「用了多少」,没有剩余额度/套餐窗口;本卡片的百分比只来自区域油表(分母是
1321
+ // 每日模型量),其余行按文本窗口下发(宿主只出数字与 K/M/B、s、% 这类语言中立单位)。
1322
+ // 总量统计(请求/错误/输入输出 Token/积分/延迟/缓存命中率)不上卡:面板网页就看得到,卡片
1323
+ // 顶部改为**限流台账**——`GET <同源>/panel/api/overview` 的
1324
+ // `accounts[].rate_limited_models[]`(模型级 6004;`reset_at` 是上游原始重置墙钟,`until` 是
1325
+ // 网关自己的路由解锁时刻)与账号级 `cooling`/`until`/`reason`。限流台账是附加信息:拉不到
1326
+ // (旧面板没有该端点/权限不足)就不出限流行,绝不影响用量与油表。
1327
+ // 时间窗:面板「用量」默认档是 72h(`TRANGE_DEFAULT = '72'`),但用户实测 72h 与面板
1328
+ // 账号表仍有出入(面板侧可能另有口径),**明确要求改回 24 小时**。窗口是单一常量,
1329
+ // 改档只动这里;服务端对流式窗口按整点对齐(24h 覆盖「当前整点−23h」到此刻)。
1330
+ const WORKBUDDY_USAGE_PATH = '/panel/api/usage?hours=24'
1331
+ const WORKBUDDY_OVERVIEW_PATH = '/panel/api/overview'
1332
+ const MAX_WORKBUDDY_REALMS = 4
1333
+ const MAX_WORKBUDDY_ACCOUNTS = 6
1334
+ const MAX_WORKBUDDY_LIMIT_REALMS = 4
1335
+ const MAX_WORKBUDDY_LIMIT_MODELS = 2
1336
+ const MAX_WORKBUDDY_COOLING_ACCOUNTS = 2
1337
+ const WORKBUDDY_USAGE_WINDOW_LABEL = '24h'
1338
+ // 两个区的**每日模型量**上限(CodeBuddy 账号口径,用户提供):cn 200M / global 100M。
1339
+ // 这两个数是画油表唯一需要的分母——面板 API 本身只回用量、不回上限。
1340
+ // **可在额度页的「配置」区按 provider 覆盖**(写进 `$DSH_HOME/dsh-service-quota.json` 的
1341
+ // `realmCaps` 区块);这里是未配置时的默认值。未登记上限的区只出文字行、不画进度(不编分母)。
1342
+ const WORKBUDDY_DEFAULT_REALM_CAPS = Object.freeze({ cn: 200000000, global: 100000000 })
1343
+ const MAX_QUOTA_REALM_CAPS = 8
1344
+
1345
+ /** 非负有限数;其它(null/布尔/空串/负数/NaN)一律 undefined,不猜 0。
1346
+ * 注意 Number(null)/Number('')/Number(false) 都是 0——必须先按类型拒掉,否则「缺失」
1347
+ * 会被渲染成「0 请求 / 0 Token」的假数据。 */
1348
+ function workbuddyNumber(value) {
1349
+ if (typeof value === 'number') return Number.isFinite(value) && value >= 0 ? value : undefined
1350
+ if (typeof value === 'string' && value.trim() !== '') {
1351
+ const n = Number(value)
1352
+ return Number.isFinite(n) && n >= 0 ? n : undefined
1353
+ }
1354
+ return undefined
1355
+ }
1356
+
1357
+ /** 与客户端 formatCompactCount 同一套 K/M/B 档位(语言中立单位,宿主可安全拼进文本)。 */
1358
+ function workbuddyCompactCount(value) {
1359
+ if (value >= 1e9) return `${Math.round(value / 1e8) / 10}B`
1360
+ if (value >= 1e6) return `${Math.round(value / 1e5) / 10}M`
1361
+ if (value >= 1e3) return `${Math.round(value / 1e2) / 10}K`
1362
+ return String(Math.round(value))
1363
+ }
1364
+
1365
+ /** 积分等小数量:两位小数(与面板同口径),不引入本地化千分位。 */
1366
+ function workbuddyDecimalText(value) {
1367
+ return (Math.round(value * 100) / 100).toFixed(2)
1368
+ }
1369
+
1370
+ /** 面板时间(`reset_at`/`until`)归一成 ISO:只有可解析的字符串才收,其余 undefined。
1371
+ * 归一化阶段**不按「现在」过滤**(宿主时钟/浏览器时钟差不该让台账凭空消失),
1372
+ * 倒计时是否显示由客户端按自己的时钟判断。 */
1373
+ function workbuddyIsoTime(value) {
1374
+ if (typeof value !== 'string' || value.trim() === '') return undefined
1375
+ const parsed = Date.parse(value)
1376
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined
1377
+ }
1378
+
1379
+ /** 账号/区域标签是纯数据:去控制字符、压空白、限长;空值返回 undefined(不造占位文案)。 */
1380
+ function workbuddyLabel(value, maxLength) {
1381
+ if (typeof value !== 'string') return undefined
1382
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
1383
+ return cleaned === '' ? undefined : cleaned.slice(0, maxLength)
1384
+ }
1385
+
1386
+ /** 汇总行的「请求 / Token / 积分」三段:缺哪段就少哪段,全缺返回空数组(调用方跳过整行)。 */
1387
+ function workbuddyUsageParts(row) {
1388
+ const parts = []
1389
+ const requests = workbuddyNumber(row?.requests)
1390
+ if (requests !== undefined) parts.push({ kindKey: 'requests', text: String(Math.round(requests)) })
1391
+ const tokens = workbuddyNumber(row?.total_tokens)
1392
+ if (tokens !== undefined) parts.push({ kindKey: 'tokens', text: workbuddyCompactCount(tokens) })
1393
+ const credits = workbuddyNumber(row?.credits)
1394
+ if (credits !== undefined) parts.push({ kindKey: 'credits', text: workbuddyDecimalText(credits) })
1395
+ return parts
1396
+ }
1397
+
1398
+ /** 面板余额台账(`/panel/api/overview` 的 `accounts[].credits` / `credits_total`):
1399
+ * key = 账号 uid,与用量 `by_account[].key` 同值(2026-10-19 真机核对)。返回
1400
+ * `Map<uid, {credits, total}>`;没有余额字段的账号不进表——宁可回落「本窗口消耗积分」,
1401
+ * 也不把未知余额渲染成 0。 */
1402
+ function workbuddyCreditBalances(limits) {
1403
+ const balances = new Map()
1404
+ if (limits === null || typeof limits !== 'object' || Array.isArray(limits) || !Array.isArray(limits.accounts)) return balances
1405
+ for (const account of limits.accounts) {
1406
+ if (account === null || typeof account !== 'object' || Array.isArray(account)) continue
1407
+ const uid = workbuddyLabel(account.uid, 64)
1408
+ const credits = workbuddyNumber(account.credits)
1409
+ if (uid === undefined || credits === undefined) continue
1410
+ balances.set(uid, { credits, total: workbuddyNumber(account.credits_total) })
1411
+ }
1412
+ return balances
1413
+ }
1414
+
1415
+ /** 剩余积分文本:面板积分是整数;有总额度时给「剩余 / 总额」,否则只给剩余。 */
1416
+ function workbuddyCreditBalanceText(balance) {
1417
+ const credits = String(Math.round(balance.credits))
1418
+ return balance.total !== undefined && balance.total > 0 ? `${credits} / ${String(Math.round(balance.total))}` : credits
1419
+ }
1420
+
1421
+ /** 限流行:把面板限流台账(`/panel/api/overview`)转成文字窗口。
1422
+ * - 模型级(上游 6004):按域一行,段 = 模型 + 上游重置时刻(`reset_at`;缺失退 `until`);
1423
+ * - 账号级冷却(429/5xx 等):每号一行(≤2),段 = 原因 + 恢复时刻;
1424
+ * - 台账可用但当前无锁 → 一行「—」明确「现在是通的」;台账不可用(null)→ 不出行。
1425
+ * 时间一律下发 ISO(part 的 `iso` 字段),由客户端按浏览器时区格式化——宿主时区可能是
1426
+ * UTC,直接拼「HH:mm」会让用户看到错的时间。 */
1427
+ function workbuddyLimitWindows(limits, realmOrder) {
1428
+ if (limits === null || typeof limits !== 'object' || Array.isArray(limits) || !Array.isArray(limits.accounts)) return []
1429
+ const accounts = limits.accounts
1430
+ const byRealm = new Map()
1431
+ const cooling = []
1432
+ for (const account of accounts) {
1433
+ if (account === null || typeof account !== 'object' || Array.isArray(account)) continue
1434
+ const realm = workbuddyLabel(account.realm, 32)
1435
+ const listed = Array.isArray(account.rate_limited_models) ? account.rate_limited_models : []
1436
+ for (const entry of listed) {
1437
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
1438
+ // 只收「配额限流」:11102 model_unavailable 是模型不存在/不可用,不是额度,重试时间也不该
1439
+ // 被读成「配额重置」。
1440
+ if (typeof entry.kind === 'string' && entry.kind !== '' && entry.kind !== 'rate_limit') continue
1441
+ const model = workbuddyLabel(entry.model, 64)
1442
+ if (model === undefined) continue
1443
+ const at = workbuddyIsoTime(entry.reset_at) ?? workbuddyIsoTime(entry.until)
1444
+ const key = realm ?? ''
1445
+ if (!byRealm.has(key)) byRealm.set(key, new Map())
1446
+ const bucket = byRealm.get(key)
1447
+ const previous = bucket.get(model)
1448
+ // 同一模型可能挂在多个账号上:取最早的重置时刻(先恢复的那个)。
1449
+ if (previous === undefined || (at !== undefined && (previous === undefined || Date.parse(at) < Date.parse(previous)))) bucket.set(model, at)
1450
+ }
1451
+ if (account.cooling === true) {
1452
+ const parts = []
1453
+ const reason = workbuddyLabel(account.reason, 48)
1454
+ if (reason !== undefined) parts.push({ kindKey: 'reason', text: reason })
1455
+ const until = workbuddyIsoTime(account.until)
1456
+ if (until !== undefined) parts.push({ kindKey: 'reset-at', iso: until })
1457
+ if (parts.length === 0) continue
1458
+ const label = workbuddyLabel(account.nickname, 48) ?? realm
1459
+ cooling.push({ ...(label !== undefined ? { label } : {}), parts, ...(until !== undefined ? { resetsAt: until } : {}) })
1460
+ }
1461
+ }
1462
+ const windows = []
1463
+ const rank = (key) => {
1464
+ const index = realmOrder.indexOf(key)
1465
+ return index === -1 ? realmOrder.length : index
1466
+ }
1467
+ const keys = [...byRealm.keys()].sort((a, b) => rank(a) - rank(b)).slice(0, MAX_WORKBUDDY_LIMIT_REALMS)
1468
+ for (const key of keys) {
1469
+ const parts = []
1470
+ let shown = 0
1471
+ let hidden = 0
1472
+ let earliest
1473
+ for (const [model, at] of byRealm.get(key)) {
1474
+ if (shown >= MAX_WORKBUDDY_LIMIT_MODELS) { hidden += 1; continue }
1475
+ shown += 1
1476
+ parts.push({ kindKey: '', text: model })
1477
+ if (at !== undefined) {
1478
+ parts.push({ kindKey: 'reset-at', iso: at })
1479
+ if (earliest === undefined || Date.parse(at) < Date.parse(earliest)) earliest = at
1480
+ }
1481
+ }
1482
+ if (hidden > 0) parts.push({ kindKey: '', text: `+${hidden}` })
1483
+ windows.push({
1484
+ id: key === '' ? `limit-${windows.length}` : `limit-${key}`,
1485
+ kindKey: 'limit-models',
1486
+ ...(key === '' ? {} : { label: key }),
1487
+ parts,
1488
+ ...(earliest !== undefined ? { resetsAt: earliest } : {}),
1489
+ })
1490
+ }
1491
+ for (const entry of cooling.slice(0, MAX_WORKBUDDY_COOLING_ACCOUNTS)) {
1492
+ windows.push({ id: `cooling-${windows.length}`, kindKey: 'account-cooling', ...entry })
1493
+ }
1494
+ if (windows.length === 0) windows.push({ id: 'limit', kindKey: 'rate-limits', text: '—' })
1495
+ return windows
1496
+ }
1497
+
1498
+ function normalizeWorkbuddyUsage(payload, realmCaps, limits) {
1499
+ const caps = realmCaps !== null && typeof realmCaps === 'object' && !Array.isArray(realmCaps) ? realmCaps : WORKBUDDY_DEFAULT_REALM_CAPS
1500
+ const windows = []
1501
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return { windows }
1502
+ const totals = payload.totals
1503
+ const hasTotals = totals !== null && typeof totals === 'object' && !Array.isArray(totals)
1504
+ const realms = (Array.isArray(payload.by_realm) ? payload.by_realm : [])
1505
+ .filter((realm) => realm !== null && typeof realm === 'object' && !Array.isArray(realm))
1506
+ .slice(0, MAX_WORKBUDDY_REALMS)
1507
+ const accounts = (Array.isArray(payload.by_account) ? payload.by_account : [])
1508
+ .filter((account) => account !== null && typeof account === 'object' && !Array.isArray(account))
1509
+ .sort((a, b) => (workbuddyNumber(b.total_tokens) ?? 0) - (workbuddyNumber(a.total_tokens) ?? 0))
1510
+ .slice(0, MAX_WORKBUDDY_ACCOUNTS)
1511
+ // 面板返回既没有 totals 也没有任何汇总 → 形状不认识(fetcher 转 bad-payload:shape,不谎报成功)。
1512
+ if (!hasTotals && realms.length === 0 && accounts.length === 0) return { windows }
1513
+ // 统计口径行:下面区域油表与账号行都是这个滚动窗口内的数字。
1514
+ windows.push({ id: 'usage-window', kindKey: 'usage-window', text: WORKBUDDY_USAGE_WINDOW_LABEL })
1515
+ // 限流台账(用户要求:卡片顶部只展示限流相关信息,总量统计不再上卡)。
1516
+ const realmOrder = realms.map((realm) => workbuddyLabel(realm.key, 32)).filter((key) => key !== undefined)
1517
+ windows.push(...workbuddyLimitWindows(limits, realmOrder))
1518
+
1519
+ // 区域行画成**百分比窗口**(油表/进度条):分母 = 该区每日模型量上限。这是本渠道唯一
1520
+ // 有真实分母的量——账号行没有上限,只能出文字行(不编分母)。
1521
+ // percent 截到 100,`used`/`limit` 原值下发,客户端显示「100% · 200.7M / 200M」
1522
+ // (超上限时仍看得见真实用量,不是假的刚好 100%)。
1523
+ for (const realm of realms) {
1524
+ const key = workbuddyLabel(realm.key, 32)
1525
+ if (key === undefined) continue
1526
+ const cap = Object.prototype.hasOwnProperty.call(caps, key) ? Number(caps[key]) : undefined
1527
+ const tokens = workbuddyNumber(realm.total_tokens)
1528
+ if (cap === undefined || tokens === undefined || cap <= 0) {
1529
+ // 没上限的区退回文字行(请求 / Token / 积分),不画进度。
1530
+ const parts = workbuddyUsageParts(realm)
1531
+ if (parts.length > 0) windows.push({ id: `realm-${key}`, kindKey: 'realm-usage', label: key, parts })
1532
+ continue
1533
+ }
1534
+ windows.push({
1535
+ id: `realm-${key}`,
1536
+ kindKey: 'realm-daily-tokens',
1537
+ label: key,
1538
+ percent: Math.min(100, Math.round((tokens / cap) * 100)),
1539
+ used: tokens,
1540
+ limit: cap,
1541
+ })
1542
+ }
1543
+
1544
+ const balances = workbuddyCreditBalances(limits)
1545
+ accounts.forEach((account, index) => {
1546
+ const parts = workbuddyUsageParts(account)
1547
+ if (parts.length === 0) return
1548
+ // 剩余积分优先用面板余额台账(`credits` 是余额,用量行的 `credits` 是本窗口消耗):
1549
+ // 同一段里替换掉「消耗」,否则一行放不下两个积分口径。台账缺失(旧面板/无权限)就
1550
+ // 保留窗口消耗,与改版前一致。
1551
+ const uid = workbuddyLabel(account.key, 64)
1552
+ const balance = uid === undefined ? undefined : balances.get(uid)
1553
+ if (balance !== undefined) {
1554
+ const part = { kindKey: 'credit-balance', text: workbuddyCreditBalanceText(balance) }
1555
+ const at = parts.findIndex((entry) => entry.kindKey === 'credits')
1556
+ if (at === -1) parts.push(part)
1557
+ else parts[at] = part
1558
+ }
1559
+ // 标签优先级:账号昵称/邮箱(数据)→ 区域码 → 截断的账号键;全缺就不带标签。
1560
+ const label = workbuddyLabel(account.extra, 128) ?? workbuddyLabel(account.realm, 32) ?? workbuddyLabel(account.key, 32)
1561
+ windows.push({ id: `account-${index}`, kindKey: 'account-usage', ...(label !== undefined ? { label } : {}), parts })
1562
+ })
1563
+ return { windows }
1564
+ }
1565
+
1566
+ /**
1567
+ * 面板端点 = 所选渠道 baseURL 的同源 + 固定路径(中转网关与面板 API 同域)。
1568
+ * 只接受 HTTPS、无 userinfo、默认端口;空/非法 baseURL 如实报错,不猜、不回退写死主机。
1569
+ */
1570
+ function workbuddyPanelUrl(profile) {
1571
+ const declared = String(profile?.baseURL ?? '').trim()
1572
+ if (declared === '') return { error: 'no-base-url' }
1573
+ let parsed
1574
+ try { parsed = new URL(declared) } catch (_) { return { error: 'unsafe-provider-endpoint' } }
1575
+ if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return { error: 'unsafe-provider-endpoint' }
1576
+ if (parsed.port !== '' && parsed.port !== '443') return { error: 'unsafe-provider-endpoint' }
1577
+ return { url: `${parsed.origin}${WORKBUDDY_USAGE_PATH}` }
1578
+ }
1579
+
1580
+ /**
1581
+ * 单个 provider 的 realmCaps 归一:区键 `[A-Za-z0-9_-]{1,32}`、值正整数 tokens ≤1e12,
1582
+ * 至多 `maxRealms` 条。宿主配置解析与 RPC 写入共用这一份,避免两边校验漂移。
1583
+ * @returns 归一后的 `{realm: tokens}`;无有效条目时返回 `{}`。
1584
+ */
1585
+ function sanitizeRealmCaps(raw, maxRealms = MAX_QUOTA_REALM_CAPS) {
1586
+ const out = {}
1587
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out
1588
+ let count = 0
1589
+ for (const [realm, value] of Object.entries(raw)) {
1590
+ if (count >= maxRealms) break
1591
+ if (typeof realm !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(realm)) continue
1592
+ const numeric = Number(value)
1593
+ if (!Number.isFinite(numeric) || numeric < 1 || numeric > 1e12) continue
1594
+ out[realm] = Math.round(numeric)
1595
+ count += 1
1596
+ }
1597
+ return out
1598
+ }
1599
+
1600
+ /** 配置覆盖合并到默认上限:只接受正整数,其它条目忽略(坏配置不得让油表变成乱码)。 */
1601
+ function mergeWorkbuddyRealmCaps(providerCaps) {
1602
+ return { ...WORKBUDDY_DEFAULT_REALM_CAPS, ...sanitizeRealmCaps(providerCaps) }
1603
+ }
1604
+
1605
+ /**
1606
+ * 有效上限(默认 + provider 覆盖),供额度快照下发给客户端渲染输入框。
1607
+ * 没有默认值的 Adapter(不画油表的渠道)返回 undefined。
1608
+ */
1609
+ function quotaAdapterRealmCaps(adapter, providerCaps) {
1610
+ const metadata = ADAPTER_METADATA.get(adapter)
1611
+ if (metadata === undefined || metadata.realmCaps === undefined) return undefined
1612
+ return mergeWorkbuddyRealmCaps(providerCaps)
1613
+ }
1614
+
1615
+ async function fetchWorkbuddyUsage({ profile, config, credential, signal, requestJson }) {
1616
+ if (typeof requestJson !== 'function') throw new Error('transport-unavailable')
1617
+ const endpoint = workbuddyPanelUrl(profile)
1618
+ if (endpoint.error !== undefined) throw new Error(endpoint.error)
1619
+ const raw = String(credential ?? '').trim()
1620
+ if (raw === '') throw new Error('credential-missing')
1621
+ // 发现链已按 policy.format 加了 Bearer;粘贴带回的 `Authorization: Bearer x` 整头也容错。
1622
+ const token = raw.replace(/^authorization:\s*/i, '').replace(/^bearer\s+/i, '')
1623
+ if (token === '') throw new Error('credential-missing')
1624
+ let payload
1625
+ try {
1626
+ payload = await requestJson(endpoint.url, { authorization: `Bearer ${token}`, signal })
1627
+ } catch (error) {
1628
+ if (error?.message === 'http-status:401' || error?.message === 'http-status:403') {
1629
+ const rejected = new Error('credential-rejected')
1630
+ if (typeof error?.detail === 'string' && error.detail !== '') rejected.detail = error.detail
1631
+ throw rejected
1632
+ }
1633
+ throw error
1634
+ }
1635
+ // 限流台账是**附加信息**:同源的 /panel/api/overview 拿账号池状态(含 6004 的 reset_at)。
1636
+ // 旧面板没有该端点、或该 key 无权读面板面 → 静默降级(limits = null,卡片不出限流行),
1637
+ // 绝不让附加查询的失败把「用量」本身打成错误。
1638
+ let limits = null
1639
+ try {
1640
+ const origin = new URL(endpoint.url).origin
1641
+ const overview = await requestJson(`${origin}${WORKBUDDY_OVERVIEW_PATH}`, { authorization: `Bearer ${token}`, signal })
1642
+ if (overview !== null && typeof overview === 'object' && Array.isArray(overview.accounts)) limits = overview
1643
+ } catch (error) {
1644
+ if (signal?.aborted === true) throw error
1645
+ limits = null
1646
+ }
1647
+ const providerCaps = config?.realmCaps !== null && typeof config?.realmCaps === 'object'
1648
+ ? config.realmCaps[String(profile?.name ?? '')]
1649
+ : undefined
1650
+ const windows = normalizeWorkbuddyUsage(payload, mergeWorkbuddyRealmCaps(providerCaps), limits).windows
1651
+ if (windows.length === 0) throw new Error('bad-payload:shape')
1652
+ return windows
1653
+ }
1654
+
1304
1655
  function createQuotaAdapterCatalog() {
1305
1656
  return Object.freeze([
1306
1657
  createEndpointAdapter({
@@ -1448,6 +1799,16 @@ function createQuotaAdapterCatalog() {
1448
1799
  routeIds: ['command-goat'],
1449
1800
  usageUrl: 'https://commandcode.ai/settings/usage',
1450
1801
  }),
1802
+ createComposedAdapter({
1803
+ kind: 'workbuddy',
1804
+ fetch: fetchWorkbuddyUsage,
1805
+ keyHints: ['WORKBUDDY_API_KEY'],
1806
+ // 只走手动适配:baseURL 是中转地址,主机名不构成服务身份(见上方分节注释)。
1807
+ // 不声明 hosts/routeIds ⇒ recognize() 恒 false,绝不自动认领任何渠道。
1808
+ // 不设 usageUrl:中转域名随部署而变,静态外链会指错(面板就在所选渠道同源根路径)。
1809
+ // realmCaps:油表分母的默认值(额度页可按 provider 覆盖,写进 quota 配置的 realmCaps)。
1810
+ realmCaps: WORKBUDDY_DEFAULT_REALM_CAPS,
1811
+ }),
1451
1812
  ])
1452
1813
  }
1453
1814
 
@@ -1487,6 +1848,10 @@ export {
1487
1848
  fetchCliproxyUsage,
1488
1849
  fetchCommandCodeQuota,
1489
1850
  fetchStepFunStepPlanUsage,
1851
+ fetchWorkbuddyUsage,
1852
+ MAX_QUOTA_REALM_CAPS,
1853
+ WORKBUDDY_DEFAULT_REALM_CAPS,
1854
+ workbuddyPanelUrl,
1490
1855
  fetchXiaomiTokenPlanUsage,
1491
1856
  findQuotaAdapter,
1492
1857
  normalizeAntigravityModels,
@@ -1501,10 +1866,14 @@ export {
1501
1866
  normalizeSiliconFlowInfo,
1502
1867
  normalizeStepfunBalance,
1503
1868
  normalizeStepFunStepPlanUsage,
1869
+ mergeWorkbuddyRealmCaps,
1870
+ normalizeWorkbuddyUsage,
1504
1871
  normalizeXiaomiTokenPlanUsage,
1505
1872
  normalizeZaiCodingUsage,
1506
1873
  prepareQuotaAdapterConfig,
1507
1874
  quotaAdapterEndpoints,
1875
+ quotaAdapterRealmCaps,
1876
+ sanitizeRealmCaps,
1508
1877
  quotaAdapterUsageUrl,
1509
1878
  quotaErrorCode,
1510
1879
  quotaErrorDetailFromText,
package/quota-routes.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { randomBytes } from 'node:crypto'
7
7
  import { resolve } from 'node:path'
8
- import { prepareQuotaAdapterConfig, quotaAdapterUsageUrl } from './quota-adapters.js'
8
+ import { prepareQuotaAdapterConfig, quotaAdapterRealmCaps, quotaAdapterUsageUrl, sanitizeRealmCaps } from './quota-adapters.js'
9
9
 
10
10
  export function createQuotaRoutes({
11
11
  ctx,
@@ -113,6 +113,12 @@ export function createQuotaRoutes({
113
113
  credentialEntryKey: adapter.credentialPolicy(profile).entryKey,
114
114
  // 官网用户页余额网址由具体 Adapter 持有(宿主常量白名单;无则缺省)。
115
115
  ...(typeof quotaAdapterUsageUrl(adapter) === 'string' && quotaAdapterUsageUrl(adapter) !== '' ? { usageUrl: quotaAdapterUsageUrl(adapter) } : {}),
116
+ // 油表分母(默认 + 配置覆盖):只有声明了默认值的 Adapter 才有,客户端据此渲染
117
+ // 「每日模型量」输入框;未声明的渠道不下发,UI 自然不出现这块。
118
+ ...(() => {
119
+ const caps = quotaAdapterRealmCaps(adapter, config?.realmCaps?.[profile.name])
120
+ return caps === undefined ? {} : { realmCaps: caps }
121
+ })(),
116
122
  })
117
123
  }
118
124
  return { ok: true, value: { providers: rows, serverTime: Date.now() } }
@@ -183,6 +189,35 @@ export function createQuotaRoutes({
183
189
  return rpcTechnicalFailure(error)
184
190
  }
185
191
 
192
+ } },
193
+ 'quota-realm-caps': { feature: 'quotaLookup', audit: true, handle: async (payload, rpcEndpoint) => {
194
+ try {
195
+ // 油表分母的 provider 级覆盖:{provider, caps:{realm: tokens}} 写入,{clear:true} 恢复默认。
196
+ // 只接受已适配且**声明了默认分母**的 Adapter(目前是 workbuddy)——其它渠道写进来
197
+ // 也没有消费者,反而会污染配置文件。provider 必须来自宿主自己的渠道清单。
198
+ const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
199
+ const profileForProvider = readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).find((candidate) => candidate.name === providerName)
200
+ if (profileForProvider === undefined) return { ok: false, error: 'unknown-provider' }
201
+ const config = await refreshQuotaConfigCache()
202
+ const { adapter } = resolveQuotaKind(config, profileForProvider)
203
+ if (adapter === undefined) return { ok: false, error: 'not-adapted' }
204
+ if (quotaAdapterRealmCaps(adapter, {}) === undefined) return { ok: false, error: 'realm-caps-unsupported' }
205
+ let nextCaps = {}
206
+ if (payload?.clear !== true) {
207
+ nextCaps = sanitizeRealmCaps(payload?.caps)
208
+ if (Object.keys(nextCaps).length === 0) return { ok: false, error: 'invalid-caps' }
209
+ }
210
+ return await serializeQuotaConfigWrite(async (draft) => {
211
+ if (Object.keys(nextCaps).length === 0) delete draft.realmCaps[providerName]
212
+ else draft.realmCaps[providerName] = nextCaps
213
+ // 分母变了,缓存里的窗口还是旧口径:清闸让客户端随后那次手动刷新真的打上游重算。
214
+ quotaThrottle.resetGates(providerName)
215
+ return { value: { ok: true, value: { realmCaps: nextCaps } } }
216
+ })
217
+ } catch (error) {
218
+ return rpcTechnicalFailure(error)
219
+ }
220
+
186
221
  } },
187
222
  'quota-credential-set': { feature: 'quotaLookup', audit: true, handle: (payload, rpcEndpoint) => quotaCredentialEndpoint(ctx, refreshQuotaConfigCache, quotaThrottle, payload, rpcEndpoint) },
188
223
  'quota-credential-unset': { feature: 'quotaLookup', audit: true, handle: (payload, rpcEndpoint) => quotaCredentialEndpoint(ctx, refreshQuotaConfigCache, quotaThrottle, payload, rpcEndpoint) },