@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.
Files changed (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. 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
  }