diff --git a/packages/opencode/src/provider/discover.ts b/packages/opencode/src/provider/discover.ts new file mode 100644 index 000000000000..7c1b59774c5d --- /dev/null +++ b/packages/opencode/src/provider/discover.ts @@ -0,0 +1,80 @@ +export * as ProviderDiscover from "./discover" + +// Maps an OpenAI-compatible /models response to the provider Model shape. +// Used to keep custom config providers in sync with the models their +// endpoint actually serves instead of a static or catalog-stale list. + +const DISCOVERY_TIMEOUT_MS = 5_000 + +type RawModel = { + id?: unknown + object?: unknown + created?: unknown + owned_by?: unknown + context_length?: unknown + max_model_len?: unknown +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +export type Discovered = { + id: string + name?: string + created?: number + context?: number + output?: number +} + +export function parseModels(data: unknown): Discovered[] { + if (!Array.isArray(data)) return [] + const out: Discovered[] = [] + for (const item of data) { + if (!item || typeof item !== "object") continue + const record = item as RawModel + const id = asString(record.id) + if (!id) continue + // Skip non-chat entries like embeddings/rerank when identifiable + if (/(embed|rerank|whisper|tts|moderation)/i.test(id)) continue + out.push({ + id, + name: asString((record as Record).name), + created: asNumber(record.created), + context: asNumber(record.context_length) ?? asNumber(record.max_model_len), + }) + } + return out +} + +export async function discover( + baseURL: string, + apiKey: string | undefined, +): Promise { + const url = `${baseURL.replace(/\/+$/, "")}/models` + const headers: Record = {} + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}` + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(`model discovery failed: ${res.status}`) + const json: unknown = await res.json() + const data = + json && typeof json === "object" && Array.isArray((json as Record).data) + ? ((json as Record).data as unknown) + : Array.isArray(json) + ? json + : undefined + if (!data) throw new Error("model discovery returned unexpected shape") + return parseModels(data) +} \ No newline at end of file diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 867ef2ca2fa8..199efafbf99f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -26,6 +26,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { isRecord } from "@/util/record" import { optional } from "@opencode-ai/core/schema" import { ProviderTransform } from "./transform" +import { ProviderDiscover } from "./discover" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { ModelStatus } from "./model-status" @@ -1579,6 +1580,74 @@ const layer = Layer.effect( database[providerID] = parsed } + // Discover models for openai-compatible config providers so the list + // reflects what the endpoint actually serves. Stale catalog/config + // entries for endpoints the server no longer serves are dropped; + // on discovery failure the configured list is kept. + const discoveryEnvs = yield* env.all() + for (const [providerID, provider] of configProviders) { + if (provider.npm !== "@ai-sdk/openai-compatible") continue + if (modelsDev[providerID]?.api === provider.options?.baseURL) continue + const id = ProviderV2.ID.make(providerID) + if (!isProviderAllowed(id)) continue + const rawURL = iife(() => { + if (typeof provider.options?.baseURL === "string" && provider.options.baseURL !== "") + return provider.options.baseURL + if (typeof provider.api === "string" && provider.api !== "") return provider.api + return undefined + }) + if (!rawURL) continue + const baseURL = rawURL.replace(/\$\{([^}]+)\}/g, (item, key) => discoveryEnvs[String(key)] ?? item) + if (!/^https?:\/\//.test(baseURL)) continue + const target = database[id] + if (!target) continue + const storedAuth = yield* auth.get(id).pipe(Effect.orDie) + yield* Effect.promise(async () => { + try { + const key = + typeof provider.options?.apiKey === "string" + ? provider.options.apiKey + : (storedAuth?.type === "api" ? storedAuth.key : undefined) + const found = await ProviderDiscover.discover(baseURL, key) + if (!found.length) return + const next: Record = {} + for (const item of found) { + const template = target.models[item.id] + next[item.id] = template ?? { + id: ModelV2.ID.make(item.id), + providerID: id, + name: item.name ?? item.id, + family: "", + api: { + id: item.id, + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: item.context ?? 0, input: undefined, output: item.output ?? 0 }, + 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: item.created ? new Date(item.created * 1000).toISOString().slice(0, 10) : "", + variants: {}, + } + } + target.models = next + } catch { + // Endpoint unreachable or unsupported; keep configured models. + } + }) + } + // load env const envs = yield* env.all() for (const [id, provider] of Object.entries(database)) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 32d4e3a39b10..bba6d91f48bd 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 { afterAll, afterEach, beforeAll, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -873,7 +873,7 @@ it.instance( const providers = yield* list expect(providers[ProviderV2.ID.make("local-llm")]).toBeDefined() expect(providers[ProviderV2.ID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") - expect(providers[ProviderV2.ID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") + expect(providers[ProviderV2.ID.make("local-llm")].options.baseURL).toBe("http://127.0.0.1:9/v1") }), { config: { @@ -883,7 +883,7 @@ it.instance( npm: "@ai-sdk/openai-compatible", env: [], models: { "llama-3": { name: "Llama 3", tool_call: true, limit: { context: 8192, output: 2048 } } }, - options: { apiKey: "not-needed", baseURL: "http://localhost:11434/v1" }, + options: { apiKey: "not-needed", baseURL: "http://127.0.0.1:9/v1" }, }, }, }, @@ -2085,6 +2085,88 @@ it.effect("opencode loader keeps paid models when config apiKey is present", () }).pipe(provideMultiInstance), ) +const discoveryModelsServer = { + server: null as ReturnType | null, + url: "", + start() { + if (this.server) return + this.server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + if (url.pathname === "/v1/models") { + return Response.json({ + object: "list", + data: [ + { id: "server-model-a", object: "model", created: 1700000000, owned_by: "test" }, + { id: "server-model-b", object: "model" }, + { id: "text-embedding-x", object: "model" }, + ], + }) + } + return new Response("not found", { status: 404 }) + }, + }) + this.url = `${this.server.url.origin}/v1` + }, +} + +beforeAll(() => discoveryModelsServer.start()) +afterAll(() => discoveryModelsServer.server?.stop(true)) + +it.instance( + "openai-compatible provider discovers models from baseURL", + Effect.gen(function* () { + const providers = yield* list + const provider = providers[ProviderV2.ID.make("discovery-provider")] + expect(provider).toBeDefined() + // Discovered from the live server, not the stale configured list + expect(provider.models["server-model-a"]).toBeDefined() + expect(provider.models["server-model-b"]).toBeDefined() + expect(provider.models["text-embedding-x"]).toBeUndefined() + expect(provider.models["configured-but-gone"]).toBeUndefined() + const model = provider.models["server-model-a"] + expect(model.api.npm).toBe("@ai-sdk/openai-compatible") + expect(model.capabilities.toolcall).toBe(true) + expect(model.cost.input).toBe(0) + }), + { + config: () => ({ + provider: { + "discovery-provider": { + name: "Discovery Provider", + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "test-key", baseURL: discoveryModelsServer.url }, + models: { "configured-but-gone": { name: "Gone" } }, + }, + }, + }), + }, +) + +it.instance( + "openai-compatible discovery failure falls back to configured models", + Effect.gen(function* () { + const providers = yield* list + const provider = providers[ProviderV2.ID.make("discovery-fallback")] + expect(provider).toBeDefined() + expect(provider.models["configured-model"]).toBeDefined() + expect(provider.models["server-model-a"]).toBeUndefined() + }), + { + config: { + provider: { + "discovery-fallback": { + name: "Discovery Fallback", + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "test-key", baseURL: "http://127.0.0.1:9/v1" }, + models: { "configured-model": { name: "Configured Model" } }, + }, + }, + }, + }, +) + it.effect("opencode loader keeps paid models when auth exists", () => Effect.gen(function* () { const noneDir = yield* tmpdirScoped()