@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,291 @@
|
|
|
1
|
+
// What an OpenAI image request costs: the price of the usage OpenAI reported,
|
|
2
|
+
// and an estimate before (or instead of) one, for the daily cap
|
|
3
|
+
// (shared/openai-image-spend.mjs) and the reply line.
|
|
4
|
+
//
|
|
5
|
+
// - A model is looked up by its bare lower-case id, then with a dated
|
|
6
|
+
// snapshot suffix (-YYYY-MM-DD) stripped. There is no family fallback.
|
|
7
|
+
// - A model the table lacks gets the stand-in: never a missing cost (a cost
|
|
8
|
+
// the cap cannot add up is no cost to it), always `costUnknown: true`.
|
|
9
|
+
// - `auto` quality is the model's dearest quality. `auto` size (and a size
|
|
10
|
+
// that cannot be read) counts as 1536x1024: OpenAI says what `auto` costs
|
|
11
|
+
// depends on the image it makes, and the landscape size is the one the
|
|
12
|
+
// relay's composer offers first.
|
|
13
|
+
// - Partial images are added to estimates only (100 image output tokens
|
|
14
|
+
// each); a reported usage is priced as it is.
|
|
15
|
+
//
|
|
16
|
+
// SDK-free and side-effect free.
|
|
17
|
+
|
|
18
|
+
import { OPENAI_IMAGE_COST_SOURCES } from './openai-image-spend.mjs';
|
|
19
|
+
|
|
20
|
+
// The per-image cost of a size OpenAI chooses (`auto`), or of a size the
|
|
21
|
+
// estimate cannot read.
|
|
22
|
+
export const OPENAI_IMAGE_AUTO_SIZE = '1536x1024';
|
|
23
|
+
// "each partial image will incur an additional 100 image output tokens"
|
|
24
|
+
// (developers.openai.com/api/docs/guides/image-generation, "Partial images
|
|
25
|
+
// cost", l.3673; read 2026-11-08).
|
|
26
|
+
export const OPENAI_IMAGE_PARTIAL_OUTPUT_TOKENS = 100;
|
|
27
|
+
// The input an estimate allows for, besides the images: the prompt as text
|
|
28
|
+
// tokens, and each input image (edits) as image input tokens. Assumptions,
|
|
29
|
+
// not published figures: OpenAI gives no input token counts for these
|
|
30
|
+
// models; the guide says only that gpt-image-2 reads input images at high
|
|
31
|
+
// fidelity, so edits "can use more input tokens" (l.3764-3765, 3788).
|
|
32
|
+
export const OPENAI_IMAGE_ESTIMATE_PROMPT_TOKENS = 500;
|
|
33
|
+
export const OPENAI_IMAGE_ESTIMATE_INPUT_IMAGE_TOKENS = 4000;
|
|
34
|
+
|
|
35
|
+
const PER_MILLION = 1_000_000;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// TABLE. Each entry names its source and the day it was read. Rates are USD
|
|
39
|
+
// per 1M tokens: text input, image input, image output. Standard rates only:
|
|
40
|
+
// cached input rates apply to the Responses API's image tool alone, "not ...
|
|
41
|
+
// direct Images API requests, including /v1/images/edits" (guide l.3644), and
|
|
42
|
+
// cached counts are not in `usage` (l.3646).
|
|
43
|
+
//
|
|
44
|
+
// `outputTokens`: the guide's token calculator for the 2.x models, computed
|
|
45
|
+
// for any size: base = per quality; d = base / (long edge / short edge),
|
|
46
|
+
// rounded half to even; tokens = ceil(base * d * (2e6 + w * h) / 4e6); for
|
|
47
|
+
// 2.6 that times a per-quality factor, rounded. The calculator "Excludes text
|
|
48
|
+
// and image input tokens and streaming partial images." Source:
|
|
49
|
+
// https://developers.openai.com/_astro/GptImageTokenCalculator.react.eSCKNoob.js
|
|
50
|
+
// (read 2026-11-08); its gpt-image-2 figures match the guide's published
|
|
51
|
+
// per-image table (l.3840-3854).
|
|
52
|
+
//
|
|
53
|
+
// `perImageUsd`: the published per-image prices of the older models, for
|
|
54
|
+
// their three sizes (guide "Earlier GPT Image models", l.3858-3921, and the
|
|
55
|
+
// model pages; read 2026-11-08).
|
|
56
|
+
//
|
|
57
|
+
// dall-e-3 and dall-e-2 have no entry: OpenAI lists no price for them (both
|
|
58
|
+
// were shut down 2026-05-12, developers.openai.com/api/docs/deprecations);
|
|
59
|
+
// they get the stand-in.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const GPT_IMAGE_2X_BASE = Object.freeze({ low: 16, medium: 24, high: 48, xhigh: 64, max: 96 });
|
|
63
|
+
|
|
64
|
+
// Rates: developers.openai.com/api/docs/pricing (#image-generation) and the
|
|
65
|
+
// model pages, read 2026-11-08. Output tokens: the calculator (above).
|
|
66
|
+
const GPT_IMAGE_2_6 = Object.freeze({
|
|
67
|
+
rates: Object.freeze({ textInput: 5, imageInput: 8, imageOutput: 30 }),
|
|
68
|
+
outputTokens: Object.freeze({
|
|
69
|
+
base: GPT_IMAGE_2X_BASE,
|
|
70
|
+
factor: Object.freeze({ low: 4.2, medium: 3.3, high: 1, xhigh: 1, max: 1 }),
|
|
71
|
+
}),
|
|
72
|
+
qualities: Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Rates: the pricing page and the guide (l.3638: "Both GPT Image 2.5 models
|
|
76
|
+
// use the same token rates"), read 2026-11-08. Output tokens: the calculator.
|
|
77
|
+
const GPT_IMAGE_2_5 = Object.freeze({
|
|
78
|
+
rates: Object.freeze({ textInput: 5, imageInput: 8, imageOutput: 30 }),
|
|
79
|
+
outputTokens: Object.freeze({ base: GPT_IMAGE_2X_BASE }),
|
|
80
|
+
qualities: Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const OPENAI_IMAGE_PRICE_TABLE = Object.freeze({
|
|
84
|
+
'gpt-image-2.6-sunburst': GPT_IMAGE_2_6,
|
|
85
|
+
'gpt-image-2.6-flare': GPT_IMAGE_2_6,
|
|
86
|
+
'gpt-image-2.5-sunburst': GPT_IMAGE_2_5,
|
|
87
|
+
'gpt-image-2.5-flare': GPT_IMAGE_2_5,
|
|
88
|
+
// Rates: the pricing page and the model page, read 2026-11-08. Output
|
|
89
|
+
// tokens: the calculator (= the guide's table, l.3840-3854).
|
|
90
|
+
'gpt-image-2': Object.freeze({
|
|
91
|
+
rates: Object.freeze({ textInput: 5, imageInput: 8, imageOutput: 30 }),
|
|
92
|
+
outputTokens: Object.freeze({ base: Object.freeze({ low: 16, medium: 48, high: 96 }) }),
|
|
93
|
+
qualities: Object.freeze(['low', 'medium', 'high']),
|
|
94
|
+
}),
|
|
95
|
+
// Rates: the pricing page and the model page (deprecated, off
|
|
96
|
+
// 2026-12-01), read 2026-11-08. Per image: guide l.3858-3921.
|
|
97
|
+
'gpt-image-1.5': Object.freeze({
|
|
98
|
+
rates: Object.freeze({ textInput: 5, imageInput: 8, imageOutput: 32 }),
|
|
99
|
+
perImageUsd: Object.freeze({
|
|
100
|
+
low: Object.freeze({ '1024x1024': 0.009, '1024x1536': 0.013, '1536x1024': 0.013 }),
|
|
101
|
+
medium: Object.freeze({ '1024x1024': 0.034, '1024x1536': 0.05, '1536x1024': 0.05 }),
|
|
102
|
+
high: Object.freeze({ '1024x1024': 0.133, '1024x1536': 0.2, '1536x1024': 0.2 }),
|
|
103
|
+
}),
|
|
104
|
+
qualities: Object.freeze(['low', 'medium', 'high']),
|
|
105
|
+
}),
|
|
106
|
+
// Rates: the pricing page and the model page (deprecated, off
|
|
107
|
+
// 2026-12-01), read 2026-11-08. Per image: guide l.3858-3921.
|
|
108
|
+
'gpt-image-1-mini': Object.freeze({
|
|
109
|
+
rates: Object.freeze({ textInput: 2, imageInput: 2.5, imageOutput: 8 }),
|
|
110
|
+
perImageUsd: Object.freeze({
|
|
111
|
+
low: Object.freeze({ '1024x1024': 0.005, '1024x1536': 0.006, '1536x1024': 0.006 }),
|
|
112
|
+
medium: Object.freeze({ '1024x1024': 0.011, '1024x1536': 0.015, '1536x1024': 0.015 }),
|
|
113
|
+
high: Object.freeze({ '1024x1024': 0.036, '1024x1536': 0.052, '1536x1024': 0.052 }),
|
|
114
|
+
}),
|
|
115
|
+
qualities: Object.freeze(['low', 'medium', 'high']),
|
|
116
|
+
}),
|
|
117
|
+
// Rates: the model page (shut down 2026-10-23), read 2026-11-08. Per
|
|
118
|
+
// image: guide l.3858-3921.
|
|
119
|
+
'gpt-image-1': Object.freeze({
|
|
120
|
+
rates: Object.freeze({ textInput: 5, imageInput: 10, imageOutput: 40 }),
|
|
121
|
+
perImageUsd: Object.freeze({
|
|
122
|
+
low: Object.freeze({ '1024x1024': 0.011, '1024x1536': 0.016, '1536x1024': 0.016 }),
|
|
123
|
+
medium: Object.freeze({ '1024x1024': 0.042, '1024x1536': 0.063, '1536x1024': 0.063 }),
|
|
124
|
+
high: Object.freeze({ '1024x1024': 0.167, '1024x1536': 0.25, '1536x1024': 0.25 }),
|
|
125
|
+
}),
|
|
126
|
+
qualities: Object.freeze(['low', 'medium', 'high']),
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
// END OF TABLE
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
// The per-image stand-in for a model the table lacks: the dearest of every
|
|
133
|
+
// model the table prices, at the quality and size asked for (a model's
|
|
134
|
+
// dearest quality where it lacks the one asked for), so an unknown model is
|
|
135
|
+
// never estimated below a known one.
|
|
136
|
+
const STAND_IN_PER_IMAGE_MODELS = Object.freeze(Object.keys(OPENAI_IMAGE_PRICE_TABLE));
|
|
137
|
+
|
|
138
|
+
// The token rates of the stand-in: the dearest rate of each kind the table
|
|
139
|
+
// has, so a usage of an unknown model is never priced low.
|
|
140
|
+
const STAND_IN_RATES = Object.freeze(Object.fromEntries(
|
|
141
|
+
['textInput', 'imageInput', 'imageOutput'].map((key) => [
|
|
142
|
+
key,
|
|
143
|
+
Math.max(...Object.values(OPENAI_IMAGE_PRICE_TABLE).map((entry) => entry.rates[key])),
|
|
144
|
+
]),
|
|
145
|
+
));
|
|
146
|
+
|
|
147
|
+
/** The models the table prices, for tests and the settings view. */
|
|
148
|
+
export const OPENAI_IMAGE_PRICED_MODELS = Object.freeze(Object.keys(OPENAI_IMAGE_PRICE_TABLE));
|
|
149
|
+
|
|
150
|
+
/** The id an image turn books under: bare (no `openai/`), lower-case. */
|
|
151
|
+
export function openAIImageBookingModel(model) {
|
|
152
|
+
return String(model || '').trim().toLowerCase().replace(/^openai\//, '');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The table entry of a model: its exact id, else the id without a dated snapshot suffix; or null. */
|
|
156
|
+
export function findOpenAIImagePriceEntry(model) {
|
|
157
|
+
const id = openAIImageBookingModel(model);
|
|
158
|
+
if (!id) return null;
|
|
159
|
+
if (Object.hasOwn(OPENAI_IMAGE_PRICE_TABLE, id)) return OPENAI_IMAGE_PRICE_TABLE[id];
|
|
160
|
+
const bare = id.replace(/-\d{4}-\d{2}-\d{2}$/, '');
|
|
161
|
+
return bare !== id && Object.hasOwn(OPENAI_IMAGE_PRICE_TABLE, bare) ? OPENAI_IMAGE_PRICE_TABLE[bare] : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function roundUsd(value) {
|
|
165
|
+
return Math.round(value * 1e9) / 1e9;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sizeOf(size) {
|
|
169
|
+
const match = /^\s*(\d{1,5})\s*[x×]\s*(\d{1,5})\s*$/i.exec(String(size ?? ''));
|
|
170
|
+
const text = match ? `${Number(match[1])}x${Number(match[2])}` : OPENAI_IMAGE_AUTO_SIZE;
|
|
171
|
+
const [width, height] = text.split('x').map(Number);
|
|
172
|
+
return width > 0 && height > 0 ? { width, height, text } : { width: 1536, height: 1024, text: OPENAI_IMAGE_AUTO_SIZE };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The quality an entry is priced at: the one asked for when it has it, else
|
|
176
|
+
// (`auto`, empty, or one it does not know) its dearest.
|
|
177
|
+
function qualityFor(entry, quality) {
|
|
178
|
+
const wanted = String(quality || '').trim().toLowerCase();
|
|
179
|
+
return entry.qualities.includes(wanted) ? wanted : entry.qualities[entry.qualities.length - 1];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function roundHalfEven(value) {
|
|
183
|
+
const floor = Math.floor(value);
|
|
184
|
+
return value - floor === 0.5 ? floor + (floor % 2) : Math.round(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Image output tokens of one image by the guide calculator's formula. */
|
|
188
|
+
export function openAIImageOutputTokens({ model, size, quality }) {
|
|
189
|
+
const entry = findOpenAIImagePriceEntry(model);
|
|
190
|
+
if (!entry?.outputTokens) return null;
|
|
191
|
+
const { width, height } = sizeOf(size);
|
|
192
|
+
const priced = qualityFor(entry, quality);
|
|
193
|
+
const base = entry.outputTokens.base[priced];
|
|
194
|
+
const d = roundHalfEven(base / (Math.max(width, height) / Math.min(width, height)));
|
|
195
|
+
const tokens = Math.ceil((base * d * (2e6 + width * height)) / 4e6);
|
|
196
|
+
const factor = entry.outputTokens.factor?.[priced];
|
|
197
|
+
return factor ? Math.round(tokens * factor) : tokens;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// One image's price for a table entry: its output tokens at the output
|
|
201
|
+
// rate, or the published price of the listed size the image is closest to
|
|
202
|
+
// in shape, scaled by area.
|
|
203
|
+
function entryPerImageUsd(entry, model, size, quality) {
|
|
204
|
+
if (entry.outputTokens) {
|
|
205
|
+
return (openAIImageOutputTokens({ model, size, quality }) * entry.rates.imageOutput) / PER_MILLION;
|
|
206
|
+
}
|
|
207
|
+
const prices = entry.perImageUsd[qualityFor(entry, quality)];
|
|
208
|
+
const { width, height, text } = sizeOf(size);
|
|
209
|
+
if (Object.hasOwn(prices, text)) return prices[text];
|
|
210
|
+
const listed = width > height * 1.25 ? '1536x1024' : (height > width * 1.25 ? '1024x1536' : '1024x1024');
|
|
211
|
+
const [listedWidth, listedHeight] = listed.split('x').map(Number);
|
|
212
|
+
return prices[listed] * ((width * height) / (listedWidth * listedHeight));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function standInPerImageUsd(size, quality) {
|
|
216
|
+
return Math.max(...STAND_IN_PER_IMAGE_MODELS.map((id) => (
|
|
217
|
+
entryPerImageUsd(OPENAI_IMAGE_PRICE_TABLE[id], id, size, quality)
|
|
218
|
+
)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function rateOf(entry, key) {
|
|
222
|
+
return (entry ? entry.rates : STAND_IN_RATES)[key];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function countOf(value) {
|
|
226
|
+
const count = Math.trunc(Number(value));
|
|
227
|
+
return Number.isFinite(count) && count > 0 ? count : 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function tokensOf(value) {
|
|
231
|
+
const number = Number(value);
|
|
232
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One image's estimated price, times `count`: what the images of a request
|
|
237
|
+
* cost at least, without input or partials. `{ costUsd, costUnknown }`;
|
|
238
|
+
* `costUnknown` for the stand-in.
|
|
239
|
+
*/
|
|
240
|
+
export function openAIImageCostFloor({ model, size, quality, count = 1 } = {}) {
|
|
241
|
+
const entry = findOpenAIImagePriceEntry(model);
|
|
242
|
+
const perImage = entry ? entryPerImageUsd(entry, model, size, quality) : standInPerImageUsd(size, quality);
|
|
243
|
+
return { costUsd: roundUsd(perImage * countOf(count)), costUnknown: !entry };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* What a request may cost before OpenAI has said: the floor, plus the
|
|
248
|
+
* partial images received (100 image output tokens each), plus an allowance
|
|
249
|
+
* for the prompt and each input image. `{ costUsd, costUnknown }`, never a
|
|
250
|
+
* missing cost.
|
|
251
|
+
*/
|
|
252
|
+
export function estimateOpenAIImageCost({ model, size, quality, count = 1, partials = 0, inputImages = 0 } = {}) {
|
|
253
|
+
const entry = findOpenAIImagePriceEntry(model);
|
|
254
|
+
const floor = openAIImageCostFloor({ model, size, quality, count });
|
|
255
|
+
const partialUsd = (tokensOf(partials) * OPENAI_IMAGE_PARTIAL_OUTPUT_TOKENS * rateOf(entry, 'imageOutput')) / PER_MILLION;
|
|
256
|
+
const inputUsd = (OPENAI_IMAGE_ESTIMATE_PROMPT_TOKENS * rateOf(entry, 'textInput')
|
|
257
|
+
+ tokensOf(inputImages) * OPENAI_IMAGE_ESTIMATE_INPUT_IMAGE_TOKENS * rateOf(entry, 'imageInput')) / PER_MILLION;
|
|
258
|
+
return { costUsd: roundUsd(floor.costUsd + partialUsd + inputUsd), costUnknown: floor.costUnknown };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The price of a usage OpenAI reported (`input_tokens`, `output_tokens`,
|
|
263
|
+
* `input_tokens_details: { text_tokens, image_tokens }`):
|
|
264
|
+
* `{ costUsd, costUnknown, source, inputTokens, outputTokens }`, `source`
|
|
265
|
+
* being the cost_source to book. Input without the text/image split is
|
|
266
|
+
* priced at the image input rate (the dearer). A model the table lacks is
|
|
267
|
+
* priced at the dearest rates the table has, `costUnknown`. Null when the
|
|
268
|
+
* usage holds no token count.
|
|
269
|
+
*/
|
|
270
|
+
export function priceOpenAIImageUsage({ model, usage } = {}) {
|
|
271
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
272
|
+
const hasInput = Number.isFinite(Number(usage.input_tokens)) && usage.input_tokens !== null;
|
|
273
|
+
const hasOutput = Number.isFinite(Number(usage.output_tokens)) && usage.output_tokens !== null;
|
|
274
|
+
if (!hasInput && !hasOutput) return null;
|
|
275
|
+
const entry = findOpenAIImagePriceEntry(model);
|
|
276
|
+
const input = tokensOf(usage.input_tokens);
|
|
277
|
+
const output = tokensOf(usage.output_tokens);
|
|
278
|
+
const details = usage.input_tokens_details && typeof usage.input_tokens_details === 'object' ? usage.input_tokens_details : null;
|
|
279
|
+
const textTokens = details ? Math.min(input, tokensOf(details.text_tokens)) : 0;
|
|
280
|
+
const imageTokens = input - textTokens;
|
|
281
|
+
const costUsd = (textTokens * rateOf(entry, 'textInput')
|
|
282
|
+
+ imageTokens * rateOf(entry, 'imageInput')
|
|
283
|
+
+ output * rateOf(entry, 'imageOutput')) / PER_MILLION;
|
|
284
|
+
return {
|
|
285
|
+
costUsd: roundUsd(costUsd),
|
|
286
|
+
costUnknown: !entry,
|
|
287
|
+
source: entry ? OPENAI_IMAGE_COST_SOURCES.table : OPENAI_IMAGE_COST_SOURCES.estimate,
|
|
288
|
+
inputTokens: hasInput ? input : null,
|
|
289
|
+
outputTokens: hasOutput ? output : null,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// What OpenAI image turns cost a relay, and its daily cap: the setting keys,
|
|
2
|
+
// the codes, the routes and the shape of the spend state the server, the
|
|
3
|
+
// dispatcher and the web client share.
|
|
4
|
+
//
|
|
5
|
+
// "Today" is the relay's local day, the one turn_token_usage.day and
|
|
6
|
+
// token_usage_days use. Image turns book there as provider 'openai',
|
|
7
|
+
// billing 'image', one row per try (run_id image:<attempt or claim>:try<n>,
|
|
8
|
+
// or …:recovered), priced from shared/openai-image-prices.mjs. Estimates and
|
|
9
|
+
// the stand-in price of an unknown model count toward the cap, on purpose:
|
|
10
|
+
// a cap that misses what may have been billed is no cap.
|
|
11
|
+
//
|
|
12
|
+
// SDK-free and side-effect free.
|
|
13
|
+
|
|
14
|
+
export const OPENAI_IMAGE_BOOKING_PROVIDER = 'openai';
|
|
15
|
+
export const OPENAI_IMAGE_BOOKING_BILLING = 'image';
|
|
16
|
+
|
|
17
|
+
// cost_source of a booked row: priced from the usage OpenAI reported, or an
|
|
18
|
+
// estimate (a try that may have been billed without a usage, a model the
|
|
19
|
+
// table lacks: then cost_unknown is 1 too).
|
|
20
|
+
export const OPENAI_IMAGE_COST_SOURCES = Object.freeze({
|
|
21
|
+
table: 'oar-openai-image-table',
|
|
22
|
+
estimate: 'oar-openai-image-estimate',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// app_settings keys (JSON values).
|
|
26
|
+
export const OPENAI_IMAGE_SPEND_SETTING_KEYS = Object.freeze({
|
|
27
|
+
// USD. 0 means no cap; missing or unreadable means USD 5.
|
|
28
|
+
dailyCapUsd: 'openai_image_daily_cap_usd',
|
|
29
|
+
// The relay-local day ("YYYY-MM-DD") the owner allowed image turns past
|
|
30
|
+
// the cap, and when; it ends by itself when the day does.
|
|
31
|
+
overrideDay: 'openai_image_cap_override_day',
|
|
32
|
+
overrideAt: 'openai_image_cap_override_at',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD = 5;
|
|
36
|
+
// The share of the cap from which replies and image chats show a warning.
|
|
37
|
+
export const OPENAI_IMAGE_CAP_WARN_RATIO = 0.8;
|
|
38
|
+
|
|
39
|
+
// A try the cap stops before it is sent, and a send POST /api/message
|
|
40
|
+
// refuses (409), when the cap is reached and today is not allowed.
|
|
41
|
+
export const OPENAI_IMAGE_DAILY_CAP_REACHED_CODE = 'OPENAI_IMAGE_DAILY_CAP_REACHED';
|
|
42
|
+
|
|
43
|
+
// GET: the spend state below. Owner token; a paired relay may read it (its
|
|
44
|
+
// dispatcher checks before create_session) but never write the cap. With
|
|
45
|
+
// `?conversationId=<id>` the state carries `conversation`: that chat's
|
|
46
|
+
// { costUsd, estimatedUsd, images, tries }.
|
|
47
|
+
export const OPENAI_IMAGE_USAGE_PATH = '/api/image-usage';
|
|
48
|
+
|
|
49
|
+
// Socket event (owner clients) with the spend state after every booking and
|
|
50
|
+
// every change of the cap or the override.
|
|
51
|
+
export const OPENAI_IMAGE_USAGE_EVENT = 'image_usage_updated';
|
|
52
|
+
|
|
53
|
+
// POST /api/settings/openai takes, besides its other fields:
|
|
54
|
+
// imageDailyCapUsd: number ≥ 0 (0 = no cap)
|
|
55
|
+
// allowImagesToday: true (sets the override for today) | false (clears it)
|
|
56
|
+
// Refused with 403 for a paired relay's request.
|
|
57
|
+
export const OPENAI_IMAGE_SETTINGS_FIELDS = Object.freeze({
|
|
58
|
+
dailyCapUsd: 'imageDailyCapUsd',
|
|
59
|
+
allowToday: 'allowImagesToday',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// The presets routes (owner token, never a paired relay's write):
|
|
63
|
+
// GET /api/openai-image-presets → { presets: [{ name, …values, builtIn }] }
|
|
64
|
+
// PUT /api/openai-image-presets body { preset: { name, …values }, replace? } → { presets }
|
|
65
|
+
// DELETE /api/openai-image-presets/:name → { presets }
|
|
66
|
+
export const OPENAI_IMAGE_PRESETS_PATH = '/api/openai-image-presets';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The spend state (GET OPENAI_IMAGE_USAGE_PATH, the `usage` action's meter,
|
|
70
|
+
* the Check Usage card, the banner):
|
|
71
|
+
* {
|
|
72
|
+
* day: 'YYYY-MM-DD', // relay local
|
|
73
|
+
* capUsd: number | null, // null: no cap
|
|
74
|
+
* spentUsd: number, // booked today, estimates included
|
|
75
|
+
* reservedUsd: number, // tries in flight today
|
|
76
|
+
* estimatedUsd: number, // the part of spentUsd that is estimates
|
|
77
|
+
* unknownPriceUsd: number, // the part booked at the stand-in price
|
|
78
|
+
* monthUsd: number, // this relay-local month, booked
|
|
79
|
+
* ratio: number | null, // (spent + reserved) / cap
|
|
80
|
+
* warning: boolean, // ratio ≥ OPENAI_IMAGE_CAP_WARN_RATIO
|
|
81
|
+
* reached: boolean, // spent + reserved ≥ cap
|
|
82
|
+
* allowedToday: boolean, // the owner's override for this day
|
|
83
|
+
* allowedAt: string | null,
|
|
84
|
+
* }
|
|
85
|
+
*/
|
|
86
|
+
export function formatOpenAIImageUsd(value) {
|
|
87
|
+
const number = Number(value);
|
|
88
|
+
if (!Number.isFinite(number)) return 'USD ?';
|
|
89
|
+
return `USD ${number > 0 && number < 0.01 ? number.toFixed(3) : number.toFixed(2)}`;
|
|
90
|
+
}
|
|
@@ -35,6 +35,18 @@ import {
|
|
|
35
35
|
REMOTE_RELAY_APPROVAL_SOURCE,
|
|
36
36
|
normalizeRelayAskInput,
|
|
37
37
|
} from './relay-asks.mjs';
|
|
38
|
+
import {
|
|
39
|
+
OPENAI_IMAGE_BACKGROUNDS,
|
|
40
|
+
OPENAI_IMAGE_LIMITS,
|
|
41
|
+
OPENAI_IMAGE_OUTPUT_FORMATS,
|
|
42
|
+
validateOpenAIImageOptions,
|
|
43
|
+
} from './openai-image-options.mjs';
|
|
44
|
+
import {
|
|
45
|
+
OPENAI_IMAGE_BUILTIN_PRESETS,
|
|
46
|
+
OPENAI_IMAGE_PRESET_LIMITS,
|
|
47
|
+
cleanOpenAIImagePresetName,
|
|
48
|
+
splitOpenAIImagePreset,
|
|
49
|
+
} from './openai-image-presets.mjs';
|
|
38
50
|
|
|
39
51
|
// The one name every provider lists the tool under. Up to 0.9.17 it was
|
|
40
52
|
// `remote_relay`; that name is still accepted where a runtime dispatches a
|
|
@@ -156,6 +168,8 @@ export const REMOTE_RELAY_SESSION_SETTINGS = Object.freeze([
|
|
|
156
168
|
// Plan-only: agents may set and clear it; set, a send or an
|
|
157
169
|
// update_session naming another mode is refused.
|
|
158
170
|
'planOnly',
|
|
171
|
+
// An image session's defaults (size, quality, count, format, background).
|
|
172
|
+
'image',
|
|
159
173
|
]);
|
|
160
174
|
export const REMOTE_RELAY_THINKING_DISPLAYS = THINKING_DISPLAY_MODES;
|
|
161
175
|
// update_session's `group` value that takes a session out of its group (JSON
|
|
@@ -237,6 +251,10 @@ export const REMOTE_RELAY_LIMITS = Object.freeze({
|
|
|
237
251
|
healthIntervalMs: 60_000,
|
|
238
252
|
requestTimeoutMs: 15_000,
|
|
239
253
|
responseMaxBytes: 5 * 1024 * 1024,
|
|
254
|
+
// send / create_session to an image session: paths of images on the target
|
|
255
|
+
// relay that go to the model with the prompt.
|
|
256
|
+
imagesMax: OPENAI_IMAGE_LIMITS.inputImagesMax,
|
|
257
|
+
imagePathMaxChars: 4096,
|
|
240
258
|
});
|
|
241
259
|
|
|
242
260
|
export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
|
|
@@ -313,6 +331,8 @@ export const AGENT_SESSIONS_SOCKET_EVENT = 'agent_sessions_settings_updated';
|
|
|
313
331
|
export const REMOTE_RELAY_LOCAL_ALIAS = 'this';
|
|
314
332
|
export const REMOTE_RELAY_LOCAL_ID = 'self';
|
|
315
333
|
export const REMOTE_RELAY_CLOUD_PROVIDER = 'claude-cloud';
|
|
334
|
+
// The provider of image sessions (gpt-image-*, dall-e-*): images and image go there.
|
|
335
|
+
export const REMOTE_RELAY_IMAGE_PROVIDER = 'openai';
|
|
316
336
|
|
|
317
337
|
export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
318
338
|
'Work with sessions on OAR relays: the other relays this relay is paired with ("remote relays") '
|
|
@@ -360,8 +380,14 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
360
380
|
+ 'answer: ask the user (unless they told you to decide yourself) and pass the answer '
|
|
361
381
|
+ 'with answer_question. Write actions can first need the user\'s approval. Always '
|
|
362
382
|
+ 'tell the user which relay and session you worked on. '
|
|
383
|
+
+ 'Image sessions (provider "openai" with a gpt-image or dall-e model): send and create_session take images (absolute paths '
|
|
384
|
+
+ 'on the target relay to edit or work from) and image {size, quality, count, output_format, background, preset} for that turn '
|
|
385
|
+
+ '(preset: a name relay_info lists in imagePresets; the fields named win over it); '
|
|
386
|
+
+ 'update_session image sets the session\'s defaults. Their replies list images [{path, width, height, mime}] (absolute paths '
|
|
387
|
+
+ 'on that relay, which a later send can attach) with a line on size and model; a failed image call is not repeated more than once. '
|
|
363
388
|
+ 'usage reports what the provider plans of a relay have left (Copilot, Claude, Claude Cloud, Cursor, Grok: '
|
|
364
|
-
+ 'meters with used, allowance, remaining, utilization and reset time; providers not set up or not tracked say so
|
|
389
|
+
+ 'meters with used, allowance, remaining, utilization and reset time; providers not set up or not tracked say so; '
|
|
390
|
+
+ 'OpenAI images: today\'s spend against the relay\'s daily cap) '
|
|
365
391
|
+ 'and whether a Claude usage limit has paused turns there, before you start work that costs. '
|
|
366
392
|
+ 'read_task_output {session, task_id} reads the end of a background shell or Monitor task\'s output there '
|
|
367
393
|
+ '(task ids from read_session\'s tasks; also for an hour after the task ended): the last lines, minutes '
|
|
@@ -436,7 +462,7 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
436
462
|
+ 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions. '
|
|
437
463
|
+ 'read_task_output: the end of a background shell task\'s output. send: prompt an '
|
|
438
464
|
+ 'existing session. create_session: start a new session with a first prompt. update_session: change '
|
|
439
|
-
+ 'another session\'s settings (model, effort, mode, auto-compact window, title, group, thinking, cwd). wait: keep waiting for '
|
|
465
|
+
+ 'another session\'s settings (model, effort, mode, auto-compact window, title, group, thinking, cwd, image defaults). wait: keep waiting for '
|
|
440
466
|
+ 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
|
|
441
467
|
+ 'archive: archive a session. subscribe / unsubscribe / list_subscriptions / transfer_subscriptions: '
|
|
442
468
|
+ 'be woken by events of sessions on this relay or a paired one (relay may be left out: "this"). '
|
|
@@ -545,6 +571,35 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
545
571
|
enum: [...THINKING_DISPLAY_MODES],
|
|
546
572
|
description: 'update_session: whether a Claude session\'s thinking text shows in its transcript (summarized) or not (omitted).',
|
|
547
573
|
},
|
|
574
|
+
images: {
|
|
575
|
+
type: 'array',
|
|
576
|
+
items: { type: 'string' },
|
|
577
|
+
maxItems: REMOTE_RELAY_LIMITS.imagesMax,
|
|
578
|
+
description: `send / create_session to an OpenAI image session (provider openai, a gpt-image or dall-e model): up to ${REMOTE_RELAY_LIMITS.imagesMax} `
|
|
579
|
+
+ `absolute paths of PNG, JPEG or WebP files on the target relay (each at most ${OPENAI_IMAGE_LIMITS.inputImageBytesMax / (1024 * 1024)} MB) that the model edits or works from, `
|
|
580
|
+
+ 'with text as the instruction. Allowed: files inside the target session\'s working directory, or images any session there '
|
|
581
|
+
+ 'generated (the paths its replies list). Refused for other models; a paired relay must run an OAR that takes them.',
|
|
582
|
+
},
|
|
583
|
+
image: {
|
|
584
|
+
type: 'object',
|
|
585
|
+
properties: {
|
|
586
|
+
size: { type: 'string', description: '"auto" or WIDTHxHEIGHT, e.g. 1536x1024: gpt-image takes both edges multiples of 16, the longer at most 3840, at most 3840x2160 pixels (gpt-image-2 and later: at least 655360 pixels, e.g. 1024x640), aspect 1:3 to 3:1; dall-e-3 1024x1024, 1792x1024 or 1024x1792; dall-e-2 256x256, 512x512 or 1024x1024.' },
|
|
587
|
+
quality: { type: 'string', description: 'gpt-image: auto, low, medium or high, and xhigh or max on the gpt-image 2.5 and 2.6 models (checked against the session\'s model); dall-e-3: standard or hd.' },
|
|
588
|
+
count: { type: 'integer', minimum: 1, maximum: OPENAI_IMAGE_LIMITS.countMax, description: `Images per turn, 1 to ${OPENAI_IMAGE_LIMITS.countMax} (dall-e-3: 1). Default 1.` },
|
|
589
|
+
output_format: { type: 'string', enum: [...OPENAI_IMAGE_OUTPUT_FORMATS], description: 'gpt-image only: png (default), webp or jpeg.' },
|
|
590
|
+
background: { type: 'string', enum: [...OPENAI_IMAGE_BACKGROUNDS], description: 'gpt-image only: auto (default), transparent (png or webp) or opaque.' },
|
|
591
|
+
preset: {
|
|
592
|
+
type: 'string',
|
|
593
|
+
maxLength: OPENAI_IMAGE_PRESET_LIMITS.nameChars,
|
|
594
|
+
description: `send / create_session only: a named set of these options, e.g. ${OPENAI_IMAGE_BUILTIN_PRESETS.map((preset) => `"${preset.name}"`).join(', ')} `
|
|
595
|
+
+ '(the built-ins; relay_info lists imagePresets with the relay owner\'s own). It sits over the session\'s defaults, '
|
|
596
|
+
+ 'and the fields named next to it win over it. An unknown name is refused with the list; nothing is sent.',
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
description: 'send / create_session: image options for that turn, over the session\'s defaults. update_session: the image '
|
|
600
|
+
+ 'session\'s defaults from now on (the fields named replace those defaults; auto, 1 and png set one back; no preset). '
|
|
601
|
+
+ 'Refused for a session whose model makes no images.',
|
|
602
|
+
},
|
|
548
603
|
question_id: { type: 'string', description: 'answer_question: id from pendingQuestions. withdraw: the id ask returned.' },
|
|
549
604
|
question: {
|
|
550
605
|
type: 'string',
|
|
@@ -696,8 +751,8 @@ export const REMOTE_RELAY_ACTION_ARGUMENTS = Object.freeze({
|
|
|
696
751
|
read_session: Object.freeze(['session', 'last', 'before', 'include_activity', 'max_chars']),
|
|
697
752
|
read_task_output: Object.freeze(['session', 'task_id', 'lines']),
|
|
698
753
|
wait: Object.freeze(['session', 'message_id', 'wait_seconds']),
|
|
699
|
-
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider']),
|
|
700
|
-
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds', 'planOnly']),
|
|
754
|
+
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider', 'images', 'image']),
|
|
755
|
+
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds', 'planOnly', 'images', 'image']),
|
|
701
756
|
update_session: Object.freeze(['session', ...REMOTE_RELAY_SESSION_SETTINGS]),
|
|
702
757
|
answer_question: Object.freeze(['question_id', 'answer', 'choices']),
|
|
703
758
|
stop: Object.freeze(['session']),
|
|
@@ -913,6 +968,12 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
|
|
|
913
968
|
}
|
|
914
969
|
args.effort = effort;
|
|
915
970
|
}
|
|
971
|
+
const imagesCheck = readImagePaths(input?.images);
|
|
972
|
+
if (!imagesCheck.ok) return imagesCheck;
|
|
973
|
+
if (imagesCheck.paths.length) args.images = imagesCheck.paths;
|
|
974
|
+
const imageCheck = readTurnImageOptions(input?.image);
|
|
975
|
+
if (!imageCheck.ok) return imageCheck;
|
|
976
|
+
if (Object.keys(imageCheck.values).length) args.image = imageCheck.values;
|
|
916
977
|
if (action === 'send') {
|
|
917
978
|
const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
|
|
918
979
|
if (!busyCheck.ok) return busyCheck;
|
|
@@ -920,10 +981,16 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
|
|
|
920
981
|
const providerCheck = optionalProvider(sendProviders, (value, list) =>
|
|
921
982
|
`provider "${value}" can't be used with send; send takes one of: ${list} (the Claude session's subscription or API key)`);
|
|
922
983
|
if (!providerCheck.ok) return providerCheck;
|
|
984
|
+
if (args.provider && (args.images || args.image)) {
|
|
985
|
+
return invalid('images and image go to an OpenAI image session, not with provider (a Claude session\'s billing)');
|
|
986
|
+
}
|
|
923
987
|
} else {
|
|
924
988
|
const providerCheck = optionalProvider(providers, (value, list) =>
|
|
925
989
|
`provider "${value}" is not a provider here; this relay offers: ${list}`);
|
|
926
990
|
if (!providerCheck.ok) return providerCheck;
|
|
991
|
+
if (args.provider && args.provider !== REMOTE_RELAY_IMAGE_PROVIDER && (args.images || args.image)) {
|
|
992
|
+
return invalid(`images and image go with provider "${REMOTE_RELAY_IMAGE_PROVIDER}" and an image model (gpt-image-*, dall-e-*), not provider "${args.provider}"`);
|
|
993
|
+
}
|
|
927
994
|
optionalText('cwd', 1000);
|
|
928
995
|
optionalText('title', 200);
|
|
929
996
|
// An empty group is no group: the default grouping applies.
|
|
@@ -982,6 +1049,53 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
|
|
|
982
1049
|
return { ok: true, action, args, ...(waits ? waitLimited : {}) };
|
|
983
1050
|
}
|
|
984
1051
|
|
|
1052
|
+
// Image paths are read on the target relay, whose platform this relay may not
|
|
1053
|
+
// share: a POSIX path or a drive path (C:\ or C:/). Network and device paths
|
|
1054
|
+
// (\\host\share, //host/share, \\?\, \\.\) are refused: merely touching one can
|
|
1055
|
+
// open a connection that hands out the relay's credentials.
|
|
1056
|
+
const ABSOLUTE_PATH_PATTERN = /^(?:\/(?!\/)|[A-Za-z]:[\\/])/;
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* send / create_session's `images`: absolute paths, at most imagesMax, the
|
|
1060
|
+
* same path named twice kept once. A single text is one path.
|
|
1061
|
+
*/
|
|
1062
|
+
function readImagePaths(value) {
|
|
1063
|
+
if (value === undefined || value === null || value === '') return { ok: true, paths: [] };
|
|
1064
|
+
const list = typeof value === 'string' ? [value] : value;
|
|
1065
|
+
if (!Array.isArray(list)) return invalid('images must be a list of absolute file paths on the target relay');
|
|
1066
|
+
const paths = [];
|
|
1067
|
+
for (const entry of list) {
|
|
1068
|
+
if (typeof entry !== 'string') return invalid('images must be a list of absolute file paths on the target relay');
|
|
1069
|
+
const path = entry.trim();
|
|
1070
|
+
if (!path) continue;
|
|
1071
|
+
if (path.length > REMOTE_RELAY_LIMITS.imagePathMaxChars || path.includes('\0')) return invalid('images: a path is too long or not a path');
|
|
1072
|
+
if (!ABSOLUTE_PATH_PATTERN.test(path)) {
|
|
1073
|
+
return invalid(`images: "${path.slice(0, 120)}" is not an absolute local path; give the full path on the target relay (no network paths)`);
|
|
1074
|
+
}
|
|
1075
|
+
if (!paths.includes(path)) paths.push(path);
|
|
1076
|
+
}
|
|
1077
|
+
if (paths.length > REMOTE_RELAY_LIMITS.imagesMax) return invalid(`images takes at most ${REMOTE_RELAY_LIMITS.imagesMax} paths`);
|
|
1078
|
+
return { ok: true, paths };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* send / create_session's `image`: the options as the validator checks them
|
|
1083
|
+
* (without a model; the target checks them against its session's), and
|
|
1084
|
+
* `preset` as a clean name. Only its shape is checked here: the target
|
|
1085
|
+
* relay knows its presets and resolves the name.
|
|
1086
|
+
*/
|
|
1087
|
+
function readTurnImageOptions(value) {
|
|
1088
|
+
const { preset, own } = splitOpenAIImagePreset(value);
|
|
1089
|
+
const checked = validateOpenAIImageOptions(own, { field: 'image' });
|
|
1090
|
+
if (!checked.ok) return invalid(checked.error);
|
|
1091
|
+
if (preset === null || preset === undefined || (typeof preset === 'string' && !preset.trim())) {
|
|
1092
|
+
return { ok: true, values: checked.values };
|
|
1093
|
+
}
|
|
1094
|
+
const name = cleanOpenAIImagePresetName(preset);
|
|
1095
|
+
if (name.error) return invalid(`image.preset: ${name.error}`);
|
|
1096
|
+
return { ok: true, values: { ...checked.values, preset: name.name } };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
985
1099
|
function readBoolean(value) {
|
|
986
1100
|
if (value === true || value === false) return value;
|
|
987
1101
|
const text = toText(value).toLowerCase();
|
|
@@ -1060,6 +1174,14 @@ export function validateRemoteRelaySessionSettings(input) {
|
|
|
1060
1174
|
if (!THINKING_DISPLAY_MODES.includes(display)) return invalid(`thinking_display must be one of: ${THINKING_DISPLAY_MODES.join(', ')}`);
|
|
1061
1175
|
values.thinking_display = display;
|
|
1062
1176
|
}
|
|
1177
|
+
// A preset is a turn's: the defaults are the fields themselves.
|
|
1178
|
+
const { preset: defaultsPreset, own: defaults } = splitOpenAIImagePreset(input?.image);
|
|
1179
|
+
if (defaultsPreset !== null && defaultsPreset !== undefined && toText(defaultsPreset) !== '') {
|
|
1180
|
+
return invalid('image.preset: a preset is per turn (send, create_session); name its fields to set defaults');
|
|
1181
|
+
}
|
|
1182
|
+
const image = validateOpenAIImageOptions(defaults, { field: 'image' });
|
|
1183
|
+
if (!image.ok) return invalid(image.error);
|
|
1184
|
+
if (Object.keys(image.values).length) values.image = image.values;
|
|
1063
1185
|
if (!Object.keys(values).length) {
|
|
1064
1186
|
return invalid(`update_session needs at least one setting to change: ${REMOTE_RELAY_SESSION_SETTINGS.join(', ')}`);
|
|
1065
1187
|
}
|