diff --git a/.github/workflows/provider-tests.yml b/.github/workflows/provider-tests.yml index c0b2f42..4f08493 100644 --- a/.github/workflows/provider-tests.yml +++ b/.github/workflows/provider-tests.yml @@ -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 diff --git a/README.md b/README.md index 578df84..5e04057 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 `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 @@ -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" @@ -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 @@ -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 @@ -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 @@ -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: 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..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" }, @@ -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/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 069b480..a7bc3ac 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -9,7 +9,7 @@ import { OPENAI_COMPATIBLE_NPM, } from "./constants" import { - getCurrentProviderConfig, + getConfiguredTargets, getProviderApiKey, getProviderTargets, saveProviderTarget, @@ -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 @@ -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 _" @@ -90,7 +95,7 @@ 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" }, @@ -98,34 +103,34 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { { 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, } }, }, diff --git a/src/providers/index.ts b/src/providers/index.ts index 629a230..4d25736 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://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", +} + export const supportedProviderKinds = Object.keys(supportedProviders) as LocalProviderKind[] 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", + }, + }) +}) diff --git a/tests/docker/compose.providers.yml b/tests/docker/compose.providers.yml index 7e15353..a1752f2 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/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.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) 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"],