Merge PR #885 antigravity-audit into release/v3.4.2
# Conflicts: # open-sse/executors/antigravity.ts
This commit is contained in:
@@ -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"],
|
||||
},
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: () => [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user