@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,267 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
5
+ import type { DeepSeekBalance, DeepSeekSessionSpend, DeepSeekTodaySpend } from '@rayadesu/dsh-llm-billing/types'
6
+ import { BalanceBadge, type BalanceBadgeProps } from '../src/client/BalanceBadge.tsx'
7
+ import { zh } from '../src/client/locales.ts'
8
+
9
+ afterEach(() => {
10
+ cleanup()
11
+ })
12
+
13
+ const t: BalanceBadgeProps['t'] = makeTranslate(zh)
14
+
15
+ const SPEND: DeepSeekSessionSpend = {
16
+ total: 0.04,
17
+ models: [{
18
+ model: 'deepseek-v4-flash',
19
+ displayName: 'DeepSeek-V4-Flash',
20
+ cost: 0.04,
21
+ peakCost: 0.04,
22
+ offPeakCost: 0,
23
+ cacheHitInputTokens: 1000,
24
+ cacheMissInputTokens: 100000,
25
+ outputTokens: 20000,
26
+ cacheHitInputCost: 0.01,
27
+ cacheMissInputCost: 0.02,
28
+ outputCost: 0.01,
29
+ }],
30
+ }
31
+
32
+ const TODAY_SPEND: DeepSeekTodaySpend = {
33
+ total: 0.31,
34
+ models: [{
35
+ model: 'deepseek-v4-flash',
36
+ displayName: 'DeepSeek-V4-Flash',
37
+ cost: 0.31,
38
+ peakCost: 0.2,
39
+ offPeakCost: 0.11,
40
+ cacheHitInputTokens: 2000,
41
+ cacheMissInputTokens: 200000,
42
+ outputTokens: 30000,
43
+ cacheHitInputCost: 0.01,
44
+ cacheMissInputCost: 0.2,
45
+ outputCost: 0.1,
46
+ }],
47
+ }
48
+
49
+ function balance(over: Partial<DeepSeekBalance> = {}): DeepSeekBalance {
50
+ return {
51
+ isAvailable: true,
52
+ lines: [{ currency: 'CNY', total: '110.00', granted: '10.00', toppedUp: '100.00' }],
53
+ ...over,
54
+ }
55
+ }
56
+
57
+ function props(
58
+ getBalance: () => Promise<DeepSeekBalance>,
59
+ getSessionSpend: () => Promise<DeepSeekSessionSpend> = async () => SPEND,
60
+ getTodaySpend: () => Promise<DeepSeekTodaySpend> = async () => TODAY_SPEND,
61
+ useSession: (selector: (snapshot: { chat: { order: readonly string[] } }) => number) => number = () => 0,
62
+ ): BalanceBadgeProps {
63
+ return {
64
+ getBalance,
65
+ getSessionSpend,
66
+ getTodaySpend,
67
+ useSession,
68
+ sessionId: 'session-1',
69
+ t,
70
+ } as BalanceBadgeProps
71
+ }
72
+
73
+ describe('BalanceBadge', () => {
74
+ it('renders nothing while the first fetch is in flight', () => {
75
+ const { container } = render(<BalanceBadge {...props(() => new Promise(() => {}))} />)
76
+ expect(container.innerHTML).toBe('')
77
+ })
78
+
79
+ it('appears as soon as the balance settles, without waiting for the spends', async () => {
80
+ const getSessionSpend = vi.fn(() => new Promise<DeepSeekSessionSpend>(() => {}))
81
+ const getTodaySpend = vi.fn(() => new Promise<DeepSeekTodaySpend>(() => {}))
82
+ render(<BalanceBadge {...props(async () => balance(), getSessionSpend, getTodaySpend)} />)
83
+ // The balance landed; the badge renders even though both spends never settle.
84
+ expect(await screen.findByText('剩余额度:¥110.00')).toBeDefined()
85
+ })
86
+
87
+ it('shows the balance and this conversation spend on the trigger', async () => {
88
+ render(<BalanceBadge {...props(async () => balance())} />)
89
+ expect(await screen.findByText('剩余额度:¥110.00')).toBeDefined()
90
+ expect(screen.getByText('本轮对话花费:¥0.04')).toBeDefined()
91
+ })
92
+
93
+ it('keeps the spend line hidden while the conversation has no priced usage', async () => {
94
+ render(<BalanceBadge {...props(async () => balance(), async () => ({ total: 0, models: [] }))} />)
95
+ expect(await screen.findByText('剩余额度:¥110.00')).toBeDefined()
96
+ expect(screen.queryByText(/本轮对话花费/)).toBeNull()
97
+ })
98
+
99
+ it('opens the label box with the amount, the spend, today\'s spend, and the cache-hit/input/output breakdown', async () => {
100
+ render(<BalanceBadge {...props(async () => balance())} />)
101
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
102
+ expect(await screen.findByText('API 剩余金额:¥110.00')).toBeDefined()
103
+ expect(screen.getByText('本会话花费:¥0.04')).toBeDefined()
104
+ expect(screen.getByText('今日共花费:¥0.31')).toBeDefined()
105
+ expect(screen.getByText('DeepSeek-V4-Flash')).toBeDefined()
106
+ expect(screen.getByText('¥0.04')).toBeDefined()
107
+ expect(screen.getByText('缓存命中 ¥0.01 · 未命中输入 ¥0.02 · 输出 ¥0.01')).toBeDefined()
108
+ })
109
+
110
+ it('renders an info button with the spend disclaimer', async () => {
111
+ render(<BalanceBadge {...props(async () => balance())} />)
112
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
113
+ expect(await screen.findByRole('button', { name: zh['info.aria'] })).toBeDefined()
114
+ })
115
+
116
+ it('renders the unavailable word when the fetch rejects', async () => {
117
+ render(<BalanceBadge {...props(async () => { throw new Error('no key') })} />)
118
+ expect(await screen.findByText(zh['state.unavailable'])).toBeDefined()
119
+ })
120
+
121
+ it('keeps the last value visible while a refresh is in flight, then updates it', async () => {
122
+ const getBalance = vi.fn()
123
+ .mockResolvedValueOnce(balance())
124
+ .mockResolvedValueOnce(new Promise(resolve => setTimeout(() => { resolve(balance({
125
+ lines: [{ currency: 'CNY', total: '9.00', granted: '0.00', toppedUp: '9.00' }],
126
+ })) }, 20)))
127
+ render(<BalanceBadge {...props(getBalance)} />)
128
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
129
+ fireEvent.click(await screen.findByRole('button', { name: zh['action.refresh'] }))
130
+ // The previous value must stay on screen during the refetch.
131
+ expect(screen.getByText('剩余额度:¥110.00')).toBeDefined()
132
+ await waitFor(() => { expect(getBalance).toHaveBeenCalledTimes(2) })
133
+ await waitFor(() => { expect(screen.getByText('剩余额度:¥9.00')).toBeDefined() })
134
+ })
135
+
136
+ it('keeps both spend values when a refresh rejects', async () => {
137
+ const getSessionSpend = vi.fn()
138
+ .mockResolvedValueOnce(SPEND)
139
+ .mockRejectedValueOnce(new Error('boom'))
140
+ const getTodaySpend = vi.fn()
141
+ .mockResolvedValueOnce(TODAY_SPEND)
142
+ .mockRejectedValueOnce(new Error('boom'))
143
+ render(<BalanceBadge {...props(async () => balance(), getSessionSpend, getTodaySpend)} />)
144
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
145
+ fireEvent.click(await screen.findByRole('button', { name: zh['action.refresh'] }))
146
+ await waitFor(() => { expect(getSessionSpend).toHaveBeenCalledTimes(2) })
147
+ await waitFor(() => { expect(getTodaySpend).toHaveBeenCalledTimes(2) })
148
+ // A failed refetch keeps the previous values instead of blanking them.
149
+ expect(screen.getByText('本会话花费:¥0.04')).toBeDefined()
150
+ expect(screen.getByText('今日共花费:¥0.31')).toBeDefined()
151
+ })
152
+
153
+ it('recomputes only the spends when a new message lands, without refetching the balance', async () => {
154
+ let messageCount = 0
155
+ const getBalance = vi.fn(async () => balance())
156
+ const getSessionSpend = vi.fn(async () => SPEND)
157
+ const getTodaySpend = vi.fn(async () => TODAY_SPEND)
158
+ const { rerender } = render(
159
+ <BalanceBadge {...props(getBalance, getSessionSpend, getTodaySpend, () => messageCount)} />,
160
+ )
161
+ expect(await screen.findByText('剩余额度:¥110.00')).toBeDefined()
162
+ expect(getBalance).toHaveBeenCalledTimes(1)
163
+ const spendCallsBeforeMessage = getSessionSpend.mock.calls.length
164
+ const todayCallsBeforeMessage = getTodaySpend.mock.calls.length
165
+
166
+ // A new message lands: the in-window message count changes and the badge
167
+ // recomputes this session's spend and today's spend only — the balance
168
+ // stays untouched.
169
+ messageCount = 1
170
+ rerender(<BalanceBadge {...props(getBalance, getSessionSpend, getTodaySpend, () => messageCount)} />)
171
+ await waitFor(() => {
172
+ expect(getSessionSpend.mock.calls.length).toBeGreaterThan(spendCallsBeforeMessage)
173
+ })
174
+ await waitFor(() => {
175
+ expect(getTodaySpend.mock.calls.length).toBeGreaterThan(todayCallsBeforeMessage)
176
+ })
177
+ expect(getBalance).toHaveBeenCalledTimes(1)
178
+ })
179
+
180
+ it('keeps both spend values when a new-message recompute rejects', async () => {
181
+ let messageCount = 0
182
+ const getBalance = vi.fn(async () => balance())
183
+ const getSessionSpend = vi.fn(async () => SPEND)
184
+ const getTodaySpend = vi.fn(async () => TODAY_SPEND)
185
+ const { rerender } = render(
186
+ <BalanceBadge {...props(getBalance, getSessionSpend, getTodaySpend, () => messageCount)} />,
187
+ )
188
+ await screen.findByText('剩余额度:¥110.00')
189
+
190
+ // A new message lands but both recomputes reject: the previous values stay.
191
+ messageCount = 1
192
+ getSessionSpend.mockRejectedValueOnce(new Error('boom'))
193
+ getTodaySpend.mockRejectedValueOnce(new Error('boom'))
194
+ rerender(<BalanceBadge {...props(getBalance, getSessionSpend, getTodaySpend, () => messageCount)} />)
195
+ await waitFor(() => { expect(getTodaySpend.mock.calls.length).toBe(2) })
196
+ expect(screen.getByText('本轮对话花费:¥0.04')).toBeDefined()
197
+ })
198
+
199
+ it('prefixes USD with the dollar sign', async () => {
200
+ const usd = balance({ lines: [{ currency: 'USD', total: '5.00', granted: '0.00', toppedUp: '5.00' }] })
201
+ render(<BalanceBadge {...props(async () => usd)} />)
202
+ await waitFor(() => { expect(screen.getByText('剩余额度:$5.00')).toBeDefined() })
203
+ })
204
+
205
+ it('shows the no-usage word for a session without priced usage', async () => {
206
+ render(<BalanceBadge {...props(async () => balance(), async () => ({ total: 0, models: [] }))} />)
207
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
208
+ expect(await screen.findByText(`本会话花费:${zh['stat.none']}`)).toBeDefined()
209
+ })
210
+
211
+ it('shows the no-usage word for a day without priced usage across every session', async () => {
212
+ render(<BalanceBadge
213
+ {...props(async () => balance(), async () => SPEND, async () => ({ total: 0, models: [] }))}
214
+ />)
215
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
216
+ expect(await screen.findByText(`今日共花费:${zh['stat.none']}`)).toBeDefined()
217
+ })
218
+
219
+ it('shows a placeholder for today\'s spend when it fails to load', async () => {
220
+ const getTodaySpend = vi.fn(async () => { throw new Error('boom') })
221
+ render(<BalanceBadge {...props(async () => balance(), async () => SPEND, getTodaySpend)} />)
222
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
223
+ // The balance and session spend resolved; today's spend has no value yet.
224
+ expect(await screen.findByText('本会话花费:¥0.04')).toBeDefined()
225
+ expect(screen.getByText('今日共花费:—')).toBeDefined()
226
+ })
227
+
228
+ it('shows the session spend as soon as it settles, without waiting for today\'s spend', async () => {
229
+ const getTodaySpend = vi.fn(() => new Promise<DeepSeekTodaySpend>(() => {}))
230
+ render(<BalanceBadge {...props(async () => balance(), async () => SPEND, getTodaySpend)} />)
231
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
232
+ // The session spend landed; today's spend never settles, so its line
233
+ // keeps the placeholder instead of blanking the other line.
234
+ expect(await screen.findByText('本会话花费:¥0.04')).toBeDefined()
235
+ expect(screen.getByText('今日共花费:—')).toBeDefined()
236
+ })
237
+
238
+ it('shows today\'s spend as soon as it settles, without waiting for the session spend', async () => {
239
+ const getSessionSpend = vi.fn(() => new Promise<DeepSeekSessionSpend>(() => {}))
240
+ render(<BalanceBadge {...props(async () => balance(), getSessionSpend, async () => TODAY_SPEND)} />)
241
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
242
+ expect(await screen.findByText('今日共花费:¥0.31')).toBeDefined()
243
+ expect(screen.getByText('本会话花费:—')).toBeDefined()
244
+ })
245
+
246
+ it('trims trailing zeros in the spend amount', async () => {
247
+ const trimmed: DeepSeekSessionSpend = {
248
+ total: 0.3,
249
+ models: [{
250
+ model: 'deepseek-v4-flash',
251
+ displayName: 'DeepSeek-V4-Flash',
252
+ cost: 0.3,
253
+ peakCost: 0,
254
+ offPeakCost: 0.3,
255
+ cacheHitInputTokens: 0,
256
+ cacheMissInputTokens: 100000,
257
+ outputTokens: 20000,
258
+ cacheHitInputCost: 0,
259
+ cacheMissInputCost: 0.2,
260
+ outputCost: 0.1,
261
+ }],
262
+ }
263
+ render(<BalanceBadge {...props(async () => balance(), async () => trimmed)} />)
264
+ fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek 额度:¥110.00' }))
265
+ expect(await screen.findByText('本会话花费:¥0.3')).toBeDefined()
266
+ })
267
+ })
@@ -0,0 +1,205 @@
1
+ /**
2
+ * ui-billing plugin halves: the browser entry's dictionary and header-slot
3
+ * registrations against the real SlotRegistry (with fiber teardown proving
4
+ * removal — HMR safety), the inert node entry, and the invariant companion's
5
+ * ownership reservation.
6
+ */
7
+ import { Context, Service } from '@deepseek-ai/cordis'
8
+ import { describe, expect, it, vi } from 'vitest'
9
+ import InvariantRegistry from '@deepseek-ai/dsh-invariants'
10
+ import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
11
+ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
13
+ import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
14
+ import { apply, inject } from '../src/client/index.ts'
15
+ import { BalanceBadge } from '../src/client/BalanceBadge.tsx'
16
+ import type { BalanceBadgeInjected } from '../src/client/BalanceBadge.tsx'
17
+ import { apply as applyNode } from '../src/index.ts'
18
+ import * as BillingInvariant from '../src/invariant.ts'
19
+ import { en, NS, zh } from '../src/client/locales.ts'
20
+
21
+ const BALANCE = {
22
+ isAvailable: true,
23
+ lines: [{ currency: 'CNY', total: '110.00', granted: '10.00', toppedUp: '100.00' }],
24
+ }
25
+
26
+ const SPEND = {
27
+ total: 0.31,
28
+ models: [{
29
+ model: 'deepseek-v4-flash',
30
+ displayName: 'DeepSeek-V4-Flash',
31
+ cost: 0.31,
32
+ peakCost: 0.31,
33
+ offPeakCost: 0,
34
+ cacheHitInputTokens: 1000,
35
+ cacheMissInputTokens: 100000,
36
+ outputTokens: 20000,
37
+ cacheHitInputCost: 0.01,
38
+ cacheMissInputCost: 0.20,
39
+ outputCost: 0.10,
40
+ }],
41
+ }
42
+
43
+ const TODAY_SPEND = {
44
+ total: 0.62,
45
+ models: [{
46
+ model: 'deepseek-v4-flash',
47
+ displayName: 'DeepSeek-V4-Flash',
48
+ cost: 0.62,
49
+ peakCost: 0.31,
50
+ offPeakCost: 0.31,
51
+ cacheHitInputTokens: 2000,
52
+ cacheMissInputTokens: 200000,
53
+ outputTokens: 40000,
54
+ cacheHitInputCost: 0.02,
55
+ cacheMissInputCost: 0.40,
56
+ outputCost: 0.20,
57
+ }],
58
+ }
59
+
60
+ type BalanceResult =
61
+ | { readonly ok: true; readonly value: typeof BALANCE }
62
+ | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
63
+
64
+ type SpendResult =
65
+ | { readonly ok: true; readonly value: typeof SPEND }
66
+ | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
67
+
68
+ type TodaySpendResult =
69
+ | { readonly ok: true; readonly value: typeof TODAY_SPEND }
70
+ | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
71
+
72
+ /** Slot ledger reader: entry ids currently registered in the header utilities list. */
73
+ function headerEntryIds(ctx: Context): (string | undefined)[] {
74
+ return ctx.slots
75
+ .entries('conversation.session.header.utilities')
76
+ .map(entry => entry.options.id)
77
+ }
78
+
79
+ /** Boot the browser half over a real slot tree that declares the header list. */
80
+ async function bench(): Promise<{
81
+ ctx: Context
82
+ fiber: ReturnType<Context['plugin']>
83
+ getBalance: ReturnType<typeof vi.fn>
84
+ getSessionSpend: ReturnType<typeof vi.fn>
85
+ getTodaySpend: ReturnType<typeof vi.fn>
86
+ }> {
87
+ const ctx = new Context()
88
+ await ctx.plugin(SlotRegistry).await()
89
+ ctx.slots.register({
90
+ name: 'root',
91
+ children: {
92
+ 'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
93
+ },
94
+ } as never, () => null)
95
+ ctx.provide('sessions', {})
96
+ // The locale plugin binds a settings scope, which reads the connection handle
97
+ // and the forwarded-event port.
98
+ ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
99
+ ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
100
+ // The `remote` Service gives the associate mechanism a target; the generated
101
+ // namespace lives as the sibling `remote.billing` service.
102
+ class RemoteService extends Service {
103
+ constructor(serviceCtx: Context) {
104
+ super(serviceCtx, 'remote')
105
+ }
106
+ $mount = vi.fn().mockResolvedValue(async () => {})
107
+ }
108
+ new RemoteService(ctx)
109
+ const getBalance = vi.fn<() => Promise<BalanceResult>>()
110
+ .mockResolvedValue({ ok: true, value: BALANCE })
111
+ const getSessionSpend = vi.fn<(sessionId: SessionId) => Promise<SpendResult>>()
112
+ .mockResolvedValue({ ok: true, value: SPEND })
113
+ const getTodaySpend = vi.fn<() => Promise<TodaySpendResult>>()
114
+ .mockResolvedValue({ ok: true, value: TODAY_SPEND })
115
+ ctx.provide('remote.billing', { getBalance, getSessionSpend, getTodaySpend })
116
+ await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
117
+ const fiber = ctx.plugin({ inject: [...inject], apply })
118
+ await fiber.await()
119
+ return { ctx, fiber, getBalance, getSessionSpend, getTodaySpend }
120
+ }
121
+
122
+ describe('ui-billing browser half', () => {
123
+ it('declares the services it binds', () => {
124
+ expect(inject).toEqual(['slots', 'locale', 'remote'])
125
+ })
126
+
127
+ it('registers the header utility, and fiber teardown removes it (HMR safety)', async () => {
128
+ const { ctx, fiber } = await bench()
129
+ expect(headerEntryIds(ctx)).toContain('billing-balance')
130
+ const entry = ctx.slots.entries('conversation.session.header.utilities')[0]!
131
+ expect(entry.component).toBe(BalanceBadge)
132
+ expect(entry.locale).toBe(NS)
133
+ await fiber.dispose()
134
+ expect(headerEntryIds(ctx)).not.toContain('billing-balance')
135
+ })
136
+
137
+ it('injects a getBalance face that unwraps the Remote result and reports failures', async () => {
138
+ const { ctx, getBalance } = await bench()
139
+ const entry = ctx.slots.entries('conversation.session.header.utilities')[0]!
140
+ const injected = (entry.inject as unknown as () => BalanceBadgeInjected)()
141
+ await expect(injected.getBalance()).resolves.toEqual(BALANCE)
142
+ expect(getBalance).toHaveBeenCalledOnce()
143
+ getBalance.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'no key' } })
144
+ await expect(injected.getBalance()).rejects.toThrow('billing.getBalance failed: internal: no key')
145
+ await ctx.fiber.dispose()
146
+ })
147
+
148
+ it('injects a getSessionSpend face that forwards the session id, unwraps, and reports failures', async () => {
149
+ const { ctx, getSessionSpend } = await bench()
150
+ const entry = ctx.slots.entries('conversation.session.header.utilities')[0]!
151
+ const injected = (entry.inject as unknown as () => BalanceBadgeInjected)()
152
+ await expect(injected.getSessionSpend('session-1' as SessionId)).resolves.toEqual(SPEND)
153
+ expect(getSessionSpend).toHaveBeenCalledWith('session-1')
154
+ getSessionSpend.mockResolvedValueOnce({ ok: false, error: { code: 'not_found', message: 'unknown session' } })
155
+ await expect(injected.getSessionSpend('session-2' as SessionId))
156
+ .rejects.toThrow('billing.getSessionSpend failed: not_found: unknown session')
157
+ await ctx.fiber.dispose()
158
+ })
159
+
160
+ it('injects a getTodaySpend face that unwraps the Remote result and reports failures', async () => {
161
+ const { ctx, getTodaySpend } = await bench()
162
+ const entry = ctx.slots.entries('conversation.session.header.utilities')[0]!
163
+ const injected = (entry.inject as unknown as () => BalanceBadgeInjected)()
164
+ await expect(injected.getTodaySpend()).resolves.toEqual(TODAY_SPEND)
165
+ expect(getTodaySpend).toHaveBeenCalledOnce()
166
+ getTodaySpend.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'no key' } })
167
+ await expect(injected.getTodaySpend()).rejects.toThrow('billing.getTodaySpend failed: internal: no key')
168
+ await ctx.fiber.dispose()
169
+ })
170
+
171
+ it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
172
+ const { ctx, fiber } = await bench()
173
+ const translate = ctx.locale.bind(NS)
174
+ ctx.locale.setLocale('zh')
175
+ expect(translate('state.unavailable')).toBe(zh['state.unavailable'])
176
+ ctx.locale.setLocale('en')
177
+ expect(translate('state.unavailable')).toBe(en['state.unavailable'])
178
+ await fiber.dispose()
179
+ expect(translate('state.unavailable')).not.toBe(en['state.unavailable'])
180
+ })
181
+
182
+ it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
183
+ expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
184
+ })
185
+ })
186
+
187
+ describe('ui-billing node half', () => {
188
+ it('contributes no host behavior', () => {
189
+ // The node half exists only so the plugin appears in the Loader tree.
190
+ expect(applyNode).not.toThrow()
191
+ })
192
+ })
193
+
194
+ describe('ui-billing invariant companion', () => {
195
+ it('reserves package ownership under its declared companion name', async () => {
196
+ const ctx = new Context()
197
+ await ctx.plugin(InvariantRegistry, { enabled: true })
198
+ const fiber = ctx.plugin(BillingInvariant)
199
+ await fiber.await()
200
+ expect(BillingInvariant.name).toBe('client-ui-billing-invariant')
201
+ expect(BillingInvariant.inject).toEqual(['invariants'])
202
+ expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow()
203
+ await fiber.dispose()
204
+ })
205
+ })
@@ -0,0 +1,36 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.client.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib/types"
6
+ },
7
+ "include": [
8
+ "src"
9
+ ],
10
+ "references": [
11
+ {
12
+ "path": "../../../vendor/cordis"
13
+ },
14
+ {
15
+ "path": "../../llm/llm-billing"
16
+ },
17
+ {
18
+ "path": "../locale"
19
+ },
20
+ {
21
+ "path": "../runtime"
22
+ },
23
+ {
24
+ "path": "../ui-conversation"
25
+ },
26
+ {
27
+ "path": "../ui-primitives"
28
+ },
29
+ {
30
+ "path": "../ui-slots"
31
+ },
32
+ {
33
+ "path": "../../runtime-diagnostics/invariants"
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,3 @@
1
+ import { clientBundle } from '../tsdown.client.ts'
2
+
3
+ export default clientBundle('@rayadesu/dsh-client-ui-billing', ['lib/types/index.js', 'lib/types/invariant.js'])