fix: prevent Antigravity 429 cascade from locking out entire connection

A 429 from one Antigravity model was marking the entire provider connection
as rate-limited, blocking ALL other models on the same account. This happened
in two places: chatCore's error classification (primary) and
markAccountUnavailable (secondary). Both now use model-only lockModel() for
passthrough providers instead of connection-wide rateLimitedUntil.

Also adds:
- Bare Pro model ID normalization (gemini-3-pro → gemini-3-pro-low)
  matching OpenClaw convention
- Internal model exclusion list for quota display, matching CLIProxyAPI
This commit is contained in:
Chris Staley
2026-03-31 21:51:02 -06:00
parent adb8127a30
commit c4e2627b43
4 changed files with 65 additions and 19 deletions

View File

@@ -5,13 +5,21 @@ import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"
const MAX_RETRY_AFTER_MS = 60_000;
const LONG_RETRY_THRESHOLD_MS = 60_000;
const BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
/**
* Strip provider prefixes (e.g. "antigravity/model" → "model").
* Ensures the model name sent to the upstream API never contains a routing prefix.
*/
function cleanModelName(model: string): string {
if (!model) return model;
return model.includes("/") ? model.split("/").pop()! : model;
let clean = model.includes("/") ? model.split("/").pop()! : model;
// Normalize bare Pro IDs to the Low tier (matching OpenClaw convention).
// The upstream API requires an explicit tier suffix; bare IDs cause errors.
if (BARE_PRO_IDS.has(clean)) {
clean = `${clean}-low`;
}
return clean;
}
export class AntigravityExecutor extends BaseExecutor {

View File

@@ -13,7 +13,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.ts";
import { createRequestLogger } from "../utils/requestLogger.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { getUnsupportedParams, getPassthroughProviders } from "../config/providerRegistry.ts";
import {
buildErrorBody,
createErrorResult,
@@ -1211,19 +1211,32 @@ export async function handleChatCore({
`[provider] Node ${connectionId} account deactivated (${statusCode}) — disabling permanently`
);
} else if (errorType === PROVIDER_ERROR_TYPES.RATE_LIMITED) {
const rateLimitedUntil = new Date(Date.now() + retryAfterMs).toISOString();
await updateProviderConnection(connectionId, {
rateLimitedUntil: rateLimitedUntil,
testStatus: "credits_exhausted",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
healthCheckInterval: null,
lastHealthCheckAt: null,
});
console.warn(
`[provider] Node ${connectionId} rate limited (${statusCode}) - Next available at ${rateLimitedUntil}`
);
// For passthrough providers (e.g. Antigravity), each model has independent
// quota. A 429 on one model must NOT lock out the entire connection — other
// models may still have quota available. Use lockModel() instead.
const isPassthrough = provider && getPassthroughProviders().has(provider);
if (isPassthrough) {
const { lockModel } = await import("../services/accountFallback.ts");
const cooldown = retryAfterMs || 60_000;
lockModel(provider, connectionId, model, "rate_limited", cooldown);
console.warn(
`[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil(cooldown / 1000)}s (connection stays active)`
);
} else {
const rateLimitedUntil = new Date(Date.now() + retryAfterMs).toISOString();
await updateProviderConnection(connectionId, {
rateLimitedUntil: rateLimitedUntil,
testStatus: "credits_exhausted",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
healthCheckInterval: null,
lastHealthCheckAt: null,
});
console.warn(
`[provider] Node ${connectionId} rate limited (${statusCode}) - Next available at ${rateLimitedUntil}`
);
}
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
await updateProviderConnection(connectionId, {
testStatus: "credits_exhausted",

View File

@@ -691,15 +691,25 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
const modelEntries = toRecord(dataObj.models);
const quotas: Record<string, UsageQuota> = {};
// Models excluded from quota display — internal/special-purpose models that
// the Antigravity API returns quota for but are not user-callable via
// generateContent. Matches CLIProxyAPI's hardcoded exclusion list.
const ANTIGRAVITY_EXCLUDED_MODELS = new Set([
"chat_20706",
"chat_23310",
"tab_flash_lite_preview",
"tab_jump_flash_lite_preview",
"gemini-2.5-flash-thinking",
"gemini-2.5-pro", // browser subagent model — not user-callable
]);
// Parse per-model quota info from fetchAvailableModels response.
// Show all models that have quota data, excluding only internal models
// (tab-completion, chat placeholders, etc.).
for (const [modelKey, infoValue] of Object.entries(modelEntries)) {
const info = toRecord(infoValue);
const quotaInfo = toRecord(info.quotaInfo);
// Skip internal models and models without quota info
if (info.isInternal === true || Object.keys(quotaInfo).length === 0) {
// Skip internal, excluded, and models without quota info
if (info.isInternal === true || ANTIGRAVITY_EXCLUDED_MODELS.has(modelKey) || Object.keys(quotaInfo).length === 0) {
continue;
}

View File

@@ -803,6 +803,21 @@ export async function markAccountUnavailable(
return { shouldFallback: true, cooldownMs: localCooldown };
}
// ── 429 model-only lockout for passthrough providers ──
// For passthrough providers like Antigravity, each model has independent quota.
// A 429 on one model should NOT lock out the entire connection — other models
// may still have quota available. Use lockModel() instead of connection-wide
// rateLimitedUntil, same pattern as the 404 model-only lockout above.
if (isPassthroughProvider && status === 429 && provider && model) {
const modelCooldown = cooldownMs || COOLDOWN_MS.rateLimited;
lockModel(provider, connectionId, model, reason || "rate_limited", modelCooldown);
log.info(
"AUTH",
`Model-only lockout for ${model} — 429 rate limit ${Math.ceil(modelCooldown / 1000)}s (connection stays active)`
);
return { shouldFallback: true, cooldownMs: modelCooldown };
}
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";