113 lines
5.2 KiB
TypeScript
113 lines
5.2 KiB
TypeScript
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" };
|
|
}
|