Files
gods-eye-view/src/voice/voiceCost.js
T

471 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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',
/** 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',
/** 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,
};
}
/* ------------------------------------------------------------------ *
* 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) {
const usd = estimateUsageCostUsd(usage, model.rates);
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();
},
};
}