你準備做 OpenRouter 契約測試時,把同一個 model slug 交給 Provider A 與 Provider B,兩邊都回了 200 OK,是不是就會把它們當成可互換?真正上線後,風險往往藏在「看起來成功」裡:可見答案是空的、tool call 參數無法解析、上一輪 reasoning history 接不回去,或圖片根本沒有送進模型。
OpenRouter 契約測試就是上線前的驗收單。本文專為第一次替 AI 應用接多 Provider 的讀者寫:你會從零建立 6 道可重跑的 gate,保存 request/response 證據,再把 bounded retry、fallback 與 circuit breaker 接到 release 流程。你不必相信某家 Provider「應該沒問題」,只要看它能不能通過你的產品契約。
先說結論:200 OK 只是送達,不是驗收
先記住這個心智模型:
OpenRouter 上線成功 = 可用內容 × 可解析工具 × 可延續狀態 × 可追蹤路由
乘號的意思是:任何一項歸零,整次任務就不該算成功。OpenRouter 的官方錯誤文件也明確說明,上游已開始處理後,非串流回應可能在 HTTP 200 的 body 裡只有 error、沒有 choices;串流則可能以錯誤事件與 finish_reason:"error" 收尾。因此 gate 要驗證你要求的結果,不能只驗狀態碼。

這篇與 Provider Pinning 教學差在哪?
先讀過 AlphaLab 的OpenRouter Provider Pinning 與 Endpoint Accuracy 實戰也沒關係。那篇回答的是「同一模型下,哪個 endpoint 對我的題目比較好」;本文回答的是更前面的工程問題:這個 endpoint 有沒有遵守應用程式賴以運作的 wire contract,而且壞掉時能不能安全復原。我們不重跑 12 題品質榜,而是固定 6 個協定級 fixture。
一份 2026 年 9 月 7 日的生產經驗報告提供了題目來源:作者稱其服務 Olly 累計處理逾 1,800 萬則訊息,其中約三分之一涉及透過 OpenRouter 使用開源模型。這是單一服務的自述,message 也不等於 API request;它不能代表平台故障率。不過報告保留的 probe 與案例很適合轉成驗收項目,例如 reasoning-only 的空白答案、tool markup 外洩、history 不相容與來源位置相關的 429。本文把這些線索與 OpenRouter 當前官方協定交叉核對,再做成你可自行重跑的 gate。
OpenRouter 契約測試前,先凍結 5 個變因
開始前準備 Node.js 22 以上、已授權的 OpenRouter API key、一個 model slug,以及模型頁面當下列出的完整 endpoint slug。若只寫 Provider 基礎名稱,可能匹配多個 variant;嚴格測試應依官方 Provider Selection 文件使用完整 slug、provider.only、allow_fallbacks:false 與 require_parameters:true。這是在隔離受測端點;正式流量是否開 fallback,是另一個測試階段。
- 模型:保存 canonical model slug,以及測試當日的 model/endpoint discovery JSON。
- Provider:每輪只放一個完整 endpoint slug;不要讓 A 失敗後偷偷由 B 代答。
- Request:固定 system prompt、tool schema、reasoning 設定、圖片 bytes 與 token 上限。
- 執行環境:記錄 production base URL、egress region、concurrency 與 client 版本。
- 證據:逐筆保存去除秘密後的 raw request、raw response、HTTP status、耗時、response ID、usage、router metadata 與時間戳。
把它想成驗收插座:測試時先拔掉延長線與轉接頭,才能知道到底是哪一孔壞了。若你還不熟 Agent 為什麼需要獨立執行層,可先看AI Agent Harness 是什麼;若不知道 fixture、grader 與回歸測試怎麼分工,則先補AI Evals 七步教學。
OpenRouter 契約測試:上線前必跑 6 道 gate
① Content gate:回 200,真的有可用答案嗎?
痛點:response.ok === true 仍可能沒有可交付內容。合法的 tool-call 訊息也常以 content:null 回來,所以「content 不是空字串」同樣不是通用成功條件。
解法:替每種任務定義 semantic success。純文字 fixture 要求只回 ROUTER_OK,同時拒絕頂層 error、缺少 choices、finish_reason:"error" 與空白內容;工具任務則允許 content 為 null,但必須通過下一道 tool gate。這會同時抓出「只有 reasoning、沒有可見答案」和真正 hollow 的 200。
② Tool gate:函式名、JSON 與 schema 都對嗎?
痛點:模型可能選錯 function、把 arguments 包成無法解析的字串,或把供應商自己的 tool markup 當一般文字吐出來。看到 tool_calls 不等於能執行。
解法:用 tool_choice 強制呼叫一個無副作用的 report_status,暫時設 parallel_tool_calls:false,再依序驗證 finish_reason 是 tool_calls、只有一個具唯一 ID 的 call、名稱相符、function.arguments 可被 JSON.parse,以及結果精確符合 JSON Schema。OpenRouter 的工具呼叫文件也要求第二輪帶回原 assistant tool call、相符的 tool_call_id 與完整 tools;別只測第一跳。
③ History gate:tool result 回去後,對話接得起來嗎?
痛點:第一輪工具格式正確,不代表第二輪能續寫。Reasoning model 可能回傳結構化 reasoning_details;如果 client 丟掉、重排或改寫它,另一個 Provider 可能拒絕 history,或失去必要狀態。
解法:保存第一輪的完整 assistant message,原樣帶回 reasoning_details 與 tool_calls,再附上 tool result,要求第二輪只回 HISTORY_OK。官方Reasoning Tokens 文件特別要求連續 detail blocks 不被修改或重排。若你的產品不用 reasoning/tools,可把這道標成 not-required;若會用,就不能用第一輪通過代替。
④ Vision gate:圖片真的被模型看見嗎?
痛點:模型支援 vision、endpoint metadata 也列出 image input,仍不等於你的 multipart request 在每條路徑都被正確轉送。文字回應流暢,也可能只是在猜。
解法:把一張很小、無個資、答案唯一的 PNG 放進 repository,例如白底上只有紫色三角形與字母 K;同一 bytes 同時測 base64,必要時再測 URL。依官方 image input 格式,在 messages[].content 先放文字、再放 image_url,並斷言回覆同時包含「紫色、三角形、K」。這是在驗傳輸契約,不是考美術理解。
⑤ Receipt gate:成本與實際路由查得到嗎?
痛點:沒有 usage 與 route receipt,就無法算完成成本,也無法回答「最後是哪個 Provider 服務」。一般 Chat success schema 並不保證頂層一定有 provider。
解法:送出 X-OpenRouter-Metadata: enabled,依官方 Router Metadata 文件把 openrouter_metadata 當 additive schema 解碼:必要欄位嚴格驗,未知欄位忽略,不要因官方新增 stage 就讓 parser 崩潰。再用 response id 查 generation record,保存 provider_name、實際 model 與 total_cost。官方 Usage Accounting 文件說完整非串流 body 或串流最後事件會帶 usage;其中 cost 是帳戶被收取的 credits,不要自行改標成美元。Cache replay 與部分錯誤可能沒有 router metadata,因此「缺少 metadata」要分類,不能武斷解讀成未經路由。
⑥ Production-path gate:本機會跑,正式環境也會跑嗎?
痛點:開發機與 production 可能經過不同 base URL、egress、proxy、連線池、concurrency 與帳戶政策;本機綠燈不能替正式路徑背書。前述生產報告曾觀察到來源位置相關的 429,作者懷疑是下游的 IP policy;這個因果未被證實,原始 probe 也沒有完整重現 Mac 對 production 的對照。
解法:讓相同 6 道 probe 從實際 deployment region 發出,在代表性的 concurrency 下分開記錄 2xx semantic failure、408、429、5xx、TTFT、總耗時、retry 後完成成本與任務 pass rate。若你的 Business/Enterprise 帳戶使用美國或歐盟 in-region routing,base URL 本身也是契約,應對該區域的 /models 重新 discovery;不要從模型公司的國籍推論 inference 地點。

把 OpenRouter 契約測試做成可重跑 harness
下面是核心 runner。它只用 Node 內建 fetch,不用承擔 SDK 版本差異;會對每個 Provider 固定同一 model、關閉 fallback、保留 raw receipt,並示範 content、tool 與 receipt 三個共用 validator。History、vision 與 production-path 依產品能力接在同一個 call() 上。程式已做語法檢查,但本文沒有可授權的 OpenRouter key,因此不宣稱 AlphaLab 跑過任何 Provider 成績。
import { mkdir, writeFile } from "node:fs/promises";
import { performance } from "node:perf_hooks";
const KEY = process.env.OPENROUTER_API_KEY;
const MODEL = process.env.MODEL;
const PROVIDERS = (process.env.PROVIDERS || "").split(",")
.map(value => value.trim()).filter(Boolean);
const OUT = "./receipts";
const RUN = crypto.randomUUID();
if (!KEY || !MODEL || PROVIDERS.length === 0) {
throw new Error("請設定 OPENROUTER_API_KEY、MODEL、PROVIDERS");
}
await mkdir(OUT, { recursive: true });
function text(message = {}) {
if (typeof message.content === "string") return message.content.trim();
return (message.content || [])
.filter(part => part?.type === "text")
.map(part => part.text || "").join("").trim();
}
function envelope(data = {}) {
return !data.error && Array.isArray(data.choices) && data.choices.length;
}
async function generation(id) {
for (let i = 0; i < 3; i++) {
const r = await fetch(
`https://openrouter.ai/api/v1/generation?id=${encodeURIComponent(id)}`,
{ headers: { Authorization: `Bearer ${KEY}` } }
);
const body = await r.json().catch(() => ({}));
if (r.ok && body?.data?.provider_name) return body.data;
await new Promise(ok => setTimeout(ok, 1000 * (i + 1)));
}
return null;
}
function toolCall(message = {}) {
if (message.tool_calls?.length !== 1) return false;
const call = message.tool_calls[0];
if (!call.id || call.function?.name !== "report_status") return false;
try {
const args = JSON.parse(call.function.arguments);
return args.status === "ROUTER_OK"
&& Object.keys(args).length === 1;
} catch { return false; }
}
async function call(provider, label, payload) {
const request = {
model: MODEL, ...payload,
provider: {
only: [provider],
allow_fallbacks: false,
require_parameters: true
}
};
const started = performance.now();
const response = await fetch(
"https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"X-OpenRouter-Metadata": "enabled"
},
body: JSON.stringify(request)
}
);
const raw = await response.text();
let data;
try { data = JSON.parse(raw); } catch { data = { raw }; }
const receipt = {
at: new Date().toISOString(), provider, label,
latency_ms: Math.round(performance.now() - started),
http_status: response.status, request, response: data
};
const safe = provider.replace(/[^a-z0-9._-]+/gi, "_");
await writeFile(`${OUT}/${safe}-${label}.json`,
JSON.stringify(receipt, null, 2));
return { response, data };
}
const TOOL = {
type: "function",
function: {
name: "report_status",
description: "回報固定契約字串",
parameters: {
type: "object",
properties: {
status: { type: "string", enum: ["ROUTER_OK"] }
},
required: ["status"],
additionalProperties: false
}
}
};
for (const provider of PROVIDERS) {
const a = await call(provider, "content", {
messages: [{ role: "user",
content: `驗收編號 ${RUN}。只回覆 ROUTER_OK` }],
temperature: 0, max_completion_tokens: 32
});
const contentPass = a.response.ok && envelope(a.data)
&& a.data.choices[0].finish_reason !== "error"
&& text(a.data.choices[0].message) === "ROUTER_OK";
const b = await call(provider, "tool", {
messages: [{ role: "user",
content: `驗收編號 ${RUN}。請用工具回報狀態` }],
tools: [TOOL],
parallel_tool_calls: false,
tool_choice: {
type: "function", function: { name: "report_status" }
},
temperature: 0, max_completion_tokens: 96
});
const toolPass = b.response.ok && envelope(b.data)
&& b.data.choices[0].finish_reason === "tool_calls"
&& toolCall(b.data.choices[0].message);
const usage = a.data.usage;
const route = a.data.openrouter_metadata
|| (a.data.id ? await generation(a.data.id) : null);
const receiptPass = Number.isFinite(usage?.prompt_tokens)
&& Number.isFinite(usage?.completion_tokens)
&& Number.isFinite(usage?.cost)
&& Boolean(route);
console.log({ provider, contentPass, toolPass, receiptPass });
if (!contentPass || !toolPass || !receiptPass) process.exitCode = 1;
}
先把檔案存成 contract.mjs,再執行:
OPENROUTER_API_KEY='你的金鑰' \
MODEL='你的/model-slug' \
PROVIDERS='provider-a/exact-tag,provider-b/exact-tag' \
node contract.mjs
不要把 key 寫進 receipt 或 commit。完整版本再加兩個 helper:一個用 response ID 查 generation record;另一個把第一輪 assistant message 原樣帶進 history request。Vision fixture 則用 readFileSync("fixtures/vision-k.png").toString("base64") 組成 data:image/png;base64,...。想把這個 loop 擴成完整 Agent 執行器,可接著看30 行做出 AI Agent Harness。
完整 trace:A 回 200,為什麼仍要淘汰?
假設購物助理要先呼叫 lookup_stock,再根據庫存回答。一次完整驗收應這樣讀:
- Provider A:HTTP 200、content 為 null、也有一個 tool call,看似通過。
- 解析 arguments:
{"sku":42}的sku應是字串,schema gate 失敗;不要真的執行庫存工具。 - Provider B:同一 fixture 回
{"sku":"A-42"},tool result 回傳後又精確回答HISTORY_OK,內容、工具與 history 三關通過。 - Receipt:response ID 可追到 B 的 generation,usage 也完整;B 才能進入 production allowlist。
- 稍後退化:production probe 對 B 連續出現 429;應先依
Retry-After做有上限的退避,再打開 breaker 暫時隔離 B,將新任務交給已通過相同契約的 C。
注意最後一步只適用尚未產生副作用的新任務。工具已扣款、下單或寄信後,不可把整段 history 盲目重送給另一家;應使用 idempotency key(重複提交仍只執行一次的識別碼)與任務狀態機。這也是 contract test 與一般聊天 benchmark 最大的差別。

怎麼把 OpenRouter 契約測試接進 release gate?
不要先抄別人的「p95 必須低於幾秒」。你的 prompt 長度、輸出長度、region 與併發都不同。先用一週自己的健康流量建立 baseline,再把門檻寫進版本化設定:
- Blocking:required gate 任一失敗、error envelope 未分類、tool schema 錯誤、history 斷裂、vision fixture 誤判,立即擋 release。
- Budget:比較相同任務的 p50/p95 TTFT、總耗時與 retry-adjusted cost;超過你自己的預算才擋。
- Bounded retry:只對明確的暫時性錯誤與尚未啟動副作用的 semantic empty 重試;尊重
Retry-After,設定總時間與總次數上限。 - Fallback:候選 Provider 必須先各自通過 strict run;production run 才開 fallback,並檢查最終 model 與 route evidence。
- Circuit breaker:以滑動窗口累積同類失敗,達到團隊設定的門檻後暫時 quarantine;冷卻後先送 probe,不直接恢復全部流量。
- Expiry:allowlist 不是永久認證。設定短期有效期限,模型、schema、SDK、endpoint roster 或 production region 改變時立即重驗。
例如「每個 Provider 最多再試 2 次、breaker 連續 3 次失敗後開啟、24 小時重驗」只能算起始政策範例,不是 OpenRouter 官方建議或 SLA。先從保守小流量開始,用你的錯誤成本調整。想把 route、token、tool trace 一起觀測,可延伸讀AI Agent Observability 實戰。
截至 2026 年 9 月,兩個容易誤判的新機制
Auto Exacto 會幫忙,但不替你的產品簽收
OpenRouter 的 Auto Exacto 文件目前說明,工具請求會使用 tool-call 成功資料與 JSON/schema validator 調整 Provider 排序。這能改善預設 routing,卻不知道你的 lookup_stock 是否具有正確商業語意,也不會替你驗 history、vision 或 production egress。平台級訊號適合當候選排序;你的 contract gate 才能決定是否放行。
Zero Completion Insurance 不等於「空答案都安全」
官方的 Zero Completion Insurance只涵蓋特定條件:零 completion tokens,並且 finish reason 為空或 error。若回應消耗了 reasoning tokens、以 stop 結束,卻沒有你要的可見答案,它可能不符合該條件;即使某次費用為零,任務也仍然失敗。財務補償與產品正確性是兩張不同的驗收單。
FAQ:OpenRouter 契約測試常見問題
1. HTTP 200 加上非空 content,還不夠嗎?
不夠。先排除 error envelope 與錯誤 finish reason,再檢查任務語意。工具任務的 content 可以合法為 null;純文字任務則應要求 sentinel 或結構化 invariant。
2. 設定 provider.order 就固定 Provider 了嗎?
沒有完整固定。order 主要表達優先順序;嚴格測試還要使用完整 endpoint slug、單一 allowlist,並設 allow_fallbacks:false。基礎 slug 仍可能匹配多個 variant。
3. require_parameters:true 代表工具一定正常嗎?
不代表。它會排除未宣告支援該參數的端點,但不證明 function 名稱、arguments schema、history round-trip 或商業語意正確;那些仍要由 fixture 驗。
4. 同一 reasoning effort 應該產生相同 token 數嗎?
不應這樣假設。只測 model discovery 當下宣告支援的 effort,並把 tokens 當觀測值;不同 Provider 的轉換方式與模型行為不保證逐字或逐 token 相同。
5. 每次 429 都應立刻換 Provider 嗎?
不一定。429 可能來自 OpenRouter 或上游;先分類、尊重可用的 Retry-After,再受總次數與時間預算約束。若工具已有副作用,不要重播整個任務。
6. 可以在筆電跑完就放行 production 嗎?
不可以替正式路徑簽收。筆電 smoke test 能先抓 schema 問題;最後一輪仍要由 production 的 base URL、egress、帳戶政策與代表性 concurrency 發出。
7. Router metadata 不見,就是測試失敗嗎?
要看情境。先確認 header、非串流/SSE 收尾處理與 cache 狀態;部分錯誤本來就可能沒有 metadata。用 response ID 查 generation,並把「證據缺失」獨立分類。
8. Provider 通過一次,可以永久 pin 嗎?
不建議。Endpoint roster、serving stack 與路由政策會變。讓 allowlist 自帶期限,在 schema、模型、地區或錯誤分布改變時提前重驗;pin 是受測設定,不是永久保固。
新手最後記住這 5 點
- 先測 semantic outcome;HTTP 200 只證明回應抵達。
- 嚴格測試固定完整 endpoint slug 並關 fallback;正式路由另開一輪。
- 工具要驗名稱、JSON、schema 與第二輪 history,不只看有沒有
tool_calls。 - 每筆保存 request、response ID、usage、route evidence 與 production context,秘密則排除。
- Retry、fallback、breaker 與 expiry 是你要版本化的產品政策,不是平台承諾。
接著閱讀
左右滑動查看更多推薦
下一步:先讓一個 Provider 誠實失敗
先不要一次接十家。今天就選一個 model、兩個完整 Provider endpoint,建立 ROUTER_OK、tool schema 與固定小圖三個 fixture,從 production runner 留下第一批 receipt。接著刻意餵一個錯誤 schema,確認 release gate 真的會紅,而不是永遠報喜。
最後回到開頭那行:可用內容 × 可解析工具 × 可延續狀態 × 可追蹤路由。你不是在替某家 Provider 打永久分數,而是在證明今天這個版本能完成你的任務,也知道明天壞掉時怎麼退。想把 contract、eval 與 Agent harness 串成完整開發流程,可到 AlphaLab 課程安排下一個實作專案。






