mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +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:
1
changelog.d/fixes/7629-provider-flow-consistency.md
Normal file
1
changelog.d/fixes/7629-provider-flow-consistency.md
Normal 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)
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
50
tests/unit/provider-connection-status.test.ts
Normal file
50
tests/unit/provider-connection-status.test.ts
Normal 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);
|
||||
});
|
||||
17
tests/unit/provider-section-visibility.test.ts
Normal file
17
tests/unit/provider-section-visibility.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user