feat(opencode): local LAN provider discovery + auto-discover models - #27554
feat(opencode): local LAN provider discovery + auto-discover models#27554androidand wants to merge 153 commits into
Conversation
|
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 |
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>
|
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>
…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.
|
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>
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>
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>
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>
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>
Issue for this PR
Closes #6231
Closes #27553
Type of change
What does this PR do?
Adds
Local (LAN)discovery in/connectfor local OpenAI-compatible servers.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_lengthwhen 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_lengthandmax_output_tokensto the/v1/modelsresponse. 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 typecheckbun test test/provider/provider.test.ts --timeout 30000bun turbo typecheck/connect -> Local (LAN)against multiple local OpenAI-compatible servers on localhost and LAN.Screenshots / recordings
Provider config example
Checklist