@oar-sh/oar 0.9.23 → 0.9.25

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 (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. package/shared/worker-runtime/direct-openai-image.mjs +98 -48
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * OpenAI images on the Check Usage modal (provider `openai`, the booking's
5
+ * provider, so the Tokens sections of plan-usage-tokens.mjs land on it).
6
+ *
7
+ * The figures are the spend state of shared/openai-image-spend.mjs: today
8
+ * and this month in the relay's local days, booked by OAR from its price
9
+ * table, estimates and the stand-in price of an unknown model included (they
10
+ * count toward the cap on purpose). The card shows the daily cap as a meter,
11
+ * the warning share, the owner's "Allow for today", and how much of today is
12
+ * estimated. The raw state rides along as `imageSpend` for the browser's cap
13
+ * bar.
14
+ */
15
+
16
+ import {
17
+ SOURCE_WORKER,
18
+ STATUS_OK,
19
+ STATUS_UNAVAILABLE,
20
+ buildMeter,
21
+ buildProviderCard,
22
+ roundCurrency,
23
+ toFiniteNumber,
24
+ } from './plan-usage-contract.mjs';
25
+ import {
26
+ OPENAI_IMAGE_BOOKING_PROVIDER,
27
+ OPENAI_IMAGE_CAP_WARN_RATIO,
28
+ formatOpenAIImageUsd,
29
+ } from '../../shared/openai-image-spend.mjs';
30
+
31
+ export const OPENAI_IMAGE_CARD_LABEL = 'OpenAI images';
32
+
33
+ const OPENAI_USAGE_URL = 'https://platform.openai.com/usage';
34
+
35
+ const usd = (value) => Math.max(0, toFiniteNumber(value) ?? 0);
36
+
37
+ /**
38
+ * The card, or null when there is nothing to show: OpenAI is off and nothing
39
+ * was booked this month. `state` is the spend state; `error` replaces it
40
+ * when it could not be read.
41
+ */
42
+ export function buildOpenAIImagePlanCard({ state = null, error = null, configured = false } = {}) {
43
+ const links = [{ label: 'OpenAI usage', url: OPENAI_USAGE_URL }];
44
+ if (!state) {
45
+ if (!configured) return null;
46
+ return buildProviderCard({
47
+ provider: OPENAI_IMAGE_BOOKING_PROVIDER,
48
+ label: OPENAI_IMAGE_CARD_LABEL,
49
+ status: STATUS_UNAVAILABLE,
50
+ message: `This relay's OpenAI image spend could not be read${error ? `: ${error}` : ''}.`,
51
+ source: SOURCE_WORKER,
52
+ links,
53
+ });
54
+ }
55
+ const spent = usd(state.spentUsd);
56
+ const reserved = usd(state.reservedUsd);
57
+ const month = usd(state.monthUsd);
58
+ if (!configured && month <= 0 && spent <= 0 && reserved <= 0) return null;
59
+
60
+ const capUsd = toFiniteNumber(state.capUsd);
61
+ const hasCap = capUsd !== null && capUsd > 0;
62
+ const used = roundCurrency(spent + reserved);
63
+ const allowed = state.allowedToday === true;
64
+ const percent = hasCap ? Math.round((used / capUsd) * 100) : null;
65
+
66
+ const messages = [];
67
+ if (hasCap && state.reached === true) {
68
+ messages.push(allowed
69
+ ? `Today's image spend has reached the daily cap of ${formatOpenAIImageUsd(capUsd)}; image turns run anyway, because the owner allowed them for today.`
70
+ : `Today's image spend has reached the daily cap of ${formatOpenAIImageUsd(capUsd)}: new image turns are refused until tomorrow (relay time), or until the owner allows more for today (Settings → Providers → OpenAI).`);
71
+ } else if (hasCap && state.warning === true) {
72
+ messages.push(`Today's image spend is at ${percent} % of the daily cap of ${formatOpenAIImageUsd(capUsd)}.`);
73
+ }
74
+ if (!hasCap) messages.push('No daily cap is set for OpenAI images on this relay.');
75
+
76
+ const meters = [];
77
+ if (hasCap) {
78
+ meters.push(buildMeter({
79
+ id: 'openai-images-daily-cap',
80
+ label: 'Daily cap',
81
+ unit: 'usd',
82
+ used,
83
+ allowance: capUsd,
84
+ remaining: roundCurrency(Math.max(0, capUsd - used)),
85
+ estimated: true,
86
+ severity: state.reached === true ? 'reached' : (state.warning === true ? 'warning' : null),
87
+ note: `This relay's image spend today (${state.day || 'relay time'}), images in progress included; a warning from ${Math.round(OPENAI_IMAGE_CAP_WARN_RATIO * 100)} %, and at the cap new image turns are refused${allowed ? ' (allowed for today by the owner)' : ''}. It starts over at midnight, relay time.`,
88
+ }));
89
+ }
90
+
91
+ const estimated = usd(state.estimatedUsd);
92
+ const unknown = usd(state.unknownPriceUsd);
93
+ const notes = [
94
+ {
95
+ id: 'openai-images-today',
96
+ label: 'Today',
97
+ value: formatOpenAIImageUsd(used),
98
+ hint: [
99
+ reserved > 0 ? `${formatOpenAIImageUsd(reserved)} in progress` : '',
100
+ estimated > 0 ? `${formatOpenAIImageUsd(estimated)} estimated` : '',
101
+ unknown > 0 ? `${formatOpenAIImageUsd(unknown)} at an unknown model's stand-in price` : '',
102
+ ].filter(Boolean).join(' · ') || null,
103
+ text: `OpenAI image spend today: ${formatOpenAIImageUsd(used)}${reserved > 0 ? `, of it ${formatOpenAIImageUsd(reserved)} in progress` : ''}.`,
104
+ },
105
+ {
106
+ id: 'openai-images-month',
107
+ label: 'This month',
108
+ value: formatOpenAIImageUsd(month),
109
+ text: `OpenAI image spend this month: ${formatOpenAIImageUsd(month)}.`,
110
+ },
111
+ {
112
+ id: 'openai-images-override',
113
+ label: 'Allow for today',
114
+ value: allowed ? 'on' : 'off',
115
+ hint: allowed && state.allowedAt ? `since ${state.allowedAt}` : null,
116
+ text: allowed
117
+ ? 'The owner allowed image turns past the cap for today; it ends at midnight, relay time.'
118
+ : 'Past the cap, image turns are refused unless the owner allows them for today.',
119
+ },
120
+ ];
121
+ const conversation = state.conversation && typeof state.conversation === 'object' ? state.conversation : null;
122
+ if (conversation) {
123
+ const images = Math.max(0, Math.trunc(toFiniteNumber(conversation.images) ?? 0));
124
+ notes.push({
125
+ id: 'openai-images-this-chat',
126
+ label: 'This chat',
127
+ value: formatOpenAIImageUsd(usd(conversation.costUsd)),
128
+ hint: `${images} ${images === 1 ? 'image' : 'images'}`,
129
+ text: `OpenAI images in this chat: ${formatOpenAIImageUsd(usd(conversation.costUsd))} (${images} ${images === 1 ? 'image' : 'images'}).`,
130
+ });
131
+ }
132
+ notes.push({
133
+ id: 'openai-images-estimate',
134
+ text: 'Priced by OAR from its own table of OpenAI image prices, for the image turns of this relay; a try that may have been billed without a usage report counts at its estimate. OpenAI\'s usage page has every request on the key.',
135
+ });
136
+
137
+ const card = buildProviderCard({
138
+ provider: OPENAI_IMAGE_BOOKING_PROVIDER,
139
+ label: OPENAI_IMAGE_CARD_LABEL,
140
+ status: STATUS_OK,
141
+ message: messages.join(' ') || null,
142
+ source: SOURCE_WORKER,
143
+ meters: meters.filter(Boolean),
144
+ notes,
145
+ links,
146
+ });
147
+ return card ? { ...card, imageSpend: state } : null;
148
+ }
@@ -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
+ }