703 lines
22 KiB
JavaScript
703 lines
22 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
},
|
||
});
|
||
}
|