mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 23:34:48 +00:00
fix(core): share the models.dev snapshot across Locations (#46784)
This commit is contained in:
parent
85e2b0a23a
commit
b605f355ca
4 changed files with 529 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
210
packages/core/script/benchmark-location-memory.ts
Normal file
210
packages/core/script/benchmark-location-memory.ts
Normal file
|
|
@ -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<number>) => {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map(copy) as T
|
||||
if (value !== null && typeof value === "object") {
|
||||
|
|
|
|||
|
|
@ -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<T>(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: ["<end>"] },
|
||||
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("<stop>")
|
||||
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: ["<end>", "<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: ["<end>"],
|
||||
})
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue