handleDeleteModel(modelId, alias)
- : source === "alias" && alias
- ? () => onDeleteAlias(alias)
- : undefined
- }
- t={t}
- showDeveloperToggle={!isAnthropic}
- effectiveModelNormalize={effectiveModelNormalize}
- effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
- getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
- saveModelCompatFlags={saveModelCompatFlags}
- compatDisabled={compatSavingModelId === modelId}
- onToggleHidden={onToggleHidden}
- togglingHidden={togglingModelId === modelId}
- />
- ))}
+
+ {filteredModels.map(({ modelId, alias, isHidden, source, isFree }) => {
+ const fullModel = `${providerDisplayAlias}/${modelId}`;
+ return (
+
handleDeleteModel(modelId, alias)
+ : source === "alias" && alias
+ ? () => onDeleteAlias(alias)
+ : undefined
+ }
+ t={t}
+ showDeveloperToggle={!isAnthropic}
+ effectiveModelNormalize={effectiveModelNormalize}
+ effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
+ getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
+ saveModelCompatFlags={saveModelCompatFlags}
+ compatDisabled={compatSavingModelId === modelId}
+ onToggleHidden={onToggleHidden}
+ togglingHidden={togglingModelId === modelId}
+ onTestModel={onTestModel}
+ testStatus={modelTestStatus?.[modelId] || null}
+ testingModel={testingModelId === modelId}
+ />
+ );
+ })}
+
{filteredModels.length === 0 && modelFilter && (
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
@@ -7510,40 +7555,6 @@ function ConnectionRow({
>
)}
- {onToggleProxyEnabled && (
- <>
- |
-
- >
- )}
- {onTogglePerKeyProxyEnabled && (
- <>
- |
-
- >
- )}
{hasProxy &&
(() => {
const colorClass =
diff --git a/src/app/api/models/catalog/route.ts b/src/app/api/models/catalog/route.ts
index cd067a10b8..026ee9bcea 100644
--- a/src/app/api/models/catalog/route.ts
+++ b/src/app/api/models/catalog/route.ts
@@ -38,6 +38,7 @@ export async function GET(request: Request) {
name: model.name || model.root || model.id,
type: model.type || "chat",
custom: model.custom === true,
+ ...(model.free === true ? { free: true } : {}),
...(model.capabilities ? { capabilities: model.capabilities } : {}),
...(typeof model.context_length === "number"
? { context_length: model.context_length }
diff --git a/src/app/api/providers/[id]/refresh/route.ts b/src/app/api/providers/[id]/refresh/route.ts
index 1e40bccf63..30c926270c 100644
--- a/src/app/api/providers/[id]/refresh/route.ts
+++ b/src/app/api/providers/[id]/refresh/route.ts
@@ -46,19 +46,13 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
const provider = connection.provider;
- // Codex multi-account family-revocation cascade guard.
- // Rotating-refresh providers (Codex/OpenAI share one Auth0 client_id, etc.)
- // mint a single-use refresh_token on every refresh. This endpoint is invoked
- // per-connection by the dashboard (incl. an OLD cached frontend that bulk-
- // refreshes every expiring connection on a page load); rotating several
- // sibling accounts makes Auth0 revoke the whole token family
- // (openai/codex#9648), killing every account but the last. Never proactively
- // rotate a rotating provider here — the access_token is reused as-is and
- // genuine expiry is handled by the reactive, serialized 401 path on the next
- // real request. This was the last unguarded proactive-refresh entry point
- // (refreshAndUpdateCredentials and the connection-test route are already
- // guarded). Non-rotating providers keep refreshing on demand below.
- if (rotationGroupFor(provider) !== null) {
+ // Codex/OpenAI multi-account family-revocation cascade guard.
+ // These two providers share the same Auth0 client_id and can revoke sibling
+ // accounts when several refresh_tokens are rotated proactively. Other
+ // serialized providers (for example Kiro) still support safe manual refresh;
+ // the serializer only prevents concurrent sibling refreshes.
+ const rotationGroup = rotationGroupFor(provider);
+ if (rotationGroup === "openai-auth0") {
return NextResponse.json({
success: true,
skipped: true,
diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts
index 24a50f5c78..f5baf36690 100644
--- a/src/app/api/v1/models/catalog.ts
+++ b/src/app/api/v1/models/catalog.ts
@@ -20,6 +20,7 @@ import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/mod
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo";
import { getAllSyncedAvailableModels, type SyncedAvailableModel } from "@/lib/db/models";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
+import { getOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog";
import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules";
import {
INTERNAL_PROXY_ERROR,
@@ -143,6 +144,50 @@ function getVisionCapabilityFields(modelId: string) {
};
}
+function qualifyOpenRouterModelId(modelId: string): string {
+ return modelId.startsWith("openrouter/") ? modelId : `openrouter/${modelId}`;
+}
+
+function normalizeOpenRouterModalities(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
+ : [];
+}
+
+function getOpenRouterModelType(inputModalities: string[], outputModalities: string[]) {
+ if (outputModalities.includes("image")) return "image";
+ if (outputModalities.includes("audio")) return "audio";
+ if (outputModalities.includes("video")) return "video";
+ if (outputModalities.includes("embedding")) return "embedding";
+ return "chat";
+}
+
+function isZeroPrice(value: unknown) {
+ if (typeof value === "number") return value === 0;
+ if (typeof value !== "string") return false;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed === 0;
+}
+
+function isOpenRouterFreeModel(model: {
+ id?: string;
+ pricing?: { prompt?: string; completion?: string };
+}) {
+ if (typeof model.id === "string" && model.id.endsWith(":free")) return true;
+ return isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion);
+}
+
+function getOpenRouterDisplayName(model: {
+ id?: string;
+ name?: string;
+ pricing?: { prompt?: string; completion?: string };
+}) {
+ const name = model.name || model.id || "OpenRouter model";
+ return isOpenRouterFreeModel(model) && !/\bgr[aá]tis\b/i.test(name)
+ ? `${name} (Grátis)`
+ : name;
+}
+
function extractBearer(headers: Headers): string | null {
const authHeader = headers.get("authorization") || headers.get("Authorization");
if (!authHeader?.trim().toLowerCase().startsWith("bearer ")) return null;
@@ -822,6 +867,72 @@ export async function getUnifiedModelsResponse(
console.error("[catalog] Error fetching synced provider models:", err);
}
+ if (
+ activeAliases.has("openrouter") &&
+ !blockedProviders.has("openrouter") &&
+ !providersWithSyncedModels.has("openrouter")
+ ) {
+ try {
+ const openRouterCatalog = await getOpenRouterCatalog();
+ for (const openRouterModel of openRouterCatalog.data || []) {
+ if (!openRouterModel?.id || typeof openRouterModel.id !== "string") continue;
+ const qualifiedId = qualifyOpenRouterModelId(openRouterModel.id);
+ if (models.some((existingModel: any) => existingModel?.id === qualifiedId)) continue;
+
+ const inputModalities = normalizeOpenRouterModalities(
+ openRouterModel.architecture?.input_modalities
+ );
+ const outputModalities = normalizeOpenRouterModalities(
+ openRouterModel.architecture?.output_modalities
+ );
+ const modelType = getOpenRouterModelType(inputModalities, outputModalities);
+ const isFree = isOpenRouterFreeModel(openRouterModel);
+ const supportedParameters = Array.isArray(openRouterModel.supported_parameters)
+ ? openRouterModel.supported_parameters
+ : [];
+ const capabilities: Record = {};
+ if (inputModalities.includes("image")) capabilities.vision = true;
+ if (
+ supportedParameters.includes("reasoning") ||
+ supportedParameters.includes("include_reasoning")
+ ) {
+ capabilities.reasoning = true;
+ }
+ if (supportedParameters.includes("tools")) capabilities.tool_calling = true;
+ if (
+ supportedParameters.includes("structured_outputs") ||
+ supportedParameters.includes("response_format")
+ ) {
+ capabilities.structured_output = true;
+ }
+
+ models.push({
+ id: qualifiedId,
+ object: "model",
+ created: openRouterModel.created || timestamp,
+ owned_by: "openrouter",
+ permission: [],
+ root: openRouterModel.id,
+ parent: null,
+ name: getOpenRouterDisplayName(openRouterModel),
+ type: modelType,
+ ...(isFree ? { free: true } : {}),
+ ...(typeof openRouterModel.context_length === "number"
+ ? { context_length: openRouterModel.context_length }
+ : {}),
+ ...(typeof openRouterModel.top_provider?.max_completion_tokens === "number"
+ ? { max_output_tokens: openRouterModel.top_provider.max_completion_tokens }
+ : {}),
+ ...(inputModalities.length > 0 ? { input_modalities: inputModalities } : {}),
+ ...(outputModalities.length > 0 ? { output_modalities: outputModalities } : {}),
+ ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
+ });
+ }
+ } catch (err) {
+ console.error("[catalog] Error loading OpenRouter catalog:", err);
+ }
+ }
+
// Helper: check if a provider is active (by provider id or alias)
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index f7566516da..255e43dd6e 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -4381,6 +4381,11 @@
"apiKeyProviders": "Provedores por Chave de API",
"compatibleProviders": "Provedores Compatíveis por Chave de API",
"testAll": "Testar Todos",
+ "freeBadge": "Grátis",
+ "distributeProxies": "Distribuir proxies",
+ "distributing": "Distribuindo...",
+ "selectedCount": "{count, plural, one {# selecionada} other {# selecionadas}}",
+ "accountsCount": "{count, plural, one {# conta} other {# contas}}",
"testAllOAuth": "Testar todas as conexões OAuth",
"testAllFree": "Testar todas as conexões gratuitas",
"testAllApiKey": "Testar todas as conexões por chave de API",
@@ -4580,6 +4585,15 @@
"configured": "configurado",
"providerProxyConfigureHint": "Configurar proxy para todas as conexões deste provedor",
"providerProxy": "Proxy do Provedor",
+ "proxyConfiguredBySource": "Proxy ({source}): {host}",
+ "proxyOn": "Proxy ligado",
+ "proxyOff": "Proxy desligado",
+ "proxyEnabledTitle": "Proxy ativado para esta conta",
+ "proxyDisabledTitle": "Proxy desativado para esta conta",
+ "perKeyProxyOn": "Por chave",
+ "perKeyProxyOff": "Por conta",
+ "perKeyProxyEnabledTitle": "Distribuição de proxy por chave ativada para este provedor",
+ "perKeyProxyDisabledTitle": "Distribuição de proxy por conta ativada para este provedor",
"repairEnv": "Repair env",
"repairEnvWorking": "Repairing...",
"repairEnvHint": "Restore missing OAuth defaults into .env without overwriting existing values.",
diff --git a/src/lib/catalog/openrouterCatalog.ts b/src/lib/catalog/openrouterCatalog.ts
index 585f1140dd..cb60ec0f96 100644
--- a/src/lib/catalog/openrouterCatalog.ts
+++ b/src/lib/catalog/openrouterCatalog.ts
@@ -44,9 +44,12 @@ interface CatalogEntry {
};
architecture?: {
modality?: string;
+ input_modalities?: string[];
+ output_modalities?: string[];
tokenizer?: string;
instruct_type?: string;
};
+ supported_parameters?: string[];
created?: number;
}
diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts
index ad74037856..e2ac5d9b66 100644
--- a/src/lib/providers/validation.ts
+++ b/src/lib/providers/validation.ts
@@ -113,6 +113,9 @@ function addModelsSuffix(baseUrl: string) {
if (!normalized) return "";
const suffixes = ["/chat/completions", "/responses", "/chat", "/messages"];
+ if (normalized.endsWith("/models")) {
+ return normalized;
+ }
for (const suffix of suffixes) {
if (normalized.endsWith(suffix)) {
return `${normalized.slice(0, -suffix.length)}/models`;
@@ -4012,9 +4015,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
const baseUrlRaw =
providerSpecificData?.baseUrl || "https://integrate.api.nvidia.com/v1/chat/completions";
const normalized = normalizeBaseUrl(baseUrlRaw);
+ const chatBase = normalized.replace(/\/models$/, "");
const chatUrl = normalized.endsWith("/chat/completions")
? normalized
- : `${normalized}/chat/completions`;
+ : `${chatBase}/chat/completions`;
// #3116: probe a universally-available model rather than models[0]
// (z-ai/glm-5.1), which requires the "Public API Endpoints" account permission
// and can hang/be DEGRADED — making a *valid* key fail with "Upstream Error".
@@ -4170,6 +4174,30 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
});
}
+ if (entry.format === "antigravity") {
+ const expiresAt =
+ providerSpecificData?.tokenExpiresAt ||
+ providerSpecificData?.expiresAt ||
+ providerSpecificData?.expiry_date ||
+ providerSpecificData?.expiryDate;
+ const expiryMs =
+ typeof expiresAt === "number"
+ ? expiresAt
+ : typeof expiresAt === "string" && expiresAt.trim()
+ ? Date.parse(expiresAt)
+ : Number.NaN;
+
+ if (Number.isFinite(expiryMs) && expiryMs > 0 && expiryMs < Date.now()) {
+ return {
+ valid: false,
+ error: "Antigravity OAuth token has expired. Re-import or refresh the CLI login.",
+ unsupported: false,
+ };
+ }
+
+ return { valid: true, error: null, unsupported: false };
+ }
+
return { valid: false, error: "Provider validation not supported", unsupported: true };
} catch (error: any) {
return toValidationErrorResult(error);
diff --git a/tests/unit/codex-manual-refresh-rotating-guard.test.ts b/tests/unit/codex-manual-refresh-rotating-guard.test.ts
index 8ba3cc8ef3..9c526d2e8b 100644
--- a/tests/unit/codex-manual-refresh-rotating-guard.test.ts
+++ b/tests/unit/codex-manual-refresh-rotating-guard.test.ts
@@ -32,13 +32,13 @@ test("manual refresh route imports rotationGroupFor", async () => {
);
});
-test("manual refresh route skips proactive refresh for rotating providers BEFORE calling getAccessToken", async () => {
+test("manual refresh route skips proactive refresh for the OpenAI Auth0 family BEFORE calling getAccessToken", async () => {
const src = await read();
- const guardIdx = src.search(/rotationGroupFor\s*\(\s*[\w.]*provider[\w.]*\s*\)\s*!==\s*null/);
+ const guardIdx = src.search(/rotationGroup\s*===\s*["']openai-auth0["']/);
assert.ok(
guardIdx >= 0,
- "refresh route must guard with `rotationGroupFor(provider) !== null` to skip rotating providers"
+ "refresh route must only skip proactive refresh for the OpenAI Auth0 family"
);
const getAccessTokenIdx = src.indexOf("getAccessToken(");
@@ -46,7 +46,7 @@ test("manual refresh route skips proactive refresh for rotating providers BEFORE
assert.ok(
guardIdx < getAccessTokenIdx,
- "the rotating-provider guard must run BEFORE getAccessToken so the rotating refresh_token is never exercised"
+ "the OpenAI Auth0 guard must run BEFORE getAccessToken so the risky refresh_token is never exercised"
);
// The guard short-circuits with an early return (no token rotation).
@@ -54,6 +54,20 @@ test("manual refresh route skips proactive refresh for rotating providers BEFORE
assert.match(
guardBlock,
/return\b/,
- "the rotating-provider guard must return early (defer to the reactive 401 path) instead of refreshing"
+ "the OpenAI Auth0 guard must return early (defer to the reactive 401 path) instead of refreshing"
+ );
+});
+
+test("manual refresh route does not skip Kiro just because it is serialized", async () => {
+ const src = await read();
+ assert.doesNotMatch(
+ src,
+ /rotationGroupFor\s*\(\s*[\w.]*provider[\w.]*\s*\)\s*!==\s*null/,
+ "a blanket rotation-group skip blocks Kiro manual refresh"
+ );
+ assert.match(
+ src,
+ /rotationGroup\s*===\s*["']openai-auth0["']/,
+ "only the OpenAI Auth0 family should be skipped by the manual refresh route"
);
});
diff --git a/tests/unit/nvidia-nim-validator.test.ts b/tests/unit/nvidia-nim-validator.test.ts
index d20eb112dc..f6f82e44f6 100644
--- a/tests/unit/nvidia-nim-validator.test.ts
+++ b/tests/unit/nvidia-nim-validator.test.ts
@@ -79,7 +79,7 @@ test("nvidia specialty validator returns Invalid API key on 401", async () => {
);
});
-test("nvidia specialty validator skips /models probe entirely", async () => {
+test("nvidia specialty validator accepts a successful chat probe", async () => {
const calls: string[] = [];
await withMockServer(
(req, res) => {
@@ -105,3 +105,37 @@ test("nvidia specialty validator skips /models probe entirely", async () => {
}
);
});
+
+test("nvidia specialty validator falls back to stable chat validation model", async () => {
+ let payload: any = null;
+ const calls: string[] = [];
+ await withMockServer(
+ (req, res) => {
+ calls.push(String(req.url));
+ let body = "";
+ req.on("data", (chunk) => {
+ body += String(chunk);
+ });
+ req.on("end", () => {
+ if (String(req.url).endsWith("/chat/completions")) {
+ payload = JSON.parse(body || "{}");
+ }
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({}));
+ });
+ },
+ async (baseUrl) => {
+ const result = await validateProviderApiKey({
+ provider: "nvidia",
+ apiKey: "nv-key",
+ providerSpecificData: { baseUrl },
+ });
+ assert.equal(result.valid, true);
+ assert.ok(
+ calls.some((u) => u.endsWith("/chat/completions")),
+ `should fall back to /chat/completions, called: ${JSON.stringify(calls)}`
+ );
+ assert.equal(payload?.model, "meta/llama-3.1-8b-instruct");
+ }
+ );
+});