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:
nguyenha935
2026-07-19 07:20:09 +07:00
committed by GitHub
parent 2ad7da5151
commit aaddfcd545
11 changed files with 327 additions and 123 deletions

View File

@@ -0,0 +1 @@
- fix(providers): unify connection status across dashboard surfaces, deduplicate provider cards, preserve no-auth model discovery for metadata-only rows, and route playground models through the provider alias (#7629)

View File

@@ -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));

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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"]);

View File

@@ -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 });
}

View 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))
);
}

View File

@@ -8,6 +8,7 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-opencode-
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
test.after(() => {
@@ -73,7 +74,11 @@ test("models route fetches live models from modelsUrl for noAuth provider with m
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.provider, "opencode");
assert.equal(body.source, "upstream", "should report source as 'upstream' when live fetch succeeds");
assert.equal(
body.source,
"upstream",
"should report source as 'upstream' when live fetch succeeds"
);
assert.ok(Array.isArray(body.models), "models should be an array");
const ids = body.models.map((m: { id: string }) => m.id);
assert.ok(ids.includes("live-model-alpha"), "should include live model alpha");
@@ -83,6 +88,42 @@ test("models route fetches live models from modelsUrl for noAuth provider with m
}
});
test("metadata-only no-auth connection row still uses public model discovery", async () => {
const connection = await providersDb.createProviderConnection({
provider: "opencode",
authType: "apikey",
name: "opencode-metadata",
isActive: true,
testStatus: "unknown",
providerSpecificData: {
fingerprints: [{ id: "fingerprint-1" }],
accountProxies: [],
},
});
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url: string | URL) => {
if (String(url) === "https://opencode.ai/zen/v1/models") {
return Response.json({ data: LIVE_MODEL_LIST });
}
return new Response("unexpected", { status: 500 });
};
try {
const response = await modelsRoute.GET(
new Request(`http://localhost/api/providers/${connection.id}/models`),
{ params: { id: connection.id } }
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.provider, "opencode");
assert.equal(body.connectionId, connection.id);
assert.equal(body.source, "upstream");
assert.ok(body.models.some((model: { id: string }) => model.id === "live-model-alpha"));
} finally {
globalThis.fetch = originalFetch;
}
});
test("models route falls back to local_catalog when live modelsUrl fetch throws (#3611 fallback on error)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url: string | URL, _init?: RequestInit) => {
@@ -124,7 +165,11 @@ test("models route falls back to local_catalog when live modelsUrl fetch returns
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.provider, "opencode");
assert.equal(body.source, "local_catalog", "should fall back to local_catalog when upstream returns non-OK");
assert.equal(
body.source,
"local_catalog",
"should fall back to local_catalog when upstream returns non-OK"
);
assert.ok(Array.isArray(body.models) && body.models.length > 0, "should have catalog models");
} finally {
globalThis.fetch = originalFetch;

View File

@@ -1,9 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
const { qualifyPlaygroundModel } = await import(
"../../src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx"
);
const { qualifyPlaygroundModel } =
await import("../../src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx");
// #3050 — vendor-namespaced model ids already contain a "/", so the old
// `.includes("/")` heuristic skipped the provider prefix and the request was
@@ -21,7 +20,10 @@ test("qualifyPlaygroundModel prefixes a bare model", () => {
});
test("qualifyPlaygroundModel does not double-prefix an already-qualified model", () => {
assert.equal(qualifyPlaygroundModel("nim/moonshotai/kimi-k2.6", "nim"), "nim/moonshotai/kimi-k2.6");
assert.equal(
qualifyPlaygroundModel("nim/moonshotai/kimi-k2.6", "nim"),
"nim/moonshotai/kimi-k2.6"
);
assert.equal(qualifyPlaygroundModel("nim", "nim"), "nim");
});
@@ -29,3 +31,9 @@ test("qualifyPlaygroundModel returns the model unchanged without a providerId",
assert.equal(qualifyPlaygroundModel("moonshotai/kimi-k2.6", ""), "moonshotai/kimi-k2.6");
assert.equal(qualifyPlaygroundModel("", "nim"), "");
});
test("OpenCode Free playground uses its routing alias instead of the reserved provider id", async () => {
const { getProviderAlias } = await import("../../src/shared/constants/providers.ts");
assert.equal(getProviderAlias("opencode"), "oc");
assert.equal(qualifyPlaygroundModel("big-pickle", getProviderAlias("opencode")), "oc/big-pickle");
});

View File

@@ -0,0 +1,50 @@
import test from "node:test";
import assert from "node:assert/strict";
const {
getEffectiveProviderConnectionStatus,
isProviderConnectionConnected,
isProviderConnectionErrored,
} = await import("../../src/shared/utils/providerConnectionStatus.ts");
test("an active connection with unknown status is configured but not errored", () => {
const connection = { isActive: true, testStatus: "unknown" };
assert.equal(isProviderConnectionConnected(connection), true);
assert.equal(isProviderConnectionErrored(connection), false);
});
test("disabled connections are excluded from connected and error counts", () => {
assert.equal(isProviderConnectionConnected({ isActive: false, testStatus: "success" }), false);
assert.equal(isProviderConnectionErrored({ isActive: false, testStatus: "error" }), false);
});
test("expired cooldown restores unavailable connection to active", () => {
const now = Date.parse("2026-07-17T00:00:00.000Z");
const connection = {
isActive: true,
testStatus: "unavailable",
rateLimitedUntil: "2026-07-16T23:59:59.000Z",
};
assert.equal(getEffectiveProviderConnectionStatus(connection, now), "active");
assert.equal(isProviderConnectionConnected(connection, now), true);
assert.equal(isProviderConnectionErrored(connection, now), false);
});
test("active cooldown remains unavailable", () => {
const now = Date.parse("2026-07-17T00:00:00.000Z");
const connection = {
isActive: true,
testStatus: "unavailable",
rateLimitedUntil: "2026-07-17T00:00:01.000Z",
};
assert.equal(getEffectiveProviderConnectionStatus(connection, now), "unavailable");
assert.equal(isProviderConnectionConnected(connection, now), false);
assert.equal(isProviderConnectionErrored(connection, now), true);
});
test("unavailable without a live cooldown is restored to active", () => {
const connection = { isActive: true, testStatus: "unavailable" };
assert.equal(getEffectiveProviderConnectionStatus(connection), "active");
assert.equal(isProviderConnectionConnected(connection), true);
assert.equal(isProviderConnectionErrored(connection), false);
});

View File

@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
const providerPageUtils = await import(
"../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts"
);
test("default provider view hides cross-cutting sections that duplicate primary cards", () => {
const { shouldShowProviderSection } = providerPageUtils;
assert.equal(shouldShowProviderSection("oauth", null, false), true);
assert.equal(shouldShowProviderSection("free", null, false), false);
assert.equal(shouldShowProviderSection("webfetch", null, false), false);
assert.equal(shouldShowProviderSection("webfetch", "webfetch", false), true);
assert.equal(shouldShowProviderSection("free", null, true), true);
assert.equal(shouldShowProviderSection("oauth", null, true), false);
});