Skip to content

Commit 28678c8

Browse files
committed
Count only a non-empty streamed reply as finished, so an empty one is refunded
1 parent 3c4914b commit 28678c8

2 files changed

Lines changed: 22 additions & 5 deletions

File tree

‎supabase/functions/ai/provider.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,9 @@ export interface GenerationStream {
218218
/** The request moved to a step that writes nothing yet. */
219219
phase: (name: GenerationPhase) => void;
220220
/**
221-
* The model ended its answer (any finish_reason but "length", which is
222-
* retried). Arrives before the usage trailer, which a disconnect can lose.
221+
* The model ended a non-empty answer (any finish_reason but "length",
222+
* which is retried, or [DONE]). Arrives before the usage trailer, which a
223+
* disconnect can lose.
223224
*/
224225
finished?: () => void;
225226
}
@@ -484,8 +485,9 @@ export async function readOpenRouterStream(
484485
const data = trimmed.slice("data:".length).trim();
485486
if (data === "[DONE]") {
486487
done = true;
487-
// A stream that ends without a finish_reason still finished its answer.
488-
if (!finishReason) stream.finished?.();
488+
// A stream that ends without a finish_reason still finished its answer,
489+
// if it wrote one: an empty reply is a failure, not an answer.
490+
if (!finishReason && text) stream.finished?.();
489491
return;
490492
}
491493
let chunk: OpenRouterStreamChunk;
@@ -510,7 +512,7 @@ export async function readOpenRouterStream(
510512
stream.delta(content);
511513
}
512514
if (typeof choice?.finish_reason === "string" && choice.finish_reason) {
513-
if (!finishReason && choice.finish_reason !== "length") stream.finished?.();
515+
if (!finishReason && choice.finish_reason !== "length" && text) stream.finished?.();
514516
finishReason = choice.finish_reason;
515517
}
516518
};

‎supabase/functions/ai/quota-sessions_test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,21 @@ Deno.test("a finished answer stays charged when the provider connection drops be
333333
}
334334
});
335335

336+
Deno.test("an empty streamed reply is refunded even though the model said it finished", async () => {
337+
const originalError = console.error;
338+
console.error = () => undefined;
339+
install();
340+
try {
341+
reset({ content: "" });
342+
const text = await (await send({ feature: "ask", input: { message: "A question", mode: "general", webSearch: false }, stream: true })).text();
343+
assertEquals(rpcNames(), ["consume_ai_quota", "refund_daily_quota"]);
344+
assertEquals(text.includes('"charged":false'), true);
345+
} finally {
346+
globalThis.fetch = originalFetch;
347+
console.error = originalError;
348+
}
349+
});
350+
336351
Deno.test("a quota check reports a round's cost and what is left without reserving", async () => {
337352
install();
338353
try {

0 commit comments

Comments
 (0)