↓ 跳过正文
  1. Posts/

自建免费 AI 网关:把 Cloudflare Workers AI 白嫖成 OpenAI 接口

·4907 字·10 分钟· loading · loading · ·
HJP
作者
HJP
一个正在不断学习与成长的人。
目录

本文只讲方法:如何把 Cloudflare Workers AI 的免费额度包装成一个带鉴权、带限额的 OpenAI 兼容网关。所有密钥、域名请替换成你自己的,文中用 <...> 标注占位,代码可直接复制部署。

上一篇文章介绍了自建 OpenRouter 智能网关——那是转发付费模型、按用量计费的方案。今天这条路线完全不同:直接用 Cloudflare 自己托管的模型库 Workers AI,每天有 10,000 neurons 免费额度(所有模型共享,每天 00:00 UTC 重置,Free 计划超限直接失败、不扣钱)。

换句话说:每月 30 万 neurons 的推理额度,一分钱不花。个人使用、小群分享完全够——前提是做好两件事:鉴权(防止陌生人刷爆)和限额(防止朋友多了被刷爆)。

网关解决的三件事
#

  1. 鉴权防盗刷:客户端只持有一个自定义 ACCESS_KEY,网关校验通过后才转发给 Workers AI。真实凭据永远不下发。
  2. KV 每日限额:全局日限额 + 单 IP 日限额,按 UTC 日期计数,超限返回 429。给"只信得过的小群"再加一道保险。
  3. OpenAI 兼容:/v1/chat/completions(非流式 + SSE 流式)+ /v1/models,Cherry Studio、Chatbox、OpenWebUI 等客户端填上 Base URL 就能用。

架构概览
#

客户端(Cherry Studio 等 OpenAI 兼容客户端)
        │  Authorization: Bearer <ACCESS_KEY>
        ▼
Cloudflare Worker(free-ai-gateway)
   ├─ 请求路径:鉴权 → KV 限额检查 → 模型映射 → env.AI.run()
   ├─ KV(QUOTA_KV):quota:day:{日期} / quota:ip:{日期}:{IP} / quota:model:{日期}:{模型}
   └─ Workers AI 绑定:env.AI(Cloudflare 托管模型库)
        │
        ▼
Workers AI(每日 10,000 neurons 免费额度)

关键点:网关本身也跑在 Cloudflare 上,调用 Workers AI 走的是内部绑定(env.AI.run()),不产生额外网络费用;只有 neurons 消耗计入每日 10,000 免费额度。

免费额度到底怎么算
#

Cloudflare 用 neuron 作为推理计算单位(官方定价页):

  • Workers Free 计划:每天 10,000 neurons 免费,超限直接失败,不会扣钱。
  • Workers Paid 计划:每天同样 10,000 neurons 免费,超出部分 $0.011 / 1,000 neurons。

不同模型消耗不同:小模型(如 llama-3.1-8b)单次推理可能只要几个 neurons,大模型(如 llama-3.3-70b)单次可能要几十到几百。所以"每天能跑多少次"取决于你用什么模型、聊多长。给网关加请求次数限额,是防止单个人把额度刷完的第一道闸。

从零部署
#

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

1. 初始化并安装 wrangler

mkdir free-ai-gateway && cd free-ai-gateway
npm init -y
npm install -D wrangler

2. 登录 Cloudflare

npx wrangler login

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

3. 创建 KV 命名空间

npx wrangler kv namespace create QUOTA_KV

wrangler 4.x 语法是 kv namespace(带空格)。成功后把输出的 id = "xxx" 填到 wrangler.toml。

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

npx wrangler secret put ACCESS_KEY   # 自定义访问密钥,生成一段足够长的随机串

5. 编写 wrangler.toml

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

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

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

# Workers AI 绑定:env.AI.run(model, input)
[ai]
binding = "AI"

[vars]
DEFAULT_MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
DAILY_LIMIT = "1000"      # 全局每日请求上限(次/天)
IP_DAILY_LIMIT = "200"    # 单 IP 每日请求上限(次/天)

6. 部署

npx wrangler deploy

部署输出应包含你的自定义域名。绑定自定义域名后,workers.dev 默认地址会自动停用。

7. 部署后立即验证

# ① 模型列表(公开)
curl https://<你的网关地址>/v1/models

# ② 无鉴权 → 401
curl https://<你的网关地址>/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"hi"}]}'

# ③ 对话(非流式)
curl https://<你的网关地址>/v1/chat/completions \
  -H "Authorization: Bearer <你的 ACCESS_KEY>" -H "Content-Type: application/json" \
  -d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"你好"}]}'

# ④ 流式
curl -N https://<你的网关地址>/v1/chat/completions \
  -H "Authorization: Bearer <你的 ACCESS_KEY>" -H "Content-Type: application/json" \
  -d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"hi"}],"stream":true}'

# ⑤ 今日用量(需鉴权)
curl -H "Authorization: Bearer <你的 ACCESS_KEY>" https://<你的网关地址>/stats

完整 Worker 代码
#

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

/**
 * FreeAI 网关 —— Cloudflare Workers AI 免费额度网关
 * 功能:鉴权防盗刷 / KV 每日限额 / OpenAI 兼容(非流式 + SSE 流式)/ 模型映射
 *
 * KV 键设计(QUOTA_KV,键按 UTC 日期分片,自动过期 3 天):
 *   quota:day:{date}         当日全局请求数
 *   quota:ip:{date}:{ip}     当日单 IP 请求数
 *   quota:model:{date}:{m}   当日各模型请求数(/stats 展示用)
 */

const MODEL_MAP = {
  "llama-3.3-70b": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
  "llama-3.1-8b": "@cf/meta/llama-3.1-8b-instruct-fp8",
  "qwen-30b": "@cf/qwen/qwen3-30b-a3b-fp8",
  "glm-4.7-flash": "@cf/zai-org/glm-4.7-flash",
  "mistral-small": "@cf/mistralai/mistral-small-3.1-24b-instruct",
  "kimi-k2.6": "@cf/moonshotai/kimi-k2.6",
  "deepseek-v4-flash": "@cf/deepseek-ai/deepseek-v4-flash-0731",
};

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 KV_TTL_SECONDS = 3 * 24 * 3600;

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

function todayKey() {
  return new Date().toISOString().slice(0, 10);
}

async function kvIncr(kv, key, ttl) {
  const raw = await kv.get(key);
  const next = (parseInt(raw || "0", 10) || 0) + 1;
  await kv.put(key, String(next), { expirationTtl: ttl });
  return next;
}

async function checkQuota(env, ip, modelId) {
  const date = todayKey();
  const dailyLimit = parseInt(env.DAILY_LIMIT, 10) || 1000;
  const ipLimit = parseInt(env.IP_DAILY_LIMIT, 10) || 200;

  const [globalCount, ipCount] = await Promise.all([
    kvIncr(env.QUOTA_KV, `quota:day:${date}`, KV_TTL_SECONDS),
    kvIncr(env.QUOTA_KV, `quota:ip:${date}:${ip}`, KV_TTL_SECONDS),
  ]);
  kvIncr(env.QUOTA_KV, `quota:model:${date}:${modelId}`, KV_TTL_SECONDS).catch(() => {});

  if (globalCount > dailyLimit) {
    return jsonResponse(429, { error: { message: `Daily global limit reached (${dailyLimit}/day)`, code: 429 } });
  }
  if (ipCount > ipLimit) {
    return jsonResponse(429, { error: { message: `Daily per-IP limit reached (${ipLimit}/day)`, code: 429 } });
  }
  return null;
}

function resolveModel(body, env) {
  const raw = typeof body.model === "string" ? body.model.trim() : "";
  if (MODEL_MAP[raw]) return MODEL_MAP[raw];
  if (/^@cf\/[A-Za-z0-9._/-]{1,100}$/.test(raw)) return raw;
  return env.DEFAULT_MODEL || "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
}

/** 兼容提取文本:llama 返回 {response:"文本"},glm 返回嵌套 OpenAI JSON */
function extractContent(response) {
  if (typeof response === "string") {
    try {
      const parsed = JSON.parse(response);
      const inner = extractContent(parsed);
      if (inner != null) return inner;
    } catch {}
    return response;
  }
  if (response && typeof response === "object") {
    if (Array.isArray(response.choices) && response.choices.length) {
      const msg = response.choices[0].message ?? response.choices[0].delta ?? null;
      if (msg && typeof msg.content === "string") return msg.content;
    }
    if (typeof response.response === "string") return extractContent(response.response);
    return null;
  }
  return response == null ? null : String(response);
}

function toOpenAICompletion(out, modelName) {
  let content = typeof out === "string" ? out : extractContent(out);
  if (content == null) content = JSON.stringify(out);
  return {
    id: "chatcmpl-" + Date.now(),
    object: "chat.completion",
    created: Math.floor(Date.now() / 1000),
    model: modelName,
    choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
  };
}

/** Workers AI 流式(SSE 事件)→ OpenAI SSE 格式 */
function streamToOpenAI(stream, modelName, id) {
  const encoder = new TextEncoder();
  const decoder = new TextDecoder();
  let buffer = "";
  const sendChunk = (obj) => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`);

  return new ReadableStream({
    async start(controller) {
      const reader = stream.getReader();
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });
          const events = buffer.split("\n\n");
          buffer = events.pop() ?? "";
          for (const event of events) {
            const dataLine = event.trim().split("\n").find((l) => l.startsWith("data:"));
            if (!dataLine) continue;
            const payload = dataLine.slice(5).trim();
            if (!payload || payload === "[DONE]") continue;
            let chunk;
            try { chunk = JSON.parse(payload); } catch { continue; }
            const delta = extractContent(chunk.response ?? chunk);
            if (!delta) continue;
            controller.enqueue(sendChunk({
              id, object: "chat.completion.chunk",
              created: Math.floor(Date.now() / 1000), model: modelName,
              choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
            }));
          }
        }
      } catch (err) {
        console.error("[stream] read failed:", err.message);
      } finally {
        controller.enqueue(sendChunk({
          id, object: "chat.completion.chunk",
          created: Math.floor(Date.now() / 1000), model: modelName,
          choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
        }));
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      }
    },
  });
}

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

    const url = new URL(request.url);

    // 模型列表(公开,客户端启动探测用)
    if (request.method === "GET" && (url.pathname === "/v1/models" || url.pathname === "/models")) {
      const ids = [...new Set([...Object.keys(MODEL_MAP), ...Object.values(MODEL_MAP)])];
      return jsonResponse(200, { object: "list", data: ids.map((id) => ({ id, object: "model", owned_by: "free-ai-gateway" })) });
    }

    // 以下全部需要鉴权
    const authHeader = request.headers.get("Authorization") || "";
    if (authHeader !== `Bearer ${env.ACCESS_KEY}`) {
      return jsonResponse(401, { error: { message: "Unauthorized: invalid access key", code: 401 } });
    }

    // 今日用量(运维用)
    if (request.method === "GET" && url.pathname === "/stats") {
      const date = todayKey();
      const globalRaw = await env.QUOTA_KV.get(`quota:day:${date}`).catch(() => null);
      const modelList = await env.QUOTA_KV.list({ prefix: `quota:model:${date}:` }).catch(() => ({ keys: [] }));
      const byModel = {};
      for (const k of modelList.keys) {
        const m = k.name.slice(`quota:model:${date}:`.length);
        byModel[m] = parseInt(await env.QUOTA_KV.get(k.name).catch(() => "0"), 10) || 0;
      }
      return jsonResponse(200, {
        date, globalCount: parseInt(globalRaw || "0", 10) || 0,
        globalLimit: parseInt(env.DAILY_LIMIT, 10) || 1000,
        ipLimit: parseInt(env.IP_DAILY_LIMIT, 10) || 200,
        byModel,
      });
    }

    // 主接口:/v1/chat/completions
    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 } });
    }

    const messages = Array.isArray(body.messages) ? body.messages : [];
    if (messages.length === 0) {
      return jsonResponse(400, { error: { message: "messages is required", code: 400 } });
    }

    const modelId = resolveModel(body, env);
    const clientModel = typeof body.model === "string" && body.model.trim() ? body.model.trim() : modelId;
    const clientIp = request.headers.get("CF-Connecting-IP") || "unknown";

    const quotaRes = await checkQuota(env, clientIp, modelId);
    if (quotaRes) return quotaRes;

    const isStream = body.stream === true;
    const id = "chatcmpl-" + Date.now();

    if (isStream) {
      try {
        const aiStream = await env.AI.run(modelId, { messages, stream: true });
        return new Response(streamToOpenAI(aiStream, clientModel, id), {
          status: 200,
          headers: { ...CORS_HEADERS, "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
        });
      } catch (err) {
        console.warn("[stream] fallback to non-stream:", err.message);
        try {
          const out = await env.AI.run(modelId, { messages });
          const completion = toOpenAICompletion(out, clientModel);
          const encoder = new TextEncoder();
          const body2 =
            `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: completion.created, model: clientModel, choices: [{ index: 0, delta: { content: completion.choices[0].message.content }, finish_reason: null }] })}\n\n` +
            `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: completion.created, model: clientModel, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n` +
            "data: [DONE]\n\n";
          return new Response(encoder.encode(body2), {
            status: 200,
            headers: { ...CORS_HEADERS, "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
          });
        } catch (err2) {
          return jsonResponse(502, { error: { message: `AI upstream failed: ${err2.message}`, code: 502 } });
        }
      }
    }

    try {
      const out = await env.AI.run(modelId, { messages });
      return jsonResponse(200, toOpenAICompletion(out, clientModel));
    } catch (err) {
      return jsonResponse(502, { error: { message: `AI upstream failed: ${err.message}`, code: 502 } });
    }
  },
};

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

变量默认说明
DEFAULT_MODEL@cf/meta/llama-3.3-70b-instruct-fp8-fast默认模型(客户端未指定/无法识别时兜底)
DAILY_LIMIT1000全局每日请求上限(次/天),按 UTC 日期计数
IP_DAILY_LIMIT200单 IP 每日请求上限(次/天)

调参后重新 npx wrangler deploy 生效。比如朋友很多怕被刷爆,把 DAILY_LIMIT 调小(如 300)。

模型怎么选
#

网关内置了几个友好名映射(客户端填 llama-3.3-70b 即可),也可以直接填 @cf/... 原始 ID。实测注意:只有 Free 计划可用的模型才能跑(kimi-k2.6、deepseek-v4-flash 等会返回 5035 需要升级付费计划):

友好名Workers AI 模型 ID适合约 100 字回复的 neurons 消耗
llama-3.3-70b(默认)@cf/meta/llama-3.3-70b-instruct-fp8-fast通用均衡,免费额度主力~15
llama-3.1-8b@cf/meta/llama-3.1-8b-instruct-fp8轻量快,最省 neurons~1.1
qwen-30b@cf/qwen/qwen3-30b-a3b-fp8中文高效 MoE~15
qwen-coder-32b@cf/qwen/qwen2.5-coder-32b-instruct代码—
glm-4.7-flash@cf/zai-org/glm-4.7-flash中文,带回显 reasoning~26
mistral-small@cf/mistralai/mistral-small-3.1-24b-instruct英文~11
llama-vision@cf/meta/llama-3.2-11b-vision-instruct看图对话(多模态)~8-37

📷 视觉模型:llama-vision 支持 OpenAI 标准的图片输入格式(content 数组里带 image_url,data URL 或 http URL 均可),Cherry Studio 等客户端直接传图就能用。首次使用需在 Cloudflare 账号级同意一次模型许可协议(实测 2026-08-21:logo 图 8 neurons/次,真实照片约 37 neurons/次)。 完整的账号模型目录可以在 Cloudflare 控制台 Workers AI 页面查看,也可以在网关里直接填任意 @cf/... ID 实测——Free 计划不可用的模型会返回 5035 错误。

客户端怎么用
#

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

  • Base URL:https://<你的网关地址>/v1
  • API Key:<你的 ACCESS_KEY>(就是第 4 步自己设的那个随机串,不是 Cloudflare 的凭据)
  • 模型 ID:填 llama-3.3-70b 或上表其他友好名,换模型直接改这里

运维接口
#

接口方法说明
/GET首页(使用说明 HTML,公开)
/v1/modelsGET模型列表(公开,客户端启动探测用)
/v1/chat/completionsPOST主代理接口(OpenAI 兼容,支持 stream)
/statsGET今日用量(需鉴权:全局计数/限额/各模型计数)
# 查看今日用量
curl -H "Authorization: Bearer <你的 ACCESS_KEY>" https://<你的网关地址>/stats

故障排查
#

症状排查步骤
域名打不开① npx wrangler deployments 看部署是否成功;② CF 面板 Workers → 域名绑定是否在;③ DNS 记录是否自动创建(子域);④ 等 1–5 分钟 DNS 生效
401 鉴权失败ACCESS_KEY 是否被换过?CF 面板 Settings → Variables and Secrets 里核对(secret 不回显,重设一次即可)
一直 429今日额度用完(/stats 看计数):全局 DAILY_LIMIT 或单 IP IP_DAILY_LIMIT。等 UTC 次日 00:00 重置,或调大限额
模型调用报错模型 ID 是否在账号模型目录内?用 /v1/models 列出的 ID;换个模型试试
限额计数不准KV 读有边缘缓存 + 读改写非原子,限额是"软闸",极端并发下可能略微超限;真正的硬上限是 CF 侧 10,000 neurons
想看实时日志wrangler tail 可能受本机网络限制连不上,改用 CF 面板 Workers → Logs

踩坑记录
#

  1. wrangler kv:namespace create 在 wrangler 4.x 已改为 wrangler kv namespace create(去掉冒号)。
  2. KV get 的 cacheTtl 最小 30 秒,不能传 0(报 400 Invalid cache_ttl)。
  3. Workers AI 流式响应是 SSE 格式(data: {...}\n\n 事件),不是裸 JSON 流。llama 类 chunk 是 {"response":"纯文本"},glm 类直接是 OpenAI 风格嵌套——代码里的 extractContent() 统一兼容两种。
  4. glm-4.7-flash 非流式返回嵌套 OpenAI JSON 字符串(out.response 是完整 chat.completion JSON),必须解析提取,不能当纯文本直接返回。
  5. Free 计划不是所有模型都能跑:kimi-k2.6、deepseek-v4-flash 等前沿模型返回 5035(需 Workers Paid 计划)。部署后先实测一遍模型列表再分享。
  6. 部署后 1–2 分钟内新旧版本并存(Cloudflare 渐进式发布),刚部署完立刻测试可能打到旧版本。
  7. 本机 curl 访问外网若 IPv6 不通,加 -4 强制 IPv4。

和 OR 网关怎么选
#

FreeAI 网关(本文)OR 网关(上篇)
上游Cloudflare Workers AI(自家模型库)OpenRouter(聚合多家模型)
成本每天 10,000 neurons 免费,超限即停按 OpenRouter 用量计费
模型开源模型(Llama/Qwen/GLM 等)全 OpenRouter 模型(含闭源)
适合翻译、文案、摘要、日常对话等轻量任务需要推理/代码的复杂任务、长会话

结语
#

这套网关本质上是"鉴权 + 限额 + 转发“三件事,跑在 Cloudflare 免费额度里。10,000 neurons/天对个人完全够用,给小群分享时记得设好 DAILY_LIMIT 和 IP_DAILY_LIMIT 两道闸。模型能力上别期待它能打过 DeepSeek 这类闭源旗舰——但日常翻译、写文案、摘要这类轻量任务,白嫖 Cloudflare 的 GPU 不香吗?

相关文章