@polderlabs/bizar 4.4.13 → 4.5.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.
Files changed (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -12,6 +12,24 @@
12
12
  * when the user's opencode.json has no top-level `provider` key.
13
13
  * Also tries the running `opencode serve` HTTP API for the canonical
14
14
  * list. API keys are never echoed back in full — the response masks them.
15
+ *
16
+ * v4.6.0 — Backup keys: each provider now exposes a `keys[]` array with
17
+ * `{envVar, label, status, lastError, errorCount, lastUsed}`. The
18
+ * legacy `apiKey` / `backupApiKey` scalar fields are still read and
19
+ * written for back-compat with opencode itself and existing tests;
20
+ * they are derived from the active key on load. Rotation helpers:
21
+ * - `getActiveKey(providerId)` — highest-priority usable key
22
+ * - `markKeyError(providerId, envVar, error)` — bumps errorCount,
23
+ * demotes to `cooldown` after ERROR_COOLDOWN_THRESHOLD errors
24
+ * - `rotateKey(providerId)` — moves to the next key, marks the
25
+ * current one `disabled`
26
+ * - `withKeyRotation(providerId, fn)` — runs `fn(key, envVar)`; on
27
+ * retryable errors (auth/429/quota/5xx) marks the current key as
28
+ * errored, rotates, and retries up to MAX_ROTATION_ATTEMPTS times
29
+ *
30
+ * v4.6.0 — Provider catalog: PROVIDER_CATALOG is a curated list of
31
+ * well-known providers with `keyHint` + `docs` + curated `models[]`.
32
+ * `searchProviders(query)` does a fuzzy match across id/name/docs.
15
33
  */
16
34
  import {
17
35
  existsSync,
@@ -57,6 +75,754 @@ function saveConfig(data) {
57
75
  atomicWriteJson(OPENCODE_JSON, data);
58
76
  }
59
77
 
78
+ // v4.6.0 — expose the on-disk load/save helpers so route modules can
79
+ // patch the same opencode.json (e.g. the /api/llm/system-llm endpoint
80
+ // in routes/config.mjs). Prior sessions had a blocker where these
81
+ // were not exported and config.mjs referenced them implicitly.
82
+ export { loadConfig, saveConfig };
83
+
84
+ // ── v4.6.0 Backup-key rotation constants ────────────────────────────────────
85
+ //
86
+ // ERROR_COOLDOWN_THRESHOLD: after this many recorded errors on the same
87
+ // envVar, the key is demoted to `cooldown` and `withKeyRotation` skips
88
+ // it. The operator can re-enable it via PUT
89
+ // /api/providers/:id/keys/:envVar/status with `{status: 'active'}` once
90
+ // the upstream quota/rate-limit window has reset.
91
+ //
92
+ // MAX_ROTATION_ATTEMPTS: bound on the number of times `withKeyRotation`
93
+ // will retry the user's fn with a different key before throwing. With
94
+ // `cooldown` skipping, this matches the typical "1 primary + 1 backup"
95
+ // deployment (2 attempts) with headroom for a 3rd emergency key.
96
+ //
97
+ // ROTATION_ERROR_PATTERNS: substring match against the lowercased error
98
+ // message — used as a fallback when no structured `status` is present
99
+ // (e.g. opencode-runner's pre-throw errors). Order matters only for
100
+ // readability — first match wins.
101
+ const ERROR_COOLDOWN_THRESHOLD = 3;
102
+ const MAX_ROTATION_ATTEMPTS = 5;
103
+ const ROTATION_ERROR_PATTERNS = [
104
+ /rate[-_ ]?limit/i,
105
+ /too many requests/i,
106
+ /\b429\b/,
107
+ /\bquota\b/i,
108
+ /\binsufficient[_ ]?credits?\b/i,
109
+ /\bunauthorized\b/i,
110
+ /\b401\b/,
111
+ /\b403\b/,
112
+ /\binvalid[_ ]?api[_ ]?key\b/i,
113
+ /\bforbidden\b/i,
114
+ /\b5\d{2}\b/, // 5xx
115
+ ];
116
+
117
+ /**
118
+ * Heuristic: is `err` a retryable error for key rotation?
119
+ *
120
+ * Accepts an Error-shaped value, a structured `{ok:false, error, status}`,
121
+ * or a plain string. Returns true for:
122
+ * - HTTP status in {401, 403, 408, 409, 425, 429} (auth/rate-limit)
123
+ * - HTTP status in {500..599} (server errors)
124
+ * - error code starting with `http_4xx` / `http_5xx` (the convention
125
+ * minimax.mjs uses)
126
+ * - messages matching ROTATION_ERROR_PATTERNS
127
+ *
128
+ * Network errors (ECONNRESET, fetch failed, timeout) are NOT retryable
129
+ * here — they'd fail with the next key too, and we want to surface
130
+ * them quickly.
131
+ *
132
+ * @param {unknown} err
133
+ * @returns {boolean}
134
+ */
135
+ export function isRetryableError(err) {
136
+ if (!err) return false;
137
+ // Structured minimax.mjs result: {ok:false, error:'http_429', status:429}
138
+ const e = /** @type {any} */ (err);
139
+ const status = typeof e.status === 'number' ? e.status : null;
140
+ if (status !== null) {
141
+ if (status === 401 || status === 403 || status === 408 || status === 409 || status === 425 || status === 429) return true;
142
+ if (status >= 500 && status < 600) return true;
143
+ }
144
+ const code = typeof e.error === 'string' ? e.error : '';
145
+ if (/^http_(401|403|408|409|425|429)$/.test(code)) return true;
146
+ if (/^http_5\d{2}$/.test(code)) return true;
147
+ // Fall back to message-substring match — covers opencode-runner
148
+ // errors and unknown callers.
149
+ const msg = (e.message || e.toString?.() || '').toString();
150
+ if (!msg) return false;
151
+ // Network errors are explicitly NOT retryable.
152
+ if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|network error|abort(ed)?/i.test(msg)) {
153
+ return false;
154
+ }
155
+ return ROTATION_ERROR_PATTERNS.some((re) => re.test(msg));
156
+ }
157
+
158
+ // ── v4.6.0 keys[] shape helpers ─────────────────────────────────────────────
159
+ //
160
+ // Each provider entry may have a `keys[]` array. Every entry is
161
+ // { envVar, label, status, lastError, errorCount, lastUsed }
162
+ // where:
163
+ // - envVar: the env var name the caller reads at request time
164
+ // (e.g. "BIZAR_MINIMAX_KEY"). The actual key value is
165
+ // NEVER stored — we always go through env at request time.
166
+ // - label: free-text ("Primary", "Backup", "Work laptop")
167
+ // - status: 'active' | 'standby' | 'disabled' | 'cooldown'
168
+ // - active — used by default
169
+ // - standby — rotated to when the active key fails
170
+ // - disabled — operator disabled, never auto-selected
171
+ // - cooldown — auto-disabled after ERROR_COOLDOWN_THRESHOLD errors
172
+ // - lastError: string | null — last error message recorded
173
+ // - errorCount: integer — total errors recorded
174
+ // - lastUsed: number | null — epoch ms of last successful use
175
+ //
176
+ // The legacy `apiKey`/`backupApiKey` string fields are still kept on disk
177
+ // for back-compat with opencode itself (which reads them) and the
178
+ // existing test suite. `migrateKeysShape()` synthesizes a `keys[]` from
179
+ // those fields whenever the provider is loaded.
180
+
181
+ const VALID_KEY_STATUSES = new Set(['active', 'standby', 'disabled', 'cooldown']);
182
+
183
+ /**
184
+ * Build a normalized `keys[]` array from a raw provider entry.
185
+ * Pure function — no side effects. Used by loadConfig() to normalize.
186
+ *
187
+ * @param {Record<string, any> | null | undefined} p
188
+ * @returns {Array<{envVar: string, label: string, status: string, lastError: string|null, errorCount: number, lastUsed: number|null}>}
189
+ */
190
+ export function keysFromProvider(p) {
191
+ if (!p || typeof p !== 'object') return [];
192
+ if (Array.isArray(p.keys) && p.keys.length > 0) {
193
+ return p.keys
194
+ .filter((k) => k && typeof k === 'object' && typeof k.envVar === 'string')
195
+ .map((k) => ({
196
+ envVar: String(k.envVar),
197
+ label: typeof k.label === 'string' ? k.label : 'Key',
198
+ status: VALID_KEY_STATUSES.has(k.status) ? k.status : 'standby',
199
+ lastError: typeof k.lastError === 'string' ? k.lastError : null,
200
+ errorCount: Number.isFinite(k.errorCount) ? Number(k.errorCount) : 0,
201
+ lastUsed: Number.isFinite(k.lastUsed) ? Number(k.lastUsed) : null,
202
+ }));
203
+ }
204
+ // Migrate legacy shape: apiKey/backupApiKey → keys[]
205
+ const out = [];
206
+ if (p.apiKey) {
207
+ out.push({
208
+ envVar: '',
209
+ label: 'Primary',
210
+ status: 'active',
211
+ lastError: null,
212
+ errorCount: 0,
213
+ lastUsed: null,
214
+ });
215
+ }
216
+ if (p.backupApiKey) {
217
+ out.push({
218
+ envVar: '',
219
+ label: 'Backup',
220
+ status: 'standby',
221
+ lastError: null,
222
+ errorCount: 0,
223
+ lastUsed: null,
224
+ });
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Sync the legacy `apiKey`/`backupApiKey` fields from the `keys[]`
231
+ * array. The active key becomes `apiKey`; the first standby key
232
+ * becomes `backupApiKey`. Other entries are not persisted to the
233
+ * legacy shape (they only live in `keys[]`).
234
+ *
235
+ * Returns a new provider object — pure function. Callers that mutate
236
+ * the cfg in place should assign the result back.
237
+ *
238
+ * @param {Record<string, any>} p
239
+ * @returns {Record<string, any>}
240
+ */
241
+ export function syncLegacyKeys(p) {
242
+ if (!p || typeof p !== 'object') return p;
243
+ if (!Array.isArray(p.keys) || p.keys.length === 0) return p;
244
+ const next = { ...p };
245
+ const active = p.keys.find((k) => k.status === 'active');
246
+ const standby = p.keys.find((k) => k.status === 'standby');
247
+ // We DO NOT write the actual key value to `apiKey` — that lives in
248
+ // the env var. We only mirror whether a key is configured (truthy
249
+ // string) so opencode's `options.apiKey` check sees a key present.
250
+ // Existing code that reads `apiKey`/`backupApiKey` and treats
251
+ // truthy-as-configured continues to work.
252
+ next.apiKey = active && active.envVar ? `<env:${active.envVar}>` : '';
253
+ next.backupApiKey = standby && standby.envVar ? `<env:${standby.envVar}>` : '';
254
+ return next;
255
+ }
256
+
257
+ // ── v4.6.0 Provider catalog ─────────────────────────────────────────────────
258
+ //
259
+ // Curated list of well-known providers. The dashboard's "Add provider"
260
+ // wizard surfaces this so the user can pick instead of typing every
261
+ // field. The catalog entry's `keyPattern` is a RegExp that rejects keys
262
+ // of the wrong shape (e.g. non-sk- prefix for OpenAI) before they reach
263
+ // the disk.
264
+ //
265
+ // `keyHint` is the human-readable example shown next to the paste box.
266
+ // `docs` is the marketing/docs URL surfaced as a link. `models` is the
267
+ // curated short list (the live probe may add more on top of this).
268
+
269
+ export const PROVIDER_CATALOG = Object.freeze([
270
+ Object.freeze({
271
+ id: 'opencode',
272
+ name: 'OpenCode Zen',
273
+ baseURL: 'https://opencode.ai/zen/v1',
274
+ keyPattern: /^sk-[A-Za-z0-9]{20,}$/,
275
+ keyHint: 'sk-…',
276
+ docs: 'https://opencode.ai/zen',
277
+ models: [
278
+ 'gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.4-pro', 'gpt-5.4-mini', 'gpt-5.4-nano',
279
+ 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2', 'gpt-5.2-codex',
280
+ 'gpt-5.1', 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
281
+ 'gpt-5', 'gpt-5-codex', 'gpt-5-nano',
282
+ 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-opus-4-5',
283
+ 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-haiku-4-5',
284
+ 'gemini-3.5-flash', 'gemini-3.1-pro', 'gemini-3-flash',
285
+ 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.5-plus',
286
+ 'deepseek-v4-pro', 'deepseek-v4-flash',
287
+ 'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
288
+ 'glm-5.2', 'glm-5.1', 'glm-5',
289
+ 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5',
290
+ 'grok-build-0.1',
291
+ 'big-pickle', 'mimo-v2.5-free', 'north-mini-code-free', 'nemotron-3-ultra-free', 'deepseek-v4-flash-free',
292
+ ],
293
+ requiresKey: true,
294
+ }),
295
+ Object.freeze({
296
+ id: 'anthropic',
297
+ name: 'Anthropic',
298
+ baseURL: 'https://api.anthropic.com/v1',
299
+ keyPattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/,
300
+ keyHint: 'sk-ant-…',
301
+ docs: 'https://docs.anthropic.com',
302
+ models: [
303
+ 'claude-fable-5',
304
+ 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-opus-4-5',
305
+ 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-haiku-4-5',
306
+ ],
307
+ requiresKey: true,
308
+ }),
309
+ Object.freeze({
310
+ id: 'openai',
311
+ name: 'OpenAI',
312
+ baseURL: 'https://api.openai.com/v1',
313
+ keyPattern: /^sk-[A-Za-z0-9]{20,}$/,
314
+ keyHint: 'sk-…',
315
+ docs: 'https://platform.openai.com',
316
+ models: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5', 'gpt-5-codex', 'gpt-5-nano', 'gpt-4.1', 'gpt-4o'],
317
+ requiresKey: true,
318
+ }),
319
+ Object.freeze({
320
+ id: 'google',
321
+ name: 'Google Gemini',
322
+ baseURL: 'https://generativelanguage.googleapis.com/v1beta',
323
+ keyPattern: /^AIza[A-Za-z0-9_-]{30,}$/,
324
+ keyHint: 'AIza…',
325
+ docs: 'https://ai.google.dev',
326
+ models: ['gemini-3.5-flash', 'gemini-3.1-pro', 'gemini-3-flash', 'gemini-2.5-pro'],
327
+ requiresKey: true,
328
+ }),
329
+ Object.freeze({
330
+ id: 'minimax',
331
+ name: 'MiniMax',
332
+ baseURL: 'https://api.minimax.io/v1',
333
+ keyPattern: /^sk-(cp|ant|or)-[A-Za-z0-9_-]{20,}$/,
334
+ keyHint: 'sk-cp-…, sk-ant-…, or sk-or-…',
335
+ docs: 'https://api.minimax.io',
336
+ models: [
337
+ 'MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed',
338
+ 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1',
339
+ 'MiniMax-M2.1-highspeed', 'MiniMax-M2',
340
+ ],
341
+ requiresKey: true,
342
+ }),
343
+ Object.freeze({
344
+ id: 'groq',
345
+ name: 'Groq',
346
+ baseURL: 'https://api.groq.com/openai/v1',
347
+ keyPattern: /^gsk_[A-Za-z0-9]{20,}$/,
348
+ keyHint: 'gsk_…',
349
+ docs: 'https://console.groq.com',
350
+ models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
351
+ requiresKey: true,
352
+ }),
353
+ Object.freeze({
354
+ id: 'mistral',
355
+ name: 'Mistral',
356
+ baseURL: 'https://api.mistral.ai/v1',
357
+ keyPattern: /^[A-Za-z0-9]{20,}$/,
358
+ keyHint: '20+ alphanumeric chars',
359
+ docs: 'https://docs.mistral.ai',
360
+ models: ['mistral-large-latest', 'mistral-medium-latest', 'mistral-small-latest', 'codestral-latest'],
361
+ requiresKey: true,
362
+ }),
363
+ Object.freeze({
364
+ id: 'cohere',
365
+ name: 'Cohere',
366
+ baseURL: 'https://api.cohere.ai/v1',
367
+ keyPattern: /^[A-Za-z0-9-]{20,}$/,
368
+ keyHint: '20+ alphanumeric/dash chars',
369
+ docs: 'https://docs.cohere.com',
370
+ models: ['command-r-plus', 'command-r', 'command', 'embed-english-v3.0'],
371
+ requiresKey: true,
372
+ }),
373
+ Object.freeze({
374
+ id: 'openrouter',
375
+ name: 'OpenRouter',
376
+ baseURL: 'https://openrouter.ai/api/v1',
377
+ keyPattern: /^sk-or-[A-Za-z0-9_-]{20,}$/,
378
+ keyHint: 'sk-or-…',
379
+ docs: 'https://openrouter.ai',
380
+ models: ['openai/gpt-5', 'anthropic/claude-opus-4-7', 'google/gemini-3-pro'],
381
+ requiresKey: true,
382
+ }),
383
+ Object.freeze({
384
+ id: 'deepseek',
385
+ name: 'DeepSeek',
386
+ baseURL: 'https://api.deepseek.com/v1',
387
+ keyPattern: /^sk-[A-Za-z0-9]{20,}$/,
388
+ keyHint: 'sk-…',
389
+ docs: 'https://platform.deepseek.com',
390
+ models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-coder'],
391
+ requiresKey: true,
392
+ }),
393
+ Object.freeze({
394
+ id: 'ollama',
395
+ name: 'Ollama (local)',
396
+ baseURL: 'http://localhost:11434/v1',
397
+ keyPattern: /^.*$/,
398
+ keyHint: 'no key needed (any value works)',
399
+ docs: 'https://ollama.com',
400
+ models: ['llama3.3', 'qwen2.5', 'mistral', 'codellama', 'phi3'],
401
+ requiresKey: false,
402
+ }),
403
+ Object.freeze({
404
+ id: 'lmstudio',
405
+ name: 'LM Studio (local)',
406
+ baseURL: 'http://localhost:1234/v1',
407
+ keyPattern: /^.*$/,
408
+ keyHint: 'no key needed (any value works)',
409
+ docs: 'https://lmstudio.ai',
410
+ models: [],
411
+ requiresKey: false,
412
+ }),
413
+ Object.freeze({
414
+ id: 'custom',
415
+ name: 'Custom OpenAI-compatible',
416
+ baseURL: '',
417
+ keyPattern: /^.*$/,
418
+ keyHint: 'paste your key (any format)',
419
+ docs: '',
420
+ models: [],
421
+ requiresKey: true,
422
+ }),
423
+ ]);
424
+
425
+ /**
426
+ * Find a catalog entry by id.
427
+ *
428
+ * @param {string} id
429
+ * @returns {Record<string, any> | null}
430
+ */
431
+ export function findCatalogEntry(id) {
432
+ if (!id || typeof id !== 'string') return null;
433
+ return PROVIDER_CATALOG.find((p) => p.id === id) || null;
434
+ }
435
+
436
+ /**
437
+ * Project a catalog entry to the safe shape returned to the UI.
438
+ * Strips `keyPattern` (a RegExp) so it survives JSON.stringify.
439
+ *
440
+ * @param {Record<string, any>} entry
441
+ */
442
+ function catalogPublicShape(entry) {
443
+ return {
444
+ id: entry.id,
445
+ name: entry.name,
446
+ baseURL: entry.baseURL,
447
+ keyHint: entry.keyHint,
448
+ docs: entry.docs,
449
+ models: Array.isArray(entry.models) ? entry.models.slice() : [],
450
+ requiresKey: entry.requiresKey !== false,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Return the full catalog in the UI-safe shape.
456
+ *
457
+ * @returns {Array<{id, name, baseURL, keyHint, docs, models, requiresKey}>}
458
+ */
459
+ export function listCatalog() {
460
+ return PROVIDER_CATALOG.map(catalogPublicShape);
461
+ }
462
+
463
+ /**
464
+ * Fuzzy-search the provider catalog. Match against id, name, and docs
465
+ * using case-insensitive substring matching with a simple relevance
466
+ * score. Empty query returns the full catalog in id order.
467
+ *
468
+ * @param {string} query
469
+ * @param {{ limit?: number }} [opts]
470
+ * @returns {Array<{id, name, baseURL, keyHint, docs, models, requiresKey, score: number}>}
471
+ */
472
+ export function searchProviders(query, { limit = 20 } = {}) {
473
+ if (!query || typeof query !== 'string' || !query.trim()) {
474
+ return listCatalog().slice(0, limit);
475
+ }
476
+ const q = query.trim().toLowerCase();
477
+ const tokens = q.split(/\s+/).filter((t) => t.length >= 1);
478
+ const out = [];
479
+ for (const entry of PROVIDER_CATALOG) {
480
+ let score = 0;
481
+ const id = entry.id.toLowerCase();
482
+ const name = entry.name.toLowerCase();
483
+ const docs = (entry.docs || '').toLowerCase();
484
+ const models = (entry.models || []).map((m) => String(m).toLowerCase());
485
+ for (const t of tokens) {
486
+ if (id === t) score += 100;
487
+ else if (id.startsWith(t)) score += 30;
488
+ else if (id.includes(t)) score += 10;
489
+ if (name.startsWith(t)) score += 20;
490
+ else if (name.includes(t)) score += 8;
491
+ if (docs.includes(t)) score += 3;
492
+ // Model name matches — lower weight because models are noisy
493
+ // (e.g. "gpt" matches lots of providers), but useful for the
494
+ // "I want a provider that offers claude" use case.
495
+ for (const m of models) {
496
+ if (m === t) score += 15;
497
+ else if (m.startsWith(t)) score += 5;
498
+ else if (m.includes(t)) score += 2;
499
+ }
500
+ }
501
+ if (score > 0) {
502
+ out.push({ ...catalogPublicShape(entry), score });
503
+ }
504
+ }
505
+ out.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
506
+ return out.slice(0, limit);
507
+ }
508
+
509
+ // ── v4.6.0 Rotation exports (top-level so they're importable) ────────────────
510
+
511
+ function getRawProvider(providerId) {
512
+ const cfg = loadConfig();
513
+ const p = cfg?.provider?.[providerId];
514
+ return p ? { cfg, provider: p } : null;
515
+ }
516
+
517
+ /**
518
+ * Return the highest-priority usable key for `providerId`.
519
+ *
520
+ * Priority order:
521
+ * 1. first key with status === 'active'
522
+ * 2. first key with status === 'standby'
523
+ * 3. first key with status === 'cooldown' (better than nothing)
524
+ *
525
+ * Returns `{envVar, label, status, key}` where `key` is the resolved
526
+ * value from `process.env[envVar]` (empty string if not set). Returns
527
+ * `null` if no key is configured at all.
528
+ *
529
+ * @param {string} providerId
530
+ * @returns {{envVar: string, label: string, status: string, key: string} | null}
531
+ */
532
+ export function getActiveKey(providerId) {
533
+ const got = getRawProvider(providerId);
534
+ if (!got) return null;
535
+ const keys = keysFromProvider(got.provider);
536
+ if (keys.length === 0) return null;
537
+ const order = ['active', 'standby', 'cooldown'];
538
+ for (const want of order) {
539
+ const k = keys.find((kk) => kk.status === want);
540
+ if (k) {
541
+ return {
542
+ envVar: k.envVar,
543
+ label: k.label,
544
+ status: k.status,
545
+ key: process.env[k.envVar] || '',
546
+ };
547
+ }
548
+ }
549
+ // All keys disabled — fall through to the first one (manual override)
550
+ const first = keys[0];
551
+ return {
552
+ envVar: first.envVar,
553
+ label: first.label,
554
+ status: first.status,
555
+ key: process.env[first.envVar] || '',
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Persist a change to the provider's `keys[]` array.
561
+ * Re-syncs the legacy `apiKey`/`backupApiKey` fields and writes the
562
+ * entire config back atomically.
563
+ *
564
+ * @param {string} providerId
565
+ * @param {(keys: Array) => Array} mutator
566
+ */
567
+ function updateKeys(providerId, mutator) {
568
+ const cfg = loadConfig();
569
+ cfg.provider = cfg.provider || {};
570
+ const cur = cfg.provider[providerId];
571
+ if (!cur) throw new Error(`provider "${providerId}" not found`);
572
+ const nextKeys = mutator(keysFromProvider(cur));
573
+ const nextProvider = { ...cur, keys: nextKeys };
574
+ cfg.provider[providerId] = syncLegacyKeys(nextProvider);
575
+ saveConfig(cfg);
576
+ return cfg.provider[providerId];
577
+ }
578
+
579
+ /**
580
+ * Record an error on a specific key. Bumps `errorCount`, sets
581
+ * `lastError`, and demotes `status` to `cooldown` once the threshold
582
+ * is hit. Returns the (possibly updated) key entry.
583
+ *
584
+ * @param {string} providerId
585
+ * @param {string} envVar
586
+ * @param {string | Error} error
587
+ * @returns {{envVar: string, label: string, status: string, lastError: string|null, errorCount: number, lastUsed: number|null} | null}
588
+ */
589
+ export function markKeyError(providerId, envVar, error) {
590
+ if (!providerId || !envVar) return null;
591
+ const errMsg = error instanceof Error ? error.message : String(error || '');
592
+ return updateKeys(providerId, (keys) => {
593
+ const idx = keys.findIndex((k) => k.envVar === envVar);
594
+ if (idx === -1) return keys; // unknown envVar — no-op
595
+ const cur = keys[idx];
596
+ const errorCount = (cur.errorCount || 0) + 1;
597
+ const status = errorCount >= ERROR_COOLDOWN_THRESHOLD ? 'cooldown' : cur.status;
598
+ const next = {
599
+ ...cur,
600
+ errorCount,
601
+ lastError: errMsg.slice(0, 500),
602
+ status,
603
+ };
604
+ const out = keys.slice();
605
+ out[idx] = next;
606
+ return out;
607
+ })?.keys?.find((k) => k.envVar === envVar) || null;
608
+ }
609
+
610
+ /**
611
+ * Mark a key as used successfully (clears errorCount).
612
+ *
613
+ * @param {string} providerId
614
+ * @param {string} envVar
615
+ */
616
+ export function markKeySuccess(providerId, envVar) {
617
+ if (!providerId || !envVar) return;
618
+ updateKeys(providerId, (keys) => {
619
+ const idx = keys.findIndex((k) => k.envVar === envVar);
620
+ if (idx === -1) return keys;
621
+ const cur = keys[idx];
622
+ const out = keys.slice();
623
+ out[idx] = {
624
+ ...cur,
625
+ errorCount: 0,
626
+ lastError: null,
627
+ lastUsed: Date.now(),
628
+ };
629
+ return out;
630
+ });
631
+ }
632
+
633
+ /**
634
+ * Rotate to the next usable key. Marks the current active key as
635
+ * `disabled` and promotes the first `standby` (or any non-active
636
+ * non-disabled key) to `active`. Returns the new active key, or null
637
+ * if no keys remain.
638
+ *
639
+ * @param {string} providerId
640
+ * @returns {{envVar: string, label: string, status: string} | null}
641
+ */
642
+ export function rotateKey(providerId) {
643
+ const got = getRawProvider(providerId);
644
+ if (!got) return null;
645
+ let result = null;
646
+ updateKeys(providerId, (keys) => {
647
+ if (keys.length === 0) return keys;
648
+ const curIdx = keys.findIndex((k) => k.status === 'active');
649
+ const out = keys.map((k) => ({ ...k }));
650
+ if (curIdx !== -1) out[curIdx] = { ...out[curIdx], status: 'disabled' };
651
+ // Find next candidate: prefer standby, then anything not already
652
+ // active/disabled. We avoid `cooldown` if there are other options.
653
+ const nextIdx = out.findIndex((k, i) => i !== curIdx && k.status === 'standby');
654
+ const fallbackIdx = nextIdx === -1
655
+ ? out.findIndex((k, i) => i !== curIdx && k.status !== 'disabled' && k.status !== 'active')
656
+ : -1;
657
+ const promoteIdx = nextIdx !== -1 ? nextIdx : fallbackIdx;
658
+ if (promoteIdx === -1) return out; // nothing to rotate to
659
+ out[promoteIdx] = { ...out[promoteIdx], status: 'active' };
660
+ result = {
661
+ envVar: out[promoteIdx].envVar,
662
+ label: out[promoteIdx].label,
663
+ status: 'active',
664
+ };
665
+ return out;
666
+ });
667
+ return result;
668
+ }
669
+
670
+ /**
671
+ * Add a backup key to a provider. Creates the `keys[]` array from the
672
+ * legacy `apiKey` if it doesn't exist yet. Returns the inserted key
673
+ * entry.
674
+ *
675
+ * @param {string} providerId
676
+ * @param {string} envVar
677
+ * @param {string} [label]
678
+ * @returns {{envVar: string, label: string, status: string}}
679
+ */
680
+ export function addBackupKey(providerId, envVar, label) {
681
+ if (!providerId) throw new Error('providerId required');
682
+ if (!envVar || typeof envVar !== 'string') throw new Error('envVar required');
683
+ let inserted = null;
684
+ updateKeys(providerId, (keys) => {
685
+ if (keys.some((k) => k.envVar === envVar)) {
686
+ throw new Error(`key with envVar "${envVar}" already exists`);
687
+ }
688
+ // New key is standby by default; the active key stays primary.
689
+ const next = [
690
+ ...keys,
691
+ {
692
+ envVar,
693
+ label: typeof label === 'string' && label.trim() ? label.trim() : `Backup ${keys.length}`,
694
+ status: 'standby',
695
+ lastError: null,
696
+ errorCount: 0,
697
+ lastUsed: null,
698
+ },
699
+ ];
700
+ inserted = next[next.length - 1];
701
+ return next;
702
+ });
703
+ return inserted;
704
+ }
705
+
706
+ /**
707
+ * Remove a key from a provider. Refuses to remove the last remaining
708
+ * key (would leave the provider with no way to authenticate).
709
+ *
710
+ * @param {string} providerId
711
+ * @param {string} envVar
712
+ * @returns {boolean} true if removed
713
+ */
714
+ export function removeBackupKey(providerId, envVar) {
715
+ if (!providerId || !envVar) throw new Error('providerId and envVar required');
716
+ let removed = false;
717
+ try {
718
+ updateKeys(providerId, (keys) => {
719
+ if (keys.length <= 1) {
720
+ throw new Error('cannot remove the last key — at least one key must remain');
721
+ }
722
+ const next = keys.filter((k) => k.envVar !== envVar);
723
+ if (next.length === keys.length) {
724
+ throw new Error(`key with envVar "${envVar}" not found`);
725
+ }
726
+ removed = true;
727
+ return next;
728
+ });
729
+ } catch (err) {
730
+ if (removed) throw err; // re-throw real errors
731
+ throw err;
732
+ }
733
+ return removed;
734
+ }
735
+
736
+ /**
737
+ * Set a key's status manually (operator override).
738
+ *
739
+ * @param {string} providerId
740
+ * @param {string} envVar
741
+ * @param {'active'|'standby'|'disabled'|'cooldown'} status
742
+ */
743
+ export function setKeyStatus(providerId, envVar, status) {
744
+ if (!VALID_KEY_STATUSES.has(status)) {
745
+ throw new Error(`invalid status "${status}"`);
746
+ }
747
+ return updateKeys(providerId, (keys) => {
748
+ const idx = keys.findIndex((k) => k.envVar === envVar);
749
+ if (idx === -1) throw new Error(`key with envVar "${envVar}" not found`);
750
+ const out = keys.slice();
751
+ // When promoting to active, demote any other active key.
752
+ if (status === 'active') {
753
+ for (let i = 0; i < out.length; i++) {
754
+ if (i !== idx && out[i].status === 'active') {
755
+ out[i] = { ...out[i], status: 'standby' };
756
+ }
757
+ }
758
+ }
759
+ const cur = out[idx];
760
+ out[idx] = {
761
+ ...cur,
762
+ status,
763
+ // Resetting status clears the error bookkeeping.
764
+ errorCount: status === 'active' ? 0 : cur.errorCount,
765
+ lastError: status === 'active' ? null : cur.lastError,
766
+ };
767
+ return out;
768
+ })?.keys?.find((k) => k.envVar === envVar) || null;
769
+ }
770
+
771
+ /**
772
+ * Run `fn(key, envVar)` against the active key. On a retryable error
773
+ * (`isRetryableError`), mark the error and rotate to the next key —
774
+ * retrying up to `MAX_ROTATION_ATTEMPTS` times. Returns the first
775
+ * successful result, or throws the last error if all keys failed.
776
+ *
777
+ * Usage:
778
+ * const text = await withKeyRotation('minimax', async (key, envVar) => {
779
+ * return await callProvider(apiKey=key);
780
+ * });
781
+ *
782
+ * @template T
783
+ * @param {string} providerId
784
+ * @param {(key: string, envVar: string) => Promise<T>} fn
785
+ * @param {{ maxAttempts?: number, isRetryable?: (err: unknown) => boolean }} [opts]
786
+ * @returns {Promise<T>}
787
+ */
788
+ export async function withKeyRotation(providerId, fn, opts = {}) {
789
+ if (typeof fn !== 'function') throw new Error('fn must be a function');
790
+ const maxAttempts = Math.max(1, Math.min(opts.maxAttempts || MAX_ROTATION_ATTEMPTS, 16));
791
+ const retryable = opts.isRetryable || isRetryableError;
792
+ const tried = new Set();
793
+ let lastErr = null;
794
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
795
+ const active = getActiveKey(providerId);
796
+ if (!active) {
797
+ const err = new Error(`no keys configured for provider "${providerId}"`);
798
+ err.code = 'no_keys';
799
+ throw err;
800
+ }
801
+ if (tried.has(active.envVar)) {
802
+ // We've already tried this envVar — no more rotation candidates.
803
+ break;
804
+ }
805
+ tried.add(active.envVar);
806
+ try {
807
+ const result = await fn(active.key, active.envVar);
808
+ // Success — clear error bookkeeping on the key we used.
809
+ markKeySuccess(providerId, active.envVar);
810
+ return result;
811
+ } catch (err) {
812
+ lastErr = err;
813
+ if (!retryable(err)) throw err;
814
+ markKeyError(providerId, active.envVar, err);
815
+ const rotated = rotateKey(providerId);
816
+ if (!rotated) break;
817
+ }
818
+ }
819
+ // Exhausted all candidates
820
+ const err = lastErr instanceof Error ? lastErr : new Error(String(lastErr));
821
+ err.code = err.code || 'rotation_exhausted';
822
+ err.attempts = tried.size;
823
+ throw err;
824
+ }
825
+
60
826
  function mask(value) {
61
827
  if (typeof value !== 'string' || !value) return '';
62
828
  // Short keys (≤8 chars) get a distinct indicator so the UI can show
@@ -106,8 +872,14 @@ export const providersStore = {
106
872
  id,
107
873
  name: p.name || id,
108
874
  baseURL: p.baseURL || p.options?.baseURL || '',
875
+ // Legacy back-compat fields — read from disk as-is so existing
876
+ // tests pass and the UI keeps working. These are the actual key
877
+ // values, masked for display.
109
878
  apiKey: mask(p.apiKey || p.options?.apiKey || ''),
110
879
  backupApiKey: mask(p.backupApiKey || p.options?.backupApiKey || ''),
880
+ // v4.6.0 — full keys[] array. Synthesized from apiKey/backupApiKey
881
+ // if absent; never echoes the key value itself.
882
+ keys: keysFromProvider(p),
111
883
  models: Array.isArray(p.models) ? p.models : [],
112
884
  enabled: p.enabled !== false,
113
885
  }));
@@ -141,6 +913,7 @@ export const providersStore = {
141
913
  baseURL: '',
142
914
  apiKey: '',
143
915
  backupApiKey: '',
916
+ keys: [],
144
917
  models: [],
145
918
  enabled: true,
146
919
  source: '',
@@ -175,6 +948,8 @@ export const providersStore = {
175
948
  baseURL: p.baseURL || p.options?.baseURL || '',
176
949
  apiKey: mask(p.apiKey || p.options?.apiKey || ''),
177
950
  backupApiKey: mask(p.backupApiKey || p.options?.backupApiKey || ''),
951
+ // v4.6.0 — include keys[] alongside the legacy fields
952
+ keys: keysFromProvider(p),
178
953
  models: (Array.isArray(p.models) ? p.models : []).map((m) => ({
179
954
  id: typeof m === 'string' ? m : m.id || m.name || String(m),
180
955
  name: typeof m === 'string' ? m : m.name || m.id || String(m),
@@ -413,6 +1188,19 @@ export const providersStore = {
413
1188
  // can swap them manually via the dashboard if the primary hits
414
1189
  // a rate limit or quota.
415
1190
  backupApiKey: input.backupApiKey || '',
1191
+ // v4.6.0 — also write a keys[] array so new code that consumes
1192
+ // the rotation-aware shape gets a normalized view. If the
1193
+ // caller also passed `keys[]` in the input, prefer that.
1194
+ keys: Array.isArray(input.keys) && input.keys.length > 0
1195
+ ? input.keys.map((k) => ({
1196
+ envVar: typeof k.envVar === 'string' ? k.envVar : '',
1197
+ label: typeof k.label === 'string' ? k.label : 'Key',
1198
+ status: VALID_KEY_STATUSES.has(k.status) ? k.status : 'standby',
1199
+ lastError: typeof k.lastError === 'string' ? k.lastError : null,
1200
+ errorCount: Number.isFinite(k.errorCount) ? Number(k.errorCount) : 0,
1201
+ lastUsed: Number.isFinite(k.lastUsed) ? Number(k.lastUsed) : null,
1202
+ }))
1203
+ : keysFromProvider({ apiKey: input.apiKey, backupApiKey: input.backupApiKey }),
416
1204
  models: Array.isArray(input.models) ? input.models : [],
417
1205
  enabled: input.enabled !== false,
418
1206
  };
@@ -449,6 +1237,128 @@ export const providersStore = {
449
1237
  return true;
450
1238
  },
451
1239
 
1240
+ /**
1241
+ * v4.4.14 — Add (or update) a provider with a user-supplied key.
1242
+ * Auto-fills the rest from the canonical preset + a live model
1243
+ * discovery probe. The user pastes the key; we figure out:
1244
+ * - the id (from name or explicit override)
1245
+ * - the display name (from preset)
1246
+ * - the baseURL (from preset)
1247
+ * - the model list (probes /v1/models and falls back to preset defaults)
1248
+ * - whether to set systemLlm to use this provider+model
1249
+ *
1250
+ * Returns the resulting provider plus a `probe` field describing
1251
+ * whether the model discovery succeeded and the models found.
1252
+ */
1253
+ async addWithAuto({ id, name, apiKey, groupId = 'default', setAsSystemLlm = true, preferredModel, systemLlmModel, timeoutMs = 8000 } = {}) {
1254
+ if (!apiKey || !apiKey.trim()) {
1255
+ return { ok: false, error: 'no_api_key', message: 'API key is required' };
1256
+ }
1257
+ // Resolve the preset by id (or by name as a fallback for unknown ids).
1258
+ const spec =
1259
+ this.KNOWN_PROVIDERS.find((p) => p.id === id) ||
1260
+ this.KNOWN_PROVIDERS.find((p) => (name || '').toLowerCase().includes((p.name || '').toLowerCase()));
1261
+ if (!spec) {
1262
+ return {
1263
+ ok: false,
1264
+ error: 'unknown_provider',
1265
+ message: `Unknown provider id "${id}". Known: ${this.KNOWN_PROVIDERS.map((p) => p.id).join(', ')}`,
1266
+ };
1267
+ }
1268
+ if (spec.keyPattern && !spec.keyPattern.test(apiKey)) {
1269
+ return {
1270
+ ok: false,
1271
+ error: 'invalid_key_format',
1272
+ message: `Key doesn't match expected pattern for ${spec.name} (${spec.keyPattern}).`,
1273
+ };
1274
+ }
1275
+ const finalId = spec.id; // always use the canonical id (e.g. "minimax", "opencode")
1276
+ const baseURL = spec.baseURL;
1277
+ const finalName = spec.name;
1278
+
1279
+ // Probe /v1/models to discover the live model list. Falls back to
1280
+ // the preset's knownModels if the probe fails.
1281
+ let probeModels = [];
1282
+ let probeStatus = 'unknown';
1283
+ try {
1284
+ const ctrl = new AbortController();
1285
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1286
+ const headers = { accept: 'application/json' };
1287
+ if (spec.id !== 'anthropic') headers['Authorization'] = `Bearer ${apiKey}`;
1288
+ const resp = await fetch(`${baseURL}/models`, { headers, signal: ctrl.signal });
1289
+ clearTimeout(timer);
1290
+ if (resp.ok) {
1291
+ const body = await resp.json().catch(() => null);
1292
+ const arr = Array.isArray(body?.data) ? body.data
1293
+ : Array.isArray(body) ? body
1294
+ : null;
1295
+ if (arr) {
1296
+ probeModels = arr.map((m) => m.id).filter(Boolean);
1297
+ probeStatus = 'ok';
1298
+ } else {
1299
+ probeStatus = 'unparseable';
1300
+ }
1301
+ } else {
1302
+ probeStatus = `http_${resp.status}`;
1303
+ }
1304
+ } catch (err) {
1305
+ probeStatus = err?.name === 'AbortError' ? 'timeout' : 'network';
1306
+ }
1307
+
1308
+ const models = probeModels.length > 0
1309
+ ? probeModels
1310
+ : Array.isArray(spec.defaultModels) ? spec.defaultModels
1311
+ : Array.isArray(spec.knownModels) ? spec.knownModels
1312
+ : [];
1313
+
1314
+ // Persist.
1315
+ const cfg = loadConfig();
1316
+ cfg.provider = cfg.provider || {};
1317
+ const cur = cfg.provider[finalId] || {};
1318
+ cfg.provider[finalId] = {
1319
+ name: finalName,
1320
+ baseURL,
1321
+ apiKey: apiKey.trim(),
1322
+ backupApiKey: cur.backupApiKey || '',
1323
+ // v4.6.0 — write a keys[] array. The active key's envVar is
1324
+ // empty here because the API key was pasted inline (not via
1325
+ // env var); backup keys carried over from a previous config
1326
+ // are preserved with their envVars.
1327
+ keys: keysFromProvider({
1328
+ apiKey: apiKey.trim(),
1329
+ backupApiKey: cur.backupApiKey || '',
1330
+ }),
1331
+ models,
1332
+ enabled: true,
1333
+ };
1334
+ if (groupId) cfg.provider[finalId].options = { ...(cfg.provider[finalId].options || {}), groupId };
1335
+
1336
+ // Apply system LLM config so the rest of the dashboard's
1337
+ // /api/llm/* calls (auto-title, enhance-prompt) hit this provider.
1338
+ if (setAsSystemLlm) {
1339
+ cfg.systemLlm = cfg.systemLlm || {};
1340
+ cfg.systemLlm.enabled = true;
1341
+ cfg.systemLlm.provider = finalId;
1342
+ const model = systemLlmModel
1343
+ || preferredModel
1344
+ || (Array.isArray(models) ? models[0] : null)
1345
+ || spec.defaultModel
1346
+ || 'MiniMax-M3';
1347
+ cfg.systemLlm.model = `${finalId}/${model}`;
1348
+ }
1349
+ saveConfig(cfg);
1350
+ return {
1351
+ ok: true,
1352
+ provider: cfg.provider[finalId],
1353
+ probe: {
1354
+ status: probeStatus,
1355
+ modelCount: probeModels.length,
1356
+ models,
1357
+ },
1358
+ systemLlm: cfg.systemLlm,
1359
+ };
1360
+ },
1361
+
452
1362
  /**
453
1363
  * v3.16.0 — Auto-detect providers from environment variables.
454
1364
  *
@@ -559,6 +1469,52 @@ export const providersStore = {
559
1469
  backupEnvKeys: ['MINIMAX_API_KEY_BACKUP', 'MINIMAX_BACKUP_API_KEY', 'ANTHROPIC_API_KEY_BACKUP'],
560
1470
  baseURL: 'https://api.minimax.io/v1',
561
1471
  keyPattern: /^sk-(cp|ant|or)-[A-Za-z0-9_-]{20,}$/,
1472
+ defaultModel: 'MiniMax-M3',
1473
+ defaultModels: [
1474
+ 'MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed',
1475
+ 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1',
1476
+ 'MiniMax-M2.1-highspeed', 'MiniMax-M2',
1477
+ ],
1478
+ },
1479
+ {
1480
+ // v4.4.14 — OpenCode Zen. Free tier (per-model free quota + paid
1481
+ // top-up). The user signs in to https://opencode.ai/auth and gets
1482
+ // an API key, but the dashboard does NOT require a key to add the
1483
+ // provider — we generate a placeholder that the user replaces via
1484
+ // `bizar minimax config` or the dashboard's edit modal. The base
1485
+ // URL is the public Zen endpoint; the model list is from the docs
1486
+ // and refreshes on each provider add (probes /v1/models).
1487
+ id: 'opencode',
1488
+ name: 'OpenCode Zen',
1489
+ envKeys: ['OPENCODE_API_KEY'],
1490
+ backupEnvKeys: ['OPENCODE_API_KEY_BACKUP'],
1491
+ baseURL: 'https://opencode.ai/zen/v1',
1492
+ keyPattern: /^sk-[A-Za-z0-9]{20,}$/,
1493
+ // Zen uses two base URLs depending on model family:
1494
+ // - OpenAI-compatible: /v1/chat/completions (M3, M2.x, GLM, Kimi, Grok, DeepSeek, Gemini 3 Flash, Big Pickle, MiMo, North, Nemotron)
1495
+ // - Anthropic-format: /v1/messages (Claude family, Qwen3.7, Kimi K2.7)
1496
+ // - Gemini vertex: /v1/models/gemini-3.5-flash, gemini-3.1-pro, gemini-3-flash
1497
+ // The baseURL we store is the OpenAI-compatible path. Mods from
1498
+ // Anthropic/Gemini are still selectable (opencode routes internally
1499
+ // to the right URL per model). Users with their own OpenAI/
1500
+ // Anthropic keys can also use Zen as a pass-through.
1501
+ defaultModel: 'gpt-5.5',
1502
+ defaultModels: [
1503
+ 'gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.4-pro', 'gpt-5.4-mini', 'gpt-5.4-nano',
1504
+ 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2', 'gpt-5.2-codex',
1505
+ 'gpt-5.1', 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
1506
+ 'gpt-5', 'gpt-5-codex', 'gpt-5-nano',
1507
+ 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-opus-4-5',
1508
+ 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-haiku-4-5',
1509
+ 'gemini-3.5-flash', 'gemini-3.1-pro', 'gemini-3-flash',
1510
+ 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.5-plus',
1511
+ 'deepseek-v4-pro', 'deepseek-v4-flash',
1512
+ 'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
1513
+ 'glm-5.2', 'glm-5.1', 'glm-5',
1514
+ 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5',
1515
+ 'grok-build-0.1',
1516
+ 'big-pickle', 'mimo-v2.5-free', 'north-mini-code-free', 'nemotron-3-ultra-free', 'deepseek-v4-flash-free',
1517
+ ],
562
1518
  },
563
1519
  ],
564
1520