@linxin666/dsh-usage 0.3.23 → 0.4.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.
Files changed (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +7 -9
  3. package/README.zh.md +7 -9
  4. package/icon.svg +3 -0
  5. package/lib/client.js +621 -65
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +258 -271
  8. package/lib/types/client/UsageFootCard.d.ts +55 -0
  9. package/lib/types/client/UsageSectionCard.d.ts +3 -4
  10. package/lib/types/client/body-mutations.d.ts +45 -0
  11. package/lib/types/client/foot-card-mount.d.ts +34 -0
  12. package/lib/types/client/index.d.ts +21 -8
  13. package/lib/types/client/locales.d.ts +6 -4
  14. package/lib/types/core/provider-routes.d.ts +26 -0
  15. package/lib/types/core/types.d.ts +14 -1
  16. package/lib/types/host/access.d.ts +17 -0
  17. package/lib/types/host/http.d.ts +18 -1
  18. package/lib/types/host/pair-access.d.ts +34 -0
  19. package/lib/types/host/routes.d.ts +16 -7
  20. package/lib/types/host/usage-service.d.ts +32 -76
  21. package/lib/types/index.d.ts +14 -2
  22. package/package.json +20 -17
  23. package/src/client/UsageFootCard.tsx +300 -0
  24. package/src/client/UsageSectionCard.tsx +41 -23
  25. package/src/client/body-mutations.ts +133 -0
  26. package/src/client/foot-card-mount.tsx +112 -0
  27. package/src/client/index.ts +57 -16
  28. package/src/client/locales.ts +12 -8
  29. package/src/client/usage.module.css +188 -0
  30. package/src/core/adapters.ts +40 -6
  31. package/src/core/provider-routes.ts +37 -0
  32. package/src/core/types.ts +14 -1
  33. package/src/host/access.ts +21 -0
  34. package/src/host/http.ts +23 -1
  35. package/src/host/pair-access.ts +49 -0
  36. package/src/host/routes.ts +19 -10
  37. package/src/host/usage-service.ts +74 -218
  38. package/src/index.ts +59 -60
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Sidebar foot card mounting.
3
+ *
4
+ * The foot's only slot (sidebar.footer.action) stacks ABOVE the Settings row
5
+ * and cannot host a block, so the card container is inserted into the shell's
6
+ * foot area directly above the Settings seat — keeping Settings the foot's
7
+ * terminal row instead of hanging a card off the sidebar floor. It self-heals
8
+ * with the page-wide body-mutation hub: a React re-render that displaces the
9
+ * container re-seats it on the next frame, and a whole-pane rebuild is noticed
10
+ * by the same body-level watcher the family entry rows use.
11
+ *
12
+ * The container is a plain div carrying its own React root, so it can never
13
+ * disturb the shell's reconciliation.
14
+ * @module @linxin666/dsh-usage/client/foot-card-mount
15
+ */
16
+
17
+ import { createElement } from 'react'
18
+ import { createRoot, type Root } from 'react-dom/client'
19
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
20
+ import { UsageFootCard, type UsageFootCardProps } from './UsageFootCard.tsx'
21
+
22
+ /** Stable data attribute identifying the injected card container. */
23
+ export const FOOT_CARD_SELECTOR = '[data-dsh-usage-foot-card]'
24
+
25
+ /** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
26
+ function footArea(): HTMLElement | undefined {
27
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
+ return column?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
29
+ }
30
+
31
+ /**
32
+ * Open the settings panel on the usage section. The shell owns the panel's
33
+ * open state and exposes no service for it, so the card replays the user's
34
+ * own path: activate the sidebar Settings trigger, then pick the nav row
35
+ * carrying the section's (localized) label. Every step degrades silently —
36
+ * with the panel already open only the nav pick runs, and a missing row
37
+ * leaves the panel on its default section.
38
+ * @param label - the usage section's nav label in the active locale.
39
+ */
40
+ export function openUsageSettings(label: () => string): void {
41
+ /** Click the nav row carrying the usage label inside the open panel. */
42
+ const pick = (panel: Element): void => {
43
+ const wanted = label()
44
+ for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
45
+ if (row.textContent !== null && row.textContent.includes(wanted)) {
46
+ row.click()
47
+ return
48
+ }
49
+ }
50
+ // Panel open but the usage row is gone (plugin toggled off): stay put.
51
+ }
52
+ // A panel that is already open must never be toggled shut by the trigger;
53
+ // only the nav pick runs.
54
+ const open = document.querySelector('[role="dialog"]')
55
+ if (open !== null) {
56
+ pick(open)
57
+ return
58
+ }
59
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
60
+ const trigger = column?.querySelector<HTMLButtonElement>('[class*="settingsArea"] button')
61
+ trigger?.click()
62
+ let tries = 0
63
+ const attempt = (): void => {
64
+ const panel = document.querySelector('[role="dialog"]')
65
+ if (panel !== null) {
66
+ pick(panel)
67
+ return
68
+ }
69
+ tries += 1
70
+ // The panel mounts on the shell's next frames; wait briefly, then give up.
71
+ if (tries <= 20) window.setTimeout(attempt, 50)
72
+ }
73
+ window.setTimeout(attempt, 0)
74
+ }
75
+
76
+ /**
77
+ * Mount the sidebar foot card directly above the Settings row.
78
+ * @param props - the store/poll/settings/open inputs of the apply body.
79
+ * @returns disposer removing the container and its observers.
80
+ */
81
+ export function mountUsageFootCard(props: UsageFootCardProps): () => void {
82
+ // DOM-level idempotency: whatever path mounted a card before this call (a
83
+ // duplicated apply, an HMR re-injection), never mount a second one.
84
+ if (typeof document !== 'undefined' && document.querySelector(FOOT_CARD_SELECTOR) !== null) {
85
+ return () => {}
86
+ }
87
+ const container = document.createElement('div')
88
+ container.setAttribute('data-dsh-usage-foot-card', '')
89
+ const root: Root = createRoot(container)
90
+ root.render(createElement(UsageFootCard, props))
91
+
92
+ /** Keep the container directly above the Settings seat inside the foot area. */
93
+ const place = (): void => {
94
+ const foot = footArea()
95
+ if (foot === undefined) return
96
+ const settings = foot.querySelector<HTMLElement>('[class*="settingsArea"]')
97
+ if (settings !== null) {
98
+ if (container.nextElementSibling !== settings) foot.insertBefore(container, settings)
99
+ return
100
+ }
101
+ // No Settings seat to anchor against (shell change): fall back to the tail.
102
+ if (foot.lastElementChild !== container) foot.append(container)
103
+ }
104
+ place()
105
+ const unsubscribeBody = subscribeBodyInvalidations(place)
106
+
107
+ return () => {
108
+ unsubscribeBody()
109
+ root.unmount()
110
+ container.remove()
111
+ }
112
+ }
@@ -1,24 +1,26 @@
1
1
  /**
2
2
  * dsh-usage browser half — seats the first-level 使用统计 settings section
3
- * (below the Workshop entry) and polls the host overview only while the
4
- * section is open. All provider probing and credential handling happens in
5
- * the host half; this bundle only renders the overview document.
3
+ * (below the Workshop entry) plus the compact usage glance card below the
4
+ * sidebar's Settings row. The section polls the host overview only while it
5
+ * is open; the foot card runs its own relaxed loop because the sidebar foot
6
+ * is permanently mounted. All provider probing and credential handling
7
+ * happens in the host half; this bundle only renders the overview document.
6
8
  * @module @linxin666/dsh-usage/client
7
9
  */
8
10
 
9
11
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
10
- import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
11
13
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
12
14
  import type {} from '@deepseek-ai/dsh-client-locale/client'
13
- // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
15
+ // Type-only: pulls the shared-forms Context merge (ctx.configForms).
14
16
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
17
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
16
18
  // Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
17
19
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
18
- import { createElement } from 'react'
19
20
  import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
20
21
  import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
21
- import { NS, en, zh } from './locales.ts'
22
+ import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
23
+ import { NS, en, zh, t } from './locales.ts'
22
24
  import type { UsageOverviewView } from '../core/types.ts'
23
25
 
24
26
  /** The host usage API as the browser sees it (same-origin JSON endpoints). */
@@ -44,27 +46,39 @@ const usageApi: UsageHttpApi = {
44
46
  refresh: () => usageFetch('/api/dsh-usage/refresh', 'POST'),
45
47
  }
46
48
 
47
- /** Settings namespace the section edits (the host plugin registers it). */
49
+ /** Settings namespace the section edits (dsh-web-settings maps it onto this row's profile entry id). */
48
50
  const USAGE_SETTINGS_NS = 'dsh-usage'
49
51
 
50
52
  /** First-level nav position: directly below the Workshop section (order 150). */
51
53
  const SECTION_ORDER = 151
52
54
 
53
55
  /** Required services. */
54
- export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
56
+ export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
55
57
 
56
58
  export type { UsageSectionProps, UsageSectionFace } from './UsageSectionCard.tsx'
57
59
  export type { UsageUiState } from './usage-store.ts'
58
60
  export type { UsageSettings }
59
61
 
62
+ /**
63
+ * One settings namespace a family card binds. The 0.1.7 client exports no spec
64
+ * type (the form controller takes it privately), so the binder's input shape is
65
+ * restated here.
66
+ */
67
+ export interface UsageSettingsBindSpec<T> {
68
+ /** Settings namespace registered by the owning host plugin. */
69
+ namespace: string
70
+ /** Narrow one wire section; undefined keeps the last accepted value. */
71
+ decode?: (section: unknown) => T | undefined
72
+ }
73
+
60
74
  declare module '@deepseek-ai/cordis' {
61
75
  interface Context {
62
76
  /**
63
- * Optional rc.6 compatibility binder provided by dsh-web-settings;
64
- * absent when that group plugin is not installed, so callers fall back to
65
- * the official settings scope.
77
+ * Optional family settings binder provided by dsh-web-settings; absent when
78
+ * that group plugin is not installed, so callers fall back to the native
79
+ * per-entry forms (`ctx.configForms`).
66
80
  */
67
- webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
81
+ webUiSettings?: { bind<S>(spec: UsageSettingsBindSpec<S>): ConfigForm<S> }
68
82
  }
69
83
  }
70
84
 
@@ -83,8 +97,16 @@ export function apply(ctx: ClientContext): void {
83
97
  }
84
98
  }, 'dsh-usage: dictionaries')
85
99
 
86
- const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
87
- const settingsScope = binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
100
+ // The family binder is what maps this namespace onto the row's profile entry
101
+ // id and hands back the native form. Without the group plugin the client can
102
+ // only address an entry id it already knows: the namespace itself is one when
103
+ // the profile keeps the family spelling. Any other entry id leaves the form
104
+ // unavailable, which renders the row's controls disabled — the Host's own
105
+ // generated page for the row still edits the same Config.
106
+ const binder = ctx.get('webUiSettings')
107
+ const settingsForm = binder !== undefined
108
+ ? binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
109
+ : ctx.configForms.get<UsageSettings>(USAGE_SETTINGS_NS)
88
110
 
89
111
  // One store instance per apply body; the section mounts and unmounts with
90
112
  // the settings page, and the store survives between visits so the last
@@ -121,7 +143,26 @@ export function apply(ctx: ClientContext): void {
121
143
  })
122
144
  }
123
145
 
124
- const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsScope })
146
+ const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsForm })
147
+
148
+ // Sidebar foot card: the compact usage glance seated below the shell's
149
+ // Settings row. It shares the section's store and poll path (the sequence
150
+ // guard absorbs interleaved calls), runs its own relaxed poll loop, and
151
+ // opens the settings panel on the usage section when clicked.
152
+ const disposeFootCard = mountUsageFootCard({
153
+ store,
154
+ poll,
155
+ settings: settingsForm,
156
+ onOpen: () => { openUsageSettings(() => t('usage.title')) },
157
+ locale: ctx.locale,
158
+ })
159
+ ctx.effect(() => () => {
160
+ try {
161
+ disposeFootCard()
162
+ } catch {
163
+ // Card container already gone (teardown race).
164
+ }
165
+ }, 'dsh-usage: sidebar foot card')
125
166
 
126
167
  ctx.slots.inject('settings.section', () => {
127
168
  try {
@@ -48,10 +48,7 @@ export const zh = {
48
48
  'usage.config.title': '设置',
49
49
  'usage.config.enabled': '启用插件',
50
50
  'usage.config.pollIntervalSec': '轮询间隔(秒)',
51
- 'usage.config.bubbleMode': '宠物气泡',
52
- 'usage.config.bubbleMode.always': '常驻显示',
53
- 'usage.config.bubbleMode.change': '仅变化时',
54
- 'usage.config.bubbleMode.off': '关闭',
51
+ 'usage.config.saveFailed': '保存失败:设置未被接受。',
55
52
  'usage.bank.title': '鲸元券',
56
53
  'usage.bank.hint': '官方 API 每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
57
54
  'usage.bank.noUsage': '暂无 DeepSeek 官方用量数据(统计自插件启用起)',
@@ -62,6 +59,11 @@ export const zh = {
62
59
  'usage.bank.save': '保存图片',
63
60
  'usage.bank.share': '分享',
64
61
  'usage.bank.drawError': '票券生成失败:{error}',
62
+ 'usage.foot.cost': '今日消费',
63
+ 'usage.foot.noData': '今日暂无用量',
64
+ 'usage.foot.open': '打开使用统计设置',
65
+ 'usage.foot.collapse': '收起用量卡片',
66
+ 'usage.foot.expand': '展开用量卡片',
65
67
  }
66
68
 
67
69
  /** English mirror; every zh key present. */
@@ -105,10 +107,7 @@ export const en: Record<UsageKey, string> = {
105
107
  'usage.config.title': 'Settings',
106
108
  'usage.config.enabled': 'Enable plugin',
107
109
  'usage.config.pollIntervalSec': 'Poll interval (seconds)',
108
- 'usage.config.bubbleMode': 'Pet bubble',
109
- 'usage.config.bubbleMode.always': 'Always visible',
110
- 'usage.config.bubbleMode.change': 'On change',
111
- 'usage.config.bubbleMode.off': 'Off',
110
+ 'usage.config.saveFailed': 'Save failed: the deployment did not accept the setting.',
112
111
  'usage.bank.title': 'Whale-yuan voucher',
113
112
  'usage.bank.hint': 'Every 1,000,000 tokens spent on the official API mint one whale yuan; save or share the note.',
114
113
  'usage.bank.noUsage': 'No official DeepSeek usage yet (counting starts when the plugin is enabled)',
@@ -119,6 +118,11 @@ export const en: Record<UsageKey, string> = {
119
118
  'usage.bank.save': 'Save image',
120
119
  'usage.bank.share': 'Share',
121
120
  'usage.bank.drawError': 'Failed to render the voucher: {error}',
121
+ 'usage.foot.cost': 'Today spend',
122
+ 'usage.foot.noData': 'No usage yet today',
123
+ 'usage.foot.open': 'Open usage statistics settings',
124
+ 'usage.foot.collapse': 'Collapse the usage card',
125
+ 'usage.foot.expand': 'Expand the usage card',
122
126
  }
123
127
 
124
128
  export type UsageKey = keyof typeof zh
@@ -381,3 +381,191 @@
381
381
  outline: none;
382
382
  box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
383
383
  }
384
+
385
+
386
+ /* ---------------------------------------------------------------------------
387
+ Sidebar foot card: the compact usage glance the mount seats as the shell
388
+ foot area's last child, directly below the Settings row. The chrome is an
389
+ outer div; the content is one transparent main button (the whole card opens
390
+ the usage settings section) with a sibling corner chevron toggling the
391
+ collapsed strip — sibling buttons, since nesting buttons is invalid HTML.
392
+ Quiet card on the same currentColor mixes as the section cards; hidden in
393
+ the 56px rail (the frame carries data-sidebar-collapsed).
394
+ --------------------------------------------------------------------------- */
395
+
396
+ /* Seated above the Settings row as a native sidebar row: the shell's row
397
+ radius and hover fill, with none of the card chrome that made it read as a
398
+ pill pasted onto the foot. Only the expanded body keeps a faint surface to
399
+ hold its four lines together; the collapsed strip is a plain 36px row. */
400
+ .footCard {
401
+ position: relative;
402
+ box-sizing: border-box;
403
+ width: 100%;
404
+ margin: 2px 0 4px;
405
+ border: none;
406
+ border-radius: 12px;
407
+ background: color-mix(in srgb, currentColor 4%, transparent);
408
+ color: inherit;
409
+ transition: background-color 120ms ease;
410
+ }
411
+
412
+ .footCard:hover {
413
+ background: var(--dsw-alias-interactive-bg-hover);
414
+ }
415
+
416
+ .footCardCollapsed {
417
+ background: transparent;
418
+ }
419
+
420
+ .footMain {
421
+ appearance: none;
422
+ box-sizing: border-box;
423
+ display: flex;
424
+ flex-direction: column;
425
+ gap: 4px;
426
+ width: 100%;
427
+ padding: 8px;
428
+ border: none;
429
+ border-radius: 12px;
430
+ background: transparent;
431
+ color: inherit;
432
+ font: inherit;
433
+ text-align: left;
434
+ cursor: pointer;
435
+ }
436
+
437
+ .footMain:focus-visible {
438
+ outline: none;
439
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
440
+ }
441
+
442
+ .footToggle {
443
+ appearance: none;
444
+ position: absolute;
445
+ top: 6px;
446
+ right: 6px;
447
+ display: inline-flex;
448
+ align-items: center;
449
+ justify-content: center;
450
+ width: 20px;
451
+ height: 20px;
452
+ padding: 0;
453
+ border: none;
454
+ border-radius: 6px;
455
+ background: transparent;
456
+ color: inherit;
457
+ cursor: pointer;
458
+ opacity: 0.55;
459
+ transition: opacity 120ms ease, background-color 120ms ease;
460
+ }
461
+
462
+ .footCard:hover .footToggle {
463
+ opacity: 0.8;
464
+ }
465
+
466
+ .footToggle:hover {
467
+ opacity: 1;
468
+ background: color-mix(in srgb, currentColor 10%, transparent);
469
+ }
470
+
471
+ .footToggle:focus-visible {
472
+ outline: none;
473
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
474
+ }
475
+
476
+ .footToggle svg {
477
+ display: block;
478
+ }
479
+
480
+ .footHead {
481
+ display: flex;
482
+ align-items: baseline;
483
+ justify-content: space-between;
484
+ gap: 8px;
485
+ }
486
+
487
+ .footTitle {
488
+ display: inline-flex;
489
+ align-items: center;
490
+ gap: 6px;
491
+ min-width: 0;
492
+ font-size: 12px;
493
+ opacity: 0.65;
494
+ white-space: nowrap;
495
+ }
496
+
497
+ .footTitle svg {
498
+ display: block;
499
+ flex: none;
500
+ }
501
+
502
+ /* The headline value leaves room for the corner toggle. */
503
+ .footValue {
504
+ margin-right: 18px;
505
+ font-size: 15px;
506
+ font-weight: 600;
507
+ font-variant-numeric: tabular-nums;
508
+ white-space: nowrap;
509
+ }
510
+
511
+ .footLine {
512
+ font-size: 11px;
513
+ opacity: 0.7;
514
+ font-variant-numeric: tabular-nums;
515
+ overflow: hidden;
516
+ text-overflow: ellipsis;
517
+ white-space: nowrap;
518
+ }
519
+
520
+ .footMeta {
521
+ font-size: 10px;
522
+ opacity: 0.5;
523
+ font-variant-numeric: tabular-nums;
524
+ }
525
+
526
+ /* Collapsed strip: tighter chrome, one horizontal line, room for the toggle. */
527
+ .footCardCollapsed .footMain {
528
+ min-height: 36px;
529
+ padding: 7px 8px;
530
+ }
531
+
532
+ .footStrip {
533
+ display: flex;
534
+ align-items: center;
535
+ gap: 6px;
536
+ padding-right: 20px;
537
+ font-size: 12px;
538
+ white-space: nowrap;
539
+ overflow: hidden;
540
+ }
541
+
542
+ .footStrip svg {
543
+ display: block;
544
+ flex: none;
545
+ opacity: 0.65;
546
+ }
547
+
548
+ .footStripProvider {
549
+ flex: none;
550
+ max-width: 45%;
551
+ overflow: hidden;
552
+ text-overflow: ellipsis;
553
+ font-weight: 500;
554
+ }
555
+
556
+ .footStripLabel {
557
+ opacity: 0.65;
558
+ overflow: hidden;
559
+ text-overflow: ellipsis;
560
+ }
561
+
562
+ .footStripValue {
563
+ margin-left: auto;
564
+ font-weight: 600;
565
+ font-variant-numeric: tabular-nums;
566
+ }
567
+
568
+ :global([data-dsh-frame][data-sidebar-collapsed]) .footCard,
569
+ :global([data-sidebar-collapsed]) .footCard {
570
+ display: none;
571
+ }
@@ -67,6 +67,11 @@ function str(value: unknown): string | undefined {
67
67
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
68
68
  }
69
69
 
70
+ /** Clamp a provider percentage into the renderable 0-100 band. */
71
+ function clampPercent(value: number): number {
72
+ return Math.max(0, Math.min(100, value))
73
+ }
74
+
70
75
  /** Format a number to a fixed 2-decimal display string. */
71
76
  function money(value: number): string {
72
77
  return value.toFixed(2)
@@ -190,7 +195,26 @@ const KIMI_CODING: ProviderAdapter = {
190
195
  },
191
196
  }
192
197
 
193
- /** GLM Coding Plan quota; auth is the RAW key without a Bearer prefix. */
198
+ /**
199
+ * GLM Coding Plan quota windows, keyed by the provider's own `unit` code
200
+ * rather than by row position so an added or reordered limit cannot shift a
201
+ * window's meaning: 3 = the rolling 5-hour window, 6 = the weekly window,
202
+ * 5 = the monthly one. Two row kinds carry quota — `TOKENS_LIMIT` (token
203
+ * plans) and `CREDIT_LIMIT` (credit plans, whose exact ratio is
204
+ * `currentValue` / `usage`) — while `TIME_LIMIT` is the MCP request cap and
205
+ * is not a plan window at all. Auth is the RAW key without a Bearer prefix.
206
+ */
207
+ const GLM_UNIT_KEYS: Readonly<Record<number, string>> = { 3: '5h', 5: 'month', 6: 'week' }
208
+
209
+ /** Percent from a credit row's exact ratio, else the provider's own percentage. */
210
+ function glmCreditPercent(row: Record<string, unknown>): number | undefined {
211
+ const used = toNum(row.currentValue)
212
+ const limit = toNum(row.usage)
213
+ if (used !== undefined && limit !== undefined && limit > 0) return clampPercent((used / limit) * 100)
214
+ const percentage = toNum(row.percentage)
215
+ return percentage === undefined ? undefined : clampPercent(percentage)
216
+ }
217
+
194
218
  function glmPlan(host: string, ids: readonly string[]): ProviderAdapter {
195
219
  return {
196
220
  ids,
@@ -212,11 +236,21 @@ function glmPlan(host: string, ids: readonly string[]): ProviderAdapter {
212
236
  for (const entry of limits) {
213
237
  if (typeof entry !== 'object' || entry === null) continue
214
238
  const row = entry as Record<string, unknown>
215
- const unit = toNum(row.unit)
216
- const percent = toNum(row.percentage)
239
+ const kind = str(row.type)
240
+ // The MCP request cap is a per-minute request budget, not a plan window.
241
+ if (kind === 'TIME_LIMIT') continue
242
+ const key = GLM_UNIT_KEYS[toNum(row.unit) ?? Number.NaN]
243
+ // Unknown units fall through: rendering them would invent a window.
244
+ if (key === undefined) continue
245
+ if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue
217
246
  windows.push({
218
- key: unit === 3 ? '5h' : unit === 6 ? 'week' : unit !== undefined ? `unit-${unit}` : 'window',
219
- percent: percent === undefined ? undefined : Math.max(0, Math.min(100, percent)),
247
+ key,
248
+ percent: kind === 'CREDIT_LIMIT'
249
+ ? glmCreditPercent(row)
250
+ : (() => {
251
+ const percentage = toNum(row.percentage)
252
+ return percentage === undefined ? undefined : clampPercent(percentage)
253
+ })(),
220
254
  resetsAt: toIso(row.nextResetTime),
221
255
  })
222
256
  }
@@ -409,7 +443,7 @@ export const PROVIDER_ADAPTERS: readonly ProviderAdapter[] = [
409
443
  moonshotBalance('api.moonshot.ai', 'USD', ['moonshotai']),
410
444
  KIMI_CODING,
411
445
  glmPlan('open.bigmodel.cn', ['zai-coding-cn']),
412
- glmPlan('api.z.ai', ['zai-coding']),
446
+ glmPlan('api.z.ai', ['zai', 'zai-coding']),
413
447
  OPENCODE_GO,
414
448
  minimaxPlan('api.minimaxi.com', ['minimax-cn']),
415
449
  minimaxPlan('api.minimax.io', ['minimax']),
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Alias folding for the enumerated provider routes. Kept in core because the
3
+ * rule is pure — the host enumerates routes and this decides which of two
4
+ * routes serving one adapter family survives.
5
+ * @module @linxin666/dsh-usage/core/provider-routes
6
+ */
7
+
8
+ import { adapterFor } from './adapters.ts'
9
+
10
+ /** The facts alias folding needs about one enumerated provider route. */
11
+ export interface FoldableRoute {
12
+ /** Provider route key (`deepseek`, `deepseek-official`, custom routes, ...). */
13
+ id: string
14
+ /** Whether the LLM runtime serves requests on this route; a catalog-only entry is dormant. */
15
+ live: boolean
16
+ }
17
+
18
+ /**
19
+ * Drop dormant catalog aliases that shadow a live route of the same adapter
20
+ * family. The pi-ai catalog ships route keys the runtime serves nothing on
21
+ * until a profile declares them: for DeepSeek the catalog entry `deepseek`
22
+ * sits beside the llm-deepseek runtime route `deepseek-official`, and both
23
+ * resolve the same credential (the family's `apiKeyEnv` fallback), so probing
24
+ * and rendering both turns one account into two identical balance rows.
25
+ *
26
+ * A family keeps every live route it has — two configured profiles are two
27
+ * real accounts, not aliases. A route with no adapter, or one whose family
28
+ * has no live route at all, is returned untouched.
29
+ */
30
+ export function foldAliasRoutes<T extends FoldableRoute>(routes: readonly T[]): T[] {
31
+ return routes.filter((route) => {
32
+ if (route.live) return true
33
+ const family = adapterFor(route.id)
34
+ if (family === undefined) return true
35
+ return !routes.some((other) => other.live && adapterFor(other.id) === family)
36
+ })
37
+ }
package/src/core/types.ts CHANGED
@@ -140,12 +140,25 @@ export interface ProviderSnapshotView {
140
140
  export interface UsageOverviewView {
141
141
  updatedAt: number
142
142
  providers: ProviderSnapshotView[]
143
- /** The provider the pet bubble and the header highlight; `source` says how it was picked. */
143
+ /** The provider the collapsed sidebar strip and the header highlight; `source` says how it was picked. */
144
144
  current: {
145
145
  provider?: string
146
146
  model?: string
147
147
  /** `live` — last request seen this boot; `default` — the agent default model. */
148
148
  source: 'live' | 'default'
149
+ /**
150
+ * Resolved display name (snapshot first, route-derived otherwise), so the
151
+ * strip needs no name-resolution of its own. Optional so an older host
152
+ * document still renders.
153
+ */
154
+ displayName?: string
155
+ /**
156
+ * Today's ledger totals for the provider's adapter family (the route
157
+ * itself when adapter-less), carrying tokens, calls, and the priced cost.
158
+ * Absent on a day without usage; optional for the same older-host
159
+ * tolerance as displayName.
160
+ */
161
+ today?: UsageTokenTotals
149
162
  }
150
163
  usage: {
151
164
  today: { date: string; totals: UsageTokenTotals; providers: UsageProviderSummary[] }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Usage trust fence (issue #1592): loopback (the desktop) always passes, and a
3
+ * live paired-device cookie is an additional allow path when remote-web-ui is
4
+ * loaded — the same family fence the skill center uses. Without that service
5
+ * the fence stays loopback-only, so an unpaired LAN browser keeps its 403.
6
+ * The decision logic lives in the generated pair-access.ts copy (shared by
7
+ * git-graph / pet / skill-explorer).
8
+ */
9
+ import type { IncomingMessage } from 'node:http'
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import { isPairedOrLoopbackAllowed } from './pair-access.ts'
12
+
13
+ /**
14
+ * Whether this request may read the usage overview or trigger a refresh.
15
+ * @param ctx - host context; may expose remoteWebUiPairing.
16
+ * @param request - the incoming HTTP request.
17
+ * @returns true for loopback, or a live paired-device cookie.
18
+ */
19
+ export function isUsageAllowed(ctx: Context, request: IncomingMessage): boolean {
20
+ return isPairedOrLoopbackAllowed(ctx, request)
21
+ }
package/src/host/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or