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 { 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; 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; 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; 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 { 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); }