Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions supabase/functions/_shared/crossfire-response.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 }>,
Expand Down
35 changes: 35 additions & 0 deletions supabase/functions/_shared/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

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() };
}
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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)) {
Expand Down
14 changes: 14 additions & 0 deletions supabase/functions/ai/features/crossfire-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}` };
Expand Down
6 changes: 5 additions & 1 deletion supabase/functions/ai/features/crossfire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ export async function handleRequest(req: Request): Promise<Response> {
},
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);
Expand Down
62 changes: 61 additions & 1 deletion supabase/functions/ai/provider-errors_test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
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; }
});
Loading
Loading