Compare commits

..

4 Commits

Author SHA1 Message Date
Agnes
b1b8faab94 refactor(settings): reduce proxy registry component size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 08:43:01 -03:00
Agnes
074dccdc8e refactor(settings): extract proxy registry helpers
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 08:34:03 -03:00
Agnes
8cd3fd86b0 test(proxy-pool): cover toolbar layout 2026-08-09 01:38:58 -03:00
Agnes
88e7699074 feat(proxy-pool): streamline pool actions 2026-08-09 01:38:58 -03:00
14 changed files with 511 additions and 1571 deletions

View File

@@ -4,21 +4,28 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
import { matchesSearch } from "@/shared/utils/turkishText";
import {
toModelOverrideTargets,
type PricingCatalogProvider,
} from "@/lib/modelCapabilityOverrideTargets";
type ModelOverrideKey = "context_length" | "max_input_tokens" | "max_output_tokens";
type StatusTone = "success" | "error" | "info";
type ModelOverrideTarget = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget;
type ModelOverrideTarget = {
target: string;
provider: string;
modelId: string;
label: string;
};
interface PricingCatalogModel {
id: string;
name: string;
}
interface PricingCatalogProvider {
id: string;
alias: string;
models: PricingCatalogModel[];
}
interface ModelCapabilityOverride {
target: string;
key: ModelOverrideKey;
@@ -113,11 +120,22 @@ function useModelCapabilityOverridesData() {
return { catalog, overrides, loading, statusMessage, saveOverride, removeOverride };
}
function toTargets(catalog: Record<string, PricingCatalogProvider>): ModelOverrideTarget[] {
return Object.values(catalog).flatMap((provider) =>
provider.models.map((model) => ({
target: `${provider.id}/${model.id}`,
provider: provider.id,
modelId: model.id,
label: `${provider.id}/${model.id}`,
}))
);
}
export default function ModelCapabilityOverridesTab() {
const t = useTranslations("settings");
const { catalog, overrides, loading, statusMessage, saveOverride, removeOverride } =
useModelCapabilityOverridesData();
const targets = useMemo(() => toModelOverrideTargets(catalog), [catalog]);
const targets = useMemo(() => toTargets(catalog), [catalog]);
if (loading) return <div className="text-sm text-text-muted animate-pulse">{t("loading")}</div>;

View File

@@ -1,7 +1,5 @@
"use client";
import { FilterSelect, HeroStat, SyncMini } from "./PricingTabHelpers";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, Button } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
@@ -50,8 +48,6 @@ interface PricingCatalogProvider {
format: string;
modelCount: number;
models: PricingCatalogModel[];
/** Original pricing namespace (e.g. public prefix) when it differs from `alias`. */
pricingKey?: string;
}
function getSourceTone(source: PricingSource): string {
@@ -137,15 +133,11 @@ export default function PricingTab() {
const allProviders = useMemo(() => {
return Object.entries(catalog)
.map(([alias, info]) => {
const pricingKey = info.pricingKey || alias;
return {
...info,
alias,
pricingKey,
pricedModels: pricingData[pricingKey] ? Object.keys(pricingData[pricingKey]).length : 0,
};
})
.map(([alias, info]) => ({
...info,
alias,
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
}))
.sort((left, right) => right.modelCount - left.modelCount);
}, [catalog, pricingData]);
@@ -320,14 +312,13 @@ export default function PricingTab() {
);
const saveProvider = useCallback(
async (providerAlias: string, pricingKey?: string) => {
async (providerAlias: string) => {
setSaving(true);
try {
const writeKey = pricingKey || providerAlias;
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [writeKey]: pricingData[writeKey] || {} }),
body: JSON.stringify({ [providerAlias]: pricingData[providerAlias] || {} }),
});
if (!response.ok) {
@@ -337,7 +328,7 @@ export default function PricingTab() {
setEditedProviders((previous) => {
const next = new Set(previous);
next.delete(writeKey);
next.delete(providerAlias);
return next;
});
await loadData();
@@ -357,13 +348,11 @@ export default function PricingTab() {
);
const resetProvider = useCallback(
async (providerAlias: string, pricingKey?: string) => {
async (providerAlias: string) => {
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
try {
const writeKey = pricingKey || providerAlias;
const params = new URLSearchParams({ provider: writeKey });
const response = await fetch(`/api/pricing?${params.toString()}`, {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, {
method: "DELETE",
});
@@ -374,7 +363,7 @@ export default function PricingTab() {
setEditedProviders((previous) => {
const next = new Set(previous);
next.delete(writeKey);
next.delete(providerAlias);
return next;
});
await loadData();
@@ -691,16 +680,16 @@ export default function PricingTab() {
<ProviderSection
key={provider.alias}
provider={provider}
pricingData={pricingData[provider.pricingKey || provider.alias] || {}}
sourceMap={pricingSources[provider.pricingKey || provider.alias] || {}}
pricingData={pricingData[provider.alias] || {}}
sourceMap={pricingSources[provider.alias] || {}}
isExpanded={expandedProviders.has(provider.alias)}
isEdited={editedProviders.has(provider.pricingKey || provider.alias)}
isEdited={editedProviders.has(provider.alias)}
onToggle={() => toggleProvider(provider.alias)}
onPricingChange={(model, field, value) =>
handlePricingChange(provider.pricingKey || provider.alias, model, field, value)
handlePricingChange(provider.alias, model, field, value)
}
onSave={() => void saveProvider(provider.alias, provider.pricingKey)}
onReset={() => void resetProvider(provider.alias, provider.pricingKey)}
onSave={() => void saveProvider(provider.alias)}
onReset={() => void resetProvider(provider.alias)}
saving={saving}
getSourceLabel={getSourceLabel}
/>
@@ -728,6 +717,63 @@ export default function PricingTab() {
);
}
function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) {
return (
<div className="text-center">
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold truncate">
{label}
</div>
<div
className={`text-2xl font-bold tabular-nums leading-tight ${accent || "text-text-main"}`}
>
{value}
</div>
</div>
);
}
function SyncMini({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border/30 bg-bg-base/40 px-2 py-1.5">
<p className="text-[9px] uppercase tracking-wide text-text-muted font-semibold truncate">
{label}
</p>
<p className="text-[11px] font-medium text-text-main mt-0.5 truncate" title={value}>
{value}
</p>
</div>
);
}
function FilterSelect({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<label className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="font-semibold uppercase tracking-wide">{label}:</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer focus:outline-none focus:border-primary"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}
function ProviderSection({
provider,
pricingData,

View File

@@ -1,56 +0,0 @@
export function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) {
return (
<div className="text-center">
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold truncate">
{label}
</div>
<div
className={`text-2xl font-bold tabular-nums leading-tight ${accent || "text-text-main"}`}
>
{value}
</div>
</div>
);
}
export function SyncMini({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border/30 bg-bg-base/40 px-2 py-1.5">
<p className="text-[9px] uppercase tracking-wide text-text-muted font-semibold truncate">
{label}
</p>
<p className="text-[11px] font-medium text-text-main mt-0.5 truncate" title={value}>
{value}
</p>
</div>
);
}
export function FilterSelect({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<label className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="font-semibold uppercase tracking-wide">{label}:</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer focus:outline-none focus:border-primary"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { z } from "zod";
import { Button, Card, Modal } from "@/shared/components";
import { useProxyBatchOperations } from "./useProxyBatchOperations";
import { ProxyStatusBadge } from "./ProxyStatusBadge";
@@ -16,90 +15,32 @@ import {
} from "./parseBulkProxyImport";
import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions";
import type { ProxyItem } from "./proxyRegistryTypes";
import {
BULK_IMPORT_PLACEHOLDER,
EMPTY_FORM,
type HealthInfo,
type ProxyRegistryManagerProps,
type TestResult,
type UsageInfo,
} from "./proxyRegistryConstants";
import {
loadAllProxyUsage,
loadProxyHealth,
loadProxyUsage,
repairRelayResponseSchema,
} from "./proxyRegistryData";
type UsageInfo = {
count: number;
assignments: Array<{ scope: string; scopeId: string | null }>;
};
type HealthInfo = {
proxyId: string;
totalRequests: number;
successRate: number | null;
avgLatencyMs: number | null;
lastSeenAt: string | null;
};
type TestResult = {
success: boolean;
publicIp?: string;
latencyMs?: number;
country?: string;
error?: string;
};
const EMPTY_FORM = {
id: "",
name: "",
type: "http",
host: "",
port: "8080",
username: "",
password: "",
region: "",
notes: "",
status: "active",
family: "auto",
};
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# ─────────────────────────────────────────────────────────────────────────────
# FORMAT 1 — Pipe-delimited (full control):
# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
#
# FORMAT 2 — Shorthand (one proxy per line, no pipe needed):
# ip:port → no auth, type defaults to socks5
# ip:port:user:pass → with auth
# user:pass@ip:port → with auth (@-style)
# user:pass:ip:port → with auth (user-pass-first)
# protocol://ip:port → explicit protocol
# protocol://user:pass@ip:port → explicit protocol + auth
#
# FORMAT 3 — Protocol header mode:
# Put a bare protocol (http, https, socks5) on its own line to set
# the default type for all subsequent shorthand lines that don't
# include an explicit protocol:// prefix.
#
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# ─────────────────────────────────────────────────────────────────────────────
# Pipe-delimited examples:
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
#
# Shorthand examples:
# 138.99.147.218:50101
# 138.99.147.218:50101:myuser:mypass
# myuser:mypass@138.99.147.218:50101
# myuser:mypass:138.99.147.218:50101
# http://10.0.0.50:8080
# https://admin:secret123@proxy.example.com:443
#
# Protocol header mode example:
# socks5
# 138.99.147.218:50101:myuser:mypass
# 200.234.177.62:50101:otheruser:otherpass
#`;
export default function ProxyRegistryManager({
export default function ProxyRegistryManager({
onRedeployRelay,
}: {
onRedeployRelay?: (proxy: ProxyItem) => void;
} = {}) {
showVercelRelay = false,
showDenoRelay = false,
showCloudflareRelay = false,
onOpenVercelRelay,
onOpenDenoRelay,
onOpenCloudflareRelay,
}: ProxyRegistryManagerProps = {}) {
const t = useTranslations("proxyRegistry");
const settingsT = useTranslations("settings");
const [items, setItems] = useState<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -135,7 +76,7 @@ export default function ProxyRegistryManager({
const [poolLoaded, setPoolLoaded] = useState(false);
const [poolSaving, setPoolSaving] = useState(false);
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
const [bulkImportText, setBulkImportText] = useState("");
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
@@ -146,53 +87,40 @@ export default function ProxyRegistryManager({
updated: number;
failed: number;
} | null>(null);
const [actionsOpen, setActionsOpen] = useState(false);
const [relayMenuOpen, setRelayMenuOpen] = useState(false);
const actionsRef = useRef<HTMLDivElement | null>(null);
const relayRef = useRef<HTMLDivElement | null>(null);
const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay;
useEffect(() => {
if (!actionsOpen && !relayMenuOpen) return;
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (actionsOpen && actionsRef.current && !actionsRef.current.contains(target)) {
setActionsOpen(false);
}
if (relayMenuOpen && relayRef.current && !relayRef.current.contains(target)) {
setRelayMenuOpen(false);
}
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [actionsOpen, relayMenuOpen]);
const closeActions = () => {
setActionsOpen(false);
setRelayMenuOpen(false);
};
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
try {
const res = await fetch("/api/settings/proxies/health?hours=24");
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const entries = Array.isArray(data?.items) ? data.items : [];
const mapped = Object.fromEntries(
entries.map((entry: HealthInfo) => [entry.proxyId, entry])
) as Record<string, HealthInfo>;
setHealthById(mapped);
} catch {
// ignore health loading errors in UI
}
}, []);
const loadAllUsage = useCallback(async (proxyIds: string[]) => {
if (!proxyIds.length) return;
try {
const results = await Promise.all(
proxyIds.map((id) =>
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const rawAssignments: Array<{ scope: string; scopeId: string | null }> =
Array.isArray(data?.items) ? data.items : [];
// Deduplicate by scope+scopeId — prevents double-counting when both
// a provider-scope and account-scope row exist for the same proxy
const seen = new Set<string>();
const assignments = rawAssignments.filter((a) => {
const key = `${a.scope}:${a.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
})
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
)
);
setUsageById(Object.fromEntries(results));
} catch {
// ignore
}
}, []);
const loadHealth = useCallback(() => loadProxyHealth(setHealthById), []);
const loadAllUsage = useCallback(
(proxyIds: string[]) => loadAllProxyUsage(proxyIds, setUsageById),
[]
);
const load = useCallback(async () => {
setLoading(true);
@@ -240,17 +168,9 @@ export default function ProxyRegistryManager({
const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id));
const handleBatchDelete = useCallback(() => {
hookHandleBatchDelete(setError);
}, [hookHandleBatchDelete, setError]);
const handleBatchActivate = useCallback(() => {
hookHandleBatchActivate(setError, "active");
}, [hookHandleBatchActivate, setError]);
const handleAutoTestAll = useCallback(() => {
hookHandleAutoTestAll(setError, setTestById);
}, [hookHandleAutoTestAll, setError, setTestById]);
const handleBatchDelete = () => hookHandleBatchDelete(setError);
const handleBatchActivate = () => hookHandleBatchActivate(setError, "active");
const handleAutoTestAll = () => hookHandleAutoTestAll(setError, setTestById);
useEffect(() => {
void load();
@@ -284,33 +204,7 @@ export default function ProxyRegistryManager({
setModalOpen(true);
};
const loadUsage = async (proxyId: string) => {
try {
const res = await fetch(
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray(
data?.items
)
? data.items
: [];
const seen = new Set<string>();
const assignments = rawAssignments.filter((a) => {
const key = `${a.scope}:${a.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
setUsageById((prev) => ({
...prev,
[proxyId]: { count: assignments.length, assignments },
}));
} catch {
// ignore usage loading errors in UI
}
};
const loadUsage = (proxyId: string) => loadProxyUsage(proxyId, setUsageById);
const handleTestProxy = async (item: ProxyItem) => {
if (testingId) return;
@@ -345,12 +239,6 @@ export default function ProxyRegistryManager({
}
};
const repairRelayResponseSchema = z.object({
repaired: z.boolean().optional(),
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
error: z.object({ message: z.string() }).optional(),
});
const handleRepairRelay = async (item: ProxyItem) => {
if (repairingId || !item.relayInfo?.isRelay) return;
setRepairingId(item.id);
@@ -724,7 +612,7 @@ export default function ProxyRegistryManager({
};
const openBulkImport = () => {
setBulkImportText(BULK_IMPORT_TEMPLATE);
setBulkImportText("");
setBulkImportParsed([]);
setBulkImportErrors([]);
setBulkImportSkipped(0);
@@ -736,49 +624,13 @@ export default function ProxyRegistryManager({
return (
<>
<Card className="p-6">
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<div className="mb-4 flex flex-col gap-3">
<div className="w-full min-w-0">
<h3 className="text-lg font-semibold">{t("title")}</h3>
<p className="text-sm text-text-muted">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
icon="upgrade"
onClick={handleMigrate}
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
{t("importLegacy")}
</Button>
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={openBulkImport}
data-testid="proxy-registry-open-bulk-import"
>
{t("bulkImport")}
</Button>
<Button
size="sm"
variant="secondary"
icon="account_tree"
onClick={() => setBulkOpen(true)}
data-testid="proxy-registry-open-bulk"
>
{t("bulkAssign")}
</Button>
<Button
size="sm"
variant="secondary"
icon="hub"
onClick={openPool}
data-testid="proxy-registry-open-pool"
>
{t("managePool")}
</Button>
<div className="w-full border-t border-border" aria-hidden="true" />
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<ProxyBatchActions
selectedCount={selectedIds.size}
batchDeleting={batchDeleting}
@@ -788,6 +640,152 @@ export default function ProxyRegistryManager({
onBatchActivate={handleBatchActivate}
onAutoTestAll={handleAutoTestAll}
/>
<Button
size="sm"
variant="secondary"
icon="hub"
onClick={openPool}
data-testid="proxy-registry-open-pool"
>
{t("managePool")}
</Button>
{showAnyRelay && (
<div className="relative inline-flex items-center" ref={relayRef}>
<Button
size="sm"
variant="secondary"
icon="rocket_launch"
iconRight="expand_more"
onClick={() => {
setRelayMenuOpen((value) => !value);
setActionsOpen(false);
}}
aria-haspopup="menu"
aria-expanded={relayMenuOpen}
data-testid="proxy-registry-deploy-relay"
>
{settingsT("deployRelayButton")}
</Button>
{relayMenuOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-surface p-1 shadow-xl"
role="menu"
>
{showVercelRelay && (
<Button
size="sm"
variant="ghost"
icon="cloud_upload"
fullWidth
className="justify-start"
onClick={() => {
onOpenVercelRelay?.();
closeActions();
}}
>
{settingsT("vercelRelayButton")}
</Button>
)}
{showDenoRelay && (
<Button
size="sm"
variant="ghost"
icon="terminal"
fullWidth
className="justify-start"
onClick={() => {
onOpenDenoRelay?.();
closeActions();
}}
>
{settingsT("denoRelayButton")}
</Button>
)}
{showCloudflareRelay && (
<Button
size="sm"
variant="ghost"
icon="cloud"
fullWidth
className="justify-start"
onClick={() => {
onOpenCloudflareRelay?.();
closeActions();
}}
>
{settingsT("cloudflareRelayButton")}
</Button>
)}
</div>
)}
</div>
)}
<div className="relative inline-flex items-center" ref={actionsRef}>
<Button
size="sm"
variant="secondary"
onClick={() => {
setActionsOpen((value) => !value);
setRelayMenuOpen(false);
}}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={actionsOpen}
data-testid="proxy-registry-more-actions"
>
</Button>
{actionsOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-surface p-1 shadow-xl"
role="menu"
>
<Button
size="sm"
variant="ghost"
icon="upload_file"
fullWidth
className="justify-start"
onClick={() => {
openBulkImport();
closeActions();
}}
data-testid="proxy-registry-open-bulk-import"
>
{t("bulkImport")}
</Button>
<Button
size="sm"
variant="ghost"
icon="upload_file"
fullWidth
className="justify-start"
onClick={() => {
handleMigrate();
closeActions();
}}
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
{t("importLegacy")}
</Button>
<Button
size="sm"
variant="ghost"
icon="account_tree"
fullWidth
className="justify-start"
onClick={() => {
setBulkOpen(true);
closeActions();
}}
data-testid="proxy-registry-open-bulk"
>
{t("bulkAssign")}
</Button>
</div>
)}
</div>
<Button
size="sm"
icon="add"
@@ -1328,9 +1326,10 @@ export default function ProxyRegistryManager({
<div>
<textarea
data-testid="proxy-registry-bulk-import-textarea"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed placeholder:whitespace-pre-wrap placeholder:text-text-muted/70"
rows={14}
value={bulkImportText}
placeholder={BULK_IMPORT_PLACEHOLDER}
onChange={(e) => {
setBulkImportText(e.target.value);
setBulkImportParsedOnce(false);

View File

@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/shared/components";
import { useState } from "react";
import { useTranslations } from "next-intl";
import ProxyRegistryManager from "../ProxyRegistryManager";
import VercelRelayModal from "./VercelRelayModal";
@@ -13,27 +12,10 @@ export default function ProxyPoolTab() {
const [vercelModalOpen, setVercelModalOpen] = useState(false);
const [denoModalOpen, setDenoModalOpen] = useState(false);
const [cloudflareModalOpen, setCloudflareModalOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false";
const showDenoRelay = process.env.NEXT_PUBLIC_DENO_RELAY_ENABLED !== "false";
const showCloudflareRelay = process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED !== "false";
const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay;
// Close the dropdown on outside click — mirrors the upstream PR-1437
// grouped-button UX so adding more relay backends does not blow up the
// toolbar horizontally.
useEffect(() => {
if (!menuOpen) return;
const onMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [menuOpen]);
const handleVercelDeployed = (_poolProxyId: string, relayUrl: string) => {
alert(`${t("vercelRelaySuccess")}: ${relayUrl}`);
@@ -50,79 +32,15 @@ export default function ProxyPoolTab() {
return (
<div className="space-y-4">
{showAnyRelay && (
<div className="flex justify-end">
<div className="relative" ref={menuRef}>
<Button
size="sm"
variant="secondary"
icon="rocket_launch"
onClick={() => setMenuOpen((v) => !v)}
>
{t("deployRelayButton")}
</Button>
{menuOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-xl">
{showVercelRelay && (
<button
type="button"
onClick={() => {
setVercelModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
cloud_upload
</span>
{t("vercelRelayButton")}
</button>
)}
{showDenoRelay && (
<button
type="button"
onClick={() => {
setDenoModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
terminal
</span>
{t("denoRelayButton")}
</button>
)}
{showCloudflareRelay && (
<button
type="button"
onClick={() => {
setCloudflareModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
cloud
</span>
{t("cloudflareRelayButton")}
</button>
)}
</div>
)}
</div>
</div>
)}
<ProxyRegistryManager onRedeployRelay={handleRedeployRelay} />
<ProxyRegistryManager
onRedeployRelay={handleRedeployRelay}
showVercelRelay={showVercelRelay}
showDenoRelay={showDenoRelay}
showCloudflareRelay={showCloudflareRelay}
onOpenVercelRelay={() => setVercelModalOpen(true)}
onOpenDenoRelay={() => setDenoModalOpen(true)}
onOpenCloudflareRelay={() => setCloudflareModalOpen(true)}
/>
<VercelRelayModal
isOpen={vercelModalOpen}
onClose={() => setVercelModalOpen(false)}

View File

@@ -0,0 +1,88 @@
import type { ProxyItem } from "./proxyRegistryTypes";
export type UsageInfo = {
count: number;
assignments: Array<{ scope: string; scopeId: string | null }>;
};
export type HealthInfo = {
proxyId: string;
totalRequests: number;
successRate: number | null;
avgLatencyMs: number | null;
lastSeenAt: string | null;
};
export type TestResult = {
success: boolean;
publicIp?: string;
latencyMs?: number;
country?: string;
error?: string;
};
export const EMPTY_FORM = {
id: "",
name: "",
type: "http",
host: "",
port: "8080",
username: "",
password: "",
region: "",
notes: "",
status: "active",
family: "auto",
};
export const BULK_IMPORT_PLACEHOLDER = `# Proxy Bulk Import
# ─────────────────────────────────────────────────────────────────────────────
# FORMAT 1 — Pipe-delimited (full control):
# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
#
# FORMAT 2 — Shorthand (one proxy per line, no pipe needed):
# ip:port → no auth, type defaults to socks5
# ip:port:user:pass → with auth
# user:pass@ip:port → with auth (@-style)
# user:pass:ip:port → with auth (user-pass-first)
# protocol://ip:port → explicit protocol
# protocol://user:pass@ip:port → explicit protocol + auth
#
# FORMAT 3 — Protocol header mode:
# Put a bare protocol (http, https, socks5) on its own line to set
# the default type for all subsequent shorthand lines that don't
# include an explicit protocol:// prefix.
#
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# ─────────────────────────────────────────────────────────────────────────────
# Pipe-delimited examples:
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
#
# Shorthand examples:
# 138.99.147.218:50101
# 138.99.147.218:50101:myuser:mypass
# myuser:mypass@138.99.147.218:50101
# myuser:mypass:138.99.147.218:50101
# http://10.0.0.50:8080
# https://admin:secret123@proxy.example.com:443
#
# Protocol header mode example:
# socks5
# 138.99.147.218:50101:myuser:mypass
# 200.234.177.62:50101:otheruser:otherpass
#`;
export type ProxyRegistryManagerProps = {
onRedeployRelay?: (proxy: ProxyItem) => void;
showVercelRelay?: boolean;
showDenoRelay?: boolean;
showCloudflareRelay?: boolean;
onOpenVercelRelay?: () => void;
onOpenDenoRelay?: () => void;
onOpenCloudflareRelay?: () => void;
};

View File

@@ -0,0 +1,78 @@
import { z } from "zod";
import type { HealthInfo, UsageInfo } from "./proxyRegistryConstants";
type SetState<T> = (value: T | ((previous: T) => T)) => void;
type Assignment = { scope: string; scopeId: string | null };
function uniqueAssignments(assignments: Assignment[]) {
const seen = new Set<string>();
return assignments.filter((assignment) => {
const key = `${assignment.scope}:${assignment.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export async function loadProxyHealth(setHealthById: SetState<Record<string, HealthInfo>>) {
try {
const response = await fetch("/api/settings/proxies/health?hours=24");
const data = await response.json().catch(() => ({}));
if (!response.ok) return;
const entries = Array.isArray(data?.items) ? data.items : [];
setHealthById(Object.fromEntries(entries.map((entry: HealthInfo) => [entry.proxyId, entry])));
} catch {
// Ignore health-loading errors in the UI.
}
}
export async function loadAllProxyUsage(
proxyIds: string[],
setUsageById: SetState<Record<string, UsageInfo>>
) {
if (!proxyIds.length) return;
try {
const results = await Promise.all(
proxyIds.map((id) =>
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const assignments = uniqueAssignments(
Array.isArray(data?.items) ? data.items : []
);
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
})
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
)
);
setUsageById(Object.fromEntries(results));
} catch {
// Ignore usage-loading errors in the UI.
}
}
export const repairRelayResponseSchema = z.object({
repaired: z.boolean().optional(),
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
error: z.object({ message: z.string() }).optional(),
});
export async function loadProxyUsage(
proxyId: string,
setUsageById: SetState<Record<string, UsageInfo>>
) {
try {
const response = await fetch(
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
);
const data = await response.json().catch(() => ({}));
if (!response.ok) return;
const assignments = uniqueAssignments(Array.isArray(data?.items) ? data.items : []);
setUsageById((previous) => ({
...previous,
[proxyId]: { count: assignments.length, assignments },
}));
} catch {
// Ignore usage-loading errors in the UI.
}
}

View File

@@ -14,39 +14,12 @@ import {
removeModelContextOverride,
setModelContextOverride,
} from "@/lib/db/modelContextOverrides";
import { getProviderPrefixIndex, type ProviderPrefixEntry } from "@/lib/providerNodePrefixes";
const overrideKeySchema = z.enum(["context_length", "max_input_tokens", "max_output_tokens"]);
type PublicOverrideKey = z.infer<typeof overrideKeySchema>;
type PublicOverride = Omit<ModelCapabilityOverride, "key"> & { key: PublicOverrideKey };
/**
* One-time per-request snapshot of the provider-node prefix index. Loaded once
* per handler (never N times per row) straight from the DB — no module-global
* mutable caches, no route-to-route imports.
*/
async function loadPrefixMaps(): Promise<{
entries: Map<string, ProviderPrefixEntry>;
nodeToPrefix: Map<string, string>;
prefixToNode: Map<string, string>;
eligibleNodeIds: Set<string>;
compatibleNodeIds: Set<string>;
}> {
const index = await getProviderPrefixIndex();
return {
entries: index.entries,
nodeToPrefix: index.nodeToPrefix,
prefixToNode: index.prefixToNode,
eligibleNodeIds: index.eligibleNodeIds,
compatibleNodeIds: index.compatibleNodeIds,
};
}
async function listPublicOverrides(
nodeToPrefix: Map<string, string>,
eligibleNodeIds: Set<string>,
compatibleNodeIds: Set<string>
): Promise<PublicOverride[]> {
function listPublicOverrides(): PublicOverride[] {
const capabilityOverrides = listModelCapabilityOverrides() as PublicOverride[];
const contextOverrides = listModelContextOverrides().map((override): PublicOverride => ({
provider: override.provider,
@@ -56,27 +29,9 @@ async function listPublicOverrides(
value: override.realContext,
refreshedAt: override.refreshedAt,
}));
const merged = [...capabilityOverrides, ...contextOverrides];
return merged
.filter((override) => {
// A compatible node that is NOT the unique non-reserved prefix winner is
// ineligible for Model Overrides: never surface it under a generated node
// UUID. Eligible winners are those in `eligibleNodeIds` (routable via
// their public prefix); built-in providers (not in `compatibleNodeIds`)
// are always eligible.
return !compatibleNodeIds.has(override.provider) || eligibleNodeIds.has(override.provider);
})
.map((override) => {
const displayProvider = nodeToPrefix.get(override.provider) || override.provider;
return {
...override,
// Both `provider` and `target` are exposed under the public prefix so no
// generated node UUID ever leaks into the JSON for a prefixed node.
provider: displayProvider,
target: `${displayProvider}/${override.modelId}`,
};
})
.sort((left, right) => right.refreshedAt.localeCompare(left.refreshedAt));
return [...capabilityOverrides, ...contextOverrides].sort((left, right) =>
right.refreshedAt.localeCompare(left.refreshedAt)
);
}
const upsertOverrideSchema = z.object({
@@ -85,65 +40,23 @@ const upsertOverrideSchema = z.object({
value: z.coerce.number().int().positive(),
});
/**
* Canonicalize a public `<prefix>/<model>` target to `<internalNodeId>/<model>`
* so the override is stored where runtime lookup reads it. Mirrors runtime
* prefix routing exactly:
*
* - `unique` configured prefix → canonicalize to the single runtime-routable
* winner node (first openai-compatible then anthropic-compatible, by id).
* - `reserved` configured prefix (collides with a built-in registry id/alias,
* e.g. a node with `prefix="cx"`) → route via `resolveProviderAlias` to the
* built-in canonical provider (runtime routes `cx/` to codex), never 400.
* - `ambiguous` (no runtime winner selectable) → fail closed (400).
* - A bare built-in alias/id that is NOT a configured node prefix (e.g.
* `openai` typed directly) resolves via `resolveProviderAlias` (unchanged).
*
* Returns the canonical `provider/model` on success, or `{ ok: false }`.
*/
function canonicalizeTarget(
target: string,
entries: Map<string, ProviderPrefixEntry>,
prefixToNode: Map<string, string>,
compatibleNodeIds: Set<string>,
eligibleNodeIds: Set<string>
): { ok: true; target: string } | { ok: false } {
function canonicalizeTarget(target: string): string | null {
const raw = target.trim();
const slashIndex = raw.indexOf("/");
if (slashIndex <= 0 || slashIndex === raw.length - 1) return { ok: false };
if (slashIndex <= 0 || slashIndex === raw.length - 1) return null;
const provider = raw.slice(0, slashIndex).trim();
const modelId = raw.slice(slashIndex + 1).trim();
if (!provider || !modelId) return { ok: false };
if (!provider || !modelId) return null;
// Raw internal compatible-node IDs are never a public Model Overrides target.
// Only the public prefix of an eligible runtime winner may select a node.
// Reject ineligible raw IDs as well as eligible raw IDs so stale or direct API
// callers cannot create UUID-keyed overrides that the UI cannot manage.
if (compatibleNodeIds.has(provider)) return { ok: false };
const configured = entries.get(provider);
// A reserved configured prefix is routable to the built-in canonical provider
// (runtime never routes it to the compatible node). `resolveProviderAlias`
// maps e.g. `cx` → `codex`. Only an ambiguous prefix has no routable target.
if (configured && configured.status === "ambiguous") {
return { ok: false };
}
const resolvedNodeId = prefixToNode.get(provider);
if (resolvedNodeId && !eligibleNodeIds.has(resolvedNodeId)) return { ok: false };
const canonicalProvider = resolvedNodeId || resolveProviderAlias(provider) || provider;
return { ok: true, target: `${canonicalProvider}/${modelId}` };
return `${resolveProviderAlias(provider) || provider}/${modelId}`;
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { nodeToPrefix, eligibleNodeIds, compatibleNodeIds } = await loadPrefixMaps();
return NextResponse.json({
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
});
return NextResponse.json({ overrides: listPublicOverrides() });
}
export async function PATCH(request: Request) {
@@ -162,28 +75,17 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
}
const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
await loadPrefixMaps();
const canonical = canonicalizeTarget(
parsed.data.target,
entries,
prefixToNode,
compatibleNodeIds,
eligibleNodeIds
);
if (!canonical.ok) {
return NextResponse.json(
{ error: "Invalid or ambiguous model capability override target" },
{ status: 400 }
);
const target = canonicalizeTarget(parsed.data.target);
if (!target) {
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
}
const targetParts = canonical.target.split(/\/(.*)/s);
const targetParts = target.split(/\/(.*)/s);
const written =
parsed.data.key === "context_length"
? setModelContextOverride(targetParts[0], targetParts[1], parsed.data.value, "manual")
: setModelCapabilityOverride(
canonical.target,
target,
parsed.data.key as ModelCapabilityOverrideKey,
parsed.data.value
);
@@ -191,9 +93,7 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
}
return NextResponse.json({
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
});
return NextResponse.json({ overrides: listPublicOverrides() });
}
export async function DELETE(request: Request) {
@@ -201,33 +101,19 @@ export async function DELETE(request: Request) {
if (authError) return authError;
const { searchParams } = new URL(request.url);
const target = canonicalizeTarget(searchParams.get("target") || "");
const key = searchParams.get("key") || "";
const parsedKey = overrideKeySchema.safeParse(key);
const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
await loadPrefixMaps();
const canonical = canonicalizeTarget(
searchParams.get("target") || "",
entries,
prefixToNode,
compatibleNodeIds,
eligibleNodeIds
);
if (!canonical.ok || !parsedKey.success) {
return NextResponse.json(
{ error: "target and key are required; target must be a valid model override target" },
{ status: 400 }
);
if (!target || !parsedKey.success) {
return NextResponse.json({ error: "target and key are required" }, { status: 400 });
}
if (parsedKey.data === "context_length") {
const targetParts = canonical.target.split(/\/(.*)/s);
const targetParts = target.split(/\/(.*)/s);
removeModelContextOverride(targetParts[0], targetParts[1]);
} else {
removeModelCapabilityOverride(canonical.target, parsedKey.data as ModelCapabilityOverrideKey);
removeModelCapabilityOverride(target, parsedKey.data as ModelCapabilityOverrideKey);
}
return NextResponse.json({
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
});
return NextResponse.json({ overrides: listPublicOverrides() });
}

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb";
import { getProviderPrefixIndex } from "@/lib/providerNodePrefixes";
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
@@ -29,12 +28,6 @@ export async function GET() {
try {
const catalog: Record<string, any> = {};
// Pre-load compatible-provider node public prefixes once (shared across the
// whole catalog build — never N lookups per model). Only uniquely-routable
// prefixes are exposed as public targets (reserved/ambiguous are not).
const { nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
await getProviderPrefixIndex();
// ── 1. Registry models (hardcoded) ──────────────────────────────
for (const entry of Object.values(REGISTRY)) {
const alias = entry.alias || entry.id;
@@ -61,13 +54,6 @@ export async function GET() {
return providerId;
};
// A compatible provider node should surface under its configured public
// prefix, never its generated `openai-compatible-chat-<uuid>` node id
// (#9557). The internal `id` (node id) is preserved for PricingTab and
// runtime capability lookup. Only a uniquely-routable non-reserved winner
// is Model-Overrides eligible (marked explicitly); a compatible node that
// is reserved/losing/no-prefix is marked ineligible and skipped by the
// Model-Overrides helper.
const ensureCatalogProvider = (providerId: string, alias: string) => {
if (!catalog[alias]) {
catalog[alias] = {
@@ -78,11 +64,6 @@ export async function GET() {
format: "openai",
models: [],
};
const prefix = nodeToPrefix.get(providerId);
if (prefix) catalog[alias].displayPrefix = prefix;
if (compatibleNodeIds.has(providerId)) {
catalog[alias].modelOverrideEligible = eligibleNodeIds.has(providerId);
}
}
return catalog[alias];
};
@@ -130,13 +111,6 @@ export async function GET() {
}
// ── 4. Pricing-only models (DB) ─────────────────────────────────
// Pricing may be keyed by the node's public prefix (what the operator typed)
// or by the internal node id. When keyed by a uniquely-routable public
// prefix, reconcile it to that node so the model list merges into the
// canonical compatible-provider entry instead of duplicating it, and
// preserve the original pricing namespace as `pricingKey` so PricingTab can
// read/save/reset against it. Reserved / ambiguous prefixes have no single
// routable node and stay as-is.
let pricingData: Record<string, any> = {};
try {
pricingData = await getPricing();
@@ -144,10 +118,7 @@ export async function GET() {
/* DB may not be ready */
}
for (const [rawProviderAlias, models] of Object.entries(pricingData)) {
// `rawProviderAlias` is the original pricing namespace the operator used.
const pricingKey = rawProviderAlias;
const providerAlias = prefixToNode.get(rawProviderAlias) || rawProviderAlias;
for (const [providerAlias, models] of Object.entries(pricingData)) {
if (!catalog[providerAlias]) {
catalog[providerAlias] = {
id: providerAlias,
@@ -157,16 +128,6 @@ export async function GET() {
format: "openai",
models: [],
};
const prefix = nodeToPrefix.get(providerAlias);
if (prefix) catalog[providerAlias].displayPrefix = prefix;
if (compatibleNodeIds.has(providerAlias)) {
catalog[providerAlias].modelOverrideEligible = eligibleNodeIds.has(providerAlias);
}
}
// When the entry is keyed internally by the node id but priced under a
// public prefix, remember the original pricing namespace for PricingTab.
if (pricingKey !== providerAlias && !catalog[providerAlias].pricingKey) {
catalog[providerAlias].pricingKey = pricingKey;
}
const existingIds = new Set(catalog[providerAlias].models.map((m) => m.id));

View File

@@ -1,79 +0,0 @@
/**
* Pure catalog → Model Override target conversion.
*
* The operator-facing Model Overrides surface must present a compatible
* provider node under its configured public `prefix` (e.g. `vibeproxy/gpt-4o`)
* — never the generated `openai-compatible-chat-<uuid>` node id (#9557).
*
* The pricing catalog keeps the internal `id` (the DB node id, which PricingTab
* uses to key pricing data) and, when the node has a configured prefix, also
* carries `displayPrefix`. This helper prefers `displayPrefix` for the public
* label/target while leaving the raw id untouched for storage/runtime lookup.
*
* Model-Overrides eligibility seam: a compatible provider node is eligible only
* when it is the unique, non-reserved runtime-routable winner of its configured
* prefix. The catalog marks such winners with `modelOverrideEligible === true`
* (and a `displayPrefix`); reserved/losing/no-public-prefix compatible nodes are
* marked `modelOverrideEligible === false` and are SKIPPED — never surfaced
* under a generated node UUID. Built-in / no-compatible catalog entries carry no
* flag and remain targetable.
*/
export interface PricingCatalogModel {
id: string;
name: string;
}
export interface PricingCatalogProvider {
id: string;
alias: string;
displayPrefix?: string;
/** Explicit Model-Overrides eligibility; undefined ⇒ eligible (built-in/no-compatible). */
modelOverrideEligible?: boolean;
models: PricingCatalogModel[];
}
export interface ModelOverrideTarget {
target: string;
provider: string;
modelId: string;
label: string;
}
/**
* Whether a catalog provider is targetable in Model Overrides. Only compatible
* nodes marked ineligible (reserved/losing/no-public-prefix) are skipped; all
* built-in and no-compatible entries are eligible.
*/
export function isModelOverrideEligible(provider: PricingCatalogProvider): boolean {
return provider.modelOverrideEligible !== false;
}
/**
* Public display prefix for a compatible provider node, falling back to its
* internal id when no operator-configured prefix is set.
*/
export function modelOverrideProviderPrefix(provider: PricingCatalogProvider): string {
return provider.displayPrefix?.trim() || provider.id;
}
/**
* Convert the /api/pricing/models catalog into Model Override targets. Each
* target uses the node's public prefix (when configured) so the selector,
* search, selected model, and the target sent to the override API never expose
* a generated node UUID. Ineligible compatible nodes are skipped entirely.
*/
export function toModelOverrideTargets(
catalog: Record<string, PricingCatalogProvider>
): ModelOverrideTarget[] {
return Object.values(catalog).flatMap((provider) => {
if (!isModelOverrideEligible(provider)) return [];
const prefix = modelOverrideProviderPrefix(provider);
return provider.models.map((model) => ({
target: `${prefix}/${model.id}`,
provider: prefix,
modelId: model.id,
label: `${prefix}/${model.id}`,
}));
});
}

View File

@@ -1,144 +0,0 @@
/**
* Shared provider-node public-prefix index (#9557).
*
* A compatible provider node (openai/anthropic-compatible) can carry an
* operator-configured public `prefix` (e.g. `vibeproxy`) that the Model
* Overrides surface must expose instead of the generated
* `openai-compatible-chat-<uuid>` node id.
*
* This module is the single narrow home for that index so both the pricing
* catalog route and the override route resolve node → prefix / prefix → node
* consistently. It does one `getProviderNodes()` DB read per call and derives
* every map from it — no module-global mutable caches, no route-to-route
* imports.
*
* Classification of each configured prefix (mirrors runtime semantics):
* - `reserved`: the prefix collides with a built-in registry id/alias
* (e.g. `cx` → codex). Such a node must NOT be advertised as a compatible
* public target and the prefix must never be canonicalized to that node —
* runtime routes reserved prefixes to the built-in provider, so the
* override route must too.
* - `unique`: a single runtime-routable node owns the prefix. When two or
* more nodes share a prefix, the runtime winner is deterministic (first
* openai-compatible node by id order, else first anthropic-compatible
* node — see `getModelInfo`), and the prefix index selects that same
* winner. Only the winner is targetable/displayed under the prefix;
* losing nodes are ineligible and never fall back to a node UUID target.
* - `ambiguous`: multiple nodes share the prefix but no runtime winner is
* selectable (no compatible node matches) — practically unreachable since
* only compatible nodes carry prefixes, kept for safety.
*
* Model-Overrides eligibility: a compatible node is eligible only when it is
* the unique, non-reserved winner of its configured prefix (i.e. it is in
* `eligibleNodeIds`). Reserved/losing/no-public-prefix compatible nodes are
* ineligible and must be skipped — never surfaced under a generated node UUID.
* Built-in/no-compatible catalog entries are always eligible.
*/
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getProviderNodes } from "@/lib/db/providers/nodes";
export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved";
export interface ProviderPrefixEntry {
prefix: string;
status: ProviderPrefixStatus;
/** Present only when `status === "unique"`. */
nodeId?: string;
}
export interface ProviderPrefixIndex {
/** prefix → classification entry (every configured prefix). */
entries: Map<string, ProviderPrefixEntry>;
/** nodeId → public prefix, only for uniquely-routable non-reserved winners. */
nodeToPrefix: Map<string, string>;
/** public prefix → nodeId, only for uniquely-routable non-reserved winners. */
prefixToNode: Map<string, string>;
/** Every compatible provider node id present in the node table. */
compatibleNodeIds: Set<string>;
/** Compatible node ids eligible for Model Overrides (unique non-reserved winners). */
eligibleNodeIds: Set<string>;
}
/**
* Built-in reserved prefixes — registry ids + aliases, the same semantics the
* runtime `getReservedProviderPrefixes()` uses so user-defined compatible-node
* prefixes can never shadow a built-in provider.
*/
export function buildReservedPrefixes(): Set<string> {
const reserved = new Set<string>();
for (const entry of Object.values(REGISTRY)) {
if (entry?.id) reserved.add(entry.id);
if (entry?.alias) reserved.add(entry.alias);
}
return reserved;
}
export interface CompatibleNodeLike {
id?: string;
type?: string;
prefix?: string | null;
}
/**
* Pure prefix→node winner selection replicating the runtime `getModelInfo`
* rule exactly: the first openai-compatible node (by DB/id order) whose
* `prefix` matches wins; otherwise the first anthropic-compatible node.
* `nodes` must already be in the runtime's id-ascending order (as
* `getProviderNodes` returns).
*/
export function selectCompatibleNodeForPrefix(
nodes: CompatibleNodeLike[],
prefix: string
): CompatibleNodeLike | null {
const openaiMatch = nodes.find((n) => n.type === "openai-compatible" && n.prefix === prefix);
if (openaiMatch) return openaiMatch;
return nodes.find((n) => n.type === "anthropic-compatible" && n.prefix === prefix) ?? null;
}
export async function getProviderPrefixIndex(): Promise<ProviderPrefixIndex> {
const reserved = buildReservedPrefixes();
const nodes = (await getProviderNodes()) as CompatibleNodeLike[];
const compatible = nodes.filter(
(n) => n.type === "openai-compatible" || n.type === "anthropic-compatible"
);
const compatibleNodeIds = new Set<string>();
for (const node of compatible) {
if (node.id) compatibleNodeIds.add(node.id);
}
const byPrefix = new Map<string, CompatibleNodeLike[]>();
for (const node of compatible) {
const prefix = node.prefix?.trim();
if (!node.id || !prefix) continue;
const list = byPrefix.get(prefix) ?? [];
list.push(node);
byPrefix.set(prefix, list);
}
const entries = new Map<string, ProviderPrefixEntry>();
const nodeToPrefix = new Map<string, string>();
const prefixToNode = new Map<string, string>();
const eligibleNodeIds = new Set<string>();
for (const [prefix, prefixNodes] of byPrefix) {
if (reserved.has(prefix)) {
// Built-in registry id/alias — never a compatible public target.
entries.set(prefix, { prefix, status: "reserved" });
continue;
}
const winner = selectCompatibleNodeForPrefix(prefixNodes, prefix);
if (!winner?.id) {
entries.set(prefix, { prefix, status: "ambiguous" });
continue;
}
// The runtime-routable winner alone owns the prefix.
entries.set(prefix, { prefix, status: "unique", nodeId: winner.id });
nodeToPrefix.set(winner.id, prefix);
prefixToNode.set(prefix, winner.id);
eligibleNodeIds.add(winner.id);
}
return { entries, nodeToPrefix, prefixToNode, compatibleNodeIds, eligibleNodeIds };
}

View File

@@ -1,584 +0,0 @@
import { describe, it, beforeEach, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-model-overrides-prefix-"));
process.env.DATA_DIR = moduleDataDir;
const coreDb = await import("../../src/lib/db/core.ts");
const nodes = await import("../../src/lib/db/providers/nodes.ts");
const models = await import("../../src/lib/db/models.ts");
const overrides = await import("../../src/lib/db/modelCapabilityOverrides.ts");
const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts");
const pricingRoute = await import("../../src/app/api/pricing/models/route.ts");
const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts");
const targets = await import("../../src/lib/modelCapabilityOverrideTargets.ts");
const caps = await import("../../src/lib/modelCapabilities.ts");
const prefixIndex = await import("../../src/lib/providerNodePrefixes.ts");
// Runtime prefix→node resolution (same path the request pipeline uses).
const sseModel = await import("../../src/sse/services/model.ts");
beforeEach(() => {
coreDb.resetDbInstance();
fs.rmSync(moduleDataDir, { recursive: true, force: true });
fs.mkdirSync(moduleDataDir, { recursive: true });
coreDb.getDbInstance();
});
after(() => {
coreDb.resetDbInstance();
fs.rmSync(moduleDataDir, { recursive: true, force: true });
});
const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441";
const NODE_PREFIX = "vibeproxy";
const NODE_TYPE = "openai-compatible";
async function seedNodeWithSyncedModel(modelId = "gpt-4o", opts: { prefix?: string | null } = {}) {
await nodes.createProviderNode({
id: NODE_ID,
type: NODE_TYPE,
prefix: opts.prefix === undefined ? NODE_PREFIX : opts.prefix,
name: "VibeProxy",
apiType: "chat",
baseUrl: "https://example.com/v1",
});
await models.replaceSyncedAvailableModelsForConnection(NODE_ID, NODE_ID, [
{ id: modelId, name: modelId },
]);
}
describe("issue #9557: model overrides expose configured provider prefix, not node UUID", () => {
it("pricing/models returns public displayPrefix while retaining internal node id", async () => {
await seedNodeWithSyncedModel();
const response = await pricingRoute.GET();
assert.equal(response.status, 200);
const catalog = (await response.json()) as Record<
string,
{ id: string; alias: string; displayPrefix?: string; models: Array<{ id: string }> }
>;
const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID);
assert.ok(entry, "compatible node must appear in the pricing catalog");
assert.equal(entry.id, NODE_ID, "internal node id must be preserved for PricingTab");
assert.equal(entry.displayPrefix, NODE_PREFIX, "public display prefix must be exposed");
assert.ok(
entry.models.some((model) => model.id === "gpt-4o"),
"synced model must be listed under the compatible node"
);
});
it("toModelOverrideTargets labels/selects with the public prefix, never the node UUID", () => {
const catalog = {
[NODE_ID]: {
id: NODE_ID,
alias: NODE_ID,
displayPrefix: NODE_PREFIX,
models: [{ id: "gpt-4o", name: "gpt-4o" }],
},
openai: {
id: "openai",
alias: "openai",
models: [{ id: "gpt-4o", name: "gpt-4o" }],
},
// A losing/reserved compatible node explicitly marked ineligible must be
// skipped entirely — never surfaced under a node UUID.
"openai-compatible-chat-loser": {
id: "openai-compatible-chat-loser",
alias: "openai-compatible-chat-loser",
displayPrefix: NODE_PREFIX,
modelOverrideEligible: false,
models: [{ id: "lost-model", name: "lost-model" }],
},
};
const result = targets.toModelOverrideTargets(catalog);
const [compatible, builtin] = result;
assert.equal(result.length, 2, "ineligible compatible node is skipped");
assert.equal(compatible.target, `${NODE_PREFIX}/gpt-4o`);
assert.equal(compatible.provider, NODE_PREFIX);
assert.equal(compatible.label, `${NODE_PREFIX}/gpt-4o`);
assert.ok(!compatible.target.includes(NODE_ID), "public target must not leak the node UUID");
assert.ok(!result.some((t) => t.target.includes("lost-model")), "lost node not a target");
assert.equal(builtin.target, "openai/gpt-4o");
assert.equal(
targets.isModelOverrideEligible(catalog[NODE_ID]),
true,
"unflagged compatible winner is eligible"
);
assert.equal(
targets.isModelOverrideEligible(catalog["openai-compatible-chat-loser"]),
false,
"explicitly ineligible node is excluded"
);
assert.equal(targets.isModelOverrideEligible(catalog.openai), true, "built-in is eligible");
});
it("PATCH canonicalizes a configured prefix to the internal node id and runtime lookup applies it", async () => {
await seedNodeWithSyncedModel("gpt-4o");
const patch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${NODE_PREFIX}/gpt-4o`,
key: "max_output_tokens",
value: 123456,
}),
})
);
assert.equal(patch.status, 200);
// Persisted under the internal node id (where runtime lookup reads it).
const stored = overrides.listModelCapabilityOverrides();
assert.equal(stored.length, 1);
assert.equal(stored[0].provider, NODE_ID);
assert.equal(stored[0].modelId, "gpt-4o");
// Runtime capability lookup resolves provider/model and applies the override.
const resolved = caps.getResolvedModelCapabilities({
provider: NODE_ID,
model: "gpt-4o",
});
assert.equal(resolved.maxOutputTokens, 123456);
const explicit = caps.getExplicitModelOutputCap({ provider: NODE_ID, model: "gpt-4o" });
assert.equal(explicit, 123456);
});
it("runtime getModelInfo resolves prefix/model back to the node id (routing seam)", async () => {
await seedNodeWithSyncedModel("gpt-4o");
const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`);
assert.ok(info, "getModelInfo must resolve");
assert.equal(info.provider, NODE_ID, "prefix must route to the internal node id at runtime");
});
it("index winner matches runtime getModelInfo for a duplicated prefix", async () => {
// Two nodes share the prefix. Runtime resolves the FIRST openai-compatible
// node by id order. The prefix index must select that same winner so the
// UI and PATCH agree with the routing seam.
const nodeAId = NODE_ID;
const nodeBId = "openai-compatible-chat-22222222-2222-4333-8444-555555555555";
await nodes.createProviderNode({
id: nodeBId,
type: NODE_TYPE,
prefix: NODE_PREFIX,
name: "VibeProxy B",
apiType: "chat",
baseUrl: "https://example.com/b/v1",
});
await nodes.createProviderNode({
id: nodeAId,
type: NODE_TYPE,
prefix: NODE_PREFIX,
name: "VibeProxy A",
apiType: "chat",
baseUrl: "https://example.com/a/v1",
});
await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [
{ id: "gpt-4o", name: "gpt-4o" },
]);
const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`);
assert.equal(info.provider, nodeAId, "runtime resolves the first node by id");
const index = await prefixIndex.getProviderPrefixIndex();
assert.equal(
index.prefixToNode.get(NODE_PREFIX),
info.provider,
"index prefix→node must match the runtime winner"
);
assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, info.provider);
assert.ok(index.eligibleNodeIds.has(info.provider));
assert.ok(!index.eligibleNodeIds.has(nodeBId));
// And PATCH persists to the same winner runtime resolves to.
const patch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${NODE_PREFIX}/gpt-4o`,
key: "max_input_tokens",
value: 77777,
}),
})
);
assert.equal(patch.status, 200);
assert.equal(overrides.listModelCapabilityOverrides()[0].provider, info.provider);
});
it("GET surfaces stored overrides under the public prefix and old raw rows still list/apply", async () => {
await seedNodeWithSyncedModel("gpt-4o");
// New-style row saved via the API (canonicalized to node id).
await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${NODE_PREFIX}/gpt-4o`,
key: "max_input_tokens",
value: 99999,
}),
})
);
// Old raw-UUID-keyed row inserted directly (legacy pre-#9557 data).
assert.equal(
overrides.setModelCapabilityOverride(`${NODE_ID}/gpt-4o`, "max_output_tokens", 55555),
true
);
const get = await overrideRoute.GET(
new Request("http://localhost/api/model-capability-overrides")
);
assert.equal(get.status, 200);
const payload = (await get.json()) as {
overrides: Array<{ target: string; key: string; value: number }>;
};
const targetsFound = payload.overrides.map((entry) => entry.target).sort();
assert.deepEqual(targetsFound, [`${NODE_PREFIX}/gpt-4o`, `${NODE_PREFIX}/gpt-4o`]);
assert.ok(
payload.overrides.every((entry) => !entry.target.includes(NODE_ID)),
"public override list must not leak the node UUID"
);
// Old raw row still applies at runtime.
assert.equal(
caps.getResolvedModelCapabilities({ provider: NODE_ID, model: "gpt-4o" }).maxOutputTokens,
55555
);
});
it("GET/PATCH/DELETE JSON responses never leak the node UUID for a prefixed node", async () => {
await seedNodeWithSyncedModel("gpt-4o");
await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${NODE_PREFIX}/gpt-4o`,
key: "max_output_tokens",
value: 333,
}),
})
);
const get = await overrideRoute.GET(
new Request("http://localhost/api/model-capability-overrides")
);
assert.ok(!(await get.text()).includes(NODE_ID), "GET body must not contain node UUID");
const patch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${NODE_PREFIX}/gpt-4o`,
key: "max_input_tokens",
value: 444,
}),
})
);
assert.ok(!(await patch.text()).includes(NODE_ID), "PATCH body must not contain node UUID");
const del = await overrideRoute.DELETE(
new Request(
`http://localhost/api/model-capability-overrides?target=${NODE_PREFIX}/gpt-4o&key=max_output_tokens`,
{ method: "DELETE" }
)
);
assert.equal(del.status, 200);
const delBody = await del.text();
assert.ok(!delBody.includes(NODE_ID), "DELETE body must not contain node UUID");
// DELETE still returns the updated override list for the UI.
const delPayload = JSON.parse(delBody) as { overrides: unknown[] };
assert.ok(Array.isArray(delPayload.overrides), "DELETE returns { overrides }");
assert.equal(delPayload.overrides.length, 1, "remaining override still present");
});
it("duplicate prefix selects the same runtime winner; losing node never falls back to UUID", async () => {
// Two nodes share the same prefix → the runtime winner is the FIRST
// openai-compatible node by id order. The index and PATCH must choose that
// same winner; the losing node must NOT be targetable/displayed under the
// prefix and must NOT fall back to a node-UUID target.
const nodeAId = NODE_ID;
const nodeBId = "openai-compatible-chat-11111111-2222-4333-8444-555555555555";
await nodes.createProviderNode({
id: nodeBId,
type: NODE_TYPE,
prefix: NODE_PREFIX,
name: "VibeProxy B",
apiType: "chat",
baseUrl: "https://example.com/b/v1",
});
await nodes.createProviderNode({
id: nodeAId,
type: NODE_TYPE,
prefix: NODE_PREFIX,
name: "VibeProxy A",
apiType: "chat",
baseUrl: "https://example.com/a/v1",
});
await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [
{ id: "gpt-4o", name: "gpt-4o" },
]);
const index = await prefixIndex.getProviderPrefixIndex();
// Runtime resolves the first openai-compatible node by id order → nodeAId.
assert.equal(index.entries.get(NODE_PREFIX)?.status, "unique");
assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, nodeAId, "winner is first by id");
assert.equal(index.prefixToNode.get(NODE_PREFIX), nodeAId);
assert.equal(index.nodeToPrefix.get(nodeAId), NODE_PREFIX);
// Losing node is ineligible — no node-UUID target, no prefix→node mapping.
assert.ok(!index.eligibleNodeIds.has(nodeBId), "losing node is ineligible");
assert.ok(!index.prefixToNode.has(nodeBId), "losing node id must not be a prefix target");
assert.ok(!index.nodeToPrefix.has(nodeBId), "losing node must not map to the shared prefix");
// Catalog advertises only the winner under the public prefix.
const catalogRes = await pricingRoute.GET();
const catalog = (await catalogRes.json()) as Record<
string,
{ id: string; displayPrefix?: string }
>;
const winner = Object.values(catalog).find((p) => p.id === nodeAId);
assert.equal(winner?.displayPrefix, NODE_PREFIX, "winner advertised under prefix");
assert.ok(
!Object.values(catalog).some((p) => p.id === nodeBId && p.displayPrefix === NODE_PREFIX),
"losing node must not be advertised under the shared prefix"
);
// PATCH via the prefix canonicalizes to the winner (first by id) and the
// losing node is not targetable.
const patch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${NODE_PREFIX}/gpt-4o`,
key: "max_output_tokens",
value: 123456,
}),
})
);
assert.equal(patch.status, 200);
const stored = overrides.listModelCapabilityOverrides();
assert.equal(stored.length, 1);
assert.equal(stored[0].provider, nodeAId, "PATCH must persist to the runtime winner");
// Raw compatible-node UUIDs are not public Model Overrides targets. The
// losing node remains routable internally, but stale/direct clients must
// use the configured public prefix rather than create an unmanageable row.
const losePatch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${nodeBId}/gpt-4o`,
key: "max_input_tokens",
value: 7,
}),
})
);
assert.equal(losePatch.status, 400);
const loseDelete = await overrideRoute.DELETE(
new Request(
`http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${nodeBId}/gpt-4o`)}&key=max_input_tokens`,
{ method: "DELETE" }
)
);
assert.equal(loseDelete.status, 400);
assert.equal(overrides.listModelCapabilityOverrides().length, 1);
});
it("reserved prefix (cx → codex) is not advertised and not canonicalized to any node", async () => {
const RESERVED = "cx";
const reservedNodeId = "openai-compatible-chat-99999999-2222-4333-8444-555555555555";
await nodes.createProviderNode({
id: reservedNodeId,
type: NODE_TYPE,
prefix: RESERVED,
name: "Reserved Hijack",
apiType: "chat",
baseUrl: "https://example.com/cx/v1",
});
const index = await prefixIndex.getProviderPrefixIndex();
assert.equal(index.entries.get(RESERVED)?.status, "reserved");
assert.ok(
!index.nodeToPrefix.has(reservedNodeId),
"reserved-prefix node must not be exposed as a compatible target"
);
assert.ok(!index.prefixToNode.has(RESERVED), "reserved prefix must not map to any node");
// Catalog: reserved prefix must not surface as a displayPrefix on any entry.
const catalogRes = await pricingRoute.GET();
const catalog = (await catalogRes.json()) as Record<
string,
{ displayPrefix?: string; id?: string }
>;
assert.ok(
!Object.values(catalog).some((provider) => provider.displayPrefix === RESERVED),
"reserved prefix must not be advertised as a compatible public target"
);
// PATCH using `cx/<model>` must route via resolveProviderAlias to the
// built-in codex provider (runtime routes `cx/` to codex) — never 400, and
// never persisted under the reserved-hijack node.
const patch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${RESERVED}/gpt-4o`,
key: "max_output_tokens",
value: 1,
}),
})
);
assert.equal(patch.status, 200, "PATCH must route reserved prefix via resolveProviderAlias");
const stored = overrides.listModelCapabilityOverrides();
assert.equal(
stored.length,
1,
"reserved prefix override is persisted to the built-in provider"
);
assert.notEqual(stored[0].provider, reservedNodeId, "must never persist to the hijack node");
assert.equal(stored[0].provider, "codex", "reserved prefix must resolve to built-in codex");
const rawPatch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${reservedNodeId}/gpt-4o`,
key: "max_input_tokens",
value: 2,
}),
})
);
assert.equal(rawPatch.status, 400, "reserved node UUID must not be writable directly");
});
it("pricing-only compatible-provider model entry carries displayPrefix + pricingKey", async () => {
// Node exists but has NO synced/custom models; pricing is keyed by the
// public prefix and must be reconciled onto the unique node without
// duplicating entries, while preserving the original pricing namespace.
await nodes.createProviderNode({
id: NODE_ID,
type: NODE_TYPE,
prefix: NODE_PREFIX,
name: "VibeProxy",
apiType: "chat",
baseUrl: "https://example.com/v1",
});
const { updatePricing } = await import("../../src/lib/db/settings/pricing.ts");
// Seed a user pricing row keyed by the node's public prefix.
await updatePricing({
[NODE_PREFIX]: { "priced-model": { input_cost_per_million: 1, output_cost_per_million: 2 } },
});
const catalogRes = await pricingRoute.GET();
const catalog = (await catalogRes.json()) as Record<
string,
{ id: string; displayPrefix?: string; pricingKey?: string; models: Array<{ id: string }> }
>;
const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID);
assert.ok(entry, "pricing-only model must reconcile onto the compatible node");
assert.equal(entry.displayPrefix, NODE_PREFIX);
assert.equal(
entry.pricingKey,
NODE_PREFIX,
"pricingKey must preserve the original pricing namespace"
);
assert.deepEqual(
entry.models.map((model) => model.id),
["priced-model"],
"pricing-only entry must be created without relying on synced/custom models"
);
// No duplicate entry keyed by the public prefix alone.
assert.ok(
!Object.values(catalog).some(
(provider) => provider.id === NODE_PREFIX && provider.displayPrefix !== NODE_PREFIX
),
"pricing must not create a duplicate node-keyed entry"
);
});
it("no-prefix fallback and built-in providers keep raw internal id / unchanged behavior", async () => {
// Node with no prefix → target falls back to internal node id.
const noPrefixNodeId = "openai-compatible-chat-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
await nodes.createProviderNode({
id: noPrefixNodeId,
type: NODE_TYPE,
prefix: null,
name: "NoPrefix",
apiType: "chat",
baseUrl: "https://example.com/np/v1",
});
await models.replaceSyncedAvailableModelsForConnection(noPrefixNodeId, noPrefixNodeId, [
{ id: "np-model", name: "np-model" },
]);
const patch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: `${noPrefixNodeId}/np-model`,
key: "max_output_tokens",
value: 777,
}),
})
);
assert.equal(patch.status, 400, "no-prefix compatible node UUID must not be writable");
assert.equal(overrides.listModelCapabilityOverrides().length, 0);
const rawDelete = await overrideRoute.DELETE(
new Request(
`http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${noPrefixNodeId}/np-model`)}&key=max_output_tokens`,
{ method: "DELETE" }
)
);
assert.equal(rawDelete.status, 400);
// Model-Overrides eligibility seam: a compatible node with NO public prefix
// is skipped from the public override list — it is never surfaced under a
// generated node UUID. Built-in / no-compatible entries remain targetable.
const get = await overrideRoute.GET(
new Request("http://localhost/api/model-capability-overrides")
);
const payload = (await get.json()) as { overrides: Array<{ target: string }> };
assert.ok(
!payload.overrides.some((entry) => entry.target === `${noPrefixNodeId}/np-model`),
"no-public-prefix compatible node must be skipped from the override list"
);
// Built-in provider (openai) unchanged.
const openaiPatch = await overrideRoute.PATCH(
new Request("http://localhost/api/model-capability-overrides", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
target: "openai/gpt-4o",
key: "max_input_tokens",
value: 888,
}),
})
);
assert.equal(openaiPatch.status, 200);
const openaiGet = await overrideRoute.GET(
new Request("http://localhost/api/model-capability-overrides")
);
const openaiPayload = (await openaiGet.json()) as { overrides: Array<{ target: string }> };
assert.ok(
openaiPayload.overrides.some((entry) => entry.target === "openai/gpt-4o"),
"built-in provider target must remain openai/gpt-4o"
);
});
});

View File

@@ -23,5 +23,7 @@ describe("ProxyRegistryManager (TDZ regression #5918)", () => {
const html = renderToString(React.createElement(ProxyRegistryManager));
// The heading key is rendered via the mocked translator (key echo).
expect(html).toContain("title");
expect(html).toContain("w-full border-t border-border");
expect(html).toContain("flex w-full flex-wrap items-center justify-end gap-2");
});
});

View File

@@ -1,193 +0,0 @@
// @vitest-environment jsdom
//
// Issue #9557 UI regression: the Model Overrides dashboard tab must render and
// PATCH/DELETE against the operator-configured public provider prefix
// (e.g. `vibeproxy/gpt-4o`), never the generated `openai-compatible-chat-<uuid>`
// node id. We mount the REAL ModelCapabilityOverridesTab and drive it through
// its fetch-backed data hook with a stubbed `global.fetch`.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCapabilityOverridesTab from "@/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab";
const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441";
const NODE_PREFIX = "vibeproxy";
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function render() {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ModelCapabilityOverridesTab />);
});
roots.push({ root, el });
}
function jsonResponse(body: unknown, init: { ok: boolean } = { ok: true }): Response {
return new Response(JSON.stringify(body), {
status: init.ok ? 200 : 500,
headers: { "content-type": "application/json" },
});
}
async function flush(): Promise<void> {
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
for (const { root, el } of roots.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.unstubAllGlobals();
});
describe("ModelCapabilityOverridesTab (issue #9557)", () => {
it("renders the public prefix, never the node UUID, and PATCH/DELETE use prefix/model", async () => {
const catalog = {
[NODE_ID]: {
id: NODE_ID,
alias: NODE_ID,
displayPrefix: NODE_PREFIX,
name: "VibeProxy",
authType: "unknown",
format: "openai",
models: [{ id: "gpt-4o", name: "gpt-4o" }],
},
};
const overrides: Array<{ target: string; provider: string; key: string; value: number }> = [
{
target: `${NODE_PREFIX}/gpt-4o`,
provider: NODE_PREFIX,
key: "max_output_tokens",
value: 123456,
},
];
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementation((input: any) => {
const url = String(input);
if (url.includes("/api/pricing/models")) {
return Promise.resolve(jsonResponse(catalog));
}
if (url.includes("/api/model-capability-overrides")) {
return Promise.resolve(jsonResponse({ overrides }));
}
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
});
render();
// Flush the async load.
await act(async () => {
await flush();
});
// The target label (public prefix) must be visible.
const bodyText = document.body.textContent ?? "";
expect(bodyText).toContain(`${NODE_PREFIX}/gpt-4o`);
expect(bodyText).not.toContain(NODE_ID);
// The stored override value is rendered.
expect(bodyText).toContain("123456");
// Click the Add button to PATCH a new override on the currently-selected
// (prefixed) target, then assert the request body used prefix/model.
const addButton = Array.from(document.querySelectorAll("button")).find((b) =>
(b.textContent ?? "").includes("Add key value")
);
expect(addButton).toBeTruthy();
// Value field + Add → PATCH with a new max_input_tokens value.
const valueInput = document.querySelector('input[type="number"]') as HTMLInputElement;
expect(valueInput).toBeTruthy();
// Select a different key for the new override.
const select = document.querySelector("select") as HTMLSelectElement;
act(() => {
select.value = "max_input_tokens";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
act(() => {
// React controlled inputs ignore direct `.value` writes — use the native
// descriptor so the onChange handler fires with the new value.
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(valueInput, "99999");
valueInput.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
addButton!.click();
await flush();
});
const patchCall = fetchMock.mock.calls.find(([input, init]) => {
const u = String(input);
const method = (init as RequestInit | undefined)?.method;
return u.includes("/api/model-capability-overrides") && method === "PATCH";
});
expect(patchCall).toBeTruthy();
const patchBody = JSON.parse(String(patchCall![1].body)) as { target: string };
expect(patchBody.target).toBe(`${NODE_PREFIX}/gpt-4o`);
expect(patchBody.target).not.toContain(NODE_ID);
// DELETE path: the row's Remove button must send a DELETE with prefix/model.
// Re-mock GET to return the saved override so the row renders.
const withNewOverride = [
...overrides,
{
target: `${NODE_PREFIX}/gpt-4o`,
provider: NODE_PREFIX,
key: "max_input_tokens",
value: 99999,
},
];
fetchMock.mockImplementation((input: any, init: RequestInit | undefined) => {
const url = String(input);
const method = init?.method ?? "GET";
if (url.includes("/api/pricing/models")) return Promise.resolve(jsonResponse(catalog));
if (url.includes("/api/model-capability-overrides")) {
if (method === "PATCH")
return Promise.resolve(jsonResponse({ overrides: withNewOverride }));
if (method === "DELETE")
return Promise.resolve(jsonResponse({ overrides: [withNewOverride[0]] }));
return Promise.resolve(jsonResponse({ overrides: withNewOverride }));
}
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
});
// Re-render fresh to pick up the new override list.
for (const { root, el } of roots.splice(0)) act(() => root.unmount());
render();
await act(async () => {
await flush();
});
const removeButtons = Array.from(document.querySelectorAll("button")).filter(
(b) => (b.textContent ?? "").trim() === "Remove"
);
expect(removeButtons.length).toBeGreaterThan(0);
await act(async () => {
removeButtons[0].click();
await flush();
});
const deleteCall = fetchMock.mock.calls.find(([input, init]) => {
const method = (init as RequestInit | undefined)?.method;
return method === "DELETE";
});
expect(deleteCall).toBeTruthy();
const deleteUrl = String(deleteCall![0]);
expect(deleteUrl).toContain(`target=${encodeURIComponent(`${NODE_PREFIX}/gpt-4o`)}`);
expect(deleteUrl).not.toContain(NODE_ID);
});
});