@polderlabs/bizar 4.4.11 → 4.4.13

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.
@@ -29,10 +29,10 @@
29
29
  }
30
30
  })();
31
31
  </script>
32
- <script type="module" crossorigin src="/assets/main-CEazNxxy.js"></script>
32
+ <script type="module" crossorigin src="/assets/main-BB5mJurD.js"></script>
33
33
  <link rel="modulepreload" crossorigin href="/assets/mobile-Dl1q7Cyq.js">
34
34
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
35
- <link rel="stylesheet" crossorigin href="/assets/main-Nq8Dq3VR.css">
35
+ <link rel="stylesheet" crossorigin href="/assets/main-BsnQLXdh.css">
36
36
  </head>
37
37
  <body>
38
38
  <div id="root"></div>
@@ -48,6 +48,7 @@ import { createDiagnosticsRouter } from './routes/diagnostics.mjs';
48
48
  import { createPairRouter } from './routes/pair.mjs';
49
49
  import { createThemesRouter } from './routes/themes.mjs';
50
50
  import { createNotificationsRouter } from './routes/notifications.mjs';
51
+ import { createMinimaxRouter } from './routes/minimax.mjs';
51
52
  import { createMiscRouter } from './routes/misc.mjs';
52
53
 
53
54
  /**
@@ -118,6 +119,7 @@ export async function createApiRouter({
118
119
  router.use(createThemesRouter({ state }));
119
120
  router.use(createNotificationsRouter({ broadcast }));
120
121
  router.use(createArtifactsRouter({ state, broadcast, projectRoot }));
122
+ router.use(createMinimaxRouter({ state, broadcast }));
121
123
  router.use(createMiscRouter({ state, broadcast }));
122
124
 
123
125
  // /api/auth/* must be reachable WITHOUT the bearer token so a fresh
@@ -0,0 +1,478 @@
1
+ /**
2
+ * src/server/minimax.mjs
3
+ *
4
+ * v4.5.0 — MiniMax API client.
5
+ *
6
+ * Owns the network calls to platform.minimax.io for the Bizar
7
+ * dashboard. The Subscription Key is read from opencode's canonical
8
+ * auth store (`~/.local/share/opencode/auth.json`) so the user only
9
+ * enters the key once (via opencode's `/connect` command) and the
10
+ * dashboard picks it up automatically.
11
+ *
12
+ * Key resolution chain (in order, first match wins):
13
+ * 1. process.env.MINIMAX_API_KEY
14
+ * 2. process.env.ANTHROPIC_API_KEY (MiniMax accepts Anthropic keys)
15
+ * 3. ~/.local/share/opencode/auth.json → "minimax" → "key"
16
+ * 4. ~/.config/opencode/opencode.json → provider.minimax.options.apiKey
17
+ * 5. ~/.config/opencode/opencode.json → provider.minimax.apiKey
18
+ *
19
+ * Two surfaces:
20
+ * - fetchRemains() — Token Plan quota (5h + weekly per model)
21
+ * - chatCompletion() — one-shot chat call for the "test prompt" UI
22
+ *
23
+ * All network calls are guarded by a 10s timeout so a hung API
24
+ * never wedges the dashboard. Responses are cached in memory (60s)
25
+ * and on disk (5min at ~/.config/bizar/minimax/remains-cache.json) so
26
+ * renders are fast and the API isn't hammered.
27
+ */
28
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, statSync } from 'node:fs';
29
+ import { dirname, join } from 'node:path';
30
+ import { homedir } from 'node:os';
31
+
32
+ const HOME = homedir();
33
+ const BIZAR_HOME = join(HOME, '.config', 'bizar');
34
+ const OPENCODE_AUTH_FILE = join(HOME, '.local', 'share', 'opencode', 'auth.json');
35
+ const OPENCODE_CONFIG_FILE = join(HOME, '.config', 'opencode', 'opencode.json');
36
+ const CACHE_DIR = join(BIZAR_HOME, 'minimax');
37
+ const CACHE_FILE = join(CACHE_DIR, 'remains-cache.json');
38
+
39
+ // Default base URLs. The docs say `https://www.minimax.io/v1/token_plan/remains`
40
+ // (note: the `www` host, not `api`). The chat completions / OpenAI-format
41
+ // surface lives on api.minimax.io.
42
+ export const DEFAULT_BASE_URL = 'https://www.minimax.io';
43
+ export const DEFAULT_CHAT_BASE_URL = 'https://api.minimax.io/v1';
44
+
45
+ // Full MiniMax model list. Pulled live from /v1/models on the user's key
46
+ // and cached in the dashboard. The dashboard's "Usage" view shows
47
+ // remaining quota per-model.
48
+ export const KNOWN_MODELS = [
49
+ 'MiniMax-M3',
50
+ 'MiniMax-M2.7',
51
+ 'MiniMax-M2.7-highspeed',
52
+ 'MiniMax-M2.5',
53
+ 'MiniMax-M2.5-highspeed',
54
+ 'MiniMax-M2.1',
55
+ 'MiniMax-M2.1-highspeed',
56
+ 'MiniMax-M2',
57
+ ];
58
+
59
+ // Valid MiniMax API key prefixes (subscription + pay-as-you-go).
60
+ // The user's key starts with `sk-cp-` and is 125 chars long. Real
61
+ // keys have variable lengths and may include dashes, so the pattern
62
+ // only validates the prefix.
63
+ const MINIMAX_KEY_PATTERN = /^sk-(cp|ant|or)-[A-Za-z0-9_-]{20,}$/;
64
+
65
+ // ─── Key resolution ─────────────────────────────────────────────────────
66
+
67
+ function safeReadJson(file, fallback = null) {
68
+ try {
69
+ if (!existsSync(file)) return fallback;
70
+ return JSON.parse(readFileSync(file, 'utf8'));
71
+ } catch {
72
+ return fallback;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Resolve the MiniMax Subscription Key from opencode's canonical store.
78
+ * Returns `{ key: string|null, source: string, groupId: string }`.
79
+ *
80
+ * `source` is one of:
81
+ * - 'env:MINIMAX_API_KEY'
82
+ * - 'env:ANTHROPIC_API_KEY'
83
+ * - 'auth.json'
84
+ * - 'opencode.json:options.apiKey'
85
+ * - 'opencode.json:apiKey'
86
+ * - 'none'
87
+ */
88
+ export function resolveApiKey() {
89
+ // 1. Env vars — opencode reads MINIMAX_API_KEY first
90
+ if (process.env.MINIMAX_API_KEY && process.env.MINIMAX_API_KEY.trim()) {
91
+ return {
92
+ key: process.env.MINIMAX_API_KEY.trim(),
93
+ source: 'env:MINIMAX_API_KEY',
94
+ groupId: 'default',
95
+ };
96
+ }
97
+ // 2. Anthropic key works against MiniMax's Anthropic-format surface
98
+ if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
99
+ return {
100
+ key: process.env.ANTHROPIC_API_KEY.trim(),
101
+ source: 'env:ANTHROPIC_API_KEY',
102
+ groupId: 'default',
103
+ };
104
+ }
105
+ // 3. ~/.local/share/opencode/auth.json
106
+ const auth = safeReadJson(OPENCODE_AUTH_FILE, null);
107
+ if (auth && typeof auth === 'object' && auth.minimax && auth.minimax.key) {
108
+ return {
109
+ key: String(auth.minimax.key).trim(),
110
+ source: 'auth.json',
111
+ groupId: String(auth.minimax.group_id || 'default'),
112
+ };
113
+ }
114
+ // 4. opencode.json → provider.minimax.options.apiKey
115
+ const cfg = safeReadJson(OPENCODE_CONFIG_FILE, null);
116
+ const minimax = cfg?.provider?.minimax;
117
+ if (minimax?.options?.apiKey) {
118
+ return {
119
+ key: String(minimax.options.apiKey).trim(),
120
+ source: 'opencode.json:options.apiKey',
121
+ groupId: String(cfg.provider.minimax.group_id || 'default'),
122
+ };
123
+ }
124
+ // 5. opencode.json → provider.minimax.apiKey
125
+ if (minimax?.apiKey) {
126
+ return {
127
+ key: String(minimax.apiKey).trim(),
128
+ source: 'opencode.json:apiKey',
129
+ groupId: String(cfg.provider.minimax.group_id || 'default'),
130
+ };
131
+ }
132
+ return { key: null, source: 'none', groupId: 'default' };
133
+ }
134
+
135
+ /**
136
+ * Base URL resolution. The Token Plan endpoint lives on www.minimax.io;
137
+ * the chat completions live on api.minimax.io/v1. If the user has
138
+ * set a custom base URL in opencode.json's `options.baseURL`, that
139
+ * wins (with path-aware logic — we extract the host).
140
+ */
141
+ export function resolveBaseUrls() {
142
+ const cfg = safeReadJson(OPENCODE_CONFIG_FILE, null);
143
+ const minimaxOpts = cfg?.provider?.minimax?.options || {};
144
+ let tokenBase = DEFAULT_BASE_URL;
145
+ let chatBase = DEFAULT_CHAT_BASE_URL;
146
+ if (typeof minimaxOpts.baseURL === 'string' && minimaxOpts.baseURL.trim()) {
147
+ const u = minimaxOpts.baseURL.trim();
148
+ // If the user already points at the OpenAI-format surface, keep
149
+ // it as chatBase. Otherwise we use it for both, falling back to
150
+ // known hosts when the path looks like a generic base.
151
+ try {
152
+ const parsed = new URL(u);
153
+ const host = parsed.host;
154
+ if (host.startsWith('api.')) {
155
+ chatBase = u.replace(/\/$/, '');
156
+ tokenBase = chatBase.replace(/^https?:\/\/api\./, 'https://www.');
157
+ } else if (host.startsWith('www.')) {
158
+ tokenBase = u.replace(/\/$/, '');
159
+ chatBase = tokenBase.replace(/^https?:\/\/www\./, 'https://api.') + '/v1';
160
+ }
161
+ } catch {
162
+ // ignore — fall through to defaults
163
+ }
164
+ }
165
+ return { tokenBase, chatBase };
166
+ }
167
+
168
+ // ─── Cache ────────────────────────────────────────────────────────────────
169
+
170
+ let memoryCache = null;
171
+
172
+ function readDiskCache() {
173
+ try {
174
+ if (!existsSync(CACHE_FILE)) return null;
175
+ const stat = statSync(CACHE_FILE);
176
+ if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) return null; // 5 min TTL
177
+ return JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function writeDiskCache(snapshot) {
184
+ try {
185
+ mkdirSync(CACHE_DIR, { recursive: true });
186
+ writeFileSync(CACHE_FILE, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
187
+ } catch { /* best-effort */ }
188
+ }
189
+
190
+ export function clearRemainsCache() {
191
+ memoryCache = null;
192
+ try {
193
+ if (existsSync(CACHE_FILE)) unlinkSync(CACHE_FILE);
194
+ } catch { /* best-effort */ }
195
+ }
196
+
197
+ export function readCachedRemains() {
198
+ if (memoryCache) return memoryCache;
199
+ const fromDisk = readDiskCache();
200
+ if (fromDisk) {
201
+ memoryCache = fromDisk;
202
+ return fromDisk;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // ─── Network helpers ─────────────────────────────────────────────────────
208
+
209
+ async function fetchWithTimeout(url, opts = {}, timeoutMs = 10_000) {
210
+ const ctl = new AbortController();
211
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
212
+ try {
213
+ return await fetch(url, { ...opts, signal: ctl.signal });
214
+ } finally {
215
+ clearTimeout(t);
216
+ }
217
+ }
218
+
219
+ // ─── Auth-file writer (for onboarding) ─────────────────────────────────
220
+
221
+ /**
222
+ * Write the MiniMax key to opencode's auth store at the canonical
223
+ * path. Returns the path that was written. Used by the dashboard's
224
+ * onboarding wizard so the user only enters the key once.
225
+ */
226
+ export function writeAuthFile(key, groupId = 'default') {
227
+ mkdirSync(dirname(OPENCODE_AUTH_FILE), { recursive: true });
228
+ const cur = safeReadJson(OPENCODE_AUTH_FILE, {}) || {};
229
+ cur.minimax = {
230
+ type: 'api',
231
+ key: String(key).trim(),
232
+ };
233
+ if (groupId) cur.minimax.group_id = groupId;
234
+ writeFileSync(OPENCODE_AUTH_FILE, JSON.stringify(cur, null, 2) + '\n', 'utf8');
235
+ return OPENCODE_AUTH_FILE;
236
+ }
237
+
238
+ // ─── Public API ─────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Read the dashboard-side config that opencode doesn't own:
242
+ * - the onboarding "dismissed" flag (so we don't keep nagging the
243
+ * user with the first-run wizard after they've seen it once)
244
+ * - the per-model enabled flag (so the user can hide a model
245
+ * they don't want to track)
246
+ *
247
+ * Stored at ~/.config/bizar/minimax/onboarding.json.
248
+ */
249
+ export function readOnboarding() {
250
+ return safeReadJson(join(BIZAR_HOME, 'minimax', 'onboarding.json'), {
251
+ dismissedAt: null,
252
+ hiddenModels: [],
253
+ });
254
+ }
255
+
256
+ export function writeOnboarding(patch) {
257
+ const cur = readOnboarding();
258
+ const next = { ...cur, ...patch };
259
+ mkdirSync(CACHE_DIR, { recursive: true });
260
+ writeFileSync(join(BIZAR_HOME, 'minimax', 'onboarding.json'), JSON.stringify(next, null, 2) + '\n', 'utf8');
261
+ return next;
262
+ }
263
+
264
+ /**
265
+ * Snapshot of the current MiniMax state for the dashboard. The
266
+ * `status` route returns this so the React view knows whether to
267
+ * show the wizard, the live dashboard, or a "needs configuration"
268
+ * banner.
269
+ */
270
+ export function getStatus() {
271
+ const resolved = resolveApiKey();
272
+ const urls = resolveBaseUrls();
273
+ const cached = readCachedRemains();
274
+ return {
275
+ configured: !!resolved.key,
276
+ apiKeyHint: maskKey(resolved.key),
277
+ source: resolved.source,
278
+ groupId: resolved.groupId,
279
+ tokenBaseUrl: urls.tokenBase,
280
+ chatBaseUrl: urls.chatBase,
281
+ knownModels: KNOWN_MODELS,
282
+ keyPatternValid: resolved.key ? MINIMAX_KEY_PATTERN.test(resolved.key) : null,
283
+ cache: cached
284
+ ? {
285
+ fetchedAt: cached.fetchedAt,
286
+ apiKeyHint: cached.apiKeyHint,
287
+ modelCount: (cached.models || []).length,
288
+ }
289
+ : null,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Hit /v1/token_plan/remains with the user's resolved key.
295
+ *
296
+ * Returns the raw response, augmented with `fetchedAt`, ISO timestamps
297
+ * for the reset windows, and pre-computed consumed-percent fields so
298
+ * the UI doesn't have to do arithmetic on every cell.
299
+ */
300
+ export async function fetchRemains({ force = false } = {}) {
301
+ const resolved = resolveApiKey();
302
+ if (!resolved.key) {
303
+ return { ok: false, error: 'no_api_key', message: 'MiniMax Subscription Key is not configured. Run `bizar setup` or add it via the dashboard onboarding.' };
304
+ }
305
+ if (!MINIMAX_KEY_PATTERN.test(resolved.key)) {
306
+ return { ok: false, error: 'invalid_key_format', message: 'MiniMax key does not look right. Expected sk-cp-…, sk-ant-…, or sk-or-… prefix.' };
307
+ }
308
+ if (!force) {
309
+ const cached = readCachedRemains();
310
+ if (cached && cached.apiKeyHint === maskKey(resolved.key) && (Date.now() - cached.fetchedAt) < 60_000) {
311
+ return { ok: true, cached: true, ...cached };
312
+ }
313
+ }
314
+ const urls = resolveBaseUrls();
315
+ const url = `${urls.tokenBase.replace(/\/$/, '')}/v1/token_plan/remains?group_id=${encodeURIComponent(resolved.groupId)}`;
316
+ let resp;
317
+ try {
318
+ resp = await fetchWithTimeout(url, {
319
+ method: 'GET',
320
+ headers: {
321
+ Authorization: `Bearer ${resolved.key}`,
322
+ 'Content-Type': 'application/json',
323
+ Accept: 'application/json',
324
+ },
325
+ });
326
+ } catch (err) {
327
+ return { ok: false, error: 'network_error', message: err.message || 'fetch failed' };
328
+ }
329
+ const text = await resp.text();
330
+ let body = null;
331
+ try { body = text ? JSON.parse(text) : null; } catch { /* keep as null */ }
332
+ if (!resp.ok) {
333
+ return {
334
+ ok: false,
335
+ error: `http_${resp.status}`,
336
+ message: body?.base_resp?.status_msg || resp.statusText || 'request failed',
337
+ status: resp.status,
338
+ };
339
+ }
340
+ if (!body || body.base_resp?.status_code !== 0) {
341
+ return {
342
+ ok: false,
343
+ error: 'api_error',
344
+ message: body?.base_resp?.status_msg || 'unknown error',
345
+ raw: body,
346
+ };
347
+ }
348
+
349
+ // Augment with ISO timestamps + human labels for the UI.
350
+ const models = (body.model_remains || []).map((m) => ({
351
+ ...m,
352
+ endTimeISO: new Date(m.end_time).toISOString(),
353
+ weeklyEndTimeISO: new Date(m.weekly_end_time).toISOString(),
354
+ intervalResetInMs: m.remains_time,
355
+ weeklyResetInMs: m.weekly_remains_time,
356
+ intervalResetInHuman: humanizeDuration(m.remains_time),
357
+ weeklyResetInHuman: humanizeDuration(m.weekly_remains_time),
358
+ intervalConsumedPercent: clamp(100 - (m.current_interval_remaining_percent || 0), 0, 100),
359
+ weeklyConsumedPercent: clamp(100 - (m.current_weekly_remaining_percent || 0), 0, 100),
360
+ intervalUsed: m.current_interval_usage_count || 0,
361
+ intervalTotal: m.current_interval_total_count || 0,
362
+ weeklyUsed: m.current_weekly_usage_count || 0,
363
+ weeklyTotal: m.current_weekly_total_count || 0,
364
+ }));
365
+
366
+ const snapshot = {
367
+ ok: true,
368
+ fetchedAt: Date.now(),
369
+ apiKeyHint: maskKey(resolved.key),
370
+ keySource: resolved.source,
371
+ groupId: resolved.groupId,
372
+ baseUrl: urls.tokenBase,
373
+ models,
374
+ baseResp: body.base_resp,
375
+ };
376
+ memoryCache = snapshot;
377
+ writeDiskCache(snapshot);
378
+ return snapshot;
379
+ }
380
+
381
+ /**
382
+ * Send a single chat-completion to /v1/chat/completions and return
383
+ * the parsed response + parsed `usage` block. Used by the dashboard's
384
+ * "Send a test prompt" button.
385
+ */
386
+ export async function chatCompletion({
387
+ prompt,
388
+ model = 'MiniMax-M3',
389
+ maxTokens = 256,
390
+ } = {}) {
391
+ const resolved = resolveApiKey();
392
+ if (!resolved.key) {
393
+ return { ok: false, error: 'no_api_key', message: 'MiniMax Subscription Key is not configured.' };
394
+ }
395
+ if (!prompt || !prompt.trim()) {
396
+ return { ok: false, error: 'no_prompt', message: 'prompt is empty' };
397
+ }
398
+ const urls = resolveBaseUrls();
399
+ const url = `${urls.chatBase.replace(/\/$/, '')}/chat/completions`;
400
+ const body = JSON.stringify({
401
+ model,
402
+ messages: [{ role: 'user', content: prompt }],
403
+ max_completion_tokens: maxTokens,
404
+ stream: false,
405
+ });
406
+ let resp;
407
+ try {
408
+ resp = await fetchWithTimeout(url, {
409
+ method: 'POST',
410
+ headers: {
411
+ Authorization: `Bearer ${resolved.key}`,
412
+ 'Content-Type': 'application/json',
413
+ Accept: 'application/json',
414
+ },
415
+ body,
416
+ });
417
+ } catch (err) {
418
+ return { ok: false, error: 'network_error', message: err.message || 'fetch failed' };
419
+ }
420
+ const text = await resp.text();
421
+ let data = null;
422
+ try { data = text ? JSON.parse(text) : null; } catch { /* ignore */ }
423
+ if (!resp.ok) {
424
+ return {
425
+ ok: false,
426
+ error: `http_${resp.status}`,
427
+ message: data?.base_resp?.status_msg || resp.statusText || 'request failed',
428
+ status: resp.status,
429
+ raw: data,
430
+ };
431
+ }
432
+ if (!data || data.base_resp?.status_code !== 0) {
433
+ return {
434
+ ok: false,
435
+ error: 'api_error',
436
+ message: data?.base_resp?.status_msg || 'unknown error',
437
+ raw: data,
438
+ };
439
+ }
440
+ const choice = (data.choices || [])[0] || {};
441
+ const content = choice?.message?.content || '';
442
+ return {
443
+ ok: true,
444
+ model: data.model || model,
445
+ content,
446
+ reasoning: choice?.message?.reasoning_content || null,
447
+ finishReason: choice?.finish_reason || null,
448
+ usage: data.usage || null,
449
+ baseResp: data.base_resp,
450
+ };
451
+ }
452
+
453
+ // ─── Helpers ────────────────────────────────────────────────────────────────
454
+
455
+ /** Mask the API key for safe logging / display. Shows last 4 chars. */
456
+ export function maskKey(key) {
457
+ if (!key) return '';
458
+ const trimmed = String(key).trim();
459
+ if (trimmed.length <= 6) return '****';
460
+ return `****${trimmed.slice(-4)}`;
461
+ }
462
+
463
+ /** Convert a millisecond duration into a compact human label. */
464
+ export function humanizeDuration(ms) {
465
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return 'now';
466
+ const sec = Math.floor(ms / 1000);
467
+ const min = Math.floor(sec / 60);
468
+ const hr = Math.floor(min / 60);
469
+ const day = Math.floor(hr / 24);
470
+ if (day >= 1) return `${day}d ${hr % 24}h`;
471
+ if (hr >= 1) return `${hr}h ${min % 60}m`;
472
+ if (min >= 1) return `${min}m`;
473
+ return `${sec}s`;
474
+ }
475
+
476
+ function clamp(n, lo, hi) {
477
+ return Math.max(lo, Math.min(hi, n));
478
+ }
@@ -545,16 +545,20 @@ export const providersStore = {
545
545
  keyPattern: /^sk-[A-Za-z0-9]{20,}$/,
546
546
  },
547
547
  {
548
- // MiniMax accepts ANTHROPIC_API_KEY as a fallback because
549
- // MiniMax's API is Anthropic-format-compatible and many users
550
- // already have an Anthropic key configured. The backup key
551
- // is the second slot keep both as the most-resilient config.
548
+ // v4.5.0 — MiniMax provider, fully implemented. The dashboard
549
+ // onboarding wizard writes the Subscription Key to
550
+ // ~/.local/share/opencode/auth.json (the canonical store written
551
+ // by opencode's `/connect` command). The MiniMax key has a
552
+ // variable-length format; valid prefixes are `sk-cp-` (Token
553
+ // Plan + Coding Plan), `sk-ant-` (Anthropic-format compatible),
554
+ // and `sk-or-` (OpenRouter-style). The chat completions host
555
+ // is `api.minimax.io/v1` (NOT `api.minimax.chat` which 404s).
552
556
  id: 'minimax',
553
557
  name: 'MiniMax',
554
558
  envKeys: ['MINIMAX_API_KEY', 'ANTHROPIC_API_KEY'],
555
559
  backupEnvKeys: ['MINIMAX_API_KEY_BACKUP', 'MINIMAX_BACKUP_API_KEY', 'ANTHROPIC_API_KEY_BACKUP'],
556
- baseURL: 'https://api.minimax.chat/v1',
557
- keyPattern: /^[A-Za-z0-9]{20,}$/,
560
+ baseURL: 'https://api.minimax.io/v1',
561
+ keyPattern: /^sk-(cp|ant|or)-[A-Za-z0-9_-]{20,}$/,
558
562
  },
559
563
  ],
560
564
 
@@ -134,6 +134,20 @@ export const DEFAULT_SETTINGS = {
134
134
  provider: 'opencode',
135
135
  model: 'opencode/deepseek-v4-flash-free',
136
136
  },
137
+ // v4.5.0 — MiniMax Token Plan integration. `apiKey` is the
138
+ // user's Subscription Key from
139
+ // https://platform.minimax.io/user-center/payment/token-plan
140
+ // `groupId` is usually 'default' for an individual team. `baseUrl`
141
+ // is the Token Plan host (the remains endpoint lives on www.*, NOT
142
+ // api.*). `chatBaseUrl` is the chat-completions host (api.*).
143
+ // No key is shipped by default — the user enters their own.
144
+ minimax: {
145
+ enabled: true,
146
+ apiKey: '',
147
+ groupId: 'default',
148
+ baseUrl: 'https://www.minimax.io',
149
+ chatBaseUrl: 'https://api.minimax.io/v1',
150
+ },
137
151
  };
138
152
 
139
153
  /**
@@ -159,6 +173,7 @@ export function mergeSettings(existing) {
159
173
  merged.about = { ...DEFAULT_SETTINGS.about, ...(existing.about || {}) };
160
174
  merged.agents = { ...DEFAULT_SETTINGS.agents, ...(existing.agents || {}) };
161
175
  merged.systemLlm = { ...DEFAULT_SETTINGS.systemLlm, ...(existing.systemLlm || {}) };
176
+ merged.minimax = { ...DEFAULT_SETTINGS.minimax, ...(existing.minimax || {}) };
162
177
  // Always use the package version — never let user settings override it
163
178
  merged.about.version = DEFAULT_SETTINGS.about.version;
164
179
  return merged;