Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/api/app/v1/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export async function POST(req: Request): Promise<Response> {
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 } });

Expand All @@ -135,7 +136,7 @@ export async function POST(req: Request): Promise<Response> {
// 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"
Expand Down Expand Up @@ -491,6 +492,13 @@ export async function POST(req: Request): Promise<Response> {
}
} 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't block Telegram's final edit on persistence

For internal Telegram turns, streamToTelegram only calls editFinal after the for await over res.body reaches EOF (apps/api/app/api/telegram/route.ts:364-388). Since this line waits for persistTurn() before controller.close(), any slow or hung transcript/usage/cache/telemetry write leaves users on “…” or a throttled draft even though the terminal answer has already been emitted; on cache-hit/refusal paths there are no deltas, so the placeholder is delayed entirely. Consider rendering the final answer before draining/persisting, while still keeping the invocation alive for persistence.

Useful? React with 👍 / 👎.

// 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 {
Expand All @@ -507,7 +515,7 @@ export async function POST(req: Request): Promise<Response> {
// 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<void> {
await Promise.allSettled([
flushTelemetry(),
// Shared spend meter is best-effort; the in-memory per-instance meter still applies.
Expand Down Expand Up @@ -536,7 +544,8 @@ export async function POST(req: Request): Promise<Response> {
)
: Promise.resolve(),
]);
});
}
if (!internal) after(persistTurn);

return new Response(stream, {
headers: { ...cors, ...gate.headers, "content-type": "application/x-ndjson", "cache-control": "no-store" },
Expand Down
Loading