mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: improve the behavior for the web app
This commit is contained in:
@@ -9,6 +9,7 @@ import { cn } from "@/shared/utils/cn";
|
|||||||
import { APP_CONFIG } from "@/shared/constants/config";
|
import { APP_CONFIG } from "@/shared/constants/config";
|
||||||
import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config";
|
import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config";
|
||||||
import { LOCALE_FLAGS } from "@/shared/constants/locales";
|
import { LOCALE_FLAGS } from "@/shared/constants/locales";
|
||||||
|
import useUserStore from "@/store/userStore";
|
||||||
|
|
||||||
function getLocaleFromCookie() {
|
function getLocaleFromCookie() {
|
||||||
if (typeof document === "undefined") return "en";
|
if (typeof document === "undefined") return "en";
|
||||||
@@ -21,6 +22,8 @@ function getLocaleFromCookie() {
|
|||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { theme, setTheme, isDark } = useTheme();
|
const { theme, setTheme, isDark } = useTheme();
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
|
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
|
||||||
const [locale, setLocale] = useState("en");
|
const [locale, setLocale] = useState("en");
|
||||||
const [langOpen, setLangOpen] = useState(false);
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||||
@@ -58,6 +61,10 @@ export default function ProfilePage() {
|
|||||||
const [proxyLoading, setProxyLoading] = useState(false);
|
const [proxyLoading, setProxyLoading] = useState(false);
|
||||||
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) fetchCurrentUser();
|
||||||
|
}, [fetchCurrentUser, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocale(getLocaleFromCookie());
|
setLocale(getLocaleFromCookie());
|
||||||
}, [langOpen]);
|
}, [langOpen]);
|
||||||
@@ -532,6 +539,13 @@ export default function ProfilePage() {
|
|||||||
throw new Error(data.error || "Failed to import database");
|
throw new Error(data.error || "Failed to import database");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backup may replace the current account and its permissions. The API
|
||||||
|
// clears the session cookie; redirect immediately to prevent stale data.
|
||||||
|
if (data.requiresLogin) {
|
||||||
|
window.location.assign("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await reloadSettings();
|
await reloadSettings();
|
||||||
setDbStatus({ type: "success", message: "Database imported successfully" });
|
setDbStatus({ type: "success", message: "Database imported successfully" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -610,46 +624,51 @@ export default function ProfilePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 pt-4 border-t border-border">
|
{user?.role === "admin" ? (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-bg border border-border gap-2">
|
<div className="flex flex-col gap-3 pt-4 border-t border-border">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-bg border border-border gap-2">
|
||||||
<p className="font-medium text-sm sm:text-base">Database Location</p>
|
<div>
|
||||||
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">~/.9router/db/data.sqlite</p>
|
<p className="font-medium text-sm sm:text-base">Database Location</p>
|
||||||
|
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">~/.9router/db/data.sqlite</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="secondary"
|
icon="download"
|
||||||
icon="download"
|
onClick={() => setDbAuth({ open: true, mode: "export", password: "" })}
|
||||||
onClick={() => setDbAuth({ open: true, mode: "export", password: "" })}
|
loading={dbLoading}
|
||||||
loading={dbLoading}
|
className="w-full sm:w-auto"
|
||||||
className="w-full sm:w-auto"
|
>
|
||||||
>
|
Download Backup
|
||||||
Download Backup
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
icon="upload"
|
||||||
icon="upload"
|
onClick={() => importFileRef.current?.click()}
|
||||||
onClick={() => importFileRef.current?.click()}
|
disabled={dbLoading}
|
||||||
disabled={dbLoading}
|
className="w-full sm:w-auto"
|
||||||
className="w-full sm:w-auto"
|
>
|
||||||
>
|
Import Backup
|
||||||
Import Backup
|
</Button>
|
||||||
</Button>
|
<input
|
||||||
<input
|
ref={importFileRef}
|
||||||
ref={importFileRef}
|
type="file"
|
||||||
type="file"
|
accept="application/json,.json"
|
||||||
accept="application/json,.json"
|
className="hidden"
|
||||||
className="hidden"
|
onChange={handleImportDatabase}
|
||||||
onChange={handleImportDatabase}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<p className="text-xs sm:text-sm text-text-muted">
|
||||||
{dbStatus.message && (
|
Backups include connected-model availability settings from the Models page.
|
||||||
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
|
|
||||||
{dbStatus.message}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
{dbStatus.message && (
|
||||||
</div>
|
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
|
||||||
|
{dbStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Language */}
|
{/* Language */}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
||||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||||
import { verifyCurrentDashboardUserPassword } from "@/lib/auth/currentUser";
|
import { requireAdminUser, verifyCurrentDashboardUserPassword } from "@/lib/auth/currentUser";
|
||||||
|
import { clearDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||||
|
|
||||||
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||||
const PASSWORD_HEADER = "x-9r-password";
|
const PASSWORD_HEADER = "x-9r-password";
|
||||||
@@ -11,14 +12,29 @@ function isCliRequest(request) {
|
|||||||
return Boolean(request.headers.get(CLI_TOKEN_HEADER));
|
return Boolean(request.headers.get(CLI_TOKEN_HEADER));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAuthorizationErrorResponse(error) {
|
||||||
|
if (error.message === "Unauthorized") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (error.message === "Forbidden") {
|
||||||
|
return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(request.headers.get(PASSWORD_HEADER)))) {
|
if (!isCliRequest(request)) {
|
||||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
await requireAdminUser();
|
||||||
|
if (!(await verifyCurrentDashboardUserPassword(request.headers.get(PASSWORD_HEADER)))) {
|
||||||
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const payload = await exportDb();
|
const payload = await exportDb();
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const authorizationError = getAuthorizationErrorResponse(error);
|
||||||
|
if (authorizationError) return authorizationError;
|
||||||
console.log("Error exporting database:", error);
|
console.log("Error exporting database:", error);
|
||||||
return NextResponse.json({ error: "Failed to export database" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to export database" }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -27,8 +43,11 @@ export async function GET(request) {
|
|||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { password, ...payload } = await request.json();
|
const { password, ...payload } = await request.json();
|
||||||
if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(password))) {
|
if (!isCliRequest(request)) {
|
||||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
await requireAdminUser();
|
||||||
|
if (!(await verifyCurrentDashboardUserPassword(password))) {
|
||||||
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await importDb(payload);
|
await importDb(payload);
|
||||||
|
|
||||||
@@ -40,8 +59,21 @@ export async function POST(request) {
|
|||||||
console.warn("[Settings][DatabaseImport] Failed to re-apply outbound proxy env:", err);
|
console.warn("[Settings][DatabaseImport] Failed to re-apply outbound proxy env:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
const response = NextResponse.json({ success: true, requiresLogin: !isCliRequest(request) });
|
||||||
|
|
||||||
|
// The imported database can replace the current account and permissions.
|
||||||
|
// Remove the browser session so all dashboard data is loaded under a new login.
|
||||||
|
if (!isCliRequest(request)) {
|
||||||
|
clearDashboardAuthCookie(response.cookies);
|
||||||
|
response.cookies.delete("oidc_state");
|
||||||
|
response.cookies.delete("oidc_nonce");
|
||||||
|
response.cookies.delete("oidc_code_verifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const authorizationError = getAuthorizationErrorResponse(error);
|
||||||
|
if (authorizationError) return authorizationError;
|
||||||
console.log("Error importing database:", error);
|
console.log("Error importing database:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error?.message || "Failed to import database" },
|
{ error: error?.message || "Failed to import database" },
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
import { getUsageTopologyProviders } from "@/lib/providers/usageTopologyProviders";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/usage/topology-providers
|
||||||
|
* Returns provider types that the request router can currently select.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireUsageDashboardUser();
|
||||||
|
const providers = await getUsageTopologyProviders();
|
||||||
|
return NextResponse.json({ providers });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error("[API] Failed to get usage topology providers:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch topology providers" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -91,12 +91,14 @@ export async function exportDb() {
|
|||||||
customModels: [],
|
customModels: [],
|
||||||
mitmAlias: {},
|
mitmAlias: {},
|
||||||
pricing: {},
|
pricing: {},
|
||||||
|
disabledModels: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`)) out.modelAliases[r.key] = parseJson(r.value);
|
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`)) out.modelAliases[r.key] = parseJson(r.value);
|
||||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`)) out.customModels.push(parseJson(r.value));
|
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`)) out.customModels.push(parseJson(r.value));
|
||||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'mitmAlias'`)) out.mitmAlias[r.key] = parseJson(r.value);
|
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'mitmAlias'`)) out.mitmAlias[r.key] = parseJson(r.value);
|
||||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`)) out.pricing[r.key] = parseJson(r.value);
|
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`)) out.pricing[r.key] = parseJson(r.value);
|
||||||
|
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'disabledModels'`)) out.disabledModels[r.key] = parseJson(r.value, []);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -127,7 +129,7 @@ export async function importDb(payload) {
|
|||||||
db.run(`DELETE FROM proxyPools`);
|
db.run(`DELETE FROM proxyPools`);
|
||||||
db.run(`DELETE FROM apiKeys`);
|
db.run(`DELETE FROM apiKeys`);
|
||||||
db.run(`DELETE FROM combos`);
|
db.run(`DELETE FROM combos`);
|
||||||
db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing')`);
|
db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing', 'disabledModels')`);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
if (payload.settings) {
|
if (payload.settings) {
|
||||||
@@ -192,6 +194,14 @@ export async function importDb(payload) {
|
|||||||
for (const [provider, models] of Object.entries(payload.pricing || {})) {
|
for (const [provider, models] of Object.entries(payload.pricing || {})) {
|
||||||
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('pricing', ?, ?)`, [provider, stringifyJson(models || {})]);
|
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('pricing', ?, ?)`, [provider, stringifyJson(models || {})]);
|
||||||
}
|
}
|
||||||
|
for (const [providerAlias, modelIds] of Object.entries(payload.disabledModels || {})) {
|
||||||
|
const validModelIds = Array.isArray(modelIds)
|
||||||
|
? modelIds.filter((modelId) => typeof modelId === "string" && modelId)
|
||||||
|
: [];
|
||||||
|
if (providerAlias && validModelIds.length > 0) {
|
||||||
|
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('disabledModels', ?, ?)`, [providerAlias, stringifyJson([...new Set(validModelIds)])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return await exportDb();
|
return await exportDb();
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { getProviderConnections, getProviderNodes } from "@/lib/db";
|
||||||
|
import { AI_PROVIDERS, FREE_PROVIDERS } from "@/shared/constants/providers";
|
||||||
|
|
||||||
|
function isLLMProvider(providerId) {
|
||||||
|
const provider = AI_PROVIDERS[providerId];
|
||||||
|
return !provider?.serviceKinds || provider.serviceKinds.includes("llm");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the provider types available to the request router.
|
||||||
|
*
|
||||||
|
* Provider credentials are selected globally after a valid dashboard API key
|
||||||
|
* is authenticated, so a regular user can route through any active
|
||||||
|
* connection. This intentionally differs from provider-management APIs,
|
||||||
|
* which only return connections owned by the signed-in user.
|
||||||
|
*/
|
||||||
|
export async function getUsageTopologyProviders() {
|
||||||
|
const [connections, providerNodes] = await Promise.all([
|
||||||
|
getProviderConnections({ isActive: true }),
|
||||||
|
getProviderNodes(),
|
||||||
|
]);
|
||||||
|
const nodeNameMap = Object.fromEntries(
|
||||||
|
providerNodes
|
||||||
|
.filter((node) => node.id && node.name)
|
||||||
|
.map((node) => [node.id, node.name]),
|
||||||
|
);
|
||||||
|
const seen = new Set();
|
||||||
|
const providers = [];
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
if (!connection.provider || !isLLMProvider(connection.provider) || seen.has(connection.provider)) continue;
|
||||||
|
seen.add(connection.provider);
|
||||||
|
providers.push({
|
||||||
|
provider: connection.provider,
|
||||||
|
nodeName: nodeNameMap[connection.provider] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of Object.values(FREE_PROVIDERS)) {
|
||||||
|
if (!provider.noAuth || !provider.id || !isLLMProvider(provider.id) || seen.has(provider.id)) continue;
|
||||||
|
seen.add(provider.id);
|
||||||
|
providers.push({ provider: provider.id, name: provider.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
@@ -2,14 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers";
|
|
||||||
|
|
||||||
// Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds
|
|
||||||
function isLLMProvider(id) {
|
|
||||||
const p = AI_PROVIDERS[id];
|
|
||||||
if (!p?.serviceKinds) return true;
|
|
||||||
return p.serviceKinds.includes("llm");
|
|
||||||
}
|
|
||||||
import Badge from "./Badge";
|
import Badge from "./Badge";
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
|
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
|
||||||
@@ -225,35 +217,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
}, [stats?.availableTableViews]);
|
}, [stats?.availableTableViews]);
|
||||||
const activeTableView = tableOptions.some((option) => option.value === tableView) ? tableView : "model";
|
const activeTableView = tableOptions.some((option) => option.value === tableView) ? tableView : "model";
|
||||||
|
|
||||||
// Fetch connected providers once, deduplicate by provider type
|
// The topology must use router availability rather than the provider-management
|
||||||
// Always include noAuth free providers (e.g. opencode) regardless of connections
|
// list. The latter is intentionally limited to a regular user's owned connections.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
fetch("/api/usage/topology-providers")
|
||||||
fetch("/api/providers").then((r) => r.ok ? r.json() : null),
|
.then((response) => response.ok ? response.json() : null)
|
||||||
fetch("/api/provider-nodes").then((r) => r.ok ? r.json() : null),
|
.then((data) => setProviders(data?.providers || []))
|
||||||
])
|
|
||||||
.then(([d, nodesData]) => {
|
|
||||||
// Build node name lookup for custom providers
|
|
||||||
const nodeNameMap = {};
|
|
||||||
for (const node of (nodesData?.nodes || [])) {
|
|
||||||
nodeNameMap[node.id] = node.name;
|
|
||||||
}
|
|
||||||
const seen = new Set();
|
|
||||||
const unique = (d?.connections || []).filter((c) => {
|
|
||||||
if (c.isActive === false) return false;
|
|
||||||
if (!isLLMProvider(c.provider)) return false;
|
|
||||||
if (seen.has(c.provider)) return false;
|
|
||||||
seen.add(c.provider);
|
|
||||||
return true;
|
|
||||||
}).map((c) => ({
|
|
||||||
...c,
|
|
||||||
nodeName: nodeNameMap[c.provider] || null,
|
|
||||||
}));
|
|
||||||
const noAuthProviders = Object.values(FREE_PROVIDERS)
|
|
||||||
.filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id))
|
|
||||||
.map((p) => ({ provider: p.id, name: p.name }));
|
|
||||||
setProviders([...unique, ...noAuthProviders]);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,20 @@ describe("DB SQLite layer — public API parity", () => {
|
|||||||
expect((await sqliteDb.getModelAliases()).marker).toBe("before");
|
expect((await sqliteDb.getModelAliases()).marker).toBe("before");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exportDb / importDb preserves disabled model settings", async () => {
|
||||||
|
await sqliteDb.disableModels("backup-provider", ["backup-model"]);
|
||||||
|
const snapshot = await sqliteDb.exportDb();
|
||||||
|
|
||||||
|
expect(snapshot.disabledModels).toMatchObject({
|
||||||
|
"backup-provider": ["backup-model"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await sqliteDb.enableModels("backup-provider", []);
|
||||||
|
await sqliteDb.importDb(snapshot);
|
||||||
|
|
||||||
|
expect(await sqliteDb.getDisabledByProvider("backup-provider")).toEqual(["backup-model"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("pricing: user pricing merged with constants", async () => {
|
it("pricing: user pricing merged with constants", async () => {
|
||||||
await sqliteDb.updatePricing({ openai: { "gpt-test": { input: 1, output: 2 } } });
|
await sqliteDb.updatePricing({ openai: { "gpt-test": { input: 1, output: 2 } } });
|
||||||
const p = await sqliteDb.getPricing();
|
const p = await sqliteDb.getPricing();
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const originalDataDir = process.env.DATA_DIR;
|
||||||
|
let tempDir;
|
||||||
|
let db;
|
||||||
|
let getUsageTopologyProviders;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-usage-topology-"));
|
||||||
|
process.env.DATA_DIR = tempDir;
|
||||||
|
vi.resetModules();
|
||||||
|
db = await import("@/lib/db/index.js");
|
||||||
|
await db.initDb();
|
||||||
|
({ getUsageTopologyProviders } = await import("@/lib/providers/usageTopologyProviders.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = originalDataDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("usage topology providers", () => {
|
||||||
|
it("includes every active router connection, regardless of dashboard owner", async () => {
|
||||||
|
const firstUser = await db.createUser({ username: "topology-first", password: "password", role: "user" });
|
||||||
|
const secondUser = await db.createUser({ username: "topology-second", password: "password", role: "user" });
|
||||||
|
|
||||||
|
await db.createProviderConnection({
|
||||||
|
provider: "topology-provider-first",
|
||||||
|
authType: "apikey",
|
||||||
|
apiKey: "first-key",
|
||||||
|
ownerId: firstUser.id,
|
||||||
|
});
|
||||||
|
await db.createProviderConnection({
|
||||||
|
provider: "topology-provider-second",
|
||||||
|
authType: "apikey",
|
||||||
|
apiKey: "second-key",
|
||||||
|
ownerId: secondUser.id,
|
||||||
|
});
|
||||||
|
await db.createProviderConnection({
|
||||||
|
provider: "topology-provider-inactive",
|
||||||
|
authType: "apikey",
|
||||||
|
apiKey: "inactive-key",
|
||||||
|
ownerId: secondUser.id,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await getUsageTopologyProviders();
|
||||||
|
const providerIds = providers.map((provider) => provider.provider);
|
||||||
|
|
||||||
|
expect(providerIds).toContain("topology-provider-first");
|
||||||
|
expect(providerIds).toContain("topology-provider-second");
|
||||||
|
expect(providerIds).not.toContain("topology-provider-inactive");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user