@oar-sh/oar 0.9.24 → 0.9.26
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 +284 -0
- package/README.md +12 -11
- package/package.json +5 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- 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/api-client.js +58 -0
- 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 +85 -25
- 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/context-usage-view.mjs +2 -2
- 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/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- 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/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/pwa-install.js +25 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +517 -123
- 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/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +21 -0
- package/server/services/database-file.mjs +6 -4
- 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/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/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +409 -23
- package/server/services/remote-relay-registry-service.mjs +10 -2
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/math-instructions.mjs +30 -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 +96 -50
- package/server/public/app/math-dollars.mjs +0 -89
|
@@ -21,6 +21,51 @@ function normalizeBoardText(value, fallback = '') {
|
|
|
21
21
|
return text.slice(0, 12_000);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// A plan is shown whole on its card: the old shared limit of 12,000 characters
|
|
25
|
+
// cut a longer one in mid-sentence and left no mark. Past this size the card
|
|
26
|
+
// says how much is missing.
|
|
27
|
+
const BOARD_BODY_MAX_CHARS = 200_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The code fence still open at the end of `text`, as the characters that
|
|
31
|
+
* close it, or ''. By the Markdown rules: a fence is three or more backticks
|
|
32
|
+
* or tildes behind at most three spaces (a backtick fence's info string holds
|
|
33
|
+
* no backtick); it is closed by a line of the same character, at least as
|
|
34
|
+
* long, with nothing but blanks behind it. Lines inside it open nothing.
|
|
35
|
+
*/
|
|
36
|
+
function openCodeFence(text) {
|
|
37
|
+
let open = null;
|
|
38
|
+
// A plan file read on Windows ends its lines with CRLF.
|
|
39
|
+
for (const line of text.split(/\r?\n/)) {
|
|
40
|
+
const fence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
41
|
+
if (!fence) continue;
|
|
42
|
+
const [, run, rest] = fence;
|
|
43
|
+
if (!open) {
|
|
44
|
+
if (run[0] === '`' && rest.includes('`')) continue;
|
|
45
|
+
open = { char: run[0], length: run.length };
|
|
46
|
+
} else if (run[0] === open.char && run.length >= open.length && !rest.trim()) {
|
|
47
|
+
open = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return open ? open.char.repeat(open.length) : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeBoardBody(value, context) {
|
|
54
|
+
const text = String(value || '').trim();
|
|
55
|
+
if (text.length <= BOARD_BODY_MAX_CHARS) return text;
|
|
56
|
+
const lineBreak = text.lastIndexOf('\n', BOARD_BODY_MAX_CHARS);
|
|
57
|
+
let kept = text.slice(0, lineBreak > BOARD_BODY_MAX_CHARS / 2 ? lineBreak : BOARD_BODY_MAX_CHARS);
|
|
58
|
+
// Not half a character.
|
|
59
|
+
if (/[\uD800-\uDBFF]$/.test(kept)) kept = kept.slice(0, -1);
|
|
60
|
+
kept = kept.trimEnd();
|
|
61
|
+
// A cut inside a code block would put the note into the code: close the block first.
|
|
62
|
+
const fence = openCodeFence(kept);
|
|
63
|
+
const openFence = fence ? `\n${fence}` : '';
|
|
64
|
+
const planFilePath = String(context?.planFilePath || '').trim();
|
|
65
|
+
const where = planFilePath ? `; the whole plan is in \`${planFilePath}\`` : '';
|
|
66
|
+
return `${kept}${openFence}\n\n… cut here: ${text.length - kept.length} more characters${where}.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
function normalizeBoardType(value) {
|
|
25
70
|
const text = String(value || '').trim().toLowerCase();
|
|
26
71
|
if (!text) return 'generic';
|
|
@@ -197,7 +242,7 @@ export function registerRelayBoardRoutes(app, deps) {
|
|
|
197
242
|
normalizedBoardType,
|
|
198
243
|
relayMode,
|
|
199
244
|
normalizeBoardText(title, 'Plan ready for review'),
|
|
200
|
-
|
|
245
|
+
normalizeBoardBody(body, boardContext),
|
|
201
246
|
normalizedActions.length ? JSON.stringify(normalizedActions) : null,
|
|
202
247
|
normalizedRecommended,
|
|
203
248
|
boardContext ? JSON.stringify(boardContext) : null,
|
|
@@ -44,7 +44,7 @@ export function registerRemoteRelayRoutes(app, deps) {
|
|
|
44
44
|
app.get('/api/remote-relays', auth, (_req, res) => {
|
|
45
45
|
res.json({
|
|
46
46
|
relays: registry.listPublic(),
|
|
47
|
-
self:
|
|
47
|
+
self: registry.selfPayload(),
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
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
|
-
|
|
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
|
-
|
|
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), {
|