ask-bb-ai: swap from Anthropic to local Ollama on AI_002

Editorial rule is no paid AI services in production — this endpoint was
calling api.anthropic.com which violated that. Rewires to local Ollama
at 10.10.0.67:11434 using llama3:latest (warm, ~4.7GB, reliable).

Adds 3-attempt retry with backoff for 503/busy responses since AI_002
is shared with the article rewrite pipeline that periodically saturates
Ollama's pending queue. Drops max_tokens 1024 → 512 to keep responses
snappy and reduce queue pressure.

OLLAMA_HOST + BB_ASK_AI_MODEL env vars supported for overrides.

Removes the "AI not configured" 503 — the only way that error fires
now is if Ollama itself is unreachable for all 3 retries.
This commit is contained in:
Ryan Salazar
2026-05-27 13:39:24 +00:00
parent 1793c4f0e2
commit 52a6792c9e

View File

@@ -4,8 +4,9 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const maxDuration = 60; export const maxDuration = 60;
const ANTHROPIC_API = "https://api.anthropic.com/v1/messages"; // Local Ollama on AI_002 (10.10.0.67). No paid AI services per policy.
const MODEL = process.env.BB_REWRITE_MODEL || "claude-opus-4-7"; const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://10.10.0.67:11434";
const MODEL = process.env.BB_ASK_AI_MODEL || "llama3:latest";
const RATE_LIMIT_MAX = 30; const RATE_LIMIT_MAX = 30;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
@@ -32,30 +33,26 @@ Broadcast Beat archive.
Tone: trade-press, factual, lightly conversational. Plain English. Tone: trade-press, factual, lightly conversational. Plain English.
Citations: whenever you reference an article, cite it inline as Citations: whenever you reference an article, cite it inline as
[title](/articles/{slug}). When you reference an event, cite as [title](/news/{slug}). When you reference an event, cite as
[Event name](/show-coverage/{slug}). Use real slugs. [Event name](/show-coverage/{slug}). Use real slugs.
Constraints: Constraints:
- Never mention Anthropic, Claude, or any underlying model. - Never reveal which AI model or vendor you run on.
- Never invent facts. If you don't know, say so. - Never invent facts. If you don't know, say so.
- Keep responses short and useful — 1-3 short paragraphs unless asked - Keep responses short and useful — 1-3 short paragraphs unless asked for more.
for more. - If the user asks about something outside broadcast/production technology,
- If the user asks about something outside broadcast/production politely steer them back.
technology, politely steer them back.
`; `;
interface ClientMsg { role: "user" | "assistant"; content: string } interface ClientMsg { role: "user" | "assistant"; content: string }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return NextResponse.json({ error: "AI not configured" }, { status: 503 });
const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0]?.trim() || "anon"; const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0]?.trim() || "anon";
const rl = rateLimit(ip); const rl = rateLimit(ip);
if (!rl.ok) { if (!rl.ok) {
return NextResponse.json( return NextResponse.json(
{ error: "Too many questions — try again in an hour." }, { error: "Too many questions — try again in an hour." },
{ status: 429 } { status: 429 },
); );
} }
@@ -65,39 +62,52 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid message list" }, { status: 400 }); return NextResponse.json({ error: "Invalid message list" }, { status: 400 });
} }
const lastUser = [...msgs].reverse().find((m) => m.role === "user"); // Format as Ollama chat
if (!lastUser) return NextResponse.json({ error: "No user message" }, { status: 400 }); const chatMessages = [
{ role: "system", content: SYSTEM_PROMPT },
...msgs.map((m) => ({ role: m.role, content: m.content })),
];
const apiBody = { // Retry up to 2x with backoff if Ollama returns 503/busy
model: MODEL, let lastErr = "";
max_tokens: 1024, for (let attempt = 1; attempt <= 3; attempt++) {
system: [
{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } },
],
messages: msgs.map((m) => ({ role: m.role, content: m.content })),
};
let res: Response;
try { try {
res = await fetch(ANTHROPIC_API, { const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: "POST", method: "POST",
headers: { headers: { "content-type": "application/json" },
"x-api-key": apiKey, body: JSON.stringify({
"anthropic-version": "2023-06-01", model: MODEL,
"content-type": "application/json", messages: chatMessages,
}, stream: false,
body: JSON.stringify(apiBody), options: { temperature: 0.4, num_predict: 512 },
}),
signal: AbortSignal.timeout(50_000),
}); });
} catch (err: any) { if (res.ok) {
return NextResponse.json({ error: "Upstream error: " + (err.message || String(err)) }, { status: 502 });
}
if (!res.ok) {
const t = await res.text();
return NextResponse.json({ error: `Upstream ${res.status}: ${t.slice(0, 200)}` }, { status: 502 });
}
const data: any = await res.json(); const data: any = await res.json();
const reply = (data.content?.[0]?.text || "").trim(); const reply = (data?.message?.content || "").trim();
if (!reply) {
lastErr = "empty response";
continue;
}
return NextResponse.json({ reply }); return NextResponse.json({ reply });
}
const txt = await res.text();
lastErr = `Ollama ${res.status}: ${txt.slice(0, 180)}`;
if (res.status === 503 || /busy|pending requests exceeded/i.test(txt)) {
// Server busy — wait and retry
await new Promise((r) => setTimeout(r, attempt * 1500));
continue;
}
break; // non-retriable
} catch (err: any) {
lastErr = "Upstream error: " + (err?.message || String(err));
if (attempt < 3) await new Promise((r) => setTimeout(r, attempt * 1500));
}
}
return NextResponse.json(
{ error: lastErr || "AI temporarily unavailable. Please try again in a moment." },
{ status: 502 },
);
} }