@linxin666/dsh-usage 0.4.4 → 0.4.5

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.
@@ -52,6 +52,24 @@ export interface UsageDaySummary {
52
52
  date: string;
53
53
  totals: UsageTokenTotals;
54
54
  }
55
+ /**
56
+ * One local day aggregated per provider and model. The overview serves today
57
+ * through this shape and the day route serves any retained day with it, so a
58
+ * day the user picks in the browser and the live today card are the same
59
+ * document.
60
+ */
61
+ export interface UsageDayView {
62
+ /** Local-date key `YYYY-MM-DD`. */
63
+ date: string;
64
+ totals: UsageTokenTotals;
65
+ /** Per-provider rows, heaviest by total tokens first. */
66
+ providers: UsageProviderSummary[];
67
+ }
68
+ /** The day route's response body. */
69
+ export interface UsageDayResponse {
70
+ ok: true;
71
+ day: UsageDayView;
72
+ }
55
73
  /** A balance fact normalized from a provider probe. */
56
74
  export interface BalanceView {
57
75
  /** ISO 4217 code the provider bills in (`CNY`, `USD`, ...). */
@@ -139,13 +157,16 @@ export interface UsageOverviewView {
139
157
  today?: UsageTokenTotals;
140
158
  };
141
159
  usage: {
142
- today: {
143
- date: string;
144
- totals: UsageTokenTotals;
145
- providers: UsageProviderSummary[];
146
- };
160
+ today: UsageDayView;
147
161
  /** Last N local days ascending, including today. */
148
162
  days: UsageDaySummary[];
163
+ /**
164
+ * Every retained local day that recorded usage, ascending — the day
165
+ * picker's option list, reaching past the trend cap to the whole
166
+ * retention window. Optional so an older host document still renders
167
+ * (the section then offers no picker).
168
+ */
169
+ availableDays?: string[];
149
170
  /**
150
171
  * The same window aggregated per provider and model — the trend card's
151
172
  * bar-chart data. Optional so an older host document still renders.
@@ -13,6 +13,17 @@ export declare const USAGE_API_PREFIX = "/api/dsh-usage";
13
13
  * @returns the route.
14
14
  */
15
15
  export declare function makeUsageOverviewRoute(ctx: Context, service: UsageService): WebRoute;
16
+ /**
17
+ * One retained day route: `GET /api/dsh-usage/day?date=YYYY-MM-DD` answers
18
+ * that local day's per-provider and per-model totals, so the section's day
19
+ * pickers can show a past day instead of only today. Same trust fence as the
20
+ * overview route, and a key that is not a real local date is refused with 400
21
+ * rather than folded into a bogus day.
22
+ * @param ctx - host context; may expose remoteWebUiPairing.
23
+ * @param service - the usage service.
24
+ * @returns the route.
25
+ */
26
+ export declare function makeUsageDayRoute(ctx: Context, service: UsageService): WebRoute;
16
27
  /**
17
28
  * Manual refresh: forces one probe cycle now and answers with the fresh
18
29
  * overview. Same trust fence as the overview route.
@@ -7,7 +7,7 @@
7
7
  * @module @linxin666/dsh-usage/host/usage-service
8
8
  */
9
9
  import type { Context } from '@deepseek-ai/cordis';
10
- import type { UsageOverviewView } from '../core/types.ts';
10
+ import type { UsageDayView, UsageOverviewView } from '../core/types.ts';
11
11
  /**
12
12
  * Persisted accrual state for the official DeepSeek family's real spend:
13
13
  * consecutive observations of the official CNY balance, decreases counted
@@ -78,6 +78,14 @@ export declare class UsageService {
78
78
  * a strip about nothing is noise, not information.
79
79
  */
80
80
  private currentView;
81
+ /**
82
+ * One retained local day aggregated per provider and model, as the day
83
+ * route serves it. A day the ledger never held — pruned by retention, or
84
+ * simply without usage — reads as a zeroed day rather than an error, so a
85
+ * picker selection that aged out degrades to "no usage" instead of a broken
86
+ * panel. The route gates the key's shape before it gets here.
87
+ */
88
+ day(dateKey: string): UsageDayView;
81
89
  /** One local day aggregated per provider. */
82
90
  private daySummary;
83
91
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-usage",
3
3
  "description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a compact price glance card above the sidebar's Settings row",
4
- "version": "0.4.4",
4
+ "version": "0.4.5",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -33,7 +33,7 @@
33
33
  "license": "Apache-2.0",
34
34
  "dsh": {
35
35
  "engines": {
36
- "dsh": ">=0.2.0-rc.1"
36
+ "dsh": ">=0.2.0-rc.2"
37
37
  },
38
38
  "bundle": {
39
39
  "patch": "./cordis.patch.yml"
@@ -53,22 +53,22 @@
53
53
  "@deepseek-ai/schemastery": "^3.18.4"
54
54
  },
55
55
  "peerDependencies": {
56
- "@deepseek-ai/dsh": ">=0.2.0-rc.1",
56
+ "@deepseek-ai/dsh": ">=0.2.0-rc.2",
57
57
  "react": "^18.2.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@deepseek-ai/cordis": "^4.0.4",
61
- "@deepseek-ai/dsh-credentials": "^0.2.0-rc.1",
62
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
63
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
64
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.1",
65
- "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.1",
66
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
67
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
68
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
69
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
70
- "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
71
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-credentials": "^0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
63
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
68
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
69
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
72
72
  "@testing-library/dom": "^10.4.1",
73
73
  "@testing-library/react": "^16.3.2",
74
74
  "@types/node": "^22.20.0",
@@ -16,7 +16,7 @@ import styles from './usage.module.css'
16
16
  import { isDeepSeekProviderRoute } from '../core/adapters.ts'
17
17
  import { deepseekPeriodAt } from '../core/pricing.ts'
18
18
  import { deepseekVoucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
19
- import type { ObservedSpendView, ProviderSnapshotView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
19
+ import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
20
20
 
21
21
  /** The settings fields this section edits (immediate-apply semantics). */
22
22
  export interface UsageSettings {
@@ -32,6 +32,12 @@ export interface UsageSectionFace {
32
32
  poll: () => void
33
33
  /** Force a host probe cycle now (resolves with the fresh overview). */
34
34
  refresh: () => void
35
+ /**
36
+ * Fetch one retained local day (YYYY-MM-DD) from the host. The overview
37
+ * only carries today plus the list of recorded days, so a day the user picks
38
+ * is one small on-demand request.
39
+ */
40
+ loadDay: (date: string) => Promise<UsageDayView>
35
41
  /** The shared configuration form this section's settings row reads and writes. */
36
42
  settings: ConfigForm<UsageSettings>
37
43
  }
@@ -44,6 +50,12 @@ export interface UsageSectionProps extends UsageSectionFace {
44
50
  /** Poll cadence while the section is open. */
45
51
  const SECTION_POLL_MS = 10_000
46
52
 
53
+ /**
54
+ * The Token bank's default window: the whole retained ledger. It is a select
55
+ * value rather than a date, so it can never collide with a day key.
56
+ */
57
+ const BANK_ALL_DAYS = 'all'
58
+
47
59
  /** Compact token count: 12345 -> 12.3k, 1234567 -> 1.23M. */
48
60
  export function formatTokens(value: number): string {
49
61
  if (!Number.isFinite(value) || value <= 0) return '0'
@@ -149,13 +161,87 @@ function ProviderRow(props: { provider: ProviderSnapshotView; current?: string }
149
161
  )
150
162
  }
151
163
 
164
+ /** One day the pickers selected, as its fetch progresses. */
165
+ interface DayLoad {
166
+ status: 'loading' | 'ready' | 'error'
167
+ /** The served day, once the host answered. */
168
+ view?: UsageDayView
169
+ /** Transport error message, on a failed load. */
170
+ error?: string
171
+ }
172
+
173
+ /**
174
+ * Fetch the day a picker selected, or stay idle for the default window. Today
175
+ * never fetches: the overview already carries it live, so switching back to
176
+ * today is instant and keeps counting with the poll. A superseded selection
177
+ * never lands (the effect cancels), and a failed load reports its own message
178
+ * instead of leaving the card on a stale day.
179
+ */
180
+ function useDayView(loadDay: (date: string) => Promise<UsageDayView>, date: string | undefined, today: string | undefined): DayLoad | undefined {
181
+ const [load, setLoad] = useState<DayLoad | undefined>(undefined)
182
+ useEffect(() => {
183
+ if (date === undefined || date === today) {
184
+ setLoad((previous) => (previous === undefined ? previous : undefined))
185
+ return undefined
186
+ }
187
+ let cancelled = false
188
+ setLoad({ status: 'loading' })
189
+ loadDay(date).then(
190
+ (view) => { if (!cancelled) setLoad({ status: 'ready', view }) },
191
+ (error: unknown) => { if (!cancelled) setLoad({ status: 'error', error: error instanceof Error ? error.message : String(error) }) },
192
+ )
193
+ return () => { cancelled = true }
194
+ }, [date, today, loadDay])
195
+ return load
196
+ }
197
+
198
+ /**
199
+ * The day picker: today plus every retained day that recorded usage, newest
200
+ * first (the host serves the list ascending). Without that list (an older host
201
+ * document) no picker renders and the card stays on its default window.
202
+ */
203
+ function DayPicker(props: {
204
+ value: string
205
+ options: string[]
206
+ today: string
207
+ /** An extra leading option; the Token bank passes its whole-ledger window. */
208
+ leading?: { value: string; label: string }
209
+ onSelect: (date: string) => void
210
+ /** Namespaces the element id, so the two tabs keep one control each. */
211
+ part: string
212
+ }): ReactNode {
213
+ const { value, options, today, leading, onSelect, part } = props
214
+ if (options.length === 0) return null
215
+ return (
216
+ <div className={styles.dayPicker}>
217
+ <label className={styles.dayPickerLabel} htmlFor={'dsh-usage-day-' + part}>{t('usage.day.label')}</label>
218
+ <select
219
+ id={'dsh-usage-day-' + part}
220
+ className={styles.daySelect}
221
+ value={value}
222
+ onChange={(event) => { onSelect(event.target.value) }}
223
+ >
224
+ {leading !== undefined && <option value={leading.value}>{leading.label}</option>}
225
+ {options.map((date) => (
226
+ <option key={date} value={date}>{date === today ? t('usage.day.today') : date}</option>
227
+ ))}
228
+ </select>
229
+ </div>
230
+ )
231
+ }
232
+
152
233
  /** The section component; the slot merges the face into these props. */
153
234
  export function UsageSectionCard(props: UsageSectionProps): ReactNode {
154
- const { store, poll, refresh, settings } = props
235
+ const { store, poll, refresh, loadDay, settings } = props
155
236
  const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
156
237
  const settingsSnapshot = settings.getSnapshot()
157
238
  const settingsValue = settingsSnapshot.value ?? {}
158
239
  const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
240
+ // Undefined means "the default window of this tab": today on the usage tab,
241
+ // the whole retained ledger in the Token bank. A date switches that tab to
242
+ // one retained day instead.
243
+ const [usageDay, setUsageDay] = useState<string | undefined>(undefined)
244
+ const [bankDay, setBankDay] = useState<string | undefined>(undefined)
159
245
  const [refreshing, setRefreshing] = useState(false)
160
246
  // The enable checkbox writes through the shared form, so subscribing here
161
247
  // keeps the flag below live: the form republishes the Host's accepted value,
@@ -195,6 +281,31 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
195
281
  }, [poll, enabled])
196
282
 
197
283
  const snapshot = ui.snapshot
284
+ const todayDate = snapshot?.usage.today.date
285
+ // Every retained day that recorded usage, newest first, plus today so the
286
+ // default window is always one of the options even before it has usage.
287
+ const dayOptions = snapshot === null
288
+ ? []
289
+ : [...new Set([todayDate, ...(snapshot.usage.availableDays ?? [])]
290
+ .filter((date): date is string => date !== undefined))].sort().reverse()
291
+ // A selection retention no longer holds falls back to the tab's default
292
+ // window, so a picker can never keep displaying a day the host dropped.
293
+ const usageSelection = usageDay !== undefined && dayOptions.includes(usageDay) ? usageDay : undefined
294
+ const bankSelection = bankDay !== undefined && dayOptions.includes(bankDay) ? bankDay : undefined
295
+ const selectedDay = usageSelection ?? todayDate
296
+ const isToday = selectedDay === todayDate
297
+ const usageDayLoad = useDayView(loadDay, usageSelection, todayDate)
298
+ const bankDayLoad = useDayView(loadDay, bankSelection, todayDate)
299
+ // A selected day rides its own fetch; the default windows ride the overview.
300
+ const day = isToday ? snapshot?.usage.today : usageDayLoad?.view
301
+ const bankDayView = bankSelection === undefined
302
+ ? undefined
303
+ : bankSelection === todayDate ? snapshot?.usage.today : bankDayLoad?.view
304
+ const bankWindow = bankSelection === undefined
305
+ ? snapshot?.usage.all ?? snapshot?.usage.range
306
+ : bankDayView === undefined
307
+ ? undefined
308
+ : { from: bankDayView.date, to: bankDayView.date, totals: bankDayView.totals, providers: bankDayView.providers }
198
309
 
199
310
  const onRefresh = (): void => {
200
311
  setRefreshing(true)
@@ -227,10 +338,11 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
227
338
  const current = snapshot.current
228
339
  const currentProvider = snapshot.providers.find((provider) => provider.provider === current.provider)
229
340
  // Peak-period line: only when the official DeepSeek family is in play this
230
- // session (the current route, or spend recorded under one today).
341
+ // session (the current route, or spend recorded under one today). It
342
+ // describes the pricing window now, so a past day's card drops it.
231
343
  const deepseekPeriod = deepseekPeriodAt(Date.now())
232
- const deepseekVisible = (current.provider !== undefined && isDeepSeekProviderRoute(current.provider))
233
- || snapshot.usage.today.providers.some((row) => isDeepSeekProviderRoute(row.provider))
344
+ const deepseekVisible = isToday && ((current.provider !== undefined && isDeepSeekProviderRoute(current.provider))
345
+ || snapshot.usage.today.providers.some((row) => isDeepSeekProviderRoute(row.provider)))
234
346
  // Plans tab: only configured routes with a real coding-plan/subscription
235
347
  // adapter (planSupported; an older host without the flag falls back to "has
236
348
  // a plan fact"). Balance-only providers (DeepSeek, ZenMux, ...) and
@@ -268,24 +380,35 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
268
380
  {tab === 'usage' && (
269
381
  <>
270
382
  <div className={styles.card} data-dsh-part="today-card">
271
- <span className={styles.cardTitle}>{t('usage.today')}</span>
383
+ <span className={styles.cardTitle}>{isToday ? t('usage.today') : t('usage.day.title', { date: selectedDay ?? '' })}</span>
384
+ <DayPicker
385
+ part="usage"
386
+ value={selectedDay ?? ''}
387
+ options={dayOptions}
388
+ today={todayDate ?? ''}
389
+ onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
390
+ />
272
391
  {deepseekVisible && (
273
392
  <span className={styles.muted} data-dsh-part="peak-status">
274
393
  {t(deepseekPeriod.peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(deepseekPeriod.boundaryMs) })}
275
394
  </span>
276
395
  )}
277
- {snapshot.usage.today.totals.calls === 0
278
- ? <span className={styles.muted}>{t('usage.noData')}</span>
279
- : <TotalsRow totals={snapshot.usage.today.totals} />}
280
- {snapshot.usage.today.totals.cost > 0 && (
396
+ {usageDayLoad?.status === 'error'
397
+ ? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
398
+ : day === undefined
399
+ ? <span className={styles.muted}>{t('usage.day.loading')}</span>
400
+ : day.totals.calls === 0
401
+ ? <span className={styles.muted}>{isToday ? t('usage.noData') : t('usage.day.empty')}</span>
402
+ : <TotalsRow totals={day.totals} />}
403
+ {(day?.totals.cost ?? 0) > 0 && (
281
404
  <div className={styles.providerRow} data-dsh-part="today-cost">
282
- <span className={styles.providerName}>{t('usage.today.cost')}</span>
283
- <span className={styles.providerTokens}>{formatCost(snapshot.usage.today.totals.cost)}</span>
405
+ <span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
406
+ <span className={styles.providerTokens}>{formatCost(day?.totals.cost ?? 0)}</span>
284
407
  </div>
285
408
  )}
286
- {snapshot.usage.today.providers.length > 0 && (
409
+ {(day?.providers.length ?? 0) > 0 && (
287
410
  <div data-dsh-part="provider-list">
288
- {snapshot.usage.today.providers.map((row) => (
411
+ {(day?.providers ?? []).map((row) => (
289
412
  <div key={row.provider} className={styles.providerRow}>
290
413
  <span className={styles.providerName}>
291
414
  {snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
@@ -332,7 +455,27 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
332
455
  : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
333
456
  )}
334
457
 
335
- {tab === 'bank' && <VoucherCard window={snapshot.usage.all ?? snapshot.usage.range} observedSpend={snapshot.usage.observedSpend} />}
458
+ {tab === 'bank' && (
459
+ <>
460
+ <DayPicker
461
+ part="bank"
462
+ value={bankSelection ?? BANK_ALL_DAYS}
463
+ options={dayOptions}
464
+ today={todayDate ?? ''}
465
+ leading={{ value: BANK_ALL_DAYS, label: t('usage.bank.windowAll') }}
466
+ onSelect={(date) => { setBankDay(date === BANK_ALL_DAYS ? undefined : date) }}
467
+ />
468
+ <VoucherCard
469
+ window={bankWindow}
470
+ // The observed-spend watch accrues from its own first balance
471
+ // reading, so it describes the whole window only: a single day's
472
+ // voucher falls back to that day's fold-time estimate.
473
+ observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
474
+ loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
475
+ {...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
476
+ />
477
+ </>
478
+ )}
336
479
  </div>
337
480
  )
338
481
  }
@@ -408,15 +551,16 @@ function totalOf(totals: UsageTokenTotals): number {
408
551
 
409
552
  /**
410
553
  * The Token 银行 card: the DeepSeek official family's retained-ledger usage
411
- * minted onto the whale-yuan note at 1,000,000 tokens per whale yuan. The window
412
- * prefers the host's whole-ledger aggregate and falls back to the 30-day
413
- * trend when an older host serves no `all`; the spend line prefers the
414
- * official balance watch and falls back to the fold-time estimate; the
415
- * artwork draw failure degrades to an error line and never takes the
416
- * section down.
554
+ * minted onto the whale-yuan note at 1,000,000 tokens per whale yuan. The
555
+ * window is the whole retained ledger by default (the host's aggregate,
556
+ * falling back to the 30-day trend when an older host serves no `all`) or the
557
+ * single day the user picked; the spend line prefers the official balance
558
+ * watch (whole window only) and falls back to the fold-time estimate; the
559
+ * artwork draw failure degrades to an error line and never takes the section
560
+ * down.
417
561
  */
418
- function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView }): ReactNode {
419
- const { window: ledger, observedSpend } = props
562
+ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string }): ReactNode {
563
+ const { window: ledger, observedSpend, loading, error } = props
420
564
  const data = deepseekVoucherData(ledger)
421
565
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
422
566
  const [drawError, setDrawError] = useState<string | undefined>(undefined)
@@ -478,9 +622,13 @@ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: Obser
478
622
  return (
479
623
  <div className={styles.card} data-dsh-part="bank-card">
480
624
  <span className={styles.cardTitle}>{t('usage.bank.title')}</span>
481
- {data === undefined
482
- ? <span className={styles.muted}>{t('usage.bank.noUsage')}</span>
483
- : <>
625
+ {error !== undefined
626
+ ? <span className={styles.errorLine}>{t('usage.day.error', { error })}</span>
627
+ : loading === true
628
+ ? <span className={styles.muted}>{t('usage.day.loading')}</span>
629
+ : data === undefined
630
+ ? <span className={styles.muted}>{t('usage.bank.noUsage')}</span>
631
+ : <>
484
632
  <span className={styles.muted}>{t('usage.bank.hint')}</span>
485
633
  <div className={styles.voucherPreview} data-dsh-part="voucher-preview">
486
634
  <canvas ref={canvasRef} aria-label={t('usage.bank.title')} />
@@ -22,11 +22,13 @@ import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './U
22
22
  import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
23
23
  import { NS, en, zh, t } from './locales.ts'
24
24
  import { createServedEntryForm } from './settings-entry-form.ts'
25
- import type { UsageOverviewView } from '../core/types.ts'
25
+ import type { UsageDayResponse, UsageDayView, UsageOverviewView } from '../core/types.ts'
26
26
 
27
27
  /** The host usage API as the browser sees it (same-origin JSON endpoints). */
28
28
  interface UsageHttpApi {
29
29
  overview(): Promise<UsageOverviewView>
30
+ /** One retained local day (YYYY-MM-DD), the day pickers' detail fetch. */
31
+ day(date: string): Promise<UsageDayView>
30
32
  refresh(): Promise<UsageOverviewView>
31
33
  }
32
34
 
@@ -47,6 +49,7 @@ const usageApi: UsageHttpApi = {
47
49
  // `<base href="./">`, so a sub-path deployment resolves these against its
48
50
  // entry directory instead of escaping to the origin root.
49
51
  overview: () => usageFetch('api/dsh-usage/overview', 'GET'),
52
+ day: async (date: string) => (await usageFetch<UsageDayResponse>('api/dsh-usage/day?date=' + encodeURIComponent(date), 'GET')).day,
50
53
  refresh: () => usageFetch('api/dsh-usage/refresh', 'POST'),
51
54
  }
52
55
 
@@ -156,7 +159,12 @@ export function apply(ctx: ClientContext): void {
156
159
  })
157
160
  }
158
161
 
159
- const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsForm })
162
+ // The day pickers fetch their own day: the overview carries today (always
163
+ // live) plus the list of recorded days, and one selected day is a single
164
+ // small local request the 10 s poll has no reason to repeat.
165
+ const loadDay = (date: string): Promise<UsageDayView> => usageApi.day(date)
166
+
167
+ const face = (): UsageSectionFace => ({ store, poll, refresh, loadDay, settings: settingsForm })
160
168
 
161
169
  // Sidebar foot card: the compact usage glance seated below the shell's
162
170
  // Settings row. It shares the section's store and poll path (the sequence
@@ -22,6 +22,13 @@ export const zh = {
22
22
  'usage.current': '当前',
23
23
  'usage.today': '今日用量',
24
24
  'usage.today.cost': '今日消费(估算)',
25
+ 'usage.day.label': '查看日期',
26
+ 'usage.day.today': '今日',
27
+ 'usage.day.title': '{date} 用量',
28
+ 'usage.day.cost': '当日消费(估算)',
29
+ 'usage.day.empty': '该日没有用量记录',
30
+ 'usage.day.loading': '正在加载该日用量…',
31
+ 'usage.day.error': '该日用量加载失败:{error}',
25
32
  'usage.peak.on': 'DeepSeek 高峰时段:计价 ×2,{time} 结束',
26
33
  'usage.peak.off': 'DeepSeek 空闲时段:计价为高峰一半,{time} 进入高峰',
27
34
  'usage.calls': '{n} 次调用',
@@ -56,6 +63,7 @@ export const zh = {
56
63
  'usage.bank.spend.observed': '官方余额实测花费 ¥{cost}(自 {since} 起)',
57
64
  'usage.bank.spend.estimated': '消费估算:约 ¥{cost}',
58
65
  'usage.bank.window': '统计窗口 {from} ~ {to}',
66
+ 'usage.bank.windowAll': '全部保留期',
59
67
  'usage.bank.save': '保存图片',
60
68
  'usage.bank.share': '分享',
61
69
  'usage.bank.drawError': '票券生成失败:{error}',
@@ -81,6 +89,13 @@ export const en: Record<UsageKey, string> = {
81
89
  'usage.current': 'Current',
82
90
  'usage.today': 'Today',
83
91
  'usage.today.cost': 'Today spend (estimated)',
92
+ 'usage.day.label': 'Day',
93
+ 'usage.day.today': 'Today',
94
+ 'usage.day.title': 'Usage · {date}',
95
+ 'usage.day.cost': 'Day spend (estimated)',
96
+ 'usage.day.empty': 'No usage recorded on this day',
97
+ 'usage.day.loading': 'Loading that day…',
98
+ 'usage.day.error': 'Failed to load that day: {error}',
84
99
  'usage.peak.on': 'DeepSeek peak hours: 2x pricing, ends {time}',
85
100
  'usage.peak.off': 'DeepSeek off-peak: half of peak pricing, peak returns {time}',
86
101
  'usage.calls': '{n} calls',
@@ -115,6 +130,7 @@ export const en: Record<UsageKey, string> = {
115
130
  'usage.bank.spend.observed': 'Spent ¥{cost} observed on the official balance (watching since {since})',
116
131
  'usage.bank.spend.estimated': 'Estimated spend: about ¥{cost}',
117
132
  'usage.bank.window': 'Window {from} - {to}',
133
+ 'usage.bank.windowAll': 'All retained days',
118
134
  'usage.bank.save': 'Save image',
119
135
  'usage.bank.share': 'Share',
120
136
  'usage.bank.drawError': 'Failed to render the voucher: {error}',
@@ -102,6 +102,50 @@
102
102
  opacity: 0.6;
103
103
  }
104
104
 
105
+ /* Day picker: a label plus the native select, sized to the card and
106
+ inheriting the section's currentColor chrome. */
107
+ .dayPicker {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 8px;
111
+ }
112
+
113
+ .dayPickerLabel {
114
+ font-size: 11px;
115
+ opacity: 0.6;
116
+ }
117
+
118
+ .daySelect {
119
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
120
+ border-radius: 6px;
121
+ background: transparent;
122
+ color: inherit;
123
+ font: inherit;
124
+ font-size: 12px;
125
+ padding: 3px 8px;
126
+ font-variant-numeric: tabular-nums;
127
+ }
128
+
129
+ .daySelect:focus-visible {
130
+ outline: none;
131
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
132
+ }
133
+
134
+ /* A native select paints its option list itself: it does NOT inherit the
135
+ control's background, so an unset option background falls back to the
136
+ browser's own (light) list surface while the option text still inherits the
137
+ section's light text color — light text on a light list, unreadable on a
138
+ dark skin. Both the day picker and the settings-row select therefore give
139
+ their option list one explicit surface/label pair, read from the tokens the
140
+ platform and the skins ship (a skin that omits the surface token falls back
141
+ to the platform's, a host with no theme layer to Canvas). Text and
142
+ background always travel together, per theme half. */
143
+ .daySelect option,
144
+ .settingItem select option {
145
+ background-color: var(--dsw-alias-bg-multi-select, Canvas);
146
+ color: var(--dsw-alias-label-primary, CanvasText);
147
+ }
148
+
105
149
  .statRow {
106
150
  display: flex;
107
151
  flex-wrap: wrap;
@@ -129,6 +173,9 @@
129
173
  display: flex;
130
174
  align-items: center;
131
175
  justify-content: space-between;
176
+ /* A long per-day figure (tokens · calls · cost) drops to its own line in a
177
+ narrow panel instead of running into the provider name. */
178
+ flex-wrap: wrap;
132
179
  gap: 12px;
133
180
  padding: 6px 0;
134
181
  border-top: 1px solid color-mix(in srgb, currentColor 8%, transparent);
@@ -48,6 +48,24 @@ export interface PlanParse {
48
48
  export interface ProviderAdapter {
49
49
  /** Provider route keys this adapter serves. */
50
50
  ids: readonly string[]
51
+ /**
52
+ * Route keys that belong to this family for accounting (token folding, the
53
+ * spend price book, the whale-yuan bank) but must never be probed with this
54
+ * adapter's endpoints.
55
+ *
56
+ * A route reaches the DeepSeek family without an API key when the user is
57
+ * signed in to a DeepSeek account: the llm-deepseek-account plugin registers
58
+ * the route id deepseek-account, which authenticates with the account token
59
+ * rather than a key. Its tokens bill the same account the official route
60
+ * bills, so the family must own it, but the balance endpoint reads an
61
+ * API-key account: probing it would either fail on the account token or,
62
+ * when the user also configured DEEPSEEK_API_KEY, print the API-key account's
63
+ * money under the account route's name (issue #1772, the same
64
+ * mis-attribution class as #1688). adapterFor() deliberately does not
65
+ * resolve these ids, so no probe, credential fallback, or alias fold can
66
+ * reach them; family membership is read through isDeepSeekProviderRoute().
67
+ */
68
+ familyOnlyIds?: readonly string[]
51
69
  /** Fallback display name when the LLM runtime has none. */
52
70
  displayName: string
53
71
  balance?: {
@@ -134,6 +152,10 @@ export const DEEPSEEK_API_ORIGIN = 'https://api.deepseek.com'
134
152
 
135
153
  const DEEPSEEK: ProviderAdapter = {
136
154
  ids: ['deepseek', 'deepseek-official'],
155
+ // The signed-in DeepSeek account route bills the same account but holds no
156
+ // API key, so it joins the family for accounting and stays out of the probe
157
+ // directory (issue #1772).
158
+ familyOnlyIds: ['deepseek-account'],
137
159
  displayName: 'DeepSeek',
138
160
  balance: {
139
161
  origin: DEEPSEEK_API_ORIGIN,
@@ -518,11 +540,20 @@ export function balanceAppliesToRoute(adapter: ProviderAdapter, baseURL: string
518
540
  /**
519
541
  * Whether a provider route belongs to the official DeepSeek family: the only
520
542
  * family with a spend price book and a settings-section-owned env credential
521
- * (llm-deepseek) rather than a pi-ai profile. Drives the env fallback in
522
- * credential resolution and the fold-time cost stamping.
543
+ * (llm-deepseek) rather than a pi-ai profile. Drives the fold-time cost
544
+ * stamping, the client fold-time spend estimate, and the whale-yuan bank.
545
+ *
546
+ * Family membership is wider than the probe directory. The account route
547
+ * (deepseek-account) bills the same account but authenticates with the account
548
+ * token, so it prices and mints while `adapterFor()` still returns undefined
549
+ * for it and no probe or env-credential fallback can reach it. Every caller of
550
+ * this predicate is an accounting decision; the credential and probing
551
+ * decisions read `adapterFor()` instead, so the two never widen together
552
+ * (issue #1772).
523
553
  */
524
554
  export function isDeepSeekProviderRoute(provider: string): boolean {
525
- return adapterFor(provider) === DEEPSEEK
555
+ if (adapterFor(provider) === DEEPSEEK) return true
556
+ return DEEPSEEK.familyOnlyIds?.includes(provider) === true
526
557
  }
527
558
 
528
559
  /**