diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 4f3ca41..3429820 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -16,7 +16,12 @@ 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, portableRuntimeModelCandidates } from "./model-configuration.js"; +import { GenerationSubmissionService } from "./generation-submission.js"; +import { + GenerationModelConfigurationCatalog, + 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"; @@ -28,6 +33,7 @@ let credits: CreditService | undefined; let storage: ManagedStorage | undefined; let latestExports: LatestExportService | undefined; let models: ModelConfigurationService | undefined; +let generations: GenerationSubmissionService | undefined; let recentAssets: RecentAssetService | undefined; let stickers: StickerReleaseService | undefined; let amap: AmapAdapter = new MockAmapAdapter(); @@ -59,6 +65,11 @@ if (credentialChannelEnabled) { stickers = new StickerReleaseService({ databasePath, storage }); latestExports = new LatestExportService({ databasePath, storage }); models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates }); + generations = new GenerationSubmissionService({ + credits, + models: new GenerationModelConfigurationCatalog(models), + storage, + }); recentAssets = new RecentAssetService({ database: registration.database }); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); } catch (error) { @@ -68,6 +79,8 @@ if (credentialChannelEnabled) { stickers = undefined; latestExports?.close(); latestExports = undefined; + generations?.close(); + generations = undefined; storage?.close(); storage = undefined; credits?.close(); @@ -102,6 +115,7 @@ const app = await createApp({ amap, ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), + ...(generations ? { generations } : {}), ...(latestExports ? { latestExports } : {}), ...(registration && localTestAuth ? { localTestAuth: true } : {}), ...(models ? { models } : {}), @@ -125,6 +139,7 @@ if (controlPipeIndex >= 0) { await app.close(); amap.dispose?.(); latestExports?.close(); + generations?.close(); credits?.close(); projects?.close(); registration?.close(); diff --git a/apps/api/src/model-configuration.ts b/apps/api/src/model-configuration.ts index c539ca8..3fe2bc5 100644 --- a/apps/api/src/model-configuration.ts +++ b/apps/api/src/model-configuration.ts @@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto"; import type BetterSqlite3 from "better-sqlite3"; import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js"; +import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js"; +import { projectRatios } from "./projects.js"; export const modelIds = [ "gemini-3.1-flash-image-preview", @@ -59,6 +61,45 @@ export interface ModelConfigurationView { models: ModelConfigView[]; } +type ReadableModelConfiguration = Pick; + +function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] { + if (reason === "gateway_balance_insufficient") return reason; + if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid"; + if (reason === "available") return null; + return "model_disabled"; +} + +export class GenerationModelConfigurationCatalog implements GenerationModelCatalog { + constructor(private readonly models: ReadableModelConfiguration) {} + + readModel(modelId: string): GenerationModelSnapshot | undefined { + const configuration = this.models.read(); + const model = configuration.models.find((entry) => entry.model_id === modelId); + if (!model) return undefined; + const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio)); + return { + configSetVersion: configuration.config_set_version, + configVersion: model.config_version, + contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified", + creditCost: model.credit_cost, + enabled: model.enabled, + modelId: model.model_id, + promptMaxLength: model.prompt_max_length, + referenceLimits: { + maxFileBytes: model.reference_limits.max_file_bytes, + maxFiles: model.reference_limits.max_files, + maxTotalBytes: model.reference_limits.max_total_bytes, + }, + runtimeAvailability: { + availableForNewJobs: model.runtime_availability.available_for_new_jobs, + reason: generationRuntimeReason(model.runtime_availability.reason), + }, + supportedRatios, + }; + } +} + export class ModelConfigurationError extends Error { constructor( readonly code: diff --git a/tests/api/postv1-generation-runtime-wiring.test.ts b/tests/api/postv1-generation-runtime-wiring.test.ts new file mode 100644 index 0000000..20d16d4 --- /dev/null +++ b/tests/api/postv1-generation-runtime-wiring.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { GenerationModelConfigurationCatalog, type ModelConfigurationView } from "../../apps/api/src/model-configuration.js"; + +describe("POSTV1-04 generation runtime wiring", () => { + it("constructs, injects and closes the production generation submission service", () => { + const main = readFileSync("apps/api/src/main.ts", "utf8"); + + expect(main).toContain('import { GenerationSubmissionService } from "./generation-submission.js";'); + expect(main).toContain("let generations: GenerationSubmissionService | undefined;"); + expect(main).toContain("generations = new GenerationSubmissionService({"); + expect(main).toContain("models: new GenerationModelConfigurationCatalog(models),"); + expect(main).toContain("...(generations ? { generations } : {}),"); + expect(main).toContain("generations?.close();"); + }); + + it("maps the current model configuration into the generation submission contract", () => { + const configuration: ModelConfigurationView = { + config_set_version: 7, + configured_default_model_id: "gemini-3.1-flash-image-preview", + recommended_model_id: "gemini-3.1-flash-image-preview", + models: [{ + config_version: 3, + contract_evidence_ref: "fixture-contract", + contract_validation_status: "verified", + credit_cost: 2, + display_name: "Fixture model", + enabled: true, + error_mapping_profile: {}, + gateway_account_ref: "fixture-gateway", + is_default: true, + model_id: "gemini-3.1-flash-image-preview", + prompt_max_length: 1_000, + recommendation_priority: 1, + reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 }, + route_profile: {}, + runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-05T00:00:00.000Z", reason: "available" }, + safety_source: "provider", + supported_ratios: ["3:4", "invalid"], + }], + }; + const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration }); + + expect(catalog.readModel("gemini-3.1-flash-image-preview")).toEqual({ + configSetVersion: 7, + configVersion: 3, + contractValidationStatus: "verified", + creditCost: 2, + enabled: true, + modelId: "gemini-3.1-flash-image-preview", + promptMaxLength: 1_000, + referenceLimits: { maxFileBytes: 10, maxFiles: 2, maxTotalBytes: 20 }, + runtimeAvailability: { availableForNewJobs: true, reason: null }, + supportedRatios: ["3:4"], + }); + expect(catalog.readModel("missing")).toBeUndefined(); + }); + + it("maps model and contract blocks into generation error categories", () => { + const configuration = { + config_set_version: 1, + configured_default_model_id: "gemini-3.1-flash-image-preview", + recommended_model_id: null, + models: [], + } satisfies ModelConfigurationView; + const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration }); + const base = { + config_version: 1, + contract_evidence_ref: null, + contract_validation_status: "verified" as const, + credit_cost: 1, + display_name: "Fixture model", + enabled: true, + error_mapping_profile: {}, + gateway_account_ref: "fixture-gateway", + is_default: true, + model_id: "gemini-3.1-flash-image-preview" as const, + prompt_max_length: 1_000, + recommendation_priority: 1, + reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 }, + route_profile: {}, + safety_source: "provider", + supported_ratios: ["3:4"], + }; + + configuration.models = [{ + ...base, + runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "contract_blocked" }, + }]; + expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("gateway_contract_invalid"); + + configuration.models = [{ + ...base, + runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "worker_degraded" }, + }]; + expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("model_disabled"); + }); +});