From fc6b9a4eb0408225d1cf89ee215d9d9938358129 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 24 Sep 2026 11:51:39 +0000 Subject: [PATCH 1/2] fix(ai): give provider calls the time research leaves, not a fixed 60 s Claude Opus 5 needs about 68 s to write a case, so every Opus case generation hit the 60 s provider timeout and returned 500. Each call now shares one 140 s request budget, which stays under Supabase's 150 s response limit. Research, the main call, and its JSON repair retry all use what is left. The model matrix reproduces the failure and proves the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- supabase/functions/ai/index.ts | 40 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index bb888fb9c..4d9d93d02 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -737,6 +737,11 @@ async function callAIOnce( ); } +// Supabase answers 504 when a function has not responded within 150 s. Every +// provider call shares one budget below that, so a slow model can use the time +// research left instead of a fixed 60 s. The margin covers logging and saving. +const REQUEST_BUDGET_MS = 140_000; + async function callOpenRouter( messages: Array<{ role: string; content: string }>, model: string, @@ -762,7 +767,7 @@ async function callOpenRouter( method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, body: JSON.stringify(payload), - }, 60000, signal); + }, REQUEST_BUDGET_MS, signal); if (!response.ok) throw new Error(`OpenRouter error: ${errorBody ?? ""}`); if (!data?.choices || !Array.isArray(data.choices) || data.choices.length === 0) throw new Error("OpenRouter returned no choices"); const usage = normalizeProviderUsage(data.usage) ?? missingOpenRouterUsage("response"); @@ -839,6 +844,7 @@ export async function handleRequest(req: Request): Promise { if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } + const workSignal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_BUDGET_MS)]); if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: corsHeaders }); } @@ -1054,7 +1060,7 @@ export async function handleRequest(req: Request): Promise { const evaluated = await evaluateCrossfireExchange({ transcript: crossfireInput.transcript, reply: crossfireInput.reply, - signal: req.signal, + signal: workSignal, }); const signalUsage = evaluated.jev?.usage ?? evaluated.jevUsage; if (signalUsage) await logUsage(userId, "jev_crossfire_signal", provider, JEV_MODEL, signalUsage, undefined, evaluated.jevUsage ? "error" : "success", evaluated.jevUsage ? "jev_incomplete" : undefined); @@ -1235,7 +1241,7 @@ export async function handleRequest(req: Request): Promise { canAccessProModels: Boolean(rateLimit.canAccessProModels), reasoningEffort: requestedReasoningEffort, sourceCount: Array.isArray(rawSources) ? rawSources.length : 0, - signal: req.signal, + signal: workSignal, }); model = resolution.resolvedModel; modelEntry = resolution.modelEntry; @@ -1285,7 +1291,7 @@ export async function handleRequest(req: Request): Promise { feature, requestText: JSON.stringify(sanitizedInput), sources: sourceDecisionCandidates, - signal: req.signal, + signal: workSignal, }); sourceAssessments = ranked.assessments; sources = ranked.sources.map(({ title, content, origin, type }) => ({ title, content, ...(origin ? { origin } : {}), type })); @@ -1314,7 +1320,7 @@ export async function handleRequest(req: Request): Promise { provider, model, reasoningEffort, - signal: req.signal, + signal: workSignal, onTimeout: () => { researchAccountingIncomplete = true; }, @@ -1376,7 +1382,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature); const userPrompt = buildUserPrompt(feature, sparringRequest as unknown as Record, ""); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); const normalizedOutput = normalizeSparringOutput(output, sparringRequest.action); const parsedOutput = parseSparringProviderOutput(normalizedOutput, sparringRequest.action); @@ -1445,7 +1451,7 @@ export async function handleRequest(req: Request): Promise { [{ role: "user", content: userPrompt }], temperature, reasoningEffort, - req.signal, + workSignal, )); const cleanedOutput = cleanGeneratedText(aiResult.output); const output = crossfireInput.operation === "next-turn" @@ -1487,7 +1493,7 @@ export async function handleRequest(req: Request): Promise { const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); // Post-process: quality floor. Only citations whose URL matches fetched, @@ -1545,7 +1551,7 @@ export async function handleRequest(req: Request): Promise { let urlContext = ""; if (urls.size > 0) { const contents = await Promise.all(Array.from(urls).slice(0, 3).map(async (u) => { - const content = await exaGetContents(u, req.signal); + const content = await exaGetContents(u, workSignal); return content.trim() ? `--- Content from ${u} ---\n${content.slice(0, 8000)}\n--- End of ${u} ---` : ""; @@ -1600,7 +1606,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, effectiveResearch); const prompt = `${basePrompt}${urlContext}${webSearchContext}`; const messages = [...history, { role: "user", content: prompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); if (output && typeof output === "object") { @@ -1688,7 +1694,7 @@ export async function handleRequest(req: Request): Promise { feature, requestText: JSON.stringify(sanitizedInput), sources: discoveredCandidates, - signal: req.signal, + signal: workSignal, }); const discoveredRankingUsage = discoveredRanking.jev?.usage ?? discoveredRanking.jevUsage; if (discoveredRankingUsage) await logUsage(userId, "jev_source_ranking", provider, JEV_MODEL, discoveredRankingUsage, undefined, discoveredRanking.jevUsage ? "error" : "success", discoveredRanking.jevUsage ? "jev_incomplete" : undefined); @@ -1717,7 +1723,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); // Preserve model evidence warnings when research exists. If research @@ -1765,7 +1771,7 @@ export async function handleRequest(req: Request): Promise { const caseUsage = await identifyCaseSourceUsage({ caseText: JSON.stringify(output), sources: usageCandidates, - signal: req.signal, + signal: workSignal, }); const caseUsageCost = caseUsage.jev?.usage ?? caseUsage.jevUsage; if (caseUsageCost) await logUsage(userId, "jev_source_usage", provider, JEV_MODEL, caseUsageCost, undefined, caseUsage.jevUsage ? "error" : "success", caseUsage.jevUsage ? "jev_incomplete" : undefined); @@ -1812,7 +1818,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); enforceBrainstormControls(output, side, outputMode); @@ -1840,7 +1846,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); enforceAttackControls(output, detectFallacies, analyzeWeighing); @@ -1856,7 +1862,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature); const userPrompt = buildUserPrompt(feature, sanitizedInput, ""); const messages = [{ role: "user", content: userPrompt }]; - const fallbackResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); + const fallbackResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); const output = cleanGeneratedText(fallbackResult.output); const latencyMs = Date.now() - startTime; @@ -1890,7 +1896,7 @@ export async function handleRequest(req: Request): Promise { : undefined; // A successful main call takes precedence over usage attached to a later // post-processing error. This avoids counting its repair attempts twice. - const mainErrorUsage = completedMainUsage ?? selectObservedUsage(usageError, req.signal?.aborted === true); + const mainErrorUsage = completedMainUsage ?? selectObservedUsage(usageError, workSignal?.aborted === true); await logUsage( userId, feature, From 3334dc211b4007ca7de774e2df1ddb3224ad6d24 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 24 Sep 2026 12:18:56 +0000 Subject: [PATCH 2/2] fix(ai): keep a finished case when the source-usage check runs late The post-generation Jev source-usage check shared the request budget, so a model that finished near the deadline could lose its completed case to a 500. The check now follows only the client's signal and its own 8 s Jev timeout, and the provider budget drops to 130 s to leave room for it under Supabase's 150 s limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- supabase/functions/ai/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 4d9d93d02..2fb805e36 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -739,8 +739,9 @@ async function callAIOnce( // Supabase answers 504 when a function has not responded within 150 s. Every // provider call shares one budget below that, so a slow model can use the time -// research left instead of a fixed 60 s. The margin covers logging and saving. -const REQUEST_BUDGET_MS = 140_000; +// research left instead of a fixed 60 s. The margin covers the post-generation +// source-usage check (its own 8 s Jev timeout), logging, and saving. +const REQUEST_BUDGET_MS = 130_000; async function callOpenRouter( messages: Array<{ role: string; content: string }>, @@ -1768,10 +1769,12 @@ export async function handleRequest(req: Request): Promise { strength: discoveredAssessmentByKey.get(source.key)?.strength ?? 0, })), ].slice(0, 10); + // The case is already complete, so this optional check must not inherit + // an almost spent request budget and turn a finished case into a 500. const caseUsage = await identifyCaseSourceUsage({ caseText: JSON.stringify(output), sources: usageCandidates, - signal: workSignal, + signal: req.signal, }); const caseUsageCost = caseUsage.jev?.usage ?? caseUsage.jevUsage; if (caseUsageCost) await logUsage(userId, "jev_source_usage", provider, JEV_MODEL, caseUsageCost, undefined, caseUsage.jevUsage ? "error" : "success", caseUsage.jevUsage ? "jev_incomplete" : undefined);