From adcc36d6d1612ce64b0d0dfacdd6496ac424093b Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Sun, 19 Jul 2026 16:51:10 +0200 Subject: [PATCH] fix(opencode): restore desktop provider catalog --- packages/core/src/models-dev.ts | 15 +- packages/core/test/models.test.ts | 22 +- packages/opencode/src/provider/provider.ts | 258 ++++++++---------- .../instance/httpapi/handlers/provider.ts | 19 +- .../opencode/test/provider/provider.test.ts | 176 +++++------- 5 files changed, 222 insertions(+), 268 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0caf414cce64..b67d1bffd8c8 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -77,9 +77,14 @@ type SourceProvider = { readonly models: Readonly> } +type SnapshotModel = ModelV2.Info & { + readonly capabilities: ModelV2.Info["capabilities"] & + Partial> +} + export type Snapshot = { readonly info: ProviderV2.Info - readonly models: readonly ModelV2.Info[] + readonly models: readonly SnapshotModel[] readonly environment: readonly string[] } @@ -93,7 +98,7 @@ function normalize(input: Record): readonly Snapshot[] { package: ProviderV2.aisdk(item.npm), ...(item.api ? { settings: { baseURL: item.api } } : {}), } satisfies ProviderV2.Info - const models: ModelV2.Info[] = [] + const models: SnapshotModel[] = [] for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) const variants = reasoningVariants(item, model) @@ -492,7 +497,7 @@ function modelInfo( readonly request?: NonNullable["modes"]>[string]["provider"] readonly variants?: NonNullable } = {}, -): ModelV2.Info { +): SnapshotModel { return { id, modelID: ModelV2.ID.make(model.id), @@ -503,6 +508,10 @@ function modelInfo( settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, capabilities: { tools: model.tool_call, + temperature: model.temperature, + reasoning: model.reasoning, + attachment: model.attachment, + interleaved: model.interleaved, input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], }, diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 638cfc463ea1..0b07950b9ce6 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -43,7 +43,7 @@ const fixture = { id: "acme-1", name: "Acme One", release_date: "2026-01-01", - attachment: false, + attachment: true, reasoning: false, temperature: true, tool_call: true, @@ -69,7 +69,15 @@ const fixtureSnapshot = [ family: undefined, package: undefined, settings: undefined, - capabilities: { tools: true, input: [], output: [] }, + capabilities: { + tools: true, + temperature: true, + reasoning: false, + attachment: true, + interleaved: undefined, + input: [], + output: [], + }, variants: [], time: { released: Date.parse("2026-01-01") }, cost: [ @@ -130,7 +138,15 @@ const fixture2Snapshot = [ family: undefined, package: undefined, settings: undefined, - capabilities: { tools: false, input: [], output: [] }, + capabilities: { + tools: false, + temperature: false, + reasoning: true, + attachment: false, + interleaved: undefined, + input: [], + output: [], + }, variants: [], time: { released: Date.parse("2026-02-01") }, cost: [ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f70a9ac3c141..8d8c34fbab9a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1171,140 +1171,112 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) -// @ts-expect-error dead V1 consumes the removed pre-normalized ModelsDev model type. -function cost(c: ModelsDev.Model["cost"]): Model["cost"] { - const result: Model["cost"] = { - input: c?.input ?? 0, - output: c?.output ?? 0, - cache: { - read: c?.cache_read ?? 0, - write: c?.cache_write ?? 0, - }, - } - if (c?.tiers) { - // @ts-expect-error dead V1 relies on the removed ModelsDev cost tier context. - result.tiers = c.tiers.map((item) => ({ - input: item.input, - output: item.output, - cache: { - read: item.cache_read ?? 0, - write: item.cache_write ?? 0, - }, - tier: item.tier, - })) - } - if (c?.context_over_200k) { - result.experimentalOver200K = { - cache: { - read: c.context_over_200k.cache_read ?? 0, - write: c.context_over_200k.cache_write ?? 0, - }, - input: c.context_over_200k.input, - output: c.context_over_200k.output, - } - } - return result -} - -// @ts-expect-error dead V1 consumes removed provider and model types instead of snapshots. -function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { - const base: Model = { - id: ModelV2.ID.make(model.id), - providerID: ProviderV2.ID.make(provider.id), - name: model.name, - family: model.family, - api: { - id: model.id, - url: model.provider?.api ?? provider.api ?? "", - npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", - }, - status: model.status ?? "active", - headers: {}, - options: {}, - cost: cost(model.cost), - limit: { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - }, - capabilities: { - temperature: model.temperature ?? false, - reasoning: model.reasoning ?? false, - attachment: model.attachment ?? false, - toolcall: model.tool_call ?? true, - input: { - text: model.modalities?.input?.includes("text") ?? false, - audio: model.modalities?.input?.includes("audio") ?? false, - image: model.modalities?.input?.includes("image") ?? false, - video: model.modalities?.input?.includes("video") ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? false, - }, - output: { - text: model.modalities?.output?.includes("text") ?? false, - audio: model.modalities?.output?.includes("audio") ?? false, - image: model.modalities?.output?.includes("image") ?? false, - video: model.modalities?.output?.includes("video") ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? false, - }, - interleaved: model.interleaved ?? false, - }, - release_date: model.release_date ?? "", - variants: {}, - } - +export function fromModelsDevSnapshot(snapshot: ModelsDev.Snapshot): Info { + const providerURL = baseURL(snapshot.info.settings) ?? "" + const providerPackage = ProviderV2.packageName(snapshot.info.package) ?? "@ai-sdk/openai-compatible" return { - ...base, - variants: mapValues(ProviderTransform.variants(base), (v) => v), - } -} - -// @ts-expect-error dead V1 consumes the removed pre-normalized ModelsDev provider type. -export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { - const models: Record = {} - for (const [key, model] of Object.entries(provider.models)) { - models[key] = fromModelsDevModel(provider, model) - // @ts-expect-error dead V1 expects raw model metadata inside a provider instead of normalized snapshots. - for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { - // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data. - const id = `${model.id}-${mode}` - const base = fromModelsDevModel(provider, model) - const providerOptions = ( - opts as { - readonly provider?: { - readonly body?: Record - readonly headers?: Record - } - } - ).provider - models[id] = { - ...base, - id: ModelV2.ID.make(id), - // @ts-expect-error dead V1 expects raw model names and mode options. - name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, - // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data. - cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, - options: modeOptions(base, providerOptions?.body), - headers: providerOptions?.headers ?? base.headers, - } - } - } - return { - id: ProviderV2.ID.make(provider.id), + id: snapshot.info.id, source: "custom", - name: provider.name, - env: [...(provider.env ?? [])], + name: snapshot.info.name, + env: [...snapshot.environment], options: {}, - models, + models: Object.fromEntries( + snapshot.models.map((model) => { + const [base, ...tiers] = model.cost + const over200K = tiers.find((tier) => tier.tier?.type === "context" && tier.tier.size === 200_000) + const api = { + id: model.modelID, + url: baseURL(model.settings) ?? providerURL, + npm: ProviderV2.packageName(model.package) ?? providerPackage, + } + const result: Model = { + id: model.id, + providerID: snapshot.info.id, + name: model.name, + family: model.family, + api, + status: model.status, + headers: model.headers ? { ...model.headers } : {}, + options: modeOptions(api.npm, model.body), + cost: { + input: base?.input ?? 0, + output: base?.output ?? 0, + cache: { + read: base?.cache.read ?? 0, + write: base?.cache.write ?? 0, + }, + ...(over200K + ? { + experimentalOver200K: { + input: over200K.input, + output: over200K.output, + cache: { read: over200K.cache.read, write: over200K.cache.write }, + }, + } + : {}), + tiers: tiers.flatMap((tier) => + tier.tier + ? [ + { + input: tier.input, + output: tier.output, + cache: { read: tier.cache.read, write: tier.cache.write }, + tier: tier.tier, + }, + ] + : [], + ), + }, + limit: { ...model.limit }, + capabilities: { + temperature: model.capabilities.temperature ?? false, + reasoning: model.capabilities.reasoning ?? model.variants.length > 0, + attachment: model.capabilities.attachment ?? model.capabilities.input.some((input) => input !== "text"), + toolcall: model.capabilities.tools, + input: { + text: model.capabilities.input.includes("text"), + audio: model.capabilities.input.includes("audio"), + image: model.capabilities.input.includes("image"), + video: model.capabilities.input.includes("video"), + pdf: model.capabilities.input.includes("pdf"), + }, + output: { + text: model.capabilities.output.includes("text"), + audio: model.capabilities.output.includes("audio"), + image: model.capabilities.output.includes("image"), + video: model.capabilities.output.includes("video"), + pdf: model.capabilities.output.includes("pdf"), + }, + interleaved: model.capabilities.interleaved ?? false, + }, + release_date: model.time.released ? new Date(model.time.released).toISOString().slice(0, 10) : "", + variants: {}, + } + const variants = Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])) + return [ + model.id, + { + ...result, + variants: Object.keys(variants).length ? variants : ProviderTransform.variants(result), + }, + ] + }), + ), } } -function modeOptions(model: Model, body: Record | undefined) { - if (!body) return model.options +function baseURL(settings: Readonly> | undefined) { + const value = settings?.baseURL + return typeof value === "string" ? value : undefined +} + +function modeOptions(npm: string, body: Record | undefined) { + if (!body) return {} const options = Object.fromEntries( Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]), ) const reasoning = body.reasoning - if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options + if (npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options const { reasoning: _, ...rest } = options return { ...rest, reasoningMode: reasoning.mode } } @@ -1354,7 +1326,9 @@ const layer = Layer.effect( const bridge = yield* EffectBridge.make() const cfg = yield* config.get() const modelsDev = yield* modelsDevSvc.get() - const catalog = mapValues(modelsDev, fromModelsDevProvider) + const catalog = Object.fromEntries( + modelsDev.map((snapshot) => [snapshot.info.id, fromModelsDevSnapshot(snapshot)]), + ) as Record const database = mapValues(catalog, toPublicInfo) const providers: Record = {} as Record @@ -1379,14 +1353,12 @@ const layer = Layer.effect( function mergeProvider(providerID: ProviderV2.ID, provider: Partial) { const existing = providers[providerID] if (existing) { - // @ts-expect-error dead V1 merges partial provider records with legacy semantics. - providers[providerID] = mergeDeep(existing, provider) + providers[providerID] = mergeDeep(existing, provider) as Info return } - // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const match = database[providerID] if (!match) return - providers[providerID] = mergeDeep(match, provider) + providers[providerID] = mergeDeep(match, provider) as Info } // load plugins first so config() hook runs before reading cfg.provider @@ -1411,7 +1383,6 @@ const layer = Layer.effect( const providerID = ProviderV2.ID.make(p.id) if (disabled.has(providerID)) continue - // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const provider = database[providerID] if (!provider) continue const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) @@ -1432,12 +1403,13 @@ const layer = Layer.effect( } // extend database from config - for (const [providerID, provider] of configProviders) { - // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. + for (const [id, provider] of configProviders) { + const providerID = ProviderV2.ID.make(id) const existing = database[providerID] + const snapshot = modelsDev.find((item) => item.info.id === providerID) const parsed: Info = { - id: ProviderV2.ID.make(providerID), - name: provider.name ?? existing?.name ?? providerID, + id: providerID, + name: provider.name ?? existing?.name ?? id, env: provider.env ?? existing?.env ?? [], options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), source: "config", @@ -1451,8 +1423,7 @@ const layer = Layer.effect( model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? - // @ts-expect-error dead V1 indexes normalized ModelsDev snapshots for raw provider fields. - modelsDev[providerID]?.npm ?? + ProviderV2.packageName(snapshot?.info.package) ?? "@ai-sdk/openai-compatible" const name = iife(() => { if (model.name) return model.name @@ -1464,12 +1435,16 @@ const layer = Layer.effect( api: { id: apiID, npm: apiNpm, - // @ts-expect-error dead V1 indexes normalized ModelsDev snapshots for raw provider fields. - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", + url: + model.provider?.api ?? + provider?.api ?? + existingModel?.api.url ?? + baseURL(snapshot?.info.settings) ?? + "", }, status: model.status ?? existingModel?.status ?? "active", name, - providerID: ProviderV2.ID.make(providerID), + providerID, capabilities: { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, @@ -1525,7 +1500,6 @@ const layer = Layer.effect( ) parsed.models[modelID] = parsedModel } - // @ts-expect-error dead V1 mutates a provider-keyed record, not normalized snapshots. database[providerID] = parsed } @@ -1568,8 +1542,7 @@ const layer = Layer.effect( const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. - toPublicInfo(database[plugin.auth!.provider]), + toPublicInfo(database[ProviderV2.ID.make(plugin.auth!.provider)]), ), ) const opts = options ?? {} @@ -1580,7 +1553,6 @@ const layer = Layer.effect( for (const [id, fn] of Object.entries(custom(dep))) { const providerID = ProviderV2.ID.make(id) if (disabled.has(providerID)) continue - // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const data = database[providerID] if (!data) { continue diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 19087c01f64e..8961d96a538e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -3,7 +3,6 @@ import { Config } from "@/config/config" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Provider } from "@/provider/provider" -import { mapValues } from "remeda" import { Effect, Schema } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -36,22 +35,20 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const cfg = yield* Config.Service const provider = yield* Provider.Service const svc = yield* ProviderAuth.Service + const modelsDev = yield* ModelsDev.Service const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* ModelsDev.Service.use((s) => s.get()) + const snapshots = yield* modelsDev.get() const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - // @ts-expect-error dead V1 expects a provider-keyed record, not normalized ModelsDev snapshots. - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value - } - const connected = yield* provider.list() - const providers = Object.assign( - mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), - connected, + const catalog = Object.fromEntries( + snapshots + .filter((snapshot) => (enabled ? enabled.has(snapshot.info.id) : true) && !disabled.has(snapshot.info.id)) + .map((snapshot) => [snapshot.info.id, Provider.fromModelsDevSnapshot(snapshot)]), ) + const connected = yield* provider.list() + const providers = Object.assign(catalog, connected) return { all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 964c7d5dfdf5..bd77862690a5 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -23,8 +23,7 @@ import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" - -type ModelsDevProvider = Parameters[0] +import { Money } from "@opencode-ai/schema/money" const originalEnv = new Map() @@ -1339,121 +1338,82 @@ it.instance( }, ) -test("mode options and cost are derived from the base model", () => { - const provider = { - id: "openai", - name: "OpenAI", - env: [], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { - "gpt-5.6-sol": { - id: "gpt-5.6-sol", - name: "GPT-5.6 Sol", - family: "gpt", - release_date: "2026-03-05", - attachment: true, - reasoning: true, - temperature: false, - tool_call: true, - cost: { - input: 2.5, - output: 15, - cache_read: 0.25, - context_over_200k: { - input: 5, - output: 22.5, - cache_read: 0.5, - }, - }, - limit: { - context: 1_050_000, - input: 922_000, - output: 128_000, +test("converts normalized models.dev snapshots", () => { + const snapshot = { + info: { + id: ProviderV2.ID.openai, + name: "OpenAI", + package: ProviderV2.aisdk("@ai-sdk/openai"), + settings: { baseURL: "https://api.openai.com/v1" }, + }, + environment: ["OPENAI_API_KEY"], + models: [ + { + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-test")), + modelID: ModelV2.ID.make("gpt-test-api"), + package: ProviderV2.aisdk("@ai-sdk/openai"), + capabilities: { + tools: true, + temperature: true, + reasoning: true, + attachment: true, + interleaved: { field: "reasoning_content" }, + input: ["text"], + output: ["text"], }, - experimental: { - modes: { - fast: { - cost: { - input: 5, - output: 30, - cache_read: 0.5, - }, - provider: { - body: { - service_tier: "priority", - }, - }, - }, - pro: { - provider: { - body: { - reasoning: { mode: "pro" }, - service_tier: "priority", - }, - }, - }, + body: { reasoning: { mode: "pro" }, service_tier: "priority" }, + cost: [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero }, }, - }, + { + tier: { type: "context", size: 200_000 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { read: Money.USDPerMillionTokens.make(0.3), write: Money.USDPerMillionTokens.make(0.4) }, + }, + ], }, - }, - } as unknown as ModelsDevProvider - - const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"] - expect(model.cost.input).toEqual(5) - expect(model.cost.output).toEqual(30) - expect(model.cost.cache.read).toEqual(0.5) - expect(model.cost.cache.write).toEqual(0) - expect(model.options["serviceTier"]).toEqual("priority") - const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"] - expect(pro.api.id).toEqual("gpt-5.6-sol") - expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" }) + ], + } satisfies ModelsDev.Snapshot + + const provider = Provider.fromModelsDevSnapshot(snapshot) + const model = provider.models["gpt-test"] + + expect(provider.env).toEqual(["OPENAI_API_KEY"]) + expect(model.api).toEqual({ + id: "gpt-test-api", + npm: "@ai-sdk/openai", + url: "https://api.openai.com/v1", + }) + expect(model.capabilities).toMatchObject({ + attachment: true, + reasoning: true, + temperature: true, + toolcall: true, + interleaved: { field: "reasoning_content" }, + }) + expect(model.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" }) expect(model.cost.experimentalOver200K).toEqual({ - input: 5, - output: 22.5, - cache: { read: 0.5, write: 0 }, + input: 3, + output: 4, + cache: { read: 0.3, write: 0.4 }, }) }) -test("models.dev normalization fills required response fields", () => { - const provider = { - id: "gateway", - name: "Gateway", - env: [], - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - family: "gpt", - cost: { input: 2.5, output: 15 }, - limit: { context: 1_050_000, input: 922_000, output: 128_000 }, - }, - }, - } as unknown as ModelsDevProvider - - const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"] - expect(model.api.url).toBe("") - expect(model.capabilities.temperature).toBe(false) - expect(model.capabilities.reasoning).toBe(false) - expect(model.capabilities.attachment).toBe(false) - expect(model.capabilities.toolcall).toBe(true) - expect(model.release_date).toBe("") -}) - test("public provider info omits invalid models", () => { - const provider = Provider.fromModelsDevProvider({ - id: "test", - name: "Test", - env: [], - models: { - valid: { - id: "valid", - name: "Valid", - cost: { input: 1, output: 1 }, - limit: { context: 128_000, output: 16_000 }, + const provider = Provider.fromModelsDevSnapshot({ + info: { id: ProviderV2.ID.make("test"), name: "Test", package: ProviderV2.aisdk("@ai-sdk/openai-compatible") }, + environment: [], + models: [ + { + ...ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("valid")), + capabilities: { tools: true, input: [], output: [] }, }, - }, - } as unknown as ModelsDevProvider) + ], + }) provider.models.invalid = { ...provider.models.valid, id: ModelV2.ID.make("invalid"),