commit 1ae0174d136179426ffc523a988347dc1305f0d2 Author: shumengya Date: Wed Jun 24 22:10:29 2026 +0800 chore: sync local changes to Gitea diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..3d01dad --- /dev/null +++ b/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "3fdbaad92364222635c5c1c41ff1af8b", + "name": "shumengya" + } +} \ No newline at end of file diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..3386261 --- /dev/null +++ b/worker.js @@ -0,0 +1,702 @@ +/** + * 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); + } + }, + }); +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..bfe2b41 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,13 @@ +# Cloudflare Workers AI 反向代理(OpenAI / Anthropic 兼容)。 +# 在此填写账户 ID;API Token 不要提交仓库,应用 secret 注入: +# npx wrangler secret put CLOUDFLARE_API_TOKEN +# +name = "worker-ai-proxy" +main = "worker.js" +compatibility_date = "2025-05-18" + +[vars] +# 访客密钥(Authorization: Bearer ... 或 x-api-key)。 +PROXY_API_KEY = "sk-shumengya666" +# Cloudflare 账户 ID(控制台 URL 或 Workers 概览可查看)。 +CLOUDFLARE_ACCOUNT_ID = "3fdbaad92364222635c5c1c41ff1af8b"