diff --git a/src/providers/quota.ts b/src/providers/quota.ts index bb3bab2837..24b06ef7a9 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -13,7 +13,7 @@ import { getAccountCredential, getAccountSet, getCredential } from "../oauth/sto import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; -import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry"; +import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; import { @@ -2084,7 +2084,14 @@ async function maybeFetchProviderQuota( && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) { return fetchCommandCodeQuota(name, provider); } - if ((provider.authMode ?? "key") === "key" && name === "opencode-go") { + // Identify OpenCode Go by where it routes, not by what the row is called. Multi-account + // setups keep the same destination under names like `opencode-go-2` (#1924), and those rows + // silently had no quota panel and no `ocx provider quota --json` report while the literal + // name was the gate. `registryEntryForProviderDestination` is the existing predicate for + // exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind + // a different adapter is still not OpenCode Go. The defensive URL check inside + // `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate. + if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") { return fetchOpenCodeGoQuota(name, provider); } if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) { diff --git a/tests/opencode-go-quota.test.ts b/tests/opencode-go-quota.test.ts index ece8d22b53..df2bb52525 100644 --- a/tests/opencode-go-quota.test.ts +++ b/tests/opencode-go-quota.test.ts @@ -87,4 +87,61 @@ describe("OpenCode Go provider quota", () => { expect(fetchCalls).toBe(0); expect(result.reports).toEqual([]); }); + + // #1924: a multi-account setup points several rows at the same OpenCode Go endpoint under + // names the registry has never heard of. Gating dispatch on the literal name `opencode-go` + // meant those rows had no dashboard quota panel and no `ocx provider quota --json` report, + // even though each one holds a working key for the same upstream. + test("a canonical sibling row under any name is probed and reported", async () => { + const bearers: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + if (headers?.Authorization) bearers.push(headers.Authorization); + return new Response(JSON.stringify({ + usage: { rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T20:00:00.000Z" } }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const config = openCodeGoConfig(); + config.providers["opencode-go-2"] = { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://opencode.ai/zen/go/v1", + // Kept under the privacy scanner's bearer-token length floor: a longer fixture reads as + // a real credential to `privacy:scan` even inside a test. + apiKey: "sibling-secret", + }; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports.map(report => report.provider).sort()).toEqual(["opencode-go", "opencode-go-2"]); + expect(bearers.sort()).toEqual(["Bearer opencode-go-secret", "Bearer sibling-secret"]); + expect(JSON.stringify(result)).not.toContain("sibling-secret"); + }); + + // The distinction between "routes to the OpenCode Go endpoint" and "is the OpenCode Go + // provider": a bare URL match would probe this row, but a different adapter speaks a + // different protocol to that host and is not the provider whose quota shape we parse. + test("a canonical URL behind a different adapter is not OpenCode Go", async () => { + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports({ + defaultProvider: "not-really-go", + providers: { + "not-really-go": { + adapter: "anthropic", + authMode: "key", + baseUrl: "https://opencode.ai/zen/go/v1", + apiKey: "unrelated-secret", + }, + }, + } as OcxConfig, true); + + expect(fetchCalls).toBe(0); + expect(result.reports).toEqual([]); + }); });