chore: sync local changes to Gitea
This commit is contained in:
6
.wrangler/cache/wrangler-account.json
vendored
Normal file
6
.wrangler/cache/wrangler-account.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"account": {
|
||||||
|
"id": "3fdbaad92364222635c5c1c41ff1af8b",
|
||||||
|
"name": "shumengya"
|
||||||
|
}
|
||||||
|
}
|
||||||
702
worker.js
Normal file
702
worker.js
Normal file
@@ -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 <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 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<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 流式请求体需 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<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
13
wrangler.toml
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user