diff --git a/src/lib/configTransfer.test.ts b/src/lib/configTransfer.test.ts new file mode 100644 index 0000000..b8665ef --- /dev/null +++ b/src/lib/configTransfer.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { + buildImageImportBody, + buildRegistryImportBody, + exportFilename, + IMAGE_EXPORT_KIND, + imageMissingSecrets, + mergeImageSettings, + mergeRegistry, + parseImageExport, + parseRegistryExport, + REGISTRY_EXPORT_KIND, + registryMissingSecrets, + type ImageExportEnvelope, + type RegistryExportEnvelope, +} from "./configTransfer"; +import type { ProviderConfig, RegistryV4 } from "./modelRegistry"; +import type { ImageGenerationSettings, ImageModel } from "./imageGeneration"; + +function provider(id: string, modelKeys: string[] = ["m1"]): ProviderConfig { + return { + id, + name: id, + adapter: "openai-compatible", + base_url: "https://example.com/v1", + auth: { mode: "api_key", credential_id: `${id}-cred` }, + enabled: true, + runtime: { + timeout_seconds: 120, + max_retries: 2, + default_temperature: null, + default_top_p: null, + default_reasoning_effort: "auto", + }, + models: modelKeys.map((key) => ({ + key, + name: key, + upstream_model_id: key, + enabled: true, + runtime: { + limit_mode: "combined", + context_window_tokens: 128000, + max_input_tokens: null, + max_output_tokens: 4096, + min_effective_input_tokens: 8192, + fixed_system_reserve_tokens: 4096, + fixed_tools_reserve_tokens: 8192, + fixed_attachments_reserve_tokens: 4096, + limits_status: "confirmed", + limits_source: "provider", + temperature: null, + top_p: null, + reasoning_effort: "auto", + declared_capabilities: { + tools: true, + vision: false, + structured_output: true, + }, + }, + })), + }; +} + +function registry(providers: ProviderConfig[], primaryId: string | null = null): RegistryV4 { + const primary = + primaryId === null + ? null + : { provider_id: primaryId, model_key: providers.find((p) => p.id === primaryId)?.models[0]?.key ?? "m1" }; + return { + version: 4, + revision: 1, + state: "active", + defaults: { primary }, + providers, + }; +} + +function registryEnvelope(providers: ProviderConfig[]): RegistryExportEnvelope { + return { + kind: REGISTRY_EXPORT_KIND, + format_version: 1, + exported_at: "2026-08-12T09:00:00Z", + payload: { + registry: registry(providers), + credentials: providers.map((p) => ({ + credential_id: `${p.id}-cred`, + secret_value: `sk-${p.id}`, + })), + }, + }; +} + +function imageModel(id: string, apiKey = `sk-${id}`): ImageModel { + return { + id, + name: id, + provider: "openai", + api_key: apiKey, + base_url: "https://example.com/v1", + enabled: true, + default_size: "1024x1024", + default_quality: "auto", + params: {}, + }; +} + +function imageEnvelope(models: ImageModel[]): ImageExportEnvelope { + return { + kind: IMAGE_EXPORT_KIND, + format_version: 1, + exported_at: "2026-08-12T09:00:00Z", + payload: { default_model: models[0]?.id ?? "", timeout_seconds: 60, models }, + }; +} + +function imageSettings(models: Array<{ id: string; configured: boolean }>): ImageGenerationSettings { + return { + default_model: models[0]?.id ?? "", + timeout_seconds: 120, + models: models.map(({ id, configured }) => ({ + ...imageModel(id, ""), + api_key_configured: configured, + api_key_hint: configured ? "...cret" : null, + })), + }; +} + +describe("parseRegistryExport", () => { + it("parses a valid envelope", () => { + const envelope = registryEnvelope([provider("a")]); + const result = parseRegistryExport(JSON.stringify(envelope)); + expect(result).toEqual({ ok: true, value: envelope }); + }); + it("rejects text over 1 MB", () => { + expect(parseRegistryExport("x".repeat(1_000_001))).toEqual({ + ok: false, + code: "too_large", + }); + }); + it("rejects invalid JSON", () => { + expect(parseRegistryExport("{nope")).toEqual({ ok: false, code: "invalid_json" }); + }); + it("rejects the wrong kind", () => { + const envelope = { ...registryEnvelope([provider("a")]), kind: IMAGE_EXPORT_KIND }; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "wrong_kind", + }); + }); + it("rejects an unsupported format_version", () => { + const envelope = { ...registryEnvelope([provider("a")]), format_version: 2 }; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "unsupported_version", + }); + }); + it("rejects a malformed payload", () => { + const envelope = { kind: REGISTRY_EXPORT_KIND, format_version: 1, payload: { registry: { version: 3 }, credentials: [] } }; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "invalid_shape", + }); + }); + it("rejects credentials with non-string secrets", () => { + const envelope = registryEnvelope([provider("a")]); + (envelope.payload.credentials[0] as unknown as { secret_value: number }).secret_value = 42; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "invalid_shape", + }); + }); +}); + +describe("parseImageExport", () => { + it("parses a valid envelope", () => { + const envelope = imageEnvelope([imageModel("gpt-image-2")]); + expect(parseImageExport(JSON.stringify(envelope))).toEqual({ + ok: true, + value: envelope, + }); + }); + it("rejects a registry file", () => { + expect(parseImageExport(JSON.stringify(registryEnvelope([provider("a")])))).toEqual({ + ok: false, + code: "wrong_kind", + }); + }); + it("rejects a model with an unknown provider", () => { + const envelope = imageEnvelope([imageModel("x")]); + (envelope.payload.models[0] as unknown as { provider: string }).provider = "midjourney"; + expect(parseImageExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "invalid_shape", + }); + }); +}); + +describe("mergeRegistry", () => { + it("replaces same-id providers, appends new, keeps unmentioned", () => { + const current = registry([provider("keep"), provider("old")]); + const imported = registry([provider("old", ["m2"]), provider("new")]); + const merged = mergeRegistry(current, imported); + expect(merged.providers.map((p) => p.id)).toEqual(["keep", "old", "new"]); + expect(merged.providers[1].models.map((m) => m.key)).toEqual(["m2"]); + }); + it("keeps the current default primary when it survives the merge", () => { + const current = registry([provider("keep"), provider("old")], "keep"); + const merged = mergeRegistry(current, registry([provider("old")])); + expect(merged.defaults.primary?.provider_id).toBe("keep"); + }); + it("falls back to the imported primary when the current one is gone", () => { + // The import replaces provider "gone" wholesale, dropping model "m1", so + // the current primary {gone, m1} no longer exists in the merged registry. + const current = registry([provider("gone")], "gone"); + const imported = registry([provider("gone", ["m2"]), provider("new")], "new"); + const merged = mergeRegistry(current, imported); + expect(merged.defaults.primary?.provider_id).toBe("new"); + }); +}); + +describe("buildRegistryImportBody", () => { + it("replace mode sends the imported registry plus credential writes", () => { + const envelope = registryEnvelope([provider("a")]); + const body = buildRegistryImportBody(envelope, { revision: 7, registry: registry([provider("z")]) }, "replace"); + expect(body.expected_revision).toBe(7); + expect(body.registry.providers.map((p) => p.id)).toEqual(["a"]); + expect(body.credential_writes).toEqual([ + { credential_id: "a-cred", operation: "replace", secret_value: "sk-a" }, + ]); + }); + it("merge mode merges against the current registry", () => { + const envelope = registryEnvelope([provider("a")]); + const body = buildRegistryImportBody(envelope, { revision: 3, registry: registry([provider("z")]) }, "merge"); + expect(body.registry.providers.map((p) => p.id)).toEqual(["z", "a"]); + }); +}); + +describe("registryMissingSecrets", () => { + it("lists providers whose credential is absent from the file", () => { + const envelope = registryEnvelope([provider("a"), provider("b")]); + envelope.payload.credentials = envelope.payload.credentials.filter( + (c) => c.credential_id !== "b-cred" + ); + expect(registryMissingSecrets(envelope)).toEqual(["b"]); + }); +}); + +describe("mergeImageSettings", () => { + it("replaces same-id models, appends new, keeps unmentioned with blank key", () => { + const current = imageSettings([ + { id: "keep", configured: true }, + { id: "old", configured: true }, + ]); + const merged = mergeImageSettings(current, imageEnvelope([imageModel("old"), imageModel("new")]).payload); + expect(merged.models.map((m) => m.id)).toEqual(["keep", "old", "new"]); + expect(merged.models[0].api_key).toBe(""); // blank → backend keeps stored key + expect(merged.models[1].api_key).toBe("sk-old"); + }); + it("takes scalars from the import", () => { + const current = imageSettings([{ id: "a", configured: false }]); + const merged = mergeImageSettings(current, { default_model: "a", timeout_seconds: 30, models: [imageModel("a")] }); + expect(merged.timeout_seconds).toBe(30); + }); +}); + +describe("buildImageImportBody", () => { + it("replace mode sends imported models as-is", () => { + const envelope = imageEnvelope([imageModel("a", "")]); + const body = buildImageImportBody(envelope, imageSettings([{ id: "z", configured: true }]), "replace"); + expect(body.models.map((m) => m.id)).toEqual(["a"]); + expect(body.models[0].api_key).toBe(""); + }); +}); + +describe("imageMissingSecrets", () => { + it("lists models with no key in the file and none stored", () => { + const envelope = imageEnvelope([imageModel("a", ""), imageModel("b", ""), imageModel("c")]); + const current = imageSettings([{ id: "b", configured: true }]); + expect(imageMissingSecrets(envelope, current)).toEqual(["a"]); + }); +}); + +describe("exportFilename", () => { + it("formats the date-stamped filename", () => { + expect(exportFilename("model-registry", new Date("2026-08-12T09:00:00Z"))).toBe( + "evoscientist-model-registry-2026-08-12.json" + ); + expect(exportFilename("image-generation", new Date("2026-08-12T09:00:00Z"))).toBe( + "evoscientist-image-generation-2026-08-12.json" + ); + }); +}); diff --git a/src/lib/configTransfer.ts b/src/lib/configTransfer.ts new file mode 100644 index 0000000..df07169 --- /dev/null +++ b/src/lib/configTransfer.ts @@ -0,0 +1,297 @@ +import type { + CredentialWrite, + ModelRef, + PutModelRegistryRequest, + RegistryV4, +} from "@/lib/modelRegistry"; +import type { + ImageGenerationSettings, + ImageModel, + PutImageGenerationRequest, +} from "@/lib/imageGeneration"; + +export const REGISTRY_EXPORT_KIND = "evoscientist.model-registry"; +export const IMAGE_EXPORT_KIND = "evoscientist.image-generation"; +export const EXPORT_FORMAT_VERSION = 1; +export const MAX_IMPORT_BYTES = 1_000_000; + +export interface RegistryExportCredential { + credential_id: string; + secret_value: string; +} + +export interface RegistryExportEnvelope { + kind: typeof REGISTRY_EXPORT_KIND; + format_version: typeof EXPORT_FORMAT_VERSION; + exported_at: string; + payload: { + registry: RegistryV4; + credentials: RegistryExportCredential[]; + }; +} + +export type ImageExportPayload = PutImageGenerationRequest; + +export interface ImageExportEnvelope { + kind: typeof IMAGE_EXPORT_KIND; + format_version: typeof EXPORT_FORMAT_VERSION; + exported_at: string; + payload: ImageExportPayload; +} + +export type ParseErrorCode = + | "too_large" + | "invalid_json" + | "wrong_kind" + | "unsupported_version" + | "invalid_shape"; + +export type ParseResult = + | { ok: true; value: T } + | { ok: false; code: ParseErrorCode }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseEnvelope( + text: string, + expectedKind: string +): ParseResult> { + if (new TextEncoder().encode(text).length > MAX_IMPORT_BYTES) { + return { ok: false, code: "too_large" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, code: "invalid_json" }; + } + if (!isRecord(parsed)) return { ok: false, code: "invalid_shape" }; + if (parsed.kind !== expectedKind) return { ok: false, code: "wrong_kind" }; + if (parsed.format_version !== EXPORT_FORMAT_VERSION) { + return { ok: false, code: "unsupported_version" }; + } + return { ok: true, value: parsed }; +} + +export function parseRegistryExport( + text: string +): ParseResult { + const parsed = parseEnvelope(text, REGISTRY_EXPORT_KIND); + if (!parsed.ok) return parsed; + const payload = parsed.value.payload; + if (!isRecord(payload)) return { ok: false, code: "invalid_shape" }; + const registry = payload.registry; + if ( + !isRecord(registry) || + registry.version !== 4 || + !Array.isArray(registry.providers) + ) { + return { ok: false, code: "invalid_shape" }; + } + for (const provider of registry.providers) { + if ( + !isRecord(provider) || + typeof provider.id !== "string" || + !Array.isArray(provider.models) + ) { + return { ok: false, code: "invalid_shape" }; + } + for (const model of provider.models) { + if (!isRecord(model) || typeof model.key !== "string") { + return { ok: false, code: "invalid_shape" }; + } + } + } + if (!Array.isArray(payload.credentials)) { + return { ok: false, code: "invalid_shape" }; + } + for (const credential of payload.credentials) { + if ( + !isRecord(credential) || + typeof credential.credential_id !== "string" || + typeof credential.secret_value !== "string" + ) { + return { ok: false, code: "invalid_shape" }; + } + } + return { ok: true, value: parsed.value as unknown as RegistryExportEnvelope }; +} + +export function parseImageExport(text: string): ParseResult { + const parsed = parseEnvelope(text, IMAGE_EXPORT_KIND); + if (!parsed.ok) return parsed; + const payload = parsed.value.payload; + if ( + !isRecord(payload) || + typeof payload.default_model !== "string" || + typeof payload.timeout_seconds !== "number" || + !Array.isArray(payload.models) + ) { + return { ok: false, code: "invalid_shape" }; + } + for (const model of payload.models) { + if ( + !isRecord(model) || + typeof model.id !== "string" || + (model.provider !== "openai" && model.provider !== "gemini") || + typeof model.api_key !== "string" || + typeof model.base_url !== "string" || + !isRecord(model.params) + ) { + return { ok: false, code: "invalid_shape" }; + } + } + return { ok: true, value: parsed.value as unknown as ImageExportEnvelope }; +} + +function modelRefExists(registry: RegistryV4, ref: ModelRef | null): boolean { + if (!ref) return false; + const provider = registry.providers.find((p) => p.id === ref.provider_id); + return provider?.models.some((m) => m.key === ref.model_key) ?? false; +} + +/** Merge by provider id: imported replaces same-id wholesale, new ids are + * appended, unmentioned providers are kept. */ +export function mergeRegistry( + current: RegistryV4, + imported: RegistryV4 +): RegistryV4 { + const importedById = new Map( + imported.providers.map((provider) => [provider.id, provider]) + ); + const currentIds = new Set(current.providers.map((provider) => provider.id)); + const providers = current.providers.map( + (provider) => importedById.get(provider.id) ?? provider + ); + for (const provider of imported.providers) { + if (!currentIds.has(provider.id)) providers.push(provider); + } + const merged = structuredClone(current); + merged.providers = structuredClone(providers); + merged.defaults = { + primary: + [current.defaults.primary, imported.defaults.primary].find((ref) => + modelRefExists(merged, ref) + ) ?? null, + }; + return merged; +} + +function registryCredentialWrites( + envelope: RegistryExportEnvelope +): CredentialWrite[] { + return envelope.payload.credentials.map((credential) => ({ + credential_id: credential.credential_id, + operation: "replace", + secret_value: credential.secret_value, + })); +} + +export function buildRegistryImportBody( + envelope: RegistryExportEnvelope, + current: { revision: number; registry: RegistryV4 }, + mode: "replace" | "merge" +): PutModelRegistryRequest { + return { + expected_revision: current.revision, + registry: + mode === "replace" + ? structuredClone(envelope.payload.registry) + : mergeRegistry(current.registry, envelope.payload.registry), + credential_writes: registryCredentialWrites(envelope), + }; +} + +/** Merge by model id: same rule as the registry. Kept entries send a blank + * api_key, which the backend interprets as "keep the stored key". */ +export function mergeImageSettings( + current: ImageGenerationSettings, + imported: ImageExportPayload +): PutImageGenerationRequest { + const importedById = new Map(imported.models.map((model) => [model.id, model])); + const currentIds = new Set(current.models.map((model) => model.id)); + const models: ImageModel[] = current.models.map((model) => { + const replacement = importedById.get(model.id); + if (replacement) return structuredClone(replacement); + return { + id: model.id, + name: model.name, + provider: model.provider, + api_key: "", + base_url: model.base_url, + enabled: model.enabled, + default_size: model.default_size, + default_quality: model.default_quality, + params: structuredClone(model.params), + }; + }); + for (const model of imported.models) { + if (!currentIds.has(model.id)) models.push(structuredClone(model)); + } + return { + default_model: imported.default_model, + timeout_seconds: imported.timeout_seconds, + models, + }; +} + +export function buildImageImportBody( + envelope: ImageExportEnvelope, + current: ImageGenerationSettings, + mode: "replace" | "merge" +): PutImageGenerationRequest { + if (mode === "replace") { + return structuredClone(envelope.payload); + } + return mergeImageSettings(current, envelope.payload); +} + +/** Ids of providers that need a credential but have none in the file. */ +export function registryMissingSecrets( + envelope: RegistryExportEnvelope +): string[] { + const withSecret = new Set( + envelope.payload.credentials.map((credential) => credential.credential_id) + ); + const missing: string[] = []; + for (const provider of envelope.payload.registry.providers) { + const credentialId = provider.auth?.credential_id; + if (provider.auth?.mode !== "none" && credentialId && !withSecret.has(credentialId)) { + missing.push(provider.id); + } + } + return missing; +} + +/** Ids of imported image models with no key in the file and no stored key. */ +export function imageMissingSecrets( + envelope: ImageExportEnvelope, + current: ImageGenerationSettings +): string[] { + const configured = new Set( + current.models + .filter((model) => model.api_key_configured) + .map((model) => model.id) + ); + return envelope.payload.models + .filter((model) => !model.api_key && !configured.has(model.id)) + .map((model) => model.id); +} + +export function exportFilename( + kind: "model-registry" | "image-generation", + date: Date = new Date() +): string { + return `evoscientist-${kind}-${date.toISOString().slice(0, 10)}.json`; +} + +export function downloadTextFile(filename: string, text: string): void { + const url = URL.createObjectURL(new Blob([text], { type: "application/json" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}