mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-09 07:25:34 +00:00
fix(ai-speech2txt): charge Gemini transcriptions that fail after Google answers
Google bills an interaction it processed whether or not it completed, so an incomplete, failed or malformed reply was free to the caller and paid by Puter; audio whose transcript overran the output cap could be resubmitted at no cost. Usage is now recorded before the reply is checked, falling back to the counted audio tokens when the reported usage can't be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
724c0e6a7d
commit
a1d800d94d
3 files changed
+44
-30
No files matched your search
+30
-16
@@ -809,7 +809,7 @@ describe('GeminiSpeechToTextProvider response', () => {
|
||||
|
||||
describe('GeminiSpeechToTextProvider upstream failures', () => {
|
||||
it.each(['incomplete', 'failed', 'cancelled', 'in_progress'])(
|
||||
'fails a %s interaction as 502 upstream_failed without charging',
|
||||
'fails a %s interaction as 502 upstream_failed and charges its usage',
|
||||
async (status) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
@@ -821,10 +821,25 @@ describe('GeminiSpeechToTextProvider upstream failures', () => {
|
||||
),
|
||||
).toMatchObject({ statusCode: 502, legacyCode: 'upstream_failed' });
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([71, 6]);
|
||||
},
|
||||
);
|
||||
|
||||
it('charges the counted audio for an incomplete reply without usage', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(interaction({ status: 'incomplete', usage: undefined }));
|
||||
expect(
|
||||
await callerError(() => driver.transcribe({ file: dataUrl() }), actor),
|
||||
).toMatchObject({ statusCode: 502, legacyCode: 'upstream_failed' });
|
||||
expect(metered()).toEqual([
|
||||
expect.objectContaining({
|
||||
usageType: 'gemini:gemini-3.5-transcribe:input:audio',
|
||||
usageAmount: COUNTED_TOKENS,
|
||||
costOverride: COUNTED_TOKENS * 200,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails a reply with no transcript text as 400 upstream_bad_request', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(interaction({ content: [] }));
|
||||
@@ -834,7 +849,7 @@ describe('GeminiSpeechToTextProvider upstream failures', () => {
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([71, 6]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -868,7 +883,7 @@ describe('GeminiSpeechToTextProvider upstream failures', () => {
|
||||
statusCode: 502,
|
||||
message: 'Gemini transcription returned a malformed transcript',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([71, 6]);
|
||||
});
|
||||
|
||||
it('fails a non-JSON reply as 502', async () => {
|
||||
@@ -1262,22 +1277,21 @@ describe('GeminiSpeechToTextProvider usage', () => {
|
||||
},
|
||||
],
|
||||
])(
|
||||
'fails %s as 502 without fabricating usage',
|
||||
'charges only the counted audio for %s',
|
||||
async (_label, usage) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
reply(interaction({ usage }));
|
||||
expect(
|
||||
await callerError(
|
||||
() => driver.transcribe({ file: dataUrl() }),
|
||||
actor,
|
||||
),
|
||||
).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
message: 'Gemini transcription returned unusable usage',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
withActor(actor, () => driver.transcribe({ file: dataUrl() })),
|
||||
).resolves.toMatchObject({ text: 'The blue lantern is ready.' });
|
||||
expect(metered()).toEqual([
|
||||
expect.objectContaining({
|
||||
usageType: 'gemini:gemini-3.5-transcribe:input:audio',
|
||||
usageAmount: COUNTED_TOKENS,
|
||||
costOverride: COUNTED_TOKENS * 200,
|
||||
}),
|
||||
]);
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -402,19 +402,13 @@ export class GeminiSpeechToTextProvider extends SpeechToTextProvider {
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (interaction.status !== 'completed') {
|
||||
throw geminiUpstreamFailure(
|
||||
'Gemini transcription did not complete the request',
|
||||
);
|
||||
}
|
||||
const { text, words } = this.#readTranscript(interaction);
|
||||
|
||||
const billed = billedTokens(interaction.usage);
|
||||
if (!billed) {
|
||||
throw geminiUpstreamFailure(
|
||||
'Gemini transcription returned unusable usage',
|
||||
);
|
||||
}
|
||||
// Google bills what it processed, finished or not, so usage is
|
||||
// recorded before the reply is checked. The counted audio stands in
|
||||
// when the reply's usage can't be read.
|
||||
const billed = billedTokens(interaction.usage) ?? {
|
||||
audio: inputTokens,
|
||||
output: 0,
|
||||
};
|
||||
const usages: UsageInput[] = [
|
||||
{
|
||||
usageType: `${usagePrefix}:input:audio`,
|
||||
@@ -431,6 +425,12 @@ export class GeminiSpeechToTextProvider extends SpeechToTextProvider {
|
||||
await metering.batchIncrementUsages(actor, usages);
|
||||
}
|
||||
|
||||
if (interaction.status !== 'completed') {
|
||||
throw geminiUpstreamFailure(
|
||||
'Gemini transcription did not complete the request',
|
||||
);
|
||||
}
|
||||
const { text, words } = this.#readTranscript(interaction);
|
||||
return this.#result(text, options, words);
|
||||
} finally {
|
||||
await hold.release();
|
||||
|
||||
@@ -64,7 +64,7 @@ Gemini rejects options it can't honor with `bad_request` instead of ignoring the
|
||||
|
||||
Gemini accepts WAV, MP3, AIFF, AAC, OGG, FLAC, MPEG, M4A, L16, Opus, A-law, mu-law and WebM audio, sent with an `audio/*` MIME type (`audio/wav`, `audio/mpeg`, `audio/webm`, and so on). Other types fail with `bad_request`, and the audio isn't converted. The audio can be at most 10 MiB, or 1 hour long, or 30 minutes long with word timestamps or `diarize`. Gemini enforces the duration limits itself, and the call fails when the audio is longer. A transcript is limited to 8,192 output tokens. A longer one fails with `502 upstream_failed`, so split long recordings into shorter files.
|
||||
|
||||
Before transcribing, Puter reserves the cost of the counted audio tokens plus the full 8,192 output tokens, then charges the tokens Gemini reports.
|
||||
Before transcribing, Puter reserves the cost of the counted audio tokens plus the full 8,192 output tokens, then charges the tokens Gemini reports. A transcription Gemini answers is charged even when it fails, as Gemini bills it, including one cut off at the output limit. When Gemini reports no usable token counts, only the counted audio is charged.
|
||||
|
||||
#### `testMode` (Boolean) (optional)
|
||||
|
||||
|
||||
Reference in new issue
Block a user