Skip to content

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

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

androidand wants to merge 10000 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.

@androidand
androidand force-pushed the dev branch 2 times, most recently from c368353 to a56a8fe Compare May 14, 2026 14:53
@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. 👍

@androidand
androidand requested a review from adamdotdevin as a code owner May 14, 2026 15:34
@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. 👍

@androidand

Copy link
Copy Markdown
Author

Please have a look, I think people will appreciate this, especially in combination with the updated llama-swap which adds feature parity (and more) with ollama.

@vlameiras

Copy link
Copy Markdown

It would be very much appreciated. Thanks!

@bandwith

Copy link
Copy Markdown

This PR contains too much irrelevant data. You're adding local skills and theme content, along with debugging data from your local system.

codec.ts:95-102 and lead.ts:195-201, each verified by reading the line at both
ends of the range. An earlier check appeared to show lead.ts:195 was wrong; that
was the check resolving src/cli/cmd/lead.ts instead of src/peer/lead.ts.
The user rejected the project server, so the premise of my recommendation is gone.
Recording that rather than quietly rewriting it: the reasoning was sound against D5,
and leaving it standing would point the decision at a destination that does not
exist.

Two corrections that came out of consulting the peer session that owns lead
authority, after it re-checked the code path itself:

1. I cited the absence of SO_PEERCRED/getpeereid as a gap in this codebase. It is a
   platform constraint: probed on Bun 1.3.14 over a live unix socket, Socket exposes
   no getPeerName, getPeerCredentials or getPeerCert. An unforgeable connection
   identity is not reachable here at all, so any proposal resting on peer credentials
   is not implementable as written.

2. #111 is inert as merged. Nothing passes authorSessionID yet, so the driver check
   enforces nothing. Recorded as driver-side plumbing rather than a control in force.

What survives is the conclusion, not the route: callback confirmation, where the
receiver asks the claimed socket whether it sent a given msg_id and the real session
answers from its own sent-log. Roughly one frame type plus a sent-log, and it hardens
verifyLead as well. The residual belongs in the document rather than in a chat log:
against a same-user shell-capable agent, nothing short of OS separation makes sender
identity unforgeable. That is why the controls are bounded claims, and why 1b.1 should
have the record written by a tool from a session id the tool holds, never parsed from
message text.
44 sidecars accumulated on one machine and had to be killed by explicit PID. Two
independent mechanisms miss them, and neither is accidental.

sweepStaleSidecars removes registration FILES whose process has already exited. It
never signals a running process, so it cannot reclaim an orphan by construction.

The self-termination guard tests process.ppid !== originalPpid every 2s, which
catches a parent that dies after startup. If the parent dies BEFORE the child reads
process.ppid, the child is already reparented to 1, originalPpid captures that, and
the comparison is false forever. The sidecar then has no parent to signal it and no
sweep that can see it.

Worse in combination: sidecar-manager.ts:121 spawns before writeSidecarRegistration,
so a parent exiting in that window leaves a process that is both unregistered and
unreclaimable. All 44 were in exactly that state — none held a session database, and
none were routable, since resolveOpencodeSender resolves only through the registry.
They could not receive anything and could be addressed by nothing.

The existing test at sidecar-e2e.test.ts:222 waits 1500ms for registration before
killing the parent, so it covers the case the guard handles. Neither startup race
is tested.

Phase 0 is confirmation, not implementation: reproduce the race, establish how a
sidecar loses its registration, census for other leak shapes, and decide whether
the fix must also reclaim sidecars already leaked elsewhere. A fix that only
prevents future leaks leaves existing ones running.

The spec keeps the boot sweep's safety property explicit: files for dead processes
only, never a signal to a running one. Reclaiming a live sidecar is an explicit,
confirming operator action that must refuse any candidate whose session is live.

No code change.
…tarted

The ppid guard compares process.ppid against a value captured at startup, so it
can only see a parent that dies afterwards. If the parent is already gone by the
time the sidecar reads its own ppid, that value is the reparented one, the
comparison is false forever, and the sidecar survives indefinitely — which is
how 44 of them accumulated here and had to be killed by explicit PID. Mechanism
reproduced before fixing (Bun 1.3.14/macOS): SIGKILL the spawner before the
child's first ppid read and the child records ppid 1 and never notices.

The parent's stdin pipe closes when the parent dies, at any point in the sidecar's
life, so EOF cannot be missed by a startup race. Exit on it.

The discriminator is isSocket(), not isFIFO(). Measured: a child spawned with
stdio ["pipe"] reports isSocket()=true, isFIFO()=false (mode 140000), while
stdio "ignore" reports isCharacterDevice()=true (/dev/null). My first attempt
checked isFIFO(), which silently disabled this guard in production and matched
nothing — the failing test caught it, not review. The check exists because every
test spawns with stdio "ignore", where stdin is /dev/null and reads EOF
immediately; an unguarded listener would kill a healthy sidecar on startup.

Also bound the stop. shutdown() set shuttingDown before awaiting sidecar.stop(),
so a stop() that never settled left the process alive and every later SIGTERM
swallowed — killable only by SIGKILL. The exit now happens on a timer rather than
on stop() settling.

The new test retries rather than asserting once: hitting the race depends on bun's
startup time, so an attempt where the sidecar never registered proves nothing and
is discarded. It only passes having observed a registration appear and then
disappear, so it cannot pass vacuously. Verified by disabling the guard — the test
fails, and passes with it restored. test/peer 211 pass / 0 fail.

Not addressed here: reclaiming sidecars already leaked elsewhere, and the operator
command. Those are separate tasks in the change.
Two peer sessions reproduced the race independently. The second narrowed the
window in a way that corrects this document: it is the duration of
startSidecar, not an abstract read of process.ppid. Registration is written from
inside startSidecar (sidecar-server.ts:156) before originalPpid is captured
(sidecar-entry.ts:93), which splits the window in two — alive-and-unregistered
before the write, alive-and-registered after it. Both are real; the sweep skips
both. All 44 observed were the first.

Also records that the discriminator is isSocket() and not isFIFO(), because an
isFIFO check reads as correct, matches nothing in production, and would have left
the guard dead. That was caught by the failing test rather than by review, which
is the only reason it is written down here.

Phase 1 is done in 7efff55; Phase 0's reproduction and registration questions
are answered. Reclamation of already-leaked sidecars is untouched.
- 5 tests covering disk fallback, synced repo exclusion, empty dir,
  missing openspec dir, and sibling discovery
- Fix querySpecsync to handle specsync returning null (not []) for
  empty results
- Add fs.existsSync check in runAuto loop; items with missing repos
  are recorded as 'skipped' with cause 'repo not found'
- Exclude skipped outcomes from ChangeOutcome[] (type only allows
  completed|quarantined)
- Add test verifying a resolvable item starts a run and completes
- Add capacity() helper that probes local providers via parentCapacity,
  returning max(1, free) to bound concurrent item processing
- Replace sequential for loop with worker pool bounded by capacity
- Add runningRepos per-repo exclusivity guard (one run per repo at a time)
- Add tests: 'concurrency follows the fleet' and 'one working tree, one run'
- Track haltsThisPass per pass in processItem
- Accumulate consecutiveHalts across passes when no gate passes
- Reset consecutiveHalts when a gate passes or a pass has no halts
- Stop run with stalled status when consecutiveHalts >= 3
- Report suspected environmental cause in the run report
- Add tests: one bad item among five, three identically broken items

All 257 loop tests pass, typecheck clean.
- Add claimedItems set to track which items have been claimed
- Claim items via claimChange before running gates
- Release claims in finally block via releaseChange
- Gate loop checks status at each iteration, so cancelled runs
  break out and release claims
- Add test: cancel stops the run and releases claims

All 258 loop tests pass, typecheck clean.
- Add mode === 'auto' to fenced condition
- Previously auto runs were only fenced when policy was 'scoped'
- Add test: authority ceiling is inherited and not widened

All 259 loop tests pass, typecheck clean.
- Report generation in runAuto builds a multi-line report from outcomes
- Each item listed with repo, change, and outcome (completed/HALTED/SKIPPED)
- Environmental guard report also includes aggregated outcomes
- Add test: aggregated report covers all items

All 260 loop tests pass, typecheck clean.
- Added everFoundItems flag to track whether any items were ever found
- If no items found and no outcomes exist, run ends with 'nothing found'
  report instead of 'drained'
- Add test: distinguishes drained from found nothing

All 261 loop tests pass, typecheck clean.
- Added --auto flag to opencode loop with documentation
- Documents that auto is fenced with QueueDenyRules
- Documents where work comes from (work source: specsync or local openspec)
- runAuto already uses getWorkItems which uses the work source

All 261 loop tests pass, typecheck clean.
- Added test: two-repository fixture drains to completion
- Creates two fixture repos with changes, runs auto mode
- Verifies both complete and report mentions both changes

All 262 loop tests pass, typecheck clean.
- Added test: halt path end-to-end
- Creates two fixture repos: one halts, other completes
- Verifies report names both changes

All 263 loop tests pass, typecheck clean.
ci(release): refuse a skein release version below the provider floor
… exits

Reviewer was right, and the criticism was of the same kind I have been making
of other checks: the test proved the sidecar terminates, not that the EOF guard
terminates it. If the parent dies after the child reads process.ppid, the
pre-existing ppid guard kills the sidecar anyway, so removing the EOF guard left
the test green. It was a real mutation check on paper and not one in fact.

Replaced with a deterministic test. The parent is this test process and stays
alive for the whole test, so process.ppid never changes and the ppid guard cannot
fire. Closing only the stdin pipe gives the child EOF and nothing else, so if the
sidecar exits and unregisters, the EOF guard is the only possible cause.
Verified: with the guard disabled this test fails, and only it fails.

Removed the timing-based test. With the fix in place the sidecar now dies so fast
after its parent exits that the test could no longer reliably observe the
registration it was asserting on, and the 4-attempt retry was passing for the
wrong reason or failing for a timing reason. Hitting that race depends on bun's
startup time and cannot be made deterministic; the new test isolates the
mechanism without depending on it at all. Suite went from 15s and flaky to 1.7s
and stable.

test/peer 211 pass / 0 fail. Typecheck clean, openspec validate passes,
fork:verify 251/251 owned, 117/117 patched.
docs(review-on-done): peer sender identity is a claim, not an authentication
fix(sidecar): a sidecar cannot outlive a parent that died before it started
feat(loop): cross-repo auto mode with work-source and drain-complete

This branch has not been deployed

No deployments
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.

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

6 participants