mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(providers): improve refresh validation and model catalog UI (#3261)
Provider refresh/validation, OpenRouter catalog and proxy UI fixes — incl. NVIDIA NIM /models-suffix path fix (real-VPS validated). Integrated into release/v3.8.12. Thanks @strangersp.
This commit is contained in:
@@ -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")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-base leading-none">tune</span>
|
||||
{t("compatButtonLabel")}
|
||||
{!compact && t("compatButtonLabel")}
|
||||
</button>
|
||||
{open &&
|
||||
typeof document !== "undefined" &&
|
||||
@@ -4518,8 +4521,8 @@ export default function ProviderDetailPage() {
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? `${selectedIds.size} selected`
|
||||
: `${connections.length} accounts`}
|
||||
? t("selectedCount", { count: selectedIds.size })
|
||||
: t("accountsCount", { count: connections.length })}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -4677,8 +4680,8 @@ export default function ProviderDetailPage() {
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? `${selectedIds.size} selected`
|
||||
: `${connections.length} accounts`}
|
||||
? t("selectedCount", { count: selectedIds.size })
|
||||
: t("accountsCount", { count: connections.length })}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -4691,7 +4694,7 @@ export default function ProviderDetailPage() {
|
||||
loading={distributingProxies}
|
||||
onClick={() => handleDistributeProxies()}
|
||||
>
|
||||
Distribute Proxies
|
||||
{t("distributeProxies")}
|
||||
</Button>
|
||||
)}
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -5726,6 +5729,7 @@ function PassthroughModelsSection({
|
||||
alias: string | null;
|
||||
displayName: string;
|
||||
source: string;
|
||||
isFree: boolean;
|
||||
isHidden: boolean;
|
||||
}> = [];
|
||||
const seenModelIds = new Set<string>();
|
||||
@@ -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 }) => (
|
||||
<PassthroughModelRow
|
||||
key={fullModel as string}
|
||||
modelId={modelId}
|
||||
fullModel={fullModel}
|
||||
source={source}
|
||||
isHidden={isHidden}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={source === "alias" && alias ? () => 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}
|
||||
/>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => (
|
||||
<PassthroughModelRow
|
||||
key={fullModel as string}
|
||||
modelId={modelId}
|
||||
fullModel={fullModel}
|
||||
source={source}
|
||||
isFree={isFree}
|
||||
isHidden={isHidden}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={source === "alias" && alias ? () => 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredModels.length === 0 && modelFilter && (
|
||||
<p className="py-2 text-sm text-text-muted">
|
||||
{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 (
|
||||
<div
|
||||
className={`flex gap-0 rounded-lg border border-border p-3 transition-opacity hover:bg-sidebar/50 ${
|
||||
className={`flex min-w-0 flex-col gap-2 rounded-lg border border-border px-3.5 py-3 transition-opacity hover:bg-sidebar/50 ${
|
||||
isHidden ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined shrink-0 text-base text-text-muted"
|
||||
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
|
||||
>
|
||||
smart_toy
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{modelId}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<code className="rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted">
|
||||
{fullModel}
|
||||
</code>
|
||||
<ModelSourceBadge source={source} />
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
title={t("copyModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<code
|
||||
className="min-w-0 truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted"
|
||||
title={fullModel}
|
||||
>
|
||||
{fullModel}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 self-start">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<ModelSourceBadge source={source} />
|
||||
{isFree && (
|
||||
<Badge variant="success" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||||
{providerText(t, "freeBadge", "Free")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
title={t("copyModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(modelId, fullModel)}
|
||||
@@ -6017,6 +6042,7 @@ function PassthroughModelRow({
|
||||
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
compact
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
{onDeleteAlias && (
|
||||
@@ -6028,6 +6054,7 @@ function PassthroughModelRow({
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -6609,6 +6636,7 @@ function CompatibleModelsSection({
|
||||
alias: string | null;
|
||||
displayName: string;
|
||||
source: string;
|
||||
isFree: boolean;
|
||||
isHidden: boolean;
|
||||
}> = [];
|
||||
const seenModelIds = new Set<string>();
|
||||
@@ -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 }) => (
|
||||
<PassthroughModelRow
|
||||
key={`${providerStorageAlias}:${modelId}`}
|
||||
modelId={modelId}
|
||||
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||
source={source}
|
||||
isHidden={isHidden}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={
|
||||
source === "custom" || source === "manual"
|
||||
? () => 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}
|
||||
/>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredModels.map(({ modelId, alias, isHidden, source, isFree }) => {
|
||||
const fullModel = `${providerDisplayAlias}/${modelId}`;
|
||||
return (
|
||||
<PassthroughModelRow
|
||||
key={`${providerStorageAlias}:${modelId}`}
|
||||
modelId={modelId}
|
||||
fullModel={fullModel}
|
||||
source={source}
|
||||
isFree={isFree}
|
||||
isHidden={isHidden}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={
|
||||
source === "custom" || source === "manual"
|
||||
? () => 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredModels.length === 0 && modelFilter && (
|
||||
<p className="py-2 text-sm text-text-muted">
|
||||
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
|
||||
@@ -7510,40 +7555,6 @@ function ConnectionRow({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onToggleProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleProxyEnabled(!proxyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
proxyEnabled
|
||||
? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">vpn_lock</span>
|
||||
{proxyEnabled ? t("proxyOn") : t("proxyOff")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onTogglePerKeyProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
perKeyProxyEnabled
|
||||
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">key</span>
|
||||
{perKeyProxyEnabled ? t("perKeyProxyOn") : t("perKeyProxyOff")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasProxy &&
|
||||
(() => {
|
||||
const colorClass =
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, boolean> = {};
|
||||
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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user