mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- i18n scanner: lastError sink, no false sinks on defaults and ternaries - shared src/i18n/marker.js for modules that only mark strings - wind field messages move to wind/fields.js; CITY_POIS imports from cityPois.js - drop review IDs, change history and stale references from comments and docs Co-Authored-By: Claude Opus 5.5 <[email protected]>
541 lines
20 KiB
JavaScript
541 lines
20 KiB
JavaScript
// src/voice/voiceCost.js
|
||
/**
|
||
* Voice model registry + Realtime session cost estimation.
|
||
*
|
||
* Pure module (no DOM, no network, no imports) so it can be shared by three
|
||
* callers that cannot share anything else:
|
||
* 1. the browser voice UI (`gevRealtime.js`) — live "~$0.42" readout + caps
|
||
* 2. the dev-server token endpoint (`vite.config.js` → `/api/realtime/token`)
|
||
* 3. unit tests (`voiceCost.test.mjs`)
|
||
*
|
||
* Two independent concerns live here:
|
||
* - MODEL TIERS: 'standard' (default) vs 'mini' (cheaper). The client asks
|
||
* for a tier by NAME; only this module maps a tier to an OpenAI model id,
|
||
* so an unknown/hostile tier string can never reach the OpenAI API.
|
||
* - SPEND GUARD: token usage → USD, with a soft warning and a hard cap.
|
||
*
|
||
* @module voice/voiceCost
|
||
*/
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* MODEL REGISTRY
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* ⚠️ VERIFY AT RELEASE — MODEL IDS AND PRICES ARE EXTERNAL FACTS THAT DRIFT. ⚠️
|
||
*
|
||
* Both model ids and every rate below were read from OpenAI's own model +
|
||
* pricing pages on 2026-08-18:
|
||
* - https://developers.openai.com/api/docs/models/gpt-realtime-2
|
||
* - https://developers.openai.com/api/docs/models/gpt-realtime-2.1-mini
|
||
* - https://developers.openai.com/api/docs/pricing
|
||
*
|
||
* Cross-check at release time (a wrong rate silently mis-sizes the spend cap,
|
||
* and a wrong model id fails the session at connect time):
|
||
* - `standard` MUST stay in sync with OPENAI_REALTIME_MODEL / the
|
||
* OPENAI_REALTIME_MODEL_DEFAULT constant in vite.config.js.
|
||
* - `mini` has no in-repo history — it is new here. OpenAI publishes both
|
||
* `gpt-realtime-2.1-mini` (current, used below) and an older
|
||
* `gpt-realtime-mini`; there is NO `gpt-realtime-2-mini`. If the id ever
|
||
* moves, override it with OPENAI_REALTIME_MODEL_MINI rather than editing
|
||
* code — see .env.example.
|
||
*
|
||
* Rates are USD per 1,000,000 tokens.
|
||
*/
|
||
export const VOICE_MODEL_RATES_VERIFIED_ON = '2026-08-18';
|
||
|
||
/** @typedef {'standard'|'mini'} VoiceModelTier */
|
||
|
||
export const VOICE_MODELS = Object.freeze({
|
||
standard: Object.freeze({
|
||
tier: 'standard',
|
||
id: 'gpt-realtime-2',
|
||
label: 'STANDARD', // i18n-ignore: model data; the tier button has its own text
|
||
/** USD per 1M tokens — gpt-realtime-2. */
|
||
rates: Object.freeze({
|
||
textInput: 4,
|
||
textCachedInput: 0.4,
|
||
textOutput: 24,
|
||
audioInput: 32,
|
||
audioCachedInput: 0.4,
|
||
audioOutput: 64,
|
||
imageInput: 5,
|
||
imageCachedInput: 0.5,
|
||
}),
|
||
}),
|
||
mini: Object.freeze({
|
||
tier: 'mini',
|
||
id: 'gpt-realtime-2.1-mini',
|
||
label: 'MINI', // i18n-ignore: model data; the tier button has its own text
|
||
/** USD per 1M tokens — gpt-realtime-2.1-mini (~3.2× cheaper on audio). */
|
||
rates: Object.freeze({
|
||
textInput: 0.6,
|
||
textCachedInput: 0.06,
|
||
textOutput: 2.4,
|
||
audioInput: 10,
|
||
audioCachedInput: 0.3,
|
||
audioOutput: 20,
|
||
imageInput: 0.8,
|
||
imageCachedInput: 0.08,
|
||
}),
|
||
}),
|
||
});
|
||
|
||
/** The tier used when nothing (or nonsense) was requested. */
|
||
export const DEFAULT_VOICE_TIER = 'standard';
|
||
|
||
/** Every tier name the UI and the token endpoint accept. */
|
||
export const VOICE_TIERS = Object.freeze(Object.keys(VOICE_MODELS));
|
||
|
||
/**
|
||
* Map a requested tier name to its model entry, falling back to `standard`.
|
||
*
|
||
* Deliberately total: an unknown, empty, non-string, or hostile value resolves
|
||
* to the default rather than throwing, so a bad querystring degrades to the
|
||
* normal session instead of breaking the mic. Callers that need to know a
|
||
* fallback happened compare `entry.tier` to what they asked for.
|
||
*
|
||
* @param {unknown} tier
|
||
* @returns {{tier: VoiceModelTier, id: string, label: string, rates: object}}
|
||
*/
|
||
export function resolveVoiceModel(tier) {
|
||
// Own-property check, NOT `VOICE_MODELS[key] || default`: inherited keys
|
||
// ('constructor', 'toString', '__proto__') resolve to truthy Object.prototype
|
||
// members, which would sail past a `||` fallback and hand the token endpoint
|
||
// a bogus entry whose `.id` is undefined.
|
||
return isKnownVoiceTier(tier)
|
||
? VOICE_MODELS[String(tier).trim().toLowerCase()]
|
||
: VOICE_MODELS[DEFAULT_VOICE_TIER];
|
||
}
|
||
|
||
/** True only for a tier name this build knows (own properties only). */
|
||
export function isKnownVoiceTier(tier) {
|
||
const key = typeof tier === 'string' ? tier.trim().toLowerCase() : '';
|
||
return Object.prototype.hasOwnProperty.call(VOICE_MODELS, key);
|
||
}
|
||
|
||
/**
|
||
* The priciest known rate table, derived (not hardcoded) so it stays correct if
|
||
* the registry gains a tier. Used as the conservative default whenever we do
|
||
* not recognise the model a session is actually running on.
|
||
*/
|
||
export function mostExpensiveVoiceModel() {
|
||
// Rank by audio output — the dominant cost in a speech-to-speech session.
|
||
return Object.values(VOICE_MODELS).reduce((worst, entry) =>
|
||
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve the rate table for the model a session is ACTUALLY running on.
|
||
*
|
||
* The tier a client asked for is only a request: `OPENAI_REALTIME_MODEL` /
|
||
* `OPENAI_REALTIME_MODEL_MINI` can point a tier at any model id, so pricing by
|
||
* tier would silently mis-meter (and overrun the cap) whenever an override is
|
||
* set. Pricing by the id the server echoes back closes that gap.
|
||
*
|
||
* An unrecognised id bills at the most expensive known rates rather than
|
||
* guessing cheap — under-metering is what lets a cap be overrun.
|
||
*
|
||
* @param {unknown} modelId
|
||
* @returns {{tier: string, id: string, label: string, rates: object, recognized: boolean}}
|
||
*/
|
||
export function resolveVoiceModelById(modelId) {
|
||
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
||
for (const entry of Object.values(VOICE_MODELS)) {
|
||
if (entry.id === id) return { ...entry, recognized: true };
|
||
}
|
||
const worst = mostExpensiveVoiceModel();
|
||
return {
|
||
...worst,
|
||
// Report the real model so the UI/diagnostics never claim the wrong one.
|
||
id: id || worst.id,
|
||
recognized: false,
|
||
};
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* INPUT TRANSCRIPTION (voice-card captions)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* ⚠️ VERIFY AT RELEASE — external prices, USD per 1M tokens, same shape as
|
||
* the realtime rate tables. Transcription is billed separately from the
|
||
* session's responses and arrives as `usage` on
|
||
* `conversation.item.input_audio_transcription.completed`. An unknown model
|
||
* bills at the most expensive entry, as for session models.
|
||
*/
|
||
const transcriptionTable = ({ textInput, audioInput, textOutput }) =>
|
||
Object.freeze({
|
||
textInput,
|
||
audioInput,
|
||
textOutput,
|
||
// Transcription output is text; usage without output details would
|
||
// otherwise be read as audio and priced at zero. Cached input is priced
|
||
// as uncached so the meter never under-counts.
|
||
audioOutput: textOutput,
|
||
textCachedInput: textInput,
|
||
audioCachedInput: audioInput,
|
||
});
|
||
|
||
export const TRANSCRIPTION_MODEL_RATES = Object.freeze({
|
||
'gpt-4o-mini-transcribe': transcriptionTable({
|
||
textInput: 1.25,
|
||
audioInput: 3,
|
||
textOutput: 5,
|
||
}),
|
||
'gpt-4o-transcribe': transcriptionTable({
|
||
textInput: 2.5,
|
||
audioInput: 6,
|
||
textOutput: 10,
|
||
}),
|
||
});
|
||
|
||
/** Per-second rate for duration-billed transcription (whisper-style). */
|
||
export const TRANSCRIPTION_USD_PER_SECOND = 0.006 / 60;
|
||
|
||
/** Rate table for a transcription model; unknown ids bill at the priciest. */
|
||
export function transcriptionRates(modelId) {
|
||
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
||
if (Object.prototype.hasOwnProperty.call(TRANSCRIPTION_MODEL_RATES, id))
|
||
return TRANSCRIPTION_MODEL_RATES[id];
|
||
return Object.values(TRANSCRIPTION_MODEL_RATES).reduce((worst, rates) =>
|
||
rates.audioInput > worst.audioInput ? rates : worst,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* USD for one transcription's usage: token usage priced with the model's
|
||
* rates, or duration usage priced per second.
|
||
*/
|
||
export function estimateTranscriptionCostUsd(usage, modelId) {
|
||
if (!usage) return 0;
|
||
if (usage.type === 'duration') {
|
||
const usd = nonNegative(usage.seconds) * TRANSCRIPTION_USD_PER_SECOND;
|
||
return Number.isFinite(usd) ? usd : 0;
|
||
}
|
||
return estimateUsageCostUsd(usage, transcriptionRates(modelId));
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* SPEND GUARD CONFIG
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Session spend thresholds, in USD. Deliberately generous — this is a runaway
|
||
* guard (a hot mic left open, a feedback loop), not a budget.
|
||
*
|
||
* `warnUsd`: soft — one visual cue + one console line, session continues.
|
||
* `capUsd`: hard — the session is closed through the normal stop path.
|
||
*
|
||
* Either may be null/0/Infinity to disable that threshold.
|
||
*/
|
||
export const VOICE_COST_LIMITS = Object.freeze({
|
||
warnUsd: 2,
|
||
capUsd: 5,
|
||
});
|
||
|
||
/**
|
||
* Serialized sentinel for a disabled threshold.
|
||
*
|
||
* A disabled threshold is Infinity in memory, but `JSON.stringify(Infinity)`
|
||
* is `null`, which reads back as "absent" and silently restores the DEFAULT —
|
||
* so a deliberately disabled cap would quietly re-arm on the next session.
|
||
* Persist this string instead; `normalizeCostLimits` accepts it on the way in.
|
||
*/
|
||
export const VOICE_COST_LIMIT_OFF = 'off';
|
||
|
||
/**
|
||
* Normalize a partial limits object against the defaults.
|
||
*
|
||
* Accepted "disabled" spellings: the `'off'` sentinel, Infinity, and a
|
||
* 0/negative number. Absent/undefined/null and unparseable values fall back to
|
||
* the default — a corrupt entry must never disarm the cap.
|
||
*/
|
||
export function normalizeCostLimits(limits) {
|
||
const clean = (value, fallback) => {
|
||
if (
|
||
typeof value === 'string' &&
|
||
value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF
|
||
) {
|
||
return Infinity;
|
||
}
|
||
if (value === Infinity) return Infinity;
|
||
if (value === null || value === undefined) return fallback;
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return n > 0 ? n : Infinity; // 0/negative = "no threshold"
|
||
};
|
||
return Object.freeze({
|
||
warnUsd: clean(limits?.warnUsd, VOICE_COST_LIMITS.warnUsd),
|
||
capUsd: clean(limits?.capUsd, VOICE_COST_LIMITS.capUsd),
|
||
});
|
||
}
|
||
|
||
/** Convert limits to a JSON-safe shape that round-trips a disabled threshold. */
|
||
export function serializeCostLimits(limits) {
|
||
const normalized = normalizeCostLimits(limits);
|
||
const encode = (value) =>
|
||
Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF;
|
||
return {
|
||
warnUsd: encode(normalized.warnUsd),
|
||
capUsd: encode(normalized.capUsd),
|
||
};
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* USAGE → USD
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const nonNegative = (value) => {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||
};
|
||
|
||
/**
|
||
* Split one Realtime `response.done` usage object into billable token buckets.
|
||
*
|
||
* The Realtime usage shape reports per-modality detail with CACHED TOKENS
|
||
* INCLUDED in the modality totals, so uncached = modality − cached(modality).
|
||
*
|
||
* Older/partial payloads may omit `input_token_details` entirely. In that case
|
||
* the whole input is attributed to AUDIO, the most expensive modality. That
|
||
* deliberately over-estimates: for a spend cap the safe failure direction is
|
||
* stopping early, never billing past the cap because a field was missing.
|
||
*
|
||
* The same policy applies to PARTIAL detail objects, which are the subtler
|
||
* case: if `input_tokens` is 1000 but the only detail present is
|
||
* `text_tokens: 100`, the other 900 tokens are real and billed upstream.
|
||
* Counting only the named buckets would silently drop them and UNDER-meter —
|
||
* exactly the direction that lets a cap be overrun. Any aggregate-minus-details
|
||
* residual is therefore attributed to audio rates.
|
||
*
|
||
* @param {object|null|undefined} usage - `response.usage` from `response.done`.
|
||
*/
|
||
export function splitUsageTokens(usage) {
|
||
const inDetails = usage?.input_token_details || null;
|
||
const outDetails = usage?.output_token_details || null;
|
||
const cached = inDetails?.cached_tokens_details || null;
|
||
|
||
const inputTotal = nonNegative(usage?.input_tokens);
|
||
const outputTotal = nonNegative(usage?.output_tokens);
|
||
|
||
let textIn;
|
||
let audioIn;
|
||
let imageIn;
|
||
let textCached;
|
||
let audioCached;
|
||
let imageCached;
|
||
|
||
if (inDetails) {
|
||
textCached = nonNegative(cached?.text_tokens);
|
||
audioCached = nonNegative(cached?.audio_tokens);
|
||
imageCached = nonNegative(cached?.image_tokens);
|
||
// When only an aggregate `cached_tokens` is present, attribute it to audio
|
||
// (dominant modality in a voice session) so cache savings still register.
|
||
if (!cached && nonNegative(inDetails.cached_tokens) > 0) {
|
||
audioCached = Math.min(
|
||
nonNegative(inDetails.cached_tokens),
|
||
nonNegative(inDetails.audio_tokens),
|
||
);
|
||
}
|
||
textIn = Math.max(0, nonNegative(inDetails.text_tokens) - textCached);
|
||
audioIn = Math.max(0, nonNegative(inDetails.audio_tokens) - audioCached);
|
||
imageIn = Math.max(0, nonNegative(inDetails.image_tokens) - imageCached);
|
||
} else {
|
||
// No detail — conservative all-audio attribution (see note above).
|
||
textIn = 0;
|
||
audioIn = inputTotal;
|
||
imageIn = 0;
|
||
textCached = 0;
|
||
audioCached = 0;
|
||
imageCached = 0;
|
||
}
|
||
|
||
// Residual = billed tokens the detail buckets did not account for. Attributed
|
||
// to audio (priciest) so a partial payload over-estimates, never under.
|
||
const inputResidual = Math.max(
|
||
0,
|
||
inputTotal -
|
||
(textIn + audioIn + imageIn + textCached + audioCached + imageCached),
|
||
);
|
||
audioIn += inputResidual;
|
||
|
||
const textOut = outDetails ? nonNegative(outDetails.text_tokens) : 0;
|
||
let audioOut = outDetails
|
||
? nonNegative(outDetails.audio_tokens)
|
||
: outputTotal;
|
||
const outputResidual = Math.max(0, outputTotal - (textOut + audioOut));
|
||
audioOut += outputResidual;
|
||
|
||
return {
|
||
textIn,
|
||
audioIn,
|
||
imageIn,
|
||
textCached,
|
||
audioCached,
|
||
imageCached,
|
||
textOut,
|
||
audioOut,
|
||
inputTotal,
|
||
outputTotal,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Estimate the USD cost of one Realtime response's usage.
|
||
*
|
||
* @param {object|null|undefined} usage - `response.usage` from `response.done`.
|
||
* @param {object} rates - a `VOICE_MODELS[tier].rates` table (USD per 1M).
|
||
* @returns {number} USD, always finite and >= 0.
|
||
*/
|
||
export function estimateUsageCostUsd(usage, rates) {
|
||
if (!usage || !rates) return 0;
|
||
const t = splitUsageTokens(usage);
|
||
const usd =
|
||
(t.textIn * nonNegative(rates.textInput) +
|
||
t.textCached * nonNegative(rates.textCachedInput) +
|
||
t.textOut * nonNegative(rates.textOutput) +
|
||
t.audioIn * nonNegative(rates.audioInput) +
|
||
t.audioCached * nonNegative(rates.audioCachedInput) +
|
||
t.audioOut * nonNegative(rates.audioOutput) +
|
||
t.imageIn * nonNegative(rates.imageInput) +
|
||
t.imageCached * nonNegative(rates.imageCachedInput)) /
|
||
1_000_000;
|
||
return Number.isFinite(usd) && usd > 0 ? usd : 0;
|
||
}
|
||
|
||
/** Format a running cost for the compact UI readout ("~$0.42"). */
|
||
export function formatCostUsd(usd) {
|
||
const n = Number.isFinite(Number(usd)) ? Math.max(0, Number(usd)) : 0;
|
||
if (n > 0 && n < 0.01) return '~$0.01';
|
||
return `~$${n.toFixed(2)}`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* SESSION COST TRACKER (state machine)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Accumulate per-response usage into a running session cost and latch the two
|
||
* threshold crossings.
|
||
*
|
||
* `response.done` usage is PER RESPONSE, not cumulative, so responses sum.
|
||
*
|
||
* Both crossings are independent one-shot latches: each fires on the first
|
||
* `record()` that carries the total at or past its threshold and never again,
|
||
* so one warning line is logged and the cap stop is requested exactly once —
|
||
* even though `record()` keeps being called while the session tears down.
|
||
*
|
||
* The model binding is fixed at construction and never changes: a tracker
|
||
* belongs to exactly one session, priced against the model that session is
|
||
* actually running on. Pass `modelId` (the id the server echoed back) when it
|
||
* is known — it outranks `tier`, because an env override can point a tier at a
|
||
* different model. `tier` alone is only used before a session exists.
|
||
*
|
||
* @param {{tier?: string, modelId?: string,
|
||
* limits?: {warnUsd?: number, capUsd?: number}}} [options]
|
||
*/
|
||
export function createVoiceCostTracker(options = {}) {
|
||
const model = options.modelId
|
||
? resolveVoiceModelById(options.modelId)
|
||
: { ...resolveVoiceModel(options.tier), recognized: true };
|
||
const limits = normalizeCostLimits(options.limits);
|
||
|
||
let totalUsd = 0;
|
||
let responses = 0;
|
||
let warned = false;
|
||
let capped = false;
|
||
let incomplete = false;
|
||
|
||
const snapshot = (warnCrossed = false, capCrossed = false) => ({
|
||
tier: model.tier,
|
||
modelId: model.id,
|
||
/** False when the model was unrecognised and billed at worst-case rates. */
|
||
ratesRecognized: model.recognized !== false,
|
||
totalUsd,
|
||
responses,
|
||
warnUsd: limits.warnUsd,
|
||
capUsd: limits.capUsd,
|
||
/** 'ok' | 'warn' | 'cap' — monotonic; never steps back down. */
|
||
level: capped ? 'cap' : warned ? 'warn' : 'ok',
|
||
/** True only on the single record() that crossed the soft threshold. */
|
||
warnCrossed,
|
||
/** True only on the single record() that crossed the hard cap. */
|
||
capCrossed,
|
||
/** True once the cap is latched — the session must be stopped. */
|
||
capReached: capped,
|
||
/**
|
||
* True when at least one billed response never reported usage (the session
|
||
* was torn down mid-response), so the accounting is PARTIAL.
|
||
*
|
||
* Deliberately not called a lower bound: the estimate can also run high
|
||
* (residuals and unknown models bill at worst-case rates, and sub-cent
|
||
* totals round up), so the total is neither a guaranteed floor nor a
|
||
* guaranteed ceiling — just incomplete. We have no token telemetry for an
|
||
* unfinished response and do not invent one.
|
||
*/
|
||
incomplete,
|
||
/** Compact chip text. The '*' is a see-note mark, NOT a direction claim. */
|
||
display: formatCostUsd(totalUsd) + (incomplete ? '*' : ''),
|
||
/** Prose for the tooltip; null when the accounting is complete. */
|
||
note: incomplete
|
||
? 'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.'
|
||
: null,
|
||
});
|
||
|
||
return {
|
||
model,
|
||
limits,
|
||
/**
|
||
* Fold one response's usage into the session total.
|
||
* @param {object} usage - `response.usage`
|
||
*/
|
||
record(usage) {
|
||
return this.recordUsd(estimateUsageCostUsd(usage, model.rates));
|
||
},
|
||
/**
|
||
* Fold an already-priced cost (e.g. input transcription) into the same
|
||
* session total, so the warning and the cap cover it.
|
||
* @param {number} usd
|
||
*/
|
||
recordUsd(usd) {
|
||
if (usd > 0) {
|
||
totalUsd += usd;
|
||
responses += 1;
|
||
}
|
||
let warnCrossed = false;
|
||
let capCrossed = false;
|
||
if (!warned && totalUsd >= limits.warnUsd) {
|
||
warned = true;
|
||
warnCrossed = true;
|
||
}
|
||
if (!capped && totalUsd >= limits.capUsd) {
|
||
capped = true;
|
||
capCrossed = true;
|
||
}
|
||
return snapshot(warnCrossed, capCrossed);
|
||
},
|
||
/** Current state without folding in new usage. */
|
||
state: () => snapshot(),
|
||
/**
|
||
* Record that a billed response will never report its usage (torn down
|
||
* mid-response). Flags the accounting as incomplete rather than
|
||
* fabricating a token count for it.
|
||
*/
|
||
markIncomplete() {
|
||
incomplete = true;
|
||
return snapshot();
|
||
},
|
||
/** Reset for a new session (same model/limits). */
|
||
reset() {
|
||
totalUsd = 0;
|
||
responses = 0;
|
||
warned = false;
|
||
capped = false;
|
||
incomplete = false;
|
||
return snapshot();
|
||
},
|
||
};
|
||
}
|