chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:29 +08:00
commit 1ae0174d13
3 changed files with 721 additions and 0 deletions

6
.wrangler/cache/wrangler-account.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"account": {
"id": "3fdbaad92364222635c5c1c41ff1af8b",
"name": "shumengya"
}
}

702
worker.js Normal file
View File

@@ -0,0 +1,702 @@
/**
* 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);
}
},
});
}

13
wrangler.toml Normal file
View File

@@ -0,0 +1,13 @@
# Cloudflare Workers AI 反向代理OpenAI / Anthropic 兼容)。
# 在此填写账户 IDAPI 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"