@linxin666/dsh-usage 0.4.7 → 0.4.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.
- package/README.i18n.yaml +2 -2
- package/README.md +5 -5
- package/README.zh.md +5 -5
- package/lib/client.js +329 -141
- package/lib/client.js.map +1 -1
- package/lib/index.js +75 -7
- package/lib/types/client/PeakBadge.d.ts +22 -0
- package/lib/types/client/UsageSectionCard.d.ts +5 -1
- package/lib/types/client/foot-card-mount.d.ts +52 -8
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/voucher.d.ts +20 -25
- package/lib/types/core/spend.d.ts +46 -0
- package/lib/types/core/types.d.ts +7 -0
- package/lib/types/host/loopback.d.ts +41 -1
- package/lib/types/host/usage-service.d.ts +7 -0
- package/lib/types/index.d.ts +11 -0
- package/package.json +14 -14
- package/src/client/PeakBadge.tsx +48 -0
- package/src/client/UsageFootCard.tsx +30 -16
- package/src/client/UsageSectionCard.tsx +52 -32
- package/src/client/foot-card-mount.tsx +161 -37
- package/src/client/index.ts +12 -3
- package/src/client/locales.ts +20 -8
- package/src/client/usage.module.css +59 -1
- package/src/client/voucher.ts +20 -30
- package/src/core/spend.ts +62 -0
- package/src/core/types.ts +8 -0
- package/src/host/loopback.ts +57 -1
- package/src/host/pair-access.ts +26 -3
- package/src/host/usage-service.ts +9 -0
- package/src/index.ts +17 -0
|
@@ -594,7 +594,7 @@
|
|
|
594
594
|
|
|
595
595
|
.footStripProvider {
|
|
596
596
|
flex: none;
|
|
597
|
-
max-width:
|
|
597
|
+
max-width: 38%;
|
|
598
598
|
overflow: hidden;
|
|
599
599
|
text-overflow: ellipsis;
|
|
600
600
|
font-weight: 500;
|
|
@@ -616,3 +616,61 @@
|
|
|
616
616
|
:global([data-sidebar-collapsed]) .footCard {
|
|
617
617
|
display: none;
|
|
618
618
|
}
|
|
619
|
+
|
|
620
|
+
/* ---------------------------------------------------------------------------
|
|
621
|
+
DeepSeek pricing clock: red while the peak window bills double, green while
|
|
622
|
+
off-peak bills half. The period word rides with the dot, so colour is never
|
|
623
|
+
the only signal, and the priced sentence lives in the title attribute.
|
|
624
|
+
--------------------------------------------------------------------------- */
|
|
625
|
+
|
|
626
|
+
.peakBadge,
|
|
627
|
+
.peakLine {
|
|
628
|
+
display: inline-flex;
|
|
629
|
+
align-items: center;
|
|
630
|
+
gap: 4px;
|
|
631
|
+
font-size: 11px;
|
|
632
|
+
font-variant-numeric: tabular-nums;
|
|
633
|
+
white-space: nowrap;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.peakBadge {
|
|
637
|
+
flex: none;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.peakLine {
|
|
641
|
+
gap: 6px;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
.peakBadge[data-peak='on'],
|
|
645
|
+
.peakLine[data-peak='on'] {
|
|
646
|
+
color: var(--dsw-alias-state-error-primary, #b42318);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.peakBadge[data-peak='off'],
|
|
650
|
+
.peakLine[data-peak='off'] {
|
|
651
|
+
color: var(--dsw-alias-state-success-primary, #067647);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.peakDot {
|
|
655
|
+
flex: none;
|
|
656
|
+
width: 6px;
|
|
657
|
+
height: 6px;
|
|
658
|
+
border-radius: 50%;
|
|
659
|
+
background: currentColor;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* Expanded foot card: its own line at full opacity (the meta line below is
|
|
663
|
+
dimmed) so the red/green reads on every skin. */
|
|
664
|
+
.footPeak {
|
|
665
|
+
display: flex;
|
|
666
|
+
align-items: center;
|
|
667
|
+
gap: 5px;
|
|
668
|
+
overflow: hidden;
|
|
669
|
+
font-size: 10px;
|
|
670
|
+
white-space: nowrap;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.footPeakClock {
|
|
674
|
+
opacity: 0.7;
|
|
675
|
+
font-variant-numeric: tabular-nums;
|
|
676
|
+
}
|
package/src/client/voucher.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Whale-yuan voucher (鲸元券): the
|
|
2
|
+
* The Whale-yuan voucher (鲸元券): the profile's retained ledger totals
|
|
3
3
|
* stamped onto the banknote artwork as a denomination, ready
|
|
4
4
|
* to save or share. The canvas copy is deliberately locale-neutral (digits,
|
|
5
5
|
* latin captions, ISO dates) so the exported image needs no dictionary;
|
|
@@ -16,11 +16,11 @@ import { VOUCHER_ART_DATA_URL } from './voucher-art.generated.ts'
|
|
|
16
16
|
|
|
17
17
|
/** The facts stamped onto one voucher. */
|
|
18
18
|
export interface VoucherData {
|
|
19
|
-
/**
|
|
19
|
+
/** Every provider's total tokens over the retained window. */
|
|
20
20
|
tokens: number
|
|
21
|
-
/** Provider calls that reported usage. */
|
|
21
|
+
/** Provider calls that reported usage, summed across every provider. */
|
|
22
22
|
calls: number
|
|
23
|
-
/** Fold-time spend estimate in CNY; only the priced family contributes. */
|
|
23
|
+
/** Fold-time spend estimate in CNY; only the priced (official DeepSeek) family contributes. */
|
|
24
24
|
cost: number
|
|
25
25
|
/** Retained window bounds (local date keys, inclusive). */
|
|
26
26
|
from: string
|
|
@@ -44,32 +44,23 @@ export function formatDay(ms: number): string {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
export function mintsWhaleYuan(provider: string): boolean {
|
|
60
|
-
return isDeepSeekProviderRoute(provider) || EXTRA_MINT_ROUTE_IDS.includes(provider)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Sum the mint family's rows out of a usage window: the whole DeepSeek
|
|
65
|
-
* official family (the `deepseek` catalog alias, the live `deepseek-official`
|
|
66
|
-
* route and the signed-in `deepseek-account` route all fold into one voucher)
|
|
67
|
-
* plus {@link EXTRA_MINT_ROUTE_IDS}. Undefined when the window is absent
|
|
68
|
-
* (older host) or the family has no usage — a zero-token note mints nothing.
|
|
47
|
+
* Sum every provider's rows out of a usage window: the note is a
|
|
48
|
+
* proof-of-spend for what the profile actually used, so the tokens and calls
|
|
49
|
+
* of every route in the ledger mint — the DeepSeek official family (the
|
|
50
|
+
* `deepseek` catalog alias, the live `deepseek-official` route and the
|
|
51
|
+
* signed-in `deepseek-account` route) beside every subscription and relay
|
|
52
|
+
* route. Undefined when the window is absent (older host) or nothing was used
|
|
53
|
+
* — a zero-token note mints nothing.
|
|
54
|
+
*
|
|
55
|
+
* Minting is an accounting decision over ledger rows, so it reads no adapter:
|
|
56
|
+
* widening it must never widen the credential or balance-probe paths, which
|
|
57
|
+
* keep reading `adapterFor()` (the split `isDeepSeekProviderRoute()` also
|
|
58
|
+
* keeps, issue #1772).
|
|
69
59
|
*
|
|
70
|
-
* Only the priced family contributes `cost`:
|
|
71
|
-
*
|
|
72
|
-
* family's fold-time estimate (and the observed-balance
|
|
60
|
+
* Only the priced family contributes `cost`: subscription and relay routes
|
|
61
|
+
* ship no price book, so they mint tokens and calls while the spend line keeps
|
|
62
|
+
* reporting the official family's fold-time estimate (and the observed-balance
|
|
63
|
+
* watch above it).
|
|
73
64
|
*/
|
|
74
65
|
export function voucherData(window: UsageWindowSummary | undefined): VoucherData | undefined {
|
|
75
66
|
if (window === undefined) return undefined
|
|
@@ -77,7 +68,6 @@ export function voucherData(window: UsageWindowSummary | undefined): VoucherData
|
|
|
77
68
|
let calls = 0
|
|
78
69
|
let cost = 0
|
|
79
70
|
for (const row of window.providers) {
|
|
80
|
-
if (!mintsWhaleYuan(row.provider)) continue
|
|
81
71
|
tokens += totalTokens(row.totals)
|
|
82
72
|
calls += row.totals.calls
|
|
83
73
|
if (isDeepSeekProviderRoute(row.provider)) cost += row.totals.cost
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend display: the ledger keeps every cost estimate in the priced provider's
|
|
3
|
+
* billing currency (CNY), and that stored value never moves. This module turns
|
|
4
|
+
* one CNY figure into the currency the user chose to READ it in, so switching
|
|
5
|
+
* the display is a rendering decision, not a data migration (issue #1937).
|
|
6
|
+
* @module @linxin666/dsh-usage/core/spend
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A currency the spend display can render. */
|
|
10
|
+
export type SpendCurrency = 'CNY' | 'USD'
|
|
11
|
+
|
|
12
|
+
/** How to read a stored CNY cost. */
|
|
13
|
+
export interface SpendDisplay {
|
|
14
|
+
/** The currency to render in. */
|
|
15
|
+
currency: SpendCurrency
|
|
16
|
+
/** CNY per USD; consulted only when the display currency is USD. */
|
|
17
|
+
cnyPerUsd: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The rate used when the row configures none: a plain default, so an
|
|
22
|
+
* unconfigured display still has a defined美元 figure instead of a blank card.
|
|
23
|
+
* The plugin never fetches a live rate (its host routes are loopback-fenced and
|
|
24
|
+
* its browser half has no network), so there is no fetch that could fail and
|
|
25
|
+
* leave the card empty.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_CNY_PER_USD = 7.2
|
|
28
|
+
|
|
29
|
+
/** The display used when the row carries no setting: the historical CNY rendering. */
|
|
30
|
+
export const DEFAULT_SPEND_DISPLAY: SpendDisplay = { currency: 'CNY', cnyPerUsd: DEFAULT_CNY_PER_USD }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a configured display.
|
|
34
|
+
* An unknown currency reads as CNY, and a rate that is not a positive finite
|
|
35
|
+
* number falls back to the default rate — the display must never be able to
|
|
36
|
+
* blank the card.
|
|
37
|
+
* @param input - the configured currency and rate, if any.
|
|
38
|
+
* @returns a display that can always render.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSpendDisplay(input?: { currency?: unknown; cnyPerUsd?: unknown }): SpendDisplay {
|
|
41
|
+
const currency: SpendCurrency = input?.currency === 'USD' ? 'USD' : 'CNY'
|
|
42
|
+
const rate = typeof input?.cnyPerUsd === 'number' && Number.isFinite(input.cnyPerUsd) && input.cnyPerUsd > 0
|
|
43
|
+
? input.cnyPerUsd
|
|
44
|
+
: DEFAULT_CNY_PER_USD
|
|
45
|
+
return { currency, cnyPerUsd: rate }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render one stored CNY cost in the display currency.
|
|
50
|
+
* A non-finite or negative cost renders as zero rather than as `NaN`/`¥-0.10`.
|
|
51
|
+
* @param costCny - the stored cost in CNY.
|
|
52
|
+
* @param display - the display to render in; defaults to CNY.
|
|
53
|
+
* @returns the formatted amount with its currency symbol.
|
|
54
|
+
*/
|
|
55
|
+
export function formatSpend(costCny: number, display: SpendDisplay = DEFAULT_SPEND_DISPLAY): string {
|
|
56
|
+
const safe = Number.isFinite(costCny) && costCny > 0 ? costCny : 0
|
|
57
|
+
if (display.currency === 'USD') {
|
|
58
|
+
const rate = Number.isFinite(display.cnyPerUsd) && display.cnyPerUsd > 0 ? display.cnyPerUsd : DEFAULT_CNY_PER_USD
|
|
59
|
+
return '$' + (safe / rate).toFixed(2)
|
|
60
|
+
}
|
|
61
|
+
return '¥' + safe.toFixed(2)
|
|
62
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* @module @linxin666/dsh-usage/core/types
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { SpendDisplay } from './spend.ts'
|
|
10
|
+
|
|
9
11
|
/** One disjoint token bucket total, aggregated over calls (billed input = input + cacheRead + cacheWrite). */
|
|
10
12
|
export interface UsageTokenTotals {
|
|
11
13
|
/** Uncached input tokens. */
|
|
@@ -159,6 +161,12 @@ export interface ProviderSnapshotView {
|
|
|
159
161
|
/** The overview the browser section renders. */
|
|
160
162
|
export interface UsageOverviewView {
|
|
161
163
|
updatedAt: number
|
|
164
|
+
/**
|
|
165
|
+
* How to read every CNY cost in this document. The host stamps the row's
|
|
166
|
+
* configured display here so both browser halves format spend the same way;
|
|
167
|
+
* absent on an older host document, which renders as CNY (issue #1937).
|
|
168
|
+
*/
|
|
169
|
+
display?: SpendDisplay
|
|
162
170
|
providers: ProviderSnapshotView[]
|
|
163
171
|
/** The provider the collapsed sidebar strip and the header highlight; `source` says how it was picked. */
|
|
164
172
|
current: {
|
package/src/host/loopback.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
9
|
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
10
|
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
*
|
|
12
|
+
* A reverse proxy in front of the host rewrites the Host header to its own
|
|
13
|
+
* upstream literal (a Cloudflare tunnel, frp and an nginx reverse proxy all
|
|
14
|
+
* dial 127.0.0.1:<port>), and the socket the origin sees is the proxy's, which
|
|
15
|
+
* is loopback by construction. Both halves of the fence then hold for a public
|
|
16
|
+
* request. Forwarding headers are the signal that a hop stands in between: a
|
|
17
|
+
* request that carries CF-Connecting-IP or X-Forwarded-For is answered through
|
|
18
|
+
* a proxy and is never treated as a local one (issue #1906).
|
|
19
|
+
*
|
|
20
|
+
* An operator who runs that proxy or tunnel themselves can opt into the
|
|
21
|
+
* narrower case: isForwardedLoopbackRequest describes a forwarded request that
|
|
22
|
+
* still landed on this machine's loopback upstream, and a route family admits
|
|
23
|
+
* it only behind its own default-off switch (issue #1918).
|
|
11
24
|
* @module dsh-web-shared/host/loopback
|
|
12
25
|
*/
|
|
13
26
|
|
|
@@ -36,13 +49,29 @@ export function isLoopbackHostname(hostname: string): boolean {
|
|
|
36
49
|
return isIPv4Loopback(hostname)
|
|
37
50
|
}
|
|
38
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Whether a reverse proxy stood between the client and this host: the request
|
|
54
|
+
* carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
|
|
55
|
+
* hop that forwards traffic, so the request did not originate on this machine.
|
|
56
|
+
* @param request - node HTTP request.
|
|
57
|
+
* @returns true when a forwarding header is present.
|
|
58
|
+
*/
|
|
59
|
+
export function isProxiedRequest(request: IncomingMessage): boolean {
|
|
60
|
+
return request.headers['cf-connecting-ip'] !== undefined
|
|
61
|
+
|| request.headers['x-forwarded-for'] !== undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
/**
|
|
40
65
|
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
66
|
* header, plus browser same-origin markers. The socket address is
|
|
42
|
-
* authoritative;
|
|
67
|
+
* authoritative for a direct connection; a request that carries a forwarding
|
|
68
|
+
* header is refused outright, because a proxy in front of the host makes both
|
|
69
|
+
* the socket address and the Host header describe the proxy rather than the
|
|
70
|
+
* client. X-Forwarded-For is therefore never evidence *for* loopback.
|
|
43
71
|
*/
|
|
44
72
|
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
73
|
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
74
|
+
if (isProxiedRequest(request)) return false
|
|
46
75
|
const host = request.headers.host
|
|
47
76
|
if (typeof host !== 'string') return false
|
|
48
77
|
let hostUrl: URL
|
|
@@ -61,3 +90,30 @@ export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
|
61
90
|
return false
|
|
62
91
|
}
|
|
63
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a forwarded request landed on this machine's loopback upstream: a
|
|
96
|
+
* forwarding header (the proxy's own stamp), a loopback socket address (the
|
|
97
|
+
* proxy dials 127.0.0.1:<port>), and a loopback Host header (the proxy rewrote
|
|
98
|
+
* the authority to its upstream literal).
|
|
99
|
+
*
|
|
100
|
+
* This is a fact about the route, not a trust decision. A public request to a
|
|
101
|
+
* tunnel satisfies all three by construction, because the proxy is the client
|
|
102
|
+
* of this process: only a caller that operates the proxy can decide it stands
|
|
103
|
+
* for the browser, which is why every consumer keeps this behind an explicit
|
|
104
|
+
* default-off switch and its own browser markers. isLoopbackRequest is
|
|
105
|
+
* unchanged and still refuses a forwarded request (issue #1906).
|
|
106
|
+
* @param request - node HTTP request.
|
|
107
|
+
* @returns true when a forwarding header, a loopback socket, and a loopback Host all hold.
|
|
108
|
+
*/
|
|
109
|
+
export function isForwardedLoopbackRequest(request: IncomingMessage): boolean {
|
|
110
|
+
if (!isProxiedRequest(request)) return false
|
|
111
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
112
|
+
const host = request.headers.host
|
|
113
|
+
if (typeof host !== 'string') return false
|
|
114
|
+
try {
|
|
115
|
+
return isLoopbackHostname(new URL('http://' + host).hostname)
|
|
116
|
+
} catch {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/host/pair-access.ts
CHANGED
|
@@ -37,9 +37,32 @@ interface LookupCtx {
|
|
|
37
37
|
*/
|
|
38
38
|
export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
|
|
39
39
|
if (isLoopbackRequest(request)) return true
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
return lookupPairing(ctx)?.isPairedDevice(request) === true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The pairing service, or undefined when this composition does not expose it.
|
|
45
|
+
*
|
|
46
|
+
* Two lookups exist because the fence is shared with test harnesses that hand
|
|
47
|
+
* over a bare object: a real cordis context resolves services through `get`, a
|
|
48
|
+
* harness may carry the property directly. `get(name, false)` is the
|
|
49
|
+
* non-strict form, so an unloaded service answers undefined rather than
|
|
50
|
+
* throwing. Reading an un-injected service as a PROPERTY throws instead
|
|
51
|
+
* (`cannot get property "remoteWebUiPairing" without inject`); a trust fence
|
|
52
|
+
* must answer "not paired" in that case, never leak the error into the route
|
|
53
|
+
* handler, where the host web server turns it into an empty-body 400 (#1942).
|
|
54
|
+
*/
|
|
55
|
+
function lookupPairing(ctx: LookupCtx): PairingAccess | undefined {
|
|
56
|
+
if (typeof ctx.get === 'function') {
|
|
57
|
+
const viaGet = ctx.get('remoteWebUiPairing', false)
|
|
58
|
+
return isPairingAccess(viaGet) ? viaGet : undefined
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const viaProperty = ctx.remoteWebUiPairing
|
|
62
|
+
return isPairingAccess(viaProperty) ? viaProperty : undefined
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
43
66
|
}
|
|
44
67
|
|
|
45
68
|
function isPairingAccess(value: unknown): value is PairingAccess {
|
|
@@ -22,6 +22,7 @@ import { deepseekModelSpend } from '../core/pricing.ts'
|
|
|
22
22
|
import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
|
|
23
23
|
import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageDayView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
|
|
24
24
|
import { addTotals, emptyTotals } from '../core/types.ts'
|
|
25
|
+
import { resolveSpendDisplay, type SpendDisplay } from '../core/spend.ts'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Persisted accrual state for the official DeepSeek family's real spend:
|
|
@@ -60,6 +61,12 @@ interface ProviderRoute {
|
|
|
60
61
|
export interface UsageServiceOptions {
|
|
61
62
|
pollIntervalSec: number
|
|
62
63
|
retainDays: number
|
|
64
|
+
/**
|
|
65
|
+
* How the browser halves should read the stored CNY costs. Optional so a
|
|
66
|
+
* caller that predates the display setting keeps the CNY rendering (issue
|
|
67
|
+
* #1937); the overview stamps the resolved value on every document.
|
|
68
|
+
*/
|
|
69
|
+
display?: SpendDisplay
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/** Probe timeout per HTTP call. */
|
|
@@ -336,6 +343,8 @@ export class UsageService {
|
|
|
336
343
|
const all = summarizeDays(allKeys.map((key) => this.ledger.days[key] ?? {}))
|
|
337
344
|
return {
|
|
338
345
|
updatedAt: Date.now(),
|
|
346
|
+
// Display only: costs stay stored in CNY (issue #1937).
|
|
347
|
+
display: resolveSpendDisplay(this.options.display),
|
|
339
348
|
providers,
|
|
340
349
|
current: this.currentView(),
|
|
341
350
|
usage: {
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
|
4
4
|
import z from '@deepseek-ai/schemastery'
|
|
5
5
|
import { mountOnce } from './mount-once.ts'
|
|
6
6
|
import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
|
|
7
|
+
import { DEFAULT_CNY_PER_USD, resolveSpendDisplay, type SpendDisplay } from './core/spend.ts'
|
|
7
8
|
import { makeUsageDayRoute, makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
|
|
8
9
|
|
|
9
10
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -46,6 +47,10 @@ export interface UsageConfigFields {
|
|
|
46
47
|
pollIntervalSec?: ConfigField<number>
|
|
47
48
|
/** Ledger retention in local days. */
|
|
48
49
|
retainDays?: ConfigField<number>
|
|
50
|
+
/** Currency the spend figures are DISPLAYED in; the ledger itself stays CNY. */
|
|
51
|
+
spendCurrency?: ConfigField<'CNY' | 'USD'>
|
|
52
|
+
/** CNY per USD, consulted only while the display currency is USD. */
|
|
53
|
+
cnyPerUsd?: ConfigField<number>
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
@@ -66,10 +71,18 @@ export const Config = z.object({
|
|
|
66
71
|
enabled: z.boolean().default(true).volatile(),
|
|
67
72
|
pollIntervalSec: z.number().min(30).max(3600).default(60).volatile(),
|
|
68
73
|
retainDays: z.number().min(7).max(730).default(180).volatile(),
|
|
74
|
+
// Display only: the ledger keeps every cost estimate in CNY, and switching
|
|
75
|
+
// this field never rewrites history (issue #1937). The plugin fetches no live
|
|
76
|
+
// rate, so an unset or unusable rate renders with the documented default
|
|
77
|
+
// instead of failing the card.
|
|
78
|
+
spendCurrency: z.union(['CNY', 'USD'] as const).default('CNY').volatile(),
|
|
79
|
+
cnyPerUsd: z.number().min(0.01).max(1000).default(DEFAULT_CNY_PER_USD).volatile(),
|
|
69
80
|
})
|
|
70
81
|
|
|
71
82
|
export interface ResolvedConfig extends UsageServiceOptions {
|
|
72
83
|
enabled: boolean
|
|
84
|
+
/** The resolved spend display; always present, unlike the optional service option. */
|
|
85
|
+
display: SpendDisplay
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/** Read one config field, following the live reference the schema produces. */
|
|
@@ -99,6 +112,10 @@ export function resolveConfig(config?: UsageConfigFields): ResolvedConfig {
|
|
|
99
112
|
enabled: readConfigField(config?.enabled) ?? true,
|
|
100
113
|
pollIntervalSec: readNumber(config?.pollIntervalSec, 60),
|
|
101
114
|
retainDays: readNumber(config?.retainDays, 180),
|
|
115
|
+
display: resolveSpendDisplay({
|
|
116
|
+
currency: readConfigField(config?.spendCurrency),
|
|
117
|
+
cnyPerUsd: readNumber(config?.cnyPerUsd, DEFAULT_CNY_PER_USD),
|
|
118
|
+
}),
|
|
102
119
|
}
|
|
103
120
|
}
|
|
104
121
|
|