Files
worker-ai-proxy/worker.js
2026-06-24 22:10:29 +08:00

703 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cloudflare Workers AI 反向代理。
*
* 环境变量wrangler
* CLOUDFLARE_ACCOUNT_ID — Cloudflare 账户 ID[vars],非密钥)
* CLOUDFLARE_API_TOKEN — 具备 Workers AI 读写的 API Tokensecretwrangler 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 SDKNode默认基路径带 /v1应配置为「Worker 域名 + /v1」例如
* new OpenAI({ apiKey: PROXY_API_KEY, baseURL: `${WORKER_URL}/v1` })
*
* Anthropic SDKbaseURL 填 Worker 根域名(不带 /v1SDK 会自行拼接 /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 <PROXY_API_KEY>", "可选: x-api-key: <PROXY_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 JSONstream=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<string, string>} [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 流式请求体需 duplexfetch 选项,部分类型定义未收录)
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<string, unknown>} 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<Uint8Array>} 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<Uint8Array>} 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<Uint8Array>} 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);
}
},
});
}