Files
modelping/worker/probe.ts
2026-06-24 22:10:25 +08:00

584 lines
18 KiB
TypeScript
Raw Permalink 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.
export type Protocol = "openai" | "openai_responses" | "claude";
const PROBE_TIMEOUT_MS = 45_000;
const PROBE_MAX_TOKENS = 256;
/** 存入 D1 前在 Worker 侧截断的流式输出上限 */
const STREAM_OUTPUT_CAP = 4096;
/** 非 2xx 时读取响应体截断长度scheduler 还会再截断入库) */
const HTTP_ERROR_BODY_MAX = 6000;
function tryFormatJsonBody(raw: string): string {
const t = raw.trim();
if (!t.startsWith("{") && !t.startsWith("[")) return raw;
try {
return JSON.stringify(JSON.parse(t), null, 2);
} catch {
return raw;
}
}
async function readHttpErrorBody(res: Response): Promise<string> {
try {
const raw = (await res.text()).trim();
if (!raw) return "";
const formatted = tryFormatJsonBody(raw);
if (formatted.length <= HTTP_ERROR_BODY_MAX) return formatted;
return formatted.slice(0, HTTP_ERROR_BODY_MAX) + "…";
} catch {
return "";
}
}
function buildHttpErrorMessage(res: Response, body: string): string {
const line = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
if (!body) return `${line}\n响应体为空常见原因网关未返回 JSON 错误详情)`;
return `${line}\n\n${body}`;
}
function normalizeBase(url: string): string {
return url.replace(/\/+$/, "");
}
function appendCap(base: string, add: string, max: number): string {
if (base.length >= max) return base;
const room = max - base.length;
return base + (add.length <= room ? add : add.slice(0, room));
}
/** 经典 Chat Completions/v1/chat/completions兼容绝大多数 OpenAI 兼容网关 */
function openAiChatUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/chat/completions")) return b;
if (b.endsWith("/v1")) return `${b}/chat/completions`;
return `${b}/chat/completions`;
}
/** OpenAI Responses API/v1/responses */
function openAiResponsesUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/responses")) return b;
if (b.endsWith("/v1")) return `${b}/responses`;
return `${b}/v1/responses`;
}
function claudeUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/messages")) return b;
if (b.endsWith("/v1")) return `${b}/messages`;
return `${b}/v1/messages`;
}
/**
* Chat Completions 流式里 `choices[0].delta` 的正文抽取。
* 对齐 OpenAI 常见形态delta.content 字符串;多段/多模态下为 part 数组;
* 部分国产/推理模型会在 delta.reasoning_content 等字段里先出字。
*/
function streamDeltaTextFromOpenAiChatChoice(delta: unknown): string {
if (delta == null || typeof delta !== "object") return "";
const d = delta as Record<string, unknown>;
const c = d.content;
if (typeof c === "string" && c !== "") return c;
if (Array.isArray(c)) {
let s = "";
for (const part of c) {
if (part && typeof part === "object") {
const p = part as { type?: string; text?: string };
if (typeof p.text === "string") s += p.text;
}
}
return s;
}
for (const k of ["reasoning_content", "reasoning"] as const) {
const v = d[k];
if (typeof v === "string" && v !== "") return v;
}
return "";
}
async function readStreamOpenAIChat(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (payload === "[DONE]") {
return { firstTokenMs, httpStatus: res.status, outputText };
}
try {
const obj = JSON.parse(payload) as {
choices?: Array<{ delta?: unknown }>;
};
const delta = obj.choices?.[0]?.delta;
const chunk = streamDeltaTextFromOpenAiChatChoice(delta);
if (chunk !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, chunk, STREAM_OUTPUT_CAP);
}
} catch {
/* ignore bad json line */
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
async function readStreamOpenAIResponses(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const obj = JSON.parse(payload) as { type?: string; delta?: string };
if (obj.type === "response.output_text.delta" && obj.delta != null && obj.delta !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, obj.delta, STREAM_OUTPUT_CAP);
}
} catch {
/* ignore */
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
function openAiChatMessageContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
let s = "";
for (const part of content) {
if (part && typeof part === "object") {
const p = part as { type?: string; text?: string };
if (typeof p.text === "string") s += p.text;
}
}
return s;
}
function parseOpenAiChatNonStream(
json: unknown,
started: number
): { firstTokenMs: number | null; outputText: string } {
const obj = json as { choices?: Array<{ message?: { content?: unknown } }> };
const raw = openAiChatMessageContentToText(obj.choices?.[0]?.message?.content);
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
return { firstTokenMs, outputText };
}
function extractOpenAiResponsesText(data: unknown): string {
if (data == null || typeof data !== "object") return "";
const d = data as Record<string, unknown>;
if (typeof d.output_text === "string") return d.output_text;
const out = d.output;
if (!Array.isArray(out)) return "";
const parts: string[] = [];
const walk = (node: unknown): void => {
if (node == null) return;
if (typeof node === "string") {
parts.push(node);
return;
}
if (Array.isArray(node)) {
for (const x of node) walk(x);
return;
}
if (typeof node !== "object") return;
const o = node as Record<string, unknown>;
if (o.type === "output_text" && typeof o.text === "string") {
parts.push(o.text);
return;
}
if (typeof o.text === "string" && typeof o.type === "string" && o.type.includes("text")) {
parts.push(o.text);
return;
}
if (o.content != null) walk(o.content);
if (o.output != null) walk(o.output);
};
for (const item of out) walk(item);
return parts.join("");
}
function parseOpenAiResponsesNonStream(
json: unknown,
started: number
): { firstTokenMs: number | null; outputText: string } {
const raw = extractOpenAiResponsesText(json);
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
return { firstTokenMs, outputText };
}
function extractClaudeNonStreamText(json: unknown): string {
if (json == null || typeof json !== "object") return "";
const d = json as { content?: unknown };
const content = d.content;
if (!Array.isArray(content)) return "";
let s = "";
for (const block of content) {
if (block && typeof block === "object") {
const b = block as { type?: string; text?: string };
if (b.type === "text" && typeof b.text === "string") s += b.text;
}
}
return s;
}
function parseClaudeNonStream(
json: unknown,
started: number
): { firstTokenMs: number | null; outputText: string } {
const raw = extractClaudeNonStreamText(json);
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
return { firstTokenMs, outputText };
}
/** 首字可来自正文或 thinking输出只累计 assistant 可见正文 delta.text */
function claudeStreamDeltaParts(
data: string,
currentEvent: string
): { outputChunk: string | null; marksFirstToken: boolean } {
try {
const obj = JSON.parse(data) as {
type?: string;
delta?: { type?: string; text?: string; thinking?: string };
};
const match = currentEvent === "content_block_delta" || obj.type === "content_block_delta";
if (!match) return { outputChunk: null, marksFirstToken: false };
const d = obj.delta;
if (!d) return { outputChunk: null, marksFirstToken: false };
const hasThinking = typeof d.thinking === "string" && d.thinking !== "";
const hasText = typeof d.text === "string" && d.text !== "";
return {
outputChunk: hasText ? (d.text as string) : null,
marksFirstToken: hasThinking || hasText,
};
} catch {
return { outputChunk: null, marksFirstToken: false };
}
}
async function readStreamClaude(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let currentEvent = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
let line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line === "") {
currentEvent = "";
continue;
}
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
continue;
}
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
const parts = claudeStreamDeltaParts(data, currentEvent);
if (parts.marksFirstToken && firstTokenMs == null) {
firstTokenMs = Math.max(0, Date.now() - started);
}
if (parts.outputChunk) {
outputText = appendCap(outputText, parts.outputChunk, STREAM_OUTPUT_CAP);
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
export type ProbeResult = {
ok: boolean;
firstTokenMs: number | null;
httpStatus: number | null;
errorMessage: string | null;
requestMessage: string;
responseText: string | null;
};
export async function runProbe(params: {
apiBaseUrl: string;
apiKey: string;
model: string;
protocol: Protocol;
userMessage: string;
/** 默认 true流式false 时使用非流式 JSON 响应 */
stream?: boolean;
}): Promise<ProbeResult> {
const userMessage = params.userMessage.trim() || "ping";
const useStream = params.stream !== false;
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
const started = Date.now();
try {
if (params.protocol === "openai") {
const url = openAiChatUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
messages: [{ role: "user", content: userMessage }],
max_tokens: PROBE_MAX_TOKENS,
stream: useStream,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
if (useStream) {
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIChat(res, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus,
errorMessage: ok ? null : "no_stream_token",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
let json: unknown;
try {
json = await res.json();
} catch {
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: "invalid_json_body",
requestMessage: userMessage,
responseText: null,
};
}
const { firstTokenMs, outputText } = parseOpenAiChatNonStream(json, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus: res.status,
errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
if (params.protocol === "openai_responses") {
const url = openAiResponsesUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
input: userMessage,
stream: useStream,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
if (useStream) {
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIResponses(res, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus,
errorMessage: ok ? null : "no_stream_token",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
let json: unknown;
try {
json = await res.json();
} catch {
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: "invalid_json_body",
requestMessage: userMessage,
responseText: null,
};
}
const { firstTokenMs, outputText } = parseOpenAiResponsesNonStream(json, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus: res.status,
errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
const url = claudeUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
"x-api-key": params.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: params.model,
max_tokens: PROBE_MAX_TOKENS,
messages: [{ role: "user", content: userMessage }],
stream: useStream,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
if (useStream) {
const { firstTokenMs, httpStatus, outputText } = await readStreamClaude(res, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus,
errorMessage: ok ? null : "no_stream_token",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
let json: unknown;
try {
json = await res.json();
} catch {
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: "invalid_json_body",
requestMessage: userMessage,
responseText: null,
};
}
const { firstTokenMs, outputText } = parseClaudeNonStream(json, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus: res.status,
errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
} catch (e) {
const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "unknown_error";
return {
ok: false,
firstTokenMs: null,
httpStatus: null,
errorMessage: truncateErr(msg),
requestMessage: userMessage,
responseText: null,
};
} finally {
clearTimeout(t);
}
}
function truncateErr(s: string, max = 120): string {
if (s.length <= max) return s;
return s.slice(0, max);
}