113 lines
5.8 KiB
JavaScript
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());
|
|
}
|