feat: complete TASK-WP3-03 adapters and contract evidence
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { ModelConfigurationService, modelIds, type ModelConfigCandidate } from "./model-configuration.js";
|
||||
|
||||
const safeRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||
const requiredRatios = ["3:4", "1:1", "4:3", "9:16"];
|
||||
const requiredAdapterErrors = [
|
||||
"upstream_timeout", "upstream_failed", "safety_rejected", "gateway_balance_insufficient",
|
||||
"gateway_contract_invalid", "reference_invalid", "unknown_retryable", "unknown_non_retryable",
|
||||
];
|
||||
const forbiddenKeyFragments = ["credential", "password", "prompt_text", "raw", "secret", "token"];
|
||||
|
||||
export interface ModelContractEvidenceInput {
|
||||
evidence_hash: string;
|
||||
evidence_ref: string;
|
||||
matrix: unknown;
|
||||
model_id: string;
|
||||
verified_at: string;
|
||||
verifier_ref: string;
|
||||
}
|
||||
|
||||
function hasForbiddenEvidenceKey(value: unknown, depth = 0): boolean {
|
||||
if (depth > 8 || !value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some((entry) => hasForbiddenEvidenceKey(entry, depth + 1));
|
||||
return Object.entries(value).some(([key, entry]) => (
|
||||
forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||
|| hasForbiddenEvidenceKey(entry, depth + 1)
|
||||
));
|
||||
}
|
||||
|
||||
function validateEvidence(input: ModelContractEvidenceInput) {
|
||||
if (!modelIds.includes(input.model_id as typeof modelIds[number])) throw new Error("contract_evidence_model_invalid");
|
||||
if (!safeRefPattern.test(input.evidence_hash) || !safeRefPattern.test(input.evidence_ref) || !safeRefPattern.test(input.verifier_ref)) {
|
||||
throw new Error("contract_evidence_reference_invalid");
|
||||
}
|
||||
const verifiedAt = Date.parse(input.verified_at);
|
||||
if (!Number.isFinite(verifiedAt) || new Date(verifiedAt).toISOString() !== input.verified_at) throw new Error("contract_evidence_time_invalid");
|
||||
if (hasForbiddenEvidenceKey(input.matrix)) throw new Error("contract_evidence_sensitive_field");
|
||||
if (!input.matrix || typeof input.matrix !== "object") throw new Error("contract_evidence_matrix_incomplete");
|
||||
const matrix = input.matrix as Record<string, unknown>;
|
||||
const passedSingleOutput = (value: unknown) => Boolean(value && typeof value === "object"
|
||||
&& "status" in value && value.status === "passed" && "outputs" in value && value.outputs === 1);
|
||||
const ratios = Array.isArray(matrix.ratios) ? matrix.ratios as Array<Record<string, unknown>> : [];
|
||||
const ratioNames = ratios.filter(passedSingleOutput).map((entry) => entry.ratio).toSorted();
|
||||
const executionModes = Array.isArray(matrix.execution_modes) ? matrix.execution_modes : [];
|
||||
const errorMapping = Array.isArray(matrix.error_mapping) ? matrix.error_mapping : [];
|
||||
const executionComplete = executionModes.includes("sync") || (executionModes.includes("async") && executionModes.includes("poll"));
|
||||
if (matrix.model_id !== input.model_id || !passedSingleOutput(matrix.pure_text) || !passedSingleOutput(matrix.reference_image)
|
||||
|| JSON.stringify(ratioNames) !== JSON.stringify([...requiredRatios].toSorted()) || !executionComplete
|
||||
|| !requiredAdapterErrors.every((category) => errorMapping.includes(category))) {
|
||||
throw new Error("contract_evidence_matrix_incomplete");
|
||||
}
|
||||
return verifiedAt;
|
||||
}
|
||||
|
||||
function editableCandidates(models: ReturnType<ModelConfigurationService["read"]>["models"]): ModelConfigCandidate[] {
|
||||
return models.map(({ config_version: _configVersion, runtime_availability: _runtime, ...candidate }) => structuredClone(candidate));
|
||||
}
|
||||
|
||||
export class ModelContractEvidenceService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
private readonly models: ModelConfigurationService;
|
||||
|
||||
constructor(input: { clock?: () => number; database: BetterSqlite3.Database; models: ModelConfigurationService }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.database = input.database;
|
||||
this.models = input.models;
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
recordVerified(input: {
|
||||
actorId: string;
|
||||
evidence: ModelContractEvidenceInput;
|
||||
expectedConfigSetVersion: number;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
const verifiedAt = validateEvidence(input.evidence);
|
||||
return this.database.transaction(() => {
|
||||
const current = this.models.read();
|
||||
if (current.config_set_version !== input.expectedConfigSetVersion) throw new Error("contract_evidence_config_set_conflict");
|
||||
const existingHash = this.database.prepare("SELECT model_id FROM model_contract_evidence WHERE evidence_hash = ?").get(input.evidence.evidence_hash) as { model_id: string } | undefined;
|
||||
if (existingHash && existingHash.model_id !== input.evidence.model_id) throw new Error("contract_evidence_shared_between_models");
|
||||
const candidates = editableCandidates(current.models);
|
||||
const target = candidates.find((model) => model.model_id === input.evidence.model_id)!;
|
||||
target.contract_validation_status = "verified";
|
||||
target.contract_evidence_ref = input.evidence.evidence_ref;
|
||||
const configuration = this.models.replace({
|
||||
actorId: input.actorId,
|
||||
expectedConfigSetVersion: input.expectedConfigSetVersion,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
models: candidates,
|
||||
});
|
||||
const model = configuration.models.find((candidate) => candidate.model_id === input.evidence.model_id)!;
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO model_contract_evidence (
|
||||
model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.evidence.model_id, model.config_version, input.evidence.evidence_hash, input.evidence.evidence_ref,
|
||||
input.evidence.verifier_ref, verifiedAt, JSON.stringify(input.evidence.matrix), this.clock(),
|
||||
);
|
||||
return { configuration, evidence: structuredClone(input.evidence), model };
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
read(modelId: string, configVersion: number) {
|
||||
return this.database.prepare(`
|
||||
SELECT model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json
|
||||
FROM model_contract_evidence WHERE model_id = ? AND config_version = ?
|
||||
`).get(modelId, configVersion) as {
|
||||
config_version: number; evidence_hash: string; evidence_json: string; evidence_ref: string;
|
||||
model_id: string; verified_at: number; verifier_ref: string;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS model_contract_evidence (
|
||||
model_id TEXT NOT NULL,
|
||||
config_version INTEGER NOT NULL,
|
||||
evidence_hash TEXT NOT NULL UNIQUE,
|
||||
evidence_ref TEXT NOT NULL,
|
||||
verifier_ref TEXT NOT NULL,
|
||||
verified_at INTEGER NOT NULL,
|
||||
evidence_json TEXT NOT NULL CHECK (json_valid(evidence_json)),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (model_id, config_version),
|
||||
FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version)
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_update BEFORE UPDATE ON model_contract_evidence
|
||||
BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_delete BEFORE DELETE ON model_contract_evidence
|
||||
BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import {
|
||||
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||
type ModelAdapter, supportedAdapterRatios,
|
||||
} from "./model-adapter.js";
|
||||
|
||||
const expectedContract = {
|
||||
endpoint: "https://mock.invalid/gemini/flash",
|
||||
fixture_id: "gemini-flash-mock-v1",
|
||||
mode: "sync",
|
||||
protocol_version: "gemini-flash-v1",
|
||||
response_shape: "candidates.inline_data",
|
||||
supports_reference_images: true,
|
||||
supported_ratios: [...supportedAdapterRatios],
|
||||
};
|
||||
|
||||
function defaultTransport(): AdapterTransport {
|
||||
return {
|
||||
async start(request) {
|
||||
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||
return { candidates: [{ inline_data: { data: mockPngBytes("flash").toString("base64"), mime_type: "image/png" }, ...dimensions }] };
|
||||
},
|
||||
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||
};
|
||||
}
|
||||
|
||||
export class GeminiFlashAdapter implements ModelAdapter {
|
||||
readonly contractVersion = "gemini-flash-v1";
|
||||
readonly fixtureId = "gemini-flash-mock-v1";
|
||||
readonly modelId = "gemini-3.1-flash-image-preview";
|
||||
private readonly transport: AdapterTransport;
|
||||
|
||||
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||
try {
|
||||
validateAdapterRequest(request, this.modelId);
|
||||
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||
if (response && typeof response === "object" && "status" in response && response.status === "failed") {
|
||||
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
return { outputs: [this.normalizeOutput(response)], status: "completed" };
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||
void upstreamJobReference;
|
||||
return { category: "gateway_contract_invalid", sourceCategory: "unexpected_poll", status: "failed" };
|
||||
}
|
||||
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||
if (!response || typeof response !== "object" || !("candidates" in response) || !Array.isArray(response.candidates) || response.candidates.length !== 1) {
|
||||
throw new AdapterContractError("response_single_image_required");
|
||||
}
|
||||
const candidate = response.candidates[0];
|
||||
if (!candidate || typeof candidate !== "object" || !("inline_data" in candidate) || !candidate.inline_data || typeof candidate.inline_data !== "object") {
|
||||
throw new AdapterContractError("response_shape_invalid");
|
||||
}
|
||||
const data = "data" in candidate.inline_data && typeof candidate.inline_data.data === "string" ? Buffer.from(candidate.inline_data.data, "base64") : undefined;
|
||||
const mimeType = "mime_type" in candidate.inline_data && candidate.inline_data.mime_type === "image/png" ? "image/png" : undefined;
|
||||
const pixelHeight = "pixelHeight" in candidate && typeof candidate.pixelHeight === "number" ? candidate.pixelHeight : undefined;
|
||||
const pixelWidth = "pixelWidth" in candidate && typeof candidate.pixelWidth === "number" ? candidate.pixelWidth : undefined;
|
||||
if (!data || !data.length || !mimeType || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||
return { bytes: data, mimeType, pixelHeight, pixelWidth };
|
||||
}
|
||||
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>) {
|
||||
return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion);
|
||||
}
|
||||
}
|
||||
|
||||
export { expectedContract as geminiFlashMockContract };
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import {
|
||||
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||
type ModelAdapter, supportedAdapterRatios,
|
||||
} from "./model-adapter.js";
|
||||
|
||||
const expectedContract = {
|
||||
endpoint: "https://mock.invalid/gemini/pro",
|
||||
fixture_id: "gemini-pro-mock-v1",
|
||||
mode: "async",
|
||||
protocol_version: "gemini-pro-v1",
|
||||
response_shape: "operation.poll.candidates",
|
||||
supports_reference_images: true,
|
||||
supported_ratios: [...supportedAdapterRatios],
|
||||
};
|
||||
|
||||
function defaultTransport(): AdapterTransport {
|
||||
return {
|
||||
async start(request) {
|
||||
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||
return { operation: { done: true, response: { candidates: [{ inline_data: { data: mockPngBytes("pro").toString("base64"), mime_type: "image/png" }, ...dimensions }] } } };
|
||||
},
|
||||
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||
};
|
||||
}
|
||||
|
||||
export class GeminiProAdapter implements ModelAdapter {
|
||||
readonly contractVersion = "gemini-pro-v1";
|
||||
readonly fixtureId = "gemini-pro-mock-v1";
|
||||
readonly modelId = "gemini-3-pro-image-preview";
|
||||
private readonly transport: AdapterTransport;
|
||||
|
||||
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||
try {
|
||||
validateAdapterRequest(request, this.modelId);
|
||||
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||
return this.interpret(response, request.configSnapshot.error_mapping_profile as Record<string, string> ?? {});
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||
try {
|
||||
const response = await this.transport.poll({ operation: "poll", modelId: this.modelId, upstreamJobReference });
|
||||
return this.interpret(response, {});
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
private interpret(response: unknown, mappingProfile: Readonly<Record<string, string>>): AdapterStartResult {
|
||||
if (!response || typeof response !== "object" || !("operation" in response) || !response.operation || typeof response.operation !== "object") {
|
||||
return { category: "gateway_contract_invalid", sourceCategory: "response_shape_invalid", status: "failed" };
|
||||
}
|
||||
if ("status" in response && response.status === "failed") {
|
||||
const classified = this.classifyError("error" in response ? response.error : undefined, mappingProfile);
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
const operation = response.operation;
|
||||
if ("done" in operation && operation.done === false) {
|
||||
const reference = "name" in operation && typeof operation.name === "string" ? operation.name : undefined;
|
||||
return reference ? { status: "pending", upstreamJobReference: reference } : { category: "gateway_contract_invalid", sourceCategory: "upstream_reference_missing", status: "failed" };
|
||||
}
|
||||
try {
|
||||
return { outputs: [this.normalizeOutput("response" in operation ? operation.response : undefined)], status: "completed" };
|
||||
} catch (error) {
|
||||
return { category: "gateway_contract_invalid", sourceCategory: error instanceof AdapterContractError ? error.sourceCategory : "response_shape_invalid", status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||
if (!response || typeof response !== "object" || !("candidates" in response) || !Array.isArray(response.candidates) || response.candidates.length !== 1) throw new AdapterContractError("response_single_image_required");
|
||||
const candidate = response.candidates[0];
|
||||
if (!candidate || typeof candidate !== "object" || !("inline_data" in candidate) || !candidate.inline_data || typeof candidate.inline_data !== "object") throw new AdapterContractError("response_shape_invalid");
|
||||
const data = "data" in candidate.inline_data && typeof candidate.inline_data.data === "string" ? Buffer.from(candidate.inline_data.data, "base64") : undefined;
|
||||
const mimeType = "mime_type" in candidate.inline_data && candidate.inline_data.mime_type === "image/png" ? "image/png" : undefined;
|
||||
const pixelHeight = "pixelHeight" in candidate && typeof candidate.pixelHeight === "number" ? candidate.pixelHeight : undefined;
|
||||
const pixelWidth = "pixelWidth" in candidate && typeof candidate.pixelWidth === "number" ? candidate.pixelWidth : undefined;
|
||||
if (!data || !data.length || !mimeType || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||
return { bytes: data, mimeType, pixelHeight, pixelWidth };
|
||||
}
|
||||
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>) { return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion); }
|
||||
}
|
||||
|
||||
export { expectedContract as geminiProMockContract };
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import {
|
||||
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||
type ModelAdapter, supportedAdapterRatios,
|
||||
} from "./model-adapter.js";
|
||||
|
||||
const expectedContract = {
|
||||
endpoint: "https://mock.invalid/openai/images",
|
||||
fixture_id: "gpt-image-mock-v1",
|
||||
mode: "sync",
|
||||
protocol_version: "gpt-image-v1",
|
||||
response_shape: "data.b64_json",
|
||||
supports_reference_images: true,
|
||||
supported_ratios: [...supportedAdapterRatios],
|
||||
};
|
||||
|
||||
function defaultTransport(): AdapterTransport {
|
||||
return {
|
||||
async start(request) {
|
||||
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||
return { data: [{ b64_json: mockPngBytes("gpt-image").toString("base64"), ...dimensions }] };
|
||||
},
|
||||
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||
};
|
||||
}
|
||||
|
||||
export class GptImageAdapter implements ModelAdapter {
|
||||
readonly contractVersion = "gpt-image-v1";
|
||||
readonly fixtureId = "gpt-image-mock-v1";
|
||||
readonly modelId = "gpt-image-2";
|
||||
private readonly transport: AdapterTransport;
|
||||
|
||||
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||
try {
|
||||
validateAdapterRequest(request, this.modelId);
|
||||
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||
if (response && typeof response === "object" && "status" in response && response.status === "failed") {
|
||||
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
return { outputs: [this.normalizeOutput(response)], status: "completed" };
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||
void upstreamJobReference;
|
||||
return { category: "gateway_contract_invalid", sourceCategory: "unexpected_poll", status: "failed" };
|
||||
}
|
||||
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data) || response.data.length !== 1) throw new AdapterContractError("response_single_image_required");
|
||||
const image = response.data[0];
|
||||
if (!image || typeof image !== "object") throw new AdapterContractError("response_shape_invalid");
|
||||
const data = "b64_json" in image && typeof image.b64_json === "string" ? Buffer.from(image.b64_json, "base64") : undefined;
|
||||
const pixelHeight = "pixelHeight" in image && typeof image.pixelHeight === "number" ? image.pixelHeight : undefined;
|
||||
const pixelWidth = "pixelWidth" in image && typeof image.pixelWidth === "number" ? image.pixelWidth : undefined;
|
||||
if (!data || !data.length || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||
return { bytes: data, mimeType: "image/png", pixelHeight, pixelWidth };
|
||||
}
|
||||
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>) { return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion); }
|
||||
}
|
||||
|
||||
export { expectedContract as gptImageMockContract };
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import type { GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
|
||||
export type AdapterStartResult =
|
||||
| { outputs: readonly NormalizedGenerationOutput[]; status: "completed" }
|
||||
| { status: "pending"; upstreamJobReference: string }
|
||||
| { category: GenerationErrorCategory; sourceCategory: string; status: "failed" };
|
||||
|
||||
export interface AdapterTransportRequest {
|
||||
operation: "start" | "poll";
|
||||
modelId: string;
|
||||
prompt?: string;
|
||||
ratio?: GenerationAdapterRequest["ratio"];
|
||||
referenceAssetIds?: readonly string[];
|
||||
upstreamJobReference?: string;
|
||||
}
|
||||
|
||||
export interface AdapterTransport {
|
||||
start(request: AdapterTransportRequest): Promise<unknown>;
|
||||
poll(request: AdapterTransportRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ContractValidationResult {
|
||||
contractVersion: string;
|
||||
evidenceRef: string;
|
||||
fixtureId: string;
|
||||
modelId: string;
|
||||
reason?: "contract_profile_mismatch";
|
||||
status: "blocked" | "verified";
|
||||
}
|
||||
|
||||
export interface AdapterBalanceSignal {
|
||||
gatewayAccountRef: string;
|
||||
impactScope: "model" | "account" | "unknown";
|
||||
}
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly contractVersion: string;
|
||||
readonly fixtureId: string;
|
||||
readonly modelId: string;
|
||||
checkBalanceSignal(response: unknown): AdapterBalanceSignal | undefined;
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>): { category: GenerationErrorCategory; sourceCategory: string };
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput;
|
||||
poll(upstreamJobReference: string): Promise<AdapterStartResult>;
|
||||
start(request: GenerationAdapterRequest): Promise<AdapterStartResult>;
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>): ContractValidationResult;
|
||||
}
|
||||
|
||||
export class AdapterContractError extends Error {
|
||||
constructor(readonly sourceCategory: string, message = sourceCategory) {
|
||||
super(message);
|
||||
this.name = "AdapterContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export const supportedAdapterRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
|
||||
|
||||
export function dimensionsForRatio(ratio: GenerationAdapterRequest["ratio"]) {
|
||||
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||
}
|
||||
|
||||
export function mockPngBytes(seed: string) {
|
||||
const body = Buffer.from(`DADA-MOCK-${seed}`, "ascii");
|
||||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), body]);
|
||||
}
|
||||
|
||||
export function validateAdapterRequest(request: GenerationAdapterRequest, modelId: string) {
|
||||
if (request.modelId !== modelId) throw new AdapterContractError("model_id_mismatch");
|
||||
if (!supportedAdapterRatios.includes(request.ratio)) throw new AdapterContractError("ratio_unsupported");
|
||||
if (request.referenceAssetIds.length > 2) throw new AdapterContractError("reference_count_exceeded");
|
||||
if (!request.prompt.trim()) throw new AdapterContractError("prompt_empty");
|
||||
}
|
||||
|
||||
const errorCategories = new Set<GenerationErrorCategory>([
|
||||
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
|
||||
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
|
||||
"unknown_retryable", "unknown_non_retryable",
|
||||
]);
|
||||
|
||||
export function classifyAdapterError(error: unknown, mappingProfile: Readonly<Record<string, string>>) {
|
||||
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "unknown";
|
||||
const mapped = mappingProfile[code] ?? (code === "timeout" ? "upstream_timeout" : code === "safety" ? "safety_rejected" : "unknown_retryable");
|
||||
const category = errorCategories.has(mapped as GenerationErrorCategory) ? mapped as GenerationErrorCategory : "unknown_retryable";
|
||||
const sourceCategory = /^[a-z][a-z0-9_]{0,79}$/.test(code) ? code : "adapter_error";
|
||||
return { category, sourceCategory };
|
||||
}
|
||||
|
||||
export function balanceSignalFromResponse(response: unknown): AdapterBalanceSignal | undefined {
|
||||
if (!response || typeof response !== "object" || !("balance_signal" in response)) return undefined;
|
||||
const signal = response.balance_signal;
|
||||
if (!signal || typeof signal !== "object") return undefined;
|
||||
const gatewayAccountRef = "gateway_account_ref" in signal && typeof signal.gateway_account_ref === "string" ? signal.gateway_account_ref : "";
|
||||
const impactScope = "impact_scope" in signal && (signal.impact_scope === "model" || signal.impact_scope === "account" || signal.impact_scope === "unknown")
|
||||
? signal.impact_scope : undefined;
|
||||
return gatewayAccountRef && impactScope ? { gatewayAccountRef, impactScope } : undefined;
|
||||
}
|
||||
|
||||
export function validateMockContract(
|
||||
routeProfile: Readonly<Record<string, unknown>>,
|
||||
expected: Readonly<Record<string, unknown>>,
|
||||
modelId: string,
|
||||
fixtureId: string,
|
||||
contractVersion: string,
|
||||
): ContractValidationResult {
|
||||
const matches = Object.entries(expected).every(([key, value]) => JSON.stringify(routeProfile[key]) === JSON.stringify(value));
|
||||
return matches
|
||||
? { contractVersion, evidenceRef: `${fixtureId}:mock`, fixtureId, modelId, status: "verified" }
|
||||
: { contractVersion, evidenceRef: `${fixtureId}:mock`, fixtureId, modelId, reason: "contract_profile_mismatch", status: "blocked" };
|
||||
}
|
||||
Reference in New Issue
Block a user