From ce7ffb40057c04e3d449f3f9563142f5cb65b0fd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:16:28 +0900 Subject: [PATCH 1/2] feat(accounts): classify Antigravity quota probe failures safely --- devlog/_plan/260912_accounts/070_tun.md | 6 + .../_plan/260912_accounts/071_tun_delivery.md | 7 + .../src/content/docs/guides/providers.md | 7 + .../src/content/docs/ko/guides/providers.md | 7 + .../ProviderAccountQuota.tsx | 6 +- .../provider-workspace/ProviderAuthPanel.tsx | 4 +- .../components/provider-workspace/types.ts | 2 + gui/src/hooks/useProviderAccountPools.ts | 13 +- gui/src/i18n/de.ts | 10 + gui/src/i18n/en.ts | 10 + gui/src/i18n/fr.ts | 10 + gui/src/i18n/ja.ts | 10 + gui/src/i18n/ko.ts | 10 + gui/src/i18n/ru.ts | 10 + gui/src/i18n/tr.ts | 10 + gui/src/i18n/zh-TW.ts | 10 + gui/src/i18n/zh.ts | 10 + .../provider-account-quota-loading.test.tsx | 28 +++ gui/tests/provider-current-quota.test.tsx | 16 ++ src/cli/account-api.ts | 6 + src/cli/account.ts | 2 +- src/providers/quota-types.ts | 12 ++ src/providers/quota.ts | 191 +++++++++--------- src/server/management/oauth-account-routes.ts | 4 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 9 +- structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-account.test.ts | 13 ++ tests/oauth/oauth-accounts-api.test.ts | 42 ++++ .../providers/provider-account-quota.test.ts | 82 +++++++- 44 files changed, 474 insertions(+), 105 deletions(-) create mode 100644 devlog/_plan/260912_accounts/071_tun_delivery.md diff --git a/devlog/_plan/260912_accounts/070_tun.md b/devlog/_plan/260912_accounts/070_tun.md index d37cd09632..77f46a02cc 100644 --- a/devlog/_plan/260912_accounts/070_tun.md +++ b/devlog/_plan/260912_accounts/070_tun.md @@ -21,3 +21,9 @@ MODIFY GUI `components/provider-workspace/types.ts`, `hooks/useProviderAccountPo Field chain: private probe→transient cache→account results→authenticated API JSON→enum-normalized client/CLI→current/all-account quota text. Ranking/health/history do not consume it. Tests: each enum trigger, summary failure/fallback success, final-attempt precedence, stale bars, recovery, cross-account isolation, stale-config, late response, unknown wire code, and secret-free projection. Existing provider account quota fixtures supply transport injection; new files require both layout entries. Fix inventory's stale IPv6 proxy-only sentence and update every touched area owner. Local suites/build NOT RUN; hosted backend/GUI checks and rendered final-tip artifact. Authenticated TUN observation remains unmet until an authorized operator supplies exact SHA, proxy/TUN mode and sanitized successful refresh; no network/account changes here. Reflection TUN-R01/R02 accepted. HTTP 300–399→redirect_blocked, 401/403→access_denied, 429→rate_limited, other non-2xx→upstream_error; success with unusable quota→response_unusable. Keep providerRedirectError cancellation and discard its message. Neither status establishes plan or reauth. fetchAntigravityQuota may reuse the private probe preserving null/rejection and success source; ProviderQuota/ProviderQuotaReport gain no diagnostic field, report-only views remain generic. getCachedProviderAccountQuota returns last-good quota only. + +P revalidation on489af939: parent added explicit account readers and Combo quota evidence. Classification stays limited to Antigravity account probe/cache; provider report remains its existing report(...) projection with no inference authority or diagnosticfield. Current functions moved but contract unchanged. Active tun cursor honored after history A mismatch; history source implementation remains pending on its own branch. Local tests/build/typecheck/install still NOT RUN; authenticated field acceptance untouched. + +A1 credential-currentness accepted. Reuse the existing private explicitQuotaIdentity hash recipe via a pure quotaCredentialIdentity(provider,id,capturedCredential,target) helper; existing explicit readers keep byte-identical hashes. Antigravity diagnostic capture uses its fixed canonical target and the credential whose access token matches the resolved probe token, captured before I/O. Preparation failure may use a pre-resolution identity only if it still matches. Record an epoch-bound private isQuotaFailureCurrent callback; recheck before cache publication, cached reads, result assembly and API projection. A changed/missing/unreadable identity omits the diagnostic only, preserving current last-good quota/unavailable semantics. No private digest or callback serialized. Add optional nonenumerable quotaFailureIsCurrent to the internal ProviderAccountQuota result instead of using isCurrent, whose existing API branch invalidates the entire quota row. Cache/result quotaFailure appears only while unavailable and current. Tests reauth during delayed probe and cached failure after same-id replacement; stale diagnosis is omitted, not attached to replacement credentials. + +TUN-D source proposal labels refer to the read-only Faraday design outputs recorded in this task. The executable source of truth is this document's full type/flow contract, not an absent external file. No inference-wide authority is added. diff --git a/devlog/_plan/260912_accounts/071_tun_delivery.md b/devlog/_plan/260912_accounts/071_tun_delivery.md new file mode 100644 index 0000000000..45f86ff584 --- /dev/null +++ b/devlog/_plan/260912_accounts/071_tun_delivery.md @@ -0,0 +1,7 @@ +# Safe Antigravity quota diagnostics + +The existing canonical transport stays intact. A private probe result preserves exported null/rejection compatibility and classifies the final attempted endpoint. Account failures carry only an allowlisted code, bound to the probed credential/project and invalidated independently from last-good bars. API, CLI and both dashboard quota views consume it; unknown values and local management failures stay generic. All nine locale dictionaries updated. + +Regression sources exercise status/typed transport categories, fallback recovery, old bars, same-id replacement during and after a probe, private-value serialization, API projection, GUI normalization/recovery and CLI code filtering. Local suites/build/typecheck/install: NOT RUN. Text whitespace checks only. Backend source reviewer Rawls found no material scoped defect; full consumer/security review and final hosted CI/render proof remain pending. Authenticated TUN field acceptance remains open for #3781; no live credential or network configuration was changed. + +Resume binding verified: session01a093dc-e20d-79a1-8d46-231ced9ee05b, currentaccounts-tun branch489af939 baseline, actual FSMB. Host goal is blocked and has not been edited or described as active. Existing successful callback CI34673984380/fa4226a9ba is retained and not rerun. Other original PRs have changed heads and require current evidence; no merged PR is recreated. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a093f22dc6..81192627d5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1038,3 +1038,10 @@ no quota bars rather than a fabricated one, and windows the plan does not report absent instead of rendering as 0%. A provider using a non-canonical `baseUrl` is never sent the key for this probe. + + +### Diagnosing an Antigravity quota refresh + +The account quota view and `ocx account list google-antigravity --quota --refresh` distinguish access denial, rate limiting, blocked destinations or redirects, DNS/connection/timeouts, and unusable quota data. Last-known bars remain visible with their observation time when a refresh fails. Reauthentication retires diagnoses from the previous credential; a successful refresh clears the failure. + +An access-denied result does not by itself prove an expired login or an ineligible plan. A blocked destination is a network-policy decision, not proof of a Fake-IP defect. Canonical Google quota destinations retain TLS verification and redirect/private-address restrictions. Authenticated TUN behavior must be checked in the affected environment; injected transport fixtures alone do not establish that field result. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f35396cff..21b472f721 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -446,3 +446,10 @@ opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은 **Custom**을 선택하거나 `ocx init`에서 `custom`을 선택한 뒤 베이스 URL을 입력하세요. 모든 프로바이더 필드 (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …)는 [설정 레퍼런스](/ko/reference/configuration/)를 참고하세요. + + +### Antigravity 쿼터 조회 실패 확인 + +계정 쿼터 화면과 `ocx account list google-antigravity --quota --refresh`는 접근 거부, 요청 한도, 목적지·리디렉션 차단, DNS·연결·시간 초과, 읽을 수 없는 응답을 구분합니다. 조회가 실패해도 마지막 관측 막대와 시각은 유지합니다. 재로그인하면 이전 자격 증명의 진단을 버리고, 조회에 성공하면 오류 표시를 지웁니다. + +접근 거부만으로 로그인 만료나 플랜 사용 불가를 단정하지 않습니다. 목적지 차단도 Fake-IP 결함의 증거는 아닙니다. Google의 고정 쿼터 주소에는 TLS 인증서 확인과 리디렉션·사설 주소 제한이 유지됩니다. 인증된 TUN 환경의 동작은 해당 환경에서 별도로 확인해야 합니다. diff --git a/gui/src/components/provider-workspace/ProviderAccountQuota.tsx b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx index 42f4d12af2..dc1f7f9a7b 100644 --- a/gui/src/components/provider-workspace/ProviderAccountQuota.tsx +++ b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx @@ -1,3 +1,4 @@ +import { parseQuotaFailureCode } from "../../../../src/providers/quota-types"; import { useT } from "../../i18n/shared"; import { accountQuotaFromReport } from "../../provider-workspace/report"; import { formatRelativeTime, relativeTimeLabelsFromT } from "../../provider-workspace/usage"; @@ -5,16 +6,17 @@ import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; import type { AccountQuotaReading } from "./types"; /** The same reading states and credit/window renderer for current and all-account views. */ -export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quotaUnavailable, quotaPending }: AccountQuotaReading) { +export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quotaUnavailable, quotaPending, quotaFailure }: AccountQuotaReading) { const t = useT(); const quota = accountQuotaFromReport({ quota: rawQuota }); if (quotaMode === "unsupported") { return

{t("pws.quotaUnsupported")}

; } + const failure = quotaMode === "probe" && quotaUnavailable ? parseQuotaFailureCode(quotaFailure) : undefined; const pending = quotaMode === "probe" && quotaPending === true; const state = quotaUnavailable ? "unavailable" : pending ? "pending" : quota ? "ready" : quotaMode === "passive" ? "unobserved" : "unknown"; return
- {quotaUnavailable &&

{t("pws.accountQuotaUnavailable")}

} + {quotaUnavailable &&

{t(failure ? `pws.quotaFailure.${failure}` : "pws.accountQuotaUnavailable")}

} {quota || pending ? (
+ quotaUnavailable={account.quotaUnavailable} quotaPending={account.quotaPending} quotaFailure={account.quotaFailure} />
); @@ -657,7 +657,7 @@ export default function ProviderAuthPanel({
+ quotaUnavailable={entry.quotaUnavailable} quotaPending={entry.quotaPending} quotaFailure={entry.quotaFailure} />
))} diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index e722d10dd7..6f913e1bcf 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -1,3 +1,4 @@ +import type { QuotaFailureCode } from "../../../../src/providers/quota-types"; /** * provider-workspace/types.ts — shared view-model types for the Providers * workspace shell/rail/detail (WP080a). Data shapes only; no React. @@ -46,6 +47,7 @@ export interface AccountQuotaReading { quotaMode?: AccountQuotaMode; quota?: AccountQuota | null; quotaUnavailable?: boolean; + quotaFailure?: QuotaFailureCode; /** Client-owned enrichment state, never inferred from missing quota data. */ quotaPending?: boolean; } diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 0197a547d5..393727d094 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -1,3 +1,4 @@ +import { parseQuotaFailureCode } from "../../../src/providers/quota-types"; import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import type { AccountLoadState, AccountQuotaReading } from "../components/provider-workspace/types"; import { createBoundedFetch } from "../bounded-fetch"; @@ -36,7 +37,9 @@ function mergeRosterRows(rows: T[], previous: T[]): T[] { return mergeQuotaRows(rows, previous, false).map(row => supportsQuotaRead(row) ? { ...row, quotaPending: prior.get(row.id)?.quotaPending ?? false, - quotaUnavailable: prior.get(row.id)?.quotaUnavailable ?? false, + quotaUnavailable: prior.get(row.id)?.quotaMode === row.quotaMode ? prior.get(row.id)?.quotaUnavailable ?? false : false, + quotaFailure: row.quotaMode === "probe" && prior.get(row.id)?.quotaMode === row.quotaMode && prior.get(row.id)?.quotaUnavailable + ? parseQuotaFailureCode(prior.get(row.id)?.quotaFailure) : undefined, } : row); } @@ -47,7 +50,7 @@ function mergeLateQuotaRows(rows: T[], enriched: T[]): T[] { const incoming = byId.get(row.id); if (!incoming || incoming.quotaMode !== row.quotaMode) return row; const quota = mergeQuotaRows([incoming], [row], true)[0]; - return { ...row, quota: quota.quota, quotaPending: quota.quotaPending, quotaUnavailable: quota.quotaUnavailable }; + return { ...row, quota: quota.quota, quotaPending: quota.quotaPending, quotaUnavailable: quota.quotaUnavailable, quotaFailure: quota.quotaFailure }; }); } @@ -60,7 +63,7 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched: const supported = supportsQuotaRead(row); // Legacy/unknown mode must not acquire synthetic flags that would override // a provider report or imply that a quota probe is supported. - if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined }; + if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined, quotaFailure: undefined }; // Only surviving credential IDs can retain omitted data. Explicit null is an // authoritative invalidation, including failed/expired credential readings. const retain = supported && (!enriched || row.quotaUnavailable === true); @@ -69,6 +72,8 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched: quota: row.quotaMode === "unsupported" ? null : row.quota !== undefined ? row.quota : retain ? prior.get(row.id)?.quota : undefined, quotaPending: !enriched && row.quotaMode === "probe", quotaUnavailable: enriched ? row.quotaUnavailable === true : false, + quotaFailure: enriched && row.quotaMode === "probe" && row.quotaUnavailable === true + ? parseQuotaFailureCode(row.quotaFailure) : undefined, }; }); } @@ -76,7 +81,7 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched: function unavailableQuotaRows(rows: T[], attempted?: T[]): T[] { const attemptedModes = attempted && new Map(attempted.map(row => [row.id, row.quotaMode])); return rows.map(row => supportsQuotaRead(row) && (!attemptedModes || attemptedModes.get(row.id) === row.quotaMode) - ? { ...row, quotaUnavailable: true, quotaPending: false } + ? { ...row, quotaUnavailable: true, quotaPending: false, quotaFailure: undefined } : row); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index db6b8c889f..a37372a833 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2072,6 +2072,16 @@ export const de: Record = { "pws.rateLimits": "Limits", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", + "pws.quotaFailure.account_unavailable": "Kontodaten für die Kontingentprüfung sind nicht verfügbar.", + "pws.quotaFailure.access_denied": "Der Anbieter verweigert den Zugriff auf Kontingentdaten.", + "pws.quotaFailure.rate_limited": "Die Kontingentprüfung wurde durch ein Anfragelimit begrenzt.", + "pws.quotaFailure.upstream_error": "Der Anbieter konnte die Kontingentprüfung nicht abschließen.", + "pws.quotaFailure.redirect_blocked": "Eine Weiterleitung des Kontingentendpunkts wurde blockiert.", + "pws.quotaFailure.destination_blocked": "Die Netzwerkrichtlinie blockiert das Kontingentziel.", + "pws.quotaFailure.dns_failed": "Der Hostname des Kontingentservers konnte nicht aufgelöst werden.", + "pws.quotaFailure.timeout": "Die Kontingentanfrage hat das Zeitlimit überschritten.", + "pws.quotaFailure.transport_error": "Die Verbindung zum Kontingentserver ist fehlgeschlagen.", + "pws.quotaFailure.response_unusable": "Der Anbieter lieferte unbrauchbare Kontingentdaten.", "pws.selected": "Ausgewählt", "pws.copyModelId": "ID kopieren", "pws.modelCopied": "Kopiert!", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 74e4ded4d1..599037fee9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1284,6 +1284,16 @@ export const en = { "pws.rateLimits": "Rate limits", "pws.quotaUnavailable": "No quota data for this provider.", "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", + "pws.quotaFailure.account_unavailable": "Account details are unavailable for this quota check.", + "pws.quotaFailure.access_denied": "The provider denied access to quota data.", + "pws.quotaFailure.rate_limited": "The provider rate-limited the quota check.", + "pws.quotaFailure.upstream_error": "The provider could not complete the quota check.", + "pws.quotaFailure.redirect_blocked": "The quota endpoint returned a blocked redirect.", + "pws.quotaFailure.destination_blocked": "The quota destination was blocked by network policy.", + "pws.quotaFailure.dns_failed": "The quota hostname could not be resolved.", + "pws.quotaFailure.timeout": "The quota request timed out.", + "pws.quotaFailure.transport_error": "The quota connection failed.", + "pws.quotaFailure.response_unusable": "The provider returned unusable quota data.", "pws.selected": "Selected", "pws.copyModelId": "Copy ID", "pws.modelCopied": "Copied!", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2bc3d5ab51..75c42b75b2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1257,6 +1257,16 @@ export const fr: Record = { "pws.rateLimits": "Limites de débit", "pws.quotaUnavailable": "Aucune donnée de quota pour ce fournisseur.", "pws.accountQuotaUnavailable": "Données de limite de débit temporairement indisponibles ; affichage des dernières valeurs connues, le cas échéant.", + "pws.quotaFailure.account_unavailable": "Les données du compte sont indisponibles pour cette vérification.", + "pws.quotaFailure.access_denied": "Le fournisseur a refusé l’accès aux données de quota.", + "pws.quotaFailure.rate_limited": "Le fournisseur a limité la fréquence des vérifications de quota.", + "pws.quotaFailure.upstream_error": "Le fournisseur n’a pas pu vérifier le quota.", + "pws.quotaFailure.redirect_blocked": "La redirection du service de quota a été bloquée.", + "pws.quotaFailure.destination_blocked": "La politique réseau a bloqué la destination du quota.", + "pws.quotaFailure.dns_failed": "Le nom du serveur de quota n’a pas pu être résolu.", + "pws.quotaFailure.timeout": "La requête de quota a expiré.", + "pws.quotaFailure.transport_error": "La connexion au service de quota a échoué.", + "pws.quotaFailure.response_unusable": "Les données de quota reçues sont inutilisables.", "pws.selected": "Sélectionné", "pws.copyModelId": "Copier l’ID", "pws.modelCopied": "Copié !", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a787ab735b..9e027d1db7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1182,6 +1182,16 @@ export const ja: Record = { "pws.rateLimits": "レート制限", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", + "pws.quotaFailure.account_unavailable": "割り当て確認に必要なアカウント情報を利用できません。", + "pws.quotaFailure.access_denied": "プロバイダーが割り当てデータへのアクセスを拒否しました。", + "pws.quotaFailure.rate_limited": "割り当て確認のレート制限に達しました。", + "pws.quotaFailure.upstream_error": "プロバイダーが割り当て確認を完了できませんでした。", + "pws.quotaFailure.redirect_blocked": "割り当てエンドポイントのリダイレクトをブロックしました。", + "pws.quotaFailure.destination_blocked": "ネットワークポリシーが割り当ての接続先をブロックしました。", + "pws.quotaFailure.dns_failed": "割り当てサーバーのホスト名を解決できませんでした。", + "pws.quotaFailure.timeout": "割り当てリクエストがタイムアウトしました。", + "pws.quotaFailure.transport_error": "割り当てサーバーへの接続に失敗しました。", + "pws.quotaFailure.response_unusable": "割り当てデータを読み取れませんでした。", "pws.selected": "選択中", "pws.copyModelId": "ID をコピー", "pws.modelCopied": "コピーしました!", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ccd2b06735..5b9eaad43f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2111,6 +2111,16 @@ export const ko: Record = { "pws.rateLimits": "요청 한도", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", + "pws.quotaFailure.account_unavailable": "쿼터 조회에 필요한 계정 정보를 사용할 수 없습니다.", + "pws.quotaFailure.access_denied": "제공자가 쿼터 데이터 접근을 거부했습니다.", + "pws.quotaFailure.rate_limited": "제공자의 쿼터 조회 요청 한도에 도달했습니다.", + "pws.quotaFailure.upstream_error": "제공자가 쿼터 조회를 처리하지 못했습니다.", + "pws.quotaFailure.redirect_blocked": "쿼터 주소의 리디렉션이 차단됐습니다.", + "pws.quotaFailure.destination_blocked": "네트워크 정책이 쿼터 조회 목적지를 차단했습니다.", + "pws.quotaFailure.dns_failed": "쿼터 서버의 호스트 이름을 확인하지 못했습니다.", + "pws.quotaFailure.timeout": "쿼터 조회 시간이 초과됐습니다.", + "pws.quotaFailure.transport_error": "쿼터 서버에 연결하지 못했습니다.", + "pws.quotaFailure.response_unusable": "제공자가 반환한 쿼터 데이터를 읽을 수 없습니다.", "pws.selected": "선택됨", "pws.copyModelId": "ID 복사", "pws.modelCopied": "복사됨!", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ceb0c18a92..d71f326505 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1252,6 +1252,16 @@ export const ru: Record = { "pws.rateLimits": "Лимиты запросов", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", + "pws.quotaFailure.account_unavailable": "Данные аккаунта для проверки квоты недоступны.", + "pws.quotaFailure.access_denied": "Провайдер запретил доступ к данным квоты.", + "pws.quotaFailure.rate_limited": "Превышен лимит запросов квоты.", + "pws.quotaFailure.upstream_error": "Провайдер не смог проверить квоту.", + "pws.quotaFailure.redirect_blocked": "Перенаправление сервиса квоты заблокировано.", + "pws.quotaFailure.destination_blocked": "Сетевая политика заблокировала адрес сервиса квоты.", + "pws.quotaFailure.dns_failed": "Не удалось разрешить имя сервера квоты.", + "pws.quotaFailure.timeout": "Время ожидания запроса квоты истекло.", + "pws.quotaFailure.transport_error": "Не удалось подключиться к сервису квоты.", + "pws.quotaFailure.response_unusable": "Провайдер вернул непригодные данные квоты.", "pws.selected": "Выбрана", "pws.copyModelId": "Копировать ID", "pws.modelCopied": "Скопировано!", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 26a8f93d09..283ea21333 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1271,6 +1271,16 @@ export const tr: Record = { "pws.rateLimits": "Oran limitleri", "pws.quotaUnavailable": "Bu sağlayıcı için kota verisi yok.", "pws.accountQuotaUnavailable": "Oran limiti verisi geçici olarak kullanılamıyor.", + "pws.quotaFailure.account_unavailable": "Kota denetimi için hesap bilgileri kullanılamıyor.", + "pws.quotaFailure.access_denied": "Sağlayıcı kota verilerine erişimi reddetti.", + "pws.quotaFailure.rate_limited": "Kota denetimi istek sınırına ulaştı.", + "pws.quotaFailure.upstream_error": "Sağlayıcı kota denetimini tamamlayamadı.", + "pws.quotaFailure.redirect_blocked": "Kota uç noktasının yönlendirmesi engellendi.", + "pws.quotaFailure.destination_blocked": "Ağ ilkesi kota hedefini engelledi.", + "pws.quotaFailure.dns_failed": "Kota sunucusunun ana bilgisayar adı çözümlenemedi.", + "pws.quotaFailure.timeout": "Kota isteği zaman aşımına uğradı.", + "pws.quotaFailure.transport_error": "Kota bağlantısı başarısız oldu.", + "pws.quotaFailure.response_unusable": "Sağlayıcı kullanılamayan kota verileri döndürdü.", "pws.selected": "Seçilen", "pws.copyModelId": "ID Kopyala", "pws.modelCopied": "Kopyalandı!", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1fb8387f4d..a3e291871c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1050,6 +1050,16 @@ export const zhTW: Record = { "pws.rateLimits": "速率限制", "pws.quotaUnavailable": "此供應商暫無配額資料。", "pws.accountQuotaUnavailable": "速率限制資料暫時不可用;若有上次已知值則繼續顯示。", + "pws.quotaFailure.account_unavailable": "無法取得配額查詢所需的帳號資訊。", + "pws.quotaFailure.access_denied": "供應商拒絕存取配額資料。", + "pws.quotaFailure.rate_limited": "配額查詢觸發了請求頻率限制。", + "pws.quotaFailure.upstream_error": "供應商未能完成配額查詢。", + "pws.quotaFailure.redirect_blocked": "配額端點的重新導向已被封鎖。", + "pws.quotaFailure.destination_blocked": "網路政策封鎖了配額查詢目的地。", + "pws.quotaFailure.dns_failed": "無法解析配額伺服器的主機名稱。", + "pws.quotaFailure.timeout": "配額請求逾時。", + "pws.quotaFailure.transport_error": "配額連線失敗。", + "pws.quotaFailure.response_unusable": "供應商回傳的配額資料無法使用。", "pws.selected": "已選擇", "pws.copyModelId": "複製 ID", "pws.modelCopied": "已複製!", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4e2cd831cc..62d10c5e1b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2092,6 +2092,16 @@ export const zh: Record = { "pws.rateLimits": "速率限制", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", + "pws.quotaFailure.account_unavailable": "无法获取配额查询所需的账号信息。", + "pws.quotaFailure.access_denied": "提供商拒绝访问配额数据。", + "pws.quotaFailure.rate_limited": "配额查询触发了请求频率限制。", + "pws.quotaFailure.upstream_error": "提供商未能完成配额查询。", + "pws.quotaFailure.redirect_blocked": "配额端点的重定向已被阻止。", + "pws.quotaFailure.destination_blocked": "网络策略阻止了配额查询目标。", + "pws.quotaFailure.dns_failed": "无法解析配额服务器的主机名。", + "pws.quotaFailure.timeout": "配额请求超时。", + "pws.quotaFailure.transport_error": "配额连接失败。", + "pws.quotaFailure.response_unusable": "提供商返回的配额数据不可用。", "pws.selected": "已选择", "pws.copyModelId": "复制 ID", "pws.modelCopied": "已复制!", diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx index bfd811ae7f..3f60bafb4a 100644 --- a/gui/tests/provider-account-quota-loading.test.tsx +++ b/gui/tests/provider-account-quota-loading.test.tsx @@ -417,3 +417,31 @@ test("a hanging fetch reaches its deadline, preserves last-good and clears probe else Reflect.deleteProperty(AbortSignal, "timeout"); } }); + + +test("quota diagnostics normalize and clear on recovery or management failure", async () => { + let failure: string | undefined = "dns_failed"; + let localFailure = false; + respond = async url => url.includes("quota=1") && localFailure ? new Response(null, { status: 503 }) : Response.json({ + accounts: [{ id: "account", active: true, quotaMode: "probe", quota: reading, + ...(url.includes("quota=1") ? { quotaUnavailable: failure !== undefined, quotaFailure: failure } : {}), + }], + }); + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBe("dns_failed"); + failure = "private-unrecognized-error"; + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); + failure = "rate_limited"; + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBe("rate_limited"); + localFailure = true; + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: true, quota: reading }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); + localFailure = false; + failure = undefined; + await act(async () => { expect(await pools.fetchAccountSets(["oauth"], true)).toBe(true); }); + expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: false, quota: reading }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); +}); diff --git a/gui/tests/provider-current-quota.test.tsx b/gui/tests/provider-current-quota.test.tsx index 4acb16fc5f..b36c7fa33f 100644 --- a/gui/tests/provider-current-quota.test.tsx +++ b/gui/tests/provider-current-quota.test.tsx @@ -100,3 +100,19 @@ test("unsupported, passive unobserved, explicit loading and failed last-good are expect(failed).toContain("12% used"); expect(failed).toContain("Quota updated"); }); + + +test("safe account failure categories reach both current and all-account quota views", () => { + const quota = { weeklyPercent: 12, updatedAt: observedAt }; + const reading = { quotaMode: "probe" as const, quota, quotaUnavailable: true, quotaFailure: "dns_failed" as const }; + for (const view of [, ]) { + const markup = render(view); + expect(markup).toContain("The quota hostname could not be resolved."); + expect(markup).toContain("12% used"); + } + const unknown = render(); + expect(unknown).not.toContain("private-error"); + expect(unknown).not.toContain("hostname"); + const recovered = render(); + expect(recovered).not.toContain("hostname"); +}); diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index e0e573e251..e8cc09d74f 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -1,3 +1,4 @@ +import { parseQuotaFailureCode, type QuotaFailureCode } from "../providers/quota-types"; /** * Data-access layer for `ocx account` (issue #180) — live-proxy HTTP client and * per-family account readers. Kept separate from account.ts (command handlers) @@ -32,6 +33,8 @@ export interface AccountRow { priority?: number; quota?: CodexQuotaDto | null; quotaRefresh?: CodexQuotaRefreshOutcome; + quotaUnavailable?: boolean; + quotaFailure?: QuotaFailureCode; /** * Whether the pool is holding this account out of rotation. * @@ -330,6 +333,7 @@ interface OAuthAccountDto { plan?: string | null; quota?: CodexQuotaDto | null; quotaUnavailable?: boolean; + quotaFailure?: unknown; } async function fetchOAuthRows( @@ -363,6 +367,8 @@ async function fetchOAuthRows( plan: a.plan ?? null, ...(a.quota !== undefined ? { quota: a.quota } : {}), ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}), + ...(a.quotaUnavailable === true && parseQuotaFailureCode(a.quotaFailure) + ? { quotaFailure: parseQuotaFailureCode(a.quotaFailure) } : {}), })); return { rows, activeId, status: 200 }; } diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..240cba31bf 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -115,7 +115,7 @@ function priorityText(row: AccountRow): string { * decides on before a long session. The full breakdown stays in `--json`. */ function quotaText(row: AccountRow): string { - if ((row as { quotaUnavailable?: boolean }).quotaUnavailable) return "unavailable"; + if (row.quotaUnavailable) return row.quotaFailure ? `unavailable (${row.quotaFailure})` : "unavailable"; const quota = row.quota; if (!quota) return "-"; const parts: string[] = []; diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index e0bdf9cb4f..3b7b2e236a 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -49,4 +49,16 @@ export interface AccountQuotaFields { quotaMode?: AccountQuotaMode; quota?: ProviderQuota | null; quotaUnavailable?: boolean; + quotaFailure?: QuotaFailureCode; +} + + +/** Closed account-probe diagnoses; never upstream text, URLs, credentials or routing policy. */ +export const QUOTA_FAILURE_CODES = [ + "account_unavailable", "access_denied", "rate_limited", "upstream_error", "redirect_blocked", + "destination_blocked", "dns_failed", "timeout", "transport_error", "response_unusable", +] as const; +export type QuotaFailureCode = typeof QUOTA_FAILURE_CODES[number]; +export function parseQuotaFailureCode(value: unknown): QuotaFailureCode | undefined { + return QUOTA_FAILURE_CODES.find(code => code === value); } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index fbff1afb1a..b3f9769abe 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -15,7 +15,9 @@ import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; -import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; +import { DestinationDnsResolutionError } from "../lib/destination-policy"; +import { PinnedHttpError } from "../lib/pinned-http"; +import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry"; @@ -51,6 +53,7 @@ import { } from "./codex-capacity"; import type { AccountQuotaMode, + QuotaFailureCode, ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow, @@ -1642,6 +1645,8 @@ type AccountQuotaCacheEntry = { quota: ProviderQuota | null; /** Last probe failed (429 / network / expired login); still may hold last-good quota. */ unavailable?: true; + quotaFailure?: QuotaFailureCode; + quotaFailureIsCurrent?: () => boolean; /** Private new-reader identity; never persisted or serialized. */ identity?: string; isCurrent?: () => boolean; @@ -1753,6 +1758,8 @@ export interface ProviderAccountQuota { quota: ProviderQuota | null; /** Set when the probe could not reach upstream (expired login, 429, network). */ unavailable?: true; + quotaFailure?: QuotaFailureCode; + quotaFailureIsCurrent?: () => boolean; isCurrent?: () => boolean; } @@ -2030,6 +2037,10 @@ function explicitQuotaIdentity(provider: string, accountId: string, configured?: const credential = getAccountCredential(provider, accountId); const target = explicitQuotaConfig(provider, configured); if (!credential || !target) return undefined; + return quotaCredentialIdentity(provider, accountId, credential, target); +} + +function quotaCredentialIdentity(provider: string, accountId: string, credential: NonNullable>, target: OcxProviderConfig): string { return createHash("sha256").update(JSON.stringify([ provider, accountId, credential.access, credential.refresh, credential.expires, credential.accountId, credential.projectId, credential.source, @@ -2121,6 +2132,12 @@ async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderCo return read.result; } +function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined { + return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, { + adapter: "google", baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE, authMode: "oauth", + }) : undefined; +} + async function fetchAccountQuota( provider: string, accountId: string, @@ -2134,13 +2151,23 @@ async function fetchAccountQuota( const writerGeneration = captureConfigGeneration(); const cached = accountQuotaCache.get(key); if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { + if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined }; return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached; } const joinable = accountQuotaInflight.get(key); if (joinable) return joinable; + const epoch = explicitAccountEpoch; const probe = (async (): Promise => { + let diagnosticIdentity: string | undefined; + let quotaFailure: QuotaFailureCode | undefined; + const quotaFailureIsCurrent = () => { + try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); } + catch { return false; } + }; + const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {}; try { + if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId); let quota: ProviderQuota | null; let kiroSnapshot: KiroUsageSnapshot | null = null; if (provider === "kiro") { @@ -2158,9 +2185,12 @@ async function fetchAccountQuota( // Per-account Gem/Cla windows (#1082). The project id is part of the stored // credential; without it the probe cannot be made, and that is "unavailable", // never 0%. - const projectId = getAccountCredential(provider, accountId)?.projectId; - if (!projectId) throw new Error("antigravity account has no project id"); - quota = await fetchAntigravityUsageQuota(token, projectId); + const credential = getAccountCredential(provider, accountId); + diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined; + if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable"); + const result = await probeAntigravityUsageQuota(token, credential.projectId); + quota = result.kind === "available" ? result.quota : null; + if (result.kind === "unavailable") quotaFailure = result.failure; } else if (provider === "anthropic") { quota = await fetchAnthropicUsageQuota(token); } else { @@ -2176,6 +2206,7 @@ async function fetchAccountQuota( quota: provider === "anthropic" ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, + ...diagnosticFields(), }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); @@ -2196,11 +2227,13 @@ async function fetchAccountQuota( } return entry; } catch { + if (provider === "google-antigravity") quotaFailure = "account_unavailable"; const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, + ...diagnosticFields(), }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); @@ -2233,7 +2266,9 @@ export async function fetchProviderAccountQuotas( accountId: account.id, quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, ...(entry.unavailable ? { unavailable: true as const } : {}), + ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}), }; + if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent }); if (!explicitAccountReader(provider)) return result; const identity = entry.identity; Object.defineProperty(result, "isCurrent", { value: () => { @@ -2864,104 +2899,78 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid * the destination keeps the `provider\0accountId` cache identity exact across config changes. * A redirect or non-2xx yields null (unavailable), never a partial row. */ -export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; +type AntigravityQuotaProbeResult = + | { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" } + | { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } }; + +function quotaTransportFailure(error: unknown): QuotaFailureCode { + if (error instanceof ProviderOutboundPolicyError) return "destination_blocked"; + if (error instanceof DestinationDnsResolutionError) return "dns_failed"; + if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout"; + if (error instanceof DOMException && error.name === "TimeoutError") return "timeout"; + return "transport_error"; +} + +function quotaHttpFailure(status: number): QuotaFailureCode { + if (status >= 300 && status < 400) return "redirect_blocked"; + if (status === 401 || status === 403) return "access_denied"; + if (status === 429) return "rate_limited"; + return "upstream_error"; +} + +function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult { + return { kind: "unavailable", failure, legacy: { kind: "null" } }; +} + +/** Final attempt determines the safe diagnosis; a successful fallback clears the first failure. */ +async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { + headers: { + Accept: "application/json", "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, antigravityOutboundDependencies); try { - const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - if (await providerRedirectError(summaryResponse, summaryUrl)) return null; - if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; - if (summaryResponse.ok) { - const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); - if (quota) return quota; + const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked"); + if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied"); + if (response.ok) { + const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response))); + if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" }; } } catch { - // Fallback to fetchAvailableModels on error + // Existing behavior: summary transport/parse failure may recover through the models probe. } + try { + const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) return unavailableAntigravityQuota("redirect_blocked"); + if (!response.ok) return unavailableAntigravityQuota(quotaHttpFailure(response.status)); + const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); + if (!customWindows.length) return unavailableAntigravityQuota("response_unusable"); + return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" }; + } catch (error) { + // The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO. + return { kind: "unavailable", failure: quotaTransportFailure(error), legacy: { kind: "throw", error } }; + } +} - 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", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: 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; - return { customWindows, updatedAt: Date.now() }; +export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const result = await probeAntigravityUsageQuota(accessToken, projectId); + if (result.kind === "available") return result.quota; + if (result.legacy.kind === "throw") throw result.legacy.error; + return null; } async function fetchAntigravityQuota(provider: string): Promise { const credential = getCredential("google-antigravity"); if (!credential?.projectId) return null; let accessToken: string; - try { - accessToken = await getValidAccessToken("google-antigravity"); - } catch { - return null; - } - - // 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 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: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: credential.projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - if (await providerRedirectError(summaryResponse, summaryUrl)) return null; - if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; - if (summaryResponse.ok) { - const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); - if (quota) { - return report(provider, "google-antigravity:retrieveUserQuotaSummary", quota); - } - } - } catch { - // Fallback on network/fetch error - } - - 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", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - 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; - return report(provider, "google-antigravity:fetchAvailableModels", { - customWindows, - updatedAt: Date.now(), - }); + try { accessToken = await getValidAccessToken("google-antigravity"); } catch { return null; } + const result = await probeAntigravityUsageQuota(accessToken, credential.projectId); + if (result.kind === "available") return report(provider, result.source, result.quota); + if (result.legacy.kind === "throw") throw result.legacy.error; + return null; } type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 89f80e5f88..5879a0d98a 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -325,7 +325,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return { ...account, quota: row.quota, - ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true } : {}), + ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true, + ...(row.unavailable && row.quotaFailure && row.quotaFailureIsCurrent?.() === true ? { quotaFailure: row.quotaFailure } : {}), + } : {}), }; }), }); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..31c670bee4 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..4c2bd8c390 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -278,3 +278,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2914823958..f65deed768 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -91,3 +91,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/config.md b/structure/config.md index 80bb62bc73..dac64baf0a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -205,3 +205,5 @@ The Cline client keeps connection settings and models in a separate native file `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 25646c7de4..1e5eb4dd8b 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -79,3 +79,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..69cadb7174 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,5 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 690b3b1287..d0e424a69a 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -41,3 +41,5 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline uses the existing file-integration page, tabs, status badge and rollback dialogs. Its localized semantics identify both files and the required stop/restart boundary before users mutate them. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 73090d646e..17ccf9956d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -543,3 +543,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 827540194a..0e912984af 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -314,3 +314,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 39dc9a82da..91b770a34e 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -142,3 +142,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..99d2f461fa 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -108,3 +108,5 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..46c9ecbc1b 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..40ff88b129 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -225,3 +225,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..ece7f4c748 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -214,3 +214,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..1cc5f0a465 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -46,7 +46,7 @@ Two fake-IP DNS accommodations exist, both for resolved answers only (a literal still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is -admitted only when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, +admitted for fixed canonical destinations under the transparent TUN exception, or when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option rather than environment inference. Both gates live in the outbound wrapper, not in classification: @@ -70,3 +70,10 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + + +## Account quota failure diagnostics + +Antigravity account quota probes expose only a closed `quotaFailure` category when the read is unavailable. Typed transport failures, rejected destinations, redirects, denied access, rate limits and unusable bodies are distinguished; successful fallback clears the earlier failure. The last attempted endpoint determines the diagnosis. A 401/403 category does not change account health, entitlement or routing eligibility. + +`src/providers/quota.ts` binds diagnoses to the probed credential/project and rechecks before cache reads and API projection. Reauthentication invalidates an old diagnosis independently of last-good quota bars. Private digests, callbacks and upstream error values are not serialized. The CLI and current/all-account dashboard views consume the same closed code; unknown codes and local management-read failures retain generic unavailable text. Codes are transient, never persisted quota evidence. Authenticated TUN field acceptance remains separate from deterministic transport coverage. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..cfbda9aa2b 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..bafcba4335 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..cf044c221d 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,19 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("OAuth quota diagnostics use a closed code in human and JSON output", async () => { + oauthAccounts = [{ id: "acct_1", quotaUnavailable: true, quotaFailure: "dns_failed" }]; + const human = await run(["list", "anthropic", "--quota"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("unavailable (dns_failed)"); + const machine = await run(["list", "anthropic", "--quota", "--json"]); + expect(JSON.parse(machine.stdout).accounts[0].quotaFailure).toBe("dns_failed"); + oauthAccounts = [{ id: "acct_1", quotaUnavailable: true, quotaFailure: RAW_SENTINEL }]; + const unknown = await run(["list", "anthropic", "--quota", "--json"]); + expect(unknown.stdout).not.toContain(RAW_SENTINEL); + expect(JSON.parse(unknown.stdout).accounts[0]).not.toHaveProperty("quotaFailure"); + }); + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index 09a46b95a6..f152d9fc1f 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -829,3 +829,45 @@ describe("multiauth accounts API", () => { } }); }); + + +describe("Antigravity quota diagnosis projection", () => { + let savedProxyEnv: Record; + const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + beforeEach(() => { + savedProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + for (const key of proxyKeys) delete process.env[key]; + }); + afterEach(() => { + for (const key of proxyKeys) { + if (savedProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedProxyEnv[key]; + } + }); + test("authenticated account reads expose only the current safe failure category", async () => { + const { saveCredential } = await import("../../src/oauth/store"); + const { clearAccountQuotaCache, setAntigravityAccountQuotaTransportForTests } = await import("../../src/providers/quota"); + const cfg = baseConfig(); + cfg.providers["google-antigravity"] = { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; + saveConfig(cfg); + await saveCredential("google-antigravity", { access: "private-diagnostic-access", refresh: "private-diagnostic-refresh", expires: Date.now() + 3600_000, projectId: "private-diagnostic-project", accountId: "diag-account" }); + clearAccountQuotaCache(); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => new Response(null, { status: 403 }), + }); + const server = startServer(0); + try { + const response = await fetch(new URL("/api/oauth/accounts?provider=google-antigravity"a=1&refresh=1", server.url)); + expect(response.status).toBe(200); + const body = await response.json() as { accounts: Array<{ quotaFailure?: string; quotaUnavailable?: boolean }> }; + expect(body.accounts[0]).toMatchObject({ quotaFailure: "access_denied", quotaUnavailable: true }); + const text = JSON.stringify(body); + for (const secret of ["private-diagnostic-access", "private-diagnostic-refresh", "private-diagnostic-project", "quotaFailureIsCurrent"]) expect(text).not.toContain(secret); + } finally { + await server.stop(true); + clearAccountQuotaCache(); + setAntigravityAccountQuotaTransportForTests(null); + } + }); +}); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index e8e05de9d6..bf4855caa5 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -1,3 +1,5 @@ +import { PinnedHttpError } from "../../src/lib/pinned-http"; +import { DestinationDnsResolutionError } from "../../src/lib/destination-policy"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; @@ -810,6 +812,80 @@ describe("google-antigravity per-account quota (#1082)", () => { }); } + test.each([ + [429, "rate_limited"], [503, "upstream_error"], [200, "response_unusable"], + ] as const)("final models HTTP %s has a safe diagnosis", async (status, failure) => { + await saveCredential("google-antigravity", { access: "diagnostic-access", refresh: "diagnostic-refresh", expires: Date.now() + 3600_000, projectId: "private-project", accountId: "agy-a", email: "a@example.com" }); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => new Response("{}", { status: url === summaryUrl ? 500 : status }), + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + expect(rows[0]).toMatchObject({ unavailable: true, quotaFailure: failure }); + const serialized = JSON.stringify(rows); + for (const secret of ["diagnostic-access", "diagnostic-refresh", "private-project", "quotaFailureIsCurrent", "identity"]) expect(serialized).not.toContain(secret); + }); + + test.each([ + [new PinnedHttpError("connect_timeout", "private-error"), "timeout"], + [new PinnedHttpError("output_byte_limit", "private-error"), "response_unusable"], + [new DOMException("private-error", "TimeoutError"), "timeout"], + [new Error("private-error"), "transport_error"], + ] as const)("typed fallback failure maps to %s", async (error, failure) => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => { throw error; }, + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + expect(rows[0]?.quotaFailure).toBe(failure); + expect(JSON.stringify(rows)).not.toContain("private-error"); + const { fetchAntigravityUsageQuota } = await import("../../src/providers/quota"); + await expect(fetchAntigravityUsageQuota("agy-first", "proj-first")).rejects.toBe(error); + }); + + test("DNS diagnosis and recovery preserve last-good bars and clear the error", 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 goodTransport = { + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url: string) => url === summaryUrl ? new Response(null, { status: 500 }) : new Response(antigravityBody(0.5, 0.4)), + }; + setAntigravityAccountQuotaTransportForTests(goodTransport); + const good = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(good.quota).not.toBeNull(); + setAntigravityAccountQuotaTransportForTests({ resolveAddresses: async () => { throw new DestinationDnsResolutionError("private-host"); } }); + const failed = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(failed).toMatchObject({ unavailable: true, quotaFailure: "dns_failed", quota: good.quota }); + setAntigravityAccountQuotaTransportForTests(goodTransport); + const recovered = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(recovered).not.toHaveProperty("quotaFailure"); + expect(recovered).not.toHaveProperty("unavailable"); + expect(recovered.quota).not.toBeNull(); + }); + + test("same-id replacement retires pending and cached failure diagnoses", async () => { + const credential = { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }; + await saveCredential("google-antigravity", credential); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => { entered.resolve(); await release.promise; return new Response(null, { status: 403 }); }, + }); + const pending = fetchProviderAccountQuotas("google-antigravity", true); + await entered.promise; + const id = idFor("a@example.com"); + try { await saveCredential("google-antigravity", { ...credential, access: "agy-second" }); } + finally { release.resolve(); } + expect(idFor("a@example.com")).toBe(id); + expect((await pending)[0]).not.toHaveProperty("quotaFailure"); + const cached = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(cached.quotaFailure).toBe("access_denied"); + await saveCredential("google-antigravity", { ...credential, access: "agy-third" }); + expect(cached.quotaFailureIsCurrent?.()).toBe(false); + expect((await fetchProviderAccountQuotas("google-antigravity"))[0]).not.toHaveProperty("quotaFailure"); + }); + 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"; @@ -826,7 +902,7 @@ describe("google-antigravity per-account quota (#1082)", () => { }, 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(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: "destination_blocked" }]); expect(admitted).toEqual([false, false]); expect(posted).toBe(0); expect(plainFetchCalls).toBe(0); @@ -847,7 +923,7 @@ describe("google-antigravity per-account quota (#1082)", () => { 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(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: status < 400 ? "redirect_blocked" : "access_denied" }]); expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : [summaryUrl]); expect(plainFetchCalls).toBe(0); }); @@ -864,7 +940,7 @@ describe("google-antigravity per-account quota (#1082)", () => { }); const rows = await fetchProviderAccountQuotas("google-antigravity"); expect(posted).toBe(0); - expect(rows).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(rows).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: "destination_blocked" }]); }); test("a redirecting upstream yields unavailable and the credential-less account is skipped without a request", async () => { From 208512f9c984549da44c2ffa12bcb3def1008995 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:18:47 +0900 Subject: [PATCH 2/2] test(accounts): cover quota diagnostic merge and recovery paths --- .../_plan/260912_accounts/071_tun_delivery.md | 2 + .../provider-account-quota-loading.test.tsx | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/devlog/_plan/260912_accounts/071_tun_delivery.md b/devlog/_plan/260912_accounts/071_tun_delivery.md index 45f86ff584..cc5de963c4 100644 --- a/devlog/_plan/260912_accounts/071_tun_delivery.md +++ b/devlog/_plan/260912_accounts/071_tun_delivery.md @@ -5,3 +5,5 @@ The existing canonical transport stays intact. A private probe result preserves Regression sources exercise status/typed transport categories, fallback recovery, old bars, same-id replacement during and after a probe, private-value serialization, API projection, GUI normalization/recovery and CLI code filtering. Local suites/build/typecheck/install: NOT RUN. Text whitespace checks only. Backend source reviewer Rawls found no material scoped defect; full consumer/security review and final hosted CI/render proof remain pending. Authenticated TUN field acceptance remains open for #3781; no live credential or network configuration was changed. Resume binding verified: session01a093dc-e20d-79a1-8d46-231ced9ee05b, currentaccounts-tun branch489af939 baseline, actual FSMB. Host goal is blocked and has not been edited or described as active. Existing successful callback CI34673984380/fa4226a9ba is retained and not rerun. Other original PRs have changed heads and require current evidence; no merged PR is recreated. + +Full source review found a diagnostic-specific merge regression gap. Added initial-roster-failure clearing, roster-only same-mode retention/mode-change clearing, and delayed failure/recovery after newer selection with surviving membership. No product behavior changed in this correction. Local suites NOT RUN. diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx index 3f60bafb4a..d13fc9040a 100644 --- a/gui/tests/provider-account-quota-loading.test.tsx +++ b/gui/tests/provider-account-quota-loading.test.tsx @@ -445,3 +445,65 @@ test("quota diagnostics normalize and clear on recovery or management failure", expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: false, quota: reading }); expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); }); + +test("initial roster failure clears a settled diagnosis while retaining bars", async () => { + await act(async () => { + pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [ + { id: "a", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true, quotaFailure: "dns_failed" }, + ] } }); + }); + respond = async () => new Response(null, { status: 503 }); + await act(async () => { expect(await pools.fetchAccountSets(["fixture"], true)).toBe(false); }); + expect(pools.accountSets.fixture.accounts[0]).toMatchObject({ quota: reading, quotaUnavailable: true }); + expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBeUndefined(); +}); + +test("roster-only refresh retains a matching diagnosis and clears it after mode change", async () => { + await act(async () => { + pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [ + { id: "a", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true, quotaFailure: "rate_limited" }, + ] } }); + }); + let mode = "probe"; + respond = async () => Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, quotaMode: mode }] }); + await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBe("rate_limited"); + mode = "passive"; + await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBeUndefined(); + expect(requests.every(request => !request.url.includes("quota=1"))).toBe(true); +}); + +test.each([true, false])("late quota failure=%s preserves newer selection and matching membership", async failure => { + const response = deferred(); + const started = deferred(); + const original = ["a", "b", "removed"].map(id => ({ id, active: id === "a", quotaMode: "probe" })); + respond = async url => { + if (url.includes("quota=1")) { started.resolve(); return response.promise; } + return Response.json({ activeAccountId: "a", accounts: original }); + }; + let full!: Promise; + await act(async () => { full = pools.fetchAccountSets(["fixture"], true); await started.promise; }); + const latest = original.filter(row => row.id !== "removed").map(row => ({ ...row, active: row.id === "b" })); + respond = async () => Response.json({ activeAccountId: "b", accounts: latest }); + await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + if (!failure) { + await act(async () => { + pools.setAccountSets(current => ({ ...current, fixture: { ...current.fixture, accounts: current.fixture.accounts.map(row => ({ + ...row, quotaUnavailable: true, quotaFailure: "dns_failed", + })) } })); + }); + } + await act(async () => { + response.resolve(Response.json({ activeAccountId: "a", accounts: original.map(row => ({ + ...row, quota: reading, quotaUnavailable: failure, ...(failure ? { quotaFailure: "access_denied" } : {}), + })) })); + await full; + }); + expect(pools.accountSets.fixture.accounts.map(row => row.id)).toEqual(["a", "b"]); + expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b"); + for (const row of pools.accountSets.fixture.accounts) { + expect(row.quotaFailure).toBe(failure ? "access_denied" : undefined); + expect(row.quotaUnavailable).toBe(failure); + } +});