@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.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +289 -94
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- 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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
7156
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
const
|
|
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
|
-
|
|
8990
|
-
|
|
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
|
|
9019
|
-
|
|
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
|
|
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
|
-
|
|
9094
|
-
|
|
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',
|
|
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), {
|