omnirush 0.5.0 → 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.
- package/assets/extensions/omnirush/auth.js +102 -11
- package/assets/extensions/omnirush/collector-lib.ts +79 -54
- package/assets/extensions/omnirush/collector.ts +38 -0
- package/assets/extensions/omnirush/sota-lib.ts +16 -2
- package/assets/extensions/omnirush/sota.ts +36 -1
- package/package.json +1 -1
- package/src/lib.js +48 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
340
|
+
if (served === undefined || !isSotaViolation(expectedModelId, served)) return;
|
|
306
341
|
reported = true;
|
|
307
342
|
onViolation(expectedModelId, served);
|
|
308
343
|
};
|
package/package.json
CHANGED
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)
|
|
81
|
-
// Live-verified 2026-09-22 against the production
|
|
82
|
-
// lists both ids and POST /v1/responses with
|
|
83
|
-
// as gpt-5.6-sol (x-omnirush-model
|
|
84
|
-
// every SSE frame), so the client
|
|
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
|
/**
|