From b54f6be51d785535bba331a48b3005235d691d32 Mon Sep 17 00:00:00 2001 From: Ruslan Beliy <13097754+nikakoy131@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:21:44 +0300 Subject: [PATCH] feat(provider): discover models for openai-compatible providers --- packages/opencode/src/cli/cmd/models.ts | 3 +- packages/opencode/src/provider/provider.ts | 136 ++++++++- packages/opencode/test/fake/provider.ts | 1 + .../opencode/test/provider/provider.test.ts | 263 +++++++++++++++++- packages/opencode/test/session/llm.test.ts | 6 +- 5 files changed, 404 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 38ac4881ccdf..b11c1881fa4d 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -25,12 +25,13 @@ export const ModelsCommand = effectCmd({ }), handler: Effect.fn("Cli.models")(function* (args) { const { Provider } = yield* Effect.promise(() => import("@/provider/provider")) + const provider = yield* Provider.Service if (args.refresh) { yield* ModelsDev.Service.use((s) => s.refresh(true)) + yield* provider.refreshDiscovery() UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - const provider = yield* Provider.Service const providers = yield* provider.list() const print = (providerID: ProviderV2.ID, verbose?: boolean) => { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 867ef2ca2fa8..d1d08b9fb498 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1135,7 +1135,10 @@ export function toPublicInfo(provider: Info): Info { } export function defaultModelIDs }>(providers: Record) { - return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) + return mapValues( + pickBy(providers, (item) => Object.keys(item.models).length > 0), + (item) => sort(Object.values(item.models))[0].id, + ) } export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderModelNotFoundError", { @@ -1203,6 +1206,7 @@ export interface Interface { ) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined> readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect readonly defaultModel: () => Effect.Effect<{ providerID: ProviderV2.ID; modelID: ModelV2.ID }, DefaultModelError> + readonly refreshDiscovery: () => Effect.Effect } interface State { @@ -1397,6 +1401,54 @@ const layer = Layer.effect( const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service + const runDiscovery = Effect.fn("Provider.runDiscovery")(function* (providers: Record) { + const cfg = yield* config.get() + const modelsDev = yield* modelsDevSvc.get() + const disabled = new Set(cfg.disabled_providers ?? []) + const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null + const eligible: Array<{ providerID: ProviderV2.ID; info: Info; npm: string; baseURL: string }> = [] + for (const [id, info] of Object.entries(providers)) { + const providerID = ProviderV2.ID.make(id) + if (enabled && !enabled.has(providerID)) continue + if (disabled.has(providerID)) continue + const configProvider = cfg.provider?.[providerID] + if (!configProvider) continue + if (Object.keys(configProvider.models ?? {}).length > 0) continue + const npm = configProvider.npm ?? modelsDev[providerID]?.npm ?? "@ai-sdk/openai-compatible" + if (npm !== "@ai-sdk/openai-compatible") continue + const baseURL = info.options.baseURL + if (typeof baseURL !== "string" || baseURL === "") continue + eligible.push({ providerID, info, npm, baseURL }) + } + if (eligible.length === 0) return + yield* Effect.promise(async () => { + const results = await Promise.all( + eligible.map(async (entry) => ({ + entry, + models: await discoverOpenAICompatibleModels({ + baseURL: entry.baseURL, + apiKey: resolveApiKey(entry.info), + providerID: entry.providerID, + npm: entry.npm, + }), + })), + ) + for (const { entry, models } of results) { + // Keep existing models when discovery finds nothing, so catalog providers pointed + // at an unreachable endpoint are not wiped; custom providers with no models are + // still removed by the 0-model filter below. + if (Object.keys(models).length === 0 && Object.keys(entry.info.models).length > 0) continue + const manual = cfg.provider?.[entry.providerID]?.models ?? {} + for (const modelID of Object.keys(entry.info.models)) { + if (!manual[modelID]) delete entry.info.models[modelID] + } + for (const [modelID, model] of Object.entries(models)) { + entry.info.models[modelID] = model + } + } + }) + }) + const state = yield* InstanceState.make(() => Effect.gen(function* () { const bridge = yield* EffectBridge.make() @@ -1668,6 +1720,8 @@ const layer = Layer.effect( }) } + yield* runDiscovery(providers) + for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderV2.ID.make(id) if (!isProviderAllowed(providerID)) { @@ -2040,7 +2094,12 @@ const layer = Layer.effect( } }) - return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) + const refreshDiscovery = Effect.fn("Provider.refreshDiscovery")(function* () { + const s = yield* InstanceState.get(state) + yield* runDiscovery(s.providers) + }) + + return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel, refreshDiscovery }) }), ) @@ -2063,6 +2122,79 @@ export function parseModel(model: string) { } } +export function discoverOpenAICompatibleModels(input: { + baseURL: string + apiKey?: string + providerID: ProviderV2.ID + npm: string + fetchFn?: (input: RequestInfo | URL, init?: RequestInit) => Promise +}): Promise> { + const baseURL = input.baseURL.replace(/\/+$/, "") + const url = baseURL + "/models" + const headers: Record = {} + if (typeof input.apiKey === "string" && input.apiKey !== "") headers.authorization = `Bearer ${input.apiKey}` + const modalities = (value: unknown): Model["capabilities"]["input"] => { + const list = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] + if (list.length === 0) return { text: true, audio: false, image: false, video: false, pdf: false } + return { + text: list.includes("text"), + audio: list.includes("audio"), + image: list.includes("image"), + video: list.includes("video"), + pdf: list.includes("pdf"), + } + } + const build = (item: { id: string } & Record): Model => { + const id = item.id + const architecture = isRecord(item.architecture) ? item.architecture : {} + return { + id: ModelV2.ID.make(id), + providerID: input.providerID, + api: { id, npm: input.npm, url: baseURL }, + name: id, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, output: 0 }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: modalities(architecture.input_modalities), + output: modalities(architecture.output_modalities), + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + return Promise.resolve() + .then(() => (input.fetchFn ?? fetch)(url, { headers, signal: AbortSignal.timeout(5_000) })) + .then((res) => (res.ok ? res.json() : undefined)) + .then((json) => { + if (!isRecord(json) || !Array.isArray(json.data)) return {} + const items = json.data.filter( + (item): item is { id: string } & Record => + isRecord(item) && typeof item.id === "string" && item.id !== "", + ) + const models: Record = {} + for (const item of items) { + models[item.id] = build(item) + } + return models + }) + .catch(() => ({})) +} + +function resolveApiKey(provider: Info): string | undefined { + const fromOptions = provider.options?.apiKey + if (typeof fromOptions === "string" && fromOptions !== "") return fromOptions + if (typeof provider.key === "string" && provider.key !== "") return provider.key + return undefined +} + export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index 1dbfa6fa71a6..3574607a8f45 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -74,6 +74,7 @@ export namespace ProviderTest { defaultModel: Effect.fn("TestProvider.defaultModel")(() => Effect.succeed({ providerID: row.id, modelID: mdl.id }), ), + refreshDiscovery: () => Effect.void, ...override, }), ), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 32d4e3a39b10..7514d4cfa3be 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, afterAll, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -2116,3 +2116,264 @@ it.effect("opencode loader keeps paid models when auth exists", () => expect(keyedCount).toBeGreaterThan(0) }).pipe(provideMultiInstance), ) + +const discoveryServer = Bun.serve({ + port: 0, + fetch: (request) => { + if (!request.url.endsWith("/v1/models")) return new Response("not found", { status: 404 }) + return Response.json({ + data: [ + { + id: "qwen36-35b-moe-128k", + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + }, + { id: "qwen38-27b-128k" }, + ], + }) + }, +}) + +const discoveryBaseURL = `http://127.0.0.1:${discoveryServer.port}/v1` + +afterAll(() => { + discoveryServer.stop(true) +}) + +test("discoverOpenAICompatibleModels maps /models entries to models", async () => { + let captured: { url: string; headers: Record | undefined } | undefined + const models = await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1/", + apiKey: "test-key", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: (input, init) => { + captured = { url: String(input), headers: init?.headers as Record | undefined } + return Promise.resolve( + Response.json({ + data: [ + { + id: "qwen36-35b-moe-128k", + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + }, + { id: "qwen38-27b-128k" }, + ], + }), + ) + }, + }) + + expect(captured?.url).toBe("http://127.0.0.1:8080/v1/models") + expect(captured?.headers).toEqual({ authorization: "Bearer test-key" }) + expect(Object.keys(models).sort()).toEqual(["qwen36-35b-moe-128k", "qwen38-27b-128k"]) + + const model = models["qwen36-35b-moe-128k"] + expect(model.id).toBe(ModelV2.ID.make("qwen36-35b-moe-128k")) + expect(model.providerID).toBe(ProviderV2.ID.make("llama.cpp")) + expect(model.api).toEqual({ id: "qwen36-35b-moe-128k", npm: "@ai-sdk/openai-compatible", url: "http://127.0.0.1:8080/v1" }) + expect(model.name).toBe("qwen36-35b-moe-128k") + expect(model.status).toBe("active") + expect(model.cost).toEqual({ input: 0, output: 0, cache: { read: 0, write: 0 } }) + expect(model.limit).toEqual({ context: 0, output: 0 }) + expect(model.capabilities).toEqual({ + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }) + expect(models["qwen38-27b-128k"].capabilities.input).toEqual({ text: true, audio: false, image: false, video: false, pdf: false }) +}) + +test("discoverOpenAICompatibleModels omits authorization header without apiKey", async () => { + let headers: unknown + const models = await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: (_input, init) => { + headers = init?.headers + return Promise.resolve(Response.json({ data: [{ id: "solo" }] })) + }, + }) + expect(headers ?? {}).toEqual({}) + expect(Object.keys(models)).toEqual(["solo"]) +}) + +test("discoverOpenAICompatibleModels returns {} when fetch rejects", async () => { + expect( + await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: () => Promise.reject(new Error("unreachable")), + }), + ).toEqual({}) +}) + +test("discoverOpenAICompatibleModels returns {} for non-2xx responses", async () => { + expect( + await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: () => Promise.resolve(new Response("unavailable", { status: 503 })), + }), + ).toEqual({}) +}) + +test("discoverOpenAICompatibleModels returns {} when response is not JSON", async () => { + expect( + await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: () => Promise.resolve(new Response("not json")), + }), + ).toEqual({}) +}) + +test("discoverOpenAICompatibleModels returns {} when data is not an array", async () => { + expect( + await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: () => Promise.resolve(Response.json({ data: "nope" })), + }), + ).toEqual({}) +}) + +test("discoverOpenAICompatibleModels skips entries without a usable id", async () => { + const models = await Provider.discoverOpenAICompatibleModels({ + baseURL: "http://127.0.0.1:8080/v1", + providerID: ProviderV2.ID.make("llama.cpp"), + npm: "@ai-sdk/openai-compatible", + fetchFn: () => Promise.resolve(Response.json({ data: [{ id: 42 }, { id: "" }, { id: "solo" }, "junk"] })), + }) + expect(Object.keys(models)).toEqual(["solo"]) +}) + +test("defaultModelIDs ignores providers without models", () => { + expect( + Provider.defaultModelIDs({ + empty: { models: {} }, + full: { models: { solo: { id: "solo" } } }, + }), + ).toEqual({ full: "solo" }) +}) + +it.instance( + "discovers models for custom openai-compatible providers", + () => + Effect.gen(function* () { + const providers = yield* list + const provider = providers[ProviderV2.ID.make("llama.cpp")] + expect(provider).toBeDefined() + expect(Object.keys(provider.models).sort()).toEqual(["qwen36-35b-moe-128k", "qwen38-27b-128k"]) + const model = provider.models["qwen36-35b-moe-128k"] + expect(model.api.url).toBe(discoveryBaseURL) + expect(model.api.npm).toBe("@ai-sdk/openai-compatible") + expect(model.capabilities.input).toEqual({ text: true, audio: false, image: false, video: false, pdf: false }) + expect(model.limit).toEqual({ context: 0, output: 0 }) + }), + { + config: { + provider: { + "llama.cpp": { + name: "llama.cpp", + options: { baseURL: discoveryBaseURL, apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "drops custom providers whose discovery endpoint is unreachable", + () => + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("llama.cpp")]).toBeUndefined() + }), + { + config: { + provider: { + "llama.cpp": { + name: "llama.cpp", + options: { baseURL: "http://127.0.0.1:9/v1" }, + }, + }, + }, + }, +) + +it.instance( + "keeps manual models config instead of discovered models", + () => + Effect.gen(function* () { + const providers = yield* list + const provider = providers[ProviderV2.ID.make("llama.cpp")] + expect(provider).toBeDefined() + expect(Object.keys(provider.models)).toEqual(["my-manual-model"]) + expect(provider.models["my-manual-model"].name).toBe("My Manual Model") + }), + { + config: { + provider: { + "llama.cpp": { + name: "llama.cpp", + options: { baseURL: discoveryBaseURL }, + models: { + "my-manual-model": { + name: "My Manual Model", + }, + }, + }, + }, + }, + }, +) + +it.instance( + "skips discovery for custom providers that are not openai-compatible", + () => + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("llama.cpp")]).toBeUndefined() + }), + { + config: { + provider: { + "llama.cpp": { + name: "llama.cpp", + npm: "@ai-sdk/openai", + options: { baseURL: discoveryBaseURL }, + }, + }, + }, + }, +) + +it.instance( + "does not discover models for catalog providers with a custom baseURL", + () => + Effect.gen(function* () { + yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key") + const providers = yield* list + const provider = providers[ProviderV2.ID.openai] + expect(provider).toBeDefined() + expect(provider.models["gpt-4"]).toBeDefined() + expect(provider.models["qwen36-35b-moe-128k"]).toBeUndefined() + }), + { + config: { + provider: { + openai: { + options: { baseURL: discoveryBaseURL }, + }, + }, + }, + }, +) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index fcb536f46d91..cb6e31a7e041 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -641,12 +641,16 @@ beforeAll(() => { state.server = Bun.serve({ port: 0, async fetch(req) { + const url = new URL(req.url) + if (req.method === "GET" && url.pathname.endsWith("/models")) { + return Response.json({ data: [] }) + } + const next = state.queue.shift() if (!next) { return new Response("unexpected request", { status: 500 }) } - const url = new URL(req.url) const body = (await req.json()) as Record next.resolve({ url, headers: req.headers, body })