Merge PR #885 antigravity-audit into release/v3.4.2

# Conflicts:
#	open-sse/executors/antigravity.ts
This commit is contained in:
diegosouzapw
2026-04-01 02:19:56 -03:00
16 changed files with 295 additions and 106 deletions
+1 -4
View File
@@ -63,10 +63,7 @@ export const IMAGE_PROVIDERS = {
authType: "oauth",
authHeader: "bearer",
format: "gemini-image", // Special format: uses Gemini generateContent API
models: [
{ id: "gemini-2.5-flash-preview-image-generation", name: "Gemini 2.5 Flash Image" },
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" },
],
models: [],
supportedSizes: ["1024x1024"],
},
+21 -11
View File
@@ -46,7 +46,7 @@ export interface RegistryOAuth {
export interface RegistryEntry {
id: string;
alias: string;
alias?: string;
format: string;
executor: string;
baseUrl?: string;
@@ -359,7 +359,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
antigravity: {
id: "antigravity",
alias: "ag",
alias: undefined,
format: "antigravity",
executor: "antigravity",
baseUrls: [
@@ -387,16 +387,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
models: [
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
{ id: "gpt-5", name: "GPT 5" },
{ id: "gpt-5-mini", name: "GPT 5 Mini" },
],
passthroughModels: true,
},
@@ -1550,6 +1545,21 @@ export function isLocalProvider(baseUrl?: string | null): boolean {
}
}
/** Set of provider IDs with passthroughModels enabled — 404s are model-specific, not account-level. */
const _passthroughProviderIds: Set<string> | null = (() => {
try {
const ids = new Set<string>();
for (const entry of Object.values(REGISTRY)) {
if (entry.passthroughModels) ids.add(entry.id);
}
return ids;
} catch { return null; }
})();
export function getPassthroughProviders(): Set<string> {
return _passthroughProviderIds ?? new Set<string>();
}
// ── Registry Lookup Helpers ───────────────────────────────────────────────
const _byAlias = new Map<string, RegistryEntry>();
+135 -7
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.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 {
@@ -22,8 +30,12 @@ export class AntigravityExecutor extends BaseExecutor {
buildUrl(model, stream, urlIndex = 0) {
const baseUrls = this.getBaseUrls();
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${baseUrl}/v1internal:${action}`;
// Always use streaming endpoint — the non-streaming `generateContent` causes
// upstream 400 errors for some models (e.g. gpt-oss-120b-medium) because the
// Cloud Code API internally converts to OpenAI format and injects
// stream_options without setting stream=true. chatCore already handles
// SSE→JSON conversion for non-streaming client requests.
return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`;
}
buildHeaders(credentials, stream = true) {
@@ -32,7 +44,7 @@ export class AntigravityExecutor extends BaseExecutor {
Authorization: `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64",
"X-OmniRoute-Source": "omniroute",
...(stream && { Accept: "text/event-stream" }),
Accept: "text/event-stream",
};
}
@@ -202,6 +214,106 @@ export class AntigravityExecutor extends BaseExecutor {
return totalMs > 0 ? totalMs : null;
}
/**
* Collect an SSE streaming response into a single non-streaming JSON response.
* Parses Gemini-format SSE chunks and assembles text content + usage into one
* OpenAI-format chat.completion payload.
*/
collectStreamToResponse(response, model, url, headers, transformedBody, log?, signal?) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const SSE_COLLECT_TIMEOUT_MS = 120_000;
const collect = async () => {
const chunks: string[] = [];
let timedOut = false;
const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS);
try {
// eslint-disable-next-line no-constant-condition
while (true) {
if (signal?.aborted) throw new Error("Request aborted during SSE collection");
const { done, value } = await Promise.race([
reader.read(),
new Promise<never>((_, reject) =>
timeout.addEventListener("abort", () => reject(new Error("SSE collection timed out")), { once: true })
),
]);
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
} catch (err) {
const msg = err?.message || String(err);
timedOut = msg.includes("timed out");
log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${msg}`);
// Fall through — return whatever was collected so far
}
const rawSSE = chunks.join("");
// Parse Gemini SSE: each line is "data: {json}"
let textContent = "";
let finishReason = "stop";
let usage: Record<string, unknown> | null = null;
const lines = rawSSE.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const parsed = JSON.parse(payload);
const candidate = parsed?.response?.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) {
textContent += part.text;
}
}
}
if (candidate?.finishReason) {
finishReason = candidate.finishReason.toLowerCase() === "stop" ? "stop" : candidate.finishReason.toLowerCase();
}
if (parsed?.response?.usageMetadata) {
const um = parsed.response.usageMetadata;
usage = {
prompt_tokens: um.promptTokenCount || 0,
completion_tokens: um.candidatesTokenCount || 0,
total_tokens: um.totalTokenCount || 0,
};
}
} catch (e) {
log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`);
}
}
const result = {
id: `chatcmpl-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: { role: "assistant", content: textContent },
finish_reason: timedOut ? "length" : finishReason,
},
],
...(usage && { usage }),
};
const syntheticStatus = timedOut ? 504 : response.status;
const syntheticResponse = new Response(JSON.stringify(result), {
status: syntheticStatus,
statusText: timedOut ? "Gateway Timeout" : response.statusText,
headers: [["Content-Type", "application/json"]],
});
return { response: syntheticResponse, url, headers, transformedBody };
};
return collect();
}
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
@@ -209,11 +321,21 @@ export class AntigravityExecutor extends BaseExecutor {
const MAX_AUTO_RETRIES = 3;
const retryAttemptsByUrl = {}; // Track retry attempts per URL
// Always stream upstream — buildUrl always returns the streaming endpoint.
// For non-streaming clients, we collect the SSE below and return a synthetic
// non-streaming Response so chatCore's non-streaming path stays unchanged.
const upstreamStream = true;
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex);
const headers = this.buildHeaders(credentials, stream);
const url = this.buildUrl(model, upstreamStream, urlIndex);
const headers = this.buildHeaders(credentials, upstreamStream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = await this.transformRequest(model, body, stream, credentials);
const transformedBody = await this.transformRequest(
model,
body,
upstreamStream,
credentials
);
// Initialize retry counter for this URL
if (!retryAttemptsByUrl[urlIndex]) {
@@ -349,6 +471,12 @@ export class AntigravityExecutor extends BaseExecutor {
}
}
// For non-streaming clients, collect the SSE stream and return a synthetic
// non-streaming Response so chatCore doesn't need to handle SSE conversion.
if (!stream) {
return this.collectStreamToResponse(response, model, url, headers, transformedBody, log, signal);
}
return { response, url, headers, transformedBody };
} catch (error) {
lastError = error;
+27 -14
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,
@@ -1208,19 +1208,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 || 120_000; // 2 min default, same as COOLDOWN_MS.rateLimit
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",
-3
View File
@@ -51,9 +51,6 @@ const REASONING_UNSUPPORTED_PATTERNS = [
"antigravity/claude-sonnet-4-6",
"antigravity/claude-sonnet-4-5",
"antigravity/claude-sonnet-4",
"ag/claude-sonnet-4-6",
"ag/claude-sonnet-4-5",
"ag/claude-sonnet-4",
];
function getRegistryReasoningFlag(providerIdOrAlias: string, modelId: string): boolean | null {
+62 -39
View File
@@ -657,34 +657,26 @@ function getAntigravityPlanLabel(subscriptionInfo) {
/**
* Antigravity Usage - Fetch quota from Google Cloud Code API
* Now calls loadCodeAssist ONCE (cached) and reuses for projectId + plan.
* Uses retrieveUserQuota API (same as Gemini CLI) for accurate quota data across all tiers.
* Uses fetchAvailableModels API which returns ALL models (including Claude)
* with per-model quotaInfo (remainingFraction, resetTime).
* retrieveUserQuota only returns Gemini models not suitable for Antigravity.
*/
async function getAntigravityUsage(accessToken, providerSpecificData) {
try {
const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken);
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
if (!projectId) {
return {
plan: getAntigravityPlanLabel(subscriptionInfo),
message: "Antigravity project ID not available.",
};
}
// Use retrieveUserQuota API (same as Gemini CLI) - works correctly for both Free and Pro tiers
const response = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ project: projectId }),
signal: AbortSignal.timeout(10000),
}
);
// Fetch model list with quota info from fetchAvailableModels
const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
},
body: JSON.stringify(projectId ? { project: projectId } : {}),
signal: AbortSignal.timeout(10000),
});
if (response.status === 403) {
return { message: "Antigravity access forbidden. Check subscription." };
@@ -695,28 +687,59 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
}
const data = await response.json();
const dataObj = toRecord(data);
const modelEntries = toRecord(dataObj.models);
const quotas: Record<string, UsageQuota> = {};
// Parse buckets from retrieveUserQuota response (same format as Gemini CLI)
if (Array.isArray(data.buckets)) {
for (const bucket of data.buckets) {
if (!bucket.modelId || bucket.remainingFraction == null) continue;
// 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
"gemini-2.5-flash", // internal — quota always exhausted on free tier
"gemini-2.5-flash-lite", // internal — quota always exhausted on free tier
"gemini-2.5-flash-preview-image-generation", // image-gen only, not usable for chat
"gemini-3.1-flash-image-preview", // image-gen preview, not usable for chat
"gemini-3-flash-agent", // internal agent model — not user-callable
"gemini-3.1-flash-lite", // not usable for chat
"gemini-3-pro-low", // not usable for chat
"gemini-3-pro-high", // not usable for chat
]);
const remainingFraction = toNumber(bucket.remainingFraction, 0);
const remainingPercentage = remainingFraction * 100;
const QUOTA_NORMALIZED_BASE = 1000;
const total = QUOTA_NORMALIZED_BASE;
const remaining = Math.round(total * remainingFraction);
const used = Math.max(0, total - remaining);
// Parse per-model quota info from fetchAvailableModels response.
for (const [modelKey, infoValue] of Object.entries(modelEntries)) {
const info = toRecord(infoValue);
const quotaInfo = toRecord(info.quotaInfo);
quotas[bucket.modelId] = {
used,
total,
resetAt: parseResetTime(bucket.resetTime),
remainingPercentage,
unlimited: false,
};
// Skip internal, excluded, and models without quota info
if (info.isInternal === true || ANTIGRAVITY_EXCLUDED_MODELS.has(modelKey) || Object.keys(quotaInfo).length === 0) {
continue;
}
const rawFraction = toNumber(quotaInfo.remainingFraction, -1);
const resetAt = parseResetTime(quotaInfo.resetTime);
// Default to 100% when the API doesn't report a fraction
const remainingFraction = rawFraction < 0 ? 1 : rawFraction;
// Models with no resetTime and full remaining are unlimited (e.g. tab-completion models)
const isUnlimited = !resetAt && remainingFraction >= 1;
const remainingPercentage = remainingFraction * 100;
const QUOTA_NORMALIZED_BASE = 1000;
const total = QUOTA_NORMALIZED_BASE;
const remaining = Math.round(total * remainingFraction);
const used = isUnlimited ? 0 : Math.max(0, total - remaining);
quotas[modelKey] = {
used,
total: isUnlimited ? 0 : total,
resetAt,
remainingPercentage: isUnlimited ? 100 : remainingPercentage,
unlimited: isUnlimited,
};
}
return {
@@ -1450,10 +1450,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const PAID_PREMIUM_PRESET_MODELS = [
{ model: "cu/claude-4.6-opus-high", weight: 0 },
{ model: "ag/claude-sonnet-4-6", weight: 0 },
{ model: "antigravity/claude-sonnet-4-6", weight: 0 },
{ model: "cu/claude-4.6-sonnet-high", weight: 0 },
{ model: "ag/gpt-5", weight: 0 },
{ model: "ag/gemini-3.1-pro-preview", weight: 0 },
{ model: "antigravity/gemini-3.1-pro-high", weight: 0 },
{ model: "antigravity/gemini-3-pro-high", weight: 0 },
];
const applyTemplate = (template) => {
@@ -619,7 +619,9 @@ export default function ProviderLimits() {
<div className="text-xs text-text-muted italic">{quota.message}</div>
) : quota?.quotas?.length > 0 ? (
quota.quotas.map((q, i) => {
const remainingPercentage = calculatePercentage(q.used, q.total);
const remainingPercentage = q.unlimited
? 100
: (q.remainingPercentage ?? calculatePercentage(q.used, q.total));
const colors = getBarColor(remainingPercentage);
const cd = formatCountdown(q.resetAt);
const shortName = formatQuotaLabel(q.name);
@@ -204,8 +204,8 @@ export function parseQuotaData(provider, data) {
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
normalizedQuotas.push(
normalizeQuotaEntry(quota.displayName || modelKey, quota, {
modelKey: modelKey, // Keep modelKey for sorting
normalizeQuotaEntry(modelKey, quota, {
modelKey: modelKey,
})
);
});
+4 -5
View File
@@ -69,11 +69,10 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
antigravity: () => [
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
],
claude: () => [
+1 -1
View File
@@ -394,7 +394,7 @@ export default function DocsPage() {
{t("clientClaudeBullet1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">cc/</code>{" "}
{t("clientClaudeBullet1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">ag/</code>{" "}
<code className="px-1 rounded bg-bg-subtle">antigravity/</code>{" "}
{t("clientClaudeBullet1Suffix")}
</li>
<li>{t("oauthAutoRefresh")}</li>
+2 -2
View File
@@ -32,7 +32,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
// ── Gemini 3.1 Pro High ─────────────────────────────────────────
"gemini-3.1-pro-high": {
maxOutputTokens: 131072,
maxOutputTokens: 65535,
contextWindow: 1048576,
defaultThinkingBudget: 24576,
thinkingBudgetCap: 32768,
@@ -45,7 +45,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
// ── Gemini 3.1 Pro Low ──────────────────────────────────────────
"gemini-3.1-pro-low": {
maxOutputTokens: 131072,
maxOutputTokens: 65535,
contextWindow: 1048576,
defaultThinkingBudget: 8192,
thinkingBudgetCap: 16000,
+3 -3
View File
@@ -22,7 +22,7 @@ export const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
antigravity: {
id: "antigravity",
alias: "ag",
alias: undefined,
name: "Antigravity",
icon: "rocket_launch",
color: "#F59E0B",
@@ -643,13 +643,13 @@ export function getProviderAlias(providerId) {
// Alias to ID mapping (for quick lookup)
export const ALIAS_TO_ID = Object.values(AI_PROVIDERS).reduce((acc, p) => {
acc[p.alias] = p.id;
if (p.alias) acc[p.alias] = p.id;
return acc;
}, {});
// ID to Alias mapping
export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
acc[p.id] = p.alias;
acc[p.id] = p.alias || p.id;
return acc;
}, {});
+1 -1
View File
@@ -12,7 +12,7 @@ import { z } from "zod";
export const ProviderSchema = z.object({
id: z.string().min(1),
alias: z.string().min(1),
alias: z.string().min(1).optional(),
name: z.string().min(1),
icon: z.string().min(1),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Must be a valid hex color (#RRGGBB)"),
+24 -7
View File
@@ -15,7 +15,7 @@ import {
isModelLocked,
lockModel,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { isLocalProvider, getPassthroughProviders } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
import * as log from "../utils/logger";
@@ -784,23 +784,40 @@ export async function markAccountUnavailable(
const { shouldFallback, cooldownMs, newBackoffLevel, reason } = result;
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
// ── Local provider 404: model-only lockout, connection stays active ──
// Detection: URL-based only (apiKey===null heuristic was too broad — could match
// cloud providers with non-standard auth stored in providerSpecificData).
// ── 404 model-only lockout: connection stays active ──
// For local providers (detected by URL) and cloud providers with passthrough models
// (like Antigravity), a 404 means the specific model doesn't exist or isn't available
// for this account — it should NOT lock out the entire connection.
const connBaseUrl = (conn?.providerSpecificData as Record<string, unknown>)?.baseUrl as
| string
| undefined;
if (isLocalProvider(connBaseUrl) && status === 404 && provider && model) {
const isPassthroughProvider = provider && getPassthroughProviders().has(provider);
if ((isLocalProvider(connBaseUrl) || isPassthroughProvider) && status === 404 && provider && model) {
const localCooldown = COOLDOWN_MS.notFoundLocal;
lockModel(provider, connectionId, model, "local_not_found", localCooldown);
lockModel(provider, connectionId, model, "not_found", localCooldown);
log.info(
"AUTH",
`Local 404 for ${model}model-only lockout ${localCooldown / 1000}s (connection stays active)`
`Model-only lockout for ${model}404 lockout ${localCooldown / 1000}s (connection stays active)`
);
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.rateLimit;
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";
@@ -15,11 +15,14 @@ test("T28: gemini catalog includes preview models from 9router", () => {
assert.ok(geminiCliIds.includes("gemini-3-flash-preview"));
});
test("T28: antigravity static catalog includes Gemini 3.1 preview fallbacks", () => {
test("T28: antigravity static catalog includes Gemini 3 Pro High/Low tier models", () => {
const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id);
assert.ok(staticIds.includes("gemini-3.1-pro-preview"));
assert.ok(staticIds.includes("gemini-3.1-flash-lite-preview"));
assert.ok(staticIds.includes("gemini-3.1-pro-high"));
assert.ok(staticIds.includes("gemini-3.1-pro-low"));
assert.ok(staticIds.includes("gemini-3-pro-high"));
assert.ok(staticIds.includes("gemini-3-pro-low"));
assert.ok(staticIds.includes("gemini-3-flash"));
});
test("T28: qwen registry uses native chat.qwen.ai base URL", () => {