@linxin666/dsh-usage 0.3.23 → 0.3.24

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 +5 -7
  3. package/README.zh.md +5 -7
  4. package/lib/client.js +744 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +171 -201
  7. package/lib/types/client/UsageSectionCard.d.ts +0 -1
  8. package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
  9. package/lib/types/client/body-mutations.d.ts +45 -0
  10. package/lib/types/client/locales.d.ts +9 -4
  11. package/lib/types/client/sidebar-entry-core.d.ts +87 -0
  12. package/lib/types/client/sidebar-entry.d.ts +35 -0
  13. package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
  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/pair-access.d.ts +34 -0
  18. package/lib/types/host/routes.d.ts +16 -7
  19. package/lib/types/host/usage-service.d.ts +28 -73
  20. package/lib/types/index.d.ts +0 -2
  21. package/package.json +4 -3
  22. package/src/client/UsageSectionCard.tsx +0 -14
  23. package/src/client/UsageSidebarPanel.tsx +196 -0
  24. package/src/client/body-mutations.ts +133 -0
  25. package/src/client/index.ts +22 -1
  26. package/src/client/locales.ts +18 -8
  27. package/src/client/sidebar-entry-core.ts +321 -0
  28. package/src/client/sidebar-entry.ts +101 -0
  29. package/src/client/sidebar-panel-mount.tsx +113 -0
  30. package/src/client/usage.module.css +252 -0
  31. package/src/core/adapters.ts +40 -6
  32. package/src/core/provider-routes.ts +37 -0
  33. package/src/core/types.ts +14 -1
  34. package/src/host/access.ts +21 -0
  35. package/src/host/pair-access.ts +49 -0
  36. package/src/host/routes.ts +19 -10
  37. package/src/host/usage-service.ts +65 -214
  38. package/src/index.ts +1 -6
@@ -381,3 +381,255 @@
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 usage panel (issue #1592): the entry row reuses the shared
388
+ sidebar-entry core (its class names are entry / entryIcon / entryLabel), and
389
+ the collapsed rail rules mirror the skill center's so the row becomes an
390
+ icon in the 56px rail. Tokens only; no framework styles.
391
+ --------------------------------------------------------------------------- */
392
+
393
+ .entry {
394
+ box-sizing: border-box;
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 8px;
398
+ width: 100%;
399
+ height: 36px;
400
+ padding: 0 10px;
401
+ background: transparent;
402
+ border: none;
403
+ border-radius: 8px;
404
+ color: var(--dsw-alias-label-secondary);
405
+ cursor: pointer;
406
+ font-size: 13px;
407
+ white-space: nowrap;
408
+ }
409
+
410
+ .entry:hover {
411
+ background: var(--dsw-alias-interactive-bg-hover);
412
+ color: var(--dsw-alias-label-primary);
413
+ }
414
+
415
+ .entryIcon {
416
+ display: inline-flex;
417
+ align-items: center;
418
+ justify-content: center;
419
+ width: 24px;
420
+ height: 24px;
421
+ flex: none;
422
+ }
423
+
424
+ .entryIcon svg {
425
+ display: block;
426
+ width: 18px;
427
+ height: 18px;
428
+ }
429
+
430
+ .entryLabel {
431
+ overflow: hidden;
432
+ text-overflow: ellipsis;
433
+ }
434
+
435
+ /* Composite row (the core's trailing-actions structure): the main button
436
+ keeps the old row's hit area and the action buttons seat at the right
437
+ edge. */
438
+ .entryMain {
439
+ appearance: none;
440
+ display: flex;
441
+ align-items: center;
442
+ gap: 8px;
443
+ flex: 1;
444
+ min-width: 0;
445
+ height: 100%;
446
+ padding: 0;
447
+ background: transparent;
448
+ border: none;
449
+ color: inherit;
450
+ font: inherit;
451
+ text-align: left;
452
+ cursor: pointer;
453
+ }
454
+
455
+ .entryAction {
456
+ appearance: none;
457
+ display: inline-flex;
458
+ align-items: center;
459
+ justify-content: center;
460
+ width: 22px;
461
+ height: 22px;
462
+ flex: none;
463
+ padding: 0;
464
+ border: none;
465
+ border-radius: 6px;
466
+ background: transparent;
467
+ color: inherit;
468
+ cursor: pointer;
469
+ opacity: 0.55;
470
+ transition: opacity 120ms ease, background-color 120ms ease;
471
+ }
472
+
473
+ .entry:hover .entryAction {
474
+ opacity: 0.8;
475
+ }
476
+
477
+ .entryAction:hover {
478
+ opacity: 1;
479
+ background: color-mix(in srgb, currentColor 10%, transparent);
480
+ }
481
+
482
+ .entryAction:focus-visible,
483
+ .entryMain:focus-visible {
484
+ outline: none;
485
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
486
+ }
487
+
488
+ .entryAction:disabled {
489
+ cursor: default;
490
+ opacity: 0.4;
491
+ }
492
+
493
+ .entryAction svg {
494
+ display: block;
495
+ width: 14px;
496
+ height: 14px;
497
+ }
498
+
499
+ /* Refresh cooldown: the spinning glyph marks the in-flight probe cycle. */
500
+ .entryAction:disabled svg {
501
+ animation: usageEntrySpin 900ms linear infinite;
502
+ }
503
+
504
+ @keyframes usageEntrySpin {
505
+ to {
506
+ transform: rotate(360deg);
507
+ }
508
+ }
509
+
510
+ :global([data-dsh-frame][data-sidebar-collapsed]) .entry,
511
+ :global([data-sidebar-collapsed]) .entry {
512
+ justify-content: center;
513
+ width: 36px;
514
+ height: 36px;
515
+ padding: 0;
516
+ margin: 0 auto 12px;
517
+ border-radius: 50%;
518
+ }
519
+
520
+ :global([data-dsh-frame][data-sidebar-collapsed]) .entryMain,
521
+ :global([data-sidebar-collapsed]) .entryMain {
522
+ flex: none;
523
+ width: 100%;
524
+ justify-content: center;
525
+ }
526
+
527
+ :global([data-dsh-frame][data-sidebar-collapsed]) .entryAction,
528
+ :global([data-sidebar-collapsed]) .entryAction {
529
+ display: none;
530
+ }
531
+
532
+ :global([data-dsh-frame][data-sidebar-collapsed]) .sidebarSummary,
533
+ :global([data-sidebar-collapsed]) .sidebarSummary {
534
+ display: none;
535
+ }
536
+
537
+ :global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
538
+ :global([data-sidebar-collapsed]) .entryLabel {
539
+ display: none;
540
+ }
541
+
542
+ /* Collapsed strip: the current session provider's today usage in one
543
+ quiet line under the entry row (hidden in the 56px rail). */
544
+ .sidebarSummary {
545
+ display: flex;
546
+ align-items: baseline;
547
+ gap: 6px;
548
+ margin: 0 4px 12px;
549
+ padding: 0 10px;
550
+ font-size: 11px;
551
+ color: inherit;
552
+ opacity: 0.75;
553
+ white-space: nowrap;
554
+ overflow: hidden;
555
+ }
556
+
557
+ .sidebarSummaryName {
558
+ flex: none;
559
+ max-width: 40%;
560
+ overflow: hidden;
561
+ text-overflow: ellipsis;
562
+ font-weight: 600;
563
+ }
564
+
565
+ .sidebarSummaryFacts {
566
+ overflow: hidden;
567
+ text-overflow: ellipsis;
568
+ font-variant-numeric: tabular-nums;
569
+ }
570
+
571
+ /* Expanded panel: a quiet card stack directly under the entry row. */
572
+ .sidebarPanel {
573
+ display: flex;
574
+ flex-direction: column;
575
+ gap: 8px;
576
+ margin: 0 4px 12px;
577
+ padding: 10px 10px 12px;
578
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
579
+ border-radius: 10px;
580
+ color: inherit;
581
+ background: color-mix(in srgb, currentColor 4%, transparent);
582
+ }
583
+
584
+ .sidebarProvider {
585
+ display: flex;
586
+ flex-direction: column;
587
+ gap: 4px;
588
+ }
589
+
590
+ .sidebarProviderHead {
591
+ display: flex;
592
+ align-items: baseline;
593
+ justify-content: space-between;
594
+ gap: 8px;
595
+ font-size: 12px;
596
+ }
597
+
598
+ .sidebarProviderName {
599
+ overflow: hidden;
600
+ text-overflow: ellipsis;
601
+ white-space: nowrap;
602
+ }
603
+
604
+ .sidebarBalance {
605
+ flex: none;
606
+ opacity: 0.75;
607
+ }
608
+
609
+ .sidebarWindowRow {
610
+ display: flex;
611
+ align-items: center;
612
+ gap: 6px;
613
+ font-size: 11px;
614
+ opacity: 0.85;
615
+ }
616
+
617
+ .sidebarBar {
618
+ flex: 1;
619
+ height: 4px;
620
+ border-radius: 2px;
621
+ background: color-mix(in srgb, currentColor 14%, transparent);
622
+ overflow: hidden;
623
+ }
624
+
625
+ .sidebarBarFill {
626
+ display: block;
627
+ height: 100%;
628
+ border-radius: 2px;
629
+ background: currentColor;
630
+ }
631
+
632
+ .sidebarMuted {
633
+ font-size: 11px;
634
+ opacity: 0.6;
635
+ }
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Pairing trust fence shared by plugins that expose host routes: loopback
4
+ * (the desktop) always passes; a live paired-device cookie is an additional
5
+ * allow path when remote-web-ui is loaded. The consuming plugin never
6
+ * depends on that plugin — without the service the fence stays
7
+ * loopback-only.
8
+ *
9
+ * Per-package wrappers (access.ts) call this with their own name so each
10
+ * plugin keeps a self-describing export; the security decision lives only
11
+ * here.
12
+ */
13
+ import type { IncomingMessage } from 'node:http'
14
+ import { isLoopbackRequest } from './loopback.ts'
15
+
16
+ /** Structural pairing lookup (no package dependency on remote-web-ui). */
17
+ interface PairingAccess {
18
+ isPairedDevice(request: IncomingMessage): boolean
19
+ }
20
+
21
+ /**
22
+ * Structural host-context shape: shared sources carry no @deepseek-ai
23
+ * dependency (the shared package must typecheck standalone), so the fence
24
+ * reads only the two members it needs; cordis Context satisfies this.
25
+ * ctx.get is optional on the test harness; production Context always has it.
26
+ */
27
+ interface LookupCtx {
28
+ get?(name: string, strict?: boolean): unknown
29
+ remoteWebUiPairing?: PairingAccess
30
+ }
31
+
32
+ /**
33
+ * Whether this request may enter the plugin's host routes.
34
+ * @param ctx - host context; may expose remoteWebUiPairing.
35
+ * @param request - the incoming HTTP request.
36
+ * @returns true for loopback, or a live paired-device cookie.
37
+ */
38
+ export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
39
+ if (isLoopbackRequest(request)) return true
40
+ const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
41
+ const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
42
+ return pairing?.isPairedDevice(request) === true
43
+ }
44
+
45
+ function isPairingAccess(value: unknown): value is PairingAccess {
46
+ return value !== undefined
47
+ && value !== null
48
+ && typeof (value as PairingAccess).isPairedDevice === 'function'
49
+ }
@@ -1,22 +1,28 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { Context } from '@deepseek-ai/cordis'
2
3
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
3
- import { isLoopbackRequest } from './loopback.ts'
4
+ import { isUsageAllowed } from './access.ts'
4
5
  import { writeJson } from './http.ts'
5
6
  import type { UsageService } from './usage-service.ts'
6
7
 
7
8
  export const USAGE_API_PREFIX = '/api/dsh-usage'
8
9
 
9
10
  /**
10
- * Loopback-fenced overview route: provider balances, plan quotas, and token
11
- * usage totals. Personal account data, so the loopback fence mirrors
12
- * dsh-perf's stats surface; the browser runs on the same machine.
11
+ * Overview route: provider balances, plan quotas, and token usage totals.
12
+ * Personal account data behind the family trust fence — loopback always
13
+ * passes, and a live paired-device cookie passes too when remote-web-ui is
14
+ * loaded (issue #1592: the sidebar usage panel reads this from a paired LAN
15
+ * browser); every unpaired LAN request keeps its 403.
16
+ * @param ctx - host context; may expose remoteWebUiPairing.
17
+ * @param service - the usage service.
18
+ * @returns the route.
13
19
  */
14
- export function makeUsageOverviewRoute(service: UsageService): WebRoute {
20
+ export function makeUsageOverviewRoute(ctx: Context, service: UsageService): WebRoute {
15
21
  return {
16
22
  kind: 'exact',
17
23
  path: USAGE_API_PREFIX + '/overview',
18
24
  handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
19
- if (!isLoopbackRequest(req)) {
25
+ if (!isUsageAllowed(ctx, req)) {
20
26
  writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
21
27
  return
22
28
  }
@@ -26,15 +32,18 @@ export function makeUsageOverviewRoute(service: UsageService): WebRoute {
26
32
  }
27
33
 
28
34
  /**
29
- * Loopback-fenced manual refresh: forces one probe cycle now and answers
30
- * with the fresh overview.
35
+ * Manual refresh: forces one probe cycle now and answers with the fresh
36
+ * overview. Same trust fence as the overview route.
37
+ * @param ctx - host context; may expose remoteWebUiPairing.
38
+ * @param service - the usage service.
39
+ * @returns the route.
31
40
  */
32
- export function makeUsageRefreshRoute(service: UsageService): WebRoute {
41
+ export function makeUsageRefreshRoute(ctx: Context, service: UsageService): WebRoute {
33
42
  return {
34
43
  kind: 'exact',
35
44
  path: USAGE_API_PREFIX + '/refresh',
36
45
  handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
37
- if (!isLoopbackRequest(req)) {
46
+ if (!isUsageAllowed(ctx, req)) {
38
47
  writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
39
48
  return
40
49
  }