Engineering manualAPI route source
/api/docs-ask
Exact current route handler: hosted-app/app/api/docs-ask/route.js
Methods: POST. Input signals: none detected. Literal response-status signals: 400, 429, 502.
Source: hosted-app/app/api/docs-ask/route.js. Imports: ../../../lib/bounded-http.mjs, ../../../lib/comprehensive-pull.mjs, ../../../lib/docs-source, ../../../lib/local/ai-gateway.mjs, ../../../lib/local/model-registry.mjs, ../../../lib/model-usage-ledger.mjs, ../../../lib/supabase-rest.mjs, next/server. Exact source is shown for inspection; its presence does not prove authentication, validation or live behavior.
All API routes · API and execution contract
import { readBoundedJsonRequest } from "../../../lib/bounded-http.mjs";
// POST /api/docs-ask — a question answered ONLY from these documentation pages.
//
// THE APPROVED PROPOSAL'S OWN CAVEAT is the specification for this file:
// "An ask-the-docs answer is a generated claim about our own documentation. It
// needs the same rule as everything else — cite the page, or say it does not
// know."
//
// So the rule is enforced in CODE, not in the prompt:
//
// 1. RETRIEVAL IS OURS. The model is handed a fixed set of pages selected by
// term overlap against each page's own structured data. It cannot ask for
// more, and it never sees anything that is not published at a public URL.
// 2. EVERY CITED PAGE MUST BE ONE WE SENT. A returned url that is not in the
// candidate set is dropped before the response is written — the same
// posture the voice check takes with a quote it cannot find in the page.
// 3. AN ANSWER WITH NO SURVIVING CITATION IS NOT AN ANSWER. It is replaced by
// a plain "that is not in these docs", because a confident paragraph about
// documentation it did not read is the exact failure mode worth spending
// code to prevent.
//
// PUBLIC, AND THEREFORE BOUNDED. /docs is the surface a stranger reads first,
// so this route takes no account — which means the bucket is per IP rather than
// per user, and the caps are tighter than the signed-in brand-check's. Same
// per-instance caveat that route documents: serverless instances are ephemeral,
// so this is a speed bump and production must also carry a platform-level
// limit.
//
// WHAT IT COSTS US. model-registry.mjs's `ask_docs` row, which is a mini-tier
// model on the same door every other call in this product uses. The context is
// capped at CONTEXT_CHARS and the answer at MAX_OUTPUT_TOKENS, so a question is
// a fraction of a cent and cannot become an essay.
import { NextResponse } from "next/server";
import { modelForUse, priceModelTokens } from "../../../lib/local/model-registry.mjs";
import {
AI_TRANSPORTS,
modelRouteHeaders,
reportedCostUsd,
resolveModelRoute,
routeEvidence,
transportHttpReason
} from "../../../lib/local/ai-gateway.mjs";
import { source } from "../../../lib/docs-source";
import { supabaseConfigFromEnv } from "../../../lib/supabase-rest.mjs";
import { bearerTokenFromHeader, resolveAuthContext } from "../../../lib/comprehensive-pull.mjs";
import { modelUsageAllowed, modelUsageEntry, recordModelUsage } from "../../../lib/model-usage-ledger.mjs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Eight questions an hour per address. A reader asks two or three. */
const ASKS_PER_HOUR = 8;
const WINDOW_MS = 3600000;
/** How many pages may be assembled into one prompt. */
const MAX_PAGES = 5;
/** The hard ceiling on the assembled context, in characters. */
const CONTEXT_CHARS = 18000;
/** The answer is a paragraph and a list of links, not an essay. */
const MAX_OUTPUT_TOKENS = 700;
/** A question longer than this is a paste, not a question. */
const MAX_QUESTION_CHARS = 400;
/** The model call's own budget. A reader is waiting. */
const TIMEOUT_MS = 25000;
const buckets = new Map();
const allowRequest = (key) => {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now - bucket.startedAt >= WINDOW_MS) {
buckets.set(key, { startedAt: now, used: 1 });
if (buckets.size > 5000) buckets.clear();
return true;
}
if (bucket.used >= ASKS_PER_HOUR) return false;
bucket.used += 1;
return true;
};
/** Exported for the test: the bucket is per address and resets on the hour. */
export const __resetDocsAskBuckets = () => buckets.clear();
const clientAddress = (request) =>
String(request.headers.get("x-forwarded-for") || "")
.split(",")[0]
.trim()
|| request.headers.get("x-real-ip")
|| "unknown";
/**
* Words worth matching on.
*
* Deliberately crude — lowercase, split on non-letters, drop the short ones.
* A docs corpus of forty pages does not need embeddings to find the page about
* backlinks when somebody types "backlinks", and an embedding index would be a
* second thing to keep in sync with the generator.
*/
export const questionTerms = (question) =>
[...new Set(String(question || "").toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) || [])];
/** One page, flattened to the text we would search and quote. */
export const pageText = (page) => {
const data = page?.data?.structuredData;
const parts = [String(page?.data?.title || ""), String(page?.data?.description || "")];
for (const heading of Array.isArray(data?.headings) ? data.headings : []) {
parts.push(String(heading?.content || ""));
}
for (const block of Array.isArray(data?.contents) ? data.contents : []) {
parts.push(String(block?.content || ""));
}
return parts.filter(Boolean).join("\n");
};
/**
* The candidate pages for a question, best first.
*
* Score is the number of question terms that appear in the page, with the title
* counted twice — a page named "Backlinks" should beat a page that mentions
* backlinks once in a caveat. Ties break on URL so the selection is stable.
*/
export const selectPages = (pages, question, limit = MAX_PAGES) => {
const terms = questionTerms(question);
if (!terms.length) return [];
const scored = pages
.map((page) => {
const title = String(page?.data?.title || "").toLowerCase();
const body = pageText(page).toLowerCase();
let score = 0;
for (const term of terms) {
if (title.includes(term)) score += 2;
if (body.includes(term)) score += 1;
}
return { page, score };
})
.filter((row) => row.score > 0);
scored.sort((a, b) => (b.score - a.score) || (a.page.url < b.page.url ? -1 : 1));
return scored.slice(0, limit).map((row) => row.page);
};
/** The prompt's evidence block, capped. */
export const buildContext = (pages, budget = CONTEXT_CHARS) => {
const blocks = [];
let used = 0;
for (const page of pages) {
const header = `## ${page.data.title}\nURL: ${page.url}\n`;
const remaining = budget - used - header.length;
if (remaining <= 200) break;
const body = pageText(page).slice(0, remaining);
blocks.push(`${header}${body}`);
used += header.length + body.length;
}
return blocks.join("\n\n---\n\n");
};
const SYSTEM = [
"You answer questions about Brand Studio using ONLY the documentation pages provided below.",
"You may not use anything you know from anywhere else, and you may not guess.",
"If the pages do not answer the question, say so plainly and cite nothing.",
"Return JSON only, matching the schema. `answer` is at most three short paragraphs of plain",
"text with no markdown. `pages` lists the URLs of the pages your answer actually came from,",
"copied exactly from the URL lines below. Cite only pages you used."
].join(" ");
const RESPONSE_SCHEMA = {
type: "json_schema",
json_schema: {
name: "docs_answer",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["answer", "pages"],
properties: {
answer: { type: "string" },
pages: { type: "array", items: { type: "string" } }
}
}
}
};
const NOT_IN_DOCS =
"That is not in these documentation pages. Everything here is generated from what the product"
+ " actually does, so if a question has no answer on a page, the honest answer is that nobody"
+ " has written it down yet.";
export async function POST(request) {
let body = null;
try {
body = await readBoundedJsonRequest(request);
} catch {
return NextResponse.json({ ok: false, reason: "bad_json" }, { status: 400 });
}
const question = String(body?.question ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_QUESTION_CHARS);
if (!question) return NextResponse.json({ ok: false, reason: "no_question" }, { status: 400 });
if (!allowRequest(clientAddress(request))) {
return NextResponse.json(
{ ok: false, reason: "rate_limited", limit_per_hour: ASKS_PER_HOUR },
{ status: 429 }
);
}
const candidates = selectPages(source.getPages(), question);
if (!candidates.length) {
return NextResponse.json({ ok: true, answer: NOT_IN_DOCS, pages: [], model: null, searched: 0 });
}
const model = modelForUse("ask_docs", process.env);
const route = resolveModelRoute({ env: process.env, model });
if (process.env.BRAND_STUDIO_ASSISTED_PREFLIGHT !== "on" || route.transport === AI_TRANSPORTS.NONE) {
// Honest, and it names the pages anyway — the retrieval half of this
// feature works with no key at all, and a reader is better served by five
// links than by an error.
return NextResponse.json({
ok: true,
answer:
(route.transport === AI_TRANSPORTS.NONE ? "No model key is configured. No model was called." : "Assisted documentation answers are not enabled here. No model was called.")
+ " These are the pages that mention what you asked about.",
pages: candidates.map((page) => ({ url: page.url, title: page.data.title })),
model: null,
reason: process.env.BRAND_STUDIO_ASSISTED_PREFLIGHT !== "on" ? "assisted_preflight_not_enabled" : route.reason,
searched: candidates.length
});
}
// A MODEL ANSWER NEEDS AN ACCOUNT (independent review, 2026-09-26). The page
// list above stays public and free; a paid model call is made only for a
// signed-in reader, so it can never be an anonymous, unbounded spend path.
const { supabaseUrl, supabaseAnonKey } = supabaseConfigFromEnv(process.env);
const auth = await resolveAuthContext({ token: bearerTokenFromHeader(request.headers.get("authorization")), supabaseUrl, supabaseAnonKey });
if (!auth.authenticated) {
return NextResponse.json({
ok: true,
answer: "Sign in to get a written answer. These are the pages that mention what you asked about.",
pages: candidates.map((page) => ({ url: page.url, title: page.data.title })),
model: null,
reason: "sign_in_for_answers",
searched: candidates.length
});
}
// Today's model-call caps, checked before the call (model-usage-ledger.mjs).
const cap = await modelUsageAllowed({ userId: auth.user.id });
if (!cap.ok) {
return NextResponse.json({
ok: true,
answer: "Written answers have reached today's limit. These are the pages that mention what you asked about.",
pages: candidates.map((page) => ({ url: page.url, title: page.data.title })),
model: null,
reason: cap.reason,
searched: candidates.length
});
}
const allowed = new Set(candidates.map((page) => page.url));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let payload = null;
let usage = null;
let generationId = null;
try {
const response = await fetch(route.url, {
method: "POST",
headers: modelRouteHeaders(route),
signal: controller.signal,
body: JSON.stringify({
model: route.model,
max_completion_tokens: MAX_OUTPUT_TOKENS,
response_format: RESPONSE_SCHEMA,
messages: [
{ role: "system", content: SYSTEM },
{
role: "user",
content: `QUESTION: ${question}\n\nDOCUMENTATION PAGES:\n\n${buildContext(candidates)}`
}
]
})
});
if (!response.ok) {
return NextResponse.json(
{ ok: false, reason: transportHttpReason(route, response.status) },
{ status: 502 }
);
}
const json = await response.json();
usage = json?.usage || null;
generationId = json?.id || json?.generationId || null;
payload = JSON.parse(String(json?.choices?.[0]?.message?.content ?? "{}"));
} catch (error) {
return NextResponse.json(
{ ok: false, reason: error?.name === "AbortError" ? "timeout" : "model_call_failed" },
{ status: 502 }
);
} finally {
clearTimeout(timer);
}
// RULE 2 AND RULE 3, in code. A citation we did not supply is dropped, and an
// answer with nothing left standing is replaced rather than softened.
const cited = (Array.isArray(payload?.pages) ? payload.pages : [])
.map((url) => String(url || "").trim())
.filter((url) => allowed.has(url));
const unique = [...new Set(cited)];
const answer = String(payload?.answer ?? "").trim();
const grounded = Boolean(answer) && unique.length > 0;
const byUrl = new Map(candidates.map((page) => [page.url, page.data.title]));
// The Gateway's own charge when it states one; our table otherwise.
const usd = reportedCostUsd(usage) ?? priceModelTokens(model, {
prompt: Number(usage?.prompt_tokens) || 0,
completion: Number(usage?.completion_tokens) || 0
});
// Written down against the signed-in reader as a zero-amount usage row
// (model-usage-ledger.mjs); nothing is charged.
if (usage) await recordModelUsage({ entry: modelUsageEntry({ userId: auth.user.id, line: "docs_answer", cost: { model: route.model || model, input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, usd, generation_id: generationId } }) });
return NextResponse.json({
ok: true,
answer: grounded ? answer : NOT_IN_DOCS,
pages: grounded ? unique.map((url) => ({ url, title: byUrl.get(url) || url })) : [],
searched: candidates.length,
model: routeEvidence(route),
// The same discipline every other model call in this product follows: the
// call's real cost, written down, never omitted and never zero when tokens
// moved.
cost: usage ? { prompt: usage.prompt_tokens ?? null, completion: usage.completion_tokens ?? null, usd } : null
});
}