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
54 changes: 50 additions & 4 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,49 @@ export namespace Provider {
},
}
},
async local(input) {
const baseURL = input.options?.baseURL
if (!baseURL) return { autoload: false }
try {
const url = `${String(baseURL).replace(/\/+$/, "")}/models`
const headers: Record<string, string> = { Accept: "application/json" }
const apiKey = input.options?.apiKey ?? input.key
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
const res = await fetch(url, { headers, signal: AbortSignal.timeout(3000) })
if (!res.ok) return { autoload: false }
const json = (await res.json()) as { data?: Array<{ id: string }> }
if (!json.data?.length) return { autoload: false }
for (const model of json.data) {
if (!model.id || input.models[model.id]) continue
input.models[model.id] = {
id: ModelID.make(model.id),
providerID: ProviderID.make("local"),
name: model.id,
api: { id: model.id, url: baseURL, npm: "@ai-sdk/openai-compatible" },
status: "active",
headers: {},
options: {},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 0, output: 0 },
capabilities: {
temperature: true,
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,
},
family: "",
release_date: "",
}
}
log.info("auto-discovered local models", { count: json.data.length })
return { autoload: true }
} catch {
return { autoload: false }
}
},
}

export const Model = z
Expand Down Expand Up @@ -1048,10 +1091,13 @@ export namespace Provider {
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
const data = database[providerID]
if (!data) {
log.error("Provider does not exist in model list " + providerID)
continue
const data = database[providerID] ?? {
id: providerID,
name: id,
source: "custom" as const,
env: [],
options: configProviders.find(([k]) => k === id)?.[1]?.options ?? {},
models: {},
}
const result = await fn(data)
if (result && (result.autoload || providers[providerID])) {
Expand Down
127 changes: 127 additions & 0 deletions packages/opencode/test/provider/local.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { test, expect, mock, afterEach } from "bun:test"
import path from "path"

import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"

const originalFetch = globalThis.fetch

function mockFetch(handler: (url: string) => Response | undefined) {
;(globalThis as any).fetch = mock((url: string | URL | Request) => {
const u = typeof url === "string" ? url : url instanceof URL ? url.href : url.url
const result = handler(u)
if (result) return Promise.resolve(result)
return originalFetch(url as RequestInfo, undefined)
})
}

afterEach(() => {
globalThis.fetch = originalFetch
})

test("local provider discovers models from /models endpoint", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
local: {
options: {
baseURL: "http://localhost:11434/v1",
},
},
},
}),
)
},
})

mockFetch((url) => {
if (url === "http://localhost:11434/v1/models") {
return new Response(
JSON.stringify({
data: [{ id: "llama-3-8b" }, { id: "mistral-7b" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const local = providers["local"]
expect(local).toBeDefined()
expect(local.models["llama-3-8b"]).toBeDefined()
expect(local.models["mistral-7b"]).toBeDefined()
expect(local.models["llama-3-8b"].providerID).toBe(ProviderID.make("local"))
},
})
})

test("local provider not loaded when endpoint unreachable", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
local: {
options: {
baseURL: "http://localhost:9999/v1",
},
},
},
}),
)
},
})

;(globalThis as any).fetch = mock((url: string | URL | Request) => {
const u = typeof url === "string" ? url : url instanceof URL ? url.href : url.url
if (u === "http://localhost:9999/v1/models") {
return Promise.reject(new Error("ECONNREFUSED"))
}
return originalFetch(url as RequestInfo, undefined)
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers["local"]).toBeUndefined()
},
})
})

test("local provider not loaded without baseURL", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
local: {
options: {},
},
},
}),
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers["local"]).toBeUndefined()
},
})
})
Loading