@linxin666/dsh-usage 0.3.22 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +6 -8
- package/README.zh.md +6 -8
- package/lib/client.js +749 -78
- package/lib/client.js.map +1 -1
- package/lib/index.js +171 -201
- package/lib/types/client/UsageSectionCard.d.ts +0 -1
- package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -4
- package/lib/types/client/sidebar-entry-core.d.ts +87 -0
- package/lib/types/client/sidebar-entry.d.ts +35 -0
- package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
- package/lib/types/client/voucher.d.ts +2 -2
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +28 -73
- package/lib/types/index.d.ts +0 -2
- package/package.json +4 -3
- package/src/client/UsageSectionCard.tsx +1 -15
- package/src/client/UsageSidebarPanel.tsx +196 -0
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +22 -1
- package/src/client/locales.ts +20 -10
- package/src/client/sidebar-entry-core.ts +321 -0
- package/src/client/sidebar-entry.ts +101 -0
- package/src/client/sidebar-panel-mount.tsx +113 -0
- package/src/client/usage.module.css +252 -0
- package/src/client/voucher.ts +2 -2
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +65 -214
- 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
|
+
}
|
package/src/client/voucher.ts
CHANGED
|
@@ -27,8 +27,8 @@ export interface VoucherData {
|
|
|
27
27
|
to: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Anti-inflation exchange rate:
|
|
31
|
-
export const TOKENS_PER_WHALE_YUAN =
|
|
30
|
+
/** Anti-inflation exchange rate: 1,000,000 tokens mint one whale yuan. */
|
|
31
|
+
export const TOKENS_PER_WHALE_YUAN = 1_000_000
|
|
32
32
|
|
|
33
33
|
/** The note's face value in whale yuan; the smallest denomination is 1. */
|
|
34
34
|
export function faceValue(tokens: number): number {
|
package/src/core/adapters.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
216
|
-
|
|
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
|
|
219
|
-
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
|
|
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
|
+
}
|
package/src/host/routes.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 (!
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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 (!
|
|
46
|
+
if (!isUsageAllowed(ctx, req)) {
|
|
38
47
|
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
39
48
|
return
|
|
40
49
|
}
|