Skip to content

Commit 12b440f

Browse files
committed
fix: add billing guard to prevent duplicate charges
1 parent 3d6bd04 commit 12b440f

File tree

1 file changed

+18
-3
lines changed

1 file changed

+18
-3
lines changed

web/src/llm-api/avian.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function getAvianModelId(openrouterModel: string): string {
5252
return AVIAN_MODEL_MAP[openrouterModel] ?? openrouterModel
5353
}
5454

55-
type StreamState = { responseText: string; reasoningText: string; ttftMs: number | null }
55+
type StreamState = { responseText: string; reasoningText: string; ttftMs: number | null; billedAlready: boolean }
5656

5757
type LineResult = {
5858
state: StreamState
@@ -232,7 +232,7 @@ export async function handleAvianStream({
232232
}
233233

234234
let heartbeatInterval: NodeJS.Timeout
235-
let state: StreamState = { responseText: '', reasoningText: '', ttftMs: null }
235+
let state: StreamState = { responseText: '', reasoningText: '', ttftMs: null, billedAlready: false }
236236
let clientDisconnected = false
237237

238238
const stream = new ReadableStream({
@@ -421,6 +421,12 @@ async function handleLine({
421421
return { state: result.state, billedCredits: result.billedCredits, patchedLine }
422422
}
423423

424+
function isFinalChunk(data: Record<string, unknown>): boolean {
425+
const choices = data.choices as Array<Record<string, unknown>> | undefined
426+
if (!choices || choices.length === 0) return true
427+
return choices.some(c => c.finish_reason != null)
428+
}
429+
424430
async function handleResponse({
425431
userId,
426432
stripeCustomerId,
@@ -454,13 +460,22 @@ async function handleResponse({
454460
}): Promise<{ state: StreamState; billedCredits?: number }> {
455461
state = handleStreamChunk({ data, state, startTime, logger, userId, agentId, model: originalModel })
456462

457-
if ('error' in data || !data.usage) {
463+
// Some providers send cumulative usage on EVERY chunk (not just the final one),
464+
// so we must only bill once on the final chunk to avoid charging N times.
465+
if ('error' in data || !data.usage || state.billedAlready || !isFinalChunk(data)) {
466+
// Strip usage from non-final chunks and duplicate final chunks
467+
// so the SDK doesn't see multiple usage objects
468+
if (data.usage && (!isFinalChunk(data) || state.billedAlready)) {
469+
delete data.usage
470+
}
458471
return { state }
459472
}
460473

461474
const usageData = extractUsageAndCost(data.usage as Record<string, unknown>, avianModelId)
462475
const messageId = typeof data.id === 'string' ? data.id : 'unknown'
463476

477+
state.billedAlready = true
478+
464479
insertMessageToBigQuery({
465480
messageId,
466481
userId,

0 commit comments

Comments
 (0)