From af6ea3cee2b5a2f7fba48f0bf83ae37ff29ca08d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 24 Apr 2026 14:22:44 +0800 Subject: [PATCH] fix: make models refresh publish safely --- packages/opencode/src/provider/models.ts | 182 +++++++++- packages/opencode/src/provider/provider.ts | 54 ++- .../test/provider/models-refresh.test.ts | 329 ++++++++++++++++++ 3 files changed, 542 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/test/provider/models-refresh.test.ts diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 227d5f9b6..7737dc241 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,6 +1,7 @@ import { Global } from "../global" import { Log } from "../util" import path from "path" +import { rename, rm } from "fs/promises" import z from "zod" import { Installation } from "../installation" import { Flag } from "../flag/flag" @@ -21,6 +22,7 @@ const filepath = path.join( source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, ) const ttl = 5 * 60 * 1000 +let catalogVersion = 0 type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] @@ -108,10 +110,86 @@ export const Provider = z.object({ export type Provider = z.infer +const PublishModel = z + .object({ + id: z.string(), + name: z.string(), + family: z.string().optional(), + release_date: z.string().optional(), + attachment: z.boolean().optional(), + reasoning: z.boolean().optional(), + temperature: z.boolean().optional(), + tool_call: z.boolean().optional(), + interleaved: z + .union([ + z.literal(true), + z + .object({ + field: z.enum(["reasoning_content", "reasoning_details"]), + }) + .strict(), + ]) + .optional(), + cost: Cost.optional(), + limit: z.object({ + context: z.number(), + input: z.number().optional(), + output: z.number(), + }), + modalities: z + .object({ + input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + }) + .optional(), + experimental: z + .object({ + modes: z + .record( + z.string(), + z.object({ + cost: Cost.optional(), + provider: z + .object({ + body: z.record(z.string(), JsonValue).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), + }) + .passthrough() + +const PublishProvider = z + .object({ + api: z.string().optional(), + name: z.string(), + env: z.array(z.string()).optional(), + id: z.string(), + npm: z.string().optional(), + models: z.record(z.string(), PublishModel), + }) + .passthrough() + +const PublishCatalog = z.record(z.string(), PublishProvider) + function url() { return Flag.OPENCODE_MODELS_URL || "https://models.dev" } +function modelsPathOverride() { + return process.env["OPENCODE_MODELS_PATH"] +} + +export function version() { + return catalogVersion +} + function fresh() { return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl } @@ -128,8 +206,53 @@ const fetchApi = async () => { return { ok: result.ok, text: await result.text() } } +type Catalog = Record + +function parseCatalog(text: string): Catalog { + const parsed = JSON.parse(text) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("models.dev catalog must be an object") + } + return PublishCatalog.parse(parsed) as unknown as Catalog +} + +async function validateCatalog(catalog: Catalog) { + const runtime = await import("./provider") + const withLocalProviders = withPawWorkProviders(catalog) + for (const provider of Object.values(withLocalProviders)) { + runtime.fromModelsDevProvider(provider) + } +} + +async function atomicWriteFile(target: string, content: string) { + const temp = `${target}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + try { + await Filesystem.write(temp, content) + await rename(temp, target) + } catch (error) { + await rm(temp, { force: true }) + throw error + } +} + +async function publishCandidate(text: string) { + const catalog = parseCatalog(text) + await validateCatalog(catalog) + await atomicWriteFile(filepath, text) + catalogVersion++ + Data.reset() + return withPawWorkProviders(catalog) +} + +async function loadCandidate(text: string) { + const catalog = parseCatalog(text) + await validateCatalog(catalog) + return withPawWorkProviders(catalog) +} + export const Data = lazy(async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) + const overridePath = modelsPathOverride() + const result = await Filesystem.readJson(overridePath ?? filepath).catch(() => {}) if (result) return result // @ts-ignore const snapshot = await import("./models-snapshot.js") @@ -138,15 +261,25 @@ export const Data = lazy(async () => { if (snapshot) return snapshot if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) + const overridePath = modelsPathOverride() + const result = await Filesystem.readJson(overridePath ?? filepath).catch(() => {}) if (result) return result const result2 = await fetchApi() if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) + try { + const catalog = await loadCandidate(result2.text) + try { + await atomicWriteFile(filepath, result2.text) + catalogVersion++ + } catch (e) { + log.warn("failed to write initial models.dev catalog", { error: e }) + } + return catalog + } catch (e) { + log.warn("failed to publish initial models.dev catalog", { error: e }) + } } - return JSON.parse(result2.text) + return {} }) }) @@ -155,14 +288,39 @@ export async function get() { return withPawWorkProviders(result as Record) } +export async function getWithVersion() { + while (true) { + const before = version() + const result = await Data() + const after = version() + if (before === after) { + return { + providers: withPawWorkProviders(result as Record), + version: after, + } + } + Data.reset() + } +} + export async function refresh(force = false) { - if (skip(force)) return Data.reset() + if (modelsPathOverride()) { + catalogVersion++ + Data.reset() + return + } + if (skip(force)) { + catalogVersion++ + return Data.reset() + } await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return Data.reset() + if (skip(force)) { + catalogVersion++ + return Data.reset() + } const result = await fetchApi() if (!result.ok) return - await Filesystem.write(filepath, result.text) - Data.reset() + await publishCandidate(result.text) }).catch((e) => { log.error("Failed to fetch models.dev", { error: e, @@ -174,7 +332,9 @@ const ModelsDevModelValue = Model const ModelsDevProviderValue = Provider const ModelsDevDataValue = Data const ModelsDevGetValue = get +const ModelsDevGetWithVersionValue = getWithVersion const ModelsDevRefreshValue = refresh +const ModelsDevVersionValue = version export namespace ModelsDev { export type Model = import("./models").Model @@ -184,7 +344,9 @@ export namespace ModelsDev { export const Provider = ModelsDevProviderValue export const Data = ModelsDevDataValue export const get = ModelsDevGetValue + export const getWithVersion = ModelsDevGetWithVersionValue export const refresh = ModelsDevRefreshValue + export const version = ModelsDevVersionValue } if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ca023842c..a0c3b0c72 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -961,6 +961,7 @@ export interface Interface { } interface State { + modelsVersion: number models: Map providers: Record sdk: Map @@ -1095,8 +1096,8 @@ const layer: Layer.Layer< using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) - const database = mapValues(modelsDev, fromModelsDevProvider) + const modelsDev = yield* Effect.promise(() => ModelsDev.getWithVersion()) + const database = mapValues(modelsDev.providers, fromModelsDevProvider) const providers: Record = {} as Record const languages = new Map() @@ -1173,9 +1174,14 @@ const layer: Layer.Layer< model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? - modelsDev[providerID]?.npm ?? + modelsDev.providers[providerID]?.npm ?? "@ai-sdk/openai-compatible", - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", + url: + model.provider?.api ?? + provider?.api ?? + existingModel?.api.url ?? + modelsDev.providers[providerID]?.api ?? + "", }, status: model.status ?? existingModel?.status ?? "active", name, @@ -1397,6 +1403,7 @@ const layer: Layer.Layer< } return { + modelsVersion: modelsDev.version, models: languages, providers, sdk, @@ -1406,7 +1413,17 @@ const layer: Layer.Layer< }), ) - const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + const currentState = Effect.fn("Provider.currentState")(function* () { + const existing = yield* InstanceState.get(state) + if (existing.modelsVersion === ModelsDev.version()) return existing + yield* InstanceState.invalidate(state) + return yield* InstanceState.get(state) + }) + + const list = Effect.fn("Provider.list")(function* () { + const s = yield* currentState() + return s.providers + }) async function resolveSDK(model: Model, s: State, envs: Record) { try { @@ -1545,12 +1562,13 @@ const layer: Layer.Layer< } } - const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => - InstanceState.use(state, (s) => s.providers[providerID]), - ) + const getProvider = Effect.fn("Provider.getProvider")(function* (providerID: ProviderID) { + const s = yield* currentState() + return s.providers[providerID] + }) const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { - const s = yield* InstanceState.get(state) + const s = yield* currentState() const provider = s.providers[providerID] if (!provider) { const available = Object.keys(s.providers) @@ -1568,7 +1586,7 @@ const layer: Layer.Layer< }) const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { - const s = yield* InstanceState.get(state) + const s = yield* currentState() const envs = yield* env.all() const key = `${model.providerID}/${model.id}` if (s.models.has(key)) return s.models.get(key)! @@ -1588,6 +1606,16 @@ const layer: Layer.Layer< } const provider = s.providers[model.providerID] + if (!provider) { + const available = Object.keys(s.providers) + const matches = fuzzysort.go(model.providerID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ + providerID: model.providerID, + modelID: model.id, + suggestions: matches.map((m) => m.target), + }) + } + const sdk = await resolveSDK(model, s, envs) try { @@ -1614,7 +1642,7 @@ const layer: Layer.Layer< }) const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { - const s = yield* InstanceState.get(state) + const s = yield* currentState() const provider = s.providers[providerID] if (!provider) return undefined for (const item of query) { @@ -1633,7 +1661,7 @@ const layer: Layer.Layer< return yield* getModel(parsed.providerID, parsed.modelID) } - const s = yield* InstanceState.get(state) + const s = yield* currentState() const provider = s.providers[providerID] if (!provider) return undefined @@ -1685,7 +1713,7 @@ const layer: Layer.Layer< const cfg = yield* config.get() if (cfg.model) return parseModel(cfg.model) - const s = yield* InstanceState.get(state) + const s = yield* currentState() const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => { if (!isRecord(x) || !Array.isArray(x.recent)) return [] diff --git a/packages/opencode/test/provider/models-refresh.test.ts b/packages/opencode/test/provider/models-refresh.test.ts new file mode 100644 index 000000000..c9fd30915 --- /dev/null +++ b/packages/opencode/test/provider/models-refresh.test.ts @@ -0,0 +1,329 @@ +import { afterEach, expect, test } from "bun:test" +import { mkdir, readFile, rm, writeFile } from "fs/promises" +import path from "path" + +import { tmpdir } from "../fixture/fixture" +import { makeRuntime } from "../../src/effect/run-service" +import { Env } from "../../src/env" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { ModelsDev, Provider } from "../../src/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Filesystem } from "../../src/util/filesystem" + +const originalFetch = globalThis.fetch +const originalModelsPath = process.env.OPENCODE_MODELS_PATH +const originalWrite = Filesystem.write +const env = makeRuntime(Env.Service, Env.defaultLayer) +const setEnv = (key: string, value: string) => env.runSync((svc) => svc.set(key, value)) + +afterEach(async () => { + globalThis.fetch = originalFetch + ;(Filesystem as { write: typeof Filesystem.write }).write = originalWrite + if (originalModelsPath === undefined) delete process.env.OPENCODE_MODELS_PATH + else process.env.OPENCODE_MODELS_PATH = originalModelsPath + ModelsDev.Data.reset() + await rm(cachePath(), { force: true }) +}) + +function cachePath() { + return path.join(Global.Path.cache, "models.json") +} + +async function writeCache(catalog: Record) { + await mkdir(path.dirname(cachePath()), { recursive: true }) + await writeFile(cachePath(), JSON.stringify(catalog)) +} + +async function readCacheText() { + return await readFile(cachePath(), "utf8") +} + +function asFetch(fn: (...args: Parameters) => Promise) { + return fn as unknown as typeof fetch +} + +function mockFetchWithCatalog(catalog: Record) { + globalThis.fetch = asFetch(async () => new Response(JSON.stringify(catalog), { status: 200 })) +} + +function catalogWithModels(modelIDs: string[]) { + return { + "moonshotai-cn": { + id: "moonshotai-cn", + name: "Moonshot AI China", + npm: "@ai-sdk/openai-compatible", + api: "https://api.moonshot.cn/v1", + env: ["MOONSHOT_API_KEY"], + models: Object.fromEntries(modelIDs.map((id) => [id, model(id)])), + }, + } +} + +function model(id: string) { + return { + id, + name: id, + release_date: "2026-04-21", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 4096 }, + modalities: { input: ["text"], output: ["text"] }, + } +} + +async function withTestInstance(fn: () => Promise) { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + setEnv("MOONSHOT_API_KEY", "test-key") + }, + fn, + }) +} + +const moonshotProviderID = ProviderID.make("moonshotai-cn") +const kimi26ModelID = ModelID.make("kimi-k2.6") + +test("refresh publishes a valid candidate catalog", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + mockFetchWithCatalog(catalogWithModels(["kimi-k2.5", "kimi-k2.6"])) + + const before = ModelsDev.version() + await ModelsDev.refresh(true) + + expect(await readCacheText()).toContain("kimi-k2.6") + expect(ModelsDev.version()).toBe(before + 1) +}) + +test("refresh keeps existing cache when candidate JSON is invalid", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + const beforeCache = await readCacheText() + const beforeVersion = ModelsDev.version() + globalThis.fetch = asFetch(async () => new Response("not json", { status: 200 })) + + await ModelsDev.refresh(true) + + expect(await readCacheText()).toBe(beforeCache) + expect(ModelsDev.version()).toBe(beforeVersion) +}) + +test("refresh keeps existing cache when candidate catalog cannot become runtime providers", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + const beforeCache = await readCacheText() + const beforeVersion = ModelsDev.version() + const invalidCatalog: any = catalogWithModels(["kimi-k2.6"]) + delete invalidCatalog["moonshotai-cn"].models["kimi-k2.6"].limit + mockFetchWithCatalog(invalidCatalog) + + await ModelsDev.refresh(true) + + expect(await readCacheText()).toBe(beforeCache) + expect(ModelsDev.version()).toBe(beforeVersion) +}) + +test("refresh keeps existing cache when candidate catalog has invalid field types", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + const beforeCache = await readCacheText() + const beforeVersion = ModelsDev.version() + const invalidCatalog: any = catalogWithModels(["kimi-k2.6"]) + invalidCatalog["moonshotai-cn"].models["kimi-k2.6"].temperature = "true" + mockFetchWithCatalog(invalidCatalog) + + await ModelsDev.refresh(true) + + expect(await readCacheText()).toBe(beforeCache) + expect(ModelsDev.version()).toBe(beforeVersion) +}) + +test("refresh keeps existing cache when network fetch fails", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + const beforeCache = await readCacheText() + const beforeVersion = ModelsDev.version() + globalThis.fetch = asFetch(async () => { + throw new Error("network down") + }) + + await expect(ModelsDev.refresh(true)).resolves.toBeUndefined() + + expect(await readCacheText()).toBe(beforeCache) + expect(ModelsDev.version()).toBe(beforeVersion) +}) + +test("refresh keeps existing cache when HTTP response is not successful", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + const beforeCache = await readCacheText() + const beforeVersion = ModelsDev.version() + globalThis.fetch = asFetch(async () => new Response("server down", { status: 500 })) + + await ModelsDev.refresh(true) + + expect(await readCacheText()).toBe(beforeCache) + expect(ModelsDev.version()).toBe(beforeVersion) +}) + +test("refresh keeps existing cache when atomic publish write fails", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + const beforeCache = await readCacheText() + const beforeVersion = ModelsDev.version() + mockFetchWithCatalog(catalogWithModels(["kimi-k2.5", "kimi-k2.6"])) + ;(Filesystem as { write: typeof Filesystem.write }).write = async (target, content, mode) => { + if (target.endsWith(".tmp")) throw new Error("write failed") + return originalWrite(target, content, mode) + } + + await ModelsDev.refresh(true) + + expect(await readCacheText()).toBe(beforeCache) + expect(ModelsDev.version()).toBe(beforeVersion) +}) + +test("refresh does not publish to normal cache when models path override is set", async () => { + await using tmp = await tmpdir() + const overridePath = path.join(tmp.path, "models.json") + await writeFile(overridePath, JSON.stringify(catalogWithModels(["kimi-k2.5"]))) + process.env.OPENCODE_MODELS_PATH = overridePath + mockFetchWithCatalog(catalogWithModels(["kimi-k2.5", "kimi-k2.6"])) + const beforeVersion = ModelsDev.version() + + await ModelsDev.refresh(true) + + await expect(readFile(cachePath(), "utf8")).rejects.toThrow() + expect(ModelsDev.version()).toBe(beforeVersion + 1) +}) + +test("provider state rebuilds after models path override refresh", async () => { + await using tmp = await tmpdir() + const overridePath = path.join(tmp.path, "models.json") + await writeFile(overridePath, JSON.stringify(catalogWithModels(["kimi-k2.5"]))) + process.env.OPENCODE_MODELS_PATH = overridePath + ModelsDev.Data.reset() + + await withTestInstance(async () => { + const before = await Provider.list() + expect(before[moonshotProviderID]?.models[kimi26ModelID]).toBeUndefined() + + await writeFile(overridePath, JSON.stringify(catalogWithModels(["kimi-k2.5", "kimi-k2.6"]))) + await ModelsDev.refresh(true) + + const model = await Provider.getModel(moonshotProviderID, kimi26ModelID) + expect(model.id).toBe(kimi26ModelID) + }) +}) + +test("provider state rebuilds after a successful catalog refresh", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + ModelsDev.Data.reset() + + await withTestInstance(async () => { + const before = await Provider.list() + expect(before[moonshotProviderID]?.models[kimi26ModelID]).toBeUndefined() + + mockFetchWithCatalog(catalogWithModels(["kimi-k2.5", "kimi-k2.6"])) + await ModelsDev.refresh(true) + + const model = await Provider.getModel(moonshotProviderID, kimi26ModelID) + expect(model.id).toBe(kimi26ModelID) + + const after = await Provider.list() + expect(after[moonshotProviderID]?.models[kimi26ModelID]).toBeDefined() + }) +}) + +test("provider state rebuilds when refresh observes an already fresh cache from another process", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + ModelsDev.Data.reset() + + await withTestInstance(async () => { + const before = await Provider.list() + expect(before[moonshotProviderID]?.models[kimi26ModelID]).toBeUndefined() + + await writeCache(catalogWithModels(["kimi-k2.5", "kimi-k2.6"])) + await ModelsDev.refresh(false) + + const model = await Provider.getModel(moonshotProviderID, kimi26ModelID) + expect(model.id).toBe(kimi26ModelID) + }) +}) + +test("getLanguage reports ModelNotFoundError when refreshed catalog removes the provider", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + ModelsDev.Data.reset() + + await withTestInstance(async () => { + const oldModel = await Provider.getModel(moonshotProviderID, ModelID.make("kimi-k2.5")) + + mockFetchWithCatalog({}) + await ModelsDev.refresh(true) + + await expect(Provider.getLanguage(oldModel)).rejects.toThrow("ProviderModelNotFoundError") + }) +}) + +test("getLanguage reports provider suggestions after refreshed catalog replaces the provider", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + ModelsDev.Data.reset() + + await withTestInstance(async () => { + const oldModel = await Provider.getModel(moonshotProviderID, ModelID.make("kimi-k2.5")) + mockFetchWithCatalog({ + "moonshotai-cn-next": { + id: "moonshotai-cn-next", + name: "Moonshot AI China Next", + npm: "@ai-sdk/openai-compatible", + api: "https://api.moonshot.cn/v1", + env: ["MOONSHOT_API_KEY"], + models: { + "kimi-k2.6": model("kimi-k2.6"), + }, + }, + }) + await ModelsDev.refresh(true) + + const error = await Provider.getLanguage(oldModel).catch((error) => error) + expect(error.name).toBe("ProviderModelNotFoundError") + expect(error.data).toMatchObject({ + providerID: moonshotProviderID, + modelID: oldModel.id, + suggestions: ["moonshotai-cn-next"], + }) + }) +}) + +test("connected provider overlay uses refreshed provider state", async () => { + delete process.env.OPENCODE_MODELS_PATH + await writeCache(catalogWithModels(["kimi-k2.5"])) + ModelsDev.Data.reset() + + await withTestInstance(async () => { + await Provider.list() + + mockFetchWithCatalog(catalogWithModels(["kimi-k2.5", "kimi-k2.6"])) + await ModelsDev.refresh(true) + + const allProviders = await ModelsDev.get() + const connected = await Provider.list() + const merged = Object.assign( + Object.fromEntries( + Object.entries(allProviders).map(([id, provider]) => [id, Provider.fromModelsDevProvider(provider)]), + ), + connected, + ) + + expect(merged[moonshotProviderID]?.models[kimi26ModelID]).toBeDefined() + }) +})