From f512d00b9c8bf48476f762d3d7b29e6df5908e90 Mon Sep 17 00:00:00 2001 From: Cardin Lee <553816+cardin@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:06:18 +0800 Subject: [PATCH 1/3] fix: discover LiteLLM providers registered after plugin setup OpenCode 2 registers user-configured providers *after* plugin setup, so the one-shot provider.list() during setup only ever saw built-in providers. The documented `providers.litellm.settings.baseURL` config was therefore invisible to the plugin, which fell back to auto-detection on localhost:4000/8000/8080, registered nothing, and surfaced "Model unavailable: litellm/". Reconcile instead of snapshot: keep sources in a map keyed by provider id, re-read the provider list on `provider.updated` and on `session.created`, discover any newly visible LiteLLM-shaped provider and publish it with a registry reload. Overlapping triggers are folded into a single follow-up pass so a provider appearing mid-discovery is neither missed nor discovered twice. Verified against OpenCode 2.0.19 with a mock LiteLLM proxy: the previously broken config now discovers models and completes a chat completion, and the new regression test (which models the real ordering) fails without this change. --- src/plugin/v2.ts | 129 +++++++++++++++++++++++++++++++++-------- test/plugin-v2.test.ts | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 23 deletions(-) diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index d05b9c3..54b14e5 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -414,41 +414,77 @@ async function refreshProviderSources( await Promise.all(sources.map((source) => refreshProviderSource(context, source, inFlight))) } +/** + * LiteLLM-shaped providers the host currently exposes. + * + * OpenCode 2 registers user-configured providers *after* plugin setup, so the + * list has to be re-read on later `provider.updated` events instead of being + * trusted once at startup. Otherwise a `providers.litellm.settings.baseURL` + * config is invisible to the plugin and discovery silently falls back to + * auto-detection. + */ +async function listMatchingProviders(context: Context): Promise { + try { + const providers = (await context.provider.list()).data + return providers.filter((provider) => + isLiteLLMProvider(provider.id, asRecord(provider.settings)), + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logInfo(`[opencode-litellm] Could not read configured providers: ${message}`) + return [] + } +} + +/** + * Discover and add sources for LiteLLM providers that have appeared since the + * last reconciliation. Ids already present are left untouched, so this is safe + * to call repeatedly (startup, `provider.updated`, `session.created`). Returns + * the newly added sources so callers can reload the registry. + */ +async function reconcileProviderSources( + context: Context, + pluginOptions: Record, + sources: Map, +): Promise { + const added: ProviderSource[] = [] + for (const provider of await listMatchingProviders(context)) { + if (sources.has(provider.id)) continue + const source = await makeProviderSource(context, provider, pluginOptions) + if (!source) continue + sources.set(source.id, source) + added.push(source) + } + return added +} + const definition = Plugin.define({ id: 'opencode-litellm', async setup(context) { initV2Logging() const pluginOptions = asRecord(context.options) - let providers: ProviderInfo[] = [] - try { - providers = (await context.provider.list()).data - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logInfo(`[opencode-litellm] Could not read configured providers: ${message}`) - } - - const matchingProviders = providers.filter((provider) => - isLiteLLMProvider(provider.id, asRecord(provider.settings)), - ) - const candidates: Array = matchingProviders.length - ? matchingProviders - : [undefined] - const sources: ProviderSource[] = [] - - for (const candidate of candidates) { - const source = await makeProviderSource(context, candidate, pluginOptions) - if (source) sources.push(source) + const sources = new Map() + + // First pass. On OpenCode 2 the host has not registered user-configured + // providers yet, so this typically finds only built-ins; the event loop + // below reconciles again when they appear. + await reconcileProviderSources(context, pluginOptions, sources) + // With no configured provider, fall back to purely option/env-driven + // discovery so `LITELLM_BASE_URL` and plugin options keep working. + if (sources.size === 0) { + const fallback = await makeProviderSource(context, undefined, pluginOptions) + if (fallback) sources.set(fallback.id, fallback) } - if (sources.length === 0) { + if (sources.size === 0) { logInfo( '[opencode-litellm] No LiteLLM proxy found. Configure providers.litellm.settings.baseURL or start LiteLLM on port 4000/8000/8080.', ) } const providerRegistration = await context.provider.transform((editor) => { - for (const source of sources) { + for (const source of sources.values()) { const current = editor.get(source.id) if (current) { editor.update(source.id, (provider) => { @@ -492,12 +528,56 @@ const definition = Plugin.define({ const controller = new AbortController() const inFlight = new Map>() + + // Reconciliation is kicked off from the event loop, which must not block on + // discovery. Overlapping triggers are folded into one follow-up pass, so a + // provider that shows up mid-discovery is neither missed nor discovered + // twice. + let reconcileRunning: Promise | null = null + let reconcileAgain = false + + const reconcileOnce = async (): Promise => { + const added = await reconcileProviderSources(context, pluginOptions, sources) + if (added.length === 0) return + try { + await context.provider.reload() + logInfo( + `[opencode-litellm] Registered ${added.length} newly configured LiteLLM provider(s).`, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logInfo( + `[opencode-litellm] Could not reload providers after discovering a new provider: ${message}`, + ) + } + } + + const reconcileAndReload = async (): Promise => { + if (reconcileRunning) { + reconcileAgain = true + return reconcileRunning + } + reconcileRunning = (async () => { + do { + reconcileAgain = false + await reconcileOnce() + } while (reconcileAgain) + })().finally(() => { + reconcileRunning = null + }) + return reconcileRunning + } + void (async () => { try { for await (const event of context.event.subscribe({ signal: controller.signal })) { + if (event.type === 'provider.updated') { + void reconcileAndReload() + continue + } if (event.type === 'credential.switched') { const changedSources = (await Promise.all( - sources + [...sources.values()] .filter((source) => source.integrationID === event.data.integrationID) .map(async (source) => (await refreshConnectionCredential(context, source)) ? source : undefined, @@ -513,7 +593,10 @@ const definition = Plugin.define({ continue } if (event.type !== 'session.created') continue - void refreshProviderSources(context, sources, inFlight) + void (async () => { + await reconcileAndReload() + await refreshProviderSources(context, [...sources.values()], inFlight) + })() } } catch (error) { if (controller.signal.aborted) return diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 9185d4d..1441092 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -386,6 +386,119 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('registers providers that only appear after setup (provider.updated)', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-late-provider-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.startsWith('http://127.0.0.1:44445')) { + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: [{ id: 'anthropic/claude-3-5-sonnet', object: 'model' }] }), + { status: 200 }, + ) + } + // Nothing is listening on the auto-detection ports in this scenario, so + // the setup-time fallback cannot rescue an unseen provider. + return new Response('not found', { status: 503 }) + }) + const fetchMock = globalThis.fetch as ReturnType + + // Real OpenCode 2.0.x ordering: the provider is absent during plugin setup + // and only becomes visible afterwards. + let providerListCalls = 0 + const configuredProvider = { + id: 'litellm', + name: 'LiteLLM (proxy)', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { baseURL: 'http://127.0.0.1:44445/v1' }, + headers: {}, + } + const providerList = vi.fn(async () => { + providerListCalls += 1 + return { data: providerListCalls === 1 ? [] : [configuredProvider] } + }) + + const registered: Array<{ info: Record; models: Array> }> = [] + let providerTransform: ((editor: unknown) => void) | undefined + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; models: Array> }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { + set: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + } + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + + let releaseEvents!: () => void + const eventsGate = new Promise((resolve) => { + releaseEvents = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: {}, + provider: { + list: providerList, + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + await eventsGate + // A burst: the host can emit several updates back to back. + yield { type: 'provider.updated' } + yield { type: 'provider.updated' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + // The provider was invisible at setup and nothing could be registered. + expect(registered).toHaveLength(0) + + releaseEvents() + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + expect(registered).toHaveLength(1) + expect(registered[0].info).toMatchObject({ id: 'litellm', activation: 'enabled' }) + expect(registered[0].info.settings).toMatchObject({ + baseURL: 'http://127.0.0.1:44445/v1', + }) + expect(registered[0].models.map((model) => model.id)).toEqual([ + 'anthropic/claude-3-5-sonnet', + ]) + + // The second trigger must not re-discover or re-publish the same provider. + // By the time the provider list has been read three times (setup plus one + // pass per trigger) both passes have settled. + await vi.waitFor(() => expect(providerList).toHaveBeenCalledTimes(3)) + expect(reload).toHaveBeenCalledTimes(1) + // One health check plus the parallel models/model-info pair. + const discoveryCalls = fetchMock.mock.calls + .map((call) => String(call[0])) + .filter((url) => url.startsWith('http://127.0.0.1:44445')) + expect(discoveryCalls).toHaveLength(3) + expect(registered).toHaveLength(1) + await cleanup?.() + }) + it('retries discovery and provider reload after a failed registry reload', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-retry-test-')) process.env.XDG_CACHE_HOME = cacheDirectory From bd3880d625a2139068bdbcdfcc9ae822b0b16dd8 Mon Sep 17 00:00:00 2001 From: Yusef Mohamadi Date: Wed, 30 Sep 2026 00:17:03 +0200 Subject: [PATCH 2/3] fix: replace fallback source when configured provider appears, retry failed publication Addresses review feedback on #35: - Replace an option/env/port fallback when a host-registered provider with the same id appears, so providers..settings.baseURL wins instead of being skipped (CodeRabbit inline #1 / review item 1). - Keep a newly added source retryable until provider.reload() succeeds, so a transient reload failure is retried on the next provider.updated / session.created instead of stranding the provider until restart (CodeRabbit inline #2 / review item 5). - Guard the reconcile pass against exceptions so the fire-and-forget path cannot produce an unhandled rejection. - Re-check the queued flag in the coalescing finally block to avoid dropping a trigger that arrives in the settle window. - Kick one idempotent reconcile after subscribing to close the setup->subscribe event window. - Add a regression test proving the configured baseURL supersedes an env fallback; it fails against the previous logic. --- src/plugin/v2.ts | 58 +++++++++++++++++++++------ test/plugin-v2.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 54b14e5..29a42c4 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -43,6 +43,13 @@ interface ProviderSource { customHeaders?: Record credentialReloadRequired: boolean refreshRequired: boolean + /** + * True when this source came from option/env/port auto-detection rather than + * a host-registered provider. A fallback only stands in until the real + * provider appears; reconcile replaces it at that point so the user's + * `providers..settings.baseURL` wins over the guessed endpoint. + */ + fromFallback: boolean filters: ModelFilters capabilities: ModelCapabilities formatModelNames: boolean @@ -285,6 +292,7 @@ async function makeProviderSource( usesConnectionCredential, credentialReloadRequired: false, refreshRequired: false, + fromFallback: provider === undefined, baseURL, apiKey, customHeaders: Object.keys(customHeaders).length > 0 ? customHeaders : undefined, @@ -438,24 +446,28 @@ async function listMatchingProviders(context: Context): Promise /** * Discover and add sources for LiteLLM providers that have appeared since the - * last reconciliation. Ids already present are left untouched, so this is safe - * to call repeatedly (startup, `provider.updated`, `session.created`). Returns - * the newly added sources so callers can reload the registry. + * last reconciliation. Ids already present are left untouched *unless* the + * existing entry is a fallback that never came from a host-registered + * provider — in that case it is replaced, so merging a late provider overrides + * the option/env/auto-detected endpoint (including its baseURL and + * credentials). Safe to call repeatedly (startup, `provider.updated`, + * `session.created`). Returns sources that need publishing via `reload()`. */ async function reconcileProviderSources( context: Context, pluginOptions: Record, sources: Map, ): Promise { - const added: ProviderSource[] = [] + const changed: ProviderSource[] = [] for (const provider of await listMatchingProviders(context)) { - if (sources.has(provider.id)) continue + const existing = sources.get(provider.id) + if (existing && !existing.fromFallback) continue const source = await makeProviderSource(context, provider, pluginOptions) if (!source) continue sources.set(source.id, source) - added.push(source) + changed.push(source) } - return added + return changed } const definition = Plugin.define({ @@ -476,7 +488,6 @@ const definition = Plugin.define({ const fallback = await makeProviderSource(context, undefined, pluginOptions) if (fallback) sources.set(fallback.id, fallback) } - if (sources.size === 0) { logInfo( '[opencode-litellm] No LiteLLM proxy found. Configure providers.litellm.settings.baseURL or start LiteLLM on port 4000/8000/8080.', @@ -537,17 +548,31 @@ const definition = Plugin.define({ let reconcileAgain = false const reconcileOnce = async (): Promise => { - const added = await reconcileProviderSources(context, pluginOptions, sources) - if (added.length === 0) return + // A newly added source is only "published" once `reload()` succeeds. + // Until then it must stay retryable, so we record `pending` and only + // clear their fallback marker after a successful reload. A transient + // reload failure therefore leaves the entry as a pending fallback and + // the next trigger (provider.updated / session.created) retries it. + let pending: ProviderSource[] + try { + pending = await reconcileProviderSources(context, pluginOptions, sources) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logInfo(`[opencode-litellm] Could not reconcile configured providers: ${message}`) + return + } + if (pending.length === 0) return try { await context.provider.reload() + for (const source of pending) source.fromFallback = false logInfo( - `[opencode-litellm] Registered ${added.length} newly configured LiteLLM provider(s).`, + `[opencode-litellm] Registered ${pending.length} newly configured LiteLLM provider(s).`, ) } catch (error) { + // Leave the sources marked as fallback so the next event retries. const message = error instanceof Error ? error.message : String(error) logInfo( - `[opencode-litellm] Could not reload providers after discovering a new provider: ${message}`, + `[opencode-litellm] Could not reload providers after discovering a new provider; will retry: ${message}`, ) } } @@ -564,6 +589,9 @@ const definition = Plugin.define({ } while (reconcileAgain) })().finally(() => { reconcileRunning = null + // A trigger can land in the gap between the loop's final check and this + // continuation. Without re-checking, that update would be dropped. + if (reconcileAgain) void reconcileAndReload() }) return reconcileRunning } @@ -605,6 +633,12 @@ const definition = Plugin.define({ } })() + // A provider can register during setup's own auto-detection probes, before + // the subscription above is established; that `provider.updated` would be + // lost until the next session. One idempotent pass right after subscribing + // closes that window. + void reconcileAndReload() + return async () => { controller.abort() await providerRegistration.dispose() diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 1441092..8e8886c 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -499,6 +499,96 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('replaces an env/option fallback when the configured provider appears later', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-fallback-replace-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + process.env.LITELLM_BASE_URL = 'http://127.0.0.1:44448/v1' + + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: [{ id: 'model-from-config', object: 'model' }] }), + { status: 200 }, + ) + }) + + // The host registers the real provider only after setup, exactly like the + // late-registration ordering this PR fixes. + let providerListCalls = 0 + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { baseURL: 'http://127.0.0.1:44449/v1' }, + headers: {}, + } + const providerList = vi.fn(async () => { + providerListCalls += 1 + return { data: providerListCalls === 1 ? [] : [configuredProvider] } + }) + + const registered: Array<{ info: Record; models: Array> }> = [] + let providerTransform: ((editor: unknown) => void) | undefined + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; models: Array> }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + + let releaseEvents!: () => void + const eventsGate = new Promise((resolve) => { + releaseEvents = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: {}, + provider: { + list: providerList, + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + await eventsGate + yield { type: 'provider.updated' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + // The env fallback won the initial setup (id 'litellm') and used its URL. + expect(registered).toHaveLength(1) + expect(registered[0].info.settings).toMatchObject({ + baseURL: 'http://127.0.0.1:44448/v1', + }) + + releaseEvents() + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + + // The configured provider must supersede the fallback, not be skipped + // because id 'litellm' was already known. + const lastSettings = registered[registered.length - 1].info.settings as Record + expect(lastSettings.baseURL).toBe('http://127.0.0.1:44449/v1') + await cleanup?.() + }) + it('retries discovery and provider reload after a failed registry reload', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-retry-test-')) process.env.XDG_CACHE_HOME = cacheDirectory From ea11974c20f1fa04c8476123e5804c5930aabb9d Mon Sep 17 00:00:00 2001 From: Yusef Mohamadi Date: Wed, 30 Sep 2026 00:24:21 +0200 Subject: [PATCH 3/3] fix: retry publication of pending sources after a failed reload Follow-up to CodeRabbit's review of bd3880d. The previous commit keyed 'needs publishing' off fromFallback, but a source built from a real host provider gets fromFallback: false immediately, so a failed reload left it skipped by every later reconcile and it was never retried. - Add an explicit pendingPublication flag, set when a source is built and cleared only after provider.reload() succeeds. reconcileProviderSources returns any configured source still pending, so a transient reload failure is retried on the next provider.updated / session.created. - Clear pendingPublication for sources registered by setup's own transform, since that is their initial publication step; otherwise the post-subscribe reconcile would re-reload already-published sources. - Add a regression test: provider appears late, first publication fails, a later trigger retries and publishes it. Fails against the previous logic (reload called once, never retried). --- src/plugin/v2.ts | 34 +++++++++---- test/plugin-v2.test.ts | 108 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 29a42c4..e7e2d24 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -48,8 +48,18 @@ interface ProviderSource { * a host-registered provider. A fallback only stands in until the real * provider appears; reconcile replaces it at that point so the user's * `providers..settings.baseURL` wins over the guessed endpoint. + * + * Distinct from {@link pendingPublication}: a fallback source is always + * pending until a configured provider supersedes it, whereas a configured + * source is pending only until its first successful `reload()`. */ fromFallback: boolean + /** + * True until this source has been published to the provider registry by a + * successful `reload()`. Reconciliation retries (and re-adds) any source + * still pending, so a transient reload failure does not strand it. + */ + pendingPublication: boolean filters: ModelFilters capabilities: ModelCapabilities formatModelNames: boolean @@ -293,6 +303,7 @@ async function makeProviderSource( credentialReloadRequired: false, refreshRequired: false, fromFallback: provider === undefined, + pendingPublication: true, baseURL, apiKey, customHeaders: Object.keys(customHeaders).length > 0 ? customHeaders : undefined, @@ -451,7 +462,8 @@ async function listMatchingProviders(context: Context): Promise * provider — in that case it is replaced, so merging a late provider overrides * the option/env/auto-detected endpoint (including its baseURL and * credentials). Safe to call repeatedly (startup, `provider.updated`, - * `session.created`). Returns sources that need publishing via `reload()`. + * `session.created`). Returns sources that still need publishing via + * `reload()` (new providers plus any whose previous publication failed). */ async function reconcileProviderSources( context: Context, @@ -461,7 +473,9 @@ async function reconcileProviderSources( const changed: ProviderSource[] = [] for (const provider of await listMatchingProviders(context)) { const existing = sources.get(provider.id) - if (existing && !existing.fromFallback) continue + // Skip sources that are both configured (not a fallback) and already + // published; a pending configured source must be returned so it is retried. + if (existing && !existing.fromFallback && !existing.pendingPublication) continue const source = await makeProviderSource(context, provider, pluginOptions) if (!source) continue sources.set(source.id, source) @@ -535,6 +549,9 @@ const definition = Plugin.define({ }) } } + // The initial transform is itself the publication step for sources built + // during setup, so they are no longer "pending" after this point. + for (const source of sources.values()) source.pendingPublication = false }) const controller = new AbortController() @@ -548,11 +565,9 @@ const definition = Plugin.define({ let reconcileAgain = false const reconcileOnce = async (): Promise => { - // A newly added source is only "published" once `reload()` succeeds. - // Until then it must stay retryable, so we record `pending` and only - // clear their fallback marker after a successful reload. A transient - // reload failure therefore leaves the entry as a pending fallback and - // the next trigger (provider.updated / session.created) retries it. + // A newly built source is only "published" once `reload()` succeeds. Until + // then it stays `pendingPublication`, so the next pass returns and retries + // it instead of skipping it forever after a transient reload failure. let pending: ProviderSource[] try { pending = await reconcileProviderSources(context, pluginOptions, sources) @@ -564,12 +579,13 @@ const definition = Plugin.define({ if (pending.length === 0) return try { await context.provider.reload() - for (const source of pending) source.fromFallback = false + for (const source of pending) source.pendingPublication = false logInfo( `[opencode-litellm] Registered ${pending.length} newly configured LiteLLM provider(s).`, ) } catch (error) { - // Leave the sources marked as fallback so the next event retries. + // Leave `pendingPublication` set so the next trigger retries instead of + // treating these sources as already published. const message = error instanceof Error ? error.message : String(error) logInfo( `[opencode-litellm] Could not reload providers after discovering a new provider; will retry: ${message}`, diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 8e8886c..cc56848 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -589,6 +589,114 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('retries publishing a newly configured provider after a failed reload', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-late-provider-retry-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + const configuredBaseURL = 'http://127.0.0.1:44450' + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + // Only the configured host answers, so the setup-time fallback cannot + // succeed against an auto-detection port and mask the real scenario. + if (!url.startsWith(configuredBaseURL)) { + return new Response('not found', { status: 503 }) + } + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: [{ id: 'late-model', object: 'model' }] }), + { status: 200 }, + ) + }) + + // Provider is invisible during setup, then appears on provider.updated. + let providerListCalls = 0 + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { baseURL: `${configuredBaseURL}/v1` }, + headers: {}, + } + const providerList = vi.fn(async () => { + providerListCalls += 1 + return { data: providerListCalls === 1 ? [] : [configuredProvider] } + }) + + const registered: Array<{ info: Record; models: Array> }> = [] + let providerTransform: ((editor: unknown) => void) | undefined + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; models: Array> }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + // First publication attempt fails; a later pass must retry and succeed. + const reload = vi.fn(async () => { + if (reload.mock.calls.length === 1) throw new Error('temporary registry failure') + providerTransform?.(editor) + }) + + let releaseFirst!: () => void + const firstEvent = new Promise((resolve) => { + releaseFirst = resolve + }) + let releaseSecond!: () => void + const secondEvent = new Promise((resolve) => { + releaseSecond = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: {}, + provider: { + list: providerList, + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + await firstEvent + yield { type: 'provider.updated' } + await secondEvent + yield { type: 'session.created' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + // The post-subscribe reconcile runs at startup; discovery finds the + // provider but the first publication attempt fails, so nothing is + // registered and the source stays pending. + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)) + expect(registered).toHaveLength(0) + + // A later trigger must retry publication, not skip the source as "known". + releaseFirst() + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(2)) + expect(registered).toHaveLength(1) + expect(registered[0].info.settings).toMatchObject({ + baseURL: 'http://127.0.0.1:44450/v1', + }) + expect(registered[0].models.map((model) => model.id)).toEqual(['late-model']) + + // A further trigger must not republish an already-published source. + releaseSecond() + await vi.waitFor(() => expect(providerList).toHaveBeenCalledTimes(4)) + expect(reload).toHaveBeenCalledTimes(2) + await cleanup?.() + }) + it('retries discovery and provider reload after a failed registry reload', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-retry-test-')) process.env.XDG_CACHE_HOME = cacheDirectory