feat(lib): add configTransfer parse/merge logic for config import/export
This commit is contained in:
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user