diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index b1e773e49..541e2cc04 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -3208,6 +3208,7 @@ test('OAuth cloud sessions report unknown cost and usage without tripping the AP responses: 1, input: 0, output: 1562500, + captions: 0, costKnown: false, }); const after = controller.costTracker.state(); @@ -3220,6 +3221,28 @@ test('OAuth cloud sessions report unknown cost and usage without tripping the AP assert.match(ui.costValue.title, /Usage is incomplete/); }); +test('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => { + const { controller, ui } = costControllerHarness(); + controller.status = 'listening'; + controller._cost.cloudVoiceAuth = 'oauth'; + controller._cost.sessionCloudVoiceAuth = 'oauth'; + controller._cost.bindTranscriptionModel('gpt-4o-mini-transcribe'); + const before = controller.costTracker.state(); + // Priced, this much caption audio would cross the $5 cap. + const result = controller.recordTranscriptionUsage({ + type: 'duration', + seconds: 3_600_000, + }); + assert.equal(result.captions, 1); + assert.equal(result.costKnown, false); + const after = controller.costTracker.state(); + assert.equal(after.totalUsd, before.totalUsd); + assert.equal(after.capReached, false); + assert.equal(controller.costCapStopped, false); + assert.equal(ui.costValue.textContent, 'COST UNKNOWN'); + assert.match(ui.costValue.title, /1 caption transcription\(s\)/); +}); + test('F1: when idle, toggling does re-price the preview meter', () => { const { controller } = costControllerHarness(); controller.status = 'idle'; diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index 0d48a7701..d3d7f684e 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -20,7 +20,7 @@ export class RealtimeCost { this.voiceLimits = readStoredVoiceLimits(); this.cloudVoiceAuth = readStoredCloudVoiceAuthMode(); this.sessionCloudVoiceAuth = null; - this.oauthUsage = { responses: 0, input: 0, output: 0 }; + this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 0 }; this.transcribeModel = null; this.costTracker = createVoiceCostTracker({ tier: this.voiceTier, @@ -64,12 +64,12 @@ export class RealtimeCost { if (this.ui?.costValue) { const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth; if (liveAuth === 'oauth') { - const { responses, input, output } = this.oauthUsage; + 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 reported. ` + + `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.' @@ -176,6 +176,13 @@ export class RealtimeCost { */ 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), @@ -243,7 +250,7 @@ export class RealtimeCost { this.voiceLimits = readStoredVoiceLimits(); this.cloudVoiceAuth = readStoredCloudVoiceAuthMode(); this.sessionCloudVoiceAuth = this.cloudVoiceAuth; - this.oauthUsage = { responses: 0, input: 0, output: 0 }; + 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