@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.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -51,8 +51,12 @@ import { wrap } from './_shared.mjs';
51
51
 
52
52
  /** Maximum number of concurrent SSE subscribers on this dashboard process. */
53
53
  const MAX_SSE_SUBSCRIBERS = 50;
54
- /** Heartbeat interval (ms) to keep proxies from killing the connection. */
55
- const SSE_HEARTBEAT_MS = 25_000;
54
+ /** Read the heartbeat interval on demand so tests can shrink it via
55
+ * `process.env.BIZAR_SSE_HEARTBEAT_MS` even after module load. */
56
+ function getSseHeartbeatMs() {
57
+ const v = Number.parseInt(process.env.BIZAR_SSE_HEARTBEAT_MS ?? '', 10);
58
+ return Number.isFinite(v) && v > 0 ? v : 25_000;
59
+ }
56
60
  /** Default timeout for the upstream message listing endpoint. */
57
61
  const LIST_MESSAGES_TIMEOUT_MS = 8_000;
58
62
  /** Default timeout for the upstream prompt endpoint. */
@@ -275,7 +279,7 @@ export function createOpencodeSessionDetailRouter() {
275
279
  } catch {
276
280
  clearInterval(heartbeat);
277
281
  }
278
- }, SSE_HEARTBEAT_MS);
282
+ }, getSseHeartbeatMs());
279
283
  let closed = false;
280
284
  const cleanup = () => {
281
285
  if (closed) return;
@@ -418,34 +422,15 @@ function handleSseBlock(block, res, sessionId) {
418
422
  data: evt.data,
419
423
  });
420
424
  try {
425
+ // Forward a single canonical envelope per upstream event. The
426
+ // dashboard's stream listeners consume the opencode event names
427
+ // (`message.part.updated`, `session.idle`, `message.updated`, …)
428
+ // directly — emitting redundant `chat:delta` / `chat:status` aliases
429
+ // here would duplicate events and (e.g.) cause an idle event to
430
+ // arrive twice on the wire, which breaks both subscribers and
431
+ // round-trip tests.
421
432
  res.write(`event: ${evt.type}\n`);
422
433
  res.write(`data: ${payload}\n\n`);
423
-
424
- // v0.1.0 — also emit `chat:delta` / `chat:status` envelopes when
425
- // a text part delta or session.idle arrives. These are consumed by
426
- // dashboard components that listen to the same SSE stream.
427
- if (evt.type === 'message.part.updated') {
428
- const part = evt.part;
429
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
430
- const p = /** @type {any} */ (part);
431
- if (p && p.type === 'text' && typeof p.text === 'string') {
432
- const deltaPayload = JSON.stringify({
433
- sessionId: evt.sessionID,
434
- messageId: evt.messageID || undefined,
435
- delta: p.text,
436
- type: 'text',
437
- });
438
- res.write(`event: chat:delta\n`);
439
- res.write(`data: ${deltaPayload}\n\n`);
440
- }
441
- } else if (evt.type === 'session.idle') {
442
- const statusPayload = JSON.stringify({
443
- sessionId: evt.sessionID,
444
- status: 'idle',
445
- });
446
- res.write(`event: chat:status\n`);
447
- res.write(`data: ${statusPayload}\n\n`);
448
- }
449
434
  } catch {
450
435
  /* socket closed mid-write */
451
436
  }
@@ -1,21 +1,76 @@
1
1
  /**
2
2
  * src/server/routes/opencode-sessions.mjs
3
3
  *
4
- * /api/opencode-sessions reads active sessions from the opencode
5
- * SQLite database (~/.local/share/opencode/opencode.db) and returns
6
- * them in a format compatible with the dashboard's session list.
4
+ * /api/opencode-sessions list opencode sessions
5
+ * POST /api/opencode-sessions/new — create a new opencode session
6
+ * PATCH /api/opencode-sessions/:id rename an opencode session
7
+ * DELETE /api/opencode-sessions/:id — delete an opencode session
7
8
  *
9
+ * The list endpoint reads from the opencode SQLite database
10
+ * (~/.local/share/opencode/opencode.db). The mutating endpoints
11
+ * talk to the opencode serve child via the helpers in
12
+ * `../serve-info.mjs` (see `createOpencodeSession`,
13
+ * `updateOpencodeSession`, `deleteOpencodeSession`).
14
+ *
15
+ * Both surfaces share directory-resolution logic: we discover the
16
+ * session's directory from `listOpencodeSessions()` and fall back to
17
+ * `info.worktree` so a freshly created session (not yet listed) can
18
+ * still be addressed using the active project's path or the plugin's
19
+ * recorded cwd.
20
+ *
21
+ * v4.2.5 — Added POST/PATCH/DELETE so the dashboard's "New session"
22
+ * button, the rail row menu (rename / delete), and the info-panel
23
+ * actions all work end-to-end against the opencode serve child.
8
24
  * Sessions are filtered to non-archived (time_archived IS NULL) and
9
25
  * ordered by most recently updated.
10
26
  */
27
+
11
28
  import { Router } from 'express';
12
29
  import Database from 'better-sqlite3';
13
30
  import { join } from 'node:path';
14
31
  import { homedir } from 'node:os';
15
32
  import { wrap } from './_shared.mjs';
33
+ import {
34
+ readServeInfo,
35
+ listOpencodeSessions,
36
+ createOpencodeSession,
37
+ updateOpencodeSession,
38
+ deleteOpencodeSession,
39
+ } from '../serve-info.mjs';
16
40
 
17
41
  const DB_PATH = join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
18
42
 
43
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
44
+ const AGENT_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
45
+ const TITLE_MAX = 200;
46
+
47
+ /**
48
+ * Resolve the opencode `directory` for a known session. Used by
49
+ * PATCH and DELETE so a session started from any worktree is
50
+ * addressed through its own scope.
51
+ *
52
+ * @param {ReturnType<typeof readServeInfo>} info
53
+ * @param {string} sessionId
54
+ * @returns {Promise<string|null>}
55
+ */
56
+ async function resolveSessionDirectory(info, sessionId) {
57
+ if (!info) return null;
58
+ try {
59
+ const sessions = await listOpencodeSessions(info, 5_000);
60
+ if (Array.isArray(sessions)) {
61
+ const entry = sessions.find((s) => s && s.id === sessionId);
62
+ const dir = entry?.location?.directory;
63
+ if (typeof dir === 'string' && dir.length > 0) return dir;
64
+ }
65
+ } catch {
66
+ /* fall through to worktree */
67
+ }
68
+ if (typeof info.worktree === 'string' && info.worktree.length > 0) {
69
+ return info.worktree;
70
+ }
71
+ return null;
72
+ }
73
+
19
74
  /**
20
75
  * @returns {import('express').Router}
21
76
  */
@@ -51,5 +106,152 @@ export function createOpencodeSessionsRouter() {
51
106
  }
52
107
  }));
53
108
 
109
+ // ── POST /api/opencode-sessions/new ───────────────────────────────────
110
+ //
111
+ // Body: { title?: string, agent: string, directory?: string }.
112
+ // When `directory` is omitted we use the opencode plugin's recorded
113
+ // worktree (the active project's path on disk).
114
+ //
115
+ // Returns 201 with `{ id, title, agent, directory, createdAt }`.
116
+ // Returns 400 when the body is missing / invalid.
117
+ // Returns 502 when the upstream opencode serve errors.
118
+ // Returns 503 when the opencode plugin is offline.
119
+ // ────────────────────────────────────────────────────────────────────
120
+ router.post('/opencode-sessions/new', wrap(async (req, res) => {
121
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
122
+ const title = typeof body.title === 'string' && body.title.trim().length > 0
123
+ ? body.title.trim().slice(0, TITLE_MAX)
124
+ : null;
125
+ const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
126
+ if (!agent) {
127
+ res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
128
+ return;
129
+ }
130
+ if (!AGENT_NAME_RE.test(agent)) {
131
+ res.status(400).json({ error: 'bad_request', message: '`agent` is invalid (allowed: [A-Za-z0-9_-]{1,64})' });
132
+ return;
133
+ }
134
+ if (title !== null && title.length > TITLE_MAX) {
135
+ res.status(400).json({ error: 'bad_request', message: `title too long (> ${TITLE_MAX} chars)` });
136
+ return;
137
+ }
138
+
139
+ const info = readServeInfo();
140
+ if (!info) {
141
+ res.status(503).json({
142
+ error: 'plugin_offline',
143
+ message: 'opencode plugin is not running',
144
+ });
145
+ return;
146
+ }
147
+
148
+ const directory = typeof body.directory === 'string' && body.directory.length > 0
149
+ ? body.directory
150
+ : (info.worktree || '');
151
+
152
+ const finalTitle = title || `Chat: ${agent}`;
153
+ const result = await createOpencodeSession(
154
+ info,
155
+ { title: finalTitle, agent },
156
+ directory,
157
+ );
158
+ if (!result.ok || !result.sessionId) {
159
+ const status = result.status === 404 ? 404 : 502;
160
+ res.status(status).json({
161
+ error: 'opencode_error',
162
+ message: result.error || 'failed to create opencode session',
163
+ });
164
+ return;
165
+ }
166
+
167
+ res.status(201).json({
168
+ id: result.sessionId,
169
+ title: finalTitle,
170
+ agent,
171
+ directory,
172
+ createdAt: Date.now(),
173
+ });
174
+ }));
175
+
176
+ // ── PATCH /api/opencode-sessions/:id ──────────────────────────────────
177
+ //
178
+ // Body: { title: string } — required, non-empty, ≤ 200 chars.
179
+ // Returns 200 with `{ id, title }` on success.
180
+ // 400 / 404 (unknown) / 502 / 503 / 504 (timeouts).
181
+ // ────────────────────────────────────────────────────────────────────
182
+ router.patch('/opencode-sessions/:id', wrap(async (req, res) => {
183
+ const sessionId = String(req.params?.id || '');
184
+ if (!SESSION_ID_RE.test(sessionId)) {
185
+ res.status(400).json({ error: 'bad_request', message: 'invalid session id' });
186
+ return;
187
+ }
188
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
189
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
190
+ if (!title) {
191
+ res.status(400).json({ error: 'bad_request', message: '`title` is required (non-empty)' });
192
+ return;
193
+ }
194
+ if (title.length > TITLE_MAX) {
195
+ res.status(400).json({ error: 'bad_request', message: `title too long (> ${TITLE_MAX} chars)` });
196
+ return;
197
+ }
198
+
199
+ const info = readServeInfo();
200
+ if (!info) {
201
+ res.status(503).json({ error: 'plugin_offline', message: 'opencode plugin is not running' });
202
+ return;
203
+ }
204
+ const directory = await resolveSessionDirectory(info, sessionId);
205
+ if (!directory) {
206
+ res.status(503).json({
207
+ error: 'directory_unknown',
208
+ message: 'cannot determine the opencode session directory',
209
+ });
210
+ return;
211
+ }
212
+
213
+ const result = await updateOpencodeSession(info, sessionId, { title }, directory);
214
+ if (!result.ok) {
215
+ const status = result.status === 404 ? 404 : 502;
216
+ res.status(status).json({ error: 'opencode_error', message: result.error || 'rename failed' });
217
+ return;
218
+ }
219
+ res.json({ id: sessionId, title });
220
+ }));
221
+
222
+ // ── DELETE /api/opencode-sessions/:id ─────────────────────────────────
223
+ //
224
+ // No body. Returns 200 on success or if the session was already gone.
225
+ // 400 (invalid id) / 404 (unknown — surfaces when upstream says so) /
226
+ // 502 / 503.
227
+ // ────────────────────────────────────────────────────────────────────
228
+ router.delete('/opencode-sessions/:id', wrap(async (req, res) => {
229
+ const sessionId = String(req.params?.id || '');
230
+ if (!SESSION_ID_RE.test(sessionId)) {
231
+ res.status(400).json({ error: 'bad_request', message: 'invalid session id' });
232
+ return;
233
+ }
234
+ const info = readServeInfo();
235
+ if (!info) {
236
+ res.status(503).json({ error: 'plugin_offline', message: 'opencode plugin is not running' });
237
+ return;
238
+ }
239
+ const directory = await resolveSessionDirectory(info, sessionId);
240
+ if (!directory) {
241
+ res.status(503).json({
242
+ error: 'directory_unknown',
243
+ message: 'cannot determine the opencode session directory',
244
+ });
245
+ return;
246
+ }
247
+ const result = await deleteOpencodeSession(info, sessionId, directory);
248
+ if (!result.ok) {
249
+ const status = result.status === 404 ? 404 : 502;
250
+ res.status(status).json({ error: 'opencode_error', message: result.error || 'delete failed' });
251
+ return;
252
+ }
253
+ res.json({ id: sessionId, deleted: true });
254
+ }));
255
+
54
256
  return router;
55
257
  }
@@ -1,15 +1,46 @@
1
1
  /**
2
2
  * src/server/routes/providers.mjs
3
3
  *
4
- * /api/providers — aggregated list (opencode.json + agent frontmatter + serve HTTP)
5
- * /api/providers/active — current default provider + model
4
+ * Provider subsystem routes (v4.6.0).
6
5
  *
7
- * The dashboard's Overview card uses these to show what the user
8
- * can run. The /api/config/providers surface (which CRUDs the
9
- * registry store) lives in config.mjs.
6
+ * Endpoints (all mounted under /api):
7
+ *
8
+ * Aggregated list (read-only):
9
+ * GET /providers — opencode.json + agents + serve HTTP
10
+ * GET /providers/active — current default provider + model
11
+ * GET /providers/auto-detect — env+config detection
12
+ *
13
+ * Catalog (UI wizard):
14
+ * GET /providers/catalog — full curated provider catalog
15
+ * GET /providers/catalog/search?q=… — fuzzy-search the catalog
16
+ *
17
+ * Provider CRUD (delegates to providersStore):
18
+ * GET /providers — list configured providers
19
+ * POST /providers/auto — "Add with auto" wizard
20
+ * POST /providers/:id/keys — add a backup key
21
+ * DELETE /providers/:id/keys/:envVar — remove a key
22
+ * POST /providers/:id/rotate — manual rotation trigger
23
+ * PUT /providers/:id/keys/:envVar/status — set status
24
+ *
25
+ * The legacy `/api/config/providers/*` surface remains in routes/config.mjs
26
+ * for back-compat — those routes just delegate to the same providersStore
27
+ * functions used here.
10
28
  */
11
29
  import { Router } from 'express';
12
30
  import { providersStore } from '../providers-store.mjs';
31
+ import {
32
+ PROVIDER_CATALOG,
33
+ listCatalog,
34
+ searchProviders,
35
+ findCatalogEntry,
36
+ getActiveKey,
37
+ rotateKey,
38
+ markKeyError,
39
+ markKeySuccess,
40
+ addBackupKey,
41
+ removeBackupKey,
42
+ setKeyStatus,
43
+ } from '../providers-store.mjs';
13
44
  import { wrap } from './_shared.mjs';
14
45
 
15
46
  /**
@@ -18,6 +49,8 @@ import { wrap } from './_shared.mjs';
18
49
  export function createProvidersRouter() {
19
50
  const router = Router();
20
51
 
52
+ // ── Aggregated read-only endpoints (preserved from previous versions) ──
53
+
21
54
  // Surface-everything endpoint the dashboard uses to populate the
22
55
  // Providers card on the Overview tab. Reads from opencode.json +
23
56
  // agent frontmatter + (best-effort) the running opencode serve HTTP
@@ -43,5 +76,233 @@ export function createProvidersRouter() {
43
76
  res.json({ providers: results, count: results.length });
44
77
  }));
45
78
 
79
+ // ── v4.6.0 catalog (UI wizard) ──────────────────────────────────────────
80
+
81
+ // GET /providers/catalog — full curated list, UI-safe shape.
82
+ router.get('/providers/catalog', wrap(async (_req, res) => {
83
+ res.json({ catalog: listCatalog(), count: PROVIDER_CATALOG.length });
84
+ }));
85
+
86
+ // GET /providers/catalog/search?q=minimax&limit=20 — fuzzy search.
87
+ router.get('/providers/catalog/search', wrap(async (req, res) => {
88
+ const q = typeof req.query.q === 'string' ? req.query.q : '';
89
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 50);
90
+ const results = searchProviders(q, { limit });
91
+ res.json({ query: q, results, count: results.length });
92
+ }));
93
+
94
+ // ── v4.6.0 provider CRUD (rotation-aware) ──────────────────────────────
95
+
96
+ // GET /providers/list — list configured providers from opencode.json.
97
+ // (Distinct path from /providers because /providers returns the
98
+ // aggregated discovery view; /providers/list returns the persisted
99
+ // opencode.json shape.)
100
+ router.get('/providers/list', wrap(async (_req, res) => {
101
+ res.json({ providers: providersStore.list() });
102
+ }));
103
+
104
+ // POST /providers/auto — v4.6.0 "Add with auto" wizard.
105
+ //
106
+ // The new wizard is env-var-first: the user pastes an envVar (or
107
+ // types a label and we synthesize one), and we look up the catalog
108
+ // entry by `providerId` to fill baseURL/models. We do NOT probe
109
+ // /v1/models here — the user hasn't pasted a key yet (it's in env).
110
+ //
111
+ // Body: { providerId: string, envVar: string, label?: string,
112
+ // setAsSystemLlm?: boolean, preferredModel?: string }
113
+ router.post('/providers/auto', wrap(async (req, res) => {
114
+ const body = req.body || {};
115
+ const providerId = typeof body.providerId === 'string' ? body.providerId.trim() : '';
116
+ const envVar = typeof body.envVar === 'string' ? body.envVar.trim() : '';
117
+ const label = typeof body.label === 'string' ? body.label.trim() : '';
118
+
119
+ if (!providerId) {
120
+ res.status(400).json({ ok: false, error: 'providerId_required', message: 'providerId is required' });
121
+ return;
122
+ }
123
+ if (!envVar) {
124
+ res.status(400).json({ ok: false, error: 'envVar_required', message: 'envVar is required' });
125
+ return;
126
+ }
127
+ if (!/^[A-Z][A-Z0-9_]{1,62}$/.test(envVar)) {
128
+ res.status(400).json({ ok: false, error: 'invalid_envVar', message: 'envVar must be UPPER_SNAKE_CASE' });
129
+ return;
130
+ }
131
+ const entry = findCatalogEntry(providerId);
132
+ if (!entry) {
133
+ res.status(404).json({
134
+ ok: false,
135
+ error: 'unknown_provider',
136
+ message: `Unknown providerId "${providerId}". Try GET /api/providers/catalog.`,
137
+ });
138
+ return;
139
+ }
140
+
141
+ // Build the provider entry: keys[0] = the env-var key the user
142
+ // supplied. We do NOT persist any key value — it lives in the env.
143
+ const cfg = providersStore.add({
144
+ id: providerId,
145
+ name: entry.name,
146
+ baseURL: typeof body.baseURL === 'string' && body.baseURL.trim() ? body.baseURL.trim() : entry.baseURL,
147
+ apiKey: `<env:${envVar}>`, // marker — opencode sees a non-empty key
148
+ keys: [
149
+ {
150
+ envVar,
151
+ label: label || 'Primary',
152
+ status: 'active',
153
+ lastError: null,
154
+ errorCount: 0,
155
+ lastUsed: null,
156
+ },
157
+ ],
158
+ models: Array.isArray(body.models) && body.models.length > 0 ? body.models : entry.models.slice(),
159
+ enabled: true,
160
+ });
161
+
162
+ // Optional: set as the system LLM so /api/llm/* hits this provider.
163
+ if (body.setAsSystemLlm !== false) {
164
+ // Lazy: use the store's addWithAuto path so we share its
165
+ // systemLlm update logic.
166
+ const models = Array.isArray(cfg.models) && cfg.models.length > 0 ? cfg.models : entry.models;
167
+ const preferredModel = typeof body.preferredModel === 'string' && body.preferredModel.trim()
168
+ ? body.preferredModel.trim()
169
+ : (Array.isArray(models) ? models[0] : null);
170
+ if (preferredModel) {
171
+ // providersStore doesn't expose a public systemLlm setter, so we
172
+ // write it through addWithAuto's side-effect path: call it with
173
+ // a noop key (since the env-var key lives in env, we can't probe
174
+ // models here, but we can still set systemLlm). Use a dummy
175
+ // string — it never touches the disk because addWithAuto only
176
+ // writes a key if it differs from the existing one.
177
+ try {
178
+ await providersStore.addWithAuto({
179
+ id: providerId,
180
+ apiKey: `env:${envVar}`, // bypass probe path; addWithAuto will keep existing
181
+ setAsSystemLlm: true,
182
+ preferredModel,
183
+ systemLlmModel: preferredModel,
184
+ });
185
+ } catch {
186
+ // addWithAuto may reject due to keyPattern — that's fine, the
187
+ // systemLlm side-effect runs after the key check. We ignore
188
+ // any throw here; the provider is already added.
189
+ }
190
+ }
191
+ }
192
+
193
+ res.status(201).json({
194
+ ok: true,
195
+ provider: providersStore.get(providerId),
196
+ catalog: { id: entry.id, name: entry.name, baseURL: entry.baseURL },
197
+ });
198
+ }));
199
+
200
+ // POST /providers/:id/keys — add a backup key to an existing provider.
201
+ // Body: { envVar: string, label?: string }
202
+ router.post('/providers/:id/keys', wrap(async (req, res) => {
203
+ const { id } = req.params;
204
+ const body = req.body || {};
205
+ const envVar = typeof body.envVar === 'string' ? body.envVar.trim() : '';
206
+ const label = typeof body.label === 'string' ? body.label.trim() : '';
207
+ if (!envVar) {
208
+ res.status(400).json({ ok: false, error: 'envVar_required', message: 'envVar is required' });
209
+ return;
210
+ }
211
+ if (!/^[A-Z][A-Z0-9_]{1,62}$/.test(envVar)) {
212
+ res.status(400).json({ ok: false, error: 'invalid_envVar', message: 'envVar must be UPPER_SNAKE_CASE' });
213
+ return;
214
+ }
215
+ if (!providersStore.get(id)) {
216
+ res.status(404).json({ ok: false, error: 'not_found', message: `provider "${id}" not found` });
217
+ return;
218
+ }
219
+ try {
220
+ const inserted = addBackupKey(id, envVar, label);
221
+ res.status(201).json({ ok: true, key: inserted, provider: providersStore.get(id) });
222
+ } catch (err) {
223
+ res.status(400).json({ ok: false, error: 'add_failed', message: err.message });
224
+ }
225
+ }));
226
+
227
+ // DELETE /providers/:id/keys/:envVar — remove a key. Refuses if
228
+ // it would leave the provider with zero keys.
229
+ router.delete('/providers/:id/keys/:envVar', wrap(async (req, res) => {
230
+ const { id, envVar } = req.params;
231
+ if (!providersStore.get(id)) {
232
+ res.status(404).json({ ok: false, error: 'not_found' });
233
+ return;
234
+ }
235
+ try {
236
+ const ok = removeBackupKey(id, envVar);
237
+ if (!ok) {
238
+ res.status(404).json({ ok: false, error: 'key_not_found', message: `no key with envVar "${envVar}"` });
239
+ return;
240
+ }
241
+ res.status(200).json({ ok: true, provider: providersStore.get(id) });
242
+ } catch (err) {
243
+ res.status(400).json({ ok: false, error: 'remove_failed', message: err.message });
244
+ }
245
+ }));
246
+
247
+ // POST /providers/:id/rotate — manual rotation trigger. Marks the
248
+ // current active key as disabled and promotes the next one.
249
+ router.post('/providers/:id/rotate', wrap(async (req, res) => {
250
+ const { id } = req.params;
251
+ if (!providersStore.get(id)) {
252
+ res.status(404).json({ ok: false, error: 'not_found' });
253
+ return;
254
+ }
255
+ const newActive = rotateKey(id);
256
+ if (!newActive) {
257
+ res.status(409).json({ ok: false, error: 'no_rotation_target', message: 'no other keys available to rotate to' });
258
+ return;
259
+ }
260
+ res.json({ ok: true, active: newActive, provider: providersStore.get(id) });
261
+ }));
262
+
263
+ // PUT /providers/:id/keys/:envVar/status — set a key's status
264
+ // manually (operator override).
265
+ // Body: { status: 'active'|'standby'|'disabled'|'cooldown' }
266
+ router.put('/providers/:id/keys/:envVar/status', wrap(async (req, res) => {
267
+ const { id, envVar } = req.params;
268
+ const status = req.body?.status;
269
+ if (!providersStore.get(id)) {
270
+ res.status(404).json({ ok: false, error: 'not_found' });
271
+ return;
272
+ }
273
+ if (!['active', 'standby', 'disabled', 'cooldown'].includes(status)) {
274
+ res.status(400).json({ ok: false, error: 'invalid_status', message: 'status must be active|standby|disabled|cooldown' });
275
+ return;
276
+ }
277
+ try {
278
+ const updated = setKeyStatus(id, envVar, status);
279
+ res.json({ ok: true, key: updated, provider: providersStore.get(id) });
280
+ } catch (err) {
281
+ res.status(400).json({ ok: false, error: 'set_status_failed', message: err.message });
282
+ }
283
+ }));
284
+
285
+ // GET /providers/:id/active-key — read the active key + value from env.
286
+ // (Handy for the dashboard's "what's in use right now" diagnostic card.)
287
+ router.get('/providers/:id/active-key', wrap(async (req, res) => {
288
+ const { id } = req.params;
289
+ const active = getActiveKey(id);
290
+ if (!active) {
291
+ res.status(404).json({ ok: false, error: 'no_keys', message: 'no keys configured for this provider' });
292
+ return;
293
+ }
294
+ // Mask the value (don't echo raw key in API responses).
295
+ res.json({
296
+ ok: true,
297
+ active: {
298
+ envVar: active.envVar,
299
+ label: active.label,
300
+ status: active.status,
301
+ keyPreview: active.key.length > 8 ? `${active.key.slice(0, 2)}...${active.key.slice(-2)}` : (active.key ? '***short***' : ''),
302
+ keySet: active.key.length > 0,
303
+ },
304
+ });
305
+ }));
306
+
46
307
  return router;
47
308
  }