Files
gods-eye-view/src/voice/realtimeCost.js
T
Sameh Khamis c99653047f fix(voice): count OAuth caption transcriptions instead of pricing them
Voice-card captions from main priced transcription usage into the API-key
dollar meter. In a ChatGPT OAuth session that could trip the $2 warning or
$5 cap while the readout said COST UNKNOWN. Count them with the session's
other OAuth usage instead.
2026-10-08 11:01:43 -07:00

271 lines
10 KiB
JavaScript

import {
readStoredVoiceTier,
readStoredVoiceLimits,
writeStoredVoiceTier,
writeStoredVoiceLimits,
} from './realtimePreferences.js';
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
import {
createVoiceCostTracker,
resolveVoiceModel,
formatCostUsd,
estimateTranscriptionCostUsd,
} from './voiceCost.js';
/** Own next-session preferences and the immutable-model session cost meter. */
export class RealtimeCost {
constructor({ readUi, readStatus, operations }) {
Object.assign(this, { readUi, readStatus }, operations);
this.voiceTier = readStoredVoiceTier();
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = null;
this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 0 };
this.transcribeModel = null;
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
limits: this.voiceLimits,
});
this.costCapStopped = false;
}
get ui() {
return this.readUi();
}
get status() {
return this.readStatus();
}
/**
* Paint the tier toggle + running cost readout.
*
* Two DIFFERENT sources on purpose: the toggle shows the PENDING preference
* (`this.voiceTier` — what the next session will use), while the cost readout
* shows the LIVE session meter (`this.costTracker` — bound to the model this
* session actually connected with). During a session those two can legitimately
* disagree, which is exactly what "applies next session" means.
*/
syncCostUi() {
const state = this.costTracker.state();
const pendingTier = resolveVoiceModel(this.voiceTier).tier;
const isMini = pendingTier === 'mini';
if (this.ui?.tierButton) {
this.ui.tierButton.textContent = isMini ? 'MINI' : 'STD';
this.ui.tierButton.setAttribute(
'aria-pressed',
isMini ? 'true' : 'false',
);
const pendingId = resolveVoiceModel(pendingTier).id;
this.ui.tierButton.title =
this.isActive() && state.modelId !== pendingId
? `Next session: ${pendingId} — this session stays on ${state.modelId}`
: `Voice model: ${pendingId} — click to switch to ${
isMini ? 'standard' : 'mini'
}; applies next session`;
}
if (this.ui?.costValue) {
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
if (liveAuth === 'oauth') {
const { responses, input, output, captions } = this.oauthUsage;
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = 'COST UNKNOWN';
this.ui.costValue.dataset.level = 'unknown';
this.ui.costValue.title =
`ChatGPT OAuth session: ${responses} response(s), ${input} input and ${output} output tokens, ${captions} caption transcription(s) reported. ` +
'USD cost and the API spend cap are unavailable for this auth mode.' +
(state.incomplete
? ' Usage is incomplete because a response was still in flight when the session ended.'
: '');
return;
}
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = state.display;
this.ui.costValue.dataset.level = state.level;
this.ui.costValue.title =
`Estimated session cost on ${state.modelId} — ${state.responses} response(s). ` +
`Warns at ${formatCostUsd(state.warnUsd)}, ends the session at ${formatCostUsd(state.capUsd)}.` +
(state.note ? ` ${state.note}` : '');
}
}
/**
* Flip STANDARD <-> MINI. Takes effect on the NEXT session: the model is
* fixed when the ephemeral token is minted, so a live session is deliberately
* left alone rather than reconnected mid-sentence.
*/
toggleVoiceTier() {
// Reads the PERSISTED PREFERENCE, never the tracker. The tracker is bound
// to the live session's model and is immutable, so deriving from it made
// every click during a standard session select 'mini' again instead of
// alternating.
const current = resolveVoiceModel(this.voiceTier).tier;
return this.setVoiceTier(current === 'mini' ? 'standard' : 'mini');
}
/**
* Set the voice model tier and persist it as the NEXT-session preference.
*
* INVARIANT — the cost tracker's lifetime is the SESSION's lifetime, and its
* model binding is immutable from start() to stop(). Rebuilding it here would
* erase accrued spend, re-price later usage against a model the session is
* not running on, and let repeated toggles reset the meter past the cap
* indefinitely. So unless the session is FULLY SETTLED (see
* isVoiceSessionSettled — no session AND no transport, which excludes the
* error state that still holds a live channel) this writes the preference
* ONLY. Once settled there is no session meter to protect, so the provisional
* tracker is refreshed to preview the newly selected model.
*/
setVoiceTier(tier) {
this.voiceTier = writeStoredVoiceTier(tier);
if (this.isVoiceSessionSettled()) {
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
limits: this.voiceLimits,
});
}
this.syncCostUi();
if (this.isActive() && this.ui?.detail) {
this.setStatus(
this.status,
`${this.voiceTier.toUpperCase()} applies next session`,
);
}
return this.voiceTier;
}
/**
* Update the spend thresholds ({warnUsd, capUsd}) and persist them.
* Exposed for the settings surface and for tests; no new panel.
*
* Like the tier, this does not rebuild a LIVE session's tracker — that would
* discard accrued spend. New limits arm at the next session start.
*/
setVoiceCostLimits(limits) {
this.voiceLimits = writeStoredVoiceLimits(limits);
if (this.isVoiceSessionSettled()) {
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
limits: this.voiceLimits,
});
}
this.syncCostUi();
return this.voiceLimits;
}
/**
* Fold one response's token usage into the session cost, then act on the
* thresholds: a soft warning (visual + one console line) and a hard cap that
* ends the session through the normal stop path.
*/
recordUsage(usage) {
if (!usage) return null;
if (this.sessionCloudVoiceAuth === 'oauth') {
const count = (value) =>
Number.isFinite(value) && value > 0 ? value : 0;
this.oauthUsage.responses += 1;
this.oauthUsage.input += count(usage.input_tokens);
this.oauthUsage.output += count(usage.output_tokens);
this.syncCostUi();
return { ...this.oauthUsage, costKnown: false };
}
return this.applyCost(this.costTracker.record(usage));
}
/**
* Meter one input transcription (voice-card captions). It is billed apart
* from responses, so it is priced with the transcription model the server
* reported and folded into the same total, warning and cap.
*/
recordTranscriptionUsage(usage) {
if (!usage) return null;
// An OAuth session has no API-dollar meter, so captions are counted
// rather than priced into the API-key warning and cap.
if (this.sessionCloudVoiceAuth === 'oauth') {
this.oauthUsage.captions += 1;
this.syncCostUi();
return { ...this.oauthUsage, costKnown: false };
}
return this.applyCost(
this.costTracker.recordUsd(
estimateTranscriptionCostUsd(usage, this.transcribeModel),
),
);
}
bindTranscriptionModel(model) {
this.transcribeModel = model && model !== 'off' ? model : null;
}
applyCost(state) {
this.syncCostUi();
if (state.warnCrossed) {
// Exactly one line — the latch in the tracker guarantees it.
console.warn(
`[GEV voice] session cost ${state.display} crossed the ${formatCostUsd(
state.warnUsd,
)} warning threshold (model ${state.modelId}); hard cap ${formatCostUsd(state.capUsd)}.`,
);
}
// NOTE: field names avoid /token|secret|key/ — the debug-log sanitizer
// redacts values under any such key, which would blank the usage numbers.
this.debugLog('voice.cost', {
costUsd: Number(state.totalUsd.toFixed(6)),
tier: state.tier,
modelId: state.modelId,
responses: state.responses,
level: state.level,
});
if (state.capCrossed) this.handleCostCap(state);
return state;
}
/**
* Hard cap reached — end the session gracefully. Uses the ordinary stop path
* (data channel closed, peer connection closed, mic tracks stopped) so the
* mic is genuinely released, then overrides the status line with the reason.
* `preserveStatus` keeps stop() from writing its own "Voice off" over it.
*/
handleCostCap(state) {
if (this.costCapStopped) return;
this.costCapStopped = true;
console.warn(
`[GEV voice] session cost ${state.display} reached the ${formatCostUsd(
state.capUsd,
)} cap — ending the voice session.`,
);
this.debugLog('voice.cost.cap', {
costUsd: Number(state.totalUsd.toFixed(6)),
capUsd: state.capUsd,
tier: state.tier,
modelId: state.modelId,
});
try {
this.stop({ preserveStatus: true });
} finally {
this.setStatus('idle', `Session ended — cost cap ${state.display}`);
this.syncCostUi();
}
}
prepareSession() {
this.voiceTier = readStoredVoiceTier();
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = this.cloudVoiceAuth;
this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 0 };
this.costCapStopped = false;
// Provisional meter (tier-priced) so the readout shows $0.00 while
// connecting. It is REPLACED below with one bound to the model the server
// actually served, before any usage can arrive.
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
limits: this.voiceLimits,
});
}
bindServedModel(model) {
this.costTracker = createVoiceCostTracker({
modelId: model || resolveVoiceModel(this.voiceTier).id,
limits: this.voiceLimits,
});
return this.costTracker.state();
}
}