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: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.

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.
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. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's `cost` is only its fee, so the logged cost adds `cost_details.upstream_inference_cost`; the weekly cap still counts only `cost`. Exa searches and page reads report their charge as `costDollars.total`; it joins the request's row, with one `raw_usage.calls` entry per Exa call. 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.

Expand Down
74 changes: 73 additions & 1 deletion supabase/functions/_shared/cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
markIncompleteUsage,
mergeNormalizedUsage,
normalizeElevenLabsUsage,
normalizeExaUsage,
normalizeOpenRouterUsage,
selectObservedUsage,
} from "./cost.ts";
Expand All @@ -15,7 +16,68 @@ Deno.test("OpenRouter usage uses its reported account cost, including zero", ()
});
if (result.costUsd !== 0) throw new Error(`zero cost was discarded: ${result.costUsd}`);
if (result.inputTokens !== 1000 || result.outputTokens !== 500) throw new Error("token counts lost");
if (result.costCalculationVersion !== "openrouter-usage-v1") throw new Error("wrong calculation version");
if (result.costCalculationVersion !== "openrouter-usage-v2") throw new Error("wrong calculation version");
});

// Shape copied from a staging Claude Haiku 5.5 row that v1 logged as $0.
const BYOK_USAGE = {
prompt_tokens: 1527,
completion_tokens: 671,
cost: 0,
is_byok: true,
cost_details: {
upstream_inference_cost: 0.0004882,
upstream_inference_prompt_cost: 0.0001527,
upstream_inference_completions_cost: 0.0003355,
},
};

Deno.test("BYOK OpenRouter usage bills the upstream provider charge plus OpenRouter's fee", () => {
const free = calculateRequestCost({
provider: "openrouter",
model: "anthropic/claude-haiku-5.5",
usage: normalizeOpenRouterUsage(BYOK_USAGE),
});
if (free.costUsd !== 0.0004882) throw new Error(`BYOK upstream charge was not billed: ${free.costUsd}`);

const withFee = normalizeOpenRouterUsage({ ...BYOK_USAGE, cost: 0.0001 });
if (Math.abs((withFee.reportedCostUsd ?? -1) - 0.0005882) > 1e-12) {
throw new Error(`BYOK fee was not added to the upstream charge: ${withFee.reportedCostUsd}`);
}
});

Deno.test("BYOK OpenRouter usage without an upstream charge fails closed instead of logging $0", () => {
const { cost_details: _details, ...withoutUpstream } = BYOK_USAGE;
const result = calculateRequestCost({
provider: "openrouter",
model: "anthropic/claude-haiku-5.5",
usage: normalizeOpenRouterUsage(withoutUpstream),
});
if (result.costUsd !== null) throw new Error(`missing BYOK upstream cost was logged as ${result.costUsd}`);
});

Deno.test("Exa charges merge into the request row next to model usage", () => {
const search = normalizeExaUsage("search", { requestId: "s1", costDollars: { total: 0.007, search: { neural: 0.007 } } });
const read = normalizeExaUsage("contents", { requestId: "c1", costDollars: { total: 0.001 } });
const merged = mergeNormalizedUsage([normalizeOpenRouterUsage(BYOK_USAGE), search, read]);
const result = calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-haiku-5.5", usage: merged });
if (Math.abs((result.costUsd ?? -1) - 0.0084882) > 1e-12) throw new Error(`Exa cost missing from total: ${result.costUsd}`);
if (result.inputTokens !== 1527) throw new Error("Exa usage changed token totals");
const calls = (result.rawUsage as { calls: Array<Record<string, unknown>> }).calls;
if (calls[1]?.provider !== "exa" || calls[1]?.endpoint !== "search" || calls[2]?.request_id !== "c1") {
throw new Error("per-call Exa audit entries were lost");
}
});

Deno.test("an Exa response without a charge makes the request cost unknown", () => {
const unpriced = normalizeExaUsage("search", { requestId: "s1", results: [] });
if (!unpriced.incomplete) throw new Error("missing Exa charge was not marked incomplete");
const result = calculateRequestCost({
provider: "openrouter",
model: "anthropic/claude-haiku-5.5",
usage: mergeNormalizedUsage([normalizeOpenRouterUsage(BYOK_USAGE), unpriced]),
});
if (result.costUsd !== null) throw new Error(`unpriced Exa call was treated as free: ${result.costUsd}`);
});

Deno.test("OpenRouter usage without a reported cost fails closed", () => {
Expand Down Expand Up @@ -256,3 +318,13 @@ Deno.test("ElevenLabs voice cost counts toward the cap", () => {
const result = calculateRequestCost({ provider: "elevenlabs", model: "eleven_v4_turbo", usage });
if (result.capCostUsd !== result.costUsd || result.costUsd === null) throw new Error("voice cost missing from the cap");
});

Deno.test("a BYOK call logs the provider charge but counts only OpenRouter's fee toward the cap", () => {
const byok = normalizeOpenRouterUsage({ ...BYOK_USAGE, cost: 0.0001 });
const result = calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-sonnet-5.5", usage: byok });
if (Math.abs((result.costUsd ?? -1) - 0.0005882) > 1e-12) throw new Error(`logged cost wrong: ${result.costUsd}`);
if (result.capCostUsd !== 0.0001) throw new Error(`cap counted the BYOK provider charge: ${result.capCostUsd}`);
// A BYOK usage with no fee figure stays unknown for both, never the provider charge alone.
const noFee = normalizeOpenRouterUsage({ ...BYOK_USAGE, cost: undefined });
if (noFee.reportedCostUsd !== undefined || noFee.capCostUsd !== undefined) throw new Error("a BYOK call without a fee got a cost");
});
46 changes: 44 additions & 2 deletions supabase/functions/_shared/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// costUsd = null, never an invented estimate. Raw provider usage is preserved
// so every billing-relevant field stays auditable.

export const OPENROUTER_COST_CALCULATION_VERSION = "openrouter-usage-v1";
// v2 adds the upstream charge OpenRouter reports for BYOK calls, which v1
// logged as $0, and the Exa charges merged into a request's research.
export const OPENROUTER_COST_CALCULATION_VERSION = "openrouter-usage-v2";

export const EXA_PROVIDER = "exa";

export const ELEVENLABS_PROVIDER = "elevenlabs";
export const ELEVENLABS_COST_CALCULATION_VERSION = "elevenlabs-characters-v1";
Expand Down Expand Up @@ -77,6 +81,20 @@ function reportedCostOrUndefined(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}

/**
* OpenRouter's `cost` is what it charged the account. With a provider key
* attached (BYOK, `is_byok: true`) that is only its fee, often 0, and the
* provider bills our own key for `cost_details.upstream_inference_cost`. The
* real cost is the sum. A BYOK response without the upstream figure stays
* unpriced rather than logging the fee alone as the whole bill.
*/
function openRouterCostOrUndefined(usage: Record<string, unknown>): number | undefined {
const fee = reportedCostOrUndefined(usage.cost);
if (usage.is_byok !== true || fee === undefined) return fee;
const upstream = reportedCostOrUndefined(asRecord(usage.cost_details)?.upstream_inference_cost);
return upstream === undefined ? undefined : fee + upstream;
}

export function normalizeOpenRouterUsage(usage: Record<string, unknown>): NormalizedUsage {
const promptDetails = asRecord(usage.prompt_tokens_details);
const completionDetails = asRecord(usage.completion_tokens_details);
Expand All @@ -86,7 +104,9 @@ export function normalizeOpenRouterUsage(usage: Record<string, unknown>): Normal
cacheWriteTokens: intOrUndefined(promptDetails?.cache_write_tokens),
outputTokens: intOrUndefined(usage.completion_tokens),
reasoningOutputTokens: intOrUndefined(completionDetails?.reasoning_tokens),
reportedCostUsd: reportedCostOrUndefined(usage.cost),
// The logged charge: for a BYOK call, OpenRouter's fee plus what the
// provider billed our key (calculator v2).
reportedCostUsd: openRouterCostOrUndefined(usage),
// usage.cost is OpenRouter's credit charge. For a BYOK call it is only the
// OpenRouter fee, so Claude on our own Anthropic key stays out of the cap,
// and a call that fell back to OpenRouter credits counts in full.
Expand All @@ -95,6 +115,28 @@ export function normalizeOpenRouterUsage(usage: Record<string, unknown>): Normal
};
}

/**
* Exa reports each call's charge as `costDollars.total`. The usage joins the
* request's research usage, so the parent ai_requests row carries the cost
* and `raw_usage.calls` keeps one entry per Exa call. A successful response
* without a charge is incomplete: the call was billed, the amount is unknown.
*/
export function normalizeExaUsage(endpoint: "search" | "contents", data: unknown): NormalizedUsage {
const body = asRecord(data);
const costDollars = asRecord(body?.costDollars);
const total = reportedCostOrUndefined(costDollars?.total);
return {
incomplete: total === undefined || undefined,
reportedCostUsd: total,
raw: {
provider: EXA_PROVIDER,
endpoint,
request_id: typeof body?.requestId === "string" ? body.requestId : null,
cost_dollars: costDollars ?? null,
},
};
}

/**
* ElevenLabs bills per input character and reports the count in the
* `character-cost` response header. Without a usable header, the characters
Expand Down
63 changes: 63 additions & 0 deletions supabase/functions/_shared/research.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
MAX_PROVIDER_RESPONSE_BYTES,
type ResearchContext,
} from "./research.ts";
import type { NormalizedUsage } from "./cost.ts";
import { MAX_FETCHED_PAGE_CHARS } from "./prompt-budget.ts";

function ctxWith(overrides: Partial<ResearchContext>): ResearchContext {
Expand Down Expand Up @@ -315,6 +316,68 @@ Deno.test("filtered research retries without domain filters when Exa returns no
}
});

Deno.test("research reports every billed Exa call, none for error statuses, and unknown for unsettled ones", async () => {
const originalFetch = globalThis.fetch;
const previousApiKey = Deno.env.get("EXA_API_KEY");
Deno.env.set("EXA_API_KEY", "test-key");
const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}));
globalThis.fetch = ((input: Request | URL | string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
if (String(input).endsWith("/contents")) {
const [url] = body.urls as string[];
if (url.includes("energy.gov")) return Promise.reject(new TypeError("connection reset"));
return url.includes("gsa.gov")
? json({ requestId: "read-ok", results: [{ text: "page" }], costDollars: { total: 0.001 } })
: json({ error: "upstream" }, 500);
}
// The domain-filtered search finds nothing, so research pays for a fallback.
return Array.isArray(body.includeDomains)
? json({ requestId: "filtered", results: [], costDollars: { total: 0.005 } })
: json({
requestId: "fallback",
results: [
{ title: "Official guidance", url: "https://gsa.gov/policy", text: "Official government procurement policy with detailed methodology." },
{ title: "Agency data", url: "https://data.gov/procurement", text: "Official government procurement data and supporting methodology." },
{ title: "Energy procurement", url: "https://energy.gov/procurement", text: "Official government procurement rules and supporting methodology." },
],
costDollars: { total: 0.007 },
});
}) as typeof fetch;

const usages: NormalizedUsage[] = [];
try {
await deepResearch("federal procurement reform", "United States", {
provider: "openrouter",
model: "test-model",
reasoningEffort: "low",
callModel: async () => ({ output: { queries: [] } }),
onExaUsage: (usage) => usages.push(usage),
}, {
maxFollowUpQueries: 0,
maxUrlsToRead: 3,
maxSeedQueries: 1,
overallTimeoutMs: 1000,
preferredSourceType: "government",
});
const billed = usages.filter((usage) => !usage.incomplete);
const ids = billed.map((usage) => usage.raw?.request_id).sort();
if (ids.join(",") !== "fallback,filtered,read-ok") throw new Error(`unexpected billed Exa calls: ${ids.join(",")}`);
const total = billed.reduce((sum, usage) => sum + (usage.reportedCostUsd ?? 0), 0);
if (Math.abs(total - 0.013) > 1e-12) throw new Error(`Exa charges did not add up: ${total}`);
const unsettled = usages.filter((usage) => usage.incomplete);
if (unsettled.length !== 1 || unsettled[0].raw?.endpoint !== "contents") {
throw new Error(`the reset page read was not reported as unknown: ${JSON.stringify(unsettled)}`);
}
} finally {
globalThis.fetch = originalFetch;
if (previousApiKey === undefined) Deno.env.delete("EXA_API_KEY");
else Deno.env.set("EXA_API_KEY", previousApiKey);
}
});

Deno.test("JSON body timeout cancels a read that stalled after headers", async () => {
const originalFetch = globalThis.fetch;
let bodyStartedResolve!: () => void;
Expand Down
Loading
Loading