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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user