@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +54 -4
  4. package/package.json +9 -7
  5. package/server/agent-sdk-loader.mjs +116 -0
  6. package/server/agent-sdks/claude/entry.mjs +4 -0
  7. package/server/agent-sdks/claude/package-lock.json +1438 -0
  8. package/server/agent-sdks/claude/package.json +13 -0
  9. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  10. package/server/agent-sdks/cursor/entry.mjs +3 -0
  11. package/server/agent-sdks/cursor/package-lock.json +171 -0
  12. package/server/agent-sdks/cursor/package.json +11 -0
  13. package/server/agent-sdks/koffi/entry.cjs +2 -0
  14. package/server/agent-sdks/koffi/package-lock.json +368 -0
  15. package/server/agent-sdks/koffi/package.json +10 -0
  16. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  17. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  18. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  20. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  21. package/server/public/app/agent-sdk-ui.js +349 -0
  22. package/server/public/app/api-client.js +91 -8
  23. package/server/public/app/bootstrap.js +204 -69
  24. package/server/public/app/model-refresh-ui.mjs +413 -0
  25. package/server/public/app/no-conversation-state.mjs +5 -2
  26. package/server/public/app/provider-readiness.mjs +67 -4
  27. package/server/public/app/settings-modal.js +167 -0
  28. package/server/public/app/socket-handlers.js +21 -0
  29. package/server/public/index.html +95 -2
  30. package/server/routes/agent-sdk-routes.mjs +73 -0
  31. package/server/routes/messages-routes.mjs +91 -17
  32. package/server/routes/sessions-routes.mjs +235 -109
  33. package/server/server-runtime.mjs +395 -81
  34. package/server/services/about-service.mjs +26 -6
  35. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  36. package/server/services/agent-sdk-paths.mjs +307 -0
  37. package/server/services/agent-sdk-store.mjs +1211 -0
  38. package/server/services/claude-api-settings-service.mjs +54 -17
  39. package/server/services/cli-install-service.mjs +3 -2
  40. package/server/services/copilot-model-discovery-service.mjs +7 -1
  41. package/server/services/database-file.mjs +17 -1
  42. package/server/services/deepseek-settings-service.mjs +58 -20
  43. package/server/services/earlier-database-service.mjs +4 -2
  44. package/server/services/model-refresh-scheduler.mjs +153 -0
  45. package/server/services/model-variant-catalog-service.mjs +141 -9
  46. package/server/services/oar-cli-helpers.mjs +72 -9
  47. package/server/services/oar-state-migration-service.mjs +2 -12
  48. package/server/services/oar-state-root.mjs +17 -0
  49. package/server/services/provider-model-refresh.mjs +326 -0
  50. package/server/services/provider-model-state.mjs +395 -0
  51. package/server/services/relay-singleton-guard.mjs +72 -10
  52. package/server/services/session-worker-launch-service.mjs +13 -0
  53. package/server/services/session-worker-supervisor-service.mjs +19 -1
  54. package/server/services/session-worker-websocket-service.mjs +15 -0
  55. package/server/services/update-install-service.mjs +2 -2
  56. package/server/services/worker-launch-note-service.mjs +14 -2
  57. package/server/services/worker-recycle-service.mjs +145 -11
  58. package/shared/model-refresh-schedule.mjs +120 -0
  59. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ // One place for "a provider's model list was refreshed".
4
+ //
5
+ // The ledger keeps when each provider was last refreshed (model_refresh_last)
6
+ // whatever asked for it: the Select Models Refresh button, a settings save, a
7
+ // login, a CLI install, the boot discovery, a Copilot snapshot or the
8
+ // background check (model-refresh-scheduler.mjs). Each refresh function is
9
+ // wrapped once with `track(provider, fn)`, so every caller resets the
10
+ // provider's clock without knowing about it. A `skipped` result (provider off,
11
+ // CLI missing) is neither a success nor a failure. The ledger also queues the
12
+ // models a refresh found for the first time, which the next `models_updated`
13
+ // carries as `newModels` (the toast).
14
+ //
15
+ // `refreshProviderModels` is the Promise.allSettled block of
16
+ // POST /api/model-variants/refresh, shared with the background check.
17
+
18
+ import {
19
+ MODEL_REFRESH_AUTO_SETTING_KEY,
20
+ MODEL_REFRESH_EXCLUDED_SETTING_KEY,
21
+ MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY,
22
+ MODEL_REFRESH_LAST_SETTING_KEY,
23
+ MODEL_REFRESH_PROVIDERS,
24
+ modelRefreshProvider,
25
+ parseModelRefreshAutoUpdate,
26
+ parseModelRefreshExcludedUpdate,
27
+ parseModelRefreshIntervalHoursUpdate,
28
+ readModelRefreshAutoSetting,
29
+ readModelRefreshExcludedSetting,
30
+ readModelRefreshIntervalHoursSetting,
31
+ readModelRefreshLastSetting,
32
+ } from '../../shared/model-refresh-schedule.mjs';
33
+
34
+ const SKIPPED = Object.freeze({ ok: true, skipped: true, models: [], error: null });
35
+ // What a failed refresh keeps of its error text.
36
+ export const MODEL_REFRESH_ERROR_MAX_CHARS = 240;
37
+ const REDACTED = '[redacted]';
38
+
39
+ /**
40
+ * A refresh error without the provider's secrets: each one given is cut out
41
+ * wherever it appears, and so is whatever follows "Bearer" or an
42
+ * Authorization header an answer echoed back. Then capped.
43
+ */
44
+ export function redactModelRefreshError(text, secrets = []) {
45
+ let out = String(text ?? '');
46
+ for (const secret of Array.isArray(secrets) ? secrets : []) {
47
+ const value = String(secret ?? '').trim();
48
+ if (value.length >= 4) out = out.split(value).join(REDACTED);
49
+ }
50
+ out = out
51
+ .replace(/\b(authorization\s*["']?\s*[:=]\s*["']?)(?:bearer\s+)?[^\s"',;}]+/gi, `$1${REDACTED}`)
52
+ .replace(/\bbearer\s+(?!\[redacted\])[^\s"',;}]+/gi, `Bearer ${REDACTED}`)
53
+ .replace(/\s+/g, ' ')
54
+ .trim();
55
+ return out.length > MODEL_REFRESH_ERROR_MAX_CHARS ? `${out.slice(0, MODEL_REFRESH_ERROR_MAX_CHARS - 1)}…` : out;
56
+ }
57
+
58
+ /**
59
+ * A failed refresh in a log line: its HTTP status, a timeout, or the words
60
+ * before the first colon, never the answer's body.
61
+ */
62
+ export function modelRefreshErrorSummary(text) {
63
+ const value = String(text ?? '');
64
+ const status = value.match(/\((\d{3})\)/) || value.match(/\b(?:status|HTTP)\s*:?\s*(\d{3})\b/i);
65
+ if (status) return `HTTP ${status[1]}`;
66
+ if (/timed out|timeout/i.test(value)) return 'timed out';
67
+ const head = value.split(':')[0].replace(/\s+/g, ' ').trim();
68
+ return (head || 'unknown error').slice(0, 80);
69
+ }
70
+
71
+ function isoOf(value) {
72
+ const date = value instanceof Date ? value : new Date(value ?? Date.now());
73
+ return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
74
+ }
75
+
76
+ export function createModelRefreshLedger({
77
+ readSetting = () => '',
78
+ writeSetting = null,
79
+ now = () => new Date(),
80
+ // (provider) => the secrets its errors may carry (API keys, tokens): cut
81
+ // out before an error is stored, answered by the settings route or logged.
82
+ secretsFor = () => [],
83
+ } = {}) {
84
+ let pendingNewModels = [];
85
+
86
+ function readLast() {
87
+ try {
88
+ return readModelRefreshLastSetting(readSetting(MODEL_REFRESH_LAST_SETTING_KEY));
89
+ } catch {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ /** New models of one provider for the next models_updated; one entry per provider and state. */
95
+ function noteNewModels(provider, added = []) {
96
+ const meta = modelRefreshProvider(provider);
97
+ if (!meta) return;
98
+ for (const { id, on } of Array.isArray(added) ? added : []) {
99
+ const modelId = String(id || '').trim();
100
+ if (!modelId) continue;
101
+ let entry = pendingNewModels.find((candidate) => candidate.provider === meta.id && candidate.on === (on === true));
102
+ if (!entry) {
103
+ entry = { provider: meta.id, tab: meta.tab, label: meta.label, ids: [], on: on === true };
104
+ pendingNewModels.push(entry);
105
+ }
106
+ if (!entry.ids.includes(modelId)) entry.ids.push(modelId);
107
+ }
108
+ }
109
+
110
+ /** The queued new models, emptied. */
111
+ function takeNewModels() {
112
+ const taken = pendingNewModels;
113
+ pendingNewModels = [];
114
+ return taken;
115
+ }
116
+
117
+ function secretsOf(provider) {
118
+ try {
119
+ const secrets = secretsFor(provider);
120
+ return Array.isArray(secrets) ? secrets : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ /** Notes one refresh result; returns the provider's entry, or null when skipped. */
127
+ function record(provider, result) {
128
+ const meta = modelRefreshProvider(provider);
129
+ if (!meta || !result || typeof result !== 'object' || result.skipped === true) return null;
130
+ const stamp = isoOf(now());
131
+ const ok = result.ok === true;
132
+ const last = readLast();
133
+ const previous = last[meta.id] || {};
134
+ last[meta.id] = {
135
+ at: stamp,
136
+ ok,
137
+ error: ok ? null : (redactModelRefreshError(result.error || 'Model refresh failed', secretsOf(meta.id)) || 'Model refresh failed'),
138
+ lastOkAt: ok ? stamp : (previous.lastOkAt || null),
139
+ lastFailureAt: ok ? (previous.lastFailureAt || null) : stamp,
140
+ };
141
+ try {
142
+ writeSetting?.(MODEL_REFRESH_LAST_SETTING_KEY, JSON.stringify(last), stamp);
143
+ } catch {
144
+ /* the clock is best-effort; the next refresh writes it again */
145
+ }
146
+ if (ok) noteNewModels(meta.id, result.newModels);
147
+ return last[meta.id];
148
+ }
149
+
150
+ /** `fn` with every call noted against `provider`; a throw counts as a failure. */
151
+ function track(provider, fn) {
152
+ if (typeof fn !== 'function') return fn;
153
+ return async (...args) => {
154
+ let result;
155
+ try {
156
+ result = await fn(...args);
157
+ } catch (error) {
158
+ record(provider, { ok: false, error: error?.message || String(error || 'Model refresh failed') });
159
+ throw error;
160
+ }
161
+ record(provider, result);
162
+ return result;
163
+ };
164
+ }
165
+
166
+ return { readLast, record, track, noteNewModels, takeNewModels };
167
+ }
168
+
169
+ /** The check's own settings: `{ auto, intervalHours, excluded }`. */
170
+ export function createModelRefreshSettings({
171
+ readSetting = () => '',
172
+ writeSetting = null,
173
+ now = () => new Date(),
174
+ } = {}) {
175
+ function get() {
176
+ return {
177
+ auto: readModelRefreshAutoSetting(readSetting(MODEL_REFRESH_AUTO_SETTING_KEY)),
178
+ intervalHours: readModelRefreshIntervalHoursSetting(readSetting(MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY)),
179
+ excluded: readModelRefreshExcludedSetting(readSetting(MODEL_REFRESH_EXCLUDED_SETTING_KEY)),
180
+ };
181
+ }
182
+
183
+ /** Checks every field given before writing any; `{ ok, settings }` or `{ ok: false, error }`. */
184
+ function update(body = {}) {
185
+ const payload = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
186
+ const has = (key) => Object.prototype.hasOwnProperty.call(payload, key);
187
+ if (!has('auto') && !has('intervalHours') && !has('excluded')) return { ok: false, error: 'No model refresh settings update provided' };
188
+ const auto = has('auto') ? parseModelRefreshAutoUpdate(payload.auto) : null;
189
+ const interval = has('intervalHours') ? parseModelRefreshIntervalHoursUpdate(payload.intervalHours) : null;
190
+ const excluded = has('excluded') ? parseModelRefreshExcludedUpdate(payload.excluded) : null;
191
+ const refused = [auto, interval, excluded].find((parsed) => parsed && !parsed.ok);
192
+ if (refused) return { ok: false, error: refused.error };
193
+ if (typeof writeSetting !== 'function') return { ok: false, error: 'Model refresh settings are unavailable' };
194
+ const stamp = isoOf(now());
195
+ if (auto) writeSetting(MODEL_REFRESH_AUTO_SETTING_KEY, auto.auto ? 'true' : 'false', stamp);
196
+ if (interval) writeSetting(MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY, String(interval.intervalHours), stamp);
197
+ if (excluded) writeSetting(MODEL_REFRESH_EXCLUDED_SETTING_KEY, JSON.stringify(excluded.excluded), stamp);
198
+ return { ok: true, settings: get() };
199
+ }
200
+
201
+ return { get, update };
202
+ }
203
+
204
+ /**
205
+ * The providers the refresh can ask, from the route deps: `isEnabled` gates
206
+ * the Refresh button (a provider that is off is skipped), `isSetUp` the
207
+ * background check (Copilot: an installed CLI; Claude and Grok: their CLI
208
+ * or their login; the others: switched on, which needs their key),
209
+ * `currentModels` is what a failed refresh answers with.
210
+ */
211
+ export function buildProviderRefreshTable({
212
+ getOpenAIProviderSettings = () => null,
213
+ refreshOpenAIProviderModels = null,
214
+ getClaudeProviderSettings = () => null,
215
+ refreshClaudeProviderModels = null,
216
+ getCursorProviderSettings = () => null,
217
+ refreshCursorProviderModels = null,
218
+ getGrokProviderSettings = () => null,
219
+ refreshGrokProviderModels = null,
220
+ deepseekSettingsService = null,
221
+ claudeApiSettingsService = null,
222
+ copilotModelDiscoveryService = null,
223
+ // Claude and Grok run on the host's CLI and its login: set up when the CLI
224
+ // is installed or the login is there, as the relay knows it without
225
+ // spawning anything. Absent (most tests): enabled is enough.
226
+ isClaudeSetUp = () => true,
227
+ isGrokSetUp = () => true,
228
+ } = {}) {
229
+ const enabledOf = (getSettings) => () => {
230
+ try {
231
+ return getSettings?.()?.enabled === true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ };
236
+ const modelsOf = (getSettings) => () => {
237
+ try {
238
+ const models = getSettings?.()?.models;
239
+ return Array.isArray(models) ? models : [];
240
+ } catch {
241
+ return [];
242
+ }
243
+ };
244
+ const entry = (getSettings, refresh, setUp = () => true) => ({
245
+ isEnabled: enabledOf(getSettings),
246
+ isSetUp: () => {
247
+ if (!enabledOf(getSettings)()) return false;
248
+ try {
249
+ return setUp() === true;
250
+ } catch {
251
+ return false;
252
+ }
253
+ },
254
+ currentModels: modelsOf(getSettings),
255
+ refresh: typeof refresh === 'function' ? () => refresh() : null,
256
+ });
257
+ const deepseekSettings = () => deepseekSettingsService?.getSettings?.() || null;
258
+ const claudeApiSettings = () => claudeApiSettingsService?.getSettings?.() || null;
259
+ const hasCopilotDiscovery = typeof copilotModelDiscoveryService?.refresh === 'function';
260
+ return {
261
+ // Copilot discovery gates itself on an installed CLI (skipped when there
262
+ // is none), so the Refresh button always asks it.
263
+ github: {
264
+ isEnabled: () => hasCopilotDiscovery,
265
+ isSetUp: () => {
266
+ if (!hasCopilotDiscovery) return false;
267
+ try {
268
+ return typeof copilotModelDiscoveryService.isCliResolvable === 'function'
269
+ ? copilotModelDiscoveryService.isCliResolvable() === true
270
+ : true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ },
275
+ currentModels: () => [],
276
+ refresh: hasCopilotDiscovery ? (reason) => copilotModelDiscoveryService.refresh(reason) : null,
277
+ },
278
+ openai: entry(getOpenAIProviderSettings, refreshOpenAIProviderModels),
279
+ claude: entry(getClaudeProviderSettings, refreshClaudeProviderModels, isClaudeSetUp),
280
+ 'claude-api': entry(claudeApiSettings, claudeApiSettingsService?.refreshModels),
281
+ cursor: entry(getCursorProviderSettings, refreshCursorProviderModels),
282
+ grok: entry(getGrokProviderSettings, refreshGrokProviderModels, isGrokSetUp),
283
+ deepseek: entry(deepseekSettings, deepseekSettingsService?.refreshModels),
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Refreshes the given providers (all by default) side by side. Returns
289
+ * `{ results: { <provider>: result }, changed }`: a provider that is off or
290
+ * has no refresh is skipped; a refresh that throws answers ok:false with the
291
+ * provider's current models. `changed` tells whether any provider's offered
292
+ * models changed or a refresh found models it had not seen before.
293
+ */
294
+ export async function refreshProviderModels({
295
+ table = {},
296
+ providers = MODEL_REFRESH_PROVIDERS.map((provider) => provider.id),
297
+ reason = 'manual-refresh',
298
+ gate = 'isEnabled',
299
+ } = {}) {
300
+ const ids = providers.filter((id) => table[id]);
301
+ const before = new Map(ids.map((id) => [id, JSON.stringify(table[id].currentModels?.() || [])]));
302
+ const settled = await Promise.allSettled(ids.map((id) => {
303
+ const entry = table[id];
304
+ const open = typeof entry?.[gate] === 'function' ? entry[gate]() : false;
305
+ if (!open || typeof entry.refresh !== 'function') return Promise.resolve({ ...SKIPPED });
306
+ return Promise.resolve().then(() => entry.refresh(reason));
307
+ }));
308
+ const results = {};
309
+ let changed = false;
310
+ ids.forEach((id, index) => {
311
+ const outcome = settled[index];
312
+ const label = modelRefreshProvider(id)?.label || id;
313
+ const result = outcome.status === 'fulfilled' && outcome.value && typeof outcome.value === 'object'
314
+ ? outcome.value
315
+ : {
316
+ ok: false,
317
+ models: table[id].currentModels?.() || [],
318
+ error: outcome.reason?.message || `${label} model discovery failed`,
319
+ };
320
+ results[id] = result;
321
+ if (result.skipped === true) return;
322
+ if (Array.isArray(result.newModels) && result.newModels.length) changed = true;
323
+ if (JSON.stringify(table[id].currentModels?.() || []) !== before.get(id)) changed = true;
324
+ });
325
+ return { results, changed };
326
+ }