diff --git a/packages/core/package.json b/packages/core/package.json index 3e1f656c553..5ccb87ee814 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,7 @@ "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "benchmark:location": "bun run script/benchmark-location.ts", + "benchmark:location-memory": "bun run script/benchmark-location-memory.ts", "build": "bun run script/build.ts", "update-models-snapshot": "bun run script/update-models-snapshot.ts", "test": "bun run script/test.ts", diff --git a/packages/core/script/benchmark-location-memory.ts b/packages/core/script/benchmark-location-memory.ts new file mode 100644 index 00000000000..637516fd92c --- /dev/null +++ b/packages/core/script/benchmark-location-memory.ts @@ -0,0 +1,210 @@ +// Measures the heap retained by Location service graphs and by the models.dev +// catalog plugin. Everything runs against a temporary global directory with a +// temporary home, an in-memory database, no filesystem watchers, and no network, +// so it never touches a live server, database, or user configuration. +// +// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json] +// +// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the +// highest heapUsed sampled without forcing GC and are reported separately. +import fs from "fs/promises" +import os from "os" +import path from "path" +import { heapStats } from "bun:jsc" +import { Effect, Layer, Logger, Scope } from "effect" +import { Global } from "@opencode-ai/util/global" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { Bus } from "../src/bus" +import { Catalog } from "../src/catalog" +import { Database } from "../src/database/database" +import { Integration } from "../src/integration" +import { Location } from "../src/location" +import { LocationServiceMap } from "../src/location-service-map" +import { ModelsDev } from "../src/models-dev" +import { Plugin } from "../src/plugin" +import { ModelsDevPlugin } from "../src/plugin/models-dev" +import { AbsolutePath } from "../src/schema" +import { Watcher } from "../src/filesystem/watcher" +import { location } from "../test/fixture/location" +import { catalogHost, host, integrationHost } from "../test/plugin/host" + +const args = process.argv.slice(2) +const flag = (name: string, fallback: number) => { + const index = args.indexOf(`--${name}`) + if (index === -1) return fallback + const value = Number(args[index + 1]) + if (!Number.isInteger(value) || value < 1) { + console.error(`--${name} must be a positive integer`) + process.exit(1) + } + return value +} +const locationCount = flag("locations", 6) +const pluginCount = flag("plugins", 8) +const jsonIndex = args.indexOf("--json") +const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1] + +type Sample = { heapUsed: number; rss: number; objects: number } + +const sample = (): Sample => { + Bun.gc(true) + Bun.gc(true) + const usage = process.memoryUsage() + return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount } +} + +const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8) + +const median = (values: ReadonlyArray) => { + const sorted = values.toSorted((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + const upper = sorted[middle] ?? 0 + return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper +} + +const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-"))) +const globalLayer = Global.layerWith({ + home: path.join(root, "home"), + data: path.join(root, "data"), + cache: path.join(root, "cache"), + config: path.join(root, "config"), + state: path.join(root, "state"), + tmp: path.join(root, "tmp"), + bin: path.join(root, "cache", "bin"), + log: path.join(root, "data", "log"), + repos: path.join(root, "data", "repos"), +}) +const replacements = [ + Global.node.replace(globalLayer), + ModelsDev.node.replace(ModelsDev.configured({ fetch: false })), + Watcher.node.replace(Watcher.configured({ enabled: false })), +] + +// One full Location graph per directory, retained for the rest of the run, the +// way a long-running server retains every directory a client has touched. +const locationsProgram = Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const scope = yield* Scope.Scope + const before = sample() + const deltas: number[] = [] + const rss: number[] = [] + let previous = before + for (let index = 0; index < locationCount; index++) { + const directory = path.join(root, "projects", `location-${index}`) + yield* Effect.promise(() => fs.mkdir(directory, { recursive: true })) + const context = yield* locations + .contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) })) + .pipe(Scope.provide(scope)) + const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context)) + yield* plugins.awaitActivation + const current = sample() + deltas.push(current.heapUsed - previous.heapUsed) + rss.push(current.rss) + previous = current + } + const catalog = yield* Catalog.Service.pipe( + Effect.provideContext( + yield* locations + .contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) })) + .pipe(Scope.provide(scope)), + ), + ) + const models = yield* catalog.model.all() + const providers = yield* catalog.provider.all() + return { + before, + after: previous, + deltas, + rss, + catalog: { providers: providers.length, models: models.length }, + } +}).pipe(Effect.scoped) + +// The models.dev plugin alone, against a real Catalog and Integration state per +// instance, isolates the catalog-copy contribution from the rest of the graph. +const pluginProgram = Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const snapshot = yield* modelsDev.get() + const scope = yield* Scope.Scope + const before = sample() + const deltas: number[] = [] + let previous = before + for (let index = 0; index < pluginCount; index++) { + const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`)) + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location(Location.Ref.make({ directory }))), + ) + const context = yield* Layer.build( + AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [ + Location.node.replace(locationLayer), + ...replacements, + ]), + ).pipe(Scope.provide(scope)) + const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context)) + const integration = yield* Integration.Service.pipe(Effect.provideContext(context)) + yield* ModelsDevPlugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }), + ).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope)) + yield* catalog.model.all() + yield* integration.list() + const current = sample() + deltas.push(current.heapUsed - previous.heapUsed) + previous = current + } + return { + before, + after: previous, + deltas, + snapshot: { + providers: snapshot.length, + models: snapshot.reduce((total, provider) => total + provider.models.length, 0), + }, + } +}).pipe(Effect.scoped) + +const program = Effect.gen(function* () { + const plugin = yield* pluginProgram.pipe( + Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)), + ) + const locations = yield* locationsProgram.pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements), + ), + ) + return { plugin, locations } +}).pipe(Effect.provide(Logger.layer([]))) + +const result = await Effect.runPromise(program) +await fs.rm(root, { recursive: true, force: true }).catch(() => undefined) + +console.log( + `models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`, +) +console.log(`ModelsDevPlugin instances: ${pluginCount}`) +console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`) +console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`) +console.log( + `Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`, +) +console.log( + ` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`, +) +console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`) +console.log( + ` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`, +) +console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`) + +if (jsonPath) { + await fs.mkdir(path.dirname(jsonPath), { recursive: true }) + await fs.writeFile( + jsonPath, + JSON.stringify( + { revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result }, + null, + 2, + ), + ) +} diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 26ddf555355..9235733a7a4 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -10,6 +10,8 @@ export const ModelsDevPlugin = define({ effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const bus = yield* Bus.Service + // The normalized snapshot is shared by every Location and only read here; the catalog + // receives copies below, so retaining a second copy per Location is unnecessary. const loaded = { data: snapshots(yield* modelsDev.get()) } yield* ctx.integration.transform((integrations) => { for (const provider of loaded.data) { @@ -32,7 +34,7 @@ export const ModelsDevPlugin = define({ yield* ctx.catalog.transform((catalog) => { for (const provider of loaded.data) { catalog.provider.update(provider.info.id, (draft) => { - Object.assign(draft, provider.info) + Object.assign(draft, copy(provider.info)) draft.integrationID = Integration.ID.make(provider.info.id) }) for (const model of provider.models) { @@ -65,15 +67,16 @@ function environmentNames(provider: ModelsDev.Snapshot) { } function snapshots(data: readonly ModelsDev.Snapshot[]) { - return copy(data).filter( + return data.filter( // These deprecated aliases are replaced by the canonical Azure and Google Vertex providers. (provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic", ) } -// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the -// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of -// magnitude faster than structuredClone's general graph walk on the startup path. +// The catalog owns and mutates its provider and model records in place, so every rebuild +// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain +// JSON, and a direct copy is an order of magnitude faster than structuredClone's general +// graph walk on the startup path. function copy(value: T): T { if (Array.isArray(value)) return value.map(copy) as T if (value !== null && typeof value === "object") { diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 4b92064e242..d4177ffec40 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,7 +1,8 @@ import path from "path" import { describe, expect } from "bun:test" import { Money } from "@opencode-ai/schema/money" -import { Effect, Exit, Layer, Scope } from "effect" +import { Context, Effect, Exit, Layer, Scope } from "effect" +import { TestClock } from "effect/testing" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -31,10 +32,245 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no ]) const it = testEffect(layer) const real = testEffect(PluginTestLayer) +const isolated = testEffect(Layer.empty) const models = (file: string) => AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))]) +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +// One complete Location graph behind the production plugin host. Two of these stand in for +// two Locations that share a single models.dev snapshot instance. +const owner = Effect.gen(function* () { + const context = yield* Layer.build(PluginTestLayer) + const host = yield* PluginHost.make(Context.get(context, Plugin.Service)).pipe(Effect.provideContext(context)) + return { + context, + host, + bus: Context.get(context, Bus.Service), + catalog: Context.get(context, Catalog.Service), + integration: Context.get(context, Integration.Service), + } +}) + +// Every nested overlay shape the normalized snapshot can carry, so in-place mutation of any +// existing nested value is observable on the source object. +const richSnapshot = (name = "Acme") => { + const providerID = Provider.ID.make("acme") + const modelID = Model.ID.make("gpt-5.4") + const snapshot = [ + { + info: { + id: providerID, + name, + activation: "auto", + package: Provider.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } }, + headers: { "x-acme": "provider" }, + body: { service_tier: "default", tags: ["stable"] }, + }, + environment: ["ACME_API_KEY", "ACME_HOST"], + models: [ + { + id: modelID, + modelID, + providerID, + name: "GPT-5.4", + family: Model.Family.make("gpt"), + settings: { baseURL: "https://models.acme.test/v1", reasoning: { effort: "low" } }, + headers: { "x-mode": "fast" }, + body: { service_tier: "priority", options: { top_k: 1 }, stop: [""] }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [ + { + id: Model.VariantID.make("low"), + settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + body: { max_tokens: 1024 }, + }, + ], + time: { released: Date.parse("2026-01-01") }, + cost: [ + { + input: Money.USDPerMillionTokens.make(2.5), + output: Money.USDPerMillionTokens.make(15), + cache: { read: Money.USDPerMillionTokens.make(0.25), write: Money.USDPerMillionTokens.zero }, + }, + { + tier: { type: "context", size: 200_000 }, + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache: { read: Money.USDPerMillionTokens.make(0.5), write: Money.USDPerMillionTokens.zero }, + }, + ], + status: "active", + enabled: true, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + }, + ], + }, + ] satisfies readonly ModelsDev.Snapshot[] + return { providerID, modelID, snapshot } +} + describe("ModelsDevPlugin", () => { + isolated.effect("shares one snapshot between Locations while each catalog mutates only its own copies", () => + Effect.gen(function* () { + const { providerID, modelID, snapshot } = richSnapshot() + const pristine = JSON.stringify(snapshot) + const source = ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void }) + const first = yield* owner + const second = yield* owner + for (const each of [first, second]) + yield* ModelsDevPlugin.effect(each.host).pipe( + Effect.provideService(ModelsDev.Service, source), + Effect.provideContext(each.context), + ) + + // The stored environment method must own its names array; the source array is shared. + let names: readonly string[] | undefined + yield* first.integration.transform((draft) => { + names = draft.method + .list(Integration.ID.make(providerID)) + .flatMap((method) => (method.type === "env" ? [method.names] : []))[0] + }) + expect(names).toEqual(snapshot[0].environment) + expect(names).not.toBe(snapshot[0].environment) + + // Catalog-owned provider records are copies, not the source's nested objects. + const stored = required(yield* first.catalog.provider.get(providerID)) + expect(stored.settings).toEqual(snapshot[0].info.settings) + expect(stored.settings).not.toBe(snapshot[0].info.settings) + expect(stored.headers).not.toBe(snapshot[0].info.headers) + expect(stored.body).not.toBe(snapshot[0].info.body) + + // A later plugin in the first Location mutates existing nested values in place through the + // production host, the way the Bedrock, Azure, and config provider plugins do. + const scope = yield* Scope.make() + yield* first.host.catalog + .transform((catalog) => { + catalog.provider.update(providerID, (provider) => { + required(provider.settings).baseURL = "https://override.acme.test/v1" + required(provider.settings).thinking.type = "disabled" + required(provider.headers)["x-acme"] = "override" + required(provider.body).service_tier = "priority" + required(provider.body).tags.push("override") + }) + catalog.model.update(providerID, modelID, (model) => { + required(model.settings).reasoning.effort = "high" + required(model.headers)["x-mode"] = "slow" + required(model.body).options.top_k = 7 + required(model.body).stop.push("") + const variant = required(model.variants[0]) + required(variant.settings).thinking.type = "disabled" + required(variant.body).max_tokens = 4096 + model.capabilities.input.push("image") + required(model.cost[0]).cache.read = Money.USDPerMillionTokens.make(9) + required(required(model.cost[1]).tier).size = 1 + required(model.cost[1]).input = Money.USDPerMillionTokens.make(42) + model.limit.context = 1 + model.time.released = 5 + }) + }) + .pipe(Scope.provide(scope)) + + const mutatedProvider = required(yield* first.catalog.provider.get(providerID)) + const mutated = required(yield* first.catalog.model.get(providerID, modelID)) + expect(mutatedProvider.settings).toEqual({ + baseURL: "https://override.acme.test/v1", + thinking: { type: "disabled", display: "summarized" }, + }) + expect(mutatedProvider.headers).toEqual({ "x-acme": "override" }) + expect(mutatedProvider.body).toEqual({ service_tier: "priority", tags: ["stable", "override"] }) + expect(mutated.settings).toEqual({ + baseURL: "https://models.acme.test/v1", + thinking: { type: "disabled", display: "summarized" }, + reasoning: { effort: "high" }, + }) + expect(mutated.headers).toEqual({ "x-acme": "override", "x-mode": "slow" }) + expect(mutated.body).toEqual({ + service_tier: "priority", + tags: ["stable", "override"], + options: { top_k: 7 }, + stop: ["", ""], + }) + expect(mutated.variants).toEqual([ + { + id: Model.VariantID.make("low"), + settings: { thinking: { type: "disabled", display: "summarized" }, effort: "low" }, + body: { max_tokens: 4096 }, + }, + ]) + expect(mutated.capabilities.input).toEqual(["text", "image"]) + expect(mutated.cost[0]?.cache.read).toBe(Money.USDPerMillionTokens.make(9)) + expect(mutated.cost[1]).toMatchObject({ tier: { type: "context", size: 1 }, input: 42 }) + expect(mutated.limit.context).toBe(1) + expect(mutated.time.released).toBe(5) + + // The sibling Location and the shared source are untouched. + const sibling = required(yield* second.catalog.model.get(providerID, modelID)) + expect(sibling.settings).toEqual({ + baseURL: "https://models.acme.test/v1", + thinking: { type: "adaptive", display: "summarized" }, + reasoning: { effort: "low" }, + }) + expect(sibling.headers).toEqual({ "x-acme": "provider", "x-mode": "fast" }) + expect(sibling.body).toEqual({ + service_tier: "priority", + tags: ["stable"], + options: { top_k: 1 }, + stop: [""], + }) + expect(sibling.variants).toEqual(snapshot[0].models[0].variants) + expect(sibling.capabilities.input).toEqual(["text"]) + expect(sibling.cost).toEqual(snapshot[0].models[0].cost) + expect(sibling.limit).toEqual(snapshot[0].models[0].limit) + expect(sibling.time).toEqual(snapshot[0].models[0].time) + expect(required(yield* second.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings) + expect(JSON.stringify(snapshot)).toBe(pristine) + + // Removing the mutating transform rebuilds the first catalog from the shared source. + yield* Scope.close(scope, Exit.void) + expect(yield* first.catalog.model.get(providerID, modelID)).toEqual(sibling) + expect(required(yield* first.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings) + expect(JSON.stringify(snapshot)).toBe(pristine) + }), + ) + + isolated.effect("replaces its snapshot reference on refresh without touching the sibling Location", () => + Effect.gen(function* () { + const initial = richSnapshot("Acme") + const refreshed = richSnapshot("Acme Refreshed") + const pristine = JSON.stringify(initial.snapshot) + const current = { snapshot: initial.snapshot } + const source = ModelsDev.Service.of({ + get: () => Effect.sync(() => current.snapshot), + refresh: () => Effect.void, + }) + const first = yield* owner + const second = yield* owner + for (const each of [first, second]) + yield* ModelsDevPlugin.effect(each.host).pipe( + Effect.provideService(ModelsDev.Service, source), + Effect.provideContext(each.context), + ) + expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme") + + current.snapshot = refreshed.snapshot + yield* first.bus.publish(ModelsDev.Event.Refreshed, {}) + // Integration and catalog reloads are debounced sequentially. + yield* TestClock.adjust("500 millis") + yield* TestClock.adjust("500 millis") + yield* TestClock.adjust("500 millis") + + expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme Refreshed") + expect(required(yield* second.catalog.provider.get(initial.providerID)).name).toBe("Acme") + expect(JSON.stringify(initial.snapshot)).toBe(pristine) + expect(JSON.stringify(refreshed.snapshot)).toBe(JSON.stringify(richSnapshot("Acme Refreshed").snapshot)) + }), + ) + real.effect("keeps the retained model seed unchanged across catalog replay", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -92,6 +328,79 @@ describe("ModelsDevPlugin", () => { }), ) + it.effect("keeps the shared models.dev snapshot pristine while catalog transforms mutate records in place", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + const providerID = Provider.ID.make("acme") + const modelID = Model.ID.make("gpt-5.4") + const snapshot = [ + { + info: { + id: providerID, + name: "Acme", + activation: "auto", + package: Provider.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://api.acme.test/v1" }, + headers: { "x-acme": "provider" }, + }, + environment: ["ACME_API_KEY"], + models: [ + { + id: modelID, + modelID, + providerID, + name: "GPT-5.4", + settings: { baseURL: "https://models.acme.test/v1" }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: Date.parse("2026-01-01") }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1_050_000, output: 128_000 }, + }, + ], + }, + ] satisfies readonly ModelsDev.Snapshot[] + const pristine = JSON.stringify(snapshot) + // The plugin receives the same snapshot instance every Location shares. + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ).pipe( + Effect.provideService( + ModelsDev.Service, + ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void }), + ), + ) + + // Later plugins mutate nested provider and model records in place, as the Bedrock provider does. + yield* catalog.transform((draft) => { + draft.provider.update(providerID, (provider) => { + if (provider.settings) provider.settings.baseURL = "https://override.acme.test/v1" + if (provider.headers) provider.headers["x-acme"] = "override" + }) + draft.model.update(providerID, modelID, (model) => { + if (model.settings) model.settings.baseURL = "https://override.models.acme.test/v1" + model.variants.push({ id: Model.VariantID.make("configured") }) + model.capabilities.input.push("image") + }) + }) + + const provider = yield* catalog.provider.get(providerID) + const model = yield* catalog.model.get(providerID, modelID) + expect(provider?.settings?.baseURL).toBe("https://override.acme.test/v1") + expect(provider?.headers).toEqual({ "x-acme": "override" }) + expect(model?.settings?.baseURL).toBe("https://override.models.acme.test/v1") + expect(model?.variants).toEqual([{ id: Model.VariantID.make("configured") }]) + expect(model?.capabilities.input).toEqual(["text", "image"]) + expect(JSON.stringify(snapshot)).toBe(pristine) + }), + ) + it.effect("projects normalized models.dev snapshots into the catalog", () => Effect.gen(function* () { const integrations = yield* Integration.Service