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
105 changes: 104 additions & 1 deletion packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hooks["auth"]>

Expand Down Expand Up @@ -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,
Expand All @@ -298,6 +300,11 @@ export const AuthLoginCommand = cmd({
}[x.id],
})),
),
{
value: "local",
label: "Local",
hint: "Ollama, LMStudio, llama.cpp, vLLM",
},
{
value: "other",
label: "Other",
Expand All @@ -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<string> {
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",
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -17,6 +18,7 @@ export const DebugCommand = cmd({
yargs
.command(ConfigCommand)
.command(LSPCommand)
.command(ProviderCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(ScrapCommand)
Expand Down
76 changes: 76 additions & 0 deletions packages/opencode/src/cli/cmd/debug/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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: "Provider debugging utilities",
builder: (yargs) => yargs.command(ProviderDetectCommand).command(ProviderProbeCommand).demandCommand(),
async handler() {},
})

export const ProviderDetectCommand = cmd({
command: "detect <providerId>",
describe: "probe models by provider ID",
builder: (yargs) =>
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(`Detecting models for provider ID: ${providerId}`)
const detectionResult = await ProviderModelDetection.detect(provider)
if (!detectionResult) {
console.log("No models detected.")
return
}

console.log(`Detected ${detectionResult.length} models:`)
console.log(JSON.stringify(detectionResult, null, 2))
},
})
},
})

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))
},
})
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions packages/opencode/src/provider/local/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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",
}

const LOCAL_PROVIDER_DEFAULTS: Record<LocalProvider, string> = {
[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
tool_call: boolean
vision: boolean
}

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)
return base
}

export async function detect_provider(url: string): Promise<LocalProvider | null> {
const base = normalizeUrl(url)
log.debug(`Detecting local provider at URL: ${base}`)

if (await ollama_detect_provider(base)) {
log.info(`Detected Ollama provider at URL: ${base}`)
return LocalProvider.Ollama
}

if (await lmstudio_detect_provider(base)) {
log.info(`Detected LMStudio provider at URL: ${base}`)
return LocalProvider.LMStudio
}

if (await llamacpp_detect_provider(base)) {
log.info(`Detected LlamaCPP provider at URL: ${base}`)
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
}

export async function probe_provider(provider: LocalProvider, url: string): Promise<LocalModel[]> {
const base = normalizeUrl(url)
switch (provider) {
case LocalProvider.Ollama:
return await ollama_probe_loaded_models(base)
case LocalProvider.LMStudio:
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}`)
}
}

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)]
}
}
Loading