@linxin666/dsh-usage 0.3.7 → 0.3.10
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 +4 -3
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +655 -49
- package/lib/client.js.map +1 -1
- package/lib/index.js +560 -368
- package/lib/types/client/locales.d.ts +5 -0
- package/lib/types/core/adapters.d.ts +10 -1
- package/lib/types/core/ledger.d.ts +15 -2
- package/lib/types/core/pricing.d.ts +30 -0
- package/lib/types/core/types.d.ts +34 -1
- package/lib/types/host/usage-service.d.ts +72 -11
- package/lib/types/index.d.ts +2 -1
- package/package.json +15 -15
- package/src/client/UsageSectionCard.tsx +139 -56
- package/src/client/index.ts +11 -2
- package/src/client/locales.ts +10 -0
- package/src/client/usage.module.css +71 -12
- package/src/core/adapters.ts +84 -3
- package/src/core/ledger.ts +65 -3
- package/src/core/pricing.ts +109 -0
- package/src/core/types.ts +37 -2
- package/src/host/usage-service.ts +300 -131
- package/src/index.ts +30 -17
|
@@ -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,10 +34,12 @@ 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;
|
|
37
41
|
'usage.provider.error': string;
|
|
42
|
+
'usage.errorListSeparator': string;
|
|
38
43
|
'usage.config.title': string;
|
|
39
44
|
'usage.config.enabled': string;
|
|
40
45
|
'usage.config.pollIntervalSec': 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
|
|
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
|
|
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
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
70
|
-
*
|
|
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.
|
|
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
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
4
|
+
"version": "0.3.10",
|
|
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.
|
|
58
|
-
"@deepseek-ai/dsh-credentials": "^0.1.2-alpha.
|
|
59
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.
|
|
60
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.
|
|
61
|
-
"@deepseek-ai/dsh-client-store": "^0.1.2-alpha.
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.
|
|
65
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.
|
|
66
|
-
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.
|
|
67
|
-
"@deepseek-ai/dsh-session": "^0.1.2-alpha.
|
|
68
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.
|
|
69
|
-
"@linxin666/dsh-pet": "0.3.
|
|
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.10",
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,40 +264,21 @@ 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
|
-
{
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
{snapshot.providers.filter((provider) => provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join('
|
|
276
|
+
{snapshot.providers.filter((provider) => provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(t('usage.errorListSeparator'))}
|
|
245
277
|
</span>
|
|
246
278
|
)}
|
|
247
279
|
</div>
|
|
248
280
|
|
|
249
|
-
{snapshot.usage.
|
|
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.
|
|
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
|
-
|
|
286
|
-
|
|
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.
|
|
297
|
-
? <span className={styles.muted}>{t('usage.
|
|
298
|
-
: provider.plan.windows.
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
<span
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
}
|