@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
@@ -42,6 +42,7 @@ import { mapUsageSnapshotRow, fetchUsageSummaryPromise } from '../services/usage
42
42
  import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '../services/claude-context-usage.mjs';
43
43
  import { buildContextUsageView } from '../services/context-usage-view.mjs';
44
44
  import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
45
+ import { withGeneratedImageFiles } from '../services/generated-image-files.mjs';
45
46
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
46
47
  import { isWorkerOnOtherBuild } from '../services/worker-recycle-service.mjs';
47
48
  import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
@@ -97,6 +98,30 @@ import {
97
98
  isSafeProviderModelId,
98
99
  } from '../../shared/model-id.mjs';
99
100
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
101
+ import {
102
+ IMAGE_INPUT_REFUSED_CODE,
103
+ OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE,
104
+ OPENAI_IMAGE_NO_MODEL_CODE,
105
+ isOpenAIImageModelId,
106
+ openAIImageModelNeedsOpenAIText,
107
+ openAIImageNoModelText,
108
+ pickOpenAIImageDefaultModel,
109
+ validateOpenAIImageOptions,
110
+ } from '../../shared/openai-image-options.mjs';
111
+ import {
112
+ OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD,
113
+ OPENAI_IMAGE_PRESETS_PATH,
114
+ OPENAI_IMAGE_SETTINGS_FIELDS,
115
+ OPENAI_IMAGE_SPEND_SETTING_KEYS,
116
+ } from '../../shared/openai-image-spend.mjs';
117
+ import {
118
+ OPENAI_IMAGE_PRESET_REFUSED_CODE,
119
+ OPENAI_IMAGE_PRESETS_SETTING_KEY,
120
+ applyOwnOpenAIImagePresetChange,
121
+ listOpenAIImagePresets,
122
+ parseOwnOpenAIImagePresets,
123
+ } from '../../shared/openai-image-presets.mjs';
124
+ import { buildOpenAIImagePlanCard } from '../services/plan-usage-openai-image.mjs';
100
125
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
101
126
  import {
102
127
  REMOTE_RELAY_ERROR_CODES,
@@ -155,6 +180,8 @@ import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs
155
180
  import { appendTokenSections } from '../services/plan-usage-tokens.mjs';
156
181
  import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
157
182
  import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
183
+ import { buildProviderRefreshTable, refreshProviderModels } from '../services/provider-model-refresh.mjs';
184
+ import { MODEL_REFRESH_INTERVAL_HOURS_CHOICES, MODEL_REFRESH_PROVIDERS, DEFAULT_MODEL_REFRESH_AUTO, DEFAULT_MODEL_REFRESH_INTERVAL_HOURS } from '../../shared/model-refresh-schedule.mjs';
158
185
 
159
186
  export { mapUsageSnapshotRow };
160
187
 
@@ -305,8 +332,11 @@ export function parseOpenAISettingsUpdateRequest(body = {}) {
305
332
  const hasEnabled = typeof payload.enabled === 'boolean';
306
333
  const hasBaseUrl = Object.prototype.hasOwnProperty.call(payload, 'baseUrl');
307
334
  const baseUrl = hasBaseUrl ? String(payload.baseUrl || '').trim() : undefined;
308
- if (!remove && !hasModel && !apiKey && !hasEnabled && !hasBaseUrl) {
309
- return { ok: false, error: 'No OpenAI settings update provided' };
335
+ const hasEnabledModels = Array.isArray(payload.enabledModels);
336
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
337
+ if (!remove && !hasModel && !apiKey && !hasEnabled && !hasBaseUrl && !hasEnabledModels && !hasAutoEnableNew) {
338
+ // `empty`: the body may still carry the image cap fields alone.
339
+ return { ok: false, empty: true, error: 'No OpenAI settings update provided' };
310
340
  }
311
341
  if (hasModel && !isSafeProviderModelId(model)) {
312
342
  return { ok: false, error: 'Invalid OpenAI model ID' };
@@ -326,10 +356,46 @@ export function parseOpenAISettingsUpdateRequest(body = {}) {
326
356
  apiKey,
327
357
  ...(hasModel ? { model } : {}),
328
358
  ...(hasBaseUrl ? { baseUrl } : {}),
359
+ ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
360
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
361
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
329
362
  enabled: hasEnabled ? payload.enabled : (apiKey ? true : undefined),
330
363
  };
331
364
  }
332
365
 
366
+ // The highest daily cap of OpenAI image spend the settings take, in USD.
367
+ export const OPENAI_IMAGE_DAILY_CAP_MAX_USD = 10_000;
368
+
369
+ /**
370
+ * The image cap fields of POST /api/settings/openai: `imageDailyCapUsd` (USD,
371
+ * 0 to OPENAI_IMAGE_DAILY_CAP_MAX_USD, to the cent; 0 or null: no cap) and
372
+ * `allowImagesToday` (true sets the owner's override for the relay's day,
373
+ * false clears it). `{ ok, present, dailyCapUsd?, allowToday? }`, or
374
+ * `{ ok:false, present:true, error }`.
375
+ */
376
+ export function parseOpenAIImageSpendSettingsRequest(body = {}) {
377
+ const payload = body && typeof body === 'object' ? body : {};
378
+ const capField = OPENAI_IMAGE_SETTINGS_FIELDS.dailyCapUsd;
379
+ const allowField = OPENAI_IMAGE_SETTINGS_FIELDS.allowToday;
380
+ const hasCap = payload[capField] !== undefined;
381
+ const hasAllow = payload[allowField] !== undefined;
382
+ if (!hasCap && !hasAllow) return { ok: true, present: false };
383
+ const result = { ok: true, present: true };
384
+ if (hasCap) {
385
+ const raw = payload[capField];
386
+ const number = raw === null ? 0 : (typeof raw === 'string' && raw.trim() ? Number(raw.trim()) : raw);
387
+ if (typeof number !== 'number' || !Number.isFinite(number) || number < 0 || number > OPENAI_IMAGE_DAILY_CAP_MAX_USD) {
388
+ return { ok: false, present: true, error: `${capField} must be a number of USD from 0 (no cap) to ${OPENAI_IMAGE_DAILY_CAP_MAX_USD}` };
389
+ }
390
+ result.dailyCapUsd = Math.round(number * 100) / 100;
391
+ }
392
+ if (hasAllow) {
393
+ if (typeof payload[allowField] !== 'boolean') return { ok: false, present: true, error: `${allowField} must be true or false` };
394
+ result.allowToday = payload[allowField];
395
+ }
396
+ return result;
397
+ }
398
+
333
399
  export function parseClaudeSettingsUpdateRequest(body = {}) {
334
400
  const payload = body && typeof body === 'object' ? body : {};
335
401
  const hasEnabled = typeof payload.enabled === 'boolean';
@@ -337,13 +403,14 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
337
403
  const model = hasModel ? (String(payload.model || '').trim() || 'claude-sonnet-5') : undefined;
338
404
  const hasModels = Array.isArray(payload.models);
339
405
  const hasEnabledModels = Array.isArray(payload.enabledModels);
406
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
340
407
  const hasAttributionMode = Object.prototype.hasOwnProperty.call(payload, 'attributionMode');
341
408
  const attributionMode = hasAttributionMode ? normalizeClaudeAttributionMode(payload.attributionMode) : undefined;
342
409
  const hasBackgroundCommandLimit = Object.prototype.hasOwnProperty.call(payload, 'backgroundCommandLimitMinutes');
343
410
  const backgroundCommandLimit = hasBackgroundCommandLimit
344
411
  ? parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes)
345
412
  : null;
346
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode && !hasBackgroundCommandLimit) {
413
+ if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAutoEnableNew && !hasAttributionMode && !hasBackgroundCommandLimit) {
347
414
  return { ok: false, error: 'No Claude settings update provided' };
348
415
  }
349
416
  if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
@@ -361,6 +428,8 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
361
428
  ...(hasModel ? { model } : {}),
362
429
  ...(hasModels ? { models: payload.models } : {}),
363
430
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
431
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
432
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
364
433
  ...(hasAttributionMode ? { attributionMode } : {}),
365
434
  // Clamped to 30 min … 8 h and snapped to 30-minute steps.
366
435
  ...(backgroundCommandLimit ? { backgroundCommandLimitMinutes: backgroundCommandLimit.minutes } : {}),
@@ -459,7 +528,8 @@ export function parseCursorSettingsUpdateRequest(body = {}) {
459
528
  const apiKey = String(payload.apiKey || '').trim();
460
529
  const hasEnabled = typeof payload.enabled === 'boolean';
461
530
  const hasEnabledModels = Array.isArray(payload.enabledModels);
462
- if (!remove && !hasModel && !apiKey && !hasEnabled && !hasEnabledModels) {
531
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
532
+ if (!remove && !hasModel && !apiKey && !hasEnabled && !hasEnabledModels && !hasAutoEnableNew) {
463
533
  return { ok: false, error: 'No Cursor settings update provided' };
464
534
  }
465
535
  if (hasModel && !isSafeCursorModelId(model)) {
@@ -480,6 +550,8 @@ export function parseCursorSettingsUpdateRequest(body = {}) {
480
550
  apiKey,
481
551
  ...(hasModel ? { model } : {}),
482
552
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
553
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
554
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
483
555
  enabled: hasEnabled ? payload.enabled : (apiKey ? true : undefined),
484
556
  };
485
557
  }
@@ -491,7 +563,8 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
491
563
  const model = hasModel ? (String(payload.model || '').trim() || 'grok-4.5') : undefined;
492
564
  const hasModels = Array.isArray(payload.models);
493
565
  const hasEnabledModels = Array.isArray(payload.enabledModels);
494
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels) {
566
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
567
+ if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAutoEnableNew) {
495
568
  return { ok: false, error: 'No Grok settings update provided' };
496
569
  }
497
570
  if (hasModel && !isSafeGrokModelId(model)) {
@@ -503,6 +576,8 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
503
576
  ...(hasModel ? { model } : {}),
504
577
  ...(hasModels ? { models: payload.models } : {}),
505
578
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
579
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
580
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
506
581
  };
507
582
  }
508
583
 
@@ -529,6 +604,42 @@ function copilotListedModelIds(modelState = {}) {
529
604
  );
530
605
  }
531
606
 
607
+ /**
608
+ * A Copilot catalog without its image ids (gpt-image-*, dall-e-*): the
609
+ * Copilot runtime cannot serve them, so neither New Chat, the composer nor
610
+ * relay_info offers them for Copilot. The OpenAI layer adds back the ones
611
+ * the OpenAI provider lists, as that provider's alone.
612
+ */
613
+ export function withoutCopilotImageModels(modelState = {}) {
614
+ const models = Array.isArray(modelState?.models) ? modelState.models : [];
615
+ if (!models.some((modelId) => isOpenAIImageModelId(modelId))) return modelState;
616
+ const kept = models.filter((modelId) => !isOpenAIImageModelId(modelId));
617
+ const withoutImageKeys = (map) => (map && typeof map === 'object' && !Array.isArray(map)
618
+ ? Object.fromEntries(Object.entries(map).filter(([modelId]) => !isOpenAIImageModelId(modelId)))
619
+ : map);
620
+ const listedOr = (modelId) => (isOpenAIImageModelId(modelId) ? (kept[0] || '') : modelId);
621
+ return {
622
+ ...modelState,
623
+ models: kept,
624
+ ...(modelState.currentModel !== undefined ? { currentModel: listedOr(modelState.currentModel) } : {}),
625
+ ...(modelState.defaultModel !== undefined ? { defaultModel: listedOr(modelState.defaultModel) } : {}),
626
+ ...(modelState.reasoningByModel ? { reasoningByModel: withoutImageKeys(modelState.reasoningByModel) } : {}),
627
+ ...(modelState.contextLimitsByModel ? { contextLimitsByModel: withoutImageKeys(modelState.contextLimitsByModel) } : {}),
628
+ ...(modelState.modelMetadataByModel ? { modelMetadataByModel: withoutImageKeys(modelState.modelMetadataByModel) } : {}),
629
+ ...(modelState.providersByModel ? { providersByModel: withoutImageKeys(modelState.providersByModel) } : {}),
630
+ ...(Array.isArray(modelState.unavailableModels)
631
+ ? { unavailableModels: modelState.unavailableModels.filter((modelId) => !isOpenAIImageModelId(modelId)) }
632
+ : {}),
633
+ };
634
+ }
635
+
636
+ /** The refusal of a Copilot chat on an image model (400), or null for any other model. */
637
+ function copilotImageModelRefusal(model = '') {
638
+ const modelId = String(model || '').trim();
639
+ if (!isOpenAIImageModelId(modelId)) return null;
640
+ return { ok: false, error: openAIImageModelNeedsOpenAIText(modelId), code: OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE };
641
+ }
642
+
532
643
  export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
533
644
  const configured = claudeSettings?.enabled === true;
534
645
  const model = String(claudeSettings?.model || '').trim();
@@ -964,11 +1075,6 @@ function normalizeRequestedProviderType(value = '') {
964
1075
  return '';
965
1076
  }
966
1077
 
967
- function isOpenAIImageModelId(model = '') {
968
- const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
969
- return normalized.startsWith('gpt-image-') || normalized.startsWith('dall-e-');
970
- }
971
-
972
1078
  function isPidAlive(pidValue) {
973
1079
  const pid = Number(pidValue);
974
1080
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -2199,11 +2305,27 @@ export function registerSessionsRoutes(app, deps) {
2199
2305
  deleteOrphanedUploads,
2200
2306
  cleanupGeneratedImagesForConversation = cleanupGeneratedImagesForConversationDefault,
2201
2307
  queueCounts,
2202
- getModelCatalogState,
2308
+ getModelCatalogState: readCopilotModelCatalogState,
2203
2309
  updateModelCatalog,
2204
2310
  listModelVariantRows,
2205
2311
  refreshModelVariantCatalogFromCli,
2206
2312
  setEnabledModelVariants,
2313
+ // Copilot's model map (model-variant-catalog-service.mjs) and the other
2314
+ // providers' (provider-model-state.mjs): the switch, the "New" flags.
2315
+ getCopilotModelState = () => ({ autoEnableNew: false, unseen: [] }),
2316
+ setCopilotAutoEnableNew = null,
2317
+ markCopilotModelsSeen = () => false,
2318
+ markProviderModelsSeen = () => false,
2319
+ // When each provider was last refreshed and the new models the next
2320
+ // models_updated carries (provider-model-refresh.mjs), the background
2321
+ // check's settings and the check itself. Absent in most tests.
2322
+ modelRefreshLedger = null,
2323
+ modelRefreshSettings = null,
2324
+ modelRefreshScheduler = null,
2325
+ // Whether Claude and Grok are set up for the background check (their CLI
2326
+ // or their login); absent, switched on is enough.
2327
+ isClaudeModelRefreshSetUp = () => true,
2328
+ isGrokModelRefreshSetUp = () => true,
2207
2329
  SUPPORTED_REASONING_EFFORTS,
2208
2330
  buildRelayReadyBannerData,
2209
2331
  workspaceRootPayload,
@@ -2269,6 +2391,12 @@ export function registerSessionsRoutes(app, deps) {
2269
2391
  // Tokens per turn and per day (services/token-usage-service.mjs): a
2270
2392
  // deleted chat drops its turn rows. Absent in older wiring and most tests.
2271
2393
  tokenUsageService = null,
2394
+ // OpenAI image spend (shared/openai-image-spend.mjs): the state for the
2395
+ // Check Usage card (`({ conversationId? }) → state`), and the call that
2396
+ // re-announces it after the cap or the override changed. Absent in older
2397
+ // wiring and most tests: no card, nothing announced.
2398
+ getOpenAIImageSpendState = null,
2399
+ notifyOpenAIImageSpendChanged = () => {},
2272
2400
  reconcileUnstartedConversationProviders = async () => ({
2273
2401
  updatedUnstartedConversations: 0,
2274
2402
  skippedStartedConversations: 0,
@@ -2399,6 +2527,9 @@ export function registerSessionsRoutes(app, deps) {
2399
2527
  // the wiring does not pass one.
2400
2528
  shareRepository = null,
2401
2529
  } = deps;
2530
+ // Copilot's catalog as every route here serves it: without the image ids
2531
+ // its runtime cannot serve (an OpenAI Image chat takes them from OpenAI).
2532
+ const getModelCatalogState = () => withoutCopilotImageModels(readCopilotModelCatalogState());
2402
2533
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2403
2534
  // Built on first use: many route tests hand in a database without tables.
2404
2535
  let shareRepositoryInstance = shareRepository;
@@ -2664,10 +2795,62 @@ export function registerSessionsRoutes(app, deps) {
2664
2795
  );
2665
2796
  }
2666
2797
 
2798
+ // The providers the Refresh button and the background check ask, with
2799
+ // their enablement and refresh functions (provider-model-refresh.mjs).
2800
+ const providerRefreshTable = buildProviderRefreshTable({
2801
+ getOpenAIProviderSettings,
2802
+ refreshOpenAIProviderModels,
2803
+ getClaudeProviderSettings,
2804
+ refreshClaudeProviderModels,
2805
+ getCursorProviderSettings,
2806
+ refreshCursorProviderModels,
2807
+ getGrokProviderSettings,
2808
+ refreshGrokProviderModels,
2809
+ deepseekSettingsService,
2810
+ claudeApiSettingsService,
2811
+ copilotModelDiscoveryService,
2812
+ isClaudeSetUp: isClaudeModelRefreshSetUp,
2813
+ isGrokSetUp: isGrokModelRefreshSetUp,
2814
+ });
2815
+
2816
+ /** Models flagged new on the enabled providers: the Select Models menu dot. */
2817
+ function countUnseenNewModels() {
2818
+ let count = 0;
2819
+ for (const settings of [
2820
+ getOpenAIProviderSettings(),
2821
+ getClaudeProviderSettings(),
2822
+ getCursorProviderSettings(),
2823
+ getGrokProviderSettings(),
2824
+ getDeepSeekProviderSettings(),
2825
+ getClaudeApiProviderSettings(),
2826
+ ]) {
2827
+ if (settings?.enabled === true && Array.isArray(settings.unseenModels)) count += settings.unseenModels.length;
2828
+ }
2829
+ const copilot = getCopilotModelState();
2830
+ return count + (Array.isArray(copilot?.unseen) ? copilot.unseen.length : 0);
2831
+ }
2832
+
2833
+ /**
2834
+ * What models_updated carries: the catalog, the menu dot's count and, once,
2835
+ * the models a refresh found for the first time (the toast).
2836
+ */
2837
+ function modelsUpdatedPayload(modelState, openAISettings = getOpenAIProviderSettings()) {
2838
+ const newModels = modelRefreshLedger?.takeNewModels?.() || [];
2839
+ return {
2840
+ ...buildModelCatalogWithProviders(modelState, openAISettings),
2841
+ unseenNewModelCount: countUnseenNewModels(),
2842
+ ...(newModels.length ? { newModels } : {}),
2843
+ };
2844
+ }
2845
+
2667
2846
  // A discovery that lands outside any route (the deferred boot refresh) still
2668
2847
  // has to reach connected pickers the way a snapshot POST does.
2669
2848
  copilotModelDiscoveryService?.setOnCatalogUpdated?.(() => {
2670
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
2849
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
2850
+ });
2851
+ // So does a background check that changed a provider's models.
2852
+ modelRefreshScheduler?.setOnModelsChanged?.(() => {
2853
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
2671
2854
  });
2672
2855
 
2673
2856
  // The client address as Express derives it through `trust proxy`: a
@@ -4972,7 +5155,9 @@ export function registerSessionsRoutes(app, deps) {
4972
5155
  group: conversationGroupPayload(resolvedConversationId, req),
4973
5156
  // Its place in the group's hand order, as on the list rows.
4974
5157
  groupPosition: conversationGroupPositionOf(conv),
4975
- messages: history.messages,
5158
+ // Generated images with their file on this relay and their size, for
5159
+ // this page's rows only (agents on other relays name the files).
5160
+ messages: withGeneratedImageFiles(history.messages, { resolveSessionStateRoot }),
4976
5161
  pageInfo: history.pageInfo,
4977
5162
  });
4978
5163
  });
@@ -5247,6 +5432,18 @@ export function registerSessionsRoutes(app, deps) {
5247
5432
  return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5248
5433
  }
5249
5434
  }
5435
+ // A Copilot chat cannot move to an image model: its runtime cannot serve
5436
+ // one. The model it has already is written back as before.
5437
+ if (
5438
+ existing
5439
+ && Object.prototype.hasOwnProperty.call(req.body || {}, 'preferredModel')
5440
+ && normalizePreferredModel(req.body.preferredModel) !== normalizePreferredModel(existing.preferred_model)
5441
+ ) {
5442
+ const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5443
+ const provider = String(runtime?.provider_type || '').trim().toLowerCase();
5444
+ const imageRefusal = provider === 'github' ? copilotImageModelRefusal(req.body.preferredModel) : null;
5445
+ if (imageRefusal) return res.status(400).json(imageRefusal);
5446
+ }
5250
5447
  if (remoteRequest && readPlanOnly(existing)
5251
5448
  && Object.prototype.hasOwnProperty.call(req.body || {}, 'preferredRelayMode')
5252
5449
  && planOnlyRefusesMode(req.body.preferredRelayMode)) {
@@ -5260,7 +5457,7 @@ export function registerSessionsRoutes(app, deps) {
5260
5457
  // ─── PATCH /api/conversation/:id/settings — the relay_tool's update_session ───
5261
5458
  //
5262
5459
  // `{ settings: { model, effort, mode, auto_compact_window, title, group,
5263
- // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
5460
+ // thinking_enabled, thinking_display, cwd, image }, dry_run, owner_approved }`.
5264
5461
  // Reached by an agent of this relay (the loopback) and by a paired relay's
5265
5462
  // agent (under the inbound switch); the owner may call it too. It applies
5266
5463
  // this relay's own rule: an agent changes freely only a session its own
@@ -5277,11 +5474,21 @@ export function registerSessionsRoutes(app, deps) {
5277
5474
 
5278
5475
  const SESSION_SETTINGS_INVALID = 'SESSION_SETTINGS_INVALID';
5279
5476
 
5477
+ /** An image session's defaults as stored (`{}` for none, or for any other session). */
5478
+ function readImageDefaults(conversationId) {
5479
+ try {
5480
+ return stmts.readConversationImageDefaults?.(conversationId) || {};
5481
+ } catch {
5482
+ return {};
5483
+ }
5484
+ }
5485
+
5280
5486
  function currentSessionSettings(conversationId) {
5281
5487
  const conv = stmts.getConvAnyStatus.get(conversationId) || {};
5282
5488
  const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5283
5489
  const workspace = resolveConversationWorkspaceState?.({ conversationId }) || null;
5284
5490
  const window = parseAutoCompactWindow(conv.auto_compact_window);
5491
+ const imageDefaults = readImageDefaults(conversationId);
5285
5492
  return {
5286
5493
  model: normalizePreferredModel(conv.preferred_model) || String(runtime?.provider_model || runtime?.model || '').trim() || null,
5287
5494
  effort: normalizePreferredReasoningEffort(conv.preferred_reasoning_effort) || null,
@@ -5293,6 +5500,7 @@ export function registerSessionsRoutes(app, deps) {
5293
5500
  thinking_display: parseThinkingDisplay(conv.thinking_display),
5294
5501
  cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
5295
5502
  planOnly: readPlanOnly(conv),
5503
+ ...(Object.keys(imageDefaults).length ? { image: imageDefaults } : {}),
5296
5504
  };
5297
5505
  }
5298
5506
 
@@ -5345,6 +5553,8 @@ export function registerSessionsRoutes(app, deps) {
5345
5553
  const change = (key) => { if (!plan.changed.includes(key)) plan.changed.push(key); };
5346
5554
 
5347
5555
  if (wanted.model !== undefined && normalizePreferredModel(wanted.model) !== normalizePreferredModel(conversation.preferred_model)) {
5556
+ const imageRefusal = provider === 'github' ? copilotImageModelRefusal(wanted.model) : null;
5557
+ if (imageRefusal) return fail(400, `${imageRefusal.error} Nothing was changed.`, { code: imageRefusal.code });
5348
5558
  plan.preferences.preferredModel = wanted.model;
5349
5559
  change('model');
5350
5560
  }
@@ -5466,6 +5676,29 @@ export function registerSessionsRoutes(app, deps) {
5466
5676
  change('cwd');
5467
5677
  }
5468
5678
  }
5679
+ // An image session's defaults: what is named replaces the stored value of
5680
+ // that option, the rest stays. Checked against the model the session has
5681
+ // after this call; any other session is refused.
5682
+ if (wanted.image !== undefined) {
5683
+ // The model an image turn runs: the runtime session's (the send path's),
5684
+ // or the one this call moves it to.
5685
+ const imageModel = String(plan.preferences.preferredModel || runtime?.provider_model || runtime?.model || current.model || '').trim();
5686
+ if (provider === 'github' && isOpenAIImageModelId(imageModel)) {
5687
+ return fail(400, `${openAIImageModelNeedsOpenAIText(imageModel)} Nothing was changed.`, { code: OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE });
5688
+ }
5689
+ if (provider !== 'openai' || !isOpenAIImageModelId(imageModel)) {
5690
+ return fail(400, `Image defaults go to an OpenAI image session; this session runs ${imageModel || provider}. Nothing was changed.`, { code: IMAGE_INPUT_REFUSED_CODE });
5691
+ }
5692
+ const stored = readImageDefaults(conversationId);
5693
+ const checkedImage = validateOpenAIImageOptions({ ...stored, ...wanted.image }, { model: imageModel });
5694
+ if (!checkedImage.ok) return fail(400, `${checkedImage.error}. Nothing was changed.`, { code: IMAGE_INPUT_REFUSED_CODE });
5695
+ const sameAsStored = Object.keys(checkedImage.values).length === Object.keys(stored).length
5696
+ && Object.entries(checkedImage.values).every(([key, value]) => stored[key] === value);
5697
+ if (!sameAsStored) {
5698
+ plan.image = checkedImage.values;
5699
+ change('image');
5700
+ }
5701
+ }
5469
5702
  plan.changed.sort((a, b) => REMOTE_RELAY_SESSION_SETTINGS.indexOf(a) - REMOTE_RELAY_SESSION_SETTINGS.indexOf(b));
5470
5703
  return { ok: true, plan };
5471
5704
  }
@@ -5627,13 +5860,22 @@ export function registerSessionsRoutes(app, deps) {
5627
5860
  if (!titled.ok) return failAfter(titled.statusCode || 500, { error: titled.error || 'Failed to update the title.' });
5628
5861
  written.push('title');
5629
5862
  }
5863
+ if (plan.image) {
5864
+ try {
5865
+ if (typeof stmts.writeConversationImageDefaults !== 'function') throw new Error('Image session defaults are unavailable on this relay');
5866
+ stmts.writeConversationImageDefaults(conversationId, plan.image);
5867
+ } catch (error) {
5868
+ return failAfter(503, { code: 'IMAGE_DEFAULTS_UNAVAILABLE', error: `${error?.message || 'The image defaults could not be stored'}.` });
5869
+ }
5870
+ written.push('image');
5871
+ }
5630
5872
  if (Object.keys(plan.preferences).length) {
5631
5873
  applyConversationPreferences({
5632
5874
  conversationId,
5633
5875
  existing: stmts.getConvAnyStatus.get(conversationId),
5634
5876
  body: plan.preferences,
5635
5877
  });
5636
- written.push(...plan.changed.filter((key) => !['cwd', 'title', 'group'].includes(key)));
5878
+ written.push(...plan.changed.filter((key) => !['cwd', 'title', 'group', 'image'].includes(key)));
5637
5879
  }
5638
5880
  // Groups through the agent path: an owner's group the session leaves is
5639
5881
  // never removed (it stays, empty if it was the last member); a group an
@@ -6674,6 +6916,12 @@ export function registerSessionsRoutes(app, deps) {
6674
6916
  requestedBootstrapModel
6675
6917
  || (isProviderModelAvailable(catalogSelectedModel, availableModels) ? catalogSelectedModel : '')
6676
6918
  );
6919
+ // An OpenAI Image chat asked for without a model starts on the image
6920
+ // default the OpenAI list offers (else its first gpt-image), never on
6921
+ // the shared catalog's current model, which makes no images.
6922
+ const openAIImageDefaultModel = requestedProviderType === 'openai-image' && !requestedBootstrapModel
6923
+ ? pickOpenAIImageDefaultModel([...availableOpenAIModels])
6924
+ : '';
6677
6925
  const providerModelSelection = useClaudeCloudProvider
6678
6926
  // The cloud takes plain ids; with no model in the request the tab's
6679
6927
  // default applies (never the shared catalog's current model).
@@ -6690,7 +6938,7 @@ export function registerSessionsRoutes(app, deps) {
6690
6938
  })
6691
6939
  : useOpenAIProvider
6692
6940
  ? resolveProviderModelSelection({
6693
- requestedModel: providerRequestedModel(availableOpenAIModels),
6941
+ requestedModel: openAIImageDefaultModel || providerRequestedModel(availableOpenAIModels),
6694
6942
  // The removed resolveOpenAISessionModel carried this last-resort id.
6695
6943
  configuredModel: openAISettings.model || 'gpt-4o',
6696
6944
  availableModels: availableOpenAIModels,
@@ -6749,6 +6997,12 @@ export function registerSessionsRoutes(app, deps) {
6749
6997
  supportedModels: providerModelSelection.availableModels || [],
6750
6998
  });
6751
6999
  }
7000
+ const useCopilotProvider = !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider
7001
+ && !useGrokProvider && !useDeepSeekProvider && !useClaudeApiProvider;
7002
+ // A Copilot chat on an image id would reach the Copilot runtime, which
7003
+ // cannot serve it: refused before anything is written.
7004
+ const copilotImageRefusal = useCopilotProvider ? copilotImageModelRefusal(requestedBootstrapModel || catalogSelectedModel) : null;
7005
+ if (copilotImageRefusal) return res.status(400).json(copilotImageRefusal);
6752
7006
  // Copilot falls back to the catalogue's current model for an id it does
6753
7007
  // not list, which New Chat (it only offers listed ids) never meets. An
6754
7008
  // agent naming a mistyped id ("gpt-5-4") would get a session on another
@@ -6887,6 +7141,11 @@ export function registerSessionsRoutes(app, deps) {
6887
7141
  }
6888
7142
 
6889
7143
  const selectedModel = providerModelSelection ? providerModelSelection.model : (copilotBoundModel || catalogSelectedModel);
7144
+ // Asked for without a model, an image chat needs a gpt-image model in the
7145
+ // OpenAI list; it never falls back to a chat model, which makes no images.
7146
+ if (requestedProviderType === 'openai-image' && !requestedBootstrapModel && !openAIImageDefaultModel) {
7147
+ return res.status(400).json({ ok: false, error: openAIImageNoModelText(), code: OPENAI_IMAGE_NO_MODEL_CODE });
7148
+ }
6890
7149
  if (requestedProviderType === 'openai-image' && !isOpenAIImageModelId(selectedModel)) {
6891
7150
  return res.status(400).json({
6892
7151
  ok: false,
@@ -7152,8 +7411,12 @@ export function registerSessionsRoutes(app, deps) {
7152
7411
  runtimeSessionId: runtimeSession?.id || null,
7153
7412
  status: workerState?.status || 'ready',
7154
7413
  });
7155
- const pendingDepth = Number(queueCounts?.().pendingCount || 0);
7156
- sessionWorkerSupervisor?.markIdle?.(ownerSessionId, pendingDepth);
7414
+ // A deferred start (the agent SDK still installs) has no worker to
7415
+ // call idle: it launches when the install ends.
7416
+ if (!ensureResult.deferred) {
7417
+ const pendingDepth = Number(queueCounts?.().pendingCount || 0);
7418
+ sessionWorkerSupervisor?.markIdle?.(ownerSessionId, pendingDepth);
7419
+ }
7157
7420
  } else if (ownerSessionId) {
7158
7421
  // Same state as the 409 above: the conversation is committed, only the
7159
7422
  // worker is missing, so the client gets what it needs to open it.
@@ -7345,6 +7608,73 @@ export function registerSessionsRoutes(app, deps) {
7345
7608
  });
7346
7609
  });
7347
7610
 
7611
+ // ─── OpenAI image spend: the daily cap and the owner's override ─────────────
7612
+ // app_settings values here are JSON; a bare string reads as itself.
7613
+ const readJsonAppSetting = (key) => {
7614
+ let row = null;
7615
+ try {
7616
+ row = stmts.getAppSetting?.get?.(key) || null;
7617
+ } catch {
7618
+ row = null;
7619
+ }
7620
+ if (!row || row.value === null || row.value === undefined) return undefined;
7621
+ try {
7622
+ return JSON.parse(row.value);
7623
+ } catch {
7624
+ return row.value;
7625
+ }
7626
+ };
7627
+ // The relay's local day, the one image spend is booked and capped by.
7628
+ const relayLocalDay = () => {
7629
+ try {
7630
+ const day = String(tokenUsageService?.localDay?.() || '');
7631
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day)) return day;
7632
+ } catch {}
7633
+ const now = new Date();
7634
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
7635
+ };
7636
+ /**
7637
+ * The cap fields every OpenAI settings answer carries: `imageDailyCapUsd`
7638
+ * (USD; 0 = no cap; never saved = the default), `imagesAllowedToday` (the
7639
+ * owner allowed image turns past the cap for the relay's current day) and
7640
+ * `imagesAllowedAt` (when, while it holds).
7641
+ */
7642
+ const openAIImageSpendSettingsPayload = () => {
7643
+ const stored = readJsonAppSetting(OPENAI_IMAGE_SPEND_SETTING_KEYS.dailyCapUsd);
7644
+ const cap = stored === undefined ? OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD : Number(stored);
7645
+ const allowed = readJsonAppSetting(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideDay) === relayLocalDay();
7646
+ const allowedAt = allowed ? readJsonAppSetting(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideAt) : null;
7647
+ return {
7648
+ // An unreadable value is the default, never "no cap".
7649
+ imageDailyCapUsd: Number.isFinite(cap) && cap >= 0 ? cap : OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD,
7650
+ imagesAllowedToday: allowed,
7651
+ imagesAllowedAt: allowed && typeof allowedAt === 'string' ? allowedAt : null,
7652
+ };
7653
+ };
7654
+ /** Writes the parsed cap fields together; throws when the settings cannot be written. */
7655
+ const writeOpenAIImageSpendSettings = (change) => {
7656
+ if (typeof stmts?.upsertAppSetting?.run !== 'function' || typeof stmts?.deleteAppSetting?.run !== 'function') {
7657
+ throw new Error('OpenAI image settings are unavailable');
7658
+ }
7659
+ const nowIso = new Date().toISOString();
7660
+ db.transaction(() => {
7661
+ if (change.dailyCapUsd !== undefined) {
7662
+ stmts.upsertAppSetting.run(OPENAI_IMAGE_SPEND_SETTING_KEYS.dailyCapUsd, JSON.stringify(change.dailyCapUsd), nowIso);
7663
+ }
7664
+ // Changing the cap keeps the override; only the switch sets or ends it.
7665
+ if (change.allowToday === true) {
7666
+ stmts.upsertAppSetting.run(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideDay, JSON.stringify(relayLocalDay()), nowIso);
7667
+ stmts.upsertAppSetting.run(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideAt, JSON.stringify(nowIso), nowIso);
7668
+ } else if (change.allowToday === false) {
7669
+ stmts.deleteAppSetting.run(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideDay);
7670
+ stmts.deleteAppSetting.run(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideAt);
7671
+ }
7672
+ })();
7673
+ try {
7674
+ notifyOpenAIImageSpendChanged();
7675
+ } catch {}
7676
+ };
7677
+
7348
7678
  app.get('/api/settings/openai', auth, (_req, res) => {
7349
7679
  const settings = getOpenAIProviderSettings();
7350
7680
  return res.json({
@@ -7352,12 +7682,43 @@ export function registerSessionsRoutes(app, deps) {
7352
7682
  enabled: settings?.enabled === true,
7353
7683
  model: String(settings?.model || 'gpt-4o').trim() || 'gpt-4o',
7354
7684
  baseUrl: String(settings?.baseUrl || 'https://api.openai.com/v1').trim() || 'https://api.openai.com/v1',
7685
+ // What New Chat preselects for an OpenAI Image chat (null: no image model on).
7686
+ imageDefaultModel: settings?.enabled === true
7687
+ ? (pickOpenAIImageDefaultModel([settings.model, ...(Array.isArray(settings.models) ? settings.models : [])]) || null)
7688
+ : null,
7689
+ ...openAIImageSpendSettingsPayload(),
7355
7690
  });
7356
7691
  });
7357
7692
 
7358
7693
  app.post('/api/settings/openai', auth, async (req, res) => {
7694
+ // The image cap and "Allow for today" are the owner's alone: an agent of
7695
+ // this relay or a paired one could lift the cap on itself. Nothing of the
7696
+ // request is applied then.
7697
+ const imageSpend = parseOpenAIImageSpendSettingsRequest(req.body);
7698
+ if (imageSpend.present && readRemoteRelayRequest(req, remoteRelayInbound).remote) {
7699
+ return res.status(403).json({ error: 'The OpenAI image cap and "Allow for today" are this relay\'s owner\'s settings alone.' });
7700
+ }
7701
+ if (!imageSpend.ok) return res.status(400).json({ error: imageSpend.error });
7359
7702
  const parsed = parseOpenAISettingsUpdateRequest(req.body);
7360
- if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7703
+ if (!parsed.ok && !(parsed.empty && imageSpend.present)) return res.status(400).json({ error: parsed.error });
7704
+ if (!parsed.ok) {
7705
+ // The cap fields alone: the provider settings stay as they are.
7706
+ try {
7707
+ writeOpenAIImageSpendSettings(imageSpend);
7708
+ } catch (error) {
7709
+ return res.status(500).json({ error: String(error?.message || error) });
7710
+ }
7711
+ const current = getOpenAIProviderSettings();
7712
+ const payload = {
7713
+ configured: current?.configured === true,
7714
+ enabled: current?.enabled === true,
7715
+ model: String(current?.model || 'gpt-4o').trim() || 'gpt-4o',
7716
+ baseUrl: String(current?.baseUrl || 'https://api.openai.com/v1').trim() || 'https://api.openai.com/v1',
7717
+ ...openAIImageSpendSettingsPayload(),
7718
+ };
7719
+ io.emit('openai_settings_updated', payload);
7720
+ return res.json({ ok: true, ...payload, models: [], warning: null });
7721
+ }
7361
7722
  const previous = getOpenAIProviderSettings();
7362
7723
  const result = setOpenAIProviderSettings(parsed);
7363
7724
  if (!result?.ok) {
@@ -7370,6 +7731,14 @@ export function registerSessionsRoutes(app, deps) {
7370
7731
  activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
7371
7732
  });
7372
7733
  }
7734
+ let imageSpendError = null;
7735
+ if (imageSpend.present) {
7736
+ try {
7737
+ writeOpenAIImageSpendSettings(imageSpend);
7738
+ } catch (error) {
7739
+ imageSpendError = `The image cap was not saved: ${String(error?.message || error)}`;
7740
+ }
7741
+ }
7373
7742
  const shouldDiscover = result.enabled === true && (
7374
7743
  !!parsed.apiKey
7375
7744
  || previous?.enabled !== true
@@ -7408,8 +7777,9 @@ export function registerSessionsRoutes(app, deps) {
7408
7777
  model: String(currentSettings?.model || result.model || 'gpt-4o').trim() || 'gpt-4o',
7409
7778
  baseUrl: String(currentSettings?.baseUrl || 'https://api.openai.com/v1').trim() || 'https://api.openai.com/v1',
7410
7779
  reconciliation,
7780
+ ...openAIImageSpendSettingsPayload(),
7411
7781
  };
7412
- io.emit('models_updated', buildModelCatalogWithProviders(
7782
+ io.emit('models_updated', modelsUpdatedPayload(
7413
7783
  getModelCatalogState(),
7414
7784
  currentSettings,
7415
7785
  ));
@@ -7421,12 +7791,60 @@ export function registerSessionsRoutes(app, deps) {
7421
7791
  ok: true,
7422
7792
  ...settingsPayload,
7423
7793
  models: Array.isArray(discovery?.models) ? discovery.models : [],
7424
- warning: reconciliationFailures.length
7794
+ warning: imageSpendError || (reconciliationFailures.length
7425
7795
  ? `${reconciliationFailures.length} unstarted conversation(s) could not switch provider.`
7426
- : (discovery?.ok ? null : (discovery?.error || 'OpenAI model discovery failed')),
7796
+ : (discovery?.ok ? null : (discovery?.error || 'OpenAI model discovery failed'))),
7427
7797
  });
7428
7798
  });
7429
7799
 
7800
+ // ─── OpenAI image presets ─────────────────────────────────────────────────
7801
+ // The built-ins and the owner's own (app_settings openai_image_presets).
7802
+ // Every change is one preset, read and written in one transaction, so two
7803
+ // pages saving at once cannot drop each other's preset. A paired relay's
7804
+ // agent may read them (relay_info lists them) but never change them.
7805
+ const readOwnOpenAIImagePresets = () => parseOwnOpenAIImagePresets(readJsonAppSetting(OPENAI_IMAGE_PRESETS_SETTING_KEY) ?? []);
7806
+ const openAIImagePresetsPayload = (own = readOwnOpenAIImagePresets()) => ({ presets: listOpenAIImagePresets(own) });
7807
+ const changeOwnOpenAIImagePresets = (req, res, change) => {
7808
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
7809
+ return res.status(403).json({ error: 'Image presets are changed by this relay\'s owner only.' });
7810
+ }
7811
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
7812
+ return res.status(500).json({ error: 'Image preset settings are unavailable' });
7813
+ }
7814
+ let outcome;
7815
+ try {
7816
+ outcome = db.transaction(() => {
7817
+ const applied = applyOwnOpenAIImagePresetChange(readOwnOpenAIImagePresets(), change);
7818
+ if (applied.ok) {
7819
+ stmts.upsertAppSetting.run(OPENAI_IMAGE_PRESETS_SETTING_KEY, JSON.stringify(applied.presets), new Date().toISOString());
7820
+ }
7821
+ return applied;
7822
+ })();
7823
+ } catch (error) {
7824
+ return res.status(500).json({ error: String(error?.message || error) });
7825
+ }
7826
+ if (!outcome.ok) {
7827
+ return res.status(400).json({ error: outcome.error, code: OPENAI_IMAGE_PRESET_REFUSED_CODE });
7828
+ }
7829
+ const payload = openAIImagePresetsPayload(outcome.presets);
7830
+ io.emit('openai_image_presets_updated', payload);
7831
+ return res.json({ ok: true, ...payload });
7832
+ };
7833
+
7834
+ app.get(OPENAI_IMAGE_PRESETS_PATH, auth, (_req, res) => res.json(openAIImagePresetsPayload()));
7835
+
7836
+ // body { preset: { name, size?, quality?, count?, output_format?, background? }, replace? }
7837
+ app.put(OPENAI_IMAGE_PRESETS_PATH, auth, (req, res) => changeOwnOpenAIImagePresets(req, res, {
7838
+ op: 'put',
7839
+ preset: req.body?.preset ?? null,
7840
+ replace: req.body?.replace === true,
7841
+ }));
7842
+
7843
+ app.delete(`${OPENAI_IMAGE_PRESETS_PATH}/:name`, auth, (req, res) => changeOwnOpenAIImagePresets(req, res, {
7844
+ op: 'delete',
7845
+ name: String(req.params?.name ?? ''),
7846
+ }));
7847
+
7430
7848
  // Copilot has no credentials or model list of its own in settings (it
7431
7849
  // authenticates through the host's `copilot` login and its catalog comes from
7432
7850
  // the CLI), so this pair carries exactly one thing: which engine runs it.
@@ -7523,7 +7941,7 @@ export function registerSessionsRoutes(app, deps) {
7523
7941
  ...claudeBackgroundCommandLimitSettingsFields(currentSettings),
7524
7942
  reconciliation,
7525
7943
  };
7526
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7944
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7527
7945
  io.emit('claude_settings_updated', settingsPayload);
7528
7946
  const reconciliationFailures = Array.isArray(reconciliation?.failedConversations)
7529
7947
  ? reconciliation.failedConversations
@@ -7646,7 +8064,7 @@ export function registerSessionsRoutes(app, deps) {
7646
8064
  console.log(`[claude-auth] model refresh after login failed: ${error?.message || error}`);
7647
8065
  }
7648
8066
  const settings = getClaudeProviderSettings();
7649
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8067
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7650
8068
  io.emit('claude_settings_updated', {
7651
8069
  configured: settings?.configured === true,
7652
8070
  enabled: settings?.enabled === true,
@@ -7738,7 +8156,7 @@ export function registerSessionsRoutes(app, deps) {
7738
8156
  console.log(`[cli-install] Grok model refresh after install failed: ${error?.message || error}`);
7739
8157
  }
7740
8158
  const settings = getGrokProviderSettings();
7741
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8159
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7742
8160
  io.emit('grok_settings_updated', {
7743
8161
  configured: settings?.configured === true,
7744
8162
  enabled: settings?.enabled === true,
@@ -7755,7 +8173,7 @@ export function registerSessionsRoutes(app, deps) {
7755
8173
  console.log(`[cli-install] Claude model refresh after install failed: ${error?.message || error}`);
7756
8174
  }
7757
8175
  const settings = getClaudeProviderSettings();
7758
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8176
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7759
8177
  io.emit('claude_settings_updated', {
7760
8178
  configured: settings?.configured === true,
7761
8179
  enabled: settings?.enabled === true,
@@ -7939,7 +8357,7 @@ export function registerSessionsRoutes(app, deps) {
7939
8357
  console.log(`[grok-auth] model refresh after login failed: ${error?.message || error}`);
7940
8358
  }
7941
8359
  const settings = getGrokProviderSettings();
7942
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8360
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7943
8361
  io.emit('grok_settings_updated', {
7944
8362
  configured: settings?.configured === true,
7945
8363
  enabled: settings?.enabled === true,
@@ -8040,7 +8458,7 @@ export function registerSessionsRoutes(app, deps) {
8040
8458
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
8041
8459
  reconciliation,
8042
8460
  };
8043
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8461
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8044
8462
  io.emit('grok_settings_updated', settingsPayload);
8045
8463
  const reconciliationFailures = Array.isArray(reconciliation?.failedConversations)
8046
8464
  ? reconciliation.failedConversations
@@ -8176,7 +8594,7 @@ export function registerSessionsRoutes(app, deps) {
8176
8594
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
8177
8595
  reconciliation,
8178
8596
  };
8179
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8597
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8180
8598
  io.emit('cursor_settings_updated', settingsPayload);
8181
8599
  const reconciliationFailures = Array.isArray(reconciliation?.failedConversations)
8182
8600
  ? reconciliation.failedConversations
@@ -8270,7 +8688,7 @@ export function registerSessionsRoutes(app, deps) {
8270
8688
  failedConversations,
8271
8689
  },
8272
8690
  };
8273
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8691
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8274
8692
  io.emit('deepseek_settings_updated', settingsPayload);
8275
8693
  return res.json({
8276
8694
  ok: true,
@@ -8324,7 +8742,7 @@ export function registerSessionsRoutes(app, deps) {
8324
8742
  failedConversations,
8325
8743
  },
8326
8744
  };
8327
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8745
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8328
8746
  io.emit('claude_api_settings_updated', settingsPayload);
8329
8747
  try { claudeApiStopLedger?.clearAll?.(); } catch {}
8330
8748
  try { checkClaudeApiCap('settings'); } catch {}
@@ -8910,9 +9328,19 @@ export function registerSessionsRoutes(app, deps) {
8910
9328
  });
8911
9329
  });
8912
9330
 
9331
+ // Each provider tab's switch, "New" tags and fallback warning.
9332
+ function modelStateFields(settings) {
9333
+ return {
9334
+ autoEnableNew: settings?.autoEnableNew !== false,
9335
+ unseen: Array.isArray(settings?.unseenModels) ? settings.unseenModels : [],
9336
+ fallback: settings?.fallback === true,
9337
+ };
9338
+ }
9339
+
8913
9340
  function buildModelVariantCatalogPayloadForRoute() {
8914
9341
  const rows = typeof listModelVariantRows === 'function' ? listModelVariantRows() : [];
8915
9342
  const modelState = getModelCatalogState();
9343
+ const openAISettings = getOpenAIProviderSettings();
8916
9344
  const claudeSettings = getClaudeProviderSettings();
8917
9345
  const cursorSettings = getCursorProviderSettings();
8918
9346
  const grokSettings = getGrokProviderSettings();
@@ -8926,11 +9354,21 @@ export function registerSessionsRoutes(app, deps) {
8926
9354
  contextLimitsByModel: modelState.contextLimitsByModel || {},
8927
9355
  modelMetadataByModel: modelState.modelMetadataByModel || {},
8928
9356
  }),
9357
+ copilotModelState: getCopilotModelState(),
9358
+ openaiModels: openAISettings?.enabled === true
9359
+ ? {
9360
+ defaultModel: openAISettings.model,
9361
+ availableModels: Array.isArray(openAISettings.availableModels) ? openAISettings.availableModels : [],
9362
+ enabledModels: Array.isArray(openAISettings.models) ? openAISettings.models : [],
9363
+ ...modelStateFields(openAISettings),
9364
+ }
9365
+ : null,
8929
9366
  claudeModels: claudeSettings?.enabled === true
8930
9367
  ? {
8931
9368
  defaultModel: claudeSettings.model,
8932
9369
  availableModels: Array.isArray(claudeSettings.availableModels) ? claudeSettings.availableModels : [],
8933
9370
  enabledModels: Array.isArray(claudeSettings.models) ? claudeSettings.models : [],
9371
+ ...modelStateFields(claudeSettings),
8934
9372
  }
8935
9373
  : null,
8936
9374
  cursorModels: cursorSettings?.enabled === true
@@ -8938,6 +9376,7 @@ export function registerSessionsRoutes(app, deps) {
8938
9376
  defaultModel: cursorSettings.model,
8939
9377
  availableModels: Array.isArray(cursorSettings.availableModels) ? cursorSettings.availableModels : [],
8940
9378
  enabledModels: Array.isArray(cursorSettings.models) ? cursorSettings.models : [],
9379
+ ...modelStateFields(cursorSettings),
8941
9380
  }
8942
9381
  : null,
8943
9382
  grokModels: grokSettings?.enabled === true
@@ -8945,6 +9384,7 @@ export function registerSessionsRoutes(app, deps) {
8945
9384
  defaultModel: grokSettings.model,
8946
9385
  availableModels: Array.isArray(grokSettings.availableModels) ? grokSettings.availableModels : [],
8947
9386
  enabledModels: Array.isArray(grokSettings.models) ? grokSettings.models : [],
9387
+ ...modelStateFields(grokSettings),
8948
9388
  }
8949
9389
  : null,
8950
9390
  deepseekModels: deepseekSettings?.enabled === true
@@ -8952,6 +9392,7 @@ export function registerSessionsRoutes(app, deps) {
8952
9392
  defaultModel: deepseekSettings.model,
8953
9393
  availableModels: Array.isArray(deepseekSettings.availableModels) ? deepseekSettings.availableModels : [],
8954
9394
  enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
9395
+ ...modelStateFields(deepseekSettings),
8955
9396
  }
8956
9397
  : null,
8957
9398
  // The Select Models → Claude API tab: its own list, "[1m]" ids included
@@ -8962,6 +9403,7 @@ export function registerSessionsRoutes(app, deps) {
8962
9403
  availableModels: Array.isArray(claudeApiSettings.availableModels) ? claudeApiSettings.availableModels : [],
8963
9404
  enabledModels: Array.isArray(claudeApiSettings.models) ? claudeApiSettings.models : [],
8964
9405
  discovered: claudeApiSettings.discovered === true,
9406
+ ...modelStateFields(claudeApiSettings),
8965
9407
  }
8966
9408
  : null,
8967
9409
  };
@@ -8978,101 +9420,28 @@ export function registerSessionsRoutes(app, deps) {
8978
9420
  return res.status(501).json({ error: 'Model refresh is unavailable' });
8979
9421
  }
8980
9422
  try {
8981
- const openAISettings = getOpenAIProviderSettings();
8982
- const claudeSettings = getClaudeProviderSettings();
8983
- const cursorSettings = getCursorProviderSettings();
8984
- const grokSettings = getGrokProviderSettings();
8985
- const deepseekSettings = getDeepSeekProviderSettings();
8986
- const claudeApiSettings = getClaudeApiProviderSettings();
8987
- const refreshTasks = [
9423
+ // The help-text CLI refresh beside every enabled provider's discovery
9424
+ // (provider-model-refresh.mjs; Copilot discovery gates itself on an
9425
+ // installed CLI). Only a failed CLI refresh fails the request.
9426
+ const [cliRefresh, providerRefresh] = await Promise.allSettled([
8988
9427
  refreshModelVariantCatalogFromCli(),
8989
- openAISettings?.enabled === true
8990
- ? refreshOpenAIProviderModels()
8991
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8992
- claudeSettings?.enabled === true
8993
- ? refreshClaudeProviderModels()
8994
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8995
- cursorSettings?.enabled === true
8996
- ? refreshCursorProviderModels()
8997
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8998
- grokSettings?.enabled === true
8999
- ? refreshGrokProviderModels()
9000
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9001
- // Copilot SDK discovery self-gates on an installed CLI (skipped:true
9002
- // when there is none), so unlike the providers above it has no
9003
- // enablement flag to check here.
9004
- typeof copilotModelDiscoveryService?.refresh === 'function'
9005
- ? copilotModelDiscoveryService.refresh('manual-refresh')
9006
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9007
- deepseekSettings?.enabled === true && deepseekSettingsService
9008
- ? deepseekSettingsService.refreshModels()
9009
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9010
- claudeApiSettings?.enabled === true && claudeApiSettingsService
9011
- ? claudeApiSettingsService.refreshModels()
9012
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9013
- ];
9014
- const [
9015
- cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh, claudeApiRefresh,
9016
- ] = await Promise.allSettled(refreshTasks);
9428
+ refreshProviderModels({ table: providerRefreshTable, reason: 'manual-refresh' }),
9429
+ ]);
9017
9430
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
9018
- const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
9019
- ? openAIRefresh.value
9020
- : {
9021
- ok: false,
9022
- models: Array.isArray(openAISettings?.models) ? openAISettings.models : [],
9023
- error: openAIRefresh.reason?.message || 'OpenAI model discovery failed',
9024
- };
9025
- const claudeModelDiscovery = claudeRefresh.status === 'fulfilled'
9026
- ? claudeRefresh.value
9027
- : {
9028
- ok: false,
9029
- models: Array.isArray(claudeSettings?.models) ? claudeSettings.models : [],
9030
- error: claudeRefresh.reason?.message || 'Claude model discovery failed',
9031
- };
9032
- const cursorModelDiscovery = cursorRefresh.status === 'fulfilled'
9033
- ? cursorRefresh.value
9034
- : {
9035
- ok: false,
9036
- models: Array.isArray(cursorSettings?.models) ? cursorSettings.models : [],
9037
- error: cursorRefresh.reason?.message || 'Cursor model discovery failed',
9038
- };
9039
- const grokModelDiscovery = grokRefresh.status === 'fulfilled'
9040
- ? grokRefresh.value
9041
- : {
9042
- ok: false,
9043
- models: Array.isArray(grokSettings?.models) ? grokSettings.models : [],
9044
- error: grokRefresh.reason?.message || 'Grok model discovery failed',
9045
- };
9046
- const deepseekModelDiscovery = deepseekRefresh.status === 'fulfilled'
9047
- ? deepseekRefresh.value
9048
- : {
9049
- ok: false,
9050
- models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
9051
- error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
9052
- };
9053
- const claudeApiModelDiscovery = claudeApiRefresh.status === 'fulfilled'
9054
- ? claudeApiRefresh.value
9055
- : {
9056
- ok: false,
9057
- models: Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [],
9058
- error: claudeApiRefresh.reason?.message || 'Claude API model discovery failed',
9059
- };
9060
- const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
9061
- ? copilotDiscoveryRefresh.value
9062
- : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
9063
- io.emit('models_updated', buildModelCatalogWithProviders(
9431
+ const results = providerRefresh.status === 'fulfilled' ? providerRefresh.value.results : {};
9432
+ io.emit('models_updated', modelsUpdatedPayload(
9064
9433
  getModelCatalogState(),
9065
9434
  ));
9066
9435
  return res.json({
9067
9436
  ok: true,
9068
9437
  ...buildModelVariantCatalogPayloadForRoute(),
9069
- openAIModelDiscovery,
9070
- claudeModelDiscovery,
9071
- cursorModelDiscovery,
9072
- grokModelDiscovery,
9073
- deepseekModelDiscovery,
9074
- claudeApiModelDiscovery,
9075
- copilotModelDiscovery,
9438
+ openAIModelDiscovery: results.openai,
9439
+ claudeModelDiscovery: results.claude,
9440
+ cursorModelDiscovery: results.cursor,
9441
+ grokModelDiscovery: results.grok,
9442
+ deepseekModelDiscovery: results.deepseek,
9443
+ claudeApiModelDiscovery: results['claude-api'],
9444
+ copilotModelDiscovery: results.github,
9076
9445
  });
9077
9446
  } catch (error) {
9078
9447
  return res.status(500).json({
@@ -9087,11 +9456,28 @@ export function registerSessionsRoutes(app, deps) {
9087
9456
  if (typeof setEnabledModelVariants !== 'function') {
9088
9457
  return res.status(501).json({ error: 'Model variant updates are unavailable' });
9089
9458
  }
9090
- const enabledVariantIds = Array.isArray(req.body?.enabledVariantIds)
9459
+ const hasVariantIds = Array.isArray(req.body?.enabledVariantIds);
9460
+ const autoEnableNew = req.body?.autoEnableNew;
9461
+ if (autoEnableNew !== undefined && typeof autoEnableNew !== 'boolean') {
9462
+ return res.status(400).json({ error: 'autoEnableNew must be true or false' });
9463
+ }
9464
+ if (!hasVariantIds && autoEnableNew === undefined) {
9465
+ return res.status(400).json({ error: 'No model variant update provided' });
9466
+ }
9467
+ const enabledVariantIds = hasVariantIds
9091
9468
  ? req.body.enabledVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
9092
9469
  : [];
9093
- setEnabledModelVariants(enabledVariantIds);
9094
- io.emit('models_updated', buildModelCatalogWithProviders(
9470
+ // Every row off would leave the pickers with the fallback model only.
9471
+ if (hasVariantIds && !enabledVariantIds.length) {
9472
+ return res.status(400).json({ error: 'Select at least one model variant' });
9473
+ }
9474
+ // The rows the browser drew: only those change (an older page sends none).
9475
+ const shownVariantIds = Array.isArray(req.body?.shownVariantIds)
9476
+ ? req.body.shownVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
9477
+ : null;
9478
+ if (hasVariantIds) setEnabledModelVariants(enabledVariantIds, shownVariantIds ? { shownVariantIds } : undefined);
9479
+ if (typeof autoEnableNew === 'boolean') setCopilotAutoEnableNew?.(autoEnableNew);
9480
+ io.emit('models_updated', modelsUpdatedPayload(
9095
9481
  getModelCatalogState(),
9096
9482
  ));
9097
9483
  return res.json({
@@ -9100,6 +9486,74 @@ export function registerSessionsRoutes(app, deps) {
9100
9486
  });
9101
9487
  });
9102
9488
 
9489
+ // Select Models was opened: every provider's "New" flags clear.
9490
+ app.post('/api/model-variants/seen', auth, (req, res) => {
9491
+ ensureSessionId(req, res);
9492
+ const markers = [
9493
+ () => markCopilotModelsSeen(),
9494
+ () => markProviderModelsSeen('openai'),
9495
+ () => markProviderModelsSeen('claude'),
9496
+ () => markProviderModelsSeen('cursor'),
9497
+ () => markProviderModelsSeen('grok'),
9498
+ () => deepseekSettingsService?.markModelsSeen?.() === true,
9499
+ () => claudeApiSettingsService?.markModelsSeen?.() === true,
9500
+ ];
9501
+ let changed = false;
9502
+ for (const mark of markers) {
9503
+ try {
9504
+ if (mark() === true) changed = true;
9505
+ } catch {
9506
+ /* one provider's map does not hold up the others */
9507
+ }
9508
+ }
9509
+ if (changed) {
9510
+ io.emit('models_updated', modelsUpdatedPayload(
9511
+ getModelCatalogState(),
9512
+ ));
9513
+ }
9514
+ return res.json({ ok: true, unseenNewModelCount: countUnseenNewModels() });
9515
+ });
9516
+
9517
+ // "Check for new models" (Settings → General → Models): the switch, the
9518
+ // interval, the providers left out, and each enabled provider's last check.
9519
+ function modelRefreshSettingsPayload(settings) {
9520
+ const last = modelRefreshLedger?.readLast?.() || {};
9521
+ const lastOf = (id) => (last[id] ? { at: last[id].at, ok: last[id].ok, error: last[id].error } : null);
9522
+ const ask = (id, gate) => {
9523
+ try {
9524
+ return providerRefreshTable[id]?.[gate]?.() === true;
9525
+ } catch {
9526
+ return false;
9527
+ }
9528
+ };
9529
+ // Every switched-on provider; one that is not set up is not checked and
9530
+ // says so.
9531
+ const providers = MODEL_REFRESH_PROVIDERS
9532
+ .filter(({ id }) => ask(id, 'isEnabled'))
9533
+ .map(({ id, label }) => ({ id, label, setUp: ask(id, 'isSetUp'), last: lastOf(id) }));
9534
+ return {
9535
+ auto: settings.auto,
9536
+ intervalHours: settings.intervalHours,
9537
+ intervalChoices: [...MODEL_REFRESH_INTERVAL_HOURS_CHOICES],
9538
+ default: { auto: DEFAULT_MODEL_REFRESH_AUTO, intervalHours: DEFAULT_MODEL_REFRESH_INTERVAL_HOURS },
9539
+ excluded: settings.excluded,
9540
+ providers,
9541
+ last: Object.fromEntries(Object.keys(last).map((id) => [id, { at: last[id].at, ok: last[id].ok }])),
9542
+ };
9543
+ }
9544
+
9545
+ app.get('/api/model-refresh-settings', auth, (_req, res) => {
9546
+ if (!modelRefreshSettings) return res.status(501).json({ error: 'Model refresh settings are unavailable' });
9547
+ return res.json(modelRefreshSettingsPayload(modelRefreshSettings.get()));
9548
+ });
9549
+
9550
+ app.post('/api/model-refresh-settings', auth, (req, res) => {
9551
+ if (!modelRefreshSettings) return res.status(501).json({ error: 'Model refresh settings are unavailable' });
9552
+ const result = modelRefreshSettings.update(req.body);
9553
+ if (!result.ok) return res.status(400).json({ error: result.error });
9554
+ return res.json(modelRefreshSettingsPayload(result.settings));
9555
+ });
9556
+
9103
9557
  app.get('/api/models', auth, (req, res) => {
9104
9558
  ensureSessionId(req, res);
9105
9559
  const modelState = buildModelCatalogWithProviders(
@@ -9121,6 +9575,8 @@ export function registerSessionsRoutes(app, deps) {
9121
9575
  claudeContextTiersByModel: modelState.claudeContextTiersByModel || {},
9122
9576
  claudeApiContextTiersByModel: modelState.claudeApiContextTiersByModel || {},
9123
9577
  providersByModel: modelState.providersByModel || {},
9578
+ // Models flagged new in Select Models: the menu item's dot.
9579
+ unseenNewModelCount: countUnseenNewModels(),
9124
9580
  stale: modelState.stale,
9125
9581
  metadataValid: modelState.metadataValid === true,
9126
9582
  reasoningMetadataValid: modelState.reasoningMetadataValid === true,
@@ -9142,7 +9598,7 @@ export function registerSessionsRoutes(app, deps) {
9142
9598
  source: source || 'relay-extension',
9143
9599
  error,
9144
9600
  });
9145
- io.emit('models_updated', buildModelCatalogWithProviders(
9601
+ io.emit('models_updated', modelsUpdatedPayload(
9146
9602
  getModelCatalogState(),
9147
9603
  ));
9148
9604
  res.json({
@@ -9326,6 +9782,23 @@ export function registerSessionsRoutes(app, deps) {
9326
9782
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9327
9783
  claudeApiCard,
9328
9784
  });
9785
+ // OpenAI images: today's spend against the daily cap, from the booking.
9786
+ if (typeof getOpenAIImageSpendState === 'function') {
9787
+ const conversationId = String(req.query?.conversationId || '').trim();
9788
+ let imageSpendState = null;
9789
+ let imageSpendError = null;
9790
+ try {
9791
+ imageSpendState = await getOpenAIImageSpendState(conversationId ? { conversationId } : {});
9792
+ } catch (error) {
9793
+ imageSpendError = String(error?.message || error);
9794
+ }
9795
+ let openAIConfigured = false;
9796
+ try {
9797
+ openAIConfigured = getOpenAIProviderSettings()?.configured === true;
9798
+ } catch {}
9799
+ const imageCard = buildOpenAIImagePlanCard({ state: imageSpendState, error: imageSpendError, configured: openAIConfigured });
9800
+ if (imageCard && Array.isArray(report.providers)) report.providers.push(imageCard);
9801
+ }
9329
9802
  // Every tab's Tokens sections, from the token booking.
9330
9803
  if (tokenUsageService) {
9331
9804
  appendTokenSections(report.providers, (options) => tokenUsageService.readPeriods(options), {