fix(POSTV1-02): 接通真实AI生成链路
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
This commit is contained in:
@@ -7,6 +7,11 @@ export interface GenerationAdapterRequest {
|
||||
prompt: string;
|
||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||
referenceAssetIds: readonly string[];
|
||||
referenceImages?: readonly {
|
||||
assetId: string;
|
||||
bytes: Buffer;
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface NormalizedGenerationOutput {
|
||||
@@ -27,6 +32,7 @@ export type GenerationAdapterResult =
|
||||
};
|
||||
|
||||
export interface GenerationAdapter {
|
||||
dispose?(): void;
|
||||
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
||||
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import type { GenerationAdapter, GenerationAdapterRequest, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
||||
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||
@@ -120,6 +120,7 @@ export class GenerationProcessor {
|
||||
.run("worker_stopped", now, this.workerId);
|
||||
});
|
||||
this.gatewayBalance.close();
|
||||
this.adapter.dispose?.();
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
@@ -144,6 +145,12 @@ export class GenerationProcessor {
|
||||
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||
`).all(generationId) as Array<{ managed_file_id: string }>;
|
||||
let adapterResult: GenerationAdapterResult;
|
||||
let referenceImages: NonNullable<GenerationAdapterRequest["referenceImages"]>;
|
||||
try {
|
||||
referenceImages = this.loadReferenceImages(references.map((row) => row.managed_file_id));
|
||||
} catch {
|
||||
return this.completeFailure(job, "reference_invalid", "reference_load_failed");
|
||||
}
|
||||
try {
|
||||
if (job.upstream_job_reference) {
|
||||
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
||||
@@ -156,10 +163,13 @@ export class GenerationProcessor {
|
||||
prompt: job.prompt,
|
||||
ratio: job.ratio,
|
||||
referenceAssetIds: references.map((row) => row.managed_file_id),
|
||||
referenceImages,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
||||
} finally {
|
||||
for (const reference of referenceImages) reference.bytes.fill(0);
|
||||
}
|
||||
|
||||
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
||||
@@ -175,6 +185,29 @@ export class GenerationProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private loadReferenceImages(referenceAssetIds: string[]): NonNullable<GenerationAdapterRequest["referenceImages"]> {
|
||||
return referenceAssetIds.map((assetId) => {
|
||||
const row = this.database.prepare(`
|
||||
SELECT relative_path, mime_type FROM managed_files
|
||||
WHERE file_id = ? AND file_kind = 'reference' AND status = 'committed'
|
||||
`).get(assetId) as { mime_type: string; relative_path: string } | undefined;
|
||||
if (!row || !["image/jpeg", "image/png", "image/webp"].includes(row.mime_type) || isAbsolute(row.relative_path)) {
|
||||
throw new Error("reference_invalid");
|
||||
}
|
||||
const path = resolve(this.dataRoot, row.relative_path);
|
||||
const child = relative(this.dataRoot, path);
|
||||
if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)
|
||||
|| !existsSync(path) || !statSync(path).isFile()) {
|
||||
throw new Error("reference_invalid");
|
||||
}
|
||||
return {
|
||||
assetId,
|
||||
bytes: readFileSync(path),
|
||||
mimeType: row.mime_type as "image/jpeg" | "image/png" | "image/webp",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private claim(generationId: string) {
|
||||
return this.immediate(() => {
|
||||
const row = this.readJob(generationId);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
import type {
|
||||
GenerationAdapter,
|
||||
GenerationAdapterRequest,
|
||||
GenerationAdapterResult,
|
||||
NormalizedGenerationOutput,
|
||||
} from "./ai-adapter-contract.js";
|
||||
import { gptImageRequestSizeForRatio, normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
|
||||
|
||||
const geminiProductModelId = "gemini-3.1-flash-image-preview";
|
||||
const geminiProviderModelId = "gemini-3.1-flash-image";
|
||||
const gptImageModelId = "gpt-image-2";
|
||||
const geminiEndpoint = "https://oneapi.intelligrow.cn/v1/chat/completions";
|
||||
const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
||||
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||
const requestTimeoutMilliseconds = 180_000;
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
class OneApiRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly category: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | "upstream_failed" | "upstream_timeout" | "unknown_non_retryable",
|
||||
readonly sourceCategory: string,
|
||||
) {
|
||||
super(sourceCategory);
|
||||
}
|
||||
}
|
||||
|
||||
function failure(error: unknown): GenerationAdapterResult {
|
||||
if (error instanceof OneApiRuntimeError) {
|
||||
return { category: error.category, sourceCategory: error.sourceCategory, status: "failed" };
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return { category: "upstream_timeout", sourceCategory: "upstream_timeout", status: "failed" };
|
||||
}
|
||||
return { category: "upstream_failed", sourceCategory: "upstream_failed", status: "failed" };
|
||||
}
|
||||
|
||||
function mapHttpFailure(status: number) {
|
||||
if (status === 408 || status === 504) return new OneApiRuntimeError("upstream_timeout", `upstream_http_${status}`);
|
||||
if (status === 429) return new OneApiRuntimeError("gateway_balance_insufficient", "upstream_http_429");
|
||||
if (status >= 500) return new OneApiRuntimeError("upstream_failed", `upstream_http_${status}`);
|
||||
if (status === 400 || status === 404 || status === 422) return new OneApiRuntimeError("gateway_contract_invalid", `upstream_http_${status}`);
|
||||
return new OneApiRuntimeError("unknown_non_retryable", `upstream_http_${status}`);
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response) {
|
||||
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) {
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
|
||||
}
|
||||
if (!response.body) throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_empty");
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
const chunk = Buffer.from(next.value);
|
||||
total += chunk.length;
|
||||
if (total > maximumResponseBytes) {
|
||||
await reader.cancel();
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||
} catch {
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_invalid");
|
||||
}
|
||||
} finally {
|
||||
for (const chunk of chunks) chunk.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function extractGeminiImage(response: unknown) {
|
||||
if (!response || typeof response !== "object" || !("choices" in response) || !Array.isArray(response.choices)) {
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "response_shape_invalid");
|
||||
}
|
||||
const choice = response.choices[0];
|
||||
const content = choice && typeof choice === "object" && "message" in choice && choice.message && typeof choice.message === "object"
|
||||
&& "content" in choice.message && typeof choice.message.content === "string" ? choice.message.content : "";
|
||||
const matches = [...content.matchAll(/!\[[^\]]*\]\(\s*data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\r\n]+)\s*\)/gi)];
|
||||
if (matches.length !== 1) throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
|
||||
return { bytes: Buffer.from(matches[0]![2]!, "base64"), declaredMimeType: matches[0]![1]!.toLowerCase() };
|
||||
}
|
||||
|
||||
function extractGptImage(response: unknown) {
|
||||
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data)
|
||||
|| response.data.length !== 1 || !response.data[0] || typeof response.data[0] !== "object"
|
||||
|| !("b64_json" in response.data[0]) || typeof response.data[0].b64_json !== "string") {
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
|
||||
}
|
||||
return { bytes: Buffer.from(response.data[0].b64_json, "base64"), declaredMimeType: undefined };
|
||||
}
|
||||
|
||||
async function normalizeOutput(bytes: Buffer, declaredMimeType: string | undefined, ratio: GenerationAdapterRequest["ratio"]): Promise<NormalizedGenerationOutput> {
|
||||
try {
|
||||
const metadata = await sharp(bytes, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
|
||||
const mimeType = metadata.format === "png" ? "image/png" : metadata.format === "jpeg" ? "image/jpeg" : metadata.format === "webp" ? "image/webp" : undefined;
|
||||
if (!mimeType || !metadata.width || !metadata.height || (declaredMimeType && declaredMimeType !== mimeType)) {
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
|
||||
}
|
||||
const normalized = await normalizeImageOutputToRatio({ bytes, mimeType, pixelHeight: metadata.height, pixelWidth: metadata.width, ratio });
|
||||
return { bytes: normalized.bytes, mimeType: normalized.mimeType, pixelHeight: normalized.pixelHeight, pixelWidth: normalized.pixelWidth };
|
||||
} catch (error) {
|
||||
if (error instanceof OneApiRuntimeError) throw error;
|
||||
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequest(request: GenerationAdapterRequest) {
|
||||
if (!request.prompt.trim() || request.prompt.length > 1_000) throw new OneApiRuntimeError("gateway_contract_invalid", "prompt_invalid");
|
||||
const references = request.referenceImages ?? [];
|
||||
if (references.length !== request.referenceAssetIds.length || references.length > 2) {
|
||||
throw new OneApiRuntimeError("reference_invalid", "reference_count_invalid");
|
||||
}
|
||||
const totalBytes = references.reduce((total, reference) => total + reference.bytes.length, 0);
|
||||
if (totalBytes > 20 * 1024 * 1024 || references.some((reference) => reference.bytes.length === 0 || reference.bytes.length > 10 * 1024 * 1024)) {
|
||||
throw new OneApiRuntimeError("reference_invalid", "reference_size_invalid");
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function buildRequest(request: GenerationAdapterRequest) {
|
||||
const references = validateRequest(request);
|
||||
if (request.modelId === geminiProductModelId) {
|
||||
const content = references.length === 0
|
||||
? request.prompt
|
||||
: [
|
||||
{ text: request.prompt, type: "text" },
|
||||
...references.map((reference) => ({
|
||||
image_url: { url: `data:${reference.mimeType};base64,${reference.bytes.toString("base64")}` },
|
||||
type: "image_url",
|
||||
})),
|
||||
];
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||
messages: [{ content, role: "user" }],
|
||||
model: geminiProviderModelId,
|
||||
stream: false,
|
||||
}),
|
||||
contentType: "application/json",
|
||||
endpoint: geminiEndpoint,
|
||||
parser: extractGeminiImage,
|
||||
};
|
||||
}
|
||||
if (request.modelId !== gptImageModelId) throw new OneApiRuntimeError("model_disabled", "model_not_supported");
|
||||
if (references.length > 0) {
|
||||
const form = new FormData();
|
||||
form.append("model", gptImageModelId);
|
||||
form.append("prompt", request.prompt);
|
||||
form.append("response_format", "b64_json");
|
||||
form.append("size", gptImageRequestSizeForRatio(request.ratio));
|
||||
references.forEach((reference, index) => form.append("image[]", new Blob([reference.bytes], { type: reference.mimeType }), `reference-${index + 1}.png`));
|
||||
return { body: form, contentType: undefined, endpoint: gptImageReferenceEndpoint, parser: extractGptImage };
|
||||
}
|
||||
return {
|
||||
body: JSON.stringify({ model: gptImageModelId, prompt: request.prompt, response_format: "b64_json", size: gptImageRequestSizeForRatio(request.ratio) }),
|
||||
contentType: "application/json",
|
||||
endpoint: gptImageEndpoint,
|
||||
parser: extractGptImage,
|
||||
};
|
||||
}
|
||||
|
||||
export class OneApiGenerationAdapter implements GenerationAdapter {
|
||||
private readonly credential: Buffer;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private disposed = false;
|
||||
|
||||
constructor(input: { credential: Buffer; fetch?: FetchLike }) {
|
||||
if (input.credential.length < 8) throw new Error("ai_gateway_credential_invalid");
|
||||
this.credential = Buffer.from(input.credential);
|
||||
this.fetchImpl = input.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
|
||||
if (this.disposed) return { category: "upstream_failed", sourceCategory: "adapter_disposed", status: "failed" };
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMilliseconds);
|
||||
let sourceBytes: Buffer | undefined;
|
||||
try {
|
||||
const providerRequest = buildRequest(request);
|
||||
const headers = new Headers({ authorization: `Bearer ${this.credential.toString("utf8")}` });
|
||||
if (providerRequest.contentType) headers.set("content-type", providerRequest.contentType);
|
||||
const response = await this.fetchImpl(providerRequest.endpoint, {
|
||||
body: providerRequest.body,
|
||||
headers,
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw mapHttpFailure(response.status);
|
||||
const parsed = await readBoundedJson(response);
|
||||
const extracted = providerRequest.parser(parsed);
|
||||
sourceBytes = extracted.bytes;
|
||||
const output = await normalizeOutput(sourceBytes, extracted.declaredMimeType, request.ratio);
|
||||
return { outputs: [output], status: "completed" };
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
sourceBytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.credential.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,13 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
||||
}
|
||||
|
||||
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
|
||||
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
||||
const value = credentials["Dada/P0A/worker/ai-gateway"];
|
||||
try {
|
||||
if (!value) throw new Error("worker_ai_gateway_not_configured");
|
||||
return { aiGatewayCredential: Buffer.from(value, "utf8") };
|
||||
} finally {
|
||||
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
||||
}
|
||||
}
|
||||
|
||||
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { parentPort } from "node:worker_threads";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||
import { MockGenerationAdapter } from "./ai-adapter-contract.js";
|
||||
import { GenerationProcessor } from "./generation-processor.js";
|
||||
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||
import { RetentionCleanup } from "./retention-cleanup.js";
|
||||
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
||||
@@ -24,7 +24,7 @@ if (workerPort) {
|
||||
}
|
||||
|
||||
if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||
initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
||||
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
||||
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||
@@ -51,15 +51,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||
storage = new WorkerStorageStatus(databasePath);
|
||||
retention = new RetentionCleanup({ databasePath });
|
||||
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
||||
processor = new GenerationProcessor({
|
||||
adapter: new MockGenerationAdapter({
|
||||
status: "completed",
|
||||
outputs: [{ bytes: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), mimeType: "image/png", pixelWidth: 1080, pixelHeight: 1440 }],
|
||||
}),
|
||||
dataRoot,
|
||||
databasePath,
|
||||
workerId: `portable-mock-worker-${process.pid}`,
|
||||
});
|
||||
let adapter: OneApiGenerationAdapter;
|
||||
try {
|
||||
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
|
||||
} finally {
|
||||
credentialClient.aiGatewayCredential.fill(0);
|
||||
}
|
||||
processor = new GenerationProcessor({ adapter, dataRoot, databasePath, workerId: `portable-oneapi-worker-${process.pid}` });
|
||||
const runRetentionCleanup = () => {
|
||||
try {
|
||||
retention?.purgeExpired();
|
||||
|
||||
Reference in New Issue
Block a user