diff --git a/apps/api/app/v1/chat/route.ts b/apps/api/app/v1/chat/route.ts index 83fa290..7eeab59 100644 --- a/apps/api/app/v1/chat/route.ts +++ b/apps/api/app/v1/chat/route.ts @@ -113,7 +113,8 @@ export async function POST(req: Request): Promise { const cors = corsHeaders(req); // An in-process caller (the Telegram bridge, proven by the per-boot token) skips the // origin/key checks and rate-limits on the upstream user id it names in the bucket header. - const gate = await requestGate(req, { internal: isInternalCall(req) }); + const internal = isInternalCall(req); + const gate = await requestGate(req, { internal }); if (!gate.ok) return Response.json({ error: gate.reason }, { status: gate.status, headers: { ...cors, ...gate.headers } }); @@ -135,7 +136,7 @@ export async function POST(req: Request): Promise { // per-app breakdown. telegram is server-derived (only the in-process bridge holds the // internal token); browsers always send an Origin on a cross-site POST, so an Origin-less // call is server-to-server API traffic. Client-declared bits are analytics, not trust. - const channel: Channel = isInternalCall(req) + const channel: Channel = internal ? "telegram" : body.channel === "playground" ? "playground" @@ -491,6 +492,13 @@ export async function POST(req: Request): Promise { } } finally { send({ type: "done" }); + // The Telegram bridge invokes this handler from inside its own after() callback, + // and an after() registered there NEVER fires — which silently dropped usage + // rollups and transcripts for every bridge turn. Internal calls therefore persist + // INLINE before the stream closes: the bridge reads to EOF, so completion is + // guaranteed. Public calls keep after() below so their EOF isn't delayed by DB + // writes. + if (internal) await persistTurn(); // close() also throws once the client has disconnected (already-closed controller); // swallow it like send() so an abort doesn't reject out of start(). try { @@ -507,7 +515,7 @@ export async function POST(req: Request): Promise { // After the streamed response finishes: flush spans, record spend, write the cache, roll up // usage, persist the turn. All best-effort and independent — allSettled so a failure in one // never skips the others (or affects the already-streamed response). - after(async () => { + async function persistTurn(): Promise { await Promise.allSettled([ flushTelemetry(), // Shared spend meter is best-effort; the in-memory per-instance meter still applies. @@ -536,7 +544,8 @@ export async function POST(req: Request): Promise { ) : Promise.resolve(), ]); - }); + } + if (!internal) after(persistTurn); return new Response(stream, { headers: { ...cors, ...gate.headers, "content-type": "application/x-ndjson", "cache-control": "no-store" },