@polderlabs/bizar 4.4.11 → 4.4.12

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.
@@ -0,0 +1,543 @@
1
+ // src/views/MiniMaxUsage.tsx — v4.5.0
2
+ //
3
+ // Token Plan usage tracking dashboard.
4
+ //
5
+ // Renders the user's current 5-hour rolling + weekly remaining quota
6
+ // per model, fetched from https://www.minimax.io/v1/token_plan/remains.
7
+ // The Subscription Key is set inline on this page (no separate Settings
8
+ // trip needed) — the value is written to settings.json under
9
+ // `minimax.apiKey` and read back on next load.
10
+
11
+ import { useCallback, useEffect, useState } from 'react';
12
+ import {
13
+ Coins,
14
+ RefreshCw,
15
+ AlertTriangle,
16
+ CheckCircle2,
17
+ KeyRound,
18
+ Send,
19
+ Loader2,
20
+ Calendar,
21
+ Activity,
22
+ Save,
23
+ Eye,
24
+ EyeOff,
25
+ } from 'lucide-react';
26
+ import { Card, CardTitle, CardMeta } from '../components/Card';
27
+ import { Button } from '../components/Button';
28
+ import { Spinner } from '../components/Spinner';
29
+ import { useToast } from '../components/Toast';
30
+ import { api } from '../lib/api';
31
+ import { cn } from '../lib/utils';
32
+ import type { Settings, Snapshot } from '../lib/types';
33
+
34
+ type Props = {
35
+ snapshot: Snapshot;
36
+ settings: Settings;
37
+ activeTab: string;
38
+ setActiveTab: (id: string) => void;
39
+ refreshSnapshot: () => Promise<void>;
40
+ };
41
+
42
+ type RemainsModel = {
43
+ model_name: string;
44
+ current_interval_remaining_percent: number;
45
+ current_weekly_remaining_percent: number;
46
+ current_interval_consumed_percent?: number;
47
+ weekly_consumed_percent?: number;
48
+ current_interval_status: number;
49
+ current_weekly_status: number;
50
+ current_interval_total_count: number;
51
+ current_interval_usage_count: number;
52
+ current_weekly_total_count: number;
53
+ current_weekly_usage_count: number;
54
+ endTimeISO?: string;
55
+ weeklyEndTimeISO?: string;
56
+ intervalResetInMs?: number;
57
+ weeklyResetInMs?: number;
58
+ intervalResetInHuman?: string;
59
+ weeklyResetInHuman?: string;
60
+ intervalUsed?: number;
61
+ intervalTotal?: number;
62
+ weeklyUsed?: number;
63
+ weeklyTotal?: number;
64
+ };
65
+
66
+ type RemainsSnapshot = {
67
+ ok: boolean;
68
+ cached?: boolean;
69
+ fetchedAt?: number;
70
+ apiKeyHint?: string;
71
+ groupId?: string;
72
+ baseUrl?: string;
73
+ models?: RemainsModel[];
74
+ baseResp?: { status_code: number; status_msg: string };
75
+ error?: string;
76
+ message?: string;
77
+ };
78
+
79
+ type Status = {
80
+ enabled: boolean;
81
+ configured: boolean;
82
+ apiKeyHint: string;
83
+ groupId: string;
84
+ baseUrl: string;
85
+ chatBaseUrl: string;
86
+ cache: { fetchedAt: number; apiKeyHint: string; modelCount: number } | null;
87
+ };
88
+
89
+ type TestResult = {
90
+ ok: boolean;
91
+ error?: string;
92
+ message?: string;
93
+ model?: string;
94
+ content?: string;
95
+ reasoning?: string;
96
+ finishReason?: string;
97
+ usage?: {
98
+ total_tokens?: number;
99
+ prompt_tokens?: number;
100
+ completion_tokens?: number;
101
+ prompt_tokens_details?: { cached_tokens?: number };
102
+ };
103
+ status?: number;
104
+ raw?: unknown;
105
+ };
106
+
107
+ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot }: Props) {
108
+ const toast = useToast();
109
+ const [status, setStatus] = useState<Status | null>(null);
110
+ const [remains, setRemains] = useState<RemainsSnapshot | null>(null);
111
+ const [loading, setLoading] = useState(true);
112
+ const [refreshing, setRefreshing] = useState(false);
113
+ const [error, setError] = useState<string | null>(null);
114
+ const [testResult, setTestResult] = useState<TestResult | null>(null);
115
+ const [testing, setTesting] = useState(false);
116
+ // Inline key config — saves a trip to Settings.
117
+ const [keyDraft, setKeyDraft] = useState('');
118
+ const [showKey, setShowKey] = useState(false);
119
+ const [savingKey, setSavingKey] = useState(false);
120
+
121
+ const load = useCallback(async () => {
122
+ setLoading(true);
123
+ setError(null);
124
+ try {
125
+ const [s, r] = await Promise.all([
126
+ api.get<Status>('/minimax/status'),
127
+ api.get<RemainsSnapshot>('/minimax/remains'),
128
+ ]);
129
+ setStatus(s);
130
+ setRemains(r);
131
+ setKeyDraft(s.configured ? '' : ''); // never prefill — security
132
+ } catch (err) {
133
+ setError((err as Error).message || 'Failed to load MiniMax data');
134
+ } finally {
135
+ setLoading(false);
136
+ }
137
+ }, []);
138
+
139
+ useEffect(() => { load(); }, [load]);
140
+
141
+ const saveKey = useCallback(async () => {
142
+ if (!keyDraft.trim()) {
143
+ toast.error('Paste a Subscription Key first.');
144
+ return;
145
+ }
146
+ setSavingKey(true);
147
+ try {
148
+ const r = await api.put<{ minimax: { apiKey: string } }>('/settings', {
149
+ minimax: {
150
+ ...(settings.minimax || {}),
151
+ apiKey: keyDraft.trim(),
152
+ },
153
+ });
154
+ // Wipe the in-memory draft + clear the remains cache so the
155
+ // next load picks up the new key.
156
+ setKeyDraft('');
157
+ clearRemainsCacheClient();
158
+ toast.success('Subscription Key saved. Refreshing…');
159
+ void load();
160
+ } catch (err) {
161
+ toast.error(`Save failed: ${(err as Error).message}`);
162
+ } finally {
163
+ setSavingKey(false);
164
+ }
165
+ }, [keyDraft, settings, toast, load]);
166
+
167
+ // Clears the on-disk + in-memory cache via the dashboard's
168
+ // /api/minimax/cache DELETE endpoint. Wrapped in a helper so the
169
+ // success path can await the round-trip.
170
+ const clearRemainsCacheClient = useCallback(async () => {
171
+ try { await api.del('/minimax/cache'); } catch { /* best-effort */ }
172
+ }, []);
173
+
174
+ const refresh = useCallback(async () => {
175
+ setRefreshing(true);
176
+ setError(null);
177
+ try {
178
+ await api.post('/minimax/remains/refresh');
179
+ await load();
180
+ toast.success('Refreshed');
181
+ } catch (err) {
182
+ toast.error(`Refresh failed: ${(err as Error).message}`);
183
+ } finally {
184
+ setRefreshing(false);
185
+ }
186
+ }, [load, toast]);
187
+
188
+ const sendTest = useCallback(async () => {
189
+ setTesting(true);
190
+ setTestResult(null);
191
+ try {
192
+ const r = await api.post<TestResult>('/minimax/test', {
193
+ prompt: 'Reply with a single word: pong',
194
+ model: 'MiniMax-M3',
195
+ maxTokens: 16,
196
+ });
197
+ setTestResult(r);
198
+ if (r.ok) {
199
+ toast.success(`Test ok — used ${r.usage?.total_tokens ?? '?'} tokens`);
200
+ } else {
201
+ toast.error(`Test failed: ${r.message ?? r.error ?? 'unknown'}`);
202
+ }
203
+ } catch (err) {
204
+ toast.error(`Test request failed: ${(err as Error).message}`);
205
+ } finally {
206
+ setTesting(false);
207
+ }
208
+ }, [toast]);
209
+
210
+ if (loading && !status) {
211
+ return (
212
+ <div className="view-container">
213
+ <Spinner /> Loading MiniMax data…
214
+ </div>
215
+ );
216
+ }
217
+
218
+ if (!status) {
219
+ return (
220
+ <div className="view-container">
221
+ <div className="error-card">
222
+ <AlertTriangle size={20} />
223
+ <div>
224
+ <strong>Couldn't load MiniMax status</strong>
225
+ <pre>{error ?? 'unknown error'}</pre>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ return (
233
+ <div className="view-container view-minimax-usage">
234
+ {/* ── Header ─────────────────────────────────────────────────── */}
235
+ <header className="view-header">
236
+ <div className="view-header-titles">
237
+ <h1 className="view-title">
238
+ <Coins size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
239
+ MiniMax Token Plan
240
+ </h1>
241
+ <p className="view-subtitle">
242
+ Remaining 5-hour + weekly quota per model. Fetches live from{' '}
243
+ <code>www.minimax.io/v1/token_plan/remains</code>.
244
+ </p>
245
+ </div>
246
+ <div className="view-header-actions">
247
+ <Button
248
+ variant="secondary"
249
+ size="sm"
250
+ onClick={() => setActiveTab('settings')}
251
+ title="Configure your Subscription Key"
252
+ >
253
+ <KeyRound size={14} /> {status.configured ? `Key ${status.apiKeyHint}` : 'Add key'}
254
+ </Button>
255
+ <Button variant="secondary" size="sm" onClick={refresh} disabled={refreshing}>
256
+ {refreshing ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
257
+ Refresh
258
+ </Button>
259
+ </div>
260
+ </header>
261
+
262
+ {/* ── Status banners ─────────────────────────────────────────── */}
263
+ {!status.enabled && (
264
+ <div className="banner banner-warn">
265
+ <AlertTriangle size={16} />
266
+ <span>
267
+ MiniMax integration is <strong>disabled</strong> in settings. Enable it in Settings → MiniMax.
268
+ </span>
269
+ </div>
270
+ )}
271
+
272
+ {/* ── Subscription Key config (inline) ──────────────────── */}
273
+ {status.enabled && (
274
+ <Card id="minimax-key">
275
+ <CardTitle>
276
+ <KeyRound size={14} /> Subscription Key
277
+ </CardTitle>
278
+ <CardMeta>
279
+ Get it from{' '}
280
+ <a
281
+ href="https://platform.minimax.io/user-center/payment/token-plan"
282
+ target="_blank"
283
+ rel="noreferrer"
284
+ className="minimax-link"
285
+ >
286
+ platform.minimax.io/user-center/payment/token-plan
287
+ </a>
288
+ . Stored locally in <code>~/.config/bizar/settings.json</code>;
289
+ never sent to any other host. Status:{' '}
290
+ {status.configured ? (
291
+ <strong className="is-ok">configured ({status.apiKeyHint})</strong>
292
+ ) : (
293
+ <strong className="is-warn">not configured</strong>
294
+ )}
295
+ .
296
+ </CardMeta>
297
+ <div className="minimax-key-row">
298
+ <div className="minimax-key-input-wrap">
299
+ <KeyRound size={12} className="minimax-key-icon" />
300
+ <input
301
+ type={showKey ? 'text' : 'password'}
302
+ value={keyDraft}
303
+ onChange={(e) => setKeyDraft(e.target.value)}
304
+ placeholder={status.configured ? '•••• paste a new key to replace' : 'eyJhbGciOi...'}
305
+ spellCheck={false}
306
+ autoComplete="off"
307
+ className="minimax-key-input"
308
+ />
309
+ <button
310
+ type="button"
311
+ onClick={() => setShowKey((v) => !v)}
312
+ className="minimax-key-toggle"
313
+ aria-label={showKey ? 'Hide key' : 'Show key'}
314
+ title={showKey ? 'Hide key' : 'Show key'}
315
+ >
316
+ {showKey ? <EyeOff size={13} /> : <Eye size={13} />}
317
+ </button>
318
+ </div>
319
+ <Button onClick={saveKey} disabled={savingKey || !keyDraft.trim()} size="sm">
320
+ {savingKey ? <Loader2 size={14} className="spin" /> : <Save size={14} />}
321
+ Save key
322
+ </Button>
323
+ </div>
324
+ </Card>
325
+ )}
326
+
327
+ {remains && !remains.ok && (
328
+ <div className="banner banner-err">
329
+ <AlertTriangle size={16} />
330
+ <span>
331
+ <strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}
332
+ </span>
333
+ </div>
334
+ )}
335
+
336
+ {/* ── Summary stats ─────────────────────────────────────────── */}
337
+ {remains?.ok && remains.models && (
338
+ <>
339
+ <div className="minimax-stats-row">
340
+ <Stat
341
+ label="Models tracked"
342
+ value={String(remains.models.length)}
343
+ hint="Distinct model quotas returned by the API"
344
+ />
345
+ <Stat
346
+ label="Last fetched"
347
+ value={remains.fetchedAt ? relativeTime(remains.fetchedAt) : '—'}
348
+ hint="Refreshed on demand + every 60s"
349
+ />
350
+ <Stat
351
+ label="Group"
352
+ value={remains.groupId ?? '—'}
353
+ hint="Usually 'default' for an individual team"
354
+ />
355
+ <Stat
356
+ label="API key"
357
+ value={remains.apiKeyHint ?? '—'}
358
+ hint="Masked; the real key never leaves settings.json"
359
+ />
360
+ </div>
361
+
362
+ {/* ── Per-model quota cards ──────────────────────────────── */}
363
+ <div className="minimax-models-grid">
364
+ {remains.models.map((m) => (
365
+ <ModelQuotaCard key={m.model_name} model={m} />
366
+ ))}
367
+ </div>
368
+ </>
369
+ )}
370
+
371
+ {/* ── Test prompt card ─────────────────────────────────────── */}
372
+ {status.configured && (
373
+ <Card id="minimax-test">
374
+ <CardTitle>
375
+ <Send size={14} /> Test the API key
376
+ </CardTitle>
377
+ <CardMeta>
378
+ Sends a one-shot chat completion to{' '}
379
+ <code>{status.chatBaseUrl}/chat/completions</code> to verify the
380
+ key works and surface live token usage.
381
+ </CardMeta>
382
+ <div className="minimax-test-actions">
383
+ <Button onClick={sendTest} disabled={testing} variant="primary" size="sm">
384
+ {testing ? <Loader2 size={14} className="spin" /> : <Send size={14} />}
385
+ Send test prompt
386
+ </Button>
387
+ <code className="minimax-test-prompt">"Reply with a single word: pong"</code>
388
+ </div>
389
+ {testResult && (
390
+ <div className={cn('minimax-test-result', testResult.ok ? 'ok' : 'err')}>
391
+ {testResult.ok ? <CheckCircle2 size={14} /> : <AlertTriangle size={14} />}
392
+ <div className="minimax-test-body">
393
+ {testResult.ok ? (
394
+ <>
395
+ <div className="minimax-test-line">
396
+ <strong>model:</strong> <code>{testResult.model}</code> ·{' '}
397
+ <strong>finish:</strong> {testResult.finishReason ?? '—'}
398
+ </div>
399
+ {testResult.content && (
400
+ <div className="minimax-test-content">
401
+ <code>{testResult.content}</code>
402
+ </div>
403
+ )}
404
+ {testResult.usage && (
405
+ <div className="minimax-test-usage">
406
+ <strong>usage:</strong> total{' '}
407
+ <code>{testResult.usage.total_tokens ?? '?'}</code> · prompt{' '}
408
+ <code>{testResult.usage.prompt_tokens ?? '?'}</code> · completion{' '}
409
+ <code>{testResult.usage.completion_tokens ?? '?'}</code>
410
+ {testResult.usage.prompt_tokens_details?.cached_tokens != null && (
411
+ <> · cached <code>{testResult.usage.prompt_tokens_details.cached_tokens}</code></>
412
+ )}
413
+ </div>
414
+ )}
415
+ </>
416
+ ) : (
417
+ <pre>{testResult.message ?? testResult.error ?? 'unknown error'}</pre>
418
+ )}
419
+ </div>
420
+ </div>
421
+ )}
422
+ </Card>
423
+ )}
424
+ </div>
425
+ );
426
+ }
427
+
428
+ // ─── Sub-components ────────────────────────────────────────────────────────
429
+
430
+ function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
431
+ return (
432
+ <div className="stat">
433
+ <div className="stat-label">{label}</div>
434
+ <div className="stat-value">{value}</div>
435
+ {hint && <div className="stat-hint">{hint}</div>}
436
+ </div>
437
+ );
438
+ }
439
+
440
+ function ModelQuotaCard({ model }: { model: RemainsModel }) {
441
+ const fivePct = clamp(model.current_interval_remaining_percent ?? 0, 0, 100);
442
+ const weekPct = clamp(model.current_weekly_remaining_percent ?? 0, 0, 100);
443
+ const fiveConsumed = clamp(100 - fivePct, 0, 100);
444
+ const weekConsumed = clamp(100 - weekPct, 0, 100);
445
+ return (
446
+ <Card id={`minimax-model-${model.model_name}`} className="minimax-model-card">
447
+ <div className="minimax-model-head">
448
+ <div className="minimax-model-name">{model.model_name}</div>
449
+ <div
450
+ className={cn(
451
+ 'minimax-model-status',
452
+ fivePct < 25 || weekPct < 25 ? 'is-warn' : 'is-ok'
453
+ )}
454
+ >
455
+ {model.current_interval_status === 1 ? 'active' : 'idle'} ·{' '}
456
+ {model.current_weekly_status === 1 ? 'week active' : 'week idle'}
457
+ </div>
458
+ </div>
459
+
460
+ <div className="minimax-quota-rows">
461
+ <QuotaBar
462
+ icon={<Activity size={12} />}
463
+ label="5-hour rolling"
464
+ remainingPct={fivePct}
465
+ consumedPct={fiveConsumed}
466
+ used={model.current_interval_usage_count ?? 0}
467
+ total={model.current_interval_total_count ?? 0}
468
+ resetIn={model.intervalResetInHuman}
469
+ resetISO={model.endTimeISO}
470
+ />
471
+ <QuotaBar
472
+ icon={<Calendar size={12} />}
473
+ label="Weekly"
474
+ remainingPct={weekPct}
475
+ consumedPct={weekConsumed}
476
+ used={model.current_weekly_usage_count ?? 0}
477
+ total={model.current_weekly_total_count ?? 0}
478
+ resetIn={model.weeklyResetInHuman}
479
+ resetISO={model.weeklyEndTimeISO}
480
+ />
481
+ </div>
482
+ </Card>
483
+ );
484
+ }
485
+
486
+ function QuotaBar({
487
+ icon,
488
+ label,
489
+ remainingPct,
490
+ consumedPct,
491
+ used,
492
+ total,
493
+ resetIn,
494
+ resetISO,
495
+ }: {
496
+ icon: React.ReactNode;
497
+ label: string;
498
+ remainingPct: number;
499
+ consumedPct: number;
500
+ used: number;
501
+ total: number;
502
+ resetIn?: string;
503
+ resetISO?: string;
504
+ }) {
505
+ const tone = remainingPct >= 75 ? 'good' : remainingPct >= 25 ? 'warn' : 'low';
506
+ return (
507
+ <div className={`minimax-quota-row is-${tone}`}>
508
+ <div className="minimax-quota-head">
509
+ <span className="minimax-quota-label">
510
+ {icon} {label}
511
+ </span>
512
+ <span className="minimax-quota-remaining">{remainingPct}% remaining</span>
513
+ </div>
514
+ <div className="minimax-bar">
515
+ <div
516
+ className="minimax-bar-fill"
517
+ style={{ width: `${consumedPct}%` }}
518
+ aria-label={`${consumedPct}% consumed`}
519
+ />
520
+ </div>
521
+ <div className="minimax-quota-meta">
522
+ <span>
523
+ <strong>{used}</strong> / {total || '—'} requests
524
+ </span>
525
+ <span title={resetISO}>
526
+ {resetIn ? `resets in ${resetIn}` : '—'}
527
+ </span>
528
+ </div>
529
+ </div>
530
+ );
531
+ }
532
+
533
+ function clamp(n: number, lo: number, hi: number) {
534
+ return Math.max(lo, Math.min(hi, n));
535
+ }
536
+
537
+ function relativeTime(ts: number): string {
538
+ const ms = Date.now() - ts;
539
+ if (ms < 5_000) return 'just now';
540
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`;
541
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
542
+ return `${Math.floor(ms / 3_600_000)}h ago`;
543
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "4.4.11",
3
+ "version": "4.4.12",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {