diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 5519b9ce5c..1623b598f7 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -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 { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d8a46133d3..c9612edd85 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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", diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 0e2c5fc9b8..e18cf47f46 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -691,15 +691,25 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { const modelEntries = toRecord(dataObj.models); const quotas: Record = {}; + // 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; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index c07d886d0e..7b24eaf19a 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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";