@oar-sh/oar 0.9.24 → 0.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +284 -0
- package/README.md +12 -11
- package/package.json +5 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/api-client.js +58 -0
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +85 -25
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/context-usage-view.mjs +2 -2
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/pwa-install.js +25 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +517 -123
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +21 -0
- package/server/services/database-file.mjs +6 -4
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +409 -23
- package/server/services/remote-relay-registry-service.mjs +10 -2
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/math-instructions.mjs +30 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +96 -50
- package/server/public/app/math-dollars.mjs +0 -89
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Image presets: named bundles of image options (size, quality, count,
|
|
2
|
+
// output format, background). A built-in set, and the owner's own, stored
|
|
3
|
+
// relay-wide in app_settings. A turn's options are layered: the session's
|
|
4
|
+
// defaults, then a preset, then the turn's own fields; the merged result is
|
|
5
|
+
// what the validator checks (validateOpenAIImageOptions with the model).
|
|
6
|
+
//
|
|
7
|
+
// SDK-free and side-effect free: imported by the server and the session
|
|
8
|
+
// workers (the browser keeps a tested copy, as for the options).
|
|
9
|
+
|
|
10
|
+
import { OPENAI_IMAGE_OPTION_KEYS, validateOpenAIImageOptions } from './openai-image-options.mjs';
|
|
11
|
+
|
|
12
|
+
export const OPENAI_IMAGE_PRESETS_SETTING_KEY = 'openai_image_presets';
|
|
13
|
+
|
|
14
|
+
export const OPENAI_IMAGE_PRESET_LIMITS = Object.freeze({
|
|
15
|
+
// Own presets one relay keeps, a name's characters, and the stored JSON.
|
|
16
|
+
ownMax: 30,
|
|
17
|
+
nameChars: 40,
|
|
18
|
+
storedBytesMax: 8 * 1024,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// The relay refuses a preset name it does not know, or an own preset that
|
|
22
|
+
// breaks the rules (a built-in's name, too many, too long), with this code.
|
|
23
|
+
export const OPENAI_IMAGE_PRESET_REFUSED_CODE = 'OPENAI_IMAGE_PRESET_REFUSED';
|
|
24
|
+
|
|
25
|
+
export const OPENAI_IMAGE_BUILTIN_PRESETS = Object.freeze([
|
|
26
|
+
Object.freeze({ name: 'Draft square', size: '1024x1024', quality: 'low' }),
|
|
27
|
+
Object.freeze({ name: 'Photo landscape', size: '1536x1024', quality: 'medium' }),
|
|
28
|
+
Object.freeze({ name: 'Portrait', size: '1024x1536', quality: 'medium' }),
|
|
29
|
+
Object.freeze({ name: 'Wallpaper 4K', size: '3840x2160', quality: 'high' }),
|
|
30
|
+
Object.freeze({ name: 'Sticker', size: '1024x1024', quality: 'medium', output_format: 'png', background: 'transparent' }),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Control characters, and the invisible ones that make two names look alike.
|
|
34
|
+
const HIDDEN_CHARACTERS = /[\u0000-\u001f\u007f-\u009f----]/u;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A preset name as shown: NFC, trimmed, runs of white space as one space;
|
|
38
|
+
* or `{ error }` for an empty, too long or hidden-character name.
|
|
39
|
+
*/
|
|
40
|
+
export function cleanOpenAIImagePresetName(value) {
|
|
41
|
+
if (typeof value !== 'string') return { error: 'a preset name must be text' };
|
|
42
|
+
const normalized = value.normalize('NFC');
|
|
43
|
+
if (HIDDEN_CHARACTERS.test(normalized)) return { error: 'a preset name may not hold control or invisible characters' };
|
|
44
|
+
const name = normalized.trim().replace(/\s+/gu, ' ');
|
|
45
|
+
if (!name) return { error: 'a preset name may not be empty' };
|
|
46
|
+
// "." and ".." would be path steps in the preset's URL (DELETE …/:name).
|
|
47
|
+
if (/^\.+$/.test(name)) return { error: 'a preset name may not be dots alone' };
|
|
48
|
+
if ([...name].length > OPENAI_IMAGE_PRESET_LIMITS.nameChars) {
|
|
49
|
+
return { error: `a preset name has at most ${OPENAI_IMAGE_PRESET_LIMITS.nameChars} characters` };
|
|
50
|
+
}
|
|
51
|
+
return { name };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The one key a name is saved and looked up by: the clean name, lower-cased. */
|
|
55
|
+
export function openAIImagePresetKey(value) {
|
|
56
|
+
const cleaned = cleanOpenAIImagePresetName(value);
|
|
57
|
+
return cleaned.error ? '' : cleaned.name.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function presetValues(preset) {
|
|
61
|
+
const values = {};
|
|
62
|
+
for (const key of OPENAI_IMAGE_OPTION_KEYS) {
|
|
63
|
+
if (preset?.[key] !== undefined && preset?.[key] !== null && preset?.[key] !== '') values[key] = preset[key];
|
|
64
|
+
}
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An own preset as the owner saves it, checked without a model (a preset
|
|
70
|
+
* serves any image session; its use is checked against the session's
|
|
71
|
+
* model): `{ ok:true, preset:{ name, ...values } }` or `{ ok:false, error }`.
|
|
72
|
+
*/
|
|
73
|
+
export function validateOwnOpenAIImagePreset(input) {
|
|
74
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'a preset is an object with a name and image options' };
|
|
75
|
+
const cleaned = cleanOpenAIImagePresetName(input.name);
|
|
76
|
+
if (cleaned.error) return { ok: false, error: cleaned.error };
|
|
77
|
+
const key = cleaned.name.toLowerCase();
|
|
78
|
+
if (OPENAI_IMAGE_BUILTIN_PRESETS.some((preset) => preset.name.toLowerCase() === key)) {
|
|
79
|
+
return { ok: false, error: `"${cleaned.name}" is a built-in preset; pick another name` };
|
|
80
|
+
}
|
|
81
|
+
const { name: _name, ...rest } = input;
|
|
82
|
+
const checked = validateOpenAIImageOptions(rest, { field: 'preset' });
|
|
83
|
+
if (!checked.ok) return { ok: false, error: checked.error };
|
|
84
|
+
if (!Object.keys(checked.values).length) return { ok: false, error: `a preset needs at least one of: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}` };
|
|
85
|
+
return { ok: true, preset: { name: cleaned.name, ...checked.values } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The owner's presets as stored (the app_settings JSON), the ones that still
|
|
90
|
+
* pass the rules, in their order; anything else is dropped.
|
|
91
|
+
*/
|
|
92
|
+
export function parseOwnOpenAIImagePresets(stored) {
|
|
93
|
+
let list = stored;
|
|
94
|
+
if (typeof stored === 'string') {
|
|
95
|
+
try { list = JSON.parse(stored); } catch { return []; }
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(list)) return [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const presets = [];
|
|
100
|
+
for (const entry of list) {
|
|
101
|
+
const checked = validateOwnOpenAIImagePreset(entry);
|
|
102
|
+
if (!checked.ok) continue;
|
|
103
|
+
const key = checked.preset.name.toLowerCase();
|
|
104
|
+
if (seen.has(key)) continue;
|
|
105
|
+
seen.add(key);
|
|
106
|
+
presets.push(checked.preset);
|
|
107
|
+
if (presets.length >= OPENAI_IMAGE_PRESET_LIMITS.ownMax) break;
|
|
108
|
+
}
|
|
109
|
+
return presets;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Built-ins first (`builtIn: true`), then the owner's. */
|
|
113
|
+
export function listOpenAIImagePresets(ownPresets = []) {
|
|
114
|
+
return [
|
|
115
|
+
...OPENAI_IMAGE_BUILTIN_PRESETS.map((preset) => ({ ...preset, builtIn: true })),
|
|
116
|
+
...(Array.isArray(ownPresets) ? ownPresets : []).map((preset) => ({ ...preset, builtIn: false })),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The preset a turn names, by its key: `{ ok:true, values }` (the options
|
|
122
|
+
* alone), `{ ok:true, values:{} }` for no name, or `{ ok:false, error }` for
|
|
123
|
+
* a name the relay does not know, with the names it has.
|
|
124
|
+
*/
|
|
125
|
+
export function findOpenAIImagePreset(name, ownPresets = []) {
|
|
126
|
+
if (name === undefined || name === null || (typeof name === 'string' && !name.trim())) return { ok: true, values: {} };
|
|
127
|
+
const key = openAIImagePresetKey(name);
|
|
128
|
+
const all = listOpenAIImagePresets(ownPresets);
|
|
129
|
+
const found = key ? all.find((preset) => preset.name.toLowerCase() === key) : null;
|
|
130
|
+
if (!found) {
|
|
131
|
+
return { ok: false, error: `no image preset "${String(name).slice(0, 60)}" here; presets: ${all.map((preset) => preset.name).join(', ')}` };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, values: presetValues(found), name: found.name };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Splits a turn's `image` into the preset it names and its own fields (the
|
|
138
|
+
* validator takes no `preset` key): `{ preset, own }`.
|
|
139
|
+
*/
|
|
140
|
+
export function splitOpenAIImagePreset(image) {
|
|
141
|
+
if (!image || typeof image !== 'object' || Array.isArray(image)) return { preset: null, own: image };
|
|
142
|
+
const { preset = null, ...own } = image;
|
|
143
|
+
return { preset, own };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The stored list after one change, for the server to write in the same
|
|
148
|
+
* transaction it read in: `{ ok:true, presets }` or `{ ok:false, error }`.
|
|
149
|
+
* `op` is 'put' (add, or replace one of that name only with `replace`) or
|
|
150
|
+
* 'delete'.
|
|
151
|
+
*/
|
|
152
|
+
export function applyOwnOpenAIImagePresetChange(ownPresets, { op, preset = null, name = '', replace = false } = {}) {
|
|
153
|
+
const list = Array.isArray(ownPresets) ? [...ownPresets] : [];
|
|
154
|
+
if (op === 'delete') {
|
|
155
|
+
const key = openAIImagePresetKey(name);
|
|
156
|
+
if (OPENAI_IMAGE_BUILTIN_PRESETS.some((entry) => entry.name.toLowerCase() === key)) return { ok: false, error: 'a built-in preset cannot be deleted' };
|
|
157
|
+
const index = list.findIndex((entry) => entry.name.toLowerCase() === key);
|
|
158
|
+
if (index < 0) return { ok: false, error: `no own preset "${String(name).slice(0, 60)}"` };
|
|
159
|
+
list.splice(index, 1);
|
|
160
|
+
return { ok: true, presets: list };
|
|
161
|
+
}
|
|
162
|
+
if (op !== 'put') return { ok: false, error: 'op must be put or delete' };
|
|
163
|
+
const checked = validateOwnOpenAIImagePreset(preset);
|
|
164
|
+
if (!checked.ok) return checked;
|
|
165
|
+
const key = checked.preset.name.toLowerCase();
|
|
166
|
+
const index = list.findIndex((entry) => entry.name.toLowerCase() === key);
|
|
167
|
+
if (index >= 0 && !replace) return { ok: false, error: `a preset "${list[index].name}" exists; save with replace to change it` };
|
|
168
|
+
if (index >= 0) list[index] = checked.preset;
|
|
169
|
+
else if (list.length >= OPENAI_IMAGE_PRESET_LIMITS.ownMax) return { ok: false, error: `at most ${OPENAI_IMAGE_PRESET_LIMITS.ownMax} own presets` };
|
|
170
|
+
else list.push(checked.preset);
|
|
171
|
+
if (new TextEncoder().encode(JSON.stringify(list)).length > OPENAI_IMAGE_PRESET_LIMITS.storedBytesMax) {
|
|
172
|
+
return { ok: false, error: `the presets would take more than ${OPENAI_IMAGE_PRESET_LIMITS.storedBytesMax} bytes` };
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, presets: list };
|
|
175
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Partial previews of an OpenAI image turn: what the relay asks OpenAI to
|
|
2
|
+
// stream, where the partial files live and the socket events the owner's
|
|
3
|
+
// open clients get while the image is made. Agents never see previews: their
|
|
4
|
+
// replies carry the final images only.
|
|
5
|
+
//
|
|
6
|
+
// SDK-free and side-effect free: imported by the server and the browser.
|
|
7
|
+
|
|
8
|
+
// Partial images per image OpenAI streams before the final one (0 to 3; 0
|
|
9
|
+
// turns streaming off).
|
|
10
|
+
export const OPENAI_IMAGE_PARTIAL_IMAGES = 2;
|
|
11
|
+
|
|
12
|
+
// The folder next to a turn's final images that holds its partials; emptied
|
|
13
|
+
// and removed when the turn ends, well or not.
|
|
14
|
+
export const OPENAI_IMAGE_PARTIALS_DIR = 'partials';
|
|
15
|
+
|
|
16
|
+
// Socket events (owner clients only, never shared views):
|
|
17
|
+
//
|
|
18
|
+
// `image_started` — a try of an image turn went to OpenAI:
|
|
19
|
+
// { conversationId, messageId, attempt, size, quality, count, model, startedAt }
|
|
20
|
+
// The client shows a placeholder with an elapsed timer from `startedAt`
|
|
21
|
+
// and the size and quality; a client that opens the chat later sees the
|
|
22
|
+
// running turn without it (its timer starts from the turn's own time).
|
|
23
|
+
//
|
|
24
|
+
// `image_preview` — a partial image arrived:
|
|
25
|
+
// { conversationId, messageId, attempt, imageIndex, partialIndex,
|
|
26
|
+
// contentUrl, mime, size, quality, model, startedAt }
|
|
27
|
+
// `messageId` is the user message (queue row) the turn answers; `attempt`
|
|
28
|
+
// counts the tries of that turn (1, then 2 after a retry); `imageIndex`
|
|
29
|
+
// the image of a count > 1 turn (0-based); `partialIndex` OpenAI's
|
|
30
|
+
// partial_image_index; `startedAt` the ISO time the first try began, for
|
|
31
|
+
// the elapsed timer.
|
|
32
|
+
//
|
|
33
|
+
// `image_preview_reset` — drop the previews shown so far for that turn:
|
|
34
|
+
// { conversationId, messageId, attempt, reason }
|
|
35
|
+
// `reason` is 'retry' (a second try starts: the first try's partials go),
|
|
36
|
+
// 'done' (the final message is there) or 'failed'.
|
|
37
|
+
export const OPENAI_IMAGE_STARTED_EVENT = 'image_started';
|
|
38
|
+
export const OPENAI_IMAGE_PREVIEW_EVENT = 'image_preview';
|
|
39
|
+
export const OPENAI_IMAGE_PREVIEW_RESET_EVENT = 'image_preview_reset';
|
|
40
|
+
export const OPENAI_IMAGE_PREVIEW_RESET_REASONS = Object.freeze(['retry', 'done', 'failed']);
|
|
@@ -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
|
+
}
|