From 451eae7fb1e3beea4040f3d26525501d3fdeb557 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:00:00 +0000 Subject: [PATCH 1/4] Repair model JSON locally and enforce schemas before paying for a retry An invalid reply used to cost a full second generation. Now: - the parser fixes fences, prose around the object, trailing commas and raw line breaks in strings before giving up; - a reply cut off at the output token cap is not retried, since the same cap would cut the retry too; - Crossfire turns and debriefs, the Ask search decision and research follow-up queries send a strict json_schema, so the provider holds the reply to the shape the server already validates. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 + .../functions/_shared/crossfire-response.ts | 52 +++++++ supabase/functions/_shared/research.ts | 35 +++++ .../functions/ai/features/crossfire-input.ts | 14 ++ supabase/functions/ai/features/crossfire.ts | 6 +- supabase/functions/ai/index.ts | 2 +- supabase/functions/ai/provider-errors_test.ts | 39 ++++- supabase/functions/ai/provider.ts | 140 ++++++++++++++---- 8 files changed, 262 insertions(+), 28 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 30b508c00..4c22e61d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,6 +134,8 @@ Checkout and portal functions create Stripe sessions after authenticated ownersh All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Opus 5.5 for Pro), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. +Every model call asks for JSON. Calls whose shape the server checks in code (the Crossfire next turn and debrief, the Ask search decision, and research follow-up queries) send a strict JSON Schema, so OpenRouter routes them only to endpoints that enforce it; the other features use plain JSON mode. [`supabase/functions/ai/provider.ts`](../supabase/functions/ai/provider.ts) fixes common slips in a reply locally (markdown fences, prose around the object, trailing commas, raw line breaks in strings) before it pays for one repair retry, and it does not retry a reply the provider cut off at the output token cap. + `Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and normally resolves to Claude Opus 5.5. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. ## Observability diff --git a/supabase/functions/_shared/crossfire-response.ts b/supabase/functions/_shared/crossfire-response.ts index e7f126a95..a926730af 100644 --- a/supabase/functions/_shared/crossfire-response.ts +++ b/supabase/functions/_shared/crossfire-response.ts @@ -1,3 +1,5 @@ +import type { JsonOutputSchema } from "./research.ts"; + export const MAX_CROSSFIRE_REPLY_CHARS = 2_500; export const MAX_CROSSFIRE_DEBRIEF_ITEMS = 8; export const MAX_CROSSFIRE_REFERENCE_IDS = 6; @@ -81,6 +83,56 @@ function crossfireDebriefItem( return { value: { turnIds: turnIds.value, claim: value.claim.trim() } }; } +/** + * The provider-side shape of a debrief, mirroring validateCrossfireDebrief. + * Turn IDs are pinned to the transcript's, so a reply cannot cite a turn that + * does not exist. Counts, lengths, and duplicates stay with the validator. + */ +export function crossfireDebriefSchema(transcript: Array<{ id: string }>): JsonOutputSchema { + const ids = [...new Set(transcript.map((turn) => turn.id))]; + const turnIds = { type: "array", items: ids.length ? { type: "string", enum: ids } : { type: "string" } }; + const item = { + type: "object", + properties: { turnIds, claim: { type: "string" } }, + required: ["turnIds", "claim"], + additionalProperties: false, + }; + const items = { type: "array", items: item }; + return { + name: "crossfire_debrief", + schema: { + type: "object", + properties: { + concessionsSecured: items, + controlledQuestions: items, + weakOrRepetitiveQuestions: items, + evasiveOrIncompleteAnswers: items, + missedFollowUps: items, + betterFollowUpQuestions: { + type: "array", + items: { + type: "object", + properties: { turnIds, question: { type: "string" }, reason: { type: "string" } }, + required: ["turnIds", "question", "reason"], + additionalProperties: false, + }, + }, + strongestExchange: { anyOf: [item, { type: "null" }] }, + }, + required: [ + "concessionsSecured", + "controlledQuestions", + "weakOrRepetitiveQuestions", + "evasiveOrIncompleteAnswers", + "missedFollowUps", + "betterFollowUpQuestions", + "strongestExchange", + ], + additionalProperties: false, + }, + }; +} + export function validateCrossfireDebrief( value: unknown, transcript: Array<{ id: string }>, diff --git a/supabase/functions/_shared/research.ts b/supabase/functions/_shared/research.ts index 9dc695470..4045e17b0 100644 --- a/supabase/functions/_shared/research.ts +++ b/supabase/functions/_shared/research.ts @@ -80,9 +80,42 @@ export interface ResearchDeps { messages: Array<{ role: string; content: string }>; reasoningEffort: ReasoningEffort; signal?: AbortSignal; + outputSchema?: JsonOutputSchema; }) => Promise<{ output: unknown }>; } +/** + * A JSON Schema the provider must hold the reply to. Only for outputs the + * server already validates in code: with `strict` every property is required, + * so the schema has to match that validator exactly. Keep to keywords every + * routed provider accepts: type, properties, required, items, enum, anyOf, + * and additionalProperties false. + */ +export interface JsonOutputSchema { + name: string; + schema: Record; +} + +const QUERIES_SCHEMA: JsonOutputSchema = { + name: "follow_up_queries", + schema: { + type: "object", + properties: { queries: { type: "array", items: { type: "string" } } }, + required: ["queries"], + additionalProperties: false, + }, +}; + +const SEARCH_DECISION_SCHEMA: JsonOutputSchema = { + name: "search_decision", + schema: { + type: "object", + properties: { shouldSearch: { type: "boolean" }, query: { type: "string" } }, + required: ["shouldSearch", "query"], + additionalProperties: false, + }, +}; + export function emptyResearchContext(): ResearchContext { return { searches: [], rankedResults: [], readContent: [], verifiedUrls: new Set() }; } @@ -377,6 +410,7 @@ export async function decideAskSearch( messages: [{ role: "user", content: prompt }], reasoningEffort: deps.reasoningEffort, signal: deps.signal, + outputSchema: SEARCH_DECISION_SCHEMA, }); const decision = decisionResult.output; if (decision && typeof decision === "object") { @@ -415,6 +449,7 @@ async function generateFollowUpQueries( messages: [{ role: "user", content: prompt }], reasoningEffort: deps.reasoningEffort, signal: deps.signal, + outputSchema: QUERIES_SCHEMA, }); const parsed = typeof result.output === "object" && result.output !== null ? result.output : {}; if (Array.isArray((parsed as { queries?: string[] }).queries)) { diff --git a/supabase/functions/ai/features/crossfire-input.ts b/supabase/functions/ai/features/crossfire-input.ts index 161f2ae28..db34422c5 100644 --- a/supabase/functions/ai/features/crossfire-input.ts +++ b/supabase/functions/ai/features/crossfire-input.ts @@ -15,6 +15,7 @@ import { type PromptSource, } from "../../_shared/prompts.ts"; import { MAX_CROSSFIRE_REPLY_CHARS } from "../../_shared/crossfire-response.ts"; +import type { JsonOutputSchema } from "../../_shared/research.ts"; import { hasOnlyKeys, isRecord, sanitizePromptInput } from "../request.ts"; import { sparringIntegritySecret, supabase } from "../runtime.ts"; @@ -290,6 +291,19 @@ export function boundCrossfireSources(value: unknown, enabled: boolean): { value return { value: bounded, truncated }; } +/** The provider-side shape of a next turn, mirroring crossfireReplyOutput. */ +export function crossfireReplySchema(expectedReply: "question" | "answer"): JsonOutputSchema { + return { + name: "crossfire_turn", + schema: { + type: "object", + properties: { type: { type: "string", enum: [expectedReply] }, text: { type: "string" } }, + required: ["type", "text"], + additionalProperties: false, + }, + }; +} + export function crossfireReplyOutput(value: unknown, expectedReply: "question" | "answer"): { value: { type: "question" | "answer"; text: string } } | { error: string } { if (!isRecord(value) || !hasOnlyKeys(value, ["type", "text"])) return { error: "Crossfire next-turn response must contain only type and text" }; if (value.type !== expectedReply) return { error: `Crossfire response type must be ${expectedReply}` }; diff --git a/supabase/functions/ai/features/crossfire.ts b/supabase/functions/ai/features/crossfire.ts index 125c1e65f..dd9688bb2 100644 --- a/supabase/functions/ai/features/crossfire.ts +++ b/supabase/functions/ai/features/crossfire.ts @@ -3,11 +3,12 @@ import { callAI } from "../provider.ts"; import { cleanGeneratedText, getCurrentDateContext } from "../text.ts"; import { buildCrossfireNextTurnPrompt, buildCrossfireDebriefPrompt } from "../prompt-builder.ts"; import { buildSystemPrompt, type DebatePromptContext, type PromptSource } from "../../_shared/prompts.ts"; -import { validateCrossfireDebrief } from "../../_shared/crossfire-response.ts"; +import { crossfireDebriefSchema, validateCrossfireDebrief } from "../../_shared/crossfire-response.ts"; import { createCrossfireSignalToken, type CrossfireInput, crossfireReplyOutput, + crossfireReplySchema, MAX_CROSSFIRE_PROMPT_CHARS, } from "./crossfire-input.ts"; import { prepareVoiceReply, type VoicePayload } from "../../_shared/voice-token.ts"; @@ -65,6 +66,9 @@ export async function handleCrossfire(ctx: FeatureContext, options: CrossfireHan reasoningEffort, workSignal, ctx.stream, + crossfireInput.operation === "next-turn" + ? crossfireReplySchema(crossfireInput.expectedReply) + : crossfireDebriefSchema(crossfireInput.transcript), )); const cleanedOutput = cleanGeneratedText(aiResult.output); const output = crossfireInput.operation === "next-turn" diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 786ccb8f2..d4ff7329c 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -638,7 +638,7 @@ export async function handleRequest(req: Request): Promise { }, callModel: async (args) => { try { - const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.reasoningEffort, args.signal); + const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.reasoningEffort, args.signal, undefined, args.outputSchema); // A provider that ignores cancellation must not append usage after the // research timeout has already been finalized by the parent request. if (result.usage && !args.signal?.aborted) researchUsage.push(result.usage); diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index a47a59677..923802794 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "jsr:@std/assert@1.0.19"; +import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@1.0.19"; for (const [key, value] of Object.entries({ SUPABASE_URL: "https://supabase.test", SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-key", OPENROUTER_API_KEY: "test-provider-key", @@ -40,3 +40,40 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn } } finally { globalThis.fetch = originalFetch; } }); + +Deno.test("common JSON slips are repaired locally instead of paying for a retry", async () => { + const { parseJsonFromText } = await import("./provider.ts"); + const parse = (text: string) => parseJsonFromText(text, "OpenRouter"); + assertEquals(parse('```JSON\n{"a":1}\n```'), { a: 1 }); + assertEquals(parse('```\n{"a":1}\n```'), { a: 1 }); + assertEquals(parse('Here you go: {"a":"x"} Note: {not json}'), { a: "x" }); + assertEquals(parse('Use {braces} like so. {"a":[1,2,],}'), { a: [1, 2] }); + assertEquals(parse('{"a":"line one\nline two\t!"}'), { a: "line one\nline two\t!" }); + assertEquals(parse('{"a":"keep , ] inside strings",}'), { a: "keep , ] inside strings" }); + // A broken outer object never yields one of its nested objects. + assertThrows(() => parse('{"case": {"title": "x"}, oops}')); + // A reply cut off mid-object is not completed. + assertThrows(() => parse('{"contentions": [{"title": "x"}, {"title": "y"')); +}); + +Deno.test("a reply cut at the output cap is not retried, and a schema is sent as strict json_schema", async () => { + const { callAI } = await import("./provider.ts"); + const originalFetch = globalThis.fetch; + const bodies: Array> = []; + globalThis.fetch = (async (_input: Request | URL | string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ + choices: [{ message: { content: '{"queries": ["a", "b' }, finish_reason: "length" }], + usage: { prompt_tokens: 10, completion_tokens: 5, cost: 0.01 }, + }); + }) as typeof fetch; + try { + const schema = { name: "follow_up_queries", schema: { type: "object" } }; + const error = await assertRejects(() => + callAI("openrouter", "openai/gpt-6-luna", "system", [{ role: "user", content: "hi" }], "low", undefined, undefined, schema) + ) as { usage?: { inputTokens?: number } }; + assertEquals(bodies.length, 1); + assertEquals(bodies[0].response_format, { type: "json_schema", json_schema: { name: "follow_up_queries", strict: true, schema: { type: "object" } } }); + assertEquals(error.usage?.inputTokens, 10); + } finally { globalThis.fetch = originalFetch; } +}); diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index 52e01bcda..4b250ed35 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -1,5 +1,5 @@ -// OpenRouter calls, JSON parsing with one repair retry, and the shared -// request budget. +// OpenRouter calls, JSON parsing with local repair and one paid repair retry, +// and the shared request budget. import { mergeNormalizedUsage, @@ -19,6 +19,7 @@ import { MAX_PROVIDER_ERROR_BODY_CHARS, MAX_PROVIDER_RESPONSE_BYTES, OPENROUTER_API_URL, + type JsonOutputSchema, type Provider, readResponseText, } from "../_shared/research.ts"; @@ -36,6 +37,8 @@ export class InvalidJsonResponseError extends Error { readonly rawText: string; /** Token usage of the failed attempt so repair retries can bill it. */ usage?: NormalizedUsage; + /** The provider stopped at the output token cap, so a retry would be cut too. */ + truncated = false; constructor(providerName: string, rawText: string) { super(`${providerName} returned invalid JSON`); @@ -52,31 +55,105 @@ export class InvalidJsonResponseError extends Error { export function parseProviderContent( text: string, providerName: string, - usage: NormalizedUsage | undefined + usage: NormalizedUsage | undefined, + finishReason?: unknown, ): { output: unknown; usage?: NormalizedUsage } { try { return { output: parseJsonFromText(text, providerName), usage }; } catch (err) { - if (err instanceof InvalidJsonResponseError && usage) err.usage = usage; + if (err instanceof InvalidJsonResponseError) { + if (usage) err.usage = usage; + err.truncated = finishReason === "length"; + } throw err; } } +/** + * Reads the JSON object in a model reply. A paid repair retry costs a whole + * second answer, so the common slips are fixed here first: markdown fences, + * prose around the object, trailing commas, and raw line breaks inside + * strings. A reply cut off mid-object is not completed: that would ship a + * partial result as if it were whole. + */ export function parseJsonFromText(text: string, providerName: string) { if (!text) throw new Error(`${providerName} returned empty response content`); - try { - return JSON.parse(text); - } catch { - const codeBlock = text.match(/```json\s*([\s\S]*?)\s*```/i); - if (codeBlock?.[1]) { - try { return JSON.parse(codeBlock[1]); } catch { /* fall through */ } + const candidates = [text]; + const fenced = text.match(/```[a-z]*\s*([\s\S]*?)\s*```/i)?.[1]; + if (fenced) candidates.push(fenced); + candidates.push(...balancedObjects(text)); + for (const candidate of candidates) { + try { return JSON.parse(candidate); } catch { /* try the repaired text */ } + try { return JSON.parse(repairJsonText(candidate)); } catch { /* next candidate */ } + } + throw new InvalidJsonResponseError(providerName, text); +} + +// Prose before the answer may itself hold braces, so a few starts are tried. +const MAX_OBJECT_STARTS = 8; + +/** + * Each complete top-level `{...}` span in the text, string-aware. The scan + * resumes after a span, never inside it, so a nested object is never taken + * for the answer, and it stops at an unclosed object for the same reason. + */ +function balancedObjects(text: string): string[] { + const spans: string[] = []; + let start = text.indexOf("{"); + while (start !== -1 && spans.length < MAX_OBJECT_STARTS) { + let depth = 0; + let inString = false; + let end = -1; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (char === "\\") index++; + else if (char === "\"") inString = false; + } else if (char === "\"") { + inString = true; + } else if (char === "{") { + depth++; + } else if (char === "}" && --depth === 0) { + end = index; + break; + } } - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (jsonMatch?.[0]) { - try { return JSON.parse(jsonMatch[0]); } catch { /* fall through */ } + if (end === -1) break; + spans.push(text.slice(start, end + 1)); + start = text.indexOf("{", end + 1); + } + return spans; +} + +/** Drops trailing commas and escapes raw control characters inside strings. */ +function repairJsonText(text: string): string { + let out = ""; + let inString = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (char === "\\") { + out += char + (text[index + 1] ?? ""); + index++; + } else if (char === "\"") { + inString = false; + out += char; + } else if (char === "\n") out += "\\n"; + else if (char === "\r") out += "\\r"; + else if (char === "\t") out += "\\t"; + else if (char < " ") out += `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`; + else out += char; + continue; + } + if (char === "\"") inString = true; + if (char === ",") { + let next = index + 1; + while (next < text.length && /\s/.test(text[next])) next++; + if (text[next] === "}" || text[next] === "]") continue; } + out += char; } - throw new InvalidJsonResponseError(providerName, text); + return out; } export interface AIResult { @@ -100,7 +177,7 @@ export interface GenerationStream { export type GenerationPhase = "research" | "writing" | "checking"; type OpenRouterResponse = { - choices?: Array<{ message?: { content?: string | null } }>; + choices?: Array<{ message?: { content?: string | null }; finish_reason?: unknown }>; usage?: unknown; }; @@ -128,11 +205,17 @@ export async function callAI( reasoningEffort: ReasoningEffort, signal?: AbortSignal, stream?: GenerationStream, + outputSchema?: JsonOutputSchema, ): Promise { try { - return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, signal, stream); + return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, signal, stream, outputSchema); } catch (err) { if (!(err instanceof InvalidJsonResponseError)) throw attachMissingUsage(err, "call"); + // The same request under the same output cap would be cut off again. + if (err.truncated) { + console.warn(`[AI] ${err.providerName} reply hit the output token cap; not retrying`); + throw attachMissingUsage(err, "call"); + } console.warn(`[AI] ${err.providerName} returned invalid JSON; retrying with repair prompt`); stream?.reset(); @@ -146,6 +229,7 @@ export async function callAI( reasoningEffort, signal, stream, + outputSchema, ); } catch (retryError) { // Preserve the completed first attempt if cancellation or another @@ -197,6 +281,7 @@ export async function callAIOnce( reasoningEffort: ReasoningEffort, signal?: AbortSignal, stream?: GenerationStream, + outputSchema?: JsonOutputSchema, ): Promise { if (provider !== OPENROUTER_PROVIDER) throw new Error(`Unsupported provider: ${provider}`); return await callOpenRouter( @@ -205,6 +290,7 @@ export async function callAIOnce( reasoningEffort, signal, stream, + outputSchema, ); } @@ -227,6 +313,7 @@ export async function callOpenRouter( reasoningEffort: ReasoningEffort, signal?: AbortSignal, stream?: GenerationStream, + outputSchema?: JsonOutputSchema, ): Promise { const apiKey = Deno.env.get("OPENROUTER_API_KEY"); if (!apiKey) throw new Error("OPENROUTER_API_KEY missing"); @@ -236,7 +323,10 @@ export async function callOpenRouter( model, messages, max_tokens: Math.min(modelEntry.maxTokens, MAX_REQUEST_OUTPUT_TOKENS), - response_format: { type: "json_object" }, + // With require_parameters, a schema routes only to endpoints that enforce it. + response_format: outputSchema + ? { type: "json_schema", json_schema: { name: outputSchema.name, strict: true, schema: outputSchema.schema } } + : { type: "json_object" }, reasoning: { effort: reasoningEffort, exclude: true }, // Omitting sort and order keeps OpenRouter's balanced provider load balancing. provider: { require_parameters: true }, @@ -254,7 +344,7 @@ export async function callOpenRouter( } 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"); - return parseProviderContent(data.choices[0]?.message?.content ?? "", "OpenRouter", usage); + return parseProviderContent(data.choices[0]?.message?.content ?? "", "OpenRouter", usage, data.choices[0]?.finish_reason); } // A streamed reply wraps every token in a JSON frame (ids, model, provider, @@ -280,7 +370,7 @@ async function streamOpenRouter( signal?: AbortSignal, ): Promise { stream.phase("writing"); - const { text, usage } = await fetchWithTimeoutAndConsume(`${OPENROUTER_API_URL}/v1/chat/completions`, { + const { text, usage, finishReason } = await fetchWithTimeoutAndConsume(`${OPENROUTER_API_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, body: JSON.stringify({ ...payload, stream: true, stream_options: { include_usage: true } }), @@ -294,14 +384,14 @@ async function streamOpenRouter( } return await readOpenRouterStream(response.body, stream, bodySignal); }); - return parseProviderContent(text, "OpenRouter", usage); + return parseProviderContent(text, "OpenRouter", usage, finishReason); } export async function readOpenRouterStream( body: ReadableStream, stream: GenerationStream, signal: AbortSignal, -): Promise<{ text: string; usage: NormalizedUsage }> { +): Promise<{ text: string; usage: NormalizedUsage; finishReason?: string }> { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -311,7 +401,7 @@ export async function readOpenRouterStream( const encoder = new TextEncoder(); let usageRaw: Record | undefined; let done = false; - let finished = false; + let finishReason: string | undefined; const processLine = (line: string) => { const trimmed = line.trim(); @@ -332,7 +422,7 @@ export async function readOpenRouterStream( throw new Error("AI provider request failed"); } const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; - if (typeof choice?.finish_reason === "string" && choice.finish_reason) finished = true; + if (typeof choice?.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason; const content = choice?.delta?.content; if (typeof content === "string" && content) { text += content; @@ -380,10 +470,10 @@ export async function readOpenRouterStream( } finally { void reader.cancel().catch(() => undefined); } - if (!done && !finished) { + if (!done && !finishReason) { const error = new Error("OpenRouter stream ended before completion") as Error & { usage?: NormalizedUsage }; error.usage = usageRaw ? { ...normalizeOpenRouterUsage(usageRaw), incomplete: true } : missingOpenRouterUsage("stream"); throw error; } - return { text, usage: usageRaw ? normalizeOpenRouterUsage(usageRaw) : missingOpenRouterUsage("stream") }; + return { text, usage: usageRaw ? normalizeOpenRouterUsage(usageRaw) : missingOpenRouterUsage("stream"), finishReason }; } From b0cfe0044add91822f43b69e8e07b95d0bfc4afe Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:28:45 +0000 Subject: [PATCH 2/4] Reject ambiguous or embedded JSON in the local repair Review of 451eae7 found three ways the parser took the wrong text: - the fence regex backtracked for seconds on long whitespace, and ran before the plain parse; - a fence inside a string of an unfinished object was taken as the answer; - the first parsable span won, so "{} means omitted" beat the real object. Plain JSON now parses first, a fence counts only when it wraps the whole reply (found with indexOf, no regex), and a span is accepted only when it is the one span in the reply that parses. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/ai/provider-errors_test.ts | 9 +++++- supabase/functions/ai/provider.ts | 29 ++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 923802794..e53592449 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -52,8 +52,15 @@ Deno.test("common JSON slips are repaired locally instead of paying for a retry" assertEquals(parse('{"a":"keep , ] inside strings",}'), { a: "keep , ] inside strings" }); // A broken outer object never yields one of its nested objects. assertThrows(() => parse('{"case": {"title": "x"}, oops}')); - // A reply cut off mid-object is not completed. + // A reply cut off mid-object is not completed, even around a fenced example. assertThrows(() => parse('{"contentions": [{"title": "x"}, {"title": "y"')); + assertThrows(() => parse('{"response":"Example:\n```\n{"response":"inner"}\n```\nunfinished')); + // Two objects that both parse leave the answer ambiguous. + assertThrows(() => parse('Note: {} means omitted. Answer: {"response":"Use evidence."}')); + // Fence-like text inside valid JSON parses at once. + const started = performance.now(); + assertEquals(parse(JSON.stringify({ a: "```" + " ".repeat(20_000) + "x" })), { a: "```" + " ".repeat(20_000) + "x" }); + assertEquals(performance.now() - started < 1_000, true); }); Deno.test("a reply cut at the output cap is not retried, and a schema is sent as strict json_schema", async () => { diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index 4b250ed35..05ee1a9d2 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -74,21 +74,34 @@ export function parseProviderContent( * second answer, so the common slips are fixed here first: markdown fences, * prose around the object, trailing commas, and raw line breaks inside * strings. A reply cut off mid-object is not completed: that would ship a - * partial result as if it were whole. + * partial result as if it were whole. When prose holds more than one object + * that parses, the answer is ambiguous and the reply is rejected. */ export function parseJsonFromText(text: string, providerName: string) { if (!text) throw new Error(`${providerName} returned empty response content`); - const candidates = [text]; - const fenced = text.match(/```[a-z]*\s*([\s\S]*?)\s*```/i)?.[1]; - if (fenced) candidates.push(fenced); - candidates.push(...balancedObjects(text)); - for (const candidate of candidates) { - try { return JSON.parse(candidate); } catch { /* try the repaired text */ } - try { return JSON.parse(repairJsonText(candidate)); } catch { /* next candidate */ } + const whole = parseOrRepair(text); + if (whole.ok) return whole.value; + // A fence counts only when it wraps the reply. One inside a string of an + // unfinished object is an example, not the answer. + const trimmed = text.trim(); + if (trimmed.startsWith("```")) { + const open = trimmed.indexOf("\n"); + const close = trimmed.lastIndexOf("```"); + if (open !== -1 && close > open) { + const fenced = parseOrRepair(trimmed.slice(open + 1, close)); + if (fenced.ok) return fenced.value; + } } + const parsed = balancedObjects(text).map(parseOrRepair).filter((result): result is { ok: true; value: unknown } => result.ok); + if (parsed.length === 1) return parsed[0].value; throw new InvalidJsonResponseError(providerName, text); } +function parseOrRepair(text: string): { ok: true; value: unknown } | { ok: false } { + try { return { ok: true, value: JSON.parse(text) }; } catch { /* try the repaired text */ } + try { return { ok: true, value: JSON.parse(repairJsonText(text)) }; } catch { return { ok: false }; } +} + // Prose before the answer may itself hold braces, so a few starts are tried. const MAX_OBJECT_STARTS = 8; From 36084452e848b849448ca459220fd5e25debed68 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:36:02 +0000 Subject: [PATCH 3/4] Fail closed when a fenced reply has a tail or the scan cannot finish Review of b0cfe00 found two ways the repair still accepted an example: - a reply that opened with a fence was taken even with text after the closing fence; - the scan stopped after eight spans, so a second valid object later in the reply went unseen. A fence now has to close the reply. The span scan reads every span and rejects the reply on a second parsable span, on an unclosed brace, or past 16 spans (which bounds parse work on a reply full of braces: a 1 MB reply of tiny spans went from 4.7 s to 118 ms). Rejection means the existing paid retry, as before this PR. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/ai/provider-errors_test.ts | 6 +++ supabase/functions/ai/provider.ts | 49 ++++++++++++------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index e53592449..3fd2e81f1 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -57,6 +57,12 @@ Deno.test("common JSON slips are repaired locally instead of paying for a retry" assertThrows(() => parse('{"response":"Example:\n```\n{"response":"inner"}\n```\nunfinished')); // Two objects that both parse leave the answer ambiguous. assertThrows(() => parse('Note: {} means omitted. Answer: {"response":"Use evidence."}')); + assertThrows(() => parse('```\n{"response":"example"}\n```\nActually: {"response":"final"}')); + assertThrows(() => parse("{note} ".repeat(9) + '{"response":"example"} {"response":"final"}')); + // Past the span limit the reply is rejected, never accepted early. + assertThrows(() => parse("{note} ".repeat(20) + '{"response":"only"}')); + // An unclosed brace leaves the rest unread, so an earlier object is not trusted. + assertThrows(() => parse('{"response":"example"} then { {"response":"final"}')); // Fence-like text inside valid JSON parses at once. const started = performance.now(); assertEquals(parse(JSON.stringify({ a: "```" + " ".repeat(20_000) + "x" })), { a: "```" + " ".repeat(20_000) + "x" }); diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index 05ee1a9d2..2675a7fee 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -81,39 +81,46 @@ export function parseJsonFromText(text: string, providerName: string) { if (!text) throw new Error(`${providerName} returned empty response content`); const whole = parseOrRepair(text); if (whole.ok) return whole.value; - // A fence counts only when it wraps the reply. One inside a string of an - // unfinished object is an example, not the answer. + // A fence counts only when it wraps the whole reply. One inside a string of + // an unfinished object, or followed by more text, is not the answer. const trimmed = text.trim(); - if (trimmed.startsWith("```")) { + if (trimmed.startsWith("```") && trimmed.endsWith("```")) { const open = trimmed.indexOf("\n"); - const close = trimmed.lastIndexOf("```"); + const close = trimmed.length - 3; if (open !== -1 && close > open) { const fenced = parseOrRepair(trimmed.slice(open + 1, close)); if (fenced.ok) return fenced.value; } } - const parsed = balancedObjects(text).map(parseOrRepair).filter((result): result is { ok: true; value: unknown } => result.ok); - if (parsed.length === 1) return parsed[0].value; + const single = onlyObject(text); + if (single.ok) return single.value; throw new InvalidJsonResponseError(providerName, text); } -function parseOrRepair(text: string): { ok: true; value: unknown } | { ok: false } { +type Parsed = { ok: true; value: unknown } | { ok: false }; + +// Prose around an answer holds a few braces at most. +const MAX_OBJECT_SPANS = 16; + +function parseOrRepair(text: string): Parsed { try { return { ok: true, value: JSON.parse(text) }; } catch { /* try the repaired text */ } try { return { ok: true, value: JSON.parse(repairJsonText(text)) }; } catch { return { ok: false }; } } -// Prose before the answer may itself hold braces, so a few starts are tried. -const MAX_OBJECT_STARTS = 8; - /** - * Each complete top-level `{...}` span in the text, string-aware. The scan - * resumes after a span, never inside it, so a nested object is never taken - * for the answer, and it stops at an unclosed object for the same reason. + * The one top-level `{...}` span in the text that parses, string-aware. The + * scan resumes after a span, never inside it, so a nested object is never + * taken for the answer, and each character is read once. It fails when two + * spans parse (the answer is ambiguous), when an unclosed `{` leaves the rest + * of the text unread, or past MAX_OBJECT_SPANS, which bounds the parse work + * on a reply full of braces. Failing sends the reply to the paid retry. */ -function balancedObjects(text: string): string[] { - const spans: string[] = []; +function onlyObject(text: string): Parsed { + let found: Parsed = { ok: false }; + let spans = 0; let start = text.indexOf("{"); - while (start !== -1 && spans.length < MAX_OBJECT_STARTS) { + while (start !== -1) { + if (++spans > MAX_OBJECT_SPANS) return { ok: false }; let depth = 0; let inString = false; let end = -1; @@ -131,11 +138,15 @@ function balancedObjects(text: string): string[] { break; } } - if (end === -1) break; - spans.push(text.slice(start, end + 1)); + if (end === -1) return { ok: false }; + const parsed = parseOrRepair(text.slice(start, end + 1)); + if (parsed.ok) { + if (found.ok) return { ok: false }; + found = parsed; + } start = text.indexOf("{", end + 1); } - return spans; + return found; } /** Drops trailing commas and escapes raw control characters inside strings. */ From 459566a4278d59d6cd988a7696ad828a83980ce7 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 12:57:24 +0000 Subject: [PATCH 4/4] Send a reply to the retry when a JSON-looking span fails to parse A slip in the real answer (an unescaped quote) made its span fail, and any other span that parsed, such as an example object or a nested one, was returned as the answer. --- supabase/functions/ai/provider-errors_test.ts | 5 +++++ supabase/functions/ai/provider.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 3fd2e81f1..49c4360c3 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -61,6 +61,11 @@ Deno.test("common JSON slips are repaired locally instead of paying for a retry" assertThrows(() => parse("{note} ".repeat(9) + '{"response":"example"} {"response":"final"}')); // Past the span limit the reply is rejected, never accepted early. assertThrows(() => parse("{note} ".repeat(20) + '{"response":"only"}')); + // A JSON-looking span that fails to parse may be the real answer, so no other span is trusted. + assertThrows(() => parse('Example: {"type":"question","text":"example"}\n\nAnswer: {"type":"question","text":"Why "this"?"}')); + assertThrows(() => parse('Note: {} means omitted. Answer: {"response": "Use evidence." oops}')); + // An unescaped quote that closes the outer span early does not yield the nested object. + assertThrows(() => parse('{"response": "He wrote "x} and left", "data": {"k": 1}}')); // An unclosed brace leaves the rest unread, so an earlier object is not trusted. assertThrows(() => parse('{"response":"example"} then { {"response":"final"}')); // Fence-like text inside valid JSON parses at once. diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index eee90f994..0d9f35334 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -112,7 +112,8 @@ function parseOrRepair(text: string): Parsed { * The one top-level `{...}` span in the text that parses, string-aware. The * scan resumes after a span, never inside it, so a nested object is never * taken for the answer, and each character is read once. It fails when two - * spans parse (the answer is ambiguous), when an unclosed `{` leaves the rest + * spans parse (the answer is ambiguous), when a span that looks like JSON (it + * holds a `"` or `:`) does not parse, when an unclosed `{` leaves the rest * of the text unread, or past MAX_OBJECT_SPANS, which bounds the parse work * on a reply full of braces. Failing sends the reply to the paid retry. */ @@ -140,10 +141,17 @@ function onlyObject(text: string): Parsed { } } if (end === -1) return { ok: false }; - const parsed = parseOrRepair(text.slice(start, end + 1)); + const span = text.slice(start, end + 1); + const parsed = parseOrRepair(span); if (parsed.ok) { if (found.ok) return { ok: false }; found = parsed; + } else if (/[":]/.test(span)) { + // A span that looks like JSON but does not parse may be the real answer + // with a slip (an unescaped quote can even close it early, so the scan + // resumes inside it). Trusting another span then returns the wrong + // object, so the reply goes to the retry. Prose like {braces} is skipped. + return { ok: false }; } start = text.indexOf("{", end + 1); }