Files
AI_spine/providers/http.mjs
T
2026-09-17 17:44:24 +08:00

113 lines
5.8 KiB
JavaScript

export function endpoint(baseUrl, route) {
const base = baseUrl.replace(/\/+$/u, '');
return `${base.endsWith('/v1') ? base : `${base}/v1`}/${route}`;
}
export function redact(value, secret = '') {
let text = typeof value === 'string' ? value : JSON.stringify(value);
if (secret) text = text.split(secret).join('[REDACTED]');
return text.replace(/Bearer\s+[^\s"<>]+/giu, 'Bearer [REDACTED]');
}
// Some relay channels require SSE even for offline jobs. Accept a terminal
// Responses event (not only text deltas) so truncated streams cannot pass QA.
export function parseEventStream(raw) {
let terminal = null;
let text = '';
let model = null;
let usage = null;
let id = null;
let finishReason = null;
let refusal = '';
for (const block of raw.replace(/\r\n/gu, '\n').split(/\n\n/u)) {
const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
if (!data || data.trim() === '[DONE]') continue;
let event;
try { event = JSON.parse(data); } catch { throw new Error('SSE 包含不完整或非法 JSON 事件'); }
if (event.error || event.type === 'error') throw new Error(`SSE 服务端错误:${event.error?.message || event.message || 'unknown error'}`);
if (['response.completed', 'response.incomplete', 'response.failed'].includes(event.type)) {
if (!event.response || typeof event.response !== 'object') throw new Error('SSE 终止事件缺少 response');
terminal = event.response;
terminal.status ||= event.type.slice('response.'.length);
}
if (event.choices) {
model = event.model || model; id = event.id || id; usage = event.usage || usage;
const choice = event.choices.find((item) => (item.index ?? 0) === 0);
if (typeof choice?.delta?.content === 'string') text += choice.delta.content;
if (typeof choice?.delta?.refusal === 'string') refusal += choice.delta.refusal;
if (choice?.finish_reason) finishReason = choice.finish_reason;
}
}
if (terminal) return terminal;
if (finishReason) return {id, model, usage, choices: [{finish_reason: finishReason, message: {content: text, ...(refusal ? {refusal} : {})}}]};
throw new Error('SSE 未收到完整终止事件,可能连接中断;不接受部分输出');
}
// Retry only explicit 429/5xx responses. A timeout/network failure may already
// have generated and billed an image; do not automatically duplicate that job.
export async function requestJson(stage, route, makeRequest, {
fetchImpl = fetch,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) {
const started = Date.now();
for (let attempt = 0; attempt <= stage.maxRetries; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), stage.timeoutMs);
let diagnostics = null;
try {
const request = makeRequest();
const response = await fetchImpl(endpoint(stage.baseUrl, route), {
...request,
headers: {...request.headers, Authorization: `Bearer ${stage.apiKey}`},
signal: controller.signal,
redirect: 'error',
});
const raw = await response.text();
diagnostics = {
http_status: response.status, request_id: response.headers.get('x-request-id'),
elapsed_ms: Date.now() - started, attempts: attempt + 1,
// Bounded diagnostics; normal successful artifacts retain full payloads.
response_body: raw.slice(0, 2_000_000), truncated: raw.length > 2_000_000,
};
let payload;
try { payload = JSON.parse(raw); } catch { payload = null; }
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < stage.maxRetries) {
clearTimeout(timer);
await sleep(Math.min(10000, 2000 * (2 ** attempt)));
continue;
}
const message = payload?.error?.message || `请求未成功 (${response.status})`;
throw new Error(redact(`HTTP ${response.status}: ${message}`, stage.apiKey));
}
const isSse = response.headers.get('content-type')?.includes('text/event-stream') || /^data:|\nevent:|^event:/u.test(raw);
if (!payload && isSse) payload = parseEventStream(raw);
if (!payload || typeof payload !== 'object') throw new Error('服务端没有返回 JSON 对象或完整 SSE');
if (payload.error) throw new Error(redact(`服务端错误:${payload.error.message || 'unknown error'}`, stage.apiKey));
return {payload, metadata: {
requested_model: stage.model, returned_model: payload.model || null,
request_id: response.headers.get('x-request-id') || payload.id || null,
usage: payload.usage || null, elapsed_ms: Date.now() - started, attempts: attempt + 1, wire_format: isSse ? 'sse' : 'json',
}};
} catch (error) {
const cause = error.cause?.code || error.cause?.message;
const failure = new Error(controller.signal.aborted
? `请求超时 (${stage.timeoutMs} ms),未自动重试;请检查中转站任务状态`
: redact(`${error.message}${cause ? ` (${cause})` : ''}`, stage.apiKey));
failure.diagnostics = diagnostics
? JSON.parse(redact(diagnostics, stage.apiKey))
: {elapsed_ms: Date.now() - started, attempts: attempt + 1, network_code: cause || null};
throw failure;
} finally { clearTimeout(timer); }
}
}
export async function downloadImage(url, stage, fetchImpl = fetch) {
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol)) throw new Error('图片 URL 必须使用 HTTP(S)');
const response = await fetchImpl(parsed, {signal: AbortSignal.timeout(stage.timeoutMs)});
if (!response.ok) throw new Error(`下载图片失败:HTTP ${response.status}`);
// Never forward the relay API key to image download hosts.
return Buffer.from(await response.arrayBuffer());
}