@openchamber/web 1.9.2 → 1.9.3

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 (32) hide show
  1. package/dist/assets/ToolOutputDialog-iiUOHO3c.js +16 -0
  2. package/dist/assets/index-BZ8pfXBh.css +1 -0
  3. package/dist/assets/index-DEj7Q-1y.js +2 -0
  4. package/dist/assets/{main-BFP0Fw2a.js → main-Ba2uuSTQ.js} +119 -119
  5. package/dist/assets/{vendor-.bun-CjZZibdK.js → vendor-.bun-B34wtB0D.js} +39 -39
  6. package/dist/index.html +3 -3
  7. package/package.json +1 -1
  8. package/server/TERMINAL_WS_PROTOCOL.md +48 -0
  9. package/server/lib/fs/routes.js +48 -0
  10. package/server/lib/opencode/proxy.js +106 -2
  11. package/server/lib/quota/DOCUMENTATION.md +1 -0
  12. package/server/lib/quota/index.js +2 -1
  13. package/server/lib/quota/providers/copilot.js +1 -1
  14. package/server/lib/quota/providers/index.js +8 -0
  15. package/server/lib/quota/providers/minimax-cn-coding-plan.js +141 -15
  16. package/server/lib/quota/providers/minimax-coding-plan.js +139 -15
  17. package/server/lib/quota/providers/zhipuai.js +114 -0
  18. package/server/lib/terminal/DOCUMENTATION.md +41 -80
  19. package/server/lib/terminal/index.js +27 -8
  20. package/server/lib/terminal/output-replay-buffer.js +66 -0
  21. package/server/lib/terminal/output-replay-buffer.test.js +66 -0
  22. package/server/lib/terminal/runtime.js +107 -20
  23. package/server/lib/terminal/{input-ws-protocol.js → terminal-ws-protocol.js} +13 -11
  24. package/server/lib/terminal/{input-ws-protocol.test.js → terminal-ws-protocol.test.js} +39 -32
  25. package/server/opencode-proxy.test.js +83 -0
  26. package/server/proxy-headers.js +61 -0
  27. package/server/proxy-headers.test.js +58 -0
  28. package/dist/assets/ToolOutputDialog-DwlX_M_n.js +0 -16
  29. package/dist/assets/index-BQqVuvn2.js +0 -2
  30. package/dist/assets/index-CH1IFYgs.css +0 -1
  31. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +0 -44
  32. package/server/lib/quota/providers/minimax-shared.js +0 -136
package/dist/index.html CHANGED
@@ -442,10 +442,10 @@
442
442
  pointer-events: none;
443
443
  }
444
444
  </style>
445
- <script type="module" crossorigin src="/assets/index-BQqVuvn2.js"></script>
446
- <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-CjZZibdK.js">
445
+ <script type="module" crossorigin src="/assets/index-DEj7Q-1y.js"></script>
446
+ <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-B34wtB0D.js">
447
447
  <link rel="stylesheet" crossorigin href="/assets/vendor--DbVqbJpV.css">
448
- <link rel="stylesheet" crossorigin href="/assets/index-CH1IFYgs.css">
448
+ <link rel="stylesheet" crossorigin href="/assets/index-BZ8pfXBh.css">
449
449
  </head>
450
450
  <body class="h-full bg-background text-foreground">
451
451
  <div id="root" class="h-full">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/web",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -0,0 +1,48 @@
1
+ # Terminal WebSocket Transport Protocol
2
+
3
+ ## Goal
4
+ Use a single persistent WebSocket for terminal input and output, while keeping the legacy SSE output route and HTTP input route as compatibility fallbacks.
5
+
6
+ ## Scope
7
+ - Primary full-duplex path: WebSocket (`/api/terminal/ws`)
8
+ - Legacy output fallback: SSE (`/api/terminal/:sessionId/stream`)
9
+ - HTTP input fallback remains: `POST /api/terminal/:sessionId/input`
10
+
11
+ ## Framing
12
+ - Text frame:
13
+ - client -> server: terminal keystroke payload
14
+ - server -> client: raw PTY output chunk
15
+ - Binary frame: control envelope
16
+ - Byte 0: tag (`0x01` = JSON control)
17
+ - Bytes 1..N: UTF-8 JSON payload
18
+
19
+ ## Control Messages
20
+ - Bind active socket to terminal session:
21
+ - client -> server: `{"t":"b","s":"<sessionId>","v":2}`
22
+ - Keepalive ping:
23
+ - client -> server: `{"t":"p","v":2}`
24
+ - server -> client: `{"t":"po","v":2}`
25
+ - Server control responses:
26
+ - ready: `{"t":"ok","v":2}`
27
+ - bind ok: `{"t":"bok","s":"<sessionId>","runtime":"node|bun","ptyBackend":"...","v":2}`
28
+ - exit: `{"t":"x","s":"<sessionId>","exitCode":0,"signal":null}`
29
+ - error: `{"t":"e","c":"<code>","f":true|false}`
30
+
31
+ ## Multiplexing Model
32
+ - Single shared socket per client runtime.
33
+ - Socket has one mutable bound session.
34
+ - Client sends a bind control when the active terminal changes.
35
+ - Text frames always apply to the currently bound session.
36
+ - PTY output is pushed back over the same socket as text frames.
37
+ - Client keeps the socket primed so both stream subscription and input reuse the same transport.
38
+
39
+ ## Security
40
+ - UI auth session required when UI password is enabled.
41
+ - Origin validation enforced for cookie-authenticated browser upgrades.
42
+ - Invalid or malformed frames are rate-limited and may close the socket.
43
+
44
+ ## Fallback Behavior
45
+ - New clients prefer `capabilities.stream.ws` and reuse the same socket for input.
46
+ - If stream WebSocket capability is unavailable, clients fall back to SSE output.
47
+ - If terminal input cannot be sent over WebSocket, clients fall back to HTTP input.
48
+ - The removed `/api/terminal/input-ws` path should fail with `404 Not Found`.
@@ -283,6 +283,54 @@ export const registerFsRoutes = (app, dependencies) => {
283
283
  }
284
284
  });
285
285
 
286
+ app.get('/api/fs/stat', async (req, res) => {
287
+ const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
288
+ if (!filePath) {
289
+ return res.status(400).json({ error: 'Path is required' });
290
+ }
291
+
292
+ try {
293
+ const resolved = await resolveWorkspacePathFromContext({
294
+ req,
295
+ targetPath: filePath,
296
+ resolveProjectDirectory,
297
+ path,
298
+ os,
299
+ normalizeDirectoryPath,
300
+ openchamberUserConfigRoot,
301
+ });
302
+ if (!resolved.ok) {
303
+ return res.status(400).json({ error: resolved.error });
304
+ }
305
+
306
+ const [canonicalPath, canonicalBase] = await Promise.all([
307
+ fsPromises.realpath(resolved.resolved),
308
+ fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
309
+ ]);
310
+
311
+ if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
312
+ return res.status(403).json({ error: 'Access to file denied' });
313
+ }
314
+
315
+ const stats = await fsPromises.stat(canonicalPath);
316
+ if (!stats.isFile()) {
317
+ return res.status(400).json({ error: 'Specified path is not a file' });
318
+ }
319
+
320
+ return res.json({ path: canonicalPath, isFile: true, size: stats.size });
321
+ } catch (error) {
322
+ const err = error;
323
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
324
+ return res.status(404).json({ error: 'File not found' });
325
+ }
326
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
327
+ return res.status(403).json({ error: 'Access to file denied' });
328
+ }
329
+ console.error('Failed to stat file:', error);
330
+ return res.status(500).json({ error: (error && error.message) || 'Failed to stat file' });
331
+ }
332
+ });
333
+
286
334
  app.get('/api/fs/read', async (req, res) => {
287
335
  const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
288
336
  if (!filePath) {
@@ -1,6 +1,11 @@
1
- import express from 'express';
2
1
  import { createProxyMiddleware } from 'http-proxy-middleware';
3
2
 
3
+ import {
4
+ applyForwardProxyResponseHeaders,
5
+ collectForwardProxyHeaders,
6
+ shouldForwardProxyResponseHeader,
7
+ } from '../../proxy-headers.js';
8
+
4
9
  export const registerOpenCodeProxy = (app, deps) => {
5
10
  const {
6
11
  fs,
@@ -25,6 +30,91 @@ export const registerOpenCodeProxy = (app, deps) => {
25
30
  }
26
31
  app.set('opencodeProxyConfigured', true);
27
32
 
33
+ const isAbortError = (error) => error?.name === 'AbortError';
34
+
35
+ const forwardSseRequest = async (req, res) => {
36
+ const abortController = new AbortController();
37
+ const closeUpstream = () => abortController.abort();
38
+ let upstream = null;
39
+ let reader = null;
40
+
41
+ req.on('close', closeUpstream);
42
+
43
+ try {
44
+ const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
45
+ ? req.originalUrl
46
+ : (typeof req.url === 'string' ? req.url : '');
47
+ const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
48
+ const headers = collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders());
49
+ headers.accept ??= 'text/event-stream';
50
+ headers['cache-control'] ??= 'no-cache';
51
+
52
+ upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
53
+ method: 'GET',
54
+ headers,
55
+ signal: abortController.signal,
56
+ });
57
+
58
+ res.status(upstream.status);
59
+ applyForwardProxyResponseHeaders(upstream.headers, res);
60
+
61
+ const contentType = upstream.headers.get('content-type') || 'text/event-stream';
62
+ const isEventStream = contentType.toLowerCase().includes('text/event-stream');
63
+
64
+ if (!upstream.body) {
65
+ res.end(await upstream.text().catch(() => ''));
66
+ return;
67
+ }
68
+
69
+ if (!isEventStream) {
70
+ res.end(await upstream.text());
71
+ return;
72
+ }
73
+
74
+ res.setHeader('Content-Type', contentType);
75
+ res.setHeader('Cache-Control', 'no-cache');
76
+ res.setHeader('Connection', 'keep-alive');
77
+ res.setHeader('X-Accel-Buffering', 'no');
78
+ if (typeof res.flushHeaders === 'function') {
79
+ res.flushHeaders();
80
+ }
81
+
82
+ reader = upstream.body.getReader();
83
+ while (!abortController.signal.aborted) {
84
+ const { done, value } = await reader.read();
85
+ if (done) {
86
+ break;
87
+ }
88
+ if (value && value.length > 0) {
89
+ res.write(value);
90
+ }
91
+ }
92
+
93
+ res.end();
94
+ } catch (error) {
95
+ if (isAbortError(error)) {
96
+ return;
97
+ }
98
+ console.error('[proxy] OpenCode SSE proxy error:', error?.message ?? error);
99
+ if (!res.headersSent) {
100
+ res.status(503).json({ error: 'OpenCode service unavailable' });
101
+ } else {
102
+ res.end();
103
+ }
104
+ } finally {
105
+ req.off('close', closeUpstream);
106
+ try {
107
+ if (reader) {
108
+ await reader.cancel();
109
+ reader.releaseLock();
110
+ } else if (upstream?.body && !upstream.body.locked) {
111
+ await upstream.body.cancel();
112
+ }
113
+ } catch {
114
+ }
115
+ }
116
+ };
117
+
28
118
  // Ensure API prefix is detected before proxying
29
119
  app.use('/api', (_req, _res, next) => {
30
120
  ensureOpenCodeApiPrefix();
@@ -138,7 +228,10 @@ export const registerOpenCodeProxy = (app, deps) => {
138
228
  });
139
229
  }
140
230
 
141
- // http-proxy-middleware handles SSE, large bodies, timeouts correctly
231
+ app.get('/api/global/event', forwardSseRequest);
232
+ app.get('/api/event', forwardSseRequest);
233
+
234
+ // Generic proxy for non-SSE OpenCode API routes.
142
235
  const apiProxy = createProxyMiddleware({
143
236
  target: `http://127.0.0.1:${runtime.openCodePort || 3902}`,
144
237
  changeOrigin: true,
@@ -155,6 +248,17 @@ export const registerOpenCodeProxy = (app, deps) => {
155
248
  if (authHeaders.Authorization) {
156
249
  proxyReq.setHeader('Authorization', authHeaders.Authorization);
157
250
  }
251
+
252
+ // Defensive: request identity encoding from upstream OpenCode.
253
+ // This avoids compressed-body/header mismatches in multi-proxy setups.
254
+ proxyReq.setHeader('accept-encoding', 'identity');
255
+ },
256
+ proxyRes: (proxyRes) => {
257
+ for (const key of Object.keys(proxyRes.headers || {})) {
258
+ if (!shouldForwardProxyResponseHeader(key)) {
259
+ delete proxyRes.headers[key];
260
+ }
261
+ }
158
262
  },
159
263
  error: (err, _req, res) => {
160
264
  console.error('[proxy] OpenCode proxy error:', err.message);
@@ -29,6 +29,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
29
29
  | `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` | `minimax-coding-plan` |
30
30
  | `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` | `minimax-cn-coding-plan` |
31
31
  | `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
32
+ | `zhipuai-coding-plan` | ZhipuAI | `providers/zhipuai.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
32
33
 
33
34
  ## Internal-only provider module
34
35
  - `providers/openai.js` exists for logic parity/reuse but is intentionally not registered for dispatcher ID routing.
@@ -20,5 +20,6 @@ export {
20
20
  fetchNanoGptQuota,
21
21
  fetchMinimaxCodingPlanQuota,
22
22
  fetchMinimaxCnCodingPlanQuota,
23
- fetchOllamaCloudQuota
23
+ fetchOllamaCloudQuota,
24
+ fetchZhipuaiQuota
24
25
  } from './providers/index.js';
@@ -18,7 +18,7 @@ const buildCopilotWindows = (payload) => {
18
18
  const entitlement = toNumber(snapshot.entitlement);
19
19
  const remaining = toNumber(snapshot.remaining);
20
20
  const usedPercent = entitlement && remaining !== null
21
- ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100))
21
+ ? Math.max(0, 100 - (remaining / entitlement) * 100)
22
22
  : null;
23
23
  const valueLabel = entitlement !== null && remaining !== null
24
24
  ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left`
@@ -19,6 +19,7 @@ import * as zai from './zai.js';
19
19
  import * as minimaxCodingPlan from './minimax-coding-plan.js';
20
20
  import * as minimaxCnCodingPlan from './minimax-cn-coding-plan.js';
21
21
  import * as ollamaCloud from './ollama-cloud.js';
22
+ import * as zhipuai from './zhipuai.js';
22
23
 
23
24
  const registry = {
24
25
  claude: {
@@ -92,6 +93,12 @@ const registry = {
92
93
  providerName: ollamaCloud.providerName,
93
94
  isConfigured: ollamaCloud.isConfigured,
94
95
  fetchQuota: ollamaCloud.fetchQuota
96
+ },
97
+ 'zhipuai-coding-plan': {
98
+ providerId: zhipuai.providerId,
99
+ providerName: zhipuai.providerName,
100
+ isConfigured: zhipuai.isConfigured,
101
+ fetchQuota: zhipuai.fetchQuota
95
102
  }
96
103
  };
97
104
 
@@ -150,3 +157,4 @@ export const fetchNanoGptQuota = nanogpt.fetchQuota;
150
157
  export const fetchMinimaxCodingPlanQuota = minimaxCodingPlan.fetchQuota;
151
158
  export const fetchMinimaxCnCodingPlanQuota = minimaxCnCodingPlan.fetchQuota;
152
159
  export const fetchOllamaCloudQuota = ollamaCloud.fetchQuota;
160
+ export const fetchZhipuaiQuota = zhipuai.fetchQuota;
@@ -1,15 +1,141 @@
1
- // MiniMax Coding Plan Provider
2
- import { createMiniMaxCodingPlanProvider } from './minimax-shared.js';
3
-
4
- const provider = createMiniMaxCodingPlanProvider({
5
- providerId: 'minimax-cn-coding-plan',
6
- providerName: 'MiniMax Coding Plan (minimaxi.com)',
7
- aliases: ['minimax-cn-coding-plan'],
8
- endpoint: 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
9
- });
10
-
11
- export const providerId = provider.providerId;
12
- export const providerName = provider.providerName;
13
- export const aliases = provider.aliases;
14
- export const isConfigured = provider.isConfigured;
15
- export const fetchQuota = provider.fetchQuota;
1
+ // MiniMax Coding Plan Provider (minimaxi.com)
2
+ import { readAuthFile } from '../../opencode/auth.js';
3
+ import {
4
+ getAuthEntry,
5
+ normalizeAuthEntry,
6
+ buildResult,
7
+ toUsageWindow,
8
+ toNumber,
9
+ toTimestamp,
10
+ } from '../utils/index.js';
11
+
12
+ export const providerId = 'minimax-cn-coding-plan';
13
+ export const providerName = 'MiniMax Coding Plan (minimaxi.com)';
14
+ export const aliases = ['minimax-cn-coding-plan'];
15
+
16
+ export const isConfigured = () => {
17
+ const auth = readAuthFile();
18
+ const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
19
+ return Boolean(entry?.key || entry?.token);
20
+ };
21
+
22
+ export const fetchQuota = async () => {
23
+ const auth = readAuthFile();
24
+ const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
25
+ const apiKey = entry?.key ?? entry?.token;
26
+
27
+ if (!apiKey) {
28
+ return buildResult({
29
+ providerId,
30
+ providerName,
31
+ ok: false,
32
+ configured: false,
33
+ error: 'Not configured',
34
+ });
35
+ }
36
+
37
+ try {
38
+ const response = await fetch(
39
+ 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
40
+ {
41
+ method: 'GET',
42
+ headers: {
43
+ Authorization: `Bearer ${apiKey}`,
44
+ 'Content-Type': 'application/json',
45
+ },
46
+ }
47
+ );
48
+
49
+ if (!response.ok) {
50
+ return buildResult({
51
+ providerId,
52
+ providerName,
53
+ ok: false,
54
+ configured: true,
55
+ error: `API error: ${response.status}`,
56
+ });
57
+ }
58
+
59
+ const payload = await response.json();
60
+ const baseResp = payload?.base_resp;
61
+ if (baseResp && baseResp.status_code !== 0) {
62
+ return buildResult({
63
+ providerId,
64
+ providerName,
65
+ ok: false,
66
+ configured: true,
67
+ error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
68
+ });
69
+ }
70
+
71
+ const firstModel = payload?.model_remains?.[0];
72
+ if (!firstModel) {
73
+ return buildResult({
74
+ providerId,
75
+ providerName,
76
+ ok: false,
77
+ configured: true,
78
+ error: 'No model quota data available',
79
+ });
80
+ }
81
+
82
+ const intervalTotal = toNumber(firstModel.current_interval_total_count);
83
+ const intervalUsage = toNumber(firstModel.current_interval_usage_count);
84
+ const intervalStartAt = toTimestamp(firstModel.start_time);
85
+ const intervalResetAt = toTimestamp(firstModel.end_time);
86
+ const weeklyTotal = toNumber(firstModel.current_weekly_total_count);
87
+ const weeklyUsage = toNumber(firstModel.current_weekly_usage_count);
88
+ const weeklyStartAt = toTimestamp(firstModel.weekly_start_time);
89
+ const weeklyResetAt = toTimestamp(firstModel.weekly_end_time);
90
+
91
+ // For minimaxi.com: usage_count represents USED credits
92
+ const intervalUsed = intervalUsage;
93
+ const weeklyUsed = weeklyUsage;
94
+
95
+ const intervalUsedPercent =
96
+ intervalTotal > 0 && intervalUsed != null
97
+ ? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100))
98
+ : null;
99
+ const intervalWindowSeconds =
100
+ intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt
101
+ ? Math.floor((intervalResetAt - intervalStartAt) / 1000)
102
+ : null;
103
+ const weeklyUsedPercent =
104
+ weeklyTotal > 0 && weeklyUsed != null
105
+ ? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100))
106
+ : null;
107
+ const weeklyWindowSeconds =
108
+ weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt
109
+ ? Math.floor((weeklyResetAt - weeklyStartAt) / 1000)
110
+ : null;
111
+
112
+ const windows = {
113
+ '5h': toUsageWindow({
114
+ usedPercent: intervalUsedPercent,
115
+ windowSeconds: intervalWindowSeconds,
116
+ resetAt: intervalResetAt,
117
+ }),
118
+ weekly: toUsageWindow({
119
+ usedPercent: weeklyUsedPercent,
120
+ windowSeconds: weeklyWindowSeconds,
121
+ resetAt: weeklyResetAt,
122
+ }),
123
+ };
124
+
125
+ return buildResult({
126
+ providerId,
127
+ providerName,
128
+ ok: true,
129
+ configured: true,
130
+ usage: { windows },
131
+ });
132
+ } catch (error) {
133
+ return buildResult({
134
+ providerId,
135
+ providerName,
136
+ ok: false,
137
+ configured: true,
138
+ error: error instanceof Error ? error.message : 'Request failed',
139
+ });
140
+ }
141
+ };
@@ -1,15 +1,139 @@
1
- // MiniMax Coding Plan Provider
2
- import { createMiniMaxCodingPlanProvider } from './minimax-shared.js';
3
-
4
- const provider = createMiniMaxCodingPlanProvider({
5
- providerId: 'minimax-coding-plan',
6
- providerName: 'MiniMax Coding Plan (minimax.io)',
7
- aliases: ['minimax-coding-plan'],
8
- endpoint: 'https://www.minimax.io/v1/api/openplatform/coding_plan/remains',
9
- });
10
-
11
- export const providerId = provider.providerId;
12
- export const providerName = provider.providerName;
13
- export const aliases = provider.aliases;
14
- export const isConfigured = provider.isConfigured;
15
- export const fetchQuota = provider.fetchQuota;
1
+ import { readAuthFile } from '../../opencode/auth.js';
2
+ import {
3
+ getAuthEntry,
4
+ normalizeAuthEntry,
5
+ buildResult,
6
+ toUsageWindow,
7
+ toNumber,
8
+ toTimestamp,
9
+ } from '../utils/index.js';
10
+
11
+ export const providerId = 'minimax-coding-plan';
12
+ export const providerName = 'MiniMax Coding Plan (minimax.io)';
13
+ export const aliases = ['minimax-coding-plan'];
14
+
15
+ export const isConfigured = () => {
16
+ const auth = readAuthFile();
17
+ const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
18
+ return Boolean(entry?.key || entry?.token);
19
+ };
20
+
21
+ export const fetchQuota = async () => {
22
+ const auth = readAuthFile();
23
+ const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
24
+ const apiKey = entry?.key ?? entry?.token;
25
+
26
+ if (!apiKey) {
27
+ return buildResult({
28
+ providerId,
29
+ providerName,
30
+ ok: false,
31
+ configured: false,
32
+ error: 'Not configured',
33
+ });
34
+ }
35
+
36
+ try {
37
+ const response = await fetch(
38
+ 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
39
+ {
40
+ method: 'GET',
41
+ headers: {
42
+ Authorization: `Bearer ${apiKey}`,
43
+ 'Content-Type': 'application/json',
44
+ },
45
+ }
46
+ );
47
+
48
+ if (!response.ok) {
49
+ return buildResult({
50
+ providerId,
51
+ providerName,
52
+ ok: false,
53
+ configured: true,
54
+ error: `API error: ${response.status}`,
55
+ });
56
+ }
57
+
58
+ const payload = await response.json();
59
+ const baseResp = payload?.base_resp;
60
+ if (baseResp && baseResp.status_code !== 0) {
61
+ return buildResult({
62
+ providerId,
63
+ providerName,
64
+ ok: false,
65
+ configured: true,
66
+ error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
67
+ });
68
+ }
69
+
70
+ const firstModel = payload?.model_remains?.[0];
71
+ if (!firstModel) {
72
+ return buildResult({
73
+ providerId,
74
+ providerName,
75
+ ok: false,
76
+ configured: true,
77
+ error: 'No model quota data available',
78
+ });
79
+ }
80
+
81
+ const intervalTotal = toNumber(firstModel.current_interval_total_count);
82
+ const intervalUsage = toNumber(firstModel.current_interval_usage_count);
83
+ const intervalStartAt = toTimestamp(firstModel.start_time);
84
+ const intervalResetAt = toTimestamp(firstModel.end_time);
85
+ const weeklyTotal = toNumber(firstModel.current_weekly_total_count);
86
+ const weeklyUsage = toNumber(firstModel.current_weekly_usage_count);
87
+ const weeklyStartAt = toTimestamp(firstModel.weekly_start_time);
88
+ const weeklyResetAt = toTimestamp(firstModel.weekly_end_time);
89
+
90
+ const intervalUsed = intervalUsage;
91
+ const weeklyUsed = weeklyUsage;
92
+
93
+ const intervalUsedPercent =
94
+ intervalTotal > 0 && intervalUsed !== null
95
+ ? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100))
96
+ : null;
97
+ const intervalWindowSeconds =
98
+ intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt
99
+ ? Math.floor((intervalResetAt - intervalStartAt) / 1000)
100
+ : null;
101
+ const weeklyUsedPercent =
102
+ weeklyTotal > 0 && weeklyUsed !== null
103
+ ? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100))
104
+ : null;
105
+ const weeklyWindowSeconds =
106
+ weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt
107
+ ? Math.floor((weeklyResetAt - weeklyStartAt) / 1000)
108
+ : null;
109
+
110
+ const windows = {
111
+ '5h': toUsageWindow({
112
+ usedPercent: intervalUsedPercent,
113
+ windowSeconds: intervalWindowSeconds,
114
+ resetAt: intervalResetAt,
115
+ }),
116
+ weekly: toUsageWindow({
117
+ usedPercent: weeklyUsedPercent,
118
+ windowSeconds: weeklyWindowSeconds,
119
+ resetAt: weeklyResetAt,
120
+ }),
121
+ };
122
+
123
+ return buildResult({
124
+ providerId,
125
+ providerName,
126
+ ok: true,
127
+ configured: true,
128
+ usage: { windows },
129
+ });
130
+ } catch (error) {
131
+ return buildResult({
132
+ providerId,
133
+ providerName,
134
+ ok: false,
135
+ configured: true,
136
+ error: error instanceof Error ? error.message : 'Request failed',
137
+ });
138
+ }
139
+ };