本文只讲方法:如何在 Cloudflare Workers 上从零搭一个 OpenRouter 代理网关。所有密钥、域名都请替换成你自己的,文中用 <...> 标注占位。它不依赖任何第三方服务,代码可直接复制部署。

如果你用过 OpenRouter 就会发现一个尴尬的事实:同一个模型(比如 deepseek/deepseek-v4-flash)背后挂着好几家 Provider,每家价格不同、是否支持隐式缓存不同、稳定性也不同。OpenRouter 默认会在它们之间随机/轮流调度——这对长会话、吃 KV Cache 的项目很不友好:缓存命中率一掉,成本和延迟双双起飞。

自己搭一个轻量网关,就能把这件事完全掌控起来。下面是一套已在生产流量上跑通的设计与实现。

网关解决的三件事

  1. 自动选最优 Provider:定时拉取 OpenRouter 的模型端点列表,按「缓存友好 + 价格优先」打分,锁定单一 Provider 并保持一个锁定期不换(长会话实测 KV Cache 命中率可达 98%)。
  2. 防盗刷:客户端只持有一个自定义的 CUSTOM_ACCESS_KEY,网关转发给上游时再替换成真正的 OPENROUTER_API_KEY。真实 Key 永远不下发到客户端。
  3. 自动容错:上游 429 限流自动退避重试;卡顿 / 超时 / 5xx 连续若干次自动熔断,冷却该 Provider 并立即换锁,客户端基本无感。

多模型支持:任意模型第一次请求时自动寻优并锁定,之后一直锁着;每个模型独立寻优、独立锁、独立熔断,互不干扰。用过的模型自动记录,/v1/models 和 /stats 按模型返回。

架构概览

客户端(Cherry Studio 等 OpenAI 兼容客户端)
        │  Authorization: Bearer <CUSTOM_ACCESS_KEY>
        ▼
Cloudflare Worker(openrouter-gateway)
   ├─ Cron 每 3 小时:拉端点列表 → 打分 → 写候选
   ├─ 请求路径:鉴权 → 读锁/寻优 → 强制 provider.order=[锁] → 转发上游
   └─ KV(PROVIDER_KV):锁 / 候选 / 失败计数 / 冷却 / 统计
        │
        ▼
OpenRouter /api/v1/chat/completions(Bearer <OPENROUTER_API_KEY>)

对外完全兼容 OpenAI 的 /v1/chat/completions,所以任何支持自定义 Base URL 的客户端都能直接接。

核心设计决策

决策点结论原因
寻优周期Cron 每 3 小时大项目要跑 2–3 小时,太频繁会中途换 Provider 丢缓存
锁定机制候选制 + 活跃续期cron 结果只写候选;活跃请求续期当前锁(锁永不掉),空闲后锁过期才换新。彻底避免大项目中途换上游
打分公式Score = 吞吐量 / (有效输入价×0.4 + 输出价×0.6 + ε)有效输入价 = 命中率×缓存读取价 + (1−命中率)×输入价
命中率取值支持隐式缓存的按 98%,不支持的按 0避免选中「便宜但没缓存」的 Provider,实际反而更贵
缓存奖励CACHE_BONUS=2支持隐式缓存的得分 ×2,让缓存友好优先于纯低价(可调)
时段价格按当前 UTC 时段取价部分官方端点有峰/闲时半价,闲时官方自然胜出,峰值便宜第三方也能翻盘
429 vs 卡顿429 原地重试不切换;卡顿熔断切换429 是暂时限流等几秒就好,切换会丢缓存;真卡顿才值得换
KV 结构每模型一套:BEST_PROVIDER:{model} / PROVIDER_CANDIDATE:{model} / PROVIDER_FAILURE:{model} / PROVIDER_COOLDOWN:{model} / STATS:{model};全局 MODELS_SEEN全部 JSON 字符串,多模型互不干扰
寻优节流候选 10 分钟内新鲜则复用,不重复拉 API避免并发首次请求重复寻优,控制 OpenRouter API 调用量

几个实测确认的关键事实(与 OpenRouter 平台本身有关,不涉及任何私有部署):

  • OpenRouter 按 Provider 各自的价格计费(pass-through,无加价),所以端点价格参与打分是真实有效的。
  • /api/v1/models/{model}/endpoints 是公开接口(无需 key),返回每个 Provider 的:独立定价(prompt/completion/input_cache_read)、supports_implicit_caching、status、uptime、throughput(可能为 null)。
  • provider.order 接受 slug(端点 tag 的第一段,如 deepseek),不是显示名。
  • 部分官方端点有分时段定价(overrides):闲时半价。
  • 实测某些模型(如 DeepSeek V4 Flash)无法连通其官方端点,但不可用端点会被 status / 熔断自动排除,无需担心被锁到。

从零部署

前置:本机已装 Node.js(v22+)与 npm。

1. 初始化并安装 wrangler

mkdir openrouter-gateway && cd openrouter-gateway
npm init -y
npm install -D wrangler

2. 登录 Cloudflare

npx wrangler login

会弹出浏览器 OAuth 授权页,手动点 Allow 即可。wrangler login 会等回调,不要在前台让它超时被杀。

3. 创建 KV 命名空间

npx wrangler kv namespace create PROVIDER_KV

wrangler 4.x 语法是 kv namespace(带空格),不是 kv:namespace。成功后把输出的 id = "xxx" 填到 wrangler.toml。若报 already exists,先注释掉 wrangler.toml 里的 [[kv_namespaces]] 段再跑。

4. 设置两个密钥(交互式输入,不会落盘到代码)

npx wrangler secret put OPENROUTER_API_KEY   # 粘贴你的 sk-or-v1-...
npx wrangler secret put CUSTOM_ACCESS_KEY    # 自定义访问密钥,自己生成一段足够长的随机串

也支持管道输入:echo "$key" | npx wrangler secret put OPENROUTER_API_KEY。

5. 编写 wrangler.toml

name = "openrouter-gateway"
main = "src/index.js"
compatibility_date = "2025-10-01"

## 自定义域名路由(custom_domain=true 会在 Cloudflare 自动创建 DNS 记录)
routes = [
  { pattern = "<你的网关子域>.<你的域名>", custom_domain = true },
]

## Cron 定时触发器:每 3 小时后台寻优打分
[triggers]
crons = ["0 */3 * * *"]

## KV 命名空间绑定(id 来自第 3 步的输出)
[[kv_namespaces]]
binding = "PROVIDER_KV"
id = "<你的 KV 命名空间 ID>"

[vars]
TARGET_MODEL = "deepseek/deepseek-v4-flash-0731"
HIT_RATE = "0.98"
CACHE_BONUS = "2"
MIN_UPTIME = "90"
SLOW_THRESHOLD = "20"
FAIL_LIMIT = "3"
COOLDOWN_SECONDS = "600"

routes 必须放在 [triggers] 之前,否则会被解析进 triggers 报 warning。绑定自定义域名后,workers.dev 默认地址会自动停用(更干净、防污染)。

6. 部署

npx wrangler deploy

部署输出应包含你的自定义域名与 schedule: 0 */3 * * *。

7. 部署后立即验证

## ① 手动触发一次寻优(同步等待返回)
curl -X POST -H "Authorization: Bearer <你的 CUSTOM_ACCESS_KEY>" https://<你的网关地址>/__cron

## ② 查看价格对比(含当前锁、剩余时间;支持 ?model= 过滤)
curl -H "Authorization: Bearer <你的 CUSTOM_ACCESS_KEY>" https://<你的网关地址>/stats

## ③ 发一个真实对话请求(非流式)
curl https://<你的网关地址>/v1/chat/completions \
  -H "Authorization: Bearer <你的 CUSTOM_ACCESS_KEY>" -H "Content-Type: application/json" \
  -d '{"model":"deepseek/deepseek-v4-flash-0731","messages":[{"role":"user","content":"hi"}],"max_tokens":10}'

## ④ 连发 3 次,确认响应里 provider 字段始终一致(锁定生效)
## ⑤ 测流式:加 "stream":true,应返回 text/event-stream

完整 Worker 代码

把下面内容保存为 src/index.js。它不含任何密钥——OPENROUTER_API_KEY 与 CUSTOM_ACCESS_KEY 都来自第 4 步的 wrangler secret,运行时以环境变量注入。

/**
 * OpenRouter 智能网关 Worker(多模型版)
 * ---------------------------------------------------------------
 * 功能:
 * 1. 多模型自动寻优:任意模型第一次请求时自动寻优(Cron 每 3 小时 + 首次请求触发),
 *    每个模型独立打分、独立锁 Provider、独立熔断,互不干扰。
 * 2. 候选制锁定:活跃请求续期当前锁(大项目永不中途换 Provider),
 *    锁过期(项目空闲)后才提升候选为新锁。
 * 3. 请求代理:强制注入 provider.order=[锁定 Provider] + allow_fallbacks=false。
 * 4. 429 指数退避重试(2s -> 4s -> 8s):限流是暂时的,原地等待不切换,
 *    切换会丢失 KV Cache。
 * 5. 卡顿熔断:连续 FAIL_LIMIT 次慢/超时/5xx → 冷却该 Provider 并立即
 *    重新寻优换锁,客户端无感。
 * 6. Bearer 鉴权防盗刷 + CORS 预检 + SSE 流式透传。
 *
 * KV 键设计(每个模型独立一套):
 *   BEST_PROVIDER:{model}        当前生效锁:JSON { provider, lockedAt }
 *   PROVIDER_CANDIDATE:{model}   寻优候选:JSON { provider, score, ts }
 *   PROVIDER_FAILURE:{model}     连续失败计数:JSON { provider, count, ts }
 *   PROVIDER_COOLDOWN:{model}    熔断冷却名单:JSON { provider, until }
 *   STATS:{model}                价格对比统计
 *   MODELS_SEEN                  用过的模型列表:JSON [model, ...]
 *
 * 打分公式:
 *   有效输入价 = hit_rate × input_cache_read + (1 - hit_rate) × prompt
 *   cost = 有效输入价 × 0.4 + completion × 0.6 + 0.000001
 *   Score = throughput / cost(无吞吐数据时按 1/cost,即纯价格优先)
 *   hit_rate:supports_implicit_caching=true 的 Provider 用 HIT_RATE(默认 0.98),
 *             不支持的按 0(无缓存全价)。
 *   CACHE_BONUS(默认 2):支持隐式缓存的 Provider 得分 × 该系数,
 *             保证「缓存友好优先」,同时保留价格信号。
 */

const UPSTREAM_BASE = "https://openrouter.ai/api/v1";
const UPSTREAM_CHAT_URL = `${UPSTREAM_BASE}/chat/completions`;

const KV_MODELS = "MODELS_SEEN"; // 用过的模型列表

const LOCK_TTL_SECONDS = 3 * 3600; // 锁定期 3 小时(与 cron 周期对齐),活跃请求会续期
const CANDIDATE_TTL_SECONDS = 6 * 3600; // 候选保留 6 小时(覆盖连续 2 轮 cron)
const STATS_TTL_SECONDS = 6 * 3600;
const MODELS_TTL_SECONDS = 30 * 24 * 3600; // 模型记录保留 30 天
const RENEW_INTERVAL_MS = 30 * 60 * 1000; // 续期节流:30 分钟内不重复写 KV
const BACKOFF_SECONDS = [2, 4, 8]; // 429 指数退避序列
const EPSILON = 0.000001; // 防除零
const RESELECT_FRESH_MS = 10 * 60 * 1000; // 寻优节流:候选 10 分钟内不重复寻优

// 账单估算的假设用量(/stats 展示用,可改)
const ASSUME_INPUT_TOKENS = 10_000_000; // 10M 输入 tokens(98% 命中缓存)
const ASSUME_OUTPUT_TOKENS = 1_000_000; // 1M 输出 tokens

const CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept",
  "Access-Control-Max-Age": "86400",
};

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function jsonResponse(status, payload) {
  return new Response(JSON.stringify(payload), {
    status,
    headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
  });
}

const modelKey = (prefix, model) => `${prefix}:${model}`;
const keyBest = (m) => modelKey("BEST_PROVIDER", m);
const keyCandidate = (m) => modelKey("PROVIDER_CANDIDATE", m);
const keyFailure = (m) => modelKey("PROVIDER_FAILURE", m);
const keyCooldown = (m) => modelKey("PROVIDER_COOLDOWN", m);
const keyStats = (m) => modelKey("STATS", m);

/**
 * 取端点当前时段的有效价格($/token)。
 * OpenRouter 的 pricing.overrides 是分时段定价(utc_start/utc_end 为 UTC 分钟,
 * 如 DeepSeek 官方闲时半价);无命中时用基础价。返回 { prompt, completion, cacheRead }。
 */
function getCurrentPricing(pricing) {
  const base = {
    prompt: Number(pricing?.prompt) || 0,
    completion: Number(pricing?.completion) || 0,
    cacheRead: Number(pricing?.input_cache_read) || 0,
  };
  const overrides = Array.isArray(pricing?.overrides) ? pricing.overrides : null;
  if (!overrides || overrides.length === 0) return base;

  const now = new Date();
  const utcMin = now.getUTCHours() * 60 + now.getUTCMinutes();
  for (const o of overrides) {
    const s = Number(o.utc_start);
    const e = Number(o.utc_end);
    if (!(s >= 0 && e >= 0)) continue;
    // 区间 [s, e);s > e 表示跨午夜
    const inRange = s < e ? utcMin >= s && utcMin < e : utcMin >= s || utcMin < e;
    if (!inRange) continue;
    return {
      prompt: Number(o.prompt) || base.prompt,
      completion: Number(o.completion) || base.completion,
      cacheRead: Number(o.input_cache_read) || base.cacheRead,
    };
  }
  return base;
}

/**
 * 已知模型列表 = 默认模型(TARGET_MODEL) + 用过的模型(MODELS_SEEN),去重。
 */
async function getKnownModels(env) {
  const set = new Set();
  if (env.TARGET_MODEL) set.add(env.TARGET_MODEL);
  const raw = await env.PROVIDER_KV.get(KV_MODELS).catch(() => null);
  if (raw) {
    try {
      for (const m of JSON.parse(raw)) set.add(m);
    } catch {
      // 忽略损坏列表
    }
  }
  return [...set];
}

/**
 * 记录一个用过的模型(加入 MODELS_SEEN,30 天 TTL)。
 */
async function rememberModel(env, model) {
  const list = await getKnownModels(env);
  if (list.includes(model)) return;
  list.push(model);
  await env.PROVIDER_KV.put(KV_MODELS, JSON.stringify(list), { expirationTtl: MODELS_TTL_SECONDS });
}

/**
 * 后台寻优:拉取指定模型全部 Provider 端点,按打分公式选最优,
 * 写入 PROVIDER_CANDIDATE:{model} 与 STATS:{model}。
 * 返回胜出 slug;失败返回 null(保留旧候选)。
 */
async function selectBestProvider(env, model) {
  const modelsUrl = `${UPSTREAM_BASE}/models/${model}/endpoints`;
  const res = await fetch(modelsUrl, {
    headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}` },
  });
  if (!res.ok) {
    console.error(`[cron] endpoints fetch failed (${model}): ${res.status}`);
    return null;
  }
  const data = await res.json();
  const endpoints = (data.data && data.data.endpoints) || [];

  const hitRate = Number(env.HIT_RATE) > 0 ? Number(env.HIT_RATE) : 0.98;
  const minUptime = Number(env.MIN_UPTIME) > 0 ? Number(env.MIN_UPTIME) : 90;
  // 缓存奖励:支持隐式缓存的 Provider 得分加倍,避免「便宜但没缓存」的 Provider 淹没缓存优势
  const cacheBonus = Number(env.CACHE_BONUS) > 0 ? Number(env.CACHE_BONUS) : 2;
  // 该模型的"官方"作者(模型 ID 第一段,如 deepseek/deepseek-v4-flash-0731 → deepseek)
  const officialSlug = model.split("/")[0].toLowerCase();

  // 读取熔断冷却名单:被熔断的 Provider 冷却期内不参与竞选
  const cooldown = new Set();
  try {
    const cdRaw = await env.PROVIDER_KV.get(keyCooldown(model));
    if (cdRaw) {
      const cd = JSON.parse(cdRaw);
      if (cd.provider && cd.until > Date.now()) cooldown.add(cd.provider);
    }
  } catch {
    // 忽略损坏的冷却记录
  }

  // 每个端点独立打分;同一 slug(Provider)多个端点时取最高分
  const slugBest = new Map();
  let officialPrices = null; // 官方端点当前时段价格(对比基准)

  for (const ep of endpoints) {
    const slug = (ep.tag || ep.provider_name || "").split("/")[0].toLowerCase();
    if (!slug || cooldown.has(slug)) continue; // 冷却中的 Provider 跳过
    if (ep.status !== 0) continue; // 端点异常(status=-2 等)
    if (ep.uptime_last_30m != null && ep.uptime_last_30m < minUptime) continue; // 可用性过低

    const prices = getCurrentPricing(ep.pricing);
    const promptPrice = prices.prompt;
    const completionPrice = prices.completion;
    const cachePrice = prices.cacheRead;
    if (promptPrice <= 0 && completionPrice <= 0) continue; // 无定价数据
    if (slug === officialSlug && !officialPrices) officialPrices = prices; // 记录官方基准价

    // 缓存友好:支持隐式缓存的按高命中率算有效输入价,不支持的按全价
    const rate = ep.supports_implicit_caching ? hitRate : 0;
    const effInput = rate * cachePrice + (1 - rate) * promptPrice;
    const cost = effInput * 0.4 + completionPrice * 0.6 + EPSILON;

    const throughput = ep.throughput_last_30m || ep.completion_tokens_per_second || null;
    let score = throughput != null ? throughput / cost : 1 / cost; // 无吞吐 → 纯价格优先
    if (ep.supports_implicit_caching) score *= cacheBonus; // 缓存友好优先

    const prev = slugBest.get(slug);
    if (!prev || score > prev.score) {
      slugBest.set(slug, { slug, provider: ep.provider_name, score, prices });
    }
  }

  let best = null;
  for (const entry of slugBest.values()) {
    if (!best || entry.score > best.score) best = entry;
  }
  if (!best) {
    console.error(`[cron] no eligible provider found (${model})`);
    return null;
  }

  await env.PROVIDER_KV.put(
    keyCandidate(model),
    JSON.stringify({ provider: best.slug, score: best.score, ts: Date.now() }),
    { expirationTtl: CANDIDATE_TTL_SECONDS }
  );
  console.log(`[cron] candidate (${model})=${best.slug} score=${best.score.toFixed(6)}`);

  // ---- 价格对比统计:锁定 Provider vs 官方(按假设用量估算账单)----
  try {
    const hit = hitRate;
    const bill = (p) =>
      ASSUME_INPUT_TOKENS * (hit * p.cacheRead + (1 - hit) * p.prompt) +
      ASSUME_OUTPUT_TOKENS * p.completion;
    const bestBill = bill(best.prices);
    const officialBill = officialPrices ? bill(officialPrices) : null;
    const stats = {
      ts: Date.now(),
      model,
      bestProvider: best.provider,
      bestPrices: best.prices,
      officialProvider: officialPrices ? officialSlug : null,
      officialPrices,
      assume: { inputTokens: ASSUME_INPUT_TOKENS, outputTokens: ASSUME_OUTPUT_TOKENS, hitRate: hit },
      billEstimateUsd: {
        best: +bestBill.toFixed(4),
        official: officialBill != null ? +officialBill.toFixed(4) : null,
        savingsVsOfficial: officialBill != null ? +(officialBill - bestBill).toFixed(4) : null,
      },
    };
    await env.PROVIDER_KV.put(keyStats(model), JSON.stringify(stats), { expirationTtl: STATS_TTL_SECONDS });
  } catch (err) {
    console.error("[cron] stats write failed:", err.message);
  }

  return best.slug;
}

/**
 * 寻优入口(带节流):候选 10 分钟内新鲜则直接复用,不重复拉 API。
 * 用于请求路径的即时寻优。
 */
async function reselectIfStale(env, model) {
  const candRaw = await env.PROVIDER_KV.get(keyCandidate(model)).catch(() => null);
  if (candRaw) {
    try {
      const cand = JSON.parse(candRaw);
      if (cand.provider && Date.now() - (cand.ts || 0) < RESELECT_FRESH_MS) {
        return cand.provider; // 候选新鲜,直接复用
      }
    } catch {
      // 忽略损坏候选
    }
  }
  return selectBestProvider(env, model);
}

/**
 * 读取当前生效锁:锁存在则续期(节流)并返回;锁过期则提升候选为新锁。
 * 无锁无候选返回 null(调用方走默认路由并触发即时寻优)。
 */
async function getLockedProvider(env, model) {
  const bestRaw = await env.PROVIDER_KV.get(keyBest(model));
  if (bestRaw) {
    try {
      const lock = JSON.parse(bestRaw);
      if (lock.provider && Date.now() - (lock.lockedAt || 0) > RENEW_INTERVAL_MS) {
        lock.lockedAt = Date.now();
        await env.PROVIDER_KV.put(keyBest(model), JSON.stringify(lock), { expirationTtl: LOCK_TTL_SECONDS });
      }
      return lock.provider;
    } catch {
      // 旧格式/损坏值:当作无锁处理
    }
  }

  const candRaw = await env.PROVIDER_KV.get(keyCandidate(model));
  if (candRaw) {
    try {
      const cand = JSON.parse(candRaw);
      if (cand.provider) {
        await env.PROVIDER_KV.put(
          keyBest(model),
          JSON.stringify({ provider: cand.provider, lockedAt: Date.now() }),
          { expirationTtl: LOCK_TTL_SECONDS }
        );
        return cand.provider;
      }
    } catch {
      // 忽略损坏候选
    }
  }
  return null;
}

/**
 * 熔断:冷却当前 Provider 并立即重新寻优、提升为新锁(换掉卡顿的上游)。
 * 返回值:是否触发熔断。
 */
async function recordFailureAndMaybeTrip(env, model, provider) {
  if (!provider) return false;
  const failLimit = Number(env.FAIL_LIMIT) > 0 ? Number(env.FAIL_LIMIT) : 3;

  const raw = await env.PROVIDER_KV.get(keyFailure(model));
  let f = { provider, count: 0, ts: Date.now() };
  try {
    if (raw) f = JSON.parse(raw);
  } catch {
    // 忽略损坏计数
  }
  // 换了锁或超窗(如 30 分钟无失败)则重新计数
  if (f.provider !== provider || Date.now() - (f.ts || 0) > 30 * 60 * 1000) {
    f = { provider, count: 0, ts: Date.now() };
  }
  f.count += 1;
  f.ts = Date.now();

  if (f.count >= failLimit) {
    const cdSeconds = Number(env.COOLDOWN_SECONDS) > 0 ? Number(env.COOLDOWN_SECONDS) : 600;
    await env.PROVIDER_KV.put(
      keyCooldown(model),
      JSON.stringify({ provider, until: Date.now() + cdSeconds * 1000 }),
      { expirationTtl: cdSeconds }
    );
    await env.PROVIDER_KV.delete(keyFailure(model));
    console.warn(`[proxy] ${provider} tripped breaker (${failLimit} failures, model=${model}), cooldown ${cdSeconds}s`);
    // 立即重新寻优(冷却名单会排除它)并提升为新锁
    const next = await selectBestProvider(env, model);
    if (next) {
      await env.PROVIDER_KV.put(
        keyBest(model),
        JSON.stringify({ provider: next, lockedAt: Date.now() }),
        { expirationTtl: LOCK_TTL_SECONDS }
      );
      console.log(`[proxy] breaker switched lock (${model}): ${provider} -> ${next}`);
    }
    return true;
  }

  await env.PROVIDER_KV.put(keyFailure(model), JSON.stringify(f), { expirationTtl: 3600 });
  return false;
}

export default {
  async fetch(request, env, ctx) {
    // ---- CORS 预检 ----
    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: CORS_HEADERS });
    }

    // ---- 防盗刷鉴权:Bearer <CUSTOM_ACCESS_KEY> ----
    const authHeader = request.headers.get("Authorization") || "";
    if (authHeader !== `Bearer ${env.CUSTOM_ACCESS_KEY}`) {
      return jsonResponse(401, { error: { message: "Unauthorized: invalid access key", code: 401 } });
    }

    const url = new URL(request.url);

    // 手动触发寻优(运维用,带鉴权):POST /__cron(同步执行,确保完成)
    if (request.method === "POST" && url.pathname === "/__cron") {
      const models = await getKnownModels(env);
      const results = {};
      for (const m of models) {
        try {
          const slug = await selectBestProvider(env, m);
          results[m] = slug || "failed";
        } catch (err) {
          console.error("[cron] manual trigger failed:", err.message);
          results[m] = "error";
        }
      }
      return jsonResponse(Object.keys(results).length ? 200 : 500, results);
    }

    // 模型列表(OpenAI 兼容客户端启动时需要):GET /v1/models
    if (request.method === "GET" && (url.pathname === "/v1/models" || url.pathname === "/models")) {
      const models = await getKnownModels(env);
      return jsonResponse(200, {
        object: "list",
        data: models.map((id) => ({ id, object: "model", created: 0, owned_by: "openrouter-gateway" })),
      });
    }

    // 价格对比查询:GET /stats(支持 ?model= 过滤,默认全部)
    if (request.method === "GET" && url.pathname === "/stats") {
      const filter = url.searchParams.get("model");
      const models = filter ? [filter] : await getKnownModels(env);
      const out = {};
      for (const m of models) {
        const raw = await env.PROVIDER_KV.get(keyStats(m)).catch(() => null);
        let lockInfo = null;
        const bestRaw = await env.PROVIDER_KV.get(keyBest(m)).catch(() => null);
        if (bestRaw) {
          try {
            const lock = JSON.parse(bestRaw);
            lockInfo = {
              provider: lock.provider,
              lockedAt: lock.lockedAt,
              ttlRemainingSec: Math.max(0, Math.round(LOCK_TTL_SECONDS - (Date.now() - (lock.lockedAt || 0)) / 1000)),
            };
          } catch {
            // 忽略损坏锁
          }
        }
        out[m] = { stats: raw ? JSON.parse(raw) : null, lock: lockInfo };
      }
      return jsonResponse(200, out);
    }

    if (request.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
      return jsonResponse(404, { error: { message: "Not Found", code: 404 } });
    }

    // ---- 解析请求体 ----
    let body;
    try {
      body = await request.json();
    } catch {
      return jsonResponse(400, { error: { message: "Invalid JSON body", code: 400 } });
    }

    // ---- 确定模型:body.model 优先,非法/缺失回退默认模型 ----
    const model =
      typeof body.model === "string" && /^[A-Za-z0-9][A-Za-z0-9/._:-]{1,127}$/.test(body.model)
        ? body.model
        : env.TARGET_MODEL;
    if (!model) {
      return jsonResponse(400, { error: { message: "model is required", code: 400 } });
    }

    // ---- 锁定 Provider:锁存在续期,锁过期提升候选,都没有则本次走默认路由 ----
    let best = null;
    try {
      best = await getLockedProvider(env, model);
    } catch (err) {
      console.error("[proxy] KV read failed:", err.message);
    }
    if (!best) {
      // 首次遇到该模型/锁过期:后台立即寻优(带节流),不阻塞本次请求
      ctx.waitUntil(
        Promise.all([
          rememberModel(env, model).catch(() => {}),
          reselectIfStale(env, model).catch((err) => console.error("[proxy] on-demand scoring failed:", err.message)),
        ])
      );
    } else {
      // 强制锁定单一上游,保住 KV Cache 命中率
      body.provider = { order: [best], allow_fallbacks: false };
    }

    // ---- 转发上游,429 指数退避重试(2s -> 4s -> 8s)----
    const isStream = body.stream === true;
    const slowMs = (Number(env.SLOW_THRESHOLD) > 0 ? Number(env.SLOW_THRESHOLD) : 20) * 1000;
    let upstreamRes = null;
    let timedOut = false;

    for (let attempt = 0; attempt <= BACKOFF_SECONDS.length; attempt++) {
      const upstreamReq = new Request(UPSTREAM_CHAT_URL, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
          "Content-Type": "application/json",
          Accept: isStream ? "text/event-stream" : "application/json",
        },
        body: JSON.stringify(body),
      });

      // 慢请求熔断:等待响应头超过 SLOW_THRESHOLD 秒即中止本次尝试
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), slowMs);
      try {
        upstreamRes = await fetch(upstreamReq, { signal: controller.signal });
      } catch (err) {
        clearTimeout(timer);
        timedOut = true; // 超时或网络错误:不再重试,交给熔断逻辑
        break;
      }
      clearTimeout(timer);

      if (upstreamRes.status !== 429 || attempt === BACKOFF_SECONDS.length) break;
      console.warn(`[proxy] upstream 429, retry in ${BACKOFF_SECONDS[attempt]}s (${attempt + 1}/${BACKOFF_SECONDS.length})`);
      await sleep(BACKOFF_SECONDS[attempt] * 1000);
    }

    // ---- 卡顿熔断:超时/5xx 记一次失败,连续 FAIL_LIMIT 次自动换锁(异步,不阻塞响应)----
    if (timedOut || (upstreamRes && upstreamRes.status >= 500)) {
      ctx.waitUntil(
        recordFailureAndMaybeTrip(env, model, best).catch((err) => console.error("[proxy] breaker failed:", err.message))
      );
    }

    // ---- 透传上游响应(SSE 流式透传),补 CORS 头 ----
    const respHeaders = new Headers(upstreamRes.headers);
    for (const [name, value] of Object.entries(CORS_HEADERS)) {
      respHeaders.set(name, value);
    }
    return new Response(upstreamRes.body, { status: upstreamRes.status, headers: respHeaders });
  },

  async scheduled(event, env, ctx) {
    // Cron:对每个已知模型强制重新寻优(写候选,不打断活跃会话)
    const models = await getKnownModels(env);
    for (const m of models) {
      try {
        await selectBestProvider(env, m);
      } catch (err) {
        console.error(`[cron] scoring failed (${m}):`, err.message);
      }
    }
  },
};

可调参数(wrangler.toml → [vars])

变量默认说明
TARGET_MODELdeepseek/deepseek-v4-flash-0731默认模型(客户端未指定时兜底;保证 cron 至少寻优它)
HIT_RATE0.98支持隐式缓存的 Provider 的命中率假设
CACHE_BONUS2缓存奖励系数(越大越缓存友好)
MIN_UPTIME90Provider 最低可用性(%),低于则排除
SLOW_THRESHOLD20卡顿判定:等待上游响应头超过 N 秒视为慢(0 = 禁用)
FAIL_LIMIT3连续失败 N 次触发熔断换 Provider
COOLDOWN_SECONDS600被熔断 Provider 的冷却时间(秒)

调参后重新 npx wrangler deploy 生效。比如某项目输出 token 特别多、嫌官方贵,可把 CACHE_BONUS 调低(如 1.2)让便宜 Provider 胜出;想更激进地保缓存就调高。

客户端怎么用

任何 OpenAI 兼容客户端(Cherry Studio、OpenWebUI、各种 SDK)都能直接接,只需三步:

  • Base URL:https://<你的网关地址>
  • API Key:<你的 CUSTOM_ACCESS_KEY>(就是第 4 步自己设的那个随机串,不是 OpenRouter 的 Key)
  • 模型 ID:填 OpenRouter 的模型 ID 即可,例如 deepseek/deepseek-v4-flash-0731;换模型直接改这里,网关会自动寻优,无需改配置。

运维接口

接口方法说明
/v1/chat/completionsPOST主代理接口(OpenAI 兼容)
/v1/modelsGET模型列表(客户端启动探测用)
/statsGET价格对比 + 当前锁实时信息(支持 ?model= 过滤)
/__cronPOST手动触发寻优(同步等待结果)
## 手动触发寻优(同步,等待结果返回)
curl -X POST -H "Authorization: Bearer <你的 CUSTOM_ACCESS_KEY>" https://<你的网关地址>/__cron

## 查看价格对比统计(含当前锁、剩余时间)
curl -H "Authorization: Bearer <你的 CUSTOM_ACCESS_KEY>" https://<你的网关地址>/stats

故障排查

症状排查步骤
域名打不开① npx wrangler deployments 看部署是否成功;② CF 面板 Workers → 域名绑定是否在;③ DNS 记录是否自动创建(子域 CNAME);④ 等 1–5 分钟 DNS 生效
401 鉴权失败CUSTOM_ACCESS_KEY 是否被换过?CF 面板 Settings → Variables and Secrets 里核对(secret 不回显,重设一次即可)
/stats 某模型返回 null该模型从没寻优成功:① 手动调 /__cron 看返回;② 若返回 failed,检查 OpenRouter key 是否有效;③ 模型 ID 是否在 OpenRouter 上存在
请求返回 404检查模型 ID 是否含 %2F(OpenRouter endpoints 接口不吃 URL 编码的斜杠,代码里不能用 encodeURIComponent 编码模型路径)
锁不住(每次 provider 不同)KV 里 BEST_PROVIDER 没写入:① 手动 /__cron 一次;② 检查 KV 命名空间 id 是否与 wrangler.toml 一致;③ KV 最终一致性,写入后稍等几秒
上游报错/卡顿频繁看 /stats 的 lock 字段与账单估算;SLOW_THRESHOLD/FAIL_LIMIT/COOLDOWN_SECONDS 都在 wrangler.toml,调参后重新 deploy
想要更便宜 / 更缓存友好调 CACHE_BONUS(默认 2,越大越缓存友好)、HIT_RATE(默认 0.98);改完 deploy
想看实时日志wrangler tail 可能受本机网络限制连不上,可改用 CF 面板 Workers → Logs 看实时日志
换/加模型直接改客户端模型 ID 即可(自动寻优);要改默认模型才动 wrangler.toml 的 TARGET_MODEL

踩坑记录

  1. wrangler kv:namespace create 在 wrangler 4.x 已改为 wrangler kv namespace create(去掉冒号)。
  2. routes 必须放在 [triggers] 之前,否则被解析进 triggers 报 warning。
  3. OpenRouter 的 /models/{id}/endpoints 不接受 %2F 编码的模型 ID(encodeURIComponent 会把 / 编码成 %2F 导致 404),必须用原始斜杠路径。这是寻优一直失败、stats 一直 404 的最常见根因。
  4. 部分官方端点输出价是第三方(如 OpenInference)的约 9 倍:默认 CACHE_BONUS=2 时官方仍会胜出;若某项目输出 token 特别多嫌贵,调低 CACHE_BONUS(如 1.2)即可让便宜 Provider 胜出。
  5. 本地调试:建 .dev.vars 填真实值(格式同 wrangler secret 的键值对),npx wrangler dev --test-scheduled,另开终端 curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*" 手动触发 cron。

结语

这套网关本质上是把「OpenRouter 多 Provider 调度」这件事从黑盒变成白盒:价格、缓存、稳定性全在你手里。最关键的收益是 KV Cache 命中率——长会话项目一旦锁定单一 Provider,缓存红利会直接反映在账单和延迟上。代码不到 200 行核心逻辑,跑在 Cloudflare 的免费额度里基本零成本,值得一试。

评论区待开放