From b9d58b72094e1b894eb0c50fea46682e8d1b5388 Mon Sep 17 00:00:00 2001 From: 0xzr <111210386+0xzr@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:11:22 -0400 Subject: [PATCH] fix: register freellmpool models in OpenCode --- CHANGELOG.md | 3 ++ README.md | 3 ++ docs/AGENTS.md | 6 ++- docs/INTEGRATIONS.md | 20 ++++++++-- docs/promotion/long-form-article.md | 5 ++- docs/promotion/reddit-opencode.md | 5 ++- docs/promotion/reply-bank.md | 5 ++- docs/run-opencode-on-free-models.html | 1 + integrations/opencode/README.md | 12 ++++-- integrations/opencode/freellmpool.js | 56 +++++++++++++++++++++++++++ scripts/check_opencode_packages.mjs | 15 +++++++ src/freellmpool/profiles.py | 14 ++++--- tests/test_opencode_packages.py | 27 +++++++++++++ tests/test_profiles.py | 1 + 14 files changed, 156 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbff2d..6ce9767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ All notable changes to this project are documented here. The format is based on degraded, or repeat-timeout Ollama, GitHub, and NVIDIA routes. ### Fixed +- The OpenCode plugin now registers its provider and named routing aliases + through the runtime config hook without rewriting user configuration, while + preserving existing provider options and authentication. - Public README, integration, agent, and Pages documentation now distinguishes released 0.11.4 from post-tag changes on `main`; the jailed OpenCode launcher also uses the proxy's actual port 8080 default. diff --git a/README.md b/README.md index f91f87b..d5dd1f3 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,9 @@ estimated savings, tokens served free, provider race, latency), per-request **agent routing** via the model picker (`freellmpool/agent|spread|auto|fast|quality|fair`), and `freellmpool_status` / `freellmpool_models` tools — see [integrations/opencode-tui](integrations/opencode-tui) and the [guide](https://0xzr.github.io/freellmpool/run-opencode-on-free-models.html). +The plugin registers its routing aliases automatically on supported OpenCode +versions without rewriting user configuration. Restart OpenCode and check +`opencode models freellmpool`. The package tarballs are validated in CI, but npm publication remains pending; the linked local-file instructions remain the working install path. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 5cf6a25..477a633 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -48,8 +48,10 @@ freellmpool profile doctor opencode --dry-run ``` `freellmpool code ` remains a compatibility shortcut that renders the -same profile quick-start. `profile install ` is print-only; it does not -edit third-party config files. +same profile quick-start. `profile install ` is print-only and does not +edit third-party config files. The OpenCode plugin registers its provider and +six routing aliases through the runtime config hook without writing the user's +configuration. ## OpenAI Python SDK / OpenAI Agents SDK diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index e831cbf..bb6c103 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -43,8 +43,8 @@ freellmpool profile doctor opencode --dry-run ``` `profile install` is print-only: it writes the quick-start and config snippets -to stdout so you can inspect or paste them yourself. `profile doctor --dry-run` -prints the checks it would perform without calling binaries or network URLs. +to stdout without changing third-party files. `profile doctor --dry-run` prints +the checks it would perform without calling binaries or network URLs. The init wizard detects provider keys, agent CLIs, proxy config, and Tailscale state, then prints copy-pastable setup plans without editing third-party config: @@ -85,16 +85,30 @@ while filtering out locally exhausted or cooling-down targets. These are local snapshots and never live-probe an upstream provider. ### opencode + +After installing the plugin, restart OpenCode and verify what its model picker +can see: + +```bash +opencode models freellmpool +``` + +The OpenCode plugin's config hook adds this provider automatically on supported +versions without writing the user's configuration. For older versions, use the +manual block below. These six entries are routing aliases over the full live +catalog returned by the proxy's `/v1/models` endpoint. + `opencode.json` (project or `~/.config/opencode/`): ```json { "$schema": "https://opencode.ai/config.json", - "model": "freellmpool/agent", "provider": { "freellmpool": { + "name": "freellmpool (free pool)", "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}", "headerTimeout": 600000, "timeout": 600000, "chunkTimeout": 120000 diff --git a/docs/promotion/long-form-article.md b/docs/promotion/long-form-article.md index 4aa5ad6..d829de2 100644 --- a/docs/promotion/long-form-article.md +++ b/docs/promotion/long-form-article.md @@ -114,7 +114,10 @@ OpenCode can point at the local OpenAI-compatible proxy: "provider": { "freellmpool": { "npm": "@ai-sdk/openai-compatible", - "options": { "baseURL": "http://localhost:8080/v1" }, + "options": { + "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}" + }, "models": { "auto": {}, "fast": {}, "quality": {}, "fair": {} } } } diff --git a/docs/promotion/reddit-opencode.md b/docs/promotion/reddit-opencode.md index 5d7015d..ea80d6a 100644 --- a/docs/promotion/reddit-opencode.md +++ b/docs/promotion/reddit-opencode.md @@ -45,7 +45,10 @@ freellmpool proxy --port 8080 "provider": { "freellmpool": { "npm": "@ai-sdk/openai-compatible", - "options": { "baseURL": "http://localhost:8080/v1" }, + "options": { + "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}" + }, "models": { "auto": {}, "fast": {}, "quality": {}, "fair": {} } } } diff --git a/docs/promotion/reply-bank.md b/docs/promotion/reply-bank.md index 5907382..f758eac 100644 --- a/docs/promotion/reply-bank.md +++ b/docs/promotion/reply-bank.md @@ -78,7 +78,10 @@ Then add a custom OpenAI-compatible provider in `opencode.json`: "provider": { "freellmpool": { "npm": "@ai-sdk/openai-compatible", - "options": { "baseURL": "http://localhost:8080/v1" }, + "options": { + "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}" + }, "models": { "auto": {}, "fast": {}, "quality": {}, "fair": {} } } } diff --git a/docs/run-opencode-on-free-models.html b/docs/run-opencode-on-free-models.html index c34c45e..9561247 100644 --- a/docs/run-opencode-on-free-models.html +++ b/docs/run-opencode-on-free-models.html @@ -73,6 +73,7 @@

2. Add freellmpool as an OpenCode provider

"npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}", "headerTimeout": 600000, "timeout": 600000, "chunkTimeout": 120000 diff --git a/integrations/opencode/README.md b/integrations/opencode/README.md index c1f8a3b..edd431c 100644 --- a/integrations/opencode/README.md +++ b/integrations/opencode/README.md @@ -69,17 +69,23 @@ versions that discover custom tools from that directory rather than from a plugin's `tool` hook. Keeping both paths configured preserves the served-model toast and exposes all three tools across supported OpenCode versions. -Then make sure the proxy is running (`freellmpool proxy`) and that OpenCode has a -`freellmpool` provider pointed at it (`baseURL: http://localhost:8080/v1`). Add the -routing aliases as models so you can pick them: +Then make sure the proxy is running (`freellmpool proxy`) and restart OpenCode. +The plugin's config hook adds the `freellmpool` provider and six routing aliases +to the resolved configuration without replacing your existing defaults. + +Verify what the picker can see with `opencode models freellmpool`. If an older +OpenCode version does not support the config hook, copy the provider block +below into its configuration. The plugin never rewrites that file. ```jsonc { "provider": { "freellmpool": { + "name": "freellmpool (free pool)", "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}", "headerTimeout": 600000, "timeout": 600000, "chunkTimeout": 120000 diff --git a/integrations/opencode/freellmpool.js b/integrations/opencode/freellmpool.js index 4a2ac86..5790e94 100644 --- a/integrations/opencode/freellmpool.js +++ b/integrations/opencode/freellmpool.js @@ -26,6 +26,55 @@ const BASE_URL = ( const PROXY_KEY = process.env.FREELLMPOOL_PROXY_KEY || ""; const TOAST = !/^(0|false|off|no)$/i.test(process.env.FREELLMPOOL_TOAST || ""); const TIMEOUT_MS = 5000; +const PROVIDER_MODELS = { + agent: { name: "Agent — strongest healthy tier" }, + spread: { name: "Spread — maximum pool breadth" }, + auto: { name: "Auto — proxy default routing" }, + fast: { name: "Fast — lowest latency" }, + quality: { name: "Quality — capability matched" }, + fair: { name: "Fair — provider quota spread" }, +}; + +function installProvider(config) { + if (!config || typeof config !== "object" || Array.isArray(config)) return; + if ( + Array.isArray(config.disabled_providers) && + config.disabled_providers.includes("freellmpool") + ) { + return; + } + if ( + Array.isArray(config.enabled_providers) && + !config.enabled_providers.includes("freellmpool") + ) { + config.enabled_providers.push("freellmpool"); + } + + config.provider ||= {}; + if (!config.provider || typeof config.provider !== "object") return; + config.provider.freellmpool ||= {}; + const provider = config.provider.freellmpool; + if (!provider || typeof provider !== "object") return; + + provider.name ||= "freellmpool (free pool)"; + provider.npm ||= "@ai-sdk/openai-compatible"; + provider.options ||= {}; + if (provider.options && typeof provider.options === "object") { + provider.options.baseURL ||= `${BASE_URL}/v1`; + if (PROXY_KEY) provider.options.apiKey ||= PROXY_KEY; + provider.options.headerTimeout ??= 600000; + provider.options.timeout ??= 600000; + provider.options.chunkTimeout ??= 120000; + } + + provider.models ||= {}; + if (!provider.models || typeof provider.models !== "object") return; + for (const [id, defaults] of Object.entries(PROVIDER_MODELS)) { + provider.models[id] ||= {}; + const model = provider.models[id]; + if (model && typeof model === "object") model.name ||= defaults.name; + } +} function authHeaders() { return PROXY_KEY ? { Authorization: `Bearer ${PROXY_KEY}` } : {}; @@ -166,6 +215,13 @@ export const FreellmpoolPlugin = async ({ client }) => { let lastSeenMsg = null; // dedupe message.updated (it fires repeatedly per message) return { + // OpenCode model-picker entries come from provider config, not tool hooks. + // Register the routing aliases whenever this plugin loads while preserving + // user-defined provider options, model entries, and the selected default. + config: async (config) => { + installProvider(config); + }, + tool: { freellmpool_status: tool({ description: diff --git a/scripts/check_opencode_packages.mjs b/scripts/check_opencode_packages.mjs index dcdf1c0..bd851f1 100644 --- a/scripts/check_opencode_packages.mjs +++ b/scripts/check_opencode_packages.mjs @@ -82,9 +82,24 @@ function smokeServer(installDir) { "--input-type=module", "-e", [ + 'process.env.FREELLMPOOL_PROXY_KEY = "smoke-proxy-key"', 'const mod = await import("opencode-freellmpool")', "const loaded = await mod.default({ client: { tui: { showToast: async () => {} } } })", 'if (loaded?.tool?.freellmpool_status?.description === undefined) throw new Error("server plugin did not register tools")', + 'const config = { provider: { freellmpool: { name: "Custom pool", options: { apiKey: "custom-api-key" }, models: { custom: {} } } } }', + "await loaded.config(config)", + "await loaded.config(config)", + 'if (!config.provider.freellmpool.models.agent || !config.provider.freellmpool.models.spread) throw new Error("server plugin did not register provider models")', + 'if (config.provider.freellmpool.name !== "Custom pool" || config.provider.freellmpool.options.apiKey !== "custom-api-key" || !config.provider.freellmpool.models.custom) throw new Error("server plugin replaced existing provider configuration")', + "const freshConfig = {}", + "await loaded.config(freshConfig)", + 'if (freshConfig.provider.freellmpool.options.apiKey !== "smoke-proxy-key") throw new Error("server plugin did not configure proxy authentication")', + 'const selectedConfig = { model: "existing/default" }', + "await loaded.config(selectedConfig)", + 'if (selectedConfig.model !== "existing/default") throw new Error("server plugin replaced the selected default model")', + 'const disabledConfig = { disabled_providers: ["freellmpool"] }', + "await loaded.config(disabledConfig)", + 'if (disabledConfig.provider?.freellmpool) throw new Error("server plugin ignored disabled_providers")', 'const localPlugin = await import("opencode-freellmpool/plugin/freellmpool.js")', 'if (typeof localPlugin.default !== "function") throw new Error("local plugin shim did not load")', 'for (const name of ["freellmpool_status", "freellmpool_models", "freellmpool_tokenmax"]) {', diff --git a/src/freellmpool/profiles.py b/src/freellmpool/profiles.py index ac70291..9219c9a 100644 --- a/src/freellmpool/profiles.py +++ b/src/freellmpool/profiles.py @@ -111,20 +111,22 @@ class Profile: "model": "freellmpool/agent", "provider": { "freellmpool": { + "name": "freellmpool (free pool)", "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": f"{_DEFAULT_PROXY}/v1", + "apiKey": "{env:FREELLMPOOL_PROXY_KEY}", "headerTimeout": 600_000, "timeout": 600_000, "chunkTimeout": 120_000, }, "models": { - "agent": {}, - "spread": {}, - "auto": {}, - "fast": {}, - "quality": {}, - "fair": {}, + "agent": {"name": "Agent — strongest healthy tier"}, + "spread": {"name": "Spread — maximum pool breadth"}, + "auto": {"name": "Auto — proxy default routing"}, + "fast": {"name": "Fast — lowest latency"}, + "quality": {"name": "Quality — capability matched"}, + "fair": {"name": "Fair — provider quota spread"}, }, } }, diff --git a/tests/test_opencode_packages.py b/tests/test_opencode_packages.py index 2fb1915..a601dc7 100644 --- a/tests/test_opencode_packages.py +++ b/tests/test_opencode_packages.py @@ -126,12 +126,19 @@ def test_opencode_server_plugin_registers_tools_with_sdk_helper() -> None: "freellmpool_tokenmax", ): assert f"{name}: tool({{" in source + assert "config: async (config) =>" in source + assert "installProvider(config)" in source smoke = (ROOT / "scripts" / "check_opencode_packages.mjs").read_text( encoding="utf-8" ) assert "Object.assign((definition) => definition, { schema })" in smoke assert "server plugin did not register tools" in smoke + assert "server plugin did not register provider models" in smoke + assert "server plugin replaced existing provider configuration" in smoke + assert "server plugin did not configure proxy authentication" in smoke + assert "server plugin replaced the selected default model" in smoke + assert "server plugin ignored disabled_providers" in smoke assert "local plugin shim did not load" in smoke assert "custom tool did not load" in smoke @@ -179,3 +186,23 @@ def test_opencode_docs_distinguish_released_sources_from_registry_hardening() -> assert "plugin sources are included in 0.11.4" in text assert "registry-readiness hardening" in text assert "unreleased repository additions" not in text + + +def test_opencode_config_examples_forward_protected_proxy_auth() -> None: + paths = [ + ROOT / "docs" / "INTEGRATIONS.md", + ROOT / "docs" / "run-opencode-on-free-models.html", + ROOT / "docs" / "promotion" / "long-form-article.md", + ROOT / "docs" / "promotion" / "reddit-opencode.md", + ROOT / "docs" / "promotion" / "reply-bank.md", + ROOT / "integrations" / "opencode" / "README.md", + ] + for path in paths: + text = path.read_text(encoding="utf-8") + assert '"apiKey": "{env:FREELLMPOOL_PROXY_KEY}"' in text + + integrations = (ROOT / "docs" / "INTEGRATIONS.md").read_text(encoding="utf-8") + manual_fallback = integrations.split("For older versions", 1)[1].split( + "Pick `freellmpool/", 1 + )[0] + assert '"model": "freellmpool/' not in manual_fallback diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 745d249..2427801 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -78,6 +78,7 @@ def test_opencode_profile_defaults_to_long_running_agent_route_and_timeouts(): assert provider["options"]["headerTimeout"] >= 600_000 assert provider["options"]["timeout"] >= 600_000 assert provider["options"]["chunkTimeout"] >= 60_000 + assert provider["options"]["apiKey"] == "{env:FREELLMPOOL_PROXY_KEY}" def test_hermes_profile_uses_supported_custom_endpoint(capsys):