feat(lib): add configTransfer parse/merge logic for config import/export

This commit is contained in:
m4
2026-08-12 20:36:08 +08:00
parent 8a159b3620
commit 20f39338ed
2 changed files with 589 additions and 0 deletions
+292
View File
@@ -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"
);
});
});
+297
View File
@@ -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<T> =
| { ok: true; value: T }
| { ok: false; code: ParseErrorCode };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function parseEnvelope(
text: string,
expectedKind: string
): ParseResult<Record<string, unknown>> {
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<RegistryExportEnvelope> {
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<ImageExportEnvelope> {
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);
}