diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index cee9027a2c..f44bcae52a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -582,6 +582,7 @@ interface PassthroughModelRowProps { modelId: string; fullModel: string; source?: string; + isFree?: boolean; isHidden?: boolean; copied?: string; onCopy: (text: string, key: string) => void; @@ -966,6 +967,7 @@ function ModelCompatPopover({ getUpstreamHeadersRecord, onCompatPatch, showDeveloperToggle = true, + compact = false, disabled, }: { t: (key: string) => string; @@ -981,6 +983,7 @@ function ModelCompatPopover({ } ) => void; showDeveloperToggle?: boolean; + compact?: boolean; disabled?: boolean; }) { const [open, setOpen] = useState(false); @@ -1123,7 +1126,7 @@ function ModelCompatPopover({ title={t("compatAdjustmentsTitle")} > tune - {t("compatButtonLabel")} + {!compact && t("compatButtonLabel")} {open && typeof document !== "undefined" && @@ -4518,8 +4521,8 @@ export default function ProviderDetailPage() { /> {selectedIds.size > 0 - ? `${selectedIds.size} selected` - : `${connections.length} accounts`} + ? t("selectedCount", { count: selectedIds.size }) + : t("accountsCount", { count: connections.length })} @@ -4677,8 +4680,8 @@ export default function ProviderDetailPage() { /> {selectedIds.size > 0 - ? `${selectedIds.size} selected` - : `${connections.length} accounts`} + ? t("selectedCount", { count: selectedIds.size }) + : t("accountsCount", { count: connections.length })} @@ -4691,7 +4694,7 @@ export default function ProviderDetailPage() { loading={distributingProxies} onClick={() => handleDistributeProxies()} > - Distribute Proxies + {t("distributeProxies")} )} {selectedIds.size > 0 && ( @@ -5726,6 +5729,7 @@ function PassthroughModelsSection({ alias: string | null; displayName: string; source: string; + isFree: boolean; isHidden: boolean; }> = []; const seenModelIds = new Set(); @@ -5746,6 +5750,10 @@ function PassthroughModelsSection({ alias: aliasByModelId.get(model.id) || null, displayName: model.name || model.id, source, + isFree: + Boolean((model as any).free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""), isHidden: isModelHidden(model.id), }); seenModelIds.add(model.id); @@ -5773,6 +5781,10 @@ function PassthroughModelsSection({ alias: alias as string, displayName: alias as string, source: customModel ? customModel.source || "custom" : "alias", + isFree: + modelId.endsWith(":free") || + Boolean((customModel as any)?.free) || + /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""), isHidden: isModelHidden(modelId), }); seenModelIds.add(modelId); @@ -5876,27 +5888,33 @@ function PassthroughModelsSection({ selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} /> - {filteredModels.map(({ modelId, fullModel, alias, isHidden, source }) => ( - onDeleteAlias(alias) : undefined} - t={t} - showDeveloperToggle - effectiveModelNormalize={effectiveModelNormalize} - effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} - getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} - saveModelCompatFlags={saveModelCompatFlags} - compatDisabled={compatSavingModelId === modelId} - onToggleHidden={onToggleHidden} - togglingHidden={togglingModelId === modelId} - /> - ))} +
+ {filteredModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => ( + onDeleteAlias(alias) : undefined} + t={t} + showDeveloperToggle + 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}"`, { @@ -5914,6 +5932,7 @@ function PassthroughModelRow({ modelId, fullModel, source, + isFree, isHidden, copied, onCopy, @@ -5933,37 +5952,43 @@ function PassthroughModelRow({ }: PassthroughModelRowProps) { return (

-
+
smart_toy -
-

{modelId}

-
- - {fullModel} - - - -
-
+ + {fullModel} +
-
+
+
+ + {isFree && ( + + {providerText(t, "freeBadge", "Free")} + + )} +
+
+ {onTestModel && ( )} +
); @@ -6609,6 +6636,7 @@ function CompatibleModelsSection({ alias: string | null; displayName: string; source: string; + isFree: boolean; isHidden: boolean; }> = []; const seenModelIds = new Set(); @@ -6626,6 +6654,10 @@ function CompatibleModelsSection({ alias: aliasByModelId.get(model.id) || null, displayName: model.name || model.id, source, + isFree: + Boolean((model as any).free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""), isHidden: isModelHidden(model.id), }); seenModelIds.add(model.id); @@ -6656,6 +6688,10 @@ function CompatibleModelsSection({ alias: alias as string, displayName: alias as string, source: customModel ? customModel.source || "custom" : "alias", + isFree: + modelId.endsWith(":free") || + Boolean((customModel as any)?.free) || + /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""), isHidden: isModelHidden(modelId), }); seenModelIds.add(modelId); @@ -6846,33 +6882,42 @@ function CompatibleModelsSection({ selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} /> - {filteredModels.map(({ modelId, alias, isHidden, source }) => ( - 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"); + } + ); +});