From a2c5d8a2f56edc592d442d7a97db2bed76f06373 Mon Sep 17 00:00:00 2001 From: Dohyun Jung Date: Sun, 30 Aug 2026 23:11:46 +0900 Subject: [PATCH] feat(quota): use official OpenCode Go usage API (#12124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migra o quota fetcher do OpenCode Go para a API oficial de uso, com refactor substancial que remove ~1850 linhas de código legado e atualiza a suíte de testes existente inteira para o novo contrato. Validado no worktree combinado (typecheck limpo, testes focados verdes). Obrigado! --- .env.example | 16 +- config/quality/eslint-suppressions.json | 10 - docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md | 8 +- docs/reference/ENVIRONMENT.md | 8 +- open-sse/services/opencodeOllamaUsage.ts | 414 +------------- open-sse/services/opencodeQuotaFetcher.ts | 388 +++---------- open-sse/services/usage.ts | 4 +- open-sse/services/usage/opencode.ts | 77 ++- .../components/modals/EditConnectionModal.tsx | 6 - .../components/modals/QuotaScrapingFields.tsx | 39 -- .../modals/quotaScrapingFieldValues.ts | 15 +- tests/unit/opencode-go-quota-no-zai.test.ts | 73 ++- tests/unit/opencode-go-usage.test.ts | 452 ++++----------- tests/unit/opencode-quota-fetcher.test.ts | 524 +++++++----------- .../quota-exhaustion-cutoff-opencode.test.ts | 381 ++++--------- .../ui/add-api-key-modal-free-models.test.tsx | 17 +- ...edit-connection-modal-free-models.test.tsx | 24 +- 17 files changed, 614 insertions(+), 1842 deletions(-) diff --git a/.env.example b/.env.example index f8ff0f39d8..ad3840c7cd 100644 --- a/.env.example +++ b/.env.example @@ -673,21 +673,11 @@ NEXT_PUBLIC_CLOUD_URL= # open-sse/services/usage.ts. #OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/ #OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com -#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota -# OpenCode Go has no public quota API — this has no default and stays -# unset unless you explicitly opt in to a self-hosted/mirrored endpoint: -#OMNIROUTE_OPENCODE_GO_QUOTA_URL= -#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace +# Official OpenCode Go usage endpoint, authenticated with the connection API key. +# Override only for relays or test fixtures. +#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/usage #OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings -# OpenCode Go dashboard quota scraping. Prefer configuring these per connection -# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless -# deployments or shared server defaults. The cookie is sensitive. -#OPENCODE_GO_WORKSPACE_ID=wrk_... -#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_... -#OPENCODE_GO_AUTH_COOKIE=auth=... -#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=... - # OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of # opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers. # When your clients don't already send them, set this to synthesize the CLI headers diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 97d9c608a5..e462879722 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -618,11 +618,6 @@ "count": 1 } }, - "open-sse/services/opencodeQuotaFetcher.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "open-sse/services/providerCostData.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -4477,11 +4472,6 @@ "count": 6 } }, - "tests/unit/opencode-quota-fetcher.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "tests/unit/openrouter-vision-sync-4264.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index f3698b5fc8..4528ac0a4d 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -269,13 +269,7 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 | `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | | `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | | `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API,因此没有默认值;除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 | -| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | -| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go `auth` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/usage` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode Go 官方 API key 认证用量端点。可覆盖为中继/测试固定件。 | | `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | | `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | | `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index a622e175e2..cdea3b586d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -320,17 +320,11 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. | | `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. | | `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | CrofAI quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | -| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(unset)_ | `open-sse/services/opencodeOllamaUsage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. OpenCode Go has no public quota API, so this has no default and the network call is skipped unless the operator opts in to a self-hosted/mirrored endpoint. | -| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. | -| `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. | -| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. | -| `OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` cookie used for dashboard quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/usage` | `open-sse/services/opencodeQuotaFetcher.ts` | Official API-key-authenticated OpenCode Go usage endpoint used by the Usage page. Override for relays / test fixtures. | | `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `true` | `open-sse/executors/opencode.ts` | Synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). On by default since #10571; opt out with `false`/`0`/`no`/`off`. | | `OPENCODE_USER_AGENT` | `opencode` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | | `OPENCODE_CLIENT` | `desktop` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | | `OPENCODE_PROJECT` | `global` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | -| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. | | `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OLLAMA_CLOUD_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | diff --git a/open-sse/services/opencodeOllamaUsage.ts b/open-sse/services/opencodeOllamaUsage.ts index 4dd52c4f6d..d096c32ed0 100644 --- a/open-sse/services/opencodeOllamaUsage.ts +++ b/open-sse/services/opencodeOllamaUsage.ts @@ -13,38 +13,18 @@ type UsageQuota = { currency?: string; }; -// OpenCode Go does not expose a public quota API. There is no working -// opencode.ai endpoint to default to (see #7022) — the quota-by-API-key path -// below is opt-in only and activates exclusively when the operator sets -// OMNIROUTE_OPENCODE_GO_QUOTA_URL explicitly. Never hardcode a third-party -// host here (a previous default silently sent the user's API key to an -// unrelated Z.AI endpoint). -const OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL?.trim() || ""; -const OPENCODE_GO_DASHBOARD_BASE_URL = - process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace"; -const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const; -const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const; -const OPENCODE_GO_SCRAPED_NUMBER = String.raw`(-?\d+(?:\.\d+)?)`; const OLLAMA_CLOUD_USAGE_URL = process.env.OMNIROUTE_OLLAMA_CLOUD_USAGE_URL ?? "https://ollama.com/settings"; const OLLAMA_CLOUD_SESSION_COOKIE = "__Secure-session"; -type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number]; -type DashboardWindow = { usagePercent: number; resetAt: string | null }; -type OpenCodeGoDashboardUsage = Partial>; -type OpenCodeGoDashboardConfig = - | { state: "configured"; workspaceId: string; authCookie: string } - | { state: "incomplete"; missing: string } - | { state: "none" }; +type OllamaUsageWindow = { usagePercent: number; resetAt: string | null }; type OllamaCloudUsage = { - session?: DashboardWindow; - weekly?: DashboardWindow; + session?: OllamaUsageWindow; + weekly?: OllamaUsageWindow; planTier?: string | null; }; type OllamaCloudConfig = - | { state: "configured"; cookie: string } - | { state: "invalid"; error: string } - | { state: "none" }; + { state: "configured"; cookie: string } | { state: "invalid"; error: string } | { state: "none" }; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -63,37 +43,6 @@ function toNumber(value: unknown, fallback = 0): number { function toPercentage(value: unknown): number { return Math.max(0, Math.min(100, toNumber(value, 0))); } - -function toTitleCase(value: string): string { - return value - .trim() - .split(/[\s_-]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(" "); -} - -function roundCurrency(value: number): number { - return Math.round(value * 100) / 100; -} - -function safeToIsoString(time: number): string | null { - if (!Number.isFinite(time) || time < 0 || time > 8.64e15) return null; - try { - return new Date(time).toISOString(); - } catch { - return null; - } -} - -function parseResetTime(resetValue: unknown): string | null { - const numeric = toNumber(resetValue, Number.NaN); - if (Number.isFinite(numeric) && numeric > 0) return safeToIsoString(numeric); - if (typeof resetValue !== "string" || !resetValue.trim()) return null; - const parsed = Date.parse(resetValue); - return Number.isFinite(parsed) ? safeToIsoString(parsed) : null; -} - function getProviderSpecificString(data: JsonRecord | undefined, keys: string[]): string { const obj = toRecord(data); for (const key of keys) { @@ -102,361 +51,6 @@ function getProviderSpecificString(data: JsonRecord | undefined, keys: string[]) } return ""; } - -export function resolveOpenCodeGoDashboardConfig( - providerSpecificData?: JsonRecord -): OpenCodeGoDashboardConfig { - const workspaceId = - process.env.OMNIROUTE_OPENCODE_GO_WORKSPACE_ID?.trim() || - process.env.OPENCODE_GO_WORKSPACE_ID?.trim() || - getProviderSpecificString(providerSpecificData, [ - "openCodeGoWorkspaceId", - "opencodeGoWorkspaceId", - "workspaceId", - ]); - const authCookie = - process.env.OMNIROUTE_OPENCODE_GO_AUTH_COOKIE?.trim() || - process.env.OPENCODE_GO_AUTH_COOKIE?.trim() || - getProviderSpecificString(providerSpecificData, [ - "openCodeGoAuthCookie", - "opencodeGoAuthCookie", - "authCookie", - ]); - - if (!workspaceId && !authCookie) return { state: "none" }; - if (workspaceId && authCookie) return { state: "configured", workspaceId, authCookie }; - return { - state: "incomplete", - missing: workspaceId ? "OPENCODE_GO_AUTH_COOKIE" : "OPENCODE_GO_WORKSPACE_ID", - }; -} - -function normalizeOpenCodeGoAuthCookie(value: string): string { - return value - .trim() - .replace(/^auth=/i, "") - .trim(); -} - -function buildBearerAuthorization(value: string): string { - const token = value - .trim() - .replace(/^Bearer\s+/i, "") - .trim(); - return token ? `Bearer ${token}` : ""; -} - -function getOpenCodeGoTokenQuotaName( - limit: JsonRecord, - existingQuotas: Record -): "session" | "weekly" { - const unit = toNumber(limit.unit, 0); - const number = toNumber(limit.number, 0); - if (unit === 3 && number === 5) return "session"; - if (unit === 6 && number === 1) return "weekly"; - if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly"; - return existingQuotas.session ? "weekly" : "session"; -} - -function buildOpenCodeGoDollarQuota( - quotaName: OpenCodeGoQuotaName, - percentage: unknown, - resetAt: string | null, - usedOverride?: unknown, - details?: UsageQuota["details"] -): UsageQuota { - const total = OPENCODE_GO_QUOTA_TOTALS[quotaName]; - const percentUsed = toPercentage(percentage); - const rawUsed = toNumber(usedOverride, Number.NaN); - const used = roundCurrency( - Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100 - ); - const remaining = roundCurrency(Math.max(0, total - used)); - return { - used, - total, - remaining, - remainingPercentage: - total > 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 100, - resetAt, - unlimited: false, - displayName: - quotaName === "session" ? "5-hour rolling" : quotaName === "weekly" ? "Weekly" : "Monthly", - currency: "USD", - details, - }; -} - -function orderOpenCodeGoQuotas(quotas: Record): Record { - const ordered: Record = {}; - for (const key of OPENCODE_GO_QUOTA_ORDER) if (quotas[key]) ordered[key] = quotas[key]; - for (const [key, quota] of Object.entries(quotas)) if (!ordered[key]) ordered[key] = quota; - return ordered; -} - -function parseOpenCodeGoSsrWindow(html: string, field: string): DashboardWindow | null { - for (const candidate of [ - { - usageIndex: 1, - resetIndex: 2, - pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`, - }, - { - usageIndex: 2, - resetIndex: 1, - pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`, - }, - ]) { - const match = new RegExp(candidate.pattern).exec(html); - if (!match) continue; - const usagePercent = toNumber(match[candidate.usageIndex], Number.NaN); - const resetInSec = toNumber(match[candidate.resetIndex], Number.NaN); - if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) { - return { - usagePercent, - resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), - }; - } - } - return null; -} - -function parseOpenCodeGoHumanReset(value: string): number | null { - const text = value.toLowerCase().replace(/\s+/g, " ").trim(); - if (["reset-now", "reset now", "now", "resets now"].includes(text)) return 0; - const days = text.match(/(\d+(?:\.\d+)?)\s*days?/); - const hours = text.match(/(\d+(?:\.\d+)?)\s*hours?/); - const minutes = text.match(/(\d+(?:\.\d+)?)\s*minutes?/); - const seconds = text.match(/(\d+(?:\.\d+)?)\s*seconds?/); - if (!days && !hours && !minutes && !seconds) return null; - return ( - toNumber(days?.[1], 0) * 86_400 + - toNumber(hours?.[1], 0) * 3_600 + - toNumber(minutes?.[1], 0) * 60 + - toNumber(seconds?.[1], 0) - ); -} - -function parseOpenCodeGoDashboardHtml(html: string): OpenCodeGoDashboardUsage | null { - const usage: OpenCodeGoDashboardUsage = { - session: parseOpenCodeGoSsrWindow(html, "rollingUsage") ?? undefined, - weekly: parseOpenCodeGoSsrWindow(html, "weeklyUsage") ?? undefined, - mcp_monthly: parseOpenCodeGoSsrWindow(html, "monthlyUsage") ?? undefined, - }; - if (usage.session || usage.weekly || usage.mcp_monthly) return usage; - - for (const content of html.split(/data-slot="usage-item"/).slice(1)) { - const label = content - .match(/data-slot="usage-label">([^<]+)[^0-9]*(\d+(?:\.\d+)?)/)?.[1], - Number.NaN - ); - const resetMatch = content.match(/data-slot="(reset-time|reset-now)">([\s\S]*?)<\/span>/); - if (!label || !Number.isFinite(usagePercent) || !resetMatch) continue; - const resetInSec = - resetMatch[1] === "reset-now" - ? 0 - : parseOpenCodeGoHumanReset( - // Strip any HTML comment, INCLUDING an unterminated one (React SSR emits - // / hydration markers). The `(?:-->|$)` arm consumes a - // trailing "" too, so no partial "|$)/g, "").replace(/Resets?\s*in\s*/i, "") - ); - if (resetInSec === null || !Number.isFinite(resetInSec)) continue; - const window = { - usagePercent, - resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), - }; - if (label.includes("rolling")) usage.session = window; - else if (label.includes("weekly")) usage.weekly = window; - else if (label.includes("monthly")) usage.mcp_monthly = window; - } - return usage.session || usage.weekly || usage.mcp_monthly ? usage : null; -} - -async function fetchOpenCodeGoDashboardUsage( - config: Extract -) { - const url = `${OPENCODE_GO_DASHBOARD_BASE_URL.replace(/\/+$/, "")}/${encodeURIComponent( - config.workspaceId - )}/go`; - const response = await fetch(url, { - headers: { - Accept: "text/html", - Cookie: `auth=${normalizeOpenCodeGoAuthCookie(config.authCookie)}`, - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/152.0", - }, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) - return { usage: null, message: `OpenCode Go dashboard error (${response.status}).` }; - const usage = parseOpenCodeGoDashboardHtml(await response.text()); - return { - usage, - message: usage ? undefined : "OpenCode Go dashboard response did not contain quota windows.", - }; -} - -export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: JsonRecord) { - const dashboardConfig = resolveOpenCodeGoDashboardConfig(providerSpecificData); - if (dashboardConfig.state === "incomplete") { - return { - message: `OpenCode Go dashboard quota config is incomplete. Missing ${dashboardConfig.missing}.`, - }; - } - if (dashboardConfig.state === "configured") { - try { - const dashboard = await fetchOpenCodeGoDashboardUsage(dashboardConfig); - if (!dashboard.usage) { - return { message: dashboard.message || "OpenCode Go dashboard quota data unavailable." }; - } - const quotas: Record = {}; - for (const quotaName of OPENCODE_GO_QUOTA_ORDER) { - const usage = dashboard.usage[quotaName]; - if (usage) { - quotas[quotaName] = buildOpenCodeGoDollarQuota( - quotaName, - usage.usagePercent, - usage.resetAt - ); - } - } - return { plan: "OpenCode Go", quotas: orderOpenCodeGoQuotas(quotas) }; - } catch (error) { - return { message: `OpenCode Go dashboard quota error: ${sanitizeErrorMessage(error)}` }; - } - } - - const token = apiKey.trim().replace(/^Bearer\s+/i, ""); - if (!token) { - return { - message: - "OpenCode Go quota requires OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE. " + - "The API key can be used for chat/models, but OpenCode Go does not expose quota via API key.", - }; - } - - if (!OPENCODE_GO_QUOTA_URL) { - return { - message: - "OpenCode Go does not expose a public quota API. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping, " + - "or set OMNIROUTE_OPENCODE_GO_QUOTA_URL to opt in to an explicit quota endpoint.", - }; - } - - try { - const res = await fetch(OPENCODE_GO_QUOTA_URL, { - headers: { - Authorization: buildBearerAuthorization(token), - "Accept-Language": "en-US,en", - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return { - message: - "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + - "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", - }; - } - return { - message: - `OpenCode Go quota API error (${res.status}). ` + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status.", - }; - } - - let json: unknown; - try { - json = await res.json(); - } catch { - return { message: "OpenCode Go quota response parsing failed." }; - } - const root = toRecord(json); - if ( - toNumber(root.code, 200) === 401 || - toNumber(root.code, 200) === 403 || - root.success === false - ) { - return { - message: - "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + - "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", - }; - } - - const data = toRecord(root.data); - const quotas: Record = {}; - for (const limit of Array.isArray(data.limits) ? data.limits : []) { - const src = toRecord(limit); - const type = String(src.type || "").toUpperCase(); - const resetAt = parseResetTime(src.nextResetTime); - if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") { - const quotaName = getOpenCodeGoTokenQuotaName(src, quotas); - quotas[quotaName] = buildOpenCodeGoDollarQuota( - quotaName, - src.percentage, - resetAt, - undefined, - Array.isArray(src.models) - ? src.models.map((model) => { - const modelInfo = toRecord(model); - return { - name: String(modelInfo.model || modelInfo.modelCode || "usage"), - used: toNumber(modelInfo.percentage, 0), - }; - }) - : undefined - ); - } else if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") { - quotas.mcp_monthly = buildOpenCodeGoDollarQuota( - "mcp_monthly", - src.percentage, - resetAt, - src.currentValue, - Array.isArray(src.usageDetails) - ? src.usageDetails.map((item) => { - const detail = toRecord(item); - return { - name: String(detail.modelCode || detail.name || "usage"), - used: toNumber(detail.usage, 0), - }; - }) - : undefined - ); - } - } - - const levelRaw = - typeof data.planName === "string" - ? data.planName - : typeof data.level === "string" - ? data.level - : ""; - const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, "")); - return { - plan: planLabel - ? /^opencode\s+go\b/i.test(planLabel) - ? planLabel - : `OpenCode Go ${planLabel}` - : null, - quotas: orderOpenCodeGoQuotas(quotas), - }; - } catch (error) { - return { message: `OpenCode Go quota API error: ${sanitizeErrorMessage(error)}` }; - } -} - function resolveOllamaCloudConfig(providerSpecificData?: JsonRecord): OllamaCloudConfig { const cookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE?.trim() || diff --git a/open-sse/services/opencodeQuotaFetcher.ts b/open-sse/services/opencodeQuotaFetcher.ts index 359213f97b..5420e263e1 100644 --- a/open-sse/services/opencodeQuotaFetcher.ts +++ b/open-sse/services/opencodeQuotaFetcher.ts @@ -1,68 +1,24 @@ /** - * opencodeQuotaFetcher.ts — OpenCode Go / OpenCode / OpenCode Zen Quota Fetcher + * OpenCode Go / OpenCode / OpenCode Zen quota fetcher. * - * Implements QuotaFetcher for the opencode-go, opencode, and opencode-zen providers - * (quotaPreflight.ts + quotaMonitor.ts). - * - * OpenCode Go has THREE independent quota windows per subscription: - * - 5-hour (rolling): $12 of usage - * - Weekly: $30 of usage - * - Monthly: $60 of usage - * - * Upstream endpoint (defensive — no public API exists yet): - * GET https://opencode.ai/zen/go/v1/quota + * The official API exposes rolling, weekly, and monthly usage percentages: + * GET https://opencode.ai/zen/go/v1/usage * Authorization: Bearer * - * Expected response shape: - * { - * quota: { - * window_5h: { used: number, limit: number, reset_at: number | null }, - * window_weekly: { used: number, limit: number, reset_at: number | null }, - * window_monthly: { used: number, limit: number, reset_at: number | null } - * } - * } - * - * NOTE: As of 2026, no public quota API exists for OpenCode Go / OpenCode Zen - * (tracked upstream in anomalyco/opencode#16017, #18648, #31084). The default - * endpoint currently returns HTTP 404. This fetcher is implemented defensively - * so that the dashboard shows "No quota data" gracefully rather than crashing, - * and so that when an endpoint is finally published, only the URL constant - * needs to change. - * - * On a 404 response we log ONE console.warn (latched per process — not per - * request) pointing at the upstream tracking issues, then cache the - * "endpoint unavailable" result for 5 minutes to avoid hammering. On any other - * non-200 / parse failure we return null (fail-open) silently. The first - * call from each server boot is what the operator is most likely to see, so - * we make it count. - * - * Cache: in-memory TTL (60s for success, 5 min for 404). - * - * Override: set OMNIROUTE_OPENCODE_QUOTA_URL to a working endpoint. If - * OpenCode ships a public endpoint (likely in the form of the merged PR - * #16513), the maintainer can update the default. - * - * Registration: call registerOpencodeQuotaFetcher() once at server startup. + * Successful responses are cached for 60 seconds. Network, HTTP, and response + * parsing failures remain fail-open so quota checks never block on missing data. */ import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts"; import { registerMonitorFetcher } from "./quotaMonitor.ts"; import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; -import { resolveOpenCodeGoDashboardConfig } from "./opencodeOllamaUsage.ts"; -// OpenCode quota endpoint — same key works across opencode, opencode-go, opencode-zen -// Default points at /zen/go/v1/quota which returns 404 today (no public quota API yet, -// tracked in anomalyco/opencode#16017). Set OMNIROUTE_OPENCODE_QUOTA_URL to override. const OPENCODE_QUOTA_URL = - process.env.OMNIROUTE_OPENCODE_QUOTA_URL ?? "https://opencode.ai/zen/go/v1/quota"; + process.env.OMNIROUTE_OPENCODE_QUOTA_URL ?? "https://opencode.ai/zen/go/v1/usage"; -// Cache TTL — matches Codex / DeepSeek / Bailian pattern (60s) const CACHE_TTL_MS = 60_000; -// TTL for cached "endpoint unavailable" results (404) — longer to avoid hammering -// a non-existent endpoint -const NO_ENDPOINT_TTL_MS = 5 * 60_000; // 5 minutes -// Window keys as surfaced to the dashboard and quota-window registry +// Window keys surfaced to the quota UI and quota-window registry export const OPENCODE_WINDOW_5H = "window_5h"; export const OPENCODE_WINDOW_WEEKLY = "window_weekly"; export const OPENCODE_WINDOW_MONTHLY = "window_monthly"; @@ -76,13 +32,12 @@ export interface OpencodeTripleWindowQuota extends QuotaInfo { } interface CacheEntry { - quota: OpencodeTripleWindowQuota | null; + quota: OpencodeTripleWindowQuota; fetchedAt: number; - /** true when quota is null because the upstream endpoint returned 404 */ - noEndpoint?: boolean; + apiKey: string; } -// In-memory cache: connectionId → { quota, fetchedAt } +// In-memory cache: connectionId → successful quota bound to its normalized API key const quotaCache = new Map(); // One-time 404 warning per URL (avoids spamming on every request) @@ -120,280 +75,105 @@ if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { // ─── Helpers ────────────────────────────────────────────────────────────────── -function toNumber(value: unknown, fallback = 0): number { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string") { - const parsed = parseFloat(value); - if (Number.isFinite(parsed)) return parsed; - } - return fallback; -} - function toRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } -function parseWindowResetAt(window: Record): string | null { - const resetAt = toNumber(window["reset_at"] ?? window["resetAt"], 0); - if (resetAt > 0) { - // Unix timestamp in seconds (< 1e12) or milliseconds (>= 1e12) - return new Date(resetAt < 1e12 ? resetAt * 1000 : resetAt).toISOString(); - } - const resetAfterSeconds = toNumber( - window["reset_after_seconds"] ?? window["resetAfterSeconds"], - 0 - ); - if (resetAfterSeconds > 0) { - return new Date(Date.now() + resetAfterSeconds * 1000).toISOString(); - } - return null; -} +type ParsedUsageWindow = { + percentUsed: number; + resetAt: string; + limitReached: boolean; +}; -function parseWindowPercent(window: Record): number { - const used = toNumber(window["used"] ?? window["used_amount"], 0); - const limit = toNumber(window["limit"] ?? window["limit_amount"], 0); - if (limit <= 0) return 0; - return Math.max(0, Math.min(1, used / limit)); -} - -// ─── Response Parser ────────────────────────────────────────────────────────── - -function parseOpencodeQuotaResponse(data: unknown): OpencodeTripleWindowQuota | null { - const obj = toRecord(data); - const quotaObj = toRecord(obj["quota"] ?? obj["data"] ?? obj["usage"]); - - // Look for windows under various possible keys - const w5h = toRecord( - quotaObj[OPENCODE_WINDOW_5H] ?? quotaObj["5h"] ?? quotaObj["hourly"] ?? quotaObj["short"] - ); - const wWeekly = toRecord( - quotaObj[OPENCODE_WINDOW_WEEKLY] ?? quotaObj["weekly"] ?? quotaObj["week"] ?? quotaObj["wk"] - ); - const wMonthly = toRecord( - quotaObj[OPENCODE_WINDOW_MONTHLY] ?? quotaObj["monthly"] ?? quotaObj["month"] ?? quotaObj["mo"] - ); - - const has5h = Object.keys(w5h).length > 0; - const hasWeekly = Object.keys(wWeekly).length > 0; - const hasMonthly = Object.keys(wMonthly).length > 0; - - // Need at least one window to be meaningful - if (!has5h && !hasWeekly && !hasMonthly) return null; - - const percent5h = has5h ? parseWindowPercent(w5h) : 0; - const percentWeekly = hasWeekly ? parseWindowPercent(wWeekly) : 0; - const percentMonthly = hasMonthly ? parseWindowPercent(wMonthly) : 0; - - const resetAt5h = has5h ? parseWindowResetAt(w5h) : null; - const resetAtWeekly = hasWeekly ? parseWindowResetAt(wWeekly) : null; - const resetAtMonthly = hasMonthly ? parseWindowResetAt(wMonthly) : null; - - const worstPercent = Math.max(percent5h, percentWeekly, percentMonthly); - const limitReached = - Boolean(obj["limit_reached"] ?? quotaObj["limit_reached"]) || worstPercent >= 1; - - // Dominant reset: pick the window with the worst usage - let dominantResetAt: string | null = null; - if (worstPercent === percent5h) { - dominantResetAt = resetAt5h ?? resetAtWeekly ?? resetAtMonthly; - } else if (worstPercent === percentWeekly) { - dominantResetAt = resetAtWeekly ?? resetAt5h ?? resetAtMonthly; - } else { - dominantResetAt = resetAtMonthly ?? resetAtWeekly ?? resetAt5h; - } - - const window5h = { percentUsed: percent5h, resetAt: resetAt5h }; - const windowWeekly = { percentUsed: percentWeekly, resetAt: resetAtWeekly }; - const windowMonthly = { percentUsed: percentMonthly, resetAt: resetAtMonthly }; - - const windows: Record = {}; - if (has5h) windows[OPENCODE_WINDOW_5H] = window5h; - if (hasWeekly) windows[OPENCODE_WINDOW_WEEKLY] = windowWeekly; - if (hasMonthly) windows[OPENCODE_WINDOW_MONTHLY] = windowMonthly; - - return { - used: worstPercent * 100, - total: 100, - percentUsed: worstPercent, - resetAt: dominantResetAt, - windows, - window5h, - windowWeekly, - windowMonthly, - limitReached, - }; -} - -// ─── Core Fetcher ───────────────────────────────────────────────────────────── - -// ─── Dashboard Snapshot Bridge (#11234) ─────────────────────────────────────── -// -// The live endpoint above has no public quota API today (404 — see module -// JSDoc), so without this bridge every preflight evaluated `null` and -// proceeded (fail-open) even when the dashboard already showed a drained -// window. The dashboard scrape (`getOpenCodeGoUsage` in -// opencodeOllamaUsage.ts) persists per-window snapshots through -// `src/domain/quotaCache.ts::setQuotaCache` under the window keys -// session / weekly / mcp_monthly; this bridge synthesizes the same -// OpencodeTripleWindowQuota shape from those cached snapshots so the quota -// cutoff sees them. -// -// Read-only: accessors only, never SQL, never a re-scrape on the hot path. -// Fail-open is preserved — no snapshots means `null`, exactly as before. - -// Dashboard snapshot key → fetcher/preflight window key. -const DASHBOARD_SNAPSHOT_WINDOW_MAP: ReadonlyArray = [ - ["session", OPENCODE_WINDOW_5H], - ["weekly", OPENCODE_WINDOW_WEEKLY], - ["mcp_monthly", OPENCODE_WINDOW_MONTHLY], -]; - -function hasDashboardQuotaConfig(connection?: Record): boolean { - // Snapshots can only exist when the operator configured the dashboard - // scrape for this connection (or globally via env). Gating on it keeps the - // snapshot read (and its cold-start DB hydration) off connections that - // could never have produced one. - const psd = connection?.providerSpecificData as Record | undefined; - return resolveOpenCodeGoDashboardConfig(psd).state !== "none"; -} - -async function synthesizeQuotaFromDashboardSnapshots( - connectionId: string -): Promise { - let quotaCacheDomain: typeof import("../../src/domain/quotaCache.ts"); - try { - // Dynamic import: a static edge would close an initialization cycle - // (opencodeQuotaFetcher → quotaCache → usage.ts → usage/opencode.ts → - // opencodeQuotaFetcher). - quotaCacheDomain = await import("../../src/domain/quotaCache.ts"); - } catch { +function parseUsageWindow(value: unknown): ParsedUsageWindow | null { + const window = toRecord(value); + const { status, percent, resetsAt } = window; + if ( + (status !== "ok" && status !== "rate-limited") || + typeof percent !== "number" || + !Number.isFinite(percent) || + percent < 0 || + percent > 100 || + typeof resetsAt !== "string" || + !resetsAt || + !Number.isFinite(Date.parse(resetsAt)) + ) { return null; } - // Hydrate the in-memory cache from persisted snapshots when cold (the - // accessor does this internally), then read the raw per-window rows. - quotaCacheDomain.getQuotaWindowStatus(connectionId, DASHBOARD_SNAPSHOT_WINDOW_MAP[0][0]); - const entry = quotaCacheDomain.getQuotaCache(connectionId); - const quotas = entry?.quotas; - if (!quotas || typeof quotas !== "object") return null; + return { + percentUsed: status === "rate-limited" ? 1 : percent / 100, + resetAt: resetsAt, + limitReached: status === "rate-limited" || percent === 100, + }; +} - const now = Date.now(); - const windows: Record = {}; - - for (const [snapshotKey, windowKey] of DASHBOARD_SNAPSHOT_WINDOW_MAP) { - const raw = quotas[snapshotKey]; - if (!raw || typeof raw.remainingPercentage !== "number") continue; - // #10095 mirror: a window whose fraction upstream never reported is - // "unknown", not 0% — it must not count as exhausted. - if (raw.fractionReported === false) continue; - const resetAt = typeof raw.resetAt === "string" && raw.resetAt ? raw.resetAt : null; - if (resetAt) { - const resetMs = Date.parse(resetAt); - // Mirror getQuotaWindowStatus (quotaCache.ts): an expired resetAt means - // the window has rolled into a fresh period — the cached percentage is - // stale and must not count as exhausted. - if (Number.isFinite(resetMs) && resetMs <= now) continue; - } - const remaining = Math.max(0, Math.min(100, raw.remainingPercentage)); - windows[windowKey] = { percentUsed: 1 - remaining / 100, resetAt }; - } - - if (Object.keys(windows).length === 0) return null; - - const window5h = windows[OPENCODE_WINDOW_5H] ?? { percentUsed: 0, resetAt: null }; - const windowWeekly = windows[OPENCODE_WINDOW_WEEKLY] ?? { percentUsed: 0, resetAt: null }; - const windowMonthly = windows[OPENCODE_WINDOW_MONTHLY] ?? { percentUsed: 0, resetAt: null }; +function parseOpencodeQuotaResponse(data: unknown): OpencodeTripleWindowQuota | null { + const usage = toRecord(toRecord(data).usage); + const rolling = parseUsageWindow(usage.rolling); + const weekly = parseUsageWindow(usage.weekly); + const monthly = parseUsageWindow(usage.monthly); + if (!rolling || !weekly || !monthly) return null; + const window5h = { percentUsed: rolling.percentUsed, resetAt: rolling.resetAt }; + const windowWeekly = { percentUsed: weekly.percentUsed, resetAt: weekly.resetAt }; + const windowMonthly = { percentUsed: monthly.percentUsed, resetAt: monthly.resetAt }; const worstPercent = Math.max( window5h.percentUsed, windowWeekly.percentUsed, windowMonthly.percentUsed ); - - // Dominant reset: pick the window with the worst usage (same policy as the - // live-response parser above). - let dominantResetAt: string | null = null; - if (worstPercent === window5h.percentUsed) { - dominantResetAt = window5h.resetAt ?? windowWeekly.resetAt ?? windowMonthly.resetAt; - } else if (worstPercent === windowWeekly.percentUsed) { - dominantResetAt = windowWeekly.resetAt ?? window5h.resetAt ?? windowMonthly.resetAt; - } else { - dominantResetAt = windowMonthly.resetAt ?? windowWeekly.resetAt ?? window5h.resetAt; - } + const dominantResetAt = + worstPercent === window5h.percentUsed + ? window5h.resetAt + : worstPercent === windowWeekly.percentUsed + ? windowWeekly.resetAt + : windowMonthly.resetAt; return { used: worstPercent * 100, total: 100, percentUsed: worstPercent, resetAt: dominantResetAt, - windows, + windows: { + [OPENCODE_WINDOW_5H]: window5h, + [OPENCODE_WINDOW_WEEKLY]: windowWeekly, + [OPENCODE_WINDOW_MONTHLY]: windowMonthly, + }, window5h, windowWeekly, windowMonthly, - limitReached: worstPercent >= 1, + limitReached: rolling.limitReached || weekly.limitReached || monthly.limitReached, }; } +// ─── Core Fetcher ───────────────────────────────────────────────────────────── /** * Fetch current quota for an OpenCode connection. - * Returns percentUsed = max(5h%, weekly%, monthly%) — worst-case across all windows. - * - * Defensive implementation: returns null on any non-200 / parse failure (fail-open). - * See module-level JSDoc for upstream API stability note. - * - * @param connectionId - Connection ID from the DB (used for cache keying) - * @param connection - Optional connection snapshot with apiKey - * @returns OpencodeTripleWindowQuota or null if fetch fails / no credentials + * Returns null on missing credentials, HTTP errors, malformed responses, and + * network failures so callers preserve fail-open behavior. */ export async function fetchOpencodeQuota( connectionId: string, connection?: Record ): Promise { - // Snapshots can only exist when the dashboard scrape is configured for this - // connection (or globally via env); without it the bridge stays off and the - // fetcher never touches the snapshot store. - const dashboardConfigured = hasDashboardQuotaConfig(connection); - - // Check cache first - const cached = quotaCache.get(connectionId); - if (cached) { - // 404 sentinel — use longer TTL to avoid hammering a non-existent endpoint - if (cached.noEndpoint && Date.now() - cached.fetchedAt < NO_ENDPOINT_TTL_MS) { - // The live endpoint is known-absent — serve dashboard snapshots if the - // operator configured the scrape (#11234). - return dashboardConfigured ? synthesizeQuotaFromDashboardSnapshots(connectionId) : null; - } - if (cached.quota !== null && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { - return cached.quota; - } - } - - const live = await fetchLiveOpencodeQuota(connectionId, connection); - if (live) return live; - - // #11234 — the live endpoint has no public quota API (404) or failed: - // fall back to the operator-configured dashboard snapshots, read-only. - return dashboardConfigured ? synthesizeQuotaFromDashboardSnapshots(connectionId) : null; -} - -async function fetchLiveOpencodeQuota( - connectionId: string, - connection?: Record -): Promise { - // Extract API key from connection const apiKey = - typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0 + typeof connection?.apiKey === "string" ? connection.apiKey - : null; + .trim() + .replace(/^Bearer\s+/i, "") + .trim() + : ""; + if (!apiKey) return null; - if (!apiKey) { - return null; + const cached = quotaCache.get(connectionId); + if (cached && cached.apiKey === apiKey && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; } try { - // #6911: space concurrent upstream quota fetches (mirrors codexQuotaFetcher.ts). await throttleQuotaFetch(); const response = await fetch(OPENCODE_QUOTA_URL, { method: "GET", @@ -406,29 +186,14 @@ async function fetchLiveOpencodeQuota( }); if (!response.ok) { - if (response.status === 404) { - // Upstream doesn't expose this endpoint. Warn once per URL per process so - // operators know the dashboard will be empty for opencode-go connections. - // Cache a 404 sentinel for NO_ENDPOINT_TTL_MS to avoid hammering. - // See opencode issues #10448, #16017, #18648, #31084. - if (!_warned404Urls.has(OPENCODE_QUOTA_URL)) { - _warned404Urls.add(OPENCODE_QUOTA_URL); - console.warn( - `[opencodeQuotaFetcher] ${OPENCODE_QUOTA_URL} returned 404 — opencode-go usage API is not yet public. ` + - `Set OMNIROUTE_OPENCODE_QUOTA_URL to a working endpoint, or follow ` + - `https://github.com/anomalyco/opencode/issues/16017 for upstream status.` - ); - } - quotaCache.set(connectionId, { - quota: null, - fetchedAt: Date.now(), - noEndpoint: true, - }); - return null; - } - if (response.status === 401 || response.status === 403) { - quotaCache.delete(connectionId); + if (response.status === 404 && !_warned404Urls.has(OPENCODE_QUOTA_URL)) { + _warned404Urls.add(OPENCODE_QUOTA_URL); + console.warn( + `[opencodeQuotaFetcher] Official usage endpoint ${OPENCODE_QUOTA_URL} returned 404. ` + + "Verify OMNIROUTE_OPENCODE_QUOTA_URL when using a relay or test server." + ); } + if (response.status === 401 || response.status === 403) quotaCache.delete(connectionId); return null; } @@ -436,18 +201,15 @@ async function fetchLiveOpencodeQuota( try { data = await response.json(); } catch { - // Malformed JSON — fail open return null; } const quota = parseOpencodeQuotaResponse(data); if (!quota) return null; - // Store in cache - quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + quotaCache.set(connectionId, { quota, fetchedAt: Date.now(), apiKey }); return quota; } catch { - // Network error, timeout, etc. — fail open return null; } } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 1bc537d9f6..c78e57f2ff 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -49,7 +49,7 @@ import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./us export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; import { getAdobeFireflyUsage } from "./usage/adobeFirefly.ts"; import { getOpenrouterUsage } from "./usage/openrouter.ts"; -import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; +import { getOllamaCloudUsage } from "./opencodeOllamaUsage.ts"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; import { getPromptQlUsage } from "./usage/promptql.ts"; import { getHyperAgentUsage } from "./usage/hyperagent.ts"; @@ -150,7 +150,7 @@ export async function getUsageForProvider( ...(provider === "glm-cn" ? { apiRegion: "china" } : {}), }); case "opencode-go": - return await getOpenCodeGoUsage(apiKey || "", providerSpecificData); + return await getOpencodeUsage(id || "", apiKey || ""); case "ollama-cloud": return await getOllamaCloudUsage(providerSpecificData); case "minimax": diff --git a/open-sse/services/usage/opencode.ts b/open-sse/services/usage/opencode.ts index 689a7c95ca..9dbf405833 100644 --- a/open-sse/services/usage/opencode.ts +++ b/open-sse/services/usage/opencode.ts @@ -10,7 +10,7 @@ * (dispatcher + __testing). Behavior-preserving move. */ -import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "../opencodeQuotaFetcher.ts"; +import { fetchOpencodeQuota } from "../opencodeQuotaFetcher.ts"; import { sanitizeErrorMessage } from "../../utils/error.ts"; import { type UsageQuota } from "./quota.ts"; @@ -27,52 +27,47 @@ export async function getOpencodeUsage(connectionId: string, apiKey: string) { } try { - const quota = (await fetchOpencodeQuota(connectionId, { - apiKey, - })) as OpencodeTripleWindowQuota | null; + const quota = await fetchOpencodeQuota(connectionId, { apiKey }); if (!quota) { - return { message: "OpenCode connected. Unable to fetch quota data." }; + return { + message: "OpenCode connected. Unable to fetch quota data from the official usage endpoint.", + }; } const { window5h, windowWeekly, windowMonthly, limitReached } = quota; - const quotas: Record = {}; - - // $12 / 5-hour rolling window - quotas["window_5h"] = { - used: window5h.percentUsed * 12, - total: 12, - remaining: (1 - window5h.percentUsed) * 12, - remainingPercentage: (1 - window5h.percentUsed) * 100, - resetAt: window5h.resetAt, - unlimited: false, - displayName: "$12 / 5-hour", - currency: "USD", - }; - - // $30 / weekly window - quotas["window_weekly"] = { - used: windowWeekly.percentUsed * 30, - total: 30, - remaining: (1 - windowWeekly.percentUsed) * 30, - remainingPercentage: (1 - windowWeekly.percentUsed) * 100, - resetAt: windowWeekly.resetAt, - unlimited: false, - displayName: "$30 / week", - currency: "USD", - }; - - // $60 / monthly window - quotas["window_monthly"] = { - used: windowMonthly.percentUsed * 60, - total: 60, - remaining: (1 - windowMonthly.percentUsed) * 60, - remainingPercentage: (1 - windowMonthly.percentUsed) * 100, - resetAt: windowMonthly.resetAt, - unlimited: false, - displayName: "$60 / month", - currency: "USD", + const quotas: Record = { + session: { + used: window5h.percentUsed * 12, + total: 12, + remaining: (1 - window5h.percentUsed) * 12, + remainingPercentage: (1 - window5h.percentUsed) * 100, + resetAt: window5h.resetAt, + unlimited: false, + displayName: "$12 / 5-hour", + currency: "USD", + }, + weekly: { + used: windowWeekly.percentUsed * 30, + total: 30, + remaining: (1 - windowWeekly.percentUsed) * 30, + remainingPercentage: (1 - windowWeekly.percentUsed) * 100, + resetAt: windowWeekly.resetAt, + unlimited: false, + displayName: "$30 / week", + currency: "USD", + }, + mcp_monthly: { + used: windowMonthly.percentUsed * 60, + total: 60, + remaining: (1 - windowMonthly.percentUsed) * 60, + remainingPercentage: (1 - windowMonthly.percentUsed) * 100, + resetAt: windowMonthly.resetAt, + unlimited: false, + displayName: "$60 / month", + currency: "USD", + }, }; return { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 29d9996c04..aaf302a9d1 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -275,10 +275,6 @@ export default function EditConnectionModal({ const existingOpenRouterPreset = stringField(connection.providerSpecificData?.preset); const existingCx = stringField(connection.providerSpecificData?.cx); const existingAccountId = stringField(connection.providerSpecificData?.accountId); - const existingOpenCodeGoWorkspaceId = - stringField(connection.providerSpecificData?.opencodeGoWorkspaceId) || - stringField(connection.providerSpecificData?.openCodeGoWorkspaceId) || - stringField(connection.providerSpecificData?.workspaceId); const existingGlmOrganizationId = stringField(connection.providerSpecificData?.glmOrganizationId) || stringField(connection.providerSpecificData?.bigmodelOrganization) || @@ -369,8 +365,6 @@ export default function EditConnectionModal({ quotaPerUnit: existingQuotaPerUnit, glmOrganizationId: existingGlmOrganizationId, glmProjectId: existingGlmProjectId, - opencodeGoWorkspaceId: existingOpenCodeGoWorkspaceId, - opencodeGoAuthCookie: "", ollamaCloudUsageCookie: "", alibabaConsoleCookie: stringField(connection.providerSpecificData?.alibabaConsoleCookie), qwenCloudCookie: stringField(connection.providerSpecificData?.qwenCloudCookie), diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx index 923dda2fa7..240eafea50 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -29,45 +29,6 @@ export default function QuotaScrapingFields({ t, editMode = false, }: QuotaScrapingFieldsProps) { - if (provider === "opencode-go") { - return ( -
- onChange({ opencodeGoWorkspaceId: e.target.value })} - placeholder="workspace_..." - hint={providerText( - t, - "opencodeGoWorkspaceIdHint", - "Required for quota scraping. Copy it from the OpenCode Go workspace URL." - )} - autoComplete="off" - spellCheck={false} - /> - onChange({ opencodeGoAuthCookie: e.target.value })} - placeholder="auth=..." - hint={providerText( - t, - "opencodeGoAuthCookieHint", - editMode - ? "Leave blank to keep the stored cookie. Paste auth=... or only the cookie value to replace it." - : "Paste the auth cookie value from opencode.ai. The auth= prefix is accepted." - )} - autoComplete="off" - spellCheck={false} - autoCapitalize="off" - /> -
- ); - } - if (provider === "ollama-cloud") { return (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts index dcf9d3d3d2..8558b0edf7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts @@ -1,7 +1,5 @@ /** - * quotaScrapingFieldValues.ts — form-state shape + persistence rules for the - * quota-scraping credential fields (cookies / workspace ids) rendered by - * QuotaScrapingFields.tsx. + * quota-scraping credential fields rendered by QuotaScrapingFields.tsx. * * Kept in a UI-free module on purpose: importing the .tsx pulls in * `@/shared/components`, whose barrel reaches untranspiled ESM deps @@ -13,8 +11,6 @@ export const QWEN_TOKEN_PLAN_PROVIDERS = new Set(["qwen-cloud-token-plan", "bailian-coding-plan"]); export type QuotaScrapingFieldValues = { - opencodeGoWorkspaceId: string; - opencodeGoAuthCookie: string; ollamaCloudUsageCookie: string; alibabaConsoleCookie: string; alibabaConsoleSecToken: string; @@ -23,8 +19,6 @@ export type QuotaScrapingFieldValues = { }; export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { - opencodeGoWorkspaceId: "", - opencodeGoAuthCookie: "", ollamaCloudUsageCookie: "", alibabaConsoleCookie: "", alibabaConsoleSecToken: "", @@ -37,12 +31,7 @@ export function assignQuotaScrapingProviderData( values: QuotaScrapingFieldValues, target: Record ) { - if (provider === "opencode-go") { - target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined; - if (values.opencodeGoAuthCookie.trim()) { - target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim(); - } - } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { + if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); } else if ( (provider === "alibaba" || provider === "alibaba-cn") && diff --git a/tests/unit/opencode-go-quota-no-zai.test.ts b/tests/unit/opencode-go-quota-no-zai.test.ts index b4775040fa..8fdaa464c4 100644 --- a/tests/unit/opencode-go-quota-no-zai.test.ts +++ b/tests/unit/opencode-go-quota-no-zai.test.ts @@ -1,31 +1,60 @@ import assert from "node:assert/strict"; -import { test } from "node:test"; +import test, { after } from "node:test"; -import { getOpenCodeGoUsage } from "../../open-sse/services/opencodeOllamaUsage.ts"; +const originalQuotaUrl = process.env.OMNIROUTE_OPENCODE_QUOTA_URL; +delete process.env.OMNIROUTE_OPENCODE_QUOTA_URL; -test("getOpenCodeGoUsage does not send the user's OpenCode Go API key to api.z.ai by default", async () => { - const originalFetch = globalThis.fetch; - const originalEnv = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; - delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; +// Dynamic import is required so the module captures the cleared endpoint override. +const { fetchOpencodeQuota, invalidateOpencodeQuotaCache } = + await import("../../open-sse/services/opencodeQuotaFetcher.ts"); - let calledHost: string | null = null; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - calledHost = new URL(url).host; - throw new Error(`unexpected outbound fetch to ${url}`); - }) as typeof fetch; +const originalFetch = globalThis.fetch; + +after(() => { + globalThis.fetch = originalFetch; + if (originalQuotaUrl === undefined) delete process.env.OMNIROUTE_OPENCODE_QUOTA_URL; + else process.env.OMNIROUTE_OPENCODE_QUOTA_URL = originalQuotaUrl; +}); + +test("fetchOpencodeQuota uses the official OpenCode Go usage endpoint by default", async () => { + const connectionId = `official-endpoint-${Date.now()}`; + let requestUrl = ""; + let authorization: string | null = null; + + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + requestUrl = request.url; + authorization = request.headers.get("Authorization"); + return new Response( + JSON.stringify({ + usage: { + rolling: { + status: "ok", + percent: 10, + resetsAt: "2026-09-01T01:02:03.000Z", + }, + weekly: { + status: "ok", + percent: 20, + resetsAt: "2026-09-05T04:05:06.000Z", + }, + monthly: { + status: "ok", + percent: 30, + resetsAt: "2026-09-30T07:08:09.000Z", + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; try { - const result = await getOpenCodeGoUsage("sk-fake-opencode-go-key", undefined); - assert.notStrictEqual(calledHost, "api.z.ai"); - assert.strictEqual(calledHost, null); - assert.ok( - typeof result.message === "string" && result.message.length > 0, - "expected a descriptive message when no quota URL is configured" - ); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }); + assert.ok(quota); + assert.equal(requestUrl, "https://opencode.ai/zen/go/v1/usage"); + assert.equal(authorization, "Bearer opencode-key"); } finally { - globalThis.fetch = originalFetch; - if (originalEnv === undefined) delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; - else process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = originalEnv; + invalidateOpencodeQuotaCache(connectionId); } }); diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index 302a95f244..6587368e66 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -1,97 +1,72 @@ -import test, { after } from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; -// The OpenCode Go quota-by-API-key path is opt-in only (see #7022 — there is no -// working default quota endpoint, so OMNIROUTE_OPENCODE_GO_QUOTA_URL must be set -// explicitly by the operator). The module reads this env var once at import time, -// so it has to be set BEFORE the dynamic import below for the opt-in tests in this -// file (which simulate an operator who configured the URL) to exercise the fetch path. -const ORIGINAL_OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; -process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit"; +import { getUsageForProvider } from "../../open-sse/services/usage.ts"; +import { invalidateOpencodeQuotaCache } from "../../open-sse/services/opencodeQuotaFetcher.ts"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; -const usage = await import("../../open-sse/services/usage.ts"); -const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +type ProviderQuota = { + used: number; + total: number; + remaining: number; + remainingPercentage: number; + resetAt: string | null; + unlimited: boolean; + displayName?: string; + currency?: string; +}; +type ProviderUsage = { + plan?: string | null; + quotas?: Record; + limitReached?: boolean; + message?: string; +}; -after(() => { - if (ORIGINAL_OPENCODE_GO_QUOTA_URL === undefined) { - delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; - } else { - process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = ORIGINAL_OPENCODE_GO_QUOTA_URL; - } +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; }); test("USAGE_SUPPORTED_PROVIDERS includes opencode-go", () => { - assert.ok( - (USAGE_SUPPORTED_PROVIDERS as string[]).includes("opencode-go"), - "opencode-go must be in the usage-supported providers allowlist" - ); + assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("opencode-go")); }); -test("getUsageForProvider returns helpful message when opencode-go has no apiKey", async () => { +test("getUsageForProvider does not fetch OpenCode Go quota without an API key", async () => { let called = false; - const originalFetch = globalThis.fetch; globalThis.fetch = async () => { called = true; return new Response("unexpected", { status: 500 }); }; - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-no-key", - provider: "opencode-go", - apiKey: "", - })) as { message?: string }; + const result = (await getUsageForProvider({ + id: `opencode-go-no-key-${Date.now()}`, + provider: "opencode-go", + apiKey: "", + })) as ProviderUsage; - assert.equal(called, false, "quota fetch must not run without an API key"); - assert.match(result.message ?? "", /OpenCode Go/); - assert.match(result.message ?? "", /OPENCODE_GO_WORKSPACE_ID/); - } finally { - globalThis.fetch = originalFetch; - } + assert.equal(called, false); + assert.match(result.message ?? "", /OpenCode.*API key/i); }); -test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", async () => { - const originalFetch = globalThis.fetch; - const reset5h = Date.now() + 2 * 60 * 60 * 1000; - const resetWeekly = Date.now() + 4 * 24 * 60 * 60 * 1000; - const resetMonthly = Date.now() + 20 * 24 * 60 * 60 * 1000; +test("getUsageForProvider shapes official OpenCode Go usage into Provider Limits quotas", async () => { + const connectionId = `opencode-go-usage-${Date.now()}`; + const rollingReset = "2026-09-01T01:02:03.000Z"; + const weeklyReset = "2026-09-05T04:05:06.000Z"; + const monthlyReset = "2026-09-30T07:08:09.000Z"; let requestUrl = ""; - let requestHeaders: Headers | null = null; + let authorization: string | null = null; globalThis.fetch = async (input, init) => { - requestUrl = String(input); - requestHeaders = new Headers(init?.headers as HeadersInit | undefined); - + const request = new Request(input, init); + requestUrl = request.url; + authorization = request.headers.get("Authorization"); return new Response( JSON.stringify({ - code: 200, - success: true, - data: { - level: "pro", - limits: [ - { - type: "TOKENS_LIMIT", - unit: 3, - number: 5, - percentage: 25, - nextResetTime: reset5h, - }, - { - type: "TOKENS_LIMIT", - unit: 6, - number: 1, - percentage: 50, - nextResetTime: resetWeekly, - }, - { - type: "TIME_LIMIT", - percentage: 10, - currentValue: 6, - usage: 60, - nextResetTime: resetMonthly, - usageDetails: [{ modelCode: "search-prime", usage: 3 }], - }, - ], + usage: { + rolling: { status: "ok", percent: 25, resetsAt: rollingReset }, + weekly: { status: "ok", percent: 50, resetsAt: weeklyReset }, + monthly: { status: "ok", percent: 10, resetsAt: monthlyReset }, }, }), { status: 200, headers: { "content-type": "application/json" } } @@ -99,291 +74,108 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a }; try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-usage", - provider: "opencode-go", - apiKey: "Bearer opencode-go-key", - })) as { - plan?: string | null; - quotas?: Record< - string, - { - used: number; - total: number; - remaining: number; - remainingPercentage: number; - resetAt: string | null; - displayName?: string; - currency?: string; - details?: Array<{ name: string; used: number }>; - } - >; - }; - - assert.equal(requestUrl, "https://api.z.ai/api/monitor/usage/quota/limit"); - assert.equal(requestHeaders?.get("Authorization"), "Bearer opencode-go-key"); - assert.equal(requestHeaders?.get("Content-Type"), "application/json"); - assert.equal(result.plan, "OpenCode Go Pro"); - assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); - - assert.equal(result.quotas!.session.displayName, "5-hour rolling"); - assert.equal(result.quotas!.session.currency, "USD"); - assert.equal(result.quotas!.session.used, 3); - assert.equal(result.quotas!.session.total, 12); - assert.equal(result.quotas!.session.remaining, 9); - assert.equal(result.quotas!.session.remainingPercentage, 75); - assert.equal(result.quotas!.session.resetAt, new Date(reset5h).toISOString()); - - assert.equal(result.quotas!.weekly.displayName, "Weekly"); - assert.equal(result.quotas!.weekly.used, 15); - assert.equal(result.quotas!.weekly.total, 30); - assert.equal(result.quotas!.weekly.remaining, 15); - assert.equal(result.quotas!.weekly.remainingPercentage, 50); - assert.equal(result.quotas!.weekly.resetAt, new Date(resetWeekly).toISOString()); - - assert.equal(result.quotas!.mcp_monthly.displayName, "Monthly"); - assert.equal(result.quotas!.mcp_monthly.used, 6); - assert.equal(result.quotas!.mcp_monthly.total, 60); - assert.equal(result.quotas!.mcp_monthly.remaining, 54); - assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90); - assert.equal(result.quotas!.mcp_monthly.resetAt, new Date(resetMonthly).toISOString()); - assert.deepEqual(result.quotas!.mcp_monthly.details, [{ name: "search-prime", used: 3 }]); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider ignores out-of-range OpenCode Go reset timestamps", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response( - JSON.stringify({ - code: 200, - success: true, - data: { - level: "pro", - limits: [ - { - type: "TOKENS_LIMIT", - unit: 3, - number: 5, - percentage: 25, - nextResetTime: Number.MAX_VALUE, - }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-huge-reset", + const result = (await getUsageForProvider({ + id: connectionId, provider: "opencode-go", apiKey: "opencode-go-key", - })) as { quotas?: Record }; + })) as ProviderUsage; - assert.equal(result.quotas!.session.resetAt, null); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider scrapes OpenCode Go dashboard quota when workspace cookie is configured", async () => { - const originalFetch = globalThis.fetch; - const originalWorkspace = process.env.OPENCODE_GO_WORKSPACE_ID; - const originalCookie = process.env.OPENCODE_GO_AUTH_COOKIE; - let requestUrl = ""; - let requestHeaders: Headers | null = null; - - process.env.OPENCODE_GO_WORKSPACE_ID = "workspace-123"; - process.env.OPENCODE_GO_AUTH_COOKIE = "auth-cookie-value"; - - globalThis.fetch = async (input, init) => { - requestUrl = String(input); - requestHeaders = new Headers(init?.headers as HeadersInit | undefined); - return new Response( - [ - '
', - 'Rolling Usage', - '25%', - 'Resets in 1 hour 30 minutes', - "
", - '
', - 'Weekly Usage', - '50%', - 'Resets in 2 days', - "
", - '
', - 'Monthly Usage', - '10%', - 'Resets in 10 days', - "
", - ].join(""), - { status: 200, headers: { "content-type": "text/html" } } - ); - }; - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-dashboard", - provider: "opencode-go", - apiKey: "opencode-go-key", - })) as { - plan?: string | null; - quotas?: Record; - }; - - assert.equal(requestUrl, "https://opencode.ai/workspace/workspace-123/go"); - assert.equal(requestHeaders?.get("Cookie"), "auth=auth-cookie-value"); + assert.equal(requestUrl, "https://opencode.ai/zen/go/v1/usage"); + assert.equal(authorization, "Bearer opencode-go-key"); assert.equal(result.plan, "OpenCode Go"); + assert.equal(result.limitReached, false); assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); - assert.equal(result.quotas!.session.used, 3); - assert.equal(result.quotas!.session.total, 12); - assert.equal(result.quotas!.session.remainingPercentage, 75); - assert.equal(result.quotas!.weekly.used, 15); - assert.equal(result.quotas!.weekly.remainingPercentage, 50); - assert.equal(result.quotas!.mcp_monthly.used, 6); - assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90); + + assert.deepEqual(result.quotas?.session, { + used: 3, + total: 12, + remaining: 9, + remainingPercentage: 75, + resetAt: rollingReset, + unlimited: false, + displayName: "$12 / 5-hour", + currency: "USD", + }); + assert.deepEqual(result.quotas?.weekly, { + used: 15, + total: 30, + remaining: 15, + remainingPercentage: 50, + resetAt: weeklyReset, + unlimited: false, + displayName: "$30 / week", + currency: "USD", + }); + assert.deepEqual(result.quotas?.mcp_monthly, { + used: 6, + total: 60, + remaining: 54, + remainingPercentage: 90, + resetAt: monthlyReset, + unlimited: false, + displayName: "$60 / month", + currency: "USD", + }); } finally { - globalThis.fetch = originalFetch; - if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; - else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; - if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; - else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + invalidateOpencodeQuotaCache(connectionId); } }); -// Regression: React SSR wraps the reset-time text in hydration comment markers -// (). The reset string must be fully sanitized (complete -// removal, not just the two literal markers) so the reset time still parses — and so no -// partial "Resets in 1 hour 30 minutes', - "
", - ].join(""), - { status: 200, headers: { "content-type": "text/html" } } + JSON.stringify({ + usage: { + rolling: { status: "rate-limited", percent: 5, resetsAt: rollingReset }, + weekly: { + status: "ok", + percent: 90, + resetsAt: "2026-09-05T04:05:06.000Z", + }, + monthly: { + status: "ok", + percent: 20, + resetsAt: "2026-09-30T07:08:09.000Z", + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } ); try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-dashboard", + const result = (await getUsageForProvider({ + id: connectionId, provider: "opencode-go", apiKey: "opencode-go-key", - })) as { - quotas?: Record; - }; + })) as ProviderUsage; - // session quota resolved → the comment-wrapped reset time was sanitized and parsed - assert.ok( - result.quotas?.session, - "session quota should resolve from comment-wrapped reset-time" - ); - assert.equal(result.quotas!.session.remainingPercentage, 75); + assert.equal(result.limitReached, true); + assert.equal(result.quotas?.session.used, 12); + assert.equal(result.quotas?.session.remaining, 0); + assert.equal(result.quotas?.session.remainingPercentage, 0); + assert.equal(result.quotas?.session.resetAt, rollingReset); + assert.ok(Math.abs((result.quotas?.weekly.remainingPercentage ?? Number.NaN) - 10) < 1e-9); } finally { - globalThis.fetch = originalFetch; - if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; - else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; - if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; - else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + invalidateOpencodeQuotaCache(connectionId); } }); -test("getUsageForProvider returns message for invalid OpenCode Go API keys", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => new Response("nope", { status: 401 }); +test("getUsageForProvider reports unavailable quota when the official request fails open", async () => { + const connectionId = `opencode-go-fail-open-${Date.now()}`; + globalThis.fetch = async () => new Response("unavailable", { status: 503 }); try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-401", - provider: "opencode-go", - apiKey: "bad-key", - })) as { message: string }; - assert.equal( - result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + - "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider returns message when OpenCode Go quota fetch fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => { - throw new Error("network offline"); - }; - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-network-error", + const result = (await getUsageForProvider({ + id: connectionId, provider: "opencode-go", apiKey: "opencode-go-key", - })) as { message: string }; + })) as ProviderUsage; - assert.match(result.message, /OpenCode Go quota API error:/); - assert.match(result.message, /network offline/); + assert.equal(result.plan, undefined); + assert.match(result.message ?? "", /Unable to fetch quota data/i); } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider returns message when OpenCode Go quota API returns 200 with auth error in body", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response(JSON.stringify({ code: 401, msg: "token expired or incorrect", success: false }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-body-401", - provider: "opencode-go", - apiKey: "sk-test-key", - })) as { message: string }; - assert.equal( - result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + - "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider returns message when OpenCode Go quota response is invalid JSON", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response("not json", { - status: 200, - headers: { "content-type": "text/html" }, - }); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-bad-json", - provider: "opencode-go", - apiKey: "sk-test-key", - })) as { message: string }; - assert.equal(result.message, "OpenCode Go quota response parsing failed."); - } finally { - globalThis.fetch = originalFetch; + invalidateOpencodeQuotaCache(connectionId); } }); diff --git a/tests/unit/opencode-quota-fetcher.test.ts b/tests/unit/opencode-quota-fetcher.test.ts index 3c9b905e18..516ff9cbe0 100644 --- a/tests/unit/opencode-quota-fetcher.test.ts +++ b/tests/unit/opencode-quota-fetcher.test.ts @@ -1,12 +1,12 @@ -import test from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; import { fetchOpencodeQuota, invalidateOpencodeQuotaCache, registerOpencodeQuotaFetcher, } from "../../open-sse/services/opencodeQuotaFetcher.ts"; -import { preflightQuota } from "../../open-sse/services/quotaPreflight.ts"; +import { getQuotaFetcher, getQuotaWindows } from "../../open-sse/services/quotaPreflight.ts"; import { clearQuotaMonitors, getActiveMonitorCount, @@ -15,7 +15,37 @@ import { } from "../../open-sse/services/quotaMonitor.ts"; import { clearSessions, touchSession } from "../../open-sse/services/sessionManager.ts"; +type UsageStatus = "ok" | "rate-limited"; +type UsageWindow = { + status: UsageStatus; + percent: number; + resetsAt: string; +}; +type OfficialUsage = { + rolling: UsageWindow; + weekly: UsageWindow; + monthly: UsageWindow; +}; + const originalFetch = globalThis.fetch; +const RESET_ROLLING = "2026-09-01T01:02:03.000Z"; +const RESET_WEEKLY = "2026-09-05T04:05:06.000Z"; +const RESET_MONTHLY = "2026-09-30T07:08:09.000Z"; + +function healthyUsage(): OfficialUsage { + return { + rolling: { status: "ok", percent: 25, resetsAt: RESET_ROLLING }, + weekly: { status: "ok", percent: 50, resetsAt: RESET_WEEKLY }, + monthly: { status: "ok", percent: 10, resetsAt: RESET_MONTHLY }, + }; +} + +function jsonResponse(usage: unknown): Response { + return new Response(JSON.stringify({ usage }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} test.afterEach(() => { globalThis.fetch = originalFetch; @@ -23,377 +53,217 @@ test.afterEach(() => { clearSessions(); }); -// ─── null / missing credentials ────────────────────────────────────────────── - -test("fetchOpencodeQuota returns null when no API key is provided", async () => { - const quota = await fetchOpencodeQuota(`missing-${Date.now()}`); - assert.equal(quota, null); -}); - -test("fetchOpencodeQuota returns null when connection has empty apiKey", async () => { - const quota = await fetchOpencodeQuota(`empty-key-${Date.now()}`, { apiKey: "" }); - assert.equal(quota, null); -}); - -// ─── non-200 responses (fail-open) ─────────────────────────────────────────── - -test("fetchOpencodeQuota returns null on 404 response", async () => { - const connectionId = `oc-404-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 404 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("fetchOpencodeQuota returns null on 401 (invalid token)", async () => { - const connectionId = `oc-401-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 401 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "bad-key" }); - assert.equal(quota, null); -}); - -test("fetchOpencodeQuota returns null on 403 (forbidden)", async () => { - const connectionId = `oc-403-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 403 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "bad-key" }); - assert.equal(quota, null); -}); - -test("fetchOpencodeQuota returns null on 500 server error", async () => { - const connectionId = `oc-500-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 500 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("fetchOpencodeQuota returns null on network error (fail-open)", async () => { - const connectionId = `oc-net-${Date.now()}`; - +test("fetchOpencodeQuota returns null without an API key", async () => { + let called = false; globalThis.fetch = async () => { - throw new Error("Network error"); + called = true; + return jsonResponse(healthyUsage()); }; - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); + assert.equal(await fetchOpencodeQuota(`missing-${Date.now()}`), null); + assert.equal(await fetchOpencodeQuota(`empty-${Date.now()}`, { apiKey: "" }), null); + assert.equal(called, false); }); -test("fetchOpencodeQuota returns null on timeout (fail-open)", async () => { - const connectionId = `oc-timeout-${Date.now()}`; +test("fetchOpencodeQuota parses official usage windows as fractions and sends Bearer auth", async () => { + const connectionId = `official-${Date.now()}`; + let authorization: string | null = null; + let method = ""; - globalThis.fetch = async () => { - await new Promise((_, reject) => setTimeout(reject, 100)); - throw new Error("Timeout"); + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + authorization = request.headers.get("Authorization"); + method = request.method; + return jsonResponse(healthyUsage()); }; - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); -}); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "Bearer opencode-key" }); -// ─── 3-window parsing ($12/5h, $30/wk, $60/mo) ─────────────────────────────── - -test("fetchOpencodeQuota parses three-window quota response", async () => { - const connectionId = `oc-three-${Date.now()}`; - const calls: { url: string; init: RequestInit }[] = []; - - globalThis.fetch = async (url, init) => { - calls.push({ url: url as string, init: init as RequestInit }); - return new Response( - JSON.stringify({ - quota: { - window_5h: { used: 4.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 15.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 20.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.equal(calls.length, 1); - assert.ok( - (calls[0].init as Record)?.headers && - ((calls[0].init as Record).headers as Record)[ - "Authorization" - ] === "Bearer test-key", - "should send Bearer auth" - ); - - assert.ok(quota !== null, "should return a quota object"); - assert.ok(quota!.windows, "should have windows map"); - - // window_5h: 4/12 = 33.3% - assert.ok( - Math.abs((quota!.windows!["window_5h"].percentUsed as number) - 4 / 12) < 0.001, - "window_5h percentUsed should be ~0.333" - ); - // window_weekly: 15/30 = 50% - assert.ok( - Math.abs((quota!.windows!["window_weekly"].percentUsed as number) - 0.5) < 0.001, - "window_weekly percentUsed should be 0.5" - ); - // window_monthly: 20/60 = 33.3% - assert.ok( - Math.abs((quota!.windows!["window_monthly"].percentUsed as number) - 20 / 60) < 0.001, - "window_monthly percentUsed should be ~0.333" - ); - - // Worst-case: weekly at 50% - assert.ok( - Math.abs(quota!.percentUsed - 0.5) < 0.001, - "overall percentUsed should mirror worst window" - ); + assert.ok(quota); + assert.equal(authorization, "Bearer opencode-key"); + assert.equal(method, "GET"); + assert.equal(quota.percentUsed, 0.5); + assert.equal(quota.resetAt, RESET_WEEKLY); + assert.equal(quota.limitReached, false); + assert.deepEqual(quota.windows, { + window_5h: { percentUsed: 0.25, resetAt: RESET_ROLLING }, + window_weekly: { percentUsed: 0.5, resetAt: RESET_WEEKLY }, + window_monthly: { percentUsed: 0.1, resetAt: RESET_MONTHLY }, + }); + assert.deepEqual(quota.window5h, { percentUsed: 0.25, resetAt: RESET_ROLLING }); + assert.deepEqual(quota.windowWeekly, { percentUsed: 0.5, resetAt: RESET_WEEKLY }); + assert.deepEqual(quota.windowMonthly, { percentUsed: 0.1, resetAt: RESET_MONTHLY }); invalidateOpencodeQuotaCache(connectionId); }); -test("fetchOpencodeQuota parses reset_at timestamps in windows", async () => { - const connectionId = `oc-reset-${Date.now()}`; - const futureTs = Math.floor((Date.now() + 3_600_000) / 1000); // +1h unix seconds +test("fetchOpencodeQuota makes a rate-limited window effectively exhausted", async () => { + const connectionId = `rate-limited-${Date.now()}`; + const usage = healthyUsage(); + usage.rolling = { status: "rate-limited", percent: 37, resetsAt: RESET_ROLLING }; + globalThis.fetch = async () => jsonResponse(usage); - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 10.0, limit: 12.0, reset_at: futureTs }, - window_weekly: { used: 28.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 55.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }); - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.ok(quota !== null); - // window_5h reset_at should be an ISO string - const resetAt5h = quota!.windows?.["window_5h"]?.resetAt; - assert.ok(typeof resetAt5h === "string", "window_5h resetAt should be an ISO string"); - assert.ok( - new Date(resetAt5h as string).getTime() > Date.now(), - "resetAt should be in the future" - ); + assert.ok(quota); + assert.equal(quota.window5h.percentUsed, 1); + assert.equal(quota.windows?.window_5h.percentUsed, 1); + assert.equal(quota.percentUsed, 1); + assert.equal(quota.resetAt, RESET_ROLLING); + assert.equal(quota.limitReached, true); invalidateOpencodeQuotaCache(connectionId); }); -test("fetchOpencodeQuota sets limitReached when any window is exhausted", async () => { - const connectionId = `oc-exhausted-${Date.now()}`; +test("fetchOpencodeQuota treats an ok window at 100 percent as exhausted", async () => { + const connectionId = `hundred-${Date.now()}`; + const usage = healthyUsage(); + usage.monthly = { status: "ok", percent: 100, resetsAt: RESET_MONTHLY }; + globalThis.fetch = async () => jsonResponse(usage); - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 12.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 5.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 10.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }); - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.ok(quota !== null); - // window_5h is 100% used → worst-case - assert.ok(Math.abs(quota!.percentUsed - 1.0) < 0.001, "percentUsed should be 1.0 when exhausted"); - assert.equal((quota as any).limitReached, true, "limitReached should be true"); + assert.ok(quota); + assert.equal(quota.windowMonthly.percentUsed, 1); + assert.equal(quota.percentUsed, 1); + assert.equal(quota.limitReached, true); invalidateOpencodeQuotaCache(connectionId); }); -test("fetchOpencodeQuota returns null when quota object is absent from response", async () => { - const connectionId = `oc-no-quota-${Date.now()}`; +test("fetchOpencodeQuota fails open on non-200, network, and invalid JSON responses", async (t) => { + await t.test("non-200", async () => { + const connectionId = `non-200-${Date.now()}`; + globalThis.fetch = async () => new Response("unavailable", { status: 503 }); + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); + }); - globalThis.fetch = async () => - new Response(JSON.stringify({ message: "ok" }), { - status: 200, - headers: { "content-type": "application/json" }, + await t.test("network error", async () => { + const connectionId = `network-${Date.now()}`; + globalThis.fetch = async () => { + throw new Error("network offline"); + }; + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); + }); + + await t.test("invalid JSON", async () => { + const connectionId = `invalid-json-${Date.now()}`; + globalThis.fetch = async () => + new Response("not json", { + status: 200, + headers: { "content-type": "text/html" }, + }); + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); + }); +}); + +test("fetchOpencodeQuota rejects malformed official usage windows", async (t) => { + const malformedBodies: Array<{ name: string; usage: unknown }> = [ + { + name: "unknown status", + usage: { + ...healthyUsage(), + rolling: { + status: "paused", + percent: 25, + resetsAt: RESET_ROLLING, + }, + }, + }, + { + name: "out-of-range percent", + usage: { + ...healthyUsage(), + weekly: { status: "ok", percent: 101, resetsAt: RESET_WEEKLY }, + }, + }, + { + name: "invalid reset timestamp", + usage: { + ...healthyUsage(), + monthly: { status: "ok", percent: 10, resetsAt: "not-a-date" }, + }, + }, + ]; + + for (const malformed of malformedBodies) { + await t.test(malformed.name, async () => { + const connectionId = `malformed-${malformed.name}-${Date.now()}`; + globalThis.fetch = async () => jsonResponse(malformed.usage); + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); - - invalidateOpencodeQuotaCache(connectionId); + } }); -// ─── caching ───────────────────────────────────────────────────────────────── - -test("fetchOpencodeQuota caches results within TTL (second call is a no-op)", async () => { - const connectionId = `oc-cache-${Date.now()}`; +test("fetchOpencodeQuota caches for 60 seconds but not across API key changes", async () => { + const connectionId = `cache-${Date.now()}`; + const originalNow = Date.now; + let now = originalNow(); let calls = 0; - - globalThis.fetch = async () => { - calls++; - return new Response( - JSON.stringify({ - quota: { - window_5h: { used: 2.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 10.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 20.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); + const authorizations: Array = []; + Date.now = () => now; + globalThis.fetch = async (input, init) => { + calls += 1; + authorizations.push(new Request(input, init).headers.get("Authorization")); + const usage = healthyUsage(); + usage.weekly.percent = calls === 1 ? 50 : calls === 2 ? 75 : 80; + return jsonResponse(usage); }; - const first = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - const second = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); + try { + const first = await fetchOpencodeQuota(connectionId, { apiKey: "first-key" }); + now += 59_999; + const cached = await fetchOpencodeQuota(connectionId, { apiKey: "first-key" }); + assert.equal(calls, 1); + assert.deepEqual(cached, first); - assert.equal(calls, 1, "should only hit the network once"); - assert.deepEqual(first, second, "cached result should be identical"); + const changedKey = await fetchOpencodeQuota(connectionId, { apiKey: "second-key" }); + assert.ok(changedKey); + assert.equal(calls, 2); + assert.equal(changedKey.percentUsed, 0.75); + assert.deepEqual(authorizations, ["Bearer first-key", "Bearer second-key"]); - invalidateOpencodeQuotaCache(connectionId); + now += 59_999; + const secondKeyCached = await fetchOpencodeQuota(connectionId, { apiKey: "second-key" }); + assert.equal(calls, 2); + assert.deepEqual(secondKeyCached, changedKey); - const third = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(calls, 2, "should re-fetch after cache invalidation"); - assert.ok(third !== null); - - invalidateOpencodeQuotaCache(connectionId); + now += 1; + const refreshed = await fetchOpencodeQuota(connectionId, { apiKey: "second-key" }); + assert.ok(refreshed); + assert.equal(calls, 3); + assert.equal(refreshed.percentUsed, 0.8); + } finally { + Date.now = originalNow; + invalidateOpencodeQuotaCache(connectionId); + } }); -// ─── registration + preflight integration ──────────────────────────────────── - -test("registerOpencodeQuotaFetcher exposes opencode-go quota to preflight system", async () => { - const connectionId = `oc-preflight-${Date.now()}`; - +test("registerOpencodeQuotaFetcher registers every OpenCode provider and quota window", () => { registerOpencodeQuotaFetcher(); - // Fully exhausted 5h window — preflight should block - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 12.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 5.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 10.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - const preflight = await preflightQuota("opencode-go", connectionId, { - apiKey: "test-key", - providerSpecificData: { quotaPreflightEnabled: true }, - }); - - assert.equal(preflight.proceed, false, "preflight should block when window is exhausted"); - assert.equal(preflight.reason, "quota_exhausted"); - - invalidateOpencodeQuotaCache(connectionId); + for (const provider of ["opencode-go", "opencode", "opencode-zen"]) { + assert.equal(getQuotaFetcher(provider), fetchOpencodeQuota); + assert.deepEqual(getQuotaWindows(provider), ["window_5h", "window_weekly", "window_monthly"]); + } }); -test("preflight honors an explicit opencode limit_reached flag even before a window reaches 100%", async () => { - const connectionId = `oc-limit-reached-${Date.now()}`; - const resetAt = Math.floor((Date.now() + 30 * 60_000) / 1000); - +test("registerOpencodeQuotaFetcher keeps OpenCode Go quota monitoring available", () => { + const sessionId = `session-${Date.now()}`; + const connectionId = `monitor-${Date.now()}`; registerOpencodeQuotaFetcher(); + touchSession(sessionId, connectionId); - globalThis.fetch = async () => - new Response( - JSON.stringify({ - limit_reached: true, - quota: { - window_5h: { used: 10.0, limit: 12.0, reset_at: resetAt }, - window_weekly: { used: 20.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 30.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - const preflight = await preflightQuota("opencode-go", connectionId, { - apiKey: "test-key", - providerSpecificData: { quotaPreflightEnabled: true }, - }); - - assert.equal(preflight.proceed, false, "explicit limit_reached must block the account"); - assert.equal(preflight.reason, "quota_exhausted"); - assert.equal(preflight.quotaPercent, 10 / 12); - assert.equal(preflight.resetAt, new Date(resetAt * 1000).toISOString()); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("registerOpencodeQuotaFetcher also covers opencode and opencode-zen providers", async () => { - registerOpencodeQuotaFetcher(); - - const { getQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); - - assert.ok(getQuotaFetcher("opencode-go"), "opencode-go should be registered"); - assert.ok(getQuotaFetcher("opencode"), "opencode should be registered"); - assert.ok(getQuotaFetcher("opencode-zen"), "opencode-zen should be registered"); -}); - -test("registerOpencodeQuotaFetcher registers opencode-go in quotaMonitor system", async () => { - const connectionId = `oc-monitor-${Date.now()}`; - - registerOpencodeQuotaFetcher(); - - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 11.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 29.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 58.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - touchSession("session-oc", connectionId); - startQuotaMonitor("session-oc", "opencode-go", connectionId, { + startQuotaMonitor(sessionId, "opencode-go", connectionId, { + apiKey: "opencode-key", providerSpecificData: { quotaMonitorEnabled: true }, }); - assert.equal(getActiveMonitorCount(), 1); - stopQuotaMonitor("session-oc"); + stopQuotaMonitor(sessionId); assert.equal(getActiveMonitorCount(), 0); - - invalidateOpencodeQuotaCache(connectionId); -}); - -// ─── 404 warning: log once, cache 5 min ──────────────────────────────────── - -test("404 response is cached for 5 minutes to avoid hammering", async () => { - let callCount = 0; - globalThis.fetch = (async () => { - callCount += 1; - return new Response("Not Found", { status: 404 }); - }) as typeof fetch; - - const connectionId = `conn-cache-${Date.now()}`; - - // First call: 1 fetch, no cache - await fetchOpencodeQuota(connectionId, { apiKey: "sk-test-key" }); - const callsAfterFirst = callCount; - assert.equal(callsAfterFirst, 1); - - // Second call within 5 min: should hit cache, no fetch - await fetchOpencodeQuota(connectionId, { apiKey: "sk-test-key" }); - const callsAfterSecond = callCount; - assert.equal( - callsAfterSecond, - 1, - `expected cache hit on second 404 call, but fetch ran ${callsAfterSecond - callsAfterFirst} extra times` - ); - - // After invalidation: 1 fresh fetch - invalidateOpencodeQuotaCache(connectionId); - await fetchOpencodeQuota(connectionId, { apiKey: "sk-test-key" }); - assert.equal(callCount, callsAfterSecond + 1); }); diff --git a/tests/unit/quota-exhaustion-cutoff-opencode.test.ts b/tests/unit/quota-exhaustion-cutoff-opencode.test.ts index c8aaa04d12..a1f949e124 100644 --- a/tests/unit/quota-exhaustion-cutoff-opencode.test.ts +++ b/tests/unit/quota-exhaustion-cutoff-opencode.test.ts @@ -1,297 +1,128 @@ -import test from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import test from "node:test"; -/** - * #11234 — opencode-go quota preflight ignored the dashboard quota snapshots. - * - * Root cause (two gaps): - * - * A) `fetchOpencodeQuota` (open-sse/services/opencodeQuotaFetcher.ts) only - * consulted the live upstream endpoint, which has no public quota API - * (404 — see module JSDoc). It never read the quota snapshots the - * dashboard scrape (`getOpenCodeGoUsage`, keyed session/weekly/mcp_monthly) - * persists through `src/domain/quotaCache.ts`. Every preflight therefore - * evaluated `null` and proceeded (fail-open), even with a sister - * connection sitting at 0% weekly remaining in plain sight on the - * dashboard. - * - * B) The sibling-selection latency gate in - * `src/sse/services/auth.ts::getProviderCredentialsWithQuotaPreflight` - * never consulted `resilience.quotaPreflight.enabled` - * (QUOTA_PREFLIGHT_CUTOFF_ENABLED). That flag only armed the auto-strategy - * candidate builder and the per-target cutoff for pinned connections, so - * a priority combo over sibling opencode-go connections (connectionId - * null at combo level) skipped preflight entirely. - * - * Fix: - * A) The fetcher now synthesizes its triple-window quota from the cached - * dashboard snapshots (read-only, accessors only, no re-scrape on the hot - * path) when the live endpoint yields nothing — mapping - * session→window_5h, weekly→window_weekly, mcp_monthly→window_monthly and - * mirroring `getQuotaWindowStatus` semantics (expired resetAt = window has - * rolled over = must not count as exhausted). - * B) `resilience.quotaPreflight.enabled === true` now arms the - * sibling-selection latency gate as well. - * - * These tests are the regression guards: fetcher-level for (A), selector-level - * for (B). - */ +import { + fetchOpencodeQuota, + invalidateOpencodeQuotaCache, + registerOpencodeQuotaFetcher, +} from "../../open-sse/services/opencodeQuotaFetcher.ts"; +import { preflightQuota } from "../../open-sse/services/quotaPreflight.ts"; -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-11234-")); -process.env.DATA_DIR = TEST_DATA_DIR; -// Part (B): the operator flag must be ON before the resilience settings module -// is first imported (its defaults are computed at module load). -process.env.QUOTA_PREFLIGHT_CUTOFF_ENABLED = "true"; -process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "quota-11234-secret"; +type UsageStatus = "ok" | "rate-limited"; +type WindowName = "rolling" | "weekly" | "monthly"; +type UsageWindow = { + status: UsageStatus; + percent: number; + resetsAt: string; +}; +type OfficialUsage = Record; +type ExhaustionCase = { + window: WindowName; + status: UsageStatus; + percent: number; + label: string; +}; const originalFetch = globalThis.fetch; +const resetAt: Record = { + rolling: "2026-09-01T01:02:03.000Z", + weekly: "2026-09-05T04:05:06.000Z", + monthly: "2026-09-30T07:08:09.000Z", +}; -const coreDb = await import("../../src/lib/db/core.ts"); -const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); -const quotaCache = await import("../../src/domain/quotaCache.ts"); -const providersDb = await import("../../src/lib/db/providers.ts"); -const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); -const { fetchOpencodeQuota, invalidateOpencodeQuotaCache } = - await import("../../open-sse/services/opencodeQuotaFetcher.ts"); -const { evaluateQuotaCutoff, registerQuotaFetcher } = - await import("../../open-sse/services/quotaPreflight.ts"); -const { buildAutoQuotaThresholds } = - await import("../../open-sse/services/combo/quotaExhaustionCutoff.ts"); -const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts"); -const auth = await import("../../src/sse/services/auth.ts"); - -const PROVIDER = "opencode-go"; -// Dashboard scrape window keys (opencodeOllamaUsage.ts::OPENCODE_GO_QUOTA_ORDER) -const DASH_SESSION = "session"; -const DASH_WEEKLY = "weekly"; -// Fetcher/preflight window keys (opencodeQuotaFetcher.ts registry) -const WINDOW_5H = "window_5h"; -const WINDOW_WEEKLY = "window_weekly"; - -function seedSnapshot( - connectionId: string, - windowKey: string, - remainingPercentage: number, - nextResetAt: string | null -) { - quotaSnapshotsDb.saveQuotaSnapshot({ - provider: PROVIDER, - connection_id: connectionId, - window_key: windowKey, - remaining_percentage: remainingPercentage, - is_exhausted: remainingPercentage <= 0 ? 1 : 0, - next_reset_at: nextResetAt, - window_duration_ms: null, - raw_data: null, - }); -} - -function dashboardConfiguredConnection(apiKey: string): Record { - // Mirrors the operator-configured dashboard scrape - // (opencodeOllamaUsage.ts::resolveOpenCodeGoDashboardConfig). - return { - apiKey, - providerSpecificData: { - openCodeGoWorkspaceId: "ws-11234", - openCodeGoAuthCookie: "auth-cookie-11234", - }, +function usageWith(window: WindowName, status: UsageStatus, percent: number): OfficialUsage { + const usage: OfficialUsage = { + rolling: { status: "ok", percent: 10, resetsAt: resetAt.rolling }, + weekly: { status: "ok", percent: 10, resetsAt: resetAt.weekly }, + monthly: { status: "ok", percent: 10, resetsAt: resetAt.monthly }, }; + usage[window] = { status, percent, resetsAt: resetAt[window] }; + return usage; } -function hoursFromNow(hours: number): string { - return new Date(Date.now() + hours * 3_600_000).toISOString(); -} - -test.after(() => { - globalThis.fetch = originalFetch; - coreDb.resetDbInstance(); - apiKeysDb.resetApiKeyState(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); -}); - test.afterEach(() => { globalThis.fetch = originalFetch; - quotaCache.__clearForTests(); }); -// ─── (A) fetcher bridge: dashboard snapshots → QuotaInfo ──────────────────── +const exhaustionCases: ExhaustionCase[] = [ + { window: "rolling", status: "ok", percent: 100, label: "exhausted rolling" }, + { window: "weekly", status: "ok", percent: 100, label: "exhausted weekly" }, + { window: "monthly", status: "ok", percent: 100, label: "exhausted monthly" }, + { + window: "rolling", + status: "rate-limited", + percent: 10, + label: "rate-limited rolling", + }, + { + window: "weekly", + status: "rate-limited", + percent: 10, + label: "rate-limited weekly", + }, + { + window: "monthly", + status: "rate-limited", + percent: 10, + label: "rate-limited monthly", + }, +]; -test("#11234 dashboard snapshots feed the quota cutoff when the live endpoint has no quota API", async () => { - const connectionId = `oc-11234-block-${Date.now()}`; - let fetchCalls = 0; - globalThis.fetch = async () => { - fetchCalls += 1; - return new Response(null, { status: 404 }); - }; - - // Dashboard shows: weekly fully drained (0% remaining, reset in 3 days), - // session healthy (80% remaining). - seedSnapshot(connectionId, DASH_WEEKLY, 0, hoursFromNow(72)); - seedSnapshot(connectionId, DASH_SESSION, 80, hoursFromNow(2)); - - const quota = await fetchOpencodeQuota(connectionId, dashboardConfiguredConnection("sk-test")); - - assert.ok( - quota, - "fetcher must synthesize quota from dashboard snapshots when the live endpoint 404s" - ); - assert.equal(fetchCalls, 1, "snapshot bridge must be read-only — no re-scrape on the hot path"); - - // Key mapping: weekly → window_weekly (0% remaining = 100% used), - // session → window_5h (80% remaining = 20% used). - assert.equal(quota.windows?.[WINDOW_WEEKLY]?.percentUsed, 1); - assert.ok( - Math.abs((quota.windows?.[WINDOW_5H]?.percentUsed ?? 0) - 0.2) < 1e-9, - `window_5h percentUsed should be ~0.2, got ${quota.windows?.[WINDOW_5H]?.percentUsed}` - ); - - const decision = evaluateQuotaCutoff(quota, buildAutoQuotaThresholds(PROVIDER, undefined, null)); - assert.equal(decision.proceed, false, "weekly at 0% remaining must block the connection"); - assert.equal(decision.reason, "quota_exhausted"); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("#11234 a snapshot whose reset already passed must not count as exhausted", async () => { - const connectionId = `oc-11234-expired-${Date.now()}`; - globalThis.fetch = async () => new Response(null, { status: 404 }); - - // Weekly hit 0% but its reset is 1h in the PAST — the window rolled into a - // fresh period, so the stale 0% must not block (mirrors - // getQuotaWindowStatus: expired resetAt → reachedThreshold = false). - seedSnapshot(connectionId, DASH_WEEKLY, 0, hoursFromNow(-1)); - seedSnapshot(connectionId, DASH_SESSION, 80, hoursFromNow(2)); - - const quota = await fetchOpencodeQuota(connectionId, dashboardConfiguredConnection("sk-test")); - - assert.ok(quota, "the healthy session snapshot should still synthesize"); - assert.equal( - quota.windows?.[WINDOW_WEEKLY], - undefined, - "an expired weekly window must be dropped from the synthesized quota" - ); - - const decision = evaluateQuotaCutoff(quota, buildAutoQuotaThresholds(PROVIDER, undefined, null)); - assert.equal(decision.proceed, true, "an expired weekly window must not block the connection"); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("#11234 per-window threshold overrides apply to the mapped window_weekly key", async () => { - const connectionId = `oc-11234-threshold-${Date.now()}`; - globalThis.fetch = async () => new Response(null, { status: 404 }); - - // Weekly at 40% remaining — above the factory 2% cutoff (would proceed), - // but below an operator override of 50% min-remaining for window_weekly. - seedSnapshot(connectionId, DASH_WEEKLY, 40, hoursFromNow(72)); - seedSnapshot(connectionId, DASH_SESSION, 90, hoursFromNow(2)); - - const quota = await fetchOpencodeQuota(connectionId, dashboardConfiguredConnection("sk-test")); - assert.ok(quota); - - const factoryDecision = evaluateQuotaCutoff( - quota, - buildAutoQuotaThresholds(PROVIDER, undefined, null) - ); - assert.equal( - factoryDecision.proceed, - true, - "factory 2% cutoff must not block a window at 40% remaining" - ); - - const settings = resolveResilienceSettings({ - resilienceSettings: { - quotaPreflight: { - enabled: true, - providerWindowDefaults: { [PROVIDER]: { [WINDOW_WEEKLY]: 50 } }, - }, - }, - }); - const overrideDecision = evaluateQuotaCutoff( - quota, - buildAutoQuotaThresholds(PROVIDER, undefined, settings) - ); - assert.equal( - overrideDecision.proceed, - false, - "a 50% window_weekly override must block at 40% remaining — the override resolves against the mapped key" - ); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("#11234 fail-open preserved: configured dashboard with no snapshots still returns null", async () => { - const connectionId = `oc-11234-failopen-${Date.now()}`; - globalThis.fetch = async () => new Response(null, { status: 404 }); - - const quota = await fetchOpencodeQuota(connectionId, dashboardConfiguredConnection("sk-test")); - assert.equal(quota, null, "no snapshots → fail-open (null), exactly as before"); - - invalidateOpencodeQuotaCache(connectionId); -}); - -// ─── (B) flag scope: sibling-selection latency gate ───────────────────────── - -test("#11234 quotaPreflight.enabled arms sibling selection: the exhausted sister is skipped for the healthy one", async () => { - const tag = Date.now(); - - const exhausted = await providersDb.createProviderConnection({ - provider: PROVIDER, - authType: "apikey", - name: `oc-11234-exhausted-${tag}`, - apiKey: "sk-oc-11234-exhausted", - priority: 1, - isActive: true, - testStatus: "active", - }); - const healthy = await providersDb.createProviderConnection({ - provider: PROVIDER, - authType: "apikey", - name: `oc-11234-healthy-${tag}`, - apiKey: "sk-oc-11234-healthy", - priority: 2, - isActive: true, - testStatus: "active", - }); - - // Stub the upstream quota signal: the priority-1 sister is fully drained, - // the priority-2 sister is healthy. No per-connection overrides, no - // per-(provider, window) defaults, no legacy quotaPreflightEnabled flag, - // factory 2% global threshold — so TODAY the latency gate skips preflight - // entirely and the selector returns the exhausted sister. With - // resilience.quotaPreflight.enabled arming the gate, preflight must run and - // skip her. - registerQuotaFetcher(PROVIDER, async (connectionId: string) => { - if (connectionId === exhausted.id) { - return { - used: 100, - total: 100, - percentUsed: 1.0, - resetAt: hoursFromNow(1), - }; +for (const exhaustion of exhaustionCases) { + test(`OpenCode Go preflight blocks an ${exhaustion.label} window`, async () => { + const connectionId = `preflight-${exhaustion.window}-${exhaustion.status}-${Date.now()}`; + const usage = usageWith(exhaustion.window, exhaustion.status, exhaustion.percent); + if (exhaustion.status === "rate-limited") { + for (const window of ["rolling", "weekly", "monthly"] as const) { + if (window !== exhaustion.window) usage[window].percent = 90; + } + } + globalThis.fetch = async () => + new Response( + JSON.stringify({ + usage, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + registerOpencodeQuotaFetcher(); + + try { + const decision = await preflightQuota("opencode-go", connectionId, { + apiKey: "opencode-key", + providerSpecificData: { quotaPreflightEnabled: true }, + }); + + assert.equal(decision.proceed, false); + assert.equal(decision.reason, "quota_exhausted"); + assert.equal(decision.quotaPercent, 1); + assert.equal(decision.resetAt, resetAt[exhaustion.window]); + } finally { + invalidateOpencodeQuotaCache(connectionId); } - return { used: 0, total: 100, percentUsed: 0, resetAt: null }; }); +} + +test("OpenCode Go preflight proceeds when every official usage window is healthy", async () => { + const connectionId = `preflight-healthy-${Date.now()}`; + globalThis.fetch = async () => + new Response(JSON.stringify({ usage: usageWith("rolling", "ok", 25) }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + registerOpencodeQuotaFetcher(); try { - const selection = await auth.getProviderCredentialsWithQuotaPreflight( - PROVIDER, - null, - null, - null - ); - const result = selection as { connectionId?: string } | null; + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }); + assert.ok(quota); + assert.equal(quota.limitReached, false); - assert.equal( - result?.connectionId, - healthy.id, - "with quotaPreflight.enabled the selector must skip the exhausted priority-1 sister and pick the healthy one" - ); + const decision = await preflightQuota("opencode-go", connectionId, { + apiKey: "opencode-key", + providerSpecificData: { quotaPreflightEnabled: true }, + }); + assert.equal(decision.proceed, true); + assert.equal(decision.quotaPercent, 0.25); } finally { - await providersDb.deleteProviderConnection(exhausted.id); - await providersDb.deleteProviderConnection(healthy.id); + invalidateOpencodeQuotaCache(connectionId); } }); diff --git a/tests/unit/ui/add-api-key-modal-free-models.test.tsx b/tests/unit/ui/add-api-key-modal-free-models.test.tsx index c366485a9e..b68cbf5350 100644 --- a/tests/unit/ui/add-api-key-modal-free-models.test.tsx +++ b/tests/unit/ui/add-api-key-modal-free-models.test.tsx @@ -106,20 +106,18 @@ describe("AddApiKeyModal — import only free models", () => { }); describe("AddApiKeyModal — quota scraping fields", () => { - it("saves OpenCode Go workspace and auth cookie in providerSpecificData", async () => { + it("renders only the API key for OpenCode Go and saves no scraping credentials", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ provider: "opencode-go", providerName: "OpenCode Go", onSave }); + expect(el.querySelector('input[name="opencodeGoWorkspaceId"]')).toBeNull(); + expect(el.querySelector('input[name="opencodeGoAuthCookie"]')).toBeNull(); + expect(el.querySelectorAll('input[type="password"]')).toHaveLength(1); + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; const apiKeyInput = el.querySelector('input[type="password"]')!; - const workspaceInput = el.querySelector( - 'input[name="opencodeGoWorkspaceId"]' - )!; - const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; setInputValue(nameInput, "OpenCode Go"); setInputValue(apiKeyInput, "sk-opencode-go-test"); - setInputValue(workspaceInput, "workspace-123"); - setInputValue(cookieInput, "auth=opencode-cookie"); const saveBtn = Array.from(el.querySelectorAll("button")).find( (b) => b.textContent?.trim() === "save" @@ -130,8 +128,9 @@ describe("AddApiKeyModal — quota scraping fields", () => { await waitFor(() => onSave.mock.calls.length > 0); const payload = onSave.mock.calls[0][0]; - expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-123"); - expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + expect(payload.apiKey).toBe("sk-opencode-go-test"); + expect(payload.providerSpecificData ?? {}).not.toHaveProperty("opencodeGoWorkspaceId"); + expect(payload.providerSpecificData ?? {}).not.toHaveProperty("opencodeGoAuthCookie"); }); it("saves Ollama Cloud usage cookie in providerSpecificData", async () => { diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 9445443a53..e683a5386e 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -37,14 +37,6 @@ function render(props: Record) { return el; } -function setInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; - act(() => { - setter.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); -} - async function waitFor(fn: () => boolean, timeoutMs = 2000) { const start = Date.now(); while (!fn()) { @@ -273,7 +265,7 @@ describe("EditConnectionModal — encrypted Responses reasoning", () => { }); describe("EditConnectionModal — quota scraping fields", () => { - it("saves OpenCode Go workspace and replacement auth cookie", async () => { + it("renders only the API key for OpenCode Go and saves no scraping credentials", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ providerId: "opencode-go", @@ -287,13 +279,9 @@ describe("EditConnectionModal — quota scraping fields", () => { onSave, }); - const workspaceInput = el.querySelector( - 'input[name="opencodeGoWorkspaceId"]' - )!; - const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; - expect(workspaceInput.value).toBe("workspace-existing"); - setInputValue(workspaceInput, "workspace-updated"); - setInputValue(cookieInput, "auth=opencode-cookie"); + expect(el.querySelector('input[name="opencodeGoWorkspaceId"]')).toBeNull(); + expect(el.querySelector('input[name="opencodeGoAuthCookie"]')).toBeNull(); + expect(el.querySelector('input[placeholder="enterNewApiKey"]')).toBeTruthy(); const saveBtn = Array.from(el.querySelectorAll("button")).find( (b) => b.textContent?.trim() === "save" @@ -304,8 +292,8 @@ describe("EditConnectionModal — quota scraping fields", () => { await waitFor(() => onSave.mock.calls.length > 0); const payload = onSave.mock.calls[0][0]; - expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-updated"); - expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + expect(payload.providerSpecificData).not.toHaveProperty("opencodeGoWorkspaceId"); + expect(payload.providerSpecificData).not.toHaveProperty("opencodeGoAuthCookie"); }); it("omits Ollama Cloud usage cookie when the edit field is left blank", async () => {