@polderlabs/bizar 4.4.12 → 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-EK_fzXn_.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-BKXEqU1w.css">
35
+ <link rel="stylesheet" crossorigin href="/assets/main-BsnQLXdh.css">
36
36
  </head>
37
37
  <body>
38
38
  <div id="root"></div>
@@ -4,101 +4,175 @@
4
4
  * v4.5.0 — MiniMax API client.
5
5
  *
6
6
  * Owns the network calls to platform.minimax.io for the Bizar
7
- * dashboard. Two surfaces:
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.
8
11
  *
9
- * 1. fetchRemains() — the public `/v1/token_plan/remains` endpoint.
10
- * Returns the user's remaining Token Plan quota per model — both
11
- * 5-hour rolling window and weekly window — including the reset
12
- * times for each. This is the headline data the user wants to see.
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
13
18
  *
14
- * 2. chatCompletion() — a thin wrapper around `/v1/chat/completions`
15
- * (OpenAI-compatible) for the dashboard's "Send a test prompt"
16
- * button. Also captures the `usage` block from the response so the
17
- * dashboard can show per-call consumption.
18
- *
19
- * The client always reads the API key from the user's settings
20
- * (settings.minimax.apiKey) and never logs it. The base URL is
21
- * configurable but defaults to `https://www.minimax.io` per the docs.
22
- *
23
- * Token Plan endpoints live on `www.minimax.io` (NOT `api.minimax.io`
24
- * — the chat completions endpoint uses that one). The default
25
- * `baseUrl` in the settings uses the Token Plan host so `fetchRemains`
26
- * works out of the box.
19
+ * Two surfaces:
20
+ * - fetchRemains() Token Plan quota (5h + weekly per model)
21
+ * - chatCompletion() one-shot chat call for the "test prompt" UI
27
22
  *
28
23
  * All network calls are guarded by a 10s timeout so a hung API
29
- * never wedges the dashboard.
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.
30
27
  */
31
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
28
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, statSync } from 'node:fs';
32
29
  import { dirname, join } from 'node:path';
33
30
  import { homedir } from 'node:os';
34
31
 
35
32
  const HOME = homedir();
36
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');
37
36
  const CACHE_DIR = join(BIZAR_HOME, 'minimax');
38
37
  const CACHE_FILE = join(CACHE_DIR, 'remains-cache.json');
39
38
 
40
- // Default base URL. The docs say `https://www.minimax.io/v1/token_plan/remains`
41
- // (note: the `www` host, not `api`). Allow override per-user in settings.
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
42
  export const DEFAULT_BASE_URL = 'https://www.minimax.io';
43
- // Chat completions / OpenAI-style surface lives on api.minimax.io.
44
43
  export const DEFAULT_CHAT_BASE_URL = 'https://api.minimax.io/v1';
45
44
 
46
- // Models Bizar exposes by default in the dashboard.
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.
47
48
  export const KNOWN_MODELS = [
48
49
  'MiniMax-M3',
49
50
  'MiniMax-M2.7',
51
+ 'MiniMax-M2.7-highspeed',
50
52
  'MiniMax-M2.5',
53
+ 'MiniMax-M2.5-highspeed',
51
54
  'MiniMax-M2.1',
55
+ 'MiniMax-M2.1-highspeed',
52
56
  'MiniMax-M2',
53
57
  ];
54
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
+
55
76
  /**
56
- * Read the merged settings.json and return the MiniMax config block.
57
- * Never throws returns sane defaults on parse failure.
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'
58
87
  */
59
- export function readMinimaxSettings() {
60
- const file = join(BIZAR_HOME, 'settings.json');
61
- if (!existsSync(file)) {
62
- return defaultMinimaxSettings();
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
+ };
63
96
  }
64
- try {
65
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
66
- const m = parsed.minimax;
67
- if (!m || typeof m !== 'object') return defaultMinimaxSettings();
97
+ // 2. Anthropic key works against MiniMax's Anthropic-format surface
98
+ if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
68
99
  return {
69
- enabled: m.enabled !== false,
70
- apiKey: typeof m.apiKey === 'string' ? m.apiKey : '',
71
- groupId: typeof m.groupId === 'string' ? m.groupId : 'default',
72
- baseUrl: typeof m.baseUrl === 'string' ? m.baseUrl : DEFAULT_BASE_URL,
73
- chatBaseUrl: typeof m.chatBaseUrl === 'string' ? m.chatBaseUrl : DEFAULT_CHAT_BASE_URL,
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'),
74
130
  };
75
- } catch {
76
- return defaultMinimaxSettings();
77
131
  }
132
+ return { key: null, source: 'none', groupId: 'default' };
78
133
  }
79
134
 
80
- export function defaultMinimaxSettings() {
81
- return {
82
- enabled: true,
83
- apiKey: '',
84
- groupId: 'default',
85
- baseUrl: DEFAULT_BASE_URL,
86
- chatBaseUrl: DEFAULT_CHAT_BASE_URL,
87
- };
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 };
88
166
  }
89
167
 
90
- // ─── Cache (in-memory + disk) ──────────────────────────────────────────────
168
+ // ─── Cache ────────────────────────────────────────────────────────────────
91
169
 
92
- /**
93
- * In-memory snapshot of the last remains response. Avoids re-hitting the
94
- * API on every dashboard render.
95
- */
96
170
  let memoryCache = null;
97
171
 
98
172
  function readDiskCache() {
99
173
  try {
100
174
  if (!existsSync(CACHE_FILE)) return null;
101
- const stat = require('node:fs').statSync(CACHE_FILE);
175
+ const stat = statSync(CACHE_FILE);
102
176
  if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) return null; // 5 min TTL
103
177
  return JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
104
178
  } catch {
@@ -110,23 +184,16 @@ function writeDiskCache(snapshot) {
110
184
  try {
111
185
  mkdirSync(CACHE_DIR, { recursive: true });
112
186
  writeFileSync(CACHE_FILE, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
113
- } catch {
114
- /* best-effort */
115
- }
187
+ } catch { /* best-effort */ }
116
188
  }
117
189
 
118
190
  export function clearRemainsCache() {
119
191
  memoryCache = null;
120
192
  try {
121
- if (existsSync(CACHE_FILE)) {
122
- require('node:fs').unlinkSync(CACHE_FILE);
123
- }
193
+ if (existsSync(CACHE_FILE)) unlinkSync(CACHE_FILE);
124
194
  } catch { /* best-effort */ }
125
195
  }
126
196
 
127
- /**
128
- * Try in-memory → disk cache. Returns the cached snapshot or null.
129
- */
130
197
  export function readCachedRemains() {
131
198
  if (memoryCache) return memoryCache;
132
199
  const fromDisk = readDiskCache();
@@ -137,12 +204,8 @@ export function readCachedRemains() {
137
204
  return null;
138
205
  }
139
206
 
140
- // ─── Network helpers ──────────────────────────────────────────────────────
207
+ // ─── Network helpers ─────────────────────────────────────────────────────
141
208
 
142
- /**
143
- * Fetch with a hard timeout. Uses undici (Node 18+ global fetch is
144
- * fine but AbortSignal.timeout is simpler with node:fetch).
145
- */
146
209
  async function fetchWithTimeout(url, opts = {}, timeoutMs = 10_000) {
147
210
  const ctl = new AbortController();
148
211
  const t = setTimeout(() => ctl.abort(), timeoutMs);
@@ -153,45 +216,109 @@ async function fetchWithTimeout(url, opts = {}, timeoutMs = 10_000) {
153
216
  }
154
217
  }
155
218
 
156
- // ─── Public API ────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────
157
239
 
158
240
  /**
159
- * Hit /v1/token_plan/remains with the user's API key + group_id.
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)
160
246
  *
161
- * Response shape:
162
- * {
163
- * model_remains: [
164
- * { model_name, current_interval_remaining_percent, current_weekly_remaining_percent,
165
- * start_time, end_time, remains_time, weekly_start_time, weekly_end_time,
166
- * weekly_remains_time, current_interval_total_count, current_interval_usage_count,
167
- * current_weekly_total_count, current_weekly_usage_count,
168
- * current_interval_status, current_weekly_status },
169
- * ...
170
- * ],
171
- * base_resp: { status_code, status_msg }
172
- * }
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.
173
295
  *
174
- * Returns the raw response, augmented with `fetchedAt` and (if we got
175
- * data) the per-model `endTimeISO` / `weeklyEndTimeISO` strings for
176
- * convenient rendering in the UI.
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.
177
299
  */
178
- export async function fetchRemains({ apiKey, groupId = 'default', baseUrl = DEFAULT_BASE_URL, force = false } = {}) {
179
- if (!apiKey || !apiKey.trim()) {
180
- return { ok: false, error: 'no_api_key', message: 'MiniMax API key is not configured' };
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.' };
181
307
  }
182
308
  if (!force) {
183
309
  const cached = readCachedRemains();
184
- if (cached && cached.apiKeyHint === maskKey(apiKey) && (Date.now() - cached.fetchedAt) < 60_000) {
310
+ if (cached && cached.apiKeyHint === maskKey(resolved.key) && (Date.now() - cached.fetchedAt) < 60_000) {
185
311
  return { ok: true, cached: true, ...cached };
186
312
  }
187
313
  }
188
- const url = `${baseUrl.replace(/\/$/, '')}/v1/token_plan/remains?group_id=${encodeURIComponent(groupId)}`;
314
+ const urls = resolveBaseUrls();
315
+ const url = `${urls.tokenBase.replace(/\/$/, '')}/v1/token_plan/remains?group_id=${encodeURIComponent(resolved.groupId)}`;
189
316
  let resp;
190
317
  try {
191
318
  resp = await fetchWithTimeout(url, {
192
319
  method: 'GET',
193
320
  headers: {
194
- Authorization: `Bearer ${apiKey}`,
321
+ Authorization: `Bearer ${resolved.key}`,
195
322
  'Content-Type': 'application/json',
196
323
  Accept: 'application/json',
197
324
  },
@@ -219,8 +346,7 @@ export async function fetchRemains({ apiKey, groupId = 'default', baseUrl = DEFA
219
346
  };
220
347
  }
221
348
 
222
- // Augment with ISO timestamps so the UI doesn't have to do ms → Date
223
- // arithmetic for every cell.
349
+ // Augment with ISO timestamps + human labels for the UI.
224
350
  const models = (body.model_remains || []).map((m) => ({
225
351
  ...m,
226
352
  endTimeISO: new Date(m.end_time).toISOString(),
@@ -240,9 +366,10 @@ export async function fetchRemains({ apiKey, groupId = 'default', baseUrl = DEFA
240
366
  const snapshot = {
241
367
  ok: true,
242
368
  fetchedAt: Date.now(),
243
- apiKeyHint: maskKey(apiKey),
244
- groupId,
245
- baseUrl,
369
+ apiKeyHint: maskKey(resolved.key),
370
+ keySource: resolved.source,
371
+ groupId: resolved.groupId,
372
+ baseUrl: urls.tokenBase,
246
373
  models,
247
374
  baseResp: body.base_resp,
248
375
  };
@@ -254,23 +381,22 @@ export async function fetchRemains({ apiKey, groupId = 'default', baseUrl = DEFA
254
381
  /**
255
382
  * Send a single chat-completion to /v1/chat/completions and return
256
383
  * the parsed response + parsed `usage` block. Used by the dashboard's
257
- * "Send a test prompt" button so the user can verify their key works
258
- * and see live token usage.
384
+ * "Send a test prompt" button.
259
385
  */
260
386
  export async function chatCompletion({
261
- apiKey,
262
387
  prompt,
263
388
  model = 'MiniMax-M3',
264
- baseUrl = DEFAULT_CHAT_BASE_URL,
265
389
  maxTokens = 256,
266
390
  } = {}) {
267
- if (!apiKey || !apiKey.trim()) {
268
- return { ok: false, error: 'no_api_key', message: 'MiniMax API key is not configured' };
391
+ const resolved = resolveApiKey();
392
+ if (!resolved.key) {
393
+ return { ok: false, error: 'no_api_key', message: 'MiniMax Subscription Key is not configured.' };
269
394
  }
270
395
  if (!prompt || !prompt.trim()) {
271
396
  return { ok: false, error: 'no_prompt', message: 'prompt is empty' };
272
397
  }
273
- const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
398
+ const urls = resolveBaseUrls();
399
+ const url = `${urls.chatBase.replace(/\/$/, '')}/chat/completions`;
274
400
  const body = JSON.stringify({
275
401
  model,
276
402
  messages: [{ role: 'user', content: prompt }],
@@ -282,7 +408,7 @@ export async function chatCompletion({
282
408
  resp = await fetchWithTimeout(url, {
283
409
  method: 'POST',
284
410
  headers: {
285
- Authorization: `Bearer ${apiKey}`,
411
+ Authorization: `Bearer ${resolved.key}`,
286
412
  'Content-Type': 'application/json',
287
413
  Accept: 'application/json',
288
414
  },
@@ -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