diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f1e24..8a23b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,20 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ## [Unreleased] +_No unreleased changes yet._ + +## [0.4.2] — 2026-07-23 + ### Added +- **`[Commands]` Top-level `Refresh Models` commands for Go and Zen (#78).** The `Refresh Models` action was previously buried inside the `OpenCode Go: Manage Provider` QuickPick, and Zen had no manual refresh path at all. Two new commands are now registered: `OpenCode Go: Refresh Models` and `OpenCode Zen: Refresh Models`, each bypassing the Manage menu and going straight to a model-list fetch. For parity, `OpenCode Zen: Manage Provider` is also added, matching the existing Go command. The new commands are especially useful when the picker is showing a stale or bundled list at startup (issue #78) and you want to force a re-fetch without opening the Manage menu. - **`[Vision]` Multimodal tool results — MCP screenshot forwarding (#77).** Images returned inside a `LanguageModelToolResultPart` (e.g. screenshots from `chrome-devtools-mcp`, `playwright-mcp`) are now forwarded to vision-capable models. Previously these images were silently dropped by the serialization layer and the model would report "I cannot see the image". Images are encoded as OpenAI-style `image_url` content parts and translated into the native multimodal shape for each transport: chat-completions (native array content), Anthropic messages (`tool_result.content: AnthropicContentBlock[]`), Google Gemini (`functionResponse.response.parts: [{inlineData}]`). Oversized images (>1 MB raw bytes) are replaced with an actionable placeholder note so a single full-page MCP screenshot can't push the request payload past the upstream limit. The Responses API cannot carry images in tool output and degrades to a placeholder note on that transport only. ### Fixed - **`[Logging]` Model registration log spam during UI refresh.** VS Code refreshes model info on roughly a 300 ms cadence during chat UI activity; each call previously produced one log line per registered model (22+ lines per call). `provideLanguageModelChatInformation` now emits a single summary line per invocation (`Models registered: count=N provider=… first=… last=…`). Output channel is dramatically cleaner during testing. - **`[Logging]` Transient model-list fetch failures no longer pop a modal warning.** OpenCode's shared gateway occasionally returns transient 400/503 responses that resolve on retry within seconds, and the previous behavior called `showWarningMessage` on every failure — including from auto-registered provider variants the user may not actively use (e.g. `OpenCode Zen (Agents)`). Failures now log to the Output channel only; the bundled `fallbackModels` snapshot keeps the picker functional. +- **`[Resilience]` Model-list fetch now tolerates transient network failures (#78).** On flaky networks (and especially on VS Code 1.129 where the new agent host raises the rate of concurrent `provideLanguageModelChatInformation` calls), a single `TypeError: fetch failed` at startup — DNS wobble, TCP reset, undici socket reuse race ([`nodejs/undici#5450`](https://github.com/nodejs/undici/issues/5450)) — caused the picker to drop to the bundled list or empty out entirely ("flash then disappear"). `fetchModels()` now (1) wraps each attempt in `AbortSignal.timeout(15_000)` so a hung connect can't stall the picker for the full undici default of 5 minutes, (2) retries up to 3 times with exponential backoff (500 ms / 1 s / 2 s) on transient errors only — `ECONNRESET`, `EAI_AGAIN`, `UND_ERR_CONNECT_TIMEOUT`, HTTP 408/429/5xx, and the generic `TypeError: fetch failed` wrapper — never on `AbortError` from VS Code's `CancellationToken` or on HTTP 4xx, (3) sends a `User-Agent` header built from the extension's `packageJSON.version` so strict gateways don't silently drop the request and so the version string can't drift again, (4) caches every successful fetch to `globalState` (`opencode.modelListCache.v1::`, TTL 1 hour) and prefers that snapshot over the bundled list when all retries fail, (5) composes the caller's `CancellationToken` with the timeout via `AbortSignal.any([...])` so a cancelled resolution tears down the in-flight fetch immediately, and (6) sends an explicit `Accept: application/json` header so SSL-inspecting corporate firewalls / VPN proxies (Zscaler, Netskope, Fortinet) don't drop the GET as an anonymous scanner — the #78 reporter sits behind a VPN + corporate firewall on Windows 11, where POST `/chat/completions` with a JSON content type was passing but the bare GET `/models` was being dropped. See `docs/issues/35-20260720-issue78-model-list-fetch-resilience.md`. ## [0.4.1] — 2026-07-15 diff --git a/README.md b/README.md index cac10a7..891d707 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,10 @@ The easiest way to manage your key is **Settings → Language Models** (gear ⚙ |---|---| | `OpenCode Go: Manage Provider` | Manage legacy API key, refresh models, test connection | | `OpenCode Go: Set API Key` | Store/update legacy OpenCode Go API key | +| `OpenCode Go: Refresh Models` | Force a fresh model-list fetch (bypasses the Manage menu) | | `OpenCode Go: Diagnostics` | Report of Go models + request history | +| `OpenCode Zen: Manage Provider` | Manage Zen API key, refresh models, test connection | +| `OpenCode Zen: Refresh Models` | Force a fresh Zen model-list fetch (bypasses the Manage menu) | | `OpenCode Zen: Diagnostics` | Report of Zen models + request history | | `OpenCode: Model Picker Diagnostics` | All registered models (Go + Zen + Copilot) side-by-side | | `OpenCode: Set Thinking Effort…` | Per-family thinking mode picker | diff --git a/docs/devlog.md b/docs/devlog.md index 034b5ff..12ef99b 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -1,5 +1,5 @@ # 🧠 OPENCODE COPILOT CHAT DEVLOG -**Branch:** `fix/issue-77-mcp-image-tool-result` | **Updated:** 2026-07-20 Asia/Jakarta | **Current Phase:** Issue #77 — MCP Tool Result Image Forwarding ✅ Fixed, pending PR +**Branch:** `fix/issue-78-model-list-fetch-resilience` | **Updated:** 2026-07-23 Asia/Jakarta | **Current Phase:** Issue #78 — Model List Fetch Resilience ✅ Fixed, PR open pending merge --- @@ -7,11 +7,38 @@ | Field | Value | |-------|-------| -| **Last Session** | 2026-07-20 | -| **Worked On** | Investigated issue #77 (MCP tool result images dropped — vision-capable models couldn't see screenshots from `chrome-devtools-mcp`). Root cause: `convertMessage()` serialized `LanguageModelToolResultPart.content` via `partToText()` which silently dropped nested image `LanguageModelDataPart` (catch-all returned `""`). Pasted image attachments worked because they hit the separate top-level image handler. Fix: rewrote the tool-result branch to walk `part.content` and emit multimodal `OpenAiContentPart[]` when an image is present; updated all 4 transports (chat-completions native, Anthropic `tool_result.content: AnthropicContentBlock[]`, Google `functionResponse.parts: [{inlineData}]`, Responses API degraded to placeholder note). Two follow-up bugs uncovered during manual testing: (a) 4.6 MB payload → upstream 400 because MCP screenshot loops accumulate full-page PNGs; fixed with `MAX_TOOL_RESULT_IMAGE_BYTES = 1_000_000` size guard; (b) model registration log spam (22 lines × ~3 calls/sec) + transient model-list fetch failures popping modal warnings — both replaced with Output-channel logs. Wrote `docs/issues/34-*` + `docs/features/12-*` + updated CHANGELOG `[Unreleased]`. All tests pass (107/107), compile clean. | -| **Stopped At** | Ready to commit on `fix/issue-77-mcp-image-tool-result` and open PR. User will push + open PR after reviewing. | -| **Next Action** | → User reviews commit → push → open PR `fix/issue-77-mcp-image-tool-result` → merge with merge commit (NEVER squash) → closes #77 automatically via `Fixes #77` in PR body. | -| **Open Issues** | (1) VS Code API gap: thread ID → session cost. (2) Qwen image quota. (3) `qwen3.6-plus-free` tool-call loop. (4) #57/#58 agent model visibility. (5) Vision proxy quota not documented in README (minor). (6) `estimateTokenCount` under-counts base64 payloads — no history-level image trimming yet (tracked in issue doc #34 limitations). | +| **Last Session** | 2026-07-23 | +| **Worked On** | Triaged issue #78 (reported by `@leiyu1980`, Windows 11 + VPN + corporate firewall, VS Code 1.129.0). Initial symptom looked like the closed #51 picker crash, but investigation (with web research into Node undici defaults + `nodejs/undici#5450` socket-reuse race + VS Code 1.129 agent host concurrency) confirmed it was a **transient network failure that `fetchModels()` never tolerated**. Built a 6-part resilience fix: (1) `AbortSignal.timeout(15_000)` per attempt, (2) up to 3 retries with exponential backoff (500ms/1s/2s) gated by `isTransientFetchError()` classifier, (3) `User-Agent` read from `packageJSON.version` at runtime (killed the recurring version drift), (4) `CancellationToken` composed via `AbortSignal.any([...])`, (5) 1-hour cached snapshot in `globalState` so failure falls back to last-known-good list instead of bundled, (6) `Accept: application/json` header after reporter's reply revealed their VPN/firewall passed POST `/chat/completions` but dropped bare GET `/models`. **Drive-by UX gap:** the `Refresh Models` command only existed as a sub-item inside `OpenCode Go: Manage Provider` and Zen had no Manage Provider at all — added 3 top-level commands for parity. Research stored in `/memories/repo/issue78-*.md`. Wrote `docs/issues/35-20260720-issue78-model-list-fetch-resilience.md` + updated CHANGELOG + README + bumped version `0.4.1 → 0.4.2`. Build clean: `opencode-copilot-chat-0.4.2.vsix` (1.06 MB, 115 files). | +| **Stopped At** | Ready to push `fix/issue-78-model-list-fetch-resilience` (3 commits: `8fcde64` resilience, `a04939c` commands, `ccfcb75` Accept header + version bump) and open PR. User will merge with merge commit (NEVER squash). | +| **Next Action** | → Push branch → open PR `Fixes #78` → user reviews & merges → closes #78 automatically → draft reply to `@leiyu1980` via `avoid-ai-writing` + `writing-framework-v4` skills (peer-to-peer tone, acknowledge wrong instruction in prior reply about Refresh command name). | +| **Open Issues** | (1) VS Code API gap: thread ID → session cost. (2) Qwen image quota. (3) `qwen3.6-plus-free` tool-call loop. (4) #57/#58 agent model visibility. (5) Vision proxy quota not documented in README (minor). (6) `estimateTokenCount` under-counts base64 payloads (tracked in issue doc #34). (7) `bundledModelMetadataSnapshot` could use a refresh — model list drift since 0.3.5. (8) Manual test of retry/cache path in Test C-D not run yet (network throttle + Network Link Conditioner). | + +--- + +## 🔬 Issue #78 — Model List Fetch Resilience — Session 2026-07-23 ✅ FIXED + +**Action:** Triaged issue #78 (reported by `@leiyu1980`). Initial misdiagnosis: looked like a regression of the closed #51 picker crash (TypeScript schema change on VS Code 1.126, fixed by PR #53). Web research into Node undici defaults (`headersTimeout=300s`, no connect timeout), `nodejs/undici#5450` (socket-reuse race under concurrent load, expected behavior per maintainers, recommend `interceptors.retry`), and VS Code 1.129 release notes (new agent host raises concurrent `provideLanguageModelChatInformation` calls) confirmed the real root cause: `fetchModels()` was built for the happy path only. + +**Branch:** `fix/issue-78-model-list-fetch-resilience` (created from `main` @ `742f899`) + +**Compile:** `npm run compile` exit 0 (run after every change) +**Build:** `vsce package` produces `opencode-copilot-chat-0.4.2.vsix` (1.06 MB, 115 files) +**Errors:** `get_errors` clean on all modified files + +**Commits (3, NEVER squash on merge):** + +1. `8fcde64` — `fix(resilience): make model-list fetch tolerant of transient network failures`. Core 5-part fix: timeout, retry, User-Agent runtime, CancellationToken threading, 1-hour cache. Contains `Fixes #78` in message. +2. `a04939c` — `feat(commands): add top-level Refresh Models commands + Zen Manage Provider`. Drive-by UX parity fix uncovered when reporter couldn't find `OpenCode Go: Refresh Models` in palette (it only existed inside `Manage Provider` QuickPick; Zen had no Manage Provider at all). +3. `ccfcb75` — `fix(resilience): send Accept header for corporate firewall compatibility (#78)`. Added after reporter's reply revealed signature mismatch (POST worked, GET failed on same host). Bumps `0.4.1 → 0.4.2`, promotes `[Unreleased]` to `[0.4.2] — 2026-07-23`. + +**Manual verification:** Test A (command visibility) PASS — all 3 new commands appear in palette. Tests B-F pending user. + +**What's NOT covered (honest caveats):** + +- VPN/firewall **hard block** to `opencode.ai` → user must set VS Code `http.proxy`. +- Gateway outage longer than ~3.5s retry budget → degrades to cache (1h) then bundled. +- undici socket-reuse race itself → upstream behavior, we tolerate it via retry but did not install a global custom dispatcher (`interceptors.retry` + `interceptors.dns` would affect every `fetch` in extension, deemed overkill for MVP). +- `FALLBACK_USER_AGENT` in test harnesses that stub `vscode.extensions.getExtension` → must bump manually when major version changes. --- diff --git a/docs/issues/35-20260720-issue78-model-list-fetch-resilience.md b/docs/issues/35-20260720-issue78-model-list-fetch-resilience.md new file mode 100644 index 0000000..0553889 --- /dev/null +++ b/docs/issues/35-20260720-issue78-model-list-fetch-resilience.md @@ -0,0 +1,263 @@ +# Fix: Resilient Model List Fetch (Issue #78) + +> **Status:** ✅ RESOLVED +> **Date:** July 20, 2026 +> **Extension version:** 0.4.1 → unreleased +> **Severity:** Medium — model picker appears empty or "flashes then disappears" after VS Code startup on flaky networks +> **Root Cause:** `fetchModels()` had no timeout, no retry, no `User-Agent` header, and no graceful cache fallback. A single transient network failure at startup (DNS wobble, TCP reset, undici socket reuse race) caused the picker to fall back to the bundled list or `return []`, producing the "flash then disappear" symptom reported on VS Code 1.129.0. +> **GitHub issue:** [#78 — `[BUG] Could not fetch OpenCode Zen (Agents) model list. Using bundled model list. fetch failed`](https://github.com/ltmoerdani/opencode-copilot-chat/issues/78) + +--- + +## Table of Contents + +1. [Summary](#1-summary) +2. [Reporter Environment](#2-reporter-environment) +3. [Investigation & Authoritative Sources](#3-investigation--authoritative-sources) +4. [Root Cause](#4-root-cause) +5. [Solution](#5-solution) +6. [Code Changes](#6-code-changes) +7. [Behavior Matrix](#7-behavior-matrix) +8. [What This Does NOT Fix](#8-what-this-does-not-fix) +9. [Verification](#9-verification) +10. [References](#10-references) + +--- + +## 1. Summary + +Reporter `@leiyu1980` (VS Code 1.129.0 + extension 0.4.1) reported that on startup, the model picker only shows the default model and that the full Zen list "sometimes flashes briefly before disappearing." Output logs showed: + +``` +Could not fetch OpenCode Go model list. Using bundled model list. fetch failed +Could not fetch OpenCode Zen (Agents) model list. Using bundled model list. fetch failed +``` + +Investigation confirmed the bug is **not** in VS Code 1.129's API surface, and **not** a regression of the closed #51 (which was a TypeScript schema crash fixed by PR #53). It is a **transient network failure** that the extension did not previously tolerate: + +- `fetchModels()` used a raw `fetch()` with no `AbortSignal`, no retry, and no `User-Agent`. +- Node's built-in `fetch` (undici) defaults to `headersTimeout=300s` and has **no connect timeout**, so a hung TCP connection could leave the picker stuck for up to 5 minutes before falling back. +- VS Code 1.129 introduced the **agent host** (a dedicated process for Copilot/Claude/Codex harnesses), which significantly increases concurrent model-resolution calls into the provider — multiplying the chance that a transient socket race (`ECONNRESET` / socket reuse) hits the picker. +- A successful fetch was never cached, so the picker fell straight from "live list" to "bundled list" (or `return []` in `provideLanguageModelChatInformation`), producing the flash/disappear UX. + +The fix adds a 15s per-attempt timeout, exponential retry (3 attempts) for transient errors, `User-Agent` propagation, a 1-hour cached snapshot in `globalState`, and respect for the VS Code `CancellationToken`. + +--- + +## 2. Reporter Environment + +| Component | Value | +|---|---| +| VS Code | **1.129.0** | +| OS | (not specified in report) | +| Extension | `ltmoerdani.opencode-copilot-chat` 0.4.1 | +| Symptom | Both `opencodego` and `opencodezen` providers fail to fetch; Zen list briefly flashes then vanishes | +| Comparison | `opencode zen list` (CLI) returns the full list correctly | + +--- + +## 3. Investigation & Authoritative Sources + +### 3.1 Node.js / undici `fetch` defaults + +Node's global `fetch` is backed by [undici](https://github.com/nodejs/undici). The default `Dispatcher` ships with: + +| Option | Default | Effect | +|---|---|---| +| `headersTimeout` | **300 s** | Wait up to 5 minutes for response headers before throwing | +| `bodyTimeout` | **300 s** | Wait up to 5 minutes between body chunks | +| connect timeout | **none** | A TCP connect that never returns can hang indefinitely | + +`TypeError: fetch failed` is undici's generic wrapper. The real cause is always attached as `error.cause` and is one of `ECONNRESET`, `ECONNREFUSED`, `EAI_AGAIN`, `UND_ERR_CONNECT_TIMEOUT`, etc. + +### 3.2 undici issue #5450 — concurrent-load socket reuse + +[`nodejs/undici#5450`](https://github.com/nodejs/undici/issues/5450) ("`TypeError: fetch failed` under concurrent load due to socket reuse / keep-alive timeout mismatch") is the same symptom. Maintainer `@metcoder95` closed it as **expected behavior**: + +> The behavior is expected as per undici queueing design … Each Socket maps to a single Client; each Client has its own request queue, when that Socket is teardown (by the remote server or by the Client itself), the Client is unusable and all its queue is also errored. +> +> The recommendation is to use either the `interceptor.retry` or the `RetryAgent` for this identified pattern. + +In other words: a transient `TypeError: fetch failed` on concurrent load is **the documented, expected undici behavior**, and consumers are expected to handle it with retry. + +### 3.3 VS Code 1.129 release notes + +[VS Code 1.129](https://code.visualstudio.com/updates/v1_129) (July 15, 2026) introduced the **agent host** — a dedicated process for agent sessions (Copilot / Claude / Codex harnesses) that can be connected to from multiple windows simultaneously. This raises the number of concurrent `provideLanguageModelChatInformation` calls into BYOK providers, which in turn raises the probability of hitting the undici socket-reuse race per unit time. + +No change to the `LanguageModelChatProvider` API itself breaks our extension; the regression is purely an increase in concurrent fetches against a flaky transport layer. + +### 3.4 Prior issue #51 (closed via PR #53) — different root cause + +[`#51`](https://github.com/ltmoerdani/opencode-copilot-chat/issues/51) shared the same log line ("Could not fetch … fetch failed") but the actual root cause was a `TypeError: Cannot read properties of undefined (reading 'charAt')` from VS Code 1.126's unified picker passing `category` as a plain string instead of `{ label, order }`. PR #53 fixed that schema mismatch. + +The remaining "spamming notification" half of #51 was mitigated by replacing `showWarningMessage` with an Output-channel log (in the unreleased MCP image PR). **#78 is a new, separate issue**: transient network resilience, not a schema crash. + +--- + +## 4. Root Cause + +`src/extension.ts` `fetchModels()` (pre-fix): + +```ts +private async fetchModels(apiKey?: string): Promise { + try { + const headers: Record = {}; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const response = await fetch(this.definition.modelsUrl, { headers }); + // ... parse + filterAvailableModels ... + } catch (error) { + this.log(`[fetchModels] ... Using bundled model list.`); + return this.filterAvailableModels(this.definition.fallbackModels); + } +} +``` + +Gaps: + +1. **No timeout** — a hung connect could hold the picker for up to 5 minutes. +2. **No retry** — a single `ECONNRESET`/`EAI_AGAIN` immediately downgraded to the bundled list. +3. **No `User-Agent`** — strict gateways can silently drop anonymous requests. (`refreshOpenCodeModelMetadata()` already sends one via the gateway-header builder, but `fetchModels` did not.) +4. **Stale `OPEN_CODE_USER_AGENT` constant** — hardcoded `"opencode-copilot-chat/0.3.6 VSCode"` while `package.json` was at `0.4.1` (drift, a recurring problem: see `docs/issues/17`). +5. **No `CancellationToken` threading** — VS Code cancelling a stale resolution (common during agent-host re-resolution) left the in-flight fetch running. +6. **No cache** — a previously fetched list was thrown away on every call, so transient failure always fell straight to bundled (or, via `provideLanguageModelChatInformation`'s `if (models.length === 0) return []`, to an empty list → "flash then disappear"). + +--- + +## 5. Solution + +Six coordinated changes in `src/extension.ts`: + +1. **Per-attempt timeout** — `AbortSignal.timeout(MODEL_LIST_FETCH_TIMEOUT_MS = 15_000)`. +2. **Exponential retry** — up to `MODEL_LIST_FETCH_MAX_RETRIES = 3` attempts with `500ms * 2^attempt` backoff (500 ms, 1 s, 2 s). Only transient errors are retried (`isTransientFetchError()` classifies by `error.cause.code`, `error.cause.name`, HTTP status 408/429/5xx, and the `TypeError: fetch failed` wrapper). +3. **`User-Agent` propagation** — `getUserAgent()` reads `context.extension.packageJSON.version` once, caches the result, and falls back to `FALLBACK_USER_AGENT` if unavailable. No more drift. +4. **`CancellationToken` threading** — `fetchModels(apiKey, token?)` composes the caller's token with the timeout signal via `AbortSignal.any([...])`. Cancellation short-circuits to a fallback and never retries. +5. **1-hour cached snapshot** — every successful fetch persists `{ ids, fetchedAt }` to `globalState` under `opencode.modelListCache.v1::`. On final failure, `loadCachedModelList()` returns the cached list if fresher than `MODEL_LIST_CACHE_TTL_MS = 1h`; only then does it fall back to bundled. +6. **`Accept: application/json` header** — added after the reporter's reply revealed that POST `/chat/completions` (with `Content-Type: application/json`) was passing through their VPN + corporate firewall on Windows 11, while the bare GET `/models` (no `Content-Type`, no `Accept`) was being dropped. SSL-inspecting proxies (Zscaler, Netskope, Fortinet) commonly treat anonymous GETs as scanner traffic. The explicit `Accept` header makes the request look like a legitimate API call. + +--- + +## 6. Code Changes + +### 6.1 File map + +| File | Change | +|---|---| +| `src/extension.ts` | Replace hardcoded `OPEN_CODE_USER_AGENT` with `getUserAgent()`; add `FALLBACK_USER_AGENT`, `MODEL_LIST_FETCH_TIMEOUT_MS`, `MODEL_LIST_FETCH_MAX_RETRIES`, `MODEL_LIST_FETCH_RETRY_BASE_MS`, `MODEL_LIST_CACHE_TTL_MS`, `MODEL_LIST_CACHE_KEY_PREFIX`; add helpers `getUserAgent()`, `isTransientFetchError()`, `sleep()`. Rewrite `OpenCodeProvider.fetchModels()`; add `cachedModelList` field + `modelListCacheKey` getter + `signalFromToken()`, `errMsg()`, `fallbackModelList()`, `loadCachedModelList()` helpers. Thread `token` from `provideLanguageModelChatInformation`. Pass stored API key in `refreshMetadataAndModels()`. | + +### 6.2 Key new constant + +```ts +const MODEL_LIST_FETCH_TIMEOUT_MS = 15_000; +const MODEL_LIST_FETCH_MAX_RETRIES = 3; +const MODEL_LIST_FETCH_RETRY_BASE_MS = 500; // 500ms, 1s, 2s +const MODEL_LIST_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour +``` + +### 6.3 Retry classification + +```ts +function isTransientFetchError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") return false; + const cause = (error as { cause?: { code?: string; name?: string } })?.cause; + const code = cause?.code ?? (error as { code?: string })?.code; + const name = cause?.name ?? (error as { name?: string })?.name; + if (code && /^E(AI_AGAIN|CONNRESET|CONNREFUSED|CONNABORTED|TIMEDOUT|HOSTUNREACH|NETUNREACH|PROTO|PIPE)$/.test(code)) return true; + if (name && /^UND_ERR_(CONNECT_TIMEOUT|SOCKET|REQUEST_TIMEOUT)$/.test(name)) return true; + if (error instanceof TypeError && /fetch failed/i.test(error.message)) return true; + const httpStatus = (error as { status?: number })?.status; + if (typeof httpStatus === "number") { + return httpStatus === 408 || httpStatus === 429 || httpStatus >= 500; + } + return false; +} +``` + +### 6.4 Fallback order on final failure + +``` +1. caller CancellationToken fired → cached (fresh) > bundled +2. non-transient HTTP 4xx → cached (fresh) > bundled +3. transient error after N retries → cached (fresh) > bundled +4. cached snapshot expired (>1h) → bundled only +5. cached snapshot absent → bundled only +``` + +--- + +## 7. Behavior Matrix + +| Scenario | Pre-fix | Post-fix | +|---|---|---| +| Healthy network | Live list | Live list (cached) | +| Slow DNS (`EAI_AGAIN`) at startup | Bundled list (after up to 5 min hang) | Live list after 1–2 retries (≤1.5 s extra) | +| Stale keep-alive socket (`ECONNRESET`) | Bundled list | Live list after 1 retry (500 ms) | +| Gateway 503 burst | Bundled list | Live list after retry, or cached if 503s persist | +| Gateway 401/403 (bad key) | Bundled list (silent) | Cached if available, else bundled; no retry on 4xx | +| VS Code cancels resolution mid-fetch | In-flight fetch keeps running | Aborted via `AbortSignal.any`, returns cached/bundled | +| Sustained outage >1h | Bundled list | Bundled list (cache TTL expired) | +| VS Code 1.129 agent-host concurrent calls | Flash/disappear race | Each call hits cache after first success; picker stable | + +--- + +## 8. What This Does NOT Fix + +- **Corporate proxy / VPN / firewall blocking `opencode.ai`**: extension cannot route around an outright block. Users must configure VS Code's `http.proxy` setting. +- **Gateway outage longer than the retry budget (~3.5 s)**: still degrades to cached (1h) then bundled. +- **Underlying undici socket-reuse race**: this is upstream behavior (issue #5450, expected per maintainers). The extension now tolerates it via retry; we did not install a global custom dispatcher (`interceptors.retry` + `interceptors.dns`) because that affects every `fetch` in the extension and was deemed overkill for this MVP fix. +- **`OPEN_CODE_USER_AGENT` drift in test harnesses** that stub `vscode.extensions.getExtension`: those fall back to `FALLBACK_USER_AGENT`, which must be bumped manually when the extension's major version changes. + +--- + +## 9. Verification + +- `npm run compile` (`tsc -p ./`) — clean, no errors. +- `get_errors` on `src/extension.ts` — no diagnostics. +- Manual test (next session, not in this commit): + - Throttle network in OS to "very slow" → confirm retry logs appear in Output channel and picker stays populated. + - Kill network during fetch → confirm `[fetchModels] ... Using cached model list` log and picker stays populated from cache. + - Trigger "Refresh Models" command with no network → confirm fallback to cache (1h) then bundled. + +--- + +## 9b. Drive-by: Top-level Refresh Models commands (parity fix) + +After the initial fix landed, the issue reporter (`@leiyu1980`) replied that they could not find `OpenCode Go: Refresh Models` in the Command Palette. Investigation revealed this was a UX gap, not a bug: + +### What was wrong + +- `Refresh Models` existed only as an **action inside the `OpenCode Go: Manage Provider` QuickPick** (`src/extension.ts` `manage()`). It was never registered as a top-level command. +- **Zen had no `Manage Provider` command at all** — only `OpenCode Zen: Diagnostics`. So a Zen user (which is the reporter's case: "Zen models flash briefly before disappearing") had zero manual refresh path via the palette. +- The README commands table only listed `Manage Provider` for Go, reinforcing the asymmetry. + +The maintainer's first reply had told the reporter to "run `OpenCode Go: Refresh Models` from the Command Palette", which was wrong. The command didn't exist at that name. + +### What changed + +Three new top-level commands are registered in `activate()` and declared in `package.json` `contributes.commands`: + +| Command | Behavior | +|---|---| +| `OpenCode Go: Refresh Models` | Skips the Manage Provider QuickPick. Goes straight to a fresh model-list fetch + `changeEmitter.fire()` so VS Code re-resolves the picker. Falls back to `setApiKey()` if no key is stored. | +| `OpenCode Zen: Manage Provider` | Parity with Go. Opens the same QuickPick (Set / Clear / Test / Refresh). | +| `OpenCode Zen: Refresh Models` | Same as the Go refresh command, scoped to Zen. | + +Implementation: a new public `refreshModels()` method on `OpenCodeProvider` wraps the private `refreshMetadataAndModels()` + `changeEmitter.fire()` + toast. `manage()`'s "Refresh Models" action now delegates to this method (single source of truth). No change to existing `opencodego.manage` behavior — backward compatible. + +### Why this belongs in the same PR + +The reporter explicitly expected these commands to exist when verifying the #78 fix. Shipping them together means one release closes the loop: the cache/retry fix keeps the picker populated automatically, and the new commands give users an explicit "force refresh" escape hatch for the cases where the auto behavior isn't enough. + +--- + +## 10. References + +- GitHub issue: [#78](https://github.com/ltmoerdani/opencode-copilot-chat/issues/78) +- Related (closed, different root cause): [#51](https://github.com/ltmoerdani/opencode-copilot-chat/issues/51) → PR [#53](https://github.com/ltmoerdani/opencode-copilot-chat/pull/53) +- undici concurrent-load issue: [`nodejs/undici#5450`](https://github.com/nodejs/undici/issues/5450) +- undici `Dispatcher` defaults (`headersTimeout`, `bodyTimeout`): [`Dispatcher.md`](https://github.com/nodejs/undici/blob/main/docs/docs/api/Dispatcher.md) +- undici built-in interceptors (`retry`, `dns`, `responseError`): [`Interceptors.md`](https://github.com/nodejs/undici/blob/main/docs/docs/api/Interceptors.md) +- Node.js global `fetch` / `AbortSignal.timeout` / `AbortSignal.any`: [`nodejs.org/api/globals`](https://nodejs.org/api/globals.html) +- VS Code 1.129 release notes (agent host): [`code.visualstudio.com/updates/v1_129`](https://code.visualstudio.com/updates/v1_129) +- OpenCode Zen gateway endpoints: [`opencode.ai/docs/zen`](https://opencode.ai/docs/zen/) and [`opencode.ai/docs/go`](https://opencode.ai/docs/go/) +- Prior drift incident (`OPEN_CODE_USER_AGENT`): `docs/issues/17-20260609-project-cleanup-immediate-bugfixes.md` +- Pattern reference (timeout already in use): `refreshOpenCodeModelMetadata()` in `src/extension.ts` diff --git a/package.json b/package.json index f937ec5..7d4dca5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "opencode-copilot-chat", "displayName": "OpenCode for Copilot Chat — BYOK 30+ AI Models", "description": "Use 30+ frontier AI models (DeepSeek V4, Kimi K2.6, GLM-5.1, Qwen3.7, MiMo V2.5, MiniMax M2.7, free Claude Opus, GPT-5.5, Gemini 3.5, Grok) in GitHub Copilot Chat. Bring Your Own Key — no Copilot Pro needed.", - "version": "0.4.1", + "version": "0.4.2", "publisher": "ltmoerdani", "license": "MIT", "icon": "media/opencodego.png", @@ -70,6 +70,10 @@ "command": "opencodego.setApiKey", "title": "OpenCode Go: Set API Key" }, + { + "command": "opencodego.refreshModels", + "title": "OpenCode Go: Refresh Models" + }, { "command": "opencodego.diagnostics", "title": "OpenCode Go: Diagnostics" @@ -78,6 +82,14 @@ "command": "opencodezen.diagnostics", "title": "OpenCode Zen: Diagnostics" }, + { + "command": "opencodezen.manage", + "title": "OpenCode Zen: Manage Provider" + }, + { + "command": "opencodezen.refreshModels", + "title": "OpenCode Zen: Refresh Models" + }, { "command": "opencodego.modelPickerDiagnostics", "title": "OpenCode: Model Picker Diagnostics" diff --git a/src/extension.ts b/src/extension.ts index e9373d2..6d3c9cd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -202,7 +202,111 @@ const KNOWN_UNAVAILABLE_MODEL_IDS = new Set([ const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000; const OPEN_CODE_CLIENT = "vscode-copilot-chat"; -const OPEN_CODE_USER_AGENT = "opencode-copilot-chat/0.3.6 VSCode"; +/** Fallback only — overridden at runtime by {@link getUserAgent} from packageJSON. */ +const FALLBACK_USER_AGENT = "opencode-copilot-chat/0.4.1 VSCode"; + +/** + * Hard ceiling for a single model-list fetch (connect + headers + body). + * + * Without this, undici's default `headersTimeout` (300s) can leave the picker + * stuck for up to 5 minutes on a hung TCP connection (issue #78). + */ +const MODEL_LIST_FETCH_TIMEOUT_MS = 15_000; +/** Max retry attempts for transient network failures during model-list fetch. */ +const MODEL_LIST_FETCH_MAX_RETRIES = 3; +/** Base delay for exponential backoff (500ms, 1s, 2s). */ +const MODEL_LIST_FETCH_RETRY_BASE_MS = 500; +/** TTL for the last successful model-list snapshot cached in globalState. */ +const MODEL_LIST_CACHE_TTL_MS = 60 * 60 * 1000; +/** globalState key suffix per vendor; full key = `${base}::`. */ +const MODEL_LIST_CACHE_KEY_PREFIX = "opencode.modelListCache.v1"; + +let cachedUserAgent: string | undefined; + +/** + * Build the User-Agent string from the extension's declared version. + * + * CONTRACT: + * - Reads `context.extension.packageJSON.version` once, caches the result. + * - Falls back to {@link FALLBACK_USER_AGENT} when version is unavailable + * (e.g. tests that construct a stub context). + * - Avoids the drift that previously hardcoded a version literal here + * (issue #78: header reported `0.3.6` while package.json was `0.4.1`). + */ +function getUserAgent(): string { + if (cachedUserAgent) return cachedUserAgent; + const version = vscode.extensions.getExtension("ltmoerdani.opencode-copilot-chat")?.packageJSON?.version; + cachedUserAgent = typeof version === "string" && version + ? `opencode-copilot-chat/${version} VSCode` + : FALLBACK_USER_AGENT; + return cachedUserAgent; +} + +/** + * Classify a fetch error as transient (worth retrying) vs. permanent. + * + * RULES: + * - Network-layer errors (DNS, TCP reset, connect timeout, socket errors) + * are transient — undici exposes the real code via `error.cause`. + * - HTTP 4xx (except 408/429) is permanent — retrying won't help. + * - HTTP 408/429/5xx is transient — gateway/rate-limit style failures. + * These arrive via the "Model list request failed (NNN): ..." message + * that `fetchModels()` throws on a non-2xx response. + * - AbortError from a CancellationToken is NEVER retried. + */ +function isTransientFetchError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") return false; + const cause = (error as { cause?: { code?: string; name?: string } } | undefined)?.cause; + const code = cause?.code ?? (error as { code?: string } | undefined)?.code; + const name = cause?.name ?? (error as { name?: string } | undefined)?.name; + // undici network error codes + if (code && /^E(AI_AGAIN|CONNRESET|CONNREFUSED|CONNABORTED|TIMEDOUT|HOSTUNREACH|NETUNREACH|PROTO|PIPE)$/.test(code)) { + return true; + } + if (name && /^UND_ERR_(CONNECT_TIMEOUT|SOCKET|REQUEST_TIMEOUT)$/.test(name)) { + return true; + } + // TypeError: fetch failed (the generic wrapper undici throws) — always retry; + // if the cause turns out to be non-transient, the inner check above handles it. + if (error instanceof TypeError && /fetch failed/i.test(error.message)) return true; + // Extract HTTP status from either an explicit `.status` field or the + // "Model list request failed (NNN): ..." message pattern. + const explicitStatus = (error as { status?: number } | undefined)?.status; + const msg = error instanceof Error ? error.message : String(error); + const msgMatch = msg.match(/\((\d{3})\)/); + const httpStatus = typeof explicitStatus === "number" ? explicitStatus : (msgMatch ? Number(msgMatch[1]) : undefined); + if (typeof httpStatus === "number") { + if (httpStatus === 408 || httpStatus === 429 || httpStatus >= 500) return true; + return false; + } + return false; +} + +/** + * Promise-based delay that rejects with AbortError if the token fires. + * + * Used to back off between model-list fetch retries without leaking + * CancellationToken subscriptions. + */ +function sleep(ms: number, token?: vscode.CancellationToken): Promise { + if (token?.isCancellationRequested) { + return Promise.reject(new DOMException("Aborted", "AbortError")); + } + return new Promise((resolve, reject) => { + let sub: vscode.Disposable | undefined; + const timer = setTimeout(() => { + sub?.dispose(); + resolve(); + }, ms); + if (token) { + sub = token.onCancellationRequested(() => { + clearTimeout(timer); + sub?.dispose(); + reject(new DOMException("Aborted", "AbortError")); + }); + } + }); +} /** Create an agent-variant provider definition that inherits URLs, models, and filters from a base. */ function providerVariant( @@ -579,7 +683,10 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("opencodego.manage", () => goProvider.manage()), vscode.commands.registerCommand("opencodego.diagnostics", () => goProvider.showDiagnostics()), vscode.commands.registerCommand("opencodego.setApiKey", () => goProvider.setApiKey()), + vscode.commands.registerCommand("opencodego.refreshModels", () => goProvider.refreshModels()), vscode.commands.registerCommand("opencodezen.diagnostics", () => zenProvider.showDiagnostics()), + vscode.commands.registerCommand("opencodezen.manage", () => zenProvider.manage()), + vscode.commands.registerCommand("opencodezen.refreshModels", () => zenProvider.refreshModels()), vscode.commands.registerCommand("opencodego.modelPickerDiagnostics", () => showModelPickerDiagnostics()), vscode.commands.registerCommand("opencodego.setThinkingEffort", () => showThinkingEffortPicker()), vscode.commands.registerCommand("opencodego.showUsageDetails", () => showUsageWebview(context)), @@ -1326,6 +1433,18 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { await clearOpenCodeModelMetadataCache(this.context); - await this.fetchModels(); + // Pass the stored API key so the gateway sees the authenticated + // (per-key) model list, not the anonymous default. + const apiKey = await this.context.secrets.get(SECRET_KEY); + await this.fetchModels(apiKey); + } + + /** + * Public entry point for the `OpenCode : Refresh Models` commands. + * + * CONTRACT: + * - Skips the Manage Provider QuickPick and goes straight to a fetch. + * - Reuses {@link refreshMetadataAndModels}, fires the change emitter so + * VS Code re-resolves the picker, and surfaces an informational toast. + * - On missing API key, falls back to {@link setApiKey} (same as Manage). + * + * Background: this was added after issue #78 revealed that "Refresh Models" + * was only reachable as a sub-item inside `OpenCode Go: Manage Provider` + * (and Zen had no manual refresh path at all). The top-level command matches + * what users naturally type in the Command Palette. + */ + async refreshModels(): Promise { + const apiKey = await this.context.secrets.get(SECRET_KEY); + if (!apiKey) { + await this.setApiKey(); + return; + } + await this.refreshMetadataAndModels(); + this.changeEmitter.fire(); + vscode.window.showInformationMessage(`${this.definition.displayName} models refreshed.`); } async manage(): Promise { @@ -1548,9 +1695,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { @@ -1707,7 +1852,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { - try { - const headers: Record = {}; - if (apiKey) { - headers["Authorization"] = `Bearer ${apiKey}`; - } - const response = await fetch(this.definition.modelsUrl, { headers }); + /** + * Fetch the live model list from the OpenCode gateway. + * + * CONTRACT: + * - Resilient to transient network failures (DNS, TCP reset, connect + * timeout, 5xx, 429): retries up to {@link MODEL_LIST_FETCH_MAX_RETRIES} + * times with exponential backoff. See {@link isTransientFetchError}. + * - Hard timeout of {@link MODEL_LIST_FETCH_TIMEOUT_MS} per attempt — + * undici's default 300s `headersTimeout` is far too long for the picker + * (issue #78: picker appeared stuck for minutes on hung TCP). + * - Sends `User-Agent` ({@link getUserAgent}) so strict gateways don't + * silently drop the request. + * - On final failure, prefers the last successful snapshot (cached in + * globalState, TTL {@link MODEL_LIST_CACHE_TTL_MS}) over the bundled + * `fallbackModels`, so transient failures don't make the picker "flash + * then disappear" when VS Code 1.129's agent host re-resolves frequently. + * - Respects the VS Code CancellationToken: bails early on abort, never + * retries an aborted request. + */ + private async fetchModels( + apiKey?: string, + token?: vscode.CancellationToken, + ): Promise { + if (token?.isCancellationRequested) return this.fallbackModelList(); + + // Explicit Accept + User-Agent make this look like a legitimate API call + // rather than an anonymous scanner. Some corporate firewalls / SSL + // inspection proxies (Zscaler, Netskope, Fortinet) drop bare GETs that + // lack these headers even when the host is allow-listed. Issue #78 + // reporter sits behind a VPN + corporate firewall on Windows 11. + const headers: Record = { + "User-Agent": getUserAgent(), + "Accept": "application/json", + }; + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; + } - if (!response.ok) { - throw new Error(`Model list request failed (${response.status}): ${response.statusText}`); + let lastError: unknown; + for (let attempt = 0; attempt <= MODEL_LIST_FETCH_MAX_RETRIES; attempt++) { + if (token?.isCancellationRequested) { + return this.fallbackModelList(); } + try { + // Compose the per-request abort with the caller's cancellation token + // so either one tears down the in-flight fetch. + const timeoutSignal = AbortSignal.timeout(MODEL_LIST_FETCH_TIMEOUT_MS); + const signal = token + ? AbortSignal.any([timeoutSignal, this.signalFromToken(token)]) + : timeoutSignal; + + const response = await fetch(this.definition.modelsUrl, { headers, signal }); + if (!response.ok) { + throw new Error(`Model list request failed (${response.status}): ${response.statusText}`); + } + const data = await response.json() as ModelListResponse; + this.replaceLiveModelMetadata(data.data); + const ids = data.data + ?.map((model) => model.id) + .filter((id): id is string => typeof id === "string" && id.length > 0) + .filter((id) => this.definition.filterModel?.(id) ?? true); + + const resolved = this.filterAvailableModels(ids?.length ? ids : this.definition.fallbackModels); + const filtered = await resolved; + // Persist the successful snapshot for future fallback coverage. + this.cachedModelList = { ids: filtered, fetchedAt: Date.now() }; + void this.context.globalState.update(this.modelListCacheKey, this.cachedModelList); + return filtered; + } catch (error) { + lastError = error; + // 1. If the caller's cancellation token fired, never retry — bail. + if (token?.isCancellationRequested) { + return this.fallbackModelList(); + } + // 2. Classify the error. Timeout (AbortError without token cancel) + // and transient network errors are retryable; HTTP 4xx is not. + const aborted = error instanceof DOMException && error.name === "AbortError"; + const transient = aborted || isTransientFetchError(error); + // 3. On final attempt or non-transient error, fall through to + // cache/bundled fallback below. + if (!transient || attempt === MODEL_LIST_FETCH_MAX_RETRIES) { + break; + } + const backoff = MODEL_LIST_FETCH_RETRY_BASE_MS * Math.pow(2, attempt); + this.log(`[fetchModels] ${this.definition.displayName}: transient error (attempt ${attempt + 1}/${MODEL_LIST_FETCH_MAX_RETRIES + 1}): ${this.errMsg(error)}. Retrying in ${backoff}ms.`); + try { + await sleep(backoff, token); + } catch { + // Cancellation during backoff — bail to fallback. + return this.fallbackModelList(); + } + } + } + + // Final failure: prefer cached snapshot (still fresh), then bundled list. + const cached = this.loadCachedModelList(); + if (cached) { + this.log(`[fetchModels] ${this.definition.displayName}: ${this.errMsg(lastError)}. Using cached model list (${cached.ids.length} models, fetched ${new Date(cached.fetchedAt).toISOString()}).`); + return this.filterAvailableModels(cached.ids); + } + this.log(`[fetchModels] ${this.definition.displayName}: ${this.errMsg(lastError)}. Using bundled model list (${this.definition.fallbackModels.length} models).`); + return this.filterAvailableModels(this.definition.fallbackModels); + } - const data = await response.json() as ModelListResponse; - this.replaceLiveModelMetadata(data.data); - const ids = data.data - ?.map((model) => model.id) - .filter((id): id is string => typeof id === "string" && id.length > 0) - .filter((id) => this.definition.filterModel?.(id) ?? true); + /** Bundle the cancellation semantics of a VS Code token into an AbortSignal. */ + private signalFromToken(token: vscode.CancellationToken): AbortSignal { + const controller = new AbortController(); + if (token.isCancellationRequested) { + controller.abort(); + } else { + const sub = token.onCancellationRequested(() => { + controller.abort(); + sub.dispose(); + }); + } + return controller.signal; + } - return this.filterAvailableModels(ids?.length ? ids : this.definition.fallbackModels); - } catch (error) { - // CONTRACT: Model list fetch failures are non-fatal — we always fall - // back to the bundled `fallbackModels` snapshot baked into the - // extension. VS Code refreshes model info frequently (~every 300ms - // during UI activity), and OpenCode's shared gateway occasionally - // returns transient 400/503 responses that resolve on retry within - // seconds. Showing a modal warning on every transient failure spams - // the user (especially for AGENT_* variants they may not even use). - // - // Instead of showWarningMessage, log to the Output channel so the - // failure is still diagnosable without polluting the UI. The fallback - // model list keeps the picker fully functional. - const message = error instanceof Error ? error.message : String(error); - this.log(`[fetchModels] ${this.definition.displayName}: ${message}. Using bundled model list (${this.definition.fallbackModels.length} models).`); - return this.filterAvailableModels(this.definition.fallbackModels); + private errMsg(error: unknown): string { + if (error instanceof Error) { + const cause = (error as { cause?: { code?: string; name?: string; message?: string } }).cause; + return cause?.code ? `${error.message} [${cause.code}]` : error.message; } + return String(error); + } + + /** + * Resolve the model list to use when the fetch path is short-circuited + * (cancellation, early abort). Prefers a fresh cached snapshot over bundled. + */ + private fallbackModelList(): Promise { + const cached = this.loadCachedModelList(); + if (cached) { + return this.filterAvailableModels(cached.ids); + } + return this.filterAvailableModels(this.definition.fallbackModels); + } + + /** + * Read the last successful fetch from in-memory cache or globalState. + * Returns undefined when absent or past {@link MODEL_LIST_CACHE_TTL_MS}. + */ + private loadCachedModelList(): { ids: string[]; fetchedAt: number } | undefined { + if (this.cachedModelList) { + const fresh = Date.now() - this.cachedModelList.fetchedAt < MODEL_LIST_CACHE_TTL_MS; + if (fresh) return this.cachedModelList; + } + const stored = this.context.globalState.get<{ ids: string[]; fetchedAt: number }>(this.modelListCacheKey); + if (stored && Array.isArray(stored.ids) && typeof stored.fetchedAt === "number") { + const fresh = Date.now() - stored.fetchedAt < MODEL_LIST_CACHE_TTL_MS; + if (fresh) { + this.cachedModelList = stored; + return stored; + } + } + return undefined; } private async filterAvailableModels(modelIds: string[]): Promise { @@ -2815,7 +3080,7 @@ function buildOpenCodeRequestHeaders( "x-opencode-session": sessionId, "x-opencode-request": requestId, "x-opencode-client": OPEN_CODE_CLIENT, - "User-Agent": OPEN_CODE_USER_AGENT, + "User-Agent": getUserAgent(), }; }