/** * Cloudflare Workers AI 反向代理。 * * 环境变量(wrangler): * CLOUDFLARE_ACCOUNT_ID — Cloudflare 账户 ID([vars],非密钥) * CLOUDFLARE_API_TOKEN — 具备 Workers AI 读写的 API Token(secret:wrangler secret put) * PROXY_API_KEY — 客户端调用本 Worker 所用的密钥(vars 或 secret) * * curl 示例(将 WORKER_URL 换成 Worker 域名,model 按需填写): * curl -sS "$WORKER_URL/v1/chat/completions" \ * -H "Authorization: Bearer $PROXY_API_KEY" -H "Content-Type: application/json" \ * -d '{"model":"@cf/meta/llama-3.1-8b-instruct","messages":[{"role":"user","content":"Hi"}]}' * * curl -sS "$WORKER_URL/v1/models" -H "Authorization: Bearer $PROXY_API_KEY" * * OpenAI SDK(Node):默认基路径带 /v1,应配置为「Worker 域名 + /v1」,例如: * new OpenAI({ apiKey: PROXY_API_KEY, baseURL: `${WORKER_URL}/v1` }) * * Anthropic SDK:baseURL 填 Worker 根域名(不带 /v1;SDK 会自行拼接 /v1/messages); * apiKey 使用 PROXY_API_KEY;如需 anthropic-version 请求头,常用 "2023-06-01"。 */ /** * Worker 绑定环境。 * @typedef {Object} Env * @property {string} CLOUDFLARE_ACCOUNT_ID Cloudflare 账户 ID * @property {string} CLOUDFLARE_API_TOKEN 调用 Cloudflare API 的 Bearer Token * @property {string} PROXY_API_KEY 访客 API 密钥(入站校验) */ const CF_API = "https://api.cloudflare.com/client/v4"; /** @param {Request} request 入站请求 @param {Env} env Worker 环境 */ export default { async fetch(request, env) { if (request.method === "OPTIONS") { return corsPreflight(request); } const fail = checkEnv(env); if (fail) { return json(500, { error: fail }); } const url = new URL(request.url); const path = url.pathname.replace(/\/$/, "") || "/"; /** 不要求 PROXY_API_KEY 即可访问的路径(仍为 Cloudflare Worker,非「无密钥」后端)。 */ const publicGuest = request.method === "GET" && path === "/"; if (!publicGuest && !authorize(request, env)) { return json(401, { error: "Unauthorized" }); } if (request.method === "GET" && path === "/") { return apiRootDocumentation(); } if (request.method === "GET" && path === "/v1/models") { return handleModelsList(env); } if (request.method === "POST" && path === "/v1/messages") { return handleAnthropicMessages(request, env); } if (request.method === "POST" && path === "/v1/chat/completions") { return forwardToCfOpenAI(request, env, "/v1/chat/completions"); } if (request.method === "POST" && path === "/v1/embeddings") { return forwardToCfOpenAI(request, env, "/v1/embeddings"); } if (request.method === "POST" && path === "/v1/responses") { return forwardToCfOpenAI(request, env, "/v1/responses"); } return json(404, { error: "Not found", path }); }, }; /** @param {Env} env Worker 环境 */ function checkEnv(env) { if (!env.CLOUDFLARE_ACCOUNT_ID || String(env.CLOUDFLARE_ACCOUNT_ID).trim() === "") { return "Missing CLOUDFLARE_ACCOUNT_ID"; } if (!env.CLOUDFLARE_API_TOKEN) { return "Missing CLOUDFLARE_API_TOKEN (use: wrangler secret put CLOUDFLARE_API_TOKEN)"; } if (!env.PROXY_API_KEY) { return "Missing PROXY_API_KEY"; } return ""; } /** @param {Request} request 入站请求 @param {Env} env Worker 环境 */ function authorize(request, env) { const key = env.PROXY_API_KEY; const h = request.headers.get("Authorization"); if (h?.startsWith("Bearer ")) { return h.slice(7) === key; } const x = request.headers.get("x-api-key"); if (x) return x === key; return false; } /** @param {Request} request 预检请求 */ function corsPreflight(request) { const origin = request.headers.get("Origin") || "*"; return new Response(null, { status: 204, headers: { "access-control-allow-origin": origin, "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": request.headers.get("Access-Control-Request-Headers") || "authorization, content-type, x-api-key, anthropic-version", "access-control-max-age": "86400", }, }); } /** @param {Response} res 原始响应,为其附加 CORS 头 */ function withCors(res) { const h = new Headers(res.headers); h.set("access-control-allow-origin", "*"); return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h }); } /** @param {number} status HTTP 状态码 @param {object} body JSON 响应体 */ function json(status, body) { return withCors( new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" }, }) ); } /** @returns {Response} 根路径 GET / 的 API 索引(中文简要说明)。 */ function apiRootDocumentation() { return json(200, { service: "worker-ai-proxy", title: "Cloudflare Workers AI 反代", description: "本服务将你的请求转发至 Cloudflare Workers AI 的官方 OpenAI 兼容接口与管理端模型搜索;Anthropic Messages 在边缘转换为 Chat Completions 再转回 Anthropic 形态。使用前请配置可用的 Workers AI Token 与 Account ID(见 Worker 环境变量)。", authentication: { headers: ["Authorization: Bearer ", "可选: x-api-key: "], note: "访客密钥(PROXY_API_KEY)与服务端 CLOUDFLARE_API_TOKEN 不同;勿混用。仅 GET / 不要求访客密钥,其余路由需要。", }, sdkHints: { openai: "OpenAI SDK 设置 baseURL 为 「本域名 + /v1」,例如 baseURL = https://此域名/v1", anthropic: "Anthropic SDK 的 baseURL 为本域名(根),路径由 SDK 自动加 /v1/messages;需带 anthropic-version 头时使用 2023-06-01。", }, endpoints: [ { method: "GET", path: "/", summary: "API 索引与简要说明", detail: "返回当前 JSON(服务介绍与各端点说明);本路径不要求访客密钥。", }, { method: "GET", path: "/v1/models", summary: "模型列表(OpenAI List 兼容形状)", detail: "需提供 PROXY_API_KEY(与常见 OpenAI SDK 的模型列表调用一致)。分页拉取账户侧可搜索模型,合并为 object=list。具体 id 以 Cloudflare 搜索 API 为准;对话 model 建议使用控制台或文档中的 @cf/... slug。", }, { method: "POST", path: "/v1/chat/completions", summary: "OpenAI Chat Completions 兼容", detail: "透传至 Workers AI ai/v1/chat/completions;请求体与普通 OpenAI 一致(含 model、messages、stream、temperature、max_tokens 等)。model 通常为 @cf/<厂商>/<名>。支持服务端流式 SSE。", }, { method: "POST", path: "/v1/embeddings", summary: "OpenAI Embeddings 兼容", detail: "透传至 Workers AI ai/v1/embeddings;用于向量模型,请输入文档规定的 model 与 input。", }, { method: "POST", path: "/v1/responses", summary: "OpenAI Responses(若上游开放)", detail: "透传至 ai/v1/responses。若账户或区域尚未开通该路由,上游返回 404 时本会返回可读错误 JSON。", }, { method: "POST", path: "/v1/messages", summary: "Anthropic Messages API 兼容(适配)", detail: "将 Messages 请求转为内部 Chat Completions 调用再把结果转成 Anthropic 的 message JSON;stream=true 时输出 Anthropic SSE 事件序列(message_start、content_block_delta 等)。仅做常见文本消息的简化映射,复杂工具/多模态以 Cloudflare 模型能力为准。", }, ], notes: [ "浏览器或其它客户端可先 OPTIONS / 或由本服务附带 CORS 应答。", "并非 catalog 内所有模型都适合 chat/embeddings;不匹配的模型会由上游报错。", ], }); } /** @param {Env} env Worker 环境 @returns {string} 账户级 API 前缀 URL */ function accountBase(env) { return `${CF_API}/accounts/${env.CLOUDFLARE_ACCOUNT_ID}`; } /** @param {Env} env Worker 环境 @param {Record} [extra] 追加请求头 */ function cfHeaders(env, extra = {}) { return { authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, ...extra, }; } /** * 将 OpenAI 形态请求转发到 Cloudflare Workers AI 的兼容端点。 * @param {Request} request 客户端原始 POST * @param {Env} env Worker 环境 * @param {string} openAiSuffix 路径后缀,例如 /v1/chat/completions */ async function forwardToCfOpenAI(request, env, openAiSuffix) { const target = `${accountBase(env)}/ai${openAiSuffix}`; const headers = new Headers(request.headers); headers.delete("host"); headers.delete("cf-connecting-ip"); headers.set("authorization", `Bearer ${env.CLOUDFLARE_API_TOKEN}`); const init = { method: "POST", headers, body: request.body, // @ts-ignore 流式请求体需 duplex(fetch 选项,部分类型定义未收录) duplex: "half", }; let res; try { res = await fetch(target, init); } catch (e) { return json(502, { error: "Upstream fetch failed", detail: String(e) }); } if (openAiSuffix === "/v1/responses" && res.status === 404) { return json(404, { error: "responses_not_available", message: "Upstream returned 404 for /ai/v1/responses. Your account or Workers AI build may not expose this route yet.", }); } return withCors(new Response(res.body, { status: res.status, headers: res.headers })); } /** @param {Env} env Worker 环境:分页拉取模型并入 OpenAI list 格式 */ async function handleModelsList(env) { const perPage = 100; /** @type {any[]} 合并后的上游模型条目 */ const all = []; let page = 1; for (;;) { const qs = new URLSearchParams({ per_page: String(perPage), page: String(page) }); const url = `${accountBase(env)}/ai/models/search?${qs}`; const res = await fetch(url, { headers: cfHeaders(env) }); const data = /** @type {any} */ (await res.json().catch(() => ({}))); if (!res.ok) { return json(res.status, { error: "models_search_failed", status: res.status, body: data, }); } const chunk = Array.isArray(data.result) ? data.result : Array.isArray(data.data) ? data.data : []; if (!Array.isArray(chunk) || chunk.length === 0) break; all.push(...chunk); if (chunk.length < perPage) break; page += 1; } const now = Math.floor(Date.now() / 1000); const seen = new Set(); /** @type {object[]} OpenAI `/v1/models` 样式的 data 数组 */ const data = []; for (const item of all) { const id = item?.id ?? item?.name ?? item?.model_id ?? item?.permaslug; if (!id || seen.has(id)) continue; seen.add(id); data.push({ id: String(id), object: "model", created: now, owned_by: "cloudflare-workers-ai", }); } return json(200, { object: "list", data }); } // --- Anthropic 与 OpenAI 形态之间的适配 --- /** * @param {unknown} body 已解析的值 * @returns {object | null} 若是普通对象则返回,否则 null */ function readJson(body) { if (body && typeof body === "object") return /** @type {object} */ (body); return null; } /** * 将 Anthropic 消息的 content(字符串或由 text 块组成的数组)压成单行文本。 * @param {unknown} content * @returns {string} */ function anthropicContentToText(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts = []; for (const block of content) { if (!block || typeof block !== "object") continue; const b = /** @type {{type?: string, text?: string}} */ (block); if (b.type === "text" && typeof b.text === "string") parts.push(b.text); } return parts.join(""); } /** * 将 Anthropic 的 system 提示(字符串或 text 块数组)压成一段文本。 * @param {unknown} sys * @returns {string} */ function anthropicSystemToText(sys) { if (typeof sys === "string") return sys; if (!Array.isArray(sys)) return ""; const parts = []; for (const block of sys) { if (!block || typeof block !== "object") continue; const b = /** @type {{type?: string, text?: string}} */ (block); if (b.type === "text" && typeof b.text === "string") parts.push(b.text); } return parts.join("\n"); } /** * 处理 Anthropic Messages:`/v1/messages`。内部走后端 Chat Completions,必要时做流式事件转换。 * @param {Request} request 入站请求体为 Anthropic Messages JSON * @param {Env} env Worker 环境 */ async function handleAnthropicMessages(request, env) { let raw; try { raw = await request.json(); } catch { return json(400, { error: "invalid_json", type: "error" }); } const body = readJson(raw); if (!body) return json(400, { error: "invalid_body", type: "error" }); const model = /** @type {{model?: string}} */ (body).model; if (!model) return json(400, { error: "missing_model", type: "error" }); const stream = Boolean(/** @type {{stream?: boolean}} */ (body).stream); const messagesIn = /** @type {{messages?: unknown[], system?: unknown}} */ (body).messages; if (!Array.isArray(messagesIn)) { return json(400, { error: "missing_messages", type: "error" }); } /** @type {{role: string, content: string}[]} OpenAI 风格的 messages */ const oaMessages = []; const systemText = anthropicSystemToText(/** @type {{system?: unknown}} */ (body).system); if (systemText) { oaMessages.push({ role: "system", content: systemText }); } for (const m of messagesIn) { if (!m || typeof m !== "object") continue; const msg = /** @type {{role?: string, content?: unknown}} */ (m); const role = msg.role === "assistant" ? "assistant" : "user"; const text = anthropicContentToText(msg.content); oaMessages.push({ role, content: text }); } /** @type {Record} Chat Completions 请求体 */ const oaBody = { model, messages: oaMessages, stream, }; if ("max_tokens" in body && body.max_tokens != null) { oaBody.max_tokens = body.max_tokens; } if ("temperature" in body && body.temperature != null) { oaBody.temperature = body.temperature; } if ("top_p" in body && body.top_p != null) { oaBody.top_p = body.top_p; } if ("stop_sequences" in body && Array.isArray(body.stop_sequences)) { oaBody.stop = body.stop_sequences; } const target = `${accountBase(env)}/ai/v1/chat/completions`; if (stream) { const res = await fetch(target, { method: "POST", headers: cfHeaders(env, { "content-type": "application/json", accept: "text/event-stream" }), body: JSON.stringify(oaBody), }); if (!res.ok || !res.body) { const t = await res.text(); return json(res.status, { error: "upstream_error", type: "error", status: res.status, detail: t.slice(0, 4000), }); } const out = openAIChatStreamToAnthropic(res.body, String(model)); return withCors( new Response(out, { status: 200, headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive", }, }) ); } const res = await fetch(target, { method: "POST", headers: cfHeaders(env, { "content-type": "application/json" }), body: JSON.stringify(oaBody), }); const text = await res.text(); let parsed; try { parsed = JSON.parse(text); } catch { return json(res.status, { error: "upstream_invalid_json", type: "error", raw: text.slice(0, 2000), }); } if (!res.ok) { return json(res.status, { error: "upstream_error", type: "error", detail: parsed, }); } const anth = openAiChatCompletionToAnthropic(parsed, String(model)); return json(200, anth); } /** * OpenAI `finish_reason` 映射到 Anthropic `stop_reason`。 * @param {string} openaiReason * @returns {string} */ function mapFinishToAnthropic(openaiReason) { const m = { stop: "end_turn", length: "max_tokens", content_filter: "content_filter", tool_calls: "tool_use", function_call: "tool_use", }; return m[/** @type {keyof typeof m} */ (openaiReason)] ?? "end_turn"; } /** * 单次 Chat Completion JSON 转成 Anthropic 非流式 message 响应体。 * @param {any} parsed 上游 JSON * @param {string} model 模型名 */ function openAiChatCompletionToAnthropic(parsed, model) { const choice = parsed?.choices?.[0]; const contentStr = typeof choice?.message?.content === "string" ? choice.message.content : choice?.message?.content != null ? JSON.stringify(choice.message.content) : ""; const usage = parsed?.usage || {}; const inTok = usage.prompt_tokens ?? usage.input_tokens ?? 0; const outTok = usage.completion_tokens ?? usage.output_tokens ?? 0; return { id: parsed?.id ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content: [{ type: "text", text: contentStr }], stop_reason: mapFinishToAnthropic(choice?.finish_reason ?? "stop"), stop_sequence: null, usage: { input_tokens: inTok, output_tokens: outTok, }, }; } /** * OpenAI Chat Completions 流式 SSE 转为 Anthropic Messages 流式事件。 * @param {ReadableStream} upstreamBody 上游 ReadableStream.body * @param {string} model 模型名 */ function openAIChatStreamToAnthropic(upstreamBody, model) { const encoder = new TextEncoder(); const decoder = new TextDecoder(); const msgId = `msg_${crypto.randomUUID().replace(/-/g, "")}`; let buffer = ""; let started = false; let closed = false; const blockIndex = 0; /** @param {string} event SSE 事件名 @param {object} data 事件载荷 */ function ev(event, data) { return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } const reader = upstreamBody.getReader(); /** * 解析一行 OpenAI SSE `data:` JSON,按需向下游写入 Anthropic 事件。 * @param {ReadableStreamDefaultController} controller * @param {string | undefined} dataLine JSON 负载(不含前缀 data:) */ function handleDataLine(controller, dataLine) { if (!dataLine || dataLine === "[DONE]") return; let json; try { json = JSON.parse(dataLine); } catch { return; } const choice = json.choices?.[0]; const delta = choice?.delta; const piece = typeof delta?.content === "string" ? delta.content : delta?.content != null ? String(delta.content) : ""; const finish = choice?.finish_reason; if (!started) { started = true; controller.enqueue( ev("message_start", { type: "message_start", message: { id: msgId, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 }, }, }) ); controller.enqueue( ev("content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "text", text: "" }, }) ); } if (piece) { controller.enqueue( ev("content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text: piece }, }) ); } if (finish) { const usage = json.usage || {}; controller.enqueue( ev("content_block_stop", { type: "content_block_stop", index: blockIndex, }) ); controller.enqueue( ev("message_delta", { type: "message_delta", delta: { stop_reason: mapFinishToAnthropic(finish), stop_sequence: null, }, usage: { input_tokens: usage.prompt_tokens ?? 0, output_tokens: usage.completion_tokens ?? 0, }, }) ); controller.enqueue(ev("message_stop", { type: "message_stop" })); closed = true; controller.close(); } } /** 上游结束时补齐停止事件或空流错误。 @param {ReadableStreamDefaultController} controller */ function finalizeStream(controller) { if (closed) return; if (started) { controller.enqueue( ev("content_block_stop", { type: "content_block_stop", index: blockIndex, }) ); controller.enqueue( ev("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { input_tokens: 0, output_tokens: 0 }, }) ); controller.enqueue(ev("message_stop", { type: "message_stop" })); } else { controller.enqueue( ev("error", { type: "error", error: { type: "api_error", message: "empty_stream" }, }) ); } closed = true; controller.close(); } return new ReadableStream({ async pull(controller) { if (closed) return; try { const { done, value } = await reader.read(); if (value) { buffer += decoder.decode(value, { stream: true }); } let sep; while ((sep = buffer.indexOf("\n\n")) !== -1) { const block = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); /** @type {string | undefined} 当前 SSE 帧中的 data 行 */ let dataLine; for (const line of block.split("\n")) { if (line.startsWith("data: ")) { dataLine = line.slice(6).trimEnd(); } } handleDataLine(controller, dataLine); if (closed) return; } if (done) { const tail = buffer.trim(); if (tail.startsWith("data: ") && !closed) { handleDataLine(controller, tail.slice(6).trimEnd()); } if (!closed) finalizeStream(controller); } } catch (e) { controller.error(e); } }, }); }