From f725711435b182dbdfde86d73f3fd3ee345a7fe0 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:34 -0600 Subject: [PATCH 01/24] feat(antigravity): live quota RPC and geoblock classification Probe retrieveUserQuota with catalog fallback, skip http Bearer destinations, and surface Cloud Code Assist location blocks before generic 403s. Co-authored-by: Cursor --- src/adapters/google-antigravity-hosts.ts | 24 ++ src/adapters/google-errors.ts | 7 + src/providers/antigravity-quota.ts | 212 +++++++++++++++ src/providers/quota.ts | 87 +++++-- tests/antigravity-quota.test.ts | 319 +++++++++++++++++++++++ tests/google-antigravity-errors.test.ts | 59 +++++ 6 files changed, 682 insertions(+), 26 deletions(-) create mode 100644 src/adapters/google-antigravity-hosts.ts create mode 100644 src/providers/antigravity-quota.ts create mode 100644 tests/antigravity-quota.test.ts create mode 100644 tests/google-antigravity-errors.test.ts diff --git a/src/adapters/google-antigravity-hosts.ts b/src/adapters/google-antigravity-hosts.ts new file mode 100644 index 0000000000..b9dc041b17 --- /dev/null +++ b/src/adapters/google-antigravity-hosts.ts @@ -0,0 +1,24 @@ +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])]; +} + +/** OAuth bearer requests must not use a cleartext host, even if generic baseUrl config allows http. */ +export function isAntigravityHttpsHost(host: string): boolean { + try { + return new URL(host).protocol === "https:"; + } catch { + return false; + } +} 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/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts new file mode 100644 index 0000000000..339df293ab --- /dev/null +++ b/src/providers/antigravity-quota.ts @@ -0,0 +1,212 @@ +import { antigravityUserAgent } from "../adapters/client-fingerprint"; +import { antigravityHostCandidates, isAntigravityHttpsHost } 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 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 (!isAntigravityHttpsHost(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 24b06ef7a9..7bc646a988 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, isAntigravityHttpsHost } 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,72 @@ 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()) { + if (!isAntigravityHttpsHost(host)) continue; + 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/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts new file mode 100644 index 0000000000..2564a90924 --- /dev/null +++ b/tests/antigravity-quota.test.ts @@ -0,0 +1,319 @@ +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") }, + ]); + }); + + test("does not POST fetchAvailableModels to an http host", async () => { + const httpHost = "http://daily-cloudcode-pa.googleapis.com"; + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + await fetchProviderQuotaReports(config(httpHost), true); + + expect(requested.filter(url => url.startsWith("http://"))).toEqual([]); + expect(requested).not.toContain(`${httpHost}/v1internal:fetchAvailableModels`); + }); +}); 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); + }); +}); From f3f8a5115cc66ff7009aea9fdfb94776c2199f51 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:11 -0600 Subject: [PATCH 02/24] fix(antigravity): harden quota interpretation and catalog fetch Prevent quota probes from following redirects or promoting daily summaries as weekly usage, and interpret explicit percentage fields without converting small percentages into fractions. Co-authored-by: Cursor --- src/providers/antigravity-quota.ts | 9 ++---- src/providers/quota.ts | 3 +- tests/antigravity-quota.test.ts | 30 +++++++++++++++++++ tests/provider-quota.test.ts | 48 ++++++++++++++++++++++++++++-- 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts index 339df293ab..91d1d75cc9 100644 --- a/src/providers/antigravity-quota.ts +++ b/src/providers/antigravity-quota.ts @@ -57,7 +57,7 @@ function remainingPercent(record: Record): number | undefined { ?? record.remainingPercent ?? record.remaining_percent, ); - if (percentage !== undefined) return normalizePercent(percentage <= 1 ? percentage * 100 : percentage); + if (percentage !== undefined) return normalizePercent(percentage); return undefined; } @@ -111,11 +111,8 @@ function isWeeklyPath(path: string[]): boolean { 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) { + for (const candidate of candidates) { + if (!isWeeklyPath(candidate.path)) continue; const percent = usedPercent(candidate.record); if (percent === undefined) continue; const reset = recordResetAt(candidate.record); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 7bc646a988..5844407c70 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1995,7 +1995,7 @@ function antigravityUsedPercent(quotaInfo: Record): number | un const remaining = normalizePercent(toFiniteNumber(quotaInfo.remainingFraction) !== undefined ? toFiniteNumber(quotaInfo.remainingFraction)! * 100 : toFiniteNumber(quotaInfo.remainingPercentage) !== undefined - ? toFiniteNumber(quotaInfo.remainingPercentage)! * 100 + ? toFiniteNumber(quotaInfo.remainingPercentage)! : undefined); if (remaining === undefined) return undefined; return normalizePercent(100 - remaining); @@ -2031,6 +2031,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ project: credential.projectId }), + redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index 2564a90924..518248c7aa 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -1,3 +1,33 @@ +import { expect, test } from "bun:test"; +import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; + +test("does not classify an unlabelled daily summary window as weekly", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return new Response(JSON.stringify({ + models: { + "gemini-test": { quotaInfo: { remainingFraction: 0.5 } }, + }, + }), { status: 200 }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + daily: { remainingFraction: 0.75 }, + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + + expect(result?.customWindows?.[0]?.label).toBe("Gem"); + expect(result?.weeklyPercent).toBeUndefined(); +}); import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 1c4e733683..db1bad354a 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -121,11 +121,16 @@ describe("fetchProviderQuotaReports", () => { await saveCredential("google-antigravity", { access: "agy-access-secret", refresh: "agy-refresh-secret", expires: Date.now() + 3600_000, projectId: "agy-project-secret" }); await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); - const seen: { url: string; authorization?: string; body?: string }[] = []; + const seen: { url: string; authorization?: string; body?: string; redirect?: RequestRedirect }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const headers = init?.headers as Record | undefined; - seen.push({ url, authorization: headers?.Authorization, body: typeof init?.body === "string" ? init.body : undefined }); + seen.push({ + url, + authorization: headers?.Authorization, + body: typeof init?.body === "string" ? init.body : undefined, + redirect: init?.redirect, + }); if (url === "https://chatgpt.com/backend-api/wham/usage") { return new Response(JSON.stringify({ email: "person@example.com", @@ -255,9 +260,48 @@ describe("fetchProviderQuotaReports", () => { expect(seen.find(row => row.url.includes("anthropic.com"))?.authorization).toBe("Bearer claude-access-secret"); expect(seen.find(row => row.url.includes("cloudcode-pa.googleapis.com"))?.authorization).toBe("Bearer agy-access-secret"); expect(seen.find(row => row.url.includes("cloudcode-pa.googleapis.com"))?.body).toBe(JSON.stringify({ project: "agy-project-secret" })); + expect(seen.find(row => row.url.endsWith("/v1internal:fetchAvailableModels"))?.redirect).toBe("error"); expect(seen.find(row => row.url === "https://api.kimi.com/coding/v1/usages")?.authorization).toBe("Bearer kimi-access-secret"); }); + test("treats remainingPercentage as a percentage at values one and below", async () => { + await saveCredential("google-antigravity", { + access: "agy-access-secret", + refresh: "agy-refresh-secret", + expires: Date.now() + 3600_000, + projectId: "agy-project-secret", + }); + const config = { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, + }, + } as OcxConfig; + + for (const [remainingPercentage, expectedUsedPercentage] of [[1, 99], [0.75, 99.25], [75, 25]]) { + clearProviderQuotaCache(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(":retrieveUserQuota")) return new Response("not found", { status: 404 }); + if (url.endsWith("/v1internal:fetchAvailableModels")) { + return new Response(JSON.stringify({ + models: { + "gemini-test": { quotaInfo: { remainingPercentage } }, + }, + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(expectedUsedPercentage); + } + }); + function kimiOnlyConfig(baseUrl = "https://api.kimi.com/coding/v1"): OcxConfig { return { defaultProvider: "kimi", From 23ce6d2b82166150d81d3a878ef92642eeca75ae Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:09:10 -0600 Subject: [PATCH 03/24] fix(antigravity): preserve usable quota when summary fails Treat unreadable daily quota JSON as an RPC failure while allowing the optional summary call to fail without discarding the daily window. Co-authored-by: Cursor --- src/providers/antigravity-quota.ts | 22 ++++++------- tests/antigravity-quota.test.ts | 51 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts index 91d1d75cc9..0e4a66d2ff 100644 --- a/src/providers/antigravity-quota.ts +++ b/src/providers/antigravity-quota.ts @@ -122,7 +122,9 @@ function parseWeeklyWindow(payload: unknown): { percent: number; resetAt?: numbe } async function readJson(response: Response, timeoutMs: number): Promise { - return await readProviderQuotaJsonForTests(response, timeoutMs); + const payload = await readProviderQuotaJsonForTests(response, timeoutMs); + if (payload === null) throw new Error("Antigravity quota RPC returned unreadable JSON"); + return payload; } class AntigravityQuotaRpcError extends Error { @@ -167,18 +169,16 @@ async function fetchHostQuota( 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" + && quotaResult.reason instanceof AntigravityQuotaRpcError + && !shouldRetryPeer(quotaResult.reason.status) + ) { + throw quotaResult.reason; } - if (quotaResult.status === "rejected" || summaryResult.status === "rejected") return null; + if (quotaResult.status === "rejected") return null; const quotaPayload = quotaResult.value; - const summaryPayload = summaryResult.value; + const summaryPayload = summaryResult.status === "fulfilled" ? summaryResult.value : null; const gem = parseGeminiWindow(quotaPayload); const weekly = parseWeeklyWindow(summaryPayload); if (!gem && !weekly) return null; diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index 518248c7aa..aef37eb3fc 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; +import { QUOTA_RESPONSE_MAX_BYTES } from "../src/providers/quota"; test("does not classify an unlabelled daily summary window as weekly", async () => { const result = await fetchAntigravityLiveQuota({ @@ -28,6 +29,56 @@ test("does not classify an unlabelled daily summary window as weekly", async () expect(result?.customWindows?.[0]?.label).toBe("Gem"); expect(result?.weeklyPercent).toBeUndefined(); }); + +test("keeps the daily quota when the summary RPC fails", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return new Response(JSON.stringify({ + buckets: [ + { modelId: "gemini-test", remainingFraction: 0.5 }, + ], + }), { status: 200 }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) return new Response("unavailable", { status: 503 }); + return new Response("not found", { status: 404 }); + }, + }); + + expect(result?.customWindows).toEqual([{ label: "Gem", percent: 50 }]); + expect(result?.weeklyPercent).toBeUndefined(); +}); + +test("treats a daily quota JSON read failure as an RPC failure", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return new Response(JSON.stringify({ + padding: "x".repeat(QUOTA_RESPONSE_MAX_BYTES), + }), { status: 200 }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + weekly: { remainingPercentage: 75 }, + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + + expect(result).toBeNull(); +}); + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; From 396bd294b56bd0079e07ff041ed143c417dd6caf Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:26 -0600 Subject: [PATCH 04/24] test(antigravity): assert quota RPC redirect policy Co-authored-by: Cursor --- tests/antigravity-quota.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index aef37eb3fc..b41cd2bd7b 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -3,13 +3,15 @@ import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; import { QUOTA_RESPONSE_MAX_BYTES } from "../src/providers/quota"; test("does not classify an unlabelled daily summary window as weekly", async () => { + const requestOptions: Array<{ url: string; init?: RequestInit }> = []; const result = await fetchAntigravityLiveQuota({ accessToken: "agy-access-secret", projectId: "agy-project-secret", baseUrl: "https://daily-cloudcode-pa.googleapis.com", timeoutMs: 1_000, - fetchImpl: async (input: RequestInfo | URL) => { + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); + requestOptions.push({ url, init }); if (url.endsWith(":retrieveUserQuota")) { return new Response(JSON.stringify({ models: { @@ -28,6 +30,11 @@ test("does not classify an unlabelled daily summary window as weekly", async () expect(result?.customWindows?.[0]?.label).toBe("Gem"); expect(result?.weeklyPercent).toBeUndefined(); + expect( + requestOptions + .filter(({ url }) => url.includes(":retrieveUserQuota")) + .map(({ init }) => init?.redirect), + ).toEqual(["error", "error"]); }); test("keeps the daily quota when the summary RPC fails", async () => { From 2c459372c4171f7892d7a84f15d4523ddd7a3ab2 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:17:27 -0600 Subject: [PATCH 05/24] fix(antigravity): stop quota probe on terminal RPC errors Restrict host failover to known Google daily/prod endpoints, require explicit model identifiers for Gemini window parsing, and abort live quota plus catalog probing on 401/403/429 instead of leaking tokens to production peers. Co-authored-by: Cursor --- src/adapters/google-antigravity-hosts.ts | 16 +- src/providers/antigravity-quota.ts | 40 ++-- src/providers/quota.ts | 26 ++- tests/antigravity-quota.test.ts | 266 ++++++++++++++--------- 4 files changed, 218 insertions(+), 130 deletions(-) diff --git a/src/adapters/google-antigravity-hosts.ts b/src/adapters/google-antigravity-hosts.ts index b9dc041b17..a2c1fe096d 100644 --- a/src/adapters/google-antigravity-hosts.ts +++ b/src/adapters/google-antigravity-hosts.ts @@ -2,16 +2,18 @@ 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. + * Return the configured Antigravity endpoint and, for Google's known daily/prod hosts + * only, its daily/production peer. Custom baseUrl values stay single-host. */ 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])]; + if (configured === DAILY_ANTIGRAVITY_HOST) { + return [DAILY_ANTIGRAVITY_HOST, PROD_ANTIGRAVITY_HOST]; + } + if (configured === PROD_ANTIGRAVITY_HOST) { + return [PROD_ANTIGRAVITY_HOST, DAILY_ANTIGRAVITY_HOST]; + } + return [configured]; } /** OAuth bearer requests must not use a cleartext host, even if generic baseUrl config allows http. */ diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts index 0e4a66d2ff..8bddf0b5e3 100644 --- a/src/providers/antigravity-quota.ts +++ b/src/providers/antigravity-quota.ts @@ -84,15 +84,19 @@ function collectCandidates(value: unknown, path: string[] = [], output: QuotaCan 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 candidateModelName(record: Record): string { + const explicit = record.modelId + ?? record.model_id + ?? record.modelName + ?? record.model + ?? record.name + ?? record.displayName; + return typeof explicit === "string" ? explicit.toLowerCase() : ""; } function parseGeminiWindow(payload: unknown): ProviderQuotaWindow | undefined { for (const candidate of collectCandidates(payload)) { - if (!candidateModelName(candidate).includes("gemini")) continue; + if (!candidateModelName(candidate.record).includes("gemini")) continue; const percent = usedPercent(candidate.record); if (percent === undefined) continue; const reset = recordResetAt(candidate.record); @@ -127,14 +131,19 @@ async function readJson(response: Response, timeoutMs: number): Promise return payload; } -class AntigravityQuotaRpcError extends Error { +export class AntigravityQuotaRpcError extends Error { constructor(readonly status: number) { super(`Antigravity quota RPC failed: ${status}`); } } -function shouldRetryPeer(status: number): boolean { - return status === 404 || status === 503; +export function isTerminalAntigravityQuotaStatus(status: number): boolean { + return status === 401 || status === 403 || status === 429; +} + +function terminalRpcError(result: PromiseSettledResult): AntigravityQuotaRpcError | null { + if (result.status !== "rejected" || !(result.reason instanceof AntigravityQuotaRpcError)) return null; + return isTerminalAntigravityQuotaStatus(result.reason.status) ? result.reason : null; } async function fetchRpc( @@ -169,13 +178,8 @@ async function fetchHostQuota( fetchRpc(fetchImpl, host, "retrieveUserQuota", args), fetchRpc(fetchImpl, host, "retrieveUserQuotaSummary", args), ]); - if ( - quotaResult.status === "rejected" - && quotaResult.reason instanceof AntigravityQuotaRpcError - && !shouldRetryPeer(quotaResult.reason.status) - ) { - throw quotaResult.reason; - } + const terminalError = terminalRpcError(quotaResult) ?? terminalRpcError(summaryResult); + if (terminalError) throw terminalError; if (quotaResult.status === "rejected") return null; const quotaPayload = quotaResult.value; const summaryPayload = summaryResult.status === "fulfilled" ? summaryResult.value : null; @@ -201,8 +205,10 @@ export async function fetchAntigravityLiveQuota( try { const quota = await fetchHostQuota(fetchImpl, host, args); if (quota) return quota; - } catch { - return null; + } catch (error) { + if (error instanceof AntigravityQuotaRpcError && isTerminalAntigravityQuotaStatus(error.status)) { + throw error; + } } } return null; diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 5844407c70..c7d68228c8 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -28,7 +28,11 @@ import { type CodexCapacityAggregation, type CodexCapacityQuota, } from "./codex-capacity"; -import { fetchAntigravityLiveQuota } from "./antigravity-quota"; +import { + AntigravityQuotaRpcError, + fetchAntigravityLiveQuota, + isTerminalAntigravityQuotaStatus, +} from "./antigravity-quota"; import { antigravityHostCandidates, isAntigravityHttpsHost } from "../adapters/google-antigravity-hosts"; /** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ @@ -2011,12 +2015,20 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig return null; } const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, ""); - const liveQuota = await fetchAntigravityLiveQuota({ - accessToken, - projectId: credential.projectId, - baseUrl, - timeoutMs: REQUEST_TIMEOUT_MS, - }); + let liveQuota: ProviderQuota | null; + try { + liveQuota = await fetchAntigravityLiveQuota({ + accessToken, + projectId: credential.projectId, + baseUrl, + timeoutMs: REQUEST_TIMEOUT_MS, + }); + } catch (error) { + if (error instanceof AntigravityQuotaRpcError && isTerminalAntigravityQuotaStatus(error.status)) { + return null; + } + liveQuota = null; + } const windows = new Map(); for (const [index, host] of antigravityHostCandidates(baseUrl).entries()) { diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index b41cd2bd7b..c7249dd621 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -1,91 +1,3 @@ -import { expect, test } from "bun:test"; -import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; -import { QUOTA_RESPONSE_MAX_BYTES } from "../src/providers/quota"; - -test("does not classify an unlabelled daily summary window as weekly", async () => { - const requestOptions: Array<{ url: string; init?: RequestInit }> = []; - const result = await fetchAntigravityLiveQuota({ - accessToken: "agy-access-secret", - projectId: "agy-project-secret", - baseUrl: "https://daily-cloudcode-pa.googleapis.com", - timeoutMs: 1_000, - fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - requestOptions.push({ url, init }); - if (url.endsWith(":retrieveUserQuota")) { - return new Response(JSON.stringify({ - models: { - "gemini-test": { quotaInfo: { remainingFraction: 0.5 } }, - }, - }), { status: 200 }); - } - if (url.endsWith(":retrieveUserQuotaSummary")) { - return new Response(JSON.stringify({ - daily: { remainingFraction: 0.75 }, - }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }, - }); - - expect(result?.customWindows?.[0]?.label).toBe("Gem"); - expect(result?.weeklyPercent).toBeUndefined(); - expect( - requestOptions - .filter(({ url }) => url.includes(":retrieveUserQuota")) - .map(({ init }) => init?.redirect), - ).toEqual(["error", "error"]); -}); - -test("keeps the daily quota when the summary RPC fails", async () => { - const result = await fetchAntigravityLiveQuota({ - accessToken: "agy-access-secret", - projectId: "agy-project-secret", - baseUrl: "https://daily-cloudcode-pa.googleapis.com", - timeoutMs: 1_000, - fetchImpl: async (input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith(":retrieveUserQuota")) { - return new Response(JSON.stringify({ - buckets: [ - { modelId: "gemini-test", remainingFraction: 0.5 }, - ], - }), { status: 200 }); - } - if (url.endsWith(":retrieveUserQuotaSummary")) return new Response("unavailable", { status: 503 }); - return new Response("not found", { status: 404 }); - }, - }); - - expect(result?.customWindows).toEqual([{ label: "Gem", percent: 50 }]); - expect(result?.weeklyPercent).toBeUndefined(); -}); - -test("treats a daily quota JSON read failure as an RPC failure", async () => { - const result = await fetchAntigravityLiveQuota({ - accessToken: "agy-access-secret", - projectId: "agy-project-secret", - baseUrl: "https://daily-cloudcode-pa.googleapis.com", - timeoutMs: 1_000, - fetchImpl: async (input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith(":retrieveUserQuota")) { - return new Response(JSON.stringify({ - padding: "x".repeat(QUOTA_RESPONSE_MAX_BYTES), - }), { status: 200 }); - } - if (url.endsWith(":retrieveUserQuotaSummary")) { - return new Response(JSON.stringify({ - weekly: { remainingPercentage: 75 }, - }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }, - }); - - expect(result).toBeNull(); -}); - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -183,10 +95,121 @@ afterEach(() => { rmSync(opencodexHome, { recursive: true, force: true }); }); +test("does not classify an unlabelled daily summary window as weekly", async () => { + const requestOptions: Array<{ url: string; init?: RequestInit }> = []; + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: DAILY_HOST, + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requestOptions.push({ url, init }); + if (url.endsWith(":retrieveUserQuota")) { + return jsonResponse({ + buckets: [ + { modelId: "gemini-test", remainingFraction: 0.5 }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return jsonResponse({ + daily: { remainingFraction: 0.75 }, + }); + } + return jsonResponse({}, 404); + }, + }); + + expect(result?.customWindows?.[0]?.label).toBe("Gem"); + expect(result?.weeklyPercent).toBeUndefined(); + expect( + requestOptions + .filter(({ url }) => url.includes(":retrieveUserQuota")) + .map(({ init }) => init?.redirect), + ).toEqual(["error", "error"]); +}); + +test("keeps the daily quota when the summary RPC fails", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: DAILY_HOST, + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return jsonResponse({ + buckets: [ + { modelId: "gemini-test", remainingFraction: 0.5 }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) return jsonResponse({}, 503); + return jsonResponse({}, 404); + }, + }); + + expect(result?.customWindows).toEqual([{ label: "Gem", percent: 50 }]); + expect(result?.weeklyPercent).toBeUndefined(); +}); + +test("treats a daily quota JSON read failure as an RPC failure", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: DAILY_HOST, + timeoutMs: 1_000, + fetchImpl: 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 jsonResponse({ + weekly: { remainingPercentage: 75 }, + }); + } + return jsonResponse({}, 404); + }, + }); + + expect(result).toBeNull(); +}); + +test("does not parse gemini from ancestor JSON path keys", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: TOKEN, + projectId: PROJECT, + baseUrl: DAILY_HOST, + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return jsonResponse({ + "gemini-quotas": { + items: [{ remainingFraction: 0.5 }], + }, + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) return jsonResponse({}, 404); + return jsonResponse({}, 404); + }, + }); + + expect(result?.customWindows).toBeUndefined(); +}); + describe("Antigravity live quota", () => { test("merges live Gemini and weekly quota with catalog-only Claude windows", async () => { - globalThis.fetch = (async (input: RequestInfo | URL) => { + const requestOptions: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); + requestOptions.push({ url, init }); if (url.endsWith(":retrieveUserQuota")) { return jsonResponse({ buckets: [ @@ -213,6 +236,7 @@ describe("Antigravity live quota", () => { ]); expect(report?.quota.weeklyPercent).toBe(25); expect(report?.quota.weeklyResetAt).toBe(Date.parse("2026-08-25T00:00:00Z")); + expect(requestOptions.every(({ init }) => init?.redirect === "error")).toBe(true); }); test("retries the production host after daily retrieveUserQuota returns 404", async () => { @@ -299,7 +323,7 @@ describe("Antigravity live quota", () => { expect(report?.quota.weeklyPercent).toBeUndefined(); }); - test("does not fetch the production host after daily retrieveUserQuota returns 401", async () => { + test("does not fetch production or catalog after daily retrieveUserQuota returns 401", async () => { const requested: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); @@ -307,7 +331,7 @@ describe("Antigravity live quota", () => { 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(); + if (url.endsWith(":fetchAvailableModels")) return jsonResponse({}, 404); return jsonResponse({}, 404); }) as typeof fetch; @@ -315,10 +339,11 @@ describe("Antigravity live quota", () => { 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"); + expect(requested.filter(url => url.endsWith(":fetchAvailableModels"))).toEqual([]); + expect(result.reports).toEqual([]); }); - test("does not fetch the production host when daily retrieveUserQuota 401 races a 404 summary", async () => { + test("does not fetch production or catalog when daily retrieveUserQuota 401 races a 404 summary", async () => { const requested: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); @@ -330,7 +355,7 @@ describe("Antigravity live quota", () => { 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(); + if (url.endsWith(":fetchAvailableModels")) return jsonResponse({}, 404); return jsonResponse({}, 404); }) as typeof fetch; @@ -338,10 +363,11 @@ describe("Antigravity live quota", () => { 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"); + expect(requested.filter(url => url.endsWith(":fetchAvailableModels"))).toEqual([]); + expect(result.reports).toEqual([]); }); - test("does not fetch the production host after daily retrieveUserQuota returns 429", async () => { + test("does not fetch production or catalog after daily retrieveUserQuota returns 429", async () => { const requested: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); @@ -349,6 +375,26 @@ describe("Antigravity live quota", () => { 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 jsonResponse({}, 503); + 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(requested.filter(url => url.endsWith(":fetchAvailableModels"))).toEqual([]); + expect(result.reports).toEqual([]); + }); + + test("does not fetch production or catalog after daily retrieveUserQuota returns 403", 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({}, 403); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); return jsonResponse({}, 404); }) as typeof fetch; @@ -357,6 +403,30 @@ describe("Antigravity live quota", () => { expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(requested.filter(url => url.endsWith(":fetchAvailableModels"))).toEqual([]); + expect(result.reports).toEqual([]); + }); + + test("does not fail over to daily or prod for a custom baseUrl on 404/503", async () => { + const customHost = "https://custom-proxy.example.com"; + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.startsWith(customHost) && url.includes(":retrieveUserQuota")) return jsonResponse({}, 404); + if (url.startsWith(customHost) && url.includes(":retrieveUserQuotaSummary")) return jsonResponse({}, 503); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(customHost), true); + + expect(requested.filter(url => url.startsWith(DAILY_HOST) || url.startsWith(PROD_HOST))).toEqual([]); + expect(requested.filter(url => url.startsWith(customHost))).toEqual([ + `${customHost}/v1internal:retrieveUserQuota`, + `${customHost}/v1internal:retrieveUserQuotaSummary`, + `${customHost}/v1internal:fetchAvailableModels`, + ]); expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); }); @@ -382,9 +452,7 @@ describe("Antigravity live quota", () => { 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") }, - ]); + expect(quota).toBeNull(); }); test("does not POST fetchAvailableModels to an http host", async () => { From 2946115007593444b7fb1163c49065ba5933333a Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:48:27 -0600 Subject: [PATCH 06/24] fix(antigravity): drop last-good quota on terminal RPC and classify weekly on the leaf path Co-authored-by: Cursor --- src/providers/antigravity-quota.ts | 3 +- src/providers/quota.ts | 4 +-- tests/antigravity-quota.test.ts | 48 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts index 8bddf0b5e3..c4f0cb58f2 100644 --- a/src/providers/antigravity-quota.ts +++ b/src/providers/antigravity-quota.ts @@ -110,7 +110,8 @@ function parseGeminiWindow(payload: unknown): ProviderQuotaWindow | undefined { } function isWeeklyPath(path: string[]): boolean { - return path.some(part => /weekly|week|seven[_-]?day/i.test(part)); + const leaf = path.at(-1); + return typeof leaf === "string" && /weekly|week|seven[_-]?day/i.test(leaf); } function parseWeeklyWindow(payload: unknown): { percent: number; resetAt?: number } | undefined { diff --git a/src/providers/quota.ts b/src/providers/quota.ts index c7d68228c8..c03e0b804a 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -2005,7 +2005,7 @@ function antigravityUsedPercent(quotaInfo: Record): number | un return normalizePercent(100 - remaining); } -async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { +async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { const credential = getCredential("google-antigravity"); if (!credential?.projectId) return null; let accessToken: string; @@ -2025,7 +2025,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig }); } catch (error) { if (error instanceof AntigravityQuotaRpcError && isTerminalAntigravityQuotaStatus(error.status)) { - return null; + return TERMINAL_QUOTA_FAILURE; } liveQuota = null; } diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index c7249dd621..0a910d959d 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -95,6 +95,33 @@ afterEach(() => { rmSync(opencodexHome, { recursive: true, force: true }); }); +test("does not classify a daily bucket nested under a weekly ancestor as weekly", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: TOKEN, + projectId: PROJECT, + baseUrl: DAILY_HOST, + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return jsonResponse({ + buckets: [ + { modelId: "gemini-test", remainingFraction: 0.5 }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return jsonResponse({ + weekly: { daily: { remainingPercentage: 90 } }, + }); + } + return jsonResponse({}, 404); + }, + }); + + expect(result?.weeklyPercent).toBeUndefined(); +}); + test("does not classify an unlabelled daily summary window as weekly", async () => { const requestOptions: Array<{ url: string; init?: RequestInit }> = []; const result = await fetchAntigravityLiveQuota({ @@ -343,6 +370,27 @@ describe("Antigravity live quota", () => { expect(result.reports).toEqual([]); }); + test("drops last-good Antigravity quota after a terminal 401 refresh", async () => { + let rejected = false; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (rejected && url.includes(":retrieveUserQuota") && !url.includes("Summary")) { + return jsonResponse({}, 401); + } + if (url.endsWith(":retrieveUserQuota") && !url.includes("Summary")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const valid = await fetchProviderQuotaReports(config(), true); + rejected = true; + const rejectedRefresh = await fetchProviderQuotaReports(config(), true); + + expect(valid.reports).toHaveLength(1); + expect(rejectedRefresh.reports).toEqual([]); + }); + test("does not fetch production or catalog when daily retrieveUserQuota 401 races a 404 summary", async () => { const requested: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { From b5b8a06bca7f335b62735e87bf0f7cd0a2526728 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:34 -0600 Subject: [PATCH 07/24] feat(antigravity): Claude CCA wire fidelity Send the interleaved-thinking header and preamble replacement, strip trailing Claude prefills, repair orphan tool pairs, and parse unary CCA as SSE with a pre-decode frame cap. Co-authored-by: Cursor --- src/adapters/google-antigravity-tools.ts | 87 ++++++++++++++++ src/adapters/google.ts | 74 +++++++++----- tests/google-adapter.test.ts | 27 +++++ tests/google-antigravity-wire.test.ts | 125 +++++++++++++++++++++-- tests/google-sse-frame-cap.test.ts | 116 +++++++++++++++++++++ 5 files changed, 394 insertions(+), 35 deletions(-) create mode 100644 src/adapters/google-antigravity-tools.ts create mode 100644 tests/google-sse-frame-cap.test.ts 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.ts b/src/adapters/google.ts index f152d91741..c689bb4a5b 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"; @@ -159,9 +160,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); @@ -170,7 +172,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": { @@ -315,6 +317,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. @@ -448,8 +456,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); @@ -484,6 +493,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 @@ -498,10 +510,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 { @@ -532,6 +548,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) }; } @@ -608,8 +627,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; @@ -756,6 +775,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" }); @@ -763,13 +791,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() ?? ""; @@ -842,6 +863,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. @@ -912,15 +941,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; @@ -932,10 +953,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[]); } let pendingThoughtSig: string | undefined; @@ -977,7 +997,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/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 15af3a264a..4d65b99740 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-wire.test.ts b/tests/google-antigravity-wire.test.ts index 8aefb92d46..790d2b0e50 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, registerAntigravityDiscoveredWireModels, 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([ @@ -658,6 +710,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" } }); @@ -680,10 +776,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); @@ -696,8 +802,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 } } }] }]; @@ -716,7 +822,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: { @@ -727,8 +833,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" }); @@ -748,6 +854,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: [], }, @@ -768,6 +875,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: [], }, @@ -788,6 +896,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-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`, + }); + }); +}); From 3f0e602a3cbe0fdac210f91f4e6c77e61c3ce227 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:14:53 -0600 Subject: [PATCH 08/24] fix(google): retain CCA events and cap SSE frames correctly Keep unary Cloud Code Assist events charged to the translator budget and measure SSE limits per byte-delimited frame so valid frames sharing a transport chunk are accepted. Co-authored-by: Cursor --- src/adapters/google.ts | 22 ++++++++++++++++++---- tests/google-antigravity-wire.test.ts | 14 +++++++++++++- tests/google-sse-frame-cap.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index c689bb4a5b..b5831c4046 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -324,6 +324,17 @@ export function setGoogleSseFrameMaxBytesForTests(bytes?: number): void { sseFrameMaxBytes = bytes ?? MAX_SSE_FRAME_BYTES; } +function maxSseLineBytes(bufferBytes: number, incoming: Uint8Array): number { + let lineBytes = bufferBytes; + let maximum = lineBytes; + for (const byte of incoming) { + lineBytes += 1; + maximum = Math.max(maximum, lineBytes); + if (byte === 0x0a) lineBytes = 0; + } + return maximum; +} + // Note: imagen-* models use a different API surface (prediction/image-generation // schema) and must NOT be treated as responseModalities-capable Gemini models. // Explicit allowlist only — never `/gemini/ && /image/` (resurrects media-gen IDs). @@ -627,7 +638,7 @@ 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 > sseFrameMaxBytes) { + if (budgetEncoder.encode(payload).byteLength > sseFrameMaxBytes) { yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; return "terminate"; } @@ -776,10 +787,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte 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 — + // Cap each incomplete line 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) { + // buffer.length is UTF-16 units rather than bytes. Reset at each newline so + // several sub-cap frames delivered in one network chunk are not rejected as + // one oversized frame. + if (maxSseLineBytes(bufferBytes, value) > sseFrameMaxBytes) { yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; try { await reader.cancel(); } catch { /* ignore */ } return; @@ -869,6 +882,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (provider.googleMode === "cloud-code-assist") { const events: AdapterEvent[] = []; for await (const event of this.parseStream(response, budget)) events.push(event); + retainTranslatedEventBatch(events, budget); return events; } // Reject oversized responses before JSON parse. Prefer Content-Length when diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 790d2b0e50..cb681d7a92 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -6,7 +6,7 @@ import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "../src/adapte import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_EFFORTS, canonicalAntigravityUsageModel, parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels, 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"; -import { withTestTranslatorBudget } from "./helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "./helpers/translator-budget"; const createGoogleAdapter = (...args: Parameters) => withTestTranslatorBudget(createGoogleAdapterProduction(...args)); @@ -786,6 +786,18 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { expect(events.at(-1)?.type).toBe("done"); }); + test("unary CCA responses retain the translated event batch in the translator budget", async () => { + const adapter = createGoogleAdapter(provider); + const budget = createTestTranslatorBudget({ maxTurnBytes: 1024 }); + const events = await adapter.parseResponse!(sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]), budget); + + expect(events).toContainEqual({ type: "text_delta", text: "hello" }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + }); + test("reads response.candidates + response.usageMetadata from the CCA envelope", async () => { const adapter = createGoogleAdapter(provider); const body = { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }], usageMetadata: { promptTokenCount: 9, candidatesTokenCount: 2, cachedContentTokenCount: 7 } } }; diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts index 29b1341874..a6b9a6cbd4 100644 --- a/tests/google-sse-frame-cap.test.ts +++ b/tests/google-sse-frame-cap.test.ts @@ -113,4 +113,26 @@ describe("google SSE frame byte cap", () => { message: `upstream SSE data frame exceeds ${CAP} bytes`, }); }); + + test("accepts multiple sub-cap data frames delivered in one oversized chunk", async () => { + const cap = 96; + const body = [ + `data: ${JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "a" }] } }] } })}\n`, + `data: ${JSON.stringify({ response: { candidates: [{ finishReason: "STOP" }] } })}\n`, + ].join("\n"); + const lines = body.split("\n").filter(Boolean); + expect(new TextEncoder().encode(body).byteLength).toBeGreaterThan(cap); + expect(Math.max(...lines.map(line => new TextEncoder().encode(line).byteLength))).toBeLessThanOrEqual(cap); + + setGoogleSseFrameMaxBytesForTests(cap); + const events = await collect( + createGoogleAdapter(ccaProvider()).parseStream(byteStreamResponse([ + new TextEncoder().encode(body), + ])), + ); + + expect(events).toContainEqual({ type: "text_delta", text: "a" }); + expect(events).toContainEqual({ type: "done", usage: undefined }); + expect(events.some(event => event.type === "error")).toBe(false); + }); }); From 43b9b1f51c33e40956c4a5748c385595f37298f1 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:01:27 -0600 Subject: [PATCH 09/24] fix(google): count raw SSE line bytes across UTF-8 chunk splits Decoded bufferBytes undercounts when TextDecoder holds a pending multibyte sequence, so a 33-byte line split mid-character could pass a 32-byte cap. Cap on the raw incomplete line instead. Co-authored-by: Cursor --- src/adapters/google.ts | 30 ++++++++++++------- tests/google-sse-frame-cap.test.ts | 46 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index b5831c4046..976c4de76a 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -324,15 +324,18 @@ export function setGoogleSseFrameMaxBytesForTests(bytes?: number): void { sseFrameMaxBytes = bytes ?? MAX_SSE_FRAME_BYTES; } -function maxSseLineBytes(bufferBytes: number, incoming: Uint8Array): number { - let lineBytes = bufferBytes; +function scanSseLineBytes(incompleteLineBytes: number, incoming: Uint8Array): { + maximum: number; + residual: number; +} { + let lineBytes = incompleteLineBytes; let maximum = lineBytes; for (const byte of incoming) { lineBytes += 1; maximum = Math.max(maximum, lineBytes); if (byte === 0x0a) lineBytes = 0; } - return maximum; + return { maximum, residual: lineBytes }; } // Note: imagen-* models use a different API surface (prediction/image-generation @@ -628,6 +631,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const budgetEncoder = new TextEncoder(); let buffer = ""; let bufferBytes = 0; + // Raw unterminated-line bytes, independent of TextDecoder's pending UTF-8 + // state. `bufferBytes` is the decoded residual and can undercount by 1–3 + // bytes when a chunk ends mid-character. + let incompleteLineBytes = 0; let pendingUsage: OcxUsage | undefined; let toolCallsStarted = 0; let lastFinishReason: string | undefined; @@ -786,17 +793,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte while (true) { const { done, value } = await reader.read(); if (done) break; - const incomingBytes = value?.byteLength ?? 0; - // Cap each incomplete line 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. Reset at each newline so - // several sub-cap frames delivered in one network chunk are not rejected as - // one oversized frame. - if (maxSseLineBytes(bufferBytes, value) > sseFrameMaxBytes) { + const incoming = value ?? new Uint8Array(); + // Cap each incomplete line on raw bytes before decode and before waiting + // for a newline — otherwise a single unterminated data: payload can grow + // without bound, buffer.length is UTF-16 units, and a mid-character + // decode residual undercounts the true line. Reset at each newline so + // several sub-cap frames in one network chunk are not rejected as one + // oversized frame. + const lineScan = scanSseLineBytes(incompleteLineBytes, incoming); + if (lineScan.maximum > sseFrameMaxBytes) { yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; try { await reader.cancel(); } catch { /* ignore */ } return; } + incompleteLineBytes = lineScan.residual; const nextBuffer = buffer + decoder.decode(value, { stream: true }); const nextBufferBytes = budgetEncoder.encode(nextBuffer).byteLength; const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" }); diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts index a6b9a6cbd4..80cd498b4f 100644 --- a/tests/google-sse-frame-cap.test.ts +++ b/tests/google-sse-frame-cap.test.ts @@ -114,6 +114,52 @@ describe("google SSE frame byte cap", () => { }); }); + test("rejects a split oversized multibyte line before decoding the completing chunk", async () => { + const zhong = new TextEncoder().encode("中"); + expect(zhong.byteLength).toBe(3); + const prefix = new TextEncoder().encode("data: "); + const fill = new Uint8Array(CAP + 1 - prefix.byteLength - zhong.byteLength).fill(0x61); + const first = new Uint8Array(prefix.byteLength + fill.byteLength + 1); + first.set(prefix, 0); + first.set(fill, prefix.byteLength); + first.set(zhong.subarray(0, 1), prefix.byteLength + fill.byteLength); + const completing = zhong.subarray(1); + expect(first.byteLength).toBe(CAP - 1); + expect(first.byteLength + completing.byteLength).toBe(CAP + 1); + + setGoogleSseFrameMaxBytesForTests(CAP); + let decodedCompletingChunk = false; + TextDecoder.prototype.decode = function ( + this: TextDecoder, + input?: AllowSharedBufferSource, + options?: TextDecodeOptions, + ): string { + const view = input instanceof Uint8Array + ? input + : input && typeof (input as ArrayBufferView).byteLength === "number" + ? new Uint8Array(input as ArrayBufferView) + : null; + if ( + view + && view.byteLength === completing.byteLength + && completing.every((byte, index) => view[index] === byte) + ) { + decodedCompletingChunk = true; + } + return originalDecode.call(this, input as ArrayBuffer, options); + }; + + const events = await collect( + createGoogleAdapter(googleProvider()).parseStream(byteStreamResponse([first, completing])), + ); + + expect(decodedCompletingChunk).toBe(false); + expect(events).toContainEqual({ + type: "error", + message: `upstream SSE data frame exceeds ${CAP} bytes`, + }); + }); + test("accepts multiple sub-cap data frames delivered in one oversized chunk", async () => { const cap = 96; const body = [ From b35ce84470cd5499ef8b7ea00e8b2ef9c85ed302 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:55:16 -0600 Subject: [PATCH 10/24] test(google): account for Claude continuation nudge Keep the CCA wire regression aligned with the upstream Claude prefill guard after restacking onto current dev. --- tests/google-antigravity-wire.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index cb681d7a92..de348af333 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -125,7 +125,7 @@ describe("antigravity CCA envelope", () => { }, } 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"]); + expect(loneEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["model", "user"]); }); test("exposes Gemini 3.7 Flash while retired Flash ids resolve to it", async () => { From 153e970b754fd5959550d13ce68cf40f3419368a Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:26 -0600 Subject: [PATCH 11/24] test(google): cover exact SSE cap and terminal CCA Co-authored-by: Cursor --- src/adapters/google.ts | 5 ++++- tests/google-antigravity-wire.test.ts | 6 +++++- tests/google-sse-frame-cap.test.ts | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 976c4de76a..85f43a204f 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -331,9 +331,12 @@ function scanSseLineBytes(incompleteLineBytes: number, incoming: Uint8Array): { let lineBytes = incompleteLineBytes; let maximum = lineBytes; for (const byte of incoming) { + if (byte === 0x0a) { + lineBytes = 0; + continue; + } lineBytes += 1; maximum = Math.max(maximum, lineBytes); - if (byte === 0x0a) lineBytes = 0; } return { maximum, residual: lineBytes }; } diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index de348af333..d75e1dbb6a 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -815,7 +815,11 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { // buildRequest first to set the per-adapter model/session, then parseResponse to observe. await adapter.buildRequest(parsed("hello world")); const body = { response: { candidates: [{ content: { parts: [{ functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-nonstream0000000" } ] } }] } }; - await adapter.parseResponse!(sseResponse([body])); + const events = await adapter.parseResponse!(sseResponse([ + body, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ])); + expect(events.at(-1)?.type).toBe("done"); // 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 } } }] }]; diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts index 80cd498b4f..d06d9b55f8 100644 --- a/tests/google-sse-frame-cap.test.ts +++ b/tests/google-sse-frame-cap.test.ts @@ -160,6 +160,26 @@ describe("google SSE frame byte cap", () => { }); }); + test("accepts a data line exactly at the cap before its LF delimiter", async () => { + const cap = 128; + const envelope = (text: string) => ({ + response: { candidates: [{ content: { parts: [{ text }] } }] }, + }); + const encoder = new TextEncoder(); + const emptyLine = `data: ${JSON.stringify(envelope(""))}`; + const line = `data: ${JSON.stringify(envelope("a".repeat(cap - encoder.encode(emptyLine).byteLength)))}`; + expect(encoder.encode(line).byteLength).toBe(cap); + + setGoogleSseFrameMaxBytesForTests(cap); + const events = await collect( + createGoogleAdapter(googleProvider()).parseStream( + byteStreamResponse([encoder.encode(`${line}\n\n`)]), + ), + ); + + expect(events.some(event => event.type === "error" && event.message.includes("exceeds"))).toBe(false); + }); + test("accepts multiple sub-cap data frames delivered in one oversized chunk", async () => { const cap = 96; const body = [ From f50f0b28d9f873bfa3ed55923cf818a94e0bebf0 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:31:09 -0600 Subject: [PATCH 12/24] Fix CCA Claude prefill guard and pin flat-payload test contract Track whether stripTrailingClaudePrefill removed a model tail so the continuation nudge is appended after stripping, while histories already ending on user or tool-result turns stay unchanged. Pin distinct SSE vs plain-JSON error expectations in google-hardening and document Claude on Antigravity wire behavior in providers guide. Co-authored-by: Cursor --- .../src/content/docs/guides/providers.md | 19 ++++++++++++++++++- src/adapters/google-antigravity-tools.ts | 6 ++++-- src/adapters/google.ts | 5 +++-- tests/google-antigravity-wire.test.ts | 12 +++++++----- tests/google-hardening.test.ts | 17 +++++++++++------ 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e1451dcfbd..ac0cf8c99b 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. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | @@ -153,6 +153,23 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools Inline JSON and extra positional arguments are rejected. Keep exported files private and delete or store them securely after import. +### Claude on Antigravity (Cloud Code Assist) + +The `google-antigravity` provider routes Claude models through Google's Cloud Code Assist (Antigravity) +wire rather than Anthropic's native API. opencodex translates requests and responses at the Gemini +format envelope: tool use/result pairing follows Anthropic semantics (including stable `functionCall.id` +/ `functionResponse.id` fields), and Claude thinking blocks keep their `thoughtSignature` values across +turns. + +CCA Claude models reject histories that end with an assistant (model) turn — upstream treats that as +prefill. opencodex strips trailing model turns when safe and appends a `(continue)` user nudge when the +history would otherwise end on model output (for example after context compaction or interrupted-turn +replay). Histories that already end on a user message or tool result are left unchanged. + +Antigravity exposes only SSE transport. Unary (non-streaming) callers still go through the same +`parseStream` path; plain JSON bodies without `data:` framing are rejected as truncated SSE rather +than parsed as a separate JSON response format. + ### OAuth reliability opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the diff --git a/src/adapters/google-antigravity-tools.ts b/src/adapters/google-antigravity-tools.ts index 08998da332..a1d6ad3251 100644 --- a/src/adapters/google-antigravity-tools.ts +++ b/src/adapters/google-antigravity-tools.ts @@ -77,11 +77,13 @@ export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessa * 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[] { +export function stripTrailingClaudePrefill(contents: unknown[]): boolean { + let strippedModelTail = false; 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(); + strippedModelTail = true; } - return contents; + return strippedModelTail; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 85f43a204f..d33415b6e2 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -534,7 +534,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // 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); + const strippedModelTail = /claude/i.test(wireModelId) ? stripTrailingClaudePrefill(contents) : false; if (antigravityUsesReplayCache(wireModelId)) { applyAntigravityReplay(wireModelId, sessionId, contents); } else { @@ -545,9 +545,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // must end with a user message." Context compaction, previous_response_id expansion, // and interrupted-turn replay can all produce a model-tail history. Append a user // "(continue)" nudge, mirroring the anthropic adapter's tail guard (src/adapters/anthropic.ts). + // When a trailing model turn was stripped, append even if the history now ends with user. if (/claude/i.test(wireModelId)) { const last = contents.length > 0 ? contents[contents.length - 1] as { role?: string } : undefined; - if (!last || last.role === "model") { + if (strippedModelTail || !last || last.role === "model") { contents.push({ role: "user", parts: [{ text: "(continue)" }] }); } } diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index d75e1dbb6a..9452d6eee2 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -115,7 +115,7 @@ describe("antigravity CCA envelope", () => { }, } 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"]); + expect(prefillEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["user", "user"]); const loneModel = { ...withPrefill, @@ -747,10 +747,12 @@ describe("Google Antigravity history repair", () => { }); 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" }]); + const contents = [{ role: "user" }, { role: "model" }, { role: "model" }]; + expect(stripTrailingClaudePrefill(contents)).toBe(true); + expect(contents).toEqual([{ role: "user" }]); + const soloModel = [{ role: "model" }]; + expect(stripTrailingClaudePrefill(soloModel)).toBe(false); + expect(soloModel).toEqual([{ role: "model" }]); }); }); diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 207dddd8bc..6b0b46efb0 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -88,17 +88,22 @@ describe("google provider hardening", () => { const adapter = createGoogleAdapter(antigravityProvider()); const flatPayload = { candidates: [{ content: { parts: [{ text: "unexpected" }] } }] }; + // SSE-framed flat payload: parseStream unwraps each data frame and rejects missing `response`. const streamEvents = await collect(adapter.parseStream(sseResponse([flatPayload]))); + expect(streamEvents).toEqual([{ + type: "error", + message: "google-antigravity response missing response wrapper", + }]); + + // Plain JSON without SSE framing: CCA parseResponse delegates to parseStream, which reads until + // EOF without finding a `data:` frame and fails closed as truncated SSE transport. const responseEvents = await adapter.parseResponse!( new Response(JSON.stringify(flatPayload), { status: 200 }), ); - - const expected = [{ + expect(responseEvents).toEqual([{ type: "error", - message: "google-antigravity response missing response wrapper", - }]; - expect(streamEvents).toEqual(expected); - expect(responseEvents).toEqual(expected); + message: "upstream stream ended with an incomplete SSE frame — possible truncation", + }]); }); test("truncated final JSON is a terminal stream error", async () => { From 2956980002d67f40630a9de77ed1b1d245b56a46 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:48:29 -0600 Subject: [PATCH 13/24] fix(google): pair duplicate tool ids by occurrence and reject http CCA URLs Co-authored-by: Cursor --- src/adapters/google-antigravity-tools.ts | 54 ++++++++++++------------ src/adapters/google.ts | 6 ++- tests/google-antigravity-wire.test.ts | 14 ++++++ tests/google-hardening.test.ts | 10 +++++ 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/adapters/google-antigravity-tools.ts b/src/adapters/google-antigravity-tools.ts index a1d6ad3251..4b76622f70 100644 --- a/src/adapters/google-antigravity-tools.ts +++ b/src/adapters/google-antigravity-tools.ts @@ -23,39 +23,37 @@ function isToolResult(message: OcxMessage): message is OcxToolResultMessage { * reserving ids in the request-scoped allocator. */ export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessage[] { - const matchedCallIds = new Set(); - const callIdsBeforeResult = new Set(); + const pendingCalls = new Map>(); + const matchedCallParts = new Set(); + const matchedResultIndexes = new Set(); - for (let index = 0; index < messages.length; index++) { - const message = messages[index]!; + const enqueueCall = (id: string, messageIndex: number, partIndex: number) => { + const queue = pendingCalls.get(id) ?? []; + queue.push({ messageIndex, partIndex }); + pendingCalls.set(id, queue); + }; + + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex]!; 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; - } - } + message.content.forEach((part, partIndex) => { + if (part.type !== "toolCall") return; + enqueueCall((part as OcxToolCall).id, messageIndex, partIndex); + }); + continue; } + if (!isToolResult(message)) continue; + const queue = pendingCalls.get(message.toolCallId); + const slot = queue?.shift(); + if (!slot) continue; + matchedCallParts.add(`${slot.messageIndex}:${slot.partIndex}`); + matchedResultIndexes.add(messageIndex); } const repaired: OcxMessage[] = []; - for (const message of messages) { + for (const [messageIndex, message] of messages.entries()) { if (isToolResult(message)) { - if (callIdsBeforeResult.has(message.toolCallId)) repaired.push(message); + if (matchedResultIndexes.has(messageIndex)) repaired.push(message); continue; } if (!isAssistantToolCall(message)) { @@ -63,8 +61,8 @@ export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessa continue; } - const content = message.content.filter(part => - part.type !== "toolCall" || matchedCallIds.has((part as OcxToolCall).id)); + const content = message.content.filter((part, partIndex) => + part.type !== "toolCall" || matchedCallParts.has(`${messageIndex}:${partIndex}`)); if (content.length > 0) { repaired.push(content.length === message.content.length ? message : { ...message, content }); } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index d33415b6e2..28cf10db81 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -22,6 +22,7 @@ import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./g 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 { isAntigravityHttpsHost } from "./google-antigravity-hosts"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; @@ -485,7 +486,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (!token) throw new Error("google-antigravity oauth token missing — run ocx login google-antigravity"); const base = provider.baseUrl?.trim(); if (!base) throw new Error("google-antigravity requires a non-empty baseUrl"); - const url = `${base}/v1internal:${method}${streamParam}`; + if (!isAntigravityHttpsHost(base)) { + throw new Error("google-antigravity requires an HTTPS baseUrl"); + } + const url = `${base.replace(/\/+$/, "")}/v1internal:${method}${streamParam}`; const project = provider.project; if (!project) throw new Error("Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`)."); const sessionId = antigravitySessionId(parsed); diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 9452d6eee2..7ee79957e1 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -746,6 +746,20 @@ describe("Google Antigravity history repair", () => { expect(repaired).toHaveLength(3); }); + test("pairs duplicate tool-call ids by occurrence", () => { + const messages = [ + { role: "assistant", content: [ + { type: "toolCall", id: "dup", name: "one", arguments: {} }, + { type: "toolCall", id: "dup", name: "one", arguments: {} }, + ] }, + { role: "toolResult", toolCallId: "dup", toolName: "one", content: "first", isError: false }, + ] as unknown as Parameters[0]; + + const repaired = repairGoogleToolPairs(messages); + expect((repaired[0] as { content: { id: string }[] }).content).toHaveLength(1); + expect(repaired).toHaveLength(2); + }); + test("strips only trailing model turns when another content turn remains", () => { const contents = [{ role: "user" }, { role: "model" }, { role: "model" }]; expect(stripTrailingClaudePrefill(contents)).toBe(true); diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 6b0b46efb0..fcca2ca77d 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -84,6 +84,16 @@ describe("google provider hardening", () => { ); }); + test("Antigravity rejects a cleartext http baseUrl before dispatch", async () => { + const adapter = createGoogleAdapter(antigravityProvider({ + baseUrl: "http://daily-cloudcode-pa.googleapis.com", + })); + + await expect(adapter.buildRequest(parsed())).rejects.toThrow( + "google-antigravity requires an HTTPS baseUrl", + ); + }); + test("Antigravity rejects flat Gemini payloads without the response wrapper", async () => { const adapter = createGoogleAdapter(antigravityProvider()); const flatPayload = { candidates: [{ content: { parts: [{ text: "unexpected" }] } }] }; From f48a7e3ca49f3f42fa7d7e18331005a27e8d7b85 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:11:31 -0600 Subject: [PATCH 14/24] fix(google): keep one CCA tool exchange per raw id The wire allocator maps a raw tool-call id to a single wire id, so a second complete duplicate pair would serialize as a colliding CCA exchange. --- src/adapters/google-antigravity-tools.ts | 7 +++++++ tests/google-antigravity-wire.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/adapters/google-antigravity-tools.ts b/src/adapters/google-antigravity-tools.ts index 4b76622f70..15a930c3b6 100644 --- a/src/adapters/google-antigravity-tools.ts +++ b/src/adapters/google-antigravity-tools.ts @@ -21,13 +21,20 @@ function isToolResult(message: OcxMessage): message is OcxToolResultMessage { * 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. + * + * The allocator maps one raw id to one wire id, so a second complete exchange + * that reuses the same raw id would serialize as a colliding pair. Keep only + * the first matched occurrence per raw id. */ export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessage[] { const pendingCalls = new Map>(); + const seenRawCallIds = new Set(); const matchedCallParts = new Set(); const matchedResultIndexes = new Set(); const enqueueCall = (id: string, messageIndex: number, partIndex: number) => { + if (seenRawCallIds.has(id)) return; + seenRawCallIds.add(id); const queue = pendingCalls.get(id) ?? []; queue.push({ messageIndex, partIndex }); pendingCalls.set(id, queue); diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 7ee79957e1..b04b2560d9 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -760,6 +760,24 @@ describe("Google Antigravity history repair", () => { expect(repaired).toHaveLength(2); }); + test("keeps only the first complete exchange when duplicate ids have matching results", () => { + const messages = [ + { role: "assistant", content: [ + { type: "toolCall", id: "dup", name: "one", arguments: { n: 1 } }, + { type: "toolCall", id: "dup", name: "one", arguments: { n: 2 } }, + ] }, + { role: "toolResult", toolCallId: "dup", toolName: "one", content: "first", isError: false }, + { role: "toolResult", toolCallId: "dup", toolName: "one", content: "second", isError: false }, + ] as unknown as Parameters[0]; + + const repaired = repairGoogleToolPairs(messages); + expect((repaired[0] as { content: { id: string; arguments: { n: number } }[] }).content).toEqual([ + { type: "toolCall", id: "dup", name: "one", arguments: { n: 1 } }, + ]); + expect(repaired).toHaveLength(2); + expect((repaired[1] as { content: string }).content).toBe("first"); + }); + test("strips only trailing model turns when another content turn remains", () => { const contents = [{ role: "user" }, { role: "model" }, { role: "model" }]; expect(stripTrailingClaudePrefill(contents)).toBe(true); From fe57b884c87b9ec62850d26de83092e05eeac4a0 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:34 -0600 Subject: [PATCH 15/24] feat(antigravity): CCA host failover and non-retryable image POST Always-SSE with daily/prod failover, classify peer quota/geoblock frames, and return 400 on ambiguous image transport failure so Codex does not replay a paid POST. Co-authored-by: Cursor --- .../src/content/docs/fr/guides/providers.md | 2 +- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- src/adapters/google-http.ts | 314 ++++++++++++- src/server/images.ts | 14 +- structure/04_transports-and-sidecars.md | 20 +- tests/google-hardening.test.ts | 423 +++++++++++++++++- tests/server-images.test.ts | 62 ++- 13 files changed, 822 insertions(+), 27 deletions(-) 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 ac0cf8c99b..f8e2d8f314 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. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | +| `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. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | 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 4e924458ee..9e9049207e 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/1.1 兼容路径的 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 ee7d709880..f443dac55f 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/google-http.ts b/src/adapters/google-http.ts index f7b90de87e..1a514aec97 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"); + } +} export interface GoogleRetryOptions { /** Repair-and-replay structurally invalid 400 bodies (Vertex/Antigravity behavior). */ @@ -33,15 +297,20 @@ async function normalizeFinalGoogleError(label: string, res: Response, signal?: * classified + redacted final error body. `label` is the provider-facing prefix used in error * messages. */ -export async function fetchGoogleWithRetry( +async function fetchGoogleWithRetryInternal( label: string, request: AdapterRequest, - ctx: AdapterFetchContext = {}, + ctx: AdapterFetchContext, + allowAntigravityHostFailover: boolean, opts: GoogleRetryOptions = {}, ): Promise { const repairInvalid400 = opts.repairInvalid400 ?? true; const timeoutMs = ctx.timeoutMs ?? 200_000; const executor = ctx.executor ?? globalThis.fetch; + 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; @@ -53,6 +322,31 @@ export async function fetchGoogleWithRetry( headers: activeRequest.headers, body: activeRequest.body, }, timeoutMs, ctx.abortSignal, ctx.stream, executor); + 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, + opts, + ) + : 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 && repairInvalid400 && !compatibilityReplayUsed) { let payloadText = ""; try { @@ -93,6 +387,11 @@ export async function fetchGoogleWithRetry( } 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, @@ -103,6 +402,15 @@ export async function fetchGoogleWithRetry( throw lastError ?? new Error(`${label} fetch failed`); } +export function fetchGoogleWithRetry( + label: string, + request: AdapterRequest, + ctx: AdapterFetchContext = {}, + opts: GoogleRetryOptions = {}, +): Promise { + return fetchGoogleWithRetryInternal(label, request, ctx, true, opts); +} + /** * AI Studio direct (`generativelanguage.googleapis.com`) retry wrapper. * 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/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index efd1c87dbb..3eb2460e92 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -180,9 +180,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 @@ -870,6 +872,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 @@ -878,7 +890,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/google-hardening.test.ts b/tests/google-hardening.test.ts index fcca2ca77d..6531313dbb 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"; @@ -94,6 +101,420 @@ 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" }] } }] }; @@ -108,7 +529,7 @@ describe("google provider hardening", () => { // Plain JSON without SSE framing: CCA parseResponse delegates to parseStream, which reads until // EOF without finding a `data:` frame and fails closed as truncated SSE transport. const responseEvents = await adapter.parseResponse!( - new Response(JSON.stringify(flatPayload), { status: 200 }), + sseResponse([flatPayload]), ); expect(responseEvents).toEqual([{ type: "error", 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); } From 7c09409d9e7577921d03ce16fdabc66848159aa8 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:22:41 -0600 Subject: [PATCH 16/24] fix(antigravity): skip http hosts on CCA generateContent OAuth bearer streamGenerateContent must not go to a configured http origin; rewrite to the HTTPS daily/prod peer first. Co-authored-by: Cursor --- src/adapters/google-http.ts | 15 +++++++++++---- tests/google-hardening.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 1a514aec97..52a17305e6 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -7,7 +7,7 @@ import { } from "./google-errors"; import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; -import { antigravityHostCandidates } from "./google-antigravity-hosts"; +import { antigravityHostCandidates, isAntigravityHttpsHost } from "./google-antigravity-hosts"; import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { abortError, @@ -307,12 +307,19 @@ async function fetchGoogleWithRetryInternal( const repairInvalid400 = opts.repairInvalid400 ?? true; const timeoutMs = ctx.timeoutMs ?? 200_000; const executor = ctx.executor ?? globalThis.fetch; - const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(request) - ? antigravityHostCandidates(new URL(request.url).origin) + let activeRequest = request; + if (label === "Antigravity" && isAntigravitySseRequest(activeRequest)) { + const origin = new URL(activeRequest.url).origin; + if (!isAntigravityHttpsHost(origin)) { + const httpsHost = antigravityHostCandidates(origin).find(isAntigravityHttpsHost); + if (httpsHost) activeRequest = requestForHost(activeRequest, httpsHost); + } + } + const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(activeRequest) + ? antigravityHostCandidates(new URL(activeRequest.url).origin).filter(isAntigravityHttpsHost) : []; let antigravityHostIndex = 0; let lastError: unknown; - let activeRequest = request; let compatibilityReplayUsed = false; for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) { if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 6531313dbb..5737a0611b 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -372,6 +372,32 @@ describe("google provider hardening", () => { } }); + test("CCA does not POST the OAuth bearer to an http host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const request = { + url: "http://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: { Authorization: "Bearer secret-token" }, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000 }); + expect(response.ok).toBe(true); + expect(calls.filter(url => url.startsWith("http://"))).toEqual([]); + expect(calls[0]).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); + } finally { + globalThis.fetch = realFetch; + } + }); + test("CCA geoblock records cooldown without account carousel", async () => { clearAntigravityAccountCooldown("test-antigravity-account"); const realFetch = globalThis.fetch; From c689f3a7ac51d17775f441caea38866ccb0fe986 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:08:26 -0600 Subject: [PATCH 17/24] fix(antigravity): remove oauth cooldown coupling Co-authored-by: Cursor --- src/adapters/google-http.ts | 48 +--------------------------------- tests/google-hardening.test.ts | 25 +++--------------- 2 files changed, 5 insertions(+), 68 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 52a17305e6..4a4f6f75fd 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -8,7 +8,6 @@ import { import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; import { antigravityHostCandidates, isAntigravityHttpsHost } from "./google-antigravity-hosts"; -import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { abortError, cancelResponseBodyBestEffort, @@ -35,17 +34,6 @@ function requestForHost(request: AdapterRequest, host: string): AdapterRequest { 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 { @@ -170,7 +158,6 @@ function responseWithBufferedBody( async function prepareCcaSseResponse( response: Response, fetchPeer: (() => Promise) | undefined, - accountId?: string, ): Promise { if (!response.body) return fetchPeer ? fetchPeer() : response; const reader = response.body.getReader(); @@ -203,12 +190,6 @@ async function prepareCcaSseResponse( } 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); } } @@ -234,12 +215,6 @@ async function prepareCcaSseResponse( } 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); @@ -261,23 +236,6 @@ 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"); - } -} - export interface GoogleRetryOptions { /** Repair-and-replay structurally invalid 400 bodies (Vertex/Antigravity behavior). */ repairInvalid400?: boolean; @@ -348,11 +306,7 @@ async function fetchGoogleWithRetryInternal( opts, ) : 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); + return prepareCcaSseResponse(res, fetchPeer); } if (res.status === 400 && repairInvalid400 && !compatibilityReplayUsed) { let payloadText = ""; diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 5737a0611b..f22889d46c 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -9,7 +9,6 @@ import { 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"; @@ -398,8 +397,7 @@ describe("google provider hardening", () => { } }); - test("CCA geoblock records cooldown without account carousel", async () => { - clearAntigravityAccountCooldown("test-antigravity-account"); + test("CCA geoblock maps to 403 without account carousel", async () => { const realFetch = globalThis.fetch; let calls = 0; globalThis.fetch = (async () => { @@ -417,19 +415,15 @@ describe("google provider hardening", () => { }; 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"); + test("CCA inline quota error becomes a 429 without host failover", async () => { const realFetch = globalThis.fetch; let calls = 0; globalThis.fetch = (async () => { @@ -451,19 +445,15 @@ describe("google provider hardening", () => { }; 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"); + test("CCA peer HTTP 200 RESOURCE_EXHAUSTED after first-host 404 becomes a 429", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { @@ -488,22 +478,18 @@ describe("google provider hardening", () => { }; 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"); + test("CCA peer HTTP 200 geoblock after first-host 503 remains 403 without a third host", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { @@ -527,17 +513,14 @@ describe("google provider hardening", () => { }; 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"); } }); From 2f013a4728913337077648ad6297f93c493c04fe Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:13:33 -0600 Subject: [PATCH 18/24] Drop Antigravity cooldown docs from host-failover slice. Account cooldown and antigravity-routing coupling belongs on #2069; this PR documents CCA host failover and image behavior only. Co-authored-by: Cursor --- docs-site/src/content/docs/fr/guides/providers.md | 2 +- docs-site/src/content/docs/ja/guides/providers.md | 2 +- docs-site/src/content/docs/ko/guides/providers.md | 2 +- docs-site/src/content/docs/ru/guides/providers.md | 2 +- docs-site/src/content/docs/tr/guides/providers.md | 2 +- docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- docs-site/src/content/docs/zh-tw/guides/providers.md | 2 +- structure/04_transports-and-sidecars.md | 5 ++--- 8 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index c5a95b2d01..82f04b6986 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. 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. | +| `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. | | `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/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 4606b35c74..61115f6efa 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 モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し、429 cooldown はアカウント単位のプロセス内状態です。 | +| `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 に再試行し。 | | `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 bc9aa3b7c0..13768c80eb 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 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. 429 cooldown은 계정별 프로세스 로컬 상태입니다. | +| `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로 한 번 재시도합니다. | | `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 16e53ca009..dd0f088253 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-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. Cooldown для 429 хранится локально в процессе и привязан к аккаунту. | +| `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. | | `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 040d631795..9f83498f50 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. 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. | +| `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. | | `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 9e9049207e..03d20514a1 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 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。429 cooldown 按账户保存在进程内。 | +| `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。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | 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 f443dac55f..5bc1b9e812 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。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。429 cooldown 為按帳號的 process-local 狀態。 | +| `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。 | | `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/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 3eb2460e92..0d7e26976f 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -879,8 +879,7 @@ the adapter buffers those SSE events for the unary contract. The configured dail 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. +not trigger host failover. ## Transport inventory @@ -890,7 +889,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-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`). | +| 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` | 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`). | | 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. | From 75246aadd48a1f990f073d694189fa58108b8430 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:24:04 -0600 Subject: [PATCH 19/24] fix(antigravity): preserve passthrough backpressure Drive CCA response tails from downstream pulls so open streams do not eagerly buffer unread upstream output. Co-authored-by: Cursor --- src/adapters/google-http.ts | 46 ++++++++++++++++++---------- tests/google-hardening.test.ts | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 4a4f6f75fd..d1d8fc7263 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -125,27 +125,43 @@ function responseWithBufferedBody( pending?: Uint8Array, status = response.status, ): Response { + let bufferedBody = buffered; + let pendingBody = pending; + let readerReleased = false; + const releaseReader = () => { + if (readerReleased) return; + readerReleased = true; + reader.releaseLock(); + }; 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); + async pull(controller) { + try { + if (bufferedBody.byteLength > 0 || pendingBody?.byteLength) { + if (bufferedBody.byteLength > 0) { + controller.enqueue(bufferedBody); + bufferedBody = new Uint8Array(); + } + if (pendingBody?.byteLength) { + controller.enqueue(pendingBody); + pendingBody = undefined; } + return; + } + + const { done, value } = await reader.read(); + if (done) { controller.close(); - } catch (error) { - controller.error(error); - } finally { - reader.releaseLock(); + releaseReader(); + return; } - })(); + if (value?.byteLength) controller.enqueue(value); + } catch (error) { + controller.error(error); + releaseReader(); + } }, cancel(reason) { - void reader.cancel(reason).catch(() => {}); + void reader.cancel(reason).catch(() => {}).finally(releaseReader); }, }); return new Response(body, { diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index f22889d46c..2883c92edd 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -342,6 +342,61 @@ describe("google provider hardening", () => { } }); + test("CCA passthrough does not consume an unread upstream tail", async () => { + const realFetch = globalThis.fetch; + const encoder = new TextEncoder(); + const candidate = encoder.encode( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"first"}]}}]}}\n\n', + ); + const tail = encoder.encode("data:\n\n"); + let candidateSent = false; + let tailChunksProduced = 0; + let releaseTail!: () => void; + const tailGate = new Promise(resolve => { + releaseTail = resolve; + }); + + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(streamController) { + if (!candidateSent) { + candidateSent = true; + streamController.enqueue(candidate); + return; + } + if (tailChunksProduced === 0) { + return tailGate.then(() => { + streamController.enqueue(tail); + tailChunksProduced += 1; + }); + } + if (tailChunksProduced >= 1_000) { + streamController.close(); + return; + } + streamController.enqueue(tail); + tailChunksProduced += 1; + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })) as typeof fetch; + + try { + const response = await fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }, { timeoutMs: 5_000, stream: true }); + + releaseTail(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(tailChunksProduced).toBeLessThan(10); + await response.body?.cancel(); + } finally { + releaseTail(); + globalThis.fetch = realFetch; + } + }); + test("CCA inline UNAVAILABLE fails over to the production host", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; From b2492f7bc44e4a1e1ce0c16876fc26c5b406f9e4 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:16:35 -0600 Subject: [PATCH 20/24] fix(antigravity): harden peer failover request replay Preserve repaired request bodies when CCA failover invokes the peer and lock in transport-failure coverage. Clarify the separate SSE chat and unary image endpoints across provider docs. --- .../src/content/docs/fr/guides/providers.md | 2 +- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- src/adapters/google-http.ts | 6 +- tests/google-hardening.test.ts | 83 +++++++++++++++++++ 10 files changed, 94 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 82f04b6986..4287198fc8 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. 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. | +| `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). Les requêtes de chat et d'adaptateur CCA utilisent SSE (`v1internal:streamGenerateContent?alt=sse`) et mettent ce flux en mémoire tampon pour les appels unitaires. La génération d'images intégrée utilise le point de terminaison unitaire distinct `v1internal:generateContent`. L'adaptateur réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte. | | `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 f8e2d8f314..7246d72ad6 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. 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. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | +| `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 chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries the daily/production peer on first-host transport, 404, or unavailable failures. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 61115f6efa..105ae0daa2 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 モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し。 | +| `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(`v1internal:streamGenerateContent?alt=sse`)を使用し、単項呼び出しではそのストリームをバッファリングします。組み込み画像生成は別の単項 `v1internal:generateContent` エンドポイントを使用します。アダプターは最初のホストでの transport、404、unavailable の失敗時に daily/production peer を再試行します。 | | `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 13768c80eb..ea49a737e0 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 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. | +| `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(`v1internal:streamGenerateContent?alt=sse`)를 사용하고 단항 호출에는 해당 스트림을 버퍼링합니다. 내장 이미지 생성은 별도의 단항 `v1internal:generateContent` 엔드포인트를 사용합니다. 어댑터는 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. | | `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 dd0f088253..6a4604b35e 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-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). Запросы чата и адаптера CCA используют SSE (`v1internal:streamGenerateContent?alt=sse`) и буферизуют этот поток для унарных вызовов. Встроенная генерация изображений использует отдельный унарный эндпоинт `v1internal:generateContent`. Адаптер повторяет запрос к daily/production peer при сбое транспорта, 404 или unavailable на первом хосте. | | `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 9f83498f50..23fce9593c 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. 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. | +| `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 sohbet/adaptör istekleri SSE (`v1internal:streamGenerateContent?alt=sse`) kullanır ve tekli çağrılar için bu akışı arabelleğe alır. Yerleşik görüntü oluşturma ayrı tekli `v1internal:generateContent` uç noktasını kullanır. Adaptör, ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer'ı yeniden dener. | | `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 03d20514a1..68b4566784 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 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 聊天/adapter 请求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),并为单次调用缓冲该流。内置图像生成使用单独的 unary `v1internal:generateContent` 端点。Adapter 在首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | 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 5bc1b9e812..2419bea41b 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。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。 | +| `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 聊天/adapter 請求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),並為單次呼叫緩衝該串流。內建圖片生成使用獨立的 unary `v1internal:generateContent` 端點。Adapter 會在第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。 | | `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/google-http.ts b/src/adapters/google-http.ts index d1d8fc7263..10fe4d2a4f 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -308,7 +308,7 @@ async function fetchGoogleWithRetryInternal( if (shouldTryPeer) { cancelResponseBodyBestEffort(res); antigravityHostIndex = 1; - activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + activeRequest = requestForHost(activeRequest, antigravityHosts[antigravityHostIndex]!); continue; } } @@ -316,7 +316,7 @@ async function fetchGoogleWithRetryInternal( const fetchPeer = antigravityHosts.length > 1 && antigravityHostIndex === 0 ? () => fetchGoogleWithRetryInternal( label, - requestForHost(request, antigravityHosts[1]!), + requestForHost(activeRequest, antigravityHosts[1]!), ctx, false, opts, @@ -366,7 +366,7 @@ async function fetchGoogleWithRetryInternal( lastError = err; if (antigravityHosts.length > 1 && antigravityHostIndex === 0) { antigravityHostIndex = 1; - activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + activeRequest = requestForHost(activeRequest, antigravityHosts[antigravityHostIndex]!); continue; } if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err; diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 2883c92edd..f8c623af43 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -276,6 +276,89 @@ describe("google provider hardening", () => { } }); + test("CCA throwing peer leg does not replay the first 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" } }]); + } + throw new Error("peer transport down"); + }) as typeof fetch; + try { + await expect(fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }, { timeoutMs: 5_000 })).rejects.toThrow("peer transport down"); + 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 host failover preserves a body repaired after the first host 400", async () => { + const calls: Array<{ url: string; body: string }> = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + calls.push({ url: String(input), body: String(init?.body ?? "") }); + if (calls.length === 1) { + return new Response(JSON.stringify({ + error: { status: "INVALID_ARGUMENT", message: "input schema is invalid" }, + }), { status: 400 }); + } + if (calls.length === 2) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "repaired" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + const originalBody = JSON.stringify({ + model: "gemini-3.7-flash-tiered", + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [{ + functionDeclarations: [{ + name: "lookup", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }], + }], + }, + }); + try { + const response = await fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: originalBody, + }, { timeoutMs: 5_000 }); + + expect(await response.text()).toContain("repaired"); + expect(calls.map(call => call.url)).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(calls[0]?.body).toBe(originalBody); + expect(calls[1]?.body).not.toBe(originalBody); + expect(calls[2]?.body).toBe(calls[1]?.body); + } finally { + globalThis.fetch = realFetch; + } + }); + test("CCA peer failures use retry and final error normalization", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; From 52bbffef3c1b7dac3e5729afe46fac74346d2f04 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:17:28 -0600 Subject: [PATCH 21/24] docs(antigravity): scope CCA transport invariant --- structure/04_transports-and-sidecars.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 0d7e26976f..4ebbf46a45 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -874,9 +874,10 @@ combo whose remaining eligible targets use other providers. ## 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 +CCA chat/adapter requests use `streamGenerateContent?alt=sse`, including unary callers; the adapter +buffers those SSE events for the unary contract. Built-in image generation uses the separate unary +`v1internal:generateContent` endpoint outside the adapter. 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. From 7b31ca45cef877577f1cfcca97385a678cfc4cad Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:39 -0600 Subject: [PATCH 22/24] docs(antigravity): clarify peer failover contract Co-authored-by: Cursor --- docs-site/src/content/docs/guides/providers.md | 2 +- docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- structure/04_transports-and-sidecars.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7246d72ad6..b6232b76ba 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. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries the daily/production peer on first-host transport, 404, or unavailable failures. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | +| `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 chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries its maintained daily/production peer at most once after a first-host transport failure, empty stream, 404, or `UNAVAILABLE`; authentication, geoblock, invalid-request, and exhausted-quota responses do not trigger host failover. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | 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 68b4566784..ef93c6ad8c 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 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 聊天/adapter 请求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),并为单次调用缓冲该流。内置图像生成使用单独的 unary `v1internal:generateContent` 端点。Adapter 在首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 聊天/adapter 请求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),并为单次调用缓冲该流。内置图像生成使用单独的 unary `v1internal:generateContent` 端点。Adapter 在首个主机发生传输失败、空流、404 或 `UNAVAILABLE` 后,至多重试一次其维护的 daily/production peer;认证、地理封锁、无效请求和配额耗尽不会触发主机故障转移。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4ebbf46a45..18df659179 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -878,9 +878,9 @@ CCA chat/adapter requests use `streamGenerateContent?alt=sse`, including unary c buffers those SSE events for the unary contract. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint outside the adapter. 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. +`cloudcode-pa.googleapis.com`) is eligible for a single retry after a first-host transport +failure, empty stream, 404, or `UNAVAILABLE`. Authentication, geoblock, invalid-request, and +exhausted-quota responses do not trigger host failover. ## Transport inventory From 5566275402a7fe26fb5c8fb81a545d51558e654d Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:37:21 -0600 Subject: [PATCH 23/24] fix(antigravity): bound CCA probe buffer and harden http host pairing Rewrite known daily/prod hosts from http to https, restrict peer failover to those hosts only, and cap SSE classification probe retention at 256 KiB with lossless overflow passthrough. Co-authored-by: Cursor --- src/adapters/google-antigravity-hosts.ts | 34 ++++++++++++++---- src/adapters/google-http.ts | 28 ++++++++++----- tests/google-hardening.test.ts | 46 +++++++++++++++++++++--- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/src/adapters/google-antigravity-hosts.ts b/src/adapters/google-antigravity-hosts.ts index a2c1fe096d..8fe52899da 100644 --- a/src/adapters/google-antigravity-hosts.ts +++ b/src/adapters/google-antigravity-hosts.ts @@ -1,19 +1,41 @@ -const DAILY_ANTIGRAVITY_HOST = "https://daily-cloudcode-pa.googleapis.com"; -const PROD_ANTIGRAVITY_HOST = "https://cloudcode-pa.googleapis.com"; +const DAILY_HOSTNAME = "daily-cloudcode-pa.googleapis.com"; +const PROD_HOSTNAME = "cloudcode-pa.googleapis.com"; +export const DAILY_ANTIGRAVITY_HOST = `https://${DAILY_HOSTNAME}`; +export const PROD_ANTIGRAVITY_HOST = `https://${PROD_HOSTNAME}`; + +function knownAntigravityHttpsHost(origin: string): string | undefined { + try { + const url = new URL(origin.replace(/\/+$/, "")); + if (url.hostname === DAILY_HOSTNAME) return DAILY_ANTIGRAVITY_HOST; + if (url.hostname === PROD_HOSTNAME) return PROD_ANTIGRAVITY_HOST; + return undefined; + } catch { + return undefined; + } +} + +/** + * When a configured origin is a known Google daily/prod host (http or https), return the + * canonical HTTPS origin. Custom hosts are left unchanged. + */ +export function canonicalAntigravityHttpsHost(origin: string): string | undefined { + return knownAntigravityHttpsHost(origin); +} /** * Return the configured Antigravity endpoint and, for Google's known daily/prod hosts * only, its daily/production peer. Custom baseUrl values stay single-host. */ export function antigravityHostCandidates(configuredBase: string): string[] { - const configured = configuredBase.replace(/\/+$/, ""); - if (configured === DAILY_ANTIGRAVITY_HOST) { + const trimmed = configuredBase.replace(/\/+$/, ""); + const known = knownAntigravityHttpsHost(trimmed); + if (known === DAILY_ANTIGRAVITY_HOST) { return [DAILY_ANTIGRAVITY_HOST, PROD_ANTIGRAVITY_HOST]; } - if (configured === PROD_ANTIGRAVITY_HOST) { + if (known === PROD_ANTIGRAVITY_HOST) { return [PROD_ANTIGRAVITY_HOST, DAILY_ANTIGRAVITY_HOST]; } - return [configured]; + return [trimmed]; } /** OAuth bearer requests must not use a cleartext host, even if generic baseUrl config allows http. */ diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 10fe4d2a4f..0998e3c4a9 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -7,7 +7,11 @@ import { } from "./google-errors"; import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; -import { antigravityHostCandidates, isAntigravityHttpsHost } from "./google-antigravity-hosts"; +import { + antigravityHostCandidates, + canonicalAntigravityHttpsHost, + isAntigravityHttpsHost, +} from "./google-antigravity-hosts"; import { abortError, cancelResponseBodyBestEffort, @@ -19,8 +23,8 @@ 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; +export const CCA_STREAM_PROBE_MAX_BYTES = CCA_STREAM_CLASSIFY_MAX_BYTES; function isAntigravitySseRequest(request: AdapterRequest): boolean { return request.url.includes("/v1internal:streamGenerateContent?alt=sse"); @@ -84,6 +88,7 @@ export class CcaProbeBuffer { } append(next: Uint8Array): boolean { + if (next.byteLength === 0) return true; const required = this.length + next.byteLength; if (required > this.maxBytes) return false; if (required > this.storage.byteLength) { @@ -188,7 +193,7 @@ async function prepareCcaSseResponse( return fetchPeer(); }; try { - while (probeBuffer.length < CCA_STREAM_PROBE_MAX_BYTES) { + while (probeBuffer.length < CCA_STREAM_CLASSIFY_MAX_BYTES) { if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES || probeBuffer.length >= CCA_STREAM_CLASSIFY_MAX_BYTES) { return passthrough(); } @@ -212,9 +217,12 @@ async function prepareCcaSseResponse( 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 classifyRemaining = CCA_STREAM_CLASSIFY_MAX_BYTES - probeBuffer.length; + const copyBytes = Math.min(value.byteLength, classifyRemaining); + const overflow = value.byteLength > copyBytes ? value.subarray(copyBytes) : undefined; + if (copyBytes > 0) { + probeBuffer.append(value.subarray(0, copyBytes)); + } const buffered = probeBuffer.view(); while (true) { if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES) return passthrough(overflow); @@ -284,9 +292,11 @@ async function fetchGoogleWithRetryInternal( let activeRequest = request; if (label === "Antigravity" && isAntigravitySseRequest(activeRequest)) { const origin = new URL(activeRequest.url).origin; - if (!isAntigravityHttpsHost(origin)) { - const httpsHost = antigravityHostCandidates(origin).find(isAntigravityHttpsHost); - if (httpsHost) activeRequest = requestForHost(activeRequest, httpsHost); + const canonicalHttps = canonicalAntigravityHttpsHost(origin); + if (canonicalHttps) { + activeRequest = requestForHost(activeRequest, canonicalHttps); + } else if (!isAntigravityHttpsHost(origin)) { + throw new Error("Antigravity OAuth bearer requests must use HTTPS"); } } const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(activeRequest) diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index f8c623af43..de3fee8266 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -215,12 +215,48 @@ describe("google provider hardening", () => { 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(CCA_STREAM_PROBE_MAX_BYTES).toBe(CCA_STREAM_CLASSIFY_MAX_BYTES); expect(buffer.append(new Uint8Array(cap))).toBe(true); expect(buffer.append(new Uint8Array(1))).toBe(false); expect(buffer.length).toBe(cap); }); + test("CCA large first chunk bounds probe retention and replays losslessly", async () => { + const encoder = new TextEncoder(); + const candidateFrame = 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}}\n\n'; + const padLen = 512 * 1024 - candidateFrame.length; + const fullBody = candidateFrame + "x".repeat(padLen); + const firstChunk = encoder.encode(fullBody); + expect(firstChunk.byteLength).toBe(512 * 1024); + + const probeBuffer = new CcaProbeBuffer(CCA_STREAM_CLASSIFY_MAX_BYTES); + const classifyRemaining = CCA_STREAM_CLASSIFY_MAX_BYTES - probeBuffer.length; + const copyBytes = Math.min(firstChunk.byteLength, classifyRemaining); + expect(probeBuffer.append(firstChunk.subarray(0, copyBytes))).toBe(true); + expect(probeBuffer.length).toBe(CCA_STREAM_CLASSIFY_MAX_BYTES); + expect(probeBuffer.append(firstChunk.subarray(copyBytes, copyBytes + 1))).toBe(false); + + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(firstChunk); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })) as typeof fetch; + try { + const response = await fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }, { timeoutMs: 5_000, stream: true }); + expect(response.status).toBe(200); + expect(await response.text()).toBe(fullBody); + } finally { + globalThis.fetch = realFetch; + } + }); + 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(); @@ -267,7 +303,7 @@ describe("google provider hardening", () => { "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); + expect(enqueuedBytes).toBeLessThan(CCA_STREAM_CLASSIFY_MAX_BYTES * 2); await response.body?.cancel(); } finally { abortController.abort(); @@ -673,14 +709,14 @@ describe("google provider hardening", () => { message: "google-antigravity response missing response wrapper", }]); - // Plain JSON without SSE framing: CCA parseResponse delegates to parseStream, which reads until - // EOF without finding a `data:` frame and fails closed as truncated SSE transport. + // CCA parseResponse delegates to parseStream, so the same SSE-framed payload yields the + // same terminal error rather than a separate unary JSON code path. const responseEvents = await adapter.parseResponse!( sseResponse([flatPayload]), ); expect(responseEvents).toEqual([{ type: "error", - message: "upstream stream ended with an incomplete SSE frame — possible truncation", + message: "google-antigravity response missing response wrapper", }]); }); From 689d62292422989a964c15451a88be26d49b3797 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:40:20 -0600 Subject: [PATCH 24/24] test(antigravity): align quota http-host test with HTTPS rewrite Hostname-based pairing now rewrites known Google daily hosts from http to https, so expect successful live quota RPCs instead of null; add coverage for custom http hosts that remain skipped. Co-authored-by: Cursor --- tests/antigravity-quota.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index 0a910d959d..076208c41f 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -478,7 +478,7 @@ describe("Antigravity live quota", () => { expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); }); - test("does not POST retrieveUserQuota or retrieveUserQuotaSummary to an http host", async () => { + test("rewrites known Google http host to HTTPS for live quota RPCs", async () => { const httpHost = "http://daily-cloudcode-pa.googleapis.com"; const requested: string[] = []; const fetchImpl = (async (input: RequestInfo | URL) => { @@ -500,6 +500,33 @@ describe("Antigravity live quota", () => { expect(requested.filter(url => url.startsWith("http://"))).toEqual([]); expect(requested).not.toContain(`${httpHost}/v1internal:retrieveUserQuota`); expect(requested).not.toContain(`${httpHost}/v1internal:retrieveUserQuotaSummary`); + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuotaSummary`); + expect(quota).not.toBeNull(); + expect(quota?.customWindows?.[0]?.label).toBe("Gem"); + }); + + test("returns null for a custom http host without POSTing live quota RPCs", async () => { + const httpHost = "http://custom.proxy"; + 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).toEqual([]); expect(quota).toBeNull(); });