Skip to content

feat(opencode): local LAN provider discovery + auto-discover models - #27554

Open
androidand wants to merge 153 commits into
anomalyco:devfrom
androidand:dev
Open

feat(opencode): local LAN provider discovery + auto-discover models#27554
androidand wants to merge 153 commits into
anomalyco:devfrom
androidand:dev

Conversation

@androidand

@androidand androidand commented May 14, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #6231
Closes #27553

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

  • Adds Local (LAN) discovery in /connect for local OpenAI-compatible servers.

    • Combines mDNS, localhost probing, and bounded LAN probing to find reachable local instances.
    • Lets users add discovered providers in bulk and marks already configured entries.
    • Shows if local providers are online or offline
  • Auto-discovers models for OpenAI-compatible providers from /models, including limits like context window and max output tokens when available.

  • Compatible with and preserves manually configured/edited providers and models and respects discoverModels: true|false.

  • Adds context current / context_length when available (see llama-swap PR below) to Context in sidebar and footer.

  • Fixes unrelated bugs introduced by rebase, --agent parameter would crash opencode.

Related

Server-side companion: mostlygeek/llama-swap#755 adds context_length and max_output_tokens to the /v1/models response. When those fields are present, this PR reads them to configure each model's context window and output token limits automatically — no manual config needed.

How did you verify your code works?

  • bun run typecheck
  • bun test test/provider/provider.test.ts --timeout 30000
  • bun turbo typecheck
  • Manually tested /connect -> Local (LAN) against multiple local OpenAI-compatible servers on localhost and LAN.

Screenshots / recordings

image image image

Provider config example

    "proxmox": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Proxmox",
      "options": {
        "baseURL": "http://192.168.1.123:8080/v1",
        "apiKey": "ollama"
      },
      "discoverModels": true
    },

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label May 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Based on the search results, here are the potentially related PRs:

Most Related:

Related by Pattern:

Note: PR #27554 (the current PR) appears as the top result in all searches, which is expected. The most directly related duplicate candidate is #26756, which already implements discovery from /v1/models for LM Studio specifically. You may want to review if this current PR consolidates or extends that approach to work with all OpenAI-compatible providers.

Auto-discover models from any provider using @ai-sdk/openai-compatible
with a baseURL set by calling GET /v1/models at startup. Reads id, name,
context_length / max_context_length, and max_output_tokens from the
response. Manually configured models always win (non-destructive merge).

Discovery is skipped when discoverModels:false is set, when the provider
has no baseURL, or when it uses a non-compatible npm package.

All discovery calls run in parallel (Promise.all) so N offline providers
add at most one 2-second timeout to startup rather than N x 2 seconds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label May 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

Add a new Local Providers entry to the provider connect dialog that
scans the LAN via mDNS for llama-swap instances (_llamaswap._tcp) and
lets users add or remove them as openai-compatible providers in the
global config without editing JSON by hand.

Backend:
- packages/opencode/src/local/mdns.ts: bonjour-service mDNS scan
- groups/local.ts: HttpApi schema (GET /local/scan, POST /local/connect,
  DELETE /local/connect/:providerID)
- handlers/local.ts: Effect handlers reading/writing global config via
  Config.Service.getGlobal()/updateGlobal(); scan annotates each result
  with configuredProviderID so the UI can show current state
- sdk regenerated with Local.scan/connect/disconnect

UI:
- dialog-local-discovery.tsx: scan button, per-instance Add/Remove,
  Add-all, optimistic local state, restart-required toast
- dialog-select-provider.tsx: "Local providers" entry opens discovery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@androidand
androidand requested a review from adamdotdevin as a code owner May 14, 2026 15:34
androidand and others added 8 commits May 14, 2026 18:01
…figured

connect handler now checks if any existing provider already has the same
normalised baseURL (e.g. written by sync-opencode under a different key)
and reuses that key rather than creating a duplicate entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a 'Local (LAN)' option to the TUI /connect provider dialog.
When selected, scans the local network via mDNS and probes localhost
ports for llama-swap instances, then presents them in a selection list.
Already-configured instances are shown with a checkmark and disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap bonjour init in try/catch so socket errors fall back to localhost
probe silently. Use JSON.stringify on error body in TUI toast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DialogSelect filters out disabled:true items entirely, so already-added
providers were invisible. Remove disabled flag; show checkmark gutter
for configured providers and an info toast if user selects one.
…ubnet backends

mDNS doesn't work across VLANs/subnets, so remote fleet backends (proxmox,
rocky, m5) were invisible in /connect → Local (LAN) scan. Fix by reading all
@ai-sdk/openai-compatible providers already in the global config and probing
them directly — they're already known from sync-opencode, just not reachable
by mDNS. Also fix tsgo type error (p as unknown) in configuredByURL loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace fetch+AbortController with net.Socket TCP probes for LAN
  scanning — Bun's fetch does not reliably abort connections to
  unreachable hosts, causing all workers to stall permanently.
  net.Socket.destroy() cleans up the fd even before ARP resolves.

- Wrap probeHost and probeModelIDs in withTimeout so localhost probes
  on ports that accept TCP but never respond to HTTP cannot block the
  whole scan (Promise.race with explicit setTimeout always resolves).

- Raise LAN concurrency to 192 workers at 200ms each so the full /24
  subnet (253 × 3 ports = 759 tasks) completes in ~800ms — previously
  64 workers only reached .128 before the budget fired, missing hosts
  at .218 and .219.

- Add reverse-DNS hostname resolution on the handful of live hosts
  found after TCP+HTTP probes (safe: only ~3-4 hosts, 500ms timeout).

- Replace single-select connect dialog with a multi-select
  DialogLocalProviders component (toggle with space, add with ctrl+ret)
  matching the MCP server dialog pattern.

- Fix maxOutputTokens to derive a reasonable limit from context_length
  when output limit is 0 (unknown), instead of returning OUTPUT_TOKEN_MAX
  unconditionally; add test coverage.
@androidand androidand changed the title feat(opencode): auto-discover models from openai-compatible providers feat(opencode): local LAN provider discovery + auto-discover models May 14, 2026
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. and removed needs:compliance This means the issue will auto-close after 2 hours. labels May 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

'Upgrades via TUI always fail' was undiagnosable by construction: the TUI
toast discarded the server's error text and showed a bare 'Update failed',
the unknown-method 400 didn't say WHY the method was unknown (a dev build
running from dist/ cannot self-upgrade — the common case), upgradeCurl
stripped the underlying cause, script failures dropped their output on the
floor, and only successful upgrades were ever logged.

Now: the toast shows the server's error text, the unknown-method error names
the running binary's path and explains the dev-build case, curl failures keep
their cause, script failures carry the output tail, and every failed upgrade
is logged with method/target/exit code/output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arunasp added a commit to arunasp/opencode-model-eval that referenced this pull request Jul 20, 2026
Adds discover_local_ollama_models.py, run by entrypoint.sh's 'serve'
mode before opencode starts. Queries Ollama's native /api/tags,
regenerates provider["local/ollama"].models to match whatever's
actually installed on the host, writes to a fresh writable path under
$HOME, and repoints OPENCODE_CONFIG there -- config/opencode.base.json
baked into the image stays read-only and becomes the fallback, not the
source of truth.

Matches the 'smart, auto-detect' pattern Axiom already uses on the
host side. Not built on anomalyco/opencode#27554 (mDNS + /v1/models
auto-discovery, discoverModels config flag) -- confirmed via the real
PR that it's still open/unmerged (awaiting review as of its last
activity), not in the released ghcr.io/anomalyco/opencode image this
harness pulls. This is the harness-level equivalent until that lands
upstream.

Graceful degradation is the actual design, not an afterthought: if
Ollama is unreachable at startup (still booting, wrong URL, offline),
the script writes the baked-in static list unchanged and exits 0 --
never blocks or fails serve startup over this.

Important distinction documented in README caveat #7: discovery fixes
what the PROVIDER knows about, not which models the EVAL containers
(gemma4-local, etc.) run the test suite against -- those are still an
explicit service list, unaffected by discovery. Adding a model on the
host makes it routable but doesn't auto-add an eval target.

Verified: scripts/test_discover_local_ollama_models.py runs a REAL
local HTTP server (not a mock) for both the success path (5 models
discovered, stale single-entry fallback correctly overwritten) and
the failure path (connection refused, output equals input unchanged,
exit code 0 not treated as fatal) -- both paths actually exercised,
not just reasoned about. entrypoint.sh: bash -n only (no shellcheck
binary in this sandbox). NOT verified: the real container startup
path (Docker unavailable here) -- entrypoint.sh calling this at
serve-time, in the actual image, has not been run.
openspec/ was ignored wholesale at .gitignore:38, grouped with .skein/ as
"local runtime state". That nullified openspec/changes/.gitignore, which
already draws the correct line: specs are tracked, machine-local .skein
run state is not (tracking it aborts worktree merges).

The consequence was that no spec in this repo existed in version control
at all — 127 proposals, task lists and delta specs were unrecoverable and
invisible to every other checkout, which breaks specs-live-in-main.

Illustrative fleet host names in three proposals genericised, since this
repo is public.

Co-Authored-By: Claude <noreply@anthropic.com>
LocalPlacement.pick returns a bare null for three unrelated reasons — a
cloud parent, no local peers, and a failed probe — so the caller cannot
tell them apart and inherits the parent's provider in all three. On a
single-slot llama.cpp server that queues the subagent behind the very
session that spawned it. It never returns, and the only backstop is a
10-minute wall clock, which reads as "hung forever".

The slot gate in pick() never applied here: candidates are filtered with
`info.id !== parent.providerID`, so the parent is excluded from the very
check that would have caught this. The comment above the fallback already
describes the failure ("queues behind its parent ... worse than useless")
— the code just did it anyway.

Adds parentCapacity() returning free | no-slot | unknown, consulted only
when placement actually ran and came back empty. "no-slot" now fails fast
with an actionable message instead of queueing invisibly.

"unknown" deliberately still inherits: a provider that does not serve
/api/hardware is not necessarily busy, and hard-failing those would break
working setups to fix a different bug. The wall-clock ceiling (task 5)
covers that case instead.

Refs #4

Co-authored-by: Claude <noreply@anthropic.com>
androidand and others added 2 commits July 29, 2026 10:50
Merging with the same pre-existing-red caveat verified for the other repos: the 9 packages/tui failures are identical with this work stashed (181 pass / 9 fail baseline vs 189 pass / 9 fail after), and bun run typecheck is 23/23 green. Adds 8 passes, zero new failures.
Instella-MoE recites long instruction prompts inside its think block
and ruminates instead of acting. Give it a compact prompt with the one
cue that measurably fixes tool emission: tool calls written inside
<think> are not executed - emit <tool_call> after </think>.

Selected by model id substring like the other family prompts; no other
model is affected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
androidand and others added 7 commits July 30, 2026 00:19
Remove 'end your response' easy out from BACKGROUND_STARTED and
BACKGROUND_UPDATED text. Model was stopping work after launching a
background task instead of continuing on other tasks.
The model recites its instructions inside the think block; a literal
closing think tag in the prompt text gets recited too and terminates
reasoning mid-sentence (observed on z4). Refer to thinking without
writing the markers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Small local reasoning models sometimes finish a turn having produced
neither tool calls nor text - all reasoning, then stop (or a length
cut mid-think). The loop previously went idle silently. Detect that at
the loop exit and give the model one do-over per user turn with
tool_choice forced to required, which grammar-constrains it to act.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Subagent placement scored an already-loaded model +2000 but the
parent's own model +5000, so a host with a perfectly good resident
model could be told to swap — evicting what the user (or skein)
deliberately keeps loaded and paying a multi-second reload both ways.

Adopt skein's rule as a strict ordering: an eligible resident model
wins outright; among models needing a load the parent's model is
preferred; otherwise best fit. Eligibility (allowlist, ctx, fit) still
gates everything, so residency never launders an unvetted model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The task tool gains an optional provider parameter so "run this on
rocky" is honorable: placement restricts to that host (bypassing the
parent-exclusion and cloud-parent gates — an explicit request wins),
still applies the allowlist/ctx/resident-model rules, and refuses
loudly when the host has no slot or eligible model instead of silently
placing elsewhere. The tool description lists the local providers seen
at startup so the model knows the fleet by name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prompt said 'prefer acting over asking' with no counterweight, so
the model delegated answering 'Hello' to a subagent on another host.
One line of discrimination: conversational messages get a direct text
reply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
provider.list() at tool-init dies with a defect (InstanceRef not
provided) when no project instance is loaded; orElseSucceed only
catches typed failures, so the defect crashed the whole server worker
at startup. Catch the full cause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
androidand and others added 7 commits July 30, 2026 23:36
MCP 2026-07-28 removes the initialize/session handshake and requires
server/discover for version negotiation. Most connected servers
(including our own skein, and homeops-mcp for one more revision)
still speak pre-2026-07-28. Proposes migrating the client from
@modelcontextprotocol/sdk v1 to the new v2 client package with
per-server protocolMode negotiation, cached verdicts, protocol
diagnostics, and per-server tool allowlists to address the 48-tool
context problem independent of protocol era.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase A1 of mcp-dual-era-client: add the new v2 client package without
removing the v1 sdk yet. Verified against the installed package's own
.d.mts that versionNegotiation, PriorDiscovery, and ConnectOptions.prior
exist as described, rather than assuming the earlier research was
right. bun.lock is intentionally left out of this commit — it's
already dirty from unrelated in-progress work (auto-mode) in the
working tree, and committing it here would entangle that WIP's
lockfile drift with this change's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
adjustLocalContextOnOverflow gated recovery on error.type ===
"context_too_large", which llama-skein's prompt guard never sends —
it sends type: "exceed_context_size_error", code:
"prompt_over_max_safe_ctx" (internal/server/promptguard.go) for a
prompt too large for an already-loaded, correctly-configured model.
That left recovery unreachable for this class, diagnosed live on
proxmox/qwopus3.6-27b-v2-mtp-q8-0: the model appeared repeatedly
"stuck" because compaction kept sizing its budget from a stale,
too-large limit.context that nothing ever corrected.

Split into two classes with different remedies: context_too_large
(proxy/proxymanager.go, model failed to load) keeps patching
ctx_size and retrying, unchanged. The new prompt-overflow class
self-heals the model's cached limit.context from the authoritative
X-Skein-Max-Safe-Ctx header (falling back to /api/fit) and lets the
413 propagate normally — the existing needsCompaction reactive path
in session/processor.ts was already wired for any ContextOverflowError,
it just never had a correct budget to compact against.

Also: give the /api/fit probe its own timeout instead of sharing the
/v1/models 2s budget (a probe that would have won a slightly longer
race was being silently killed), and prefer a previously-known
context value over a larger freshly-reported one when fit is
unavailable — this incidentally fixed an unrelated pre-existing
failing test with the same root cause (a manual model config's
smaller context being overridden by a larger discovered value).

provider.ts changes are staged precisely around this repo's existing
uncommitted sizeBytes/modelMb work in the same functions — that WIP
is untouched and remains uncommitted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt v2

Phase A2 of mcp-dual-era-client: port createClient/connectRemote/
connectLocal in mcp/index.ts (plus catalog.ts and oauth-provider.ts,
which share the same client surface) from the legacy
@modelcontextprotocol/sdk v1 package to the new v2 client package,
preserving behavior exactly rather than redesigning anything.

Verified against the installed package's own .d.mts instead of
assuming: SSEClientTransport is NOT removed in v2 (only deprecated),
so the StreamableHTTP-then-SSE fallback chain ports unchanged.
StdioClientTransport moved to a /stdio subpath. setRequestHandler/
setNotificationHandler now key off method strings instead of Zod
schema objects. transport.finishAuth(code) still works via an
overload. OAuthClientProvider's new optional methods mean the
existing McpOAuthProvider satisfies the interface unchanged.
callTool's 3-arg schema-validation overload is gone, so the redundant
CallToolResultSchema argument was dropped.

v2 only re-exports Zod schema *values* from @modelcontextprotocol/core
(not the plain inferred types v1 exported directly), so MCPToolDef and
LoggingMessageNotification are now derived locally via z.infer.

Also fixed a real regression the port surfaced: serverLog's switch
over log levels was exhaustive against v1's narrower level type; v2
widened it, silently leaking an undefined return. Added a default
branch.

package.json gains @modelcontextprotocol/core as an explicit
dependency (previously only transitive via @modelcontextprotocol/client).
bun.lock is intentionally left uncommitted again, same reason as the
prior commit — it's entangled with unrelated in-progress work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase A3 of mcp-dual-era-client: the last src/ importer
(cli/cmd/mcp.ts) is ported to v2, and the v1 package plus its
now-orphaned type-declaration patch are removed.

Scope grew past the stated validation (grep src/ for v1 imports):
test/mcp/ (8 files, 2142 lines) and test/fixture/mcp-session-recovery.ts
also imported v1 and would have hard-failed on module resolution once
the package was gone, so those are ported too.

Found and fixed a real, previously-undiagnosed bug in the test
infrastructure while doing this (confirmed pre-existing via git-stash
bisection, unrelated to SDK version): every mock.module() call in
these files was fire-and-forget (void mock.module(...)) despite the
API's own type signature being void | Promise<void>, and v1's
per-subpath mocks needed consolidating into one mock of v2's single
@modelcontextprotocol/client package (spreading the real exports first
so anything not explicitly overridden doesn't vanish). Result:
test/mcp/ went from 26 pass / 34 fail to 59 pass / 1 skip / 0 fail.

The 1 skip (session-recovery.test.ts) isn't a bug: it tests
session-ID-based reconnection, a feature the 2026-07-28 spec removes
entirely (SEP-2567, no more protocol-level sessions). v2's real client
confirms this - a 404 on a session-bound POST now throws
SdkHttpError(CLIENT_HTTP_NOT_IMPLEMENTED) instead of reinitializing.

Also simplified A2's catalog.ts/index.ts: the plain Tool/
LoggingMessageNotification types come directly from
@modelcontextprotocol/client's own barrel, not derived via z.infer -
A2 only checked @modelcontextprotocol/core (schema-values only) and
missed that client re-exports the plain types too.

Did not attempt a full rewrite of the test mocking architecture (real
fixture servers / dependency injection) - that's substantially larger,
separately-scoped work; the bug found and fixed was specifically the
mock.module usage pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phases B, C, D of mcp-dual-era-client, completing the change.

Phase B: protocolMode config (legacy/auto/modern, global + per-server
override) wired to v2's real ClientOptions.versionNegotiation - verified
against the installed package that this is constructor-time, not
per-connect(), and that the SDK's own default is "legacy" (we override
to "auto"). PriorDiscovery caching persists the negotiated era across
reconnects within a session, keyed by server name. Connection
diagnostics (era, protocol version, transport, capabilities) are now
part of Status and rendered in `opencode mcp list`.

Found and fixed a real latent bug while building the first real (not
mocked) integration test: storeClient() hardcoded a bare "connected"
status, silently discarding the diagnostics createAndStore had just
computed. The mocked unit tests could not have caught this - it only
surfaced against a real subprocess connection.

Phase C: mcpToolProfiles (named tool allowlists) + per-server
toolProfile, filtering MCP.tools() before conversion so excluded tools
never reach the model. Fails closed on a misconfigured/misspelled
profile reference (zero tools, not all tools) - the original draft
fell open on this, caught by the test for it, not by inspection.

Phase D: two real MCP fixture servers (mcp-legacy: hand-rolled JSON-RPC
with no server/discover handler, proving the client's genuine fallback
path; mcp-dual-era: real @modelcontextprotocol/server v2 via
serveStdio - confirmed empirically that a bare Server+StdioServerTransport
does NOT answer server/discover; the era contract lives in serveStdio's
factory model). Verified all four mode x fixture combinations plus the
real skein binary (still on its 2024-11-05 hand-rolled protocol):
`opencode mcp list` against skein renders `era: legacy protocol:
2024-11-05 transport: stdio capabilities: tools`, connecting and
listing its 48 tools successfully under both auto and legacy modes.

Found, chased extensively, and pragmatically worked around (not fully
root-caused) a bun:test cross-file mock.module() interaction: a
standalone real-integration test file failed only when run alongside
three specific mocking files in the same `bun test` invocation, never
in isolation. mock.restore() in their afterAll hooks does run but does
not undo mock.module() registrations in this bun version; several
other fixes were tried and rejected. Resolved by co-locating the
real-fixture tests in the one file already proven safe against this
interaction (fixtures.test.ts) rather than continuing to chase the
mechanism - documented in tasks.md for whoever eventually digs deeper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All tasks complete across phases A-D. Promotes the delta spec into
openspec/specs/mcp-client/spec.md as the canonical capability spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Auto-discover models from OpenAI-compatible providers Auto-discover models from OpenAI-compatible provider endpoints

3 participants