From aaddfcd545d0f0ed66bdb1485a68978704a2c2e1 Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Sun, 19 Jul 2026 07:20:09 +0700 Subject: [PATCH] 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> --- .../fixes/7629-provider-flow-consistency.md | 1 + .../(dashboard)/dashboard/HomePageClient.tsx | 20 +- .../components/LlmChatCard.tsx | 19 +- .../(dashboard)/dashboard/providers/page.tsx | 31 ++- .../dashboard/providers/providerPageUtils.ts | 14 ++ src/app/api/providers/[id]/models/route.ts | 187 ++++++++++-------- src/shared/utils/providerConnectionStatus.ts | 46 +++++ .../unit/opencode-noauth-models-route.test.ts | 49 ++++- tests/unit/playground-model-qualify.test.ts | 16 +- tests/unit/provider-connection-status.test.ts | 50 +++++ .../unit/provider-section-visibility.test.ts | 17 ++ 11 files changed, 327 insertions(+), 123 deletions(-) create mode 100644 changelog.d/fixes/7629-provider-flow-consistency.md create mode 100644 src/shared/utils/providerConnectionStatus.ts create mode 100644 tests/unit/provider-connection-status.test.ts create mode 100644 tests/unit/provider-section-visibility.test.ts diff --git a/changelog.d/fixes/7629-provider-flow-consistency.md b/changelog.d/fixes/7629-provider-flow-consistency.md new file mode 100644 index 0000000000..abe3f24845 --- /dev/null +++ b/changelog.d/fixes/7629-provider-flow-consistency.md @@ -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) diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 1f376c147a..40742b2785 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -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)); diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx index f41dbd934b..4c36f83b2e 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx @@ -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(() => { diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index fbc289a95c..3485111544 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -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(null); const notify = useNotificationStore(); - const hasSearchQuery = searchQuery.trim().length > 0 || modelSearchQuery.trim().length > 0; const sectionCategoryAliases: Record = { 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; diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 7ef9b0a4b3..b7cbdbabac 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -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> = Record; const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q", "kimi-coding"]); diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index d80e337ad6..bab48bb7db 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -109,6 +109,91 @@ import { fetchCodexGithubCatalogModels, } from "./discovery/codex"; +function toLiveModel(item: Record): { 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 { + 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> + ) + .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)[id]?.noAuth === true + ? id + : connectionProvider && + (NOAUTH_PROVIDERS as Record)[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)[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> - ) - .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 }); } diff --git a/src/shared/utils/providerConnectionStatus.ts b/src/shared/utils/providerConnectionStatus.ts new file mode 100644 index 0000000000..b34b9c64a2 --- /dev/null +++ b/src/shared/utils/providerConnectionStatus.ts @@ -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)) + ); +} diff --git a/tests/unit/opencode-noauth-models-route.test.ts b/tests/unit/opencode-noauth-models-route.test.ts index 3e031a31e6..6965c485ea 100644 --- a/tests/unit/opencode-noauth-models-route.test.ts +++ b/tests/unit/opencode-noauth-models-route.test.ts @@ -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; diff --git a/tests/unit/playground-model-qualify.test.ts b/tests/unit/playground-model-qualify.test.ts index 4d85e1022d..14ede8549a 100644 --- a/tests/unit/playground-model-qualify.test.ts +++ b/tests/unit/playground-model-qualify.test.ts @@ -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"); +}); diff --git a/tests/unit/provider-connection-status.test.ts b/tests/unit/provider-connection-status.test.ts new file mode 100644 index 0000000000..efbcc1c225 --- /dev/null +++ b/tests/unit/provider-connection-status.test.ts @@ -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); +}); diff --git a/tests/unit/provider-section-visibility.test.ts b/tests/unit/provider-section-visibility.test.ts new file mode 100644 index 0000000000..7fc9b0be42 --- /dev/null +++ b/tests/unit/provider-section-visibility.test.ts @@ -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); +});