Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/opencode/src/provider/discover.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<Discovered[]> {
const url = `${baseURL.replace(/\/+$/, "")}/models`
const headers: Record<string, string> = {}
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<string, unknown>).data)
? ((json as Record<string, unknown>).data as unknown)
: Array.isArray(json)
? json
: undefined
if (!data) throw new Error("model discovery returned unexpected shape")
return parseModels(data)
}
69 changes: 69 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, Model> = {}
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)) {
Expand Down
88 changes: 85 additions & 3 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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: {
Expand All @@ -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" },
},
},
},
Expand Down Expand Up @@ -2085,6 +2085,88 @@ it.effect("opencode loader keeps paid models when config apiKey is present", ()
}).pipe(provideMultiInstance),
)

const discoveryModelsServer = {
server: null as ReturnType<typeof Bun.serve> | 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()
Expand Down
Loading