diff --git a/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png b/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png new file mode 100644 index 0000000000..3d63f634b7 Binary files /dev/null and b/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png differ diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 5de4260813..988ca03cf7 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -124,6 +124,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | +Les vérifications de quota Google Antigravity utilisent des points de terminaison Google fixes, y compris le repli vers la liste des modèles. Elles prennent en charge le DNS Fake-IP transparent pour ces destinations en conservant la vérification TLS, le refus des redirections et les contrôles des adresses privées. Une URL de base personnalisée ne modifie que les requêtes de modèles ; `NO_PROXY` conserve la politique de connexion directe. + + Après un échec définitif d'actualisation de Nous, exécutez `ocx login nous` pour vous réauthentifier. Pour les préréglages canoniques du forfait Kimi Coding (`kimi` pour la connexion au compte et `kimi-code` diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 255c0d8dc4..05f586a816 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -125,6 +125,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | +Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. + + After a terminal Nous refresh failure, run `ocx login nous` to reauthenticate. For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 51d018f478..33be9fc694 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -114,6 +114,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | +Google Antigravity のアカウント・プロバイダーのクォータ確認は、モデル一覧へのフォールバックも含め、固定の Google エンドポイントを使用します。その宛先では透過 Fake-IP DNS に対応し、TLS 検証、リダイレクト拒否、プライベートアドレス検査を維持します。カスタム base URL はモデル要求にのみ適用されます。`NO_PROXY` は直接接続のポリシーを維持します。 + + Nous の refresh が終端失敗した場合は、再認証に `ocx login nous` を実行してください。 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 3c28be0a59..7781cc4f52 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -113,6 +113,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | +Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. + + Nous refresh가 종료 실패한 경우, `ocx login nous`로 재인증하세요. 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index b99eb88ec2..b057cf77c6 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -123,6 +123,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +Проверки квот аккаунтов и провайдера Google Antigravity используют фиксированные адреса Google, включая резервный запрос списка моделей. Для этих адресов поддерживается прозрачный Fake-IP DNS с сохранением проверки TLS, запрета перенаправлений и проверки частных адресов. Пользовательский base URL меняет только запросы моделей; `NO_PROXY` сохраняет политику прямого подключения. + + После терминального сбоя обновления Nous выполните `ocx login nous`, чтобы пройти повторную аутентификацию. Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 4559133d43..c5564e74a1 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -138,6 +138,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | +Google Antigravity hesap ve sağlayıcı kota sorguları, model listesine geri dönüş dahil sabit Google uç noktalarını kullanır. Bu hedefler için şeffaf Fake-IP DNS desteklenirken TLS doğrulaması, yönlendirme reddi ve özel adres kontrolleri korunur. Özel base URL yalnızca model isteklerini değiştirir; `NO_PROXY` doğrudan bağlantı politikasını korur. + + Uç bir Nous yenileme hatasından sonra yeniden kimlik doğrulamak için `ocx login nous` çalıştırın. diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index a4bbab6186..3fb72a4e6a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -104,6 +104,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | +Google Antigravity 账户和提供方的配额查询(包括模型列表回退)使用固定的 Google 计量端点。这些目标支持透明 Fake-IP DNS,同时保留 TLS 验证、重定向拒绝和私有地址检查。自定义 base URL 仅改变模型请求,不改变配额目标;`NO_PROXY` 仍使用直连策略。 + + Nous refresh 发生终止性失败后,请运行 `ocx login nous` 重新认证。 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 0c20867124..96cf91ccfb 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -112,6 +112,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | +Google Antigravity 帳戶與供應商的配額查詢(包括模型清單備援)使用固定的 Google 計量端點。這些目標支援透明 Fake-IP DNS,同時保留 TLS 驗證、重新導向拒絕與私有位址檢查。自訂 base URL 只改變模型請求,不改變配額目標;`NO_PROXY` 仍使用直連政策。 + + 終端 Nous refresh 失敗後,執行 `ocx login nous` 重新認證。 對 canonical Kimi Coding Plan preset(`kimi` 帳號登入與 `kimi-code` API key),opencodex 只會把 caller diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 2715f48608..a1a32e1cfa 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -20,15 +20,38 @@ export type QuotaBarRow = { /** * Window ordering is computed from RAW wire identities BEFORE localization * (ranking on translated labels breaks the moment a locale changes copy): - * shorter windows first — 5h, weekly, cursor first-party, cursor API, monthly. + * shorter windows first — 5h, weekly, cursor first-party, cursor API, monthly, + * then subscription credits before other custom windows. */ function rawCustomWindowRank(rawLabel: string): number { if (rawLabel === "5h") return 0; if (rawLabel === "First-party models") return 2; if (rawLabel === "API usage") return 3; + if (rawLabel === "Total subscription credits") return 4.5; return 5; } +const SUBSCRIPTION_CREDITS_LABEL = "Total subscription credits"; + +function canonicalCustomWindowLabel(rawLabel: string): string { + return rawLabel.trim().toLowerCase() === SUBSCRIPTION_CREDITS_LABEL.toLowerCase() + ? SUBSCRIPTION_CREDITS_LABEL + : rawLabel; +} + +/** Coverage metadata carries raw labels, while subscription rows use a canonical identity. */ +export function isCustomQuotaWindowIncomplete( + customLabel: string | undefined, + incompleteLabels?: ReadonlySet, +): boolean { + if (customLabel === undefined || !incompleteLabels) return false; + const canonical = canonicalCustomWindowLabel(customLabel); + for (const label of incompleteLabels) { + if (canonicalCustomWindowLabel(label) === canonical) return true; + } + return false; +} + function localizeCustomQuotaLabel(rawLabel: string, t: TFn): string { switch (rawLabel) { case "First-party models": @@ -84,11 +107,12 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }); } for (const w of displayQuota.customWindows ?? []) { - const localized = localizeCustomQuotaLabel(w.label, t); + const customLabel = canonicalCustomWindowLabel(w.label); + const localized = localizeCustomQuotaLabel(customLabel, t); ranked.push({ - rank: rawCustomWindowRank(w.label), + rank: rawCustomWindowRank(customLabel), row: { - customLabel: w.label, + customLabel, label: localized, limitLabel: localized, percent: w.percent, @@ -96,6 +120,24 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }, }); } + if (displayQuota.creditsUsd && typeof displayQuota.creditsUsd.percent === "number") { + const hasSubscriptionCreditsCustom = displayQuota.customWindows?.some( + w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + ); + if (!hasSubscriptionCreditsCustom) { + const localized = localizeCustomQuotaLabel(SUBSCRIPTION_CREDITS_LABEL, t); + ranked.push({ + rank: rawCustomWindowRank(SUBSCRIPTION_CREDITS_LABEL), + row: { + customLabel: SUBSCRIPTION_CREDITS_LABEL, + label: localized, + limitLabel: localized, + percent: displayQuota.creditsUsd.percent, + resetAt: displayQuota.creditsUsd.expiresAt, + }, + }); + } + } return ranked.sort((a, b) => a.rank - b.rank).map(entry => entry.row); } @@ -107,6 +149,12 @@ export function maxQuotaUtilisation(quota: AccountQuota | null): number { for (const w of quota.customWindows ?? []) { if (typeof w.percent === "number") vals.push(w.percent); } + const hasSubscriptionCreditsCustom = quota.customWindows?.some( + w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + ); + if (!hasSubscriptionCreditsCustom && typeof quota.creditsUsd?.percent === "number") { + vals.push(quota.creditsUsd.percent); + } return vals.length ? Math.max(...vals) : -1; } @@ -279,7 +327,7 @@ export default function QuotaBars({ locale={locale} incomplete={row.windowKey ? incompleteWindowKeys?.has(row.windowKey) === true - : row.customLabel !== undefined && incompleteCustomWindowLabels?.has(row.customLabel) === true} + : isCustomQuotaWindowIncomplete(row.customLabel, incompleteCustomWindowLabels)} /> ))} @@ -291,6 +339,7 @@ export default function QuotaBars({ {rows.map(row => ( +
{label} {hasReset ? t("codexAuth.resets") : ""} {reset.day} diff --git a/gui/src/styles/provider-quota.css b/gui/src/styles/provider-quota.css index 05f616b20c..37fb87223a 100644 --- a/gui/src/styles/provider-quota.css +++ b/gui/src/styles/provider-quota.css @@ -101,3 +101,18 @@ color: var(--amber); font-size: 12px; } + +/* Long subscription labels and reset/value text need separate rows in narrow cards. */ +.quota-compact { container: quota-compact / inline-size; } +@container quota-compact (max-width: 440px) { + .quota-row--credits { + grid-template-columns: max-content max-content minmax(0, 1fr) max-content; + row-gap: 4px; + } + .quota-row--credits .quota-label { grid-column: 1 / -1; grid-row: 1; } + .quota-row--credits .quota-reset-label { grid-column: 1; grid-row: 2; } + .quota-row--credits .quota-reset-day { grid-column: 2; grid-row: 2; } + .quota-row--credits .quota-reset-time { grid-column: 3 / -1; grid-row: 2; } + .quota-row--credits .bar { grid-column: 1 / 4; grid-row: 3; } + .quota-row--credits .quota-val { grid-column: 4; grid-row: 3; white-space: nowrap; } +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index bb35c7ead1..7136cd3c70 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -2607,11 +2607,22 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro } const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com"; -let antigravityOutboundDependencies: ProviderOutboundDependencies = {}; +const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; +const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; -/** Test seam: inject resolver/pinned transport for the per-account Antigravity probe. */ +/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */ +export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean { + return name === "google-antigravity" + && (url === ANTIGRAVITY_QUOTA_SUMMARY_URL || url === ANTIGRAVITY_QUOTA_MODELS_URL); +} + +let antigravityOutboundDependencies: ProviderOutboundDependencies = { + isCanonicalUrl: isCanonicalAntigravityQuotaUrl, +}; + +/** Test seam: inject resolver/pinned transport for provider and per-account probes. */ export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void { - antigravityOutboundDependencies = dependencies ?? {}; + antigravityOutboundDependencies = { ...dependencies, isCanonicalUrl: isCanonicalAntigravityQuotaUrl }; } /** @@ -2622,7 +2633,7 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid * A redirect or non-2xx yields null (unavailable), never a partial row. */ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; + const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; try { const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { headers: { @@ -2644,7 +2655,7 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: // Fallback to fetchAvailableModels on error } - const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; + const url = ANTIGRAVITY_QUOTA_MODELS_URL; const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", @@ -2662,7 +2673,7 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: return { customWindows, updatedAt: Date.now() }; } -async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { +async function fetchAntigravityQuota(provider: string): Promise { const credential = getCredential("google-antigravity"); if (!credential?.projectId) return null; let accessToken: string; @@ -2671,13 +2682,12 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig } catch { return null; } - const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, ""); - // The summary probe is pinned to Google's own host through the provider-outbound + // Both probes are pinned to Google's own host through the provider-outbound // transport, mirroring `fetchAntigravityUsageQuota` above: a configured `baseUrl` is a - // routing choice for requests, not a second source of Google's accounting, and this - // request carries the account bearer. - const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; + // routing choice for requests, not a second source of Google's accounting, and these + // requests carry the account bearer. + const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; try { const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { headers: { @@ -2701,8 +2711,8 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig // Fallback on network/fetch error } - const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { - method: "POST", + const url = ANTIGRAVITY_QUOTA_MODELS_URL; + const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", "Content-Type": "application/json", @@ -2711,7 +2721,8 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig }, body: JSON.stringify({ project: credential.projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }, antigravityOutboundDependencies); + if (await providerRedirectError(response, url)) return null; if (!response.ok) return null; const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); if (customWindows.length === 0) return null; @@ -2783,7 +2794,7 @@ async function maybeFetchProviderQuota( } if (provider.authMode === "oauth" && explicitAccountReader(name)) return await fetchExplicitCurrentQuota(name, provider, config); if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name); - if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider); + if (provider.authMode === "oauth" && name === "google-antigravity") return await fetchAntigravityQuota(name); if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name); // Passive providers (meta-muse): Meta publishes no quota endpoint, so there is no // probe to run — the row is the active account's last in-band observation. diff --git a/tests/gui/quota-bars-rows.test.ts b/tests/gui/quota-bars-rows.test.ts index 57ddd47cfd..8ca5a27579 100644 --- a/tests/gui/quota-bars-rows.test.ts +++ b/tests/gui/quota-bars-rows.test.ts @@ -3,6 +3,7 @@ import { barWidth, buildQuotaRows, formatResetFuture, + isCustomQuotaWindowIncomplete, isQuotaExhausted, isQuotaWarn, maxQuotaUtilisation, @@ -69,6 +70,102 @@ describe("buildQuotaRows (WP070)", () => { expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits"]); }); + test("direct creditsUsd renders Total subscription credits with resetAt", () => { + const reported = quota({ + creditsUsd: { used: 89.96, limit: 90, remaining: 0.04, percent: 99.96, expiresAt: 1790430938000 }, + }); + expect(buildQuotaRows(reported, null, t)).toEqual([{ + customLabel: "Total subscription credits", + label: "quota.totalSubscriptionCredits", + limitLabel: "quota.totalSubscriptionCredits", + percent: 99.96, + resetAt: 1790430938000, + }]); + expect(maxQuotaUtilisation(reported)).toBe(99.96); + }); + + test("zero direct credit usage remains a row without an invented expiry", () => { + const reported = quota({ + creditsUsd: { used: 0, limit: 100, remaining: 100, percent: 0 }, + }); + const rows = buildQuotaRows(reported, null, t); + expect(rows).toHaveLength(1); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + expect(rows[0]?.percent).toBe(0); + expect(rows[0]?.resetAt).toBeUndefined(); + expect(maxQuotaUtilisation(reported)).toBe(0); + }); + + test("subscription credits rank after monthly and before other custom windows", () => { + const rows = buildQuotaRows(quota({ + fiveHourPercent: 10, + weeklyPercent: 40, + monthlyPercent: 70, + customWindows: [ + { label: "Gem", percent: 1 }, + { label: "API usage", percent: 55 }, + { label: "First-party models", percent: 25 }, + ], + creditsUsd: { used: 80, limit: 100, remaining: 20, percent: 80 }, + }), null, t); + expect(rows.map(r => r.limitLabel)).toEqual([ + "quota.fiveHourLimit", + "quota.weeklyLimit", + "quota.cursorFirstParty", + "quota.cursorApiUsage", + "quota.monthlyLimit", + "quota.totalSubscriptionCredits", + "Gem", + ]); + }); + + test.each(["Total subscription credits", " TOTAL SUBSCRIPTION CREDITS "])( + "direct creditsUsd does not duplicate the canonical custom window: %s", + label => { + const customOnly = quota({ customWindows: [{ label, percent: 25, resetAt: 1790430938000 }] }); + const withDirect = quota({ + ...customOnly, + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99, expiresAt: 1790517338000 }, + }); + for (const reported of [customOnly, withDirect]) { + expect(buildQuotaRows(reported, null, t)).toEqual([{ + customLabel: "Total subscription credits", + label: "quota.totalSubscriptionCredits", + limitLabel: "quota.totalSubscriptionCredits", + percent: 25, + resetAt: 1790430938000, + }]); + } + }, + ); + + test("unrelated credit windows keep their raw identity and do not suppress direct credits", () => { + const reported = quota({ + customWindows: [ + { label: "API credits", percent: 20 }, + { label: " Gem ", percent: 10 }, + ], + creditsUsd: { used: 50, limit: 100, remaining: 50, percent: 50 }, + }); + const rows = buildQuotaRows(reported, null, t); + expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits", "API credits", " Gem "]); + expect(rows.map(r => r.customLabel)).toEqual(["Total subscription credits", "API credits", " Gem "]); + expect(maxQuotaUtilisation(reported)).toBe(50); + }); + + test.each(["go", "free"])("30-day plan %s retains direct credits after normalization", plan => { + const rows = buildQuotaRows(quota({ + shortPercent: 10, + shortWindowSeconds: 5 * 60 * 60, + weeklyPercent: 30, + monthlyPercent: 60, + customWindows: [{ label: "Total subscription credits", percent: 99 }], + creditsUsd: { used: 80, limit: 100, remaining: 20, percent: 80 }, + }), plan, t); + expect(rows.map(r => r.limitLabel)).toEqual(["quota.monthlyLimit", "quota.totalSubscriptionCredits"]); + expect(rows.map(r => r.percent)).toEqual([60, 80]); + }); + test("null and empty quotas produce no rows; 30-day plans strip to monthly", () => { expect(buildQuotaRows(null, null, t)).toEqual([]); expect(buildQuotaRows(quota({}), null, t)).toEqual([]); @@ -93,6 +190,51 @@ describe("maxQuotaUtilisation", () => { fiveHourPercent: 10, customWindows: [{ label: "x", percent: 95 }], }))).toBe(95); + expect(maxQuotaUtilisation(quota({ + fiveHourPercent: 0, + weeklyPercent: 0, + creditsUsd: { used: 90, limit: 90, remaining: 0, percent: 100 }, + }))).toBe(100); + }); + + test.each(["Total subscription credits", " total subscription credits "])( + "subscription-credit urgency follows the visible custom window: %s", + label => { + expect(maxQuotaUtilisation(quota({ + customWindows: [{ label, percent: 25 }], + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99 }, + }))).toBe(25); + }, + ); +}); + +describe("isCustomQuotaWindowIncomplete", () => { + test("canonical subscription rows retain raw-label coverage metadata", () => { + const rows = buildQuotaRows(quota({ + customWindows: [{ label: " TOTAL SUBSCRIPTION CREDITS ", percent: 25 }], + }), null, t); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + expect(isCustomQuotaWindowIncomplete( + rows[0]?.customLabel, + new Set(["API credits", " TOTAL SUBSCRIPTION CREDITS "]), + )).toBe(true); + expect(isCustomQuotaWindowIncomplete( + " total subscription credits ", + new Set(["Total subscription credits"]), + )).toBe(true); + }); + + test("absent and unrelated coverage does not mark a subscription row incomplete", () => { + expect(isCustomQuotaWindowIncomplete(undefined, new Set(["Total subscription credits"]))).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits")).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits", new Set())).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits", new Set(["API credits"]))).toBe(false); + }); + + test("unknown window coverage preserves exact raw-label matching", () => { + expect(isCustomQuotaWindowIncomplete(" Gem ", new Set([" Gem "]))).toBe(true); + expect(isCustomQuotaWindowIncomplete(" Gem ", new Set(["Gem"]))).toBe(false); + expect(isCustomQuotaWindowIncomplete("Gem", new Set(["gem"]))).toBe(false); }); }); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 1855e92d66..989f55a410 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -15,6 +15,7 @@ import { supportsPerAccountQuota, providerOAuthAccountQuotaMode, } from "../../src/providers/quota"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -681,7 +682,21 @@ describe("google-antigravity per-account quota (#1082)", () => { }); } - afterEach(() => setAntigravityAccountQuotaTransportForTests(null)); + const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); + const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + + beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; + }); + afterEach(() => { + setAntigravityAccountQuotaTransportForTests(null); + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } + }); test("probes each account with its own bearer and project id on the fixed Google host using retrieveUserQuotaSummary", async () => { const expires = Date.now() + 60 * 60_000; @@ -748,6 +763,95 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); }); + for (const fallback of [false, true]) { + test(`Fake-IP ${fallback ? "models fallback" : "summary"} keeps each account bearer and project separate`, async () => { + const expires = Date.now() + 3600_000; + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + await saveCredential("google-antigravity", { access: "agy-second", refresh: "r2", expires, projectId: "proj-second", accountId: "agy-b", email: "b@example.com" }); + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + const resolved: Array<{ url: string; benchmark?: boolean; private?: boolean; mihomo?: boolean }> = []; + const posted: Array<{ url: string; auth: string | null; project: string; address: string; tls?: boolean; signal: boolean }> = []; + setAntigravityAccountQuotaTransportForTests(null); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + const policy = typeof options === "object" ? options : undefined; + resolved.push({ url, benchmark: policy?.allowBenchmarkAddresses, private: policy?.allowPrivateNetwork, mihomo: policy?.allowMihomoIpv6FakeIp }); + if (!policy?.allowBenchmarkAddresses) throw new Error("benchmark address rejected"); + return { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "198.18.56.214", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async (url, pinned, body, signal, options) => { + const auth = new Headers(options?.headers).get("authorization"); + posted.push({ url, auth, project: String(JSON.parse(body).project), address: pinned.address, tls: options?.rejectUnauthorized, signal: signal instanceof AbortSignal }); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + const [gem, cla]: [number, number] = auth === "Bearer agy-first" ? [0.86, 0.38] : [0.97, 0.91]; + return new Response(url === summaryUrl ? antigravitySummaryBody(gem, cla) : antigravityBody(gem, cla)); + }, + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + expect(resolved).toHaveLength(urls.length * 2); + expect(posted).toHaveLength(urls.length * 2); + for (const url of urls) { + expect(resolved.filter(row => row.url === url)).toEqual([ + { url, benchmark: true, private: false, mihomo: false }, + { url, benchmark: true, private: false, mihomo: false }, + ]); + } + for (const [auth, project] of [["Bearer agy-first", "proj-first"], ["Bearer agy-second", "proj-second"]]) { + expect(posted.filter(row => row.auth === auth)).toEqual(urls.map(url => ({ url, auth, project, address: "198.18.56.214", tls: true, signal: true }))); + } + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(byId[idFor("a@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [14, 62] : [14, 14, 62, 62]); + expect(byId[idFor("b@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [3, 9] : [3, 3, 9, 9]); + expect(plainFetchCalls).toBe(0); + }); + } + + test("NO_PROXY denial preserves an unavailable account row without sending its bearer", async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + process.env.no_proxy = "daily-cloudcode-pa.googleapis.com"; + const admitted: Array = []; + let posted = 0; + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (_url, options) => { + const allow = typeof options === "object" ? options?.allowBenchmarkAddresses : undefined; + admitted.push(allow); + if (!allow) throw new Error("benchmark address rejected"); + return { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "198.18.56.214", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async () => { posted += 1; return new Response(antigravitySummaryBody(0.5, 0.5)); }, + }); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(admitted).toEqual([false, false]); + expect(posted).toBe(0); + expect(plainFetchCalls).toBe(0); + }); + + for (const status of [302, 307, 308, 401, 403]) { + for (const fallback of [false, true]) { + test(`account ${fallback ? "models" : "summary"} ${status} returns unavailable without following Location`, async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + const posted: string[] = []; + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => { + posted.push(url); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + return new Response(null, { status, headers: { location: "https://daily-cloudcode-pa.googleapis.com/redirect-target" } }); + }, + }); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : [summaryUrl]); + expect(plainFetchCalls).toBe(0); + }); + } + } + test("a rejected destination never receives a bearer; the row is unavailable, not 0%", async () => { const expires = Date.now() + 60 * 60_000; await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 5a02e53f28..9c12869b04 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -13,6 +13,7 @@ import { saveCredential } from "../../src/oauth/store"; import { clearProviderQuotaCache, fetchProviderQuotaReports, + isCanonicalAntigravityQuotaUrl, parseOllamaCloudQuota, parseXaiCreditsResponse, QUOTA_RESPONSE_MAX_BYTES, @@ -21,7 +22,10 @@ import { setProviderQuotaBeforePublishForTests, } from "../../src/providers/quota"; import type { OcxConfig } from "../../src/types"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { repoPath } from "../helpers/repo-root"; +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); const originalFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; @@ -75,6 +79,7 @@ function testConfig(): OcxConfig { } beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; opencodexHome = mkdtempSync(join(tmpdir(), "ocx-quota-")); codexHome = mkdtempSync(join(tmpdir(), "codex-quota-")); process.env.OPENCODEX_HOME = opencodexHome; @@ -90,6 +95,10 @@ beforeEach(() => { }); afterEach(() => { + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } globalThis.fetch = originalFetch; clearAccountQuota(); clearProviderQuotaCache(); @@ -203,15 +212,39 @@ describe("fetchProviderQuotaReports", () => { await saveCredential("google-antigravity", { access: "agy-access-secret", refresh: "agy-refresh-secret", expires: Date.now() + 3600_000, projectId: "agy-project-secret" }); await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); - // The Antigravity summary probe is pinned to Google's host through the provider-outbound - // transport and never touches globalThis.fetch; without this seam the test would make a - // real network request. A 404 here exercises the fetchAvailableModels fallback below. + const seen: { url: string; authorization?: string; body?: string }[] = []; + // Both Antigravity accounting requests use the pinned transport. Keep the + // summary unavailable so this fixture still exercises the models fallback. setAntigravityAccountQuotaTransportForTests({ resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), - pinnedPost: async () => new Response("not found", { status: 404 }), + pinnedPost: async (url, _pinned, body, _signal, options) => { + seen.push({ url, authorization: new Headers(options?.headers).get("authorization") ?? undefined, body }); + if (url === "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") { + return new Response(JSON.stringify({ + models: { + "gemini-3.6-flash-medium": { + displayName: "Gemini 3.6 Flash (Medium)", + quotaInfo: { remainingFraction: 0.64, resetTime: "2026-07-05T14:00:00Z" }, + }, + "claude-sonnet-4.6": { + displayName: "Claude Sonnet", + quotaInfoByTier: { + sonnet: { remainingFraction: 0.21, resetTime: "2026-07-05T15:00:00Z" }, + }, + }, + autocomplete: { + displayName: "Autocomplete", + quotaInfo: { remainingFraction: 0.01, resetTime: "2026-07-05T16:00:00Z" }, + }, + }, + rawProject: "agy-project-secret", + rawToken: "agy-access-secret", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }, }); - const seen: { url: string; authorization?: string; body?: string }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const headers = init?.headers as Record | undefined; @@ -258,28 +291,6 @@ describe("fetchProviderQuotaReports", () => { billingCycleEnd: "2026-08-01T00:00:00.000Z", }), { status: 200, headers: { "content-type": "application/json" } }); } - if (url === "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") { - return new Response(JSON.stringify({ - models: { - "gemini-3.6-flash-medium": { - displayName: "Gemini 3.6 Flash (Medium)", - quotaInfo: { remainingFraction: 0.64, resetTime: "2026-07-05T14:00:00Z" }, - }, - "claude-sonnet-4.6": { - displayName: "Claude Sonnet", - quotaInfoByTier: { - sonnet: { remainingFraction: 0.21, resetTime: "2026-07-05T15:00:00Z" }, - }, - }, - autocomplete: { - displayName: "Autocomplete", - quotaInfo: { remainingFraction: 0.01, resetTime: "2026-07-05T16:00:00Z" }, - }, - }, - rawProject: "agy-project-secret", - rawToken: "agy-access-secret", - }), { status: 200, headers: { "content-type": "application/json" } }); - } if (url === "https://api.kimi.com/coding/v1/usages") { return new Response(JSON.stringify({ user: { userId: "kimi-user-secret", businessId: "kimi-business-secret" }, @@ -3078,6 +3089,205 @@ describe("fetchProviderQuotaReports", () => { expect(posted).toEqual(["https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"]); }); + describe("Google Antigravity canonical quota transport (#3781)", () => { + const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + const summaryBody = JSON.stringify({ groups: [{ displayName: "Gemini", buckets: [{ window: "5h", remainingFraction: 0.6 }] }] }); + const modelsBody = JSON.stringify({ models: { gemini: { quotaInfo: { remainingFraction: 0.75 } } } }); + const publicAddress = { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }; + let plainFetchCalls: string[]; + + function config(baseUrl = "https://daily-cloudcode-pa.googleapis.com"): OcxConfig { + return { + defaultProvider: "google-antigravity", + providers: { "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl, allowPrivateNetwork: true } }, + } as OcxConfig; + } + + beforeEach(async () => { + await saveCredential("google-antigravity", { + access: "agy-canonical-access", refresh: "agy-canonical-refresh", expires: Date.now() + 3600_000, projectId: "agy-canonical-project", + }); + plainFetchCalls = []; + globalThis.fetch = (async (input) => { + plainFetchCalls.push(String(input)); + throw new Error("unexpected quota-owned raw fetch"); + }) as typeof fetch; + }); + + test("canonical proof accepts only the two exact Google accounting URLs", () => { + for (const url of [summaryUrl, modelsUrl]) { + expect(isCanonicalAntigravityQuotaUrl("google-antigravity", url)).toBe(true); + expect(isCanonicalAntigravityQuotaUrl("custom", url)).toBe(false); + for (const candidate of [ + "", "not a URL", url.replace("https:", "http:"), + url.replace(".googleapis.com", ".googleapis.com.evil.example"), + url.replace("daily-cloudcode-pa", "cloudcode-pa"), + url.replace("https://", "https://user:pass@"), + url.replace(".com/", ".com:443/"), url.replace(".com/", ".com:8443/"), + url.replace("https://", "HTTPS://"), `${url}/`, `${url}/extra`, + `${url}?token=secret`, `${url}#fragment`, ` ${url}`, + url.replace("v1internal:", "v1internal%3A"), + url.replace("v1internal:", "prefix/v1internal:"), + "https://daily-cloudcode-pa.googleapis.com/v1internal:other", + "https://198.18.0.1/v1internal:fetchAvailableModels", + "https://127.0.0.1/v1internal:fetchAvailableModels", + "https://169.254.169.254/v1internal:fetchAvailableModels", + ]) expect(isCanonicalAntigravityQuotaUrl("google-antigravity", candidate)).toBe(false); + } + }); + + for (const fallback of [false, true]) { + test(`production proof survives reset for Fake-IP ${fallback ? "fallback" : "summary"}`, async () => { + const resolved: Array<{ url: string; benchmark?: boolean; private?: boolean; mihomo?: boolean }> = []; + const posted: Array<{ url: string; address: string; tls?: boolean; auth: string | null; body: string; signal: boolean }> = []; + setAntigravityAccountQuotaTransportForTests({ isCanonicalUrl: () => false }); + setAntigravityAccountQuotaTransportForTests(null); + // Resolver/pinned-only overrides must retain the production canonical proof. + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + const policy = typeof options === "object" ? options : undefined; + resolved.push({ url, benchmark: policy?.allowBenchmarkAddresses, private: policy?.allowPrivateNetwork, mihomo: policy?.allowMihomoIpv6FakeIp }); + if (!policy?.allowBenchmarkAddresses) throw new Error("benchmark address rejected"); + return { ...publicAddress, addresses: [{ address: "198.18.56.214", family: 4 }] }; + }, + pinnedPost: async (url, pinned, body, signal, options) => { + posted.push({ url, address: pinned.address, tls: options?.rejectUnauthorized, auth: new Headers(options?.headers).get("authorization"), body, signal: signal instanceof AbortSignal }); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + return new Response(url === summaryUrl ? summaryBody : modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(), true); + const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + expect(resolved).toEqual(urls.map(url => ({ url, benchmark: true, private: false, mihomo: false }))); + expect(posted).toEqual(urls.map(url => ({ url, address: "198.18.56.214", tls: true, auth: "Bearer agy-canonical-access", body: JSON.stringify({ project: "agy-canonical-project" }), signal: true }))); + expect(result.reports[0]?.source).toBe(fallback ? "google-antigravity:fetchAvailableModels" : "google-antigravity:retrieveUserQuotaSummary"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: fallback ? 25 : 40 }]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const baseUrl of ["https://custom.example/v1", "http://127.0.0.1:1/", "https://169.254.169.254/", "https://daily-cloudcode-pa.googleapis.com.evil.example/"]) { + test(`models fallback ignores configured destination ${baseUrl}`, async () => { + const resolved: Array<{ url: string; private?: boolean }> = []; + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + resolved.push({ url, private: typeof options === "object" ? options?.allowPrivateNetwork : undefined }); + return publicAddress; + }, + pinnedPost: async (url) => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response(modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(baseUrl), true); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(resolved).toEqual([{ url: summaryUrl, private: false }, { url: modelsUrl, private: false }]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const noProxy of ["daily-cloudcode-pa.googleapis.com", "*"]) { + test(`NO_PROXY ${noProxy} keeps benchmark DNS blocked`, async () => { + process.env.NO_PROXY = noProxy; + const admitted: Array = []; + let posted = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (_url, options) => { + const allow = typeof options === "object" ? options?.allowBenchmarkAddresses : undefined; + admitted.push(allow); + if (!allow) throw new Error("benchmark address rejected"); + return publicAddress; + }, + pinnedPost: async () => { posted += 1; return new Response(summaryBody); }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(admitted).toEqual([false, false]); + expect(posted).toBe(0); + expect(plainFetchCalls).toEqual([]); + }); + } + + test("resolved-address policy rejection cannot escape to raw fallback fetch", async () => { + // The real classifier's mixed-address cases live in destination-policy-resolved.test.ts; + // this checks that quota cannot bypass its rejection through a second transport. + const resolved: string[] = []; + let posted = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async url => { resolved.push(url); throw new Error("provider URL resolves to metadata"); }, + pinnedPost: async () => { posted += 1; return new Response(modelsBody); }, + }); + expect((await fetchProviderQuotaReports(config("https://custom.example"), true)).reports).toEqual([]); + expect(resolved).toEqual([summaryUrl, modelsUrl]); + expect(posted).toBe(0); + expect(plainFetchCalls).toEqual([]); + }); + + for (const summary of ["{}", "invalid JSON"]) { + test(`unusable summary ${summary} falls back through the fixed models transport`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return new Response(url === summaryUrl ? summary : modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(), true); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [200, 500]) { + test(`unusable models payload with HTTP ${status} produces no fabricated quota`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response("invalid JSON", { status }); + }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [302, 307, 308, 401, 403]) { + test(`summary ${status} terminates without a models request`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { posted.push(url); return new Response(null, { status, headers: { location: modelsUrl } }); }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [302, 307, 308]) { + test(`models ${status} does not follow even a same-host redirect`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response(null, { status, headers: { location: summaryUrl } }); + }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + }); + test("Ollama Cloud maps 5-hour session and weekly windows from /api/usage (legacy plan)", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {