mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
fix(providers): unify connection and routing flows (#7629)
* fix(providers): unify connection and routing flows * docs(changelog): add provider flow consistency entry * test(providers): move section-visibility cases to own file (test file-size cap) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(api): extract fetchLiveNoAuthModels + toLiveModel helpers (cognitive-complexity gate on buildNoAuthModelsResponse) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,10 @@ import { useRouter } from "next/navigation";
|
||||
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { AI_PROVIDERS, NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import {
|
||||
isProviderConnectionConnected,
|
||||
isProviderConnectionErrored,
|
||||
} from "@/shared/utils/providerConnectionStatus";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
@@ -423,19 +427,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const providerStats = useMemo(() => {
|
||||
return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => {
|
||||
const connections = providerConnections.filter((conn) => conn.provider === providerId);
|
||||
const connected = connections.filter(
|
||||
(conn) =>
|
||||
conn.isActive !== false &&
|
||||
(conn.testStatus === "active" ||
|
||||
conn.testStatus === "success" ||
|
||||
conn.testStatus === "unknown")
|
||||
const connected = connections.filter((connection) =>
|
||||
isProviderConnectionConnected(connection)
|
||||
).length;
|
||||
const errors = connections.filter(
|
||||
(conn) =>
|
||||
conn.isActive !== false &&
|
||||
(conn.testStatus === "error" ||
|
||||
conn.testStatus === "expired" ||
|
||||
conn.testStatus === "unavailable")
|
||||
const errors = connections.filter((connection) =>
|
||||
isProviderConnectionErrored(connection)
|
||||
).length;
|
||||
|
||||
const providerKeys = new Set([providerId, providerInfo.alias].filter(Boolean));
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { useApiKey } from "../../providers/hooks/useApiKey";
|
||||
import { useProviderModels } from "../../providers/hooks/useProviderModels";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
|
||||
const ENDPOINT = "/api/v1/chat/completions";
|
||||
|
||||
@@ -35,22 +36,23 @@ function resolvePlaygroundKeyId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualify a provider-scoped playground model with its `providerId/` prefix so
|
||||
* Qualify a provider-scoped playground model with its routing prefix so
|
||||
* OmniRoute can resolve it unambiguously. The previous heuristic only prefixed
|
||||
* models without a `/`, which skipped vendor-namespaced ids like
|
||||
* `moonshotai/kimi-k2.6` or `nvidia/zyphra/zamba2-7b-instruct` — those already
|
||||
* contain a slash, so they were sent bare and rejected with
|
||||
* "Ambiguous model ... Use provider/model prefix" when the same id exists under
|
||||
* several providers (#3050). Always prefix unless the id is already qualified
|
||||
* with this provider.
|
||||
* several providers (#3050). The routing prefix is normally the provider alias,
|
||||
* which can intentionally differ from the provider id (for example `oc` routes
|
||||
* OpenCode Free while `opencode` is reserved by the Zen executor).
|
||||
*/
|
||||
export function qualifyPlaygroundModel(
|
||||
model: string | null | undefined,
|
||||
providerId: string | null | undefined
|
||||
routingPrefix: string | null | undefined
|
||||
): string {
|
||||
const m = (model ?? "").trim();
|
||||
if (!m || !providerId) return m;
|
||||
return m === providerId || m.startsWith(`${providerId}/`) ? m : `${providerId}/${m}`;
|
||||
if (!m || !routingPrefix) return m;
|
||||
return m === routingPrefix || m.startsWith(`${routingPrefix}/`) ? m : `${routingPrefix}/${m}`;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -163,11 +165,12 @@ export function LlmChatCard({
|
||||
|
||||
const firstModel = models[0]?.id ?? "";
|
||||
const effectiveModel = model || firstModel || initialModel || "";
|
||||
// Auto-prefix model with providerId to avoid OmniRoute "Ambiguous model"
|
||||
const routingPrefix = getProviderAlias(providerId);
|
||||
// Auto-prefix model with the provider's routing alias to avoid OmniRoute "Ambiguous model"
|
||||
// rejection when the same id is registered under multiple providers. This
|
||||
// also covers vendor-namespaced ids (e.g. `moonshotai/kimi-k2.6`) that already
|
||||
// contain a slash but still need the provider prefix (#3050).
|
||||
const qualifiedModel = qualifyPlaygroundModel(effectiveModel, providerId);
|
||||
const qualifiedModel = qualifyPlaygroundModel(effectiveModel, routingPrefix);
|
||||
|
||||
// Autofocus textarea in embedded mode
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
import { partitionNoAuthEntriesByBlocked } from "@/shared/utils/noAuthProviders";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
import {
|
||||
isProviderConnectionConnected,
|
||||
isProviderConnectionErrored,
|
||||
} from "@/shared/utils/providerConnectionStatus";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
filterConfiguredProviderEntries,
|
||||
shouldFilterProviderEntriesForDisplayMode,
|
||||
shouldShowFirstProviderHint,
|
||||
shouldShowProviderSection,
|
||||
upsertProviderNodeById,
|
||||
loadProviderPageData,
|
||||
} from "./providerPageUtils";
|
||||
@@ -201,7 +206,6 @@ export default function ProvidersPage() {
|
||||
// search and configured-only. null = no serviceKind filter.
|
||||
const [activeServiceKind, setActiveServiceKind] = useState<string | null>(null);
|
||||
const notify = useNotificationStore();
|
||||
const hasSearchQuery = searchQuery.trim().length > 0 || modelSearchQuery.trim().length > 0;
|
||||
const sectionCategoryAliases: Record<string, string> = {
|
||||
cloud: "cloudagent",
|
||||
noauth: "no-auth",
|
||||
@@ -210,9 +214,7 @@ export default function ProvidersPage() {
|
||||
};
|
||||
const showSection = (category: string) => {
|
||||
const normalizedCategory = sectionCategoryAliases[category] ?? category;
|
||||
if (showFreeOnly) return normalizedCategory === "free";
|
||||
if (hasSearchQuery && !activeCategory) return normalizedCategory !== "free";
|
||||
return !activeCategory || activeCategory === normalizedCategory;
|
||||
return shouldShowProviderSection(normalizedCategory, activeCategory, showFreeOnly);
|
||||
};
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
@@ -326,22 +328,13 @@ export default function ProvidersPage() {
|
||||
connectionMatchesProviderCard(c, providerId, authType)
|
||||
);
|
||||
|
||||
// Helper: check if connection is effectively active (cooldown expired)
|
||||
const getEffectiveStatus = (conn) => {
|
||||
const isCooldown =
|
||||
conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
|
||||
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
|
||||
};
|
||||
const connected = providerConnections.filter((connection) =>
|
||||
isProviderConnectionConnected(connection)
|
||||
).length;
|
||||
|
||||
const connected = providerConnections.filter((c) => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "active" || status === "success";
|
||||
}).length;
|
||||
|
||||
const errorConns = providerConnections.filter((c) => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "error" || status === "expired" || status === "unavailable";
|
||||
});
|
||||
const errorConns = providerConnections.filter((connection) =>
|
||||
isProviderConnectionErrored(connection)
|
||||
);
|
||||
|
||||
const error = errorConns.length;
|
||||
const total = providerConnections.length;
|
||||
|
||||
@@ -69,6 +69,20 @@ export function shouldShowFirstProviderHint(
|
||||
return connectionCount === 0 && !searchQuery?.trim();
|
||||
}
|
||||
|
||||
export function shouldShowProviderSection(
|
||||
category: string,
|
||||
activeCategory: string | null,
|
||||
showFreeOnly: boolean
|
||||
): boolean {
|
||||
if (showFreeOnly) return category === "free";
|
||||
if (activeCategory) return activeCategory === category;
|
||||
|
||||
// Free and Web Fetch are cross-cutting views assembled from providers that
|
||||
// already belong to a primary section. Rendering them in the default view
|
||||
// duplicates cards; they remain available through their summary filters.
|
||||
return category !== "free" && category !== "webfetch";
|
||||
}
|
||||
|
||||
type ProviderRecord<TProvider = Record<string, unknown>> = Record<string, TProvider>;
|
||||
|
||||
const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q", "kimi-coding"]);
|
||||
|
||||
@@ -109,6 +109,91 @@ import {
|
||||
fetchCodexGithubCatalogModels,
|
||||
} from "./discovery/codex";
|
||||
|
||||
function toLiveModel(item: Record<string, unknown>): { id: string; name: string } | null {
|
||||
const itemId = typeof item.id === "string" ? item.id.trim() : "";
|
||||
if (!itemId) return null;
|
||||
const itemName =
|
||||
typeof item.display_name === "string"
|
||||
? item.display_name
|
||||
: typeof item.name === "string"
|
||||
? item.name
|
||||
: itemId;
|
||||
return { id: itemId, name: itemName };
|
||||
}
|
||||
|
||||
async function fetchLiveNoAuthModels(
|
||||
modelsUrl: string,
|
||||
providerId: string,
|
||||
connectionId: string,
|
||||
excludeHidden: boolean
|
||||
): Promise<NextResponse | null> {
|
||||
try {
|
||||
const liveResponse = await safeOutboundFetch(modelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!liveResponse.ok) return null;
|
||||
|
||||
const data = await liveResponse.json();
|
||||
const liveModels: Array<{ id: string; name: string }> = (
|
||||
(data.data || data.models || []) as Array<Record<string, unknown>>
|
||||
)
|
||||
.map(toLiveModel)
|
||||
.filter((model): model is { id: string; name: string } => model !== null);
|
||||
if (liveModels.length === 0) return null;
|
||||
|
||||
const visible = excludeHidden
|
||||
? liveModels.filter((model) => !getModelIsHidden(providerId, model.id))
|
||||
: liveModels;
|
||||
return NextResponse.json({
|
||||
provider: providerId,
|
||||
connectionId,
|
||||
models: visible,
|
||||
source: "upstream",
|
||||
});
|
||||
} catch {
|
||||
// Live fetch failed — fall back to the bundled catalog.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildNoAuthModelsResponse(
|
||||
providerId: string,
|
||||
connectionId: string,
|
||||
excludeHidden: boolean
|
||||
) {
|
||||
if (isProviderBlockedByIdOrAlias(providerId, (await getSettings()).blockedProviders)) {
|
||||
return NextResponse.json({ error: "Provider is disabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
const registryEntry = getRegistryEntry(providerId);
|
||||
const modelsUrl =
|
||||
typeof registryEntry?.modelsUrl === "string" && registryEntry.modelsUrl.length > 0
|
||||
? registryEntry.modelsUrl
|
||||
: null;
|
||||
|
||||
if (modelsUrl) {
|
||||
const live = await fetchLiveNoAuthModels(modelsUrl, providerId, connectionId, excludeHidden);
|
||||
if (live) return live;
|
||||
}
|
||||
|
||||
const catalog = mergeLocalCatalogModels(
|
||||
getModelsByProviderId(providerId) || [],
|
||||
getStaticModelsForProvider(providerId) || []
|
||||
).map((model) => ({ id: model.id, name: model.name || model.id }));
|
||||
const visible = excludeHidden
|
||||
? catalog.filter((model) => !getModelIsHidden(providerId, model.id))
|
||||
: catalog;
|
||||
return NextResponse.json({
|
||||
provider: providerId,
|
||||
connectionId,
|
||||
models: visible,
|
||||
source: "local_catalog",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/providers/[id]/models - Get models list from provider
|
||||
*/
|
||||
@@ -127,82 +212,31 @@ export async function GET(
|
||||
const refresh = searchParams.get("refresh") === "true";
|
||||
|
||||
const connection = await getProviderConnectionById(id);
|
||||
const connectionProvider =
|
||||
typeof connection?.provider === "string" && connection.provider.trim().length > 0
|
||||
? connection.provider
|
||||
: null;
|
||||
const noAuthProviderId =
|
||||
(NOAUTH_PROVIDERS as Record<string, { noAuth?: boolean }>)[id]?.noAuth === true
|
||||
? id
|
||||
: connectionProvider &&
|
||||
(NOAUTH_PROVIDERS as Record<string, { noAuth?: boolean }>)[connectionProvider]
|
||||
?.noAuth === true
|
||||
? connectionProvider
|
||||
: null;
|
||||
|
||||
// No-auth providers may persist a connection row solely for fingerprints
|
||||
// and account-proxy metadata. That row must not turn public model discovery
|
||||
// into an API-key flow that expects a token.
|
||||
if (noAuthProviderId) {
|
||||
return buildNoAuthModelsResponse(
|
||||
noAuthProviderId,
|
||||
typeof connection?.id === "string" ? connection.id : id,
|
||||
excludeHidden
|
||||
);
|
||||
}
|
||||
|
||||
if (!connection) {
|
||||
// #3047 — no-auth providers have no connection rows; serve their catalog by provider id.
|
||||
const isNoAuthProvider =
|
||||
(NOAUTH_PROVIDERS as Record<string, { noAuth?: boolean }>)[id]?.noAuth === true;
|
||||
if (isNoAuthProvider) {
|
||||
if (isProviderBlockedByIdOrAlias(id, (await getSettings()).blockedProviders)) {
|
||||
return NextResponse.json({ error: "Provider is disabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
// #3611 — prefer the live public modelsUrl when present; fall back to local_catalog.
|
||||
const noAuthRegistryEntry = getRegistryEntry(id);
|
||||
const noAuthModelsUrl =
|
||||
typeof noAuthRegistryEntry?.modelsUrl === "string" &&
|
||||
noAuthRegistryEntry.modelsUrl.length > 0
|
||||
? noAuthRegistryEntry.modelsUrl
|
||||
: null;
|
||||
|
||||
if (noAuthModelsUrl) {
|
||||
try {
|
||||
const liveResponse = await safeOutboundFetch(noAuthModelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (liveResponse.ok) {
|
||||
const data = await liveResponse.json();
|
||||
const liveModels: Array<{ id: string; name: string }> = (
|
||||
(data.data || data.models || []) as Array<Record<string, unknown>>
|
||||
)
|
||||
.map((item) => {
|
||||
const itemId = typeof item.id === "string" ? item.id.trim() : "";
|
||||
if (!itemId) return null;
|
||||
const itemName =
|
||||
typeof item.display_name === "string"
|
||||
? item.display_name
|
||||
: typeof item.name === "string"
|
||||
? item.name
|
||||
: itemId;
|
||||
return { id: itemId, name: itemName };
|
||||
})
|
||||
.filter((m): m is { id: string; name: string } => m !== null);
|
||||
|
||||
if (liveModels.length > 0) {
|
||||
const visible = excludeHidden
|
||||
? liveModels.filter((m) => !getModelIsHidden(id, m.id))
|
||||
: liveModels;
|
||||
return NextResponse.json({
|
||||
provider: id,
|
||||
connectionId: id,
|
||||
models: visible,
|
||||
source: "upstream",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Live fetch failed — fall through to local_catalog below.
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = mergeLocalCatalogModels(
|
||||
getModelsByProviderId(id) || [],
|
||||
getStaticModelsForProvider(id) || []
|
||||
).map((model) => ({ id: model.id, name: model.name || model.id }));
|
||||
const visible = excludeHidden
|
||||
? catalog.filter((m) => !getModelIsHidden(id, m.id))
|
||||
: catalog;
|
||||
return NextResponse.json({
|
||||
provider: id,
|
||||
connectionId: id,
|
||||
models: visible,
|
||||
source: "local_catalog",
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -213,10 +247,7 @@ export async function GET(
|
||||
const staleEncryptionResponse = buildStaleEncryptionKeyResponse(connection);
|
||||
if (staleEncryptionResponse) return staleEncryptionResponse;
|
||||
|
||||
const provider =
|
||||
typeof connection.provider === "string" && connection.provider.trim().length > 0
|
||||
? connection.provider
|
||||
: null;
|
||||
const provider = connectionProvider;
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: "Invalid connection provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
46
src/shared/utils/providerConnectionStatus.ts
Normal file
46
src/shared/utils/providerConnectionStatus.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface ProviderConnectionStatusLike {
|
||||
isActive?: boolean | null;
|
||||
testStatus?: string | null;
|
||||
rateLimitedUntil?: string | number | Date | null;
|
||||
}
|
||||
|
||||
const CONNECTED_STATUSES = new Set(["active", "success", "unknown"]);
|
||||
const ERROR_STATUSES = new Set(["error", "expired", "unavailable"]);
|
||||
|
||||
export function getEffectiveProviderConnectionStatus(
|
||||
connection: ProviderConnectionStatusLike,
|
||||
now = Date.now()
|
||||
): string {
|
||||
const status = connection.testStatus || "unknown";
|
||||
if (status !== "unavailable") return status;
|
||||
|
||||
const rawCooldownUntil = connection.rateLimitedUntil;
|
||||
const cooldownUntil =
|
||||
rawCooldownUntil instanceof Date
|
||||
? rawCooldownUntil.getTime()
|
||||
: typeof rawCooldownUntil === "string" || typeof rawCooldownUntil === "number"
|
||||
? new Date(rawCooldownUntil).getTime()
|
||||
: Number.NaN;
|
||||
const isCoolingDown = Number.isFinite(cooldownUntil) && cooldownUntil > now;
|
||||
return isCoolingDown ? status : "active";
|
||||
}
|
||||
|
||||
export function isProviderConnectionConnected(
|
||||
connection: ProviderConnectionStatusLike,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
return (
|
||||
connection.isActive !== false &&
|
||||
CONNECTED_STATUSES.has(getEffectiveProviderConnectionStatus(connection, now))
|
||||
);
|
||||
}
|
||||
|
||||
export function isProviderConnectionErrored(
|
||||
connection: ProviderConnectionStatusLike,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
return (
|
||||
connection.isActive !== false &&
|
||||
ERROR_STATUSES.has(getEffectiveProviderConnectionStatus(connection, now))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user