fix(resilience): tolerate transient network failures in model-list fetch (#78)#81
Merged
Merged
Conversation
…failures On flaky networks (and especially on VS Code 1.129 where the new agent host raises the rate of concurrent provideLanguageModelChatInformation calls), a single transient `TypeError: fetch failed` at startup — DNS wobble, TCP reset, undici socket reuse race (nodejs/undici#5450) — caused the picker to drop to the bundled list or empty out entirely ("flash then disappear", issue #78). fetchModels() now: - wraps each attempt in AbortSignal.timeout(15_000) so a hung connect can't stall the picker for undici's default 5 minutes - retries up to 3 times with exponential backoff (500ms / 1s / 2s) on transient errors only — ECONNRESET, EAI_AGAIN, UND_ERR_CONNECT_TIMEOUT, HTTP 408/429/5xx, and the generic TypeError: fetch failed wrapper. Never retries on AbortError from VS Code's CancellationToken or on HTTP 4xx - sends a User-Agent header built from the extension's packageJSON.version so strict gateways don't silently drop the request and the version string can't drift again (previously hardcoded 0.3.6 while package.json was 0.4.1) - caches every successful fetch to globalState (opencode.modelListCache.v1::<vendor>, TTL 1 hour) and prefers that snapshot over the bundled list when all retries fail - composes the caller's CancellationToken with the timeout via AbortSignal.any([...]) so a cancelled resolution tears down the in-flight fetch immediately Drive-by: refreshMetadataAndModels() now passes the stored API key to fetchModels() so the gateway returns the per-key personalized model list (previously fetched anonymously). Verification: - npm run compile clean - get_errors clean on src/extension.ts, CHANGELOG.md, new doc Docs: docs/issues/35-20260720-issue78-model-list-fetch-resilience.md Fixes #78
…ovider Drive-by fix uncovered while triaging #78 reply: the reporter could not find "OpenCode Go: Refresh Models" in the Command Palette because it was never registered as a top-level command. It only existed as an action inside the "OpenCode Go: Manage Provider" QuickPick. Zen had it worse: no Manage Provider command at all, so a Zen user had zero manual refresh path via the palette. This adds three new top-level commands: - OpenCode Go: Refresh Models — skips the Manage menu, fetches straight away - OpenCode Zen: Manage Provider — parity with the Go Manage menu - OpenCode Zen: Refresh Models — same as Go refresh, scoped to Zen Implementation: new public refreshModels() method on OpenCodeProvider wraps the existing refreshMetadataAndModels() + changeEmitter.fire() + toast. The "Refresh Models" action inside manage() now delegates to this method so there is a single source of truth. No behavior change to existing opencodego.manage. Verification: - npm run compile clean - get_errors clean on all 5 modified files Docs: - CHANGELOG [Unreleased] > Added - README commands table updated (Go/Zen Refresh + Zen Manage) - docs/issues/35-...md > new section 9b explaining the parity fix Refs #78
…ity (#78) The #78 reporter's reply revealed a signature I had not considered: on their machine (Windows 11 + VPN + corporate firewall), POST requests to /chat/completions with Content-Type: application/json were succeeding while the bare GET to /models was being dropped — even though both share the same host. That points at SSL-inspecting proxies (Zscaler, Netskope, Fortinet) which commonly treat anonymous GETs without Accept or Content-Type as scanner traffic. fetchModels() now sends an explicit Accept: application/json header so the request looks like a legitimate API call, matching what testConnection() and the chat transport already send. Also bumps the version to 0.4.2 and promotes [Unreleased] to a released section dated 2026-07-23, since this is the last change before we cut the release. Verification: - npm run compile clean - vsce package builds opencode-copilot-chat-0.4.2.vsix (1.06 MB, 115 files) - get_errors clean on all 4 modified files Docs: - CHANGELOG: new [0.4.2] section, item (6) added to the [Resilience] entry - docs/issues/35-...md: solution section now lists 6 coordinated changes Refs #78
ltmoerdani
marked this pull request as ready for review
July 22, 2026 21:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Fixes #78. Reporter
@leiyu1980(Windows 11 + VPN + corporate firewall, VS Code 1.129.0, extension 0.4.1) saw the Zen model list "flash briefly before disappearing" at startup, with both Go and Zen loggingTypeError: fetch failed.Why
Initial triage looked like a regression of the closed #51 picker crash (TypeScript schema change on VS Code 1.126, fixed by PR #53). Wrong instinct. Web research confirmed the real root cause:
fetch(undici) defaults toheadersTimeout=300swith no connect timeout. A single hung TCP connect could stall the picker for up to 5 minutes.nodejs/undici#5450documents that under concurrent load, undici's socket-reuse + keep-alive race producesTypeError: fetch failed. Maintainer response: "expected behavior, use retries."provideLanguageModelChatInformationcalls into BYOK providers — multiplying the chance of hitting that race per unit time.fetchModels()was written for the happy path only: no timeout, no retry, noUser-Agent, no graceful cache fallback. One transient failure → bundled list or empty picker ("flash then disappear").After the reporter replied, a second signature surfaced: their VPN/firewall was passing POST
/chat/completions(withContent-Type: application/json) while dropping the bare GET/models(no headers). That points at SSL-inspecting proxies (Zscaler / Netskope / Fortinet) which treat anonymous GETs as scanner traffic.How
Six coordinated changes in
src/extension.ts:AbortSignal.timeout(15_000)so a hung connect can't hold the picker hostage for undici's 5-minute default.isTransientFetchError(). Retries only onECONNRESET,EAI_AGAIN,UND_ERR_CONNECT_TIMEOUT, HTTP 408/429/5xx, and the genericTypeError: fetch failedwrapper. Never onAbortErrorfrom the caller'sCancellationTokenor on HTTP 4xx.User-Agentat runtime —getUserAgent()readscontext.extension.packageJSON.versiononce, caches it, falls back toFALLBACK_USER_AGENTin test harnesses. Kills the recurring drift (header said0.3.6while package.json said0.4.1).CancellationTokenthreading —fetchModels(apiKey, token?)composes the caller's token with the timeout signal viaAbortSignal.any([...]). Cancellation short-circuits to a fallback and never retries.{ ids, fetchedAt }toglobalStateunderopencode.modelListCache.v1::<vendor>. On final failure, prefers the cached snapshot over the bundledfallbackModels. This is what kills the "flash then disappear" UX.Accept: application/jsonheader — makes the GET look like a legitimate API call instead of an anonymous scanner, matching whattestConnection()and the chat transports already send. Specifically targeted at the reporter's VPN + corporate firewall signature.Drive-by UX fix
While replying to the reporter, I told them to "run
OpenCode Go: Refresh Modelsfrom the Command Palette". That command didn't exist.Refresh Modelswas only reachable as a sub-item inside theOpenCode Go: Manage ProviderQuickPick, and Zen had noManage Providercommand at all — a Zen user (which is the reporter's case) had zero manual refresh path via the palette.Three new top-level commands are now registered:
OpenCode Go: Refresh Models— skips the Manage menu, fetches straight awayOpenCode Zen: Manage Provider— parity with GoOpenCode Zen: Refresh Models— same as Go refresh, scoped to ZenImplementation: new public
refreshModels()method onOpenCodeProviderwraps the existing privaterefreshMetadataAndModels()+changeEmitter.fire()+ toast. The "Refresh Models" action insidemanage()now delegates to this method (single source of truth). No behavior change to existingopencodego.manage.What this does NOT fix (honest caveats)
opencode.ai→ user must set VS Codehttp.proxy. Documented in README and in the issue reply.interceptors.retry+interceptors.dns) because that affects everyfetchin the extension and was deemed overkill for this MVP fix.Verification
npm run compileclean (after every change)get_errorsclean on all modified filesvsce packagebuildsopencode-copilot-chat-0.4.2.vsix(1.06 MB, 115 files)Docs
docs/issues/35-20260720-issue78-model-list-fetch-resilience.md— root-cause investigation, 10 authoritative references, behavior matrix, what this does NOT fixCHANGELOG.md— new[0.4.2] — 2026-07-23section (released), with[Resilience]entry listing all 6 changes and[Commands]entry for the parity fixREADME.md— commands table updated (Go/Zen Refresh + Zen Manage)docs/devlog.md— session handoff + new section for issue [BUG] Could not fetch OpenCode Zen (Agents) model list. Using bundled model list. fetch failed #78Commits (3 — please merge with
--merge, never--squash)8fcde64—fix(resilience): make model-list fetch tolerant of transient network failures(containsFixes #78→ auto-closes on merge)a04939c—feat(commands): add top-level Refresh Models commands + Zen Manage Provider2ab0c31—fix(resilience): send Accept header for corporate firewall compatibility (#78)+ version bump0.4.1 → 0.4.2Notes for review
isTransientFetchError()classifier parses HTTP status from both an explicit.statusfield and the"Model list request failed (NNN): ..."message string, becausefetchModels()throws a plainError(not a typed error) on non-2xx responses.AbortSignal.any()andAbortSignal.timeout()require Node 20.3+, which VS Code's^1.125.0engine floor guarantees.Refresh Modelscommand + cache behavior) can land before merge.Fixes #78