mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user