Skip to content
Merged
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
18 changes: 6 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Instead of creating one provider per server, this plugin keeps one `local` provi
- Includes supported default `127.0.0.1` targets automatically
- Detects loaded models at runtime
- Routes each model to the correct target URL
- Supports optional shared API key auth
- API key auth currently unsupported
- Uses OpenCode global config, not project-local config

## Example
Expand All @@ -45,27 +45,21 @@ Default targets are enabled automatically for these backends and ports:

If your local providers do not need auth, you can start using the `local` provider immediately.

If your local providers share an API key, run:

```bash
opencode auth login
```

Choose `local`, then choose `Set Shared API Key` and enter the shared API key.
**Note: API key authentication is currently unsupported.**

## Custom Targets

If you need non-default hosts or ports, use the CLI auth flow to add an explicit target:

```bash
opencode auth login --provider local --method "Add Custom Target (CLI only)"
opencode auth login --provider local --method "Add Custom Target"
```

This will prompt for:

- a target ID, like `studio` or `remote-ollama`
- the local provider URL
- the shared API key for that provider again, or `none` if unused
- the API key (enter `none` since API keys are currently unsupported)

The target is then stored in OpenCode global config.

Expand Down Expand Up @@ -118,7 +112,7 @@ The plugin stores explicit targets in OpenCode global config under the `local` p

With `includeDefaults: true`, the built-in default `127.0.0.1` targets are also checked at runtime even though they are not written into config.

If you set a shared API key, it is stored through OpenCode auth for the `local` provider.
**Note: API key authentication is currently unsupported.**

## How Models Appear

Expand All @@ -136,7 +130,7 @@ Each generated model keeps its own target URL internally, so requests go to the
- Model detection is runtime-based, not static
- If loaded models change in your local server, OpenCode will see the updated list on the next provider refresh
- Built-in default `127.0.0.1` targets are enabled unless you set `includeDefaults` to `false`
- Targets use one shared API key for the `local` provider
- **API key authentication is currently unsupported**

## Development

Expand Down
56 changes: 23 additions & 33 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from "./constants"
import {
getConfiguredTargets,
getProviderApiKey,
getProviderTargets,
saveProviderTarget,
} from "./config"
Expand All @@ -27,12 +26,10 @@ async function probeModels(provider: Provider, ctx: ProviderHookContext) {
const list = getProviderTargets(provider)
if (!Object.keys(list).length) return {}

const auth = getProviderApiKey(provider, ctx.auth)

const all = await Promise.all(
Object.entries(list).map(async ([id, item]) => {
try {
const found = await probe(item.url, auth, item.kind)
const found = await probe(item.url, item.kind)
return build(provider.id, id, item.url, found.models, provider.models)
} catch {
return {}
Expand Down Expand Up @@ -75,11 +72,7 @@ export const LocalProviderPlugin: Plugin = async (ctx) => {
methods: [
{
type: "api",
label: "Set Shared API Key",
},
{
type: "api",
label: "Add Custom Target (CLI only)",
label: "Add Custom Target",
prompts: [
{
type: "text",
Expand All @@ -100,37 +93,34 @@ export const LocalProviderPlugin: Plugin = async (ctx) => {
if (!trimURL(value ?? "")) return "URL is required"
},
},
{
type: "text",
key: "apiKey",
message: "Re-enter the shared API key for this provider (enter none if unused)",
placeholder: "none",
validate(value) {
if (!value?.trim()) return "API key is required; enter none if unused"
},
},
],
async authorize(input = {}) {
const id = input.target?.trim() ?? ""
const raw = trimURL(input.baseURL ?? "")
const next = input.apiKey?.trim() ?? ""
const key = next === "none" ? "" : next
if (!id || !validID(id) || !raw || !next) return { type: "failed" as const }

const kind = await detect(raw, key).catch(() => undefined)
if (!kind) return { type: "failed" as const }

try {
await probe(raw, key, kind)
if (!id || !validID(id) || !raw) {
throw new Error("Invalid target ID or URL")
}

const result = await probe(raw)
Comment thread
goniz marked this conversation as resolved.
const kind = result.kind
await saveProviderTarget(ctx.serverUrl, ctx.client, id, raw, kind)
} catch {
return { type: "failed" as const }
}

return {
type: "success" as const,
provider: LOCAL_PROVIDER_ID,
key,
return {
type: "success" as const,
provider: LOCAL_PROVIDER_ID,
key: "",
}
Comment thread
goniz marked this conversation as resolved.
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e)
await ctx.client.app.log({
body: {
service: LOCAL_PLUGIN_SERVICE,
level: "error",
message: `Authorization failed: ${errorMessage}`,
},
})
return { type: "failed" as const }
}
},
},
Expand Down
12 changes: 6 additions & 6 deletions src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,31 @@ import type { LocalProviderKind } from "./types"
import { supportedProviders, supportedProviderKinds } from "./providers"
import { rootURL } from "./url"

export async function detect(url: string, key?: string): Promise<LocalProviderKind | null> {
export async function detect(url: string): Promise<LocalProviderKind | null> {
const root = rootURL(url)

for (const kind of supportedProviderKinds) {
if (await supportedProviders[kind].detect(root, key)) return kind
if (await supportedProviders[kind].detect(root)) return kind
}

return null
}

export async function probe(url: string, key?: string, kind?: LocalProviderKind) {
export async function probe(url: string, kind?: LocalProviderKind) {
const root = rootURL(url)

if (kind) {
return {
kind,
models: await supportedProviders[kind].probe(root, key),
models: await supportedProviders[kind].probe(root),
}
}

const detected = await detect(url, key)
const detected = await detect(url)
if (!detected) throw new Error(`No supported local provider detected at: ${url}`)

return {
kind: detected,
models: await supportedProviders[detected].probe(root, key),
models: await supportedProviders[detected].probe(root),
}
}
7 changes: 2 additions & 5 deletions src/providers/exo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { LocalModel } from "../types"
import { authHeaders } from "../url"
import type { ProviderImpl } from "./shared"

type ExoState = {
Expand Down Expand Up @@ -29,10 +28,9 @@ type ExoState = {
runners?: Record<string, { RunnerReady?: object }>
}

async function detect(url: string, key?: string) {
async function detect(url: string) {
try {
const res = await fetch(url + "/v1/models", {
headers: authHeaders(key),
signal: AbortSignal.timeout(2000),
})
if (!res.ok) return false
Expand All @@ -44,9 +42,8 @@ async function detect(url: string, key?: string) {
}
}

async function probe(url: string, key?: string): Promise<LocalModel[]> {
async function probe(url: string): Promise<LocalModel[]> {
const res = await fetch(url + "/state", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`Exo probe failed: ${res.status}`)
Expand Down
13 changes: 4 additions & 9 deletions src/providers/llamacpp.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { LocalModel } from "../types"
import { authHeaders } from "../url"
import type { ProviderImpl } from "./shared"

async function runtimeContext(url: string, key?: string) {
async function runtimeContext(url: string) {
try {
const propsRes = await fetch(url + "/props", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (propsRes.ok) {
Expand All @@ -20,7 +18,6 @@ async function runtimeContext(url: string, key?: string) {

try {
const slotsRes = await fetch(url + "/slots", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (slotsRes.ok) {
Expand All @@ -33,10 +30,9 @@ async function runtimeContext(url: string, key?: string) {
return null
}

async function detect(url: string, key?: string) {
async function detect(url: string) {
try {
const res = await fetch(url, {
headers: authHeaders(key),
signal: AbortSignal.timeout(2000),
})
if (!res.ok) return false
Expand All @@ -46,10 +42,9 @@ async function detect(url: string, key?: string) {
}
}

async function probe(url: string, key?: string): Promise<LocalModel[]> {
const loadedContext = await runtimeContext(url, key)
async function probe(url: string): Promise<LocalModel[]> {
const loadedContext = await runtimeContext(url)
const res = await fetch(url + "/v1/models", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`llama.cpp probe failed: ${res.status}`)
Expand Down
7 changes: 2 additions & 5 deletions src/providers/lmstudio.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { LocalModel } from "../types"
import { authHeaders } from "../url"
import type { ProviderImpl } from "./shared"

async function detect(url: string, key?: string) {
async function detect(url: string) {
try {
const res = await fetch(url + "/lmstudio-greeting", {
headers: authHeaders(key),
signal: AbortSignal.timeout(2000),
})
if (!res.ok) return false
Expand All @@ -16,9 +14,8 @@ async function detect(url: string, key?: string) {
}
}

async function probe(url: string, key?: string): Promise<LocalModel[]> {
async function probe(url: string): Promise<LocalModel[]> {
const res = await fetch(url + "/api/v0/models", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`LM Studio probe failed: ${res.status}`)
Expand Down
12 changes: 4 additions & 8 deletions src/providers/ollama.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { LocalModel } from "../types"
import { authHeaders } from "../url"
import type { ProviderImpl } from "./shared"

async function detect(url: string, key?: string) {
async function detect(url: string) {
try {
const res = await fetch(url, {
headers: authHeaders(key),
signal: AbortSignal.timeout(2000),
})
if (!res.ok) return false
Expand All @@ -15,13 +13,12 @@ async function detect(url: string, key?: string) {
}
}

async function show(url: string, model: string, key?: string) {
async function show(url: string, model: string) {
try {
const res = await fetch(url + "/api/show", {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders(key),
},
body: JSON.stringify({ model }),
signal: AbortSignal.timeout(3000),
Expand All @@ -33,9 +30,8 @@ async function show(url: string, model: string, key?: string) {
}
}

async function probe(url: string, key?: string): Promise<LocalModel[]> {
async function probe(url: string): Promise<LocalModel[]> {
const res = await fetch(url + "/api/ps", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`Ollama probe failed: ${res.status}`)
Expand All @@ -50,7 +46,7 @@ async function probe(url: string, key?: string): Promise<LocalModel[]> {

return Promise.all(
body.models.map(async (item) => {
const extra = await show(url, item.model, key)
const extra = await show(url, item.model)
return {
id: item.name,
context: item.context_length,
Expand Down
4 changes: 2 additions & 2 deletions src/providers/shared.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { LocalModel, LocalProviderKind } from "../types"

export type ProviderImpl = {
detect(url: string, key?: string): Promise<boolean>
probe(url: string, key?: string): Promise<LocalModel[]>
detect(url: string): Promise<boolean>
probe(url: string): Promise<LocalModel[]>
}

export type ProviderMap = Record<LocalProviderKind, ProviderImpl>
7 changes: 2 additions & 5 deletions src/providers/vllm.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { LocalModel } from "../types"
import { authHeaders } from "../url"
import type { ProviderImpl } from "./shared"

async function detect(url: string, key?: string) {
async function detect(url: string) {
try {
const res = await fetch(url + "/v1/models", {
headers: authHeaders(key),
signal: AbortSignal.timeout(2000),
})
if (!res.ok) return false
Expand All @@ -18,9 +16,8 @@ async function detect(url: string, key?: string) {
}
}

async function probe(url: string, key?: string): Promise<LocalModel[]> {
async function probe(url: string): Promise<LocalModel[]> {
const res = await fetch(url + "/v1/models", {
headers: authHeaders(key),
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`vLLM probe failed: ${res.status}`)
Expand Down
5 changes: 0 additions & 5 deletions src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,3 @@ export function rootURL(url: string) {
if (!next) return ""
return next.endsWith("/v1") ? next.slice(0, -3) : next
}

export function authHeaders(key?: string) {
if (!key) return {} as Record<string, string>
return { Authorization: `Bearer ${key}` }
}
Loading