diff --git a/.superpowers/sdd/branch-review.md b/.superpowers/sdd/branch-review.md new file mode 100644 index 0000000000..4fbefb43e6 --- /dev/null +++ b/.superpowers/sdd/branch-review.md @@ -0,0 +1,128 @@ +# Antigravity Hardening Whole-Branch Review + +Base: `git merge-base origin/dev HEAD` +Head: `9757b66f9c8d76b94a21da0218d1c2b4bca31450` +Branch: `feat/antigravity-hardening` + +## Strengths + +- The branch ports the planned quota, geoblock, process-local cooldown, Claude + CCA wire, always-SSE, and daily/production failover behavior in focused + modules rather than adding another provider path. +- CCA unary requests now share the streaming parser, and the focused + Antigravity/Google tests cover the normal unary, host-failover, tool-pair, + geoblock, quota, and cooldown paths. +- The implementation preserves PKCE, does not add `src/lab` imports to the + protected core files, and keeps request bodies, tokens, and account data out + of diagnostics. `bun run privacy:scan` passed. +- `git diff --check` passed, and the focused Antigravity run passed: 160 tests, + 0 failures across 8 files. Typechecking also passed in the task validation + runs. + +## Issues + +### Critical + +None found. + +### Important + +1. **The CCA probe byte cap can be exceeded by one upstream read** + - File: `src/adapters/google-http.ts:90-107, 170-207` + - Issue: `CcaProbeBuffer.append()` grows its backing array to `required` + even when `required` is greater than `CCA_STREAM_PROBE_MAX_BYTES`. The + read loop only checks the limit before the next read, so a single large + `ReadableStream` chunk can allocate and retain more than the advertised + 100 MiB cap; line 207 then returns that oversized buffer to the parser. + - Impact: The new protection against oversized CCA streams is not a hard + memory bound. A large upstream chunk can impose an avoidable process-wide + memory spike before the parser's later frame checks run. + - Fix: Make the probe stop buffering at the cap and pass the current chunk + through without copying it into the probe, or otherwise use a bounded + prefix plus a stream that preserves the unread bytes. Add a regression + test where one read crosses the cap. + +2. **Standalone Antigravity image failover can duplicate a paid POST** + - File: `src/server/images.ts:230-249` + - Issue: A failed `fetch()` to the first host is followed by a second + `POST /v1internal:generateContent` to the peer. A transport failure is + ambiguous: the first host may have accepted and processed the generation + before the response was lost. + - Impact: The request can generate twice or incur duplicate provider-side + work/charges. This also contradicts the repository invariant in + `structure/04_transports-and-sidecars.md:153-159`, which says each paid + standalone Images POST receives one upstream attempt. + - Fix: Do not retry image-generation POSTs after an unknown transport + outcome unless the upstream provides a verified idempotency key. If host + candidates are retained for images, restrict fallback to a response that + is known to precede request acceptance and document that exception in the + structure note. + +3. **Inline SSE quota/rate-limit errors bypass cooldown and account rotation** + - Files: `src/adapters/google-http.ts:66-76`, + `src/adapters/google.ts:619-632`, `src/server/responses/core.ts:3890-3932` + - Issue: The always-SSE path can receive an HTTP-200 stream whose first data + frame is `{ error: { code: 429, status: "RESOURCE_EXHAUSTED", ... } }`. + The probe treats this as terminal and the Google parser emits an error, + but cooldown recording is only performed for HTTP 429/403 responses and + the account carousel only enters on `upstreamResponse.status === 429`. + - Impact: An account that is quota-exhausted or rate-limited in an inline + SSE error is immediately selected again, defeating the new process-local + cooldown and failover behavior. + - Fix: Preserve the classified inline error status/reason through the + adapter response path, record the same cooldown for inline 429/geo + errors, and feed inline pre-stream 429 errors into the existing bounded + account carousel. Keep geoblock non-rotating as required by the plan. + Add an HTTP-200 SSE error regression test. + +### Minor + +No remaining Minor finding beyond the documentation-table triage item below. + +## Documentation-table triage + +The leftover Task 1 docs-table Minor is **not still real**. The English +`guides/providers.md` table and the fr/ja/ko/ru/tr/zh-cn/zh-tw mirrors have +matching header/separator structure and the updated +`google-antigravity` rows contain the expected number of cells. No malformed +pipe-delimited row or locale contradiction was found. The known Astro build +extraction issue is therefore not evidence of a markdown defect. + +## Validation + +- Focused Antigravity/Google validation: **160 passed, 0 failed**. +- `bun run typecheck`: passed in task validation. +- `bun run privacy:scan`: passed. +- `git diff --check`: passed. +- A full `bun run test` was also attempted, but the repository-wide run + returned nonzero because of unrelated environment/baseline failures, + including missing GUI React runtime packages, macOS `/bin/ps` permission + failures, and unrelated auth/Lab regression tests. No Antigravity-focused + failure appeared in that run. + +## Assessment + +**Ready to merge? No — needs changes.** + +The planned feature set is substantially present and the focused tests are +strong, but the probe cap is not actually hard, image failover can duplicate a +paid operation, and inline SSE quota errors bypass the cooldown carousel. +Resolve those Important findings and rerun the focused suite plus the +repository gates before merging. + +## Fix pass + +- Finding 1 resolved: `CcaProbeBuffer` now refuses writes beyond the 100 MiB + cap, and the probe forwards an oversized read's unread bytes without copying + them into the probe buffer or failing over. +- Finding 2 resolved: standalone CCA image generation now performs exactly one + upstream POST. Ambiguous transport, 404, and 503 outcomes are surfaced rather + than replayed on the peer host; the one-attempt invariant is documented in + `structure/04_transports-and-sidecars.md`. +- Finding 3 resolved: inline CCA quota and geoblock SSE frames are converted to + cooldown-aware synthetic 429/403 responses. Quota enters the existing bounded + Antigravity account carousel; geoblock remains non-rotating. + +Fix-pass validation: the requested Antigravity, quota, routing, wire, hardening, +and image tests passed (**175 passed, 0 failed**), and `bun run typecheck` +passed. diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md new file mode 100644 index 0000000000..f431ae78de --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -0,0 +1,4 @@ + +# SDD progress — Antigravity hardening + +- Task 1/2/3: implemented (commits e558a771d3f4..986e5af9aa49, review pending) diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 0000000000..8ad940ea4f --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,29 @@ +## Status + +Implemented Task 4 CCA request fidelity. + +## Files changed + +- `src/adapters/google-antigravity-tools.ts` +- `src/adapters/google.ts` +- `tests/google-antigravity-wire.test.ts` +- `tests/google-adapter.test.ts` +- `.superpowers/sdd/task-4-report.md` + +## Test + +`bun test tests/google-antigravity-wire.test.ts tests/google-adapter.test.ts tests/google-empty-content.test.ts` — 88 passed, 0 failed. + +`bun run typecheck` — passed. + +## Behavior + +- Claude CCA sends the interleaved-thinking beta header. +- CCA requests include the system-instruction replacement preamble. +- Claude trailing model prefills are stripped while lone model turns remain. +- Orphan tool results and assistant calls without later results are removed before allocator prepass; valid parallel pairs remain intact. + +## Concerns + +- The full repository test suite was not rerun; validation used the requested focused adapter tests and strict typecheck. +- Task 5 transport behavior remains intentionally untouched. diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 0000000000..41fd2105a0 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,70 @@ +# Task 5 report — Transport + +## Status + +Implemented always-SSE Cloud Code Assist requests, daily/production host +failover, quota/image host candidate reuse, and Antigravity account cooldown +wire-up. The existing AI Studio and Vertex unary paths remain on +`generateContent`. + +## Validation + +- `bun test tests/google-antigravity-wire.test.ts tests/google-hardening.test.ts tests/antigravity-routing.test.ts tests/antigravity-quota.test.ts` + - 104 passed, 0 failed +- `bun run typecheck` + - passed +- `git diff --check` + - passed + +## Coverage + +- Unary CCA parsing buffers the SSE event contract. +- Empty CCA streams and first-host transport/404/unavailable failures try the + single maintained peer; authentication, geoblock, invalid request, and + exhausted quota do not host-fail over. +- 429 responses classify rate limits versus exhausted quota and record + account-keyed process-local cooldowns. +- Geoblock records cooldown without starting an account carousel. +- Provider documentation tables remain structurally valid after the quota and + transport notes were folded into the Antigravity rows. + +## Concerns + +CCA response inspection clones and reads up to 256 KiB before returning a +successful response so an empty stream can fail over deterministically. This +preserves the response body for the adapter, but can delay the first client +event until the bounded inspection completes. + +## Commit + +`3b48b9802` — `feat(antigravity): always-SSE unary, host failover, and account cooldowns` + +## Review fixes + +- P1 streaming: replaced clone-to-EOF inspection with a bounded first-meaningful-event probe. CCA responses return as soon as a candidate or terminal frame arrives, while the consumed bytes remain attached to the response body; empty streams still fail over at EOF. +- P1 oversized SSE: valid responses larger than the old 256 KiB inspection cap are no longer classified as empty or replayed. +- P2 inline `UNAVAILABLE`: a 200 SSE error frame with `UNAVAILABLE` (or code 503) now uses the single daily/production peer fallback. Terminal authentication, geoblock, invalid-request, and quota errors remain non-failover cases. + +## Review-fix validation + +- `bun test tests/google-antigravity-wire.test.ts tests/google-hardening.test.ts tests/antigravity-routing.test.ts tests/antigravity-quota.test.ts` + - 107 passed, 0 failed +- `bun run typecheck` + - passed +- `git diff --check` + - passed + +## Re-review fixes + +- EOF-residual CCA terminal frames now stay on the original host; only empty + residuals and retryable `UNAVAILABLE` residuals invoke peer failover. +- Peer fallback now re-enters the shared Google retry, quota classification, + compatibility replay, and final error-normalization path with host failover + disabled for the peer leg. + +## Re-review validation + +- `bun test tests/google-antigravity-wire.test.ts tests/google-hardening.test.ts tests/antigravity-routing.test.ts tests/antigravity-quota.test.ts` + - 109 passed, 0 failed +- `bun run typecheck` + - passed diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 59381edd60..c5a95b2d01 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -120,7 +120,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Modèles de programmation Kimi K2.7/K2.6/K2.5. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. Le quota est sondé via `retrieveUserQuota` et `retrieveUserQuotaSummary` (délai de 8 secondes). CCA utilise toujours SSE, met en mémoire tampon SSE pour les appels unitaires et réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte ; les délais 429 sont locaux au processus et propres au compte. | | `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. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7af39e32b3..b6b54ca816 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA always uses SSE, buffers SSE for unary callers, and retries the daily/production peer on first-host transport, 404, or unavailable failures; 429 cooldowns are process-local per account. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, 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. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 81a19e5e5b..4606b35c74 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -110,7 +110,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し、429 cooldown はアカウント単位のプロセス内状態です。 | | `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 ではありません。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f57dab7cc..bc9aa3b7c0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -109,7 +109,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. 429 cooldown은 계정별 프로세스 로컬 상태입니다. | | `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가 아닙니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1966d9db63..16e53ca009 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -119,7 +119,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. Cooldown для 429 хранится локально в процессе и привязан к аккаунту. | | `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. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index ee153a0780..040d631795 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -134,7 +134,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 kodlama modelleri. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. Kota `retrieveUserQuota` ve `retrieveUserQuotaSummary` RPC'leriyle (8 saniyelik zaman aşımı) sorgulanır. CCA her zaman SSE kullanır ve tekli çağrılar için SSE'yi arabelleğe alır; ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer denenir. 429 cooldown hesabı temel alan işlem-yerel durumdur. | | `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. | 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 b65ab443f3..fb3700663f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -100,7 +100,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。429 cooldown 按账户保存在进程内。 | | `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。 | 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 d0eb11fcb3..2302c556ab 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -108,7 +108,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding 模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。429 cooldown 為按帳號的 process-local 狀態。 | | `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。 | diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 8789a03463..7bfbc79f67 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -76,6 +76,8 @@ export interface AdapterRequest { export interface AdapterFetchContext { /** Remains attached to the returned response body after the response headers arrive. */ abortSignal?: AbortSignal; + /** OAuth account identity used for provider-local cooldown bookkeeping. */ + accountId?: string; /** Deadline for receiving response headers on each attempt, not for consuming the response body. */ timeoutMs?: number; /** Return final non-2xx responses untouched so the caller can own the error-body read. */ diff --git a/src/adapters/google-antigravity-hosts.ts b/src/adapters/google-antigravity-hosts.ts new file mode 100644 index 0000000000..eeee8d7032 --- /dev/null +++ b/src/adapters/google-antigravity-hosts.ts @@ -0,0 +1,15 @@ +const DAILY_ANTIGRAVITY_HOST = "https://daily-cloudcode-pa.googleapis.com"; +const PROD_ANTIGRAVITY_HOST = "https://cloudcode-pa.googleapis.com"; + +/** + * Return the configured Antigravity endpoint followed by its daily/production peer. + * The configured value is preserved so tests and future pinned environments keep their + * explicit first choice; the fallback is always one of Google's two known hosts. + */ +export function antigravityHostCandidates(configuredBase: string): string[] { + const configured = configuredBase.replace(/\/+$/, ""); + const other = configured === DAILY_ANTIGRAVITY_HOST + ? PROD_ANTIGRAVITY_HOST + : DAILY_ANTIGRAVITY_HOST; + return [...new Set([configured, other])]; +} diff --git a/src/adapters/google-antigravity-tools.ts b/src/adapters/google-antigravity-tools.ts new file mode 100644 index 0000000000..08998da332 --- /dev/null +++ b/src/adapters/google-antigravity-tools.ts @@ -0,0 +1,87 @@ +import type { + OcxAssistantMessage, + OcxMessage, + OcxToolCall, + OcxToolResultMessage, +} from "../types"; + +function isAssistantToolCall(message: OcxMessage): message is OcxAssistantMessage { + return message.role === "assistant"; +} + +function isToolResult(message: OcxMessage): message is OcxToolResultMessage { + return message.role === "toolResult"; +} + +/** + * Repair incomplete tool exchanges before assigning provider-visible ids. + * + * CCA translates Gemini function calls and responses into Anthropic tool blocks, + * which requires both sides of every exchange. A result is valid only when its + * call appeared earlier in the history, and a call is valid only when a result + * appears later. Filtering the history first also prevents orphan results from + * reserving ids in the request-scoped allocator. + */ +export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessage[] { + const matchedCallIds = new Set(); + const callIdsBeforeResult = new Set(); + + for (let index = 0; index < messages.length; index++) { + const message = messages[index]!; + if (isAssistantToolCall(message)) { + for (const part of message.content) { + if (part.type !== "toolCall") continue; + const toolCall = part as OcxToolCall; + for (let later = index + 1; later < messages.length; later++) { + const candidate = messages[later]!; + if (isToolResult(candidate) && candidate.toolCallId === toolCall.id) { + matchedCallIds.add(toolCall.id); + break; + } + } + } + } else if (isToolResult(message)) { + for (let earlier = index - 1; earlier >= 0; earlier--) { + const candidate = messages[earlier]!; + if (!isAssistantToolCall(candidate)) continue; + if (candidate.content.some(part => part.type === "toolCall" && (part as OcxToolCall).id === message.toolCallId)) { + callIdsBeforeResult.add(message.toolCallId); + break; + } + } + } + } + + const repaired: OcxMessage[] = []; + for (const message of messages) { + if (isToolResult(message)) { + if (callIdsBeforeResult.has(message.toolCallId)) repaired.push(message); + continue; + } + if (!isAssistantToolCall(message)) { + repaired.push(message); + continue; + } + + const content = message.content.filter(part => + part.type !== "toolCall" || matchedCallIds.has((part as OcxToolCall).id)); + if (content.length > 0) { + repaired.push(content.length === message.content.length ? message : { ...message, content }); + } + } + return repaired; +} + +/** + * Claude interprets a final model turn as a prefilled assistant response. + * CCA expects the next turn to be generated instead, except when that model + * turn is the entire conversation and must remain as the initial context. + */ +export function stripTrailingClaudePrefill(contents: unknown[]): unknown[] { + while (contents.length >= 2) { + const last = contents[contents.length - 1]; + if (typeof last !== "object" || last === null || (last as { role?: unknown }).role !== "model") break; + contents.pop(); + } + return contents; +} diff --git a/src/adapters/google-errors.ts b/src/adapters/google-errors.ts index 69e6d0cef5..371d2b70b9 100644 --- a/src/adapters/google-errors.ts +++ b/src/adapters/google-errors.ts @@ -15,6 +15,12 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st }; } +const ANTIGRAVITY_GEO_BLOCKED_MARKER = "user location is not supported for the api use"; + +export function isAntigravityGeoBlockedBody(payloadText: string): boolean { + return payloadText.toLowerCase().includes(ANTIGRAVITY_GEO_BLOCKED_MARKER); +} + function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string { const lower = `${enumStatus ?? ""} ${text}`.toLowerCase(); const quotaExhausted = @@ -29,6 +35,7 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s if (status === 401 || enumStatus === "UNAUTHENTICATED" || lower.includes("unauthenticated") || lower.includes("invalid authentication") || lower.includes("expired")) { return `${label} authentication failed`; } + if (isAntigravityGeoBlockedBody(lower)) return `${label} location not supported`; if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission denied") || lower.includes("access denied")) { return `${label} access denied`; } diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index de849cde3c..badb964fe0 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -1,7 +1,14 @@ import type { AdapterFetchContext, AdapterRequest } from "./base"; -import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors"; +import { + isAntigravityGeoBlockedBody, + isQuotaExhaustedBody, + retryableGoogleStatus, + safeGoogleHttpErrorMessage, +} from "./google-errors"; import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; +import { antigravityHostCandidates } from "./google-antigravity-hosts"; +import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { abortError, cancelResponseBodyBestEffort, @@ -13,6 +20,263 @@ import { const GOOGLE_RETRY_ATTEMPTS = 3; const GOOGLE_RETRY_BASE_MS = 250; const GOOGLE_RETRY_MAX_MS = 2_000; +export const CCA_STREAM_PROBE_MAX_BYTES = 100 * 1024 * 1024; +export const CCA_STREAM_CLASSIFY_MAX_BYTES = 256 * 1024; + +function isAntigravitySseRequest(request: AdapterRequest): boolean { + return request.url.includes("/v1internal:streamGenerateContent?alt=sse"); +} + +function requestForHost(request: AdapterRequest, host: string): AdapterRequest { + const current = new URL(request.url); + const replacement = new URL(host); + current.protocol = replacement.protocol; + current.host = replacement.host; + return { ...request, url: current.toString() }; +} + +function retryAfterMs(value: string | null, now = Date.now()): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds * 1000) : undefined; + } + const timestamp = Date.parse(text); + return Number.isFinite(timestamp) && timestamp > now ? timestamp - now : undefined; +} + +type CcaSseProbe = "empty" | "candidate" | "unavailable" | "quota_exhausted" | "geo_blocked" | "terminal"; + +function probeCcaSseEvent(bytes: Uint8Array): CcaSseProbe { + const text = new TextDecoder().decode(bytes); + let sawData = false; + for (const line of text.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + sawData = true; + const payload = line.slice(5).trim(); + if (!payload) continue; + let frame: unknown; + try { + frame = JSON.parse(payload); + } catch { + return "terminal"; + } + if (!frame || typeof frame !== "object" || Array.isArray(frame)) return "terminal"; + const record = frame as Record; + if (record.error) { + const error = record.error; + const errorRecord = error && typeof error === "object" && !Array.isArray(error) + ? error as Record + : {}; + const status = String(errorRecord.status ?? "").toUpperCase(); + const code = errorRecord.code; + if (status === "UNAVAILABLE" || status === "503" || code === 503 || code === "503") { + return "unavailable"; + } + const serialized = JSON.stringify(frame); + if (isQuotaExhaustedBody(serialized)) return "quota_exhausted"; + if (isAntigravityGeoBlockedBody(serialized)) return "geo_blocked"; + return "terminal"; + } + const response = record.response; + if (!response || typeof response !== "object" || Array.isArray(response)) return "terminal"; + const root = response as Record; + if (Array.isArray(root.candidates) && root.candidates.length > 0) return "candidate"; + } + return sawData ? "empty" : "empty"; +} + +export class CcaProbeBuffer { + private storage: Uint8Array; + length = 0; + + constructor(private readonly maxBytes = CCA_STREAM_PROBE_MAX_BYTES) { + this.storage = new Uint8Array(Math.min(64 * 1024, maxBytes)); + } + + append(next: Uint8Array): boolean { + const required = this.length + next.byteLength; + if (required > this.maxBytes) return false; + if (required > this.storage.byteLength) { + let capacity = Math.max(this.storage.byteLength, 1); + while (capacity < required) { + const grownCapacity = Math.min(this.maxBytes, capacity * 2); + capacity = grownCapacity <= capacity ? this.maxBytes : grownCapacity; + } + const grown = new Uint8Array(capacity); + grown.set(this.storage.subarray(0, this.length)); + this.storage = grown; + } + this.storage.set(next, this.length); + this.length = required; + return true; + } + + view(): Uint8Array { + return this.storage.subarray(0, this.length); + } +} + +function firstSseEventEnd(bytes: Uint8Array, from: number): number | undefined { + for (let index = from; index + 1 < bytes.byteLength; index++) { + if (bytes[index] === 10 && bytes[index + 1] === 10) return index + 2; + if (index + 3 < bytes.byteLength + && bytes[index] === 13 && bytes[index + 1] === 10 + && bytes[index + 2] === 13 && bytes[index + 3] === 10) { + return index + 4; + } + } + return undefined; +} + +function responseWithBufferedBody( + response: Response, + buffered: Uint8Array, + reader: ReadableStreamDefaultReader, + pending?: Uint8Array, + status = response.status, +): Response { + const body = new ReadableStream({ + start(controller) { + void (async () => { + try { + if (buffered.byteLength > 0) controller.enqueue(buffered); + if (pending?.byteLength) controller.enqueue(pending); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value?.byteLength) controller.enqueue(value); + } + controller.close(); + } catch (error) { + controller.error(error); + } finally { + reader.releaseLock(); + } + })(); + }, + cancel(reason) { + void reader.cancel(reason).catch(() => {}); + }, + }); + return new Response(body, { + status, + statusText: response.statusText, + headers: response.headers, + }); +} + +async function prepareCcaSseResponse( + response: Response, + fetchPeer: (() => Promise) | undefined, + accountId?: string, +): Promise { + if (!response.body) return fetchPeer ? fetchPeer() : response; + const reader = response.body.getReader(); + const probeBuffer = new CcaProbeBuffer(); + let scanned = 0; + const passthrough = (pending?: Uint8Array, status = response.status) => + responseWithBufferedBody(response, probeBuffer.view(), reader, pending, status); + const failoverOrPassthrough = async (): Promise => { + if (!fetchPeer) return passthrough(); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + return fetchPeer(); + }; + try { + while (probeBuffer.length < CCA_STREAM_PROBE_MAX_BYTES) { + if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES || probeBuffer.length >= CCA_STREAM_CLASSIFY_MAX_BYTES) { + return passthrough(); + } + const { done, value } = await reader.read(); + if (done) { + const buffered = probeBuffer.view(); + const residual = buffered.subarray(scanned); + if (residual.byteLength > 0) { + const probe = probeCcaSseEvent(residual); + if (probe === "candidate" || probe === "terminal") { + return passthrough(); + } + if (probe === "unavailable") { + return failoverOrPassthrough(); + } + if (probe === "quota_exhausted" || probe === "geo_blocked") { + const status = probe === "quota_exhausted" ? 429 : 403; + if (accountId) { + recordAntigravityCooldown( + accountId, + probe === "quota_exhausted" ? "quota_exhausted" : "geo_blocked", + ); + } + return passthrough(undefined, status); + } + } + return failoverOrPassthrough(); + } + if (!value?.byteLength) continue; + const available = CCA_STREAM_PROBE_MAX_BYTES - probeBuffer.length; + const overflow = value.byteLength > available ? value.subarray(available) : undefined; + probeBuffer.append(overflow ? value.subarray(0, available) : value); + const buffered = probeBuffer.view(); + while (true) { + if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES) return passthrough(overflow); + const eventEnd = firstSseEventEnd(buffered, scanned); + if (eventEnd === undefined) break; + const probe = probeCcaSseEvent(buffered.subarray(scanned, eventEnd)); + scanned = eventEnd; + if (probe === "candidate") return passthrough(overflow); + if (probe === "unavailable") { + if (overflow?.byteLength) { + return passthrough(overflow); + } + return failoverOrPassthrough(); + } + if (probe === "quota_exhausted" || probe === "geo_blocked") { + const status = probe === "quota_exhausted" ? 429 : 403; + if (accountId) { + recordAntigravityCooldown( + accountId, + probe === "quota_exhausted" ? "quota_exhausted" : "geo_blocked", + ); + } + return passthrough(overflow, status); + } + if (probe === "terminal") return passthrough(overflow); + } + if (overflow?.byteLength) return passthrough(overflow); + if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES || probeBuffer.length >= CCA_STREAM_CLASSIFY_MAX_BYTES) { + return passthrough(); + } + } + return passthrough(); + } catch (error) { + try { await reader.cancel(error); } catch { /* cleanup only */ } + reader.releaseLock(); + throw error; + } +} + +function isUnavailableResponse(response: Response): boolean { + return response.status === 503; +} + +function recordAntigravityHttpCooldown( + response: Response, + payloadText: string, + accountId: string | undefined, +): void { + if (!accountId) return; + if (response.status === 429) { + recordAntigravityCooldown( + accountId, + isQuotaExhaustedBody(payloadText) ? "quota_exhausted" : "rate_limited", + retryAfterMs(response.headers.get("retry-after")), + ); + } else if (response.status === 403 && isAntigravityGeoBlockedBody(payloadText)) { + recordAntigravityCooldown(accountId, "geo_blocked"); + } +} async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise { return normalizeUpstreamHttpErrorResponse(res, { @@ -27,8 +291,17 @@ async function normalizeFinalGoogleError(label: string, res: Response, signal?: * errors, `Retry-After` honoring, jittered exponential backoff, and a classified + redacted final * error body. `label` is the provider-facing prefix used in error messages. */ -export async function fetchGoogleWithRetry(label: string, request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise { +async function fetchGoogleWithRetryInternal( + label: string, + request: AdapterRequest, + ctx: AdapterFetchContext, + allowAntigravityHostFailover: boolean, +): Promise { const timeoutMs = ctx.timeoutMs ?? 200_000; + const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(request) + ? antigravityHostCandidates(new URL(request.url).origin) + : []; + let antigravityHostIndex = 0; let lastError: unknown; let activeRequest = request; let compatibilityReplayUsed = false; @@ -40,6 +313,30 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques headers: activeRequest.headers, body: activeRequest.body, }, timeoutMs, ctx.abortSignal, ctx.stream); + if (antigravityHosts.length > 1 && antigravityHostIndex === 0) { + const shouldTryPeer = res.status === 404 || isUnavailableResponse(res); + if (shouldTryPeer) { + cancelResponseBodyBestEffort(res); + antigravityHostIndex = 1; + activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + continue; + } + } + if (label === "Antigravity" && isAntigravitySseRequest(activeRequest) && res.ok) { + const fetchPeer = antigravityHosts.length > 1 && antigravityHostIndex === 0 + ? () => fetchGoogleWithRetryInternal( + label, + requestForHost(request, antigravityHosts[1]!), + ctx, + false, + ) + : undefined; + return prepareCcaSseResponse(res, fetchPeer, ctx.accountId); + } + if (label === "Antigravity" && (res.status === 429 || res.status === 403)) { + const body = await readDisplaySafeErrorPayloadText(res.clone(), ctx.abortSignal); + recordAntigravityHttpCooldown(res, body, ctx.accountId); + } if (res.status === 400 && !compatibilityReplayUsed) { let payloadText = ""; try { @@ -79,6 +376,11 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques } catch (err) { if (ctx.abortSignal?.aborted) throw err; lastError = err; + if (antigravityHosts.length > 1 && antigravityHostIndex === 0) { + antigravityHostIndex = 1; + activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + continue; + } if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err; await sleepWithAbort(retryBackoffDelayMs(attempt, { baseDelayMs: GOOGLE_RETRY_BASE_MS, @@ -89,6 +391,10 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques throw lastError ?? new Error(`${label} fetch failed`); } +export function fetchGoogleWithRetry(label: string, request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise { + return fetchGoogleWithRetryInternal(label, request, ctx, true); +} + /** Vertex AI retry wrapper. */ export function fetchVertexWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise { return fetchGoogleWithRetry("Vertex AI", request, ctx); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index ab27089e7d..fb99d29453 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -21,6 +21,7 @@ import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; +import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "./google-antigravity-tools"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; @@ -160,9 +161,10 @@ function messagesToGeminiFormat( const systemInstruction = { parts: [{ text: systemText }] }; const contents: unknown[] = []; + const messages = repairGoogleToolPairs(parsed.context.messages); const callIds = createToolCallIdAllocator(); - for (const msg of parsed.context.messages) { + for (const msg of messages) { if (msg.role === "assistant") { for (const part of (msg as OcxAssistantMessage).content) { if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id); @@ -171,7 +173,7 @@ function messagesToGeminiFormat( callIds.reserve((msg as OcxToolResultMessage).toolCallId); } } - for (const msg of parsed.context.messages) { + for (const msg of messages) { switch (msg.role) { case "user": case "developer": { @@ -310,6 +312,12 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | */ const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; const MAX_SSE_FRAME_BYTES = MAX_RESPONSE_BYTES; +let sseFrameMaxBytes = MAX_SSE_FRAME_BYTES; + +/** Test-only: lower the SSE frame byte cap without allocating a 100 MiB fixture. */ +export function setGoogleSseFrameMaxBytesForTests(bytes?: number): void { + sseFrameMaxBytes = bytes ?? MAX_SSE_FRAME_BYTES; +} // Note: imagen-* models use a different API surface (prediction/image-generation // schema) and must NOT be treated as responseModalities-capable Gemini models. @@ -434,8 +442,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig; - const method = parsed.stream ? "streamGenerateContent" : "generateContent"; - const streamParam = parsed.stream ? "?alt=sse" : ""; + const ccaAlwaysSse = provider.googleMode === "cloud-code-assist"; + const method = ccaAlwaysSse || parsed.stream ? "streamGenerateContent" : "generateContent"; + const streamParam = ccaAlwaysSse || parsed.stream ? "?alt=sse" : ""; const headers: Record = { "Content-Type": "application/json" }; if (provider.headers) Object.assign(headers, provider.headers); @@ -466,6 +475,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // nested) — matching CLIProxyAPI `generateStableSessionID`. An extra top-level/snake_case // spelling is a non-first-party key, so we send the single canonical location. const draftRequest: Record = { ...body, sessionId }; + if (systemInstruction) { + draftRequest.preambleConfig = { mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }; + } // Claude-on-Antigravity forces VALIDATED function calling (the real client always sets it). if (/claude/i.test(wireModelId)) { // VALIDATED would defeat a client's tool_choice "none": honor it by dropping the @@ -480,10 +492,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const compiled = compileGoogleWireBody(draftRequest); const request = compiled.body; + if (systemInstruction) { + request.preambleConfig = { mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }; + } restoreGoogleToolName = compiled.restoreToolName; // Compile names before replay: signatures are keyed by the exact provider-visible name. if (Array.isArray((request as { contents?: unknown[] }).contents)) { const contents = (request as { contents: unknown[] }).contents; + if (/claude/i.test(wireModelId)) stripTrailingClaudePrefill(contents); if (antigravityUsesReplayCache(wireModelId)) { applyAntigravityReplay(wireModelId, sessionId, contents); } else { @@ -503,6 +519,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }; headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA; headers["Authorization"] = `Bearer ${token}`; + if (/claude/i.test(wireModelId)) { + headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"; + } return { url, method: "POST", headers, body: JSON.stringify(envelope) }; } @@ -578,8 +597,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); if (!payload) return "continue"; - if (payload.length > MAX_SSE_FRAME_BYTES) { - yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; + if (payload.length > sseFrameMaxBytes) { + yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; return "terminate"; } let emittedContentEvent = false; @@ -717,6 +736,15 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte while (true) { const { done, value } = await reader.read(); if (done) break; + const incomingBytes = value?.byteLength ?? 0; + // Cap incomplete frames before decode and before waiting for a newline — + // otherwise a single unterminated data: payload can grow without bound, and + // buffer.length is UTF-16 units rather than bytes. + if (bufferBytes + incomingBytes > sseFrameMaxBytes) { + yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; + try { await reader.cancel(); } catch { /* ignore */ } + return; + } const nextBuffer = buffer + decoder.decode(value, { stream: true }); const nextBufferBytes = budgetEncoder.encode(nextBuffer).byteLength; const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" }); @@ -724,13 +752,6 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte appendReservation.commitRetained(); budget.releaseRetained(bufferBytes, { kind: "live_transient" }); bufferBytes = nextBufferBytes; - // Cap incomplete frames before waiting for a newline — otherwise a single - // unterminated data: payload can grow without bound. - if (buffer.length > MAX_SSE_FRAME_BYTES) { - yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; - try { await reader.cancel(); } catch { /* ignore */ } - return; - } const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -803,6 +824,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }, async parseResponse(response: Response, budget: TranslatorBudget): Promise { + // Cloud Code Assist exposes only the SSE transport. Unary callers still use this + // buffered adapter entry point, so collect the exact same events parseStream emits + // instead of maintaining a second CCA JSON parser. + if (provider.googleMode === "cloud-code-assist") { + const events: AdapterEvent[] = []; + for await (const event of this.parseStream(response, budget)) events.push(event); + return events; + } // Reject oversized responses before JSON parse. Prefer Content-Length when // present and truthful; always stream-read with a hard byte cap so a missing // or lying Content-Length cannot force a full in-memory buffer + parse. @@ -873,15 +902,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const err = raw.error as { message?: string }; return finish([{ type: "error", message: err.message ?? "upstream error" }]); } - // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it. - let json = raw; - if (provider.googleMode === "cloud-code-assist") { - const wrapped = raw.response; - if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) { - return finish([{ type: "error", message: "google-antigravity response missing response wrapper" }]); - } - json = wrapped as Record; - } + const json = raw; const events: AdapterEvent[] = []; const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined; @@ -893,10 +914,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (candidates?.[0]?.content?.parts) { // Non-streaming Google-family response: observe thought signatures for the next turn, // using the same transport-scoped namespace as the streaming path. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") - && replayModel && replaySession) { + const replayModel = vertexReplayModel; + const replaySession = vertexReplaySession; + if (provider.googleMode === "vertex" && replayModel && replaySession) { observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); } for (const part of candidates[0].content.parts) { @@ -933,7 +953,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done. - if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") + if (provider.googleMode === "vertex" && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) { return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]); } diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 55bb4f292a..d37ea67241 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -31,6 +31,7 @@ import { sweepExpiredXaiPermanentFailureVerdicts, } from "../oauth"; import { sweepExpiredAnthropicRoutingHealth } from "../oauth/anthropic-routing"; +import { sweepExpiredAntigravityRoutingHealth } from "../oauth/antigravity-routing"; import { listLiveOAuthAccountKeys, reconcileOAuthReauthState } from "../oauth/store"; import { reconcileGuardianBackoff } from "../oauth/token-guardian"; import { sweepExpiredApiKeyCooldowns } from "../providers/key-failover"; @@ -83,6 +84,7 @@ export const STATE_STORE_REGISTRATIONS = [ reconcileGeneration: reconcileComboTargetCooldowns, }, { name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth }, + { name: "antigravity-routing-health", sweepExpired: sweepExpiredAntigravityRoutingHealth }, { name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts }, { name: "responses-continuation", sweepExpired: sweepExpiredResponseStates }, { name: "antigravity-replay", sweepExpired: sweepExpiredAntigravityReplay }, diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts new file mode 100644 index 0000000000..b3c0a9af9e --- /dev/null +++ b/src/oauth/antigravity-routing.ts @@ -0,0 +1,132 @@ +export type AntigravityCooldownReason = "rate_limited" | "quota_exhausted" | "geo_blocked"; + +const DEFAULT_RATE_LIMITED_COOLDOWN_MS = 5_000; +const MAX_RATE_LIMITED_COOLDOWN_MS = 60_000; +const DEFAULT_QUOTA_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000; +const MAX_QUOTA_EXHAUSTED_COOLDOWN_MS = 7 * 24 * 60 * 60_000; +const GEO_BLOCKED_COOLDOWN_MS = 24 * 60 * 60_000; + +type AntigravityAccountHealth = { + cooldownUntil: number; +}; + +const accountHealth = new Map(); + +function positiveDurationOrDefault( + durationMs: number | undefined, + defaultMs: number, + maxMs?: number, +): number { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) { + return defaultMs; + } + return maxMs === undefined ? durationMs : Math.min(durationMs, maxMs); +} + +function cooldownDurationMs( + reason: AntigravityCooldownReason, + retryAfterMs: number | undefined, +): number { + switch (reason) { + case "rate_limited": + return positiveDurationOrDefault( + retryAfterMs, + DEFAULT_RATE_LIMITED_COOLDOWN_MS, + MAX_RATE_LIMITED_COOLDOWN_MS, + ); + case "quota_exhausted": + return positiveDurationOrDefault( + retryAfterMs, + DEFAULT_QUOTA_EXHAUSTED_COOLDOWN_MS, + MAX_QUOTA_EXHAUSTED_COOLDOWN_MS, + ); + case "geo_blocked": + return GEO_BLOCKED_COOLDOWN_MS; + } +} + +export function recordAntigravityCooldown( + accountId: string, + reason: AntigravityCooldownReason, + retryAfterMs?: number, + now = Date.now(), +): void { + const cooldownUntil = now + cooldownDurationMs(reason, retryAfterMs); + const current = accountHealth.get(accountId); + if (!current || current.cooldownUntil < cooldownUntil) { + accountHealth.set(accountId, { cooldownUntil }); + } +} + +export function isAntigravityAccountInCooldown(accountId: string, now = Date.now()): boolean { + const health = accountHealth.get(accountId); + if (!health) return false; + if (health.cooldownUntil <= now) { + accountHealth.delete(accountId); + return false; + } + return true; +} + +export function nextAntigravityAccount( + accountIds: string[], + activeId: string | undefined, + now = Date.now(), +): string | undefined { + if (accountIds.length === 0) return undefined; + + const activeIndex = activeId === undefined ? -1 : accountIds.indexOf(activeId); + const startIndex = activeIndex < 0 ? 0 : activeIndex + 1; + for (let offset = 0; offset < accountIds.length; offset += 1) { + const accountId = accountIds[(startIndex + offset) % accountIds.length]!; + if (activeId !== undefined && accountId === activeId) continue; + if (!isAntigravityAccountInCooldown(accountId, now)) return accountId; + } + return undefined; +} + +export function sweepExpiredAntigravityRoutingHealth(now = Date.now()): number { + let removed = 0; + for (const [accountId, health] of accountHealth) { + if (health.cooldownUntil > now) continue; + accountHealth.delete(accountId); + removed += 1; + } + return removed; +} + +export function clearAntigravityAccountCooldown(accountId: string): void { + accountHealth.delete(accountId); +} + +export const ANTIGRAVITY_MISSING_PROJECT_MESSAGE = + "Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`)."; + +export type BindAntigravityProjectFailure = { + ok: false; + status: 400; + type: "invalid_request_error"; + message: string; +}; + +export type BindAntigravityProjectSuccess = { + ok: true; + provider: T & { project: string }; +}; + +/** Pair Cloud Code Assist `project` with the credential in use. Never keep a previous account's id. */ +export function bindAntigravityProject( + provider: T, + projectId: string | undefined, +): BindAntigravityProjectSuccess | BindAntigravityProjectFailure { + const project = typeof projectId === "string" ? projectId.trim() : ""; + if (!project) { + return { + ok: false, + status: 400, + type: "invalid_request_error", + message: ANTIGRAVITY_MISSING_PROJECT_MESSAGE, + }; + } + return { ok: true, provider: { ...provider, project } }; +} diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts new file mode 100644 index 0000000000..a849bbb750 --- /dev/null +++ b/src/providers/antigravity-quota.ts @@ -0,0 +1,220 @@ +import { antigravityUserAgent } from "../adapters/client-fingerprint"; +import { antigravityHostCandidates } from "../adapters/google-antigravity-hosts"; +import { readProviderQuotaJsonForTests } from "./quota"; +import type { ProviderQuota, ProviderQuotaWindow } from "./quota"; + +const LIVE_QUOTA_PATH = "/v1internal:retrieveUserQuota"; +const LIVE_SUMMARY_PATH = "/v1internal:retrieveUserQuotaSummary"; + +type FetchImpl = typeof fetch; + +export interface AntigravityLiveQuotaArgs { + accessToken: string; + projectId: string; + baseUrl: string; + timeoutMs: number; + fetchImpl?: FetchImpl; +} + +interface QuotaCandidate { + record: Record; + path: string[]; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function finiteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function normalizePercent(value: unknown): number | undefined { + const numeric = finiteNumber(value); + return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric)); +} + +function resetAt(value: unknown): number | undefined { + const numeric = finiteNumber(value); + if (numeric !== undefined && numeric > 0) return numeric > 10_000_000_000 ? numeric : numeric * 1000; + if (typeof value !== "string" || !value.trim()) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function remainingPercent(record: Record): number | undefined { + const fraction = finiteNumber(record.remainingFraction); + if (fraction !== undefined) return normalizePercent(fraction * 100); + const percentage = finiteNumber( + record.remainingPercentage + ?? record.remainingPercent + ?? record.remaining_percent, + ); + if (percentage !== undefined) return normalizePercent(percentage <= 1 ? percentage * 100 : percentage); + return undefined; +} + +function usedPercent(record: Record): number | undefined { + const remaining = remainingPercent(record); + return remaining === undefined ? undefined : normalizePercent(100 - remaining); +} + +function recordResetAt(record: Record): number | undefined { + return resetAt(record.resetTime ?? record.resetAt ?? record.resetsAt ?? record.reset_time ?? record.nextReset); +} + +function collectCandidates(value: unknown, path: string[] = [], output: QuotaCandidate[] = []): QuotaCandidate[] { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) collectCandidates(item, [...path, String(index)], output); + return output; + } + const record = asRecord(value); + if (!record) return output; + output.push({ record, path }); + for (const [key, child] of Object.entries(record)) { + if (child && typeof child === "object") collectCandidates(child, [...path, key], output); + } + return output; +} + +function candidateModelName(candidate: QuotaCandidate): string { + const { record, path } = candidate; + const explicit = record.modelId ?? record.model_id ?? record.modelName ?? record.model ?? record.name; + return `${typeof explicit === "string" ? explicit : ""} ${path.join(" ")}`.toLowerCase(); +} + +function parseGeminiWindow(payload: unknown): ProviderQuotaWindow | undefined { + for (const candidate of collectCandidates(payload)) { + if (!candidateModelName(candidate).includes("gemini")) continue; + const percent = usedPercent(candidate.record); + if (percent === undefined) continue; + const reset = recordResetAt(candidate.record); + return { + label: "Gem", + percent, + ...(reset !== undefined ? { resetAt: reset } : {}), + }; + } + return undefined; +} + +function isWeeklyPath(path: string[]): boolean { + return path.some(part => /weekly|week|seven[_-]?day/i.test(part)); +} + +function parseWeeklyWindow(payload: unknown): { percent: number; resetAt?: number } | undefined { + const candidates = collectCandidates(payload); + const ordered = [ + ...candidates.filter(candidate => isWeeklyPath(candidate.path)), + ...candidates.filter(candidate => !isWeeklyPath(candidate.path)), + ]; + for (const candidate of ordered) { + const percent = usedPercent(candidate.record); + if (percent === undefined) continue; + const reset = recordResetAt(candidate.record); + return { percent, ...(reset !== undefined ? { resetAt: reset } : {}) }; + } + return undefined; +} + +async function readJson(response: Response, timeoutMs: number): Promise { + return await readProviderQuotaJsonForTests(response, timeoutMs); +} + +class AntigravityQuotaRpcError extends Error { + constructor(readonly status: number) { + super(`Antigravity quota RPC failed: ${status}`); + } +} + +function isHttpsHost(host: string): boolean { + try { + return new URL(host).protocol === "https:"; + } catch { + return false; + } +} + +function shouldRetryPeer(status: number): boolean { + return status === 404 || status === 503; +} + +async function fetchRpc( + fetchImpl: FetchImpl, + host: string, + method: "retrieveUserQuota" | "retrieveUserQuotaSummary", + args: AntigravityLiveQuotaArgs, +): Promise { + const path = method === "retrieveUserQuota" ? LIVE_QUOTA_PATH : LIVE_SUMMARY_PATH; + const response = await fetchImpl(`${host}${path}`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + Authorization: `Bearer ${args.accessToken}`, + }, + body: JSON.stringify({ project: args.projectId }), + redirect: "error", + signal: AbortSignal.timeout(args.timeoutMs), + }); + if (!response.ok) throw new AntigravityQuotaRpcError(response.status); + return readJson(response, args.timeoutMs); +} + +async function fetchHostQuota( + fetchImpl: FetchImpl, + host: string, + args: AntigravityLiveQuotaArgs, +): Promise { + const [quotaResult, summaryResult] = await Promise.allSettled([ + fetchRpc(fetchImpl, host, "retrieveUserQuota", args), + fetchRpc(fetchImpl, host, "retrieveUserQuotaSummary", args), + ]); + for (const result of [quotaResult, summaryResult]) { + if ( + result.status === "rejected" + && result.reason instanceof AntigravityQuotaRpcError + && !shouldRetryPeer(result.reason.status) + ) { + throw result.reason; + } + } + if (quotaResult.status === "rejected" || summaryResult.status === "rejected") return null; + const quotaPayload = quotaResult.value; + const summaryPayload = summaryResult.value; + const gem = parseGeminiWindow(quotaPayload); + const weekly = parseWeeklyWindow(summaryPayload); + if (!gem && !weekly) return null; + return { + ...(gem ? { customWindows: [gem] } : {}), + ...(weekly ? { + weeklyPercent: weekly.percent, + ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), + } : {}), + updatedAt: Date.now(), + }; +} + +export async function fetchAntigravityLiveQuota( + args: AntigravityLiveQuotaArgs, +): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + for (const host of antigravityHostCandidates(args.baseUrl)) { + if (!isHttpsHost(host)) continue; + try { + const quota = await fetchHostQuota(fetchImpl, host, args); + if (quota) return quota; + } catch { + return null; + } + } + return null; +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index bb3bab2837..9b80121431 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -28,6 +28,8 @@ import { type CodexCapacityAggregation, type CodexCapacityQuota, } from "./codex-capacity"; +import { fetchAntigravityLiveQuota } from "./antigravity-quota"; +import { antigravityHostCandidates } from "../adapters/google-antigravity-hosts"; /** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ const ACCOUNT_TOKEN_SKEW_MS = 60_000; @@ -2009,39 +2011,71 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig return null; } const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, ""); - const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { - method: "POST", - 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), + const liveQuota = await fetchAntigravityLiveQuota({ + accessToken, + projectId: credential.projectId, + baseUrl, + timeoutMs: REQUEST_TIMEOUT_MS, }); - if (!response.ok) return null; - const body = asRecord(await readQuotaJson(response)); - const models = asRecord(body?.models); - if (!models) return null; const windows = new Map(); - for (const [modelId, rawModelInfo] of Object.entries(models)) { - const modelInfo = asRecord(rawModelInfo); - if (!modelInfo) continue; - for (const quotaInfo of quotaInfoEntries(modelInfo)) { - const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); - if (!label || windows.has(label)) continue; - const percent = antigravityUsedPercent(quotaInfo); - if (percent === undefined) continue; - windows.set(label, { - label, - percent, - ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), + for (const [index, host] of antigravityHostCandidates(baseUrl).entries()) { + try { + const response = await fetch(`${host}/v1internal:fetchAvailableModels`, { + method: "POST", + 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), }); + if (!response.ok) { + if (index === 0 && (response.status === 404 || response.status === 503)) continue; + break; + } + const body = asRecord(await readQuotaJson(response)); + const models = asRecord(body?.models); + if (models) { + for (const [modelId, rawModelInfo] of Object.entries(models)) { + const modelInfo = asRecord(rawModelInfo); + if (!modelInfo) continue; + for (const quotaInfo of quotaInfoEntries(modelInfo)) { + const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); + if (!label || windows.has(label)) continue; + const percent = antigravityUsedPercent(quotaInfo); + if (percent === undefined) continue; + windows.set(label, { + label, + percent, + ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), + }); + } + } + } + break; + } catch { + if (index === 0) continue; + break; } } + if (liveQuota) { + const liveWindows = liveQuota.customWindows ?? []; + const catalogClaude = windows.get("Cla"); + const customWindows = [ + ...liveWindows, + ...(liveWindows.some(window => window.label === "Cla") || !catalogClaude ? [] : [catalogClaude]), + ]; + return report(provider, "google-antigravity:retrieveUserQuota", { + ...liveQuota, + ...(customWindows.length > 0 ? { customWindows } : {}), + updatedAt: Date.now(), + }); + } + const customWindows = ["Gem", "Cla"].flatMap(label => { const window = windows.get(label); return window ? [window] : []; diff --git a/src/server/images.ts b/src/server/images.ts index 5e65a1a170..8818c25407 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -223,9 +223,12 @@ async function tryCcaImageGeneration( sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`, }, }; - let upstream: Response; + let upstream: Response | undefined; try { try { + // Image generation is a paid, non-idempotent POST. A transport failure is + // ambiguous: the upstream may have accepted the request before the + // connection failed, so never replay it on a peer host. upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { method: "POST", headers: { @@ -246,12 +249,19 @@ async function tryCcaImageGeneration( // lives in an Authorization header, not in the URL, but sanitize defensively // so no upstream-rejected credential or query param can reach the client, // and strip the internal base URL host from the surfaced message. + // 400, not 5xx: the POST is paid and non-idempotent. Codex retries every + // 5xx up to 5 attempts, which would duplicate generation after an ambiguous + // transport failure (the upstream may already have accepted the request). const rawMsg = err instanceof Error ? err.message : String(err); const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( /https?:\/\/[^\s"'<>]+/gi, "[upstream-url]", ); - return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`); + return formatErrorResponse( + 400, + "invalid_request_error", + `CCA image generation may have started and must not be blindly retried: ${safeMsg}`, + ); } // Stream the upstream body with a bounded reader so an oversized or malicious diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2df5160984..4aae80d0de 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -77,6 +77,12 @@ import { resolveAnthropicAccountForSession, rotateAnthropicAccountOn429, } from "../../oauth/anthropic-routing"; +import { + bindAntigravityProject, + isAntigravityAccountInCooldown, + nextAntigravityAccount, +} from "../../oauth/antigravity-routing"; +import { getAccountCredential, getAccountSet, setActiveAccount } from "../../oauth/store"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -2051,6 +2057,8 @@ async function handleResponsesInner( let replayOAuthCredentialSnapshot: Pick | undefined; let anthropicPoolAccountId: string | null = null; let anthropicPoolFailovers = 0; + let antigravityAccountId: string | undefined; + let antigravityFailovers = 0; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -2083,25 +2091,50 @@ async function handleResponsesInner( route.provider = { ...route.provider, apiKey: accessToken }; logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config); } else { - const resolved = await getValidAccessTokenSnapshot(route.providerName); + let resolved = await getValidAccessTokenSnapshot(route.providerName); + let skippedAntigravityCooldown = false; + if (route.providerName === "google-antigravity" + && route.provider.googleMode === "cloud-code-assist" + && isAntigravityAccountInCooldown(resolved.accountId)) { + const accountIds = getAccountSet("google-antigravity")?.accounts.map(account => account.id) ?? []; + const nextAccountId = nextAntigravityAccount(accountIds, resolved.accountId); + if (!nextAccountId) { + return formatErrorResponse(429, "rate_limit_error", "All Google Antigravity OAuth accounts are temporarily unavailable"); + } + const accessToken = await getValidAccessTokenForAccount("google-antigravity", nextAccountId); + const nextCredential = getAccountCredential("google-antigravity", nextAccountId); + resolved = { + ...resolved, + accountId: nextAccountId, + accessToken, + // Always replace; omitting a missing id would keep the previous account's projectId. + projectId: nextCredential?.projectId, + }; + skippedAntigravityCooldown = true; + void setActiveAccount("google-antigravity", nextAccountId).catch(() => { /* best-effort promotion */ }); + } replayOAuthCredentialSnapshot = { accountId: resolved.accountId, generation: resolved.generation, }; + if (skippedAntigravityCooldown) replayOAuthCredentialSnapshot = undefined; if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; + if (route.providerName === "google-antigravity" && route.provider.googleMode === "cloud-code-assist") { + antigravityAccountId = resolved.accountId; + // Always overwrite `project` from the credential in use. A missing id fails closed + // so a rotated account cannot inherit the previous account's Cloud Code Assist project. + const bound = bindAntigravityProject(route.provider, resolved.projectId); + if (!bound.ok) { + return formatErrorResponse(bound.status, bound.type, bound.message); + } + route.provider = bound.provider; + } if (route.providerName === "kiro") { // `{}` is intentional: this is an account-scoped request with no stored routing metadata. // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) }; } - // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the - // CCA envelope. Keep it paired with the token snapshot so an account rotation cannot mix - // a fresh token with project metadata re-read from a different credential generation. - if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) { - const projectId = resolved.projectId; - if (projectId) route.provider = { ...route.provider, project: projectId }; - } } } catch (err) { if (err instanceof UnsupportedOAuthProviderError) { @@ -3581,6 +3614,7 @@ async function handleResponsesInner( abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream, + ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), }); } else { upstreamResponse = await fetchWithResetRetry( @@ -3671,7 +3705,12 @@ async function handleResponsesInner( try { if (activeAdapter.fetchResponse) { await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); - return await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream }); + return await activeAdapter.fetchResponse(retryRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + stream: parsed.stream, + ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), + }); } return await fetchWithHeaderTimeout(retryRequest.url, { method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, @@ -3848,6 +3887,55 @@ async function handleResponsesInner( break; } } + // Antigravity OAuth accounts use the same bounded pre-stream carousel as Anthropic, but + // their process-local routing module also excludes accounts cooled by quota/rate-limit + // responses. Geoblocked 403s intentionally never enter this loop. + while ( + upstreamResponse.status === 429 + && route.providerName === "google-antigravity" + && route.provider.googleMode === "cloud-code-assist" + && antigravityAccountId + && antigravityFailovers < 3 + ) { + const accountIds = getAccountSet("google-antigravity")?.accounts.map(account => account.id) ?? []; + const nextAccountId = nextAntigravityAccount(accountIds, antigravityAccountId); + if (!nextAccountId) break; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + try { + const accessToken = await getValidAccessTokenForAccount("google-antigravity", nextAccountId); + const nextCredential = getAccountCredential("google-antigravity", nextAccountId); + const bound = bindAntigravityProject( + { ...route.provider, apiKey: accessToken }, + nextCredential?.projectId, + ); + if (!bound.ok) { + return formatErrorResponse(bound.status, bound.type, bound.message); + } + antigravityAccountId = nextAccountId; + antigravityFailovers += 1; + route.provider = bound.provider; + replayOAuthCredentialSnapshot = undefined; + invalidateSameTargetRequest(); + activeAdapter = resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: activeAdapter.name, + codexAuthContext: authCtx, + forwardHeaders: selectedForwardHeaders, + }); + void setActiveAccount("google-antigravity", nextAccountId).catch(() => { /* best-effort promotion */ }); + const result = await rebuildAndRefetch("rate-limit-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } catch { + break; + } + } // Anthropic 413 request_too_large: rebuild once with every image one tier lower // (spiral guard: single attempt). The biased response re-enters the 429 check above. if (shouldAttemptImageTierRetry({ @@ -3997,6 +4085,7 @@ async function handleResponsesInner( abortSignal: upstream.signal, timeoutMs: connectMs, stream: nextParsed.stream, + ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), }); } return await fetchWithResetRetry( diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index f3287a65f7..731f1ac304 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -154,9 +154,11 @@ These are standalone Images API routes, not the hosted Responses `image_generati selects a custom API-key `openai-responses` provider. Explicit selection fails closed when the provider is missing, disabled, registry-managed, incompatible, or lacks a usable key; it never falls through to another paid upstream. The relay accepts bounded JSON generation and edit requests, -then forwards the decoded JSON without rewriting Codex's edit schema. Each paid Images POST receives -one upstream attempt; client cancellation aborts the upstream and pool-only failures update the -existing account-health state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. +then forwards the decoded JSON without rewriting Codex's edit schema. Each paid Images POST, +including the Google Antigravity fallback, receives one upstream attempt; an ambiguous transport +failure is never replayed on a peer host because the generation may already have been accepted. +Client cancellation aborts the upstream and pool-only failures update the existing account-health +state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. When the OpenAI credential path is unavailable or its authentication fails, `generations` (not `edits`) may fall back to Google Antigravity if that provider is logged in. The fallback is @@ -839,6 +841,16 @@ combo whose remaining eligible targets use other providers. - 장점, 단점 및 영향: Same-account model fallback works without weakening explicit upstream backoff; the account health map intentionally does not remember that one deferred reset-derived failure, while the combo target map does. ``` +## Antigravity transport failover + +Cloud Code Assist requests always use `streamGenerateContent?alt=sse`, including unary callers; +the adapter buffers those SSE events for the unary contract. The configured daily or production +host is tried first, then only its maintained peer (`daily-cloudcode-pa.googleapis.com` or +`cloudcode-pa.googleapis.com`) is eligible for one first-host transport, 404, `UNAVAILABLE`, or +empty-stream retry. Authentication, geoblock, invalid-request, and exhausted-quota responses do +not trigger host failover. Antigravity 429 cooldowns are process-local and keyed by OAuth account; +geoblock records cooldown without starting an account carousel. + ## Transport inventory The sections above cover the transports with load-bearing invariants. The rest of the transport @@ -847,7 +859,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | -| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. | +| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-antigravity-hosts.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/oauth/antigravity-routing.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. CCA host failover is daily/prod only (`google-antigravity-hosts.ts`); process-local 429/quota/geoblock cooldowns are keyed by OAuth account (`antigravity-routing.ts`). | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | diff --git a/tests/antigravity-project-bind.test.ts b/tests/antigravity-project-bind.test.ts new file mode 100644 index 0000000000..3238594108 --- /dev/null +++ b/tests/antigravity-project-bind.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { bindAntigravityProject } from "../src/oauth/antigravity-routing"; + +const MISSING_PROJECT_MESSAGE = + "Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`)."; + +describe("bindAntigravityProject", () => { + test("fails closed when the current credential has no projectId", () => { + const previous = { + apiKey: "token-a", + googleMode: "cloud-code-assist" as const, + project: "project-from-previous-account", + }; + + const bound = bindAntigravityProject(previous, undefined); + + expect(bound.ok).toBe(false); + if (bound.ok) throw new Error("expected fail-closed bind"); + expect(bound.status).toBe(400); + expect(bound.type).toBe("invalid_request_error"); + expect(bound.message).toBe(MISSING_PROJECT_MESSAGE); + expect(previous.project).toBe("project-from-previous-account"); + }); + + test("fails closed for an empty projectId instead of keeping the previous project", () => { + const previous = { project: "project-from-previous-account" }; + + const bound = bindAntigravityProject(previous, ""); + + expect(bound.ok).toBe(false); + if (bound.ok) throw new Error("expected fail-closed bind"); + expect(bound.status).toBe(400); + expect(bound.type).toBe("invalid_request_error"); + expect(bound.message).toBe(MISSING_PROJECT_MESSAGE); + expect(previous.project).toBe("project-from-previous-account"); + }); + + test("overwrites a previous account project with the current credential project", () => { + const previous = { + apiKey: "token-b", + googleMode: "cloud-code-assist" as const, + project: "project-from-previous-account", + }; + + const bound = bindAntigravityProject(previous, "project-from-current-account"); + + expect(bound.ok).toBe(true); + if (!bound.ok) throw new Error("expected successful bind"); + expect(bound.provider.project).toBe("project-from-current-account"); + expect(bound.provider.apiKey).toBe("token-b"); + expect(previous.project).toBe("project-from-previous-account"); + }); + + test("assigns the current credential project when the provider had none", () => { + const previous = { apiKey: "token-c", googleMode: "cloud-code-assist" as const }; + + const bound = bindAntigravityProject(previous, "project-from-current-account"); + + expect(bound.ok).toBe(true); + if (!bound.ok) throw new Error("expected successful bind"); + expect(bound.provider.project).toBe("project-from-current-account"); + }); +}); diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts new file mode 100644 index 0000000000..bbc739c877 --- /dev/null +++ b/tests/antigravity-quota.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; +import { + clearProviderQuotaCache, + fetchProviderQuotaReports, + QUOTA_RESPONSE_MAX_BYTES, +} from "../src/providers/quota"; +import { saveCredential } from "../src/oauth/store"; +import type { OcxConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +const DAILY_HOST = "https://daily-cloudcode-pa.googleapis.com"; +const PROD_HOST = "https://cloudcode-pa.googleapis.com"; +const TOKEN = "antigravity-access-token"; +const PROJECT = "antigravity-project"; + +function liveGeminiQuota(): Response { + return jsonResponse({ + buckets: [ + { modelId: "gemini-3.6-pro", remainingFraction: 0.4, resetTime: "2026-08-19T12:00:00Z" }, + ], + }); +} + +function liveWeeklySummary(): Response { + return jsonResponse({ + weekly: { remainingPercentage: 75, resetTime: "2026-08-25T00:00:00Z" }, + }); +} + +function config(baseUrl = DAILY_HOST): OcxConfig { + return { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl }, + }, + } as OcxConfig; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function catalogResponse(): Response { + return jsonResponse({ + models: { + "gemini-3.6-flash-medium": { + displayName: "Gemini 3.6 Flash (Medium)", + quotaInfo: { remainingFraction: 0.64, resetTime: "2026-08-20T14:00:00Z" }, + }, + "claude-sonnet-4.6": { + displayName: "Claude Sonnet", + quotaInfo: { remainingFraction: 0.21, resetTime: "2026-08-21T15:00:00Z" }, + }, + }, + }); +} + +function oversizedJsonResponse(value: Record): Response { + return new Response(JSON.stringify({ + ...value, + padding: "x".repeat(QUOTA_RESPONSE_MAX_BYTES), + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(async () => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-antigravity-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + await saveCredential("google-antigravity", { + access: TOKEN, + refresh: "antigravity-refresh-token", + expires: Date.now() + 3_600_000, + projectId: PROJECT, + }); + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +describe("Antigravity live quota", () => { + test("merges live Gemini and weekly quota with catalog-only Claude windows", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return jsonResponse({ + buckets: [ + { modelId: "gemini-3.6-pro", remainingFraction: 0.4, resetTime: "2026-08-19T12:00:00Z" }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return jsonResponse({ + weekly: { remainingPercentage: 75, resetTime: "2026-08-25T00:00:00Z" }, + }); + } + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + const report = result.reports[0]; + + expect(report?.source).toBe("google-antigravity:retrieveUserQuota"); + expect(report?.quota.customWindows).toEqual([ + { label: "Gem", percent: 60, resetAt: Date.parse("2026-08-19T12:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-08-21T15:00:00Z") }, + ]); + expect(report?.quota.weeklyPercent).toBe(25); + expect(report?.quota.weeklyResetAt).toBe(Date.parse("2026-08-25T00:00:00Z")); + }); + + test("retries the production host after daily retrieveUserQuota returns 404", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.startsWith(DAILY_HOST) && url.includes(":retrieveUserQuota")) return jsonResponse({}, 404); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested).toContain(`${PROD_HOST}/v1internal:retrieveUserQuota`); + expect(result.reports[0]?.source).toBe("google-antigravity:retrieveUserQuota"); + }); + + test("falls back to the catalog when both live RPCs return 404", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(":retrieveUserQuota")) return jsonResponse({}, 404); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + const report = result.reports[0]; + + expect(report?.source).toBe("google-antigravity:fetchAvailableModels"); + expect(report?.quota.customWindows).toEqual([ + { label: "Gem", percent: 36, resetAt: Date.parse("2026-08-20T14:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-08-21T15:00:00Z") }, + ]); + expect(report?.quota.weeklyPercent).toBeUndefined(); + }); + + test("falls back to the catalog when live RPC fetch throws", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(":retrieveUserQuota")) throw new Error("simulated timeout"); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + await expect(fetchProviderQuotaReports(config(), true)).resolves.toMatchObject({ + reports: [{ + source: "google-antigravity:fetchAvailableModels", + quota: { customWindows: expect.any(Array) }, + }], + }); + }); + + test("fails open to the catalog when live RPC bodies exceed the quota JSON limit", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return oversizedJsonResponse({ + buckets: [ + { modelId: "gemini-3.6-pro", remainingFraction: 0.01 }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return oversizedJsonResponse({ + weekly: { remainingPercentage: 1 }, + }); + } + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + const report = result.reports[0]; + + expect(report?.source).toBe("google-antigravity:fetchAvailableModels"); + expect(report?.quota.customWindows).toEqual([ + { label: "Gem", percent: 36, resetAt: Date.parse("2026-08-20T14:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-08-21T15:00:00Z") }, + ]); + expect(report?.quota.weeklyPercent).toBeUndefined(); + }); + + test("does not fetch the production host after daily retrieveUserQuota returns 401", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuota`) return jsonResponse({}, 401); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); + }); + + test("does not fetch the production host when daily retrieveUserQuota 401 races a 404 summary", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuota`) { + await Bun.sleep(20); + return jsonResponse({}, 401); + } + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuotaSummary`) return jsonResponse({}, 404); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); + }); + + test("does not fetch the production host after daily retrieveUserQuota returns 429", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuota`) return jsonResponse({}, 429); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); + }); + + test("does not POST retrieveUserQuota or retrieveUserQuotaSummary to an http host", async () => { + const httpHost = "http://daily-cloudcode-pa.googleapis.com"; + const requested: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const quota = await fetchAntigravityLiveQuota({ + accessToken: TOKEN, + projectId: PROJECT, + baseUrl: httpHost, + timeoutMs: 8_000, + fetchImpl, + }); + + expect(requested.filter(url => url.startsWith("http://"))).toEqual([]); + expect(requested).not.toContain(`${httpHost}/v1internal:retrieveUserQuota`); + expect(requested).not.toContain(`${httpHost}/v1internal:retrieveUserQuotaSummary`); + expect(quota?.customWindows).toEqual([ + { label: "Gem", percent: 60, resetAt: Date.parse("2026-08-19T12:00:00Z") }, + ]); + }); +}); diff --git a/tests/antigravity-routing.test.ts b/tests/antigravity-routing.test.ts new file mode 100644 index 0000000000..19fe8506c3 --- /dev/null +++ b/tests/antigravity-routing.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + clearAntigravityAccountCooldown, + isAntigravityAccountInCooldown, + nextAntigravityAccount, + recordAntigravityCooldown, + sweepExpiredAntigravityRoutingHealth, +} from "../src/oauth/antigravity-routing"; + +const NOW = 1_700_000_000_000; +const ACCOUNT_IDS = ["account-a", "account-b", "account-c"]; + +afterEach(() => { + for (const accountId of ACCOUNT_IDS) clearAntigravityAccountCooldown(accountId); +}); + +describe("Antigravity account cooldowns", () => { + test("records a rate-limit cooldown and sweeps it after expiry", () => { + recordAntigravityCooldown("account-a", "rate_limited", undefined, NOW); + + expect(isAntigravityAccountInCooldown("account-a", NOW)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + 4_999)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + 5_000)).toBe(false); + recordAntigravityCooldown("account-b", "rate_limited", undefined, NOW); + expect(sweepExpiredAntigravityRoutingHealth(NOW + 5_000)).toBe(1); + expect(sweepExpiredAntigravityRoutingHealth(NOW + 5_000)).toBe(0); + }); + + test("caps rate-limit Retry-After and uses a parsed quota reset", () => { + recordAntigravityCooldown("account-a", "rate_limited", 120_000, NOW); + recordAntigravityCooldown("account-b", "quota_exhausted", 30_000, NOW); + + expect(isAntigravityAccountInCooldown("account-a", NOW + 60_000)).toBe(false); + expect(isAntigravityAccountInCooldown("account-b", NOW + 29_999)).toBe(true); + expect(isAntigravityAccountInCooldown("account-b", NOW + 30_000)).toBe(false); + }); + + test("honors a quota reset longer than 24 hours", () => { + const resetDurationMs = 48 * 60 * 60_000; + recordAntigravityCooldown("account-a", "quota_exhausted", resetDurationMs, NOW); + + expect(isAntigravityAccountInCooldown("account-a", NOW + 24 * 60 * 60_000 + 1)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + resetDurationMs)).toBe(false); + }); + + test("caps quota-exhausted Retry-After at 7 days", () => { + const tenYearsMs = 10 * 365 * 24 * 60 * 60_000; + const sevenDaysMs = 7 * 24 * 60 * 60_000; + recordAntigravityCooldown("account-a", "quota_exhausted", tenYearsMs, NOW); + + expect(isAntigravityAccountInCooldown("account-a", NOW + sevenDaysMs - 1)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + sevenDaysMs)).toBe(false); + }); + + test("skips cooled accounts when selecting the next account", () => { + recordAntigravityCooldown("account-b", "rate_limited", undefined, NOW); + + expect(nextAntigravityAccount(ACCOUNT_IDS, "account-a", NOW)).toBe("account-c"); + expect(nextAntigravityAccount(ACCOUNT_IDS, "account-c", NOW)).toBe("account-a"); + expect(nextAntigravityAccount(ACCOUNT_IDS, undefined, NOW)).toBe("account-a"); + }); + + test("keeps a geo block out of the short retry-limit path", () => { + recordAntigravityCooldown("account-a", "geo_blocked", undefined, NOW); + + expect(nextAntigravityAccount(["account-a"], "account-a", NOW + 60_000)).toBeUndefined(); + expect(isAntigravityAccountInCooldown("account-a", NOW + 60_000)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + 24 * 60 * 60_000)).toBe(false); + }); + + test("retains the longest expiry from concurrent cooldown records", () => { + recordAntigravityCooldown("account-a", "rate_limited", 60_000, NOW); + recordAntigravityCooldown("account-a", "rate_limited", undefined, NOW + 1); + + expect(isAntigravityAccountInCooldown("account-a", NOW + 5_001)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + 59_999)).toBe(true); + expect(isAntigravityAccountInCooldown("account-a", NOW + 60_001)).toBe(false); + }); +}); diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 467cf19c37..6218177362 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../src/adapters/google"; +import { anthropicToolCallId } from "../src/adapters/tool-call-id"; import type { OcxParsedRequest } from "../src/types"; const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; @@ -133,6 +134,30 @@ describe("google adapter — tool-call ids on the wire", () => { expect(frPart.functionResponse.id).toBe("call_abc"); }); + test("orphan tool results are omitted instead of emitting an unmatched functionResponse", async () => { + const contents = await geminiContents(parsedWith([ + { role: "toolResult", toolCallId: "orphan", toolName: "missing", content: "discard", isError: false }, + { role: "user", content: "continue" }, + ])); + + expect(contents.flatMap(content => content.parts).some(part => "functionResponse" in part)).toBe(false); + expect(JSON.stringify(contents)).not.toContain("orphan"); + }); + + test("orphan result ids do not reserve allocator slots", async () => { + const rawId = "call:a"; + const normalizedId = anthropicToolCallId(rawId)!; + const contents = await geminiContents(parsedWith([ + { role: "assistant", content: [{ type: "toolCall", id: rawId, name: "bash", arguments: {} }] }, + { role: "toolResult", toolCallId: rawId, toolName: "bash", content: "ok", isError: false }, + { role: "toolResult", toolCallId: normalizedId, toolName: "missing", content: "discard", isError: false }, + ])); + + const functionCall = contents.find(content => content.role === "model")!.parts + .find(part => "functionCall" in part) as { functionCall: { id?: string } }; + expect(functionCall.functionCall.id).toBe(normalizedId); + }); + test("ids are normalized to Anthropic's tool_use.id charset, preserving call/response pairing", async () => { const contents = await geminiContents(parsedWith([ { role: "assistant", content: [{ type: "toolCall", id: "fc:weird/id#1", name: "bash", arguments: {} }] }, @@ -153,6 +178,8 @@ describe("google adapter — tool-call ids on the wire", () => { { type: "toolCall", id: "call:a", name: "bash", arguments: {} }, { type: "toolCall", id: "call/a", name: "bash", arguments: {} }, ] }, + { role: "toolResult", toolCallId: "call:a", toolName: "bash", content: "one", isError: false }, + { role: "toolResult", toolCallId: "call/a", toolName: "bash", content: "two", isError: false }, ])); const ids = contents.find(c => c.role === "model")!.parts .filter(p => "functionCall" in p) diff --git a/tests/google-antigravity-errors.test.ts b/tests/google-antigravity-errors.test.ts new file mode 100644 index 0000000000..fccbbf63ad --- /dev/null +++ b/tests/google-antigravity-errors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { + isAntigravityGeoBlockedBody, + isQuotaExhaustedBody, + retryableGoogleStatus, + safeAntigravityHttpErrorMessage, +} from "../src/adapters/google-errors"; + +const GEO_BLOCKED_DETAIL = "User location is not supported for the API use"; + +describe("Antigravity Google error classification", () => { + test("classifies geo-blocked 403 responses and redacts echoed credentials", () => { + const leakedToken = "geo-access-token-value-123456"; + const payload = JSON.stringify({ + error: { + status: "PERMISSION_DENIED", + message: `${GEO_BLOCKED_DETAIL}; accessToken=${leakedToken}`, + }, + }); + + expect(isAntigravityGeoBlockedBody(payload)).toBe(true); + const message = safeAntigravityHttpErrorMessage(403, payload); + expect(message).toContain("Antigravity location not supported"); + expect(message).not.toContain(leakedToken); + }); + + test("keeps ordinary permission-denied 403 responses as access denied", () => { + const payload = JSON.stringify({ + error: { + status: "PERMISSION_DENIED", + message: "The caller does not have permission to use this resource", + }, + }); + + expect(isAntigravityGeoBlockedBody(payload)).toBe(false); + expect(safeAntigravityHttpErrorMessage(403, payload)).toContain("Antigravity access denied"); + }); + + test("preserves quota and rate-limit classification for 429 responses", () => { + const quotaPayload = JSON.stringify({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Quota exceeded for this project", + }, + }); + const rateLimitPayload = JSON.stringify({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Rate limit exceeded", + }, + }); + + expect(safeAntigravityHttpErrorMessage(429, quotaPayload)).toContain("Antigravity quota exhausted"); + expect(safeAntigravityHttpErrorMessage(429, rateLimitPayload)).toContain("Antigravity rate limit exceeded"); + expect(isQuotaExhaustedBody(quotaPayload)).toBe(true); + expect(isQuotaExhaustedBody(rateLimitPayload)).toBe(false); + expect(retryableGoogleStatus(403)).toBe(false); + }); +}); diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 5ea15eff2a..47699a115b 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; import { antigravitySessionId, isLikelyRealThoughtSignature } from "../src/adapters/google-antigravity-wire"; +import { antigravityHostCandidates } from "../src/adapters/google-antigravity-hosts"; +import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "../src/adapters/google-antigravity-tools"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_EFFORTS, canonicalAntigravityUsageModel, parseAntigravityAvailableModels, resolveAntigravityEffortWireModel, resolveAntigravityWireModelId } from "../src/providers/antigravity-models"; import { MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, MODEL_DISCOVERY_MAX_MODELS } from "../src/providers/model-discovery"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -44,7 +46,7 @@ describe("antigravity CCA envelope", () => { test("wraps the gemini body in the CCA envelope with project/userAgent/requestType/requestId/sessionId", async () => { const req = await createGoogleAdapter(provider).buildRequest(parsed()); const env = JSON.parse(req.body); - expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent"); + expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); expect(env.model).toBe("gemini-3-pro"); // The envelope BODY userAgent is the protocol constant; the versioned CLI UA rides in the header. expect(env.userAgent).toBe("antigravity"); @@ -76,6 +78,56 @@ describe("antigravity CCA envelope", () => { expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); }); + test("host candidates keep the configured host first and use only daily/prod", () => { + expect(antigravityHostCandidates("https://daily-cloudcode-pa.googleapis.com")).toEqual([ + "https://daily-cloudcode-pa.googleapis.com", + "https://cloudcode-pa.googleapis.com", + ]); + expect(antigravityHostCandidates("https://cloudcode-pa.googleapis.com/")).toEqual([ + "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.googleapis.com", + ]); + }); + + test("Claude CCA adds the interleaved-thinking beta header and preamble mode", async () => { + const req = await createGoogleAdapter(provider).buildRequest(parsed("x", false, "claude-sonnet-4-6")); + const env = JSON.parse(req.body); + + expect(req.headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14"); + expect(env.request.preambleConfig).toEqual({ mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }); + }); + + test("Gemini CCA does not receive the Claude beta header", async () => { + const req = await createGoogleAdapter(provider).buildRequest(parsed()); + expect(req.headers["anthropic-beta"]).toBeUndefined(); + }); + + test("Claude CCA strips a trailing prefill model turn but keeps a lone model turn", async () => { + const withPrefill = { + ...parsed("x", false, "claude-sonnet-4-6"), + context: { + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: [{ type: "text", text: "prefill" }] }, + ], + systemPrompt: [], + tools: [], + }, + } as unknown as OcxParsedRequest; + const prefillEnv = JSON.parse((await createGoogleAdapter(provider).buildRequest(withPrefill)).body); + expect(prefillEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["user"]); + + const loneModel = { + ...withPrefill, + context: { + ...withPrefill.context, + messages: [{ role: "assistant", content: [{ type: "text", text: "only turn" }] }], + }, + } as unknown as OcxParsedRequest; + const loneEnv = JSON.parse((await createGoogleAdapter(provider).buildRequest(loneModel)).body); + expect(loneEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["model"]); + }); + test("exposes Gemini 3.7 Flash while retired Flash ids resolve to it", async () => { // Collapsed picker: base models only. expect(ANTIGRAVITY_MODELS).toEqual([ @@ -540,6 +592,50 @@ describe("antigravity CCA envelope", () => { }); }); +describe("Google Antigravity history repair", () => { + test("drops orphan tool results and unmatched trailing calls", () => { + const messages = [ + { role: "user", content: "run tools" }, + { + role: "assistant", + content: [ + { type: "toolCall", id: "call-1", name: "one", arguments: {} }, + { type: "toolCall", id: "call-2", name: "two", arguments: {} }, + ], + }, + { role: "toolResult", toolCallId: "call-1", toolName: "one", content: "ok", isError: false }, + { role: "toolResult", toolCallId: "orphan", toolName: "missing", content: "discard", isError: false }, + ] as unknown as Parameters[0]; + + const repaired = repairGoogleToolPairs(messages); + expect(repaired).toHaveLength(3); + expect((repaired[1] as { content: { id: string }[] }).content.map(part => part.id)).toEqual(["call-1"]); + expect((repaired[2] as { toolCallId: string }).toolCallId).toBe("call-1"); + }); + + test("keeps parallel calls when every call has a later result", () => { + const messages = [ + { role: "assistant", content: [ + { type: "toolCall", id: "call-1", name: "one", arguments: {} }, + { type: "toolCall", id: "call-2", name: "two", arguments: {} }, + ] }, + { role: "toolResult", toolCallId: "call-1", toolName: "one", content: "one", isError: false }, + { role: "toolResult", toolCallId: "call-2", toolName: "two", content: "two", isError: false }, + ] as unknown as Parameters[0]; + + const repaired = repairGoogleToolPairs(messages); + expect((repaired[0] as { content: { id: string }[] }).content.map(part => part.id)).toEqual(["call-1", "call-2"]); + expect(repaired).toHaveLength(3); + }); + + test("strips only trailing model turns when another content turn remains", () => { + expect(stripTrailingClaudePrefill([{ role: "user" }, { role: "model" }, { role: "model" }])) + .toEqual([{ role: "user" }]); + expect(stripTrailingClaudePrefill([{ role: "model" }])) + .toEqual([{ role: "model" }]); + }); +}); + function sseResponse(chunks: unknown[]): Response { const body = chunks.map(c => `data: ${JSON.stringify(c)}\n`).join("\n") + "\n"; return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); @@ -562,10 +658,20 @@ describe("antigravity parseStream unwraps response", () => { }); describe("antigravity parseResponse unwraps response (non-streaming)", () => { + test("buffers CCA SSE frames for unary callers", async () => { + const adapter = createGoogleAdapter(provider); + const events = await adapter.parseResponse!(sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ])); + expect(events).toContainEqual({ type: "text_delta", text: "hello" }); + expect(events.at(-1)?.type).toBe("done"); + }); + test("reads response.candidates + response.usageMetadata from the CCA envelope", async () => { const adapter = createGoogleAdapter(provider); - const body = JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "hello" }] } }], usageMetadata: { promptTokenCount: 9, candidatesTokenCount: 2, cachedContentTokenCount: 7 } } }); - const events = await adapter.parseResponse!(new Response(body, { status: 200 })); + const body = { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }], usageMetadata: { promptTokenCount: 9, candidatesTokenCount: 2, cachedContentTokenCount: 7 } } }; + const events = await adapter.parseResponse!(sseResponse([body])); expect(events.some(e => e.type === "text_delta" && e.text === "hello")).toBe(true); const done = events.find(e => e.type === "done"); expect((done as Extract).usage?.inputTokens).toBe(9); @@ -578,8 +684,8 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { const adapter = createGoogleAdapter(provider); // buildRequest first to set the per-adapter model/session, then parseResponse to observe. await adapter.buildRequest(parsed("hello world")); - const body = JSON.stringify({ response: { candidates: [{ content: { parts: [{ functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-nonstream0000000" } ] } }] } }); - await adapter.parseResponse!(new Response(body, { status: 200 })); + const body = { response: { candidates: [{ content: { parts: [{ functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-nonstream0000000" } ] } }] } }; + await adapter.parseResponse!(sseResponse([body])); // A follow-up request's history should now get the signature re-injected. const followup = parsed("hello world"); const contents = [{ role: "model", parts: [{ functionCall: { name: "do_x", args: { a: 1 } } }] }]; @@ -598,7 +704,7 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { __resetAntigravityReplayCache(); const adapter = createGoogleAdapter(provider); await adapter.buildRequest(parsed("hello world")); - const body = JSON.stringify({ + const body = { response: { candidates: [{ content: { @@ -609,8 +715,8 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { }, }], }, - }); - const events = await adapter.parseResponse!(new Response(body, { status: 200 })); + }; + const events = await adapter.parseResponse!(sseResponse([body])); expect(events).not.toContainEqual({ type: "text_delta", text: "deciding which tool to call" }); @@ -630,6 +736,7 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { messages: [ { role: "user", content: "go" }, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "get_x", namespace: "mcp__t", arguments: { a: 1 }, thoughtSignature: "sig-abcdef0123456789" }] }, + { role: "toolResult", toolCallId: "c1", toolName: "get_x", content: "ok", isError: false }, ], systemPrompt: [], tools: [], }, @@ -650,6 +757,7 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { messages: [ { role: "user", content: "go" }, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "get_x", namespace: "mcp__t", arguments: {}, thoughtSignature: "fc_d8df7548e31a4130b7624f3d27571cdd" }] }, + { role: "toolResult", toolCallId: "c1", toolName: "get_x", content: "ok", isError: false }, ], systemPrompt: [], tools: [], }, @@ -670,6 +778,7 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { messages: [ { role: "user", content: "go" }, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "get_x", namespace: "mcp__t", arguments: {}, thoughtSignature: "ctc_038f26d3f20962bc016a54f0fcfa208190a8ec0f289c2ba211" }] }, + { role: "toolResult", toolCallId: "c1", toolName: "get_x", content: "ok", isError: false }, ], systemPrompt: [], tools: [], }, diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 207dddd8bc..2af6ac0f4d 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -3,6 +3,13 @@ import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/ada import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests, setDebugSettings } from "../src/lib/debug-settings"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { + CCA_STREAM_CLASSIFY_MAX_BYTES, + CCA_STREAM_PROBE_MAX_BYTES, + CcaProbeBuffer, + fetchAntigravityWithRetry, +} from "../src/adapters/google-http"; +import { isAntigravityAccountInCooldown, clearAntigravityAccountCooldown } from "../src/oauth/antigravity-routing"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -84,13 +91,427 @@ describe("google provider hardening", () => { ); }); + test("CCA unary requests use the always-SSE endpoint", async () => { + const request = await createGoogleAdapter(antigravityProvider()).buildRequest(parsed(false)); + expect(request.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); + }); + + test("AI Studio and Vertex unary requests retain generateContent", async () => { + const aiStudio = await createGoogleAdapter(provider()).buildRequest(parsed(false)); + const vertex = await createGoogleAdapter(provider({ + baseUrl: "https://aiplatform.googleapis.com", + googleMode: "vertex", + apiKey: "vertex-test-key", + })).buildRequest(parsed(false)); + expect(aiStudio.url).toContain(":generateContent"); + expect(aiStudio.url).not.toContain(":streamGenerateContent"); + expect(vertex.url).toContain(":generateContent"); + expect(vertex.url).not.toContain(":streamGenerateContent"); + }); + + test("CCA empty first-host stream fails over to the production host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) return sseResponse([{ response: { candidates: [] } }]); + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + const events = await adapter.parseResponse!(response); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(events.at(-1)?.type).toBe("done"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA auth failure does not fail over to the second host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(JSON.stringify({ error: { status: "UNAUTHENTICATED", message: "bad token" } }), { status: 401 }); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + expect(response.status).toBe(401); + expect(calls).toHaveLength(1); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA EOF terminal error does not fail over to the second host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response( + 'data: {"error":{"status":"UNAUTHENTICATED","message":"bad token"}}', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + + expect(response.status).toBe(200); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(await response.text()).toContain("UNAUTHENTICATED"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA valid oversized SSE output stays on the first host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + const largeText = "x".repeat(300 * 1024); + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: largeText }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + const events = await adapter.parseResponse!(response); + expect(calls).toHaveLength(1); + expect(events).toContainEqual({ type: "text_delta", text: largeText }); + expect(events.at(-1)?.type).toBe("done"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA probe buffer refuses bytes beyond its hard cap", () => { + const cap = 16; + const buffer = new CcaProbeBuffer(cap); + expect(CCA_STREAM_PROBE_MAX_BYTES).toBe(100 * 1024 * 1024); + expect(buffer.append(new Uint8Array(cap))).toBe(true); + expect(buffer.append(new Uint8Array(1))).toBe(false); + expect(buffer.length).toBe(cap); + }); + + test("CCA open empty SSE stream passes through at the classify cap without buffering 100 MiB", async () => { + expect(CCA_STREAM_CLASSIFY_MAX_BYTES).toBe(256 * 1024); + const encoder = new TextEncoder(); + const emptyFrame = encoder.encode("data:\n\n"); + const calls: string[] = []; + const realFetch = globalThis.fetch; + let controller: ReadableStreamDefaultController | undefined; + let enqueuedBytes = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + }, + pull(streamController) { + // Keep the stream open so this is not the EOF-empty failover path, but stop + // enqueueing well below the 100 MiB hard cap so the test itself never allocates it. + if (enqueuedBytes >= CCA_STREAM_CLASSIFY_MAX_BYTES * 2) return; + streamController.enqueue(emptyFrame); + enqueuedBytes += emptyFrame.byteLength; + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const abortController = new AbortController(); + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await Promise.race([ + fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + abortSignal: abortController.signal, + stream: true, + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("CCA classification hung past the classify cap")), 2_000); + }), + ]); + expect(response.status).toBe(200); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(enqueuedBytes).toBeGreaterThanOrEqual(CCA_STREAM_CLASSIFY_MAX_BYTES); + expect(enqueuedBytes).toBeLessThan(CCA_STREAM_PROBE_MAX_BYTES); + await response.body?.cancel(); + } finally { + abortController.abort(); + try { controller?.close(); } catch { /* already closed */ } + globalThis.fetch = realFetch; + } + }); + + test("CCA peer failures use retry and final error normalization", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + return new Response( + JSON.stringify({ error: { status: "UNAVAILABLE", message: "peer overloaded" } }), + { status: 503, headers: { "retry-after": "0" } }, + ); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + + expect(response.status).toBe(503); + expect(await response.text()).toBe("Antigravity server overloaded: peer overloaded"); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA returns the first event before an open upstream stream ends", async () => { + const realFetch = globalThis.fetch; + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | undefined; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + streamController.enqueue(encoder.encode( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"first"}]}}]}}\n\n', + )); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })) as typeof fetch; + const abortController = new AbortController(); + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(true)); + const responsePromise = adapter.fetchResponse!(request, { + timeoutMs: 5_000, + abortSignal: abortController.signal, + stream: true, + }); + const returnedBeforeEof = await Promise.race([ + responsePromise.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 100)), + ]); + expect(returnedBeforeEof).toBe(true); + const response = await responsePromise; + await response.body?.cancel(); + } finally { + abortController.abort(); + controller?.error(new Error("test stream closed")); + globalThis.fetch = realFetch; + } + }); + + test("CCA inline UNAVAILABLE fails over to the production host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + const events = await adapter.parseResponse!(response); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(events.at(-1)?.type).toBe("done"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA geoblock records cooldown without account carousel", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const realFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ + error: { status: "PERMISSION_DENIED", message: "user location is not supported for the api use" }, + }), { status: 403 }); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(403); + expect(calls).toBe(1); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + + test("CCA inline quota error becomes a cooldown-aware 429 without host failover", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const realFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return sseResponse([{ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message: "Quota exceeded for this account", + }, + }]); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(429); + expect(calls).toBe(1); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + + test("CCA peer HTTP 200 RESOURCE_EXHAUSTED after first-host 404 becomes a cooldown-aware 429", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (String(input).includes("daily-cloudcode-pa.googleapis.com")) { + return new Response("not found", { status: 404 }); + } + return sseResponse([{ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message: "Quota exceeded for this account", + }, + }]); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(429); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + + test("CCA peer HTTP 200 geoblock after first-host 503 records cooldown without a third host", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (String(input).includes("daily-cloudcode-pa.googleapis.com")) { + return new Response("unavailable", { status: 503 }); + } + return sseResponse([{ + error: { + status: "PERMISSION_DENIED", + message: "user location is not supported for the api use", + }, + }]); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(403); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + test("Antigravity rejects flat Gemini payloads without the response wrapper", async () => { const adapter = createGoogleAdapter(antigravityProvider()); const flatPayload = { candidates: [{ content: { parts: [{ text: "unexpected" }] } }] }; const streamEvents = await collect(adapter.parseStream(sseResponse([flatPayload]))); const responseEvents = await adapter.parseResponse!( - new Response(JSON.stringify(flatPayload), { status: 200 }), + sseResponse([flatPayload]), ); const expected = [{ diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts new file mode 100644 index 0000000000..29b1341874 --- /dev/null +++ b/tests/google-sse-frame-cap.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + createGoogleAdapter as createGoogleAdapterProduction, + setGoogleSseFrameMaxBytesForTests, +} from "../src/adapters/google"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const CAP = 32; +const originalDecode = TextDecoder.prototype.decode; +let decodedOverflowByteLength = 0; + +function installDecodeProbe(): void { + decodedOverflowByteLength = 0; + TextDecoder.prototype.decode = function ( + this: TextDecoder, + input?: AllowSharedBufferSource, + options?: TextDecodeOptions, + ): string { + const size = input && typeof (input as ArrayBufferView).byteLength === "number" + ? (input as ArrayBufferView).byteLength + : 0; + if (size > CAP) decodedOverflowByteLength = size; + return originalDecode.call(this, input as ArrayBuffer, options); + }; +} + +afterEach(() => { + setGoogleSseFrameMaxBytesForTests(); + TextDecoder.prototype.decode = originalDecode; + decodedOverflowByteLength = 0; +}); + +function googleProvider(): OcxProviderConfig { + return { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "google-test-key", + authMode: "key", + }; +} + +function ccaProvider(): OcxProviderConfig { + return { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "antigravity-test-token", + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "project-test", + }; +} + +/** 20 × U+4E2D: 60 UTF-8 bytes, 20 UTF-16 units — over a 32-byte cap, under it as string length. */ +function oversizedMultibyteChunk(): Uint8Array { + const charUtf8 = new TextEncoder().encode("中"); + const repeats = 20; + const chunk = new Uint8Array(charUtf8.byteLength * repeats); + for (let i = 0; i < repeats; i++) chunk.set(charUtf8, i * charUtf8.byteLength); + return chunk; +} + +function byteStreamResponse(chunks: Uint8Array[]): Response { + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +async function collect(events: AsyncGenerator): Promise { + const collected: AdapterEvent[] = []; + for await (const event of events) collected.push(event); + return collected; +} + +describe("google SSE frame byte cap", () => { + test("rejects an oversize UTF-8 chunk before TextDecoder.decode", async () => { + const chunk = oversizedMultibyteChunk(); + expect(chunk.byteLength).toBeGreaterThan(CAP); + expect(new TextDecoder().decode(chunk).length).toBeLessThan(CAP); + + setGoogleSseFrameMaxBytesForTests(CAP); + installDecodeProbe(); + + const events = await collect( + createGoogleAdapter(googleProvider()).parseStream(byteStreamResponse([chunk])), + ); + + expect(decodedOverflowByteLength).toBe(0); + expect(events).toContainEqual({ + type: "error", + message: `upstream SSE data frame exceeds ${CAP} bytes`, + }); + }); + + test("CCA unary parseResponse applies the same SSE byte cap", async () => { + const chunk = oversizedMultibyteChunk(); + setGoogleSseFrameMaxBytesForTests(CAP); + installDecodeProbe(); + + const events = await createGoogleAdapter(ccaProvider()).parseResponse!( + byteStreamResponse([chunk]), + ); + + expect(decodedOverflowByteLength).toBe(0); + expect(events).toContainEqual({ + type: "error", + message: `upstream SSE data frame exceeds ${CAP} bytes`, + }); + }); +}); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 9499de4d85..fd829eb5bb 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1234,6 +1234,42 @@ test("CCA image fallback preserves upstream 429 status", async () => { } }); +test("CCA image fallback does not retry a transport failure on the peer host", async () => { + let calls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const hostname = new URL(requestUrl).hostname; + if (hostname === "daily-cloudcode-pa.googleapis.com") { + calls += 1; + throw new TypeError("fetch failed: connection reset after acceptance"); + } + if (hostname === "cloudcode-pa.googleapis.com") { + calls += 1; + return Response.json({ + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] }, + }], + }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const request = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + const response = await handleImages(request, ccaConfig(), "generations", { model: "", provider: "" } as never); + + expect(response.status).toBe(400); + expect(calls).toBe(1); +}); + test("CCA fallback does not serve image edits", async () => { saveConfig(ccaConfig()); await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); @@ -1399,16 +1435,19 @@ test("CCA OAuth no credential saved returns 401 (login required), not a misleadi } }); -test("CCA fetch network failure returns 502 without leaking the timeout timer", async () => { - // Mock: CCA fetch always fails with a network error. The bug was that the - // fetch catch returned 502 without calling linkedSignal.cleanup(), leaving - // the timeout timer alive. With a short timeout this would keep the process - // alive. The fix wraps everything in try/finally so cleanup always runs. +test("CCA fetch network failure returns 400 without leaking the timeout timer", async () => { + // Mock: CCA fetch always fails with a network error after the POST is attempted. + // Image generation is a paid non-idempotent POST; Codex retries every 5xx up to 5 + // attempts, so transport failure after fetch is attempted must be non-5xx (400). + // The timeout timer still must not leak: linkedSignal.cleanup() runs in finally. + // With a 10s images timeout and a 5s test timeout, a leaked timer fails this test. + let ccaPosts = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const url = new URL(requestUrl); if (url.hostname === "daily-cloudcode-pa.googleapis.com") { - throw new TypeError("fetch failed: connection refused"); + if ((init?.method ?? "GET").toUpperCase() === "POST") ccaPosts += 1; + throw new TypeError("fetch failed: https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent connection refused"); } return originalFetch(input, init); }) as typeof fetch; @@ -1423,9 +1462,14 @@ test("CCA fetch network failure returns 502 without leaking the timeout timer", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "a cat" }), }); - expect(response.status).toBe(502); - const json = await response.json() as { error: { message: string } }; - expect(json.error.message).toContain("CCA image generation failed"); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string; type: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toMatch(/may have started/i); + expect(json.error.message).toMatch(/must not be blindly retried/i); + expect(json.error.message).not.toContain("https://"); + expect(json.error.message).not.toContain("daily-cloudcode-pa.googleapis.com"); + expect(ccaPosts).toBe(1); } finally { await server.stop(true); } diff --git a/tests/state-store-sweeper.test.ts b/tests/state-store-sweeper.test.ts index 8b9ac2f10d..a63ecc63f5 100644 --- a/tests/state-store-sweeper.test.ts +++ b/tests/state-store-sweeper.test.ts @@ -87,6 +87,7 @@ describe("state-store sweeper", () => { "provider-request-pacing", "combo-target-cooldowns", "anthropic-routing-health", + "antigravity-routing-health", "xai-refresh-verdicts", "responses-continuation", "antigravity-replay",