From 9a47124e8db7dcfb5484f787e7392b6828c3ce40 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Thu, 15 Jan 2026 23:15:44 +0200 Subject: [PATCH 01/13] opencode: added logic to probe loaded models from lmstudio, ollama and llama-server --- packages/opencode/src/cli/cmd/debug/index.ts | 2 + .../opencode/src/cli/cmd/debug/provider.ts | 31 ++++ packages/opencode/src/provider/local/index.ts | 34 +++++ .../opencode/src/provider/local/llamacpp.ts | 143 ++++++++++++++++++ .../opencode/src/provider/local/lmstudio.ts | 47 ++++++ .../opencode/src/provider/local/ollama.ts | 84 ++++++++++ 6 files changed, 341 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/debug/provider.ts create mode 100644 packages/opencode/src/provider/local/index.ts create mode 100644 packages/opencode/src/provider/local/llamacpp.ts create mode 100644 packages/opencode/src/provider/local/lmstudio.ts create mode 100644 packages/opencode/src/provider/local/ollama.ts diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 8da6ff559373..0d09e3ace12f 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -4,6 +4,7 @@ import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" import { LSPCommand } from "./lsp" +import { ProviderCommand } from "./provider" import { RipgrepCommand } from "./ripgrep" import { ScrapCommand } from "./scrap" import { SkillCommand } from "./skill" @@ -17,6 +18,7 @@ export const DebugCommand = cmd({ yargs .command(ConfigCommand) .command(LSPCommand) + .command(ProviderCommand) .command(RipgrepCommand) .command(FileCommand) .command(ScrapCommand) diff --git a/packages/opencode/src/cli/cmd/debug/provider.ts b/packages/opencode/src/cli/cmd/debug/provider.ts new file mode 100644 index 000000000000..be3eaf8c9454 --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/provider.ts @@ -0,0 +1,31 @@ +import { EOL } from "os" +import { LocalProvider, type LocalModel } from "../../../provider/local" +import { cmd } from "../cmd" + +export const ProviderCommand = cmd({ + command: "provider", + describe: "probe local LLM providers", + builder: (yargs) => + yargs.command({ + command: "probe ", + describe: "probe a local provider for loaded models", + builder: (yargs) => + yargs + .positional("type", { + describe: "provider type", + type: "string", + choices: Object.values(LocalProvider).filter((v) => typeof v === "string") as LocalProvider[], + }) + .positional("url", { + describe: "provider URL", + type: "string", + }), + async handler(args) { + const type = args.type as LocalProvider + const url = args.url as string + const result = await LocalProvider.probe(type, url) + process.stdout.write(JSON.stringify(result, null, 2) + EOL) + }, + }), + async handler() {}, +}) diff --git a/packages/opencode/src/provider/local/index.ts b/packages/opencode/src/provider/local/index.ts new file mode 100644 index 000000000000..13c6cea306e0 --- /dev/null +++ b/packages/opencode/src/provider/local/index.ts @@ -0,0 +1,34 @@ +import { Log } from "../../util/log" +import { ollama_probe_loaded_models } from "./ollama" +import { lmstudio_probe_loaded_models } from "./lmstudio" +import { llamaccpp_probe_loaded_models } from "./llamacpp" + +export enum LocalProvider { + Ollama = "ollama", + LMStudio = "lmstudio", + LlamaCPP = "llamacpp", +} + +export interface LocalModel { + id: string + context_length: number + tool_call: boolean + vision: boolean +} + +export namespace LocalProvider { + const log = Log.create({ service: "localprovider" }) + + export async function probe(provider: LocalProvider, url: string): Promise { + switch (provider) { + case LocalProvider.Ollama: + return await ollama_probe_loaded_models(url) + case LocalProvider.LMStudio: + return await lmstudio_probe_loaded_models(url) + case LocalProvider.LlamaCPP: + return await llamaccpp_probe_loaded_models(url) + default: + throw new Error(`Unsupported provider: ${provider}`) + } + } +} diff --git a/packages/opencode/src/provider/local/llamacpp.ts b/packages/opencode/src/provider/local/llamacpp.ts new file mode 100644 index 000000000000..1b5d4eef76ca --- /dev/null +++ b/packages/opencode/src/provider/local/llamacpp.ts @@ -0,0 +1,143 @@ +import { type LocalModel } from "./index" + +export interface LlamaCppModelStatus { + value?: "loaded" | "loading" | "unloaded" + args?: string[] + failed?: boolean + exit_code?: number +} + +export interface LlamaCppModelInfo { + id: string + status?: LlamaCppModelStatus +} + +export interface LlamaCppModelsResponse { + data?: LlamaCppModelInfo[] +} + +export interface LlamaCppV1Model { + id: string + object?: string + meta?: Record | null +} + +export interface LlamaCppV1ModelsResponse { + data?: LlamaCppV1Model[] + object?: string +} + +export interface LlamaCppPropsResponse { + default_generation_settings?: { + n_ctx?: number + } + modalities?: { + vision?: boolean + } + chat_template_caps?: Record +} + +function llamaccpp_base_url(url: string): string { + return url.replace(/\/$/, "") +} + +function llamaccpp_tool_capable(caps?: Record): boolean { + if (!caps) return false + const value = caps.supports_tool_calls + return value === true || value === "true" +} + +function llamaccpp_context_from_meta(meta?: Record | null): number { + if (!meta) return 0 + const ctxTrain = meta.n_ctx_train + if (typeof ctxTrain === "number" && ctxTrain > 0) return ctxTrain + const ctx = meta.n_ctx + if (typeof ctx === "number" && ctx > 0) return ctx + return 0 +} + +async function llamaccpp_fetch_models(url: string): Promise { + const endpoint = llamaccpp_base_url(url) + "/models" + const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) return null + return res.json() as Promise +} + +async function llamaccpp_fetch_v1_models(url: string): Promise { + const endpoint = llamaccpp_base_url(url) + "/v1/models" + const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { + const respText = await res.text() + throw new Error(`LlamaCPP probe failed with status ${res.status}: ${respText}`) + } + + return res.json() as Promise +} + +async function llamaccpp_fetch_props(url: string, model?: string): Promise { + const base = llamaccpp_base_url(url) + const query = model ? `?model=${encodeURIComponent(model)}` : "" + const endpoint = `${base}/props${query}` + const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { + const respText = await res.text() + throw new Error(`LlamaCPP props failed with status ${res.status}: ${respText}`) + } + + return res.json() as Promise +} + +async function llamaccpp_model_from_props(url: string, id: string, model?: string): Promise { + const props = await llamaccpp_fetch_props(url, model) + const context = props.default_generation_settings?.n_ctx ?? 0 + const vision = props.modalities?.vision === true + const tool_call = llamaccpp_tool_capable(props.chat_template_caps) + + return { + id, + context_length: context, + tool_call, + vision, + } +} + +async function llamaccpp_model_from_props_or_meta( + url: string, + id: string, + model: string | undefined, + meta?: Record | null, +): Promise { + return llamaccpp_model_from_props(url, id, model).catch(() => ({ + id, + context_length: llamaccpp_context_from_meta(meta), + tool_call: false, + vision: false, + })) +} + +export async function llamaccpp_probe_loaded_models(url: string): Promise { + const models = await llamaccpp_fetch_models(url).catch(() => null) + if (models) { + const items = models.data ?? [] + const router = items.some((m) => m.status) + if (router) { + const loaded = items.filter((m) => m.status?.value === "loaded") + if (loaded.length === 0) return [] + const v1 = await llamaccpp_fetch_v1_models(url).catch(() => ({ data: [] }) as LlamaCppV1ModelsResponse) + const map = new Map(v1.data?.map((m) => [m.id, m.meta]) ?? []) + return Promise.all( + loaded.map((m) => { + const id = m.id + const meta = map.get(id) ?? null + return llamaccpp_model_from_props_or_meta(url, id, id, meta) + }), + ) + } + } + + const v1 = await llamaccpp_fetch_v1_models(url) + const v1items = v1.data ?? [] + if (v1items.length === 0) return [] + + return Promise.all(v1items.map((m) => llamaccpp_model_from_props_or_meta(url, m.id, m.id, m.meta))) +} diff --git a/packages/opencode/src/provider/local/lmstudio.ts b/packages/opencode/src/provider/local/lmstudio.ts new file mode 100644 index 000000000000..fc6d3160b509 --- /dev/null +++ b/packages/opencode/src/provider/local/lmstudio.ts @@ -0,0 +1,47 @@ +import { type LocalModel } from "./index" + +export interface LMStudioModel { + id: string + object: "model" + type: "llm" | "vlm" | "embeddings" + publisher: string + arch: string + compatibility_type: string + quantization: string + state: "loaded" | "loading" | "not-loaded" + max_context_length: number + loaded_context_length?: number + capabilities?: string[] +} + +export interface LMStudioModelsResponse { + data: LMStudioModel[] + object: "list" +} + +export async function lmstudio_probe_loaded_models(url: string): Promise { + const endpoint = url.replace(/\/$/, "") + "/api/v0/models" + + const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { + const respText = await res.text() + throw new Error(`LMStudio probe failed with status ${res.status}: ${respText}`) + } + + const body = (await res.json()) as LMStudioModelsResponse + if (!body.data) { + throw new Error("LMStudio probe failed: no data field in response") + } + + const loaded_models = body.data + .filter((m) => m.state === "loaded") + .filter((m) => m.type === "llm" || m.type === "vlm") + .filter((m) => m.loaded_context_length && m.loaded_context_length > 0) + + return loaded_models.map((m) => ({ + id: m.id, + context_length: m.loaded_context_length as number, + tool_call: m.capabilities ? m.capabilities.includes("tool_use") : false, + vision: m.type === "vlm", + })) +} diff --git a/packages/opencode/src/provider/local/ollama.ts b/packages/opencode/src/provider/local/ollama.ts new file mode 100644 index 000000000000..66d886ea1ac5 --- /dev/null +++ b/packages/opencode/src/provider/local/ollama.ts @@ -0,0 +1,84 @@ +import { type LocalModel } from "./index" + +export interface OllamaModelDetails { + parent_model?: string + format?: string + family?: string + families?: string[] + parameter_size?: string + quantization_level?: string +} + +export interface OllamaLoadedModel { + name: string + model: string + size: number + digest: string + details?: OllamaModelDetails + expires_at?: string + size_vram: number + context_length: number +} + +export interface OllamaLoadedModelsResponse { + models: OllamaLoadedModel[] +} + +export interface OllamaModelShowResponse { + parameters?: string + license?: string + capabilities?: string[] + modified_at?: string + details?: OllamaModelDetails + template?: string + model_info?: Record +} + +async function ollama_show_model(url: string, model: string): Promise { + const endpoint = url.replace(/\/$/, "") + "/api/show" + + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + signal: AbortSignal.timeout(3000), + }) + + if (!res.ok) { + const respText = await res.text() + throw new Error(`Ollama show model failed with status ${res.status}: ${respText}`) + } + + return res.json() as Promise +} + +export async function ollama_probe_loaded_models(url: string): Promise { + const endpoint = url.replace(/\/$/, "") + "/api/ps" + + const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { + const respText = await res.text() + throw new Error(`Ollama probe failed with status ${res.status}: ${respText}`) + } + + const body = (await res.json()) as OllamaLoadedModelsResponse + if (body.models === undefined) { + throw new Error("Ollama probe failed: no models field in response") + } + + const models: LocalModel[] = await Promise.all( + body.models.map(async (m) => { + const show = await ollama_show_model(url, m.model).catch(() => ({} as OllamaModelShowResponse)) + const caps = show.capabilities ?? [] + + return { + id: m.name, + context_length: m.context_length, + tool_call: caps.includes("tools"), + vision: caps.includes("vision"), + } + }) + ) + + return models +} From 7cd5036ea23947e9d63e588181f81505242986ee Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 11:30:32 +0200 Subject: [PATCH 02/13] opencode: added local provider url detection --- .../opencode/src/cli/cmd/debug/provider.ts | 27 ++++++--- packages/opencode/src/provider/local/index.ts | 41 +++++++++++-- .../opencode/src/provider/local/llamacpp.ts | 57 ++++++++++++------- .../opencode/src/provider/local/lmstudio.ts | 22 +++++++ .../opencode/src/provider/local/ollama.ts | 15 +++++ 5 files changed, 127 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/provider.ts b/packages/opencode/src/cli/cmd/debug/provider.ts index be3eaf8c9454..7438bc1441e9 100644 --- a/packages/opencode/src/cli/cmd/debug/provider.ts +++ b/packages/opencode/src/cli/cmd/debug/provider.ts @@ -7,24 +7,33 @@ export const ProviderCommand = cmd({ describe: "probe local LLM providers", builder: (yargs) => yargs.command({ - command: "probe ", + command: "probe ", describe: "probe a local provider for loaded models", builder: (yargs) => yargs - .positional("type", { - describe: "provider type", - type: "string", - choices: Object.values(LocalProvider).filter((v) => typeof v === "string") as LocalProvider[], - }) .positional("url", { describe: "provider URL", type: "string", }), async handler(args) { - const type = args.type as LocalProvider const url = args.url as string - const result = await LocalProvider.probe(type, url) - process.stdout.write(JSON.stringify(result, null, 2) + EOL) + + const type = await LocalProvider.detect_provider(url); + if (!type) { + console.error(`No supported local provider detected at URL: ${url}`) + process.exit(1) + } + + console.log(`Detected provider type: ${type} at URL: ${url}`) + const result = await LocalProvider.probe_provider(type, url) + + if (result.length === 0) { + console.log("No loaded models found") + return + } + + console.log(`Found ${result.length} loaded models:`) + console.log(JSON.stringify(result, null, 2)) }, }), async handler() {}, diff --git a/packages/opencode/src/provider/local/index.ts b/packages/opencode/src/provider/local/index.ts index 13c6cea306e0..01ecdb70287e 100644 --- a/packages/opencode/src/provider/local/index.ts +++ b/packages/opencode/src/provider/local/index.ts @@ -1,7 +1,7 @@ import { Log } from "../../util/log" -import { ollama_probe_loaded_models } from "./ollama" -import { lmstudio_probe_loaded_models } from "./lmstudio" -import { llamaccpp_probe_loaded_models } from "./llamacpp" +import { ollama_probe_loaded_models, ollama_detect_provider } from "./ollama" +import { lmstudio_probe_loaded_models, lmstudio_detect_provider } from "./lmstudio" +import { llamacpp_probe_loaded_models, llamacpp_detect_provider } from "./llamacpp" export enum LocalProvider { Ollama = "ollama", @@ -19,16 +19,47 @@ export interface LocalModel { export namespace LocalProvider { const log = Log.create({ service: "localprovider" }) - export async function probe(provider: LocalProvider, url: string): Promise { + export async function detect_provider(url: string): Promise { + log.debug(`Detecting local provider at URL: ${url}`) + + if (await ollama_detect_provider(url)) { + log.info(`Detected Ollama provider at URL: ${url}`) + return LocalProvider.Ollama + } + + if (await lmstudio_detect_provider(url)) { + log.info(`Detected LMStudio provider at URL: ${url}`) + return LocalProvider.LMStudio + } + + if (await llamacpp_detect_provider(url)) { + log.info(`Detected LlamaCPP provider at URL: ${url}`) + return LocalProvider.LlamaCPP + } + + log.info(`No supported local provider detected at URL: ${url}`) + return null + } + + export async function probe_provider(provider: LocalProvider, url: string): Promise { switch (provider) { case LocalProvider.Ollama: return await ollama_probe_loaded_models(url) case LocalProvider.LMStudio: return await lmstudio_probe_loaded_models(url) case LocalProvider.LlamaCPP: - return await llamaccpp_probe_loaded_models(url) + return await llamacpp_probe_loaded_models(url) default: throw new Error(`Unsupported provider: ${provider}`) } } + + export async function probe_url(url: string): Promise<[LocalProvider, LocalModel[]]> { + const provider = await detect_provider(url) + if (!provider) { + throw new Error(`No supported local provider detected at URL: ${url}`) + } + + return [provider, await probe_provider(provider, url)] + } } diff --git a/packages/opencode/src/provider/local/llamacpp.ts b/packages/opencode/src/provider/local/llamacpp.ts index 1b5d4eef76ca..fa4d75a31ea6 100644 --- a/packages/opencode/src/provider/local/llamacpp.ts +++ b/packages/opencode/src/provider/local/llamacpp.ts @@ -37,17 +37,17 @@ export interface LlamaCppPropsResponse { chat_template_caps?: Record } -function llamaccpp_base_url(url: string): string { +function llamacpp_base_url(url: string): string { return url.replace(/\/$/, "") } -function llamaccpp_tool_capable(caps?: Record): boolean { +function llamacpp_tool_capable(caps?: Record): boolean { if (!caps) return false const value = caps.supports_tool_calls return value === true || value === "true" } -function llamaccpp_context_from_meta(meta?: Record | null): number { +function llamacpp_context_from_meta(meta?: Record | null): number { if (!meta) return 0 const ctxTrain = meta.n_ctx_train if (typeof ctxTrain === "number" && ctxTrain > 0) return ctxTrain @@ -56,15 +56,15 @@ function llamaccpp_context_from_meta(meta?: Record | null): num return 0 } -async function llamaccpp_fetch_models(url: string): Promise { - const endpoint = llamaccpp_base_url(url) + "/models" +async function llamacpp_fetch_models(url: string): Promise { + const endpoint = llamacpp_base_url(url) + "/models" const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) return null return res.json() as Promise } -async function llamaccpp_fetch_v1_models(url: string): Promise { - const endpoint = llamaccpp_base_url(url) + "/v1/models" +async function llamacpp_fetch_v1_models(url: string): Promise { + const endpoint = llamacpp_base_url(url) + "/v1/models" const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) { const respText = await res.text() @@ -74,8 +74,8 @@ async function llamaccpp_fetch_v1_models(url: string): Promise } -async function llamaccpp_fetch_props(url: string, model?: string): Promise { - const base = llamaccpp_base_url(url) +async function llamacpp_fetch_props(url: string, model?: string): Promise { + const base = llamacpp_base_url(url) const query = model ? `?model=${encodeURIComponent(model)}` : "" const endpoint = `${base}/props${query}` const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) @@ -87,11 +87,11 @@ async function llamaccpp_fetch_props(url: string, model?: string): Promise } -async function llamaccpp_model_from_props(url: string, id: string, model?: string): Promise { - const props = await llamaccpp_fetch_props(url, model) +async function llamacpp_model_from_props(url: string, id: string, model?: string): Promise { + const props = await llamacpp_fetch_props(url, model) const context = props.default_generation_settings?.n_ctx ?? 0 const vision = props.modalities?.vision === true - const tool_call = llamaccpp_tool_capable(props.chat_template_caps) + const tool_call = llamacpp_tool_capable(props.chat_template_caps) return { id, @@ -101,43 +101,58 @@ async function llamaccpp_model_from_props(url: string, id: string, model?: strin } } -async function llamaccpp_model_from_props_or_meta( +async function llamacpp_model_from_props_or_meta( url: string, id: string, model: string | undefined, meta?: Record | null, ): Promise { - return llamaccpp_model_from_props(url, id, model).catch(() => ({ + return llamacpp_model_from_props(url, id, model).catch(() => ({ id, - context_length: llamaccpp_context_from_meta(meta), + context_length: llamacpp_context_from_meta(meta), tool_call: false, vision: false, })) } -export async function llamaccpp_probe_loaded_models(url: string): Promise { - const models = await llamaccpp_fetch_models(url).catch(() => null) +export async function llamacpp_detect_provider(url: string): Promise { + try { + const endpoint = llamacpp_base_url(url) + const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) + if (!res.ok) { + return false + } + + return res.headers.get("Server")?.toLowerCase() === "llama.cpp" + } + catch (e) { + return false + } +} + +export async function llamacpp_probe_loaded_models(url: string): Promise { + const models = await llamacpp_fetch_models(url).catch(() => null) if (models) { const items = models.data ?? [] const router = items.some((m) => m.status) if (router) { const loaded = items.filter((m) => m.status?.value === "loaded") if (loaded.length === 0) return [] - const v1 = await llamaccpp_fetch_v1_models(url).catch(() => ({ data: [] }) as LlamaCppV1ModelsResponse) + const v1 = await llamacpp_fetch_v1_models(url).catch(() => ({ data: [] }) as LlamaCppV1ModelsResponse) const map = new Map(v1.data?.map((m) => [m.id, m.meta]) ?? []) return Promise.all( loaded.map((m) => { const id = m.id const meta = map.get(id) ?? null - return llamaccpp_model_from_props_or_meta(url, id, id, meta) + return llamacpp_model_from_props_or_meta(url, id, id, meta) }), ) } } - const v1 = await llamaccpp_fetch_v1_models(url) + const v1 = await llamacpp_fetch_v1_models(url) const v1items = v1.data ?? [] if (v1items.length === 0) return [] - return Promise.all(v1items.map((m) => llamaccpp_model_from_props_or_meta(url, m.id, m.id, m.meta))) + return Promise.all(v1items.map((m) => llamacpp_model_from_props_or_meta(url, m.id, m.id, m.meta))) } diff --git a/packages/opencode/src/provider/local/lmstudio.ts b/packages/opencode/src/provider/local/lmstudio.ts index fc6d3160b509..6567c2f15615 100644 --- a/packages/opencode/src/provider/local/lmstudio.ts +++ b/packages/opencode/src/provider/local/lmstudio.ts @@ -1,3 +1,4 @@ +import { ca } from "zod/v4/locales" import { type LocalModel } from "./index" export interface LMStudioModel { @@ -19,6 +20,27 @@ export interface LMStudioModelsResponse { object: "list" } +// Documented here: https://github.com/lmstudio-ai/lms/blob/main/src/createClient.ts#L18 +interface LMStudioGreeting { + lmstudio: boolean +} + +export async function lmstudio_detect_provider(url: string): Promise { + try { + const endpoint = url.replace(/\/$/, "") + "/lmstudio-greeting" + const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) + if (!res.ok) { + return false + } + + const greeting = await res.json() as LMStudioGreeting + return greeting.lmstudio === true + } + catch (e) { + return false + } +} + export async function lmstudio_probe_loaded_models(url: string): Promise { const endpoint = url.replace(/\/$/, "") + "/api/v0/models" diff --git a/packages/opencode/src/provider/local/ollama.ts b/packages/opencode/src/provider/local/ollama.ts index 66d886ea1ac5..eaae8c0d53f8 100644 --- a/packages/opencode/src/provider/local/ollama.ts +++ b/packages/opencode/src/provider/local/ollama.ts @@ -52,6 +52,21 @@ async function ollama_show_model(url: string, model: string): Promise } +export async function ollama_detect_provider(url: string): Promise { + const endpoint = url.replace(/\/$/, "") + "/" + + try { + const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) + if (!res.ok) { + return false + } + + return await res.text() === "Ollama is running" + } catch (e) { + return false + } +} + export async function ollama_probe_loaded_models(url: string): Promise { const endpoint = url.replace(/\/$/, "") + "/api/ps" From d07ee24fb96c6094213007c3ea3f48dbe3608c16 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 12:05:19 +0200 Subject: [PATCH 03/13] fix: prevent authorization header from using dummy API key --- packages/opencode/src/provider/model-detection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/model-detection.ts b/packages/opencode/src/provider/model-detection.ts index 0dc65fd3fcd1..c1001e18db69 100644 --- a/packages/opencode/src/provider/model-detection.ts +++ b/packages/opencode/src/provider/model-detection.ts @@ -1,6 +1,7 @@ import z from "zod" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { OAUTH_DUMMY_KEY } from "@/auth" import { Provider } from "./provider" export namespace ProviderModelDetection { @@ -47,7 +48,7 @@ export namespace ProviderModelDetection.OpenAICompatible { const fetchFn = provider.options["fetch"] ?? fetch const apiKey = provider.options["apiKey"] ?? provider.key ?? "" const headers = new Headers() - if (apiKey) headers.append("Authorization", `Bearer ${apiKey}`) + if (apiKey && apiKey !== OAUTH_DUMMY_KEY) headers.append("Authorization", `Bearer ${apiKey}`) const res = await fetchFn(`${baseURL}/models`, { headers, From 10ff727fd001c42641d0fa1770dfa369d6cef2de Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 13:21:39 +0200 Subject: [PATCH 04/13] opencode: initial integration of ProviderModelDetection and LocalProvider detection --- .../opencode/src/provider/model-detection.ts | 41 +++++++++- packages/opencode/src/provider/provider.ts | 74 ++++++++----------- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/provider/model-detection.ts b/packages/opencode/src/provider/model-detection.ts index c1001e18db69..a57376c07d48 100644 --- a/packages/opencode/src/provider/model-detection.ts +++ b/packages/opencode/src/provider/model-detection.ts @@ -3,17 +3,28 @@ import { iife } from "@/util/iife" import { Log } from "@/util/log" import { OAUTH_DUMMY_KEY } from "@/auth" import { Provider } from "./provider" +import { LocalProvider, type LocalModel } from "./local" export namespace ProviderModelDetection { - export async function detect(provider: Provider.Info): Promise { + export async function detect(provider: Provider.Info): Promise { const log = Log.create({ service: "provider.model-detection" }) const model = Object.values(provider.models)[0] const providerNPM = model?.api?.npm ?? "@ai-sdk/openai-compatible" const providerBaseURL = provider.options["baseURL"] ?? model?.api?.url ?? "" + // TODO: this calls out to the provider at least once + // figure out a way to not call it for cloud providers at all (??) + const localProvider = await LocalProvider.detect_provider(providerBaseURL) + const detectedModels = await iife(async () => { try { + if (localProvider !== null) { + log.info("using local provider method", { providerID: provider.id, localProvider }) + const localModels = await LocalProvider.probe_provider(localProvider, providerBaseURL) + return expandLocalModels(provider, localModels) + } + if (providerNPM === "@ai-sdk/openai-compatible" && providerBaseURL) { log.info("using OpenAI-compatible method", { providerID: provider.id }) return await ProviderModelDetection.OpenAICompatible.listModels(providerBaseURL, provider) @@ -62,3 +73,31 @@ export namespace ProviderModelDetection.OpenAICompatible { .map((model) => model.id) } } + +function expandLocalModels(provider: Provider.Info, localModels: LocalModel[]): Provider.Model[] { + return localModels.map((localModel) => { + return { + id: localModel.id, + providerID: provider.id, + name: localModel.id, + status: "active", + limit: { + context: localModel.context_length, + // TODO: can we drop this field? + // properly detect the value? + output: 16 * 1024 + }, + capabilities: { + temperature: true, + toolcall: localModel.tool_call, + input: { + text: true, + image: localModel.vision, + }, + output: { + text: true, + }, + }, + } as Provider.Model + }) +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 223c8a44d151..0cb957ca21b0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -8,7 +8,7 @@ import { BunProc } from "../bun" import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" -import { Auth, OAUTH_DUMMY_KEY } from "../auth" +import { Auth } from "../auth" import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -37,6 +37,8 @@ import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" import { createGitLab } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" +import { Installation } from "../installation" +import { ProviderModelDetection } from "./model-detection" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -674,45 +676,6 @@ export namespace Provider { } } - const ModelsList = z.object({ - object: z.string(), - data: z.array( - z - .object({ - id: z.string(), - object: z.string().optional(), - created: z.number().optional(), - owned_by: z.string().optional(), - }) - .catchall(z.any()), - ), - }) - type ModelsList = z.infer - - async function listModels(provider: Info) { - const baseURL = provider.options["baseURL"] - const fetchFn = (provider.options["fetch"] as typeof fetch) ?? fetch - const apiKey = provider.options["apiKey"] ?? provider.key ?? "" - const headers = new Headers() - if (apiKey && apiKey !== OAUTH_DUMMY_KEY) headers.append("Authorization", `Bearer ${apiKey}`) - const models = await fetchFn(`${baseURL}/models`, { - headers, - signal: AbortSignal.timeout(3 * 1000), - }) - .then(async (resp) => { - if (!resp.ok) return - return ModelsList.parse(await resp.json()) - }) - .catch((err) => { - log.error(`Failed to fetch models from: ${baseURL}/models`, { error: err }) - }) - if (!models) return - - return models.data - .filter((model) => model.id && !model.id.includes("embedding") && !model.id.includes("embed")) - .map((model) => model.id) - } - const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() @@ -947,14 +910,37 @@ export namespace Provider { // detect models and prune invalid ones await Promise.all( Object.values(providers).map(async (provider) => { - const detected = await listModels(provider) + const detected = await ProviderModelDetection.detect(provider) if (!detected) return - const detectedSet = new Set(detected) + + // Local models return the actual loaded models + // Replace the entire models list with the detected models + if (detected.length > 0 && typeof detected[0] !== "string") { + const newModelsList = detected as Provider.Model[] + provider.models = {} + for (const model of newModelsList) { + provider.models[model.id] = model + } + + return + } + + const detectedModelIds = detected as string[] + + // remove models that were not detected + const detectedSet = new Set(detectedModelIds) for (const modelID of Object.keys(provider.models)) { if (!detectedSet.has(modelID)) delete provider.models[modelID] } - // TODO: add detected models not present in config/models.dev - // for (const modelID of detected) {} + + // add detected models not present in config/models.dev + for (const modelID of detectedModelIds) { + provider.models[modelID] = { + id: modelID, + providerID: provider.id, + name: modelID, + } as Model + } }), ) From d54adfb212c8030af7e38cb1f18cf4236c803ea7 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 15:17:34 +0200 Subject: [PATCH 05/13] fix: normalize local provider base urls --- packages/opencode/src/provider/local/index.ts | 30 ++++++++++++------- .../opencode/src/provider/local/llamacpp.ts | 17 ++++------- .../opencode/src/provider/local/lmstudio.ts | 9 +++--- .../opencode/src/provider/local/ollama.ts | 12 ++++---- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/provider/local/index.ts b/packages/opencode/src/provider/local/index.ts index 01ecdb70287e..ca73dee5a0ca 100644 --- a/packages/opencode/src/provider/local/index.ts +++ b/packages/opencode/src/provider/local/index.ts @@ -19,36 +19,44 @@ export interface LocalModel { export namespace LocalProvider { const log = Log.create({ service: "localprovider" }) + function normalizeUrl(url: string): string { + const base = url.endsWith("/v1") ? url.slice(0, -3) : url + if (base.endsWith("/")) return base.slice(0, -1) + return base + } + export async function detect_provider(url: string): Promise { - log.debug(`Detecting local provider at URL: ${url}`) + const base = normalizeUrl(url) + log.debug(`Detecting local provider at URL: ${base}`) - if (await ollama_detect_provider(url)) { - log.info(`Detected Ollama provider at URL: ${url}`) + if (await ollama_detect_provider(base)) { + log.info(`Detected Ollama provider at URL: ${base}`) return LocalProvider.Ollama } - if (await lmstudio_detect_provider(url)) { - log.info(`Detected LMStudio provider at URL: ${url}`) + if (await lmstudio_detect_provider(base)) { + log.info(`Detected LMStudio provider at URL: ${base}`) return LocalProvider.LMStudio } - if (await llamacpp_detect_provider(url)) { - log.info(`Detected LlamaCPP provider at URL: ${url}`) + if (await llamacpp_detect_provider(base)) { + log.info(`Detected LlamaCPP provider at URL: ${base}`) return LocalProvider.LlamaCPP } - log.info(`No supported local provider detected at URL: ${url}`) + log.info(`No supported local provider detected at URL: ${base}`) return null } export async function probe_provider(provider: LocalProvider, url: string): Promise { + const base = normalizeUrl(url) switch (provider) { case LocalProvider.Ollama: - return await ollama_probe_loaded_models(url) + return await ollama_probe_loaded_models(base) case LocalProvider.LMStudio: - return await lmstudio_probe_loaded_models(url) + return await lmstudio_probe_loaded_models(base) case LocalProvider.LlamaCPP: - return await llamacpp_probe_loaded_models(url) + return await llamacpp_probe_loaded_models(base) default: throw new Error(`Unsupported provider: ${provider}`) } diff --git a/packages/opencode/src/provider/local/llamacpp.ts b/packages/opencode/src/provider/local/llamacpp.ts index fa4d75a31ea6..77ee87d2cd5e 100644 --- a/packages/opencode/src/provider/local/llamacpp.ts +++ b/packages/opencode/src/provider/local/llamacpp.ts @@ -37,10 +37,6 @@ export interface LlamaCppPropsResponse { chat_template_caps?: Record } -function llamacpp_base_url(url: string): string { - return url.replace(/\/$/, "") -} - function llamacpp_tool_capable(caps?: Record): boolean { if (!caps) return false const value = caps.supports_tool_calls @@ -57,14 +53,14 @@ function llamacpp_context_from_meta(meta?: Record | null): numb } async function llamacpp_fetch_models(url: string): Promise { - const endpoint = llamacpp_base_url(url) + "/models" + const endpoint = url + "/models" const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) return null return res.json() as Promise } async function llamacpp_fetch_v1_models(url: string): Promise { - const endpoint = llamacpp_base_url(url) + "/v1/models" + const endpoint = url + "/v1/models" const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) { const respText = await res.text() @@ -75,9 +71,8 @@ async function llamacpp_fetch_v1_models(url: string): Promise { - const base = llamacpp_base_url(url) const query = model ? `?model=${encodeURIComponent(model)}` : "" - const endpoint = `${base}/props${query}` + const endpoint = `${url}/props${query}` const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) { const respText = await res.text() @@ -117,15 +112,13 @@ async function llamacpp_model_from_props_or_meta( export async function llamacpp_detect_provider(url: string): Promise { try { - const endpoint = llamacpp_base_url(url) - const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }) if (!res.ok) { return false } return res.headers.get("Server")?.toLowerCase() === "llama.cpp" - } - catch (e) { + } catch (e) { return false } } diff --git a/packages/opencode/src/provider/local/lmstudio.ts b/packages/opencode/src/provider/local/lmstudio.ts index 6567c2f15615..6505ec914051 100644 --- a/packages/opencode/src/provider/local/lmstudio.ts +++ b/packages/opencode/src/provider/local/lmstudio.ts @@ -27,22 +27,21 @@ interface LMStudioGreeting { export async function lmstudio_detect_provider(url: string): Promise { try { - const endpoint = url.replace(/\/$/, "") + "/lmstudio-greeting" + const endpoint = url + "/lmstudio-greeting" const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) if (!res.ok) { return false } - const greeting = await res.json() as LMStudioGreeting + const greeting = (await res.json()) as LMStudioGreeting return greeting.lmstudio === true - } - catch (e) { + } catch (e) { return false } } export async function lmstudio_probe_loaded_models(url: string): Promise { - const endpoint = url.replace(/\/$/, "") + "/api/v0/models" + const endpoint = url + "/api/v0/models" const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) { diff --git a/packages/opencode/src/provider/local/ollama.ts b/packages/opencode/src/provider/local/ollama.ts index eaae8c0d53f8..e2c0dcbb21fc 100644 --- a/packages/opencode/src/provider/local/ollama.ts +++ b/packages/opencode/src/provider/local/ollama.ts @@ -35,7 +35,7 @@ export interface OllamaModelShowResponse { } async function ollama_show_model(url: string, model: string): Promise { - const endpoint = url.replace(/\/$/, "") + "/api/show" + const endpoint = url + "/api/show" const res = await fetch(endpoint, { method: "POST", @@ -53,7 +53,7 @@ async function ollama_show_model(url: string, model: string): Promise { - const endpoint = url.replace(/\/$/, "") + "/" + const endpoint = url + "/" try { const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) @@ -61,14 +61,14 @@ export async function ollama_detect_provider(url: string): Promise { return false } - return await res.text() === "Ollama is running" + return (await res.text()) === "Ollama is running" } catch (e) { return false } } export async function ollama_probe_loaded_models(url: string): Promise { - const endpoint = url.replace(/\/$/, "") + "/api/ps" + const endpoint = url + "/api/ps" const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) if (!res.ok) { @@ -83,7 +83,7 @@ export async function ollama_probe_loaded_models(url: string): Promise { - const show = await ollama_show_model(url, m.model).catch(() => ({} as OllamaModelShowResponse)) + const show = await ollama_show_model(url, m.model).catch(() => ({}) as OllamaModelShowResponse) const caps = show.capabilities ?? [] return { @@ -92,7 +92,7 @@ export async function ollama_probe_loaded_models(url: string): Promise Date: Tue, 3 Feb 2026 16:19:11 +0200 Subject: [PATCH 06/13] fix: set api id for detected models --- packages/opencode/src/provider/model-detection.ts | 3 +++ packages/opencode/src/provider/provider.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/opencode/src/provider/model-detection.ts b/packages/opencode/src/provider/model-detection.ts index a57376c07d48..37fd4ffd22de 100644 --- a/packages/opencode/src/provider/model-detection.ts +++ b/packages/opencode/src/provider/model-detection.ts @@ -81,6 +81,9 @@ function expandLocalModels(provider: Provider.Info, localModels: LocalModel[]): providerID: provider.id, name: localModel.id, status: "active", + api: { + id: localModel.id, + }, limit: { context: localModel.context_length, // TODO: can we drop this field? diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0cb957ca21b0..a064314ed6bc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -939,6 +939,10 @@ export namespace Provider { id: modelID, providerID: provider.id, name: modelID, + api: { + id: modelID, + }, + capabilities: {} } as Model } }), From 4e4d154bfb71b7813c4e16b9f37bdec58c257f88 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 23:39:06 +0200 Subject: [PATCH 07/13] refactor: updated debug provider commands --- .../opencode/src/cli/cmd/debug/provider.ts | 92 +++++++++++++------ 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/provider.ts b/packages/opencode/src/cli/cmd/debug/provider.ts index 7438bc1441e9..2ac181d197e8 100644 --- a/packages/opencode/src/cli/cmd/debug/provider.ts +++ b/packages/opencode/src/cli/cmd/debug/provider.ts @@ -1,40 +1,78 @@ -import { EOL } from "os" -import { LocalProvider, type LocalModel } from "../../../provider/local" +import { ProviderModelDetection } from "@/provider/model-detection" +import { LocalProvider } from "@/provider/local" import { cmd } from "../cmd" +import { Provider } from "@/provider/provider" +import { Instance } from "@/project/instance" export const ProviderCommand = cmd({ command: "provider", - describe: "probe local LLM providers", + describe: "Provider debugging utilities", + builder: (yargs) => yargs.command(ProviderProbeCommand).command(ProviderDetectCommand).demandCommand(), + async handler() { }, +}) + +export const ProviderDetectCommand = cmd({ + command: "probe provider ", + describe: "probe models by provider ID", builder: (yargs) => - yargs.command({ - command: "probe ", - describe: "probe a local provider for loaded models", - builder: (yargs) => - yargs - .positional("url", { - describe: "provider URL", - type: "string", - }), - async handler(args) { - const url = args.url as string - - const type = await LocalProvider.detect_provider(url); - if (!type) { - console.error(`No supported local provider detected at URL: ${url}`) + yargs + .positional("providerId", { + describe: "provider ID", + type: "string", + }), + async handler(args) { + const providerId = args.providerId as string + + await Instance.provide({ + directory: process.cwd(), + async fn() { + const provider = await Provider.getProvider(providerId) + if (!provider) { + console.error(`Provider with ID '${providerId}' not found.`) process.exit(1) } - console.log(`Detected provider type: ${type} at URL: ${url}`) - const result = await LocalProvider.probe_provider(type, url) - - if (result.length === 0) { - console.log("No loaded models found") + console.log(`Detecting models for provider ID: ${providerId}`) + const detectionResult = await ProviderModelDetection.detect(provider) + if (!detectionResult) { + console.log("No models detected.") return } - console.log(`Found ${result.length} loaded models:`) - console.log(JSON.stringify(result, null, 2)) + console.log(`Detected ${detectionResult.length} models:`) + console.log(JSON.stringify(detectionResult, null, 2)) }, - }), - async handler() {}, + }) + }, +}) + +export const ProviderProbeCommand = cmd({ + command: "probe url ", + describe: "probe local provider by URL", + builder: (yargs) => + yargs + .positional("url", { + describe: "provider URL", + type: "string", + }), + async handler(args) { + const url = args.url as string + + const type = await LocalProvider.detect_provider(url); + if (!type) { + console.error(`No supported local provider detected at URL: ${url}`) + process.exit(1) + } + + console.log(`Detected provider type: ${type} at URL: ${url}`) + const result = await LocalProvider.probe_provider(type, url) + + if (result.length === 0) { + console.log("No loaded models found") + return + } + + console.log(`Found ${result.length} loaded models:`) + console.log(JSON.stringify(result, null, 2)) + }, }) From fd5aedef81931d1cd58df2a786d174a80b4f4b26 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 23:40:07 +0200 Subject: [PATCH 08/13] fix: comment out adding new models --- packages/opencode/src/provider/local/index.ts | 2 +- packages/opencode/src/provider/provider.ts | 25 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/provider/local/index.ts b/packages/opencode/src/provider/local/index.ts index ca73dee5a0ca..8cb344c54da7 100644 --- a/packages/opencode/src/provider/local/index.ts +++ b/packages/opencode/src/provider/local/index.ts @@ -17,7 +17,7 @@ export interface LocalModel { } export namespace LocalProvider { - const log = Log.create({ service: "localprovider" }) + const log = Log.create({ service: "provider.local" }) function normalizeUrl(url: string): string { const base = url.endsWith("/v1") ? url.slice(0, -3) : url diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a064314ed6bc..ba2e7eadadba 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -37,7 +37,6 @@ import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" import { createGitLab } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" -import { Installation } from "../installation" import { ProviderModelDetection } from "./model-detection" export namespace Provider { @@ -933,18 +932,18 @@ export namespace Provider { if (!detectedSet.has(modelID)) delete provider.models[modelID] } - // add detected models not present in config/models.dev - for (const modelID of detectedModelIds) { - provider.models[modelID] = { - id: modelID, - providerID: provider.id, - name: modelID, - api: { - id: modelID, - }, - capabilities: {} - } as Model - } + // TODO: add detected models not present in config/models.dev + // for (const modelID of detectedModelIds) { + // provider.models[modelID] = { + // id: modelID, + // providerID: provider.id, + // name: modelID, + // api: { + // id: modelID, + // }, + // capabilities: {} + // } as Model + // } }), ) From 75ded5e269377bb348767c7f59987b7686dc8fde Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 23:40:47 +0200 Subject: [PATCH 09/13] fix: prevent local provider detection from running on cloud providers --- .../opencode/src/provider/model-detection.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/model-detection.ts b/packages/opencode/src/provider/model-detection.ts index 37fd4ffd22de..4088ccf834dd 100644 --- a/packages/opencode/src/provider/model-detection.ts +++ b/packages/opencode/src/provider/model-detection.ts @@ -4,18 +4,24 @@ import { Log } from "@/util/log" import { OAUTH_DUMMY_KEY } from "@/auth" import { Provider } from "./provider" import { LocalProvider, type LocalModel } from "./local" +import { ModelsDev } from "./models" export namespace ProviderModelDetection { + const log = Log.create({ service: "provider.model-detection" }) + export async function detect(provider: Provider.Info): Promise { - const log = Log.create({ service: "provider.model-detection" }) + const modelsDev = await ModelsDev.get(); + delete modelsDev["lmstudio"]; // LMStudio is not a cloud provider - const model = Object.values(provider.models)[0] + // provider.models.length can be 0 for local providers that rely on detection only + const model = Object.values(provider.models).at(0) const providerNPM = model?.api?.npm ?? "@ai-sdk/openai-compatible" const providerBaseURL = provider.options["baseURL"] ?? model?.api?.url ?? "" - // TODO: this calls out to the provider at least once - // figure out a way to not call it for cloud providers at all (??) - const localProvider = await LocalProvider.detect_provider(providerBaseURL) + log.debug("starting model detection", { providerID: provider.id, providerNPM, providerBaseURL }) + + // Skip local detection for known cloud providers + const localProvider = provider.id in modelsDev ? null : await LocalProvider.detect_provider(providerBaseURL) const detectedModels = await iife(async () => { try { @@ -83,6 +89,8 @@ function expandLocalModels(provider: Provider.Info, localModels: LocalModel[]): status: "active", api: { id: localModel.id, + npm: provider.options["npm"] ?? "@ai-sdk/openai-compatible", + url: provider.options["baseURL"] ?? "", }, limit: { context: localModel.context_length, From 2c215da222a0b86dfe5ce9161a81c90716cd67a8 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 23:44:51 +0200 Subject: [PATCH 10/13] fix: correct command order and naming for provider commands --- packages/opencode/src/cli/cmd/debug/provider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/provider.ts b/packages/opencode/src/cli/cmd/debug/provider.ts index 2ac181d197e8..b70161ab940a 100644 --- a/packages/opencode/src/cli/cmd/debug/provider.ts +++ b/packages/opencode/src/cli/cmd/debug/provider.ts @@ -7,12 +7,12 @@ import { Instance } from "@/project/instance" export const ProviderCommand = cmd({ command: "provider", describe: "Provider debugging utilities", - builder: (yargs) => yargs.command(ProviderProbeCommand).command(ProviderDetectCommand).demandCommand(), + builder: (yargs) => yargs.command(ProviderDetectCommand).command(ProviderProbeCommand).demandCommand(), async handler() { }, }) export const ProviderDetectCommand = cmd({ - command: "probe provider ", + command: "detect ", describe: "probe models by provider ID", builder: (yargs) => yargs @@ -47,7 +47,7 @@ export const ProviderDetectCommand = cmd({ }) export const ProviderProbeCommand = cmd({ - command: "probe url ", + command: "probe ", describe: "probe local provider by URL", builder: (yargs) => yargs From ff2f8661223b3e1388f2ac09dcfecaecb294b2a8 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Tue, 3 Feb 2026 23:55:09 +0200 Subject: [PATCH 11/13] fix: remove 'object' field from OpenAICompatibleResponse causing errors --- packages/opencode/src/provider/model-detection.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/provider/model-detection.ts b/packages/opencode/src/provider/model-detection.ts index 4088ccf834dd..7b3f274d588b 100644 --- a/packages/opencode/src/provider/model-detection.ts +++ b/packages/opencode/src/provider/model-detection.ts @@ -49,11 +49,9 @@ export namespace ProviderModelDetection { export namespace ProviderModelDetection.OpenAICompatible { const OpenAICompatibleResponse = z.object({ - object: z.string(), data: z.array( z.object({ id: z.string(), - object: z.string().optional(), created: z.number().optional(), owned_by: z.string().optional(), }), From 1cd128d6585d7165805491e697bd7bb1c974bf78 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Thu, 12 Feb 2026 10:32:44 +0200 Subject: [PATCH 12/13] add vLLM local provider support --- packages/opencode/src/provider/local/index.ts | 9 +++ packages/opencode/src/provider/local/vllm.ts | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/opencode/src/provider/local/vllm.ts diff --git a/packages/opencode/src/provider/local/index.ts b/packages/opencode/src/provider/local/index.ts index 8cb344c54da7..10810aa4ac58 100644 --- a/packages/opencode/src/provider/local/index.ts +++ b/packages/opencode/src/provider/local/index.ts @@ -2,11 +2,13 @@ import { Log } from "../../util/log" import { ollama_probe_loaded_models, ollama_detect_provider } from "./ollama" import { lmstudio_probe_loaded_models, lmstudio_detect_provider } from "./lmstudio" import { llamacpp_probe_loaded_models, llamacpp_detect_provider } from "./llamacpp" +import { vllm_probe_loaded_models, vllm_detect_provider } from "./vllm" export enum LocalProvider { Ollama = "ollama", LMStudio = "lmstudio", LlamaCPP = "llamacpp", + Vllm = "vllm", } export interface LocalModel { @@ -44,6 +46,11 @@ export namespace LocalProvider { return LocalProvider.LlamaCPP } + if (await vllm_detect_provider(base)) { + log.info(`Detected vLLM provider at URL: ${base}`) + return LocalProvider.Vllm + } + log.info(`No supported local provider detected at URL: ${base}`) return null } @@ -57,6 +64,8 @@ export namespace LocalProvider { return await lmstudio_probe_loaded_models(base) case LocalProvider.LlamaCPP: return await llamacpp_probe_loaded_models(base) + case LocalProvider.Vllm: + return await vllm_probe_loaded_models(base) default: throw new Error(`Unsupported provider: ${provider}`) } diff --git a/packages/opencode/src/provider/local/vllm.ts b/packages/opencode/src/provider/local/vllm.ts new file mode 100644 index 000000000000..961b487bc712 --- /dev/null +++ b/packages/opencode/src/provider/local/vllm.ts @@ -0,0 +1,65 @@ +import { type LocalModel } from "./index" + +export interface VllmModel { + id: string + object: "model" + created: number + owned_by: string + root: string + parent: null + max_model_len: number +} + +export interface VllmModelsResponse { + object: "list" + data: VllmModel[] +} + +export async function vllm_detect_provider(url: string): Promise { + try { + const endpoint = url + "/v1/models" + const res = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }) + if (!res.ok) { + return false + } + + if (res.headers.get("Server")?.toLowerCase() !== "uvicorn") { + return false + } + + const body = (await res.json()) as VllmModelsResponse + const model = body.data?.[0] + if (!model) { + return false + } + + return model.owned_by === "vllm" + } catch (e) { + return false + } +} + +export async function vllm_probe_loaded_models(url: string): Promise { + const endpoint = url + "/v1/models" + + const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { + const respText = await res.text() + throw new Error(`vLLM probe failed with status ${res.status}: ${respText}`) + } + + const body = (await res.json()) as VllmModelsResponse + if (!body.data) { + throw new Error("vLLM probe failed: no data field in response") + } + + return body.data.map((m) => ({ + id: m.id, + context_length: m.max_model_len, + // vLLM model listing does not expose per-model capabilities. + // Vision is inferred with a very naive heuristic from model id. + // Tool calls are hardcoded true and may still fail at inference time. + tool_call: true, + vision: m.id.toLowerCase().includes("vl"), + })) +} From 04a3e4efacacb3dd5869a79a16ffb86a4ac83e33 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Fri, 13 Feb 2026 11:58:34 +0200 Subject: [PATCH 13/13] add local provider setup in auth login Enable onboarding for Ollama, LMStudio, llama.cpp, and vLLM with URL detection and optional API keys. Support config-only local providers via a new local option so model detection can populate models. --- packages/opencode/src/cli/cmd/auth.ts | 105 +++++++++++++++++- .../opencode/src/cli/cmd/debug/provider.ts | 24 ++-- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/provider/local/index.ts | 11 ++ .../opencode/src/provider/model-detection.ts | 8 +- packages/opencode/src/provider/provider.ts | 25 ++++- packages/sdk/js/src/v2/gen/types.gen.ts | 4 + packages/sdk/openapi.json | 4 + 8 files changed, 158 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c711..18feeb1996e8 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -11,6 +11,7 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" +import { LocalProvider } from "../../provider/local" type PluginAuth = NonNullable @@ -277,9 +278,10 @@ export const AuthLoginCommand = cmd({ openrouter: 5, vercel: 6, } + let provider = await prompts.autocomplete({ message: "Select provider", - maxItems: 8, + maxItems: 10, options: [ ...pipe( providers, @@ -298,6 +300,11 @@ export const AuthLoginCommand = cmd({ }[x.id], })), ), + { + value: "local", + label: "Local", + hint: "Ollama, LMStudio, llama.cpp, vLLM", + }, { value: "other", label: "Other", @@ -313,6 +320,102 @@ export const AuthLoginCommand = cmd({ if (handled) return } + if (provider === "local") { + const localProviderType = await prompts.select({ + message: "Select local provider", + options: [ + { label: "Ollama", value: LocalProvider.Ollama }, + { label: "LMStudio", value: LocalProvider.LMStudio }, + { label: "Llama.cpp", value: LocalProvider.LlamaCPP }, + { label: "vLLM", value: LocalProvider.Vllm }, + ], + }) + if (prompts.isCancel(localProviderType)) throw new UI.CancelledError() + + const defaultURL = LocalProvider.default_url(localProviderType) + const baseURL = await prompts.text({ + message: "Enter provider base URL", + initialValue: defaultURL, + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(baseURL)) throw new UI.CancelledError() + + const spinner = prompts.spinner() + spinner.start("Detecting provider...") + + const detected = await LocalProvider.detect_provider(baseURL) + if (!detected) { + spinner.stop(`No ${localProviderType} provider detected at this URL`, 1) + prompts.outro("Done") + return + } + + if (detected !== localProviderType) { + spinner.stop(`Expected ${localProviderType} but detected ${detected}`, 1) + prompts.outro("Done") + return + } + + spinner.stop(`Detected ${localProviderType}`) + + const providerName = await prompts.text({ + message: "Provider name", + initialValue: localProviderType, + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(providerName)) throw new UI.CancelledError() + + const config = await Config.getGlobal() + const providerID = await (async function resolveProviderID(value: string): Promise { + if (!config.provider?.[value]) return value + + const action = await prompts.select({ + message: `Provider "${value}" already exists`, + options: [ + { label: "Overwrite existing", value: "overwrite" }, + { label: "Choose a different name", value: "rename" }, + ], + }) + if (prompts.isCancel(action)) throw new UI.CancelledError() + if (action === "overwrite") return value + + const renamed = await prompts.text({ + message: "New provider name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(renamed)) throw new UI.CancelledError() + + return resolveProviderID(renamed) + })(providerName) + + const key = await prompts.password({ + message: "Enter API key (optional, press enter to skip)", + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + + await Config.updateGlobal({ + provider: { + [providerID]: { + options: { + baseURL, + local: true, + }, + }, + }, + }) + + if (key?.length) { + await Auth.set(providerID, { + type: "api", + key, + }) + } + + prompts.log.success(`Added ${localProviderType} at ${baseURL}`) + prompts.outro("Done") + return + } + if (provider === "other") { provider = await prompts.text({ message: "Enter provider id", diff --git a/packages/opencode/src/cli/cmd/debug/provider.ts b/packages/opencode/src/cli/cmd/debug/provider.ts index b70161ab940a..e33625be2528 100644 --- a/packages/opencode/src/cli/cmd/debug/provider.ts +++ b/packages/opencode/src/cli/cmd/debug/provider.ts @@ -8,21 +8,20 @@ export const ProviderCommand = cmd({ command: "provider", describe: "Provider debugging utilities", builder: (yargs) => yargs.command(ProviderDetectCommand).command(ProviderProbeCommand).demandCommand(), - async handler() { }, + async handler() {}, }) export const ProviderDetectCommand = cmd({ command: "detect ", describe: "probe models by provider ID", builder: (yargs) => - yargs - .positional("providerId", { - describe: "provider ID", - type: "string", - }), + yargs.positional("providerId", { + describe: "provider ID", + type: "string", + }), async handler(args) { const providerId = args.providerId as string - + await Instance.provide({ directory: process.cwd(), async fn() { @@ -50,15 +49,14 @@ export const ProviderProbeCommand = cmd({ command: "probe ", describe: "probe local provider by URL", builder: (yargs) => - yargs - .positional("url", { - describe: "provider URL", - type: "string", - }), + yargs.positional("url", { + describe: "provider URL", + type: "string", + }), async handler(args) { const url = args.url as string - const type = await LocalProvider.detect_provider(url); + const type = await LocalProvider.detect_provider(url) if (!type) { console.error(`No supported local provider detected at URL: ${url}`) process.exit(1) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2f1cba8a0548..c6e657322ffc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -886,6 +886,7 @@ export namespace Config { .object({ apiKey: z.string().optional(), baseURL: z.string().optional(), + local: z.boolean().optional().describe("Mark provider as a local provider (Ollama, LMStudio, etc.)"), enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), timeout: z diff --git a/packages/opencode/src/provider/local/index.ts b/packages/opencode/src/provider/local/index.ts index 10810aa4ac58..fe7b64e36e08 100644 --- a/packages/opencode/src/provider/local/index.ts +++ b/packages/opencode/src/provider/local/index.ts @@ -11,6 +11,13 @@ export enum LocalProvider { Vllm = "vllm", } +const LOCAL_PROVIDER_DEFAULTS: Record = { + [LocalProvider.Ollama]: "http://localhost:11434/v1", + [LocalProvider.LMStudio]: "http://localhost:1234/v1", + [LocalProvider.LlamaCPP]: "http://localhost:8080/v1", + [LocalProvider.Vllm]: "http://localhost:8000/v1", +} + export interface LocalModel { id: string context_length: number @@ -21,6 +28,10 @@ export interface LocalModel { export namespace LocalProvider { const log = Log.create({ service: "provider.local" }) + export function default_url(provider: LocalProvider): string { + return LOCAL_PROVIDER_DEFAULTS[provider] + } + function normalizeUrl(url: string): string { const base = url.endsWith("/v1") ? url.slice(0, -3) : url if (base.endsWith("/")) return base.slice(0, -1) diff --git a/packages/opencode/src/provider/model-detection.ts b/packages/opencode/src/provider/model-detection.ts index 7b3f274d588b..9cba7c455319 100644 --- a/packages/opencode/src/provider/model-detection.ts +++ b/packages/opencode/src/provider/model-detection.ts @@ -10,8 +10,8 @@ export namespace ProviderModelDetection { const log = Log.create({ service: "provider.model-detection" }) export async function detect(provider: Provider.Info): Promise { - const modelsDev = await ModelsDev.get(); - delete modelsDev["lmstudio"]; // LMStudio is not a cloud provider + const modelsDev = await ModelsDev.get() + delete modelsDev["lmstudio"] // LMStudio is not a cloud provider // provider.models.length can be 0 for local providers that rely on detection only const model = Object.values(provider.models).at(0) @@ -19,7 +19,7 @@ export namespace ProviderModelDetection { const providerBaseURL = provider.options["baseURL"] ?? model?.api?.url ?? "" log.debug("starting model detection", { providerID: provider.id, providerNPM, providerBaseURL }) - + // Skip local detection for known cloud providers const localProvider = provider.id in modelsDev ? null : await LocalProvider.detect_provider(providerBaseURL) @@ -94,7 +94,7 @@ function expandLocalModels(provider: Provider.Info, localModels: LocalModel[]): context: localModel.context_length, // TODO: can we drop this field? // properly detect the value? - output: 16 * 1024 + output: 16 * 1024, }, capabilities: { temperature: true, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ba2e7eadadba..e3b645c69d8f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -723,9 +723,22 @@ export namespace Provider { return } const match = database[providerID] - if (!match) return - // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) + if (match) { + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) + return + } + const isLocal = provider.options?.local === true + if (isLocal) { + providers[providerID] = { + id: providerID, + name: provider.name ?? providerID, + source: provider.source ?? "config", + env: provider.env ?? [], + options: provider.options ?? {}, + models: {}, + } + } } // extend database from config @@ -911,7 +924,7 @@ export namespace Provider { Object.values(providers).map(async (provider) => { const detected = await ProviderModelDetection.detect(provider) if (!detected) return - + // Local models return the actual loaded models // Replace the entire models list with the detected models if (detected.length > 0 && typeof detected[0] !== "string") { @@ -922,8 +935,8 @@ export namespace Provider { } return - } - + } + const detectedModelIds = detected as string[] // remove models that were not detected diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8555e84384ff..b7425a941538 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1498,6 +1498,10 @@ export type ProviderConfig = { options?: { apiKey?: string baseURL?: string + /** + * Mark provider as a local provider (Ollama, LMStudio, etc.) + */ + local?: boolean /** * GitHub Enterprise URL for copilot authentication */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index dc2e51e5fbaf..37a04e18d227 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9294,6 +9294,10 @@ "baseURL": { "type": "string" }, + "local": { + "description": "Mark provider as a local provider (Ollama, LMStudio, etc.)", + "type": "boolean" + }, "enterpriseUrl": { "description": "GitHub Enterprise URL for copilot authentication", "type": "string"