昨天結尾提到,抓網頁內文跟 AI 分類都要打外部服務,免費額度說掛就掛,今天就把 callAI 這支函式攤開來看,看 line-notion-bot 怎麼把這件事的風險降到最低。
先看整支函式的骨架:
async function callAI(env, system, user) {
if (env.MISTRAL_API_KEY) {
try {
return await callMistral(env, system, user);
} catch {
// 掉回 OpenRouter,不中斷整個分類流程
}
}
return await callOpenRouter(env, system, user);
}
邏輯很直白:有設定 MISTRAL_API_KEY 才會先試 Mistral,試了失敗(或根本沒設定這個 secret)就掉到 callOpenRouter。這裡的 catch 是空的,沒有 rethrow,代表 Mistral 那條路只要出事,不管是什麼原因,一律靜默吞掉、換下一條路繼續跑,不會讓整個分類流程在這裡就中斷掉。
Mistral 這邊呼叫很單純:
async function callMistral(env, system, user) {
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.MISTRAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-medium-latest',
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
temperature: 0.2,
}),
});
if (!res.ok) throw new Error(`Mistral 分類失敗(${res.status})`);
const data = await res.json();
return data.choices?.[0]?.message?.content ?? '';
}
打的是 Mistral 官方 API,用自己帳號申請的免費額度,model 寫死 mistral-medium-latest,失敗就 throw,讓 callAI 那層的 catch 接住。
callOpenRouter 長得很像,但關鍵差在 body 那個欄位:
async function callOpenRouter(env, system, user) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
models: env.AI_MODELS,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
temperature: 0.2,
}),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`AI 分類失敗(${res.status})${detail.slice(0, 120)}`);
}
const data = await res.json();
return data.choices?.[0]?.message?.content ?? '';
}
Mistral 那邊傳的是單數 model,這裡傳的是複數 models,一個陣列。env.AI_MODELS 對應 wrangler.toml 裡的設定:
AI_MODELS = ["meta-llama/llama-4-maverick:free", "meta-llama/llama-4-scout:free", "openrouter/free"]
OpenRouter 看到 models 這個欄位,會照陣列順序自己去試,第一個掛了換第二個,不用自己寫重試邏輯。這個陣列有個硬限制,最多只能放 3 個,超過的話 API 直接回 400(錯誤訊息是 'models' array must have 3 items or fewer.),這也是為什麼陣列長度剛好卡在三。
所以標題講的「三層」,對到的就是這三層:第一層是 Mistral 官方 API,用自己帳號的額度打頭陣;第二層是 OpenRouter 陣列裡排在前面的免費模型,目前是 llama-4-maverick 跟 llama-4-scout;第三層固定放 openrouter/free,讓 OpenRouter 自動選一個當下還活著的免費模型頂著,當最後的保底。三層疊起來,前面兩層不管哪個下架、哪個超額度,都還有下一層可以接。
會多加 Mistral 這一層,是後來才補上去的設計,理由是 Day 4 提過的那個錯誤畫面:免費 Llama 模型偶爾不照指示輸出 JSON,會回一些類似安全審查標記的字串,像是 User Safety: safe,這種情況下 Day 10 講的那三層 JSON 解析會全部失敗。Mistral 官方 API 在這方面穩定不少,設定了 MISTRAL_API_KEY 就優先打它,能把「回傳格式壞掉」這件事的機率壓低一截。
也因為 Mistral 跟 OpenRouter 這兩家的 API 規格不一樣,才拆成 callMistral 跟 callOpenRouter 兩支獨立函式:Mistral 官方 API 只吃單一 model 字串,沒有 OpenRouter 那種多模型 fallback 陣列的機制,硬要共用一套呼叫邏輯反而要寫一堆 if-else 判斷是誰在跑,不如兩支函式各自單純。MISTRAL_API_KEY 這個 secret 本身也是選填的,沒設定就直接跳過整段 Mistral 邏輯,callAI 一開始的 if (env.MISTRAL_API_KEY) 就是在做這個判斷。
三層都試過還是失敗,callOpenRouter 丟出的錯誤會一路往上,被 Day 10 那套「失敗也要給診斷資訊」的錯誤處理接住,回到 LINE 對話框裡讓我看到到底是哪一層、哪個狀態碼出的問題。明天要換個方向,講抓不到內文的網站(像 Facebook、Instagram 這種擋爬蟲的),line-notion-bot 怎麼讓筆記還是能憑網址跟備註存進去。