@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
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// What OpenAI image turns cost, the daily cap and the image presets, as the
|
|
2
|
+
// web client shows them: the pure part, no DOM.
|
|
3
|
+
//
|
|
4
|
+
// The first two blocks mirror shared/openai-image-spend.mjs and
|
|
5
|
+
// shared/openai-image-presets.mjs, which the browser cannot import (only
|
|
6
|
+
// server/public is served). openai-image-spend.test.mjs asserts they stay
|
|
7
|
+
// identical.
|
|
8
|
+
//
|
|
9
|
+
// Money is always "USD 1.23": a `$` pair renders as maths in this client.
|
|
10
|
+
|
|
11
|
+
import { escapeHtml } from './context-usage-view.mjs';
|
|
12
|
+
import {
|
|
13
|
+
OPENAI_IMAGE_OPTION_KEYS,
|
|
14
|
+
imageComposerChoices,
|
|
15
|
+
validateOpenAIImageOptions,
|
|
16
|
+
} from './openai-image-composer.mjs';
|
|
17
|
+
|
|
18
|
+
// ---- Mirror of shared/openai-image-spend.mjs ------------------------------
|
|
19
|
+
|
|
20
|
+
export const OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD = 5;
|
|
21
|
+
export const OPENAI_IMAGE_CAP_WARN_RATIO = 0.8;
|
|
22
|
+
export const OPENAI_IMAGE_DAILY_CAP_REACHED_CODE = 'OPENAI_IMAGE_DAILY_CAP_REACHED';
|
|
23
|
+
export const OPENAI_IMAGE_USAGE_PATH = '/api/image-usage';
|
|
24
|
+
export const OPENAI_IMAGE_USAGE_EVENT = 'image_usage_updated';
|
|
25
|
+
export const OPENAI_IMAGE_SETTINGS_FIELDS = Object.freeze({
|
|
26
|
+
dailyCapUsd: 'imageDailyCapUsd',
|
|
27
|
+
allowToday: 'allowImagesToday',
|
|
28
|
+
});
|
|
29
|
+
export const OPENAI_IMAGE_PRESETS_PATH = '/api/openai-image-presets';
|
|
30
|
+
|
|
31
|
+
// The relay's socket event with `{ presets }` after every preset change
|
|
32
|
+
// (sessions-routes.mjs), and the top of the cap POST /api/settings/openai
|
|
33
|
+
// takes.
|
|
34
|
+
export const OPENAI_IMAGE_PRESETS_EVENT = 'openai_image_presets_updated';
|
|
35
|
+
export const OPENAI_IMAGE_DAILY_CAP_MAX_USD = 10_000;
|
|
36
|
+
|
|
37
|
+
export function formatOpenAIImageUsd(value) {
|
|
38
|
+
const number = Number(value);
|
|
39
|
+
if (!Number.isFinite(number)) return 'USD ?';
|
|
40
|
+
return `USD ${number > 0 && number < 0.01 ? number.toFixed(3) : number.toFixed(2)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- Mirror of shared/openai-image-presets.mjs (names, built-ins) ---------
|
|
44
|
+
|
|
45
|
+
export const OPENAI_IMAGE_PRESET_LIMITS = Object.freeze({
|
|
46
|
+
ownMax: 30,
|
|
47
|
+
nameChars: 40,
|
|
48
|
+
storedBytesMax: 8 * 1024,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const OPENAI_IMAGE_PRESET_REFUSED_CODE = 'OPENAI_IMAGE_PRESET_REFUSED';
|
|
52
|
+
|
|
53
|
+
export const OPENAI_IMAGE_BUILTIN_PRESETS = Object.freeze([
|
|
54
|
+
Object.freeze({ name: 'Draft square', size: '1024x1024', quality: 'low' }),
|
|
55
|
+
Object.freeze({ name: 'Photo landscape', size: '1536x1024', quality: 'medium' }),
|
|
56
|
+
Object.freeze({ name: 'Portrait', size: '1024x1536', quality: 'medium' }),
|
|
57
|
+
Object.freeze({ name: 'Wallpaper 4K', size: '3840x2160', quality: 'high' }),
|
|
58
|
+
Object.freeze({ name: 'Sticker', size: '1024x1024', quality: 'medium', output_format: 'png', background: 'transparent' }),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const HIDDEN_CHARACTERS = /[\u0000-\u001f\u007f-\u009f----]/u;
|
|
62
|
+
|
|
63
|
+
export function cleanOpenAIImagePresetName(value) {
|
|
64
|
+
if (typeof value !== 'string') return { error: 'a preset name must be text' };
|
|
65
|
+
const normalized = value.normalize('NFC');
|
|
66
|
+
if (HIDDEN_CHARACTERS.test(normalized)) return { error: 'a preset name may not hold control or invisible characters' };
|
|
67
|
+
const name = normalized.trim().replace(/\s+/gu, ' ');
|
|
68
|
+
if (!name) return { error: 'a preset name may not be empty' };
|
|
69
|
+
// "." and ".." would be path steps in the preset's URL (DELETE …/:name).
|
|
70
|
+
if (/^\.+$/.test(name)) return { error: 'a preset name may not be dots alone' };
|
|
71
|
+
if ([...name].length > OPENAI_IMAGE_PRESET_LIMITS.nameChars) {
|
|
72
|
+
return { error: `a preset name has at most ${OPENAI_IMAGE_PRESET_LIMITS.nameChars} characters` };
|
|
73
|
+
}
|
|
74
|
+
return { name };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function openAIImagePresetKey(value) {
|
|
78
|
+
const cleaned = cleanOpenAIImagePresetName(value);
|
|
79
|
+
return cleaned.error ? '' : cleaned.name.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- The spend state ------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function finite(value, fallback = 0) {
|
|
85
|
+
const number = Number(value);
|
|
86
|
+
return Number.isFinite(number) ? number : fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** "5.00": an amount without its unit, for "USD 1.20 of 5.00". */
|
|
90
|
+
function bareAmount(value) {
|
|
91
|
+
return formatOpenAIImageUsd(value).replace(/^USD /, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The spend state of GET OPENAI_IMAGE_USAGE_PATH or the socket event, with
|
|
96
|
+
* numbers that are numbers; null for anything that is not one.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeImageSpendState(raw) {
|
|
99
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
100
|
+
const cap = raw.capUsd === null || raw.capUsd === undefined ? null : Number(raw.capUsd);
|
|
101
|
+
const capUsd = Number.isFinite(cap) && cap > 0 ? cap : null;
|
|
102
|
+
const spentUsd = Math.max(0, finite(raw.spentUsd));
|
|
103
|
+
const reservedUsd = Math.max(0, finite(raw.reservedUsd));
|
|
104
|
+
const ratio = raw.ratio === null || raw.ratio === undefined
|
|
105
|
+
? (capUsd ? (spentUsd + reservedUsd) / capUsd : null)
|
|
106
|
+
: finite(raw.ratio, null);
|
|
107
|
+
const state = {
|
|
108
|
+
day: String(raw.day || ''),
|
|
109
|
+
capUsd,
|
|
110
|
+
spentUsd,
|
|
111
|
+
reservedUsd,
|
|
112
|
+
estimatedUsd: Math.max(0, finite(raw.estimatedUsd)),
|
|
113
|
+
unknownPriceUsd: Math.max(0, finite(raw.unknownPriceUsd)),
|
|
114
|
+
monthUsd: Math.max(0, finite(raw.monthUsd)),
|
|
115
|
+
ratio: capUsd ? ratio : null,
|
|
116
|
+
warning: capUsd ? raw.warning === true : false,
|
|
117
|
+
reached: capUsd ? raw.reached === true : false,
|
|
118
|
+
allowedToday: raw.allowedToday === true,
|
|
119
|
+
allowedAt: raw.allowedAt ? String(raw.allowedAt) : null,
|
|
120
|
+
};
|
|
121
|
+
const conversation = raw.conversation;
|
|
122
|
+
if (conversation && typeof conversation === 'object' && !Array.isArray(conversation)) {
|
|
123
|
+
state.conversation = {
|
|
124
|
+
costUsd: Math.max(0, finite(conversation.costUsd)),
|
|
125
|
+
estimatedUsd: Math.max(0, finite(conversation.estimatedUsd)),
|
|
126
|
+
images: Math.max(0, Math.floor(finite(conversation.images))),
|
|
127
|
+
tries: Math.max(0, Math.floor(finite(conversation.tries))),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return state;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Today's spend the cap counts: booked plus in flight. */
|
|
134
|
+
export function imageSpendTodayUsd(state) {
|
|
135
|
+
return finite(state?.spentUsd) + finite(state?.reservedUsd);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** "USD 1.20 of 5.00", or "USD 1.20 (no cap)". */
|
|
139
|
+
function todayAgainstCap(state) {
|
|
140
|
+
const today = formatOpenAIImageUsd(imageSpendTodayUsd(state));
|
|
141
|
+
return state?.capUsd ? `${today} of ${bareAmount(state.capUsd)}` : `${today} (no cap)`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The time of an ISO stamp in the reader's clock ("14:05"), or ''. */
|
|
145
|
+
function clockOf(iso) {
|
|
146
|
+
const ms = Date.parse(String(iso || ''));
|
|
147
|
+
if (!Number.isFinite(ms)) return '';
|
|
148
|
+
const date = new Date(ms);
|
|
149
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Settings → Providers → OpenAI's spend line:
|
|
154
|
+
* "Today USD 1.20 of 5.00 · this month USD 7.80", with what is in flight and
|
|
155
|
+
* the day's override when there are any.
|
|
156
|
+
*/
|
|
157
|
+
export function imageSpendSettingsLine(state) {
|
|
158
|
+
if (!state) return '';
|
|
159
|
+
const parts = [`Today ${todayAgainstCap(state)}`];
|
|
160
|
+
if (state.reservedUsd > 0) parts.push(`${formatOpenAIImageUsd(state.reservedUsd)} of it in flight`);
|
|
161
|
+
parts.push(`this month ${formatOpenAIImageUsd(state.monthUsd)}`);
|
|
162
|
+
if (state.capUsd && state.allowedToday) {
|
|
163
|
+
const at = clockOf(state.allowedAt);
|
|
164
|
+
parts.push(at ? `allowed past the cap today (since ${at})` : 'allowed past the cap today');
|
|
165
|
+
} else if (state.reached) {
|
|
166
|
+
parts.push('cap reached');
|
|
167
|
+
}
|
|
168
|
+
return parts.join(' · ');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The image chat's banner: `{ kind: 'reached' | 'warning', text }`, or null
|
|
173
|
+
* when the spend is below the warning mark (or there is no cap).
|
|
174
|
+
*/
|
|
175
|
+
export function imageSpendBanner(state) {
|
|
176
|
+
if (!state || !state.capUsd) return null;
|
|
177
|
+
if (state.reached && !state.allowedToday) {
|
|
178
|
+
return {
|
|
179
|
+
kind: 'reached',
|
|
180
|
+
text: 'Daily image cap reached: image turns are refused until tomorrow (relay time) or until you allow more today in Settings → Providers → OpenAI',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (state.warning || state.reached) {
|
|
184
|
+
const allowed = state.allowedToday && state.reached ? ' (allowed past the cap today)' : '';
|
|
185
|
+
return { kind: 'warning', text: `Today's OpenAI image spend: ${todayAgainstCap(state)}${allowed}` };
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The chat menu's line: "OpenAI images: USD 0.42 in this chat (3 images)". */
|
|
191
|
+
export function imageChatCostText(conversation) {
|
|
192
|
+
if (!conversation) return '';
|
|
193
|
+
const images = Math.max(0, Math.floor(finite(conversation.images)));
|
|
194
|
+
const parts = [images === 1 ? '1 image' : `${images} images`];
|
|
195
|
+
if (finite(conversation.estimatedUsd) > 0) parts.push(`${formatOpenAIImageUsd(conversation.estimatedUsd)} of it estimated`);
|
|
196
|
+
return `OpenAI images: ${formatOpenAIImageUsd(conversation.costUsd)} in this chat (${parts.join(', ')})`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The Allow / Stop allowing buttons of the settings section: `{ allow:
|
|
201
|
+
* { shown, label }, stop: { shown } }`. Nothing without a cap; while allowed
|
|
202
|
+
* only Stop allowing.
|
|
203
|
+
*/
|
|
204
|
+
export function imageSpendOverrideButtons(state) {
|
|
205
|
+
const none = { allow: { shown: false, label: '' }, stop: { shown: false } };
|
|
206
|
+
if (!state || !state.capUsd) return none;
|
|
207
|
+
if (state.allowedToday) return { allow: { shown: false, label: '' }, stop: { shown: true } };
|
|
208
|
+
return {
|
|
209
|
+
allow: { shown: true, label: state.reached ? 'Allow for today' : 'Allow past the cap for today' },
|
|
210
|
+
stop: { shown: false },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- The cap field ---------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The cap as the field shows it, from GET /api/settings/openai's
|
|
218
|
+
* imageDailyCapUsd: the default (5) when the relay names none, "0" for no
|
|
219
|
+
* cap, else the amount ("12.5").
|
|
220
|
+
*/
|
|
221
|
+
export function imageCapInputText(capUsd) {
|
|
222
|
+
if (capUsd === undefined) return String(OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD);
|
|
223
|
+
if (capUsd === null) return '0';
|
|
224
|
+
const value = Number(capUsd);
|
|
225
|
+
if (!Number.isFinite(value) || value < 0) return String(OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD);
|
|
226
|
+
return String(value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The cap field's text as the relay takes it: `{ ok: true, capUsd }` (0: no
|
|
231
|
+
* cap) or `{ ok: false, error }`. The relay checks it again.
|
|
232
|
+
*/
|
|
233
|
+
export function parseImageCapInput(text) {
|
|
234
|
+
const value = String(text ?? '').trim();
|
|
235
|
+
if (!value) return { ok: false, error: 'Enter the daily image cap in US dollars, or 0 for no cap.' };
|
|
236
|
+
if (!/^(?:0|[1-9]\d{0,5})(?:\.\d{1,2})?$/.test(value) || Number(value) > OPENAI_IMAGE_DAILY_CAP_MAX_USD) {
|
|
237
|
+
return { ok: false, error: `The daily image cap is an amount of US dollars up to ${OPENAI_IMAGE_DAILY_CAP_MAX_USD} (at most two decimals), or 0 for no cap.` };
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, capUsd: Number(value) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- Presets ---------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** The option values a preset holds, in OPENAI_IMAGE_OPTION_KEYS order. */
|
|
245
|
+
export function imagePresetValues(preset) {
|
|
246
|
+
const values = {};
|
|
247
|
+
for (const key of OPENAI_IMAGE_OPTION_KEYS) {
|
|
248
|
+
const value = preset?.[key];
|
|
249
|
+
if (value !== undefined && value !== null && value !== '') values[key] = value;
|
|
250
|
+
}
|
|
251
|
+
return values;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const FORMAT_LABELS = Object.freeze({ png: 'PNG', webp: 'WebP', jpeg: 'JPEG' });
|
|
255
|
+
|
|
256
|
+
/** "1536x1024 · medium · 2 images · PNG · transparent background". */
|
|
257
|
+
export function describeImagePreset(preset) {
|
|
258
|
+
const values = imagePresetValues(preset);
|
|
259
|
+
const parts = [];
|
|
260
|
+
if (values.size) parts.push(values.size === 'auto' ? 'auto size' : String(values.size));
|
|
261
|
+
if (values.quality) parts.push(values.quality === 'auto' ? 'auto quality' : String(values.quality));
|
|
262
|
+
const count = Number(values.count);
|
|
263
|
+
if (Number.isInteger(count) && count >= 1) parts.push(count === 1 ? '1 image' : `${count} images`);
|
|
264
|
+
if (values.output_format) parts.push(FORMAT_LABELS[values.output_format] || String(values.output_format));
|
|
265
|
+
if (values.background) parts.push(`${values.background} background`);
|
|
266
|
+
return parts.join(' · ');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The presets list of GET/PUT/DELETE OPENAI_IMAGE_PRESETS_PATH, kept to
|
|
271
|
+
* entries with a clean name, built-ins first; the client's built-ins when
|
|
272
|
+
* the answer has none (an older relay, a failed read).
|
|
273
|
+
*/
|
|
274
|
+
export function normalizeImagePresets(answer) {
|
|
275
|
+
const list = Array.isArray(answer?.presets) ? answer.presets : null;
|
|
276
|
+
if (!list) return OPENAI_IMAGE_BUILTIN_PRESETS.map((preset) => ({ ...preset, builtIn: true }));
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
const presets = [];
|
|
279
|
+
for (const entry of list) {
|
|
280
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
281
|
+
const cleaned = cleanOpenAIImagePresetName(entry.name);
|
|
282
|
+
if (cleaned.error) continue;
|
|
283
|
+
const key = cleaned.name.toLowerCase();
|
|
284
|
+
if (seen.has(key)) continue;
|
|
285
|
+
seen.add(key);
|
|
286
|
+
presets.push({ name: cleaned.name, ...imagePresetValues(entry), builtIn: entry.builtIn === true });
|
|
287
|
+
}
|
|
288
|
+
return [...presets.filter((preset) => preset.builtIn), ...presets.filter((preset) => !preset.builtIn)];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The preset of that name (any case, any spacing), or null. */
|
|
292
|
+
export function findImagePreset(presets, name) {
|
|
293
|
+
const key = openAIImagePresetKey(String(name ?? ''));
|
|
294
|
+
if (!key || !Array.isArray(presets)) return null;
|
|
295
|
+
return presets.find((preset) => String(preset?.name || '').toLowerCase() === key) || null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* What picking `preset` sets in the composer for `model`: `{ values, skipped }`.
|
|
300
|
+
* `values` holds what the model's selects take (quality only when
|
|
301
|
+
* `qualities` lists it: the Quality select's options); `skipped` names the
|
|
302
|
+
* preset's fields the model does not take ("size 1536x1024"), which keep
|
|
303
|
+
* what the composer shows.
|
|
304
|
+
*/
|
|
305
|
+
export function imagePresetComposerValues(preset, model = '', { qualities = null, currentFormat = '' } = {}) {
|
|
306
|
+
const choices = imageComposerChoices(model);
|
|
307
|
+
const values = {};
|
|
308
|
+
const skipped = [];
|
|
309
|
+
if (!choices) return { values, skipped };
|
|
310
|
+
for (const [key, value] of Object.entries(imagePresetValues(preset))) {
|
|
311
|
+
const checked = validateOpenAIImageOptions({ [key]: value }, { model });
|
|
312
|
+
const clean = checked.ok ? checked.values[key] : undefined;
|
|
313
|
+
const offered = key !== 'quality' || !Array.isArray(qualities) || qualities.includes(clean);
|
|
314
|
+
if (clean === undefined || !offered) {
|
|
315
|
+
skipped.push(`${key === 'output_format' ? 'format' : key} ${value}`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
values[key] = clean;
|
|
319
|
+
}
|
|
320
|
+
// A transparent background needs PNG or WebP: with JPEG on screen and no
|
|
321
|
+
// format in the preset, PNG goes with it.
|
|
322
|
+
if (values.background === 'transparent' && !values.output_format && String(currentFormat || '').toLowerCase() === 'jpeg') {
|
|
323
|
+
values.output_format = 'png';
|
|
324
|
+
}
|
|
325
|
+
return { values, skipped };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A save refused because an own preset has that name (the relay's words). */
|
|
329
|
+
export function isImagePresetExistsRefusal(error) {
|
|
330
|
+
return /\bexists\b/i.test(String(error?.message ?? error ?? ''));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The preset a save would write from the composer's checked `image` (the
|
|
335
|
+
* send's values) under `name`: `{ ok: true, preset }` or `{ ok: false,
|
|
336
|
+
* error }` for a name the relay would refuse (a built-in's included).
|
|
337
|
+
*/
|
|
338
|
+
export function buildImagePresetToSave(name, image = {}) {
|
|
339
|
+
const cleaned = cleanOpenAIImagePresetName(String(name ?? ''));
|
|
340
|
+
if (cleaned.error) return { ok: false, error: `${cleaned.error[0].toUpperCase()}${cleaned.error.slice(1)}.` };
|
|
341
|
+
const key = cleaned.name.toLowerCase();
|
|
342
|
+
if (OPENAI_IMAGE_BUILTIN_PRESETS.some((preset) => preset.name.toLowerCase() === key)) {
|
|
343
|
+
return { ok: false, error: `"${cleaned.name}" is a built-in preset; pick another name.` };
|
|
344
|
+
}
|
|
345
|
+
const values = imagePresetValues(image);
|
|
346
|
+
if (!Object.keys(values).length) return { ok: false, error: 'Nothing to save: pick a size or a quality first.' };
|
|
347
|
+
return { ok: true, preset: { name: cleaned.name, ...values } };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- The Check Usage card ---------------------------------------------------
|
|
351
|
+
//
|
|
352
|
+
// The relay's "OpenAI images" card of GET /api/usage (provider 'openai')
|
|
353
|
+
// carries the spend state as `imageSpend`: plan-usage-view.mjs draws its
|
|
354
|
+
// meter, figures and badges from it with these, so a live state (the socket
|
|
355
|
+
// event) redraws the whole card.
|
|
356
|
+
|
|
357
|
+
function usageColor(ratio) {
|
|
358
|
+
if (ratio === null) return '#6e7681';
|
|
359
|
+
if (ratio >= 1) return '#f85149';
|
|
360
|
+
if (ratio >= OPENAI_IMAGE_CAP_WARN_RATIO) return '#e3b341';
|
|
361
|
+
if (ratio >= 0.5) return '#d29922';
|
|
362
|
+
return '#3fb950';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The figures of the card: `[{ id, label, value, hint? }]`. */
|
|
366
|
+
export function imageUsageCardFacts(state) {
|
|
367
|
+
if (!state) return [];
|
|
368
|
+
const facts = [
|
|
369
|
+
{ id: 'openai-images-month', label: 'This month', value: formatOpenAIImageUsd(state.monthUsd) },
|
|
370
|
+
{ id: 'openai-images-cap', label: 'Daily cap', value: state.capUsd ? formatOpenAIImageUsd(state.capUsd) : 'No cap' },
|
|
371
|
+
];
|
|
372
|
+
if (state.reservedUsd > 0) {
|
|
373
|
+
facts.push({ id: 'openai-images-reserved', label: 'In progress', value: formatOpenAIImageUsd(state.reservedUsd), hint: 'tries running now, counted toward the cap' });
|
|
374
|
+
}
|
|
375
|
+
if (state.estimatedUsd > 0) {
|
|
376
|
+
facts.push({ id: 'openai-images-estimated', label: 'Estimated', value: formatOpenAIImageUsd(state.estimatedUsd), hint: 'part of today: tries that may have been billed without a usage report' });
|
|
377
|
+
}
|
|
378
|
+
if (state.unknownPriceUsd > 0) {
|
|
379
|
+
facts.push({ id: 'openai-images-unknown-price', label: 'Unknown price', value: formatOpenAIImageUsd(state.unknownPriceUsd), hint: 'part of today: a model the price table lacks, booked at a stand-in price' });
|
|
380
|
+
}
|
|
381
|
+
if (state.capUsd) {
|
|
382
|
+
const at = clockOf(state.allowedAt);
|
|
383
|
+
facts.push({
|
|
384
|
+
id: 'openai-images-override',
|
|
385
|
+
label: 'Past the cap today',
|
|
386
|
+
value: state.allowedToday ? (at ? `Allowed since ${at}` : 'Allowed') : 'Not allowed',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return facts;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The card's badges: the cap reached, or allowed past it today. */
|
|
393
|
+
export function imageUsageBadgesHtml(state) {
|
|
394
|
+
if (!state?.capUsd) return '';
|
|
395
|
+
if (state.allowedToday) return '<span class="plan-usage-badge" data-testid="openai-image-usage-allowed">Allowed today</span>';
|
|
396
|
+
if (state.reached) return '<span class="plan-usage-badge plan-usage-badge-status" data-testid="openai-image-usage-reached">Cap reached</span>';
|
|
397
|
+
return '';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The card's message, from the state: the cap reached (and whether today
|
|
402
|
+
* is allowed), the warning share, or no cap.
|
|
403
|
+
*/
|
|
404
|
+
export function imageUsageCardMessage(state) {
|
|
405
|
+
if (!state) return '';
|
|
406
|
+
if (!state.capUsd) return 'No daily cap is set for OpenAI images on this relay.';
|
|
407
|
+
if (state.reached && state.allowedToday) return 'Today\'s image spend has reached the daily cap; image turns run anyway, allowed for today in Settings → Providers → OpenAI.';
|
|
408
|
+
const banner = imageSpendBanner(state);
|
|
409
|
+
return banner ? `${banner.text}.` : '';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Today against the cap: the bar with the 80 % mark, "USD 1.20 of 5.00 ·
|
|
414
|
+
* resets at midnight (relay time)"; without a cap the amount alone.
|
|
415
|
+
*/
|
|
416
|
+
export function renderImageUsageMeterHtml(state) {
|
|
417
|
+
if (!state) return '';
|
|
418
|
+
const today = imageSpendTodayUsd(state);
|
|
419
|
+
const ratio = state.capUsd ? today / state.capUsd : null;
|
|
420
|
+
const percentLabel = ratio === null ? '—' : `${Math.round(ratio * 100)}%`;
|
|
421
|
+
const width = ratio === null ? 0 : Math.max(0, Math.min(100, ratio * 100));
|
|
422
|
+
const markLeft = Math.round(OPENAI_IMAGE_CAP_WARN_RATIO * 100);
|
|
423
|
+
const meta = state.capUsd
|
|
424
|
+
? `${formatOpenAIImageUsd(today)} of ${bareAmount(state.capUsd)} · resets at midnight (relay time)`
|
|
425
|
+
: `${formatOpenAIImageUsd(today)} today · no cap`;
|
|
426
|
+
const flags = state.estimatedUsd > 0 ? '<span class="plan-usage-flag">estimated</span>' : '';
|
|
427
|
+
const bar = state.capUsd
|
|
428
|
+
? `<div class="plan-usage-bar plan-usage-bar-marked" role="img" aria-label="${escapeHtml(`Today: ${percentLabel} of the daily cap`)}">
|
|
429
|
+
<span class="plan-usage-bar-fill" style="width:${width.toFixed(1)}%;background:${usageColor(ratio)}"></span>
|
|
430
|
+
<span class="plan-usage-bar-mark" style="left:${markLeft}%" title="${markLeft}%: the warning mark" aria-hidden="true"></span>
|
|
431
|
+
</div>`
|
|
432
|
+
: '';
|
|
433
|
+
return `
|
|
434
|
+
<div class="plan-usage-meter plan-usage-meter-primary" data-meter-id="openai-images-daily-cap" data-testid="openai-image-usage-meter">
|
|
435
|
+
<div class="plan-usage-meter-head">
|
|
436
|
+
<span class="plan-usage-meter-label">Today${flags}</span>
|
|
437
|
+
<span class="plan-usage-meter-pct">${escapeHtml(percentLabel)}</span>
|
|
438
|
+
</div>
|
|
439
|
+
${bar}
|
|
440
|
+
<div class="plan-usage-meter-meta" data-testid="openai-image-usage-today">${escapeHtml(meta)}</div>
|
|
441
|
+
</div>
|
|
442
|
+
`;
|
|
443
|
+
}
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { escapeHtml } from './context-usage-view.mjs';
|
|
12
|
+
import {
|
|
13
|
+
imageUsageBadgesHtml,
|
|
14
|
+
imageUsageCardFacts,
|
|
15
|
+
imageUsageCardMessage,
|
|
16
|
+
normalizeImageSpendState,
|
|
17
|
+
renderImageUsageMeterHtml,
|
|
18
|
+
} from './openai-image-spend.mjs';
|
|
12
19
|
|
|
13
20
|
// Null-prototype so an unrecognized `status`/`source` can only ever miss —
|
|
14
21
|
// a plain literal would resolve keys like `constructor` off the prototype and
|
|
@@ -45,6 +52,8 @@ const PROVIDER_ALIASES = Object.freeze(Object.assign(Object.create(null), {
|
|
|
45
52
|
copilot: 'github',
|
|
46
53
|
'github-copilot': 'github',
|
|
47
54
|
'copilot-github': 'github',
|
|
55
|
+
// An image chat's provider: its spend is the 'openai' card's.
|
|
56
|
+
'openai-image': 'openai',
|
|
48
57
|
}));
|
|
49
58
|
|
|
50
59
|
/** @returns {string} the canonical usage-card id for a provider spelling. */
|
|
@@ -383,15 +392,28 @@ function renderAccountLine(account) {
|
|
|
383
392
|
return `<span class="plan-usage-account">${parts.map(escapeHtml).join(' · ')}</span>`;
|
|
384
393
|
}
|
|
385
394
|
|
|
395
|
+
// The figures of the OpenAI images card the spend state draws (the relay's
|
|
396
|
+
// own ones of the same things give way).
|
|
397
|
+
const IMAGE_SPEND_FACT_IDS = new Set(['openai-images-today', 'openai-images-month', 'openai-images-override']);
|
|
398
|
+
|
|
386
399
|
function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
|
|
387
|
-
|
|
400
|
+
// OpenAI images: the spend state (`imageSpend`, live from the socket
|
|
401
|
+
// event too) draws the meter with the cap's 80 % mark, the figures, the
|
|
402
|
+
// badges and the message, in "USD 1.23".
|
|
403
|
+
const imageSpend = card.imageSpend ? normalizeImageSpendState(card.imageSpend) : null;
|
|
404
|
+
const meters = (Array.isArray(card.meters) ? card.meters : [])
|
|
405
|
+
.filter((meter) => !imageSpend || meter?.id !== 'openai-images-daily-cap');
|
|
388
406
|
const charts = Array.isArray(card.charts) ? card.charts.filter(Boolean) : [];
|
|
389
407
|
const details = Array.isArray(card.details) ? card.details : [];
|
|
390
|
-
const allNotes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : []
|
|
408
|
+
const allNotes = (Array.isArray(card.notes) ? card.notes.filter(Boolean) : [])
|
|
409
|
+
.filter((note) => !imageSpend || !IMAGE_SPEND_FACT_IDS.has(note?.id));
|
|
391
410
|
// Figures go in one list under the bars; sentences stay notes, and on a
|
|
392
411
|
// card with figures they come last, after the tables, so the numbers read
|
|
393
412
|
// first.
|
|
394
|
-
const facts =
|
|
413
|
+
const facts = [
|
|
414
|
+
...(imageSpend ? imageUsageCardFacts(imageSpend) : []),
|
|
415
|
+
...allNotes.filter(isFactNote),
|
|
416
|
+
];
|
|
395
417
|
const notes = allNotes.filter((note) => !isFactNote(note));
|
|
396
418
|
const notesHtml = notes.map((note) => renderNote(note, { now, timeZone })).join('');
|
|
397
419
|
const detailsHtml = details.map(renderDetailSection).join('');
|
|
@@ -400,6 +422,7 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
|
|
|
400
422
|
const statusBadge = STATUS_BADGES[card.status];
|
|
401
423
|
const sourceBadge = SOURCE_LABELS[card.source];
|
|
402
424
|
const badges = [
|
|
425
|
+
imageSpend ? imageUsageBadgesHtml(imageSpend) : '',
|
|
403
426
|
card.planName ? `<span class="plan-usage-badge plan-usage-badge-plan">${escapeHtml(card.planName)}</span>` : '',
|
|
404
427
|
statusBadge ? `<span class="plan-usage-badge plan-usage-badge-status">${escapeHtml(statusBadge)}</span>` : '',
|
|
405
428
|
sourceBadge ? `<span class="plan-usage-badge">${escapeHtml(sourceBadge)}</span>` : '',
|
|
@@ -408,13 +431,14 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
|
|
|
408
431
|
const primary = meters.filter((meter) => meter.emphasis !== 'secondary');
|
|
409
432
|
const secondary = meters.filter((meter) => meter.emphasis === 'secondary');
|
|
410
433
|
|
|
411
|
-
const metersHtml = meters.length
|
|
434
|
+
const metersHtml = `${imageSpend ? renderImageUsageMeterHtml(imageSpend) : ''}${meters.length
|
|
412
435
|
? `${primary.map((meter) => renderMeter(meter, now)).join('')}${
|
|
413
436
|
secondary.length
|
|
414
437
|
? `<div class="plan-usage-secondary">${secondary.map((meter) => renderMeter(meter, now)).join('')}</div>`
|
|
415
438
|
: ''
|
|
416
439
|
}`
|
|
417
|
-
: ''
|
|
440
|
+
: ''}`;
|
|
441
|
+
const message = imageSpend ? imageUsageCardMessage(imageSpend) : card.message;
|
|
418
442
|
|
|
419
443
|
const capturedLabel = card.capturedAt
|
|
420
444
|
? `Updated ${escapeHtml(String(card.capturedAt).replace('T', ' ').slice(0, 16))} UTC`
|
|
@@ -430,7 +454,7 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
|
|
|
430
454
|
</span>
|
|
431
455
|
<span class="plan-usage-badges">${badges}</span>
|
|
432
456
|
</header>
|
|
433
|
-
${
|
|
457
|
+
${message ? `<div class="plan-usage-message">${escapeHtml(message)}</div>` : ''}
|
|
434
458
|
${metersHtml}
|
|
435
459
|
${facts.length ? `<dl class="plan-usage-facts">${facts.map(renderFact).join('')}</dl>` : ''}
|
|
436
460
|
${charts.map(renderChart).join('')}
|
|
@@ -443,6 +467,14 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
|
|
|
443
467
|
`;
|
|
444
468
|
}
|
|
445
469
|
|
|
470
|
+
/**
|
|
471
|
+
* One card alone, for a live redraw in the open modal (the OpenAI images
|
|
472
|
+
* card on a new spend state).
|
|
473
|
+
*/
|
|
474
|
+
export function renderPlanUsageCardHtml(card, { now = new Date(), active = true, timeZone = undefined } = {}) {
|
|
475
|
+
return card ? renderCard(card, now, { active, timeZone }) : '';
|
|
476
|
+
}
|
|
477
|
+
|
|
446
478
|
function renderTab(card, activeProvider) {
|
|
447
479
|
const id = normalizeUsageProvider(card.provider);
|
|
448
480
|
const active = id === activeProvider;
|
|
@@ -14,9 +14,47 @@
|
|
|
14
14
|
// A payload that failed to load is `null`, and makes the whole answer
|
|
15
15
|
// `known: false`. Callers treat an unknown answer as ready, which is today's
|
|
16
16
|
// behaviour: a network blip must never show the setup wall.
|
|
17
|
+
//
|
|
18
|
+
// The agent SDK snapshot (GET /api/agent-sdks/status) is optional. With it, a
|
|
19
|
+
// provider whose SDK failed to install or has no build for this host is not
|
|
20
|
+
// ready, with the reason in `reasons`. A missing or installing SDK does not
|
|
21
|
+
// block: the relay installs it on the first launch and keeps the turn queued,
|
|
22
|
+
// so the provider stays ready and only gets a note.
|
|
17
23
|
|
|
18
24
|
export const SEED_CATALOG_SOURCE = 'bootstrap-seed';
|
|
19
25
|
|
|
26
|
+
// The SDK each provider's workers run on (session-worker-launch-service.mjs).
|
|
27
|
+
const PROVIDER_AGENT_SDKS = Object.freeze({
|
|
28
|
+
claude: 'claude',
|
|
29
|
+
'claude-api': 'claude',
|
|
30
|
+
deepseek: 'claude',
|
|
31
|
+
cursor: 'cursor',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a provider's SDK keeps it from running, or what it waits for: `null`
|
|
36
|
+
* when the SDK is ready or its state is not one we know.
|
|
37
|
+
* @returns {{ blocking: boolean, text: string } | null}
|
|
38
|
+
*/
|
|
39
|
+
export function agentSdkReadinessReason(status) {
|
|
40
|
+
if (!status || typeof status !== 'object') return null;
|
|
41
|
+
const label = String(status.label || '').trim() || 'The agent SDK';
|
|
42
|
+
switch (status.state) {
|
|
43
|
+
case 'missing':
|
|
44
|
+
return { blocking: false, text: `${label} not installed yet — it installs with the first chat or from Settings` };
|
|
45
|
+
case 'installing':
|
|
46
|
+
return { blocking: false, text: `${label} is installing — the first chat waits for it` };
|
|
47
|
+
case 'error': {
|
|
48
|
+
const error = String(status.error || '').trim();
|
|
49
|
+
return { blocking: true, text: error ? `${label} install failed: ${error}` : `${label} install failed` };
|
|
50
|
+
}
|
|
51
|
+
case 'unsupported':
|
|
52
|
+
return { blocking: true, text: `${label} not available on this platform` };
|
|
53
|
+
default:
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
20
58
|
const SETTINGS_PROVIDERS = Object.freeze([
|
|
21
59
|
['openai', 'openai'],
|
|
22
60
|
['claude', 'claude'],
|
|
@@ -40,21 +78,43 @@ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'ca
|
|
|
40
78
|
* @param {object|null} [input.grok]
|
|
41
79
|
* @param {object|null} [input.deepseek] null on a relay without DeepSeek: not offered, not unknown
|
|
42
80
|
* @param {object|null} [input.catalog]
|
|
43
|
-
* @
|
|
81
|
+
* @param {object|null} [input.agentSdks] GET /api/agent-sdks/status; absent or null changes nothing
|
|
82
|
+
* @returns {{ known: boolean, anyReady: boolean, ready: string[], copilot: 'ready' | 'unknown', reasons?: Record<string, { blocking: boolean, text: string }> }}
|
|
83
|
+
* `reasons` only with an agent SDK snapshot: by enabled provider whose SDK is not ready
|
|
44
84
|
*/
|
|
45
85
|
export function resolveProviderReadiness(input = {}) {
|
|
46
86
|
const payloads = input && typeof input === 'object' ? input : {};
|
|
47
87
|
const known = REQUIRED_INPUTS.every((key) => payloads[key] && typeof payloads[key] === 'object');
|
|
88
|
+
const sdks = payloads.agentSdks && typeof payloads.agentSdks === 'object' && payloads.agentSdks.sdks && typeof payloads.agentSdks.sdks === 'object'
|
|
89
|
+
? payloads.agentSdks.sdks
|
|
90
|
+
: null;
|
|
48
91
|
const ready = [];
|
|
92
|
+
const reasons = {};
|
|
49
93
|
for (const [key, providerId] of SETTINGS_PROVIDERS) {
|
|
50
|
-
if (payloads[key]?.enabled
|
|
94
|
+
if (payloads[key]?.enabled !== true) continue;
|
|
95
|
+
const reason = sdks && PROVIDER_AGENT_SDKS[providerId]
|
|
96
|
+
? agentSdkReadinessReason(sdks[PROVIDER_AGENT_SDKS[providerId]])
|
|
97
|
+
: null;
|
|
98
|
+
if (reason) reasons[providerId] = reason;
|
|
99
|
+
if (!reason?.blocking) ready.push(providerId);
|
|
51
100
|
}
|
|
52
101
|
const source = String(payloads.catalog?.source || '').trim();
|
|
53
102
|
const copilot = payloads.catalog && typeof payloads.catalog === 'object' && source && source !== SEED_CATALOG_SOURCE
|
|
54
103
|
? 'ready'
|
|
55
104
|
: 'unknown';
|
|
56
105
|
if (copilot === 'ready') ready.push('github');
|
|
57
|
-
|
|
106
|
+
const answer = { known, anyReady: ready.length > 0, ready, copilot };
|
|
107
|
+
if (sdks) answer.reasons = reasons;
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The blocking reasons of a readiness answer, each once (three providers share Claude's SDK). */
|
|
112
|
+
export function blockingReadinessReasons(readiness) {
|
|
113
|
+
const texts = Object.values(readiness?.reasons || {})
|
|
114
|
+
.filter((reason) => reason?.blocking === true)
|
|
115
|
+
.map((reason) => String(reason.text || '').trim())
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
return [...new Set(texts)];
|
|
58
118
|
}
|
|
59
119
|
|
|
60
120
|
/** The one question the empty screen, New Chat and the composer ask. */
|
|
@@ -76,12 +136,15 @@ const inputs = {
|
|
|
76
136
|
grok: undefined,
|
|
77
137
|
deepseek: undefined,
|
|
78
138
|
catalog: undefined,
|
|
139
|
+
agentSdks: undefined,
|
|
79
140
|
};
|
|
80
141
|
let current = resolveProviderReadiness(inputs);
|
|
81
142
|
const listeners = new Set();
|
|
82
143
|
|
|
83
144
|
function readinessKey(readiness) {
|
|
84
|
-
|
|
145
|
+
// Log flushes of an install leave the reasons as they were: no event.
|
|
146
|
+
const reasons = readiness.reasons ? JSON.stringify(readiness.reasons) : '';
|
|
147
|
+
return `${readiness.known ? 1 : 0}|${readiness.anyReady ? 1 : 0}|${readiness.ready.join(',')}|${readiness.copilot}|${reasons}`;
|
|
85
148
|
}
|
|
86
149
|
|
|
87
150
|
/**
|
|
@@ -61,6 +61,11 @@ const RELAY_ERROR_CTAS = Object.freeze({
|
|
|
61
61
|
Object.freeze({ action: 'open-claude-api-cap', label: 'Claude API settings' }),
|
|
62
62
|
SWITCH_TO_SUBSCRIPTION_CTA,
|
|
63
63
|
]),
|
|
64
|
+
// An image turn the daily OpenAI image cap stopped before its try: the
|
|
65
|
+
// cap field and "Allow for today" are on the OpenAI tab.
|
|
66
|
+
'openai-image-daily-cap-reached': Object.freeze([
|
|
67
|
+
Object.freeze({ action: 'open-openai-image-cap', label: 'OpenAI image cap' }),
|
|
68
|
+
]),
|
|
64
69
|
// A turn the Claude API refused (claude-api-backend.mjs, describeClaudeApiFailure).
|
|
65
70
|
'claude-api-key': CLAUDE_API_SETTINGS_CTA,
|
|
66
71
|
'claude-api-key-source': CLAUDE_API_SETTINGS_CTA,
|