From 768c7df2e14a15efab6d331696845b28044aec6e Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Wed, 8 Apr 2026 20:16:20 +0300 Subject: [PATCH 1/6] fix: scope tsconfig to src/ so typecheck only runs on project sources --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..0d210c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "include": ["src"], "compilerOptions": { // Environment setup & latest features "lib": ["ESNext"], From cff90e8a1d0ed305ac00d9f42a48b829d43019f2 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Thu, 9 Apr 2026 08:48:23 +0300 Subject: [PATCH 2/6] feat: auto-detect local providers on default ports Let the plugin discover supported local providers on their default URLs so setup can succeed without manual target entry. Update the OpenCode plugin dependencies to 1.4.0 and expose the Exo test port for the provider suite. --- bun.lock | 10 ++-- package.json | 6 +-- src/plugin.ts | 73 ++++++++++++++++++++++++------ src/providers/index.ts | 8 ++++ tests/docker/compose.providers.yml | 2 + 5 files changed, 76 insertions(+), 23 deletions(-) diff --git a/bun.lock b/bun.lock index b9c1364..b014292 100644 --- a/bun.lock +++ b/bun.lock @@ -5,21 +5,21 @@ "": { "name": "opencode-local-provider", "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", }, "devDependencies": { "@types/bun": "latest", }, "peerDependencies": { - "typescript": "^5", + "typescript": "^5.9.3", }, }, }, "packages": { - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.3.15", "", { "dependencies": { "@opencode-ai/sdk": "1.3.15", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.96", "@opentui/solid": ">=0.1.96" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-jZJbuvUXc5Limz8pacQl+ffATjjKGlq+xaA4wTUeW+/spwOf7Yr5Ryyvan8eNlYM8wy6h5SLfznl1rlFpjYC8w=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.0", "", { "dependencies": { "@opencode-ai/sdk": "1.4.0", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], diff --git a/package.json b/package.json index 5e01bd1..5b7063f 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,10 @@ "@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" } } diff --git a/src/plugin.ts b/src/plugin.ts index 069b480..86d485d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -15,8 +15,9 @@ import { saveProviderTarget, } from "./config" import { build } from "./models" -import { supportedProviderKinds } from "./providers" +import { supportedProviderDefaultURLs, supportedProviderKinds } from "./providers" import { detect, probe } from "./probe" +import type { LocalProviderKind } from "./types" import { trimURL } from "./url" function validID(value: string) { @@ -76,11 +77,29 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { type: "api", label: "Connect to Local Provider", prompts: [ + { + type: "select", + key: "autoDetect", + message: "Auto-detect supported local providers on their default ports?", + options: [ + { + label: "Yes", + value: "yes", + hint: "Find and configure supported local providers automatically", + }, + { + label: "No", + value: "no", + hint: "Configure a single target manually", + }, + ], + }, { type: "text", key: "target", message: "Enter a target ID", placeholder: "ollama", + when: { key: "autoDetect", op: "eq", value: "no" }, validate(value) { if (!value) return "Target ID is required" if (!validID(value)) return "Use lowercase letters, numbers, - or _" @@ -91,40 +110,64 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { key: "baseURL", message: "Enter your local provider URL", placeholder: "http://localhost:11434", + when: { key: "autoDetect", op: "eq", value: "no" }, 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", - }, ], async authorize(input = {}) { + const cur = await getCurrentProviderConfig(ctx.serverUrl, ctx.client) + const key = ctx.auth || cur.key + + if (input.autoDetect === "yes") { + const detected = await Promise.all( + Object.entries(supportedProviderDefaultURLs).map(async ([id, raw]) => { + try { + const kind = await detect(raw, key) + if (!kind) return + await probe(raw, key, kind) + return { id, raw, kind } + } catch { + return + } + }), + ) + + const targets = detected.filter( + (item): item is { id: string; raw: string; kind: LocalProviderKind } => Boolean(item), + ) + if (!targets.length) return { type: "failed" as const } + + try { + for (const item of targets) { + await saveProviderTarget(ctx.serverUrl, ctx.client, item.id, item.raw, item.kind) + } + } catch { + return { type: "failed" as const } + } + + return { + type: "success" as const, + provider: LOCAL_PROVIDER_ID, + } + } + const id = input.target?.trim() ?? "" const raw = trimURL(input.baseURL ?? "") if (!id || !validID(id) || !raw) 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 - 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, } }, diff --git a/src/providers/index.ts b/src/providers/index.ts index 629a230..5358724 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -15,4 +15,12 @@ export const supportedProviders: ProviderMap = { exo, } +export const supportedProviderDefaultURLs: Record = { + ollama: "http://localhost: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", +} + export const supportedProviderKinds = Object.keys(supportedProviders) as LocalProviderKind[] diff --git a/tests/docker/compose.providers.yml b/tests/docker/compose.providers.yml index 7e15353..9b398eb 100644 --- a/tests/docker/compose.providers.yml +++ b/tests/docker/compose.providers.yml @@ -77,6 +77,8 @@ services: environment: EXO_MODEL: LiquidAI/LFM2.5-350M-MLX-4bit EXO_PORT: 52415 + ports: + - "52415:52415" volumes: - ./exo-entrypoint.sh:/entrypoint.sh:ro entrypoint: ["bash", "/entrypoint.sh"] From ccb21cc931d1e0a9a2f1860641793586ba9de7e9 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Fri, 10 Apr 2026 09:24:25 +0300 Subject: [PATCH 3/6] refactor local target setup around defaults Make built-in localhost targets available by default at runtime and add a CLI auth flow for saving custom targets without editing config manually. --- README.md | 70 +++++++++++++++++++++++------ src/config.ts | 32 ++++++++++++-- src/plugin.ts | 103 ++++++++++++++++--------------------------- tests/config.test.ts | 85 +++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 81 deletions(-) create mode 100644 tests/config.test.ts diff --git a/README.md b/README.md index 578df84..71ff91f 100644 --- a/README.md +++ b/README.md @@ -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 localhost targets automatically - Detects loaded models at runtime - Routes each model to the correct target URL - Supports optional shared API key auth @@ -34,24 +35,68 @@ 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://localhost: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: + +- 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", + "npm": "@ai-sdk/openai-compatible", + "options": { + "includeDefaults": true, + "targets": { + "studio": { + "url": "http://192.168.1.10:1234/v1", + "kind": "lmstudio" + } + } + } + } + } +} +``` -You can repeat this flow to add more targets. (target IDs should be unique) +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 { @@ -60,11 +105,8 @@ The plugin stores targets in OpenCode global config under the `local` provider: "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" @@ -76,6 +118,8 @@ The plugin stores targets in OpenCode global config under the `local` provider: } ``` +With `includeDefaults: true`, the built-in default localhost 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 @@ -93,8 +137,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 localhost 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 diff --git a/src/config.ts b/src/config.ts index 664abd6..2cca4a9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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" @@ -50,7 +51,17 @@ function parseTargetConfig(item: unknown) { } } -export function getProviderTargets(provider?: Pick) { +const defaults = Object.fromEntries( + Object.entries(supportedProviderDefaultURLs).map(([id, url]) => [ + id, + { + url: baseURL(url), + kind: id as LocalTarget["kind"], + }, + ]), +) as Record + +export function getConfiguredTargets(provider?: Pick) { const raw = provider?.options?.targets if (raw && typeof raw === "object" && !Array.isArray(raw)) { const next = Object.fromEntries( @@ -77,6 +88,21 @@ export function getProviderTargets(provider?: Pick) { return {} } +export function getProviderTargets(provider?: Pick) { + 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 + + return { + ...builtin, + ...configured, + } +} + export function getProviderApiKey(provider?: Pick, auth?: { type: string; key?: string }) { const val = provider?.options?.apiKey if (typeof val === "string" && val) return val @@ -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 | undefined), + targets: getConfiguredTargets(provider as Pick | undefined), key: typeof provider?.options?.apiKey === "string" ? provider.options.apiKey : "", } } @@ -110,7 +136,7 @@ export async function saveProviderTarget( }, }, } - + if (key !== undefined) options.apiKey = key await createV2OpencodeClient(server, input).global.config.update({ diff --git a/src/plugin.ts b/src/plugin.ts index 86d485d..5e601b0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -9,16 +9,16 @@ import { OPENAI_COMPATIBLE_NPM, } from "./constants" import { + getConfiguredTargets, getCurrentProviderConfig, getProviderApiKey, getProviderTargets, saveProviderTarget, } from "./config" import { build } from "./models" -import { supportedProviderDefaultURLs, supportedProviderKinds } from "./providers" +import { supportedProviderKinds } from "./providers" import { detect, probe } from "./probe" -import type { LocalProviderKind } from "./types" -import { trimURL } from "./url" +import { baseURL, trimURL } from "./url" function validID(value: string) { return /^[a-z0-9][a-z0-9-_]*$/.test(value) @@ -57,9 +57,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 @@ -75,31 +76,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: "select", - key: "autoDetect", - message: "Auto-detect supported local providers on their default ports?", - options: [ - { - label: "Yes", - value: "yes", - hint: "Find and configure supported local providers automatically", - }, - { - label: "No", - value: "no", - hint: "Configure a single target manually", - }, - ], - }, { type: "text", key: "target", message: "Enter a target ID", - placeholder: "ollama", - when: { key: "autoDetect", op: "eq", value: "no" }, + placeholder: "studio", validate(value) { if (!value) return "Target ID is required" if (!validID(value)) return "Use lowercase letters, numbers, - or _" @@ -109,66 +96,52 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { type: "text", key: "baseURL", message: "Enter your local provider URL", - placeholder: "http://localhost:11434", - when: { key: "autoDetect", op: "eq", value: "no" }, + placeholder: "http://192.168.1.10:1234", validate(value) { 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 cur = await getCurrentProviderConfig(ctx.serverUrl, ctx.client) - const key = ctx.auth || cur.key - - if (input.autoDetect === "yes") { - const detected = await Promise.all( - Object.entries(supportedProviderDefaultURLs).map(async ([id, raw]) => { - try { - const kind = await detect(raw, key) - if (!kind) return - await probe(raw, key, kind) - return { id, raw, kind } - } catch { - return - } - }), - ) + 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 targets = detected.filter( - (item): item is { id: string; raw: string; kind: LocalProviderKind } => Boolean(item), - ) - if (!targets.length) return { type: "failed" as const } + const cur = await getCurrentProviderConfig(ctx.serverUrl, ctx.client) + const prev = cur.targets[id] + if (prev && prev.url !== baseURL(raw)) return { type: "failed" as const } - try { - for (const item of targets) { - await saveProviderTarget(ctx.serverUrl, ctx.client, item.id, item.raw, item.kind) - } - } catch { - return { type: "failed" as const } - } + const kind = await detect(raw, key).catch(() => undefined) + if (!kind) return { type: "failed" as const } - return { - type: "success" as const, - provider: LOCAL_PROVIDER_ID, - } + try { + await probe(raw, key, kind) + } catch { + return { type: "failed" as const } } - const id = input.target?.trim() ?? "" - const raw = trimURL(input.baseURL ?? "") - if (!id || !validID(id) || !raw) 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) } catch { - return { type: "failed" as const } + const now = await getCurrentProviderConfig(ctx.serverUrl, ctx.client).catch(() => undefined) + if (now?.targets[id]?.url !== baseURL(raw)) return { type: "failed" as const } } return { type: "success" as const, provider: LOCAL_PROVIDER_ID, + key, } }, }, diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..f5dab28 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test" + +import { getConfiguredTargets, getProviderTargets } from "../src/config" +import { supportedProviderDefaultURLs } from "../src/providers" +import { baseURL } from "../src/url" + +test("includes supported default targets by default", () => { + const targets = getProviderTargets() + + expect(Object.keys(targets)).toEqual(Object.keys(supportedProviderDefaultURLs)) + for (const [id, url] of Object.entries(supportedProviderDefaultURLs)) { + expect(targets[id]).toEqual({ + url: baseURL(url), + kind: id, + }) + } +}) + +test("keeps configured targets explicit-only", () => { + const targets = getConfiguredTargets({ + options: { + targets: { + studio: { + url: "http://192.168.1.10:1234", + kind: "lmstudio", + }, + }, + }, + }) + + expect(targets).toEqual({ + studio: { + url: "http://192.168.1.10:1234/v1", + kind: "lmstudio", + }, + }) +}) + +test("configured targets override defaults and suppress duplicate urls", () => { + const targets = getProviderTargets({ + options: { + targets: { + ollama: { + url: "http://192.168.1.20:11434", + kind: "ollama", + }, + studio: { + url: "http://127.0.0.1:1234", + kind: "lmstudio", + }, + }, + }, + }) + + expect(targets.ollama).toEqual({ + url: "http://192.168.1.20:11434/v1", + kind: "ollama", + }) + expect(targets.studio).toEqual({ + url: "http://127.0.0.1:1234/v1", + kind: "lmstudio", + }) + expect(targets.lmstudio).toBeUndefined() +}) + +test("supports opting out of default targets", () => { + const targets = getProviderTargets({ + options: { + includeDefaults: false, + targets: { + studio: { + url: "http://192.168.1.10:1234", + kind: "lmstudio", + }, + }, + }, + }) + + expect(targets).toEqual({ + studio: { + url: "http://192.168.1.10:1234/v1", + kind: "lmstudio", + }, + }) +}) From 18def7ee04566cf3fb068c4b380121637aa3b3cc Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Fri, 10 Apr 2026 10:30:24 +0300 Subject: [PATCH 4/6] test: verify opencode detects default llama.cpp model --- tests/docker/compose.providers.yml | 14 ++++++ tests/opencode.models.real.test.ts | 74 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/opencode.models.real.test.ts diff --git a/tests/docker/compose.providers.yml b/tests/docker/compose.providers.yml index 9b398eb..c1e26a1 100644 --- a/tests/docker/compose.providers.yml +++ b/tests/docker/compose.providers.yml @@ -47,6 +47,20 @@ services: retries: 60 start_period: 30s + opencode: + image: ghcr.io/anomalyco/opencode + depends_on: + llamacpp: + condition: service_healthy + network_mode: "service:llamacpp" + volumes: + - ../..:/workspace + working_dir: /tmp/opencode-test + entrypoint: + - sh + - -lc + - mkdir -p /tmp/opencode-test && while :; do sleep 3600; done + vllm: image: vllm/vllm-openai-cpu:latest-x86_64 command: diff --git a/tests/opencode.models.real.test.ts b/tests/opencode.models.real.test.ts new file mode 100644 index 0000000..848a88b --- /dev/null +++ b/tests/opencode.models.real.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import { randomUUID } from "node:crypto" +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("..", import.meta.url)) +const composeFile = fileURLToPath(new URL("./docker/compose.providers.yml", import.meta.url)) + +function run(command: string, args: string[], env: Record) { + const result = spawnSync(command, args, { + cwd: root, + env: { + ...process.env, + ...env, + }, + encoding: "utf8", + }) + + if (result.status === 0) return result.stdout + + throw new Error( + [`Command failed: ${command} ${args.join(" ")}`, result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n\n"), + ) +} + +describe("opencode docker integration", () => { + test("lists the default llama.cpp model after plugin install", () => { + const env = { + COMPOSE_PROJECT_NAME: `opencode-models-${randomUUID().slice(0, 8)}`, + } + + try { + run("bun", ["run", "build"], env) + + run("docker", ["compose", "-f", composeFile, "up", "-d", "--wait", "llamacpp", "opencode"], env) + + const output = run( + "docker", + [ + "compose", + "-f", + composeFile, + "exec", + "-T", + "opencode", + "sh", + "-lc", + [ + "export HOME=/tmp/opencode-home", + "mkdir -p \"$HOME\" /tmp/opencode-test", + "cd /tmp/opencode-test", + "opencode plugin /workspace", + "opencode models", + ].join(" && "), + ], + env, + ) + + expect(output).toContain("llamacpp/") + expect(output).toContain("LFM2.5-350M") + } finally { + spawnSync("docker", ["compose", "-f", composeFile, "down", "-v"], { + cwd: root, + env: { + ...process.env, + ...env, + }, + encoding: "utf8", + }) + } + }, 600_000) +}) From d8d2435e627ec20b06dff33a7df74ec876c27a74 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Fri, 10 Apr 2026 11:35:02 +0300 Subject: [PATCH 5/6] ci: run opencode integration test separately --- .github/workflows/provider-tests.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/provider-tests.yml b/.github/workflows/provider-tests.yml index c0b2f42..52ee353 100644 --- a/.github/workflows/provider-tests.yml +++ b/.github/workflows/provider-tests.yml @@ -30,3 +30,18 @@ jobs: - name: Run ${{ matrix.provider }} suite run: bash tests/docker/run-providers-suite.sh "${{ matrix.provider }}" + + 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.real.test.ts From e186c37e67cbd3ad794e82c3885c97e6887ead92 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Fri, 10 Apr 2026 15:21:01 +0300 Subject: [PATCH 6/6] test: move provider orchestration into bun --- .github/workflows/provider-tests.yml | 4 +- README.md | 20 ++--- package.json | 4 +- src/plugin.ts | 15 +--- src/providers/index.ts | 2 +- tests/docker/compose.providers.yml | 2 - tests/docker/compose.ts | 88 ++++++++++++++++++ tests/docker/run-providers-suite.sh | 90 ------------------- tests/opencode.models.real.test.ts | 74 --------------- tests/opencode.models.test.ts | 30 +++++++ ...oviders.real.test.ts => providers.test.ts} | 67 +++++++++----- 11 files changed, 177 insertions(+), 219 deletions(-) create mode 100644 tests/docker/compose.ts delete mode 100644 tests/docker/run-providers-suite.sh delete mode 100644 tests/opencode.models.real.test.ts create mode 100644 tests/opencode.models.test.ts rename tests/{providers.real.test.ts => providers.test.ts} (54%) diff --git a/.github/workflows/provider-tests.yml b/.github/workflows/provider-tests.yml index 52ee353..4f08493 100644 --- a/.github/workflows/provider-tests.yml +++ b/.github/workflows/provider-tests.yml @@ -29,7 +29,7 @@ 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 @@ -44,4 +44,4 @@ jobs: - run: bun install --frozen-lockfile - name: Run opencode llama.cpp suite - run: bun test ./tests/opencode.models.real.test.ts + run: bun test ./tests/opencode.models.test.ts diff --git a/README.md b/README.md index 71ff91f..5e04057 100644 --- a/README.md +++ b/README.md @@ -15,7 +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 localhost targets automatically +- 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 @@ -37,7 +37,7 @@ OpenCode will install the package and update the config for you. Default targets are enabled automatically for these backends and ports: -- Ollama: `http://localhost:11434` +- 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` @@ -76,7 +76,6 @@ You can also add explicit targets manually in config if needed: "provider": { "local": { "name": "Local Provider", - "npm": "@ai-sdk/openai-compatible", "options": { "includeDefaults": true, "targets": { @@ -103,7 +102,6 @@ The plugin stores explicit targets in OpenCode global config under the `local` p "provider": { "local": { "name": "Local Provider", - "npm": "@ai-sdk/openai-compatible", "options": { "includeDefaults": true, "targets": { @@ -118,7 +116,7 @@ The plugin stores explicit targets in OpenCode global config under the `local` p } ``` -With `includeDefaults: true`, the built-in default localhost targets are also checked at runtime even though they are not written into config. +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. @@ -137,7 +135,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 localhost targets are enabled unless you set `includeDefaults` to `false` +- 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 ## Development @@ -148,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 @@ -157,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: diff --git a/package.json b/package.json index 5b7063f..bcf9545 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ ], "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" }, diff --git a/src/plugin.ts b/src/plugin.ts index 5e601b0..a7bc3ac 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,7 +10,6 @@ import { } from "./constants" import { getConfiguredTargets, - getCurrentProviderConfig, getProviderApiKey, getProviderTargets, saveProviderTarget, @@ -18,7 +17,7 @@ import { import { build } from "./models" import { supportedProviderKinds } from "./providers" import { detect, probe } from "./probe" -import { baseURL, trimURL } from "./url" +import { trimURL } from "./url" function validID(value: string) { return /^[a-z0-9][a-z0-9-_]*$/.test(value) @@ -118,24 +117,14 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { 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.targets[id] - if (prev && prev.url !== baseURL(raw)) 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) - } catch { - return { type: "failed" as const } - } - - try { await saveProviderTarget(ctx.serverUrl, ctx.client, id, raw, kind) } catch { - const now = await getCurrentProviderConfig(ctx.serverUrl, ctx.client).catch(() => undefined) - if (now?.targets[id]?.url !== baseURL(raw)) return { type: "failed" as const } + return { type: "failed" as const } } return { diff --git a/src/providers/index.ts b/src/providers/index.ts index 5358724..4d25736 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -16,7 +16,7 @@ export const supportedProviders: ProviderMap = { } export const supportedProviderDefaultURLs: Record = { - ollama: "http://localhost:11434", + 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", diff --git a/tests/docker/compose.providers.yml b/tests/docker/compose.providers.yml index c1e26a1..a1752f2 100644 --- a/tests/docker/compose.providers.yml +++ b/tests/docker/compose.providers.yml @@ -91,8 +91,6 @@ services: environment: EXO_MODEL: LiquidAI/LFM2.5-350M-MLX-4bit EXO_PORT: 52415 - ports: - - "52415:52415" volumes: - ./exo-entrypoint.sh:/entrypoint.sh:ro entrypoint: ["bash", "/entrypoint.sh"] diff --git a/tests/docker/compose.ts b/tests/docker/compose.ts new file mode 100644 index 0000000..7c1ad50 --- /dev/null +++ b/tests/docker/compose.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto" +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("../..", import.meta.url)) +const composeFile = fileURLToPath(new URL("./compose.providers.yml", import.meta.url)) + +export function run(command: string, args: string[], env: Record = {}) { + const result = spawnSync(command, args, { + cwd: root, + env: { + ...process.env, + ...env, + }, + encoding: "utf8", + }) + + if (result.status === 0) return result.stdout + + throw new Error( + [`Command failed: ${command} ${args.join(" ")}`, result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n\n"), + ) +} + +export class ComposeEnvironment { + readonly env = { + COMPOSE_PROJECT_NAME: `provider-tests-${randomUUID().slice(0, 8)}`, + } + + up(services?: string[]) { + run( + "docker", + ["compose", "-f", composeFile, "up", "-d", "--wait", ...(services?.length ? services : [])], + this.env, + ) + } + + down() { + spawnSync("docker", ["compose", "-f", composeFile, "down", "-v"], { + cwd: root, + env: { + ...process.env, + ...this.env, + }, + encoding: "utf8", + }) + } + + serviceIP(service: string) { + const containerID = run( + "docker", + [ + "ps", + "--filter", + `label=com.docker.compose.project=${this.env.COMPOSE_PROJECT_NAME}`, + "--filter", + `label=com.docker.compose.service=${service}`, + "--format", + "{{.ID}}", + ], + this.env, + ) + .trim() + .split("\n")[0] + + if (!containerID) throw new Error(`No running container found for service: ${service}`) + + return run( + "docker", + ["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", containerID], + this.env, + ).trim() + } + + serviceURL(service: string, port: number) { + return `http://${this.serviceIP(service)}:${port}` + } + + exec(service: string, script: string) { + return run( + "docker", + ["compose", "-f", composeFile, "exec", "-T", service, "sh", "-lc", script], + this.env, + ) + } +} diff --git a/tests/docker/run-providers-suite.sh b/tests/docker/run-providers-suite.sh deleted file mode 100644 index f8685ce..0000000 --- a/tests/docker/run-providers-suite.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(dirname "$0")/../.." -COMPOSE_FILE="$ROOT/tests/docker/compose.providers.yml" -export COMPOSE_PROJECT_NAME="provider-tests" - -SUITE="${1:-all}" -SERVICES=(ollama lmstudio llamacpp vllm exo) - -is_valid_suite() { - local candidate="$1" - - if [[ "$candidate" == "all" ]]; then - return 0 - fi - - for service in "${SERVICES[@]}"; do - if [[ "$service" == "$candidate" ]]; then - return 0 - fi - done - - return 1 -} - -if ! is_valid_suite "$SUITE"; then - printf 'Unknown provider suite: %s\n' "$SUITE" >&2 - printf 'Expected one of: all, %s\n' "${SERVICES[*]}" >&2 - exit 1 -fi - -cleanup() { - docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true -} - -service_ip() { - local service="$1" - local container_ids - local container_id - - mapfile -t container_ids < <( - docker ps \ - --filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME" \ - --filter "label=com.docker.compose.service=$service" \ - --format '{{.ID}}' - ) - - container_id="${container_ids[0]:-}" - if [[ -z "$container_id" ]]; then - printf 'No running container found for service: %s\n' "$service" >&2 - exit 1 - fi - - docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_id" -} - -service_url() { - local service="$1" - local port="$2" - - if [[ "$SUITE" != "all" && "$SUITE" != "$service" ]]; then - return 0 - fi - - printf 'http://%s:%s' "$(service_ip "$service")" "$port" -} - -trap cleanup EXIT INT TERM - -if [[ "$SUITE" == "all" ]]; then - docker compose -f "$COMPOSE_FILE" up -d --wait -else - docker compose -f "$COMPOSE_FILE" up -d --wait "$SUITE" -fi - -OLLAMA_URL="$(service_url ollama 11434)" -LMSTUDIO_URL="$(service_url lmstudio 1234)" -LLAMACPP_URL="$(service_url llamacpp 8080)" -VLLM_URL="$(service_url vllm 8000)" -EXO_URL="$(service_url exo 52415)" - -REAL_PROVIDER_SUITE=1 \ -PROVIDER_SUITE="$([[ "$SUITE" == "all" ]] && printf '' || printf '%s' "$SUITE")" \ -OLLAMA_URL="$OLLAMA_URL" \ -LMSTUDIO_URL="$LMSTUDIO_URL" \ -LLAMACPP_URL="$LLAMACPP_URL" \ -VLLM_URL="$VLLM_URL" \ -EXO_URL="$EXO_URL" \ - bun test "./tests/providers.real.test.ts" diff --git a/tests/opencode.models.real.test.ts b/tests/opencode.models.real.test.ts deleted file mode 100644 index 848a88b..0000000 --- a/tests/opencode.models.real.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { randomUUID } from "node:crypto" -import { spawnSync } from "node:child_process" -import { fileURLToPath } from "node:url" - -const root = fileURLToPath(new URL("..", import.meta.url)) -const composeFile = fileURLToPath(new URL("./docker/compose.providers.yml", import.meta.url)) - -function run(command: string, args: string[], env: Record) { - const result = spawnSync(command, args, { - cwd: root, - env: { - ...process.env, - ...env, - }, - encoding: "utf8", - }) - - if (result.status === 0) return result.stdout - - throw new Error( - [`Command failed: ${command} ${args.join(" ")}`, result.stdout.trim(), result.stderr.trim()] - .filter(Boolean) - .join("\n\n"), - ) -} - -describe("opencode docker integration", () => { - test("lists the default llama.cpp model after plugin install", () => { - const env = { - COMPOSE_PROJECT_NAME: `opencode-models-${randomUUID().slice(0, 8)}`, - } - - try { - run("bun", ["run", "build"], env) - - run("docker", ["compose", "-f", composeFile, "up", "-d", "--wait", "llamacpp", "opencode"], env) - - const output = run( - "docker", - [ - "compose", - "-f", - composeFile, - "exec", - "-T", - "opencode", - "sh", - "-lc", - [ - "export HOME=/tmp/opencode-home", - "mkdir -p \"$HOME\" /tmp/opencode-test", - "cd /tmp/opencode-test", - "opencode plugin /workspace", - "opencode models", - ].join(" && "), - ], - env, - ) - - expect(output).toContain("llamacpp/") - expect(output).toContain("LFM2.5-350M") - } finally { - spawnSync("docker", ["compose", "-f", composeFile, "down", "-v"], { - cwd: root, - env: { - ...process.env, - ...env, - }, - encoding: "utf8", - }) - } - }, 600_000) -}) diff --git a/tests/opencode.models.test.ts b/tests/opencode.models.test.ts new file mode 100644 index 0000000..815fec7 --- /dev/null +++ b/tests/opencode.models.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" + +import { ComposeEnvironment, run } from "./docker/compose" + +describe("opencode docker integration", () => { + test("lists the default llama.cpp model after plugin install", () => { + const compose = new ComposeEnvironment() + + try { + run("bun", ["run", "build"]) + compose.up(["llamacpp", "opencode"]) + + const output = compose.exec( + "opencode", + [ + "export HOME=/tmp/opencode-home", + "mkdir -p \"$HOME\" /tmp/opencode-test", + "cd /tmp/opencode-test", + "opencode plugin /workspace", + "opencode models", + ].join(" && "), + ) + + expect(output).toContain("llamacpp/") + expect(output).toContain("LFM2.5-350M") + } finally { + compose.down() + } + }, 600_000) +}) diff --git a/tests/providers.real.test.ts b/tests/providers.test.ts similarity index 54% rename from tests/providers.real.test.ts rename to tests/providers.test.ts index 809d912..cd9c5cf 100644 --- a/tests/providers.real.test.ts +++ b/tests/providers.test.ts @@ -1,65 +1,86 @@ -import { describe, expect, test } from "bun:test" +import { beforeAll, afterAll, describe, expect, test } from "bun:test" import { detect, probe } from "../src/probe" import { supportedProviderKinds } from "../src/providers" +import { ComposeEnvironment } from "./docker/compose" -const enabled = process.env.REAL_PROVIDER_SUITE === "1" const selectedKind = process.env.PROVIDER_SUITE +const providerURLs: Partial> = {} + const suites = [ { kind: "ollama", - url: process.env.OLLAMA_URL ?? "http://ollama:11434", + service: "ollama", + port: 11434, + url: () => providerURLs.ollama!, modelID: process.env.OLLAMA_MODEL, expectedContext: 128, }, { kind: "lmstudio", - url: process.env.LMSTUDIO_URL ?? "http://lmstudio:1234", + service: "lmstudio", + port: 1234, + url: () => providerURLs.lmstudio!, modelID: process.env.LMSTUDIO_MODEL_ID, expectedContext: 128, }, { kind: "llamacpp", - url: process.env.LLAMACPP_URL ?? "http://llamacpp:8080", + service: "llamacpp", + port: 8080, + url: () => providerURLs.llamacpp!, modelID: process.env.LLAMACPP_MODEL_ID, expectedContext: 256, }, { kind: "vllm", - url: process.env.VLLM_URL ?? "http://vllm:8000", + service: "vllm", + port: 8000, + url: () => providerURLs.vllm!, modelID: process.env.VLLM_MODEL, expectedContext: 128, }, { kind: "exo", - url: process.env.EXO_URL ?? "http://exo:52415", + service: "exo", + port: 52415, + url: () => providerURLs.exo!, modelID: process.env.EXO_MODEL, expectedContext: 128000, }, ] as const -const activeSuites = selectedKind - ? suites.filter((item) => item.kind === selectedKind) - : suites +const activeSuites = selectedKind ? suites.filter((item) => item.kind === selectedKind) : suites + +let compose: ComposeEnvironment | undefined + +beforeAll(() => { + compose = new ComposeEnvironment() + compose.up(activeSuites.map((item) => item.service)) + + for (const item of activeSuites) { + providerURLs[item.kind] = compose.serviceURL(item.service, item.port) + } +}, 600_000) -const describeIfEnabled = enabled ? describe : describe.skip +afterAll(() => { + compose?.down() +}, 120_000) test("supported providers list stays in sync", () => { expect(supportedProviderKinds).toEqual(suites.map((item) => item.kind)) }) -describeIfEnabled("real provider integration", () => { +describe("provider integration", () => { for (const item of activeSuites) { test(`${item.kind} detects and probes from root url`, async () => { - expect(await detect(item.url)).toBe(item.kind) + expect(await detect(item.url())).toBe(item.kind) - const result = await probe(item.url) + const result = await probe(item.url()) expect(result.kind).toBe(item.kind) - const model = item.modelID - ? result.models.find((entry) => entry.id === item.modelID) - : result.models[0] + const model = item.modelID ? result.models.find((entry) => entry.id === item.modelID) : result.models[0] expect(model).toBeDefined() expect(model!.context).toBeGreaterThan(0) @@ -68,20 +89,18 @@ describeIfEnabled("real provider integration", () => { }, 120_000) test(`${item.kind} reports the expected context length`, async () => { - const result = await probe(item.url, undefined, item.kind) + const result = await probe(item.url(), undefined, item.kind) - const model = item.modelID - ? result.models.find((entry) => entry.id === item.modelID) - : result.models[0] + const model = item.modelID ? result.models.find((entry) => entry.id === item.modelID) : result.models[0] expect(model).toBeDefined() expect(model!.context).toBe(item.expectedContext) }, 120_000) test(`${item.kind} detects and probes from /v1 url`, async () => { - expect(await detect(`${item.url}/v1`)).toBe(item.kind) + expect(await detect(`${item.url()}/v1`)).toBe(item.kind) - const result = await probe(`${item.url}/v1`) + const result = await probe(`${item.url()}/v1`) expect(result.kind).toBe(item.kind) if (item.modelID) { expect(result.models.some((entry) => entry.id === item.modelID)).toBe(true) @@ -91,7 +110,7 @@ describeIfEnabled("real provider integration", () => { }, 120_000) test(`${item.kind} probes when kind is supplied`, async () => { - const result = await probe(item.url, undefined, item.kind) + const result = await probe(item.url(), undefined, item.kind) expect(result.kind).toBe(item.kind) if (item.modelID) { expect(result.models.some((entry) => entry.id === item.modelID)).toBe(true)