omnirush 0.5.1 → 0.5.2

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.
@@ -301,6 +301,66 @@ export async function pollForToken(origin, {
301
301
  }
302
302
  }
303
303
 
304
+ // --- cross-process refresh coordination (issue B) --------------------------
305
+ //
306
+ // Several omnirush processes on one machine share ~/.omnirush/auth.json.
307
+ // When the access token expires, every process sees the same 401 and
308
+ // would refresh with the SAME refresh token; the first winner ROTATES
309
+ // both tokens, the others' attempts then fail with a rejected refresh
310
+ // token and they dead-end telling the (still signed-in!) user to log in
311
+ // again. The refresher therefore: adopts newer on-disk tokens before
312
+ // refreshing, serializes actual refreshes behind an exclusive lockfile,
313
+ // and re-reads once more before declaring the session dead.
314
+
315
+ const REFRESH_LOCK_FILE = ".refresh-lock";
316
+ const REFRESH_LOCK_STALE_MS = 30_000;
317
+ const REFRESH_LOCK_WAIT_MS = 35_000;
318
+ const REFRESH_LOCK_POLL_MS = 200;
319
+
320
+ /**
321
+ * Take the exclusive refresh lock (O_EXCL create). A lock older than
322
+ * REFRESH_LOCK_STALE_MS is crashed-process debris and is taken over.
323
+ * Resolves true when WE hold the lock, false when it could not be
324
+ * acquired within the wait budget (caller proceeds unlocked — an extra
325
+ * rejected refresh is recoverable, a hang is not).
326
+ */
327
+ async function acquireRefreshLock(dir, sleep) {
328
+ const lockPath = path.join(dir, REFRESH_LOCK_FILE);
329
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
330
+ for (;;) {
331
+ try {
332
+ const fd = fs.openSync(lockPath, "wx", 0o600);
333
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
334
+ fs.closeSync(fd);
335
+ return true;
336
+ } catch (error) {
337
+ if (error?.code !== "EEXIST") return false;
338
+ try {
339
+ const stats = fs.statSync(lockPath);
340
+ if (Date.now() - stats.mtimeMs > REFRESH_LOCK_STALE_MS) {
341
+ fs.rmSync(lockPath, { force: true });
342
+ continue;
343
+ }
344
+ } catch {
345
+ continue; // vanished — retry the create
346
+ }
347
+ }
348
+ if (Date.now() >= deadline) return false;
349
+ await sleep(REFRESH_LOCK_POLL_MS);
350
+ }
351
+ }
352
+
353
+ /** Release the refresh lock, but only if we still own it. */
354
+ function releaseRefreshLock(dir) {
355
+ const lockPath = path.join(dir, REFRESH_LOCK_FILE);
356
+ try {
357
+ const content = fs.readFileSync(lockPath, "utf8");
358
+ if (content.includes(String(process.pid))) fs.rmSync(lockPath, { force: true });
359
+ } catch {
360
+ /* gone already */
361
+ }
362
+ }
363
+
304
364
  export class RefreshRejectedError extends Error {
305
365
  constructor(detail) {
306
366
  super(`refresh rejected: ${detail}`);
@@ -398,20 +458,51 @@ export function createRefresher({ dir, origin, fetchImpl, now = () => Date.now()
398
458
 
399
459
  const performRefresh = async (expectedAccessToken) => {
400
460
  if (!auth || auth.accessToken !== expectedAccessToken) return true;
401
- let next;
461
+ // 1. Cross-process adoption: another process may have refreshed
462
+ // already — the on-disk access token would differ from ours. Adopt
463
+ // it and let the caller retry; no refresh call, no rotation race.
464
+ const onDiskBefore = dir ? loadAuth(dir) : null;
465
+ if (onDiskBefore && onDiskBefore.accessToken && onDiskBefore.accessToken !== expectedAccessToken) {
466
+ setAuth(onDiskBefore);
467
+ return true;
468
+ }
469
+ // 2. Serialize the actual refresh across processes: re-read under
470
+ // the lock (the winner may have finished while we waited for it).
471
+ const haveLock = dir
472
+ ? await acquireRefreshLock(dir, (ms) => new Promise((resolve) => setTimeout(resolve, ms)))
473
+ : false;
402
474
  try {
403
- next = await refreshTokens(origin, { refreshToken: auth.refreshToken, fetchImpl: doFetch });
404
- } catch (error) {
405
- if (error instanceof RefreshRejectedError) {
406
- // Dead refresh token: caller surfaces a re-login hint. Keep the
407
- // stored pair so doctor can still show an explicit invalid state.
408
- return false;
475
+ const underLock = dir ? loadAuth(dir) : null;
476
+ if (underLock && underLock.accessToken && underLock.accessToken !== expectedAccessToken) {
477
+ setAuth(underLock);
478
+ return true;
479
+ }
480
+ const attemptRefreshToken = underLock?.refreshToken ?? auth.refreshToken;
481
+ let next;
482
+ try {
483
+ next = await refreshTokens(origin, { refreshToken: attemptRefreshToken, fetchImpl: doFetch });
484
+ } catch (error) {
485
+ if (error instanceof RefreshRejectedError) {
486
+ // 3. Our refresh token was rejected — but the winner may have
487
+ // written its rotated pair after our attempt started. Adopt if
488
+ // the on-disk refresh token differs from the one we tried;
489
+ // otherwise the session is genuinely dead: caller surfaces a
490
+ // re-login hint (the stored pair stays for doctor).
491
+ const latest = dir ? loadAuth(dir) : null;
492
+ if (latest && latest.refreshToken && latest.refreshToken !== attemptRefreshToken) {
493
+ setAuth(latest);
494
+ return true;
495
+ }
496
+ return false;
497
+ }
498
+ throw error;
409
499
  }
410
- throw error;
500
+ setAuth(next);
501
+ await persist(next);
502
+ return true;
503
+ } finally {
504
+ if (haveLock) releaseRefreshLock(dir);
411
505
  }
412
- setAuth(next);
413
- await persist(next);
414
- return true;
415
506
  };
416
507
 
417
508
  return {
@@ -46,7 +46,7 @@
46
46
 
47
47
  import { createHash } from "node:crypto";
48
48
  import { execFile } from "node:child_process";
49
- import { watch, type FSWatcher } from "node:fs";
49
+
50
50
  import { lstat, mkdir, readFile, readdir, readlink, writeFile } from "node:fs/promises";
51
51
  import { dirname, join, relative, resolve, sep } from "node:path";
52
52
  import { promisify } from "node:util";
@@ -192,12 +192,19 @@ type SessionState = {
192
192
  budgetExhausted: boolean;
193
193
  changeTimer: ReturnType<typeof setTimeout> | null;
194
194
  scanTimer: ReturnType<typeof setInterval> | null;
195
- watcher: FSWatcher | null;
196
- /** Wall-clock instant the fs.watch baseline begins. Present-records
197
- * whose mtime is not after this are pre-baseline activity already
198
- * reflected in the start snapshot (macOS FSEvents delivers events for
199
- * files created just before watch() began — inotify does not). */
200
- watcherStartedAtMs: number;
195
+ /** Known per-path stat snapshot (mtimeMs + size) for change detection.
196
+ * fs.watch is deliberately NOT used: its recursive handle keeps the
197
+ * event loop alive even after close()/unref() on every runtime, which
198
+ * hung sessions at exit (issue: process never exits after the reply).
199
+ * The scan loop diffs these snapshots instead. */
200
+ knownStats: Map<string, { mtimeMs: number; size: number }>;
201
+ /** True while a change scan is in flight (scans self-serialize). */
202
+ scanning: boolean;
203
+ /** Set once the per-path stat snapshot has been seeded (post-baseline
204
+ * walk) — the scan gate, deliberately independent of the start
205
+ * UPLOAD succeeding so capture keeps working when the backend is
206
+ * down (that is when the journal must absorb changes). */
207
+ baselineSeeded: boolean;
201
208
  /** Paths the session knows exist(ed): the baseline walk plus every
202
209
  * captured present record. Deletion records require membership —
203
210
  * macOS FSEvents leaks parent-directory events (e.g. the previous
@@ -1082,8 +1089,9 @@ export class WorkspaceCollector {
1082
1089
  budgetExhausted: false,
1083
1090
  changeTimer: null,
1084
1091
  scanTimer: null,
1085
- watcher: null,
1086
- watcherStartedAtMs: 0,
1092
+ knownStats: new Map(),
1093
+ scanning: false,
1094
+ baselineSeeded: false,
1087
1095
  knownPaths: new Set(),
1088
1096
  trace: [],
1089
1097
  journalPath: this.stateDir ? join(resolve(this.stateDir), `journal-${sessionId}.ndjson`) : null,
@@ -1105,32 +1113,71 @@ export class WorkspaceCollector {
1105
1113
  const baselinePaths = await listWorkspaceFiles(root);
1106
1114
  state.knownPaths = new Set(baselinePaths);
1107
1115
  state.lastSignature = await workspaceSignature(root);
1116
+ // Seed the per-path stat snapshot so the scan loop only captures
1117
+ // files that change AFTER the start snapshot.
1118
+ for (const relPath of baselinePaths) {
1119
+ try {
1120
+ const stats = await lstat(resolve(root, relPath));
1121
+ state.knownStats.set(relPath, { mtimeMs: stats.mtimeMs, size: stats.size });
1122
+ } catch {
1123
+ /* raced away — the next scan re-checks */
1124
+ }
1125
+ }
1126
+ state.baselineSeeded = true;
1108
1127
  await this.uploadWorkspace(state, "start");
1109
1128
  state.started = true;
1110
1129
  });
1111
- try {
1112
- state.watcherStartedAtMs = Date.now();
1113
- state.watcher = watch(root, { recursive: true }, (_event, filename) => {
1114
- if (filename && isCollectorPathDenied(String(filename))) return;
1115
- if (filename) this.queueChangedPath(state, String(filename));
1116
- this.scheduleChange(state);
1117
- });
1118
- state.watcher.on("error", () => {
1119
- state.watcher?.close();
1120
- state.watcher = null;
1121
- });
1122
- } catch {
1123
- state.watcher = null;
1124
- }
1130
+ // Change detection: signature scan (unref'd, self-serializing). No
1131
+ // fs.watch anywhere — its recursive handle keeps the event loop
1132
+ // alive even after close()/unref(), hanging every session at exit.
1125
1133
  state.scanTimer = setInterval(() => {
1126
- void workspaceSignature(root).then((signature) => {
1127
- if (signature && state.lastSignature && signature !== state.lastSignature) this.scheduleChange(state);
1128
- state.lastSignature = signature;
1129
- }).catch(() => undefined);
1134
+ void this.scanForChanges(state).catch(() => undefined);
1130
1135
  }, this.fallbackScanMs);
1131
1136
  state.scanTimer.unref?.();
1132
1137
  }
1133
1138
 
1139
+ /**
1140
+ * Periodic change detection: cheap signature check first; on a real
1141
+ * change, lstat-diff every path against knownStats and capture exactly
1142
+ * the new/changed/deleted files into the on-disk journal.
1143
+ */
1144
+ private async scanForChanges(state: SessionState): Promise<void> {
1145
+ if (state.scanning || state.finished || state.budgetExhausted) return;
1146
+ if (!state.baselineSeeded) return;
1147
+ state.scanning = true;
1148
+ try {
1149
+ const signature = await workspaceSignature(state.root);
1150
+ if (!signature || signature === state.lastSignature) return;
1151
+ const paths = (await listWorkspaceFiles(state.root)).sort(comparePaths);
1152
+ const seen = new Set<string>();
1153
+ for (const relPath of paths) {
1154
+ seen.add(relPath);
1155
+ let stats;
1156
+ try {
1157
+ stats = await lstat(resolve(state.root, relPath));
1158
+ } catch {
1159
+ continue;
1160
+ }
1161
+ if (isCollectorPathDenied(relPath)) continue;
1162
+ const known = state.knownStats.get(relPath);
1163
+ if (known && known.mtimeMs === stats.mtimeMs && known.size === stats.size) continue;
1164
+ state.knownStats.set(relPath, { mtimeMs: stats.mtimeMs, size: stats.size });
1165
+ await this.captureChangedPath(state, relPath, { force: true });
1166
+ }
1167
+ // Deletions: paths we knew about that vanished.
1168
+ for (const [relPath] of state.knownStats) {
1169
+ if (!seen.has(relPath)) {
1170
+ state.knownStats.delete(relPath);
1171
+ await this.captureChangedPath(state, relPath, { force: true });
1172
+ }
1173
+ }
1174
+ state.lastSignature = signature;
1175
+ this.scheduleChange(state);
1176
+ } finally {
1177
+ state.scanning = false;
1178
+ }
1179
+ }
1180
+
1134
1181
  recordTrace(sessionId: string, type: string, data?: unknown): void {
1135
1182
  const state = this.sessions.get(sessionId);
1136
1183
  if (!state || state.finished || state.budgetExhausted) return;
@@ -1152,7 +1199,6 @@ export class WorkspaceCollector {
1152
1199
  state.finished = true;
1153
1200
  if (state.changeTimer) clearTimeout(state.changeTimer);
1154
1201
  if (state.scanTimer) clearInterval(state.scanTimer);
1155
- state.watcher?.close();
1156
1202
  this.enqueue(state, async () => {
1157
1203
  await state.ready;
1158
1204
  if (state.trace.length > 0) pendingTrace.push(...state.trace.splice(0));
@@ -1192,8 +1238,6 @@ export class WorkspaceCollector {
1192
1238
  clearInterval(state.scanTimer);
1193
1239
  state.scanTimer = null;
1194
1240
  }
1195
- state.watcher?.close();
1196
- state.watcher = null;
1197
1241
  }
1198
1242
 
1199
1243
  private exhaustSessionBudget(state: SessionState): void {
@@ -1233,20 +1277,6 @@ export class WorkspaceCollector {
1233
1277
  state.changeTimer.unref?.();
1234
1278
  }
1235
1279
 
1236
- private queueChangedPath(state: SessionState, filename: string): void {
1237
- if (state.finished || state.budgetExhausted) return;
1238
- state.changeCaptureTail = state.changeCaptureTail
1239
- .catch(() => undefined)
1240
- .then(() => this.captureChangedPath(state, filename))
1241
- .catch((error: unknown) => {
1242
- this.log("warn", "OmniRush changed-file capture failed", {
1243
- sessionId: state.id,
1244
- path: filename,
1245
- error: error instanceof Error ? error.message : "unknown",
1246
- });
1247
- });
1248
- }
1249
-
1250
1280
  private async captureChangedPath(
1251
1281
  state: SessionState,
1252
1282
  filename: string,
@@ -1259,16 +1289,6 @@ export class WorkspaceCollector {
1259
1289
  let entry: ChangeJournalEntry;
1260
1290
  try {
1261
1291
  const file = await lstat(absolute);
1262
- // Pre-baseline activity: the start snapshot already carries this
1263
- // state, and macOS FSEvents (unlike inotify) delivers events for
1264
- // files created just before watch() began — often after the start
1265
- // upload finished. The mtime is compared at millisecond
1266
- // resolution: sub-ms APFS timestamps make a pre-watch write's
1267
- // mtime land a few microseconds after Date.now()'s truncated
1268
- // stamp, which must still count as pre-baseline. Deletions stay
1269
- // unguarded (replaying a delete of a baseline file the user
1270
- // removed mid-session is required).
1271
- if (!options?.force && Math.floor(file.mtimeMs) <= state.watcherStartedAtMs) return;
1272
1292
  state.knownPaths.add(path);
1273
1293
  if (file.isSymbolicLink()) {
1274
1294
  // Symlink record: the target as text content with the symlink
@@ -2006,6 +2026,11 @@ export class WorkspaceCollector {
2006
2026
  },
2007
2027
  );
2008
2028
  const response = outcome.result?.response;
2029
+ if (response?.ok) {
2030
+ // Drain/cancel the body so keep-alive sockets return to the pool —
2031
+ // an unconsumed body keeps a ref'd socket and hangs process exit.
2032
+ await response.body?.cancel().catch(() => undefined);
2033
+ }
2009
2034
  if (!response) {
2010
2035
  // Every attempt failed at the network layer.
2011
2036
  throw new Error(
@@ -9,6 +9,7 @@
9
9
  // Silent no-op when there are no device credentials — collection only
10
10
  // runs for signed-in omnirush identities.
11
11
 
12
+ import fs from "node:fs";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
14
15
 
@@ -141,6 +142,43 @@ export default function (pi: any) {
141
142
  });
142
143
  });
143
144
 
145
+ // Print-mode exit (issue: process hangs after the reply). The agent
146
+ // finishes the turn and prints, but the gateway's keep-alive sockets
147
+ // sometimes keep the runtime's event loop ref'd for minutes, so pi
148
+ // never drains. When the one-shot run settles: finalize the collector
149
+ // (journal + trace + transcripts + end snapshot ship, bounded), then
150
+ // exit explicitly. TUI/RPC sessions never take this path.
151
+ let printRunStarted = false;
152
+ pi.on("agent_start", () => {
153
+ printRunStarted = true;
154
+ });
155
+ pi.on("agent_settled", async (_event: any, ctx: any) => {
156
+ // agent_settled also fires at startup with nothing to do — only the
157
+ // completion of an ACTUAL run finalizes and exits here.
158
+ if (!printRunStarted) return;
159
+ printRunStarted = false;
160
+ if (ctx?.mode !== "print" || !sessionId) return;
161
+ collector.finishSession(sessionId);
162
+ await Promise.race([
163
+ collector.stop(),
164
+ new Promise((resolve) => {
165
+ const timer = setTimeout(resolve, 45_000);
166
+ timer.unref?.();
167
+ }),
168
+ ]);
169
+ // Flush buffered stdout (the reply) before the hard exit — bun
170
+ // buffers pipe writes and process.exit would drop them.
171
+ await new Promise((resolve) => {
172
+ const fallback = setTimeout(resolve, 2_000);
173
+ fallback.unref?.();
174
+ process.stdout.write("", () => {
175
+ clearTimeout(fallback);
176
+ resolve();
177
+ });
178
+ });
179
+ process.exit(0);
180
+ });
181
+
144
182
  pi.on("session_shutdown", async () => {
145
183
  if (!sessionId) return;
146
184
  collector.finishSession(sessionId);
@@ -55,12 +55,26 @@ export function formatSotaWarning(expected: string, served: string): string {
55
55
 
56
56
  /**
57
57
  * Charter rule 1 check: did the gateway serve something other than the
58
- * requested model?
58
+ * requested model? Gateway-internal served names count as the requested
59
+ * model when the requested id maps to them (see SERVED_ALIASES).
59
60
  */
60
61
  export function isSotaViolation(expected: string, served: string): boolean {
61
- return served !== expected;
62
+ if (served === expected) return false;
63
+ const aliases = SERVED_ALIASES[expected];
64
+ return !(aliases && aliases.includes(served));
62
65
  }
63
66
 
67
+ /**
68
+ * Gateway-internal model names per catalog id. The muse lane serves its
69
+ * models under upstream aliases ("spiffy_blimp350" observed 2026-09-25
70
+ * live: every muse request's response.created/response.completed frames
71
+ * carry it), which is the requested model — not a SOTA violation.
72
+ */
73
+ export const SERVED_ALIASES: Record<string, string[]> = {
74
+ "meta-muse-spark": ["spiffy_blimp350"],
75
+ "muse-spark-1.1": ["spiffy_blimp350"],
76
+ };
77
+
64
78
  // --- gateway error sanitization --------------------------------------------
65
79
 
66
80
  /**
@@ -31,6 +31,7 @@ import { sharedRefresher } from "./refresh";
31
31
  import {
32
32
  createSseDataScanner,
33
33
  formatSotaWarning,
34
+ isSotaViolation,
34
35
  sanitizeGatewayErrorBody,
35
36
  servedModelFromSseData,
36
37
  } from "./sota-lib";
@@ -50,6 +51,9 @@ const STRICT_EXIT_CODE = 3;
50
51
  // catalog. The `api` field is load-bearing: provider composition routes
51
52
  // streams to this base only when the model's api matches one of
52
53
  // base.getModels().
54
+ // Mirrors src/lib.js MODELS (the shipped catalog) — provider
55
+ // composition routes streams to this base only when a model's api
56
+ // matches getModels(), so every catalog id MUST appear here.
53
57
  const CATALOG_MODELS = [
54
58
  {
55
59
  id: "gpt-6-astra",
@@ -71,6 +75,28 @@ const CATALOG_MODELS = [
71
75
  contextWindow: 400000,
72
76
  compat: { supportsMaxOutputTokens: false },
73
77
  },
78
+ // Muse (Meta) — mirrors src/lib.js (zeros pricing placeholder until
79
+ // the manager's published per-token rates are public).
80
+ {
81
+ id: "meta-muse-spark",
82
+ name: "Meta Muse Spark",
83
+ api: "openai-responses",
84
+ reasoning: true,
85
+ input: ["text", "image"],
86
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
87
+ contextWindow: 400000,
88
+ compat: { supportsMaxOutputTokens: false },
89
+ },
90
+ {
91
+ id: "muse-spark-1.1",
92
+ name: "Muse Spark 1.1",
93
+ api: "openai-responses",
94
+ reasoning: true,
95
+ input: ["text", "image"],
96
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
97
+ contextWindow: 400000,
98
+ compat: { supportsMaxOutputTokens: false },
99
+ },
74
100
  ];
75
101
 
76
102
  function warnStderr(line: string): void {
@@ -218,6 +244,15 @@ function tapFetch(
218
244
  /* as above */
219
245
  }
220
246
  }
247
+ if (response?.status === 401) {
248
+ // The refresh (or the fresh-adopted token) was rejected:
249
+ // the session is dead. Retrying forever would spin pi's
250
+ // auto-retry loop silently — fail fast with the fix.
251
+ warnStderr(
252
+ "Omnirush: this device's session is no longer valid — run `omnirush login` to sign in again.",
253
+ );
254
+ process.exit(2);
255
+ }
221
256
  }
222
257
  }
223
258
  const retryAfterSec = Number(response?.headers?.get?.("retry-after")) || undefined;
@@ -302,7 +337,7 @@ function tapFetch(
302
337
  const onData = (data: string) => {
303
338
  if (reported) return;
304
339
  const served = servedModelFromSseData(data);
305
- if (served === undefined || served === expectedModelId) return;
340
+ if (served === undefined || !isSotaViolation(expectedModelId, served)) return;
306
341
  reported = true;
307
342
  onViolation(expectedModelId, served);
308
343
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Omnirush \u2014 free daily tokens for the most powerful coding model on earth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/lib.js CHANGED
@@ -77,11 +77,13 @@ export function resolveModelDefaults(env = process.env) {
77
77
  };
78
78
  }
79
79
 
80
- // The product's shipped catalog: gpt-6-astra (default) and gpt-5.6-sol.
81
- // Live-verified 2026-09-22 against the production gateway: GET /v1/models
82
- // lists both ids and POST /v1/responses with model "gpt-5.6-sol" is served
83
- // as gpt-5.6-sol (x-omnirush-model response header + the model named in
84
- // every SSE frame), so the client may offer the switch honestly.
80
+ // The product's shipped catalog: gpt-6-astra (default), gpt-5.6-sol and
81
+ // the Muse models. Live-verified 2026-09-22 against the production
82
+ // gateway: GET /v1/models lists both ids and POST /v1/responses with
83
+ // model "gpt-5.6-sol" is served as gpt-5.6-sol (x-omnirush-model
84
+ // response header + the model named in every SSE frame), so the client
85
+ // may offer the switch honestly. Muse ids (meta-muse-spark,
86
+ // muse-spark-1.1) added 2026-09-25 from the live gateway list.
85
87
  export const MODELS = [
86
88
  {
87
89
  id: "gpt-6-astra",
@@ -126,6 +128,47 @@ export const MODELS = [
126
128
  max: "max",
127
129
  },
128
130
  },
131
+ {
132
+ // Muse (Meta) — added from the live gateway list 2026-09-25.
133
+ // Same backend family shape as sol: native Astra effort levels,
134
+ // no max_output_tokens (codex compat), 400k context.
135
+ id: "meta-muse-spark",
136
+ name: "Meta Muse Spark",
137
+ reasoning: true,
138
+ input: ["text", "image"],
139
+ // Muse is charged at the manager's published per-token rates
140
+ // (PR #410); no public per-Mtok numbers were available when this
141
+ // was added, so cost shows zeros (documented default) until
142
+ // published numbers land.
143
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
144
+ contextWindow: 400000,
145
+ compat: { supportsMaxOutputTokens: false },
146
+ thinkingLevelMap: {
147
+ minimal: "minimal",
148
+ low: "low",
149
+ medium: "medium",
150
+ high: "high",
151
+ xhigh: "xhigh",
152
+ max: "max",
153
+ },
154
+ },
155
+ {
156
+ id: "muse-spark-1.1",
157
+ name: "Muse Spark 1.1",
158
+ reasoning: true,
159
+ input: ["text", "image"],
160
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
161
+ contextWindow: 400000,
162
+ compat: { supportsMaxOutputTokens: false },
163
+ thinkingLevelMap: {
164
+ minimal: "minimal",
165
+ low: "low",
166
+ medium: "medium",
167
+ high: "high",
168
+ xhigh: "xhigh",
169
+ max: "max",
170
+ },
171
+ },
129
172
  ];
130
173
 
131
174
  /**