新方案v1角色结构理解
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import path from 'node:path';
|
||||
import {imageFormat} from '../lib/images.mjs';
|
||||
import {requestJson, downloadImage} from './http.mjs';
|
||||
|
||||
export function validateEditInputs(stage, images, mask) {
|
||||
if (!images.length) throw new Error('图片编辑至少需要一张输入图');
|
||||
if (images.length > 1 && !stage.capabilities.multiple_images) throw new Error('尚未确认此提供方支持多图;请先验证再启用 capabilities.multiple_images');
|
||||
if (mask) {
|
||||
if (!stage.capabilities.mask) throw new Error('尚未确认此提供方支持编辑掩码;不能静默忽略 --mask');
|
||||
if (stage.maskEditable !== 'transparent') throw new Error('当前掩码适配仅支持透明像素为可编辑区域');
|
||||
if (mask.extension !== 'png' || mask.width !== images[0].width || mask.height !== images[0].height) {
|
||||
throw new Error('编辑 mask 必须为与第一张输入图同尺寸的 PNG');
|
||||
}
|
||||
if (![4, 6].includes(mask.bytes[25])) throw new Error('编辑 mask 必须包含 alpha 通道(透明处允许编辑),不能直接传 SAM 黑白掩码');
|
||||
}
|
||||
}
|
||||
|
||||
export async function editImage(stage, {images, mask, prompt}, options) {
|
||||
validateEditInputs(stage, images, mask);
|
||||
return requestJson(stage, 'images/edits', () => {
|
||||
const form = new FormData();
|
||||
form.append('model', stage.model);
|
||||
form.append('prompt', prompt);
|
||||
for (const [name, value] of Object.entries(stage.params)) {
|
||||
if (value !== null && value !== undefined) form.append(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
||||
}
|
||||
for (const image of images) form.append(stage.imageField, new Blob([image.bytes], {type: image.mime}), path.basename(image.file));
|
||||
if (mask) form.append('mask', new Blob([mask.bytes], {type: mask.mime}), path.basename(mask.file));
|
||||
return {method: 'POST', body: form};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function decodeEditedImages(payload, stage, {fetchImpl = fetch} = {}) {
|
||||
const items = payload.data || payload.images || payload.output;
|
||||
if (!Array.isArray(items) || !items.length) throw new Error('图片编辑响应没有图片列表');
|
||||
const decoded = [];
|
||||
for (const item of items) {
|
||||
let bytes;
|
||||
if (typeof item.b64_json === 'string') bytes = Buffer.from(item.b64_json, 'base64');
|
||||
else if (typeof item.url === 'string') bytes = await downloadImage(item.url, stage, fetchImpl);
|
||||
else throw new Error('图片编辑响应缺少 b64_json/url');
|
||||
decoded.push({bytes, ...imageFormat(bytes)});
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {dataUrl} from '../lib/images.mjs';
|
||||
import {requestJson} from './http.mjs';
|
||||
|
||||
export function buildVisionRequest(stage, image, schema) {
|
||||
const instruction = `${stage.prompt}\n\nCanvas: ${image.width} x ${image.height} pixels.\nReturn ONLY JSON matching this schema:\n${JSON.stringify(schema)}`;
|
||||
if (stage.protocol === 'chat-completions') {
|
||||
const body = { ...stage.params, model: stage.model, stream: stage.stream,
|
||||
messages: [{role: 'user', content: [
|
||||
{type: 'text', text: instruction},
|
||||
{type: 'image_url', image_url: {url: dataUrl(image)}},
|
||||
]}],
|
||||
};
|
||||
if (stage.outputMode === 'json-object') body.response_format = {type: 'json_object'};
|
||||
if (stage.outputMode === 'json-schema') body.response_format = {type: 'json_schema', json_schema: {name: 'semantic_parts', strict: true, schema}};
|
||||
return {route: 'chat/completions', body};
|
||||
}
|
||||
if (stage.protocol === 'responses') {
|
||||
const body = {...stage.params, model: stage.model, stream: stage.stream, instructions: stage.prompt, input: [{role: 'user', content: [
|
||||
{type: 'input_text', text: instruction},
|
||||
{type: 'input_image', image_url: dataUrl(image)},
|
||||
]}]};
|
||||
if (stage.outputMode === 'json-object') body.text = {format: {type: 'json_object'}};
|
||||
if (stage.outputMode === 'json-schema') body.text = {format: {type: 'json_schema', name: 'semantic_parts', strict: true, schema}};
|
||||
return {route: 'responses', body};
|
||||
}
|
||||
throw new Error(`未知理解协议:${stage.protocol}`);
|
||||
}
|
||||
|
||||
export function extractVisionText(payload, protocol) {
|
||||
if (protocol === 'chat-completions') {
|
||||
const choice = payload.choices?.[0];
|
||||
if (choice?.message?.refusal) throw new Error('结构理解请求被模型拒绝');
|
||||
if (choice?.finish_reason && choice.finish_reason !== 'stop') throw new Error(`结构理解响应未完整结束:${choice.finish_reason}`);
|
||||
const content = choice?.message?.content;
|
||||
if (typeof content === 'string' && content.trim()) return content;
|
||||
if (Array.isArray(content)) {
|
||||
const text = content.filter((item) => item.type === 'text').map((item) => item.text).join('\n');
|
||||
if (text.trim()) return text;
|
||||
}
|
||||
} else {
|
||||
if (payload.status && payload.status !== 'completed') throw new Error(`结构理解响应状态:${payload.status}`);
|
||||
const items = (payload.output || []).flatMap((item) => item.content || []);
|
||||
if (items.some((item) => item.type === 'refusal')) throw new Error('结构理解请求被模型拒绝');
|
||||
const text = payload.output_text || items.filter((item) => item.type === 'output_text').map((item) => item.text).join('\n');
|
||||
if (typeof text === 'string' && text.trim()) return text;
|
||||
}
|
||||
throw new Error('服务端响应中没有结构理解文本');
|
||||
}
|
||||
|
||||
export async function analyze(stage, image, schema, options) {
|
||||
const {route, body} = buildVisionRequest(stage, image, schema);
|
||||
return requestJson(stage, route, () => ({method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), options);
|
||||
}
|
||||
Reference in New Issue
Block a user