@polderlabs/bizar 4.4.13 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +116 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +86 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +68 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +18 -0
- package/bizar-dash/src/server/headroom.mjs +645 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +189 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +668 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +20 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +120 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +2 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/Memory.tsx +140 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Overview.tsx +3 -0
- package/bizar-dash/src/web/views/Settings.tsx +36 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +247 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- 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
|
|