dsh-connect-minimaxcode 0.3.0 → 0.4.0

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,28 @@ Content-Type: application/json
184
184
  | `token_plan_credit.remaining_credits` | 已购积分池 | ❌ |
185
185
  | `query_balance.available_amount` | 开放平台钱包(元) | ❌ |
186
186
 
187
+ ### 积分会过期吗?会
188
+
189
+ 积分不是一池子,而是**按笔发放的批次**,每批有自己的到期日。客户端每天发一批,每批大约在**发放后 29 天**到期,于是形成一条逐日错开的 30 天阶梯:
190
+
191
+ ```
192
+ GET https://agent.minimax.cn/minimax-cloud/api/v1/credit/details?page=1&page_size=100
193
+ → details[] { granted_amount, remaining_amount, consumed_amount, granted_at_ms, expire_at_ms }
194
+ ```
195
+
196
+ 实测本机 23 笔:09-20 发的在 10-19 到期,09-21 发的在 10-20 到期……另有一笔 08-25 发的在 **2027-08-25** 到期(一年期批次)。
197
+
198
+ 插件据此在**插件页面**(设置 → 插件 → DSH Connect MiniMax Code)与输入框下方的胶囊里给出:
199
+
200
+ - **7 天内到期**的剩余积分,以及最近一笔的到期日
201
+ - **30 天内到期**的剩余积分
202
+ - 接下来几笔**到期批次**的清单(日期 + 金额)
203
+ - 余额条与本机消耗(token 数、等效金额)
204
+
205
+ 只统计**仍有余额**的批次:已发放但用光的批次到期时并没有东西可损失,把它算进去会让每个账号都看起来"快过期了"。
206
+
207
+ > 台账的 `remaining_amount` 合计与余额接口的 `total_remaining_amount` 会**略有出入**(实测差 0.006,浮点累加所致;两次采样之间余额本身也会变)。插件以**余额接口**显示总额,台账只用来回答"什么时候过期"。
208
+
187
209
  ### 消耗怎么算
188
210
 
189
211
  两路数据各管一件事:
@@ -199,6 +221,8 @@ Content-Type: application/json
199
221
 
200
222
  折算出的金额是**等效金额**,不是扣费:积分与金额之间 MiniMax 没有公开换算关系(套餐只公布了 1×/3×/12× 的相对倍数),所以插件不会把它说成「花了多少积分」。
201
223
 
224
+ > 官方价目表没有单列**缓存写入**(`cache_creation_input_tokens`)的单价,因此插件只统计它的 token 数、**不折算金额**,以免给出一个没有公开依据的数字。
225
+
202
226
  ### 关于续期
203
227
 
204
228
  OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自己的令牌已过期时**才用 App 已存的 refresh token 续一次,并把轮换后的新令牌按客户端**原格式原子写回**同一个文件(`generation` 保持不变),因此桌面 App 的登录态不受影响。插件不会发起登录、不会登出、也不会调用任何付费/订阅类接口。
@@ -219,6 +243,13 @@ OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自
219
243
 
220
244
  ## 更新记录
221
245
 
246
+ ### 0.4.0(2026-10-19)
247
+
248
+ - **新增「积分即将过期」**:积分是按笔发放、每批独立到期的(发放后约 29 天)。插件读取积分台账 `GET /minimax-cloud/api/v1/credit/details`,在**插件页面**与胶囊里显示 7 天内到期、30 天内到期、最近一笔到期日与到期批次清单。
249
+ - **只统计仍有余额的批次**,避免把「已用光但快到期」的批次算成损失。
250
+ - **插件页面卡片**:在「设置 → 插件 → DSH Connect MiniMax Code」新增一张卡片(余额、赠送/购买拆分、到期分布、到期批次、本机消耗与等效金额),列表里则显示一行摘要。
251
+ - 胶囊与卡片共用一个状态存储与同一个轮询器,多个界面不会重复请求账户接口。
252
+
222
253
  ### 0.3.0(2026-10-19)
223
254
 
224
255
  - **新增积分余额显示**:对话框输入框下方的胶囊显示 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,45 @@ 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 every lot falls due about a month
316
+ * later on a day of its own. Credits in a lot are lost when that day arrives.
317
+ */
318
+ interface CreditLot {
319
+ /** How much this lot was granted. */
320
+ granted: number;
321
+ /** How much of it is left. */
322
+ remaining: number;
323
+ /** How much of it has been used. */
324
+ consumed: number;
325
+ /** When the lot was granted, in milliseconds since the epoch. */
326
+ grantedAtMs: number;
327
+ /** When the lot expires, in milliseconds since the epoch. */
328
+ expireAtMs: number;
329
+ /** The upstream's kind of lot, when it says. */
330
+ creditType?: number;
331
+ }
332
+ /** What the lots say about credits that are about to be lost. */
333
+ interface ExpirySummary {
334
+ /** Credits in lots that expire within {@link EXPIRING_WINDOW_DAYS} days. */
335
+ expiring: number;
336
+ /** How many lots that is. */
337
+ expiringLots: number;
338
+ /** Credits in lots that expire within {@link EXPIRING_HORIZON_DAYS} days. */
339
+ expiringSoon: number;
340
+ /** When the next lot with credits left expires. */
341
+ nextExpiryAtMs?: number;
342
+ /** The next lots with credits left, soonest first. */
343
+ upcoming: {
344
+ amount: number;
345
+ expireAtMs: number;
346
+ }[];
347
+ /** Credits left across every lot. */
348
+ totalRemaining: number;
349
+ }
311
350
  /**
312
351
  * Keeps the current balance and the change between reads.
313
352
  *
@@ -320,12 +359,32 @@ declare class MinimaxCredits {
320
359
  get snapshot(): CreditSnapshot | undefined;
321
360
  /** Why the last read failed, when one did. */
322
361
  get error(): string | undefined;
362
+ /** The credit lots as last read. */
363
+ get lots(): readonly CreditLot[];
364
+ /** What the lots say about expiry, when the ledger could be read. */
365
+ get expiry(): ExpirySummary | undefined;
366
+ /** When the ledger was last read. */
367
+ get ledgerAtMs(): number | undefined;
368
+ /** Why the ledger could not be read, when it could not. */
369
+ get ledgerError(): string | undefined;
323
370
  /**
324
371
  * Read the balance and record how much was consumed since the last read.
325
372
  *
373
+ * The credit ledger is read alongside it, but a ledger that cannot be read
374
+ * never hides the balance: the two come from different endpoints and only
375
+ * the balance is essential.
376
+ *
326
377
  * @returns Whether the balance changed.
327
378
  */
328
379
  refresh(credential: OAuthCredential, region: MinimaxRegion, fetchImpl?: typeof fetch, now?: number): Promise<boolean>;
380
+ /**
381
+ * Read the credit ledger and work out what is about to expire.
382
+ *
383
+ * A failure here is remembered but never thrown: the balance is what the
384
+ * user needs most, and a ledger that is temporarily unavailable must not
385
+ * take it down with it.
386
+ */
387
+ refreshLedger(credential: OAuthCredential, region: MinimaxRegion, fetchImpl?: typeof fetch, now?: number): Promise<void>;
329
388
  }
330
389
  //#endregion
331
390
  //#region src/usage.d.ts
@@ -434,6 +493,23 @@ interface CreditStatus {
434
493
  updatedAtMs?: number;
435
494
  /** Why the balance could not be read. */
436
495
  error?: string;
496
+ /** Credits that expire within the next week. */
497
+ expiring?: number;
498
+ /** How many grants that is. */
499
+ expiringLots?: number;
500
+ /** Credits that expire within the next month. */
501
+ expiringSoon?: number;
502
+ /** When the next grant with credits left expires. */
503
+ nextExpiryAtMs?: number;
504
+ /** The next grants with credits left, soonest first. */
505
+ upcoming?: {
506
+ amount: number;
507
+ expireAtMs: number;
508
+ }[];
509
+ /** When the ledger was read, in milliseconds since the epoch. */
510
+ ledgerAtMs?: number;
511
+ /** Why the ledger could not be read. */
512
+ ledgerError?: string;
437
513
  }
438
514
  /** What the plugin's own requests have used. */
439
515
  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,84 @@ 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, so every page is read. The number of pages comes
998
+ * from the account's own `total_count` rather than from a guess, and a lot
999
+ * count that stops growing ends the walk.
1000
+ */
1001
+ async function fetchCreditLots(credential, region, fetchImpl = fetch) {
1002
+ const pageSize = 100;
1003
+ const lots = [];
1004
+ let page = 1;
1005
+ let total = Number.POSITIVE_INFINITY;
1006
+ while (lots.length < total && page <= 50) {
1007
+ const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${CREDIT_DETAILS_PATH}?page=${page}&page_size=${pageSize}`, { headers: { authorization: `Bearer ${credential.token}` } });
1008
+ if (!response.ok) throw new Error(`credit ledger request failed: HTTP ${response.status}`);
1009
+ const document = await response.json();
1010
+ const batch = parseCreditLots(document);
1011
+ lots.push(...batch);
1012
+ const count = toNumber(document.total_count);
1013
+ if (count !== void 0) total = count;
1014
+ if (batch.length < pageSize) break;
1015
+ page += 1;
1016
+ }
1017
+ return lots;
1018
+ }
1019
+ /**
1020
+ * Summarize what is about to expire.
1021
+ *
1022
+ * Only lots that still hold credits count: a lot that was granted and used up
1023
+ * expires with nothing to lose, and counting it would make every account look
1024
+ * like it is about to lose credits.
1025
+ */
1026
+ function summarizeExpiry(lots, now = Date.now()) {
1027
+ const alive = lots.filter((lot) => lot.remaining > 0 && lot.expireAtMs > now);
1028
+ const within = (days) => alive.filter((lot) => lot.expireAtMs - now <= days * 864e5);
1029
+ const week = within(7);
1030
+ const month = within(30);
1031
+ return {
1032
+ expiring: week.reduce((sum, lot) => sum + lot.remaining, 0),
1033
+ expiringLots: week.length,
1034
+ expiringSoon: month.reduce((sum, lot) => sum + lot.remaining, 0),
1035
+ nextExpiryAtMs: alive[0]?.expireAtMs,
1036
+ upcoming: alive.slice(0, 5).map((lot) => ({
1037
+ amount: lot.remaining,
1038
+ expireAtMs: lot.expireAtMs
1039
+ })),
1040
+ totalRemaining: lots.reduce((sum, lot) => sum + lot.remaining, 0)
1041
+ };
1042
+ }
1043
+ /**
964
1044
  * Keeps the current balance and the change between reads.
965
1045
  *
966
1046
  * The first read has nothing to compare against, so it reports no movement
@@ -969,6 +1049,10 @@ async function fetchCreditSummary(credential, region, fetchImpl = fetch) {
969
1049
  var MinimaxCredits = class {
970
1050
  #snapshot;
971
1051
  #error;
1052
+ #lots = [];
1053
+ #expiry;
1054
+ #ledgerAtMs;
1055
+ #ledgerError;
972
1056
  /** The balance as last read. */
973
1057
  get snapshot() {
974
1058
  return this.#snapshot;
@@ -977,29 +1061,68 @@ var MinimaxCredits = class {
977
1061
  get error() {
978
1062
  return this.#error;
979
1063
  }
1064
+ /** The credit lots as last read. */
1065
+ get lots() {
1066
+ return this.#lots;
1067
+ }
1068
+ /** What the lots say about expiry, when the ledger could be read. */
1069
+ get expiry() {
1070
+ return this.#expiry;
1071
+ }
1072
+ /** When the ledger was last read. */
1073
+ get ledgerAtMs() {
1074
+ return this.#ledgerAtMs;
1075
+ }
1076
+ /** Why the ledger could not be read, when it could not. */
1077
+ get ledgerError() {
1078
+ return this.#ledgerError;
1079
+ }
980
1080
  /**
981
1081
  * Read the balance and record how much was consumed since the last read.
982
1082
  *
1083
+ * The credit ledger is read alongside it, but a ledger that cannot be read
1084
+ * never hides the balance: the two come from different endpoints and only
1085
+ * the balance is essential.
1086
+ *
983
1087
  * @returns Whether the balance changed.
984
1088
  */
985
1089
  async refresh(credential, region, fetchImpl = fetch, now = Date.now()) {
1090
+ let changed = false;
986
1091
  try {
987
1092
  const summary = await fetchCreditSummary(credential, region, fetchImpl);
988
- if (summary === void 0) {
989
- this.#error = "membership response carried no credit summary";
990
- return false;
1093
+ if (summary === void 0) this.#error = "membership response carried no credit summary";
1094
+ else {
1095
+ const previous = this.#snapshot;
1096
+ this.#snapshot = {
1097
+ summary,
1098
+ fetchedAtMs: now,
1099
+ consumed: previous === void 0 ? 0 : previous.summary.total - summary.total
1100
+ };
1101
+ this.#error = void 0;
1102
+ changed = previous === void 0 || previous.summary.total !== summary.total;
991
1103
  }
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
1104
  } catch (error) {
1001
1105
  this.#error = error instanceof Error ? error.message : String(error);
1002
- return false;
1106
+ }
1107
+ await this.refreshLedger(credential, region, fetchImpl, now);
1108
+ return changed;
1109
+ }
1110
+ /**
1111
+ * Read the credit ledger and work out what is about to expire.
1112
+ *
1113
+ * A failure here is remembered but never thrown: the balance is what the
1114
+ * user needs most, and a ledger that is temporarily unavailable must not
1115
+ * take it down with it.
1116
+ */
1117
+ async refreshLedger(credential, region, fetchImpl = fetch, now = Date.now()) {
1118
+ try {
1119
+ const lots = await fetchCreditLots(credential, region, fetchImpl);
1120
+ this.#lots = lots;
1121
+ this.#expiry = summarizeExpiry(lots, now);
1122
+ this.#ledgerAtMs = now;
1123
+ this.#ledgerError = void 0;
1124
+ } catch (error) {
1125
+ this.#ledgerError = error instanceof Error ? error.message : String(error);
1003
1126
  }
1004
1127
  }
1005
1128
  };
@@ -1206,6 +1329,7 @@ function apply(ctx) {
1206
1329
  /** The credit balance as the card and the browser half read it. */
1207
1330
  const buildCreditStatus = () => {
1208
1331
  const snapshot = credits.snapshot;
1332
+ const expiry = credits.expiry;
1209
1333
  if (snapshot === void 0) return {
1210
1334
  state: creditState,
1211
1335
  ...credits.error === void 0 ? {} : { error: credits.error }
@@ -1216,7 +1340,16 @@ function apply(ctx) {
1216
1340
  purchased: snapshot.summary.purchased,
1217
1341
  free: snapshot.summary.free,
1218
1342
  consumed: snapshot.consumed,
1219
- updatedAtMs: snapshot.fetchedAtMs
1343
+ updatedAtMs: snapshot.fetchedAtMs,
1344
+ ...expiry === void 0 ? {} : {
1345
+ expiring: expiry.expiring,
1346
+ expiringLots: expiry.expiringLots,
1347
+ expiringSoon: expiry.expiringSoon,
1348
+ nextExpiryAtMs: expiry.nextExpiryAtMs,
1349
+ upcoming: expiry.upcoming
1350
+ },
1351
+ ...credits.ledgerAtMs === void 0 ? {} : { ledgerAtMs: credits.ledgerAtMs },
1352
+ ...credits.ledgerError === void 0 ? {} : { ledgerError: credits.ledgerError }
1220
1353
  };
1221
1354
  };
1222
1355
  /** 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.0",
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
  }