Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ freellmpool profile doctor opencode --dry-run
```

`freellmpool code <agent>` remains a compatibility shortcut that renders the
same profile quick-start. `profile install <agent>` is print-only; it does not
edit third-party config files.
same profile quick-start. `profile install <agent>` 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

Expand Down
20 changes: 17 additions & 3 deletions docs/INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/promotion/long-form-article.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {} }
}
}
Expand Down
5 changes: 4 additions & 1 deletion docs/promotion/reddit-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {} }
}
}
Expand Down
5 changes: 4 additions & 1 deletion docs/promotion/reply-bank.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {} }
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/run-opencode-on-free-models.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ <h2>2. Add freellmpool as an OpenCode provider</h2>
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:8080/v1",
"apiKey": "{env:FREELLMPOOL_PROXY_KEY}",
"headerTimeout": 600000,
"timeout": 600000,
"chunkTimeout": 120000
Expand Down
12 changes: 9 additions & 3 deletions integrations/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions integrations/opencode/freellmpool.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}` } : {};
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions scripts/check_opencode_packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {',
Expand Down
14 changes: 8 additions & 6 deletions src/freellmpool/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
}
},
Expand Down
27 changes: 27 additions & 0 deletions tests/test_opencode_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down