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