@getmegabrain/cli 0.1.10 → 0.1.12

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.
@@ -4,43 +4,89 @@
4
4
  * `ModelClient` so the transport is swappable; in production it's the Gateway
5
5
  * (`/api/gateway/v1/messages`) authenticated with the signed-in account token.
6
6
  */
7
+ import { DEFAULT_MODEL } from './models.js';
8
+ /** Transient HTTP statuses worth retrying: overload (429/529), gateway/upstream (500/502/503/504),
9
+ * and request timeout (408). A 503 "temporarily_unavailable" from the Gateway lands here. */
10
+ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504, 529]);
7
11
  export class GatewayModel {
8
12
  url;
9
13
  apiKey;
10
14
  model;
11
15
  maxTokens;
16
+ maxRetries;
17
+ retryBaseMs;
18
+ sleep;
12
19
  constructor(options) {
13
20
  this.url =
14
21
  options.url ??
15
22
  process.env.MB_GATEWAY_URL ??
16
23
  `${(process.env.MEGABRAIN_APP_URL ?? 'https://getmegabrain.com').replace(/\/+$/, '')}/api/gateway/v1/messages`;
17
24
  this.apiKey = options.apiKey;
18
- this.model = options.model ?? process.env.MB_SCIENCE_MODEL ?? 'claude-opus-4-8';
25
+ this.model = options.model ?? DEFAULT_MODEL;
19
26
  this.maxTokens = options.maxTokens ?? 4096;
27
+ this.maxRetries = options.maxRetries ?? 4;
28
+ this.retryBaseMs = options.retryBaseMs ?? 800;
29
+ this.sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
30
+ }
31
+ /** Backoff for a given attempt (0-indexed): exponential + jitter, honoring Retry-After. */
32
+ backoffMs(attempt, retryAfter) {
33
+ const headerSeconds = retryAfter ? Number(retryAfter) : NaN;
34
+ if (Number.isFinite(headerSeconds) && headerSeconds >= 0) {
35
+ return Math.min(headerSeconds * 1000, 30_000);
36
+ }
37
+ const base = this.retryBaseMs * 2 ** attempt;
38
+ return Math.min(base, 15_000) + Math.floor(Math.random() * 250);
20
39
  }
21
40
  async complete(request) {
22
41
  if (!this.apiKey)
23
42
  throw new Error('Sign in to MegaBrain to use the agent.');
24
- const response = await fetch(this.url, {
25
- method: 'POST',
26
- headers: {
27
- 'content-type': 'application/json',
28
- authorization: `Bearer ${this.apiKey}`,
29
- 'anthropic-version': '2023-06-01',
30
- },
31
- body: JSON.stringify({
32
- model: this.model,
33
- max_tokens: this.maxTokens,
34
- system: request.system,
35
- messages: request.messages,
36
- tools: request.tools,
37
- }),
43
+ const body = JSON.stringify({
44
+ model: this.model,
45
+ max_tokens: this.maxTokens,
46
+ system: request.system,
47
+ messages: request.messages,
48
+ tools: request.tools,
38
49
  });
39
- if (!response.ok) {
40
- const detail = await response.text();
41
- throw new Error(`Gateway request failed (${response.status}): ${detail.slice(0, 500)}`);
50
+ let lastError = '';
51
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
52
+ let response;
53
+ try {
54
+ response = await fetch(this.url, {
55
+ method: 'POST',
56
+ headers: {
57
+ 'content-type': 'application/json',
58
+ authorization: `Bearer ${this.apiKey}`,
59
+ 'anthropic-version': '2023-06-01',
60
+ },
61
+ body,
62
+ });
63
+ }
64
+ catch (error) {
65
+ // Network-level failure (DNS, connection reset) — treat as transient.
66
+ lastError = `network error: ${error instanceof Error ? error.message : String(error)}`;
67
+ if (attempt < this.maxRetries) {
68
+ await this.sleep(this.backoffMs(attempt));
69
+ continue;
70
+ }
71
+ throw new Error(`Gateway request failed (${lastError}) after ${attempt + 1} attempts`);
72
+ }
73
+ if (response.ok) {
74
+ const data = (await response.json());
75
+ return { content: data.content ?? [], stopReason: data.stop_reason ?? 'end_turn' };
76
+ }
77
+ const detail = (await response.text()).slice(0, 500);
78
+ if (RETRYABLE_STATUSES.has(response.status)) {
79
+ lastError = `${response.status}: ${detail}`;
80
+ if (attempt < this.maxRetries) {
81
+ await this.sleep(this.backoffMs(attempt, response.headers.get('retry-after')));
82
+ continue;
83
+ }
84
+ // Transient, but out of retries.
85
+ throw new Error(`Gateway request failed (${lastError}) after ${attempt + 1} attempts`);
86
+ }
87
+ throw new Error(`Gateway request failed (${response.status}): ${detail}`);
42
88
  }
43
- const data = (await response.json());
44
- return { content: data.content ?? [], stopReason: data.stop_reason ?? 'end_turn' };
89
+ // Unreachable: the loop always returns or throws. Present for exhaustiveness.
90
+ throw new Error(`Gateway request failed (${lastError})`);
45
91
  }
46
92
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Model picker source for MegaBrain Science. The list of selectable models is the owner-mode
3
+ * picker the Gateway serves from the `megabrain_model_map` table (curated `mb-auto/*` tiers +
4
+ * the concrete OmniRoute coding models) — fetched live from `/api/gateway/models` with the
5
+ * signed-in token. If that fetch fails we fall back to a small hardcoded mirror so the picker
6
+ * is never empty. The old default (`claude-opus-4-8`) is no longer served and returned 503s;
7
+ * the default is now a live Auto tier.
8
+ */
9
+ /** Live default: the "frontier" Auto tier — the most capable routing for research work. */
10
+ export const DEFAULT_MODEL = process.env.MB_SCIENCE_MODEL ?? 'mb-auto/frontier';
11
+ // A minimal mirror of the live picker, used only when the Gateway list can't be fetched.
12
+ // The live Auto tiers are frontier + free (consolidated in #299); the concrete OmniRoute
13
+ // coding models stay pickable via megabrain_model_map.
14
+ export const FALLBACK_MODELS = [
15
+ { id: 'mb-auto/frontier', label: 'Auto · Frontier' },
16
+ { id: 'mb-auto/free', label: 'Auto · Free' },
17
+ { id: 'moonshotai/kimi-k2.7-code', label: 'Kimi K2.7 Code' },
18
+ { id: 'z-ai/glm-5.2', label: 'GLM-5.2' },
19
+ { id: 'minimax/minimax-m3', label: 'MiniMax M3' },
20
+ ];
21
+ function gatewayModelsUrl() {
22
+ if (process.env.MB_GATEWAY_MODELS_URL)
23
+ return process.env.MB_GATEWAY_MODELS_URL;
24
+ const base = (process.env.MEGABRAIN_APP_URL ?? 'https://getmegabrain.com').replace(/\/+$/, '');
25
+ return `${base}/api/gateway/models`;
26
+ }
27
+ /** Turn a model id into a readable label when the catalogue doesn't provide a name. */
28
+ export function prettyLabel(id) {
29
+ const autoTier = /^mb-auto\/(.+)$/.exec(id);
30
+ if (autoTier) {
31
+ const tier = autoTier[1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
32
+ return `Auto · ${tier}`;
33
+ }
34
+ const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
35
+ return tail.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
36
+ }
37
+ // Auto tiers surface first, most-capable first; unknown ids fall to the end of the Auto block.
38
+ const AUTO_TIER_ORDER = [
39
+ 'mb-auto/frontier',
40
+ 'mb-auto/best-coding',
41
+ 'mb-auto/balanced',
42
+ 'mb-auto/efficient',
43
+ 'mb-auto/small',
44
+ 'mb-auto/free',
45
+ ];
46
+ function autoRank(id) {
47
+ const i = AUTO_TIER_ORDER.indexOf(id);
48
+ if (i >= 0)
49
+ return i;
50
+ return id.startsWith('mb-auto/') ? AUTO_TIER_ORDER.length : Infinity;
51
+ }
52
+ /** Sort so the Auto tiers surface first (most capable first), then the rest alphabetically. */
53
+ function orderModels(models) {
54
+ return [...models].sort((a, b) => {
55
+ const ra = autoRank(a.id);
56
+ const rb = autoRank(b.id);
57
+ if (ra !== rb)
58
+ return ra - rb;
59
+ return a.label.localeCompare(b.label);
60
+ });
61
+ }
62
+ /**
63
+ * Fetch the owner-mode picker models from the Gateway. Returns the curated
64
+ * `megabrain_model_map` set for the signed-in account; falls back to FALLBACK_MODELS
65
+ * on any error or an empty response.
66
+ */
67
+ export async function fetchPickerModels(token) {
68
+ if (!token)
69
+ return [...FALLBACK_MODELS];
70
+ try {
71
+ const response = await fetch(gatewayModelsUrl(), {
72
+ headers: { authorization: `Bearer ${token}` },
73
+ });
74
+ if (!response.ok)
75
+ return [...FALLBACK_MODELS];
76
+ const data = (await response.json());
77
+ const models = (data.data ?? [])
78
+ .map(m => ({
79
+ id: String(m.id ?? ''),
80
+ label: m.name?.trim() || prettyLabel(String(m.id ?? '')),
81
+ }))
82
+ .filter(m => m.id);
83
+ return models.length ? orderModels(models) : [...FALLBACK_MODELS];
84
+ }
85
+ catch {
86
+ return [...FALLBACK_MODELS];
87
+ }
88
+ }
@@ -316,7 +316,10 @@ export function sessionPage(projectId, sessionId) {
316
316
  .composer-inner:focus-within { border-color:var(--accent); }
317
317
  textarea#composer { flex:1; border:0; background:transparent; resize:none; color:inherit; font:inherit;
318
318
  outline:none; max-height:160px; }
319
- .model { font-size:12px; color:var(--muted); align-self:center; white-space:nowrap; }
319
+ select.model { font-size:12px; color:var(--muted); align-self:center; white-space:nowrap;
320
+ max-width:180px; border:1px solid var(--border); border-radius:8px; background:var(--panel);
321
+ padding:5px 8px; font-family:inherit; cursor:pointer; outline:none; }
322
+ select.model:focus { border-color:var(--accent); }
320
323
  #send { border:0; border-radius:10px; background:var(--accent); color:#fff; font:inherit; font-weight:600;
321
324
  padding:8px 16px; cursor:pointer; }
322
325
  #send:disabled { opacity:.5; cursor:default; }
@@ -336,7 +339,7 @@ export function sessionPage(projectId, sessionId) {
336
339
  <div class="composer">
337
340
  <div class="composer-inner">
338
341
  <textarea id="composer" rows="1" placeholder="Ask anything — the agent runs code locally…"></textarea>
339
- <span class="model">Opus 4.8</span>
342
+ <select class="model" id="model" title="Model"></select>
340
343
  <button id="send">Send</button>
341
344
  </div>
342
345
  </div>
@@ -13,6 +13,7 @@ import { ArxivClient, literatureTool, MultiSourceClient } from './agent/literatu
13
13
  import { OpenAlexClient } from './agent/openalex.js';
14
14
  import { PubMedClient } from './agent/pubmed.js';
15
15
  import { GatewayModel } from './agent/model.js';
16
+ import { DEFAULT_MODEL, fetchPickerModels } from './agent/models.js';
16
17
  /**
17
18
  * The `megabrain science` local daemon's HTTP server. Serves the sign-in hand-off on
18
19
  * localhost (docs/megabrain-science/reference/claude-science-flow.md §1.1) and, once signed
@@ -184,6 +185,14 @@ export async function startScienceServer(host = '127.0.0.1') {
184
185
  clearCredentials();
185
186
  return json(res, { ok: true });
186
187
  }
188
+ // ── Model picker: the live owner-mode models from megabrain_model_map. ──
189
+ if (path === '/api/models' && method === 'GET') {
190
+ const creds = readCredentials();
191
+ if (!creds)
192
+ return json(res, { error: 'signed-out' }, 401);
193
+ const models = await fetchPickerModels(creds.token);
194
+ return json(res, { models, default: DEFAULT_MODEL });
195
+ }
187
196
  // ── Onboarding API (#286). Signed-in only. ──────────────────────────
188
197
  if (path === '/api/onboarding') {
189
198
  if (!isSignedIn())
@@ -263,11 +272,12 @@ export async function startScienceServer(host = '127.0.0.1') {
263
272
  const prompt = str(b.prompt)?.trim() ?? '';
264
273
  if (!prompt)
265
274
  return json(res, { error: 'prompt-required' }, 400);
275
+ const model = str(b.model)?.trim() || DEFAULT_MODEL;
266
276
  const session = store.getSession(sid);
267
277
  const project = session ? store.getProject(session.projectId) : null;
268
278
  store.touchSession(sid, session && !session.summary ? { summary: prompt } : undefined);
269
279
  fanout(sid, 'agent', { type: 'user', text: prompt });
270
- const agent = new AgentSession(new GatewayModel({ apiKey: creds.token }), [
280
+ const agent = new AgentSession(new GatewayModel({ apiKey: creds.token, model }), [
271
281
  pythonTool(kernels.get(sid)),
272
282
  literatureTool(new MultiSourceClient([new ArxivClient(), new OpenAlexClient(), new PubMedClient()])),
273
283
  ], evt => fanout(sid, 'agent', evt), project?.agentContext ?? '', store.getMessages(sid));
@@ -14,7 +14,9 @@
14
14
  var composer = $('composer');
15
15
  var sendBtn = $('send');
16
16
  var kstate = $('kstate');
17
+ var modelSel = $('model');
17
18
  var lastCard = null;
19
+ var MODEL_KEY = 'mb_sci_model';
18
20
 
19
21
  function el(tag, cls, text) {
20
22
  var e = document.createElement(tag);
@@ -116,6 +118,42 @@
116
118
  });
117
119
  }
118
120
 
121
+ // ── Model picker: the live owner-mode models (from megabrain_model_map) ──
122
+ function loadModels() {
123
+ if (!modelSel) return;
124
+ fetch('/api/models')
125
+ .then(function (r) {
126
+ return r.json();
127
+ })
128
+ .then(function (data) {
129
+ var models = (data && data.models) || [];
130
+ var saved = null;
131
+ try {
132
+ saved = localStorage.getItem(MODEL_KEY);
133
+ } catch (e) {
134
+ saved = null;
135
+ }
136
+ var want = saved || (data && data.default);
137
+ modelSel.textContent = '';
138
+ models.forEach(function (m) {
139
+ var opt = el('option', null, m.label || m.id);
140
+ opt.value = m.id;
141
+ if (m.id === want) opt.selected = true;
142
+ modelSel.appendChild(opt);
143
+ });
144
+ // If the saved/default id wasn't in the list, fall back to the first option.
145
+ if (models.length && modelSel.selectedIndex < 0) modelSel.selectedIndex = 0;
146
+ modelSel.addEventListener('change', function () {
147
+ try {
148
+ localStorage.setItem(MODEL_KEY, modelSel.value);
149
+ } catch (e) {
150
+ /* ignore */
151
+ }
152
+ });
153
+ })
154
+ .catch(function () {});
155
+ }
156
+
119
157
  // ── Live event stream ───────────────────────────────────────────────
120
158
  function connect() {
121
159
  var es = new EventSource('/api/sessions/' + sid + '/events');
@@ -151,7 +189,7 @@
151
189
  fetch('/api/sessions/' + sid + '/agent', {
152
190
  method: 'POST',
153
191
  headers: { 'content-type': 'application/json' },
154
- body: JSON.stringify({ prompt: text }),
192
+ body: JSON.stringify({ prompt: text, model: modelSel ? modelSel.value : undefined }),
155
193
  }).catch(function () {
156
194
  setRunning(false);
157
195
  });
@@ -199,6 +237,7 @@
199
237
  .catch(function () {})
200
238
  .then(function () {
201
239
  loadSidebar();
240
+ loadModels();
202
241
  connect();
203
242
  });
204
243
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmegabrain/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "MegaBrain CLI — sign in once, then code from your terminal via OpenCode wired to the MegaBrain Gateway, or run `megabrain science` for the local research workbench.",
5
5
  "license": "MIT",
6
6
  "type": "module",