diff --git a/docs/architecture.md b/docs/architecture.md index d3ebf3e95..1a04234c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,6 +142,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 Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. 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 a reply the provider cut off at its cap is retried once with twice the budget, not with the repair prompt. + `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, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. 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 fef52000c..995a12ef7 100644 --- a/supabase/functions/_shared/research.ts +++ b/supabase/functions/_shared/research.ts @@ -81,9 +81,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() }; } @@ -378,6 +411,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") { @@ -416,6 +450,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 75068fe18..2802bebde 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"; @@ -66,6 +67,9 @@ export async function handleCrossfire(ctx: FeatureContext, options: CrossfireHan crossfireInput.operation === "next-turn" ? "crossfireTurn" : "crossfireDebrief", 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 fad369420..08f283863 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -642,7 +642,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, "research", args.signal); + const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.reasoningEffort, "research", 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..565080ead 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,63 @@ 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, 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."}')); + 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"}')); + // 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. + 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 its cap is retried once with a larger cap, keeping the 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", "research", undefined, undefined, schema) + ) as { usage?: { inputTokens?: number } }; + // One cut-off retry with a larger cap, never a repair-prompt retry on top. + assertEquals(bodies.length, 2); + for (const body of bodies) { + assertEquals(body.response_format, { type: "json_schema", json_schema: { name: "follow_up_queries", strict: true, schema: { type: "object" } } }); + } + assertEquals((bodies[1].max_tokens as number) > (bodies[0].max_tokens as number), true); + // Both attempts are billed. + assertEquals(error.usage?.inputTokens, 20); + } finally { globalThis.fetch = originalFetch; } +}); diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index 281ada462..f8776f074 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, @@ -24,6 +24,7 @@ import { MAX_PROVIDER_ERROR_BODY_CHARS, MAX_PROVIDER_RESPONSE_BYTES, OPENROUTER_API_URL, + type JsonOutputSchema, type Provider, readResponseText, } from "../_shared/research.ts"; @@ -70,7 +71,7 @@ export class OutputTruncatedError extends Error { export function parseProviderContent( text: string, providerName: string, - usage: NormalizedUsage | undefined + usage: NormalizedUsage | undefined, ): { output: unknown; usage?: NormalizedUsage } { try { return { output: parseJsonFromText(text, providerName), usage }; @@ -80,23 +81,125 @@ export function parseProviderContent( } } +/** + * 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. 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`); - 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 jsonMatch = text.match(/\{[\s\S]*\}/); - if (jsonMatch?.[0]) { - try { return JSON.parse(jsonMatch[0]); } catch { /* fall through */ } + const whole = parseOrRepair(text); + if (whole.ok) return whole.value; + // 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("```") && trimmed.endsWith("```")) { + const open = trimmed.indexOf("\n"); + 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 single = onlyObject(text); + if (single.ok) return single.value; throw new InvalidJsonResponseError(providerName, text); } +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 }; } +} + +/** + * 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 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. + */ +function onlyObject(text: string): Parsed { + let found: Parsed = { ok: false }; + let spans = 0; + let start = text.indexOf("{"); + while (start !== -1) { + if (++spans > MAX_OBJECT_SPANS) return { ok: false }; + 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; + } + } + if (end === -1) return { ok: false }; + 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); + } + return found; +} + +/** 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; + } + return out; +} + export interface AIResult { output: unknown; usage?: NormalizedUsage; @@ -147,16 +250,17 @@ export async function callAI( cap: ReplyCap, signal?: AbortSignal, stream?: GenerationStream, + outputSchema?: JsonOutputSchema, ): Promise { const replyTokens = REPLY_TOKEN_CAPS[cap]; try { - return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens, signal, stream); + return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens, signal, stream, outputSchema); } catch (err) { if (err instanceof OutputTruncatedError) { console.warn(`[AI] Reply hit its ${cap} cap (${replyTokens} tokens, ${model}, effort ${reasoningEffort}); retrying with ${replyTokens * TRUNCATION_RETRY_FACTOR}`); stream?.reset(); return await retryOnce(err.usage, "truncation_retry", () => - callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens * TRUNCATION_RETRY_FACTOR, signal, stream) + callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens * TRUNCATION_RETRY_FACTOR, signal, stream, outputSchema) ); } if (!(err instanceof InvalidJsonResponseError)) throw attachMissingUsage(err, "call"); @@ -173,6 +277,7 @@ export async function callAI( replyTokens, signal, stream, + outputSchema, ) ); } @@ -237,6 +342,7 @@ export async function callAIOnce( replyTokens: number, signal?: AbortSignal, stream?: GenerationStream, + outputSchema?: JsonOutputSchema, ): Promise { if (provider !== OPENROUTER_PROVIDER) throw new Error(`Unsupported provider: ${provider}`); return await callOpenRouter( @@ -246,6 +352,7 @@ export async function callAIOnce( replyTokens, signal, stream, + outputSchema, ); } @@ -269,6 +376,7 @@ export async function callOpenRouter( replyTokens: number, signal?: AbortSignal, stream?: GenerationStream, + outputSchema?: JsonOutputSchema, ): Promise { const apiKey = Deno.env.get("OPENROUTER_API_KEY"); if (!apiKey) throw new Error("OPENROUTER_API_KEY missing"); @@ -278,7 +386,10 @@ export async function callOpenRouter( model, messages: withPromptCaching(model, messages), max_tokens: outputTokenLimit(replyTokens, reasoningEffort, modelEntry.maxTokens), - 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 },