Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ const QUOTA_CODES = new Set([
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
// Google Cloud Code 5h/weekly account caps. Bare RESOURCE_EXHAUSTED stays a throttle.
"quota_exhausted",
])
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
// Z.ai's Responses API reports account and plan rejections mid-stream as `permission_denied`.
Expand Down
40 changes: 39 additions & 1 deletion packages/ai/test/provider-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { isContextOverflow } from "../src/index.js"
import { AIError, isContextOverflow, isRetryable } from "../src/index.js"
import { classifyProviderFailure, providerErrorMessage } from "../src/provider-error.js"

describe("provider error classification", () => {
Expand Down Expand Up @@ -349,6 +349,44 @@ describe("provider error classification", () => {
).toBe("ContentPolicy")
})

test("classifies Google 5h quota exhaustion as non-retryable quota", () => {
const message = "You have exhausted your capacity on this model. Your quota will reset after 5h."
const quota = classifyProviderFailure({
message,
status: 429,
rawBody: JSON.stringify({
error: {
code: 429,
message,
status: "RESOURCE_EXHAUSTED",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "QUOTA_EXHAUSTED",
domain: "cloudcode-pa.googleapis.com",
},
],
},
}),
})
const throttle = classifyProviderFailure({
message: "Resource has been exhausted (e.g. check quota).",
status: 429,
rawBody: JSON.stringify({
error: {
code: 429,
message: "Resource has been exhausted (e.g. check quota).",
status: "RESOURCE_EXHAUSTED",
details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "RATE_LIMIT_EXCEEDED" }],
},
}),
})
expect(quota._tag).toBe("QuotaExceeded")
expect(isRetryable(new AIError({ reason: quota }))).toBe(false)
expect(throttle._tag).toBe("RateLimit")
expect(isRetryable(new AIError({ reason: throttle }))).toBe(true)
})

test("keeps Z.ai throttling and overload retryable", () => {
expect(
[
Expand Down
5 changes: 5 additions & 0 deletions packages/antigravity-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ There are no Core imports, SDK transport wrappers, or proxy servers.
- Native Gemini requests resolve the active credential afresh, then use the
Cloud Code envelope and `metadata.projectId`. Only responses belonging to
rewritten requests are unwrapped. API-key connections are unchanged.
- Failed rewritten responses are re-emitted as a Google error with the Cloud
Code status, message, and (on 429) a best-effort quota summary readout. Only
an explicit `QUOTA_EXHAUSTED` reason, an exhausted 5h bucket in the failed
model's own group, or a long-window reset message becomes non-retryable
`QUOTA_EXHAUSTED`; other `RESOURCE_EXHAUSTED` errors stay retryable throttles.
- Browser login is explicit. No HOME, legacy account, or IDE credential discovery
is performed. Offline credential import belongs to the host's test importer.
- Credentials marked `metadata.shuvcodeAuthImport: "access-only"` cannot refresh;
Expand Down
44 changes: 42 additions & 2 deletions packages/antigravity-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,49 @@ export const GoogleAntigravityPlugin = Plugin.define({
yield* ctx.session.hook(
"http.response",
(evt) =>
Effect.sync(() => {
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.google || !mapped.has(evt.request)) return
if (!evt.response.ok || !evt.response.body) return
if (!evt.response.body) return
if (!evt.response.ok) {
const text = yield* Effect.tryPromise({
try: () => evt.response.clone().text(),
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => ""))
const parsed = GoogleAntigravityWire.parseCloudCodeError(text)
if (!parsed) return
const connection = yield* ctx.integration.connection.active(GoogleAntigravityOAuth.integrationID)
const resolved = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const oauth =
resolved?.type === "oauth" && GoogleAntigravityOAuth.isSubscription(resolved) ? resolved : undefined
const projectId = oauth ? GoogleAntigravityOAuth.projectId(oauth.metadata) : undefined
const summary =
evt.response.status === 429 && oauth && projectId
? yield* Effect.tryPromise({
try: (signal) =>
GoogleAntigravityOAuth.fetchQuotaSummary(oauth.access, projectId, undefined, signal),
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const explained = GoogleAntigravityWire.explainCloudCodeError({
error: parsed,
summary,
httpStatus: evt.response.status,
modelID: evt.model.id,
})
if (!explained) return
const headers = new Headers(evt.response.headers)
headers.delete("content-length")
headers.delete("content-encoding")
headers.set("content-type", "application/json")
evt.response = new Response(explained.body, {
status: evt.response.status,
statusText: evt.response.statusText,
headers,
})
return
}
const headers = new Headers(evt.response.headers)
headers.delete("content-length")
headers.delete("content-encoding")
Expand Down
17 changes: 17 additions & 0 deletions packages/antigravity-plugin/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,23 @@ export async function fetchAvailableModels(
)
}

/** Best-effort quota readout. HTTP failures return undefined so a 429 can still be explained. */
export async function fetchQuotaSummary(access: string, project: string, request: Fetch = fetch, signal?: AbortSignal) {
const response = await request(`${cloudCodeEndpoint}/v1internal:retrieveUserQuotaSummary`, {
method: "POST",
headers: {
Authorization: `Bearer ${access}`,
"Content-Type": "application/json",
"User-Agent": userAgent(),
},
body: JSON.stringify({ project }),
signal: requestSignal(signal),
})
if (!response.ok) return
const body: unknown = await response.json()
return body
}

export async function completeAccount(
input: AccountTokens | Tokens,
request: Fetch = fetch,
Expand Down
195 changes: 195 additions & 0 deletions packages/antigravity-plugin/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,201 @@ export function unwrapSSE() {
})
}

export type CloudCodeError = {
code?: number
status?: string
message?: string
reason?: string
resetTime?: string
}

type QuotaWindow = {
id: string
remainingFraction: number
resetTime?: string
}

/**
* Read a Cloud Code error from the complete body. A 500-character prefix is not valid JSON
* once the payload is longer than that, and slicing it drops the Google status and message.
*/
export function parseCloudCodeError(text: string): CloudCodeError | undefined {
const trimmed = text.trim()
if (!trimmed) return
return cloudCodeError(trimmed) ?? cloudCodeError(payloadAfterPrefix(trimmed) ?? "")
}

function quotaWindows(summary: unknown): QuotaWindow[] {
const root = isRecord(summary) ? summary : undefined
if (!root || !Array.isArray(root.groups)) return []
const windows: QuotaWindow[] = []
const seen = new Set<string>()
root.groups.forEach((rawGroup, index) => {
const group = isRecord(rawGroup) ? rawGroup : undefined
if (!group || !Array.isArray(group.buckets)) return
const prefix = quotaGroupID(group, index)
for (const rawBucket of group.buckets) {
const bucket = isRecord(rawBucket) ? rawBucket : undefined
if (!bucket) continue
const fraction = finiteNumber(bucket.remainingFraction ?? bucket.remaining_fraction)
if (fraction === undefined) continue
const id = `${prefix}:${quotaWindowID(bucket)}`
if (seen.has(id)) continue
seen.add(id)
const resetTime = textField(bucket.resetTime ?? bucket.reset_time)
windows.push({ id, remainingFraction: fraction, ...(resetTime ? { resetTime } : {}) })
}
})
return windows
}

export function explainCloudCodeError(input: {
error: CloudCodeError
summary?: unknown
httpStatus: number
modelID?: string
}) {
if (!input.error.status && !input.error.message && !input.error.reason) return
const windows = quotaWindows(input.summary)
const fiveHour = fiveHourWindow(windows, input.modelID)
const quota =
input.error.reason?.toLowerCase() === "quota_exhausted" ||
(input.error.status === "RESOURCE_EXHAUSTED" && fiveHour !== undefined && fiveHour.remainingFraction <= 0) ||
(input.error.status === "RESOURCE_EXHAUSTED" && messageIsLongQuota(input.error.message))
const headline =
input.error.status && input.error.message
? `${input.error.status}: ${input.error.message}`
: (input.error.status ?? input.error.message ?? "")
const readout = windows.map(
(window) =>
`${window.id} remainingFraction=${JSON.stringify(window.remainingFraction)}${window.resetTime ? ` resetTime=${window.resetTime}` : ""}`,
)
const fallbackReset = windows.length === 0 && input.error.resetTime ? [`resetTime=${input.error.resetTime}`] : []
const message = [headline, ...readout, ...fallbackReset].filter((part) => part.length > 0).join(" ")
const reason = quota ? "QUOTA_EXHAUSTED" : input.error.reason
return {
message,
quota,
body: JSON.stringify({
error: {
code: input.httpStatus,
message,
...(input.error.status ? { status: input.error.status } : {}),
...(reason ? { details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason }] } : {}),
},
}),
}
}

function cloudCodeError(text: string): CloudCodeError | undefined {
if (!text) return
const parsed = Option.getOrUndefined(decodeJson(text))
if (!isRecord(parsed)) return
const error = isRecord(parsed.error) ? parsed.error : parsed
const outerMessage = textField(error.message)
const nested = outerMessage ? nestedGoogleError(outerMessage) : undefined
const outerStatus = textField(error.status)
const innerStatus = nested ? textField(nested.status) : undefined
const status = innerStatus && (!outerStatus || outerStatus === "UNKNOWN") ? innerStatus : (outerStatus ?? innerStatus)
const innerMessage = nested ? textField(nested.message) : undefined
const message =
outerMessage && innerMessage && outerMessage.includes("{") ? innerMessage : (outerMessage ?? innerMessage)
const outerDetails = errorDetails(error)
const details = outerDetails.reason ? outerDetails : nested ? errorDetails(nested) : outerDetails
const code = finiteNumber(error.code ?? nested?.code)
if (!status && !message && !details.reason) return
return {
...(code === undefined ? {} : { code }),
...(status ? { status } : {}),
...(message ? { message } : {}),
...(details.reason ? { reason: details.reason } : {}),
...(details.resetTime ? { resetTime: details.resetTime } : {}),
}
}

function nestedGoogleError(message: string) {
const start = message.indexOf("{")
if (start < 0) return
const parsed = Option.getOrUndefined(decodeJson(message.slice(start)))
if (!isRecord(parsed)) return
const error = isRecord(parsed.error) ? parsed.error : parsed
if (!textField(error.status) && !textField(error.message) && !Array.isArray(error.details)) return
return error
}

function errorDetails(error: Record<string, unknown> | undefined) {
const details = error && Array.isArray(error.details) ? error.details : []
return details.reduce<{ reason?: string; resetTime?: string }>((acc, detail) => {
if (!isRecord(detail)) return acc
const metadata = isRecord(detail.metadata) ? detail.metadata : undefined
return {
reason: acc.reason ?? textField(detail.reason),
resetTime:
acc.resetTime ??
textField(metadata?.quotaResetTimeStamp) ??
textField(metadata?.quotaResetTimestamp) ??
textField(metadata?.resetTime),
}
}, {})
}

function payloadAfterPrefix(text: string) {
const data = text.split(/\r?\n/).flatMap((line) => {
const match = /^data:\s*(.*)$/.exec(line)
return match?.[1] && match[1] !== "[DONE]" ? [match[1]] : []
})
const payload = [...data].reverse().find((line) => line.includes("{"))
if (payload) return payload
const start = text.indexOf("{")
if (start < 0) return
return text.slice(start)
}

function quotaGroupID(group: Record<string, unknown>, index: number) {
const text = (
textField(group.displayName) ??
textField(group.display_name) ??
textField(group.id) ??
""
).toLowerCase()
if (text.includes("gemini")) return "gemini"
if (text.includes("claude") || text.includes("gpt")) return "3p"
return textField(group.id) ?? `group-${index}`
}

function quotaWindowID(bucket: Record<string, unknown>) {
const raw = (textField(bucket.window) ?? "").toLowerCase()
if (raw === "5h" || raw === "weekly") return raw
return raw || textField(bucket.bucketId) || textField(bucket.bucket_id) || "limit"
}

function fiveHourWindow(windows: readonly QuotaWindow[], modelID: string | undefined) {
const name = (modelID ?? "").toLowerCase()
const preferred =
name.includes("claude") || name.includes("gpt") ? "3p:5h" : name.includes("gemini") ? "gemini:5h" : undefined
return windows.find((window) => window.id === preferred)
}

function messageIsLongQuota(message: string | undefined) {
if (!message) return false
if (/\b5h\b|5-hour|five[- ]hour/i.test(message)) return true
const hours = /reset after\s+(\d+)h/i.exec(message)
return hours !== null && Number(hours[1]) >= 1
}

function finiteNumber(value: unknown) {
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}

function textField(value: unknown) {
return typeof value === "string" && value.length > 0 ? value : undefined
}

export function applyProvider(evt: ProviderEditor, active: boolean) {
if (!active) return
evt.update(googleProviderID, (provider) => {
Expand Down
Loading
Loading