@rayadesu/dsh-billing 0.1.0-rc.8

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.
Files changed (116) hide show
  1. package/AGENTS.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +118 -0
  5. package/README.zh.md +114 -0
  6. package/cordis.patch.yml +20 -0
  7. package/package.json +33 -0
  8. package/packages/llm-billing/README.i18n.yaml +6 -0
  9. package/packages/llm-billing/README.md +44 -0
  10. package/packages/llm-billing/README.zh.md +44 -0
  11. package/packages/llm-billing/lib/index.js +562 -0
  12. package/packages/llm-billing/lib/invariant.js +23 -0
  13. package/packages/llm-billing/lib/tsconfig.tsbuildinfo +1 -0
  14. package/packages/llm-billing/lib/typert.host.d.ts +3 -0
  15. package/packages/llm-billing/lib/typert.host.js +114 -0
  16. package/packages/llm-billing/lib/typert.remote-client.d.ts +27 -0
  17. package/packages/llm-billing/lib/typert.remote-client.d.ts.map +1 -0
  18. package/packages/llm-billing/lib/typert.remote-client.js +108 -0
  19. package/packages/llm-billing/lib/types/balance.d.ts +71 -0
  20. package/packages/llm-billing/lib/types/balance.d.ts.map +1 -0
  21. package/packages/llm-billing/lib/types/balance.js +187 -0
  22. package/packages/llm-billing/lib/types/balance.js.map +1 -0
  23. package/packages/llm-billing/lib/types/billing.d.ts +106 -0
  24. package/packages/llm-billing/lib/types/billing.d.ts.map +1 -0
  25. package/packages/llm-billing/lib/types/billing.js +163 -0
  26. package/packages/llm-billing/lib/types/billing.js.map +1 -0
  27. package/packages/llm-billing/lib/types/index.d.ts +49 -0
  28. package/packages/llm-billing/lib/types/index.d.ts.map +1 -0
  29. package/packages/llm-billing/lib/types/index.js +155 -0
  30. package/packages/llm-billing/lib/types/index.js.map +1 -0
  31. package/packages/llm-billing/lib/types/invariant.d.ts +16 -0
  32. package/packages/llm-billing/lib/types/invariant.d.ts.map +1 -0
  33. package/packages/llm-billing/lib/types/invariant.js +22 -0
  34. package/packages/llm-billing/lib/types/invariant.js.map +1 -0
  35. package/packages/llm-billing/lib/types/types/balance.d.ts +71 -0
  36. package/packages/llm-billing/lib/types/types/balance.d.ts.map +1 -0
  37. package/packages/llm-billing/lib/types/types/balance.js +187 -0
  38. package/packages/llm-billing/lib/types/types/balance.js.map +1 -0
  39. package/packages/llm-billing/lib/types/types/billing.d.ts +106 -0
  40. package/packages/llm-billing/lib/types/types/billing.d.ts.map +1 -0
  41. package/packages/llm-billing/lib/types/types/billing.js +170 -0
  42. package/packages/llm-billing/lib/types/types/billing.js.map +1 -0
  43. package/packages/llm-billing/lib/types/types/index.d.ts +49 -0
  44. package/packages/llm-billing/lib/types/types/index.d.ts.map +1 -0
  45. package/packages/llm-billing/lib/types/types/index.js +156 -0
  46. package/packages/llm-billing/lib/types/types/index.js.map +1 -0
  47. package/packages/llm-billing/lib/types/types/invariant.d.ts +16 -0
  48. package/packages/llm-billing/lib/types/types/invariant.d.ts.map +1 -0
  49. package/packages/llm-billing/lib/types/types/invariant.js +22 -0
  50. package/packages/llm-billing/lib/types/types/invariant.js.map +1 -0
  51. package/packages/llm-billing/lib/types/types/types.d.ts +63 -0
  52. package/packages/llm-billing/lib/types/types/types.d.ts.map +1 -0
  53. package/packages/llm-billing/lib/types/types/types.js +7 -0
  54. package/packages/llm-billing/lib/types/types/types.js.map +1 -0
  55. package/packages/llm-billing/lib/types/types.d.ts +63 -0
  56. package/packages/llm-billing/lib/types/types.d.ts.map +1 -0
  57. package/packages/llm-billing/lib/types/types.js +7 -0
  58. package/packages/llm-billing/lib/types/types.js.map +1 -0
  59. package/packages/llm-billing/node_modules/.bin/cordis +43 -0
  60. package/packages/llm-billing/node_modules/.bin/cordis.CMD +12 -0
  61. package/packages/llm-billing/node_modules/.bin/cordis.ps1 +41 -0
  62. package/packages/llm-billing/package.json +75 -0
  63. package/packages/llm-billing/src/balance.ts +164 -0
  64. package/packages/llm-billing/src/billing.ts +251 -0
  65. package/packages/llm-billing/src/index.ts +219 -0
  66. package/packages/llm-billing/src/invariant.ts +30 -0
  67. package/packages/llm-billing/src/types.ts +67 -0
  68. package/packages/llm-billing/tests/balance.spec.ts +254 -0
  69. package/packages/llm-billing/tests/billing.spec.ts +216 -0
  70. package/packages/llm-billing/tsconfig.json +42 -0
  71. package/packages/ui-billing/README.i18n.yaml +6 -0
  72. package/packages/ui-billing/README.md +30 -0
  73. package/packages/ui-billing/README.zh.md +30 -0
  74. package/packages/ui-billing/lib/client.js +4466 -0
  75. package/packages/ui-billing/lib/client.js.map +1 -0
  76. package/packages/ui-billing/lib/index.js +11 -0
  77. package/packages/ui-billing/lib/invariant.js +25 -0
  78. package/packages/ui-billing/lib/tsconfig.tsbuildinfo +1 -0
  79. package/packages/ui-billing/lib/types/client/BalanceBadge.d.ts +33 -0
  80. package/packages/ui-billing/lib/types/client/BalanceBadge.d.ts.map +1 -0
  81. package/packages/ui-billing/lib/types/client/BalanceBadge.js +166 -0
  82. package/packages/ui-billing/lib/types/client/BalanceBadge.js.map +1 -0
  83. package/packages/ui-billing/lib/types/client/index.d.ts +20 -0
  84. package/packages/ui-billing/lib/types/client/index.d.ts.map +1 -0
  85. package/packages/ui-billing/lib/types/client/index.js +55 -0
  86. package/packages/ui-billing/lib/types/client/index.js.map +1 -0
  87. package/packages/ui-billing/lib/types/client/locales.d.ts +26 -0
  88. package/packages/ui-billing/lib/types/client/locales.d.ts.map +1 -0
  89. package/packages/ui-billing/lib/types/client/locales.js +40 -0
  90. package/packages/ui-billing/lib/types/client/locales.js.map +1 -0
  91. package/packages/ui-billing/lib/types/index.d.ts +9 -0
  92. package/packages/ui-billing/lib/types/index.d.ts.map +1 -0
  93. package/packages/ui-billing/lib/types/index.js +9 -0
  94. package/packages/ui-billing/lib/types/index.js.map +1 -0
  95. package/packages/ui-billing/lib/types/invariant.d.ts +16 -0
  96. package/packages/ui-billing/lib/types/invariant.d.ts.map +1 -0
  97. package/packages/ui-billing/lib/types/invariant.js +24 -0
  98. package/packages/ui-billing/lib/types/invariant.js.map +1 -0
  99. package/packages/ui-billing/node_modules/.bin/cordis +43 -0
  100. package/packages/ui-billing/node_modules/.bin/cordis.CMD +12 -0
  101. package/packages/ui-billing/node_modules/.bin/cordis.ps1 +41 -0
  102. package/packages/ui-billing/node_modules/.bin/vite +43 -0
  103. package/packages/ui-billing/node_modules/.bin/vite.CMD +12 -0
  104. package/packages/ui-billing/node_modules/.bin/vite.ps1 +41 -0
  105. package/packages/ui-billing/package.json +76 -0
  106. package/packages/ui-billing/src/client/BalanceBadge.module.css +184 -0
  107. package/packages/ui-billing/src/client/BalanceBadge.tsx +266 -0
  108. package/packages/ui-billing/src/client/index.ts +80 -0
  109. package/packages/ui-billing/src/client/locales.ts +45 -0
  110. package/packages/ui-billing/src/css-modules.d.ts +6 -0
  111. package/packages/ui-billing/src/index.ts +9 -0
  112. package/packages/ui-billing/src/invariant.ts +32 -0
  113. package/packages/ui-billing/tests/balance-badge.client.spec.tsx +267 -0
  114. package/packages/ui-billing/tests/browser-plugin.client.spec.ts +205 -0
  115. package/packages/ui-billing/tsconfig.json +36 -0
  116. package/packages/ui-billing/tsdown.config.ts +3 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * DeepSeek account balance and session-spend provider, as a standalone host
3
+ * plugin. It resolves the DeepSeek endpoint and API key from its own config and
4
+ * the credential/environment seams, prices each session's billed usage with the
5
+ * peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
6
+ * per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
7
+ * @module @rayadesu/dsh-llm-billing
8
+ */
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import z from '@deepseek-ai/schemastery'
12
+ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
13
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
14
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
15
+ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
16
+ import type {} from '@deepseek-ai/dsh-session-persistence'
17
+ import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from './balance.ts'
18
+ import {
19
+ computeSessionSpend,
20
+ computeTodaySpend,
21
+ DEFAULT_MODEL_PRICING,
22
+ DEFAULT_PEAK_HOURS,
23
+ resolveBilling,
24
+ } from './billing.ts'
25
+ import type { BillingConfig, BillingConfigModel } from './billing.ts'
26
+ import type { DeepSeekBalance, DeepSeekSessionSpend, DeepSeekTodaySpend } from './types.ts'
27
+
28
+ export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts'
29
+ export {
30
+ computeSessionSpend,
31
+ computeTodaySpend,
32
+ DEFAULT_MODEL_PRICING,
33
+ DEFAULT_PEAK_HOURS,
34
+ isPeak,
35
+ resolveBilling,
36
+ } from './billing.ts'
37
+ export type {
38
+ BillingConfig,
39
+ BillingConfigModel,
40
+ DeepSeekModelPricing,
41
+ DeepSeekTokenPrice,
42
+ PeakHourWindow,
43
+ ResolvedBilling,
44
+ } from './billing.ts'
45
+ export type * from './types.ts'
46
+
47
+ export const name = 'llm-billing'
48
+
49
+ const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
50
+ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
51
+ /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
52
+ export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
53
+
54
+ /** One advisory display row; requests are never restricted to this list. */
55
+ export interface BillingModel {
56
+ /** Wire model id, e.g. `deepseek-v4-flash`. */
57
+ id: string
58
+ /** Selector label; defaults to {@link id}. */
59
+ name?: string
60
+ }
61
+
62
+ const DEFAULT_MODELS: BillingModel[] = [
63
+ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
64
+ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
65
+ { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp' },
66
+ ]
67
+
68
+ /**
69
+ * Plugin config. Every field is optional: the API key resolves per call from
70
+ * {@link Config.apiKeyEnv} (credentials seam, then environment), the endpoint
71
+ * falls back to `$DEEPSEEK_BASE_URL` then the public API, and the pricing
72
+ * table and peak-hour windows fall back to the published DeepSeek rates.
73
+ */
74
+ export interface Config {
75
+ /** Credential reference (environment-variable name); defaults to `DEEPSEEK_API_KEY`. */
76
+ apiKeyEnv?: string
77
+ /** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
78
+ baseURL?: string
79
+ /** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
80
+ models?: BillingModel[]
81
+ /** Pricing table and peak-hour windows; omission uses the published defaults. */
82
+ billing?: BillingConfig
83
+ }
84
+
85
+ const billingModel: z<BillingModel> = z.object({
86
+ id: z.string().required(),
87
+ name: z.string(),
88
+ })
89
+
90
+ const tokenPrice: z<BillingConfigModel['peak']> = z.object({
91
+ cacheHitInput: z.number().min(0),
92
+ cacheMissInput: z.number().min(0),
93
+ output: z.number().min(0),
94
+ })
95
+
96
+ const billingConfig: z<BillingConfig> = z.object({
97
+ peakHours: z.array(z.object({
98
+ start: z.number().step(1).min(0).max(23),
99
+ end: z.number().step(1).min(0).max(24),
100
+ })).default(DEFAULT_PEAK_HOURS),
101
+ models: z.array(z.object({
102
+ model: z.string().required(),
103
+ peak: tokenPrice,
104
+ offPeak: tokenPrice,
105
+ })).default(DEFAULT_MODEL_PRICING),
106
+ })
107
+
108
+ export const Config: z<Config> = z.object({
109
+ apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
110
+ baseURL: z.string(),
111
+ models: z.array(billingModel).default(DEFAULT_MODELS),
112
+ billing: billingConfig,
113
+ })
114
+
115
+ /**
116
+ * Read one session's event log: the live SessionStore first, then the
117
+ * persistence backend for a flushed session.
118
+ * @param ctx - plugin context carrying the SessionStore and optional persistence.
119
+ * @param sessionId - the session to read.
120
+ * @returns the session's complete event log.
121
+ * @throws {@link LlmError} with code `NOT_FOUND` when the session is unknown.
122
+ */
123
+ async function sessionEvents(ctx: Context, sessionId: SessionId): Promise<readonly SessionEvent[]> {
124
+ const sessions = ctx.get('sessions')
125
+ const live = sessions?.get(sessionId)
126
+ if (live !== undefined) return live.events
127
+ const persistence = ctx.get('sessionPersistence')
128
+ if (persistence !== undefined) {
129
+ for (const header of await persistence.list()) {
130
+ if (header.id !== sessionId) continue
131
+ const inspection = await persistence.inspect(sessionId)
132
+ return inspection.events
133
+ }
134
+ }
135
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND')
136
+ }
137
+
138
+ /**
139
+ * Read every session's event log, concatenated: each live SessionStore
140
+ * session first (its log may hold events not yet flushed), then each persisted
141
+ * session that is not live, so no event is counted twice. Events are appended
142
+ * one at a time: spreading a very large log into `push(...)` exceeds the
143
+ * engine's argument limit and throws a stack RangeError.
144
+ * @param ctx - plugin context carrying the SessionStore and optional persistence.
145
+ * @returns every session's complete event log, concatenated.
146
+ */
147
+ async function allSessionEvents(ctx: Context): Promise<readonly SessionEvent[]> {
148
+ const events: SessionEvent[] = []
149
+ const sessions = ctx.get('sessions')
150
+ const liveIds = new Set<SessionId>()
151
+ if (sessions !== undefined) {
152
+ for (const session of sessions.list()) {
153
+ liveIds.add(session.id)
154
+ for (const event of session.events) events.push(event)
155
+ }
156
+ }
157
+ const persistence = ctx.get('sessionPersistence')
158
+ if (persistence !== undefined) {
159
+ for (const header of await persistence.list()) {
160
+ if (liveIds.has(header.id)) continue
161
+ try {
162
+ const inspection = await persistence.inspect(header.id)
163
+ for (const event of inspection.events) events.push(event)
164
+ } catch (error: unknown) {
165
+ // One unreadable session must not blank the whole-day aggregate.
166
+ ctx.logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`)
167
+ }
168
+ }
169
+ }
170
+ return events
171
+ }
172
+
173
+ /**
174
+ * Register the `billing` Remote under the `billing` namespace.
175
+ * @param ctx - owning plugin context.
176
+ * @param config - validated plugin config.
177
+ */
178
+ export function apply(ctx: Context, config: Config): void {
179
+ const baseURL = (): string => config.baseURL
180
+ ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value
181
+ ?? PUBLIC_BASE_URL
182
+ const apiKeyRef = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV)
183
+
184
+ const resolveApiKey = async (): Promise<string> => {
185
+ const credentials = ctx.get('credentials')
186
+ if (credentials !== undefined) {
187
+ const hit = await credentials.resolve(apiKeyRef)
188
+ if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-billing', apiKeyRef)
189
+ } else {
190
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyRef)
191
+ if (ambient !== undefined && ambient.value.length > 0) {
192
+ return assertUsableApiKey(ambient.value, 'llm-billing', apiKeyRef)
193
+ }
194
+ }
195
+ throw new LlmError(
196
+ `llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`,
197
+ 'MISSING_CREDENTIAL',
198
+ )
199
+ }
200
+
201
+ const fetchBalance = async (): Promise<DeepSeekBalance> => {
202
+ const apiKey = await resolveApiKey()
203
+ return fetchDeepSeekBalance(baseURL(), apiKey)
204
+ }
205
+
206
+ const fetchSessionSpend = async (sessionId: SessionId): Promise<DeepSeekSessionSpend> => {
207
+ const billing = resolveBilling(config.billing)
208
+ const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }))
209
+ return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog)
210
+ }
211
+
212
+ const fetchTodaySpend = async (): Promise<DeepSeekTodaySpend> => {
213
+ const billing = resolveBilling(config.billing)
214
+ const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }))
215
+ return computeTodaySpend(await allSessionEvents(ctx), billing, catalog)
216
+ }
217
+
218
+ new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend })
219
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Package-owned invariant companion for `@rayadesu/dsh-llm-billing`.
3
+ * @module @rayadesu/dsh-llm-billing/invariant
4
+ */
5
+
6
+ /* jscpd:ignore-start */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
+
10
+ const PACKAGE_NAME = '@rayadesu/dsh-llm-billing'
11
+
12
+ /** Cordis companion plugin name. */
13
+ export const name = 'llm-billing-invariant'
14
+ /** Service required before the companion can reserve package ownership. */
15
+ export const inject = ['invariants']
16
+
17
+ /**
18
+ * No runtime invariant: this package is a read-only Remote projection over the
19
+ * provider balance and session logs, and owns no cross-plugin mutable state.
20
+ */
21
+ const install: InvariantInstaller = () => {}
22
+
23
+ /**
24
+ * Register this package's invariant companion.
25
+ * @param ctx - Cordis context carrying the invariant service.
26
+ * @returns the installed registration's disposer after setup succeeds.
27
+ */
28
+ export const apply = (ctx: Context): Promise<() => void> =>
29
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
30
+ /* jscpd:ignore-end */
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Client-safe balance and spend vocabulary shared by the `billing` Remote,
3
+ * its generated artifacts, and the web UI.
4
+ * @module @rayadesu/dsh-llm-billing/types
5
+ */
6
+
7
+ /** One currency line of the account balance returned by `GET /user/balance`. */
8
+ export interface DeepSeekBalanceLine {
9
+ /** Currency code, e.g. `CNY` or `USD`. */
10
+ currency: string
11
+ /** Total available balance (granted plus topped up). */
12
+ total: string
13
+ /** Non-expired granted (free) balance. */
14
+ granted: string
15
+ /** Topped-up (paid) balance. */
16
+ toppedUp: string
17
+ }
18
+
19
+ /** DeepSeek account balance returned by `GET /user/balance`. */
20
+ export interface DeepSeekBalance {
21
+ /** Whether the account has any balance available for API calls. */
22
+ isAvailable: boolean
23
+ /** Balance lines, one per currency; empty when the provider reports none. */
24
+ lines: readonly DeepSeekBalanceLine[]
25
+ }
26
+
27
+ /** One model's billed spend within one session, priced by the peak/off-peak table. */
28
+ export interface DeepSeekSessionSpendModel {
29
+ /** Wire model id, e.g. `deepseek-v4-flash`. */
30
+ model: string
31
+ /** Selector label, e.g. `DeepSeek-V4-Flash`. */
32
+ displayName: string
33
+ /** Billed cost in CNY (peak plus off-peak portions). */
34
+ cost: number
35
+ /** Cost portion billed at peak rates. */
36
+ peakCost: number
37
+ /** Cost portion billed at off-peak rates. */
38
+ offPeakCost: number
39
+ /** Cache-hit input tokens billed at the hit rate. */
40
+ cacheHitInputTokens: number
41
+ /** Cache-miss input tokens (uncached input plus cache writes), billed at the miss rate. */
42
+ cacheMissInputTokens: number
43
+ /** Output tokens (reasoning included), billed at the output rate. */
44
+ outputTokens: number
45
+ /** Billed cost of cache-hit input tokens in CNY. */
46
+ cacheHitInputCost: number
47
+ /** Billed cost of cache-miss input tokens (uncached input plus cache writes) in CNY. */
48
+ cacheMissInputCost: number
49
+ /** Billed cost of output tokens (reasoning included) in CNY. */
50
+ outputCost: number
51
+ }
52
+
53
+ /** The billed spend of one session, priced per event by its Beijing-time peak/off-peak hour. */
54
+ export interface DeepSeekSessionSpend {
55
+ /** Total billed cost in CNY across every priced model. */
56
+ total: number
57
+ /** One row per model that reported usage AND has a pricing row; empty when the session has no priced usage. */
58
+ models: readonly DeepSeekSessionSpendModel[]
59
+ }
60
+
61
+ /** The billed spend of every session on one Beijing-time calendar day, priced per event by its peak/off-peak hour. */
62
+ export interface DeepSeekTodaySpend {
63
+ /** Total billed cost in CNY across every priced model and every session. */
64
+ total: number
65
+ /** One row per model that reported usage AND has a pricing row; empty when today has no priced usage. */
66
+ models: readonly DeepSeekSessionSpendModel[]
67
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Account-balance capability: the wire parser, the transport, and the Remote
3
+ * gateway binding. Fetch is exercised through a mocked global fetch, so the
4
+ * suite stays keyless and deterministic.
5
+ */
6
+ import { Context } from '@deepseek-ai/cordis'
7
+ import { LlmError } from '@deepseek-ai/dsh-llm'
8
+ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
9
+ import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from '../src/balance.ts'
12
+ import { apply as applyBilling } from '../src/index.ts'
13
+ import type { DeepSeekBalance } from '../src/types.ts'
14
+
15
+ const VALID_WIRE = {
16
+ is_available: true,
17
+ balance_infos: [
18
+ { currency: 'CNY', total_balance: '110.00', granted_balance: '10.00', topped_up_balance: '100.00' },
19
+ ],
20
+ }
21
+
22
+ const VALID_PUBLIC: DeepSeekBalance = {
23
+ isAvailable: true,
24
+ lines: [{ currency: 'CNY', total: '110.00', granted: '10.00', toppedUp: '100.00' }],
25
+ }
26
+
27
+ const VALID_SPEND = {
28
+ total: 0.31,
29
+ models: [{
30
+ model: 'deepseek-v4-flash',
31
+ displayName: 'DeepSeek-V4-Flash',
32
+ cost: 0.31,
33
+ peakCost: 0.31,
34
+ offPeakCost: 0,
35
+ cacheHitInputTokens: 1000,
36
+ cacheMissInputTokens: 100000,
37
+ outputTokens: 20000,
38
+ cacheHitInputCost: 0.01,
39
+ cacheMissInputCost: 0.20,
40
+ outputCost: 0.10,
41
+ }],
42
+ }
43
+
44
+ const VALID_TODAY_SPEND = {
45
+ total: 0.62,
46
+ models: [{
47
+ model: 'deepseek-v4-flash',
48
+ displayName: 'DeepSeek-V4-Flash',
49
+ cost: 0.62,
50
+ peakCost: 0.31,
51
+ offPeakCost: 0.31,
52
+ cacheHitInputTokens: 2000,
53
+ cacheMissInputTokens: 200000,
54
+ outputTokens: 40000,
55
+ cacheHitInputCost: 0.02,
56
+ cacheMissInputCost: 0.40,
57
+ outputCost: 0.20,
58
+ }],
59
+ }
60
+
61
+ function jsonResponse(status: number, body: unknown): Response {
62
+ return new Response(JSON.stringify(body), {
63
+ status,
64
+ headers: { 'content-type': 'application/json' },
65
+ })
66
+ }
67
+
68
+ afterEach(() => {
69
+ vi.restoreAllMocks()
70
+ })
71
+
72
+ describe('parseDeepSeekBalance', () => {
73
+ it('maps the wire response to the public snapshot', () => {
74
+ expect(parseDeepSeekBalance(VALID_WIRE)).toEqual(VALID_PUBLIC)
75
+ })
76
+
77
+ it('keeps every currency line', () => {
78
+ expect(parseDeepSeekBalance({
79
+ is_available: true,
80
+ balance_infos: [
81
+ { currency: 'CNY', total_balance: '1.00', granted_balance: '0.00', topped_up_balance: '1.00' },
82
+ { currency: 'USD', total_balance: '2.00', granted_balance: '1.00', topped_up_balance: '1.00' },
83
+ ],
84
+ })).toMatchObject({ lines: [{ currency: 'CNY' }, { currency: 'USD' }] })
85
+ })
86
+
87
+ it('rejects a non-object body', () => {
88
+ expect(() => parseDeepSeekBalance(null)).toThrow(LlmError)
89
+ })
90
+
91
+ it('rejects a body missing is_available or balance_infos', () => {
92
+ expect(() => parseDeepSeekBalance({ is_available: true })).toThrow(LlmError)
93
+ expect(() => parseDeepSeekBalance({ balance_infos: [] })).toThrow(LlmError)
94
+ })
95
+
96
+ it('rejects a malformed balance line', () => {
97
+ expect(() => parseDeepSeekBalance({
98
+ is_available: true,
99
+ balance_infos: [{ currency: 'CNY', total_balance: '1.00', granted_balance: '0.00' }],
100
+ })).toThrow(LlmError)
101
+ })
102
+ })
103
+
104
+ describe('fetchDeepSeekBalance', () => {
105
+ it('fetches /user/balance with the resolved bearer token', async () => {
106
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(200, VALID_WIRE))
107
+ await expect(fetchDeepSeekBalance('https://api.deepseek.com', 'secret')).resolves.toEqual(VALID_PUBLIC)
108
+ expect(spy).toHaveBeenCalledWith(
109
+ 'https://api.deepseek.com/user/balance',
110
+ expect.objectContaining({
111
+ method: 'GET',
112
+ // The header matcher is the assertion; objectContaining is any-typed.
113
+ headers: expect.objectContaining({ authorization: 'Bearer secret' }), // oxlint-disable-line typescript/no-unsafe-assignment
114
+ }),
115
+ )
116
+ })
117
+
118
+ it('maps an HTTP status to the provider error code', async () => {
119
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(401, {}))
120
+ await expect(fetchDeepSeekBalance('https://api.deepseek.com', 'bad')).rejects.toMatchObject({ code: 'AUTH' })
121
+ })
122
+
123
+ it('wraps a network failure as TRANSPORT', async () => {
124
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('fetch failed'))
125
+ await expect(fetchDeepSeekBalance('https://api.deepseek.com', 'key')).rejects.toMatchObject({ code: 'TRANSPORT' })
126
+ })
127
+
128
+ it('rejects a non-JSON body as TRANSPORT', async () => {
129
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not json', { status: 200 }))
130
+ await expect(fetchDeepSeekBalance('https://api.deepseek.com', 'key')).rejects.toMatchObject({ code: 'TRANSPORT' })
131
+ })
132
+
133
+ it('reports caller cancellation as ABORTED', async () => {
134
+ const controller = new AbortController()
135
+ controller.abort()
136
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('aborted'))
137
+ await expect(fetchDeepSeekBalance('https://api.deepseek.com', 'key', controller.signal))
138
+ .rejects.toMatchObject({ code: 'ABORTED' })
139
+ })
140
+ })
141
+
142
+ describe('DeepSeekBalanceGateway', () => {
143
+ const options = {
144
+ fetchBalance: async () => VALID_PUBLIC,
145
+ fetchSessionSpend: async () => VALID_SPEND,
146
+ fetchTodaySpend: async () => VALID_TODAY_SPEND,
147
+ }
148
+
149
+ it('registers under the billing namespace and exports getBalance, getSessionSpend, and getTodaySpend', () => {
150
+ const ctx = new Context()
151
+ const gateway = new DeepSeekBalanceGateway(ctx, options)
152
+ expect(gateway.typertRemote.namespace).toBe('billing')
153
+ const methods = remoteMethods(gateway).map(marker => marker.exportName ?? marker.method)
154
+ expect(methods).toContain('getBalance')
155
+ expect(methods).toContain('getSessionSpend')
156
+ expect(methods).toContain('getTodaySpend')
157
+ expect(ctx.get('billing')).toBeDefined()
158
+ })
159
+
160
+ it('delegates to the bound fetch thunks', async () => {
161
+ const ctx = new Context()
162
+ const gateway = new DeepSeekBalanceGateway(ctx, options)
163
+ await expect(gateway.getBalance()).resolves.toEqual(VALID_PUBLIC)
164
+ await expect(gateway.getSessionSpend('session-1' as SessionId)).resolves.toEqual(VALID_SPEND)
165
+ await expect(gateway.getTodaySpend()).resolves.toEqual(VALID_TODAY_SPEND)
166
+ })
167
+
168
+ it('is root-visible when constructed inside a plugin fiber', async () => {
169
+ const root = new Context()
170
+ const fiber = root.plugin({
171
+ name: 'llm-deepseek-sim',
172
+ apply: (pluginCtx: Context) => {
173
+ new DeepSeekBalanceGateway(pluginCtx, options)
174
+ },
175
+ })
176
+ await fiber.await()
177
+ expect(root.get('billing')).toBeDefined()
178
+ await root.fiber.dispose()
179
+ })
180
+ })
181
+
182
+ describe('apply / allSessionEvents', () => {
183
+ /** One today-priced flash event: now falls on today's Beijing calendar day. */
184
+ function pricedEvent(index: number): SessionEvent {
185
+ return {
186
+ type: 'assistant/message',
187
+ seq: index,
188
+ time: Date.now(),
189
+ data: {
190
+ turn: 0,
191
+ step: index,
192
+ message: {
193
+ id: `m${index}` as never,
194
+ role: 'assistant',
195
+ content: [],
196
+ source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
197
+ },
198
+ usage: { inputTokens: 100, outputTokens: 100, cacheReadTokens: 100, cacheWriteTokens: 50 },
199
+ },
200
+ } as unknown as SessionEvent
201
+ }
202
+
203
+ it('aggregates today\'s spend across a very large session log without exceeding the call stack', async () => {
204
+ const ctx = new Context()
205
+ ctx.provide('sessions', { list: () => [] } as never)
206
+ const bigEvents = Array.from({ length: 200_000 }, (_, index) => pricedEvent(index))
207
+ ctx.provide('sessionPersistence', {
208
+ list: async () => [{ id: 'session-big' }],
209
+ inspect: async () => ({ meta: {}, events: bigEvents }),
210
+ } as never)
211
+ applyBilling(ctx, {})
212
+ const gateway = ctx.get('billing') as unknown as DeepSeekBalanceGateway
213
+ await expect(gateway.getTodaySpend()).resolves.toMatchObject({
214
+ total: expect.any(Number), // oxlint-disable-line typescript/no-unsafe-assignment
215
+ models: [{ model: 'deepseek-v4-flash' }],
216
+ })
217
+ await ctx.fiber.dispose()
218
+ })
219
+
220
+ it('skips a session that fails to inspect instead of failing the whole day', async () => {
221
+ const ctx = new Context()
222
+ ctx.provide('sessions', { list: () => [] } as never)
223
+ ctx.provide('sessionPersistence', {
224
+ list: async () => [{ id: 'session-ok' }, { id: 'session-bad' }],
225
+ inspect: async (id: SessionId) => {
226
+ if (id === 'session-bad' as SessionId) throw new Error('corrupt log')
227
+ return { meta: {}, events: [pricedEvent(0)] }
228
+ },
229
+ } as never)
230
+ applyBilling(ctx, {})
231
+ const gateway = ctx.get('billing') as unknown as DeepSeekBalanceGateway
232
+ await expect(gateway.getTodaySpend()).resolves.toMatchObject({
233
+ total: expect.any(Number), // oxlint-disable-line typescript/no-unsafe-assignment
234
+ })
235
+ await ctx.fiber.dispose()
236
+ })
237
+
238
+ it('counts live sessions first and does not double-count them through persistence', async () => {
239
+ const ctx = new Context()
240
+ ctx.provide('sessions', {
241
+ list: () => [{ id: 'session-live', events: [pricedEvent(0)] }],
242
+ } as never)
243
+ ctx.provide('sessionPersistence', {
244
+ list: async () => [{ id: 'session-live' }],
245
+ inspect: async () => { throw new Error('must not be read') },
246
+ } as never)
247
+ applyBilling(ctx, {})
248
+ const gateway = ctx.get('billing') as unknown as DeepSeekBalanceGateway
249
+ await expect(gateway.getTodaySpend()).resolves.toMatchObject({
250
+ total: expect.any(Number), // oxlint-disable-line typescript/no-unsafe-assignment
251
+ })
252
+ await ctx.fiber.dispose()
253
+ })
254
+ })