@linxin666/dsh-usage 0.3.7 → 0.3.9

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.
@@ -17,6 +17,9 @@ export declare const zh: {
17
17
  'usage.error': string;
18
18
  'usage.current': string;
19
19
  'usage.today': string;
20
+ 'usage.today.cost': string;
21
+ 'usage.peak.on': string;
22
+ 'usage.peak.off': string;
20
23
  'usage.calls': string;
21
24
  'usage.tokens.total': string;
22
25
  'usage.tokens.input': string;
@@ -31,6 +34,7 @@ export declare const zh: {
31
34
  'usage.oauth': string;
32
35
  'usage.plan.reset': string;
33
36
  'usage.plan.noPlan': string;
37
+ 'usage.plan.noneConfigured': string;
34
38
  'usage.plan.windows.5h': string;
35
39
  'usage.plan.windows.week': string;
36
40
  'usage.plan.windows.month': string;
@@ -15,8 +15,10 @@ export interface ProbeSpec {
15
15
  }
16
16
  /** Inputs an adapter may use to build a probe. */
17
17
  export interface AdapterProbeContext {
18
- /** Resolved credential value (the raw key; env refs are resolved upstream). */
18
+ /** Resolved credential value: a raw API key, or an OAuth access token for the oauth-aware adapters. */
19
19
  apiKey: string;
20
+ /** Provider account id riding a dedicated header, when the credential carries one (Codex). */
21
+ accountId?: string;
20
22
  }
21
23
  /** A parsed balance fact. */
22
24
  export interface BalanceParse {
@@ -51,6 +53,13 @@ export interface ProviderAdapter {
51
53
  export declare const PROVIDER_ADAPTERS: readonly ProviderAdapter[];
52
54
  /** Find the adapter serving a provider route key, if any. */
53
55
  export declare function adapterFor(provider: string): ProviderAdapter | undefined;
56
+ /**
57
+ * Whether a provider route belongs to the official DeepSeek family: the only
58
+ * family with a spend price book and a settings-section-owned env credential
59
+ * (llm-deepseek) rather than a pi-ai profile. Drives the env fallback in
60
+ * credential resolution and the fold-time cost stamping.
61
+ */
62
+ export declare function isDeepSeekProviderRoute(provider: string): boolean;
54
63
  /**
55
64
  * Best-effort human message from a provider error body, for the per-provider
56
65
  * error line. Never throws; truncated to one short sentence.
@@ -4,18 +4,31 @@
4
4
  * Host-side state lives only in the document; the service owns persistence.
5
5
  * @module @linxin666/dsh-usage/core/ledger
6
6
  */
7
- import { type UsageLedgerDocument, type UsageTokenTotals } from './types.ts';
7
+ import { type UsageLedgerDocument, type UsageProviderSummary, type UsageTokenTotals } from './types.ts';
8
8
  /** Local-date key (`YYYY-MM-DD`) for an epoch ms timestamp. */
9
9
  export declare function localDateKey(ms: number): string;
10
10
  /** An empty ledger document. */
11
11
  export declare function createLedgerDocument(): UsageLedgerDocument;
12
12
  /**
13
13
  * Fold one usage report into the ledger in place. `provider` is the route key
14
- * and `model` the provider-owned model id the step ran under.
14
+ * and `model` the provider-owned model id the step ran under. Reports keyed
15
+ * by prototype-plumbing names are dropped.
15
16
  */
16
17
  export declare function foldUsage(doc: UsageLedgerDocument, atMs: number, provider: string, model: string, usage: Readonly<UsageTokenTotals>): void;
17
18
  /** Total tokens of a bucket (billed input + output; reasoning is inside output). */
18
19
  export declare function totalTokens(totals: Readonly<UsageTokenTotals>): number;
20
+ /** How many model rows one summarized provider keeps. */
21
+ export declare const SUMMARY_MODEL_CAP = 12;
22
+ /**
23
+ * Aggregate whole per-day maps (each `provider -> model -> totals`) into the
24
+ * per-provider/per-model summary the overview serves for one day or a range:
25
+ * disjoint bucket sums per provider, per-model rows heaviest first, and the
26
+ * grand total. Pure: reads its inputs, allocates fresh buckets.
27
+ */
28
+ export declare function summarizeDays(days: ReadonlyArray<Record<string, Record<string, UsageTokenTotals>>>): {
29
+ totals: UsageTokenTotals;
30
+ providers: UsageProviderSummary[];
31
+ };
19
32
  /** All local-date keys in the ledger, ascending. */
20
33
  export declare function ledgerDayKeys(doc: Readonly<UsageLedgerDocument>): string[];
21
34
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * DeepSeek official peak/off-peak pricing: the published V4 price book plus
3
+ * the peak-window clock, folded into a per-call spend estimate the ledger
4
+ * stamps at fold time (the provider bills each request in the period the
5
+ * request ran in, so pricing at the fold is the honest estimate).
6
+ *
7
+ * Policy (api-docs.deepseek.com pricing page, effective 2026-08-17): peak
8
+ * hours are Beijing time Monday-Friday 09:00-12:00 and 14:00-18:00; every
9
+ * other hour (nights, weekends) is off-peak and billed at half the peak
10
+ * price. All prices here are CNY per million tokens.
11
+ * @module @linxin666/dsh-usage/core/pricing
12
+ */
13
+ import type { UsageTokenTotals } from './types.ts';
14
+ /**
15
+ * The DeepSeek billing period at `ms`, plus when it next flips. The clock is
16
+ * Beijing time regardless of the host timezone (UTC+8 has no DST, so a fixed
17
+ * shift is exact). `boundaryMs` is the instant the current period ends — the
18
+ * window's close while peaking, the next window's open otherwise.
19
+ */
20
+ export declare function deepseekPeriodAt(ms: number): {
21
+ peak: boolean;
22
+ boundaryMs: number;
23
+ };
24
+ /**
25
+ * Estimate one call's DeepSeek spend in CNY from its token totals, priced in
26
+ * the billing period at `atMs`. Uncatalogued model ids take the flash-class
27
+ * row; ids from other providers never reach this function (the service gates
28
+ * by route family). Rounded to micro-CNY so the ledger stays readable.
29
+ */
30
+ export declare function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number): number;
@@ -19,6 +19,13 @@ export interface UsageTokenTotals {
19
19
  reasoningTokens: number;
20
20
  /** Provider calls that reported usage. */
21
21
  calls: number;
22
+ /**
23
+ * Spend estimate stamped at fold time, in the priced provider's billing
24
+ * currency (currently DeepSeek official only, CNY); every other provider
25
+ * stays 0 = unpriced, so the sum is never a mixed-currency total. Persisted
26
+ * buckets recorded before a price-book change keep the old pricing.
27
+ */
28
+ cost: number;
22
29
  }
23
30
  /** A zeroed totals bucket. */
24
31
  export declare function emptyTotals(): UsageTokenTotals;
@@ -81,8 +88,12 @@ export interface ProviderSnapshotView {
81
88
  /** Display name from the LLM runtime, else the adapter's, else the route key. */
82
89
  displayName: string;
83
90
  credential: CredentialKind;
84
- /** Whether a balance/plan adapter exists for this route. */
91
+ /** Whether any balance/plan adapter exists for this route. */
85
92
  supported: boolean;
93
+ /** A balance adapter exists for this route (the Usage tab's balance card). */
94
+ balanceSupported?: boolean;
95
+ /** A coding-plan adapter exists for this route — the Plans tab lists these only. */
96
+ planSupported?: boolean;
86
97
  balance?: BalanceView;
87
98
  plan?: PlanView;
88
99
  /** Last probe failure, cleared on the next success. */
@@ -108,5 +119,27 @@ export interface UsageOverviewView {
108
119
  };
109
120
  /** Last N local days ascending, including today. */
110
121
  days: UsageDaySummary[];
122
+ /**
123
+ * The same window aggregated per provider and model — the trend card's
124
+ * bar-chart data. Optional so an older host document still renders.
125
+ */
126
+ range?: {
127
+ from: string;
128
+ to: string;
129
+ totals: UsageTokenTotals;
130
+ providers: UsageProviderSummary[];
131
+ };
111
132
  };
112
133
  }
134
+ /**
135
+ * Host-side snapshot state: the wire view plus one error slot per probed
136
+ * fact. A probe half only clears its own slot, so a plan success can no
137
+ * longer mask a failing balance probe (and vice versa); the overview
138
+ * collapses them into the single wire `error` line, balance first.
139
+ */
140
+ export interface ProviderSnapshotState extends ProviderSnapshotView {
141
+ /** Last balance-probe failure; cleared on the next balance success. */
142
+ balanceError?: string;
143
+ /** Last plan-probe failure; cleared on the next plan success. */
144
+ planError?: string;
145
+ }
@@ -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 { ProviderSnapshotView, UsageOverviewView } from '../core/types.ts';
11
11
  /** Source tag the plugin stamps onto pet announcements. */
12
12
  export declare const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
13
13
  /** Poll-loop and announce options; re-applied live on settings change. */
@@ -20,6 +20,24 @@ export interface UsageServiceOptions {
20
20
  export declare function formatMoney(currency: string, totalBalance: string): string;
21
21
  /** Map a used percent to the announcement tone. */
22
22
  export declare function planTone(percent: number): 'ok' | 'warn' | 'low';
23
+ /** Announcement context the service computes per poll: today's family spend and the DeepSeek period. */
24
+ export interface AnnounceContext {
25
+ /** Today's ledger spend for the announced provider's family (CNY; 0 = unpriced). */
26
+ todayCost?: number;
27
+ /** Whether DeepSeek peak pricing is in effect right now. */
28
+ peak?: boolean;
29
+ }
30
+ /**
31
+ * Build the raw pet announce payload for one provider snapshot, or
32
+ * undefined when nothing worth announcing exists. Pure: every payload this
33
+ * returns satisfies the pet's `parseAnnouncement` contract — plan
34
+ * announcements require a numeric percent, so percent-less windows never
35
+ * announce (the pet validator would silently drop them). A priced family
36
+ * (DeepSeek) with spend today announces a cost bubble first; balance-only
37
+ * families announce the balance; plan families announce their tightest
38
+ * percent window.
39
+ */
40
+ export declare function buildAnnouncement(snapshot: Pick<ProviderSnapshotView, 'displayName' | 'balance' | 'plan'>, context?: AnnounceContext): Record<string, unknown> | undefined;
23
41
  export declare class UsageService {
24
42
  private readonly ctx;
25
43
  private options;
@@ -36,38 +54,74 @@ export declare class UsageService {
36
54
  private pollTimer;
37
55
  private flushTimer;
38
56
  private pollInFlight;
57
+ /** The running poll cycle; manual refresh joins it instead of no-oping. */
58
+ private pollPromise;
59
+ /** Serialized ledger flushes: overlapping debounce/stop flushes queue, never interleave. */
60
+ private flushChain;
61
+ /** True once loadPersisted finished; nothing may overwrite the files before that. */
62
+ private loaded;
39
63
  private disposed;
40
64
  private lastSignature;
65
+ /** Last prune guard: once per local day, and whenever retention shrinks. */
66
+ private lastPrune;
41
67
  constructor(ctx: Context, options: UsageServiceOptions);
42
68
  /** Start the listeners, load persisted state, and arm the first poll. */
43
69
  start(): void;
44
- /** Stop timers and flush pending ledger writes. */
45
- stop(): void;
46
- /** Re-apply options live (settings change); the poll cycle picks them up. */
70
+ /**
71
+ * Stop timers and flush pending ledger writes. The returned promise
72
+ * resolves after the final flush lands, so a successor instance (quick
73
+ * disable → enable) can serialize its first load behind it.
74
+ */
75
+ stop(): Promise<void>;
76
+ /** Re-apply options live (settings change); retention shrink prunes now. */
47
77
  applyOptions(options: UsageServiceOptions): void;
48
- /** Force one poll now (manual refresh route). */
78
+ /** Force one poll now (manual refresh route); joins an in-flight cycle. */
49
79
  refresh(): Promise<void>;
50
80
  /** Assemble the overview document the browser section renders. */
51
81
  overview(): UsageOverviewView;
52
82
  /** One local day aggregated per provider. */
53
83
  private daySummary;
84
+ /** Today's ledger spend for one provider's adapter family (0 when unpriced). */
85
+ private familyCostToday;
54
86
  private onSessionEvent;
55
- /** Normalize a provider TokenUsage into the ledger bucket (one call). */
87
+ /**
88
+ * Normalize a provider TokenUsage into the ledger bucket (one call). The
89
+ * DeepSeek official family is priced at the fold instant (its billing
90
+ * period is time-of-day); other families stay unpriced (cost 0).
91
+ */
56
92
  private totalsFrom;
57
93
  private loadPersisted;
58
94
  private scheduleFlush;
95
+ /**
96
+ * Queue one ledger flush behind the previous one. Debounce and stop-time
97
+ * flushes serialize here, so two `writeJsonAtomic` calls can never run
98
+ * concurrently on the same path.
99
+ */
59
100
  private flushLedger;
101
+ private writeLedgerOnce;
102
+ /**
103
+ * Prune days past retention. Runs at most once per local day and whenever
104
+ * `retainDays` shrinks — not only at startup, so long-lived processes and
105
+ * live settings changes both honor retention.
106
+ */
107
+ private pruneIfNeeded;
60
108
  private persistSnapshots;
61
109
  private rearmPoll;
62
- /** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
110
+ /**
111
+ * One poll cycle: enumerate routes, resolve credentials, probe, announce.
112
+ * A call while a cycle is already running joins that cycle instead of
113
+ * returning immediately, so a manual refresh always waits for real probes.
114
+ */
63
115
  pollNow(): Promise<void>;
64
116
  private listProviderRoutes;
65
117
  private probeRoute;
66
118
  /**
67
119
  * Resolve the credential backing one route: pi-ai credential records
68
120
  * first, then the profile's apiKeyEnv reference, then the DeepSeek
69
- * official adapter's env reference. OAuth grants resolve to a kind with
70
- * no key (this plugin does not spend third-party OAuth budgets).
121
+ * official adapter's env reference. OAuth grants hand their stored access
122
+ * token to the oauth-aware adapters (Codex plan quota); a stale token
123
+ * fails its probe with a 401 and refreshes the next time the harness
124
+ * itself runs that provider.
71
125
  */
72
126
  private resolveCredential;
73
127
  /** The llm-pi-ai profile object for one provider route, when configured. */
@@ -75,8 +129,15 @@ export declare class UsageService {
75
129
  /** The DeepSeek official adapter's credential reference name. */
76
130
  private deepseekApiKeyEnv;
77
131
  /**
78
- * Announce the current provider's balance or plan usage to the pet. In
79
- * `change` mode only meaningful value changes re-announce; `off` skips.
132
+ * Announce the current provider's spend, balance, or plan usage to the pet.
133
+ * In `change` mode only meaningful value changes re-announce; `off` skips.
134
+ * The TTL rides the poll interval (bubble_mode `always` re-announces every
135
+ * cycle, so a TTL of two cycles + margin keeps the bubble continuous
136
+ * across polls; the pet contract caps the ceiling). A route id the
137
+ * catalogs spell differently than the snapshot keys falls back to its
138
+ * adapter family's snapshot. Fully guarded: a malformed snapshot or a
139
+ * failing pet service must never break the poll loop, and a disposed
140
+ * service never announces.
80
141
  */
81
142
  private announceCurrent;
82
143
  }
@@ -1,9 +1,10 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
2
3
  import z from 'schemastery';
3
4
  import { type UsageServiceOptions } from './host/usage-service.ts';
4
5
  export declare const name = "dsh-usage";
5
6
  export declare const inject: string[];
6
- export declare const USAGE_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
7
+ export declare const USAGE_SETTINGS_NAMESPACE: SettingsNamespace;
7
8
  export interface Config {
8
9
  enabled?: boolean;
9
10
  /** Provider probe cycle in seconds; 30-3600. */
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 dedicated pet bubble for the current provider",
4
- "version": "0.3.7",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -54,19 +54,19 @@
54
54
  "react": "^18.2.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@deepseek-ai/cordis": "^4.0.1",
58
- "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.1",
59
- "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.1",
60
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.1",
61
- "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.1",
62
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.1",
63
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.1",
64
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.1",
65
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.1",
66
- "@deepseek-ai/dsh-llm": "^0.1.2-alpha.1",
67
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.1",
68
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.1",
69
- "@linxin666/dsh-pet": "0.3.7",
57
+ "@deepseek-ai/cordis": "^4.0.2",
58
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
62
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
64
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
65
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
68
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
69
+ "@linxin666/dsh-pet": "0.3.9",
70
70
  "@testing-library/dom": "^10.4.1",
71
71
  "@testing-library/react": "^16.3.2",
72
72
  "@types/node": "^22.20.0",
@@ -87,6 +87,6 @@
87
87
  "build": "tsc -p tsconfig.build.json && tsdown",
88
88
  "watch": "tsdown --watch",
89
89
  "test": "vitest run",
90
- "typecheck": "tsc --noEmit"
90
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --pretty false"
91
91
  }
92
92
  }
@@ -7,12 +7,14 @@
7
7
  * @module @linxin666/dsh-usage/client/UsageSectionCard
8
8
  */
9
9
 
10
- import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react'
10
+ import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'
11
11
  import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
12
12
  import type { UsageStoreInstance } from './usage-store.ts'
13
13
  import { t } from './locales.ts'
14
14
  import styles from './usage.module.css'
15
- import type { ProviderSnapshotView, UsageTokenTotals } from '../core/types.ts'
15
+ import { isDeepSeekProviderRoute } from '../core/adapters.ts'
16
+ import { deepseekPeriodAt } from '../core/pricing.ts'
17
+ import type { ProviderSnapshotView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals } from '../core/types.ts'
16
18
 
17
19
  /** The settings fields this section edits (immediate-apply semantics). */
18
20
  export interface UsageSettings {
@@ -62,6 +64,19 @@ function formatTime(ms: number): string {
62
64
  }
63
65
  }
64
66
 
67
+ function formatClock(ms: number): string {
68
+ try {
69
+ return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
70
+ } catch {
71
+ return ''
72
+ }
73
+ }
74
+
75
+ /** Formatted CNY spend estimate (the only priced currency today). */
76
+ function formatCost(cost: number): string {
77
+ return '¥' + cost.toFixed(2)
78
+ }
79
+
65
80
  function toneClass(percent: number): string {
66
81
  if (percent >= 90) return styles.barLow
67
82
  if (percent >= 70) return styles.barWarn
@@ -159,10 +174,6 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
159
174
  }, [poll])
160
175
 
161
176
  const snapshot = ui.snapshot
162
- const trendMax = useMemo(() => {
163
- if (snapshot === null) return 0
164
- return Math.max(1, ...snapshot.usage.days.map((day) => day.totals.inputTokens + day.totals.outputTokens + day.totals.cacheReadTokens + day.totals.cacheWriteTokens))
165
- }, [snapshot])
166
177
 
167
178
  const onRefresh = (): void => {
168
179
  setRefreshing(true)
@@ -183,7 +194,15 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
183
194
 
184
195
  const current = snapshot.current
185
196
  const currentProvider = snapshot.providers.find((provider) => provider.provider === current.provider)
186
- const planProviders = snapshot.providers.filter((provider) => provider.plan !== undefined || (provider.supported && provider.credential !== 'none' && provider.credential !== 'oauth'))
197
+ // Peak-period line: only when the official DeepSeek family is in play this
198
+ // session (the current route, or spend recorded under one today).
199
+ const deepseekPeriod = deepseekPeriodAt(Date.now())
200
+ const deepseekVisible = (current.provider !== undefined && isDeepSeekProviderRoute(current.provider))
201
+ || snapshot.usage.today.providers.some((row) => isDeepSeekProviderRoute(row.provider))
202
+ // Plans tab: only routes with a real coding-plan/subscription adapter
203
+ // (planSupported; an older host without the flag falls back to "has a plan
204
+ // fact"). Balance-only providers (DeepSeek, ZenMux, ...) never appear here.
205
+ const planProviders = snapshot.providers.filter((provider) => provider.planSupported === true || (provider.planSupported === undefined && provider.plan !== undefined))
187
206
 
188
207
  return (
189
208
  <div className={styles.section} data-dsh-plugin="usage">
@@ -214,9 +233,20 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
214
233
  <>
215
234
  <div className={styles.card} data-dsh-part="today-card">
216
235
  <span className={styles.cardTitle}>{t('usage.today')}</span>
236
+ {deepseekVisible && (
237
+ <span className={styles.muted} data-dsh-part="peak-status">
238
+ {t(deepseekPeriod.peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(deepseekPeriod.boundaryMs) })}
239
+ </span>
240
+ )}
217
241
  {snapshot.usage.today.totals.calls === 0
218
242
  ? <span className={styles.muted}>{t('usage.noData')}</span>
219
243
  : <TotalsRow totals={snapshot.usage.today.totals} />}
244
+ {snapshot.usage.today.totals.cost > 0 && (
245
+ <div className={styles.providerRow} data-dsh-part="today-cost">
246
+ <span className={styles.providerName}>{t('usage.today.cost')}</span>
247
+ <span className={styles.providerTokens}>{formatCost(snapshot.usage.today.totals.cost)}</span>
248
+ </div>
249
+ )}
220
250
  {snapshot.usage.today.providers.length > 0 && (
221
251
  <div data-dsh-part="provider-list">
222
252
  {snapshot.usage.today.providers.map((row) => (
@@ -225,7 +255,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
225
255
  {snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
226
256
  {current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
227
257
  </span>
228
- <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}</span>
258
+ <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}{row.totals.cost > 0 ? ` · ${formatCost(row.totals.cost)}` : ''}</span>
229
259
  </div>
230
260
  ))}
231
261
  </div>
@@ -234,11 +264,13 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
234
264
 
235
265
  <div className={styles.card} data-dsh-part="balance-card">
236
266
  <span className={styles.cardTitle}>{t('usage.balance')}</span>
237
- {snapshot.providers.filter((provider) => provider.supported).length === 0
238
- ? <span className={styles.muted}>{t('usage.balance.unsupported')}</span>
239
- : snapshot.providers.filter((provider) => provider.supported).map((provider) => (
267
+ {(() => {
268
+ const rows = snapshot.providers.filter((provider) => provider.balanceSupported === true || (provider.balanceSupported === undefined && provider.supported))
269
+ if (rows.length === 0) return <span className={styles.muted}>{t('usage.balance.unsupported')}</span>
270
+ return rows.map((provider) => (
240
271
  <ProviderRow key={provider.provider} provider={provider} current={current.provider} />
241
- ))}
272
+ ))
273
+ })()}
242
274
  {snapshot.providers.some((provider) => provider.error !== undefined) && (
243
275
  <span className={styles.errorLine}>
244
276
  {snapshot.providers.filter((provider) => provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(';')}
@@ -246,28 +278,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
246
278
  )}
247
279
  </div>
248
280
 
249
- {snapshot.usage.days.length > 0 && (
250
- <div className={styles.card} data-dsh-part="trend-card">
251
- <span className={styles.cardTitle}>{t('usage.trend')}</span>
252
- <div className={styles.trend}>
253
- {snapshot.usage.days.map((day, index) => {
254
- const total = day.totals.inputTokens + day.totals.outputTokens + day.totals.cacheReadTokens + day.totals.cacheWriteTokens
255
- return (
256
- <div
257
- key={day.date}
258
- className={index === snapshot.usage.days.length - 1 ? `${styles.trendBar} ${styles.trendBarToday}` : styles.trendBar}
259
- style={{ height: `${Math.max(3, Math.round((total / trendMax) * 100))}%` }}
260
- title={`${day.date}: ${formatTokens(total)}`}
261
- />
262
- )
263
- })}
264
- </div>
265
- <div className={styles.trendAxis}>
266
- <span>{snapshot.usage.days[0]?.date.slice(5)}</span>
267
- <span>{snapshot.usage.days[snapshot.usage.days.length - 1]?.date.slice(5)}</span>
268
- </div>
269
- </div>
270
- )}
281
+ <RangeCard range={snapshot.usage.range} providers={snapshot.providers} currentProvider={current.provider} />
271
282
 
272
283
  <SettingsRow settings={settings} snapshot={settingsSnapshot.status === 'ready' ? settingsSnapshot : undefined} value={settingsValue} />
273
284
  </>
@@ -275,42 +286,114 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
275
286
 
276
287
  {tab === 'plans' && (
277
288
  planProviders.length === 0
278
- ? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noPlan')}</span></div>
279
- : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} />)
289
+ ? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noneConfigured')}</span></div>
290
+ : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
280
291
  )}
281
292
  </div>
282
293
  )
283
294
  }
284
295
 
285
- function PlanCard(props: { provider: ProviderSnapshotView }): ReactNode {
286
- const { provider } = props
296
+ /** How many model sub-bars render under one provider bar. */
297
+ const CHART_MODEL_CAP = 3
298
+
299
+ /**
300
+ * The 近 30 天 card: horizontal bars per provider over the trend window,
301
+ * each with its heaviest models as nested sub-bars. Window totals come from
302
+ * the host's aggregated `usage.range` (an older host without it renders no
303
+ * card instead of a wrong one).
304
+ */
305
+ function RangeCard(props: { range?: UsageOverviewView['usage']['range']; providers: ProviderSnapshotView[]; currentProvider?: string }): ReactNode {
306
+ const { range, providers, currentProvider } = props
307
+ if (range === undefined) return null
308
+ const grandTotal = range.totals.inputTokens + range.totals.outputTokens + range.totals.cacheReadTokens + range.totals.cacheWriteTokens
309
+ const maxProvider = Math.max(1, ...range.providers.map((row) => totalOf(row.totals)))
310
+ const nameOf = (id: string): string => providers.find((provider) => provider.provider === id)?.displayName ?? id
311
+ return (
312
+ <div className={styles.card} data-dsh-part="trend-card">
313
+ <span className={styles.cardTitle}>{t('usage.trend')}</span>
314
+ {range.providers.length === 0 || grandTotal === 0
315
+ ? <span className={styles.muted}>{t('usage.noData')}</span>
316
+ : <div className={styles.chart} data-dsh-part="usage-chart">
317
+ {range.providers.map((row) => (
318
+ <ChartProviderRow key={row.provider} row={row} name={nameOf(row.provider)} max={maxProvider} current={currentProvider === row.provider} />
319
+ ))}
320
+ <span className={styles.trendAxis}>
321
+ <span>{range.from.slice(5)}</span>
322
+ <span>{range.to.slice(5)}</span>
323
+ </span>
324
+ </div>}
325
+ </div>
326
+ )
327
+ }
328
+
329
+ function ChartProviderRow(props: { row: UsageProviderSummary; name: string; max: number; current: boolean }): ReactNode {
330
+ const { row, name, max, current } = props
331
+ const total = totalOf(row.totals)
332
+ const maxModel = Math.max(1, ...row.models.slice(0, CHART_MODEL_CAP).map((model) => totalOf(model.totals)))
333
+ return (
334
+ <div className={styles.chartProvider}>
335
+ <span className={styles.chartHead}>
336
+ <span className={styles.providerName}>
337
+ {name}
338
+ {current && <span className={styles.currentBadge}>{t('usage.current')}</span>}
339
+ </span>
340
+ <span className={styles.chartTokens}>{formatTokens(total)} · {t('usage.calls', { n: row.totals.calls })}</span>
341
+ </span>
342
+ <span className={styles.chartBar}>
343
+ <span className={styles.chartFill} style={{ width: `${Math.max(2, Math.round((total / max) * 100))}%` }} />
344
+ </span>
345
+ {row.models.slice(0, CHART_MODEL_CAP).map((model) => {
346
+ const modelTotal = totalOf(model.totals)
347
+ return (
348
+ <span key={model.model} className={styles.chartModel} title={`${row.provider} · ${model.model}: ${formatTokens(modelTotal)}`}>
349
+ <span className={styles.chartModelName}>{model.model}</span>
350
+ <span className={styles.chartModelBar}>
351
+ <span className={styles.chartModelFill} style={{ width: `${Math.max(3, Math.round((modelTotal / maxModel) * 100))}%` }} />
352
+ </span>
353
+ <span className={styles.chartTokens}>{formatTokens(modelTotal)}</span>
354
+ </span>
355
+ )
356
+ })}
357
+ </div>
358
+ )
359
+ }
360
+
361
+ function totalOf(totals: UsageTokenTotals): number {
362
+ return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens
363
+ }
364
+
365
+ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }): ReactNode {
366
+ const { provider, current } = props
287
367
  return (
288
368
  <div className={`${styles.card} ${styles.planCard}`} data-dsh-part="plan-card">
289
369
  <div className={styles.planHead}>
290
370
  <span className={styles.planName}>
291
371
  {provider.displayName}
372
+ {current === provider.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
292
373
  {provider.plan?.planName !== undefined ? ` · ${provider.plan.planName}` : ''}
293
374
  </span>
294
375
  </div>
295
376
  {provider.error !== undefined && <span className={styles.errorLine}>{t('usage.provider.error', { error: provider.error })}</span>}
296
- {provider.plan === undefined || provider.plan.windows.length === 0
297
- ? <span className={styles.muted}>{t('usage.plan.noPlan')}</span>
298
- : provider.plan.windows.map((window) => (
299
- <div key={window.key} className={styles.windowRow} data-dsh-part="plan-window">
300
- <span className={styles.windowLabel}>
301
- <span>{window.name ?? t(`usage.plan.windows.${window.key}`)}</span>
302
- <span>{window.percent !== undefined ? `${window.percent >= 10 ? Math.round(window.percent) : window.percent.toFixed(1)}%` : ''}</span>
303
- </span>
304
- {window.percent !== undefined && (
305
- <span className={styles.bar}>
306
- <span className={toneClass(window.percent)} style={{ width: `${Math.min(100, Math.max(0, window.percent))}%`, display: 'block' }} />
377
+ {provider.credential === 'none' && provider.plan === undefined
378
+ ? <span className={styles.muted}>{t('usage.balance.noCredential')}</span>
379
+ : provider.plan === undefined || provider.plan.windows.length === 0
380
+ ? <span className={styles.muted}>{t('usage.plan.noPlan')}</span>
381
+ : provider.plan.windows.map((window) => (
382
+ <div key={window.key} className={styles.windowRow} data-dsh-part="plan-window">
383
+ <span className={styles.windowLabel}>
384
+ <span>{window.name ?? t(`usage.plan.windows.${window.key}`)}</span>
385
+ <span>{window.percent !== undefined ? `${window.percent >= 10 ? Math.round(window.percent) : window.percent.toFixed(1)}%` : ''}</span>
307
386
  </span>
308
- )}
309
- {window.resetsAt !== undefined && (
310
- <span className={styles.resetLine}>{t('usage.plan.reset', { date: new Date(window.resetsAt).toLocaleString() })}</span>
311
- )}
312
- </div>
313
- ))}
387
+ {window.percent !== undefined && (
388
+ <span className={styles.bar}>
389
+ <span className={toneClass(window.percent)} style={{ width: `${Math.min(100, Math.max(0, window.percent))}%`, display: 'block' }} />
390
+ </span>
391
+ )}
392
+ {window.resetsAt !== undefined && (
393
+ <span className={styles.resetLine}>{t('usage.plan.reset', { date: new Date(window.resetsAt).toLocaleString() })}</span>
394
+ )}
395
+ </div>
396
+ ))}
314
397
  </div>
315
398
  )
316
399
  }