From cbb7f658a33e7333d0fe2d5487776ad2a0f5743f Mon Sep 17 00:00:00 2001 From: suyx Date: Wed, 5 Aug 2026 12:29:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(POSTV1-02):=20=E6=8E=A5=E9=80=9A=E7=9C=9F?= =?UTF-8?q?=E5=AE=9EAI=E7=94=9F=E6=88=90=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/main.ts | 4 +- apps/api/src/model-configuration.ts | 55 ++++- apps/worker/src/ai-adapter-contract.ts | 6 + apps/worker/src/generation-processor.ts | 39 +++- apps/worker/src/oneapi-generation-adapter.ts | 217 +++++++++++++++++++ apps/worker/src/supervisor-channel.ts | 8 +- apps/worker/src/worker.ts | 20 +- tests/api/postv1-runtime-models.test.ts | 44 ++++ tests/package/postv1-runtime.test.mjs | 5 +- tests/worker/postv1-oneapi-runtime.test.ts | 66 ++++++ 10 files changed, 440 insertions(+), 24 deletions(-) create mode 100644 apps/worker/src/oneapi-generation-adapter.ts create mode 100644 tests/api/postv1-runtime-models.test.ts create mode 100644 tests/worker/postv1-oneapi-runtime.test.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 8975c21..63add52 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -16,7 +16,7 @@ import { MockResendAdapter } from "./resend-adapter.js"; import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; -import { ModelConfigurationService } from "./model-configuration.js"; +import { ModelConfigurationService, portableRuntimeModelCandidates } from "./model-configuration.js"; import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js"; import { StickerReleaseService } from "./sticker-releases.js"; import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js"; @@ -56,7 +56,7 @@ if (credentialChannelEnabled) { storage = new ManagedStorage({ dataRoot, databasePath }); stickers = new StickerReleaseService({ databasePath, storage }); latestExports = new LatestExportService({ databasePath, storage }); - models = new ModelConfigurationService({ database: registration.database }); + models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates }); recentAssets = new RecentAssetService({ database: registration.database }); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); } catch (error) { diff --git a/apps/api/src/model-configuration.ts b/apps/api/src/model-configuration.ts index f8ff23e..c539ca8 100644 --- a/apps/api/src/model-configuration.ts +++ b/apps/api/src/model-configuration.ts @@ -111,7 +111,7 @@ const defaultErrorMapping: Record = { upstream_timeout: "upstream_timeout", }; -const seedCandidates: ModelConfigCandidate[] = [ +const defaultSeedCandidates: ModelConfigCandidate[] = [ { model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true, recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" }, @@ -138,6 +138,42 @@ const seedCandidates: ModelConfigCandidate[] = [ }, ]; +export const portableRuntimeModelCandidates: ModelConfigCandidate[] = [ + { + ...defaultSeedCandidates[0]!, + display_name: "Gemini 3.1 Flash Image", + route_profile: { + endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions", + mode: "sync", + protocol_version: "gemini-openai-chat-v1", + provider_model_id: "gemini-3.1-flash-image", + }, + gateway_account_ref: "oneapi-intelligrow-test", + contract_validation_status: "verified", + contract_evidence_ref: "contract:wp7-02:gemini-3.1-flash-image:v7", + }, + { + ...defaultSeedCandidates[1]!, + enabled: false, + route_profile: { endpoint: "https://oneapi.intelligrow.cn/unsupported", mode: "disabled" }, + gateway_account_ref: "oneapi-intelligrow-test", + contract_validation_status: "unverified", + contract_evidence_ref: null, + }, + { + ...defaultSeedCandidates[2]!, + route_profile: { + endpoint: "https://oneapi.intelligrow.cn/v1/images/generations", + mode: "sync", + protocol_version: "openai-images-v1", + reference_endpoint: "https://oneapi.intelligrow.cn/v1/images/edits", + }, + gateway_account_ref: "oneapi-intelligrow-test", + contract_validation_status: "verified", + contract_evidence_ref: "contract:wp7-02:gpt-image-2:v2", + }, +]; + function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { @@ -207,17 +243,20 @@ export interface ModelConfigurationServiceOptions { clock?: () => number; database: BetterSqlite3.Database; onChanged?: (configSetVersion: number) => void; + seedCandidates?: ModelConfigCandidate[]; } export class ModelConfigurationService { readonly database: BetterSqlite3.Database; readonly #clock: () => number; readonly #onChanged: ((configSetVersion: number) => void) | undefined; + readonly #seedCandidates: ModelConfigCandidate[]; constructor(options: ModelConfigurationServiceOptions) { this.database = options.database; this.#clock = options.clock ?? Date.now; this.#onChanged = options.onChanged; + this.#seedCandidates = structuredClone(options.seedCandidates ?? defaultSeedCandidates); this.ensureSchema(); } @@ -503,7 +542,7 @@ export class ModelConfigurationService { const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined; if (current) return; const seed = this.database.transaction(() => { - validateModelConfigurationCandidateSet(seedCandidates); + validateModelConfigurationCandidateSet(this.#seedCandidates); const now = this.#clock(); const setId = randomUUID(); this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')") @@ -520,7 +559,9 @@ export class ModelConfigurationService { INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority) VALUES (?, ?, 1, ?, ?, ?) `); - for (const candidate of seedCandidates) { + for (const candidate of this.#seedCandidates) { + const contractStatus = candidate.contract_validation_status ?? "unverified"; + const contractEvidenceRef = contractStatus === "unverified" ? null : candidate.contract_evidence_ref ?? null; const routeProfileId = profileRef("route", candidate.route_profile); const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile); this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)") @@ -530,12 +571,14 @@ export class ModelConfigurationService { insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref, errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits), - candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now); + candidate.prompt_max_length, candidate.safety_source, contractStatus, contractEvidenceRef, fingerprint(candidate), now); insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority); + const available = candidate.enabled && contractStatus === "verified"; + const runtimeReason = !candidate.enabled ? "configured_disabled" : available ? "available" : "contract_unverified"; this.database.prepare(` INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version) - VALUES (?, 0, 'contract_unverified', ?, 0) - `).run(candidate.model_id, now); + VALUES (?, ?, ?, ?, 0) + `).run(candidate.model_id, available ? 1 : 0, runtimeReason, now); } this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId); }); diff --git a/apps/worker/src/ai-adapter-contract.ts b/apps/worker/src/ai-adapter-contract.ts index c95bbba..73fa61b 100644 --- a/apps/worker/src/ai-adapter-contract.ts +++ b/apps/worker/src/ai-adapter-contract.ts @@ -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; poll?(upstreamJobReference: string): Promise; } diff --git a/apps/worker/src/generation-processor.ts b/apps/worker/src/generation-processor.ts index 91844fa..4582b1d 100644 --- a/apps/worker/src/generation-processor.ts +++ b/apps/worker/src/generation-processor.ts @@ -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; + 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 { + 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); diff --git a/apps/worker/src/oneapi-generation-adapter.ts b/apps/worker/src/oneapi-generation-adapter.ts new file mode 100644 index 0000000..ce97230 --- /dev/null +++ b/apps/worker/src/oneapi-generation-adapter.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/worker/src/supervisor-channel.ts b/apps/worker/src/supervisor-channel.ts index b03f1ed..3dd3035 100644 --- a/apps/worker/src/supervisor-channel.ts +++ b/apps/worker/src/supervisor-channel.ts @@ -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) { diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts index 29eada9..91171cd 100644 --- a/apps/worker/src/worker.ts +++ b/apps/worker/src/worker.ts @@ -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(); diff --git a/tests/api/postv1-runtime-models.test.ts b/tests/api/postv1-runtime-models.test.ts new file mode 100644 index 0000000..9fd5208 --- /dev/null +++ b/tests/api/postv1-runtime-models.test.ts @@ -0,0 +1,44 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +import { + ModelConfigurationService, + portableRuntimeModelCandidates, +} from "../../apps/api/src/model-configuration.js"; + +const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url)); +const Database = requireFromApi("better-sqlite3") as new (path: string) => { + close(): void; +}; + +describe("POSTV1-02 portable runtime model seed", () => { + it("enables only models backed by the real OneAPI contract", () => { + const database = new Database(":memory:"); + try { + const models = new ModelConfigurationService({ database, seedCandidates: portableRuntimeModelCandidates }).read(); + const flash = models.models.find((model) => model.model_id === "gemini-3.1-flash-image-preview"); + const pro = models.models.find((model) => model.model_id === "gemini-3-pro-image-preview"); + const gpt = models.models.find((model) => model.model_id === "gpt-image-2"); + + expect(models.configured_default_model_id).toBe("gemini-3.1-flash-image-preview"); + expect(flash).toMatchObject({ + contract_validation_status: "verified", + enabled: true, + runtime_availability: { available_for_new_jobs: true, reason: "available" }, + }); + expect(flash?.route_profile).toMatchObject({ endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions" }); + expect(pro).toMatchObject({ + contract_validation_status: "unverified", + enabled: false, + runtime_availability: { available_for_new_jobs: false, reason: "configured_disabled" }, + }); + expect(gpt).toMatchObject({ + contract_validation_status: "verified", + enabled: true, + runtime_availability: { available_for_new_jobs: true, reason: "available" }, + }); + } finally { + database.close(); + } + }); +}); diff --git a/tests/package/postv1-runtime.test.mjs b/tests/package/postv1-runtime.test.mjs index 6593924..b34cd43 100644 --- a/tests/package/postv1-runtime.test.mjs +++ b/tests/package/postv1-runtime.test.mjs @@ -46,7 +46,10 @@ async function stop(child) { test("portable package serves the product and keeps SQLite data across API restart", async () => { assert.ok(existsSync(join(packageRoot, "Dada.exe"))); assert.ok(existsSync(join(packageRoot, "web", "index.html"))); - assert.match(await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"), /GenerationProcessor/); + const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"); + assert.match(packagedWorker, /GenerationProcessor/); + assert.match(packagedWorker, /oneapi\.intelligrow\.cn/); + assert.doesNotMatch(packagedWorker, /portable-mock-worker/); const root = await mkdtemp(join(tmpdir(), "dada-postv1-")); const dataRoot = join(root, "data"); const configPath = join(root, "instance.json"); diff --git a/tests/worker/postv1-oneapi-runtime.test.ts b/tests/worker/postv1-oneapi-runtime.test.ts new file mode 100644 index 0000000..d9ce1d9 --- /dev/null +++ b/tests/worker/postv1-oneapi-runtime.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { GenerationAdapterRequest } from "../../apps/worker/src/ai-adapter-contract.js"; +import { OneApiGenerationAdapter } from "../../apps/worker/src/oneapi-generation-adapter.js"; + +function request(overrides: Partial = {}): GenerationAdapterRequest { + return { + configSnapshot: {}, + generationId: "00000000-0000-4000-8000-000000000001", + modelId: "gemini-3.1-flash-image-preview", + prompt: "一张用于本机验收的抽象色彩图", + ratio: "1:1", + referenceAssetIds: [], + ...overrides, + }; +} + +describe("POSTV1-02 OneAPI runtime adapter", () => { + it("uses the fixed Gemini gateway and normalizes one real-shaped response", async () => { + const source = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", + ); + const gateway = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token"); + expect(init?.redirect).toBe("error"); + return new Response(JSON.stringify({ + choices: [{ message: { content: `![result](data:image/png;base64,${source.toString("base64")})` } }], + }), { headers: { "content-type": "application/json" }, status: 200 }); + }); + const credential = Buffer.from("synthetic-runtime-token"); + const adapter = new OneApiGenerationAdapter({ credential, fetch: gateway as typeof fetch }); + const result = await adapter.start(request()); + + expect(gateway).toHaveBeenCalledOnce(); + expect(gateway.mock.calls[0]?.[0]).toBe("https://oneapi.intelligrow.cn/v1/chat/completions"); + expect(result.status === "failed" ? result.sourceCategory : "completed").toBe("completed"); + if (result.status === "completed") { + expect(result.outputs).toHaveLength(1); + expect(result.outputs[0]).toMatchObject({ mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 }); + expect(result.outputs[0]?.bytes.length).toBeGreaterThan(0); + } + expect(JSON.stringify(result)).not.toContain("synthetic-runtime-token"); + adapter.dispose(); + credential.fill(0); + }); + + it("fails closed instead of returning a mock image", async () => { + const adapter = new OneApiGenerationAdapter({ + credential: Buffer.from("synthetic-runtime-token"), + fetch: vi.fn(async () => new Response(null, { status: 503 })) as typeof fetch, + }); + await expect(adapter.start(request())).resolves.toEqual({ + category: "upstream_failed", + sourceCategory: "upstream_http_503", + status: "failed", + }); + adapter.dispose(); + await expect(adapter.start(request())).resolves.toEqual({ + category: "upstream_failed", + sourceCategory: "adapter_disposed", + status: "failed", + }); + }); +});