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