跳到主要內容

【2026 最新】OpenRouter 契約測試怎麼做?上線前必跑 6 道 Provider 驗收

最後更新: ·
OpenRouter 契約測試 教學首圖

你準備做 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 要驗證你要求的結果,不能只驗狀態碼。

OpenRouter 上線驗收流程,從 HTTP 回應依序檢查內容、工具、狀態與路由證據
200 OK 只是入口;內容、工具、狀態與路由證據都成立,才算可交付的成功。

這篇與 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.onlyallow_fallbacks:falserequire_parameters:true。這是在隔離受測端點;正式流量是否開 fallback,是另一個測試階段。

  1. 模型:保存 canonical model slug,以及測試當日的 model/endpoint discovery JSON。
  2. Provider:每輪只放一個完整 endpoint slug;不要讓 A 失敗後偷偷由 B 代答。
  3. Request:固定 system prompt、tool schema、reasoning 設定、圖片 bytes 與 token 上限。
  4. 執行環境:記錄 production base URL、egress region、concurrency 與 client 版本。
  5. 證據:逐筆保存去除秘密後的 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、缺少 choicesfinish_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_reasontool_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_detailstool_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 六道契約測試矩陣,列出內容、工具、歷史、視覺、收據與正式路徑的通過條件
每一道 gate 都有獨立 pass condition;不需要的能力應明確標成 not-required,而不是默默算通過。

把 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,再根據庫存回答。一次完整驗收應這樣讀:

  1. Provider A:HTTP 200、content 為 null、也有一個 tool call,看似通過。
  2. 解析 arguments:{"sku":42}sku 應是字串,schema gate 失敗;不要真的執行庫存工具。
  3. Provider B:同一 fixture 回 {"sku":"A-42"},tool result 回傳後又精確回答 HISTORY_OK,內容、工具與 history 三關通過。
  4. Receipt:response ID 可追到 B 的 generation,usage 也完整;B 才能進入 production allowlist。
  5. 稍後退化:production probe 對 B 連續出現 429;應先依 Retry-After 做有上限的退避,再打開 breaker 暫時隔離 B,將新任務交給已通過相同契約的 C。

注意最後一步只適用尚未產生副作用的新任務。工具已扣款、下單或寄信後,不可把整段 history 盲目重送給另一家;應使用 idempotency key(重複提交仍只執行一次的識別碼)與任務狀態機。這也是 contract test 與一般聊天 benchmark 最大的差別。

OpenRouter Provider 失敗復原狀態圖,依序顯示有限重試、fallback、斷路器隔離與到期重驗
Retry、fallback 與 circuit breaker 是三個不同層次;先保護副作用,再切換到已通過契約的候選端點。

怎麼把 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 點

  1. 先測 semantic outcome;HTTP 200 只證明回應抵達。
  2. 嚴格測試固定完整 endpoint slug 並關 fallback;正式路由另開一輪。
  3. 工具要驗名稱、JSON、schema 與第二輪 history,不只看有沒有 tool_calls
  4. 每筆保存 request、response ID、usage、route evidence 與 production context,秘密則排除。
  5. 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 課程安排下一個實作專案。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。