From 3124114da1cb0b514b09eca598e8329e19ee8a5a Mon Sep 17 00:00:00 2001 From: ikkadecibel-blip <344406772+ikkadecibel-blip@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:08:53 +0000 Subject: [PATCH 1/4] fix(dashboard): never start client-version lookups from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider registry and the client-identity constants are bundled into the dashboard, so the sync version getters run in the browser too. Their background refreshes were guarded by `typeof process === "undefined"` (or not at all), but Next.js gives browser code a `process` object. Every full dashboard page load therefore fired anonymous lookups from the viewer's browser: the Codex and Zed GitHub release APIs (60 requests/hour per IP, the same budget the server's own refresh needs) and the npm registry for Claude Code, Copilot, Gemini, Qwen, CodeBuddy, Kimi and OpenCode. Once the GitHub budget ran out they showed up as "403 rate limit exceeded" in the console. - Add canStartBackgroundRefresh() (src/shared/utils/backgroundRefresh.ts): true only in a server process (no `window`, `process` present). - Gate the eight getter-triggered refreshes on it; the server path is unchanged. - Codex: a failed release lookup cached nothing, so every hot-path call retried it while rate-limited. Record the failure and wait CODEX_VERSION_RETRY_AFTER_FAILURE_MS (15 min) before the next attempt. Tests: tests/unit/background-version-refresh-browser.test.ts — for each getter, no fetch with a browser `window` present (all nine failed before the fix) plus a server-side control that still refreshes; and a Codex test that a failed lookup is not retried on the next calls (5 retries before, 0 after) but is retried after the window. --- changelog.d/fixes/browser-version-lookups.md | 1 + open-sse/config/codexClient.ts | 17 +- open-sse/config/providerHeaderProfiles.ts | 10 +- .../providers/registry/kimi/coding/runtime.ts | 4 +- open-sse/executors/zedClientVersion.ts | 9 +- open-sse/utils/opencodeHeaders.ts | 8 +- src/shared/constants/claudeCodeClient.ts | 4 +- src/shared/constants/geminiCliClient.ts | 4 +- src/shared/utils/backgroundRefresh.ts | 15 ++ ...background-version-refresh-browser.test.ts | 179 ++++++++++++++++++ 10 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/browser-version-lookups.md create mode 100644 src/shared/utils/backgroundRefresh.ts create mode 100644 tests/unit/background-version-refresh-browser.test.ts diff --git a/changelog.d/fixes/browser-version-lookups.md b/changelog.d/fixes/browser-version-lookups.md new file mode 100644 index 000000000000..ad36ffe4b487 --- /dev/null +++ b/changelog.d/fixes/browser-version-lookups.md @@ -0,0 +1 @@ +- **fix(dashboard):** Stop the dashboard from looking up client versions (Codex and Zed on the GitHub API; Claude Code, Copilot, Gemini, Qwen, CodeBuddy, Kimi and OpenCode on npm) from the viewer's browser on every page load. Next.js gives browser bundles a `process` object, so the `typeof process` guards never stopped them; they spent the per-IP GitHub API budget the server needs and surfaced as "403 rate limit exceeded" in the console. A failed Codex release lookup now also waits 15 minutes before retrying instead of retrying on every call. diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index eb49df800d04..fda2f8891745 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { SafeOutboundFetchOptions } from "@/shared/network/safeOutboundFetch"; +import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; import { CODEX_CLI_RS_ORIGINATOR, DEFAULT_CODEX_CLIENT_VERSION, @@ -53,6 +54,10 @@ type FetchLike = typeof fetch; let versionCache: CodexVersionCache | null = null; let versionInFlight: Promise | null = null; +// A failed lookup caches nothing, so without this every hot-path call would retry it — +// against an anonymous GitHub API budget of 60 requests/hour per IP. +let versionLookupFailedAt = 0; +export const CODEX_VERSION_RETRY_AFTER_FAILURE_MS = 15 * 60 * 1000; function describeError(error: unknown): string { if (error instanceof Error) return error.message; @@ -156,6 +161,9 @@ export function resolveCodexClientVersion(fetchImpl: FetchLike = fetch): Promise const version = pickCodexVersionAtLeastPin(fetched ?? versionCache?.version ?? null); if (fetched) { versionCache = { fetchedAt: Date.now(), version }; + versionLookupFailedAt = 0; + } else { + versionLookupFailedAt = Date.now(); } return version; })(); @@ -179,6 +187,7 @@ export function seedCodexClientVersionCache(version: string, fetchedAt = Date.no export function clearCodexClientVersionCache(): void { versionCache = null; versionInFlight = null; + versionLookupFailedAt = 0; } export function getCodexClientVersion(): string { @@ -191,7 +200,13 @@ export function getCodexClientVersion(): string { ) { return cachedCodexVersion; } - if (!process.env.NODE_TEST_CONTEXT && !versionCache && !versionInFlight) { + if ( + canStartBackgroundRefresh() && + !process.env.NODE_TEST_CONTEXT && + !versionCache && + !versionInFlight && + Date.now() - versionLookupFailedAt >= CODEX_VERSION_RETRY_AFTER_FAILURE_MS + ) { void resolveCodexClientVersion(); } return getCachedCodexClientVersion(); diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index f12abffb7722..f865be0aaf9c 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -1,5 +1,6 @@ import { getAntigravityContentHeaders } from "../services/antigravityHeaders.ts"; import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; +import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; // GitHub Copilot request identity. Ported to match the GitHub Copilot CLI // (`copilot` npm package) wire identity that Hermes captured live, NOT the @@ -125,7 +126,12 @@ export function getGitHubCopilotCliVersion(): string { SAFE_COPILOT_VERSION_PATTERN ); if (override) return override; - if (!process.env.NODE_TEST_CONTEXT && !readFreshCopilotVersionCache() && !copilotVersionInFlight) + if ( + canStartBackgroundRefresh() && + !process.env.NODE_TEST_CONTEXT && + !readFreshCopilotVersionCache() && + !copilotVersionInFlight + ) void resolveGitHubCopilotCliVersion(); return pickCopilotVersionAtLeastPin(readFreshCopilotVersionCache()); } @@ -263,7 +269,7 @@ function readFreshVersionCache( } function shouldAutoRefreshCliVersion(): boolean { - if (typeof process === "undefined") return false; + if (!canStartBackgroundRefresh()) return false; return !process.env.NODE_TEST_CONTEXT; } diff --git a/open-sse/config/providers/registry/kimi/coding/runtime.ts b/open-sse/config/providers/registry/kimi/coding/runtime.ts index 543e53bb7eaa..f61e2bbf2d2f 100644 --- a/open-sse/config/providers/registry/kimi/coding/runtime.ts +++ b/open-sse/config/providers/registry/kimi/coding/runtime.ts @@ -1,3 +1,5 @@ +import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; + export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1"; export const KIMI_CODING_MODELS_URL = `${KIMI_CODING_BASE_URL}/models`; export const KIMI_CODING_OPENAI_URL = `${KIMI_CODING_BASE_URL}/chat/completions`; @@ -122,7 +124,7 @@ export function getKimiCodeCliVersion(): string { const override = readEnvOverride(); if (override) return override; if ( - typeof process !== "undefined" && + canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCache() && !inFlight diff --git a/open-sse/executors/zedClientVersion.ts b/open-sse/executors/zedClientVersion.ts index 00f77e8fd782..bf2ffaae620e 100644 --- a/open-sse/executors/zedClientVersion.ts +++ b/open-sse/executors/zedClientVersion.ts @@ -7,6 +7,8 @@ * executor config.appVersion still wins over both. */ +import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; + export const ZED_CLIENT_VERSION = "0.200.0"; const ZED_RELEASE_URL = "https://api.github.com/repos/zed-industries/zed/releases/latest"; @@ -54,7 +56,12 @@ function readFreshCache(): string | null { * itself never waits on the network. */ export function getZedClientVersion(): string { - if (typeof process !== "undefined" && !process.env.NODE_TEST_CONTEXT && !readFreshCache() && !inFlight) { + if ( + canStartBackgroundRefresh() && + !process.env.NODE_TEST_CONTEXT && + !readFreshCache() && + !inFlight + ) { void refreshZedClientVersion(); } return pickAtLeastPin(readFreshCache()); diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index e7d0388405b4..21d56aa6df3a 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, randomUUID } from "crypto"; +import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; import { setUserAgentHeader } from "../executors/base/headers.ts"; import { generateSessionId } from "../services/sessionManager.ts"; import { @@ -144,7 +145,12 @@ export function resolveOpencodeCliDefaults( // throws); the default below reads the cache synchronously so synthesis never blocks. // Skipped under test runners: their globalThis.fetch stubs count dispatches, and the // registry lookup would be counted as one (same guard as adobeFireflySession). - if (!process.env.NODE_TEST_CONTEXT && !process.env.VITEST && process.env.NODE_ENV !== "test") { + if ( + canStartBackgroundRefresh() && + !process.env.NODE_TEST_CONTEXT && + !process.env.VITEST && + process.env.NODE_ENV !== "test" + ) { void refreshOpencodeCliVersion(); } const synthesizedUA = v2Fingerprint diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts index 5273cc6abf3e..2a36af619e25 100644 --- a/src/shared/constants/claudeCodeClient.ts +++ b/src/shared/constants/claudeCodeClient.ts @@ -12,6 +12,8 @@ * uses that version when it is newer than the pin. The env override wins over * both. The lookup is fire-and-forget; a cold or failed fetch stays on the pin. */ +import { canStartBackgroundRefresh } from "../utils/backgroundRefresh"; + export const CLAUDE_CODE_CLIENT_VERSION = "2.1.280"; export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1e2"; export const CLAUDE_CODE_CLIENT_BILLING_VERSION = `${CLAUDE_CODE_CLIENT_VERSION}.${CLAUDE_CODE_CLIENT_BUILD_REVISION}`; @@ -74,7 +76,7 @@ function readFreshCache(): string | null { } function shouldAutoRefreshClaudeCodeVersion(): boolean { - if (typeof process === "undefined") return false; + if (!canStartBackgroundRefresh()) return false; return !process.env.NODE_TEST_CONTEXT && !process.env.VITEST && process.env.NODE_ENV !== "test"; } diff --git a/src/shared/constants/geminiCliClient.ts b/src/shared/constants/geminiCliClient.ts index 06ab80ef3502..161fa05f4ec8 100644 --- a/src/shared/constants/geminiCliClient.ts +++ b/src/shared/constants/geminiCliClient.ts @@ -8,6 +8,8 @@ * version when it is a dotted triple newer than the pin. The lookup is * fire-and-forget; a cold or failed fetch stays on the pin. */ +import { canStartBackgroundRefresh } from "../utils/backgroundRefresh"; + export const GEMINI_CLI_CLIENT_VERSION = "0.1.0"; export const GEMINI_CLI_USER_AGENT_SUFFIX = "(linux; x64)"; @@ -52,7 +54,7 @@ function readFreshCache(): string | null { } function shouldAutoRefreshGeminiCliVersion(): boolean { - if (typeof process === "undefined") return false; + if (!canStartBackgroundRefresh()) return false; return !process.env.NODE_TEST_CONTEXT; } diff --git a/src/shared/utils/backgroundRefresh.ts b/src/shared/utils/backgroundRefresh.ts new file mode 100644 index 000000000000..5ee43487135c --- /dev/null +++ b/src/shared/utils/backgroundRefresh.ts @@ -0,0 +1,15 @@ +/** + * Whether this runtime may start a background network refresh (a client-version lookup + * against GitHub or the npm registry). + * + * Only a server process may. The provider registry and the client-identity constants are + * also bundled into the dashboard, and Next.js gives browser code a `process` object, so a + * `typeof process` check alone lets every page load fire anonymous lookups from the + * viewer's browser: they spend the per-IP GitHub API budget the server's own refresh needs + * and surface as "403 rate limit exceeded" in the console. A browser has no use for the + * result — the versions only shape headers on upstream requests, which the server sends. + */ +export function canStartBackgroundRefresh(): boolean { + if (typeof window !== "undefined") return false; + return typeof process !== "undefined"; +} diff --git a/tests/unit/background-version-refresh-browser.test.ts b/tests/unit/background-version-refresh-browser.test.ts new file mode 100644 index 000000000000..a94b2a2f6ac6 --- /dev/null +++ b/tests/unit/background-version-refresh-browser.test.ts @@ -0,0 +1,179 @@ +/** + * Background client-version refreshes must never start from a browser bundle. + * + * The provider registry is bundled into the dashboard, so the sync version getters + * below run in the browser too. Their guards checked `typeof process === "undefined"` + * (or nothing), but Next.js gives browser code a `process` object, so every full page + * load fired anonymous lookups against api.github.com (60 requests/hour per IP, shared + * with the server's own refresh) and the npm registry, and surfaced as + * "403 rate limit exceeded" in the dashboard console once the GitHub budget ran out. + * + * Each getter is checked twice: with a browser `window` present no fetch may start, + * and without one the same getter must still start its refresh (so the browser case + * is not passing just because the refresh is skipped for another reason). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Installed before any import: some modules capture `fetch` at load time. +const fetchCalls: string[] = []; +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: string | URL | Request) => { + fetchCalls.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url + ); + return new Response("{}", { status: 503 }); +}) as typeof fetch; + +const codex = await import("../../open-sse/config/codexClient.ts"); +const profiles = await import("../../open-sse/config/providerHeaderProfiles.ts"); +const kimi = await import("../../open-sse/config/providers/registry/kimi/coding/runtime.ts"); +const zed = await import("../../open-sse/executors/zedClientVersion.ts"); +const opencode = await import("../../open-sse/utils/opencodeHeaders.ts"); +const claude = await import("../../src/shared/constants/claudeCodeClient.ts"); +const gemini = await import("../../src/shared/constants/geminiCliClient.ts"); + +type Getter = { + name: string; + host: RegExp; + call: () => unknown; + reset: () => void; +}; + +const GETTERS: Getter[] = [ + { + name: "Codex CLI (GitHub releases)", + host: /api\.github\.com\/repos\/openai\/codex/, + call: () => codex.getCodexClientVersion(), + reset: () => codex.clearCodexClientVersionCache(), + }, + { + name: "Zed (GitHub releases)", + host: /api\.github\.com\/repos\/zed-industries\/zed/, + call: () => zed.getZedClientVersion(), + reset: () => zed.resetZedClientVersionCache(), + }, + { + name: "GitHub Copilot CLI (npm)", + host: /registry\.npmjs\.org\/@github\/copilot/, + call: () => profiles.getGitHubCopilotCliVersion(), + reset: () => profiles.resetGitHubCopilotCliVersionCache(), + }, + { + name: "Qwen Code (npm)", + host: /registry\.npmjs\.org\/@qwen-code/, + call: () => profiles.getQwenCliVersion(), + reset: () => profiles.resetQwenCliVersionCache(), + }, + { + name: "CodeBuddy CN (npm)", + host: /registry\.npmjs\.org\/@tencent-ai\/codebuddy-code/, + call: () => profiles.getCodeBuddyCnUserAgent(), + reset: () => profiles.resetCodeBuddyCnVersionCache(), + }, + { + name: "Kimi Code CLI (npm)", + host: /registry\.npmjs\.org\/@moonshot-ai\/kimi-code/, + call: () => kimi.getKimiCodeCliVersion(), + reset: () => kimi.resetKimiCodeCliVersionCache(), + }, + { + name: "OpenCode CLI (npm)", + host: /registry\.npmjs\.org\/opencode-ai/, + call: () => opencode.resolveOpencodeCliDefaults("opencode", false), + reset: () => {}, + }, + { + name: "Claude Code (npm)", + host: /registry\.npmjs\.org\/@anthropic-ai\/claude-code/, + call: () => claude.getClaudeCodeClientVersion(), + reset: () => claude.resetClaudeCodeClientVersionCache(), + }, + { + name: "Gemini CLI (npm)", + host: /registry\.npmjs\.org\/@google\/gemini-cli/, + call: () => gemini.getGeminiCliClientVersion(), + reset: () => gemini.resetGeminiCliClientVersionCache(), + }, +]; + +// The getters skip their refresh under a test runner; clear those markers for the +// duration of each check so the production path is the one exercised. +const RUNNER_ENV = ["NODE_TEST_CONTEXT", "VITEST", "NODE_ENV"] as const; + +async function outsideTestRunner(fn: () => Promise): Promise { + const saved = RUNNER_ENV.map((key) => [key, process.env[key]] as const); + for (const key of RUNNER_ENV) delete process.env[key]; + try { + return await fn(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function withBrowserWindow(fn: () => Promise): Promise { + const g = globalThis as { window?: unknown }; + g.window = {}; + try { + return await fn(); + } finally { + delete g.window; + } +} + +/** Runs the getter and lets any started refresh reach its fetch and settle. */ +async function fetchesStartedBy(getter: Getter): Promise { + const before = fetchCalls.filter((url) => getter.host.test(url)).length; + getter.call(); + await new Promise((resolve) => setTimeout(resolve, 20)); + return fetchCalls.filter((url) => getter.host.test(url)).length - before; +} + +test.after(() => { + globalThis.fetch = realFetch; +}); + +for (const getter of GETTERS) { + test(`${getter.name}: no background lookup from a browser bundle`, async () => { + getter.reset(); + const fromBrowser = await outsideTestRunner(() => + withBrowserWindow(() => fetchesStartedBy(getter)) + ); + assert.equal(fromBrowser, 0, `${getter.name} fetched from a browser runtime`); + + // Control: the same getter on the server still starts its refresh. + getter.reset(); + const fromServer = await outsideTestRunner(() => fetchesStartedBy(getter)); + assert.ok(fromServer >= 1, `${getter.name} did not refresh on the server either`); + getter.reset(); + }); +} + +test("Codex CLI: a failed release lookup is not retried on every call", async () => { + codex.clearCodexClientVersionCache(); + const codexGetter = GETTERS[0]; + const first = await outsideTestRunner(() => fetchesStartedBy(codexGetter)); + assert.equal(first, 1, "the first call should look the release up once"); + + // The lookup failed (503). Hot-path calls right after it must not hit GitHub again. + const again = await outsideTestRunner(async () => { + let total = 0; + for (let i = 0; i < 5; i++) total += await fetchesStartedBy(codexGetter); + return total; + }); + assert.equal(again, 0, "a rate-limited or failed lookup was retried immediately"); + + // Once the retry window has passed, the next call may try again. + const realNow = Date.now; + Date.now = () => realNow() + codex.CODEX_VERSION_RETRY_AFTER_FAILURE_MS + 1; + try { + const later = await outsideTestRunner(() => fetchesStartedBy(codexGetter)); + assert.equal(later, 1, "the lookup should be retried after the retry window"); + } finally { + Date.now = realNow; + codex.clearCodexClientVersionCache(); + } +}); From f2c1a5a3bed316789abef7c0604fa536fd63df36 Mon Sep 17 00:00:00 2001 From: ikkadecibel-blip <344406772+ikkadecibel-blip@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:23:33 +0000 Subject: [PATCH 2/4] chore(changelog): number the browser-version-lookups fragment after #18503 --- ...wser-version-lookups.md => 18503-browser-version-lookups.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/fixes/{browser-version-lookups.md => 18503-browser-version-lookups.md} (83%) diff --git a/changelog.d/fixes/browser-version-lookups.md b/changelog.d/fixes/18503-browser-version-lookups.md similarity index 83% rename from changelog.d/fixes/browser-version-lookups.md rename to changelog.d/fixes/18503-browser-version-lookups.md index ad36ffe4b487..b5358a824f20 100644 --- a/changelog.d/fixes/browser-version-lookups.md +++ b/changelog.d/fixes/18503-browser-version-lookups.md @@ -1 +1 @@ -- **fix(dashboard):** Stop the dashboard from looking up client versions (Codex and Zed on the GitHub API; Claude Code, Copilot, Gemini, Qwen, CodeBuddy, Kimi and OpenCode on npm) from the viewer's browser on every page load. Next.js gives browser bundles a `process` object, so the `typeof process` guards never stopped them; they spent the per-IP GitHub API budget the server needs and surfaced as "403 rate limit exceeded" in the console. A failed Codex release lookup now also waits 15 minutes before retrying instead of retrying on every call. +- **fix(dashboard):** Stop the dashboard from looking up client versions (Codex and Zed on the GitHub API; Claude Code, Copilot, Gemini, Qwen, CodeBuddy, Kimi and OpenCode on npm) from the viewer's browser on every page load. Next.js gives browser bundles a `process` object, so the `typeof process` guards never stopped them; they spent the per-IP GitHub API budget the server needs and surfaced as "403 rate limit exceeded" in the console. A failed Codex release lookup now also waits 15 minutes before retrying instead of retrying on every call ([#18503](https://github.com/diegosouzapw/OmniRoute/pull/18503)) From 6ccbb1fa59fe1502fa3b9e0da5bc84f933406a21 Mon Sep 17 00:00:00 2001 From: ikkadecibel-blip <344406772+ikkadecibel-blip@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:57:22 +0000 Subject: [PATCH 3/4] fix(providers): stop client-version getters from re-fetching on every call The sync version getters (Copilot, Claude Code, Gemini, Qwen, CodeBuddy, Kimi, Zed) start a background npm/GitHub lookup whenever their cache is empty, but the cache only kept versions newer than the pin. When the registry answered the pin itself (the normal state right after a pin bump), an older version, or nothing at all, no state was recorded and the next call fetched again. The server builds these headers per upstream request, so that was one registry request per request. OpenCode cached any answer but nothing on failure, and its refresh runs on every request. - BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS (15 min) in src/shared/utils/backgroundRefresh.ts. - Each getter now records when it may start the next background lookup: one module TTL (6 h) after any answer, the retry window after a failure. The shared npm helper behind Qwen/CodeBuddy takes a setNextRefreshAt callback; OpenCode checks the window inside its refresh, which only the request path calls. - Returned versions and explicit refresh calls are unchanged. Tests: tests/unit/version-refresh-retry-window.test.ts drives each getter against a stub registry. For a pin-equal answer, 5 follow-up calls made 5 lookups before (0 after); for a failure, 5 calls made 5 retries before (0 after) and the lookup runs again once the window has passed. 15/15 failed before the fix and pass after. --- .../fixes/version-lookup-retry-window.md | 1 + open-sse/config/providerHeaderProfiles.ts | 41 +++- .../providers/registry/kimi/coding/runtime.ts | 15 +- open-sse/executors/zedClientVersion.ts | 15 +- open-sse/utils/opencodeCliVersion.ts | 12 ++ src/shared/constants/claudeCodeClient.ts | 19 +- src/shared/constants/geminiCliClient.ts | 19 +- src/shared/utils/backgroundRefresh.ts | 8 + .../unit/version-refresh-retry-window.test.ts | 192 ++++++++++++++++++ 9 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/version-lookup-retry-window.md create mode 100644 tests/unit/version-refresh-retry-window.test.ts diff --git a/changelog.d/fixes/version-lookup-retry-window.md b/changelog.d/fixes/version-lookup-retry-window.md new file mode 100644 index 000000000000..05f6d1cd9221 --- /dev/null +++ b/changelog.d/fixes/version-lookup-retry-window.md @@ -0,0 +1 @@ +- **fix(providers):** Stop the Copilot, Claude Code, Gemini, Qwen, CodeBuddy, Kimi and Zed client-version getters from repeating their npm/GitHub lookup on every call when the registry answers the pinned version (their cache only kept newer versions), and stop all of them, OpenCode included, from retrying a failed lookup on every request: an answer now counts as checked for the existing 6-hour TTL, and a failure is retried after 15 minutes. diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index f865be0aaf9c..540a07a73855 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -1,6 +1,9 @@ import { getAntigravityContentHeaders } from "../services/antigravityHeaders.ts"; import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; -import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "@/shared/utils/backgroundRefresh"; // GitHub Copilot request identity. Ported to match the GitHub Copilot CLI // (`copilot` npm package) wire identity that Hermes captured live, NOT the @@ -24,6 +27,9 @@ type CopilotFetchLike = typeof fetch; let copilotVersionCache: { fetchedAt: number; version: string } | null = null; let copilotVersionInFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache only keeps versions newer than the pin), the retry window after a failure. +let copilotNextRefreshAt = 0; function getSafeCopilotEnvValue(name: string, pattern: RegExp): string | null { const raw = typeof process === "undefined" ? undefined : process.env?.[name]; @@ -103,6 +109,9 @@ export function resolveGitHubCopilotCliVersion( if (resolved && compareCopilotDottedTriple(resolved, GITHUB_COPILOT_CLI_VERSION) > 0) { copilotVersionCache = { fetchedAt: Date.now(), version: resolved }; } + copilotNextRefreshAt = + Date.now() + + (resolved ? GITHUB_COPILOT_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickCopilotVersionAtLeastPin(resolved ?? readFreshCopilotVersionCache()); })(); @@ -115,6 +124,7 @@ export function resolveGitHubCopilotCliVersion( /** Test seam: drop the registry cache so the next resolve hits the network. */ export function resetGitHubCopilotCliVersionCache(): void { + copilotNextRefreshAt = 0; copilotVersionCache = null; copilotVersionInFlight = null; } @@ -130,7 +140,8 @@ export function getGitHubCopilotCliVersion(): string { canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCopilotVersionCache() && - !copilotVersionInFlight + !copilotVersionInFlight && + Date.now() >= copilotNextRefreshAt ) void resolveGitHubCopilotCliVersion(); return pickCopilotVersionAtLeastPin(readFreshCopilotVersionCache()); @@ -242,6 +253,9 @@ let qwenVersionCache: VersionCache | null = null; let qwenVersionInFlight: Promise | null = null; let codeBuddyVersionCache: VersionCache | null = null; let codeBuddyVersionInFlight: Promise | null = null; +// Earliest time each getter may start another background lookup (see refreshNpmDottedTriple). +let qwenNextRefreshAt = 0; +let codeBuddyNextRefreshAt = 0; function parseDottedTriple(value: unknown): string | null { if (typeof value !== "string") return null; @@ -288,6 +302,8 @@ function refreshNpmDottedTriple(options: { setCache: (next: VersionCache) => void; readInFlight: () => Promise | null; setInFlight: (next: Promise | null) => void; + /** Records when a getter may start the next background lookup. */ + setNextRefreshAt: (at: number) => void; fetchImpl: NpmFetchLike; }): Promise { const fresh = readFreshVersionCache(options.readCache(), options.ttlMs); @@ -317,6 +333,11 @@ function refreshNpmDottedTriple(options: { clearTimeout(timeoutId); } + // Any answer counts as checked for the TTL — the cache only keeps newer-than-pin versions, + // so an answer equal to the pin would otherwise be re-fetched on every call. + options.setNextRefreshAt( + Date.now() + (resolved ? options.ttlMs : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS) + ); if (resolved && compareDottedTriple(resolved, options.pin) > 0) { options.setCache({ fetchedAt: Date.now(), version: resolved }); return resolved; @@ -335,7 +356,7 @@ function refreshNpmDottedTriple(options: { /** Sync hot path. Cache if newer than the pin, else the pin. Never waits. */ export function getQwenCliVersion(): string { - if (shouldAutoRefreshCliVersion() && !qwenVersionInFlight) { + if (shouldAutoRefreshCliVersion() && !qwenVersionInFlight && Date.now() >= qwenNextRefreshAt) { const fresh = readFreshVersionCache(qwenVersionCache, QWEN_CLI_VERSION_CACHE_TTL_MS); if (!fresh || compareDottedTriple(fresh, QWEN_CLI_VERSION) <= 0) { void refreshQwenCliVersion(); @@ -361,11 +382,15 @@ export function refreshQwenCliVersion(fetchImpl: NpmFetchLike = fetch): Promise< setInFlight: (next) => { qwenVersionInFlight = next; }, + setNextRefreshAt: (at) => { + qwenNextRefreshAt = at; + }, fetchImpl, }); } export function resetQwenCliVersionCache(): void { + qwenNextRefreshAt = 0; qwenVersionCache = null; qwenVersionInFlight = null; } @@ -376,7 +401,11 @@ function formatCodeBuddyCnUserAgent(version: string): string { /** Sync hot path. Both numbers move together, or the captured pin stays. */ export function getCodeBuddyCnUserAgent(): string { - if (shouldAutoRefreshCliVersion() && !codeBuddyVersionInFlight) { + if ( + shouldAutoRefreshCliVersion() && + !codeBuddyVersionInFlight && + Date.now() >= codeBuddyNextRefreshAt + ) { const fresh = readFreshVersionCache(codeBuddyVersionCache, CODEBUDDY_CN_VERSION_CACHE_TTL_MS); if (!fresh) void refreshCodeBuddyCnUserAgent(); } @@ -401,11 +430,15 @@ export function refreshCodeBuddyCnUserAgent(fetchImpl: NpmFetchLike = fetch): Pr setInFlight: (next) => { codeBuddyVersionInFlight = next; }, + setNextRefreshAt: (at) => { + codeBuddyNextRefreshAt = at; + }, fetchImpl, }).then((version) => formatCodeBuddyCnUserAgent(version)); } export function resetCodeBuddyCnVersionCache(): void { + codeBuddyNextRefreshAt = 0; codeBuddyVersionCache = null; codeBuddyVersionInFlight = null; } diff --git a/open-sse/config/providers/registry/kimi/coding/runtime.ts b/open-sse/config/providers/registry/kimi/coding/runtime.ts index f61e2bbf2d2f..b6d5196aec88 100644 --- a/open-sse/config/providers/registry/kimi/coding/runtime.ts +++ b/open-sse/config/providers/registry/kimi/coding/runtime.ts @@ -1,4 +1,7 @@ -import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "@/shared/utils/backgroundRefresh"; export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1"; export const KIMI_CODING_MODELS_URL = `${KIMI_CODING_BASE_URL}/models`; @@ -79,6 +82,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; let fetchImpl: FetchLike = fetch; function parseDottedTriple(value: unknown): string | null { @@ -127,7 +133,8 @@ export function getKimiCodeCliVersion(): string { canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCache() && - !inFlight + !inFlight && + Date.now() >= nextBackgroundRefreshAt ) { void refreshKimiCodeCliVersion(); } @@ -176,6 +183,9 @@ export function refreshKimiCodeCliVersion(): Promise { cachedVersion = resolved; cachedAt = Date.now(); } + nextBackgroundRefreshAt = + Date.now() + + (resolved ? KIMI_CODE_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickAtLeastPin(resolved ?? readFreshCache()); })(); @@ -188,6 +198,7 @@ export function refreshKimiCodeCliVersion(): Promise { /** Test seam: drop the registry cache so the next refresh hits the network. */ export function resetKimiCodeCliVersionCache(): void { + nextBackgroundRefreshAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/open-sse/executors/zedClientVersion.ts b/open-sse/executors/zedClientVersion.ts index bf2ffaae620e..c7e4e43923ac 100644 --- a/open-sse/executors/zedClientVersion.ts +++ b/open-sse/executors/zedClientVersion.ts @@ -7,7 +7,10 @@ * executor config.appVersion still wins over both. */ -import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "@/shared/utils/backgroundRefresh"; export const ZED_CLIENT_VERSION = "0.200.0"; @@ -21,6 +24,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; function parseReleaseTag(tagName: unknown): string | null { if (typeof tagName !== "string") return null; @@ -60,7 +66,8 @@ export function getZedClientVersion(): string { canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCache() && - !inFlight + !inFlight && + Date.now() >= nextBackgroundRefreshAt ) { void refreshZedClientVersion(); } @@ -104,6 +111,9 @@ export function refreshZedClientVersion(fetchImpl: FetchLike = fetch): Promise | null = null; +// A failed lookup caches nothing and this refresh runs on every request, so a failure is +// retried only after the shared retry window. +let retryAfterFailureAt = 0; export function isOpencodeCliVersion(value: unknown): value is string { return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value.trim()); @@ -57,7 +62,11 @@ export function refreshOpencodeCliVersion(): Promise { return Promise.resolve(cachedVersion); } if (inFlight) return inFlight; + if (Date.now() < retryAfterFailureAt) { + return Promise.resolve(cachedVersion ?? OPENCODE_CLI_VERSION_FALLBACK); + } inFlight = (async () => { + let answered = false; try { const response = await globalThis.fetch(NPM_OPENCODE_LATEST_URL, { headers: { Accept: "application/json" }, @@ -68,10 +77,12 @@ export function refreshOpencodeCliVersion(): Promise { if (isOpencodeCliVersion(version)) { cachedVersion = version; cachedAt = Date.now(); + answered = true; } } catch { // Registry unreachable — stale cache / pin stay valid. } + retryAfterFailureAt = answered ? 0 : Date.now() + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS; return cachedVersion ?? OPENCODE_CLI_VERSION_FALLBACK; })(); const current = inFlight; @@ -83,6 +94,7 @@ export function refreshOpencodeCliVersion(): Promise { /** Test seam: clear the cache. */ export function resetOpencodeCliVersionCache(): void { + retryAfterFailureAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts index 2a36af619e25..7a2dfb84707f 100644 --- a/src/shared/constants/claudeCodeClient.ts +++ b/src/shared/constants/claudeCodeClient.ts @@ -12,7 +12,10 @@ * uses that version when it is newer than the pin. The env override wins over * both. The lookup is fire-and-forget; a cold or failed fetch stays on the pin. */ -import { canStartBackgroundRefresh } from "../utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "../utils/backgroundRefresh"; export const CLAUDE_CODE_CLIENT_VERSION = "2.1.280"; export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1e2"; @@ -36,6 +39,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; function getSafeEnvValue(name: string, pattern: RegExp): string | null { const raw = typeof process === "undefined" ? undefined : process.env?.[name]; @@ -88,7 +94,12 @@ function shouldAutoRefreshClaudeCodeVersion(): boolean { export function getClaudeCodeClientVersion(): string { const override = getSafeEnvValue(CLAUDE_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN); if (override) return override; - if (shouldAutoRefreshClaudeCodeVersion() && !readFreshCache() && !inFlight) { + if ( + shouldAutoRefreshClaudeCodeVersion() && + !readFreshCache() && + !inFlight && + Date.now() >= nextBackgroundRefreshAt + ) { void refreshClaudeCodeClientVersion(); } return pickAtLeastPin(readFreshCache()); @@ -136,6 +147,9 @@ export function refreshClaudeCodeClientVersion(fetchImpl: FetchLike = fetch): Pr cachedVersion = resolved; cachedAt = Date.now(); } + nextBackgroundRefreshAt = + Date.now() + + (resolved ? CLAUDE_CODE_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickAtLeastPin(resolved ?? readFreshCache()); })(); @@ -148,6 +162,7 @@ export function refreshClaudeCodeClientVersion(fetchImpl: FetchLike = fetch): Pr /** Test seam: drop the registry cache so the next refresh hits the network. */ export function resetClaudeCodeClientVersionCache(): void { + nextBackgroundRefreshAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/src/shared/constants/geminiCliClient.ts b/src/shared/constants/geminiCliClient.ts index 161fa05f4ec8..033d86a28daa 100644 --- a/src/shared/constants/geminiCliClient.ts +++ b/src/shared/constants/geminiCliClient.ts @@ -8,7 +8,10 @@ * version when it is a dotted triple newer than the pin. The lookup is * fire-and-forget; a cold or failed fetch stays on the pin. */ -import { canStartBackgroundRefresh } from "../utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "../utils/backgroundRefresh"; export const GEMINI_CLI_CLIENT_VERSION = "0.1.0"; export const GEMINI_CLI_USER_AGENT_SUFFIX = "(linux; x64)"; @@ -24,6 +27,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; function parseDottedTriple(value: unknown): string | null { if (typeof value !== "string") return null; @@ -64,7 +70,12 @@ function shouldAutoRefreshGeminiCliVersion(): boolean { * itself never waits on the network. */ export function getGeminiCliClientVersion(): string { - if (shouldAutoRefreshGeminiCliVersion() && !readFreshCache() && !inFlight) { + if ( + shouldAutoRefreshGeminiCliVersion() && + !readFreshCache() && + !inFlight && + Date.now() >= nextBackgroundRefreshAt + ) { void refreshGeminiCliClientVersion(); } return pickAtLeastPin(readFreshCache()); @@ -114,6 +125,9 @@ export function refreshGeminiCliClientVersion(fetchImpl: FetchLike = fetch): Pro cachedVersion = resolved; cachedAt = Date.now(); } + nextBackgroundRefreshAt = + Date.now() + + (resolved ? GEMINI_CLI_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickAtLeastPin(resolved ?? readFreshCache()); })(); @@ -126,6 +140,7 @@ export function refreshGeminiCliClientVersion(fetchImpl: FetchLike = fetch): Pro /** Test seam: drop the registry cache so the next refresh hits the network. */ export function resetGeminiCliClientVersionCache(): void { + nextBackgroundRefreshAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/src/shared/utils/backgroundRefresh.ts b/src/shared/utils/backgroundRefresh.ts index 5ee43487135c..84dc1ca4379d 100644 --- a/src/shared/utils/backgroundRefresh.ts +++ b/src/shared/utils/backgroundRefresh.ts @@ -9,6 +9,14 @@ * and surface as "403 rate limit exceeded" in the console. A browser has no use for the * result — the versions only shape headers on upstream requests, which the server sends. */ +/** + * How long a sync getter waits before re-trying a background version lookup that failed + * (network error, non-2xx, unparseable payload). A failure caches nothing, and the getters + * run per upstream request, so without a wait a down or rate-limited registry is asked on + * every request. A lookup that answered (newer or not) waits the module's own cache TTL. + */ +export const BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS = 15 * 60 * 1000; + export function canStartBackgroundRefresh(): boolean { if (typeof window !== "undefined") return false; return typeof process !== "undefined"; diff --git a/tests/unit/version-refresh-retry-window.test.ts b/tests/unit/version-refresh-retry-window.test.ts new file mode 100644 index 000000000000..b95fadfd5b60 --- /dev/null +++ b/tests/unit/version-refresh-retry-window.test.ts @@ -0,0 +1,192 @@ +/** + * Client-version getters must not re-run their background lookup on every call. + * + * The sync getters (`get…Version()` / `get…UserAgent()`) start a background npm/GitHub + * lookup whenever their cache is empty. The cache was only written when the registry + * reported a version *newer* than the pin, so when the registry answered the pin itself (the + * normal state right after a pin bump) or the lookup failed, nothing was recorded and the next + * call — the server builds these headers per upstream request — fetched again. OpenCode + * cached any answer but nothing on failure, and its refresh runs on every request. + * + * After the fix an answer that is not newer counts as checked for the module's TTL, and a + * failure is retried only after BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +type Mode = "pin" | "fail"; +let mode: Mode = "pin"; +const fetchCalls: string[] = []; +const realFetch = globalThis.fetch; + +// Installed before any import: some modules capture `fetch` at load time. +globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchCalls.push(url); + if (mode === "fail") return new Response("{}", { status: 503 }); + const pin = PINS.find(([pattern]) => pattern.test(url))?.[1]; + if (!pin) return new Response("{}", { status: 404 }); + return Response.json({ version: pin, tag_name: `v${pin}` }); +}) as typeof fetch; + +const profiles = await import("../../open-sse/config/providerHeaderProfiles.ts"); +const kimi = await import("../../open-sse/config/providers/registry/kimi/coding/runtime.ts"); +const zed = await import("../../open-sse/executors/zedClientVersion.ts"); +const opencodeHeaders = await import("../../open-sse/utils/opencodeHeaders.ts"); +const opencodeVersion = await import("../../open-sse/utils/opencodeCliVersion.ts"); +const claude = await import("../../src/shared/constants/claudeCodeClient.ts"); +const gemini = await import("../../src/shared/constants/geminiCliClient.ts"); +const { BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS } = + await import("../../src/shared/utils/backgroundRefresh.ts"); + +const CODEBUDDY_PIN = profiles.CODEBUDDY_CN_USER_AGENT.match(/(\d+\.\d+\.\d+)/)?.[1] ?? "0.0.0"; + +// What the stub answers in "pin" mode: exactly the pinned version, never a newer one. +const PINS: Array<[RegExp, string]> = [ + [/@github\/copilot/, profiles.GITHUB_COPILOT_CLI_VERSION], + [/@qwen-code/, profiles.QWEN_CLI_VERSION], + [/@tencent-ai\/codebuddy-code/, CODEBUDDY_PIN], + [/@moonshot-ai\/kimi-code/, kimi.KIMI_CODE_CLI_VERSION], + [/zed-industries\/zed/, zed.ZED_CLIENT_VERSION], + [/@anthropic-ai\/claude-code/, claude.CLAUDE_CODE_CLIENT_VERSION], + [/@google\/gemini-cli/, gemini.GEMINI_CLI_CLIENT_VERSION], + [/opencode-ai/, opencodeVersion.OPENCODE_CLI_VERSION_FALLBACK], +]; + +type Getter = { + name: string; + host: RegExp; + call: () => unknown; + reset: () => void; + /** OpenCode already caches any successful answer; only its failure path was unbounded. */ + pinAnswerAlreadyCached?: boolean; +}; + +const GETTERS: Getter[] = [ + { + name: "GitHub Copilot CLI", + host: /@github\/copilot/, + call: () => profiles.getGitHubCopilotCliVersion(), + reset: () => profiles.resetGitHubCopilotCliVersionCache(), + }, + { + name: "Qwen Code", + host: /@qwen-code/, + call: () => profiles.getQwenCliVersion(), + reset: () => profiles.resetQwenCliVersionCache(), + }, + { + name: "CodeBuddy CN", + host: /@tencent-ai\/codebuddy-code/, + call: () => profiles.getCodeBuddyCnUserAgent(), + reset: () => profiles.resetCodeBuddyCnVersionCache(), + }, + { + name: "Kimi Code CLI", + host: /@moonshot-ai\/kimi-code/, + call: () => kimi.getKimiCodeCliVersion(), + reset: () => kimi.resetKimiCodeCliVersionCache(), + }, + { + name: "Zed", + host: /zed-industries\/zed/, + call: () => zed.getZedClientVersion(), + reset: () => zed.resetZedClientVersionCache(), + }, + { + name: "Claude Code", + host: /@anthropic-ai\/claude-code/, + call: () => claude.getClaudeCodeClientVersion(), + reset: () => claude.resetClaudeCodeClientVersionCache(), + }, + { + name: "Gemini CLI", + host: /@google\/gemini-cli/, + call: () => gemini.getGeminiCliClientVersion(), + reset: () => gemini.resetGeminiCliClientVersionCache(), + }, + { + name: "OpenCode CLI", + host: /opencode-ai/, + call: () => opencodeHeaders.resolveOpencodeCliDefaults("opencode", false), + reset: () => opencodeVersion.resetOpencodeCliVersionCache(), + pinAnswerAlreadyCached: true, + }, +]; + +// The getters skip their refresh under a test runner; clear those markers while measuring. +const RUNNER_ENV = ["NODE_TEST_CONTEXT", "VITEST", "NODE_ENV"] as const; + +async function outsideTestRunner(fn: () => Promise): Promise { + const saved = RUNNER_ENV.map((key) => [key, process.env[key]] as const); + for (const key of RUNNER_ENV) delete process.env[key]; + try { + return await fn(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +/** Calls the getter `times` times, letting each started lookup reach fetch and settle. */ +async function fetchesFrom(getter: Getter, times = 1): Promise { + const before = fetchCalls.filter((url) => getter.host.test(url)).length; + for (let i = 0; i < times; i++) { + getter.call(); + await new Promise((resolve) => setTimeout(resolve, 15)); + } + return fetchCalls.filter((url) => getter.host.test(url)).length - before; +} + +async function withClockAdvancedBy(ms: number, fn: () => Promise): Promise { + const realNow = Date.now; + Date.now = () => realNow() + ms; + try { + return await fn(); + } finally { + Date.now = realNow; + } +} + +test.after(() => { + globalThis.fetch = realFetch; +}); + +for (const getter of GETTERS) { + if (!getter.pinAnswerAlreadyCached) { + test(`${getter.name}: a registry answer equal to the pin is not re-fetched on the next calls`, async () => { + getter.reset(); + mode = "pin"; + await outsideTestRunner(async () => { + assert.equal(await fetchesFrom(getter), 1, "the first call should look the version up"); + assert.equal( + await fetchesFrom(getter, 5), + 0, + `${getter.name} re-fetched although the registry already answered the pin` + ); + }); + getter.reset(); + }); + } + + test(`${getter.name}: a failed lookup waits for the retry window`, async () => { + getter.reset(); + mode = "fail"; + await outsideTestRunner(async () => { + assert.equal(await fetchesFrom(getter), 1, "the first call should look the version up"); + assert.equal( + await fetchesFrom(getter, 5), + 0, + `${getter.name} retried a failed lookup immediately` + ); + const later = await withClockAdvancedBy(BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS + 1, () => + fetchesFrom(getter) + ); + assert.equal(later, 1, `${getter.name} should retry once the window has passed`); + }); + mode = "pin"; + getter.reset(); + }); +} From b7e4e58372aff90924000d95dcd2da963d5ed2d8 Mon Sep 17 00:00:00 2001 From: ikkadecibel-blip <344406772+ikkadecibel-blip@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:01:54 +0000 Subject: [PATCH 4/4] chore(changelog): number the version-lookup-retry-window fragment after #18507 --- ...kup-retry-window.md => 18507-version-lookup-retry-window.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/fixes/{version-lookup-retry-window.md => 18507-version-lookup-retry-window.md} (75%) diff --git a/changelog.d/fixes/version-lookup-retry-window.md b/changelog.d/fixes/18507-version-lookup-retry-window.md similarity index 75% rename from changelog.d/fixes/version-lookup-retry-window.md rename to changelog.d/fixes/18507-version-lookup-retry-window.md index 05f6d1cd9221..3f61bb16cf35 100644 --- a/changelog.d/fixes/version-lookup-retry-window.md +++ b/changelog.d/fixes/18507-version-lookup-retry-window.md @@ -1 +1 @@ -- **fix(providers):** Stop the Copilot, Claude Code, Gemini, Qwen, CodeBuddy, Kimi and Zed client-version getters from repeating their npm/GitHub lookup on every call when the registry answers the pinned version (their cache only kept newer versions), and stop all of them, OpenCode included, from retrying a failed lookup on every request: an answer now counts as checked for the existing 6-hour TTL, and a failure is retried after 15 minutes. +- **fix(providers):** Stop the Copilot, Claude Code, Gemini, Qwen, CodeBuddy, Kimi and Zed client-version getters from repeating their npm/GitHub lookup on every call when the registry answers the pinned version (their cache only kept newer versions), and stop all of them, OpenCode included, from retrying a failed lookup on every request: an answer now counts as checked for the existing 6-hour TTL, and a failure is retried after 15 minutes ([#18507](https://github.com/diegosouzapw/OmniRoute/pull/18507))