@oar-sh/oar 0.9.24 → 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 (66) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +142 -0
  3. package/README.md +12 -11
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  8. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  9. package/server/db-schema.mjs +19 -0
  10. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  11. package/server/migrations/0039-media-index.mjs +167 -0
  12. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  13. package/server/public/app/api-client.js +58 -0
  14. package/server/public/app/ask-user-view.js +2 -1
  15. package/server/public/app/attachments-view.js +66 -9
  16. package/server/public/app/bootstrap.js +85 -25
  17. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  18. package/server/public/app/context-media-tab.js +143 -0
  19. package/server/public/app/conversation-view.js +90 -5
  20. package/server/public/app/image-composer-ui.js +518 -0
  21. package/server/public/app/image-turn-placeholder.mjs +233 -0
  22. package/server/public/app/journal-view.js +12 -2
  23. package/server/public/app/media-gallery-model.mjs +202 -0
  24. package/server/public/app/media-gallery-ui.js +524 -0
  25. package/server/public/app/media-view.js +79 -0
  26. package/server/public/app/openai-image-composer.mjs +492 -0
  27. package/server/public/app/openai-image-cost-ui.js +426 -0
  28. package/server/public/app/openai-image-spend.mjs +443 -0
  29. package/server/public/app/plan-usage-view.mjs +38 -6
  30. package/server/public/app/relay-error-ctas.mjs +5 -0
  31. package/server/public/app/session-history-ui.js +2 -0
  32. package/server/public/app/settings-modal.js +4 -0
  33. package/server/public/app/socket-handlers.js +26 -0
  34. package/server/public/app/tab-strip-fade.mjs +1 -1
  35. package/server/public/app/viewer-gallery.mjs +23 -1
  36. package/server/public/index.html +383 -7
  37. package/server/relay-tools.md +1 -0
  38. package/server/repositories/image-conversation-repository.mjs +69 -0
  39. package/server/repositories/media-repository.mjs +305 -0
  40. package/server/routes/media-routes.mjs +361 -0
  41. package/server/routes/messages-routes.mjs +1410 -360
  42. package/server/routes/sessions-routes.mjs +361 -14
  43. package/server/server-runtime.mjs +19 -0
  44. package/server/services/database-file.mjs +6 -4
  45. package/server/services/generated-image-cleanup-service.mjs +2 -1
  46. package/server/services/generated-image-files.mjs +110 -0
  47. package/server/services/image-input-paths.mjs +389 -0
  48. package/server/services/image-operation-service.mjs +6 -2
  49. package/server/services/media-extract.mjs +653 -0
  50. package/server/services/media-index-service.mjs +221 -0
  51. package/server/services/openai-image-preview-turns.mjs +330 -0
  52. package/server/services/openai-image-spend-service.mjs +516 -0
  53. package/server/services/openai-image-stream.mjs +170 -0
  54. package/server/services/plan-usage-openai-image.mjs +148 -0
  55. package/server/services/remote-relay-caller-context.mjs +6 -0
  56. package/server/services/remote-relay-dispatcher.mjs +386 -22
  57. package/server/services/remote-relay-registry-service.mjs +3 -1
  58. package/server/services/token-usage-service.mjs +15 -0
  59. package/shared/image-header.mjs +90 -0
  60. package/shared/openai-image-options.mjs +299 -0
  61. package/shared/openai-image-presets.mjs +175 -0
  62. package/shared/openai-image-previews.mjs +40 -0
  63. package/shared/openai-image-prices.mjs +291 -0
  64. package/shared/openai-image-spend.mjs +90 -0
  65. package/shared/remote-relay-contract.mjs +126 -4
  66. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
@@ -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,
@@ -310,7 +335,8 @@ export function parseOpenAISettingsUpdateRequest(body = {}) {
310
335
  const hasEnabledModels = Array.isArray(payload.enabledModels);
311
336
  const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
312
337
  if (!remove && !hasModel && !apiKey && !hasEnabled && !hasBaseUrl && !hasEnabledModels && !hasAutoEnableNew) {
313
- return { ok: false, error: 'No OpenAI settings update provided' };
338
+ // `empty`: the body may still carry the image cap fields alone.
339
+ return { ok: false, empty: true, error: 'No OpenAI settings update provided' };
314
340
  }
315
341
  if (hasModel && !isSafeProviderModelId(model)) {
316
342
  return { ok: false, error: 'Invalid OpenAI model ID' };
@@ -337,6 +363,39 @@ export function parseOpenAISettingsUpdateRequest(body = {}) {
337
363
  };
338
364
  }
339
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
+
340
399
  export function parseClaudeSettingsUpdateRequest(body = {}) {
341
400
  const payload = body && typeof body === 'object' ? body : {};
342
401
  const hasEnabled = typeof payload.enabled === 'boolean';
@@ -545,6 +604,42 @@ function copilotListedModelIds(modelState = {}) {
545
604
  );
546
605
  }
547
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
+
548
643
  export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
549
644
  const configured = claudeSettings?.enabled === true;
550
645
  const model = String(claudeSettings?.model || '').trim();
@@ -980,11 +1075,6 @@ function normalizeRequestedProviderType(value = '') {
980
1075
  return '';
981
1076
  }
982
1077
 
983
- function isOpenAIImageModelId(model = '') {
984
- const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
985
- return normalized.startsWith('gpt-image-') || normalized.startsWith('dall-e-');
986
- }
987
-
988
1078
  function isPidAlive(pidValue) {
989
1079
  const pid = Number(pidValue);
990
1080
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -2215,7 +2305,7 @@ export function registerSessionsRoutes(app, deps) {
2215
2305
  deleteOrphanedUploads,
2216
2306
  cleanupGeneratedImagesForConversation = cleanupGeneratedImagesForConversationDefault,
2217
2307
  queueCounts,
2218
- getModelCatalogState,
2308
+ getModelCatalogState: readCopilotModelCatalogState,
2219
2309
  updateModelCatalog,
2220
2310
  listModelVariantRows,
2221
2311
  refreshModelVariantCatalogFromCli,
@@ -2301,6 +2391,12 @@ export function registerSessionsRoutes(app, deps) {
2301
2391
  // Tokens per turn and per day (services/token-usage-service.mjs): a
2302
2392
  // deleted chat drops its turn rows. Absent in older wiring and most tests.
2303
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 = () => {},
2304
2400
  reconcileUnstartedConversationProviders = async () => ({
2305
2401
  updatedUnstartedConversations: 0,
2306
2402
  skippedStartedConversations: 0,
@@ -2431,6 +2527,9 @@ export function registerSessionsRoutes(app, deps) {
2431
2527
  // the wiring does not pass one.
2432
2528
  shareRepository = null,
2433
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());
2434
2533
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2435
2534
  // Built on first use: many route tests hand in a database without tables.
2436
2535
  let shareRepositoryInstance = shareRepository;
@@ -5056,7 +5155,9 @@ export function registerSessionsRoutes(app, deps) {
5056
5155
  group: conversationGroupPayload(resolvedConversationId, req),
5057
5156
  // Its place in the group's hand order, as on the list rows.
5058
5157
  groupPosition: conversationGroupPositionOf(conv),
5059
- 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 }),
5060
5161
  pageInfo: history.pageInfo,
5061
5162
  });
5062
5163
  });
@@ -5331,6 +5432,18 @@ export function registerSessionsRoutes(app, deps) {
5331
5432
  return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5332
5433
  }
5333
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
+ }
5334
5447
  if (remoteRequest && readPlanOnly(existing)
5335
5448
  && Object.prototype.hasOwnProperty.call(req.body || {}, 'preferredRelayMode')
5336
5449
  && planOnlyRefusesMode(req.body.preferredRelayMode)) {
@@ -5344,7 +5457,7 @@ export function registerSessionsRoutes(app, deps) {
5344
5457
  // ─── PATCH /api/conversation/:id/settings — the relay_tool's update_session ───
5345
5458
  //
5346
5459
  // `{ settings: { model, effort, mode, auto_compact_window, title, group,
5347
- // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
5460
+ // thinking_enabled, thinking_display, cwd, image }, dry_run, owner_approved }`.
5348
5461
  // Reached by an agent of this relay (the loopback) and by a paired relay's
5349
5462
  // agent (under the inbound switch); the owner may call it too. It applies
5350
5463
  // this relay's own rule: an agent changes freely only a session its own
@@ -5361,11 +5474,21 @@ export function registerSessionsRoutes(app, deps) {
5361
5474
 
5362
5475
  const SESSION_SETTINGS_INVALID = 'SESSION_SETTINGS_INVALID';
5363
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
+
5364
5486
  function currentSessionSettings(conversationId) {
5365
5487
  const conv = stmts.getConvAnyStatus.get(conversationId) || {};
5366
5488
  const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5367
5489
  const workspace = resolveConversationWorkspaceState?.({ conversationId }) || null;
5368
5490
  const window = parseAutoCompactWindow(conv.auto_compact_window);
5491
+ const imageDefaults = readImageDefaults(conversationId);
5369
5492
  return {
5370
5493
  model: normalizePreferredModel(conv.preferred_model) || String(runtime?.provider_model || runtime?.model || '').trim() || null,
5371
5494
  effort: normalizePreferredReasoningEffort(conv.preferred_reasoning_effort) || null,
@@ -5377,6 +5500,7 @@ export function registerSessionsRoutes(app, deps) {
5377
5500
  thinking_display: parseThinkingDisplay(conv.thinking_display),
5378
5501
  cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
5379
5502
  planOnly: readPlanOnly(conv),
5503
+ ...(Object.keys(imageDefaults).length ? { image: imageDefaults } : {}),
5380
5504
  };
5381
5505
  }
5382
5506
 
@@ -5429,6 +5553,8 @@ export function registerSessionsRoutes(app, deps) {
5429
5553
  const change = (key) => { if (!plan.changed.includes(key)) plan.changed.push(key); };
5430
5554
 
5431
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 });
5432
5558
  plan.preferences.preferredModel = wanted.model;
5433
5559
  change('model');
5434
5560
  }
@@ -5550,6 +5676,29 @@ export function registerSessionsRoutes(app, deps) {
5550
5676
  change('cwd');
5551
5677
  }
5552
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
+ }
5553
5702
  plan.changed.sort((a, b) => REMOTE_RELAY_SESSION_SETTINGS.indexOf(a) - REMOTE_RELAY_SESSION_SETTINGS.indexOf(b));
5554
5703
  return { ok: true, plan };
5555
5704
  }
@@ -5711,13 +5860,22 @@ export function registerSessionsRoutes(app, deps) {
5711
5860
  if (!titled.ok) return failAfter(titled.statusCode || 500, { error: titled.error || 'Failed to update the title.' });
5712
5861
  written.push('title');
5713
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
+ }
5714
5872
  if (Object.keys(plan.preferences).length) {
5715
5873
  applyConversationPreferences({
5716
5874
  conversationId,
5717
5875
  existing: stmts.getConvAnyStatus.get(conversationId),
5718
5876
  body: plan.preferences,
5719
5877
  });
5720
- written.push(...plan.changed.filter((key) => !['cwd', 'title', 'group'].includes(key)));
5878
+ written.push(...plan.changed.filter((key) => !['cwd', 'title', 'group', 'image'].includes(key)));
5721
5879
  }
5722
5880
  // Groups through the agent path: an owner's group the session leaves is
5723
5881
  // never removed (it stays, empty if it was the last member); a group an
@@ -6758,6 +6916,12 @@ export function registerSessionsRoutes(app, deps) {
6758
6916
  requestedBootstrapModel
6759
6917
  || (isProviderModelAvailable(catalogSelectedModel, availableModels) ? catalogSelectedModel : '')
6760
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
+ : '';
6761
6925
  const providerModelSelection = useClaudeCloudProvider
6762
6926
  // The cloud takes plain ids; with no model in the request the tab's
6763
6927
  // default applies (never the shared catalog's current model).
@@ -6774,7 +6938,7 @@ export function registerSessionsRoutes(app, deps) {
6774
6938
  })
6775
6939
  : useOpenAIProvider
6776
6940
  ? resolveProviderModelSelection({
6777
- requestedModel: providerRequestedModel(availableOpenAIModels),
6941
+ requestedModel: openAIImageDefaultModel || providerRequestedModel(availableOpenAIModels),
6778
6942
  // The removed resolveOpenAISessionModel carried this last-resort id.
6779
6943
  configuredModel: openAISettings.model || 'gpt-4o',
6780
6944
  availableModels: availableOpenAIModels,
@@ -6833,6 +6997,12 @@ export function registerSessionsRoutes(app, deps) {
6833
6997
  supportedModels: providerModelSelection.availableModels || [],
6834
6998
  });
6835
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);
6836
7006
  // Copilot falls back to the catalogue's current model for an id it does
6837
7007
  // not list, which New Chat (it only offers listed ids) never meets. An
6838
7008
  // agent naming a mistyped id ("gpt-5-4") would get a session on another
@@ -6971,6 +7141,11 @@ export function registerSessionsRoutes(app, deps) {
6971
7141
  }
6972
7142
 
6973
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
+ }
6974
7149
  if (requestedProviderType === 'openai-image' && !isOpenAIImageModelId(selectedModel)) {
6975
7150
  return res.status(400).json({
6976
7151
  ok: false,
@@ -7433,6 +7608,73 @@ export function registerSessionsRoutes(app, deps) {
7433
7608
  });
7434
7609
  });
7435
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
+
7436
7678
  app.get('/api/settings/openai', auth, (_req, res) => {
7437
7679
  const settings = getOpenAIProviderSettings();
7438
7680
  return res.json({
@@ -7440,12 +7682,43 @@ export function registerSessionsRoutes(app, deps) {
7440
7682
  enabled: settings?.enabled === true,
7441
7683
  model: String(settings?.model || 'gpt-4o').trim() || 'gpt-4o',
7442
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(),
7443
7690
  });
7444
7691
  });
7445
7692
 
7446
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 });
7447
7702
  const parsed = parseOpenAISettingsUpdateRequest(req.body);
7448
- 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
+ }
7449
7722
  const previous = getOpenAIProviderSettings();
7450
7723
  const result = setOpenAIProviderSettings(parsed);
7451
7724
  if (!result?.ok) {
@@ -7458,6 +7731,14 @@ export function registerSessionsRoutes(app, deps) {
7458
7731
  activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
7459
7732
  });
7460
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
+ }
7461
7742
  const shouldDiscover = result.enabled === true && (
7462
7743
  !!parsed.apiKey
7463
7744
  || previous?.enabled !== true
@@ -7496,6 +7777,7 @@ export function registerSessionsRoutes(app, deps) {
7496
7777
  model: String(currentSettings?.model || result.model || 'gpt-4o').trim() || 'gpt-4o',
7497
7778
  baseUrl: String(currentSettings?.baseUrl || 'https://api.openai.com/v1').trim() || 'https://api.openai.com/v1',
7498
7779
  reconciliation,
7780
+ ...openAIImageSpendSettingsPayload(),
7499
7781
  };
7500
7782
  io.emit('models_updated', modelsUpdatedPayload(
7501
7783
  getModelCatalogState(),
@@ -7509,12 +7791,60 @@ export function registerSessionsRoutes(app, deps) {
7509
7791
  ok: true,
7510
7792
  ...settingsPayload,
7511
7793
  models: Array.isArray(discovery?.models) ? discovery.models : [],
7512
- warning: reconciliationFailures.length
7794
+ warning: imageSpendError || (reconciliationFailures.length
7513
7795
  ? `${reconciliationFailures.length} unstarted conversation(s) could not switch provider.`
7514
- : (discovery?.ok ? null : (discovery?.error || 'OpenAI model discovery failed')),
7796
+ : (discovery?.ok ? null : (discovery?.error || 'OpenAI model discovery failed'))),
7515
7797
  });
7516
7798
  });
7517
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
+
7518
7848
  // Copilot has no credentials or model list of its own in settings (it
7519
7849
  // authenticates through the host's `copilot` login and its catalog comes from
7520
7850
  // the CLI), so this pair carries exactly one thing: which engine runs it.
@@ -9452,6 +9782,23 @@ export function registerSessionsRoutes(app, deps) {
9452
9782
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9453
9783
  claudeApiCard,
9454
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
+ }
9455
9802
  // Every tab's Tokens sections, from the token booking.
9456
9803
  if (tokenUsageService) {
9457
9804
  appendTokenSections(report.providers, (options) => tokenUsageService.readPeriods(options), {
@@ -84,6 +84,7 @@ import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs'
84
84
  import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
85
85
  import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
86
86
  import { createTokenUsageService } from './services/token-usage-service.mjs';
87
+ import { createOpenAIImageSpendService } from './services/openai-image-spend-service.mjs';
87
88
  import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
88
89
  import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
89
90
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
@@ -5807,6 +5808,16 @@ const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console
5807
5808
  // Tokens per turn and per day for every provider, booked from the
5808
5809
  // workers' reports; the Tokens sections in Check Usage and About read it.
5809
5810
  const tokenUsageService = createTokenUsageService({ db, logger: console });
5811
+ // OpenAI image spend against its daily cap: one ledger of the tries in
5812
+ // flight for the image routes and the settings route alike. Its events go
5813
+ // to the owner's clients once the socket server is up.
5814
+ const openAIImageSpend = createOpenAIImageSpendService({
5815
+ tokenUsageService,
5816
+ readSetting: readAppSettingValue,
5817
+ db,
5818
+ emit: (event, payload) => io?.emit?.(event, payload),
5819
+ logger: console,
5820
+ });
5810
5821
  // This month's spend on the key against the cap, and the Check Usage card
5811
5822
  // (services/plan-usage-claude-api.mjs), over the same local days.
5812
5823
  const claudeApiPlanUsage = createClaudeApiPlanUsage({
@@ -8175,6 +8186,11 @@ const sharedRouteDeps = {
8175
8186
  path,
8176
8187
  uploadsDir: UPLOAD_DIR,
8177
8188
  uploadReadDirs: UPLOAD_READ_DIRS,
8189
+ // Never read for a send's imagePaths, even inside the session's folder.
8190
+ imageInputDenyPaths: {
8191
+ dirs: [DATA_DIR],
8192
+ files: [DB_PATH, `${DB_PATH}-wal`, `${DB_PATH}-shm`, `${DB_PATH}-journal`, CONFIG_PATH],
8193
+ },
8178
8194
  fetchUsageSummary,
8179
8195
  fetchCopilotBillingUsage,
8180
8196
  fetchGrokBillingUsage: createGrokBillingUsageFetcher(),
@@ -8231,6 +8247,9 @@ const sharedRouteDeps = {
8231
8247
  claudeBillingSwitchService,
8232
8248
  claudeApiUsageService,
8233
8249
  tokenUsageService,
8250
+ openAIImageSpend,
8251
+ getOpenAIImageSpendState: (options) => openAIImageSpend.buildState(options),
8252
+ notifyOpenAIImageSpendChanged: () => openAIImageSpend.notifyImageSpendChanged(),
8234
8253
  claudeApiClaimCheck,
8235
8254
  onClaudeBillingFence: handleClaudeBillingFence,
8236
8255
  claudeApiPlanUsage,
@@ -45,10 +45,12 @@ export const STARTUP_SETTING_KEYS = Object.freeze([
45
45
  'model_refresh_last',
46
46
  ]);
47
47
  // And the tables such a start fills that hold no one's work: the model
48
- // catalog and its refresh state, the restart orchestrator's one row. (The
49
- // shadow tables of the message search are left out too: they follow
50
- // messages.) Any other table with a row makes the start refuse.
51
- export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator']);
48
+ // catalog and its refresh state, the restart orchestrator's one row, the
49
+ // media index's extractor version (migration 0039; its items and queue
50
+ // follow messages and stay empty without them). (The shadow tables of the
51
+ // message search are left out too: they follow messages.) Any other table
52
+ // with a row makes the start refuse.
53
+ export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator', 'media_index_meta']);
52
54
 
53
55
  /** A database's files: the main file, its -wal and its -shm. */
54
56
  export function sideFiles(name) {
@@ -25,7 +25,8 @@ function parseMessageAttachments(rawAttachments, { parseAttachments, hydrateAtta
25
25
  return attachments.map((entry) => hydrateAttachment(entry)).filter(Boolean);
26
26
  }
27
27
 
28
- function resolveGeneratedImagePath({
28
+ /** The file of a generated-image attachment under `root`, or null for one that would leave its folder. */
29
+ export function resolveGeneratedImagePath({
29
30
  root,
30
31
  sessionId,
31
32
  relativePath,