Skip to content
Closed
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
56 changes: 56 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
195 changes: 194 additions & 1 deletion 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 { afterEach, expect, mock, test } from "bun:test"
import { mkdir, unlink } from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
Expand All @@ -22,6 +22,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"

const originalEnv = new Map<string, string | undefined>()
const originalFetch = globalThis.fetch

const rememberEnv = (k: string) => {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
Expand Down Expand Up @@ -53,6 +54,7 @@ afterEach(async () => {
else process.env[key] = value
}
originalEnv.clear()
globalThis.fetch = originalFetch
await disposeAllInstances()
})

Expand Down Expand Up @@ -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("<html>error</html>", { 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,
},
},
},
},
},
},
)
Loading