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 maxDuration = 60;
const ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
const MODEL = process.env.BB_REWRITE_MODEL || "claude-opus-4-7";
// Local Ollama on AI_002 (10.10.0.67). No paid AI services per policy.
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_WINDOW_MS = 60 * 60 * 1000;
@@ -32,30 +33,26 @@ Broadcast Beat archive.
Tone: trade-press, factual, lightly conversational. Plain English.
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.
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.
- Keep responses short and useful — 1-3 short paragraphs unless asked
for more.
- If the user asks about something outside broadcast/production
technology, politely steer them back.
- Keep responses short and useful — 1-3 short paragraphs unless asked for more.
- If the user asks about something outside broadcast/production technology,
politely steer them back.
`;
interface ClientMsg { role: "user" | "assistant"; content: string }
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 rl = rateLimit(ip);
if (!rl.ok) {
return NextResponse.json(
{ 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 });
}
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
if (!lastUser) return NextResponse.json({ error: "No user message" }, { status: 400 });
// Format as Ollama chat
const chatMessages = [
{ role: "system", content: SYSTEM_PROMPT },
...msgs.map((m) => ({ role: m.role, content: m.content })),
];
const apiBody = {
model: MODEL,
max_tokens: 1024,
system: [
{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } },
],
messages: msgs.map((m) => ({ role: m.role, content: m.content })),
};
let res: Response;
try {
res = await fetch(ANTHROPIC_API, {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(apiBody),
});
} catch (err: any) {
return NextResponse.json({ error: "Upstream error: " + (err.message || String(err)) }, { status: 502 });
// Retry up to 2x with backoff if Ollama returns 503/busy
let lastErr = "";
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: MODEL,
messages: chatMessages,
stream: false,
options: { temperature: 0.4, num_predict: 512 },
}),
signal: AbortSignal.timeout(50_000),
});
if (res.ok) {
const data: any = await res.json();
const reply = (data?.message?.content || "").trim();
if (!reply) {
lastErr = "empty response";
continue;
}
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));
}
}
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 reply = (data.content?.[0]?.text || "").trim();
return NextResponse.json({ reply });
return NextResponse.json(
{ error: lastErr || "AI temporarily unavailable. Please try again in a moment." },
{ status: 502 },
);
}