diff --git a/README.md b/README.md index db73a1a..5a12835 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,18 @@ It currently supports the following local backends: - llama-swap - oMLX - MLX-VLM +- Any other server with an OpenAI-compatible `/v1/models` endpoint (`openai`, used when nothing more specific matches) -Instead of creating one provider per server, this plugin keeps one `local` provider and lets you register multiple named targets. Each target is probed at runtime, and its currently loaded models are exposed automatically. +Instead of creating one provider per server, this plugin keeps one `local` provider and lets you register multiple named targets. Each target is probed at runtime, and its models are exposed automatically. ## Features - Adds a `local` provider to OpenCode -- Supports multiple local URLs under one provider +- Supports multiple servers under one provider - Includes supported default `127.0.0.1` targets automatically -- Detects loaded models at runtime +- Detects models at runtime and fills in context size, tool calling, and vision support where the server reports them - Routes each model to the correct target URL -- API key auth currently unsupported +- Optional API key per target, sent as `Authorization: Bearer ` - Uses OpenCode global config, not project-local config ## Example @@ -47,43 +48,49 @@ Default targets are enabled automatically for these backends and ports: - Exo: `http://127.0.0.1:52415` - llama-swap: `http://127.0.0.1:8080` - oMLX: `http://127.0.0.1:8000` -- MLX-VLM: `http://127.0.0.1:8000` +- MLX-VLM: `http://127.0.0.1:8080` -If your local providers do not need auth, you can start using the `local` provider immediately. +If your local servers run on these ports and do not need an API key, you can start using the `local` provider immediately. -**Note: oMLX requires API authentication to be disabled in the web app settings for the plugin to detect and probe models.** - -**Note: API key authentication is currently unsupported.** +**Note: oMLX with API authentication enabled needs a custom target with its API key set (see below).** ## Custom Targets -If you need non-default hosts or ports, use the CLI auth flow to add an explicit target: +Add a server on another host or port, or one that needs an API key, with **Add Custom Target**. Run it from the TUI with `/connect` → **Local LLM Provider**, or from the CLI: ```bash opencode auth login --provider local --method "Add Custom Target" ``` -This will prompt for: +It asks for: - a target ID, like `studio` or `remote-ollama` -- the local provider URL -- the API key (enter `none` since API keys are currently unsupported) +- the server URL, with or without `/v1` +- the server's API key. Leave it empty if the server has none. Enter `{env:VAR_NAME}` to store a reference to an environment variable instead of the key itself +- which models to list: only loaded models, or every model the server offers (useful for llama-swap, which loads models on demand) + +The plugin checks that the server answers before it saves anything, then adds the target to your OpenCode global config. Run it again to add more servers. -The target is then stored in OpenCode global config. +`/connect` only lists **Local LLM Provider** once the provider has at least one model. If none of your servers are reachable yet, add the first target from the CLI. -You can also add explicit targets manually in config if needed: +You can also add targets manually in config: ```json { "provider": { "local": { - "name": "Local Provider", "options": { "includeDefaults": true, "targets": { "studio": { "url": "http://192.168.1.10:1234/v1", "kind": "lmstudio" + }, + "swap": { + "url": "https://llama-swap.example.com/v1", + "kind": "llamaswap", + "apiKey": "{env:LLAMA_SWAP_API_KEY}", + "includeUnloaded": true } } } @@ -92,53 +99,34 @@ You can also add explicit targets manually in config if needed: } ``` -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 +Target fields: -The plugin stores explicit targets in OpenCode global config under the `local` provider: - -```json -{ - "provider": { - "local": { - "name": "Local Provider", - "options": { - "includeDefaults": true, - "targets": { - "studio": { - "url": "http://127.0.0.1:1234/v1", - "kind": "lmstudio" - } - } - } - } - } -} -``` +- `url` (required): the server URL +- `kind` (optional): one of `ollama`, `lmstudio`, `llamacpp`, `vllm`, `exo`, `llamaswap`, `omlx`, `mlxvlm`, `openai`. If you leave it out, the backend is detected. `openai` treats any server as a plain OpenAI-compatible endpoint +- `apiKey` (optional): sent as `Authorization: Bearer ` when probing the server and with every chat request to its models. Use `{env:VAR_NAME}` to keep the key out of the config file +- `includeUnloaded` (optional, llama-swap only): list every model llama-swap offers, not only the loaded ones. The plugin reads their details from `/v1/models` and does not load them -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. +Explicit targets override the built-in defaults when they use the same ID. A key set in `provider.local.options.apiKey` applies to every target that has no `apiKey` of its own. -**Note: API key authentication is currently unsupported.** +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. Set it to `false` if you only use custom targets. ## How Models Appear -Models are discovered dynamically from each configured target. (Only **loaded** models) +Models are discovered dynamically from each configured target. By default, only **loaded** models are listed. To avoid collisions, model IDs are prefixed with the target ID: - `ollama/llama3.2` - `studio/qwen2.5-coder` -Each generated model keeps its own target URL internally, so requests go to the correct backend. +Each generated model keeps its own target URL and API key internally, so requests go to the correct backend. ## Notes - 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` -- **API key authentication is currently unsupported** +- If a custom target cannot be probed, the reason (for example `HTTP 401` for a wrong key) is written to the OpenCode log ## Development diff --git a/bun.lock b/bun.lock index e688aa2..3a411d9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "opencode-local-provider", "dependencies": { - "@opencode-ai/plugin": "^1.14.46", - "@opencode-ai/sdk": "^1.14.46", + "@opencode-ai/plugin": "^1.18.31", + "@opencode-ai/sdk": "^1.18.31", "zod": "^4.4.3", }, "devDependencies": { @@ -18,21 +18,23 @@ }, }, "packages": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.46", "", { "dependencies": { "@opencode-ai/sdk": "1.14.46", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.6", "@opentui/keymap": ">=0.2.6", "@opentui/solid": ">=0.2.6" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-JXV0h/dMFGVmEvlKSrbkMeiiIafwWlAGTbAQ4KaK1Bn5yWCFx5DkYVcS3BvgebdJEPkQ4tjVKmG/LHjfZwbjkA=="], + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.46", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.31", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.31", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -46,21 +48,23 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], - "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "fast-check": ["fast-check@4.10.1", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], - "msgpackr": ["msgpackr@1.11.10", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA=="], + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], @@ -80,11 +84,11 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/package-lock.json b/package-lock.json index e864cf1..83c744b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "^1.14.46", - "@opencode-ai/sdk": "^1.14.46", + "@opencode-ai/plugin": "^1.18.31", + "@opencode-ai/sdk": "^1.18.31", "zod": "^4.4.3" }, "devDependencies": { @@ -20,10 +20,22 @@ "typescript": "^5.9.3" } }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -34,9 +46,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -47,9 +59,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -60,9 +72,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -73,9 +85,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -86,9 +98,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -99,19 +111,20 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.14.46", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.46.tgz", - "integrity": "sha512-JXV0h/dMFGVmEvlKSrbkMeiiIafwWlAGTbAQ4KaK1Bn5yWCFx5DkYVcS3BvgebdJEPkQ4tjVKmG/LHjfZwbjkA==", + "version": "1.18.31", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.31.tgz", + "integrity": "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.14.46", - "effect": "4.0.0-beta.59", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.31", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.6", - "@opentui/keymap": ">=0.2.6", - "@opentui/solid": ">=0.2.6" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -135,9 +148,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.14.46", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz", - "integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==", + "version": "1.18.31", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", + "integrity": "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -204,27 +217,27 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.59", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz", - "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==", + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", + "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", + "ini": "^7.0.0", "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", + "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" + "uuid": "^14.0.0", + "yaml": "^2.9.0" } }, "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.1.tgz", + "integrity": "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg==", "funding": [ { "type": "individual", @@ -250,12 +263,12 @@ "license": "MIT" }, "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/isexe": { @@ -264,6 +277,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", @@ -271,18 +290,18 @@ "license": "Apache-2.0" }, "node_modules/msgpackr": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", - "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", "license": "MIT", "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "msgpackr-extract": "^3.0.4" } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -293,18 +312,18 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", "license": "MIT" }, "node_modules/node-gyp-build-optional-packages": { @@ -332,9 +351,9 @@ } }, "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", "funding": [ { "type": "individual", @@ -369,9 +388,9 @@ } }, "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", "license": "MIT", "engines": { "node": ">=20" @@ -399,9 +418,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -427,9 +446,9 @@ } }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index a9ed283..d6837d2 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "typescript": "^5.9.3" }, "dependencies": { - "@opencode-ai/plugin": "^1.14.46", - "@opencode-ai/sdk": "^1.14.46", + "@opencode-ai/plugin": "^1.18.31", + "@opencode-ai/sdk": "^1.18.31", "zod": "^4.4.3" } } diff --git a/src/config.ts b/src/config.ts index 2cca4a9..326ef6c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,20 +32,19 @@ function createV2OpencodeClient(url: URL, input: PluginInput["client"]) { }) } -export function getAuthApiKey(auth?: { type: string; key?: string }) { - if (!auth || auth.type !== "api") return "" - return auth.key ?? "" -} - function parseTargetConfig(item: unknown) { if (typeof item === "string" && item) return { url: baseURL(item) } if (item && typeof item === "object") { const url = "url" in item ? item.url : undefined const kind = "kind" in item ? item.kind : undefined + const apiKey = "apiKey" in item ? item.apiKey : undefined + const includeUnloaded = "includeUnloaded" in item ? item.includeUnloaded : undefined if (typeof url === "string" && url) { return { url: baseURL(url), ...(typeof kind === "string" && KINDS.includes(kind as (typeof KINDS)[number]) ? { kind } : {}), + ...(typeof apiKey === "string" && apiKey ? { apiKey } : {}), + ...(includeUnloaded === true ? { includeUnloaded } : {}), } } } @@ -55,7 +54,7 @@ const defaults = Object.fromEntries( Object.entries(supportedProviderDefaultURLs).map(([id, url]) => [ id, { - url: baseURL(url), + url: baseURL(url!), kind: id as LocalTarget["kind"], }, ]), @@ -103,47 +102,42 @@ export function getProviderTargets(provider?: Pick) { } } -export function getProviderApiKey(provider?: Pick, auth?: { type: string; key?: string }) { +// A provider-wide `options.apiKey` is what OpenCode itself sends to every +// model of the provider, so probes fall back to it when a target has no key. +export function getProviderApiKey(provider?: Pick) { const val = provider?.options?.apiKey - if (typeof val === "string" && val) return val - return getAuthApiKey(auth) + return typeof val === "string" ? val : "" } -export async function getCurrentProviderConfig(url: URL, input: PluginInput["client"]) { - const cfg = await createV2OpencodeClient(url, input).global.config.get() - const provider = cfg.data?.provider?.[LOCAL_PROVIDER_ID] - return { - targets: getConfiguredTargets(provider as Pick | undefined), - key: typeof provider?.options?.apiKey === "string" ? provider.options.apiKey : "", - } +// Resolve `{env:VAR}` the same way OpenCode does when it loads config, for +// values that have not been through config loading yet (CLI prompt input). +export function resolveEnv(value: string) { + return value.replace(/\{env:([^}]+)\}/g, (_, name: string) => process.env[name] || "") } export async function saveProviderTarget( server: URL, input: PluginInput["client"], id: string, - url: string, - kind?: LocalTarget["kind"], - key?: string, + target: LocalTarget, ) { - const cur = await getCurrentProviderConfig(server, input) - const options: Record = { - targets: { - ...cur.targets, - [id]: { - url: baseURL(url), - ...(kind ? { kind } : {}), - }, - }, - } - - if (key !== undefined) options.apiKey = key - + // The global config update deep-merges into the config file, so only the + // new target is sent. Re-sending the others would replace any `{env:VAR}` + // references in them with the resolved values. await createV2OpencodeClient(server, input).global.config.update({ config: { provider: { [LOCAL_PROVIDER_ID]: { - options, + options: { + targets: { + [id]: { + url: baseURL(target.url), + ...(target.kind ? { kind: target.kind } : {}), + ...(target.apiKey ? { apiKey: target.apiKey } : {}), + includeUnloaded: target.includeUnloaded === true, + }, + }, + }, }, }, }, diff --git a/src/models.ts b/src/models.ts index f28c914..a182c84 100644 --- a/src/models.ts +++ b/src/models.ts @@ -2,7 +2,7 @@ import type { Model } from "@opencode-ai/sdk/v2" import { DEFAULT_CONTEXT, DEFAULT_OUTPUT, OPENAI_COMPATIBLE_NPM } from "./constants" import type { LocalModel } from "./types" -import { baseURL } from "./url" +import { authHeaders, baseURL } from "./url" type Modality = "text" | "audio" | "image" | "video" | "pdf" @@ -33,8 +33,8 @@ function id(target: string, model: string) { return `${target}/${model}` } -function name(target: string, model: string) { - return `${model} (${target})` +function name(target: string, model: LocalModel) { + return `${model.name ?? model.id} (${target})` } function modalities(model: LocalModel): { input: Modality[]; output: Modality[] } { @@ -57,7 +57,7 @@ function item(providerID: string, target: string, url: string, model: LocalModel url: baseURL(url), npm: prev?.api.npm ?? OPENAI_COMPATIBLE_NPM, }, - name: prev?.name ?? name(target, model.id), + name: prev?.name ?? name(target, model), family: prev?.family ?? "", capabilities: { temperature: prev?.capabilities.temperature ?? true, @@ -100,11 +100,11 @@ function item(providerID: string, target: string, url: string, model: LocalModel } } -function configItem(target: string, url: string, model: LocalModel, prev?: ConfigModel) { +function configItem(target: string, url: string, model: LocalModel, prev?: ConfigModel, apiKey?: string) { const attach = model.vision || prev?.attachment || false return { id: model.id, - name: prev?.name ?? name(target, model.id), + name: prev?.name ?? name(target, model), family: prev?.family || undefined, release_date: prev?.release_date || undefined, attachment: attach, @@ -124,7 +124,14 @@ function configItem(target: string, url: string, model: LocalModel, prev?: Confi ...prev?.options, target, }, - headers: prev?.headers ?? {}, + // OpenAI-compatible SDKs send model headers on every request, which is how + // each target gets its own key under the single `local` provider. The + // target key goes last so a header injected by an earlier run can't + // outlive a key change. + headers: { + ...prev?.headers, + ...authHeaders(apiKey), + }, provider: { npm: prev?.provider?.npm ?? OPENAI_COMPATIBLE_NPM, api: baseURL(url), @@ -141,11 +148,17 @@ export function build(providerID: string, target: string, url: string, list: Loc ) } -export function buildConfig(target: string, url: string, list: LocalModel[], prev: Record) { +export function buildConfig( + target: string, + url: string, + list: LocalModel[], + prev: Record, + apiKey?: string, +) { return Object.fromEntries( list.map((model) => { const key = id(target, model.id) - return [key, configItem(target, url, model, prev[key])] + return [key, configItem(target, url, model, prev[key], apiKey)] }), ) } diff --git a/src/plugin.ts b/src/plugin.ts index ea0fbaa..a9ca195 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,28 +10,42 @@ import { } from "./constants" import { getConfiguredTargets, + getProviderApiKey, getProviderTargets, + resolveEnv, saveProviderTarget, } from "./config" import { buildConfig } from "./models" import { supportedProviderKinds } from "./providers" -import { detect, probe } from "./probe" +import { probe } from "./probe" +import type { LocalTarget } from "./types" import { trimURL } from "./url" function validID(value: string) { return /^[a-z0-9][a-z0-9-_]*$/.test(value) } -async function probeModels(provider?: Pick) { +function errorMessage(e: unknown) { + return e instanceof Error ? e.message : String(e) +} + +async function probeModels( + provider?: Pick, + onError?: (target: string, e: unknown) => Promise, +) { const list = getProviderTargets(provider) if (!Object.keys(list).length) return {} + const sharedKey = getProviderApiKey(provider) const all = await Promise.all( Object.entries(list).map(async ([id, item]) => { try { - const found = await probe(item.url, item.kind) - return buildConfig(id, item.url, found.models, (provider?.models ?? {}) as Record) - } catch { + const found = await probe(item.url, item.kind, { ...item, apiKey: item.apiKey || sharedKey }) + // OpenAI-compatible SDKs already send the provider-wide key, so only a + // target's own key needs to be added to its models. + return buildConfig(id, item.url, found.models, (provider?.models ?? {}) as Record, item.apiKey) + } catch (e) { + await onError?.(id, e) return {} } }), @@ -41,13 +55,16 @@ async function probeModels(provider?: Pick) { } export const LocalProviderPlugin: Plugin = async (ctx) => { - await ctx.client.app.log({ - body: { - service: LOCAL_PLUGIN_SERVICE, - level: "info", - message: `Local Provider plugin loaded v${pkg.version}. Supported backends: ${supportedProviderKinds.join(", ")}`, - }, - }) + const log = (level: "info" | "warn" | "error", message: string) => + ctx.client.app.log({ + body: { + service: LOCAL_PLUGIN_SERVICE, + level, + message, + }, + }) + + await log("info", `Local Provider plugin loaded v${pkg.version}. Supported backends: ${supportedProviderKinds.join(", ")}`) return { config: async (cfg) => { @@ -65,17 +82,29 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { name: provider.name ?? LOCAL_PROVIDER_NAME, npm: provider.npm ?? OPENAI_COMPATIBLE_NPM, options, - models: await probeModels({ - options, - models: (provider as Provider).models, - }), + models: await probeModels( + { + options, + models: (provider as Provider).models, + }, + async (target, e) => { + // Built-in default targets are expected to be offline most of the time. + if (!list[target]) return + await log("warn", `Target "${target}" probe failed: ${errorMessage(e)}`) + }, + ), } }, auth: { provider: LOCAL_PROVIDER_ID, methods: [ { - type: "api", + // "oauth" is the only method type OpenCode passes to the plugin from + // both /connect and `opencode auth login`. /connect saves an "api" + // method's answers without calling the plugin, and the CLI adds an + // API key prompt the plugin never sees. "auto" finishes without a + // browser step. + type: "oauth", label: "Add Custom Target", prompts: [ { @@ -91,40 +120,63 @@ export const LocalProviderPlugin: Plugin = async (ctx) => { { type: "text", key: "baseURL", - message: "Enter your local provider URL", + message: "Enter the server URL", placeholder: "http://192.168.1.10:1234", validate(value) { if (!trimURL(value ?? "")) return "URL is required" }, }, + { + type: "text", + key: "apiKey", + message: "Enter the server's API key, or {env:VAR} to read it from the environment. Leave empty if it has none", + placeholder: "no key", + }, + { + type: "select", + key: "models", + message: "Which models should be listed?", + options: [ + { label: "Loaded models only", value: "loaded" }, + { label: "All models the server offers", value: "all", hint: "llama-swap loads them on demand" }, + ], + }, ], async authorize(input = {}) { const id = input.target?.trim() ?? "" - const raw = trimURL(input.baseURL ?? "") - try { - if (!id || !validID(id) || !raw) { - throw new Error("Invalid target ID or URL") - } - - const result = await probe(raw) - const kind = result.kind - await saveProviderTarget(ctx.serverUrl, ctx.client, id, raw, kind) + const url = trimURL(input.baseURL ?? "") + const entered = input.apiKey?.trim() ?? "" + const apiKey = entered.toLowerCase() === "none" ? "" : entered + const includeUnloaded = input.models === "all" - return { - type: "success" as const, - provider: LOCAL_PROVIDER_ID, - key: "", - } + let target: LocalTarget + try { + if (!validID(id)) throw new Error("Target ID must use lowercase letters, numbers, - or _") + if (!url) throw new Error("URL is required") + // Probe before saving so a wrong URL or key fails right away. + const found = await probe(url, undefined, { apiKey: resolveEnv(apiKey), includeUnloaded }) + target = { url, kind: found.kind, apiKey, includeUnloaded } } 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 } + await log("error", `Add Custom Target failed: ${errorMessage(e)}`) + throw e + } + + return { + url: "", + method: "auto" as const, + instructions: `Found ${target.kind} at ${url}. Saving target "${id}".`, + // Saving config restarts OpenCode's instances. Doing it here + // rather than in authorize() keeps /connect's pending login + // alive until this callback has been looked up. + async callback() { + try { + await saveProviderTarget(ctx.serverUrl, ctx.client, id, target) + return { type: "success" as const, provider: LOCAL_PROVIDER_ID, key: "" } + } catch (e) { + await log("error", `Saving target "${id}" failed: ${errorMessage(e)}`) + return { type: "failed" as const } + } + }, } }, }, diff --git a/src/probe.ts b/src/probe.ts index 793ea8a..95ff064 100644 --- a/src/probe.ts +++ b/src/probe.ts @@ -1,23 +1,48 @@ -import type { LocalProviderKind } from "./types" +import type { LocalProviderKind, LocalTarget } from "./types" import { supportedProviders, supportedProviderKinds } from "./providers" -import { rootURL } from "./url" +import { authHeaders, rootURL } from "./url" -export async function detect(url: string): Promise { +export type ProbeTarget = Pick + +export async function detect(url: string, apiKey?: string): Promise { const root = rootURL(url) + const headers = authHeaders(apiKey) for (const kind of supportedProviderKinds) { - if (await supportedProviders[kind].detect(root)) return kind + if (await supportedProviders[kind].detect(root, headers)) return kind } return null } -export async function probe(url: string, kind?: LocalProviderKind) { +// Every detector swallows errors, so a rejected key looks like an unknown +// server. Check once more to tell the two apart in the error. +async function notDetected(url: string, root: string, headers: Record) { + const status = await fetch(root + "/v1/models", { + headers, + signal: AbortSignal.timeout(1000), + }).then( + (res) => res.status, + () => undefined, + ) + if (status === 401 || status === 403) { + return new Error(`${url} rejected the request (HTTP ${status}), check the target's API key`) + } + return new Error(`No supported local provider detected at: ${url}`) +} + +export async function probe(url: string, kind?: LocalProviderKind, target: ProbeTarget = {}) { const root = rootURL(url) - const detected = await detect(root) + const headers = authHeaders(target.apiKey) + // Every backend also matches the generic "openai" kind, so asking for it + // explicitly skips detection of the specific backends. + const detected = + kind === "openai" + ? (await supportedProviders.openai.detect(root, headers)) ? kind : null + : await detect(root, target.apiKey) - if (!detected) throw new Error(`No supported local provider detected at: ${url}`) + if (!detected) throw await notDetected(url, root, headers) if (kind && detected !== kind) { throw new Error(`Expected ${kind} at ${url} but detected ${detected}`) @@ -25,6 +50,6 @@ export async function probe(url: string, kind?: LocalProviderKind) { return { kind: detected, - models: await supportedProviders[detected].probe(root), + models: await supportedProviders[detected].probe(root, headers, { url, ...target }), } } diff --git a/src/providers/exo.ts b/src/providers/exo.ts index 79cef67..9eb77e0 100644 --- a/src/providers/exo.ts +++ b/src/providers/exo.ts @@ -54,9 +54,10 @@ const ModelsResponseSchema = z.object({ .optional(), }) -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url + "/v1/models", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return false @@ -73,8 +74,9 @@ async function detect(url: string) { } } -async function probe(url: string): Promise { +async function probe(url: string, headers?: Record): Promise { const res = await fetch(url + "/state", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) throw new Error(`Exo probe failed: ${res.status}`) diff --git a/src/providers/index.ts b/src/providers/index.ts index 85afb4b..2cc4334 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ import lmstudio from "./lmstudio" import mlxvlm from "./mlxvlm" import ollama from "./ollama" import omlx from "./omlx" +import openai from "./openai" import type { ProviderMap } from "./shared" import vllm from "./vllm" @@ -19,9 +20,12 @@ export const supportedProviders: ProviderMap = { llamaswap, omlx, mlxvlm, + openai, } -export const supportedProviderDefaultURLs: Record = { +// The generic OpenAI-compatible fallback has no well-known port, so it is +// only used for explicit targets. +export const supportedProviderDefaultURLs: Partial> = { ollama: "http://127.0.0.1:11434", lmstudio: "http://127.0.0.1:1234", llamacpp: "http://127.0.0.1:8080", diff --git a/src/providers/llamacpp.ts b/src/providers/llamacpp.ts index b4d9ab9..710587a 100644 --- a/src/providers/llamacpp.ts +++ b/src/providers/llamacpp.ts @@ -21,9 +21,10 @@ const ModelsResponseSchema = z.object({ .optional(), }) -export async function runtimeContext(url: string) { +export async function runtimeContext(url: string, headers?: Record) { try { const propsRes = await fetch(url + "/props", { + headers, signal: AbortSignal.timeout(1000), }) if (propsRes.ok) { @@ -36,6 +37,7 @@ export async function runtimeContext(url: string) { try { const slotsRes = await fetch(url + "/slots", { + headers, signal: AbortSignal.timeout(1000), }) if (slotsRes.ok) { @@ -50,9 +52,10 @@ export async function runtimeContext(url: string) { return null } -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url, { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return false @@ -62,9 +65,10 @@ async function detect(url: string) { } } -async function probe(url: string): Promise { - const loadedContext = await runtimeContext(url) +async function probe(url: string, headers?: Record): Promise { + const loadedContext = await runtimeContext(url, headers) const res = await fetch(url + "/v1/models", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) throw new Error(`llama.cpp probe failed: ${res.status}`) diff --git a/src/providers/llamaswap.ts b/src/providers/llamaswap.ts index 7bfae76..f20265d 100644 --- a/src/providers/llamaswap.ts +++ b/src/providers/llamaswap.ts @@ -1,18 +1,8 @@ import { z } from "zod" -import type { LocalModel } from "../types" +import type { LocalModel, LocalTarget } from "../types" import type { ProviderImpl } from "./shared" import { runtimeContext } from "./llamacpp" - -const ModelsResponseSchema = z.object({ - data: z - .array( - z.object({ - id: z.string(), - owned_by: z.string().optional(), - }), - ) - .optional(), -}) +import { listModels, modelFromListing } from "./openai" const RunningResponseSchema = z.object({ running: z @@ -25,9 +15,10 @@ const RunningResponseSchema = z.object({ .optional(), }) -export async function runningModels(url: string) { +export async function runningModels(url: string, headers?: Record) { try { const res = await fetch(url + "/running", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return new Set() @@ -42,43 +33,30 @@ export async function runningModels(url: string) { } } -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { // llama-swap exposes /v1/models for all configured models regardless of load state. // We check owned_by to distinguish it from other OpenAI-compatible proxies. - const res = await fetch(url + "/v1/models", { - signal: AbortSignal.timeout(1000), - }) - if (!res.ok) return false - const parsed = ModelsResponseSchema.safeParse(await res.json()) - if (!parsed.success) return false - const data = parsed.data.data - if (!data) return false + const data = await listModels(url, headers) return data.some((item) => item.owned_by === "llama-swap") } catch { return false } } -async function probe(url: string): Promise { - const res = await fetch(url + "/v1/models", { - signal: AbortSignal.timeout(1000), - }) - if (!res.ok) throw new Error(`llama-swap probe failed: ${res.status}`) - const body = ModelsResponseSchema.parse(await res.json()) - if (!body.data) throw new Error("llama-swap probe failed: no data field") - const loadedModels = await runningModels(url) +async function probe(url: string, headers?: Record, target?: LocalTarget): Promise { + const listed = await listModels(url, headers) + const loadedModels = await runningModels(url, headers) + const models = target?.includeUnloaded ? listed : listed.filter((item) => loadedModels.has(item.id)) return Promise.all( - body.data.filter((item) => loadedModels.has(item.id)).map(async (item) => { - const context = (await runtimeContext(`${url}/upstream/${item.id}`)) ?? 0 - - return { - id: item.id, - context, - toolcall: false, - vision: false, - } + models.map(async (item) => { + const model = modelFromListing(item) + // Querying /upstream makes llama-swap load the model, so only running + // models get their live context size. The rest keep the listing metadata. + if (!loadedModels.has(item.id)) return model + const context = await runtimeContext(`${url}/upstream/${item.id}`, headers) + return context ? { ...model, context } : model }), ) } diff --git a/src/providers/lmstudio.ts b/src/providers/lmstudio.ts index 767852c..a1c2e65 100644 --- a/src/providers/lmstudio.ts +++ b/src/providers/lmstudio.ts @@ -18,9 +18,10 @@ const ModelsResponseSchema = z.object({ data: z.array(ModelItemSchema).optional(), }) -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url + "/lmstudio-greeting", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return false @@ -32,8 +33,9 @@ async function detect(url: string) { } } -async function probe(url: string): Promise { +async function probe(url: string, headers?: Record): Promise { const res = await fetch(url + "/api/v0/models", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) throw new Error(`LM Studio probe failed: ${res.status}`) diff --git a/src/providers/mlxvlm.ts b/src/providers/mlxvlm.ts index 6cafcde..7cd761b 100644 --- a/src/providers/mlxvlm.ts +++ b/src/providers/mlxvlm.ts @@ -9,8 +9,9 @@ const HealthSchema = z.object({ loaded_tool_parser: z.string().nullable().optional(), }) -async function runtimeHealth(url: string, timeout = 1000) { +async function runtimeHealth(url: string, headers?: Record, timeout = 1000) { const res = await fetch(url + "/health", { + headers, signal: AbortSignal.timeout(timeout), }) @@ -21,9 +22,10 @@ async function runtimeHealth(url: string, timeout = 1000) { return HealthSchema.parse(await res.json()) } -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url + "/health", { + headers, signal: AbortSignal.timeout(1000), }) @@ -37,8 +39,8 @@ async function detect(url: string) { } } -async function probe(url: string): Promise { - const health = await runtimeHealth(url) +async function probe(url: string, headers?: Record): Promise { + const health = await runtimeHealth(url, headers) if (!health) { throw new Error("MLX-VLM probe failed: health endpoint unavailable") } diff --git a/src/providers/ollama.ts b/src/providers/ollama.ts index f17c442..178b0ee 100644 --- a/src/providers/ollama.ts +++ b/src/providers/ollama.ts @@ -18,9 +18,10 @@ const ShowResponseSchema = z.object({ capabilities: z.array(z.string()).optional(), }) -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url, { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return false @@ -30,11 +31,12 @@ async function detect(url: string) { } } -async function show(url: string, model: string) { +async function show(url: string, model: string, headers?: Record) { try { const res = await fetch(url + "/api/show", { method: "POST", headers: { + ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ model }), @@ -47,8 +49,9 @@ async function show(url: string, model: string) { } } -async function probe(url: string): Promise { +async function probe(url: string, headers?: Record): Promise { const res = await fetch(url + "/api/ps", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) throw new Error(`Ollama probe failed: ${res.status}`) @@ -57,7 +60,7 @@ async function probe(url: string): Promise { return Promise.all( body.models.map(async (item) => { - const extra = await show(url, item.model) + const extra = await show(url, item.model, headers) return { id: item.name, context: item.context_length, diff --git a/src/providers/omlx.ts b/src/providers/omlx.ts index b0c30bc..c942e5c 100644 --- a/src/providers/omlx.ts +++ b/src/providers/omlx.ts @@ -23,9 +23,10 @@ const StatusResponseSchema = z.object({ ), }) -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url + "/health", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return false @@ -36,8 +37,9 @@ async function detect(url: string) { } } -async function probe(url: string): Promise { +async function probe(url: string, headers?: Record): Promise { const res = await fetch(url + "/v1/models/status", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) throw new Error(`oMLX probe failed: ${res.status}`) diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..20ae7eb --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,100 @@ +import { z } from "zod" +import type { LocalModel } from "../types" +import type { ProviderImpl } from "./shared" + +// Servers disagree on where (and whether) they put model metadata in +// /v1/models, so only `id` is required and everything else is read leniently. +const ModelItemSchema = z.object({ + id: z.string(), + owned_by: z.unknown().optional(), + name: z.unknown().optional(), + context_length: z.unknown().optional(), + context_window: z.unknown().optional(), + max_context_length: z.unknown().optional(), + max_model_len: z.unknown().optional(), + capabilities: z.unknown().optional(), + supported_parameters: z.unknown().optional(), + architecture: z.unknown().optional(), + meta: z.unknown().optional(), +}) + +export const ModelsResponseSchema = z.object({ + data: z.array(ModelItemSchema), +}) + +export type ModelItem = z.infer + +function number(value: unknown) { + return typeof value === "number" && value > 0 ? value : undefined +} + +function field(value: unknown, key: string) { + return value && typeof value === "object" ? (value as Record)[key] : undefined +} + +function strings(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] +} + +// Capabilities come as a list (["tools", "vision"]) or a flag map ({ vision: true }). +function capabilityList(value: unknown) { + if (Array.isArray(value)) return strings(value) + if (value && typeof value === "object") { + return Object.entries(value) + .filter(([, enabled]) => enabled === true) + .map(([key]) => key) + } + return [] +} + +export function modelFromListing(item: ModelItem): LocalModel { + const capabilities = capabilityList(item.capabilities) + const name = typeof item.name === "string" ? item.name.trim() : "" + return { + id: item.id, + ...(name && name !== item.id ? { name } : {}), + context: + number(item.context_length) ?? + number(item.context_window) ?? + number(item.max_context_length) ?? + number(item.max_model_len) ?? + number(field(item.meta, "n_ctx")) ?? + number(field(item.meta, "n_ctx_train")) ?? + 0, + toolcall: + capabilities.some((item) => ["tools", "tool_use", "function_calling"].includes(item)) || + strings(item.supported_parameters).includes("tools"), + vision: + capabilities.includes("vision") || + strings(field(item.architecture, "input_modalities")).includes("image"), + } +} + +export async function listModels(url: string, headers?: Record) { + const res = await fetch(url + "/v1/models", { + headers, + signal: AbortSignal.timeout(1000), + }) + if (!res.ok) throw new Error(`OpenAI-compatible probe failed: ${res.status}`) + return ModelsResponseSchema.parse(await res.json()).data +} + +async function detect(url: string, headers?: Record) { + try { + await listModels(url, headers) + return true + } catch { + return false + } +} + +async function probe(url: string, headers?: Record): Promise { + return (await listModels(url, headers)).map(modelFromListing) +} + +const openai: ProviderImpl = { + detect, + probe, +} + +export default openai diff --git a/src/providers/shared.ts b/src/providers/shared.ts index d95551b..82f84a2 100644 --- a/src/providers/shared.ts +++ b/src/providers/shared.ts @@ -1,8 +1,8 @@ -import type { LocalModel, LocalProviderKind } from "../types" +import type { LocalModel, LocalProviderKind, LocalTarget } from "../types" export type ProviderImpl = { - detect(url: string): Promise - probe(url: string): Promise + detect(url: string, headers?: Record): Promise + probe(url: string, headers?: Record, target?: LocalTarget): Promise } export type ProviderMap = Record diff --git a/src/providers/vllm.ts b/src/providers/vllm.ts index 9e721a9..898bb12 100644 --- a/src/providers/vllm.ts +++ b/src/providers/vllm.ts @@ -14,9 +14,10 @@ const ModelsResponseSchema = z.object({ .optional(), }) -async function detect(url: string) { +async function detect(url: string, headers?: Record) { try { const res = await fetch(url + "/v1/models", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) return false @@ -31,8 +32,9 @@ async function detect(url: string) { } } -async function probe(url: string): Promise { +async function probe(url: string, headers?: Record): Promise { const res = await fetch(url + "/v1/models", { + headers, signal: AbortSignal.timeout(1000), }) if (!res.ok) throw new Error(`vLLM probe failed: ${res.status}`) diff --git a/src/types.ts b/src/types.ts index 5a0f645..ca30cb9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,12 @@ -export const KINDS = ["ollama", "lmstudio", "llamacpp", "vllm", "exo", "llamaswap", "omlx", "mlxvlm"] as const +// Detection runs in this order. "openai" matches any server with a /v1/models +// listing, so it must stay last. +export const KINDS = ["ollama", "lmstudio", "llamacpp", "vllm", "exo", "llamaswap", "omlx", "mlxvlm", "openai"] as const export type LocalProviderKind = (typeof KINDS)[number] export type LocalModel = { id: string + name?: string context: number toolcall: boolean vision: boolean @@ -12,4 +15,7 @@ export type LocalModel = { export type LocalTarget = { url: string kind?: LocalProviderKind + apiKey?: string + // llama-swap: also list models that are not loaded yet + includeUnloaded?: boolean } diff --git a/src/url.ts b/src/url.ts index 423335f..422590e 100644 --- a/src/url.ts +++ b/src/url.ts @@ -13,3 +13,7 @@ export function rootURL(url: string) { if (!next) return "" return next.endsWith("/v1") ? next.slice(0, -3) : next } + +export function authHeaders(apiKey?: string): Record { + return apiKey ? { Authorization: `Bearer ${apiKey}` } : {} +} diff --git a/tests/providers.test.ts b/tests/providers.test.ts index 3e47061..32e6c56 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -94,8 +94,11 @@ afterAll(() => { compose?.down() }, 120_000) +// "openai" is the generic fallback that every suite server also satisfies. +const backendKinds = supportedProviderKinds.filter((kind) => kind !== "openai") + test("supported providers list stays in sync", () => { - expect(supportedProviderKinds).toEqual(suites.map((item) => item.kind)) + expect(backendKinds).toEqual(suites.map((item) => item.kind)) }) describe("provider integration", () => { @@ -115,7 +118,7 @@ describe("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(), item.kind) const model = item.modelID ? result.models.find((entry) => entry.id === item.modelID) : result.models[0] @@ -135,8 +138,16 @@ describe("provider integration", () => { } }, 120_000) + test(`${item.kind} also probes as a generic OpenAI-compatible server`, async () => { + const result = await probe(item.url(), "openai") + expect(result.kind).toBe("openai") + if (item.modelID) { + expect(result.models.some((entry) => entry.id === item.modelID)).toBe(true) + } + }, 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(), item.kind) expect(result.kind).toBe(item.kind) if (item.modelID) { expect(result.models.some((entry) => entry.id === item.modelID)).toBe(true) @@ -151,7 +162,7 @@ describe("provider integration", () => { for (const [otherKind, otherProvider] of Object.entries(supportedProviders)) { if (otherKind === item.kind) continue const otherDefaultURL = supportedProviderDefaultURLs[otherKind as LocalProviderKind] - if (String(new URL(otherDefaultURL).port) !== String(item.port)) continue + if (!otherDefaultURL || String(new URL(otherDefaultURL).port) !== String(item.port)) continue expect(await otherProvider.detect(rootURL(item.url()))).toBe(false) } @@ -160,10 +171,10 @@ describe("provider integration", () => { test("probe with explicit kind rejects mismatched server", async () => { for (const item of activeSuites) { - for (const otherKind of supportedProviderKinds) { + for (const otherKind of backendKinds) { if (otherKind === item.kind) continue const otherDefaultURL = supportedProviderDefaultURLs[otherKind] - if (String(new URL(otherDefaultURL).port) !== String(item.port)) continue + if (!otherDefaultURL || String(new URL(otherDefaultURL).port) !== String(item.port)) continue await expect(probe(item.url(), otherKind)).rejects.toThrow() } diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 23c5271..14cfb07 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -36,6 +36,35 @@ test("keeps configured targets explicit-only", () => { }) }) +test("keeps a non-empty target api key", () => { + const targets = getConfiguredTargets({ + options: { + targets: { + swap: { + url: "https://swap.example.com", + kind: "llamaswap", + apiKey: "secret", + }, + open: { + url: "http://192.168.1.10:1234", + apiKey: "", + }, + }, + }, + }) + + expect(targets).toEqual({ + swap: { + url: "https://swap.example.com/v1", + kind: "llamaswap", + apiKey: "secret", + }, + open: { + url: "http://192.168.1.10:1234/v1", + }, + }) +}) + test("configured targets override defaults and suppress duplicate urls", () => { const targets = getProviderTargets({ options: { diff --git a/tests/unit/llamaswap.test.ts b/tests/unit/llamaswap.test.ts index 350a4a4..a780223 100644 --- a/tests/unit/llamaswap.test.ts +++ b/tests/unit/llamaswap.test.ts @@ -67,4 +67,97 @@ describe("llama-swap provider", () => { globalThis.fetch = originalFetch } }) + + test("lists unloaded models from /v1/models without loading them", async () => { + const calls: string[] = [] + const originalFetch = globalThis.fetch + + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push(url) + + if (url === "http://llamaswap.test/v1/models") { + return Response.json({ + data: [ + { id: "loaded", owned_by: "llama-swap", context_length: 4096 }, + { + id: "idle", + owned_by: "llama-swap", + name: "Idle Model", + context_length: 32768, + capabilities: { vision: true, function_calling: true }, + supported_parameters: ["tools", "tool_choice"], + }, + ], + }) + } + + if (url === "http://llamaswap.test/running") { + return Response.json({ running: [{ model: "loaded", state: "ready" }] }) + } + + if (url === "http://llamaswap.test/upstream/loaded/props") { + return Response.json({ default_generation_settings: { n_ctx: 2048 } }) + } + + return new Response(null, { status: 404 }) + }) as unknown as typeof fetch + + try { + const result = await probe("http://llamaswap.test", "llamaswap", { includeUnloaded: true }) + + expect(result.models).toEqual([ + { id: "loaded", context: 2048, toolcall: false, vision: false }, + { id: "idle", name: "Idle Model", context: 32768, toolcall: true, vision: true }, + ]) + expect(calls.some((url) => url.includes("/upstream/idle"))).toBe(false) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("sends the target API key on every probe request", async () => { + const calls: string[] = [] + const originalFetch = globalThis.fetch + + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push(url) + + if (new Headers(init?.headers).get("Authorization") !== "Bearer secret") { + return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }) + } + + if (url === "http://llamaswap.test/v1/models") { + return Response.json({ data: [{ id: "loaded", owned_by: "llama-swap" }] }) + } + + if (url === "http://llamaswap.test/running") { + return Response.json({ running: [{ model: "loaded", state: "ready" }] }) + } + + if (url === "http://llamaswap.test/upstream/loaded/props") { + return Response.json({ default_generation_settings: { n_ctx: 8192 } }) + } + + return new Response(null, { status: 404 }) + }) as unknown as typeof fetch + + try { + await expect(probe("http://llamaswap.test")).rejects.toThrow( + "http://llamaswap.test rejected the request (HTTP 401), check the target's API key", + ) + + const result = await probe("http://llamaswap.test", "llamaswap", { apiKey: "secret" }) + + expect(result.kind).toBe("llamaswap") + expect(result.models).toEqual([ + { id: "loaded", context: 8192, toolcall: false, vision: false }, + ]) + expect(calls).toContain("http://llamaswap.test/running") + expect(calls).toContain("http://llamaswap.test/upstream/loaded/props") + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/tests/unit/openai.test.ts b/tests/unit/openai.test.ts new file mode 100644 index 0000000..3872965 --- /dev/null +++ b/tests/unit/openai.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, mock, test } from "bun:test" + +import { probe } from "../../src/probe" +import { modelFromListing } from "../../src/providers/openai" + +function mockFetch(routes: Record, calls: string[] = []) { + return mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push(url) + if (url in routes) return Response.json(routes[url]) + return new Response(null, { status: 404 }) + }) as unknown as typeof fetch +} + +describe("OpenAI-compatible model listing", () => { + test("reads llama-swap style metadata", () => { + expect( + modelFromListing({ + id: "qwen", + name: "Qwen 3.6", + context_length: 65536, + capabilities: { vision: true, function_calling: true }, + supported_parameters: ["tools", "tool_choice"], + architecture: { input_modalities: ["text", "image"] }, + }), + ).toEqual({ id: "qwen", name: "Qwen 3.6", context: 65536, toolcall: true, vision: true }) + }) + + test("reads list capabilities and nested context fields", () => { + expect( + modelFromListing({ + id: "gemma", + capabilities: ["tools"], + meta: { n_ctx_train: 8192 }, + }), + ).toEqual({ id: "gemma", context: 8192, toolcall: true, vision: false }) + expect(modelFromListing({ id: "vllm-model", max_model_len: 4096 }).context).toBe(4096) + }) + + test("ignores null or malformed metadata", () => { + expect( + modelFromListing({ + id: "plain", + name: null, + context_length: null, + capabilities: "tools", + meta: null, + }), + ).toEqual({ id: "plain", context: 0, toolcall: false, vision: false }) + }) +}) + +describe("OpenAI-compatible fallback", () => { + test("detects servers that only expose /v1/models", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = mockFetch({ + "http://proxy.test/v1/models": { + object: "list", + data: [{ id: "gpt-oss", object: "model", owned_by: "someone", context_window: 131072 }], + }, + }) + + try { + const result = await probe("http://proxy.test/v1") + expect(result.kind).toBe("openai") + expect(result.models).toEqual([{ id: "gpt-oss", context: 131072, toolcall: false, vision: false }]) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("explicit openai kind skips backend detection", async () => { + const calls: string[] = [] + const originalFetch = globalThis.fetch + globalThis.fetch = mockFetch( + { + "http://llamaswap.test/v1/models": { data: [{ id: "idle", owned_by: "llama-swap" }] }, + }, + calls, + ) + + try { + const result = await probe("http://llamaswap.test", "openai") + expect(result.kind).toBe("openai") + expect(result.models.map((item) => item.id)).toEqual(["idle"]) + expect(calls.every((url) => url === "http://llamaswap.test/v1/models")).toBe(true) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("still rejects servers without a model listing", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = mockFetch({}) + + try { + await expect(probe("http://nothing.test")).rejects.toThrow("No supported local provider detected") + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts new file mode 100644 index 0000000..067eccc --- /dev/null +++ b/tests/unit/plugin.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, mock, test } from "bun:test" +import type { Config, PluginInput } from "@opencode-ai/plugin" + +import { saveProviderTarget } from "../../src/config" +import { LocalProviderPlugin } from "../../src/plugin" + +function fakeClient(fetchImpl?: typeof fetch) { + const logs: { level: string; message: string }[] = [] + const client = { + app: { + log: async ({ body }: { body: { level: string; message: string } }) => { + logs.push(body) + }, + }, + _client: { getConfig: () => ({ fetch: fetchImpl }) }, + } + return { client: client as unknown as PluginInput["client"], logs } +} + +function swapServer(key: string) { + return mock(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (new Headers(init?.headers).get("Authorization") !== `Bearer ${key}`) { + return new Response(null, { status: 401 }) + } + if (url === "https://swap.example.com/v1/models") { + return Response.json({ + data: [{ id: "qwen", owned_by: "llama-swap", name: "Qwen", context_length: 65536 }], + }) + } + if (url === "https://swap.example.com/running") return Response.json({ running: [] }) + return new Response(null, { status: 404 }) + }) as unknown as typeof fetch +} + +describe("config hook", () => { + test("probes keyed targets and sends the key with their models", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = swapServer("secret") + const { client } = fakeClient() + + try { + const hooks = await LocalProviderPlugin({ client, serverUrl: new URL("http://opencode.test") } as PluginInput) + const cfg: Config = { + provider: { + local: { + options: { + includeDefaults: false, + targets: { + swap: { url: "https://swap.example.com", apiKey: "secret", includeUnloaded: true }, + }, + }, + }, + }, + } + + await hooks.config!(cfg) + + const model = cfg.provider!.local!.models!["swap/qwen"]! + expect(model.name).toBe("Qwen (swap)") + expect(model.limit).toEqual({ context: 65536, output: 8192 }) + expect(model.headers).toEqual({ Authorization: "Bearer secret" }) + // `provider.api` is read by OpenCode at runtime but missing from the SDK config type. + expect(model.provider as unknown).toEqual({ npm: "@ai-sdk/openai-compatible", api: "https://swap.example.com/v1" }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("falls back to the provider-wide apiKey for probing", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = swapServer("shared") + const { client } = fakeClient() + + try { + const hooks = await LocalProviderPlugin({ client, serverUrl: new URL("http://opencode.test") } as PluginInput) + const cfg: Config = { + provider: { + local: { + options: { + apiKey: "shared", + includeDefaults: false, + targets: { swap: { url: "https://swap.example.com", includeUnloaded: true } }, + }, + }, + }, + } + + await hooks.config!(cfg) + + // OpenAI-compatible SDKs send options.apiKey themselves. + expect(cfg.provider!.local!.models!["swap/qwen"]!.headers).toEqual({}) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("logs probe failures for configured targets only", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = swapServer("secret") + const { client, logs } = fakeClient() + + try { + const hooks = await LocalProviderPlugin({ client, serverUrl: new URL("http://opencode.test") } as PluginInput) + const cfg: Config = { + provider: { + local: { + options: { targets: { swap: { url: "https://swap.example.com", apiKey: "wrong" } } }, + }, + }, + } + + await hooks.config!(cfg) + + expect(cfg.provider!.local!.models).toEqual({}) + expect(logs.filter((item) => item.level === "warn").map((item) => item.message)).toEqual([ + 'Target "swap" probe failed: https://swap.example.com/v1 rejected the request (HTTP 401), check the target\'s API key', + ]) + } finally { + globalThis.fetch = originalFetch + } + }) +}) + +describe("saveProviderTarget", () => { + test("patches only the new target into global config", async () => { + const bodies: unknown[] = [] + const fetchImpl = mock(async (input: string | URL | Request) => { + const request = input as Request + bodies.push(await request.json()) + return Response.json({}) + }) as unknown as typeof fetch + const { client } = fakeClient(fetchImpl) + + await saveProviderTarget(new URL("http://opencode.test"), client, "swap", { + url: "https://swap.example.com", + kind: "llamaswap", + apiKey: "{env:SWAP_KEY}", + }) + + expect(bodies).toEqual([ + { + provider: { + local: { + options: { + targets: { + swap: { + url: "https://swap.example.com/v1", + kind: "llamaswap", + apiKey: "{env:SWAP_KEY}", + includeUnloaded: false, + }, + }, + }, + }, + }, + }, + ]) + }) +}) + +describe("Add Custom Target", () => { + function configRecorder() { + const bodies: any[] = [] + const fetchImpl = mock(async (input: string | URL | Request) => { + bodies.push(await (input as Request).json()) + return Response.json({}) + }) as unknown as typeof fetch + return { bodies, fetchImpl } + } + + async function addTarget(fetchImpl: typeof fetch, inputs: Record) { + const { client, logs } = fakeClient(fetchImpl) + const hooks = await LocalProviderPlugin({ client, serverUrl: new URL("http://opencode.test") } as PluginInput) + const method = hooks.auth!.methods[0]! + if (method.type !== "oauth") throw new Error("expected an oauth method") + return { method, logs, run: () => method.authorize(inputs) } + } + + test("saves a keyed target only once the login completes", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = swapServer("secret") + process.env.TEST_SWAP_KEY = "secret" + const { bodies, fetchImpl } = configRecorder() + + try { + const { run } = await addTarget(fetchImpl, { + target: "swap", + baseURL: "https://swap.example.com/", + apiKey: "{env:TEST_SWAP_KEY}", + models: "all", + }) + const authorization = await run() + + expect(authorization.method).toBe("auto") + expect(bodies).toEqual([]) + if (authorization.method !== "auto") return + + expect(await authorization.callback()).toEqual({ type: "success", provider: "local", key: "" }) + expect(bodies).toEqual([ + { + provider: { + local: { + options: { + targets: { + swap: { + url: "https://swap.example.com/v1", + kind: "llamaswap", + apiKey: "{env:TEST_SWAP_KEY}", + includeUnloaded: true, + }, + }, + }, + }, + }, + }, + ]) + } finally { + globalThis.fetch = originalFetch + delete process.env.TEST_SWAP_KEY + } + }) + + test("saves keyless targets without an apiKey", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url === "http://studio.test/v1/models") return Response.json({ data: [{ id: "qwen" }] }) + return new Response(null, { status: 404 }) + }) as unknown as typeof fetch + const { bodies, fetchImpl } = configRecorder() + + try { + const { run } = await addTarget(fetchImpl, { + target: "studio", + baseURL: "http://studio.test", + apiKey: "", + models: "loaded", + }) + const authorization = await run() + if (authorization.method !== "auto") throw new Error("expected auto") + await authorization.callback() + + expect(bodies[0].provider.local.options.targets).toEqual({ + studio: { url: "http://studio.test/v1", kind: "openai", includeUnloaded: false }, + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("fails before saving when the server rejects the key", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = swapServer("secret") + const { bodies, fetchImpl } = configRecorder() + + try { + const { run, logs } = await addTarget(fetchImpl, { + target: "swap", + baseURL: "https://swap.example.com", + apiKey: "wrong", + models: "loaded", + }) + + await expect(run()).rejects.toThrow("rejected the request (HTTP 401)") + expect(bodies).toEqual([]) + expect(logs.some((item) => item.level === "error" && item.message.includes("HTTP 401"))).toBe(true) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("rejects invalid target IDs", async () => { + const { fetchImpl } = configRecorder() + const { run } = await addTarget(fetchImpl, { target: "My Server", baseURL: "http://studio.test" }) + await expect(run()).rejects.toThrow("Target ID must use lowercase letters") + }) +})