diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 105d40eecab..8a29299c39a 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -375,6 +375,12 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.type === "provider.models.updated") { + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", + }) + } + if (directory === "global") { applyGlobalEvent({ event, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 86515068d46..35c46355858 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,9 +28,11 @@ import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { ProviderTransform } from "./transform" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { EventV2 } from "@opencode-ai/core/event" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" +import { EventV2Bridge } from "@/event-v2-bridge" const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 @@ -1045,6 +1047,13 @@ export const ConfigProvidersResult = Schema.Struct({ }) export type ConfigProvidersResult = Types.DeepMutable> +export const Event = { + ModelsUpdated: EventV2.define({ + type: "provider.models.updated", + schema: { providerID: ProviderV2.ID }, + }), +} + export function toPublicInfo(provider: Info): Info { return JSON.parse( JSON.stringify(provider, (_, value) => { @@ -1279,6 +1288,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make(() => Effect.gen(function* () { @@ -1334,35 +1344,15 @@ export const layer = Layer.effect( return true } - for (const hook of plugins) { - const p = hook.provider - const models = p?.models - if (!p || !models) continue - - const providerID = ProviderV2.ID.make(p.id) - if (disabled.has(providerID)) continue - - const provider = database[providerID] - if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - - provider.models = yield* Effect.promise(async () => { - const next = await models(toPublicInfo(provider), { auth: pluginAuth }) - return Object.fromEntries( - Object.entries(next).map(([id, model]) => [ - id, - { - ...model, - id: ModelV2.ID.make(id), - providerID, - }, - ]), - ) - }) - } - + const providerHooks = plugins.flatMap((hook) => { + const provider = hook.provider + if (!provider?.models) return [] + const providerID = ProviderV2.ID.make(provider.id) + if (!isProviderAllowed(providerID) || !database[providerID]) return [] + return [{ providerID, models: provider.models }] + }) // extend database from config - for (const [providerID, provider] of configProviders) { + function applyConfigProvider(providerID: string, provider: (typeof configProviders)[number][1]) { const existing = database[providerID] const parsed: Info = { id: ProviderV2.ID.make(providerID), @@ -1454,6 +1444,7 @@ export const layer = Layer.effect( } database[providerID] = parsed } + for (const [providerID, provider] of configProviders) applyConfigProvider(providerID, provider) // load env const envs = yield* env.all() @@ -1544,13 +1535,7 @@ export const layer = Layer.effect( }) } - for (const [id, provider] of Object.entries(providers)) { - const providerID = ProviderV2.ID.make(id) - if (!isProviderAllowed(providerID)) { - delete providers[providerID] - continue - } - + function finalizeProvider(providerID: ProviderV2.ID, provider: Info) { const configProvider = cfg.provider?.[providerID] for (const [modelID, model] of Object.entries(provider.models)) { @@ -1586,14 +1571,26 @@ export const layer = Layer.effect( ) } } + } - if (Object.keys(provider.models).length === 0) { + const refreshProviders = new Map( + [...new Set(providerHooks.map((hook) => hook.providerID))].flatMap((providerID) => { + const provider = providers[providerID] + return provider ? [[providerID, { ...provider, models: {} }] as const] : [] + }), + ) + + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderV2.ID.make(id) + if (!isProviderAllowed(providerID)) { delete providers[providerID] continue } + finalizeProvider(providerID, provider) + if (Object.keys(provider.models).length === 0) delete providers[providerID] } - return { + const result = { models: languages, providers, catalog, @@ -1601,6 +1598,41 @@ export const layer = Layer.effect( modelLoaders, varsLoaders, } + + yield* Effect.forEach(providerHooks, ({ providerID, models }) => + Effect.gen(function* () { + const provider = database[providerID] + if (!provider) return + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + const next = yield* Effect.promise(() => models(toPublicInfo(provider), { auth: pluginAuth })) + provider.models = Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelV2.ID.make(id), + providerID, + }, + ]), + ) + const configProvider = configProviders.find(([id]) => id === providerID) + if (configProvider) applyConfigProvider(...configProvider) + const current = providers[providerID] ?? refreshProviders.get(providerID) + if (!current) return + const nextProvider = { ...current, models: database[providerID].models } + finalizeProvider(providerID, nextProvider) + if (Object.keys(nextProvider.models).length === 0) delete providers[providerID] + else providers[providerID] = nextProvider + for (const key of languages.keys()) { + if (key.startsWith(`${providerID}/`)) languages.delete(key) + } + yield* events.publish(Event.ModelsUpdated, { providerID }) + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("Failed to refresh provider models", { providerID, cause })), + ), + ).pipe(Effect.forkScoped) + + return result }), ) @@ -1928,6 +1960,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), ), ) @@ -1957,6 +1990,7 @@ export const node = LayerNode.make(layer, [ Plugin.node, ModelsDev.node, RuntimeFlags.node, + EventV2Bridge.node, ]) export * as Provider from "./provider" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca06..e020f991d00 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -20,6 +20,7 @@ import { InstanceLayer } from "@/project/instance-layer" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { EventV2Bridge } from "@/event-v2-bridge" const originalEnv = new Map() @@ -65,6 +66,7 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), + Layer.provide(EventV2Bridge.defaultLayer), ) const list = Provider.use.list() diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index d17d7f8e5ab..fdc8182cbab 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -26,6 +26,7 @@ import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { EventV2Bridge } from "@/event-v2-bridge" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") @@ -270,6 +271,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const metadata = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4ac8dbe3702..a3066dac350 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -77,6 +77,7 @@ export type Event = | EventTuiSessionSelect2 | EventMcpToolsChanged | EventMcpBrowserOpenFailed + | EventProviderModelsUpdated | EventCommandExecuted | EventProjectDirectoriesUpdated | EventProjectUpdated @@ -1495,6 +1496,13 @@ export type GlobalEvent = { url: string } } + | { + id: string + type: "provider.models.updated" + properties: { + providerID: string + } + } | { id: string type: "command.executed" @@ -5138,6 +5146,14 @@ export type EventMcpBrowserOpenFailed = { } } +export type EventProviderModelsUpdated = { + id: string + type: "provider.models.updated" + properties: { + providerID: string + } +} + export type EventCommandExecuted = { id: string type: "command.executed" diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 4882c13920f..4766083cf75 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -159,11 +159,26 @@ export const { .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } + async function refreshProviders(workspace: string | undefined) { + const [providers, providerList] = await Promise.all([ + sdk.client.config.providers({ workspace }, { throwOnError: true }).then((x) => x.data), + sdk.client.provider.list({ workspace }, { throwOnError: true }).then((x) => x.data), + ]) + batch(() => { + setStore("provider", reconcile(providers.providers)) + setStore("provider_default", reconcile(providers.default)) + setStore("provider_next", reconcile(providerList)) + }) + } + event.subscribe((event, { workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() break + case "provider.models.updated": + void refreshProviders(workspace) + break case "permission.replied": { const requests = store.permission[event.properties.sessionID] if (!requests) break