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 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: [
|
try {
|
||||||
{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } },
|
const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
||||||
],
|
method: "POST",
|
||||||
messages: msgs.map((m) => ({ role: m.role, content: m.content })),
|
headers: { "content-type": "application/json" },
|
||||||
};
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
let res: Response;
|
messages: chatMessages,
|
||||||
try {
|
stream: false,
|
||||||
res = await fetch(ANTHROPIC_API, {
|
options: { temperature: 0.4, num_predict: 512 },
|
||||||
method: "POST",
|
}),
|
||||||
headers: {
|
signal: AbortSignal.timeout(50_000),
|
||||||
"x-api-key": apiKey,
|
});
|
||||||
"anthropic-version": "2023-06-01",
|
if (res.ok) {
|
||||||
"content-type": "application/json",
|
const data: any = await res.json();
|
||||||
},
|
const reply = (data?.message?.content || "").trim();
|
||||||
body: JSON.stringify(apiBody),
|
if (!reply) {
|
||||||
});
|
lastErr = "empty response";
|
||||||
} catch (err: any) {
|
continue;
|
||||||
return NextResponse.json({ error: "Upstream error: " + (err.message || String(err)) }, { status: 502 });
|
}
|
||||||
|
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) {
|
return NextResponse.json(
|
||||||
const t = await res.text();
|
{ error: lastErr || "AI temporarily unavailable. Please try again in a moment." },
|
||||||
return NextResponse.json({ error: `Upstream ${res.status}: ${t.slice(0, 200)}` }, { status: 502 });
|
{ status: 502 },
|
||||||
}
|
);
|
||||||
|
|
||||||
const data: any = await res.json();
|
|
||||||
const reply = (data.content?.[0]?.text || "").trim();
|
|
||||||
return NextResponse.json({ reply });
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user