dsh-connect-minimaxcode 0.3.0 → 0.4.1

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.md CHANGED
@@ -184,6 +184,30 @@ Content-Type: application/json
184
184
  | `token_plan_credit.remaining_credits` | 已购积分池 | ❌ |
185
185
  | `query_balance.available_amount` | 开放平台钱包(元) | ❌ |
186
186
 
187
+ ### 积分会过期吗?会
188
+
189
+ 积分不是一池子,而是**按笔发放的批次**,每批有自己的到期日。客户端每天发一批,每批在**发放日 + 30 个自然日**到期(按账户所在时区,当日 00:00 失效),于是形成一条逐日错开的 30 天阶梯:
190
+
191
+ ```
192
+ GET https://agent.minimax.cn/minimax-cloud/api/v1/credit/details?page_number=1&page_size=100
193
+ → details[] { granted_amount, remaining_amount, consumed_amount, granted_at_ms, expire_at_ms, credit_type }
194
+ ```
195
+
196
+ 实测本机 23 笔:09-20 发的在 10-20 到期、09-21 发的在 10-21 到期……另有一笔 08-25 发的在 2027-08-25 到期(一年期批次,`credit_type` 1=购买 / 2=签到)。**到期日一律读 `expire_at_ms`**,不写死天数——至少存在 30 天与 365 天两种制式。
197
+
198
+ 插件据此在**插件页面**(设置 → 插件 → DSH Connect MiniMax Code)与输入框下方的胶囊里给出:
199
+
200
+ - **7 天内到期**的剩余积分,以及最近一笔的到期日
201
+ - **30 天内到期**的剩余积分
202
+ - 接下来几笔**到期批次**的清单(日期 + 金额)
203
+ - 余额条与本机消耗(token 数、等效金额)
204
+
205
+ 只统计**仍有余额**的批次:已发放但用光的批次到期时并没有东西可损失,把它算进去会让每个账号都看起来"快过期了"。积分的消耗近似先进先出,所以**"7 天内到期"的金额常常是 0**(快到期的那些基本都已被用完),因此界面同时给出**最早到期时间**,那才是真正有意义的信号。
206
+
207
+ > 过期不会把批次里的 `remaining_amount` 清零(实测会留下 -0.31 这样的负值残留),而是把过期批次**排除在余额之外**;所以总额以余额接口为准。
208
+ >
209
+ > 台账的 `remaining_amount` 合计与余额接口的 `total_remaining_amount` 在**静止期逐位相等**;两次采样之间看到的差异,是账户在这段时间里被真实消耗掉的量。
210
+
187
211
  ### 消耗怎么算
188
212
 
189
213
  两路数据各管一件事:
@@ -199,6 +223,8 @@ Content-Type: application/json
199
223
 
200
224
  折算出的金额是**等效金额**,不是扣费:积分与金额之间 MiniMax 没有公开换算关系(套餐只公布了 1×/3×/12× 的相对倍数),所以插件不会把它说成「花了多少积分」。
201
225
 
226
+ > 官方价目表没有单列**缓存写入**(`cache_creation_input_tokens`)的单价,因此插件只统计它的 token 数、**不折算金额**,以免给出一个没有公开依据的数字。
227
+
202
228
  ### 关于续期
203
229
 
204
230
  OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自己的令牌已过期时**才用 App 已存的 refresh token 续一次,并把轮换后的新令牌按客户端**原格式原子写回**同一个文件(`generation` 保持不变),因此桌面 App 的登录态不受影响。插件不会发起登录、不会登出、也不会调用任何付费/订阅类接口。
@@ -219,6 +245,13 @@ OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自
219
245
 
220
246
  ## 更新记录
221
247
 
248
+ ### 0.4.0(2026-10-19)
249
+
250
+ - **新增「积分即将过期」**:积分是按笔发放、每批独立到期的(发放后约 29 天)。插件读取积分台账 `GET /minimax-cloud/api/v1/credit/details`,在**插件页面**与胶囊里显示 7 天内到期、30 天内到期、最近一笔到期日与到期批次清单。
251
+ - **只统计仍有余额的批次**,避免把「已用光但快到期」的批次算成损失。
252
+ - **插件页面卡片**:在「设置 → 插件 → DSH Connect MiniMax Code」新增一张卡片(余额、赠送/购买拆分、到期分布、到期批次、本机消耗与等效金额),列表里则显示一行摘要。
253
+ - 胶囊与卡片共用一个状态存储与同一个轮询器,多个界面不会重复请求账户接口。
254
+
222
255
  ### 0.3.0(2026-10-19)
223
256
 
224
257
  - **新增积分余额显示**:对话框输入框下方的胶囊显示 MiniMax Code 的剩余积分(赠送/购买拆分),点开可看两次读取之间的消耗、本机累计 token 与按官方价目表折算的等效金额。
package/lib/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * The browser half: a credit pill in the composer's dock.
3
+ * The browser half: the credit balance, in the composer and on the page.
4
4
  *
5
5
  * The host loads a client bundle as a classic script and calls
6
6
  * `window.__ModuleLoader__.load`, handing the factory a `require` that
@@ -8,10 +8,10 @@
8
8
  * the host — this file installs no React of its own, and ships no bundler
9
9
  * output: it is a script, so it can be dropped into the page as-is.
10
10
  *
11
- * The pill shows the account's remaining credits and what this host's
12
- * conversations have used, both read from the plugin's own status route. It
13
- * degrades quietly: a failed read keeps the last value rather than blanking
14
- * the dock.
11
+ * Two surfaces read the same data: a pill in the composer's dock, and a card
12
+ * on the plugin's own page. Both read it through one store with one ticker,
13
+ * so the account API is asked once per interval no matter how many surfaces
14
+ * are on screen.
15
15
  */
16
16
  ;
17
17
  (function bootstrapMinimaxCodeClient() {
@@ -32,7 +32,13 @@
32
32
  unavailable: '积分不可用',
33
33
  expired: '登录态已过期',
34
34
  signedOut: '未登录 MiniMax Code',
35
- close: '收起',
35
+ expiring: '即将过期',
36
+ expiringIn: '{amount} 积分 · {date} 到期',
37
+ expiringNone: '7 天内没有积分到期',
38
+ expiringSoon: '30 天内到期 {amount} 积分',
39
+ nextExpiry: '最近一笔到期',
40
+ lots: '到期批次',
41
+ noLots: '没有待到期的积分批次',
36
42
  };
37
43
  const en = {
38
44
  title: 'MiniMax Code credits',
@@ -47,7 +53,13 @@
47
53
  unavailable: 'Credits unavailable',
48
54
  expired: 'Sign-in expired',
49
55
  signedOut: 'MiniMax Code not signed in',
50
- close: 'Hide',
56
+ expiring: 'Expiring soon',
57
+ expiringIn: '{amount} credits · expires {date}',
58
+ expiringNone: 'No credits expire within 7 days',
59
+ expiringSoon: '{amount} credits expire within 30 days',
60
+ nextExpiry: 'Next expiry',
61
+ lots: 'Expiring grants',
62
+ noLots: 'No grant is waiting to expire',
51
63
  };
52
64
  const loader = globalThis.__ModuleLoader__;
53
65
  if (loader === undefined)
@@ -83,38 +95,81 @@
83
95
  return value.toFixed(4);
84
96
  return value.toFixed(2);
85
97
  }
86
- /** The pill, plus the detail panel it opens. */
87
- function CreditPill(props) {
98
+ /** Format a date as a short day. */
99
+ function day(value) {
100
+ if (value === undefined)
101
+ return '—';
102
+ const date = new Date(value);
103
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
104
+ }
105
+ /**
106
+ * The shared status store.
107
+ *
108
+ * One ticker serves every surface, so a second surface costs no extra
109
+ * request. The store starts when the first surface subscribes and stops
110
+ * when the last one leaves.
111
+ */
112
+ const listeners = new Set();
113
+ let snapshot;
114
+ let timer;
115
+ async function load() {
116
+ try {
117
+ const response = await fetch(STATUS_PATH, { headers: { accept: 'application/json' } });
118
+ if (!response.ok)
119
+ return;
120
+ const document = await response.json();
121
+ snapshot = document;
122
+ for (const listener of listeners)
123
+ listener(snapshot);
124
+ }
125
+ catch {
126
+ // Keep the last value: a failed poll is not worth a blank pill.
127
+ }
128
+ }
129
+ function subscribe(listener) {
130
+ listeners.add(listener);
131
+ if (listeners.size === 1) {
132
+ void load();
133
+ timer = setInterval(() => { void load(); }, POLL_INTERVAL_MS);
134
+ }
135
+ return () => {
136
+ listeners.delete(listener);
137
+ if (listeners.size === 0 && timer !== undefined) {
138
+ clearInterval(timer);
139
+ timer = undefined;
140
+ }
141
+ };
142
+ }
143
+ /** Read the shared status into a component's state. */
144
+ function useStatus() {
88
145
  const [status, setStatus] = React.useState(undefined);
146
+ React.useEffect(() => subscribe(setStatus), []);
147
+ return status;
148
+ }
149
+ /** A row of label and value. */
150
+ function row(label, value) {
151
+ return h('div', { style: rowStyle }, h('span', null, label), h('span', null, value));
152
+ }
153
+ /** The expiry block, shared by the pill's detail and the page card. */
154
+ function expiryBlock(t, credits_) {
155
+ if (credits_ === undefined)
156
+ return null;
157
+ const expiring = credits_.expiring ?? 0;
158
+ const next = credits_.nextExpiryAtMs;
159
+ return h('div', { style: expiryBoxStyle, 'data-minimax-credits-expiry': true }, h('div', { style: rowStyle }, h('span', null, t('expiring')), h('span', { style: expiring > 0 ? warnStyle : undefined }, expiring > 0
160
+ ? t('expiringIn', { amount: credits(expiring, 2), date: day(next) })
161
+ : t('expiringNone'))), credits_.expiringSoon !== undefined && credits_.expiringSoon > 0
162
+ ? row(t('expiringSoon', { amount: credits(credits_.expiringSoon, 2) }), '')
163
+ : null, next !== undefined ? row(t('nextExpiry'), day(next)) : null);
164
+ }
165
+ /** The pill in the composer's dock. */
166
+ function CreditPill(props) {
167
+ const status = useStatus();
89
168
  const [open, setOpen] = React.useState(false);
90
- // `t` comes from the host's locale service when the slot declares the
91
- // namespace; the built-in copy is the fallback for a host that does not.
92
169
  const t = props.t ?? ((key, params) => {
93
170
  const template = zh[key] ?? key;
94
171
  return template.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ''));
95
172
  });
96
- React.useEffect(() => {
97
- let cancelled = false;
98
- const load = async () => {
99
- try {
100
- const response = await fetch(STATUS_PATH, { headers: { accept: 'application/json' } });
101
- if (!response.ok)
102
- return;
103
- const document = await response.json();
104
- if (!cancelled)
105
- setStatus(document);
106
- }
107
- catch {
108
- // Keep the last value: a failed poll is not worth a blank pill.
109
- }
110
- };
111
- void load();
112
- const timer = setInterval(() => { void load(); }, POLL_INTERVAL_MS);
113
- return () => {
114
- cancelled = true;
115
- clearInterval(timer);
116
- };
117
- }, []);
118
173
  const credits_ = status?.credits;
119
174
  const usage = status?.usage;
120
175
  const available = credits_?.state === 'ok' && credits_.total !== undefined;
@@ -122,14 +177,12 @@
122
177
  ? t('balance', { total: credits(credits_.total, 2) })
123
178
  : t(credits_?.state === 'expired' ? 'expired' : credits_?.state === 'signed-out' ? 'signedOut' : 'unavailable');
124
179
  const detail = available
125
- ? h('div', { style: detailStyle, 'data-minimax-credits-detail': true }, h('div', { style: detailRowStyle }, h('span', null, t('free')), h('span', null, credits(credits_.free, 3))), h('div', { style: detailRowStyle }, h('span', null, t('purchased')), h('span', null, credits(credits_.purchased, 3))), credits_.consumed !== undefined && credits_.consumed > 0
126
- ? h('div', { style: detailRowStyle }, h('span', null, t('consumed')), h('span', null, credits(credits_.consumed, 3)))
127
- : null, usage !== undefined
128
- ? h('div', { style: detailRowStyle }, h('span', null, t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) })), h('span', null, t('cost', { amount: money(usage.estimatedCny) })))
129
- : null, usage !== undefined && usage.requests > 0
130
- ? h('div', { style: detailRowStyle }, h('span', null, t('requests', { count: usage.requests })), h('span', null, usage.lastModel ?? ''))
180
+ ? h('div', { style: detailStyle, 'data-minimax-credits-detail': true }, row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), credits_.consumed !== undefined && credits_.consumed > 0
181
+ ? row(t('consumed'), credits(credits_.consumed, 3))
182
+ : null, expiryBlock(t, credits_), usage !== undefined
183
+ ? h('div', { style: rowStyle }, h('span', null, t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) })), h('span', null, t('cost', { amount: money(usage.estimatedCny) })))
131
184
  : null, credits_.updatedAtMs !== undefined
132
- ? h('div', { style: detailRowStyle }, h('span', null, t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() })), h('span', null, ''))
185
+ ? row(t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }), '')
133
186
  : null)
134
187
  : null;
135
188
  return h('div', { style: wrapStyle, 'data-minimax-credits': true }, h('button', {
@@ -139,6 +192,39 @@
139
192
  onClick: () => setOpen(!open),
140
193
  }, label), open ? detail : null);
141
194
  }
195
+ /**
196
+ * The card on the plugin's own page.
197
+ *
198
+ * The summary view is the one line the plugin list shows; the page view
199
+ * is the full card, where the expiry detail belongs.
200
+ */
201
+ function PluginPage(props) {
202
+ const status = useStatus();
203
+ const t = props.t ?? ((key, params) => {
204
+ const template = zh[key] ?? key;
205
+ return template.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ''));
206
+ });
207
+ const credits_ = status?.credits;
208
+ const usage = status?.usage;
209
+ const available = credits_?.state === 'ok' && credits_.total !== undefined;
210
+ if (props.view === 'summary') {
211
+ if (!available)
212
+ return h('span', null, t('unavailable'));
213
+ return h('span', { 'data-minimax-credits-summary': true }, t('balance', { total: credits(credits_.total, 2) }), credits_.expiring !== undefined && credits_.expiring > 0
214
+ ? ` · ${t('expiringIn', { amount: credits(credits_.expiring, 2), date: day(credits_.nextExpiryAtMs) })}`
215
+ : '');
216
+ }
217
+ if (!available) {
218
+ return h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: rowStyle }, h('span', null, t('unavailable')), h('span', null, credits_?.state === 'expired' ? t('expired') : '')));
219
+ }
220
+ return h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: bigNumberStyle }, t('balance', { total: credits(credits_.total, 2) })), row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), expiryBlock(t, credits_), h('div', { style: sectionTitleStyle }, t('lots')), (credits_.upcoming ?? []).length === 0
221
+ ? h('div', { style: mutedStyle }, t('noLots'))
222
+ : h('ul', { style: listStyle }, ...(credits_.upcoming ?? []).map((lot, index) => h('li', { key: `${lot.expireAtMs}-${index}`, style: rowStyle }, h('span', null, day(lot.expireAtMs)), h('span', null, credits(lot.amount, 2))))), usage !== undefined
223
+ ? h('div', { style: usageBoxStyle }, row(t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) }), ''), row(t('cost', { amount: money(usage.estimatedCny) }), ''), usage.requests > 0 ? row(t('requests', { count: usage.requests }), usage.lastModel ?? '') : null)
224
+ : null, credits_.updatedAtMs !== undefined
225
+ ? h('div', { style: mutedStyle }, t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }))
226
+ : null);
227
+ }
142
228
  const wrapStyle = {
143
229
  position: 'relative',
144
230
  display: 'inline-flex',
@@ -162,7 +248,7 @@
162
248
  bottom: 'calc(100% + 6px)',
163
249
  left: 0,
164
250
  zIndex: 10,
165
- minWidth: '220px',
251
+ minWidth: '240px',
166
252
  borderRadius: '8px',
167
253
  background: 'var(--dsh-panel-background, Canvas)',
168
254
  color: 'var(--dsh-panel-foreground, CanvasText)',
@@ -173,11 +259,36 @@
173
259
  lineHeight: 1.6,
174
260
  textAlign: 'left',
175
261
  };
176
- const detailRowStyle = {
262
+ const rowStyle = {
177
263
  display: 'flex',
178
264
  justifyContent: 'space-between',
179
265
  gap: '16px',
180
266
  };
267
+ const expiryBoxStyle = {
268
+ marginTop: '6px',
269
+ paddingTop: '6px',
270
+ borderTop: '1px solid var(--dsh-panel-border, currentColor)',
271
+ opacity: 0.95,
272
+ };
273
+ const warnStyle = { color: 'var(--dsh-warning, #b45309)' };
274
+ const cardStyle = {
275
+ display: 'flex',
276
+ flexDirection: 'column',
277
+ gap: '6px',
278
+ fontSize: '13px',
279
+ lineHeight: 1.6,
280
+ };
281
+ const cardTitleStyle = { fontWeight: 600 };
282
+ const bigNumberStyle = { fontSize: '20px', fontWeight: 600, marginBottom: '2px' };
283
+ const sectionTitleStyle = { marginTop: '8px', fontWeight: 600 };
284
+ const mutedStyle = { opacity: 0.7, fontSize: '12px' };
285
+ const listStyle = { listStyle: 'none', margin: 0, padding: 0 };
286
+ const usageBoxStyle = {
287
+ marginTop: '8px',
288
+ paddingTop: '6px',
289
+ borderTop: '1px solid var(--dsh-panel-border, currentColor)',
290
+ opacity: 0.9,
291
+ };
181
292
  return {
182
293
  inject: ['slots', 'locale'],
183
294
  apply(ctx) {
@@ -192,6 +303,13 @@
192
303
  order: 2,
193
304
  locale: NAMESPACE,
194
305
  }, CreditPill));
306
+ // The plugin's own page, keyed by the package name, where the
307
+ // balance and the expiry schedule are spelled out.
308
+ context.slots.inject('plugins.bundle.config', () => context.slots.register({
309
+ name: 'plugins.bundle.config',
310
+ key: BUNDLE_NAME,
311
+ locale: NAMESPACE,
312
+ }, PluginPage));
195
313
  },
196
314
  };
197
315
  },
package/lib/index.d.ts CHANGED
@@ -308,6 +308,46 @@ interface CreditSnapshot {
308
308
  */
309
309
  consumed: number;
310
310
  }
311
+ /**
312
+ * One grant of credits.
313
+ *
314
+ * Credits are granted in lots, and each lot carries its own expiry: the
315
+ * desktop app grants credits daily, and a lot falls due thirty calendar days
316
+ * after the day it was granted, at midnight in the account's own timezone.
317
+ * Credits in a lot are lost when that moment arrives.
318
+ */
319
+ interface CreditLot {
320
+ /** How much this lot was granted. */
321
+ granted: number;
322
+ /** How much of it is left. */
323
+ remaining: number;
324
+ /** How much of it has been used. */
325
+ consumed: number;
326
+ /** When the lot was granted, in milliseconds since the epoch. */
327
+ grantedAtMs: number;
328
+ /** When the lot expires, in milliseconds since the epoch. */
329
+ expireAtMs: number;
330
+ /** The upstream's kind of lot, when it says. */
331
+ creditType?: number;
332
+ }
333
+ /** What the lots say about credits that are about to be lost. */
334
+ interface ExpirySummary {
335
+ /** Credits in lots that expire within {@link EXPIRING_WINDOW_DAYS} days. */
336
+ expiring: number;
337
+ /** How many lots that is. */
338
+ expiringLots: number;
339
+ /** Credits in lots that expire within {@link EXPIRING_HORIZON_DAYS} days. */
340
+ expiringSoon: number;
341
+ /** When the next lot with credits left expires. */
342
+ nextExpiryAtMs?: number;
343
+ /** The next lots with credits left, soonest first. */
344
+ upcoming: {
345
+ amount: number;
346
+ expireAtMs: number;
347
+ }[];
348
+ /** Credits left across every lot. */
349
+ totalRemaining: number;
350
+ }
311
351
  /**
312
352
  * Keeps the current balance and the change between reads.
313
353
  *
@@ -320,12 +360,32 @@ declare class MinimaxCredits {
320
360
  get snapshot(): CreditSnapshot | undefined;
321
361
  /** Why the last read failed, when one did. */
322
362
  get error(): string | undefined;
363
+ /** The credit lots as last read. */
364
+ get lots(): readonly CreditLot[];
365
+ /** What the lots say about expiry, when the ledger could be read. */
366
+ get expiry(): ExpirySummary | undefined;
367
+ /** When the ledger was last read. */
368
+ get ledgerAtMs(): number | undefined;
369
+ /** Why the ledger could not be read, when it could not. */
370
+ get ledgerError(): string | undefined;
323
371
  /**
324
372
  * Read the balance and record how much was consumed since the last read.
325
373
  *
374
+ * The credit ledger is read alongside it, but a ledger that cannot be read
375
+ * never hides the balance: the two come from different endpoints and only
376
+ * the balance is essential.
377
+ *
326
378
  * @returns Whether the balance changed.
327
379
  */
328
380
  refresh(credential: OAuthCredential, region: MinimaxRegion, fetchImpl?: typeof fetch, now?: number): Promise<boolean>;
381
+ /**
382
+ * Read the credit ledger and work out what is about to expire.
383
+ *
384
+ * A failure here is remembered but never thrown: the balance is what the
385
+ * user needs most, and a ledger that is temporarily unavailable must not
386
+ * take it down with it.
387
+ */
388
+ refreshLedger(credential: OAuthCredential, region: MinimaxRegion, fetchImpl?: typeof fetch, now?: number): Promise<void>;
329
389
  }
330
390
  //#endregion
331
391
  //#region src/usage.d.ts
@@ -434,6 +494,23 @@ interface CreditStatus {
434
494
  updatedAtMs?: number;
435
495
  /** Why the balance could not be read. */
436
496
  error?: string;
497
+ /** Credits that expire within the next week. */
498
+ expiring?: number;
499
+ /** How many grants that is. */
500
+ expiringLots?: number;
501
+ /** Credits that expire within the next month. */
502
+ expiringSoon?: number;
503
+ /** When the next grant with credits left expires. */
504
+ nextExpiryAtMs?: number;
505
+ /** The next grants with credits left, soonest first. */
506
+ upcoming?: {
507
+ amount: number;
508
+ expireAtMs: number;
509
+ }[];
510
+ /** When the ledger was read, in milliseconds since the epoch. */
511
+ ledgerAtMs?: number;
512
+ /** Why the ledger could not be read. */
513
+ ledgerError?: string;
437
514
  }
438
515
  /** What the plugin's own requests have used. */
439
516
  interface UsageStatus {
package/lib/index.js CHANGED
@@ -716,6 +716,8 @@ const MINIMAX_ACCOUNT_HOSTS = {
716
716
  };
717
717
  /** Membership endpoint: carries the credit summary. */
718
718
  const MEMBERSHIP_PATH = "/matrix/api/v1/commerce/get_membership_info";
719
+ /** Credit ledger endpoint: per-grant amounts and expiry. */
720
+ const CREDIT_DETAILS_PATH = "/minimax-cloud/api/v1/credit/details";
719
721
  /** OAuth token endpoint. */
720
722
  const TOKEN_PATH = "/oauth2/token";
721
723
  /** Absolute path of the app's credential file for a region. */
@@ -961,6 +963,89 @@ async function fetchCreditSummary(credential, region, fetchImpl = fetch) {
961
963
  return parseCreditSummary(await response.json());
962
964
  }
963
965
  /**
966
+ * Read the credit lots out of a ledger response.
967
+ *
968
+ * Lots that carry no expiry are ignored: a lot without an expiry date would
969
+ * otherwise be treated as one that never expires.
970
+ */
971
+ function parseCreditLots(document) {
972
+ if (typeof document !== "object" || document === null) return [];
973
+ const details = document.details;
974
+ if (!Array.isArray(details)) return [];
975
+ const lots = [];
976
+ for (const entry of details) {
977
+ if (typeof entry !== "object" || entry === null) continue;
978
+ const record = entry;
979
+ const expireAtMs = toNumber(record.expire_at_ms);
980
+ if (expireAtMs === void 0) continue;
981
+ const granted = toNumber(record.granted_amount) ?? 0;
982
+ const remaining = toNumber(record.remaining_amount) ?? 0;
983
+ lots.push({
984
+ granted,
985
+ remaining,
986
+ consumed: toNumber(record.consumed_amount) ?? Math.max(0, granted - remaining),
987
+ grantedAtMs: toNumber(record.granted_at_ms) ?? 0,
988
+ expireAtMs,
989
+ creditType: toNumber(record.credit_type)
990
+ });
991
+ }
992
+ return lots.sort((a, b) => a.expireAtMs - b.expireAtMs);
993
+ }
994
+ /**
995
+ * Fetch the credit ledger.
996
+ *
997
+ * The ledger is paginated, and the page parameter is `page_number` — `page`
998
+ * is accepted and then ignored, which would make every page return the same
999
+ * rows. The page size is capped upstream at 100.
1000
+ *
1001
+ * The default view already excludes expired grants, which is what this wants:
1002
+ * a grant that expired has nothing left to lose.
1003
+ */
1004
+ async function fetchCreditLots(credential, region, fetchImpl = fetch) {
1005
+ const pageSize = 100;
1006
+ const lots = [];
1007
+ let page = 1;
1008
+ let total = Number.POSITIVE_INFINITY;
1009
+ while (lots.length < total && page <= 50) {
1010
+ const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${CREDIT_DETAILS_PATH}?page_number=${page}&page_size=${pageSize}`, { headers: { authorization: `Bearer ${credential.token}` } });
1011
+ if (!response.ok) throw new Error(`credit ledger request failed: HTTP ${response.status}`);
1012
+ const document = await response.json();
1013
+ const batch = parseCreditLots(document);
1014
+ const before = lots.length;
1015
+ lots.push(...batch);
1016
+ const count = toNumber(document.total_count);
1017
+ if (count !== void 0) total = count;
1018
+ if (batch.length < pageSize) break;
1019
+ if (lots.length === before) break;
1020
+ page += 1;
1021
+ }
1022
+ return lots;
1023
+ }
1024
+ /**
1025
+ * Summarize what is about to expire.
1026
+ *
1027
+ * Only lots that still hold credits count: a lot that was granted and used up
1028
+ * expires with nothing to lose, and counting it would make every account look
1029
+ * like it is about to lose credits.
1030
+ */
1031
+ function summarizeExpiry(lots, now = Date.now()) {
1032
+ const alive = lots.filter((lot) => lot.remaining > 0 && lot.expireAtMs > now);
1033
+ const within = (days) => alive.filter((lot) => lot.expireAtMs - now <= days * 864e5);
1034
+ const week = within(7);
1035
+ const month = within(30);
1036
+ return {
1037
+ expiring: week.reduce((sum, lot) => sum + lot.remaining, 0),
1038
+ expiringLots: week.length,
1039
+ expiringSoon: month.reduce((sum, lot) => sum + lot.remaining, 0),
1040
+ nextExpiryAtMs: alive[0]?.expireAtMs,
1041
+ upcoming: alive.slice(0, 5).map((lot) => ({
1042
+ amount: lot.remaining,
1043
+ expireAtMs: lot.expireAtMs
1044
+ })),
1045
+ totalRemaining: lots.reduce((sum, lot) => sum + lot.remaining, 0)
1046
+ };
1047
+ }
1048
+ /**
964
1049
  * Keeps the current balance and the change between reads.
965
1050
  *
966
1051
  * The first read has nothing to compare against, so it reports no movement
@@ -969,6 +1054,10 @@ async function fetchCreditSummary(credential, region, fetchImpl = fetch) {
969
1054
  var MinimaxCredits = class {
970
1055
  #snapshot;
971
1056
  #error;
1057
+ #lots = [];
1058
+ #expiry;
1059
+ #ledgerAtMs;
1060
+ #ledgerError;
972
1061
  /** The balance as last read. */
973
1062
  get snapshot() {
974
1063
  return this.#snapshot;
@@ -977,29 +1066,68 @@ var MinimaxCredits = class {
977
1066
  get error() {
978
1067
  return this.#error;
979
1068
  }
1069
+ /** The credit lots as last read. */
1070
+ get lots() {
1071
+ return this.#lots;
1072
+ }
1073
+ /** What the lots say about expiry, when the ledger could be read. */
1074
+ get expiry() {
1075
+ return this.#expiry;
1076
+ }
1077
+ /** When the ledger was last read. */
1078
+ get ledgerAtMs() {
1079
+ return this.#ledgerAtMs;
1080
+ }
1081
+ /** Why the ledger could not be read, when it could not. */
1082
+ get ledgerError() {
1083
+ return this.#ledgerError;
1084
+ }
980
1085
  /**
981
1086
  * Read the balance and record how much was consumed since the last read.
982
1087
  *
1088
+ * The credit ledger is read alongside it, but a ledger that cannot be read
1089
+ * never hides the balance: the two come from different endpoints and only
1090
+ * the balance is essential.
1091
+ *
983
1092
  * @returns Whether the balance changed.
984
1093
  */
985
1094
  async refresh(credential, region, fetchImpl = fetch, now = Date.now()) {
1095
+ let changed = false;
986
1096
  try {
987
1097
  const summary = await fetchCreditSummary(credential, region, fetchImpl);
988
- if (summary === void 0) {
989
- this.#error = "membership response carried no credit summary";
990
- return false;
1098
+ if (summary === void 0) this.#error = "membership response carried no credit summary";
1099
+ else {
1100
+ const previous = this.#snapshot;
1101
+ this.#snapshot = {
1102
+ summary,
1103
+ fetchedAtMs: now,
1104
+ consumed: previous === void 0 ? 0 : previous.summary.total - summary.total
1105
+ };
1106
+ this.#error = void 0;
1107
+ changed = previous === void 0 || previous.summary.total !== summary.total;
991
1108
  }
992
- const previous = this.#snapshot;
993
- this.#snapshot = {
994
- summary,
995
- fetchedAtMs: now,
996
- consumed: previous === void 0 ? 0 : previous.summary.total - summary.total
997
- };
998
- this.#error = void 0;
999
- return previous === void 0 || previous.summary.total !== summary.total;
1000
1109
  } catch (error) {
1001
1110
  this.#error = error instanceof Error ? error.message : String(error);
1002
- return false;
1111
+ }
1112
+ await this.refreshLedger(credential, region, fetchImpl, now);
1113
+ return changed;
1114
+ }
1115
+ /**
1116
+ * Read the credit ledger and work out what is about to expire.
1117
+ *
1118
+ * A failure here is remembered but never thrown: the balance is what the
1119
+ * user needs most, and a ledger that is temporarily unavailable must not
1120
+ * take it down with it.
1121
+ */
1122
+ async refreshLedger(credential, region, fetchImpl = fetch, now = Date.now()) {
1123
+ try {
1124
+ const lots = await fetchCreditLots(credential, region, fetchImpl);
1125
+ this.#lots = lots;
1126
+ this.#expiry = summarizeExpiry(lots, now);
1127
+ this.#ledgerAtMs = now;
1128
+ this.#ledgerError = void 0;
1129
+ } catch (error) {
1130
+ this.#ledgerError = error instanceof Error ? error.message : String(error);
1003
1131
  }
1004
1132
  }
1005
1133
  };
@@ -1206,6 +1334,7 @@ function apply(ctx) {
1206
1334
  /** The credit balance as the card and the browser half read it. */
1207
1335
  const buildCreditStatus = () => {
1208
1336
  const snapshot = credits.snapshot;
1337
+ const expiry = credits.expiry;
1209
1338
  if (snapshot === void 0) return {
1210
1339
  state: creditState,
1211
1340
  ...credits.error === void 0 ? {} : { error: credits.error }
@@ -1216,7 +1345,16 @@ function apply(ctx) {
1216
1345
  purchased: snapshot.summary.purchased,
1217
1346
  free: snapshot.summary.free,
1218
1347
  consumed: snapshot.consumed,
1219
- updatedAtMs: snapshot.fetchedAtMs
1348
+ updatedAtMs: snapshot.fetchedAtMs,
1349
+ ...expiry === void 0 ? {} : {
1350
+ expiring: expiry.expiring,
1351
+ expiringLots: expiry.expiringLots,
1352
+ expiringSoon: expiry.expiringSoon,
1353
+ nextExpiryAtMs: expiry.nextExpiryAtMs,
1354
+ upcoming: expiry.upcoming
1355
+ },
1356
+ ...credits.ledgerAtMs === void 0 ? {} : { ledgerAtMs: credits.ledgerAtMs },
1357
+ ...credits.ledgerError === void 0 ? {} : { ledgerError: credits.ledgerError }
1220
1358
  };
1221
1359
  };
1222
1360
  /** What this host's own requests have used. */
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-connect-minimaxcode",
3
3
  "displayName": "DSH Connect MiniMax Code",
4
- "description": "把本机已登录的 MiniMax Code 桌面 App 的模型接入 DeepSeek Harness:复用其登录态,提供 Anthropic Messages 兼容接入与动态模型目录。",
5
- "version": "0.3.0",
4
+ "description": "把本机已登录的 MiniMax Code 桌面 App 的模型接入 DeepSeek Harness:复用其登录态,提供 Anthropic Messages 兼容接入、动态模型目录与积分余额/到期提示。",
5
+ "version": "0.4.1",
6
6
  "author": "JeffTsuiCUG",
7
7
  "license": "MIT",
8
8
  "private": false,
@@ -84,6 +84,7 @@
84
84
  "inject": [
85
85
  "@deepseek-ai/dsh-client-ui-renderer",
86
86
  "@deepseek-ai/dsh-client-ui-conversation",
87
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
87
88
  "@deepseek-ai/dsh-client-locale"
88
89
  ]
89
90
  }