From 1d7ceba894f3f90072a737b6ff62d376d21e8eac Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Sun, 14 Jun 2026 15:13:13 -0400 Subject: [PATCH 1/8] feat(provider): auto-discover models from custom OpenAI-compatible providers --- packages/opencode/src/provider/provider.ts | 56 +++++ .../opencode/test/provider/provider.test.ts | 195 +++++++++++++++++- 2 files changed, 250 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4352f8a9b519..ed72f8292429 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1557,6 +1557,62 @@ export const layer = Layer.effect( }) } + // Discover models for custom openai-compatible providers with a baseURL + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderV2.ID.make(id) + if (provider.source !== "config") continue + const baseURL = typeof provider.options?.baseURL === "string" && provider.options.baseURL !== "" ? provider.options.baseURL : undefined + if (!baseURL) continue + const configProvider = cfg.provider?.[providerID] + const npm = configProvider?.npm ?? "@ai-sdk/openai-compatible" + if (npm !== "@ai-sdk/openai-compatible") continue + + yield* Effect.promise(async () => { + try { + const response = await fetch(`${baseURL}/models`, { + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok) return + const data = await response.json() + if (!data?.data?.length) return + + for (const model of data.data) { + if (typeof model.id !== "string") continue + if (provider.models[model.id]) continue + provider.models[model.id] = { + id: ModelV2.ID.make(model.id), + providerID, + name: model.id, + family: "", + api: { + id: model.id, + url: baseURL, + npm, + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 4096 }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + } catch { + // Discovery failed silently — existing models are preserved + } + }) + } + for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderV2.ID.make(id) if (!isProviderAllowed(providerID)) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca06e..f18e0c665c75 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test" +import { afterEach, expect, mock, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { Effect, Layer } from "effect" @@ -22,6 +22,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" const originalEnv = new Map() +const originalFetch = globalThis.fetch const rememberEnv = (k: string) => { if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) @@ -53,6 +54,7 @@ afterEach(async () => { else process.env[key] = value } originalEnv.clear() + globalThis.fetch = originalFetch await disposeAllInstances() }) @@ -1791,3 +1793,194 @@ it.effect("opencode loader keeps paid models when auth exists", () => expect(keyedCount).toBeGreaterThan(0) }).pipe(provideMultiInstance), ) + +it.instance( + "custom provider with baseURL discovers models from remote /models endpoint", + () => + Effect.gen(function* () { + globalThis.fetch = mock((url: string) => { + if (url === "http://localhost:8080/v1/models") { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + { id: "mistral-7b", object: "model" }, + ], + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(originalFetch(url)) + }) as unknown as typeof fetch + + const providers = yield* list + const provider = providers[ProviderV2.ID.make("llamacpp-router")] + expect(provider).toBeDefined() + expect(Object.keys(provider.models)).toContain("llama-3.1-8b") + expect(Object.keys(provider.models)).toContain("mistral-7b") + }), + { + config: { + provider: { + "llamacpp-router": { + npm: "@ai-sdk/openai-compatible", + name: "llama.cpp router", + options: { + baseURL: "http://localhost:8080/v1", + timeout: false, + }, + }, + }, + }, + }, +) + +it.instance( + "custom provider with failing /models endpoint is removed gracefully", + () => + Effect.gen(function* () { + globalThis.fetch = mock((url: string) => { + if (url === "http://localhost:8080/v1/models") { + return Promise.resolve(new Response("Internal Server Error", { status: 500 })) + } + return Promise.resolve(originalFetch(url)) + }) as unknown as typeof fetch + + const providers = yield* list + expect(providers[ProviderV2.ID.make("failing-provider")]).toBeUndefined() + }), + { + config: { + provider: { + "failing-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Failing Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +it.instance( + "custom provider handles non-JSON response gracefully", + () => + Effect.gen(function* () { + globalThis.fetch = mock((url: string) => { + if (url === "http://localhost:8080/v1/models") { + return Promise.resolve(new Response("error", { status: 200 })) + } + return Promise.resolve(originalFetch(url)) + }) as unknown as typeof fetch + + const providers = yield* list + expect(providers[ProviderV2.ID.make("non-json-provider")]).toBeUndefined() + }), + { + config: { + provider: { + "non-json-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Non-JSON Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +it.instance( + "custom provider handles empty data array gracefully", + () => + Effect.gen(function* () { + globalThis.fetch = mock((url: string) => { + if (url === "http://localhost:8080/v1/models") { + return Promise.resolve( + new Response( + JSON.stringify({ data: [] }), + { status: 200 }, + ), + ) + } + return Promise.resolve(originalFetch(url)) + }) as unknown as typeof fetch + + const providers = yield* list + expect(providers[ProviderV2.ID.make("empty-provider")]).toBeUndefined() + }), + { + config: { + provider: { + "empty-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Empty Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +it.instance( + "custom provider discovers additional models while preserving config-defined model settings", + () => + Effect.gen(function* () { + globalThis.fetch = mock((url: string) => { + if (url === "http://localhost:8080/v1/models") { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + { id: "mistral-7b", object: "model" }, + { id: "gemma-2-9b", object: "model" }, + ], + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(originalFetch(url)) + }) as unknown as typeof fetch + + const providers = yield* list + const provider = providers[ProviderV2.ID.make("llamacpp-router")] + expect(provider).toBeDefined() + + // Config-defined model should preserve custom settings + expect(provider.models["llama-3.1-8b"]).toBeDefined() + expect(provider.models["llama-3.1-8b"].name).toBe("My Llama") + expect(provider.models["llama-3.1-8b"].capabilities.reasoning).toBe(true) + + // Discovered models should be added + expect(provider.models["mistral-7b"]).toBeDefined() + expect(provider.models["gemma-2-9b"]).toBeDefined() + }), + { + config: { + provider: { + "llamacpp-router": { + npm: "@ai-sdk/openai-compatible", + name: "llama.cpp router", + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "llama-3.1-8b": { + name: "My Llama", + reasoning: true, + }, + }, + }, + }, + }, + }, +) From f765cddbf3e7763cf78082f8ab20d3def8461639 Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Mon, 15 Jun 2026 16:10:20 -0400 Subject: [PATCH 2/8] feat(opencode): add custom provider model discovery - Fetch models from OpenAI-compatible providers with baseURL in config - Support plugin-registered discovery loaders (e.g., GitLab) - Discovery runs synchronously during bootstrap init - Remove providers with no models after successful discovery - Add tests for discovery and model registration --- packages/opencode/src/project/bootstrap.ts | 10 +- packages/opencode/src/provider/provider.ts | 237 ++++++++++++------ packages/opencode/test/fake/provider.ts | 1 + .../opencode/test/provider/provider.test.ts | 74 +++++- 4 files changed, 235 insertions(+), 87 deletions(-) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 0bbe3d4abe4f..c3ce6cf6466d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "@/lsp/lsp" +import { Provider } from "@/provider/provider" import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" @@ -20,10 +21,11 @@ export const layer = Layer.effect( // Yield each bootstrap dep at layer init so `run` itself has R = never. // InstanceStore imports only the lightweight tag from bootstrap-service.ts, // so it can depend on bootstrap without importing this implementation graph. - const config = yield* Config.Service + const config = yield* Config.Service const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service + const provider = yield* Provider.Service const project = yield* Project.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service @@ -36,10 +38,10 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - // Each service self-manages its own slow work via Effect.forkScoped against + // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, vcs, snapshot, project], + [lsp, shareNext, format, vcs, snapshot, project, provider], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -55,6 +57,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( Format.defaultLayer, LSP.defaultLayer, Plugin.defaultLayer, + Provider.defaultLayer, Project.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, @@ -67,6 +70,7 @@ export const node = LayerNode.make(layer, [ Format.node, LSP.node, Plugin.node, + Provider.node, Project.node, ShareNext.node, Snapshot.node, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ed72f8292429..35efdcc4f3d9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1110,6 +1110,7 @@ export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModels export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError export interface Interface { + readonly init: () => Effect.Effect readonly list: () => Effect.Effect> readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect readonly getModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect @@ -1129,6 +1130,7 @@ interface State { sdk: Map modelLoaders: Record varsLoaders: Record + discoveryLoaders: Record } export class Service extends Context.Service()("@opencode/Provider") {} @@ -1543,76 +1545,6 @@ export const layer = Layer.effect( mergeProvider(providerID, partial) } - const gitlab = ProviderV2.ID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { - yield* Effect.promise(async () => { - try { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model - } - } - } catch (e) {} - }) - } - - // Discover models for custom openai-compatible providers with a baseURL - for (const [id, provider] of Object.entries(providers)) { - const providerID = ProviderV2.ID.make(id) - if (provider.source !== "config") continue - const baseURL = typeof provider.options?.baseURL === "string" && provider.options.baseURL !== "" ? provider.options.baseURL : undefined - if (!baseURL) continue - const configProvider = cfg.provider?.[providerID] - const npm = configProvider?.npm ?? "@ai-sdk/openai-compatible" - if (npm !== "@ai-sdk/openai-compatible") continue - - yield* Effect.promise(async () => { - try { - const response = await fetch(`${baseURL}/models`, { - signal: AbortSignal.timeout(5_000), - }) - if (!response.ok) return - const data = await response.json() - if (!data?.data?.length) return - - for (const model of data.data) { - if (typeof model.id !== "string") continue - if (provider.models[model.id]) continue - provider.models[model.id] = { - id: ModelV2.ID.make(model.id), - providerID, - name: model.id, - family: "", - api: { - id: model.id, - url: baseURL, - npm, - }, - status: "active", - headers: {}, - options: {}, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 128000, output: 4096 }, - capabilities: { - temperature: false, - reasoning: false, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - release_date: "", - variants: {}, - } - } - } catch { - // Discovery failed silently — existing models are preserved - } - }) - } - for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderV2.ID.make(id) if (!isProviderAllowed(providerID)) { @@ -1657,7 +1589,12 @@ export const layer = Layer.effect( } if (Object.keys(provider.models).length === 0) { - delete providers[providerID] + // Don't delete providers that will have models discovered in init() + const hasDiscoveryLoader = discoveryLoaders[providerID] !== undefined + const hasConfigBaseURL = cfg.provider?.[providerID]?.options?.baseURL + if (!hasDiscoveryLoader && !hasConfigBaseURL) { + delete providers[providerID] + } continue } } @@ -1669,12 +1606,159 @@ export const layer = Layer.effect( sdk, modelLoaders, varsLoaders, + discoveryLoaders, + discoveryDone: false, } }), ) const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + const init = Effect.fn("Provider.init")(function* () { + const s = yield* InstanceState.get(state) + const cfg = yield* config.get() + + const providersToDiscover: Array<{ + providerID: string + discover?: () => Promise> + inlineFetch: boolean + }> = [] + + for (const [providerID, discover] of Object.entries(s.discoveryLoaders)) { + providersToDiscover.push({ providerID, discover, inlineFetch: false }) + } + + for (const [providerID, configProvider] of Object.entries(cfg.provider ?? {})) { + if (s.discoveryLoaders[providerID]) continue + const npm = configProvider?.npm ?? "@ai-sdk/openai-compatible" + if (npm !== "@ai-sdk/openai-compatible") continue + const baseURL = configProvider?.options?.baseURL + if (typeof baseURL !== "string" || baseURL === "") continue + providersToDiscover.push({ providerID, inlineFetch: true }) + } + + yield* Effect.forEach( + providersToDiscover, + ({ providerID, discover, inlineFetch }) => + Effect.gen(function* () { + const provider = s.providers[ProviderV2.ID.make(providerID)] + if (!provider || provider.source !== "config") return + + const api = provider.options?.baseURL ?? "" + if (!api) return + + const authExit = yield* auth.get(ProviderV2.ID.make(providerID)).pipe(Effect.exit) + const authResult = authExit._tag === "Success" ? authExit.value : undefined + + const headers: Record = {} + if (authResult?.type === "api" && authResult.key) { + headers["Authorization"] = `Bearer ${authResult.key}` + } + if (!headers["Authorization"] && provider.key) { + headers["Authorization"] = `Bearer ${provider.key}` + } + if (!headers["Authorization"] && provider.options?.apiKey) { + headers["Authorization"] = `Bearer ${provider.options.apiKey}` + } + + let discovered: Set + if (inlineFetch) { + const url = api.endsWith("/v1") ? `${api}/models` : `${api}/v1/models` + const fetchExit = yield* Effect.promise(() => + fetch(url, { headers, signal: AbortSignal.timeout(3000) }), + ).pipe(Effect.exit) + if (fetchExit._tag === "Failure") { + if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] + return + } + const res = fetchExit.value + if (!res.ok) { + if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] + return + } + + const jsonExit = yield* Effect.promise(() => res.json()).pipe(Effect.exit) + if (jsonExit._tag === "Failure") { + if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] + return + } + const json: any = jsonExit.value + discovered = new Set( + (json.data ?? []) + .filter((m: any) => m.id && typeof m.id === "string") + .map((m: any) => m.id as string), + ) + } else if (discover) { + const discoverExit = yield* Effect.promise(() => discover()).pipe(Effect.exit) + if (discoverExit._tag === "Failure") { + if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] + return + } + const result = discoverExit.value + discovered = new Set( + Object.entries(result) + .filter(([_, m]) => m && typeof m.id === "string") + .map(([id]) => id), + ) + } else { + return + } + + // Filter out Models.dev models not returned by discovery (only for inlineFetch) + // Preserve config-defined models (they were explicitly configured by the user) + if (inlineFetch) { + const configProvider = cfg.provider?.[providerID] + if (configProvider) { + const configModelIDs = configProvider.models ? Object.keys(configProvider.models) : [] + const modelIDs: string[] = [] + for (const k in provider.models) modelIDs.push(k) + for (const modelID of modelIDs) { + // Keep config-defined models, remove only Models.dev models not in discovery + if (configModelIDs.includes(modelID)) continue + if (!discovered.has(modelID)) { + delete provider.models[modelID] + } + } + } + } + + // Merge discovered models + const npm = cfg.provider?.[providerID]?.npm ?? "@ai-sdk/openai-compatible" + for (const modelID of discovered) { + if (provider.models[modelID]) continue + provider.models[modelID] = { + id: ModelV2.ID.make(modelID), + providerID: ProviderV2.ID.make(providerID), + name: modelID, + api: { id: modelID, url: api, npm }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 128000 }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + + // Remove provider if no models remain after successful discovery + if (inlineFetch && Object.keys(provider.models).length === 0) { + delete s.providers[ProviderV2.ID.make(providerID)] + } + }).pipe(Effect.ignore), + { concurrency: "unbounded" }, + ) + }) + async function resolveSDK(model: Model, s: State, envs: Record) { try { const provider = s.providers[model.providerID] @@ -1984,7 +2068,16 @@ export const layer = Layer.effect( } }) - return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) + return Service.of({ + init: () => init(), + list, + getProvider, + getModel, + getLanguage, + closest, + getSmallModel, + defaultModel, + }) }), ) diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index 1dbfa6fa71a6..0ced9a790f70 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -53,6 +53,7 @@ export namespace ProviderTest { layer: Layer.succeed( Provider.Service, Provider.Service.of({ + init: () => Effect.void, list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })), getProvider: Effect.fn("TestProvider.getProvider")((providerID) => { if (providerID === row.id) return Effect.succeed(row) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index f18e0c665c75..01b086d86940 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -17,7 +17,7 @@ import { Provider } from "@/provider/provider" import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" import { InstanceLayer } from "@/project/instance-layer" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -1815,11 +1815,21 @@ it.instance( return Promise.resolve(originalFetch(url)) }) as unknown as typeof fetch + const provider = yield* Provider.Service + yield* provider.init() const providers = yield* list - const provider = providers[ProviderV2.ID.make("llamacpp-router")] - expect(provider).toBeDefined() - expect(Object.keys(provider.models)).toContain("llama-3.1-8b") - expect(Object.keys(provider.models)).toContain("mistral-7b") + const p = providers[ProviderV2.ID.make("llamacpp-router")] + expect(p).toBeDefined() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + return p?.models["llama-3.1-8b"] && p?.models["mistral-7b"] ? (true as const) : undefined + }), + "discovered models not found", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + expect(Object.keys(discoveredProvider.models)).toContain("llama-3.1-8b") + expect(Object.keys(discoveredProvider.models)).toContain("mistral-7b") }), { config: { @@ -1848,6 +1858,15 @@ it.instance( return Promise.resolve(originalFetch(url)) }) as unknown as typeof fetch + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("failing-provider")] + return p === undefined ? (true as const) : undefined + }), + "provider not removed after failed discovery", + ) const providers = yield* list expect(providers[ProviderV2.ID.make("failing-provider")]).toBeUndefined() }), @@ -1877,6 +1896,15 @@ it.instance( return Promise.resolve(originalFetch(url)) }) as unknown as typeof fetch + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("non-json-provider")] + return p === undefined ? (true as const) : undefined + }), + "provider not removed after non-JSON response", + ) const providers = yield* list expect(providers[ProviderV2.ID.make("non-json-provider")]).toBeUndefined() }), @@ -1911,6 +1939,15 @@ it.instance( return Promise.resolve(originalFetch(url)) }) as unknown as typeof fetch + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("empty-provider")] + return p === undefined ? (true as const) : undefined + }), + "provider not removed after empty discovery", + ) const providers = yield* list expect(providers[ProviderV2.ID.make("empty-provider")]).toBeUndefined() }), @@ -1951,18 +1988,31 @@ it.instance( return Promise.resolve(originalFetch(url)) }) as unknown as typeof fetch + const provider = yield* Provider.Service + yield* provider.init() const providers = yield* list - const provider = providers[ProviderV2.ID.make("llamacpp-router")] - expect(provider).toBeDefined() + const p = providers[ProviderV2.ID.make("llamacpp-router")] + expect(p).toBeDefined() + + // Wait for discovery to complete + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + return p?.models["mistral-7b"] && p?.models["gemma-2-9b"] ? (true as const) : undefined + }), + "discovered models not found", + ) + + const discoveredProvider = (yield* list)[ProviderV2.ID.make("llamacpp-router")] // Config-defined model should preserve custom settings - expect(provider.models["llama-3.1-8b"]).toBeDefined() - expect(provider.models["llama-3.1-8b"].name).toBe("My Llama") - expect(provider.models["llama-3.1-8b"].capabilities.reasoning).toBe(true) + expect(discoveredProvider.models["llama-3.1-8b"]).toBeDefined() + expect(discoveredProvider.models["llama-3.1-8b"].name).toBe("My Llama") + expect(discoveredProvider.models["llama-3.1-8b"].capabilities.reasoning).toBe(true) // Discovered models should be added - expect(provider.models["mistral-7b"]).toBeDefined() - expect(provider.models["gemma-2-9b"]).toBeDefined() + expect(discoveredProvider.models["mistral-7b"]).toBeDefined() + expect(discoveredProvider.models["gemma-2-9b"]).toBeDefined() }), { config: { From 552be5ca76ea41120532e44053fb50a4cb719e07 Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Wed, 17 Jun 2026 15:52:03 -0400 Subject: [PATCH 3/8] fix(opencode): resolve code review issues in provider model discovery - Extract discoverProviderModels() helper from inline 110-line Effect.gen - Replace globalThis.fetch with HttpClient layer (FetchHttpClient) - Add Effect.timeout(10s) to HTTP discovery requests - Replace Exit._tag with Exit.isSuccess/IsFailure - Remove redundant config.get() in init() (use captured s.cfg) - Remove unnecessary 'as any' casts - Fix indentation to consistent 2-space - Set family: 'discovered' on discovered models - Update discovery tests to use HttpClient layer mocks --- packages/opencode/src/provider/provider.ts | 298 ++++++++++-------- .../opencode/test/provider/provider.test.ts | 122 ++++--- .../test/session/llm-native-recorded.test.ts | 4 + 3 files changed, 229 insertions(+), 195 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 35efdcc4f3d9..bddd4da3b2fd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -18,7 +18,9 @@ import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, Context, Schema, Types } from "effect" +import { Duration, Effect, Exit, Layer, Context, Option, Schema, Types } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { EffectPromise } from "@/effect/promise" @@ -136,6 +138,16 @@ const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK> type CustomModelLoader = (sdk: any, modelID: string, options?: Record, model?: Model) => Promise type CustomVarsLoader = (options: Record) => Record type CustomDiscoverModels = () => Promise> +interface OpenAIModelsResponse { + data: Array<{ + id: string + }> +} + +type DiscoveryResult = + | { action: "delete"; providerID: string } + | { action: "update"; providerID: string; models: Record } + | { action: "skip" } type CustomLoader = (provider: Info) => Effect.Effect<{ autoload: boolean getModel?: CustomModelLoader @@ -1131,6 +1143,8 @@ interface State { modelLoaders: Record varsLoaders: Record discoveryLoaders: Record + discoveryDone: boolean + cfg: ConfigV1.Info } export class Service extends Context.Service()("@opencode/Provider") {} @@ -1294,6 +1308,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service + const http = yield* HttpClient.HttpClient const state = yield* InstanceState.make(() => Effect.gen(function* () { @@ -1608,19 +1623,21 @@ export const layer = Layer.effect( varsLoaders, discoveryLoaders, discoveryDone: false, + cfg, } }), ) const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) - const init = Effect.fn("Provider.init")(function* () { + const init = Effect.fn("Provider.init")(function* () { const s = yield* InstanceState.get(state) - const cfg = yield* config.get() + if (s.discoveryDone) return + const cfg = s.cfg const providersToDiscover: Array<{ providerID: string - discover?: () => Promise> + discover?: CustomDiscoverModels inlineFetch: boolean }> = [] @@ -1637,127 +1654,154 @@ export const layer = Layer.effect( providersToDiscover.push({ providerID, inlineFetch: true }) } - yield* Effect.forEach( + const discoveryResults = yield* Effect.forEach( providersToDiscover, - ({ providerID, discover, inlineFetch }) => - Effect.gen(function* () { - const provider = s.providers[ProviderV2.ID.make(providerID)] - if (!provider || provider.source !== "config") return + ({ providerID, discover, inlineFetch }): Effect.Effect => { + const provider = s.providers[ProviderV2.ID.make(providerID)] + if (!provider || provider.source !== "config") { + return Effect.succeed({ action: "skip" }) + } + return discoverProviderModels(providerID, discover, inlineFetch, provider, cfg, s, http, auth) + }, + { concurrency: "unbounded" }, + ) - const api = provider.options?.baseURL ?? "" - if (!api) return + // Apply all discovery results atomically + for (const result of discoveryResults) { + if (result.action === "skip") continue + const key = ProviderV2.ID.make(result.providerID) + if (result.action === "delete") { + delete s.providers[key] + } else { + const p = s.providers[key] + if (p) s.providers[key] = { ...p, models: result.models } + } + } + s.discoveryDone = true + }) - const authExit = yield* auth.get(ProviderV2.ID.make(providerID)).pipe(Effect.exit) - const authResult = authExit._tag === "Success" ? authExit.value : undefined + function discoverProviderModels( + providerID: string, + discover: CustomDiscoverModels | undefined, + inlineFetch: boolean, + provider: Info, + cfg: ConfigV1.Info, + s: State, + http: HttpClient.HttpClient, + auth: Auth.Interface, + ): Effect.Effect { + return Effect.gen(function* () { + const api = provider.options?.baseURL ?? "" + if (!api) return { action: "skip" } + + const authExit = yield* auth.get(ProviderV2.ID.make(providerID)).pipe(Effect.exit) + const authResult = Exit.isSuccess(authExit) ? authExit.value : undefined + + const headers: Record = {} + if (authResult?.type === "api" && authResult.key) { + headers["Authorization"] = `Bearer ${authResult.key}` + } + if (!headers["Authorization"] && provider.key) { + headers["Authorization"] = `Bearer ${provider.key}` + } + if (!headers["Authorization"] && provider.options?.apiKey) { + headers["Authorization"] = `Bearer ${provider.options.apiKey}` + } - const headers: Record = {} - if (authResult?.type === "api" && authResult.key) { - headers["Authorization"] = `Bearer ${authResult.key}` - } - if (!headers["Authorization"] && provider.key) { - headers["Authorization"] = `Bearer ${provider.key}` - } - if (!headers["Authorization"] && provider.options?.apiKey) { - headers["Authorization"] = `Bearer ${provider.options.apiKey}` - } + // Helper: return "delete" if provider has no models, otherwise "skip" + const emptyOrSkip = (): DiscoveryResult => + Object.keys(provider.models).length === 0 + ? { action: "delete", providerID } + : { action: "skip" } + + let discovered: Set + if (inlineFetch) { + const url = api.endsWith("/v1") ? `${api}/models` : `${api}/v1/models` + const request = HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders(headers), + ) + const fetchExit = yield* http.execute(request).pipe( + Effect.timeout("10 seconds"), + Effect.exit, + ) + if (Exit.isFailure(fetchExit)) return emptyOrSkip() - let discovered: Set - if (inlineFetch) { - const url = api.endsWith("/v1") ? `${api}/models` : `${api}/v1/models` - const fetchExit = yield* Effect.promise(() => - fetch(url, { headers, signal: AbortSignal.timeout(3000) }), - ).pipe(Effect.exit) - if (fetchExit._tag === "Failure") { - if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] - return - } - const res = fetchExit.value - if (!res.ok) { - if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] - return - } + const res = fetchExit.value + if (res.status < 200 || res.status >= 300) return emptyOrSkip() - const jsonExit = yield* Effect.promise(() => res.json()).pipe(Effect.exit) - if (jsonExit._tag === "Failure") { - if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] - return - } - const json: any = jsonExit.value - discovered = new Set( - (json.data ?? []) - .filter((m: any) => m.id && typeof m.id === "string") - .map((m: any) => m.id as string), - ) - } else if (discover) { - const discoverExit = yield* Effect.promise(() => discover()).pipe(Effect.exit) - if (discoverExit._tag === "Failure") { - if (Object.keys(provider.models).length === 0) delete s.providers[ProviderV2.ID.make(providerID)] - return - } - const result = discoverExit.value - discovered = new Set( - Object.entries(result) - .filter(([_, m]) => m && typeof m.id === "string") - .map(([id]) => id), - ) - } else { - return - } + const jsonExit = yield* res.json.pipe(Effect.exit) + if (Exit.isFailure(jsonExit)) return emptyOrSkip() - // Filter out Models.dev models not returned by discovery (only for inlineFetch) - // Preserve config-defined models (they were explicitly configured by the user) - if (inlineFetch) { - const configProvider = cfg.provider?.[providerID] - if (configProvider) { - const configModelIDs = configProvider.models ? Object.keys(configProvider.models) : [] - const modelIDs: string[] = [] - for (const k in provider.models) modelIDs.push(k) - for (const modelID of modelIDs) { - // Keep config-defined models, remove only Models.dev models not in discovery - if (configModelIDs.includes(modelID)) continue - if (!discovered.has(modelID)) { - delete provider.models[modelID] - } - } - } - } + const json = jsonExit.value as unknown as OpenAIModelsResponse + discovered = new Set( + (json.data ?? []) + .filter((m) => typeof m.id === "string") + .map((m) => m.id), + ) + } else if (discover) { + const discoverExit = yield* Effect.promise(() => discover()).pipe(Effect.exit) + if (Exit.isFailure(discoverExit)) return emptyOrSkip() + + const result = discoverExit.value + discovered = new Set( + Object.entries(result) + .filter(([_, m]) => m && typeof m.id === "string") + .map(([id]) => id), + ) + } else { + return { action: "skip" } + } - // Merge discovered models - const npm = cfg.provider?.[providerID]?.npm ?? "@ai-sdk/openai-compatible" - for (const modelID of discovered) { - if (provider.models[modelID]) continue - provider.models[modelID] = { - id: ModelV2.ID.make(modelID), - providerID: ProviderV2.ID.make(providerID), - name: modelID, - api: { id: modelID, url: api, npm }, - status: "active", - headers: {}, - options: {}, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 128000, output: 128000 }, - capabilities: { - temperature: false, - reasoning: false, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - release_date: "", - variants: {}, - } - } + // Build new models record — never mutate provider.models + const newModels = { ...provider.models } - // Remove provider if no models remain after successful discovery - if (inlineFetch && Object.keys(provider.models).length === 0) { - delete s.providers[ProviderV2.ID.make(providerID)] + // Filter out Models.dev models not returned by discovery (only for inlineFetch) + // Preserve config-defined models (they were explicitly configured by the user) + if (inlineFetch) { + const configProvider = cfg.provider?.[providerID] + if (configProvider) { + const configModelIDs = configProvider.models ? Object.keys(configProvider.models) : [] + for (const modelID of Object.keys(newModels)) { + if (configModelIDs.includes(modelID)) continue + if (!discovered.has(modelID)) delete newModels[modelID] } - }).pipe(Effect.ignore), - { concurrency: "unbounded" }, - ) - }) + } + } + + // Merge discovered models into newModels + const npm = cfg.provider?.[providerID]?.npm ?? "@ai-sdk/openai-compatible" + for (const modelID of discovered) { + if (newModels[modelID]) continue + newModels[modelID] = { + id: ModelV2.ID.make(modelID), + providerID: ProviderV2.ID.make(providerID), + name: modelID, + api: { id: modelID, url: api, npm }, + family: "discovered", + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 128000 }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + + // If no models remain after discovery, signal deletion + if (Object.keys(newModels).length === 0) return { action: "delete", providerID } + return { action: "update", providerID, models: newModels } + }) + } async function resolveSDK(model: Model, s: State, envs: Record) { try { @@ -2066,18 +2110,18 @@ export const layer = Layer.effect( providerID: provider.id, modelID: model.id, } + }) + + return Service.of({ + init: () => init(), + list, + getProvider, + getModel, + getLanguage, + closest, + getSmallModel, + defaultModel, }) - - return Service.of({ - init: () => init(), - list, - getProvider, - getModel, - getLanguage, - closest, - getSmallModel, - defaultModel, - }) }), ) @@ -2090,6 +2134,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(FetchHttpClient.layer), ), ) @@ -2119,6 +2164,7 @@ export const node = LayerNode.make(layer, [ Plugin.node, ModelsDev.node, RuntimeFlags.node, + httpClient, ]) export * as Provider from "./provider" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 01b086d86940..722b5bad2783 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,7 +1,8 @@ -import { afterEach, expect, mock, test } from "bun:test" +import { afterEach, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { Effect, Layer } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/core/models-dev" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -22,7 +23,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" const originalEnv = new Map() -const originalFetch = globalThis.fetch const rememberEnv = (k: string) => { if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) @@ -54,7 +54,6 @@ afterEach(async () => { else process.env[key] = value } originalEnv.clear() - globalThis.fetch = originalFetch await disposeAllInstances() }) @@ -67,6 +66,7 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), + Layer.provide(FetchHttpClient.layer), ) const list = Provider.use.list() @@ -80,6 +80,36 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer)) + +// Mock HTTP client for discovery tests - replaces globalThis.fetch mocking +const mockHttpClientResponse = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => { + const client = HttpClient.make( + (request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))), + ) + return Layer.succeed(HttpClient.HttpClient, client) +} + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) + +const textResponse = (text: string, status = 200) => + new Response(text, { status, headers: { "Content-Type": "text/html" } }) + +const discoveryTestLayer = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => + Provider.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), + Layer.provide(RuntimeFlags.layer({})), + Layer.provide(mockHttpClientResponse(handler)), + ) + +const discoveryTestEffect = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => + testEffect(discoveryTestLayer(handler)) + const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) const alphaProviderConfig = { @@ -1794,27 +1824,17 @@ it.effect("opencode loader keeps paid models when auth exists", () => }).pipe(provideMultiInstance), ) -it.instance( +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + { id: "mistral-7b", object: "model" }, + ], + }), +).instance( "custom provider with baseURL discovers models from remote /models endpoint", () => Effect.gen(function* () { - globalThis.fetch = mock((url: string) => { - if (url === "http://localhost:8080/v1/models") { - return Promise.resolve( - new Response( - JSON.stringify({ - data: [ - { id: "llama-3.1-8b", object: "model" }, - { id: "mistral-7b", object: "model" }, - ], - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(originalFetch(url)) - }) as unknown as typeof fetch - const provider = yield* Provider.Service yield* provider.init() const providers = yield* list @@ -1847,17 +1867,10 @@ it.instance( }, ) -it.instance( +discoveryTestEffect(() => jsonResponse({}, 500)).instance( "custom provider with failing /models endpoint is removed gracefully", () => Effect.gen(function* () { - globalThis.fetch = mock((url: string) => { - if (url === "http://localhost:8080/v1/models") { - return Promise.resolve(new Response("Internal Server Error", { status: 500 })) - } - return Promise.resolve(originalFetch(url)) - }) as unknown as typeof fetch - const provider = yield* Provider.Service yield* provider.init() yield* pollWithTimeout( @@ -1885,17 +1898,10 @@ it.instance( }, ) -it.instance( +discoveryTestEffect(() => textResponse("error")).instance( "custom provider handles non-JSON response gracefully", () => Effect.gen(function* () { - globalThis.fetch = mock((url: string) => { - if (url === "http://localhost:8080/v1/models") { - return Promise.resolve(new Response("error", { status: 200 })) - } - return Promise.resolve(originalFetch(url)) - }) as unknown as typeof fetch - const provider = yield* Provider.Service yield* provider.init() yield* pollWithTimeout( @@ -1923,22 +1929,10 @@ it.instance( }, ) -it.instance( +discoveryTestEffect(() => jsonResponse({ data: [] })).instance( "custom provider handles empty data array gracefully", () => Effect.gen(function* () { - globalThis.fetch = mock((url: string) => { - if (url === "http://localhost:8080/v1/models") { - return Promise.resolve( - new Response( - JSON.stringify({ data: [] }), - { status: 200 }, - ), - ) - } - return Promise.resolve(originalFetch(url)) - }) as unknown as typeof fetch - const provider = yield* Provider.Service yield* provider.init() yield* pollWithTimeout( @@ -1966,28 +1960,18 @@ it.instance( }, ) -it.instance( +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + { id: "mistral-7b", object: "model" }, + { id: "gemma-2-9b", object: "model" }, + ], + }), +).instance( "custom provider discovers additional models while preserving config-defined model settings", () => Effect.gen(function* () { - globalThis.fetch = mock((url: string) => { - if (url === "http://localhost:8080/v1/models") { - return Promise.resolve( - new Response( - JSON.stringify({ - data: [ - { id: "llama-3.1-8b", object: "model" }, - { id: "mistral-7b", object: "model" }, - { id: "gemma-2-9b", object: "model" }, - ], - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(originalFetch(url)) - }) as unknown as typeof fetch - const provider = yield* Provider.Service yield* provider.init() const providers = yield* list diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index d17d7f8e5ab7..54b4742d709f 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -7,6 +7,9 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "node:path" import z from "zod" import { Auth } from "@/auth" @@ -270,6 +273,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(LayerNode.buildLayer(httpClient)), ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const metadata = { From 63738c00990787c554ee7d4eb5eac4fdb0ab4ffc Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Wed, 17 Jun 2026 15:57:45 -0400 Subject: [PATCH 4/8] refactor: format fixes --- packages/opencode/src/project/bootstrap.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index c3ce6cf6466d..41acdf2b2a6d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -21,7 +21,7 @@ export const layer = Layer.effect( // Yield each bootstrap dep at layer init so `run` itself has R = never. // InstanceStore imports only the lightweight tag from bootstrap-service.ts, // so it can depend on bootstrap without importing this implementation graph. - const config = yield* Config.Service + const config = yield* Config.Service const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service @@ -38,7 +38,7 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - // Each service self-manages its own slow work via Effect.forkScoped against + // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( [lsp, shareNext, format, vcs, snapshot, project, provider], From f257e8824faba00f9ed5851412839fd0cd06b062 Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Wed, 17 Jun 2026 17:35:31 -0400 Subject: [PATCH 5/8] feat(opencode): add discoverModels opt-out for provider model discovery --- packages/core/src/v1/config/provider.ts | 3 ++ packages/opencode/src/provider/provider.ts | 1 + .../opencode/test/provider/provider.test.ts | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f926..5dca5d95e604 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -117,5 +117,8 @@ export const Info = Schema.Struct({ ), ), models: Schema.optional(Schema.Record(Schema.String, Model)), + discoverModels: Schema.optional(Schema.Boolean).annotate({ + description: "Whether to discover models from the provider's /v1/models endpoint (default true)", + }), }).annotate({ identifier: "ProviderConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c0d47752f690..4ea549395d7c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1651,6 +1651,7 @@ export const layer = Layer.effect( if (npm !== "@ai-sdk/openai-compatible") continue const baseURL = configProvider?.options?.baseURL if (typeof baseURL !== "string" || baseURL === "") continue + if (configProvider?.discoverModels === false) continue providersToDiscover.push({ providerID, inlineFetch: true }) } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 722b5bad2783..3006fd2bdafc 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2018,3 +2018,52 @@ discoveryTestEffect(() => }, }, ) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + ], + }), +).instance( + "discoverModels: false skips discovery and preserves config-defined models", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("no-discovery-provider")] + return p !== undefined ? (true as const) : undefined + }), + "provider disappeared", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("no-discovery-provider")] + + // Provider should exist (not deleted by failed discovery) + expect(discoveredProvider).toBeDefined() + // Config-defined model should be preserved + expect(discoveredProvider.models["config-model"]).toBeDefined() + // Discovered model should NOT be present (discovery was skipped) + expect(discoveredProvider.models["llama-3.1-8b"]).toBeUndefined() + }), + { + config: { + provider: { + "no-discovery-provider": { + npm: "@ai-sdk/openai-compatible", + name: "No Discovery Provider", + discoverModels: false, + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "config-model": { + name: "Config Model", + }, + }, + }, + }, + }, + }, +) From d9add28fc0ab5fc1a48265b4d4a1ff26c72dab8d Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Wed, 17 Jun 2026 18:03:02 -0400 Subject: [PATCH 6/8] feat(opencode): parse context_length and max_output_tokens from discovered models --- packages/opencode/src/provider/provider.ts | 43 +++++++-- .../opencode/test/provider/provider.test.ts | 94 +++++++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4ea549395d7c..cd63098a8bec 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -141,6 +141,9 @@ type CustomDiscoverModels = () => Promise> interface OpenAIModelsResponse { data: Array<{ id: string + context_length?: number + max_context_length?: number + max_output_tokens?: number }> } @@ -1716,6 +1719,7 @@ export const layer = Layer.effect( : { action: "skip" } let discovered: Set + let discoveredLimits: Map | undefined if (inlineFetch) { const url = api.endsWith("/v1") ? `${api}/models` : `${api}/v1/models` const request = HttpClientRequest.get(url).pipe( @@ -1733,12 +1737,22 @@ export const layer = Layer.effect( const jsonExit = yield* res.json.pipe(Effect.exit) if (Exit.isFailure(jsonExit)) return emptyOrSkip() - const json = jsonExit.value as unknown as OpenAIModelsResponse - discovered = new Set( - (json.data ?? []) - .filter((m) => typeof m.id === "string") - .map((m) => m.id), - ) + const json = jsonExit.value as unknown as OpenAIModelsResponse + discovered = new Set( + (json.data ?? []) + .filter((m) => typeof m.id === "string") + .map((m) => m.id), + ) + // Parse context/output limits from API response + discoveredLimits = new Map() + for (const m of json.data ?? []) { + if (typeof m.id !== "string") continue + const context = m.context_length ?? m.max_context_length + const output = m.max_output_tokens + if (typeof context === "number" && typeof output === "number") { + discoveredLimits.set(m.id, { context, output }) + } + } } else if (discover) { const discoverExit = yield* Effect.promise(() => discover()).pipe(Effect.exit) if (Exit.isFailure(discoverExit)) return emptyOrSkip() @@ -1771,8 +1785,21 @@ export const layer = Layer.effect( // Merge discovered models into newModels const npm = cfg.provider?.[providerID]?.npm ?? "@ai-sdk/openai-compatible" + const configProvider = cfg.provider?.[providerID] + const configModelIDs = configProvider?.models ? Object.keys(configProvider.models) : [] for (const modelID of discovered) { - if (newModels[modelID]) continue + const existing = newModels[modelID] + const limits = discoveredLimits?.get(modelID) + const context = limits?.context ?? 128000 + const output = limits?.output ?? 128000 + + // Update limits for non-config models (config models preserve their limits) + if (existing && !configModelIDs.includes(modelID)) { + newModels[modelID] = { ...existing, limit: { context, output } } + continue + } + if (existing) continue + newModels[modelID] = { id: ModelV2.ID.make(modelID), providerID: ProviderV2.ID.make(providerID), @@ -1783,7 +1810,7 @@ export const layer = Layer.effect( headers: {}, options: {}, cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 128000, output: 128000 }, + limit: { context, output }, capabilities: { temperature: false, reasoning: false, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 3006fd2bdafc..468454ec0717 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2067,3 +2067,97 @@ discoveryTestEffect(() => }, }, ) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model", context_length: 131072, max_output_tokens: 8192 }, + { id: "mistral-7b", object: "model", max_context_length: 32768, max_output_tokens: 4096 }, + ], + }), +).instance( + "discovered models use context_length and max_output_tokens from API response", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("context-provider")] + return p?.models["llama-3.1-8b"] && p?.models["mistral-7b"] ? (true as const) : undefined + }), + "discovered models not found", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("context-provider")] + + // context_length field should be used for context limit + expect(discoveredProvider.models["llama-3.1-8b"].limit.context).toBe(131072) + expect(discoveredProvider.models["llama-3.1-8b"].limit.output).toBe(8192) + + // max_context_length field should also work + expect(discoveredProvider.models["mistral-7b"].limit.context).toBe(32768) + expect(discoveredProvider.models["mistral-7b"].limit.output).toBe(4096) + }), + { + config: { + provider: { + "context-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Context Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "config-model", object: "model", context_length: 131072, max_output_tokens: 8192 }, + ], + }), +).instance( + "config-defined model limits take precedence over discovered limits", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("config-limit-provider")] + return p !== undefined ? (true as const) : undefined + }), + "provider disappeared", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("config-limit-provider")] + + // Config-defined limits should be preserved, not overwritten by discovery + expect(discoveredProvider.models["config-model"].limit.context).toBe(65536) + expect(discoveredProvider.models["config-model"].limit.output).toBe(4096) + }), + { + config: { + provider: { + "config-limit-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Config Limit Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "config-model": { + name: "Config Model", + limit: { + context: 65536, + output: 4096, + }, + }, + }, + }, + }, + }, + }, +) From 52b7184f680ac1c8805e48c652fc7540dfb563a7 Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Wed, 17 Jun 2026 18:13:21 -0400 Subject: [PATCH 7/8] feat(opencode): log warnings for discovery failures and config limits exceeding discovered --- packages/opencode/src/provider/provider.ts | 47 +++++++++++++---- .../opencode/test/provider/provider.test.ts | 50 +++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cd63098a8bec..d658eca2e0a1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1729,13 +1729,22 @@ export const layer = Layer.effect( Effect.timeout("10 seconds"), Effect.exit, ) - if (Exit.isFailure(fetchExit)) return emptyOrSkip() + if (Exit.isFailure(fetchExit)) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: "fetch error or timeout" }) + return emptyOrSkip() + } const res = fetchExit.value - if (res.status < 200 || res.status >= 300) return emptyOrSkip() + if (res.status < 200 || res.status >= 300) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: `non-2xx status ${res.status}` }) + return emptyOrSkip() + } const jsonExit = yield* res.json.pipe(Effect.exit) - if (Exit.isFailure(jsonExit)) return emptyOrSkip() + if (Exit.isFailure(jsonExit)) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: "failed to parse JSON response" }) + return emptyOrSkip() + } const json = jsonExit.value as unknown as OpenAIModelsResponse discovered = new Set( @@ -1755,7 +1764,10 @@ export const layer = Layer.effect( } } else if (discover) { const discoverExit = yield* Effect.promise(() => discover()).pipe(Effect.exit) - if (Exit.isFailure(discoverExit)) return emptyOrSkip() + if (Exit.isFailure(discoverExit)) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: "custom discover function failed" }) + return emptyOrSkip() + } const result = discoverExit.value discovered = new Set( @@ -1793,12 +1805,27 @@ export const layer = Layer.effect( const context = limits?.context ?? 128000 const output = limits?.output ?? 128000 - // Update limits for non-config models (config models preserve their limits) - if (existing && !configModelIDs.includes(modelID)) { - newModels[modelID] = { ...existing, limit: { context, output } } - continue - } - if (existing) continue + // Update limits for non-config models (config models preserve their limits) + if (existing && !configModelIDs.includes(modelID)) { + newModels[modelID] = { ...existing, limit: { context, output } } + continue + } + if (existing) { + // Warn when config limits exceed discovered limits + if (configModelIDs.includes(modelID) && limits) { + if (existing.limit.context > limits.context || existing.limit.output > limits.output) { + yield* Effect.logWarning("config limits exceed discovered limits", { + provider: providerID, + model: modelID, + configContext: existing.limit.context, + discoveredContext: limits.context, + configOutput: existing.limit.output, + discoveredOutput: limits.output, + }) + } + } + continue + } newModels[modelID] = { id: ModelV2.ID.make(modelID), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 468454ec0717..4773fa1e7a4e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2161,3 +2161,53 @@ discoveryTestEffect(() => }, }, ) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "overconfigured-model", object: "model", context_length: 32768, max_output_tokens: 2048 }, + ], + }), +).instance( + "config limits exceeding discovered limits are preserved and logged as warning", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("overconfigured-provider")] + return p !== undefined ? (true as const) : undefined + }), + "provider disappeared", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("overconfigured-provider")] + + // Config limits that exceed discovered limits should still be preserved + // (a warning is logged but the user's config wins) + expect(discoveredProvider.models["overconfigured-model"].limit.context).toBe(131072) + expect(discoveredProvider.models["overconfigured-model"].limit.output).toBe(8192) + }), + { + config: { + provider: { + "overconfigured-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Overconfigured Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "overconfigured-model": { + name: "Overconfigured Model", + limit: { + context: 131072, + output: 8192, + }, + }, + }, + }, + }, + }, + }, +) From 082b2c769d577828b12112e9018c074492e0740e Mon Sep 17 00:00:00 2001 From: Seth Jones Date: Wed, 17 Jun 2026 20:09:02 -0400 Subject: [PATCH 8/8] fix(opencode): handle TOCTOU race in discovery apply and sanitize openai-compatible schemas - Log warning and continue when provider disappears between discovery snapshot and apply - Add @ai-sdk/openai-compatible to schema sanitization check - Add test case for openai-compatible schema sanitization --- packages/opencode/src/provider/provider.ts | 6 +++++- packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/test/provider/transform.test.ts | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d658eca2e0a1..daa071bfac06 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1678,7 +1678,11 @@ export const layer = Layer.effect( delete s.providers[key] } else { const p = s.providers[key] - if (p) s.providers[key] = { ...p, models: result.models } + if (!p) { + yield* Effect.logWarning("Provider disappeared between discovery and apply", { provider: result.providerID }) + continue + } + s.providers[key] = { ...p, models: result.models } } } s.discoveryDone = true diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 92ff8fece835..1bf3ea5aea9e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1395,7 +1395,7 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } */ - if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") { + if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@ai-sdk/openai-compatible") { schema = sanitizeOpenAISchema(schema) as JSONSchema7 // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 04d75bfba273..8fc9f78c78bf 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1324,6 +1324,7 @@ describe("ProviderTransform.schema - openai supported schema subset", () => { ["opencode", "@ai-sdk/openai"], ["custom-openai-compatible", "@ai-sdk/openai"], ["azure", "@ai-sdk/azure"], + ["custom-openai-compatible", "@ai-sdk/openai-compatible"], ])("sanitizes %s models using %s", (providerID, npm) => { expect( ProviderTransform.schema(