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
17 changes: 16 additions & 1 deletion .github/workflows/provider-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,19 @@ jobs:
- run: bun install --frozen-lockfile

- name: Run ${{ matrix.provider }} suite
run: bash tests/docker/run-providers-suite.sh "${{ matrix.provider }}"
run: PROVIDER_SUITE="${{ matrix.provider }}" bun test ./tests/providers.test.ts

opencode-tests:
name: opencode tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- run: bun install --frozen-lockfile

- name: Run opencode llama.cpp suite
run: bun test ./tests/opencode.models.test.ts
80 changes: 61 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Instead of creating one provider per server, this plugin keeps one `local` provi

- Adds a `local` provider to OpenCode
- Supports multiple local URLs under one provider
- 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
Expand All @@ -34,37 +35,76 @@ OpenCode will install the package and update the config for you.

## Provider Setup

Run:
Default targets are enabled automatically for these backends and ports:

- Ollama: `http://127.0.0.1:11434`
- LM Studio: `http://127.0.0.1:1234`
- llama.cpp: `http://127.0.0.1:8080`
- vLLM: `http://127.0.0.1:8000`
- Exo: `http://127.0.0.1:52415`

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 enter:
- a target ID, like `ollama` or `studio`
- the local server URL
- an optional shared API key
Choose `local`, then choose `Set Shared API Key` and enter the shared API key.

## 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)"
```

The target ID can be any valid provider ID string. It is used as the prefix for discovered model IDs.
This will prompt for:

You can repeat this flow to add more targets. (target IDs should be unique)
- 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 target is then stored in OpenCode global config.

You can also add explicit targets manually in config if needed:

```json
{
"provider": {
"local": {
"name": "Local Provider",
"options": {
"includeDefaults": true,
"targets": {
"studio": {
"url": "http://192.168.1.10:1234/v1",
"kind": "lmstudio"
}
}
}
}
}
}
```

Explicit targets override the built-in defaults when they use the same ID.
The CLI custom-target method is the supported way to add explicit targets without editing config directly.

## Resulting Config

The plugin stores targets in OpenCode global config under the `local` provider:
The plugin stores explicit targets in OpenCode global config under the `local` provider:

```json
{
"provider": {
"local": {
"name": "Local Provider",
"npm": "@ai-sdk/openai-compatible",
"options": {
"includeDefaults": true,
"targets": {
"ollama": {
"url": "http://localhost:11434/v1",
"kind": "ollama"
},
"studio": {
"url": "http://127.0.0.1:1234/v1",
"kind": "lmstudio"
Expand All @@ -76,6 +116,8 @@ The plugin stores targets in OpenCode global config under the `local` provider:
}
```

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.

## How Models Appear
Expand All @@ -93,8 +135,8 @@ 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
- Enter `none` in the auth prompt to clear the shared API key

## Development

Expand All @@ -104,7 +146,7 @@ Build the plugin:
bun run build
```

Run the real provider integration suite in Docker Compose:
Run the provider integration suite:

```bash
bun run test:providers
Expand All @@ -113,16 +155,16 @@ bun run test:providers
Run a single provider suite:

```bash
bun run test:providers ollama
PROVIDER_SUITE=ollama bun run test:providers
```

Notes:

- The suite starts real provider containers for `ollama`, `lmstudio`, `llamacpp`, `vllm`, and `exo` from `tests/docker/compose.providers.yml`.
- The runner talks to each service over the Docker Compose network using each container's internal IP. It does not require publishing ports to the host.
- The suite starts provider containers for `ollama`, `lmstudio`, `llamacpp`, `vllm`, and `exo` from `tests/docker/compose.providers.yml`.
- The Bun test runner talks to each service over the Docker Compose network using each container's internal IP. It does not require publishing ports to the host.
- The first run can be slow because the containers may need to download model assets, LM Studio bootstraps its headless runtime at startup, and Exo warms models to a real ready state before the suite proceeds.
- CI runs the same suite per provider via `.github/workflows/provider-tests.yml`.
- If you change provider models or startup behavior, update `tests/docker/compose.providers.yml` and the related health checks instead of duplicating those details here.
- If you change provider models or startup behavior, update `tests/docker/compose.providers.yml` and the Bun orchestration helpers in `tests/docker/` instead of duplicating those details here.

Install it locally in OpenCode with a file path plugin entry, for example:

Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,19 @@
],
"scripts": {
"build": "bun build src/index.ts --outdir=dist --target=bun --format=esm --packages=external",
"test": "bun test",
"test:providers": "bash tests/docker/run-providers-suite.sh",
"test": "bun test ./tests",
"test:providers": "bun test ./tests/providers.test.ts",
"typecheck": "bun tsc --noEmit",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
"typescript": "^5.9.3"
},
"dependencies": {
"@opencode-ai/plugin": "^1.3.15",
"@opencode-ai/sdk": "^1.3.15"
"@opencode-ai/plugin": "^1.4.0",
"@opencode-ai/sdk": "^1.4.0"
}
}
32 changes: 29 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Provider } from "@opencode-ai/sdk/v2"

import { LEGACY_TARGET_ID, LOCAL_PROVIDER_ID } from "./constants"
import { supportedProviderDefaultURLs } from "./providers"
import { KINDS, type LocalTarget } from "./types"
import { baseURL } from "./url"

Expand Down Expand Up @@ -50,7 +51,17 @@ function parseTargetConfig(item: unknown) {
}
}

export function getProviderTargets(provider?: Pick<Provider, "options">) {
const defaults = Object.fromEntries(
Object.entries(supportedProviderDefaultURLs).map(([id, url]) => [
id,
{
url: baseURL(url),
kind: id as LocalTarget["kind"],
},
]),
) as Record<string, LocalTarget>

export function getConfiguredTargets(provider?: Pick<Provider, "options">) {
const raw = provider?.options?.targets
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const next = Object.fromEntries(
Expand All @@ -77,6 +88,21 @@ export function getProviderTargets(provider?: Pick<Provider, "options">) {
return {}
}

export function getProviderTargets(provider?: Pick<Provider, "options">) {
const configured = getConfiguredTargets(provider)
if (provider?.options?.includeDefaults === false) return configured

const urls = new Set(Object.values(configured).map((item) => item.url))
const builtin = Object.fromEntries(
Object.entries(defaults).filter(([id, item]) => !configured[id] && !urls.has(item.url)),
) as Record<string, LocalTarget>

return {
...builtin,
...configured,
}
}

export function getProviderApiKey(provider?: Pick<Provider, "options">, auth?: { type: string; key?: string }) {
const val = provider?.options?.apiKey
if (typeof val === "string" && val) return val
Expand All @@ -87,7 +113,7 @@ export async function getCurrentProviderConfig(url: URL, input: PluginInput["cli
const cfg = await createV2OpencodeClient(url, input).global.config.get()
const provider = cfg.data?.provider?.[LOCAL_PROVIDER_ID]
return {
targets: getProviderTargets(provider as Pick<Provider, "options"> | undefined),
targets: getConfiguredTargets(provider as Pick<Provider, "options"> | undefined),
key: typeof provider?.options?.apiKey === "string" ? provider.options.apiKey : "",
}
}
Expand All @@ -110,7 +136,7 @@ export async function saveProviderTarget(
},
},
}

if (key !== undefined) options.apiKey = key

await createV2OpencodeClient(server, input).global.config.update({
Expand Down
39 changes: 22 additions & 17 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
OPENAI_COMPATIBLE_NPM,
} from "./constants"
import {
getCurrentProviderConfig,
getConfiguredTargets,
getProviderApiKey,
getProviderTargets,
saveProviderTarget,
Expand Down Expand Up @@ -56,9 +56,10 @@ export const LocalProviderPlugin: Plugin = async (ctx) => {
config: async (cfg) => {
cfg.provider ??= {}
const provider = cfg.provider[LOCAL_PROVIDER_ID] ?? {}
const list = getProviderTargets(provider as Provider)
const list = getConfiguredTargets(provider as Provider)
const options = {
...provider.options,
includeDefaults: provider.options?.includeDefaults ?? true,
targets: list,
}
delete options.baseURL
Expand All @@ -74,13 +75,17 @@ export const LocalProviderPlugin: Plugin = async (ctx) => {
methods: [
{
type: "api",
label: "Connect to Local Provider",
label: "Set Shared API Key",
},
{
type: "api",
label: "Add Custom Target (CLI only)",
prompts: [
{
type: "text",
key: "target",
message: "Enter a target ID",
placeholder: "ollama",
placeholder: "studio",
validate(value) {
if (!value) return "Target ID is required"
if (!validID(value)) return "Use lowercase letters, numbers, - or _"
Expand All @@ -90,42 +95,42 @@ export const LocalProviderPlugin: Plugin = async (ctx) => {
type: "text",
key: "baseURL",
message: "Enter your local provider URL",
placeholder: "http://localhost:11434",
placeholder: "http://192.168.1.10:1234",
validate(value) {
if (!trimURL(value ?? "")) return "URL is required"
},
},
{
type: "text",
key: "apiKey",
message: "Shared API key (leave empty to keep current, enter none to clear)",
placeholder: "Bearer token or empty",
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 ?? "")
if (!id || !validID(id) || !raw) return { type: "failed" as const }
const next = input.apiKey?.trim() ?? ""
const key = next === "none" ? "" : next
if (!id || !validID(id) || !raw || !next) return { type: "failed" as const }

const cur = await getCurrentProviderConfig(ctx.serverUrl, ctx.client)
const prev = cur.key
const next = input.apiKey?.trim()
const key = next === "none" ? "" : next || prev
const saveKey = next === "none" ? "" : next || undefined
const kind = await detect(raw, key).catch(() => undefined)
if (!kind) return { type: "failed" as const }

try {
const kind = await detect(raw, key)
if (!kind) return { type: "failed" as const }
await probe(raw, key, kind)
await saveProviderTarget(ctx.serverUrl, ctx.client, id, raw, kind, saveKey)
await saveProviderTarget(ctx.serverUrl, ctx.client, id, raw, kind)
} catch {
return { type: "failed" as const }
}

return {
type: "success" as const,
key,
provider: LOCAL_PROVIDER_ID,
key,
}
},
},
Expand Down
8 changes: 8 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,12 @@ export const supportedProviders: ProviderMap = {
exo,
}

export const supportedProviderDefaultURLs: Record<LocalProviderKind, string> = {
ollama: "http://127.0.0.1:11434",
lmstudio: "http://127.0.0.1:1234",
llamacpp: "http://127.0.0.1:8080",
vllm: "http://127.0.0.1:8000",
exo: "http://127.0.0.1:52415",
}
Comment thread
goniz marked this conversation as resolved.

export const supportedProviderKinds = Object.keys(supportedProviders) as LocalProviderKind[]
Loading
Loading