@oar-sh/oar 0.9.23 → 0.9.24
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/CHANGELOG.md +165 -0
- package/README.md +9 -9
- package/bin/oar.js +54 -4
- package/package.json +9 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +91 -8
- package/server/public/app/bootstrap.js +204 -69
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/settings-modal.js +167 -0
- package/server/public/app/socket-handlers.js +21 -0
- package/server/public/index.html +95 -2
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/messages-routes.mjs +91 -17
- package/server/routes/sessions-routes.mjs +235 -109
- package/server/server-runtime.mjs +395 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +17 -1
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/worker-runtime/direct-openai-image.mjs +8 -4
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { randomUUID } from 'node:crypto';
|
|
19
19
|
|
|
20
20
|
export const WORKER_LAUNCH_NOTE_KINDS = Object.freeze({
|
|
21
|
+
installing: 'worker-launch-installing',
|
|
21
22
|
failed: 'worker-launch-failed',
|
|
22
23
|
stopped: 'worker-launch-stopped',
|
|
23
24
|
started: 'worker-launch-started',
|
|
@@ -47,6 +48,11 @@ export function buildWorkerLaunchNoteText({ type, lifecycle = {}, retryEveryMs =
|
|
|
47
48
|
const tries = Math.max(0, Number(lifecycle?.retryCount || 0));
|
|
48
49
|
const triesText = `${tries} ${tries === 1 ? 'try' : 'tries'}`;
|
|
49
50
|
const cause = clipCause(lifecycle?.lastError);
|
|
51
|
+
if (type === 'installing') {
|
|
52
|
+
// The first chat of a provider whose agent SDK is not installed yet
|
|
53
|
+
// (agent-sdk-store.mjs): the relay installs it before the worker starts.
|
|
54
|
+
return `⏳ Installing the ${lifecycle?.sdkLabel || 'agent SDK'} on the relay host, once for this version. Your message is delivered when it is ready.`;
|
|
55
|
+
}
|
|
50
56
|
if (type === 'launched') {
|
|
51
57
|
return `✅ The worker for this conversation started at ${formatTime(lifecycle?.launchAt || new Date().toISOString())}. The queued message is being delivered.`;
|
|
52
58
|
}
|
|
@@ -77,6 +83,7 @@ export function createWorkerLaunchNoteService({
|
|
|
77
83
|
const episodes = new Map();
|
|
78
84
|
|
|
79
85
|
function kindFor(type) {
|
|
86
|
+
if (type === 'installing') return WORKER_LAUNCH_NOTE_KINDS.installing;
|
|
80
87
|
if (type === 'launched') return WORKER_LAUNCH_NOTE_KINDS.started;
|
|
81
88
|
if (type === 'stopped') return WORKER_LAUNCH_NOTE_KINDS.stopped;
|
|
82
89
|
return WORKER_LAUNCH_NOTE_KINDS.failed;
|
|
@@ -105,7 +112,7 @@ export function createWorkerLaunchNoteService({
|
|
|
105
112
|
stmts.updateConvTime?.run?.(at, conversationId);
|
|
106
113
|
});
|
|
107
114
|
write();
|
|
108
|
-
episodes.set(sdkSessionId, { messageId, conversationId, timestamp: at });
|
|
115
|
+
episodes.set(sdkSessionId, { messageId, conversationId, timestamp: at, kind, text });
|
|
109
116
|
try { emit('assistant_message', { conversationId, messageId, message: messagePayload({ messageId, text, kind, timestamp: at }) }); } catch {}
|
|
110
117
|
try { logger.log?.(`[worker-launch] conv=${conversationId.slice(0, 8)} note opened (${type}): ${clipCause(lifecycle?.lastError)}`); } catch {}
|
|
111
118
|
}
|
|
@@ -113,6 +120,11 @@ export function createWorkerLaunchNoteService({
|
|
|
113
120
|
function update(episode, sdkSessionId, { type, lifecycle }) {
|
|
114
121
|
const kind = kindFor(type);
|
|
115
122
|
const text = buildWorkerLaunchNoteText({ type, lifecycle, retryEveryMs, retryWindowMs, formatTime });
|
|
123
|
+
// The same note again (a launch deferred on every prime while an agent
|
|
124
|
+
// SDK installs): no write, no broadcast.
|
|
125
|
+
if (episode.kind === kind && episode.text === text) return;
|
|
126
|
+
episode.kind = kind;
|
|
127
|
+
episode.text = text;
|
|
116
128
|
updateNote.run(text, kind, episode.messageId);
|
|
117
129
|
try {
|
|
118
130
|
emit('message_updated', {
|
|
@@ -130,7 +142,7 @@ export function createWorkerLaunchNoteService({
|
|
|
130
142
|
/** The supervisor's onLaunchState listener. */
|
|
131
143
|
function handle({ type, sdkSessionId, lifecycle = null } = {}) {
|
|
132
144
|
const sid = String(sdkSessionId || '').trim();
|
|
133
|
-
if (!sid || !['exhausted', 'retry-failed', 'stopped', 'launched'].includes(type)) return false;
|
|
145
|
+
if (!sid || !['installing', 'exhausted', 'retry-failed', 'stopped', 'launched'].includes(type)) return false;
|
|
134
146
|
const episode = episodes.get(sid) || null;
|
|
135
147
|
if (episode) {
|
|
136
148
|
update(episode, sid, { type, lifecycle });
|
|
@@ -27,6 +27,31 @@
|
|
|
27
27
|
* `quietMs` without a delivery or a turn end for the session, the runners'
|
|
28
28
|
* own idle time.
|
|
29
29
|
*
|
|
30
|
+
* A stale worker on an agent SDK the store installs (`isAgentSdkWorker`: the
|
|
31
|
+
* claude, claude-api, deepseek and cursor kinds, unless a git checkout runs
|
|
32
|
+
* that SDK from its own node_modules) is the exception. An update moves the
|
|
33
|
+
* SDKs out of the package, so the files such a worker would start its next
|
|
34
|
+
* CLI child from may be gone, and a message it gets once its child has idled
|
|
35
|
+
* out fails. So:
|
|
36
|
+
*
|
|
37
|
+
* - it skips the 'cli' and 'quiet' holds and is recycled as soon as its turn,
|
|
38
|
+
* question and compaction holds (and the processing row) allow;
|
|
39
|
+
* - its background hold stays a hold, and is the one hold it is not fenced
|
|
40
|
+
* for: background tasks run in the CLI child, so while they do the child is
|
|
41
|
+
* up and a delivery goes to it, never to a new child from the gone files;
|
|
42
|
+
* - from the first sweep that sees it stale until its recycle, the
|
|
43
|
+
* replacement fence (below) keeps every delivery away from it, so what is
|
|
44
|
+
* queued waits for the new worker's cold start rather than failing on the
|
|
45
|
+
* old one;
|
|
46
|
+
* - a relay that has just started knows its holds only from its first
|
|
47
|
+
* heartbeat's steering snapshot: until that comes it waits ('steering'),
|
|
48
|
+
* fenced, for at most `steeringWaitMs`; one that still has sent none by
|
|
49
|
+
* then is judged as any other worker, unfenced.
|
|
50
|
+
*
|
|
51
|
+
* Besides the periodic sweep, a sweep runs at start and on every worker hello
|
|
52
|
+
* (`sweepNow`), so a stale worker that reconnects to a new relay is fenced
|
|
53
|
+
* before the hello's own request for work is answered.
|
|
54
|
+
*
|
|
30
55
|
* From the moment a recycle passes its holds until its launch is done, the
|
|
31
56
|
* replacement fence (worker-replacement.mjs) keeps every delivery away from
|
|
32
57
|
* the process being replaced, and the stop and the launch run under the
|
|
@@ -61,9 +86,10 @@ const LOG_TAG = '[worker-recycle]';
|
|
|
61
86
|
* the worker's own release holds (workerReleaseHold; 'turn' also for a
|
|
62
87
|
* processing row the session owns), 'cli' while its CLI child runs, and
|
|
63
88
|
* 'quiet' while a worker that does not report its CLI child has had a
|
|
64
|
-
* delivery or a turn end within `quietMs
|
|
89
|
+
* delivery or a turn end within `quietMs`, and 'steering' while a worker on
|
|
90
|
+
* an agent SDK has not yet sent the snapshot that names its holds.
|
|
65
91
|
*/
|
|
66
|
-
export const RECYCLE_HOLDS = Object.freeze(['turn', 'question', 'compaction', 'background', 'cli', 'quiet']);
|
|
92
|
+
export const RECYCLE_HOLDS = Object.freeze(['turn', 'question', 'compaction', 'background', 'cli', 'quiet', 'steering']);
|
|
67
93
|
const RECYCLABLE_STATUSES = new Set(['ready', 'processing']);
|
|
68
94
|
|
|
69
95
|
function sid8(sid) {
|
|
@@ -155,6 +181,15 @@ export function createWorkerRecycleService({
|
|
|
155
181
|
// without a delivery or a turn end: the Claude and Copilot runners'
|
|
156
182
|
// idleShutdownMs, after which they stop the child themselves.
|
|
157
183
|
quietMs = 10 * 60_000,
|
|
184
|
+
// worker → whether its kind runs on an agent SDK (agentSdkForWorkerKind):
|
|
185
|
+
// such a worker, once stale, skips the cli and quiet holds and is fenced
|
|
186
|
+
// until its recycle (see the header).
|
|
187
|
+
isAgentSdkWorker = () => false,
|
|
188
|
+
// How long a stale worker on an agent SDK may go without a steering
|
|
189
|
+
// snapshot (from when this service first saw its process) before it is
|
|
190
|
+
// judged as any other worker. Its heartbeat sends one within a second of
|
|
191
|
+
// a relay's hello.
|
|
192
|
+
steeringWaitMs = 30_000,
|
|
158
193
|
logger = console,
|
|
159
194
|
} = {}) {
|
|
160
195
|
if (typeof stopWorker !== 'function' || typeof launchWorker !== 'function') {
|
|
@@ -214,8 +249,13 @@ export function createWorkerRecycleService({
|
|
|
214
249
|
pendingLaunch: null,
|
|
215
250
|
lastWaitLogAt: -Infinity,
|
|
216
251
|
// When this service first saw the session's current pid: the quiet
|
|
217
|
-
// window's start for a worker with no start time and no queue rows
|
|
252
|
+
// window's start for a worker with no start time and no queue rows,
|
|
253
|
+
// and the steering wait's start.
|
|
218
254
|
firstSeen: null,
|
|
255
|
+
// The pid this service fenced as a stale worker on an agent SDK
|
|
256
|
+
// waiting for its recycle (sdkFenceUp), or null. A recycle admitted
|
|
257
|
+
// takes the fence over and clears this.
|
|
258
|
+
sdkFence: null,
|
|
219
259
|
};
|
|
220
260
|
bySession.set(sid, state);
|
|
221
261
|
}
|
|
@@ -394,8 +434,18 @@ export function createWorkerRecycleService({
|
|
|
394
434
|
});
|
|
395
435
|
} finally {
|
|
396
436
|
// A stop that did not go through (or never ran) leaves the old worker
|
|
397
|
-
// serving its session: it takes work again at once.
|
|
398
|
-
|
|
437
|
+
// serving its session: it takes work again at once. Not one on an
|
|
438
|
+
// agent SDK the update took away: its fence stays up through the
|
|
439
|
+
// stop's backoff, until the worker goes or the tries reach stuckAfter
|
|
440
|
+
// (the sweep keeps it, sdkFenced), so no queued row goes to it.
|
|
441
|
+
if (!stopped) {
|
|
442
|
+
if (isAgentSdkWorker(worker) === true && state.failures > 0 && state.failures < stuckAfter && stillSameProcess(sid, pid)) {
|
|
443
|
+
state.sdkFence = pid;
|
|
444
|
+
log(`fence kept ${sid8(sid)} pid ${pid}: its stop failed, deliveries wait for the retry`);
|
|
445
|
+
} else {
|
|
446
|
+
fence.release(sid, pid);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
399
449
|
}
|
|
400
450
|
}
|
|
401
451
|
|
|
@@ -454,17 +504,68 @@ export function createWorkerRecycleService({
|
|
|
454
504
|
* worker's start, else when this service first saw the process. Throws
|
|
455
505
|
* when a probe does; the caller counts that as a probe failure.
|
|
456
506
|
*/
|
|
457
|
-
function holdFor(sid, worker, state) {
|
|
507
|
+
function holdFor(sid, worker, state, sdkMode = null) {
|
|
458
508
|
if (hasRunningTurn(sid) === true) return 'turn';
|
|
509
|
+
if (sdkMode === 'steering') return 'steering';
|
|
459
510
|
const releaseHold = workerReleaseHold(worker);
|
|
460
511
|
if (releaseHold) return releaseHold;
|
|
512
|
+
// A stale worker on an agent SDK goes as soon as its own holds allow: its
|
|
513
|
+
// next CLI child may have no files left to start from.
|
|
514
|
+
if (sdkMode === 'fast') return null;
|
|
461
515
|
const cliAlive = worker?.steering?.cliAlive;
|
|
462
516
|
if (cliAlive === true) return 'cli';
|
|
463
517
|
if (cliAlive === false) return null;
|
|
518
|
+
const since = toTimeMs(quietSince(sid)) ?? toTimeMs(worker.startedAt) ?? firstSeenAt(state, worker);
|
|
519
|
+
return now() - since < quietMs ? 'quiet' : null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// When this service first saw the worker's current process.
|
|
523
|
+
function firstSeenAt(state, worker) {
|
|
464
524
|
const pid = toPid(worker.pid);
|
|
465
525
|
if (state.firstSeen?.pid !== pid) state.firstSeen = { pid, at: now() };
|
|
466
|
-
|
|
467
|
-
|
|
526
|
+
return state.firstSeen.at;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* How a stale worker on an agent SDK is judged: 'fast' (its steering
|
|
531
|
+
* snapshot names its holds: no cli or quiet hold, fenced until the
|
|
532
|
+
* recycle), 'steering' (no snapshot yet, within `steeringWaitMs` of when
|
|
533
|
+
* this service first saw the process: wait for it, fenced), or null (not on
|
|
534
|
+
* an agent SDK, or a snapshot never came: as any other worker). Throws
|
|
535
|
+
* when the predicate does; the caller counts that as a probe failure.
|
|
536
|
+
*/
|
|
537
|
+
function sdkModeFor(worker, state) {
|
|
538
|
+
if (isAgentSdkWorker(worker) !== true) return null;
|
|
539
|
+
if (worker?.steering && typeof worker.steering === 'object') return 'fast';
|
|
540
|
+
return now() - firstSeenAt(state, worker) < steeringWaitMs ? 'steering' : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Raises the fence for a stale worker on an agent SDK until its recycle:
|
|
544
|
+
// whatever is queued for the session waits for the new worker.
|
|
545
|
+
function sdkFenceUp(sid, pid, state, confirmed) {
|
|
546
|
+
confirmed.add(sid);
|
|
547
|
+
if (state.sdkFence === pid && fence.isHeld(sid, pid)) return;
|
|
548
|
+
fence.hold(sid, pid);
|
|
549
|
+
if (state.sdkFence !== pid) log(`fenced ${sid8(sid)} pid ${pid}: stale on an agent SDK, deliveries wait for the recycle`);
|
|
550
|
+
state.sdkFence = pid;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// A recycle (or an archived stop) admitted for the session owns the fence
|
|
554
|
+
// from here: it lowers it when it is done.
|
|
555
|
+
function sdkFenceHandOver(state) {
|
|
556
|
+
state.sdkFence = null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Lowers the fences this service raised for workers no longer judged
|
|
560
|
+
// fenced by this sweep: gone, recycled, in a background hold, a probe that
|
|
561
|
+
// failed, a snapshot that never came.
|
|
562
|
+
function sdkFencesDown(confirmed) {
|
|
563
|
+
for (const [sid, state] of bySession) {
|
|
564
|
+
if (state.sdkFence === null || confirmed.has(sid)) continue;
|
|
565
|
+
const pid = state.sdkFence;
|
|
566
|
+
state.sdkFence = null;
|
|
567
|
+
if (fence.release(sid, pid)) log(`unfenced ${sid8(sid)} pid ${pid}`);
|
|
568
|
+
}
|
|
468
569
|
}
|
|
469
570
|
|
|
470
571
|
function noteWaiting(sid, state, hold, nextWaiting) {
|
|
@@ -509,6 +610,8 @@ export function createWorkerRecycleService({
|
|
|
509
610
|
return;
|
|
510
611
|
}
|
|
511
612
|
const running = new Set();
|
|
613
|
+
// The sessions whose agent-SDK fence this sweep keeps up (sdkFenceUp).
|
|
614
|
+
const sdkFenced = new Set();
|
|
512
615
|
for (const worker of workers) {
|
|
513
616
|
const sid = String(worker?.sdkSessionId || '').trim();
|
|
514
617
|
if (!sid) continue;
|
|
@@ -527,36 +630,51 @@ export function createWorkerRecycleService({
|
|
|
527
630
|
continue;
|
|
528
631
|
}
|
|
529
632
|
const state = stateFor(sid);
|
|
530
|
-
if (state.inFlight
|
|
633
|
+
if (state.inFlight) continue;
|
|
634
|
+
if (now() < state.nextAttemptAt) {
|
|
635
|
+
// A failed stop's agent-SDK fence stays up through the backoff, up to
|
|
636
|
+
// stuckAfter tries; then the old worker serves again.
|
|
637
|
+
if (state.sdkFence !== null && state.sdkFence === pid && state.failures < stuckAfter) sdkFenced.add(sid);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
531
640
|
if (state.recycledPid && state.recycledPid === toPid(worker.pid)) continue;
|
|
532
641
|
let hold = null;
|
|
533
642
|
let archived = false;
|
|
643
|
+
let sdkMode = null;
|
|
534
644
|
try {
|
|
535
|
-
|
|
645
|
+
sdkMode = sdkModeFor(worker, state);
|
|
646
|
+
hold = holdFor(sid, worker, state, sdkMode);
|
|
536
647
|
if (!hold) archived = isArchived(sid) === true;
|
|
537
648
|
} catch (error) {
|
|
538
649
|
// The relay cannot tell whether the session is busy or archived: the
|
|
539
650
|
// worker is not stopped on a guess, and the probe is retried later.
|
|
651
|
+
// Nor is it fenced on a guess: an agent-SDK fence comes down.
|
|
540
652
|
noteFailure(sid, state, 'probe', errorText(error, 'unknown'));
|
|
541
653
|
continue;
|
|
542
654
|
}
|
|
543
655
|
if (hold) {
|
|
656
|
+
// Background tasks keep the CLI child up, so a delivery goes to it:
|
|
657
|
+
// the one hold such a worker waits out unfenced.
|
|
658
|
+
if (sdkMode && hold !== 'background') sdkFenceUp(sid, pid, state, sdkFenced);
|
|
544
659
|
noteWaiting(sid, state, hold, nextWaiting);
|
|
545
660
|
continue;
|
|
546
661
|
}
|
|
547
662
|
if (inFlight >= maxInFlight) {
|
|
548
663
|
deferred = true;
|
|
664
|
+
if (sdkMode) sdkFenceUp(sid, pid, state, sdkFenced);
|
|
549
665
|
continue;
|
|
550
666
|
}
|
|
551
667
|
if (archived) {
|
|
552
668
|
// Fenced from here: the holds just passed, and a delivery to the
|
|
553
669
|
// process being stopped would start a turn on it.
|
|
554
670
|
fence.hold(sid, pid);
|
|
671
|
+
sdkFenceHandOver(state);
|
|
555
672
|
startedSteps.push(begin(state, () => stopArchived(sid, worker, state)));
|
|
556
673
|
continue;
|
|
557
674
|
}
|
|
558
675
|
if (!canLaunchNow()) {
|
|
559
676
|
deferred = true;
|
|
677
|
+
if (sdkMode) sdkFenceUp(sid, pid, state, sdkFenced);
|
|
560
678
|
continue;
|
|
561
679
|
}
|
|
562
680
|
lastAdmitAt = now();
|
|
@@ -564,8 +682,10 @@ export function createWorkerRecycleService({
|
|
|
564
682
|
// await: no delivery can reach the old process between this check and
|
|
565
683
|
// its stop, nor between the stop and the launch.
|
|
566
684
|
fence.hold(sid, pid);
|
|
685
|
+
sdkFenceHandOver(state);
|
|
567
686
|
startedSteps.push(begin(state, () => recycle(sid, worker, state)));
|
|
568
687
|
}
|
|
688
|
+
sdkFencesDown(sdkFenced);
|
|
569
689
|
// Launches owed after a stop went through: the registry lists no new
|
|
570
690
|
// process for those sessions.
|
|
571
691
|
for (const [sid, state] of bySession) {
|
|
@@ -639,6 +759,20 @@ export function createWorkerRecycleService({
|
|
|
639
759
|
}
|
|
640
760
|
started = true;
|
|
641
761
|
scheduleNext();
|
|
762
|
+
// The start's own sweep: workers left from before the restart need not
|
|
763
|
+
// wait a whole interval.
|
|
764
|
+
runSweep();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* A sweep now, outside the interval, while the service runs (a worker's
|
|
769
|
+
* hello). Its judging, the fence included, is done synchronously before
|
|
770
|
+
* this returns; the promise resolves once the recycles it started are done.
|
|
771
|
+
* Never rejects.
|
|
772
|
+
*/
|
|
773
|
+
function sweepNow() {
|
|
774
|
+
if (!started) return Promise.resolve();
|
|
775
|
+
return sweep().catch(() => {});
|
|
642
776
|
}
|
|
643
777
|
|
|
644
778
|
function stop() {
|
|
@@ -667,7 +801,7 @@ export function createWorkerRecycleService({
|
|
|
667
801
|
};
|
|
668
802
|
}
|
|
669
803
|
|
|
670
|
-
return { start, stop, sweep, isStale, status };
|
|
804
|
+
return { start, stop, sweep, sweepNow, isStale, status };
|
|
671
805
|
}
|
|
672
806
|
|
|
673
807
|
/**
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// "Check for new models" (Settings → General → Models): the relay asks each
|
|
2
|
+
// enabled provider for its model list on a timer, skipping a provider whose
|
|
3
|
+
// list was refreshed recently by anything (the Refresh button, a settings
|
|
4
|
+
// save, a login, an install, the boot). Relay-wide app_settings keys:
|
|
5
|
+
//
|
|
6
|
+
// model_refresh_auto 'true' / 'false'; absent = on
|
|
7
|
+
// model_refresh_interval_hours 6 / 12 / 24 / 72 / 168; absent = 24
|
|
8
|
+
// model_refresh_excluded JSON list of provider ids left out; absent = none
|
|
9
|
+
// model_refresh_last JSON { <provider>: { at, ok, error, lastOkAt, lastFailureAt } }
|
|
10
|
+
|
|
11
|
+
export const MODEL_REFRESH_AUTO_SETTING_KEY = 'model_refresh_auto';
|
|
12
|
+
export const MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY = 'model_refresh_interval_hours';
|
|
13
|
+
export const MODEL_REFRESH_EXCLUDED_SETTING_KEY = 'model_refresh_excluded';
|
|
14
|
+
export const MODEL_REFRESH_LAST_SETTING_KEY = 'model_refresh_last';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MODEL_REFRESH_AUTO = true;
|
|
17
|
+
export const MODEL_REFRESH_INTERVAL_HOURS_CHOICES = Object.freeze([6, 12, 24, 72, 168]);
|
|
18
|
+
export const DEFAULT_MODEL_REFRESH_INTERVAL_HOURS = 24;
|
|
19
|
+
// A failed check is tried again at most this often, whatever the interval.
|
|
20
|
+
export const MODEL_REFRESH_FAILURE_RETRY_MS = 60 * 60 * 1000;
|
|
21
|
+
// The first look a minute after boot (overdue providers are checked then),
|
|
22
|
+
// then every quarter of an hour.
|
|
23
|
+
export const MODEL_REFRESH_FIRST_TICK_MS = 60 * 1000;
|
|
24
|
+
export const MODEL_REFRESH_TICK_MS = 15 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
// The providers in the order Settings and Select Models list them; `github`
|
|
27
|
+
// is Copilot. `tab` is the provider's Select Models tab.
|
|
28
|
+
export const MODEL_REFRESH_PROVIDERS = Object.freeze([
|
|
29
|
+
Object.freeze({ id: 'github', label: 'Copilot', tab: 'copilot' }),
|
|
30
|
+
Object.freeze({ id: 'openai', label: 'OpenAI', tab: 'openai' }),
|
|
31
|
+
Object.freeze({ id: 'claude', label: 'Claude', tab: 'anthropic' }),
|
|
32
|
+
Object.freeze({ id: 'claude-api', label: 'Claude API', tab: 'claude-api' }),
|
|
33
|
+
Object.freeze({ id: 'cursor', label: 'Cursor', tab: 'cursor' }),
|
|
34
|
+
Object.freeze({ id: 'grok', label: 'Grok', tab: 'grok' }),
|
|
35
|
+
Object.freeze({ id: 'deepseek', label: 'DeepSeek', tab: 'deepseek' }),
|
|
36
|
+
]);
|
|
37
|
+
const PROVIDER_IDS = new Set(MODEL_REFRESH_PROVIDERS.map((provider) => provider.id));
|
|
38
|
+
|
|
39
|
+
export function modelRefreshProvider(id) {
|
|
40
|
+
return MODEL_REFRESH_PROVIDERS.find((provider) => provider.id === String(id || '').trim().toLowerCase()) || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An unset or unreadable setting is on. */
|
|
44
|
+
export function readModelRefreshAutoSetting(storedValue) {
|
|
45
|
+
const text = String(storedValue ?? '').trim().toLowerCase();
|
|
46
|
+
if (text === 'false') return false;
|
|
47
|
+
if (text === 'true') return true;
|
|
48
|
+
return DEFAULT_MODEL_REFRESH_AUTO;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseModelRefreshAutoUpdate(value) {
|
|
52
|
+
if (typeof value !== 'boolean') return { ok: false, error: 'auto must be true or false' };
|
|
53
|
+
return { ok: true, auto: value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One of the choices; an unset or unknown value is the default. */
|
|
57
|
+
export function readModelRefreshIntervalHoursSetting(storedValue) {
|
|
58
|
+
const hours = Number(String(storedValue ?? '').trim());
|
|
59
|
+
return MODEL_REFRESH_INTERVAL_HOURS_CHOICES.includes(hours) ? hours : DEFAULT_MODEL_REFRESH_INTERVAL_HOURS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseModelRefreshIntervalHoursUpdate(value) {
|
|
63
|
+
const hours = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value.trim()) : NaN);
|
|
64
|
+
if (!MODEL_REFRESH_INTERVAL_HOURS_CHOICES.includes(hours)) {
|
|
65
|
+
return { ok: false, error: `intervalHours must be one of ${MODEL_REFRESH_INTERVAL_HOURS_CHOICES.join(', ')}` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, intervalHours: hours };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function knownProviderIds(values) {
|
|
71
|
+
return [...new Set((Array.isArray(values) ? values : [])
|
|
72
|
+
.map((value) => String(value || '').trim().toLowerCase())
|
|
73
|
+
.filter((value) => PROVIDER_IDS.has(value)))];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The providers left out of the check; unknown ids are dropped. */
|
|
77
|
+
export function readModelRefreshExcludedSetting(storedValue) {
|
|
78
|
+
try {
|
|
79
|
+
return knownProviderIds(JSON.parse(String(storedValue || '[]')));
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseModelRefreshExcludedUpdate(value) {
|
|
86
|
+
if (!Array.isArray(value)) return { ok: false, error: 'excluded must be a list of provider ids' };
|
|
87
|
+
const unknown = value.map((entry) => String(entry || '').trim().toLowerCase()).filter((entry) => entry && !PROVIDER_IDS.has(entry));
|
|
88
|
+
if (unknown.length) return { ok: false, error: `Unknown provider: ${unknown[0]}` };
|
|
89
|
+
return { ok: true, excluded: knownProviderIds(value) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isoOrNull(value) {
|
|
93
|
+
const text = String(value ?? '').trim();
|
|
94
|
+
return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The last refresh of each provider; entries that cannot be read are dropped. */
|
|
98
|
+
export function readModelRefreshLastSetting(storedValue) {
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(String(storedValue || '{}'));
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [id, entry] of Object.entries(parsed)) {
|
|
108
|
+
if (!PROVIDER_IDS.has(id) || !entry || typeof entry !== 'object') continue;
|
|
109
|
+
const at = isoOrNull(entry.at);
|
|
110
|
+
if (!at) continue;
|
|
111
|
+
out[id] = {
|
|
112
|
+
at,
|
|
113
|
+
ok: entry.ok === true,
|
|
114
|
+
error: entry.ok === true ? null : (String(entry.error || '').trim().slice(0, 300) || null),
|
|
115
|
+
lastOkAt: isoOrNull(entry.lastOkAt),
|
|
116
|
+
lastFailureAt: isoOrNull(entry.lastFailureAt),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
@@ -99,11 +99,15 @@ export async function runDirectOpenAIImageTurn({
|
|
|
99
99
|
attachments: Array.isArray(message?.attachments) ? message.attachments : [],
|
|
100
100
|
}, requestOptions);
|
|
101
101
|
} catch (error) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
// `relayProcessing`: the provider answered 2xx and the relay could not
|
|
103
|
+
// process the reply; the generation is billed, so no requeue either.
|
|
104
|
+
const relayProcessing = error?.body?.relayProcessing === true;
|
|
105
|
+
if (isDirectOpenAIImageTerminalStatus(error?.status) || relayProcessing) {
|
|
106
|
+
const terminalError = new Error(relayProcessing
|
|
107
|
+
? `The relay could not process the image OpenAI returned (${error?.detail || 'unknown error'}). The generation was billed; the message was not retried.`
|
|
108
|
+
: `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || 'error'}`}`);
|
|
106
109
|
terminalError.code = OPENAI_IMAGE_TERMINAL_CODE;
|
|
110
|
+
terminalError.relayProcessing = relayProcessing;
|
|
107
111
|
terminalError.status = error?.status;
|
|
108
112
|
terminalError.detail = error?.detail || null;
|
|
109
113
|
throw terminalError;
|