feat(dashboard): add custom CLI builder and eval suite management

Introduce a custom CLI tools card that generates OpenAI-compatible
env vars and JSON config, and add a cost overview tab with pricing
source visibility.

Add persisted custom eval suites with authenticated CRUD routes and
validation, plus new translator stream transformation tooling and
richer live monitor routing details.

Improve localization coverage across dashboard flows and add unit
tests for custom CLI config, pricing sources, eval suites, and
stream transformation.
This commit is contained in:
diegosouzapw
2026-04-23 16:30:49 -03:00
parent 9a8404b733
commit 01dd72cf1f
35 changed files with 4492 additions and 574 deletions

View File

@@ -451,14 +451,14 @@ export default function AgentsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t("agentName")}
placeholder="e.g. My Custom CLI"
placeholder={t("agentNamePlaceholder")}
value={newAgent.name}
onChange={(e) => setNewAgent({ ...newAgent, name: e.target.value })}
required
/>
<Input
label={t("binaryName")}
placeholder="e.g. mycli"
placeholder={t("binaryNamePlaceholder")}
value={newAgent.binary}
onChange={(e) => setNewAgent({ ...newAgent, binary: e.target.value })}
required
@@ -467,13 +467,13 @@ export default function AgentsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t("versionCommand")}
placeholder="e.g. mycli --version"
placeholder={t("versionCommandPlaceholder")}
value={newAgent.versionCommand}
onChange={(e) => setNewAgent({ ...newAgent, versionCommand: e.target.value })}
/>
<Input
label={t("spawnArgs")}
placeholder="e.g. --quiet, --json"
placeholder={t("spawnArgsPlaceholder")}
value={newAgent.spawnArgs}
onChange={(e) => setNewAgent({ ...newAgent, spawnArgs: e.target.value })}
/>

View File

@@ -18,6 +18,7 @@ import {
DefaultToolCard,
AntigravityToolCard,
CopilotToolCard,
CustomCliCard,
} from "./components";
import { useTranslations } from "next-intl";
@@ -41,6 +42,7 @@ const GUIDED_TOOL_IDS = new Set([
"qwen",
]);
const MITM_TOOL_IDS = new Set(["antigravity", "kiro"]);
const CUSTOM_TOOL_IDS = new Set(["custom"]);
export default function CLIToolsPageClient({ machineId: _machineId }) {
const t = useTranslations("cliTools");
@@ -246,6 +248,7 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
if (activeCategory === "auto") return AUTO_CONFIGURED_TOOL_IDS.has(toolId);
if (activeCategory === "guided") return GUIDED_TOOL_IDS.has(toolId);
if (activeCategory === "mitm") return MITM_TOOL_IDS.has(toolId);
if (activeCategory === "custom") return CUSTOM_TOOL_IDS.has(toolId);
return true;
});
@@ -344,6 +347,16 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
cloudEnabled={cloudEnabled}
/>
);
case "custom":
return (
<CustomCliCard
key={toolId}
{...commonProps}
availableModels={availableModels}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
default:
// #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls)
if (tool.configType === "mitm") {
@@ -421,6 +434,10 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
{ value: "auto", label: t("autoConfiguredTab") },
{ value: "guided", label: t("guidedClientsTab") },
{ value: "mitm", label: t("mitmClientsTab") },
{
value: "custom",
label: translateOrFallback("customCliTab", "Custom CLI"),
},
{ value: "all", label: t("allToolsTab") },
]}
value={activeCategory}

View File

@@ -0,0 +1,372 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import {
buildCustomCliEnvScript,
buildCustomCliJsonConfig,
normalizeOpenAiBaseUrl,
} from "./customCliConfig";
interface ModelOption {
value: string;
label: string;
}
interface CustomCliMappingRow {
id: string;
alias: string;
model: string;
}
export default function CustomCliCard({
tool,
isExpanded,
onToggle,
baseUrl,
apiKeys,
availableModels = [],
cloudEnabled = false,
hasActiveProviders = false,
}) {
const t = useTranslations("cliTools");
const translateOrFallback = useCallback(
(key: string, fallback: string, values?: Record<string, unknown>) => {
try {
const translated = t(key, values);
return translated === key || translated === `cliTools.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t]
);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [cliName, setCliName] = useState("Custom CLI");
const [defaultModel, setDefaultModel] = useState("");
const [selectedApiKeyId, setSelectedApiKeyId] = useState(() =>
apiKeys?.length > 0 ? apiKeys[0].id : ""
);
const [aliasMappings, setAliasMappings] = useState<CustomCliMappingRow[]>([]);
const effectiveSelectedApiKeyId = selectedApiKeyId || apiKeys?.[0]?.id || "";
const effectiveDefaultModel = defaultModel || availableModels[0]?.value || "";
const selectedKeyObj = apiKeys?.find((key) => key.id === effectiveSelectedApiKeyId);
const keyToUse =
selectedKeyObj?.key ||
(!cloudEnabled
? "sk_omniroute"
: translateOrFallback("yourApiKeyPlaceholder", "sk-your-omniroute-key"));
const baseUrlWithV1 = normalizeOpenAiBaseUrl(baseUrl || "http://localhost:20128");
const chatCompletionsEndpoint = `${baseUrlWithV1}/chat/completions`;
const envScript = useMemo(
() =>
buildCustomCliEnvScript({
cliName,
baseUrl: baseUrlWithV1,
apiKey: keyToUse,
defaultModel: effectiveDefaultModel,
aliasMappings,
}),
[aliasMappings, baseUrlWithV1, cliName, effectiveDefaultModel, keyToUse]
);
const jsonConfig = useMemo(
() =>
buildCustomCliJsonConfig({
cliName,
baseUrl: baseUrlWithV1,
apiKey: keyToUse,
defaultModel: effectiveDefaultModel,
aliasMappings,
}),
[aliasMappings, baseUrlWithV1, cliName, effectiveDefaultModel, keyToUse]
);
const handleCopy = async (text: string, field: string) => {
await copyToClipboard(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const handleAddMapping = () => {
setAliasMappings((prev) => [
...prev,
{
id: `mapping_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
alias: "",
model: effectiveDefaultModel,
},
]);
};
const handleUpdateMapping = (id: string, field: "alias" | "model", value: string) => {
setAliasMappings((prev) =>
prev.map((mapping) => (mapping.id === id ? { ...mapping, [field]: value } : mapping))
);
};
const handleRemoveMapping = (id: string) => {
setAliasMappings((prev) => prev.filter((mapping) => mapping.id !== id));
};
const codeBlockClass =
"rounded-lg border border-border bg-bg-secondary/70 p-4 text-xs font-mono whitespace-pre-wrap break-all";
return (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg flex items-center justify-center shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<span className="material-symbols-outlined text-xl">{tool.icon || "terminal"}</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
{translateOrFallback("custom", "Custom")}
</span>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span
className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}
>
expand_more
</span>
</div>
{isExpanded && (
<div className="mt-6 pt-6 border-t border-border space-y-5">
<div className="flex items-start gap-3 p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg">
<span className="material-symbols-outlined text-emerald-500 text-lg">
tips_and_updates
</span>
<div className="text-sm text-emerald-700 dark:text-emerald-300">
<p className="font-medium">
{translateOrFallback("customCliBuilderTitle", "OpenAI-compatible CLI builder")}
</p>
<p className="mt-1 text-xs opacity-90">
{translateOrFallback(
"customCliBuilderDescription",
"Generate env vars and JSON snippets for any CLI or SDK that accepts an OpenAI-compatible base URL, API key, and model ID."
)}
</p>
</div>
</div>
{!hasActiveProviders && (
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-lg">warning</span>
<div>
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
{translateOrFallback("noActiveProviders", "No active providers")}
</p>
<p className="text-xs text-yellow-700/80 dark:text-yellow-300/80">
{translateOrFallback(
"customCliNoModels",
"Connect at least one provider to populate the model selectors."
)}
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
{translateOrFallback("customCliNameLabel", "CLI name")}
</label>
<input
type="text"
value={cliName}
onChange={(e) => setCliName(e.target.value)}
placeholder={translateOrFallback("customCliNamePlaceholder", "e.g. My Team CLI")}
className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
{translateOrFallback("customCliDefaultModelLabel", "Default model")}
</label>
<select
value={effectiveDefaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
disabled={!hasActiveProviders}
className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-60"
>
<option value="">
{translateOrFallback("modelPlaceholder", "Select a model")}
</option>
{(availableModels as ModelOption[]).map((model) => (
<option key={model.value} value={model.value}>
{model.label}
</option>
))}
</select>
<p className="mt-2 text-xs text-text-muted">
{translateOrFallback(
"customCliDefaultModelHelp",
"Use any OmniRoute model ID or combo. Most OpenAI-compatible CLIs only need the /v1 base URL plus a model string."
)}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
{translateOrFallback("apiKey", "API Key")}
</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={effectiveSelectedApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.id}>
{key.key}
</option>
))}
</select>
) : (
<div className="px-3 py-2 rounded-lg border border-border bg-bg-secondary text-sm text-text-muted">
{keyToUse}
</div>
)}
<p className="mt-2 text-xs text-text-muted">
{translateOrFallback(
"customCliKeyHelper",
"For local installs OmniRoute can use sk_omniroute. In cloud mode, pick one of your management API keys."
)}
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium">
{translateOrFallback("customCliAliasMappingsLabel", "Alias mappings")}
</h4>
<p className="text-xs text-text-muted mt-1">
{translateOrFallback(
"customCliAliasMappingsHelp",
"Optional helper aliases for wrapper scripts or config files that want stable shorthand names."
)}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleAddMapping}>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
{translateOrFallback("customCliAddAlias", "Add alias")}
</Button>
</div>
{aliasMappings.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-4 text-xs text-text-muted">
{translateOrFallback(
"customCliNoMappings",
"No alias mappings yet. Add one if your wrapper or team scripts use stable short names."
)}
</div>
) : (
<div className="space-y-3">
{aliasMappings.map((mapping) => (
<div
key={mapping.id}
className="grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)_auto] gap-2"
>
<input
type="text"
value={mapping.alias}
onChange={(e) => handleUpdateMapping(mapping.id, "alias", e.target.value)}
placeholder={translateOrFallback(
"customCliAliasPlaceholder",
"e.g. review"
)}
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<select
value={mapping.model}
onChange={(e) => handleUpdateMapping(mapping.id, "model", e.target.value)}
disabled={!hasActiveProviders}
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-60"
>
<option value="">
{translateOrFallback("customCliTargetModelLabel", "Target model")}
</option>
{(availableModels as ModelOption[]).map((model) => (
<option key={model.value} value={model.value}>
{model.label}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveMapping(mapping.id)}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</Button>
</div>
))}
</div>
)}
</div>
</div>
<div className="rounded-lg border border-border/60 bg-black/[0.02] dark:bg-white/[0.02] p-3 text-xs text-text-muted">
<p className="font-medium text-text-main">
{translateOrFallback("customCliEndpointHintLabel", "How to wire the endpoint")}
</p>
<p className="mt-1">
{translateOrFallback(
"customCliEndpointHint",
"Point any OpenAI-compatible client to the OmniRoute /v1 base URL. The raw chat completions endpoint is {endpoint}. Use the JSON block when the tool wants a provider object, or the env script when it reads OPENAI_* variables.",
{ endpoint: chatCompletionsEndpoint }
)}
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">
{translateOrFallback("customCliEnvBlockTitle", "Env / shell snippet")}
</h4>
<Button variant="outline" size="sm" onClick={() => handleCopy(envScript, "env")}>
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedField === "env" ? "check" : "content_copy"}
</span>
{translateOrFallback("copy", "Copy")}
</Button>
</div>
<pre className={codeBlockClass}>{envScript}</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">
{translateOrFallback("customCliJsonBlockTitle", "Provider JSON block")}
</h4>
<Button variant="outline" size="sm" onClick={() => handleCopy(jsonConfig, "json")}>
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedField === "json" ? "check" : "content_copy"}
</span>
{translateOrFallback("copy", "Copy")}
</Button>
</div>
<pre className={codeBlockClass}>{jsonConfig}</pre>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,128 @@
export interface CustomCliAliasMapping {
alias: string;
model: string;
}
export interface CustomCliConfigInput {
cliName: string;
baseUrl: string;
apiKey: string;
defaultModel?: string;
aliasMappings?: CustomCliAliasMapping[];
}
export function normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = (baseUrl || "http://localhost:20128").trim().replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
}
export function slugifyCliCommand(cliName: string): string {
const normalized = cliName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "my-cli";
}
export function buildAliasEnvVar(alias: string): string | null {
const normalized = alias
.trim()
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
if (!normalized) return null;
return `OMNIROUTE_MODEL_${normalized}`;
}
function getValidMappings(aliasMappings: CustomCliAliasMapping[] = []): CustomCliAliasMapping[] {
return aliasMappings.filter(
(mapping) => mapping.alias.trim().length > 0 && mapping.model.trim().length > 0
);
}
export function buildCustomCliEnvScript({
cliName,
baseUrl,
apiKey,
defaultModel = "",
aliasMappings = [],
}: CustomCliConfigInput): string {
const normalizedBaseUrl = normalizeOpenAiBaseUrl(baseUrl);
const resolvedName = cliName.trim() || "Custom CLI";
const resolvedCommand = slugifyCliCommand(resolvedName);
const resolvedDefaultModel = defaultModel.trim();
const mappings = getValidMappings(aliasMappings);
const lines = [
`# ${resolvedName} -> OmniRoute (OpenAI-compatible)`,
`export OPENAI_BASE_URL="${normalizedBaseUrl}"`,
`export OPENAI_API_KEY="${apiKey}"`,
];
if (resolvedDefaultModel) {
lines.push(`export OPENAI_MODEL="${resolvedDefaultModel}"`);
}
if (mappings.length > 0) {
lines.push("", "# Optional alias mappings for wrapper scripts");
mappings.forEach((mapping) => {
const envVar = buildAliasEnvVar(mapping.alias);
if (!envVar) return;
lines.push(`export ${envVar}="${mapping.model.trim()}"`);
});
}
lines.push("", "# Raw chat completions endpoint", `# ${normalizedBaseUrl}/chat/completions`, "");
const exampleCommand = [
resolvedCommand,
'--base-url "$OPENAI_BASE_URL"',
'--api-key "$OPENAI_API_KEY"',
resolvedDefaultModel ? '--model "$OPENAI_MODEL"' : "",
]
.filter(Boolean)
.join(" ");
lines.push("# Example invocation", exampleCommand);
const firstAliasEnv = buildAliasEnvVar(mappings[0]?.alias ?? "");
if (firstAliasEnv) {
lines.push(`# Alias example: ${resolvedCommand} --model "\${${firstAliasEnv}:-$OPENAI_MODEL}"`);
}
return lines.join("\n");
}
export function buildCustomCliJsonConfig({
cliName,
baseUrl,
apiKey,
defaultModel = "",
aliasMappings = [],
}: CustomCliConfigInput): string {
const normalizedBaseUrl = normalizeOpenAiBaseUrl(baseUrl);
const resolvedName = cliName.trim() || "Custom CLI";
const resolvedDefaultModel = defaultModel.trim();
const mappings = getValidMappings(aliasMappings);
const config = {
name: resolvedName,
provider: {
type: "openai",
baseURL: normalizedBaseUrl,
apiKey,
...(resolvedDefaultModel ? { model: resolvedDefaultModel } : {}),
},
...(mappings.length > 0
? {
modelAliases: Object.fromEntries(
mappings.map((mapping) => [mapping.alias.trim(), mapping.model.trim()])
),
}
: {}),
};
return JSON.stringify(config, null, 2);
}

View File

@@ -7,3 +7,4 @@ export { default as KiloToolCard } from "./KiloToolCard";
export { default as DefaultToolCard } from "./DefaultToolCard";
export { default as AntigravityToolCard } from "./AntigravityToolCard";
export { default as CopilotToolCard } from "./CopilotToolCard";
export { default as CustomCliCard } from "./CustomCliCard";

View File

@@ -0,0 +1,501 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Card, EmptyState, SegmentedControl, CardSkeleton } from "@/shared/components";
import {
ResponsiveContainer,
PieChart,
Pie,
Cell,
Tooltip,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
type CostRange = "7d" | "30d" | "90d" | "all";
interface UsageAnalyticsSummary {
totalCost: number;
totalRequests: number;
uniqueModels: number;
uniqueAccounts: number;
uniqueApiKeys: number;
}
interface UsageAnalyticsProviderRow {
provider: string;
requests: number;
totalTokens: number;
cost: number;
}
interface UsageAnalyticsModelRow {
model: string;
requests: number;
totalTokens: number;
cost: number;
}
interface UsageAnalyticsTrendRow {
date: string;
cost: number;
}
interface UsageAnalyticsPayload {
summary: UsageAnalyticsSummary;
byProvider: UsageAnalyticsProviderRow[];
byModel: UsageAnalyticsModelRow[];
dailyTrend: UsageAnalyticsTrendRow[];
}
const RANGE_OPTIONS: Array<{ value: CostRange; labelKey: string }> = [
{ value: "7d", labelKey: "range7d" },
{ value: "30d", labelKey: "range30d" },
{ value: "90d", labelKey: "range90d" },
{ value: "all", labelKey: "rangeAll" },
];
const CHART_COLORS = [
"#10b981",
"#06b6d4",
"#f59e0b",
"#8b5cf6",
"#ef4444",
"#14b8a6",
"#6366f1",
"#ec4899",
];
function createCurrencyFormatter(locale: string) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export default function CostOverviewTab() {
const t = useTranslations("costs");
const locale = useLocale();
const currencyFormatter = useMemo(() => createCurrencyFormatter(locale), [locale]);
const [range, setRange] = useState<CostRange>("30d");
const [analytics, setAnalytics] = useState<UsageAnalyticsPayload | null>(null);
const [presetCosts, setPresetCosts] = useState<Record<"1d" | "7d" | "30d", number>>({
"1d": 0,
"7d": 0,
"30d": 0,
});
const [loading, setLoading] = useState(true);
const [summaryLoading, setSummaryLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAnalytics = useCallback(
async (requestedRange: string) => {
const response = await fetch(`/api/usage/analytics?range=${requestedRange}`);
if (!response.ok) {
throw new Error(t("overviewLoadFailed"));
}
return (await response.json()) as UsageAnalyticsPayload;
},
[t]
);
useEffect(() => {
let active = true;
async function loadRange() {
try {
setLoading(true);
const payload = await fetchAnalytics(range);
if (!active) return;
setAnalytics(payload);
setError(null);
} catch (loadError: any) {
if (!active) return;
setError(loadError?.message || t("overviewLoadFailed"));
} finally {
if (active) {
setLoading(false);
}
}
}
void loadRange();
return () => {
active = false;
};
}, [fetchAnalytics, range, t]);
useEffect(() => {
let active = true;
async function loadPresets() {
try {
setSummaryLoading(true);
const [day, week, month] = await Promise.all([
fetchAnalytics("1d"),
fetchAnalytics("7d"),
fetchAnalytics("30d"),
]);
if (!active) return;
setPresetCosts({
"1d": day.summary?.totalCost || 0,
"7d": week.summary?.totalCost || 0,
"30d": month.summary?.totalCost || 0,
});
} finally {
if (active) {
setSummaryLoading(false);
}
}
}
void loadPresets();
return () => {
active = false;
};
}, [fetchAnalytics]);
const selectedRangeLabel = t(
RANGE_OPTIONS.find((option) => option.value === range)?.labelKey || "range30d"
);
const summary = analytics?.summary || {
totalCost: 0,
totalRequests: 0,
uniqueModels: 0,
uniqueAccounts: 0,
uniqueApiKeys: 0,
};
const providersByCost = [...(analytics?.byProvider || [])]
.filter((provider) => provider.cost > 0)
.sort((left, right) => right.cost - left.cost);
const modelsByCost = [...(analytics?.byModel || [])]
.filter((model) => model.cost > 0)
.sort((left, right) => right.cost - left.cost);
const avgCostPerRequest =
summary.totalRequests > 0 ? summary.totalCost / summary.totalRequests : 0;
if (loading && !analytics) {
return <CardSkeleton />;
}
if (error && !analytics) {
return (
<Card className="p-6">
<EmptyState icon="payments" title={t("overviewTitle")} description={error} />
</Card>
);
}
return (
<div className="flex flex-col gap-6">
<Card className="p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-bold text-text-main">{t("overviewTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("overviewDescription")}</p>
</div>
<SegmentedControl
options={RANGE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
value={range}
onChange={(value) => setRange(value as CostRange)}
/>
</div>
</Card>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<MetricCard
label={t("spendToday")}
value={currencyFormatter.format(presetCosts["1d"] || 0)}
loading={summaryLoading}
color="text-emerald-400"
/>
<MetricCard
label={t("spend7d")}
value={currencyFormatter.format(presetCosts["7d"] || 0)}
loading={summaryLoading}
color="text-sky-400"
/>
<MetricCard
label={t("spend30d")}
value={currencyFormatter.format(presetCosts["30d"] || 0)}
loading={summaryLoading}
color="text-violet-400"
/>
<MetricCard
label={t("selectedWindow")}
value={currencyFormatter.format(summary.totalCost || 0)}
subValue={selectedRangeLabel}
color="text-amber-400"
/>
</div>
<Card className="p-5">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<CompactMetric
label={t("requestsInWindow")}
value={new Intl.NumberFormat(locale).format(summary.totalRequests || 0)}
/>
<CompactMetric
label={t("activeProviders")}
value={new Intl.NumberFormat(locale).format(providersByCost.length)}
/>
<CompactMetric
label={t("activeModels")}
value={new Intl.NumberFormat(locale).format(summary.uniqueModels || 0)}
/>
<CompactMetric
label={t("avgCostPerRequest")}
value={currencyFormatter.format(avgCostPerRequest)}
/>
</div>
</Card>
{summary.totalCost <= 0 ? (
<Card className="p-6">
<EmptyState
icon="payments"
title={t("noCostDataTitle")}
description={t("noCostDataDescription")}
/>
</Card>
) : (
<>
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
<CostTrendCard
title={t("costTrend")}
rows={analytics?.dailyTrend || []}
locale={locale}
/>
<ProviderSpendCard title={t("providerShare")} rows={providersByCost} locale={locale} />
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TopListCard
title={t("topProviders")}
nameKey="provider"
valueKey="cost"
rows={providersByCost}
locale={locale}
/>
<TopListCard
title={t("topModels")}
nameKey="model"
valueKey="cost"
rows={modelsByCost}
locale={locale}
/>
</div>
</>
)}
</div>
);
}
function MetricCard({
label,
value,
subValue,
color = "text-text-main",
loading = false,
}: {
label: string;
value: string;
subValue?: string;
color?: string;
loading?: boolean;
}) {
return (
<Card className="px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">{label}</p>
<p className={`text-2xl font-bold mt-1 ${color}`}>{loading ? "…" : value}</p>
{subValue ? <p className="text-xs text-text-muted mt-1">{subValue}</p> : null}
</Card>
);
}
function CompactMetric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">{label}</p>
<p className="text-lg font-semibold text-text-main mt-1">{value}</p>
</div>
);
}
function ProviderSpendCard({
title,
rows,
locale,
}: {
title: string;
rows: UsageAnalyticsProviderRow[];
locale: string;
}) {
const currencyFormatter = createCurrencyFormatter(locale);
const chartRows = rows.slice(0, 6).map((row, index) => ({
name: row.provider,
value: row.cost,
fill: CHART_COLORS[index % CHART_COLORS.length],
}));
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{title}
</h3>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="w-full md:w-[180px] h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartRows}
dataKey="value"
nameKey="name"
innerRadius={45}
outerRadius={72}
paddingAngle={2}
>
{chartRows.map((entry) => (
<Cell key={entry.name} fill={entry.fill} stroke="none" />
))}
</Pie>
<Tooltip
formatter={(value: number) => currencyFormatter.format(value || 0)}
contentStyle={{
background: "var(--surface)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "12px",
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-2">
{chartRows.map((row) => (
<div key={row.name} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.fill }}
/>
<span className="truncate text-text-main">{row.name}</span>
</div>
<span className="font-mono text-text-muted">
{currencyFormatter.format(row.value)}
</span>
</div>
))}
</div>
</div>
</Card>
);
}
function CostTrendCard({
title,
rows,
locale,
}: {
title: string;
rows: UsageAnalyticsTrendRow[];
locale: string;
}) {
const currencyFormatter = createCurrencyFormatter(locale);
const chartRows = rows.map((row) => ({
date: row.date.slice(5),
cost: row.cost || 0,
}));
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{title}
</h3>
<div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartRows} margin={{ top: 5, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.06)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
interval={Math.max(Math.floor(chartRows.length / 8), 0)}
/>
<YAxis
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
tickFormatter={(value) => currencyFormatter.format(value).replace(".00", "")}
width={48}
/>
<Tooltip
formatter={(value: number) => currencyFormatter.format(value || 0)}
contentStyle={{
background: "var(--surface)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "12px",
}}
/>
<Line
type="monotone"
dataKey="cost"
stroke="#10b981"
strokeWidth={2.5}
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card>
);
}
function TopListCard({
title,
rows,
nameKey,
valueKey,
locale,
}: {
title: string;
rows: Array<Record<string, string | number>>;
nameKey: string;
valueKey: string;
locale: string;
}) {
const currencyFormatter = createCurrencyFormatter(locale);
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{title}
</h3>
<div className="space-y-2">
{rows.slice(0, 6).map((row) => (
<div
key={String(row[nameKey])}
className="flex items-center justify-between gap-3 rounded-lg border border-border/20 bg-surface/20 px-4 py-3"
>
<span className="text-sm text-text-main truncate">{String(row[nameKey])}</span>
<span className="text-sm font-mono text-text-muted">
{currencyFormatter.format(Number(row[valueKey] || 0))}
</span>
</div>
))}
</div>
</Card>
);
}

View File

@@ -4,17 +4,27 @@ import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import BudgetTab from "../usage/components/BudgetTab";
import PricingTab from "../settings/components/PricingTab";
import CostOverviewTab from "./CostOverviewTab";
import { useTranslations } from "next-intl";
export default function CostsPage() {
const [activeTab, setActiveTab] = useState("budget");
const [activeTab, setActiveTab] = useState("overview");
const t = useTranslations("costs");
const ts = useTranslations("settings");
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">payments</span>
{t("title")}
</h1>
<p className="text-sm text-text-muted mt-1">{t("pageDescription")}</p>
</div>
<SegmentedControl
options={[
{ value: "overview", label: t("overview") },
{ value: "budget", label: t("budget") },
{ value: "pricing", label: ts("pricing") },
]}
@@ -22,6 +32,7 @@ export default function CostsPage() {
onChange={setActiveTab}
/>
{activeTab === "overview" && <CostOverviewTab />}
{activeTab === "budget" && <BudgetTab />}
{activeTab === "pricing" && <PricingTab />}
</div>

View File

@@ -576,8 +576,6 @@ interface EditCompatibleNodeModalProps {
isCcCompatible?: boolean;
}
const CC_COMPATIBLE_LABEL = "CC Compatible";
const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details";
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
const CODEX_REASONING_STRENGTH_OPTIONS = [
{ value: "none", label: "None" },
@@ -880,7 +878,7 @@ function ModelCompatPopover({
onChange={(e) => updateHeaderRow(row.id, { name: e.target.value })}
onBlur={onHeaderFieldBlur}
disabled={disabled}
placeholder="Authentication"
placeholder={t("compatUpstreamHeaderNamePlaceholder")}
className="gap-0 min-w-0"
inputClassName="h-9 bg-white py-1.5 px-2 text-xs font-mono dark:bg-zinc-900"
autoComplete="off"
@@ -906,7 +904,7 @@ function ModelCompatPopover({
onHeaderFieldBlur();
}}
disabled={disabled}
placeholder="•••"
placeholder={t("compatUpstreamHeaderValuePlaceholder")}
className="gap-0 min-w-0"
inputClassName="h-9 bg-white py-1.5 px-2 text-xs dark:bg-zinc-900"
autoComplete="off"
@@ -1005,7 +1003,7 @@ export default function ProviderDetailPage() {
const providerInfo = resolveDashboardProviderInfo(providerId, {
providerNode,
compatibleLabels: {
ccCompatibleName: CC_COMPATIBLE_LABEL,
ccCompatibleName: t("ccCompatibleLabel"),
anthropicCompatibleName: t("anthropicCompatibleName"),
openAiCompatibleName: t("openaiCompatibleName"),
},
@@ -2359,7 +2357,7 @@ export default function ProviderDetailPage() {
providerId === "openrouter"
? t("openRouterAnyModelHint")
: isCcCompatible
? "CC Compatible available models mirror the OAuth Claude Code provider list."
? t("ccCompatibleModelsDescription")
: t("compatibleModelsDescription", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
});
@@ -2665,7 +2663,7 @@ export default function ProviderDetailPage() {
<div>
<h2 className="text-lg font-semibold">
{isCcCompatible
? CC_COMPATIBLE_DETAILS_TITLE
? t("ccCompatibleDetailsTitle")
: isAnthropicCompatible
? t("anthropicCompatibleDetails")
: t("openaiCompatibleDetails")}
@@ -2700,7 +2698,7 @@ export default function ProviderDetailPage() {
!confirm(
t("deleteCompatibleNodeConfirm", {
type: isCcCompatible
? CC_COMPATIBLE_LABEL
? t("ccCompatibleLabel")
: isAnthropicCompatible
? t("anthropic")
: t("openai"),
@@ -3045,12 +3043,8 @@ export default function ProviderDetailPage() {
<Card>
<div className="flex flex-col gap-3">
<div>
<h2 className="text-lg font-semibold">Managed via Upstream Proxy Settings</h2>
<p className="text-sm text-text-muted mt-1">
CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider
connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each
provider via the provider proxy controls.
</p>
<h2 className="text-lg font-semibold">{t("upstreamProxyManagedTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("upstreamProxyManagedDescription")}</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
@@ -3058,14 +3052,14 @@ export default function ProviderDetailPage() {
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text-main hover:border-primary/40 hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">terminal</span>
Open CLI Tools
{t("openCliTools")}
</Link>
<Link
href="/dashboard/settings"
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text-main hover:border-primary/40 hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">settings</span>
Open Settings
{t("openSettings")}
</Link>
</div>
</div>
@@ -3094,41 +3088,24 @@ export default function ProviderDetailPage() {
{/* Search provider info */}
{isSearchProvider && (
<Card>
<h2 className="text-lg font-semibold mb-4">{t("searchProvider") || "Search Provider"}</h2>
<p className="text-sm text-text-muted">
{t("searchProviderDesc") ||
"This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."}
</p>
<h2 className="text-lg font-semibold mb-4">{t("searchProvider")}</h2>
<p className="text-sm text-text-muted">{t("searchProviderDesc")}</p>
{providerId === "perplexity-search" && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
<span className="material-symbols-outlined text-sm text-blue-400">link</span>
<p className="text-xs text-blue-300">
Uses the same API key as <strong>Perplexity</strong> (chat provider). If you already
have Perplexity configured, no additional setup is needed.
</p>
<p className="text-xs text-blue-300">{t("perplexitySearchSharedKeyInfo")}</p>
</div>
)}
{providerId === "google-pse-search" && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<span className="material-symbols-outlined text-sm text-amber-300">tune</span>
<p className="text-xs text-amber-200">
Google Programmable Search requires two values: your API key and the Search Engine
ID (<strong>cx</strong>) from the Programmable Search Engine dashboard.
</p>
<p className="text-xs text-amber-200">{t("googlePseInfo")}</p>
</div>
)}
{providerId === "searxng-search" && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
<span className="material-symbols-outlined text-sm text-emerald-300">dns</span>
<p className="text-xs text-emerald-200">
SearXNG is self-hosted. Configure the instance base URL here. API key is optional
and can be left blank for public or unauthenticated instances. Local/private URL
validation requires{" "}
<code className="rounded bg-black/20 px-1 py-0.5">
OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true
</code>
.
</p>
<p className="text-xs text-emerald-200">{t("searxngInfo")}</p>
</div>
)}
</Card>
@@ -3210,7 +3187,7 @@ export default function ProviderDetailPage() {
<button
onClick={() => setBatchTestResults(null)}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Close"
aria-label={t("close")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -3405,7 +3382,7 @@ export default function ProviderDetailPage() {
onClick={() => setShowImportModal(false)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:opacity-90 transition-opacity"
>
{t("close") || "Close"}
{t("close")}
</button>
</div>
)}
@@ -4119,7 +4096,9 @@ function CustomModelsSection({
</select>
</div>
<div className="flex-1">
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
<span className="text-xs text-text-muted mb-1 block">
{t("supportedEndpointsLabel")}
</span>
<div className="flex items-center gap-3">
{["chat", "embeddings", "images", "audio"].map((ep) => (
<label
@@ -4139,12 +4118,12 @@ function CustomModelsSection({
className="rounded border-border"
/>
{ep === "chat"
? "💬 Chat"
? `💬 ${t("supportedEndpointChat")}`
: ep === "embeddings"
? "📐 Embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "images"
? "🖼️ Images"
: "🔊 Audio"}
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}
</label>
))}
</div>
@@ -4187,22 +4166,22 @@ function CustomModelsSection({
</button>
{model.apiFormat === "responses" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 font-medium">
Responses
{t("responses")}
</span>
)}
{model.supportedEndpoints?.includes("embeddings") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/15 text-purple-400 font-medium">
📐 Embed
{`📐 ${t("supportedEndpointEmbeddings")}`}
</span>
)}
{model.supportedEndpoints?.includes("images") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-400 font-medium">
🖼 Images
{`🖼️ ${t("imagesShortLabel")}`}
</span>
)}
{model.supportedEndpoints?.includes("audio") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
🔊 Audio
{`🔊 ${t("audioShortLabel")}`}
</span>
)}
{anyNormalizeCompatBadge(model.id, customMap, overrideMap) && (
@@ -4235,7 +4214,9 @@ function CustomModelsSection({
<div className="mt-3 min-w-0 max-w-full rounded-lg border border-border bg-muted p-3 dark:bg-zinc-900">
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-2">
<div className="w-[11rem] shrink-0 min-w-0">
<label className="text-xs text-text-muted mb-1 block">API Format</label>
<label className="text-xs text-text-muted mb-1 block">
{t("apiFormatLabel")}
</label>
<select
value={editingApiFormat}
onChange={(e) => setEditingApiFormat(e.target.value)}
@@ -4251,7 +4232,7 @@ function CustomModelsSection({
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
<span className="text-xs text-text-muted shrink-0">
Supported Endpoints
{t("supportedEndpointsLabel")}
</span>
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 min-w-0">
{["chat", "embeddings", "images", "audio"].map((ep) => (
@@ -4274,12 +4255,12 @@ function CustomModelsSection({
className="rounded border-border"
/>
{ep === "chat"
? "💬 Chat"
? `💬 ${t("supportedEndpointChat")}`
: ep === "embeddings"
? "📐 Embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "images"
? "🖼️ Images"
: "🔊 Audio"}
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}
</label>
))}
</div>
@@ -5048,15 +5029,15 @@ function ConnectionRow({
(tokenMinsLeft < 0 ? (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-red-500/15 text-red-500"
title={`Token expired: ${effectiveExpiresAt}`}
title={t("tokenExpiredTitle", { date: effectiveExpiresAt })}
>
<span className="material-symbols-outlined text-[11px]">error</span>
expired
{t("tokenExpiredBadge")}
</span>
) : tokenMinsLeft < 30 ? (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/15 text-amber-500"
title={`Token expires in ${tokenMinsLeft}m`}
title={t("tokenExpiresSoonTitle", { minutes: tokenMinsLeft })}
>
<span className="material-symbols-outlined text-[11px]">warning</span>
{`~${tokenMinsLeft}m`}
@@ -5110,10 +5091,11 @@ function ConnectionRow({
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Claude extra-usage blocking"
title={t("claudeExtraUsageToggleTitle")}
>
<span className="material-symbols-outlined text-[13px]">payments</span>
Block Extra {claudeBlockExtraUsageEnabled ? "ON" : "OFF"}
{t("claudeExtraUsageShort")}{" "}
{claudeBlockExtraUsageEnabled ? t("toggleOnShort") : t("toggleOffShort")}
</button>
</>
)}
@@ -5127,14 +5109,10 @@ function ConnectionRow({
? "bg-indigo-500/15 text-indigo-500 hover:bg-indigo-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title={
cliproxyapiDeepMode
? "Using CLIProxyAPI for deeper Claude Code emulation (uTLS, multi-account, device profiles)"
: "Enable CLIProxyAPI backend for deeper Claude Code OAuth emulation"
}
title={cliproxyapiDeepMode ? t("cpaModeEnabledTitle") : t("cpaModeDisabledTitle")}
>
<span className="material-symbols-outlined text-[13px]">swap_horiz</span>
CPA {cliproxyapiDeepMode ? "ON" : "OFF"}
CPA {cliproxyapiDeepMode ? t("toggleOnShort") : t("toggleOffShort")}
</button>
</>
)}
@@ -5148,10 +5126,10 @@ function ConnectionRow({
? "bg-blue-500/15 text-blue-500 hover:bg-blue-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Codex 5h limit policy"
title={t("codex5hToggleTitle")}
>
<span className="material-symbols-outlined text-[13px]">timer</span>
5h {codex5hEnabled ? "ON" : "OFF"}
5h {codex5hEnabled ? t("toggleOnShort") : t("toggleOffShort")}
</button>
<button
onClick={() => onToggleCodexWeekly?.(!codexWeeklyEnabled)}
@@ -5160,10 +5138,10 @@ function ConnectionRow({
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Codex weekly limit policy"
title={t("codexWeeklyToggleTitle")}
>
<span className="material-symbols-outlined text-[13px]">date_range</span>
Weekly {codexWeeklyEnabled ? "ON" : "OFF"}
{t("weeklyShort")} {codexWeeklyEnabled ? t("toggleOnShort") : t("toggleOffShort")}
</button>
</>
)}
@@ -5223,9 +5201,9 @@ function ConnectionRow({
disabled={connection.isActive === false || isRefreshing}
onClick={onRefreshToken}
className="!h-7 !px-2 text-xs text-amber-500 hover:text-amber-400"
title="Refresh OAuth token manually"
title={t("refreshOauthTokenTitle")}
>
Token
{t("tokenShort")}
</Button>
)}
{isCodex && onApplyCodexAuthLocal && (
@@ -5364,22 +5342,22 @@ function getProviderBaseUrlDefault(providerId?: string | null) {
return providerId ? DEFAULT_PROVIDER_BASE_URLS[providerId] || "" : "";
}
function getProviderBaseUrlHint(providerId?: string | null) {
function getProviderBaseUrlHint(providerId?: string | null, t?: (key: string) => string) {
switch (providerId) {
case "azure-openai":
return "Required: paste your Azure OpenAI resource endpoint. OmniRoute will append /openai/deployments/{model}/chat/completions?api-version=....";
return t ? t("azureOpenAiBaseUrlHint") : undefined;
case "bailian-coding-plan":
return "Optional: Custom base URL for bailian-coding-plan provider";
return t ? t("bailianBaseUrlHint") : undefined;
case "xiaomi-mimo":
return "Optional: Xiaomi MiMo token-plan base URL. Examples: https://token-plan-ams.xiaomimimo.com/v1, https://token-plan-sgp.xiaomimimo.com/v1, https://token-plan-cn.xiaomimimo.com/v1. The app will append /chat/completions.";
return t ? t("xiaomiMimoBaseUrlHint") : undefined;
case "heroku":
return "Required: paste the Heroku Inference base URL. The app will append /v1/chat/completions.";
return t ? t("herokuBaseUrlHint") : undefined;
case "databricks":
return "Required: paste the Databricks serving-endpoints base URL. The app will append /chat/completions.";
return t ? t("databricksBaseUrlHint") : undefined;
case "snowflake":
return "Required: paste the Snowflake account base URL. The app will append /api/v2/cortex/inference:complete.";
return t ? t("snowflakeBaseUrlHint") : undefined;
case "searxng-search":
return "Required: paste your SearXNG instance base URL. The app will use /search and request format=json. Local/private URLs require OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true for dashboard validation.";
return t ? t("searxngBaseUrlHint") : undefined;
default:
return undefined;
}
@@ -5494,6 +5472,41 @@ function AddApiKeyModal({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const apiCredentialLabel = isQoder
? t("personalAccessTokenLabel")
: isWebSessionProvider
? t("sessionCookieLabel")
: isSearxng
? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})`
: t("apiKeyLabel");
const apiCredentialPlaceholder = isVertex
? t("vertexServiceAccountPlaceholder")
: isGrokWeb
? t("grokWebCookiePlaceholder")
: isPerplexityWeb
? t("perplexityWebCookiePlaceholder")
: isBlackboxWeb
? t("blackboxWebCookiePlaceholder")
: isMuseSparkWeb
? t("museSparkWebCookiePlaceholder")
: isQoder
? t("qoderPatPlaceholder")
: isSearxng
? t("optional")
: undefined;
const apiCredentialHint = isQoder
? t("qoderPatHint")
: isGrokWeb
? t("grokWebCookieHint")
: isPerplexityWeb
? t("perplexityWebCookieHint")
: isBlackboxWeb
? t("blackboxWebCookieHint")
: isMuseSparkWeb
? t("museSparkWebCookieHint")
: isSearxng
? t("apiKeyOptionalHint")
: undefined;
const handleValidate = async () => {
setValidating(true);
@@ -5527,7 +5540,7 @@ function AddApiKeyModal({
setSaveError(null);
try {
if (isGooglePse && !formData.cx.trim()) {
setSaveError("Programmable Search Engine ID (cx) is required");
setSaveError(t("searchEngineIdRequired"));
return;
}
@@ -5634,55 +5647,17 @@ function AddApiKeyModal({
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isQoder ? "Qoder PAT" : t("productionKey")}
placeholder={isQoder ? t("personalAccessTokenLabel") : t("productionKey")}
/>
<div className="flex gap-2">
<Input
label={
isQoder
? "Personal Access Token"
: isWebSessionProvider
? "Session Cookie"
: isSearxng
? "API Key (optional)"
: t("apiKeyLabel")
}
label={apiCredentialLabel}
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
placeholder={
isVertex
? "Cole o Service Account JSON aqui"
: isGrokWeb
? "Paste your sso cookie value from grok.com"
: isPerplexityWeb
? "Paste your __Secure-next-auth.session-token value"
: isBlackboxWeb
? "Paste your __Secure-authjs.session-token value"
: isMuseSparkWeb
? "Paste your abra_sess value"
: isSearxng
? "Optional"
: isQoder
? "Paste your Qoder Personal Access Token"
: undefined
}
hint={
isQoder
? "Supported path: PAT via qodercli. Browser OAuth remains experimental."
: isGrokWeb
? "Paste the sso cookie from grok.com. A full 'sso=...' value also works."
: isPerplexityWeb
? "Paste the __Secure-next-auth.session-token cookie from perplexity.ai."
: isBlackboxWeb
? "Paste the __Secure-authjs.session-token cookie from app.blackbox.ai. A full cookie header also works."
: isMuseSparkWeb
? "Paste the abra_sess cookie from meta.ai. A full cookie header also works."
: isSearxng
? "Optional. Leave blank if your SearXNG instance does not require authentication."
: undefined
}
placeholder={apiCredentialPlaceholder}
hint={apiCredentialHint}
/>
<div className="pt-6">
<Button
@@ -5701,11 +5676,11 @@ function AddApiKeyModal({
</div>
{isGooglePse && (
<Input
label="Search Engine ID (cx)"
label={t("searchEngineIdLabel")}
value={formData.cx}
onChange={(e) => setFormData({ ...formData, cx: e.target.value })}
placeholder="012345678901234567890:abc123xyz"
hint="Required. Find this in your Programmable Search Engine overview."
hint={t("searchEngineIdHint")}
/>
)}
{validationResult && (
@@ -5723,15 +5698,15 @@ function AddApiKeyModal({
<Toggle
checked={formData.ccCompatibleContext1m}
onChange={(checked) => setFormData({ ...formData, ccCompatibleContext1m: checked })}
label="CC Compatible 1M Context"
description="When enabled, this connection appends `anthropic-beta: context-1m-2025-08-07`."
label={t("ccCompatibleContext1mLabel")}
description={t("ccCompatibleContext1mDescription")}
/>
</div>
)}
{isCompatible && (
<p className="text-xs text-text-muted">
{isCcCompatible
? "Validation uses the strict Claude Code-compatible bridge request for this provider."
? t("ccCompatibleValidationHint")
: isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
@@ -5762,25 +5737,25 @@ function AddApiKeyModal({
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
>
<Input
label="Custom User-Agent"
label={t("customUserAgentLabel")}
value={formData.customUserAgent}
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
placeholder="my-app/1.0"
hint="Optional override sent upstream as the User-Agent header for this connection"
hint={t("customUserAgentHint")}
/>
<Input
label="Routing Tags"
label={t("routingTagsLabel")}
value={formData.routingTags}
onChange={(e) => setFormData({ ...formData, routingTags: e.target.value })}
placeholder="fast, cheap, eu-region"
hint="Comma-separated tags matched against request metadata.tags for tag-based routing"
placeholder={t("routingTagsPlaceholder")}
hint={t("routingTagsHint")}
/>
<Input
label="Excluded Models"
label={t("excludedModelsLabel")}
value={formData.excludedModels}
onChange={(e) => setFormData({ ...formData, excludedModels: e.target.value })}
placeholder="gpt-5*, claude-opus-*, gemini-*-pro*"
hint="Comma-separated wildcard patterns. This connection will never serve matching models."
placeholder={t("excludedModelsPlaceholder")}
hint={t("excludedModelsHint")}
/>
<Toggle
size="sm"
@@ -5791,22 +5766,22 @@ function AddApiKeyModal({
/>
{provider === "bailian-coding-plan" && (
<Input
label="Console API Key (Oracle)"
label={t("consoleApiKeyOracleLabel")}
value={formData.consoleApiKey}
onChange={(e) => setFormData({ ...formData, consoleApiKey: e.target.value })}
placeholder="Alibaba Console API Key"
hint="Required for quota fetching. Do not share."
placeholder={t("consoleApiKeyOraclePlaceholder")}
hint={t("consoleApiKeyOracleHint")}
type="password"
/>
)}
</div>
)}
<Input
label="Model ID (opcional)"
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
label={t("validationModelIdLabel")}
placeholder={t("validationModelIdPlaceholder")}
value={formData.validationModelId}
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
hint="Usado como fallback se a listagem de models não estiver disponível"
hint={t("validationModelIdHint")}
/>
<Input
label={t("priorityLabel")}
@@ -5818,45 +5793,45 @@ function AddApiKeyModal({
/>
{usesBaseUrl && (
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={getProviderBaseUrlPlaceholder(provider)}
hint={getProviderBaseUrlHint(provider)}
hint={getProviderBaseUrlHint(provider, t)}
/>
)}
{isVertex && (
<Input
label="Região (Region)"
label={t("regionLabel")}
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder={defaultRegion}
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
hint={t("regionHint")}
/>
)}
{isCloudflare && (
<Input
label="Account ID"
label={t("accountIdLabel")}
value={formData.accountId}
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
placeholder="Cloudflare Account ID"
hint="Find it in the Cloudflare dashboard URL or settings"
placeholder={t("accountIdPlaceholder")}
hint={t("accountIdHint")}
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">API Region</label>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">International (api.z.ai)</option>
<option value="china">China Mainland (open.bigmodel.cn)</option>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">
Select the endpoint region for API access and quota tracking.
</p>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
</div>
)}
<div className="flex gap-2">
@@ -6088,7 +6063,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
if (trimmedMaxConcurrent) {
const numericMaxConcurrent = Number(trimmedMaxConcurrent);
if (!Number.isInteger(numericMaxConcurrent) || numericMaxConcurrent < 0) {
setSaveError("Max concurrent must be a whole number greater than or equal to 0.");
setSaveError(t("maxConcurrentWholeNumberError"));
return;
}
parsedMaxConcurrent = numericMaxConcurrent;
@@ -6102,7 +6077,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
};
if (isGooglePse && !formData.cx.trim()) {
setSaveError("Programmable Search Engine ID (cx) is required");
setSaveError(t("searchEngineIdRequired"));
return;
}
@@ -6253,46 +6228,46 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
placeholder={isOAuth ? t("accountName") : t("productionKey")}
/>
<Input
label="Tag / Group"
label={t("tagGroupLabel")}
value={formData.tag}
onChange={(e) => setFormData({ ...formData, tag: e.target.value })}
placeholder="e.g. personal, work, team-a"
hint="Used to group accounts in the provider view"
placeholder={t("tagGroupPlaceholder")}
hint={t("tagGroupHint")}
/>
<Input
label="Routing Tags"
label={t("routingTagsLabel")}
value={formData.routingTags}
onChange={(e) => setFormData({ ...formData, routingTags: e.target.value })}
placeholder="fast, cheap, eu-region"
hint="Comma-separated tags matched against request metadata.tags for tag-based routing"
placeholder={t("routingTagsPlaceholder")}
hint={t("routingTagsHint")}
/>
<Input
label="Excluded Models"
label={t("excludedModelsLabel")}
value={formData.excludedModels}
onChange={(e) => setFormData({ ...formData, excludedModels: e.target.value })}
placeholder="gpt-5*, claude-opus-*, gemini-*-pro*"
hint="Comma-separated wildcard patterns. This connection will be skipped for matching models."
placeholder={t("excludedModelsPlaceholder")}
hint={t("excludedModelsHint")}
/>
{isCodex && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Select
label="Default thinking strength"
label={t("defaultThinkingStrengthLabel")}
value={formData.codexReasoningEffort}
options={CODEX_REASONING_STRENGTH_OPTIONS}
onChange={(e) => setFormData({ ...formData, codexReasoningEffort: e.target.value })}
hint="Used when the client does not send a reasoning effort and the global Thinking Budget mode is passthrough."
hint={t("defaultThinkingStrengthHint")}
/>
<Toggle
checked={formData.codexFastServiceTier}
onChange={(checked) => setFormData({ ...formData, codexFastServiceTier: checked })}
label="Codex Fast Service Tier"
description="When enabled, injects `service_tier=priority` for this connection if the client leaves the tier unset."
label={t("codexFastServiceTierLabel")}
description={t("codexFastServiceTierDescription")}
/>
<Toggle
checked={formData.codexOpenaiStoreEnabled}
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
label="OpenAI Responses Store"
description="Preserves `store`, `previous_response_id`, and adds a stable fallback `session_id` for long Codex sessions. Enable only when the upstream account accepts stored Responses."
label={t("openaiResponsesStoreLabel")}
description={t("openaiResponsesStoreDescription")}
/>
</div>
)}
@@ -6301,8 +6276,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
<Toggle
checked={formData.blockExtraUsage}
onChange={(checked) => setFormData({ ...formData, blockExtraUsage: checked })}
label="Block Claude Extra Usage"
description="When enabled, OmniRoute marks this Claude Code account unavailable as soon as the usage API reports `extra_usage.queued`, so fallback switches to another account before extra pay-as-you-go charges continue."
label={t("blockClaudeExtraUsageLabel")}
description={t("blockClaudeExtraUsageDescription")}
/>
</div>
)}
@@ -6311,8 +6286,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
<Toggle
checked={formData.ccCompatibleContext1m}
onChange={(checked) => setFormData({ ...formData, ccCompatibleContext1m: checked })}
label="CC Compatible 1M Context"
description="When enabled, this connection appends `anthropic-beta: context-1m-2025-08-07`."
label={t("ccCompatibleContext1mLabel")}
description={t("ccCompatibleContext1mDescription")}
/>
</div>
)}
@@ -6327,7 +6302,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
type="button"
onClick={toggleShowEmail}
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary"
title={showEmail ? "Hide email" : "Show email"}
title={showEmail ? t("hideEmail") : t("showEmail")}
>
<span className="material-symbols-outlined text-sm">
{showEmail ? "visibility_off" : "visibility"}
@@ -6386,16 +6361,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
<>
<div className="flex gap-2">
<Input
label={isSearxng ? "API Key (optional)" : t("apiKeyLabel")}
label={isSearxng ? t("apiKeyOptionalLabel") : t("apiKeyLabel")}
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder={isVertex ? "Cole o Service Account JSON aqui" : t("enterNewApiKey")}
hint={
isSearxng
? "Optional. Leave blank to keep the current key or when the instance does not require authentication."
: t("leaveBlankKeepCurrentApiKey")
}
placeholder={isVertex ? t("vertexServiceAccountPlaceholder") : t("enterNewApiKey")}
hint={isSearxng ? t("apiKeyOptionalHint") : t("leaveBlankKeepCurrentApiKey")}
className="flex-1"
/>
<div className="pt-6">
@@ -6415,11 +6386,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
</div>
{isGooglePse && (
<Input
label="Search Engine ID (cx)"
label={t("searchEngineIdLabel")}
value={formData.cx}
onChange={(e) => setFormData({ ...formData, cx: e.target.value })}
placeholder="012345678901234567890:abc123xyz"
hint="Required. Find this in your Programmable Search Engine overview."
hint={t("searchEngineIdHint")}
/>
)}
{validationResult && (
@@ -6448,11 +6419,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
>
<Input
label="Custom User-Agent"
label={t("customUserAgentLabel")}
value={formData.customUserAgent}
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
placeholder="my-app/1.0"
hint="Optional override sent upstream as the User-Agent header for this connection"
hint={t("customUserAgentHint")}
/>
<Toggle
size="sm"
@@ -6463,70 +6434,70 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
/>
{connection.provider === "bailian-coding-plan" && (
<Input
label="Console API Key (Oracle)"
label={t("consoleApiKeyOracleLabel")}
value={formData.consoleApiKey}
onChange={(e) => setFormData({ ...formData, consoleApiKey: e.target.value })}
placeholder="Alibaba Console API Key"
hint="Required for quota fetching. Do not share."
placeholder={t("consoleApiKeyOraclePlaceholder")}
hint={t("consoleApiKeyOracleHint")}
type="password"
/>
)}
</div>
)}
<Input
label="Model ID (opcional)"
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
label={t("validationModelIdLabel")}
placeholder={t("validationModelIdPlaceholder")}
value={formData.validationModelId}
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
hint="Usado como fallback se a listagem de models não estiver disponível"
hint={t("validationModelIdHint")}
/>
</>
)}
{usesBaseUrl && (
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={getProviderBaseUrlPlaceholder(connection.provider)}
hint={getProviderBaseUrlHint(connection.provider)}
hint={getProviderBaseUrlHint(connection.provider, t)}
/>
)}
{isVertex && (
<Input
label="Região (Region)"
label={t("regionLabel")}
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder={defaultRegion}
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
hint={t("regionHint")}
/>
)}
{isCloudflare && (
<Input
label="Account ID"
label={t("accountIdLabel")}
value={formData.accountId}
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
placeholder="Cloudflare Account ID"
hint="Find it in the Cloudflare dashboard URL or settings"
placeholder={t("accountIdPlaceholder")}
hint={t("accountIdHint")}
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">API Region</label>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">International (api.z.ai)</option>
<option value="china">China Mainland (open.bigmodel.cn)</option>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">
Select the endpoint region for API access and quota tracking.
</p>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
</div>
)}
@@ -6534,9 +6505,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
{!isOAuth && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-main">
Extra API Keys
{t("extraApiKeysLabel")}
<span className="ml-2 text-[11px] font-normal text-text-muted">
(round-robin rotation optional)
({t("extraApiKeysHint")})
</span>
</label>
{extraApiKeys.length > 0 && (
@@ -6544,12 +6515,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
{extraApiKeys.map((key, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate">
{`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`}
{t("extraApiKeyMasked", {
index: idx + 2,
prefix: key.slice(0, 6),
suffix: key.slice(-4),
})}
</span>
<button
onClick={() => setExtraApiKeys(extraApiKeys.filter((_, i) => i !== idx))}
className="p-1.5 rounded hover:bg-red-500/10 text-red-400 hover:text-red-500"
title="Remove this key"
title={t("removeThisKey")}
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
@@ -6562,7 +6537,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
type="password"
value={newExtraKey}
onChange={(e) => setNewExtraKey(e.target.value)}
placeholder="Add another API key..."
placeholder={t("addAnotherApiKey")}
className="flex-1 text-sm bg-sidebar/50 border border-border rounded px-3 py-2 text-text-main placeholder:text-text-muted focus:ring-1 focus:ring-primary outline-none"
onKeyDown={(e) => {
if (e.key === "Enter" && newExtraKey.trim()) {
@@ -6581,12 +6556,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
disabled={!newExtraKey.trim()}
className="px-3 py-2 rounded bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 text-sm font-medium"
>
Add
{t("add")}
</button>
</div>
{extraApiKeys.length > 0 && (
<p className="text-[11px] text-text-muted">
{extraApiKeys.length + 1} keys total rotating round-robin on each request.
{t("totalKeysRotating", { count: extraApiKeys.length + 1 })}
</p>
)}
</div>
@@ -6751,37 +6726,37 @@ function EditCompatibleNodeModal({
isOpen={isOpen}
title={
isCcCompatible
? CC_COMPATIBLE_DETAILS_TITLE
? t("ccCompatibleDetailsTitle")
: t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })
}
onClose={onClose}
>
<div className="flex flex-col gap-4">
<Input
label={isCcCompatible ? "Name" : t("nameLabel")}
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={
isCcCompatible
? "CC Compatible Production"
? t("ccCompatibleNamePlaceholder")
: t("compatibleProdPlaceholder", {
type: isAnthropic ? t("anthropic") : t("openai"),
})
}
hint={isCcCompatible ? "Display name for this provider" : t("nameHint")}
hint={isCcCompatible ? t("ccCompatibleNameHint") : t("nameHint")}
/>
<Input
label={isCcCompatible ? "Prefix" : t("prefixLabel")}
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder={
isCcCompatible
? "cc"
? t("ccCompatiblePrefixPlaceholder")
: isAnthropic
? t("anthropicPrefixPlaceholder")
: t("openaiPrefixPlaceholder")
}
hint={isCcCompatible ? "Used for aliases such as prefix/model-id" : t("prefixHint")}
hint={isCcCompatible ? t("ccCompatiblePrefixHint") : t("prefixHint")}
/>
{!isAnthropic && (
<Select
@@ -6792,19 +6767,19 @@ function EditCompatibleNodeModal({
/>
)}
<Input
label={isCcCompatible ? "Base URL" : t("baseUrlLabel")}
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={
isCcCompatible
? "https://example.com/v1"
? t("ccCompatibleBaseUrlPlaceholder")
: isAnthropic
? t("anthropicBaseUrlPlaceholder")
: t("openaiBaseUrlPlaceholder")
}
hint={
isCcCompatible
? "Base URL for the CC-compatible site. Do not include /messages."
? t("ccCompatibleBaseUrlHint")
: t("compatibleBaseUrlHint", {
type: isAnthropic ? t("anthropic") : t("openai"),
})
@@ -6828,7 +6803,7 @@ function EditCompatibleNodeModal({
{showAdvanced && (
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={isCcCompatible ? "Chat Path" : t("chatPathLabel")}
label={t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={
@@ -6838,11 +6813,7 @@ function EditCompatibleNodeModal({
? "/messages"
: t("chatPathPlaceholder")
}
hint={
isCcCompatible
? "Defaults to the strict Claude Code-compatible messages path"
: t("chatPathHint")
}
hint={isCcCompatible ? t("ccCompatibleChatPathHint") : t("chatPathHint")}
/>
{!isCcCompatible && (
<Input

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card } from "@/shared/components";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
const PRICING_FIELDS = ["input", "output", "cached", "reasoning", "cache_creation"] as const;
@@ -13,170 +13,356 @@ const FIELD_LABEL_KEYS: Record<(typeof PRICING_FIELDS)[number], string> = {
cache_creation: "cacheCreation",
};
type PricingField = (typeof PRICING_FIELDS)[number];
type PricingSource = "default" | "litellm" | "modelsDev" | "user";
interface SyncStatus {
enabled: boolean;
lastSync: string | null;
lastSyncModelCount: number;
nextSync: string | null;
intervalMs: number;
sources: string[];
}
interface PricingCatalogModel {
id: string;
name: string;
custom?: boolean;
}
interface PricingCatalogProvider {
id: string;
alias: string;
authType: string;
format: string;
modelCount: number;
models: PricingCatalogModel[];
}
function getSourceTone(source: PricingSource): string {
switch (source) {
case "user":
return "bg-amber-500/15 text-amber-400 border border-amber-500/25";
case "modelsDev":
return "bg-sky-500/15 text-sky-400 border border-sky-500/25";
case "litellm":
return "bg-emerald-500/15 text-emerald-400 border border-emerald-500/25";
default:
return "bg-bg-subtle text-text-muted border border-border/40";
}
}
export default function PricingTab() {
const [catalog, setCatalog] = useState({});
const [pricingData, setPricingData] = useState({});
const [catalog, setCatalog] = useState<Record<string, PricingCatalogProvider>>({});
const [pricingData, setPricingData] = useState<
Record<string, Record<string, Record<string, number>>>
>({});
const [pricingSources, setPricingSources] = useState<
Record<string, Record<string, PricingSource>>
>({});
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState("");
const [selectedProvider, setSelectedProvider] = useState(null);
const [expandedProviders, setExpandedProviders] = useState(new Set());
const [syncing, setSyncing] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [editedProviders, setEditedProviders] = useState(new Set());
const [editedProviders, setEditedProviders] = useState<Set<string>>(new Set());
const [statusMessage, setStatusMessage] = useState<{
tone: "success" | "error" | "info";
message: string;
} | null>(null);
const t = useTranslations("settings");
// Load catalog + pricing
useEffect(() => {
loadData();
const showStatus = useCallback((tone: "success" | "error" | "info", message: string) => {
setStatusMessage({ tone, message });
window.setTimeout(() => setStatusMessage(null), 4000);
}, []);
const loadData = async () => {
const loadData = useCallback(async () => {
setLoading(true);
try {
const [catalogRes, pricingRes] = await Promise.all([
const [catalogRes, pricingRes, syncRes] = await Promise.all([
fetch("/api/pricing/models"),
fetch("/api/pricing"),
fetch("/api/pricing?includeSources=1"),
fetch("/api/pricing/sync"),
]);
if (catalogRes.ok) setCatalog(await catalogRes.json());
if (pricingRes.ok) setPricingData(await pricingRes.json());
if (catalogRes.ok) {
setCatalog((await catalogRes.json()) as Record<string, PricingCatalogProvider>);
}
if (pricingRes.ok) {
const pricingPayload = (await pricingRes.json()) as {
pricing?: Record<string, Record<string, Record<string, number>>>;
sourceMap?: Record<string, Record<string, PricingSource>>;
};
setPricingData(pricingPayload.pricing || {});
setPricingSources(pricingPayload.sourceMap || {});
}
if (syncRes.ok) {
setSyncStatus((await syncRes.json()) as SyncStatus);
}
} catch (error) {
console.error("Failed to load pricing data:", error);
showStatus("error", t("pricingLoadFailed"));
} finally {
setLoading(false);
}
};
}, [showStatus, t]);
useEffect(() => {
void loadData();
}, [loadData]);
// All providers sorted by model count (desc)
const allProviders = useMemo(() => {
const providers = Object.entries(catalog)
.map(([alias, info]: [string, any]) => ({
alias,
return Object.entries(catalog)
.map(([alias, info]) => ({
...info,
alias,
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
}))
.sort((a, b) => b.modelCount - a.modelCount);
return providers;
.sort((left, right) => right.modelCount - left.modelCount);
}, [catalog, pricingData]);
// Filter providers by search
const filteredProviders = useMemo(() => {
if (!searchQuery.trim()) return allProviders;
const q = searchQuery.toLowerCase();
const query = searchQuery.toLowerCase();
return allProviders.filter(
(p) =>
p.alias.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q) ||
p.models.some((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
(provider) =>
provider.alias.toLowerCase().includes(query) ||
provider.id.toLowerCase().includes(query) ||
provider.models.some(
(model) =>
model.id.toLowerCase().includes(query) || model.name.toLowerCase().includes(query)
)
);
}, [allProviders, searchQuery]);
// Stats
const stats = useMemo(() => {
const totalModels = allProviders.reduce((s, p) => s + p.modelCount, 0);
const totalModels = allProviders.reduce((sum, provider) => sum + provider.modelCount, 0);
const pricedCount = Object.values(pricingData).reduce(
(s: number, models: any) => s + Object.keys(models).length,
(sum, models) => sum + Object.keys(models).length,
0
);
const overriddenCount = Object.values(pricingSources).reduce(
(sum, models) => sum + Object.values(models).filter((source) => source === "user").length,
0
);
return {
providers: allProviders.length,
totalModels,
pricedCount,
overriddenCount,
};
}, [allProviders, pricingData]);
}, [allProviders, pricingData, pricingSources]);
const toggleProvider = useCallback((alias) => {
setExpandedProviders((prev) => {
const next = new Set(prev);
if (next.has(alias)) next.delete(alias);
else next.add(alias);
return next;
});
}, []);
const displayProviders = useMemo(() => {
if (!selectedProvider) return filteredProviders;
return filteredProviders.filter((provider) => provider.alias === selectedProvider);
}, [filteredProviders, selectedProvider]);
const handlePricingChange = useCallback((provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
}, []);
const saveProvider = useCallback(
async (providerAlias) => {
setSaving(true);
setSaveStatus("");
const formatSyncDate = useCallback(
(value: string | null) => {
if (!value) return t("never");
try {
const providerPricing = pricingData[providerAlias] || {};
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [providerAlias]: providerPricing }),
});
if (response.ok) {
setSaveStatus(`${providerAlias.toUpperCase()} ${t("saved")}`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
} else {
const err = await response.json();
setSaveStatus(`${t("errorOccurred")}: ${err.error}`);
}
} catch (error) {
setSaveStatus(`${t("saveFailed")}: ${error.message}`);
} finally {
setSaving(false);
}
},
[pricingData, t]
);
const resetProvider = useCallback(
async (providerAlias) => {
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
try {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, {
method: "DELETE",
});
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} ${t("resetDefaults")}`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
} catch (error) {
setSaveStatus(`${t("resetFailed")}: ${error.message}`);
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
} catch {
return value;
}
},
[t]
);
const selectProviderFilter = useCallback((alias) => {
setSelectedProvider((prev) => (prev === alias ? null : alias));
const getSourceLabel = useCallback(
(source: PricingSource) => {
switch (source) {
case "user":
return t("pricingSourceUser");
case "modelsDev":
return t("pricingSourceModelsDev");
case "litellm":
return t("pricingSourceLiteLLM");
default:
return t("pricingSourceDefault");
}
},
[t]
);
const toggleProvider = useCallback((alias: string) => {
setExpandedProviders((previous) => {
const next = new Set(previous);
if (next.has(alias)) {
next.delete(alias);
} else {
next.add(alias);
}
return next;
});
}, []);
// Which providers to display in the main area
const displayProviders = useMemo(() => {
if (selectedProvider) {
return filteredProviders.filter((p) => p.alias === selectedProvider);
const handlePricingChange = useCallback(
(provider: string, model: string, field: PricingField, value: string) => {
const numValue = Number.parseFloat(value);
if (Number.isNaN(numValue) || numValue < 0) return;
setPricingData((previous) => {
const next = { ...previous };
if (!next[provider]) next[provider] = {};
if (!next[provider][model]) {
next[provider][model] = {
input: 0,
output: 0,
cached: 0,
reasoning: 0,
cache_creation: 0,
};
}
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((previous) => new Set(previous).add(provider));
},
[]
);
const saveProvider = useCallback(
async (providerAlias: string) => {
setSaving(true);
try {
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [providerAlias]: pricingData[providerAlias] || {} }),
});
if (!response.ok) {
const errorPayload = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(errorPayload.error || t("saveFailed"));
}
setEditedProviders((previous) => {
const next = new Set(previous);
next.delete(providerAlias);
return next;
});
await loadData();
showStatus("success", t("pricingSavedProvider", { provider: providerAlias.toUpperCase() }));
} catch (error: any) {
showStatus(
"error",
t("pricingSaveFailedWithReason", {
reason: error?.message || t("unknownError"),
})
);
} finally {
setSaving(false);
}
},
[loadData, pricingData, showStatus, t]
);
const resetProvider = useCallback(
async (providerAlias: string) => {
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
try {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, {
method: "DELETE",
});
if (!response.ok) {
const errorPayload = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(errorPayload.error || t("resetFailed"));
}
setEditedProviders((previous) => {
const next = new Set(previous);
next.delete(providerAlias);
return next;
});
await loadData();
showStatus("success", t("pricingResetProvider", { provider: providerAlias.toUpperCase() }));
} catch (error: any) {
showStatus(
"error",
t("pricingResetFailedWithReason", {
reason: error?.message || t("unknownError"),
})
);
}
},
[loadData, showStatus, t]
);
const triggerSync = useCallback(async () => {
setSyncing(true);
try {
const response = await fetch("/api/pricing/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
modelCount?: number;
error?: string;
};
if (!response.ok || payload.success === false) {
throw new Error(payload.error || t("pricingSyncFailed"));
}
await loadData();
showStatus("success", t("pricingSyncSuccess", { count: payload.modelCount || 0 }));
} catch (error: any) {
showStatus(
"error",
t("pricingSyncFailedWithReason", {
reason: error?.message || t("unknownError"),
})
);
} finally {
setSyncing(false);
}
return filteredProviders;
}, [filteredProviders, selectedProvider]);
}, [loadData, showStatus, t]);
const clearSyncedPricing = useCallback(async () => {
if (!confirm(t("clearSyncedPricingConfirm"))) return;
setSyncing(true);
try {
const response = await fetch("/api/pricing/sync", { method: "DELETE" });
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(payload.error || t("clearSyncedPricingFailed"));
}
await loadData();
showStatus("info", t("clearSyncedPricingSuccess"));
} catch (error: any) {
showStatus(
"error",
t("clearSyncedPricingFailedWithReason", {
reason: error?.message || t("unknownError"),
})
);
} finally {
setSyncing(false);
}
}, [loadData, showStatus, t]);
const selectProviderFilter = useCallback((alias: string) => {
setSelectedProvider((previous) => (previous === alias ? null : alias));
}, []);
if (loading) {
return (
@@ -188,36 +374,69 @@ export default function PricingTab() {
return (
<div className="flex flex-col gap-4">
{/* Header + Stats */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h2 className="text-xl font-bold">{t("modelPricing")}</h2>
<p className="text-text-muted text-sm mt-1">{t("modelPricingDesc")}</p>
</div>
<div className="flex gap-3 text-sm">
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">{t("providers")}</div>
<div className="text-lg font-bold">{stats.providers}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">{t("registry")}</div>
<div className="text-lg font-bold">{stats.totalModels}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">{t("priced")}</div>
<div className="text-lg font-bold text-success">{stats.pricedCount as number}</div>
</div>
<StatPill label={t("providers")} value={stats.providers} />
<StatPill label={t("registry")} value={stats.totalModels} />
<StatPill label={t("priced")} value={stats.pricedCount} accent="text-success" />
<StatPill
label={t("pricingSourceUser")}
value={stats.overriddenCount}
accent="text-amber-400"
/>
</div>
</div>
{/* Save Status */}
{saveStatus && (
<div className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm">
{saveStatus}
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
{t("pricingSyncTitle")}
</h3>
<p className="text-sm text-text-muted mt-1">{t("pricingSyncDescription")}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => void clearSyncedPricing()} loading={syncing}>
{t("clearSyncedPricing")}
</Button>
<Button variant="primary" onClick={() => void triggerSync()} loading={syncing}>
{syncing ? t("syncing") : t("syncNow")}
</Button>
</div>
</div>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 mt-4">
<SyncMetric
label={t("pricingSyncStatus")}
value={syncStatus?.enabled ? t("syncEnabled") : t("syncDisabled")}
/>
<SyncMetric label={t("lastSync")} value={formatSyncDate(syncStatus?.lastSync || null)} />
<SyncMetric
label={t("syncedModels")}
value={String(syncStatus?.lastSyncModelCount || 0)}
/>
<SyncMetric label={t("nextSync")} value={formatSyncDate(syncStatus?.nextSync || null)} />
</div>
</Card>
{statusMessage && (
<div
className={`px-3 py-2 rounded-lg border text-sm ${
statusMessage.tone === "success"
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
: statusMessage.tone === "error"
? "bg-red-500/10 border-red-500/20 text-red-400"
: "bg-sky-500/10 border-sky-500/20 text-sky-400"
}`}
>
{statusMessage.message}
</div>
)}
{/* Search + Provider Filter */}
<div className="flex gap-3 items-center flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-lg">
@@ -227,7 +446,7 @@ export default function PricingTab() {
type="text"
placeholder={t("searchProvidersModels")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(event) => setSearchQuery(event.target.value)}
className="w-full pl-10 pr-3 py-2 bg-bg-base border border-border rounded-lg focus:outline-none focus:border-primary text-sm"
/>
</div>
@@ -237,46 +456,47 @@ export default function PricingTab() {
className="px-3 py-2 text-xs bg-primary/10 text-primary border border-primary/20 rounded-lg hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">close</span>
{selectedProvider.toUpperCase()} {t("showAll")}
{selectedProvider.toUpperCase()} - {t("showAll")}
</button>
)}
</div>
{/* Provider Pills (quick filter) */}
<div className="flex flex-wrap gap-1.5">
{allProviders.map((p) => (
{allProviders.map((provider) => (
<button
key={p.alias}
onClick={() => selectProviderFilter(p.alias)}
key={provider.alias}
onClick={() => selectProviderFilter(provider.alias)}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all ${
selectedProvider === p.alias
selectedProvider === provider.alias
? "bg-primary text-white shadow-sm"
: editedProviders.has(p.alias)
: editedProviders.has(provider.alias)
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
}`}
>
{p.alias.toUpperCase()} <span className="opacity-60">({p.modelCount})</span>
{provider.alias.toUpperCase()}{" "}
<span className="opacity-60">({provider.modelCount})</span>
</button>
))}
</div>
{/* Provider Sections */}
<div className="flex flex-col gap-2">
{displayProviders.map((provider) => (
<ProviderSection
key={provider.alias}
provider={provider}
pricingData={pricingData[provider.alias] || {}}
sourceMap={pricingSources[provider.alias] || {}}
isExpanded={expandedProviders.has(provider.alias)}
isEdited={editedProviders.has(provider.alias)}
onToggle={() => toggleProvider(provider.alias)}
onPricingChange={(model, field, value) =>
handlePricingChange(provider.alias, model, field, value)
}
onSave={() => saveProvider(provider.alias)}
onReset={() => resetProvider(provider.alias)}
onSave={() => void saveProvider(provider.alias)}
onReset={() => void resetProvider(provider.alias)}
saving={saving}
getSourceLabel={getSourceLabel}
/>
))}
@@ -285,7 +505,6 @@ export default function PricingTab() {
)}
</div>
{/* Info Box */}
<Card className="p-4 mt-2">
<h3 className="text-sm font-semibold mb-2">
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
@@ -303,11 +522,28 @@ export default function PricingTab() {
);
}
// ── Provider Section (collapsible) ──────────────────────────────────────
function StatPill({ label, value, accent }: { label: string; value: number; accent?: string }) {
return (
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">{label}</div>
<div className={`text-lg font-bold ${accent || ""}`}>{value}</div>
</div>
);
}
function SyncMetric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">{label}</p>
<p className="text-sm font-medium text-text-main mt-1">{value}</p>
</div>
);
}
function ProviderSection({
provider,
pricingData,
sourceMap,
isExpanded,
isEdited,
onToggle,
@@ -315,10 +551,30 @@ function ProviderSection({
onSave,
onReset,
saving,
getSourceLabel,
}: {
provider: PricingCatalogProvider;
pricingData: Record<string, Record<string, number>>;
sourceMap: Record<string, PricingSource>;
isExpanded: boolean;
isEdited: boolean;
onToggle: () => void;
onPricingChange: (model: string, field: PricingField, value: string) => void;
onSave: () => void;
onReset: () => void;
saving: boolean;
getSourceLabel: (source: PricingSource) => string;
}) {
const t = useTranslations("settings");
const tGlobal = useTranslations();
const pricedCount = Object.keys(pricingData).length;
const sourceCounts = Object.values(sourceMap).reduce(
(counts, source) => {
counts[source] = (counts[source] || 0) + 1;
return counts;
},
{ default: 0, litellm: 0, modelsDev: 0, user: 0 } as Record<PricingSource, number>
);
const authBadge =
provider.authType === "oauth"
? tGlobal("providers.oauthLabel")
@@ -332,12 +588,11 @@ function ProviderSection({
isEdited ? "border-yellow-500/40 bg-yellow-500/5" : "border-border"
}`}
>
{/* Header (click to expand) */}
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 min-w-0">
<span
className={`material-symbols-outlined text-lg transition-transform ${
isExpanded ? "rotate-90" : ""
@@ -345,7 +600,7 @@ function ProviderSection({
>
chevron_right
</span>
<div>
<div className="min-w-0">
<span className="font-semibold text-sm">
{provider.id.charAt(0).toUpperCase() + provider.id.slice(1)}
</span>
@@ -359,6 +614,23 @@ function ProviderSection({
</span>
</div>
<div className="flex items-center gap-3">
<div className="hidden xl:flex items-center gap-1.5">
{sourceCounts.user > 0 && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${getSourceTone("user")}`}>
{sourceCounts.user} {getSourceLabel("user")}
</span>
)}
{sourceCounts.modelsDev > 0 && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${getSourceTone("modelsDev")}`}>
{sourceCounts.modelsDev} {getSourceLabel("modelsDev")}
</span>
)}
{sourceCounts.litellm > 0 && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${getSourceTone("litellm")}`}>
{sourceCounts.litellm} {getSourceLabel("litellm")}
</span>
)}
</div>
{isEdited && <span className="text-yellow-500 text-xs font-medium">{t("unsaved")}</span>}
<span className="text-text-muted text-xs">
{pricedCount}/{provider.modelCount} {t("withPricing")}
@@ -378,18 +650,16 @@ function ProviderSection({
</div>
</button>
{/* Expanded: models table */}
{isExpanded && (
<div className="border-t border-border">
{/* Actions bar */}
<div className="flex items-center justify-between px-4 py-2 bg-bg-subtle/50">
<span className="text-xs text-text-muted">
{provider.modelCount} {t("models")} {pricedCount} {t("withPricing")}
</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onClick={(event) => {
event.stopPropagation();
onReset();
}}
className="px-2.5 py-1 text-[11px] text-red-400 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
@@ -397,8 +667,8 @@ function ProviderSection({
{t("resetDefaults")}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onClick={(event) => {
event.stopPropagation();
onSave();
}}
disabled={saving || !isEdited}
@@ -409,7 +679,6 @@ function ProviderSection({
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-[11px] text-text-muted uppercase bg-bg-subtle/30">
@@ -428,6 +697,8 @@ function ProviderSection({
key={model.id}
model={model}
pricing={pricingData[model.id]}
source={sourceMap[model.id] || "default"}
getSourceLabel={getSourceLabel}
onPricingChange={(field, value) => onPricingChange(model.id, field, value)}
/>
))}
@@ -440,11 +711,21 @@ function ProviderSection({
);
}
// ── Model Row ────────────────────────────────────────────────────────────
function ModelRow({ model, pricing, onPricingChange }) {
function ModelRow({
model,
pricing,
source,
getSourceLabel,
onPricingChange,
}: {
model: PricingCatalogModel;
pricing?: Record<string, number>;
source: PricingSource;
getSourceLabel: (source: PricingSource) => string;
onPricingChange: (field: PricingField, value: string) => void;
}) {
const t = useTranslations("settings");
const hasPricing = pricing && Object.values(pricing).some((v: any) => v > 0);
const hasPricing = Boolean(pricing && Object.values(pricing).some((value) => Number(value) > 0));
return (
<tr className="hover:bg-bg-hover/30 group">
@@ -459,6 +740,9 @@ function ModelRow({ model, pricing, onPricingChange }) {
{t("custom")}
</span>
)}
<span className={`px-1.5 py-0.5 rounded text-[9px] ${getSourceTone(source)}`}>
{getSourceLabel(source)}
</span>
<span className="text-text-muted text-[10px] opacity-0 group-hover:opacity-100 transition-opacity">
{model.id}
</span>
@@ -471,7 +755,7 @@ function ModelRow({ model, pricing, onPricingChange }) {
step="0.01"
min="0"
value={pricing?.[field] || 0}
onChange={(e) => onPricingChange(field, e.target.value)}
onChange={(event) => onPricingChange(field, event.target.value)}
className="w-full px-2 py-1 text-right text-xs bg-transparent border border-transparent hover:border-border focus:border-primary focus:bg-bg-base rounded transition-colors outline-none tabular-nums"
/>
</td>

View File

@@ -2,27 +2,72 @@
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useCallback, useState } from "react";
import { SegmentedControl } from "@/shared/components";
import PlaygroundMode from "./components/PlaygroundMode";
import ChatTesterMode from "./components/ChatTesterMode";
import TestBenchMode from "./components/TestBenchMode";
import LiveMonitorMode from "./components/LiveMonitorMode";
import StreamTransformerMode from "./components/StreamTransformerMode";
export default function TranslatorPageClient() {
const t = useTranslations("translator");
const translateOrFallback = useCallback(
(key: string, fallback: string) => {
try {
const translated = t(key);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t]
);
const [mode, setMode] = useState("playground");
const modes = [
{ value: "playground", label: t("playground"), icon: "code" },
{ value: "chat-tester", label: t("chatTester"), icon: "chat" },
{ value: "test-bench", label: t("testBench"), icon: "science" },
{ value: "live-monitor", label: t("liveMonitor"), icon: "monitoring" },
{ value: "playground", label: translateOrFallback("playground", "Playground"), icon: "code" },
{
value: "chat-tester",
label: translateOrFallback("chatTester", "Chat Tester"),
icon: "chat",
},
{
value: "test-bench",
label: translateOrFallback("testBench", "Test Bench"),
icon: "science",
},
{
value: "stream-transformer",
label: translateOrFallback("streamTransformer", "Stream Transformer"),
icon: "swap_horiz",
},
{
value: "live-monitor",
label: translateOrFallback("liveMonitor", "Live Monitor"),
icon: "monitoring",
},
];
const modeDescriptions: Record<string, string> = {
playground: t("modeDescriptionPlayground"),
"chat-tester": t("modeDescriptionChatTester"),
"test-bench": t("modeDescriptionTestBench"),
"live-monitor": t("modeDescriptionLiveMonitor"),
playground: translateOrFallback(
"modeDescriptionPlayground",
"Inspect request translation step-by-step between API formats."
),
"chat-tester": translateOrFallback(
"modeDescriptionChatTester",
"Send a real prompt through the selected provider and inspect every translation stage."
),
"test-bench": translateOrFallback(
"modeDescriptionTestBench",
"Run compatibility scenarios across source formats and target providers."
),
"stream-transformer": translateOrFallback(
"modeDescriptionStreamTransformer",
"Transform Chat Completions SSE into Responses API SSE and inspect emitted events."
),
"live-monitor": translateOrFallback(
"modeDescriptionLiveMonitor",
"Watch translation events in real time as requests flow through OmniRoute."
),
};
return (
@@ -53,6 +98,7 @@ export default function TranslatorPageClient() {
{mode === "playground" && <PlaygroundMode />}
{mode === "chat-tester" && <ChatTesterMode />}
{mode === "test-bench" && <TestBenchMode />}
{mode === "stream-transformer" && <StreamTransformerMode />}
{mode === "live-monitor" && <LiveMonitorMode />}
</div>
);

View File

@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Card, Badge } from "@/shared/components";
import { FORMAT_META } from "../exampleTemplates";
@@ -14,6 +14,17 @@ import { FORMAT_META } from "../exampleTemplates";
export default function LiveMonitorMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const translateOrFallback = useCallback(
(key: string, fallback: string, values?: Record<string, unknown>) => {
try {
const translated = t(key, values);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t]
);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
@@ -48,6 +59,9 @@ export default function LiveMonitorMode() {
// Stats
const successCount = events.filter((e) => e.status === "success").length;
const errorCount = events.filter((e) => e.status === "error").length;
const comboCount = events.filter((e) => e.isComboRouted).length;
const uniqueEndpoints = new Set(events.map((e) => e.routeEndpoint || e.endpoint).filter(Boolean))
.size;
const avgLatency =
events.length > 0
? Math.round(events.reduce((sum, e) => sum + (e.latency || 0), 0) / events.length)
@@ -75,7 +89,7 @@ export default function LiveMonitorMode() {
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<StatCard
icon="translate"
label={t("totalTranslations")}
@@ -90,6 +104,18 @@ export default function LiveMonitorMode() {
value={formatLatency(avgLatency)}
color="purple"
/>
<StatCard
icon="hub"
label={translateOrFallback("comboRouted", "Combo-routed")}
value={comboCount}
color="amber"
/>
<StatCard
icon="lan"
label={translateOrFallback("uniqueEndpoints", "Unique endpoints")}
value={uniqueEndpoints}
color="cyan"
/>
</div>
{/* Controls */}
@@ -165,8 +191,8 @@ export default function LiveMonitorMode() {
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">{t("time")}</th>
<th className="pb-2 pr-4">{translateOrFallback("routeDetails", "Route")}</th>
<th className="pb-2 pr-4">{t("source")}</th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4">{t("target")}</th>
<th className="pb-2 pr-4">{t("model")}</th>
<th className="pb-2 pr-4">{t("status")}</th>
@@ -194,19 +220,37 @@ export default function LiveMonitorMode() {
? new Date(event.timestamp).toLocaleTimeString()
: notAvailable}
</td>
<td className="py-2 pr-4 min-w-[220px]">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="default" size="sm">
{event.routeProvider || event.provider || notAvailable}
</Badge>
{event.routeCombo ? (
<Badge variant="primary" size="sm">
{translateOrFallback("comboBadge", "Combo")}: {event.routeCombo}
</Badge>
) : null}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-text-muted">
<span>
{translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "}
{event.routeEndpoint || event.endpoint || notAvailable}
</span>
{event.routeConnectionShortId ? (
<span>
{translateOrFallback("routeConnectionLabel", "Conn")}:{" "}
<span className="font-mono">{event.routeConnectionShortId}</span>
</span>
) : null}
</div>
</div>
</td>
<td className="py-2 pr-4">
<Badge variant="default" size="sm">
{srcMeta.label}
</Badge>
</td>
<td className="py-2 pr-4 text-text-muted">
<span
className="material-symbols-outlined text-[14px]"
aria-hidden="true"
>
arrow_forward
</span>
</td>
<td className="py-2 pr-4">
<Badge variant="primary" size="sm">
{tgtMeta.label}
@@ -243,12 +287,22 @@ export default function LiveMonitorMode() {
}
function StatCard({ icon, label, value, color }) {
const colorMap = {
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
green: { shell: "bg-green-500/10", icon: "text-green-500" },
red: { shell: "bg-red-500/10", icon: "text-red-500" },
purple: { shell: "bg-purple-500/10", icon: "text-purple-500" },
amber: { shell: "bg-amber-500/10", icon: "text-amber-500" },
cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" },
};
const resolved = colorMap[color as keyof typeof colorMap] || colorMap.blue;
return (
<Card>
<div className="p-4 flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-lg bg-${color}-500/10`}>
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${resolved.shell}`}>
<span
className={`material-symbols-outlined text-[22px] text-${color}-500`}
className={`material-symbols-outlined text-[22px] ${resolved.icon}`}
aria-hidden="true"
>
{icon}

View File

@@ -0,0 +1,295 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button, Card } from "@/shared/components";
import { copyToClipboard } from "@/shared/utils/clipboard";
const TEXT_SAMPLE = `data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" from OmniRoute"},"finish_reason":null}]}
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}
data: [DONE]
`;
const TOOL_SAMPLE = `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"lookup_weather","arguments":"{\\"city\\":\\"Tok"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"yo\\"}"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":23,"completion_tokens":9,"total_tokens":32}}
data: [DONE]
`;
function getFramePreview(data: unknown): string {
if (typeof data === "string") return data;
if (!data || typeof data !== "object") return "";
const record = data as Record<string, unknown>;
const delta = record.delta;
if (typeof delta === "string") return delta;
const item = record.item;
if (item && typeof item === "object") {
const itemRecord = item as Record<string, unknown>;
const type = itemRecord.type;
const text = itemRecord.text;
const name = itemRecord.name;
if (typeof text === "string" && text) return text;
if (typeof name === "string" && name) return `${type || "item"}: ${name}`;
if (typeof type === "string" && type) return type;
}
const text = record.text;
if (typeof text === "string" && text) return text;
return JSON.stringify(data).slice(0, 140);
}
function parseSseFrames(rawSse: string): Array<{ event: string; preview: string }> {
return rawSse
.split("\n\n")
.map((frame) => frame.trim())
.filter(Boolean)
.map((frame) => {
const eventLine = frame
.split("\n")
.find((line) => line.startsWith("event:"))
?.replace(/^event:\s*/, "")
.trim();
const dataLine = frame
.split("\n")
.find((line) => line.startsWith("data:"))
?.replace(/^data:\s*/, "");
if (dataLine === "[DONE]") {
return { event: "done", preview: "[DONE]" };
}
let parsedData: unknown = dataLine || "";
try {
parsedData = dataLine ? JSON.parse(dataLine) : "";
} catch {
parsedData = dataLine || "";
}
return {
event: eventLine || "message",
preview: getFramePreview(parsedData),
};
});
}
export default function StreamTransformerMode() {
const t = useTranslations("translator");
const translateOrFallback = useCallback(
(key: string, fallback: string, values?: Record<string, unknown>) => {
try {
const translated = t(key, values);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t]
);
const [rawSse, setRawSse] = useState(TEXT_SAMPLE);
const [transformedSse, setTransformedSse] = useState("");
const [loading, setLoading] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const transformedFrames = useMemo(() => parseSseFrames(transformedSse), [transformedSse]);
const eventCount = transformedFrames.length;
const uniqueEventCount = new Set(transformedFrames.map((frame) => frame.event)).size;
const handleCopy = async (value: string, field: string) => {
await copyToClipboard(value);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const runTransform = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/translator/transform-stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rawSse }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || translateOrFallback("requestFailed", "Request failed"));
}
setTransformedSse(data.transformed || "");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to transform stream");
} finally {
setLoading(false);
}
};
return (
<div className="space-y-5 min-w-0">
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
swap_horiz
</span>
<div>
<p className="font-medium text-text-main mb-0.5">
{translateOrFallback("streamTransformerTitle", "Responses Stream Transformer")}
</p>
<p>
{translateOrFallback(
"streamTransformerDescription",
"Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client."
)}
</p>
</div>
</div>
<Card>
<div className="p-4 flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setRawSse(TEXT_SAMPLE)}>
{translateOrFallback("loadTextSample", "Load text sample")}
</Button>
<Button variant="outline" size="sm" onClick={() => setRawSse(TOOL_SAMPLE)}>
{translateOrFallback("loadToolSample", "Load tool-call sample")}
</Button>
<Button size="sm" icon="play_arrow" onClick={runTransform} loading={loading}>
{translateOrFallback("transformToResponses", "Transform to Responses")}
</Button>
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-main">
{translateOrFallback("rawChatSseInput", "Raw chat completions SSE")}
</h3>
<Button variant="ghost" size="sm" onClick={() => handleCopy(rawSse, "input")}>
<span className="material-symbols-outlined text-[14px]">
{copiedField === "input" ? "check" : "content_copy"}
</span>
</Button>
</div>
<textarea
value={rawSse}
onChange={(e) => setRawSse(e.target.value)}
className="min-h-[360px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50"
spellCheck={false}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-main">
{translateOrFallback("transformedResponsesSse", "Transformed Responses API SSE")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(transformedSse, "output")}
disabled={!transformedSse}
>
<span className="material-symbols-outlined text-[14px]">
{copiedField === "output" ? "check" : "content_copy"}
</span>
</Button>
</div>
<pre className="min-h-[360px] overflow-auto rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all">
{transformedSse || translateOrFallback("noResultsYet", "No results yet")}
</pre>
</div>
</div>
</div>
</Card>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<MiniStat
label={translateOrFallback("transformedEvents", "Transformed events")}
value={eventCount}
/>
<MiniStat
label={translateOrFallback("uniqueEventTypes", "Unique event types")}
value={uniqueEventCount}
/>
<MiniStat
label={translateOrFallback("inputLines", "Input lines")}
value={rawSse.split("\n").length}
/>
<MiniStat
label={translateOrFallback("outputLines", "Output lines")}
value={transformedSse ? transformedSse.split("\n").length : 0}
/>
</div>
<Card>
<div className="p-4 space-y-3">
<h3 className="text-sm font-semibold text-text-main">
{translateOrFallback("transformedEventTimeline", "Transformed event timeline")}
</h3>
{transformedFrames.length === 0 ? (
<p className="text-sm text-text-muted">
{translateOrFallback(
"transformerTimelineHint",
"Run the transformer to inspect emitted response.output_* events in order."
)}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">#</th>
<th className="pb-2 pr-4">{translateOrFallback("eventType", "Event type")}</th>
<th className="pb-2">{translateOrFallback("eventPreview", "Preview")}</th>
</tr>
</thead>
<tbody>
{transformedFrames.map((frame, index) => (
<tr
key={`${frame.event}_${index}`}
className="border-b border-border/50 align-top"
>
<td className="py-2 pr-4 text-xs text-text-muted">{index + 1}</td>
<td className="py-2 pr-4 font-mono text-xs text-primary">{frame.event}</td>
<td className="py-2 text-xs text-text-muted break-all">{frame.preview}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
);
}
function MiniStat({ label, value }: { label: string; value: number }) {
return (
<Card>
<div className="p-4">
<p className="text-lg font-bold text-text-main">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-text-muted">{label}</p>
</div>
</Card>
);
}

View File

@@ -25,10 +25,20 @@ const SCENARIOS = [
{ id: "thinking", icon: "psychology", templateId: "thinking" },
{ id: "system-prompt", icon: "settings", templateId: "system-prompt" },
{ id: "streaming", icon: "stream", templateId: "streaming" },
{ id: "vision", icon: "image", templateId: "vision" },
{ id: "schema-coercion", icon: "schema", templateId: "schema-coercion" },
];
export default function TestBenchMode() {
const t = useTranslations("translator");
const translateOrFallback = (key: string, fallback: string) => {
try {
const translated = t(key);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
};
const scenarioLabels: Record<string, string> = {
"simple-chat": t("scenarioSimpleChat"),
"tool-calling": t("scenarioToolCalling"),
@@ -36,6 +46,8 @@ export default function TestBenchMode() {
thinking: t("scenarioThinking"),
"system-prompt": t("scenarioSystemPrompt"),
streaming: t("scenarioStreaming"),
vision: translateOrFallback("scenarioVision", "Vision"),
"schema-coercion": translateOrFallback("scenarioSchemaCoercion", "Schema Coercion"),
};
const templates = useMemo(() => getExampleTemplates(t), [t]);
const [sourceFormat, setSourceFormat] = useState("claude");

View File

@@ -2,7 +2,16 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Button, EmptyState, DataTable, FilterBar, Select } from "@/shared/components";
import {
Card,
Button,
EmptyState,
DataTable,
FilterBar,
Input,
Modal,
Select,
} from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
type EvalTargetType = "suite-default" | "model" | "combo";
@@ -39,8 +48,10 @@ interface EvalSuite {
id: string;
name: string;
description?: string;
source?: "built-in" | "custom";
caseCount?: number;
cases?: EvalCasePreview[];
updatedAt?: string;
}
interface EvalResult {
@@ -104,6 +115,26 @@ interface EvalsDashboardPayload {
apiKeys: EvalApiKeyOption[];
}
type BuilderStrategy = "contains" | "exact" | "regex";
interface EvalCaseDraft {
id: string;
name: string;
model: string;
systemPrompt: string;
userPrompt: string;
strategy: BuilderStrategy;
expectedValue: string;
tags: string;
}
interface EvalSuiteDraft {
id?: string;
name: string;
description: string;
cases: EvalCaseDraft[];
}
const STRATEGIES = [
{
name: "contains",
@@ -157,6 +188,70 @@ const HISTORY_COLUMNS = [
const NO_COMPARE_TARGET = "__none__";
const AUTO_API_KEY = "__auto__";
function createDraftId() {
return `draft-${Math.random().toString(36).slice(2, 10)}`;
}
function createEmptyCaseDraft(): EvalCaseDraft {
return {
id: createDraftId(),
name: "",
model: "",
systemPrompt: "",
userPrompt: "",
strategy: "contains",
expectedValue: "",
tags: "",
};
}
function createEmptySuiteDraft(): EvalSuiteDraft {
return {
name: "",
description: "",
cases: [createEmptyCaseDraft()],
};
}
function joinPromptMessages(
messages: Array<{ role: string; content: string }> | undefined,
role: string
): string {
return (messages || [])
.filter((message) => message.role === role && typeof message.content === "string")
.map((message) => message.content)
.join("\n\n");
}
function suiteToDraft(suite: EvalSuite): EvalSuiteDraft {
return {
id: suite.id,
name: suite.name || "",
description: suite.description || "",
cases:
suite.cases && suite.cases.length > 0
? suite.cases.map((evalCase) => ({
id: evalCase.id || createDraftId(),
name: evalCase.name || "",
model: evalCase.model || "",
systemPrompt: joinPromptMessages(evalCase.input?.messages, "system"),
userPrompt:
joinPromptMessages(evalCase.input?.messages, "user") ||
(evalCase.input?.messages || [])
.filter((message) => message.role !== "system")
.map((message) => message.content)
.join("\n\n"),
strategy:
evalCase.expected?.strategy === "exact" || evalCase.expected?.strategy === "regex"
? evalCase.expected.strategy
: "contains",
expectedValue: evalCase.expected?.value || "",
tags: (evalCase.tags || []).join(", "),
}))
: [createEmptyCaseDraft()],
};
}
function getTargetLabel(
target: { type: EvalTargetType; id: string | null },
t: (key: string, values?: Record<string, unknown>) => string
@@ -246,6 +341,10 @@ export default function EvalsTab() {
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<string | null>(null);
const [showHowItWorks, setShowHowItWorks] = useState(false);
const [isBuilderOpen, setIsBuilderOpen] = useState(false);
const [suiteDraft, setSuiteDraft] = useState<EvalSuiteDraft>(createEmptySuiteDraft());
const [savingSuite, setSavingSuite] = useState(false);
const [deletingSuiteId, setDeletingSuiteId] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
@@ -336,6 +435,154 @@ export default function EvalsTab() {
setSuites(Array.isArray(payload.suites) ? payload.suites : []);
}
function openNewSuiteBuilder() {
setSuiteDraft(createEmptySuiteDraft());
setIsBuilderOpen(true);
}
function openEditSuiteBuilder(suite: EvalSuite) {
setSuiteDraft(suiteToDraft(suite));
setIsBuilderOpen(true);
}
async function handleSaveSuite() {
const suiteName = suiteDraft.name.trim();
if (!suiteName) {
notify.warning(t("suiteBuilderNameRequired"));
return;
}
if (suiteDraft.cases.length === 0) {
notify.warning(t("suiteBuilderCasesRequired"));
return;
}
const invalidCaseIndex = suiteDraft.cases.findIndex((draftCase) => {
const hasMessage =
draftCase.systemPrompt.trim().length > 0 || draftCase.userPrompt.trim().length > 0;
return (
!draftCase.name.trim() ||
!draftCase.expectedValue.trim() ||
!hasMessage ||
!draftCase.model.trim()
);
});
if (invalidCaseIndex >= 0) {
notify.warning(t("suiteBuilderCaseInvalid", { index: invalidCaseIndex + 1 }));
return;
}
setSavingSuite(true);
try {
const payload = {
name: suiteName,
description: suiteDraft.description.trim(),
cases: suiteDraft.cases.map((draftCase) => {
const messages: Array<{ role: string; content: string }> = [];
if (draftCase.systemPrompt.trim()) {
messages.push({ role: "system", content: draftCase.systemPrompt.trim() });
}
if (draftCase.userPrompt.trim()) {
messages.push({ role: "user", content: draftCase.userPrompt.trim() });
}
return {
...(draftCase.id.startsWith("draft-") ? {} : { id: draftCase.id }),
name: draftCase.name.trim(),
model: draftCase.model.trim(),
input: {
messages,
},
expected: {
strategy: draftCase.strategy,
value: draftCase.expectedValue.trim(),
},
tags: draftCase.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
};
}),
};
const isEditing = typeof suiteDraft.id === "string" && suiteDraft.id.trim().length > 0;
const response = await fetch(
isEditing ? `/api/evals/suites/${encodeURIComponent(suiteDraft.id!)}` : "/api/evals/suites",
{
method: isEditing ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
const result = await response.json();
if (!response.ok) {
throw new Error(
result?.error?.message || result?.error || result?.message || t("suiteBuilderSaveFailed")
);
}
await refreshDashboard();
setExpanded(result?.suite?.id || suiteDraft.id || null);
setIsBuilderOpen(false);
setSuiteDraft(createEmptySuiteDraft());
notify.success(
isEditing ? t("suiteBuilderUpdated") : t("suiteBuilderCreated"),
t("notifyEvalTitle", { name: suiteName })
);
} catch (error: any) {
notify.error(
t("notifyEvalRunFailedWithReason", {
reason: error?.message || t("notAvailableSymbol"),
}),
t("suiteBuilderSaveFailed")
);
} finally {
setSavingSuite(false);
}
}
async function handleDeleteSuite(suite: EvalSuite) {
if (suite.source !== "custom") return;
const confirmDelete = window.confirm(
t("suiteBuilderDeleteConfirm", { name: suite.name || suite.id })
);
if (!confirmDelete) return;
setDeletingSuiteId(suite.id);
try {
const response = await fetch(`/api/evals/suites/${encodeURIComponent(suite.id)}`, {
method: "DELETE",
});
const result = await response.json();
if (!response.ok) {
throw new Error(
result?.error?.message ||
result?.error ||
result?.message ||
t("suiteBuilderDeleteFailed")
);
}
await refreshDashboard();
if (expanded === suite.id) {
setExpanded(null);
}
notify.success(
t("suiteBuilderDeleted"),
t("notifyEvalTitle", { name: suite.name || suite.id })
);
} catch (error: any) {
notify.error(
t("notifyEvalRunFailedWithReason", {
reason: error?.message || t("notAvailableSymbol"),
}),
t("suiteBuilderDeleteFailed")
);
} finally {
setDeletingSuiteId(null);
}
}
async function handleRunEval(suite: EvalSuite) {
const cases = suite.cases || [];
if (cases.length === 0) {
@@ -728,14 +975,19 @@ export default function EvalsTab() {
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
<span className="material-symbols-outlined text-[20px]">science</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("evalSuites")}</h3>
<p className="text-xs text-text-muted">{t("evalSuitesHint")}</p>
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
<span className="material-symbols-outlined text-[20px]">science</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("evalSuites")}</h3>
<p className="text-xs text-text-muted">{t("evalSuitesHint")}</p>
</div>
</div>
<Button icon="add" onClick={openNewSuiteBuilder}>
{t("suiteBuilderNewSuite")}
</Button>
</div>
<FilterBar
@@ -782,6 +1034,17 @@ export default function EvalsTab() {
<p className="text-sm font-medium text-text-main">
{suite.name || suite.id}
</p>
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide ${
suite.source === "custom"
? "bg-sky-500/10 text-sky-400"
: "bg-text-muted/10 text-text-muted"
}`}
>
{suite.source === "custom"
? t("suiteBuilderCustomBadge")
: t("suiteBuilderBuiltInBadge")}
</span>
{typeof latestScore === "number" && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
@@ -802,6 +1065,11 @@ export default function EvalsTab() {
<span className="ml-1">- {suite.description}</span>
) : null}
</p>
{suite.source === "custom" && suite.updatedAt ? (
<p className="text-[11px] text-text-muted mt-1">
{t("suiteBuilderUpdatedAt", { value: formatTimestamp(suite.updatedAt) })}
</p>
) : null}
{suiteModels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{suiteModels.map((model) => (
@@ -817,18 +1085,49 @@ export default function EvalsTab() {
</div>
</div>
<Button
size="sm"
variant="primary"
loading={isRunning}
disabled={running !== null}
onClick={(event) => {
event.stopPropagation();
void handleRunEval(suite);
}}
>
{isRunning ? t("runEvalRunning") : t("runEval")}
</Button>
<div className="flex items-center gap-2">
{suite.source === "custom" && (
<>
<Button
size="sm"
variant="secondary"
icon="edit"
disabled={running !== null || deletingSuiteId === suite.id}
onClick={(event) => {
event.stopPropagation();
openEditSuiteBuilder(suite);
}}
>
{t("edit")}
</Button>
<Button
size="sm"
variant="ghost"
icon="delete"
disabled={running !== null || deletingSuiteId === suite.id}
loading={deletingSuiteId === suite.id}
onClick={(event) => {
event.stopPropagation();
void handleDeleteSuite(suite);
}}
>
{t("delete")}
</Button>
</>
)}
<Button
size="sm"
variant="primary"
loading={isRunning}
disabled={running !== null}
onClick={(event) => {
event.stopPropagation();
void handleRunEval(suite);
}}
>
{isRunning ? t("runEvalRunning") : t("runEval")}
</Button>
</div>
</div>
{isExpanded && (
@@ -1067,6 +1366,20 @@ export default function EvalsTab() {
})}
</div>
</Card>
<SuiteBuilderModal
draft={suiteDraft}
isOpen={isBuilderOpen}
onChange={setSuiteDraft}
onClose={() => {
if (savingSuite) return;
setIsBuilderOpen(false);
setSuiteDraft(createEmptySuiteDraft());
}}
onSave={() => void handleSaveSuite()}
saving={savingSuite}
t={t}
/>
</div>
);
}
@@ -1118,3 +1431,188 @@ function HeroSection({ t }: { t: (key: string, values?: Record<string, unknown>)
</Card>
);
}
function SuiteBuilderModal({
draft,
isOpen,
onChange,
onClose,
onSave,
saving,
t,
}: {
draft: EvalSuiteDraft;
isOpen: boolean;
onChange: (next: EvalSuiteDraft) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
t: (key: string, values?: Record<string, unknown>) => string;
}) {
const editableStrategies = STRATEGIES.filter((strategy) => strategy.name !== "custom");
function updateCase(caseId: string, patch: Partial<EvalCaseDraft>) {
onChange({
...draft,
cases: draft.cases.map((entry) => (entry.id === caseId ? { ...entry, ...patch } : entry)),
});
}
function addCase() {
onChange({
...draft,
cases: [...draft.cases, createEmptyCaseDraft()],
});
}
function removeCase(caseId: string) {
if (draft.cases.length <= 1) {
onChange({
...draft,
cases: [createEmptyCaseDraft()],
});
return;
}
onChange({
...draft,
cases: draft.cases.filter((entry) => entry.id !== caseId),
});
}
return (
<Modal
isOpen={isOpen}
title={draft.id ? t("suiteBuilderEditTitle") : t("suiteBuilderCreateTitle")}
onClose={onClose}
>
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label={t("suiteBuilderNameLabel")}
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
placeholder={t("suiteBuilderNamePlaceholder")}
/>
<Input
label={t("suiteBuilderDescriptionLabel")}
value={draft.description}
onChange={(event) => onChange({ ...draft, description: event.target.value })}
placeholder={t("suiteBuilderDescriptionPlaceholder")}
/>
</div>
<div className="rounded-xl border border-border/20 bg-surface/20 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h4 className="text-sm font-semibold text-text-main">
{t("suiteBuilderCasesTitle")}
</h4>
<p className="text-xs text-text-muted">{t("suiteBuilderCasesHint")}</p>
</div>
<Button icon="add" variant="secondary" onClick={addCase}>
{t("suiteBuilderAddCase")}
</Button>
</div>
</div>
{draft.cases.map((draftCase, index) => (
<Card key={draftCase.id} className="p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-text-main">
{t("suiteBuilderCaseCardTitle", { index: index + 1 })}
</h4>
<p className="text-xs text-text-muted">
{t("suiteBuilderCaseCardHint", { index: index + 1 })}
</p>
</div>
<Button variant="ghost" icon="delete" onClick={() => removeCase(draftCase.id)}>
{t("delete")}
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label={t("suiteBuilderCaseNameLabel")}
value={draftCase.name}
onChange={(event) => updateCase(draftCase.id, { name: event.target.value })}
placeholder={t("suiteBuilderCaseNamePlaceholder")}
/>
<Input
label={t("suiteBuilderCaseModelLabel")}
value={draftCase.model}
onChange={(event) => updateCase(draftCase.id, { model: event.target.value })}
placeholder={t("suiteBuilderCaseModelPlaceholder")}
/>
<Input
label={t("suiteBuilderCaseTagsLabel")}
value={draftCase.tags}
onChange={(event) => updateCase(draftCase.id, { tags: event.target.value })}
placeholder={t("suiteBuilderCaseTagsPlaceholder")}
hint={t("suiteBuilderCaseTagsHint")}
/>
<Select
label={t("suiteBuilderCaseStrategyLabel")}
value={draftCase.strategy}
onChange={(event) =>
updateCase(draftCase.id, { strategy: event.target.value as BuilderStrategy })
}
options={editableStrategies.map((strategy) => ({
value: strategy.name,
label: t(strategy.labelKey),
}))}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4">
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{t("suiteBuilderCaseSystemPromptLabel")}
</span>
<textarea
value={draftCase.systemPrompt}
onChange={(event) =>
updateCase(draftCase.id, { systemPrompt: event.target.value })
}
rows={3}
placeholder={t("suiteBuilderCaseSystemPromptPlaceholder")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-text-main outline-none focus:border-primary"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{t("suiteBuilderCaseUserPromptLabel")}
</span>
<textarea
value={draftCase.userPrompt}
onChange={(event) => updateCase(draftCase.id, { userPrompt: event.target.value })}
rows={4}
placeholder={t("suiteBuilderCaseUserPromptPlaceholder")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-text-main outline-none focus:border-primary"
/>
</label>
<Input
label={t("suiteBuilderCaseExpectedLabel")}
value={draftCase.expectedValue}
onChange={(event) =>
updateCase(draftCase.id, { expectedValue: event.target.value })
}
placeholder={t("suiteBuilderCaseExpectedPlaceholder")}
/>
</div>
</Card>
))}
<div className="flex gap-2">
<Button fullWidth onClick={onSave} disabled={saving}>
{saving ? t("saving") : draft.id ? t("save") : t("suiteBuilderCreateAction")}
</Button>
<Button fullWidth variant="ghost" onClick={onClose} disabled={saving}>
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { deleteCustomEvalSuite, getCustomEvalSuite, saveCustomEvalSuite } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { evalSuiteSaveSchema } from "@/shared/validation/schemas";
export async function GET(request: Request, { params }: { params: Promise<{ suiteId: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { suiteId } = await params;
const suite = getCustomEvalSuite(suiteId);
if (!suite) {
return NextResponse.json({ error: "Eval suite not found" }, { status: 404 });
}
return NextResponse.json({ suite });
} catch (error: any) {
return NextResponse.json(
{ error: error?.message || "Failed to load eval suite" },
{ status: 500 }
);
}
}
export async function PUT(request: Request, { params }: { params: Promise<{ suiteId: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
const validation = validateBody(evalSuiteSaveSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
try {
const { suiteId } = await params;
const existing = getCustomEvalSuite(suiteId);
if (!existing) {
return NextResponse.json({ error: "Eval suite not found" }, { status: 404 });
}
const suite = saveCustomEvalSuite({ ...validation.data, id: suiteId });
return NextResponse.json({ suite });
} catch (error: any) {
return NextResponse.json(
{ error: error?.message || "Failed to update eval suite" },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ suiteId: string }> }
) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { suiteId } = await params;
const deleted = deleteCustomEvalSuite(suiteId);
if (!deleted) {
return NextResponse.json({ error: "Eval suite not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json(
{ error: error?.message || "Failed to delete eval suite" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { saveCustomEvalSuite } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { evalSuiteSaveSchema } from "@/shared/validation/schemas";
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
const validation = validateBody(evalSuiteSaveSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
try {
const suite = saveCustomEvalSuite(validation.data);
return NextResponse.json({ suite }, { status: 201 });
} catch (error: any) {
return NextResponse.json(
{ error: error?.message || "Failed to create eval suite" },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,11 @@
import { NextResponse } from "next/server";
import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb";
import {
getPricing,
getPricingWithSources,
updatePricing,
resetPricing,
resetAllPricing,
} from "@/lib/localDb";
import { updatePricingSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -7,8 +13,13 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
* GET /api/pricing
* Get current pricing configuration (merged user + defaults)
*/
export async function GET() {
export async function GET(request: Request) {
try {
const includeSources = new URL(request.url).searchParams.get("includeSources") === "1";
if (includeSources) {
return NextResponse.json(await getPricingWithSources());
}
const pricing = await getPricing();
return NextResponse.json(pricing);
} catch (error) {

View File

@@ -11,8 +11,36 @@ export async function GET(request) {
const { searchParams } = new URL(request.url);
const limit = searchParams.get("limit");
const { events, total } = getTranslationEvents(limit ? Number(limit) : undefined);
const normalizedEvents = events.map((event) => {
const connectionId =
typeof event.connectionId === "string" && event.connectionId.trim().length > 0
? event.connectionId
: null;
const comboName =
typeof event.comboName === "string" && event.comboName.trim().length > 0
? event.comboName
: null;
const provider =
typeof event.provider === "string" && event.provider.trim().length > 0
? event.provider
: null;
const endpoint =
typeof event.endpoint === "string" && event.endpoint.trim().length > 0
? event.endpoint
: null;
return NextResponse.json({ success: true, events, total });
return {
...event,
routeProvider: provider,
routeCombo: comboName,
routeEndpoint: endpoint,
routeConnectionId: connectionId,
routeConnectionShortId: connectionId ? connectionId.slice(0, 8) : null,
isComboRouted: Boolean(comboName),
};
});
return NextResponse.json({ success: true, events: normalizedEvents, total });
} catch (error) {
console.error("Error fetching history:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });

View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { transformChatCompletionSseToResponses } from "@/lib/translator/streamTransform";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const transformStreamSchema = z.object({
rawSse: z.string().min(1).max(100_000),
});
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
success: false,
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
const validation = validateBody(transformStreamSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
}
try {
const transformed = await transformChatCompletionSseToResponses(validation.data.rawSse);
return NextResponse.json({ success: true, transformed });
} catch (error) {
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "Failed to transform chat completions stream",
},
{ status: 500 }
);
}
}

View File

@@ -641,8 +641,27 @@
"autoConfiguredTab": "Auto-configured",
"guidedClientsTab": "Guided clients",
"mitmClientsTab": "MITM clients",
"customCliTab": "Custom CLI",
"allToolsTab": "All tools",
"visibleToolsCount": "{count} visible tools",
"customCliBuilderTitle": "OpenAI-compatible CLI builder",
"customCliBuilderDescription": "Generate env vars and JSON snippets for any CLI or SDK that accepts an OpenAI-compatible base URL, API key, and model ID.",
"customCliNameLabel": "CLI name",
"customCliNamePlaceholder": "e.g. My Team CLI",
"customCliDefaultModelLabel": "Default model",
"customCliDefaultModelHelp": "Use any OmniRoute model ID or combo. Most OpenAI-compatible CLIs only need the /v1 base URL plus a model string.",
"customCliKeyHelper": "For local installs OmniRoute can use sk_omniroute. In cloud mode, pick one of your management API keys.",
"customCliAliasMappingsLabel": "Alias mappings",
"customCliAliasMappingsHelp": "Optional helper aliases for wrapper scripts or config files that want stable shorthand names.",
"customCliAliasPlaceholder": "e.g. review",
"customCliTargetModelLabel": "Target model",
"customCliAddAlias": "Add alias",
"customCliNoMappings": "No alias mappings yet. Add one if your wrapper or team scripts use stable short names.",
"customCliNoModels": "Connect at least one provider to populate the model selectors.",
"customCliEndpointHintLabel": "How to wire the endpoint",
"customCliEndpointHint": "Point any OpenAI-compatible client to the OmniRoute /v1 base URL. The raw chat completions endpoint is {endpoint}. Use the JSON block when the tool wants a provider object, or the env script when it reads OPENAI_* variables.",
"customCliEnvBlockTitle": "Env / shell snippet",
"customCliJsonBlockTitle": "Provider JSON block",
"toolUseCases": {
"claude": "Use when you want strong planning workflows and long multi-file refactors with Claude Code.",
"codex": "Use when your team is standardized on OpenAI Codex CLI flows and profile-based auth.",
@@ -658,7 +677,8 @@
"windsurf": "Use when you want an AI-first IDE with Codeium/Windsurf models routed through OmniRoute.",
"antigravity": "Use when Antigravity/Kiro traffic must be intercepted through MITM and routed to OmniRoute.",
"copilot": "Use when you want Copilot chat style UX while enforcing OmniRoute keys and routing rules.",
"qwen": "Use when you need Alibaba Qwen Code CLI for coding tasks."
"qwen": "Use when you need Alibaba Qwen Code CLI for coding tasks.",
"custom": "Use when your CLI or SDK is not hardcoded in OmniRoute but still accepts an OpenAI-compatible base URL, API key, and model string."
},
"toolDescriptions": {
"antigravity": "Google Antigravity IDE with MITM",
@@ -675,7 +695,8 @@
"kiro": "Amazon Kiro — AI-powered IDE",
"windsurf": "Windsurf AI Code Editor",
"copilot": "GitHub Copilot AI Assistant",
"qwen": "Alibaba Qwen Code CLI"
"qwen": "Alibaba Qwen Code CLI",
"custom": "Generic OpenAI-compatible CLI or SDK configuration generator"
},
"guides": {
"cursor": {
@@ -1142,12 +1163,35 @@
},
"costs": {
"title": "Costs",
"pageDescription": "Track spend across providers, monitor budgets, and manage synced pricing data.",
"budget": "Budget",
"overview": "Overview",
"overviewTitle": "Cost Overview",
"overviewDescription": "Global spend across all API keys, providers, and models using live usage analytics.",
"overviewLoadFailed": "Failed to load cost overview",
"totalCost": "Total Cost",
"breakdown": "Cost Breakdown",
"noData": "No cost data",
"byModel": "By Model",
"byProvider": "By Provider"
"byProvider": "By Provider",
"range7d": "7D",
"range30d": "30D",
"range90d": "90D",
"rangeAll": "All",
"spendToday": "Spend Today",
"spend7d": "Spend 7D",
"spend30d": "Spend 30D",
"selectedWindow": "Selected Window",
"requestsInWindow": "Requests",
"activeProviders": "Active Providers",
"activeModels": "Active Models",
"avgCostPerRequest": "Avg Cost / Request",
"costTrend": "Daily Cost Trend",
"providerShare": "Spend by Provider",
"topProviders": "Top Providers",
"topModels": "Top Models",
"noCostDataTitle": "No spend recorded yet",
"noCostDataDescription": "Run traffic through OmniRoute or sync pricing data to populate this dashboard."
},
"endpoint": {
"title": "API Endpoint",
@@ -1954,6 +1998,7 @@
"modelRemovedSuccess": "Model removed successfully",
"failedDeleteModelTryAgain": "Failed to delete model. Please try again.",
"compatibleModelsDescription": "Add {type}-compatible models manually or import them from the /models endpoint.",
"ccCompatibleModelsDescription": "CC Compatible available models mirror the OAuth Claude Code provider list.",
"anthropicCompatibleModelPlaceholder": "claude-3-opus-20240229",
"openaiCompatibleModelPlaceholder": "gpt-4o",
"apiKeyValidationFailed": "API key validation failed. Please check your key and try again.",
@@ -2007,7 +2052,119 @@
"statusBanned": "Banned / Sandbox Violation",
"statusCreditsExhausted": "Insufficient Balance / Quota Exhausted",
"showEmails": "Show all emails",
"hideEmails": "Hide all emails"
"hideEmails": "Hide all emails",
"upstreamProxyManagedTitle": "Managed via Upstream Proxy Settings",
"upstreamProxyManagedDescription": "CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls.",
"openCliTools": "Open CLI Tools",
"openSettings": "Open Settings",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration is needed once an API key is connected.",
"perplexitySearchSharedKeyInfo": "Uses the same API key as Perplexity (chat provider). If you already have Perplexity configured, no additional setup is needed.",
"googlePseInfo": "Google Programmable Search requires two values: your API key and the Search Engine ID (cx) from the Programmable Search Engine dashboard.",
"searxngInfo": "SearXNG is self-hosted. Configure the instance base URL here. API key is optional and can be left blank for public or unauthenticated instances. Local/private URL validation requires OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true.",
"tokenExpiredTitle": "Token expired: {date}",
"tokenExpiredBadge": "Expired",
"tokenExpiresSoonTitle": "Token expires in {minutes}m",
"claudeExtraUsageToggleTitle": "Toggle Claude extra-usage blocking",
"claudeExtraUsageShort": "Block Extra",
"cpaModeEnabledTitle": "Using CLIProxyAPI for deeper Claude Code emulation (uTLS, multi-account, device profiles)",
"cpaModeDisabledTitle": "Enable CLIProxyAPI backend for deeper Claude Code OAuth emulation",
"codex5hToggleTitle": "Toggle Codex 5h limit policy",
"codexWeeklyToggleTitle": "Toggle Codex weekly limit policy",
"weeklyShort": "Weekly",
"toggleOnShort": "ON",
"toggleOffShort": "OFF",
"refreshOauthTokenTitle": "Refresh OAuth token manually",
"tokenShort": "Token",
"supportedEndpointsLabel": "Supported Endpoints",
"supportedEndpointChat": "Chat",
"supportedEndpointEmbeddings": "Embeddings",
"supportedEndpointImages": "Images",
"supportedEndpointAudio": "Audio",
"apiFormatLabel": "API Format",
"imagesShortLabel": "Images",
"audioShortLabel": "Audio",
"searchEngineIdLabel": "Search Engine ID (cx)",
"searchEngineIdHint": "Required. Find this in your Programmable Search Engine overview.",
"searchEngineIdRequired": "Programmable Search Engine ID (cx) is required.",
"ccCompatibleContext1mLabel": "CC Compatible 1M Context",
"ccCompatibleContext1mDescription": "When enabled, this connection appends `anthropic-beta: context-1m-2025-08-07`.",
"ccCompatibleValidationHint": "Validation uses the strict Claude Code-compatible bridge request for this provider.",
"ccCompatibleDetailsTitle": "CC Compatible Details",
"customUserAgentLabel": "Custom User-Agent",
"customUserAgentHint": "Optional override sent upstream as the User-Agent header for this connection.",
"routingTagsLabel": "Routing Tags",
"routingTagsPlaceholder": "fast, cheap, eu-region",
"routingTagsHint": "Comma-separated tags matched against request metadata.tags for tag-based routing.",
"excludedModelsLabel": "Excluded Models",
"excludedModelsPlaceholder": "gpt-5*, claude-opus-*, gemini-*-pro*",
"excludedModelsHint": "Comma-separated wildcard patterns. This connection will be skipped for matching models.",
"consoleApiKeyOracleLabel": "Console API Key (Oracle)",
"consoleApiKeyOraclePlaceholder": "Alibaba Console API Key",
"consoleApiKeyOracleHint": "Required for quota fetching. Do not share.",
"validationModelIdLabel": "Model ID (optional)",
"validationModelIdPlaceholder": "e.g. grok-3 or meta-llama/Llama-3.1-8B-Instruct",
"validationModelIdHint": "Used as a fallback if model listing is unavailable.",
"regionLabel": "Region",
"regionHint": "e.g. us-central1 or europe-west4. Partner models use the global region automatically.",
"accountIdLabel": "Account ID",
"accountIdPlaceholder": "Cloudflare Account ID",
"accountIdHint": "Find it in the Cloudflare dashboard URL or settings.",
"apiRegionLabel": "API Region",
"apiRegionHint": "Select the endpoint region for API access and quota tracking.",
"apiRegionInternational": "International (api.z.ai)",
"apiRegionChina": "China Mainland (open.bigmodel.cn)",
"extraApiKeysLabel": "Extra API Keys",
"extraApiKeysHint": "Round-robin rotation — optional",
"extraApiKeyMasked": "Key #{index}: {prefix}...{suffix}",
"removeThisKey": "Remove this key",
"addAnotherApiKey": "Add another API key...",
"totalKeysRotating": "{count} keys total — rotating round-robin on each request.",
"tagGroupLabel": "Tag / Group",
"tagGroupPlaceholder": "e.g. personal, work, team-a",
"tagGroupHint": "Used to group accounts in the provider view.",
"defaultThinkingStrengthLabel": "Default thinking strength",
"defaultThinkingStrengthHint": "Used when the client does not send a reasoning effort and the global Thinking Budget mode is passthrough.",
"maxConcurrentWholeNumberError": "Max concurrent must be a whole number greater than or equal to 0.",
"codexFastServiceTierLabel": "Codex Fast Service Tier",
"codexFastServiceTierDescription": "When enabled, injects `service_tier=priority` for this connection if the client leaves the tier unset.",
"openaiResponsesStoreLabel": "OpenAI Responses Store",
"openaiResponsesStoreDescription": "Preserves `store`, `previous_response_id`, and adds a stable fallback `session_id` for long Codex sessions. Enable only when the upstream account accepts stored Responses.",
"blockClaudeExtraUsageLabel": "Block Claude Extra Usage",
"blockClaudeExtraUsageDescription": "When enabled, OmniRoute marks this Claude Code account unavailable as soon as the usage API reports `extra_usage.queued`, so fallback switches to another account before extra pay-as-you-go charges continue.",
"hideEmail": "Hide email",
"showEmail": "Show email",
"vertexServiceAccountPlaceholder": "Paste the Service Account JSON here",
"personalAccessTokenLabel": "Personal Access Token",
"sessionCookieLabel": "Session Cookie",
"qoderPatPlaceholder": "Paste your Qoder Personal Access Token",
"qoderPatHint": "Supported path: PAT via qodercli. Browser OAuth remains experimental.",
"grokWebCookiePlaceholder": "Paste your sso cookie value from grok.com",
"grokWebCookieHint": "Paste the sso cookie from grok.com. A full `sso=...` value also works.",
"perplexityWebCookiePlaceholder": "Paste your __Secure-next-auth.session-token value",
"perplexityWebCookieHint": "Paste the __Secure-next-auth.session-token cookie from perplexity.ai.",
"blackboxWebCookiePlaceholder": "Paste your __Secure-authjs.session-token value",
"blackboxWebCookieHint": "Paste the __Secure-authjs.session-token cookie from app.blackbox.ai. A full cookie header also works.",
"museSparkWebCookiePlaceholder": "Paste your abra_sess value",
"museSparkWebCookieHint": "Paste the abra_sess cookie from meta.ai. A full cookie header also works.",
"apiKeyOptionalLabel": "API Key (optional)",
"apiKeyOptionalHint": "Optional. Leave blank if your SearXNG instance does not require authentication.",
"ccCompatibleNamePlaceholder": "CC Compatible Production",
"ccCompatibleNameHint": "Display name for this provider.",
"ccCompatiblePrefixPlaceholder": "cc",
"ccCompatiblePrefixHint": "Used for aliases such as prefix/model-id.",
"ccCompatibleBaseUrlPlaceholder": "https://example.com/v1",
"ccCompatibleBaseUrlHint": "Base URL for the CC-compatible site. Do not include /messages.",
"ccCompatibleChatPathHint": "Defaults to the strict Claude Code-compatible messages path.",
"compatUpstreamHeaderNamePlaceholder": "Authentication",
"compatUpstreamHeaderValuePlaceholder": "•••",
"azureOpenAiBaseUrlHint": "Required: paste your Azure OpenAI resource endpoint. OmniRoute will append /openai/deployments/{model}/chat/completions?api-version=....",
"bailianBaseUrlHint": "Optional: custom base URL for the bailian-coding-plan provider.",
"xiaomiMimoBaseUrlHint": "Optional: Xiaomi MiMo token-plan base URL. Examples: https://token-plan-ams.xiaomimimo.com/v1, https://token-plan-sgp.xiaomimimo.com/v1, https://token-plan-cn.xiaomimimo.com/v1. The app will append /chat/completions.",
"herokuBaseUrlHint": "Required: paste the Heroku Inference base URL. The app will append /v1/chat/completions.",
"databricksBaseUrlHint": "Required: paste the Databricks serving-endpoints base URL. The app will append /chat/completions.",
"snowflakeBaseUrlHint": "Required: paste the Snowflake account base URL. The app will append /api/v2/cortex/inference:complete.",
"searxngBaseUrlHint": "Required: paste your SearXNG instance base URL. The app will use /search and request format=json. Local/private URLs require OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true for dashboard validation."
},
"settings": {
"title": "Settings",
@@ -2112,6 +2269,7 @@
"pricingRates": "Pricing Rates Format",
"currentPricing": "Current Pricing Overview",
"loadingPricing": "Loading pricing data...",
"pricingLoadFailed": "Failed to load pricing data",
"noPricing": "No pricing data available",
"input": "Input",
"output": "Output",
@@ -2119,6 +2277,29 @@
"reasoning": "Reasoning",
"cacheCreation": "Cache Creation",
"customPricing": "Custom Pricing",
"pricingSyncTitle": "Pricing Sync",
"pricingSyncDescription": "Trigger LiteLLM sync, inspect sync status, and distinguish synced prices from manual overrides.",
"pricingSyncStatus": "Sync Status",
"syncEnabled": "Periodic sync enabled",
"syncDisabled": "Manual sync only",
"syncedModels": "Synced Models",
"clearSyncedPricing": "Clear Synced",
"clearSyncedPricingConfirm": "Clear all LiteLLM-synced pricing data?",
"clearSyncedPricingSuccess": "Cleared synced pricing data",
"clearSyncedPricingFailed": "Failed to clear synced pricing data",
"clearSyncedPricingFailedWithReason": "Failed to clear synced pricing data: {reason}",
"pricingSourceUser": "Override",
"pricingSourceModelsDev": "models.dev",
"pricingSourceLiteLLM": "LiteLLM",
"pricingSourceDefault": "Default",
"pricingSavedProvider": "Saved pricing for {provider}",
"pricingSaveFailedWithReason": "Failed to save pricing: {reason}",
"pricingResetProvider": "Reset pricing for {provider}",
"pricingResetFailedWithReason": "Failed to reset pricing: {reason}",
"pricingSyncSuccess": "Synced pricing for {count} models",
"pricingSyncFailed": "Pricing sync failed",
"pricingSyncFailedWithReason": "Pricing sync failed: {reason}",
"unknownError": "Unknown error",
"databaseSize": "Database Size",
"backupDb": "Backup Database",
"restoreDb": "Restore Database",
@@ -2541,10 +2722,12 @@
"realtime": "Real-Time Translation Activity",
"chatTester": "Chat Tester",
"testBench": "Test Bench",
"streamTransformer": "Stream Transformer",
"liveMonitor": "Live Monitor",
"modeDescriptionPlayground": "Paste any API request body and see how OmniRoute translates it between provider formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API)",
"modeDescriptionChatTester": "Send real chat requests through OmniRoute and inspect the full round-trip: input, translated request, provider response, and translated output.",
"modeDescriptionTestBench": "Run predefined scenarios and compare compatibility across providers and models.",
"modeDescriptionStreamTransformer": "Transform Chat Completions SSE into Responses API SSE and inspect emitted events.",
"modeDescriptionLiveMonitor": "Watch translation events in real time as requests flow through OmniRoute.",
"modeDescriptionFallback": "Debug, test, and visualize how OmniRoute translates API requests between providers.",
"recentTranslations": "Recent Translations",
@@ -2601,6 +2784,8 @@
"scenarioThinking": "Thinking",
"scenarioSystemPrompt": "System Prompt",
"scenarioStreaming": "Streaming",
"scenarioVision": "Vision",
"scenarioSchemaCoercion": "Schema Coercion",
"templateNames": {
"simple-chat": "Simple Chat",
"tool-calling": "Tool Calling",
@@ -2681,6 +2866,27 @@
"send": "Send",
"clientRequest": "Client Request",
"clientRequestDescription": "The request body as your client would send it",
"streamTransformerTitle": "Responses Stream Transformer",
"streamTransformerDescription": "Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client.",
"rawChatSseInput": "Raw chat completions SSE",
"transformToResponses": "Transform to Responses",
"loadTextSample": "Load text sample",
"loadToolSample": "Load tool-call sample",
"transformedResponsesSse": "Transformed Responses API SSE",
"transformedEvents": "Transformed events",
"uniqueEventTypes": "Unique event types",
"inputLines": "Input lines",
"outputLines": "Output lines",
"transformedEventTimeline": "Transformed event timeline",
"transformerTimelineHint": "Run the transformer to inspect emitted response.output_* events in order.",
"eventType": "Event type",
"eventPreview": "Preview",
"comboRouted": "Combo-routed",
"uniqueEndpoints": "Unique endpoints",
"routeDetails": "Route",
"comboBadge": "Combo",
"routeEndpointLabel": "Endpoint",
"routeConnectionLabel": "Conn",
"formatDetected": "Format Detected",
"formatDetectedDescription": "OmniRoute auto-detects the API format from the request structure",
"openaiIntermediate": "OpenAI Intermediate",
@@ -2761,6 +2967,45 @@
"evaluateStepDescription": "Responses are compared against expected criteria. See pass/fail for each case with latency metrics and detailed feedback.",
"evalSuites": "Evaluation Suites",
"evalSuitesHint": "Click a suite to view test cases, then run to evaluate your LLM endpoints",
"suiteBuilderNewSuite": "New Suite",
"suiteBuilderCreateTitle": "Create Custom Eval Suite",
"suiteBuilderEditTitle": "Edit Custom Eval Suite",
"suiteBuilderNameLabel": "Suite Name",
"suiteBuilderNamePlaceholder": "Customer support regression suite",
"suiteBuilderDescriptionLabel": "Description",
"suiteBuilderDescriptionPlaceholder": "Explain what this suite validates and why it matters.",
"suiteBuilderCasesTitle": "Custom Cases",
"suiteBuilderCasesHint": "Each case sends a real prompt through OmniRoute and checks the response with contains, exact, or regex matching.",
"suiteBuilderAddCase": "Add Case",
"suiteBuilderCaseCardTitle": "Case {index}",
"suiteBuilderCaseCardHint": "Define one prompt and one assertion for this regression check.",
"suiteBuilderCaseNameLabel": "Case Name",
"suiteBuilderCaseNamePlaceholder": "Refund policy response",
"suiteBuilderCaseModelLabel": "Default Model",
"suiteBuilderCaseModelPlaceholder": "gpt-4o-mini",
"suiteBuilderCaseTagsLabel": "Tags",
"suiteBuilderCaseTagsPlaceholder": "support, regression, billing",
"suiteBuilderCaseTagsHint": "Comma-separated tags stored with the case.",
"suiteBuilderCaseStrategyLabel": "Expected Strategy",
"suiteBuilderCaseSystemPromptLabel": "System Prompt",
"suiteBuilderCaseSystemPromptPlaceholder": "Optional system instruction for this case.",
"suiteBuilderCaseUserPromptLabel": "User Prompt",
"suiteBuilderCaseUserPromptPlaceholder": "Write the exact user input to replay during the eval.",
"suiteBuilderCaseExpectedLabel": "Expected Match",
"suiteBuilderCaseExpectedPlaceholder": "Text or regex that should appear in the model response.",
"suiteBuilderCreateAction": "Create Suite",
"suiteBuilderCreated": "Custom suite created",
"suiteBuilderUpdated": "Custom suite updated",
"suiteBuilderDeleted": "Custom suite deleted",
"suiteBuilderDeleteConfirm": "Delete the custom suite \"{name}\"?",
"suiteBuilderSaveFailed": "Failed to save custom suite",
"suiteBuilderDeleteFailed": "Failed to delete custom suite",
"suiteBuilderNameRequired": "Suite name is required",
"suiteBuilderCasesRequired": "Add at least one case before saving",
"suiteBuilderCaseInvalid": "Case {index} is incomplete. Fill name, model, prompt, and expected value.",
"suiteBuilderCustomBadge": "Custom",
"suiteBuilderBuiltInBadge": "Built-in",
"suiteBuilderUpdatedAt": "Updated {value}",
"evalsLoading": "Loading eval suites...",
"noEvalSuitesFound": "No Eval Suites Found",
"noEvalSuitesDescription": "Eval suites can be defined via the API or in code. They test model outputs against expected results using strategies like contains, regex, exact match, and custom functions.",
@@ -3363,7 +3608,7 @@
},
"agents": {
"title": "Local ACP Agents (Workers)",
"description": "Discover installed CLI binaries that OmniRoute can spawn locally as ACP workers for execution tasks.",
"description": "Discover installed CLI binaries that OmniRoute can spawn locally as ACP workers to execute tasks, read local files, and act as the final execution proxy.",
"architectureTitle": "ACP execution flow",
"architectureDescription": "This screen is for binaries OmniRoute launches locally as workers. OmniRoute detects the binary, spawns it, and uses it as the final execution endpoint.",
"cliToolsRedirectTitle": "Looking for client configuration?",
@@ -3382,11 +3627,15 @@
"addCustomAgent": "Add Custom ACP Worker",
"addCustomAgentDesc": "Register any local binary OmniRoute should detect and manage as a worker. It will be rescanned automatically on refresh.",
"agentName": "Agent Name",
"agentNamePlaceholder": "e.g. My Custom Worker",
"binaryName": "Binary Name",
"binaryNamePlaceholder": "e.g. my-worker",
"versionCommand": "Version Command",
"versionCommandPlaceholder": "e.g. my-worker --version",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent",
"scanning": "Scanning system for CLI agents...",
"spawnArgsPlaceholder": "e.g. --quiet, --json",
"addAgent": "Add Worker",
"scanning": "Scanning local ACP workers...",
"opencodeIntegration": "OpenCode Integration",
"opencodeDetected": "opencode {version} detected",
"opencodeDesc": "Generate a ready-to-use {configFile} with your OmniRoute base URL and all available models — drop it in your project root and run {command}.",

View File

@@ -636,8 +636,27 @@
"autoConfiguredTab": "Auto-configurados",
"guidedClientsTab": "Clientes guiados",
"mitmClientsTab": "Clientes MITM",
"customCliTab": "Custom CLI",
"allToolsTab": "Todas as ferramentas",
"visibleToolsCount": "{count} ferramentas visíveis",
"customCliBuilderTitle": "Builder para CLI OpenAI-compatible",
"customCliBuilderDescription": "Gera variáveis de ambiente e blocos JSON para qualquer CLI ou SDK que aceite base URL, chave de API e model ID compatíveis com OpenAI.",
"customCliNameLabel": "Nome do CLI",
"customCliNamePlaceholder": "ex.: CLI do time",
"customCliDefaultModelLabel": "Modelo padrão",
"customCliDefaultModelHelp": "Use qualquer model ID ou combo do OmniRoute. A maioria dos CLIs OpenAI-compatible só precisa da base URL /v1 e de uma string de modelo.",
"customCliKeyHelper": "Em instalações locais o OmniRoute pode usar sk_omniroute. No modo cloud, escolha uma das suas management API keys.",
"customCliAliasMappingsLabel": "Mapeamentos de alias",
"customCliAliasMappingsHelp": "Aliases opcionais para wrappers ou arquivos de configuração que precisem de nomes curtos e estáveis.",
"customCliAliasPlaceholder": "ex.: review",
"customCliTargetModelLabel": "Modelo de destino",
"customCliAddAlias": "Adicionar alias",
"customCliNoMappings": "Ainda não há aliases. Adicione um se seus scripts ou wrappers usam nomes curtos estáveis.",
"customCliNoModels": "Conecte ao menos um provider para popular os seletores de modelo.",
"customCliEndpointHintLabel": "Como apontar o endpoint",
"customCliEndpointHint": "Aponte qualquer cliente OpenAI-compatible para a base URL /v1 do OmniRoute. O endpoint bruto de chat completions é {endpoint}. Use o bloco JSON quando a ferramenta pedir um objeto de provider, ou o script quando ela ler variáveis OPENAI_*.",
"customCliEnvBlockTitle": "Snippet de env / shell",
"customCliJsonBlockTitle": "Bloco JSON do provider",
"toolUseCases": {
"claude": "Use quando desejar fluxos de trabalho de planejamento robustos e refatorações longas de vários arquivos com Claude Code.",
"codex": "Use quando sua equipe estiver padronizada em fluxos OpenAI Codex CLI e autenticação baseada em perfil.",
@@ -652,7 +671,9 @@
"kiro": "Use ao integrar o Kiro e controlar o roteamento do modelo centralmente no OmniRoute.",
"antigravity": "Use quando o tráfego Antigravity/Kiro deve ser interceptado através do MITM e roteado para OmniRoute.",
"copilot": "Use quando desejar UX no estilo de bate-papo do Copilot enquanto impõe chaves OmniRoute e regras de roteamento.",
"windsurf": "Use quando quiser uma IDE AI-first com modelos Codeium/Windsurf roteados pelo OmniRoute."
"windsurf": "Use quando quiser uma IDE AI-first com modelos Codeium/Windsurf roteados pelo OmniRoute.",
"qwen": "Use quando precisar do Alibaba Qwen Code CLI para tarefas de programação.",
"custom": "Use quando seu CLI ou SDK não estiver hardcoded no OmniRoute, mas ainda aceitar base URL, chave de API e model string compatíveis com OpenAI."
},
"toolDescriptions": {
"antigravity": "Google Antigravity IDE com MITM",
@@ -669,7 +690,8 @@
"kiro": "Amazon Kiro — IDE com IA",
"windsurf": "Windsurf — Editor de Código com IA",
"copilot": "GitHub Copilot — Assistente de IA",
"qwen": "Alibaba Qwen Code CLI"
"qwen": "Alibaba Qwen Code CLI",
"custom": "Gerador genérico de configuração para CLI ou SDK OpenAI-compatible"
},
"guides": {
"cursor": {
@@ -1147,12 +1169,35 @@
},
"costs": {
"title": "Custos",
"pageDescription": "Acompanhe gasto por provedor, monitore orçamentos e gerencie preços sincronizados.",
"budget": "Orçamento",
"overview": "Visão Geral",
"overviewTitle": "Visão Geral de Custos",
"overviewDescription": "Gasto global em todas as API keys, provedores e modelos usando analytics de uso em tempo real.",
"overviewLoadFailed": "Falha ao carregar a visão geral de custos",
"totalCost": "Custo Total",
"breakdown": "Detalhamento de Custos",
"noData": "Sem dados de custo",
"byModel": "Por Modelo",
"byProvider": "Por Provedor"
"byProvider": "Por Provedor",
"range7d": "7D",
"range30d": "30D",
"range90d": "90D",
"rangeAll": "Tudo",
"spendToday": "Gasto Hoje",
"spend7d": "Gasto 7D",
"spend30d": "Gasto 30D",
"selectedWindow": "Janela Selecionada",
"requestsInWindow": "Requisições",
"activeProviders": "Provedores Ativos",
"activeModels": "Modelos Ativos",
"avgCostPerRequest": "Custo Médio / Requisição",
"costTrend": "Tendência Diária de Custo",
"providerShare": "Gasto por Provedor",
"topProviders": "Principais Provedores",
"topModels": "Principais Modelos",
"noCostDataTitle": "Ainda não há gasto registrado",
"noCostDataDescription": "Passe tráfego pelo OmniRoute ou sincronize preços para preencher este dashboard."
},
"endpoint": {
"title": "Endpoint da API",
@@ -1945,6 +1990,7 @@
"modelRemovedSuccess": "Modelo removido com sucesso",
"failedDeleteModelTryAgain": "Falha ao excluir modelo. Tente novamente.",
"compatibleModelsDescription": "Adicione modelos compatíveis com {type} manualmente ou importe-os do endpoint /models.",
"ccCompatibleModelsDescription": "Os modelos disponíveis de CC Compatível espelham a lista do provedor OAuth Claude Code.",
"anthropicCompatibleModelPlaceholder": "claude-3-opus-20240229",
"openaiCompatibleModelPlaceholder": "gpt-4o",
"apiKeyValidationFailed": "A validação da chave de API falhou. Verifique sua chave e tente novamente.",
@@ -1974,40 +2020,152 @@
"failed": "Falhou",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave de API atual.",
"editCompatibleTitle": "Editar Compatível {type}",
"compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.",
"compatibleBaseUrlHint": "URL raiz da sua API compatível com {type}. Use Configurações Avançadas para caminhos de endpoint personalizados.",
"apiKeyForCheck": "Chave de API (para verificação)",
"compatibleProdPlaceholder": "{type} Compatível (Prod)",
"tokenRefreshed": "Token refreshed successfully",
"tokenRefreshFailed": "Token refresh failed",
"tokenRefreshed": "Token atualizado com sucesso",
"tokenRefreshFailed": "Falha ao atualizar o token",
"applyCodexAuthLocal": "Aplicar auth",
"exportCodexAuthFile": "Exportar auth",
"codexAuthAppliedLocal": "auth.json do Codex aplicado localmente",
"codexAuthApplyFailed": "Falha ao aplicar o auth.json do Codex localmente",
"codexAuthExported": "auth.json do Codex exportado",
"codexAuthExportFailed": "Falha ao exportar o auth.json do Codex",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"advancedSettings": "Configurações Avançadas",
"chatPathLabel": "Caminho do Endpoint de Chat",
"chatPathPlaceholder": "/chat/completions",
"chatPathHint": "Custom chat path for providers with non-standard APIs (e.g. /v4/chat/completions)",
"modelsPathLabel": "Models Endpoint Path",
"chatPathHint": "Caminho de chat personalizado para provedores com APIs fora do padrão (ex.: /v4/chat/completions)",
"modelsPathLabel": "Caminho do Endpoint de Models",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)",
"modelsPathHint": "Caminho de models personalizado para validação (ex.: /v4/models)",
"statusDeactivated": "Desativado (Manual)",
"statusBanned": "Banido / Sandbox Violation",
"statusCreditsExhausted": "Saldo Insuficiente",
"modelsImported": "{count} models imported",
"close": "Close",
"modelsImported": "{count} modelos importados",
"close": "Fechar",
"embeddings": "Embeddings",
"audioSpeech": "Audio Speech",
"imagesGenerations": "Images Generations",
"repairEnv": "Repair env",
"repairEnvFailed": "Failed to repair .env",
"repairEnvHint": "Restore missing OAuth defaults into .env without overwriting existing values.",
"showEmails": "Show all emails",
"repairEnvWorking": "Repairing...",
"repairEnvSuccess": "OAuth defaults restored",
"hideEmails": "Hide all emails",
"audioTranscriptions": "Audio Transcriptions"
"audioSpeech": "Áudio para Fala",
"imagesGenerations": "Geração de Imagens",
"repairEnv": "Reparar env",
"repairEnvFailed": "Falha ao reparar o .env",
"repairEnvHint": "Restaura defaults OAuth ausentes no .env sem sobrescrever valores existentes.",
"showEmails": "Mostrar todos os e-mails",
"repairEnvWorking": "Reparando...",
"repairEnvSuccess": "Defaults OAuth restaurados",
"hideEmails": "Ocultar todos os e-mails",
"audioTranscriptions": "Transcrições de Áudio",
"upstreamProxyManagedTitle": "Gerenciado via Configurações de Proxy Upstream",
"upstreamProxyManagedDescription": "CLIProxyAPI é configurado como uma camada de proxy upstream, não como uma conexão direta de provedor. Gerencie o binário/runtime em CLI Tools e habilite o roteamento por proxy em cada provedor pelos controles de proxy do provedor.",
"openCliTools": "Abrir CLI Tools",
"openSettings": "Abrir Configurações",
"searchProvider": "Provedor de Busca",
"searchProviderDesc": "Este provedor é usado para busca web via POST /v1/search. Nenhuma configuração de modelo é necessária depois que uma chave de API estiver conectada.",
"perplexitySearchSharedKeyInfo": "Usa a mesma chave de API do Perplexity (provedor de chat). Se você já configurou o Perplexity, não é necessário setup adicional.",
"googlePseInfo": "O Google Programmable Search exige dois valores: sua chave de API e o Search Engine ID (cx) do painel do Programmable Search Engine.",
"searxngInfo": "SearXNG é self-hosted. Configure aqui a URL base da instância. A chave de API é opcional e pode ficar em branco para instâncias públicas ou sem autenticação. A validação de URLs locais/privadas exige OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true.",
"tokenExpiredTitle": "Token expirado: {date}",
"tokenExpiredBadge": "Expirado",
"tokenExpiresSoonTitle": "Token expira em {minutes}m",
"claudeExtraUsageToggleTitle": "Alternar bloqueio de extra usage do Claude",
"claudeExtraUsageShort": "Bloquear Extra",
"cpaModeEnabledTitle": "Usando CLIProxyAPI para uma emulação mais profunda do Claude Code (uTLS, multi-conta, perfis de dispositivo)",
"cpaModeDisabledTitle": "Habilitar backend CLIProxyAPI para emulação OAuth mais profunda do Claude Code",
"codex5hToggleTitle": "Alternar política de limite 5h do Codex",
"codexWeeklyToggleTitle": "Alternar política de limite semanal do Codex",
"weeklyShort": "Semanal",
"toggleOnShort": "ON",
"toggleOffShort": "OFF",
"refreshOauthTokenTitle": "Atualizar token OAuth manualmente",
"tokenShort": "Token",
"supportedEndpointsLabel": "Endpoints Suportados",
"supportedEndpointChat": "Chat",
"supportedEndpointEmbeddings": "Embeddings",
"supportedEndpointImages": "Imagens",
"supportedEndpointAudio": "Áudio",
"apiFormatLabel": "Formato da API",
"imagesShortLabel": "Imagens",
"audioShortLabel": "Áudio",
"searchEngineIdLabel": "Search Engine ID (cx)",
"searchEngineIdHint": "Obrigatório. Encontre isso na visão geral do seu Programmable Search Engine.",
"searchEngineIdRequired": "O Search Engine ID (cx) do Programmable Search Engine é obrigatório.",
"ccCompatibleContext1mLabel": "Contexto 1M CC Compatível",
"ccCompatibleContext1mDescription": "Quando habilitado, esta conexão adiciona `anthropic-beta: context-1m-2025-08-07`.",
"ccCompatibleValidationHint": "A validação usa a bridge estrita compatível com Claude Code para este provedor.",
"ccCompatibleDetailsTitle": "Detalhes CC Compatível",
"customUserAgentLabel": "User-Agent Customizado",
"customUserAgentHint": "Override opcional enviado upstream como cabeçalho User-Agent desta conexão.",
"routingTagsLabel": "Tags de Roteamento",
"routingTagsPlaceholder": "fast, cheap, eu-region",
"routingTagsHint": "Tags separadas por vírgula, comparadas com request metadata.tags para roteamento por tags.",
"excludedModelsLabel": "Modelos Excluídos",
"excludedModelsPlaceholder": "gpt-5*, claude-opus-*, gemini-*-pro*",
"excludedModelsHint": "Padrões wildcard separados por vírgula. Esta conexão será ignorada para modelos correspondentes.",
"consoleApiKeyOracleLabel": "Chave de API do Console (Oracle)",
"consoleApiKeyOraclePlaceholder": "Chave de API do Console Alibaba",
"consoleApiKeyOracleHint": "Obrigatório para consulta de cota. Não compartilhe.",
"validationModelIdLabel": "ID do Modelo (opcional)",
"validationModelIdPlaceholder": "ex.: grok-3 ou meta-llama/Llama-3.1-8B-Instruct",
"validationModelIdHint": "Usado como fallback se a listagem de modelos não estiver disponível.",
"regionLabel": "Região",
"regionHint": "ex.: us-central1 ou europe-west4. Modelos partner usam a região global automaticamente.",
"accountIdLabel": "Account ID",
"accountIdPlaceholder": "Cloudflare Account ID",
"accountIdHint": "Encontre isso na URL do dashboard da Cloudflare ou nas configurações.",
"apiRegionLabel": "Região da API",
"apiRegionHint": "Selecione a região do endpoint para acesso à API e rastreamento de cota.",
"apiRegionInternational": "Internacional (api.z.ai)",
"apiRegionChina": "China Continental (open.bigmodel.cn)",
"extraApiKeysLabel": "Chaves de API Extras",
"extraApiKeysHint": "Rotação round-robin — opcional",
"extraApiKeyMasked": "Chave #{index}: {prefix}...{suffix}",
"removeThisKey": "Remover esta chave",
"addAnotherApiKey": "Adicionar outra chave de API...",
"totalKeysRotating": "{count} chaves no total — rotacionando em round-robin a cada request.",
"tagGroupLabel": "Tag / Grupo",
"tagGroupPlaceholder": "ex.: personal, work, team-a",
"tagGroupHint": "Usado para agrupar contas na visão do provedor.",
"defaultThinkingStrengthLabel": "Intensidade de raciocínio padrão",
"defaultThinkingStrengthHint": "Usada quando o cliente não envia reasoning effort e o modo global Thinking Budget está em passthrough.",
"maxConcurrentWholeNumberError": "Max concurrent deve ser um número inteiro maior ou igual a 0.",
"codexFastServiceTierLabel": "Codex Fast Service Tier",
"codexFastServiceTierDescription": "Quando habilitado, injeta `service_tier=priority` para esta conexão se o cliente não definir o tier.",
"openaiResponsesStoreLabel": "OpenAI Responses Store",
"openaiResponsesStoreDescription": "Preserva `store`, `previous_response_id` e adiciona um `session_id` estável como fallback para sessões longas do Codex. Habilite apenas quando a conta upstream aceitar Responses armazenadas.",
"blockClaudeExtraUsageLabel": "Bloquear Claude Extra Usage",
"blockClaudeExtraUsageDescription": "Quando habilitado, o OmniRoute marca esta conta do Claude Code como indisponível assim que a API de usage reporta `extra_usage.queued`, para que o fallback troque para outra conta antes de continuar com cobranças extras pay-as-you-go.",
"hideEmail": "Ocultar e-mail",
"showEmail": "Mostrar e-mail",
"vertexServiceAccountPlaceholder": "Cole o JSON da Service Account aqui",
"personalAccessTokenLabel": "Personal Access Token",
"sessionCookieLabel": "Cookie de Sessão",
"qoderPatPlaceholder": "Cole seu Personal Access Token do Qoder",
"qoderPatHint": "Fluxo suportado: PAT via qodercli. OAuth pelo navegador continua experimental.",
"grokWebCookiePlaceholder": "Cole o valor do cookie sso do grok.com",
"grokWebCookieHint": "Cole o cookie sso do grok.com. Um valor completo `sso=...` também funciona.",
"perplexityWebCookiePlaceholder": "Cole o valor de __Secure-next-auth.session-token",
"perplexityWebCookieHint": "Cole o cookie __Secure-next-auth.session-token do perplexity.ai.",
"blackboxWebCookiePlaceholder": "Cole o valor de __Secure-authjs.session-token",
"blackboxWebCookieHint": "Cole o cookie __Secure-authjs.session-token de app.blackbox.ai. Um cabeçalho de cookie completo também funciona.",
"museSparkWebCookiePlaceholder": "Cole o valor de abra_sess",
"museSparkWebCookieHint": "Cole o cookie abra_sess do meta.ai. Um cabeçalho de cookie completo também funciona.",
"apiKeyOptionalLabel": "Chave de API (opcional)",
"apiKeyOptionalHint": "Opcional. Deixe em branco se sua instância SearXNG não exigir autenticação.",
"ccCompatibleNamePlaceholder": "Produção CC Compatível",
"ccCompatibleNameHint": "Nome de exibição para este provedor.",
"ccCompatiblePrefixPlaceholder": "cc",
"ccCompatiblePrefixHint": "Usado para aliases como prefix/model-id.",
"ccCompatibleBaseUrlPlaceholder": "https://example.com/v1",
"ccCompatibleBaseUrlHint": "URL base do site compatível com CC. Não inclua /messages.",
"ccCompatibleChatPathHint": "O padrão é o caminho estrito de messages compatível com Claude Code.",
"compatUpstreamHeaderNamePlaceholder": "Authentication",
"compatUpstreamHeaderValuePlaceholder": "•••",
"azureOpenAiBaseUrlHint": "Obrigatório: cole o endpoint do seu recurso Azure OpenAI. O OmniRoute adicionará /openai/deployments/{model}/chat/completions?api-version=....",
"bailianBaseUrlHint": "Opcional: URL base customizada para o provedor bailian-coding-plan.",
"xiaomiMimoBaseUrlHint": "Opcional: URL base token-plan do Xiaomi MiMo. Exemplos: https://token-plan-ams.xiaomimimo.com/v1, https://token-plan-sgp.xiaomimimo.com/v1, https://token-plan-cn.xiaomimimo.com/v1. O app adicionará /chat/completions.",
"herokuBaseUrlHint": "Obrigatório: cole a URL base do Heroku Inference. O app adicionará /v1/chat/completions.",
"databricksBaseUrlHint": "Obrigatório: cole a URL base do serving-endpoints do Databricks. O app adicionará /chat/completions.",
"snowflakeBaseUrlHint": "Obrigatório: cole a URL base da conta Snowflake. O app adicionará /api/v2/cortex/inference:complete.",
"searxngBaseUrlHint": "Obrigatório: cole a URL base da sua instância SearXNG. O app usará /search e request format=json. URLs locais/privadas exigem OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true para validação no dashboard."
},
"settings": {
"title": "Configurações",
@@ -2070,6 +2228,7 @@
"pricingRates": "Formato de Taxas de Preço",
"currentPricing": "Visão Geral de Preços Atual",
"loadingPricing": "Carregando dados de preços...",
"pricingLoadFailed": "Falha ao carregar dados de preços",
"noPricing": "Nenhum dado de preço disponível",
"input": "Entrada",
"output": "Saída",
@@ -2077,6 +2236,29 @@
"reasoning": "Raciocínio",
"cacheCreation": "Criação de Cache",
"customPricing": "Preços Personalizados",
"pricingSyncTitle": "Sincronização de Preços",
"pricingSyncDescription": "Dispare o sync do LiteLLM, inspecione o status e diferencie preços sincronizados de overrides manuais.",
"pricingSyncStatus": "Status da Sincronização",
"syncEnabled": "Sincronização periódica ativada",
"syncDisabled": "Somente sync manual",
"syncedModels": "Modelos Sincronizados",
"clearSyncedPricing": "Limpar Sync",
"clearSyncedPricingConfirm": "Limpar todos os dados de preços sincronizados via LiteLLM?",
"clearSyncedPricingSuccess": "Dados sincronizados de preços removidos",
"clearSyncedPricingFailed": "Falha ao limpar dados sincronizados de preços",
"clearSyncedPricingFailedWithReason": "Falha ao limpar dados sincronizados de preços: {reason}",
"pricingSourceUser": "Override",
"pricingSourceModelsDev": "models.dev",
"pricingSourceLiteLLM": "LiteLLM",
"pricingSourceDefault": "Padrão",
"pricingSavedProvider": "Preços salvos para {provider}",
"pricingSaveFailedWithReason": "Falha ao salvar preços: {reason}",
"pricingResetProvider": "Preços resetados para {provider}",
"pricingResetFailedWithReason": "Falha ao resetar preços: {reason}",
"pricingSyncSuccess": "Preços sincronizados para {count} modelos",
"pricingSyncFailed": "Falha na sincronização de preços",
"pricingSyncFailedWithReason": "Falha na sincronização de preços: {reason}",
"unknownError": "Erro desconhecido",
"databaseSize": "Tamanho do Banco de Dados",
"backupDb": "Backup do Banco de Dados",
"restoreDb": "Restaurar Banco de Dados",
@@ -2539,10 +2721,12 @@
"realtime": "Atividade de Tradução em Tempo Real",
"chatTester": "Testador de Chat",
"testBench": "Bancada de Testes",
"streamTransformer": "Transformer de Stream",
"liveMonitor": "Monitor ao Vivo",
"modeDescriptionPlayground": "Cole qualquer corpo de requisição de API e veja como o OmniRoute traduz entre formatos de provedores (OpenAI ↔ Claude ↔ Gemini ↔ Responses API)",
"modeDescriptionChatTester": "Envie requisições reais de chat pelo OmniRoute e inspecione o ciclo completo: entrada, requisição traduzida, resposta do provedor e saída traduzida.",
"modeDescriptionTestBench": "Execute cenários predefinidos e compare compatibilidade entre provedores e modelos.",
"modeDescriptionStreamTransformer": "Transforme SSE de Chat Completions em SSE da Responses API e inspecione os eventos emitidos.",
"modeDescriptionLiveMonitor": "Acompanhe eventos de tradução em tempo real conforme as requisições passam pelo OmniRoute.",
"modeDescriptionFallback": "Depure, teste e visualize como o OmniRoute traduz requisições de API entre provedores.",
"recentTranslations": "Traduções Recentes",
@@ -2599,6 +2783,8 @@
"scenarioThinking": "Raciocínio",
"scenarioSystemPrompt": "Prompt de Sistema",
"scenarioStreaming": "Streaming",
"scenarioVision": "Visão",
"scenarioSchemaCoercion": "Coerção de Schema",
"templateNames": {
"simple-chat": "Chat Simples",
"tool-calling": "Chamada de Ferramenta",
@@ -2679,6 +2865,27 @@
"send": "Enviar",
"clientRequest": "Requisição do Cliente",
"clientRequestDescription": "O corpo de requisição como seu cliente enviaria",
"streamTransformerTitle": "Transformer de Stream para Responses",
"streamTransformerDescription": "Cole um stream SSE de chat completions, passe-o pelo transformer de Responses do OmniRoute e inspecione os eventos response.* emitidos antes de integrar um cliente.",
"rawChatSseInput": "SSE bruto de chat completions",
"transformToResponses": "Transformar para Responses",
"loadTextSample": "Carregar exemplo de texto",
"loadToolSample": "Carregar exemplo com tool call",
"transformedResponsesSse": "SSE transformado da Responses API",
"transformedEvents": "Eventos transformados",
"uniqueEventTypes": "Tipos únicos de evento",
"inputLines": "Linhas de entrada",
"outputLines": "Linhas de saída",
"transformedEventTimeline": "Linha do tempo dos eventos transformados",
"transformerTimelineHint": "Execute o transformer para inspecionar em ordem os eventos response.output_* emitidos.",
"eventType": "Tipo de evento",
"eventPreview": "Prévia",
"comboRouted": "Roteadas por combo",
"uniqueEndpoints": "Endpoints únicos",
"routeDetails": "Rota",
"comboBadge": "Combo",
"routeEndpointLabel": "Endpoint",
"routeConnectionLabel": "Conn",
"formatDetected": "Formato Detectado",
"formatDetectedDescription": "O OmniRoute detecta automaticamente o formato da API a partir da estrutura da requisição",
"openaiIntermediate": "Intermediário OpenAI",
@@ -2759,6 +2966,45 @@
"evaluateStepDescription": "As respostas são comparadas com os critérios esperados. Veja aprovado/reprovado por caso com métricas de latência e feedback detalhado.",
"evalSuites": "Suítes de Avaliação",
"evalSuitesHint": "Clique em uma suíte para ver os casos de teste e execute para avaliar seus endpoints de LLM",
"suiteBuilderNewSuite": "Nova Suíte",
"suiteBuilderCreateTitle": "Criar Suíte Customizada de Eval",
"suiteBuilderEditTitle": "Editar Suíte Customizada de Eval",
"suiteBuilderNameLabel": "Nome da Suíte",
"suiteBuilderNamePlaceholder": "Suíte de regressão de suporte ao cliente",
"suiteBuilderDescriptionLabel": "Descrição",
"suiteBuilderDescriptionPlaceholder": "Explique o que esta suíte valida e por que ela importa.",
"suiteBuilderCasesTitle": "Casos Customizados",
"suiteBuilderCasesHint": "Cada caso envia um prompt real pelo OmniRoute e valida a resposta com contains, exact ou regex.",
"suiteBuilderAddCase": "Adicionar Caso",
"suiteBuilderCaseCardTitle": "Caso {index}",
"suiteBuilderCaseCardHint": "Defina um prompt e uma asserção para esta checagem de regressão.",
"suiteBuilderCaseNameLabel": "Nome do Caso",
"suiteBuilderCaseNamePlaceholder": "Resposta sobre política de reembolso",
"suiteBuilderCaseModelLabel": "Modelo Padrão",
"suiteBuilderCaseModelPlaceholder": "gpt-4o-mini",
"suiteBuilderCaseTagsLabel": "Tags",
"suiteBuilderCaseTagsPlaceholder": "support, regression, billing",
"suiteBuilderCaseTagsHint": "Tags separadas por vírgula e armazenadas com o caso.",
"suiteBuilderCaseStrategyLabel": "Estratégia Esperada",
"suiteBuilderCaseSystemPromptLabel": "Prompt de Sistema",
"suiteBuilderCaseSystemPromptPlaceholder": "Instrução de sistema opcional para este caso.",
"suiteBuilderCaseUserPromptLabel": "Prompt do Usuário",
"suiteBuilderCaseUserPromptPlaceholder": "Escreva a entrada exata do usuário para repetir durante o eval.",
"suiteBuilderCaseExpectedLabel": "Match Esperado",
"suiteBuilderCaseExpectedPlaceholder": "Texto ou regex que deve aparecer na resposta do modelo.",
"suiteBuilderCreateAction": "Criar Suíte",
"suiteBuilderCreated": "Suíte customizada criada",
"suiteBuilderUpdated": "Suíte customizada atualizada",
"suiteBuilderDeleted": "Suíte customizada removida",
"suiteBuilderDeleteConfirm": "Excluir a suíte customizada \"{name}\"?",
"suiteBuilderSaveFailed": "Falha ao salvar a suíte customizada",
"suiteBuilderDeleteFailed": "Falha ao excluir a suíte customizada",
"suiteBuilderNameRequired": "O nome da suíte é obrigatório",
"suiteBuilderCasesRequired": "Adicione pelo menos um caso antes de salvar",
"suiteBuilderCaseInvalid": "O caso {index} está incompleto. Preencha nome, modelo, prompt e valor esperado.",
"suiteBuilderCustomBadge": "Custom",
"suiteBuilderBuiltInBadge": "Padrão",
"suiteBuilderUpdatedAt": "Atualizado {value}",
"evalsLoading": "Carregando suítes de avaliação...",
"noEvalSuitesFound": "Nenhuma suíte de avaliação encontrada",
"noEvalSuitesDescription": "As suítes de avaliação podem ser definidas via API ou em código. Elas testam saídas de modelos contra resultados esperados usando estratégias como contains, regex, correspondência exata e funções customizadas.",
@@ -3361,7 +3607,7 @@
},
"agents": {
"title": "Agentes ACP Locais (Workers)",
"description": "Descubra binários CLI instalados que o OmniRoute pode iniciar localmente como workers ACP para tarefas de execução.",
"description": "Descubra binários CLI instalados que o OmniRoute pode iniciar localmente como workers ACP para executar tarefas, ler arquivos locais e atuar como proxy final de execução.",
"architectureTitle": "Fluxo de execução ACP",
"architectureDescription": "Esta tela é para binários que o OmniRoute inicia localmente como workers. O OmniRoute detecta o binário, faz o spawn e o usa como endpoint final de execução.",
"cliToolsRedirectTitle": "Procurando configuração de cliente?",
@@ -3380,11 +3626,15 @@
"addCustomAgent": "Adicionar Worker ACP Customizado",
"addCustomAgentDesc": "Registre qualquer binário local que o OmniRoute deve detectar e gerenciar como worker. Ele será reavaliado automaticamente ao atualizar.",
"agentName": "Nome do Agente",
"agentNamePlaceholder": "ex.: Meu Worker Customizado",
"binaryName": "Nome do Binário",
"binaryNamePlaceholder": "ex.: meu-worker",
"versionCommand": "Comando de Versão",
"versionCommandPlaceholder": "ex.: meu-worker --version",
"spawnArgs": "Argumentos",
"addAgent": "Adicionar Agente",
"scanning": "Escaneando o sistema em busca de agentes CLI...",
"spawnArgsPlaceholder": "ex.: --quiet, --json",
"addAgent": "Adicionar Worker",
"scanning": "Escaneando workers ACP locais...",
"setupGuideTitle": "Guia de configuração",
"openCliTools": "Abrir CLI Tools",
"setupGuideDetectCliTitle": "Descobrir binários locais",

View File

@@ -1,8 +1,43 @@
import { randomUUID } from "node:crypto";
import { createScorecard } from "@/lib/evals/evalRunner";
import { getDbInstance, rowToCamel } from "./core";
export type EvalTargetType = "suite-default" | "model" | "combo";
export type EvalCaseStrategy = "contains" | "exact" | "regex" | "custom";
export interface EvalMessage {
role: string;
content: string;
}
export interface EvalCaseRecord {
id: string;
suiteId: string;
name: string;
model?: string;
input: {
messages: EvalMessage[];
max_tokens?: number;
};
expected: {
strategy: EvalCaseStrategy;
value?: string;
};
tags: string[];
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface EvalSuiteRecord {
id: string;
name: string;
description?: string;
source: "custom";
caseCount: number;
cases: EvalCaseRecord[];
createdAt: string;
updatedAt: string;
}
export interface EvalTargetDescriptor {
type: EvalTargetType;
@@ -36,6 +71,7 @@ type JsonRecord = Record<string, unknown>;
interface StatementLike<TRow = unknown> {
all: (...params: unknown[]) => TRow[];
get: (...params: unknown[]) => TRow | undefined;
run: (...params: unknown[]) => { changes: number };
}
@@ -43,6 +79,83 @@ interface DbLike {
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
}
function hasColumn(db: DbLike, table: string, column: string): boolean {
const rows = db.prepare<{ name?: string }>(`PRAGMA table_info(${table})`).all();
return rows.some((row) => row && typeof row.name === "string" && row.name === column);
}
function ensureEvalSuiteTables(db: DbLike) {
db.prepare(
`CREATE TABLE IF NOT EXISTS eval_suites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`
).run();
if (!hasColumn(db, "eval_suites", "description")) {
db.prepare("ALTER TABLE eval_suites ADD COLUMN description TEXT").run();
}
if (!hasColumn(db, "eval_suites", "created_at")) {
db.prepare("ALTER TABLE eval_suites ADD COLUMN created_at TEXT NOT NULL DEFAULT ''").run();
}
if (!hasColumn(db, "eval_suites", "updated_at")) {
db.prepare("ALTER TABLE eval_suites ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''").run();
}
db.prepare(
`CREATE TABLE IF NOT EXISTS eval_cases (
id TEXT PRIMARY KEY,
suite_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
model TEXT,
input_json TEXT NOT NULL,
expected_strategy TEXT NOT NULL,
expected_value TEXT,
tags_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`
).run();
if (!hasColumn(db, "eval_cases", "sort_order")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0").run();
}
if (!hasColumn(db, "eval_cases", "model")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN model TEXT").run();
}
if (!hasColumn(db, "eval_cases", "input_json")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN input_json TEXT NOT NULL DEFAULT '{}'").run();
}
if (!hasColumn(db, "eval_cases", "expected_strategy")) {
db.prepare(
"ALTER TABLE eval_cases ADD COLUMN expected_strategy TEXT NOT NULL DEFAULT 'contains'"
).run();
}
if (!hasColumn(db, "eval_cases", "expected_value")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN expected_value TEXT").run();
}
if (!hasColumn(db, "eval_cases", "tags_json")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN tags_json TEXT").run();
}
if (!hasColumn(db, "eval_cases", "created_at")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN created_at TEXT NOT NULL DEFAULT ''").run();
}
if (!hasColumn(db, "eval_cases", "updated_at")) {
db.prepare("ALTER TABLE eval_cases ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''").run();
}
db.prepare(
"CREATE INDEX IF NOT EXISTS idx_eval_suites_updated_at ON eval_suites(updated_at DESC)"
).run();
db.prepare(
"CREATE INDEX IF NOT EXISTS idx_eval_cases_suite_order ON eval_cases(suite_id, sort_order ASC, created_at ASC)"
).run();
}
function parseJsonRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord;
@@ -87,11 +200,114 @@ function parseJsonArray(value: unknown): Array<Record<string, unknown>> {
}
}
function parseStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
if (typeof value !== "string" || value.trim().length === 0) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [];
} catch {
return [];
}
}
function parseMessages(value: unknown): EvalMessage[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return null;
}
const row = entry as Record<string, unknown>;
const role = typeof row.role === "string" ? row.role.trim() : "";
const content = typeof row.content === "string" ? row.content : "";
if (!role || !content.trim()) {
return null;
}
return {
role,
content,
} satisfies EvalMessage;
})
.filter((entry): entry is EvalMessage => entry !== null);
}
function parseNumber(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function sanitizeEvalCaseInput(value: unknown): EvalCaseRecord["input"] {
const record =
value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
const maxTokens = parseNumber(record.max_tokens);
const input: EvalCaseRecord["input"] = {
messages: parseMessages(record.messages),
};
if (maxTokens > 0) {
input.max_tokens = Math.floor(maxTokens);
}
return input;
}
function sanitizeEvalExpected(value: unknown): EvalCaseRecord["expected"] {
const record =
value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
const strategy = typeof record.strategy === "string" ? record.strategy.trim() : "";
const normalizedStrategy: EvalCaseStrategy =
strategy === "exact" || strategy === "regex" || strategy === "custom" ? strategy : "contains";
const expectedValue =
typeof record.value === "string" && record.value.trim().length > 0 ? record.value : undefined;
return {
strategy: normalizedStrategy,
...(expectedValue ? { value: expectedValue } : {}),
};
}
function createScorecardFromRuns(
runs: Array<{
suiteId: string;
suiteName: string;
summary: EvalRunSummary;
results: Array<Record<string, unknown>>;
}>
) {
const totalCases = runs.reduce((sum, run) => sum + run.summary.total, 0);
const totalPassed = runs.reduce((sum, run) => sum + run.summary.passed, 0);
return {
suites: runs.length,
totalCases,
totalPassed,
overallPassRate: totalCases > 0 ? Math.round((totalPassed / totalCases) * 100) : 0,
perSuite: runs.map((run) => ({
id: run.suiteId,
name: run.suiteName,
passRate: run.summary.passRate,
})),
};
}
export function serializeEvalTargetKey(type: EvalTargetType, id?: string | null): string {
return `${type}:${typeof id === "string" && id.trim().length > 0 ? id.trim() : "__default__"}`;
}
@@ -156,6 +372,50 @@ function toPersistedEvalRun(row: unknown): PersistedEvalRun | null {
};
}
function toEvalCaseRecord(row: unknown): EvalCaseRecord | null {
const camel = rowToCamel(row) as JsonRecord | null;
if (!camel) return null;
const input = sanitizeEvalCaseInput(parseJsonRecord(camel.inputJson));
const expected = sanitizeEvalExpected({
strategy: camel.expectedStrategy,
value: camel.expectedValue,
});
return {
id: typeof camel.id === "string" ? camel.id : "",
suiteId: typeof camel.suiteId === "string" ? camel.suiteId : "",
name: typeof camel.name === "string" ? camel.name : "",
...(typeof camel.model === "string" && camel.model.trim().length > 0
? { model: camel.model.trim() }
: {}),
input,
expected,
tags: parseStringArray(camel.tagsJson),
sortOrder: parseNumber(camel.sortOrder),
createdAt: typeof camel.createdAt === "string" ? camel.createdAt : "",
updatedAt: typeof camel.updatedAt === "string" ? camel.updatedAt : "",
};
}
function toEvalSuiteRecord(row: unknown, cases: EvalCaseRecord[]): EvalSuiteRecord | null {
const camel = rowToCamel(row) as JsonRecord | null;
if (!camel) return null;
return {
id: typeof camel.id === "string" ? camel.id : "",
name: typeof camel.name === "string" ? camel.name : "",
...(typeof camel.description === "string" && camel.description.trim().length > 0
? { description: camel.description }
: {}),
source: "custom",
caseCount: cases.length,
cases,
createdAt: typeof camel.createdAt === "string" ? camel.createdAt : "",
updatedAt: typeof camel.updatedAt === "string" ? camel.updatedAt : "",
};
}
export function saveEvalRun(input: {
runGroupId?: string | null;
suiteId: string;
@@ -266,7 +526,7 @@ export function getEvalScorecard(
suiteId?: string;
limit?: number;
} = {}
): ReturnType<typeof createScorecard> | null {
) {
const runs = listEvalRuns({ suiteId: options.suiteId, limit: options.limit || 50 });
if (runs.length === 0) return null;
@@ -278,7 +538,7 @@ export function getEvalScorecard(
}
}
return createScorecard(
return createScorecardFromRuns(
Array.from(latestByScope.values()).map((run) => ({
suiteId: `${run.suiteId}:${run.target.key}`,
suiteName: `${run.suiteName} · ${run.target.label}`,
@@ -287,3 +547,183 @@ export function getEvalScorecard(
}))
);
}
export function listCustomEvalSuites(): EvalSuiteRecord[] {
const db = getDbInstance() as unknown as DbLike;
ensureEvalSuiteTables(db);
const suiteRows = db
.prepare("SELECT * FROM eval_suites ORDER BY updated_at DESC, created_at DESC")
.all();
const caseRows = db
.prepare(
"SELECT * FROM eval_cases ORDER BY suite_id ASC, sort_order ASC, created_at ASC, id ASC"
)
.all();
const casesBySuite = new Map<string, EvalCaseRecord[]>();
for (const row of caseRows) {
const parsed = toEvalCaseRecord(row);
if (!parsed || !parsed.suiteId) continue;
const current = casesBySuite.get(parsed.suiteId) || [];
current.push(parsed);
casesBySuite.set(parsed.suiteId, current);
}
return suiteRows
.map((row) => {
const camel = rowToCamel(row) as JsonRecord | null;
const suiteId = camel && typeof camel.id === "string" ? camel.id : "";
return toEvalSuiteRecord(row, casesBySuite.get(suiteId) || []);
})
.filter((suite): suite is EvalSuiteRecord => suite !== null);
}
export function getCustomEvalSuite(suiteId: string): EvalSuiteRecord | null {
const normalizedSuiteId = suiteId.trim();
if (!normalizedSuiteId) return null;
return listCustomEvalSuites().find((suite) => suite.id === normalizedSuiteId) || null;
}
export function saveCustomEvalSuite(input: {
id?: string;
name: string;
description?: string;
cases: Array<{
id?: string;
name: string;
model?: string;
input: {
messages: EvalMessage[];
max_tokens?: number;
};
expected: {
strategy: EvalCaseStrategy;
value?: string;
};
tags?: string[];
}>;
}): EvalSuiteRecord {
const db = getDbInstance() as unknown as DbLike;
ensureEvalSuiteTables(db);
const now = new Date().toISOString();
const suiteId =
typeof input.id === "string" && input.id.trim().length > 0 ? input.id.trim() : randomUUID();
const name = input.name.trim();
const description =
typeof input.description === "string" && input.description.trim().length > 0
? input.description.trim()
: null;
if (!name) {
throw new Error("Suite name is required");
}
if (!Array.isArray(input.cases) || input.cases.length === 0) {
throw new Error("At least one eval case is required");
}
db.prepare("BEGIN").run();
try {
const existing = db
.prepare<{ id: string }>("SELECT id FROM eval_suites WHERE id = ?")
.get(suiteId);
if (existing) {
db.prepare(
`UPDATE eval_suites
SET name = ?, description = ?, updated_at = ?
WHERE id = ?`
).run(name, description, now, suiteId);
} else {
db.prepare(
`INSERT INTO eval_suites (id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
).run(suiteId, name, description, now, now);
}
db.prepare("DELETE FROM eval_cases WHERE suite_id = ?").run(suiteId);
input.cases.forEach((rawCase, index) => {
const caseId =
typeof rawCase.id === "string" && rawCase.id.trim().length > 0
? rawCase.id.trim()
: randomUUID();
const caseName = rawCase.name.trim();
const model =
typeof rawCase.model === "string" && rawCase.model.trim().length > 0
? rawCase.model.trim()
: null;
const sanitizedInput = sanitizeEvalCaseInput(rawCase.input);
const sanitizedExpected = sanitizeEvalExpected(rawCase.expected);
const tags = Array.isArray(rawCase.tags)
? rawCase.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)
: [];
if (!caseName) {
throw new Error(`Case ${index + 1} is missing a name`);
}
if (sanitizedInput.messages.length === 0) {
throw new Error(`Case ${index + 1} must include at least one message`);
}
if (
(sanitizedExpected.strategy === "contains" ||
sanitizedExpected.strategy === "exact" ||
sanitizedExpected.strategy === "regex") &&
!sanitizedExpected.value
) {
throw new Error(`Case ${index + 1} must include an expected value`);
}
db.prepare(
`INSERT INTO eval_cases
(id, suite_id, sort_order, name, model, input_json, expected_strategy, expected_value,
tags_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
caseId,
suiteId,
index,
caseName,
model,
JSON.stringify(sanitizedInput),
sanitizedExpected.strategy,
sanitizedExpected.value || null,
JSON.stringify(tags),
now,
now
);
});
db.prepare("COMMIT").run();
} catch (error) {
db.prepare("ROLLBACK").run();
throw error;
}
const saved = getCustomEvalSuite(suiteId);
if (!saved) {
throw new Error("Failed to persist eval suite");
}
return saved;
}
export function deleteCustomEvalSuite(suiteId: string): boolean {
const db = getDbInstance() as unknown as DbLike;
ensureEvalSuiteTables(db);
const normalizedSuiteId = suiteId.trim();
if (!normalizedSuiteId) return false;
db.prepare("BEGIN").run();
try {
db.prepare("DELETE FROM eval_cases WHERE suite_id = ?").run(normalizedSuiteId);
const result = db.prepare("DELETE FROM eval_suites WHERE id = ?").run(normalizedSuiteId);
db.prepare("COMMIT").run();
return result.changes > 0;
} catch (error) {
db.prepare("ROLLBACK").run();
throw error;
}
}

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS eval_suites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS eval_cases (
id TEXT PRIMARY KEY,
suite_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
model TEXT,
input_json TEXT NOT NULL,
expected_strategy TEXT NOT NULL,
expected_value TEXT,
tags_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_eval_suites_updated_at
ON eval_suites(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_eval_cases_suite_order
ON eval_cases(suite_id, sort_order ASC, created_at ASC);

View File

@@ -12,6 +12,8 @@ import { getComboModelProvider as getComboEntryProvider } from "@/lib/combos/ste
type JsonRecord = Record<string, unknown>;
type PricingModels = Record<string, JsonRecord>;
type PricingByProvider = Record<string, PricingModels>;
export type PricingSource = "default" | "litellm" | "modelsDev" | "user";
export type PricingSourceMap = Record<string, Record<string, PricingSource>>;
type ProxyValue = JsonRecord | string | null;
type ProxyMap = Record<string, ProxyValue>;
@@ -115,73 +117,43 @@ export async function isCloudEnabled() {
// ──────────────── Pricing ────────────────
export async function getPricing() {
const db = getDbInstance();
function readPricingNamespace(
db: ReturnType<typeof getDbInstance>,
namespace: string
): PricingByProvider {
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = ?").all(namespace);
const pricing: PricingByProvider = {};
// Layer 1: Hardcoded defaults (lowest priority)
const { getDefaultPricing } = await import("@/shared/constants/pricing");
const defaultPricing = getDefaultPricing();
// Layer 2: Synced external pricing from LiteLLM (middle-low priority)
const syncedRows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
.all();
const syncedPricing: PricingByProvider = {};
for (const row of syncedRows) {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
syncedPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
}
// Layer 3: Synced pricing from models.dev (middle-high priority)
const modelsDevRows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'models_dev_pricing'")
.all();
const modelsDevPricing: PricingByProvider = {};
for (const row of modelsDevRows) {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
try {
modelsDevPricing[key] = JSON.parse(rawValue) as PricingModels;
} catch {
// Corrupted data — skip silently, fallback to lower layers
}
}
// Layer 4: User overrides (highest priority)
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
const userPricing: PricingByProvider = {};
for (const row of rows) {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
userPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
try {
pricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
} catch {
// Corrupted data — skip silently, fallback to lower layers
}
}
// Merge: defaults → LiteLLM → models.dev → user (each layer overrides the previous)
return pricing;
}
function mergePricingLayers(layers: PricingByProvider[]): PricingByProvider {
const mergedPricing: PricingByProvider = {};
// Start with defaults
for (const [provider, models] of Object.entries(defaultPricing) as Array<[string, unknown]>) {
mergedPricing[provider] = { ...(toRecord(models) as PricingModels) };
}
// Layer synced (LiteLLM), then models.dev, then user on top
for (const layer of [syncedPricing, modelsDevPricing, userPricing]) {
for (const layer of layers) {
for (const [provider, models] of Object.entries(layer)) {
if (!mergedPricing[provider]) {
mergedPricing[provider] = { ...models };
} else {
for (const [model, pricing] of Object.entries(models)) {
mergedPricing[provider][model] = mergedPricing[provider][model]
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
: pricing;
}
continue;
}
for (const [model, pricing] of Object.entries(models)) {
mergedPricing[provider][model] = mergedPricing[provider][model]
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
: pricing;
}
}
}
@@ -189,6 +161,69 @@ export async function getPricing() {
return mergedPricing;
}
function buildPricingSourceMap(layers: {
defaults: PricingByProvider;
litellm: PricingByProvider;
modelsDev: PricingByProvider;
user: PricingByProvider;
}): PricingSourceMap {
const sourceMap: PricingSourceMap = {};
const mergedPricing = mergePricingLayers([
layers.defaults,
layers.litellm,
layers.modelsDev,
layers.user,
]);
for (const [provider, models] of Object.entries(mergedPricing)) {
sourceMap[provider] = {};
for (const model of Object.keys(models)) {
if (layers.user[provider]?.[model]) {
sourceMap[provider][model] = "user";
} else if (layers.modelsDev[provider]?.[model]) {
sourceMap[provider][model] = "modelsDev";
} else if (layers.litellm[provider]?.[model]) {
sourceMap[provider][model] = "litellm";
} else {
sourceMap[provider][model] = "default";
}
}
}
return sourceMap;
}
async function getPricingLayers() {
const db = getDbInstance();
// Layer 1: Hardcoded defaults (lowest priority)
const { getDefaultPricing } = await import("@/shared/constants/pricing");
return {
defaults: getDefaultPricing(),
litellm: readPricingNamespace(db, "pricing_synced"),
modelsDev: readPricingNamespace(db, "models_dev_pricing"),
user: readPricingNamespace(db, "pricing"),
};
}
export async function getPricing() {
const layers = await getPricingLayers();
// Merge: defaults → LiteLLM → models.dev → user (each layer overrides the previous)
return mergePricingLayers([layers.defaults, layers.litellm, layers.modelsDev, layers.user]);
}
export async function getPricingWithSources(): Promise<{
pricing: PricingByProvider;
sourceMap: PricingSourceMap;
}> {
const layers = await getPricingLayers();
return {
pricing: mergePricingLayers([layers.defaults, layers.litellm, layers.modelsDev, layers.user]),
sourceMap: buildPricingSourceMap(layers),
};
}
export async function getPricingForModel(provider: string, model: string) {
const pricing = await getPricing();
if (pricing[provider]?.[model]) return pricing[provider][model];

View File

@@ -8,6 +8,8 @@
* @module lib/evals/evalRunner
*/
import { getCustomEvalSuite, listCustomEvalSuites } from "@/lib/db/evals";
/**
* @typedef {Object} EvalCase
* @property {string} id - Unique case ID
@@ -58,7 +60,7 @@ export function registerSuite(suite: any) {
* @returns {EvalSuite | null}
*/
export function getSuite(suiteId: string) {
return suites.get(suiteId) || null;
return suites.get(suiteId) || getCustomEvalSuite(suiteId) || null;
}
/**
@@ -67,19 +69,40 @@ export function getSuite(suiteId: string) {
* @returns {Array<{ id: string, name: string, caseCount: number }>}
*/
export function listSuites() {
return Array.from(suites.values()).map((s) => ({
const builtInSuites = Array.from(suites.values()).map((s) => ({
id: s.id,
name: s.name,
description: s.description || "",
source: "built-in",
caseCount: s.cases.length,
cases: s.cases.map((c) => ({
id: c.id,
name: c.name,
model: c.model,
input: c.input,
expected: c.expected,
tags: c.tags || [],
})),
}));
const customSuites = listCustomEvalSuites().map((suite) => ({
id: suite.id,
name: suite.name,
description: suite.description || "",
source: "custom",
caseCount: suite.cases.length,
updatedAt: suite.updatedAt,
cases: suite.cases.map((c) => ({
id: c.id,
name: c.name,
model: c.model,
input: c.input,
expected: c.expected,
tags: c.tags || [],
})),
}));
return [...builtInSuites, ...customSuites];
}
/**
@@ -169,7 +192,7 @@ export function runSuite(
outputs: Record<string, string>,
caseMetrics: Record<string, { durationMs?: number; error?: string }> = {}
) {
const suite = suites.get(suiteId);
const suite = getSuite(suiteId);
if (!suite) {
throw new Error(`Suite not found: ${suiteId}`);
}

View File

@@ -98,10 +98,16 @@ export {
saveEvalRun,
listEvalRuns,
getEvalScorecard,
listCustomEvalSuites,
getCustomEvalSuite,
saveCustomEvalSuite,
deleteCustomEvalSuite,
serializeEvalTargetKey,
} from "./db/evals";
export type {
EvalCaseRecord,
EvalSuiteRecord,
EvalTargetType,
EvalTargetDescriptor,
EvalRunSummary,
@@ -120,6 +126,7 @@ export {
// Pricing
getPricing,
getPricingWithSources,
getPricingForModel,
updatePricing,
resetPricing,
@@ -134,6 +141,8 @@ export {
setProxyConfig,
} from "./db/settings";
export type { PricingSource, PricingSourceMap } from "./db/settings";
export {
// Proxy Registry
listProxies,

View File

@@ -0,0 +1,27 @@
import { createResponsesApiTransformStream } from "@omniroute/open-sse/transformer/responsesTransformer.ts";
export async function transformChatCompletionSseToResponses(rawSse: string): Promise<string> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const inputStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(rawSse));
controller.close();
},
});
const outputStream = inputStream.pipeThrough(createResponsesApiTransformStream());
const reader = outputStream.getReader();
let output = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
output += decoder.decode();
return output;
}

View File

@@ -524,6 +524,15 @@ amp --model "{{model}}"
}`,
},
},
custom: {
id: "custom",
name: "Custom CLI",
icon: "terminal",
color: "#10B981",
description: "Generic OpenAI-compatible CLI or SDK configuration generator",
docsUrl: "/docs?section=cli-tools",
configType: "custom-builder",
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",

View File

@@ -1378,6 +1378,26 @@ const evalTargetSchema = z
}
});
const evalMessageSchema = z.object({
role: z.string().trim().min(1, "message.role is required").max(50),
content: z.string().trim().min(1, "message.content is required").max(20000),
});
const evalCaseBuilderSchema = z.object({
id: z.string().trim().min(1).optional(),
name: z.string().trim().min(1, "case.name is required").max(200),
model: z.string().trim().min(1).max(300).optional().nullable(),
input: z.object({
messages: z.array(evalMessageSchema).min(1, "At least one message is required").max(32),
max_tokens: z.number().int().min(1).max(8192).optional(),
}),
expected: z.object({
strategy: z.enum(["contains", "exact", "regex"]),
value: z.string().trim().min(1, "expected.value is required").max(20000),
}),
tags: z.array(z.string().trim().min(1).max(64)).max(20).optional(),
});
export const evalRunSuiteSchema = z
.object({
suiteId: z.string().trim().min(1, "suiteId is required"),
@@ -1402,6 +1422,13 @@ export const evalRunSuiteSchema = z
}
});
export const evalSuiteSaveSchema = z.object({
id: z.string().trim().min(1).optional(),
name: z.string().trim().min(1, "name is required").max(200),
description: z.string().trim().max(2000).optional(),
cases: z.array(evalCaseBuilderSchema).min(1, "At least one case is required").max(200),
});
const accessScheduleSchema = z.object({
enabled: z.boolean(),
from: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),

View File

@@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildAliasEnvVar,
buildCustomCliEnvScript,
buildCustomCliJsonConfig,
normalizeOpenAiBaseUrl,
} from "../../src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts";
test("normalizeOpenAiBaseUrl appends /v1 only when needed", () => {
assert.equal(normalizeOpenAiBaseUrl("http://localhost:20128"), "http://localhost:20128/v1");
assert.equal(
normalizeOpenAiBaseUrl("https://example.com/proxy/v1"),
"https://example.com/proxy/v1"
);
});
test("buildAliasEnvVar sanitizes aliases for env variable export", () => {
assert.equal(buildAliasEnvVar("review"), "OMNIROUTE_MODEL_REVIEW");
assert.equal(buildAliasEnvVar("plan mode"), "OMNIROUTE_MODEL_PLAN_MODE");
assert.equal(buildAliasEnvVar(""), null);
});
test("custom CLI generators include default model and alias mappings", () => {
const envScript = buildCustomCliEnvScript({
cliName: "My Team CLI",
baseUrl: "http://localhost:20128",
apiKey: "sk_test_123",
defaultModel: "omniroute/fast",
aliasMappings: [
{ alias: "review", model: "cc/claude-sonnet-4-5-20250929" },
{ alias: "vision", model: "gemini/gemini-3-flash" },
],
});
assert.match(envScript, /export OPENAI_BASE_URL="http:\/\/localhost:20128\/v1"/);
assert.match(envScript, /export OPENAI_MODEL="omniroute\/fast"/);
assert.match(envScript, /export OMNIROUTE_MODEL_REVIEW="cc\/claude-sonnet-4-5-20250929"/);
assert.match(envScript, /# http:\/\/localhost:20128\/v1\/chat\/completions/);
assert.match(envScript, /my-team-cli --base-url "\$OPENAI_BASE_URL"/);
const jsonConfig = JSON.parse(
buildCustomCliJsonConfig({
cliName: "My Team CLI",
baseUrl: "http://localhost:20128",
apiKey: "sk_test_123",
defaultModel: "omniroute/fast",
aliasMappings: [{ alias: "review", model: "cc/claude-sonnet-4-5-20250929" }],
})
);
assert.deepEqual(jsonConfig, {
name: "My Team CLI",
provider: {
type: "openai",
baseURL: "http://localhost:20128/v1",
apiKey: "sk_test_123",
model: "omniroute/fast",
},
modelAliases: {
review: "cc/claude-sonnet-4-5-20250929",
},
});
});

View File

@@ -145,6 +145,53 @@ test("pricing layers merge synced, models.dev and user overrides", async () => {
assert.deepEqual(await settingsDb.resetAllPricing(), {});
});
test("getPricingWithSources reports the winning layer for each provider/model", async () => {
const db = core.getDbInstance();
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"pricing_synced",
"layer-source",
JSON.stringify({
"model-litellm": { prompt: 1, completion: 2 },
"model-user": { prompt: 3 },
})
);
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"models_dev_pricing",
"layer-source",
JSON.stringify({
"model-modelsdev": { prompt: 4, completion: 5 },
"model-user": { completion: 6 },
})
);
await settingsDb.updatePricing({
"layer-source": {
"model-user": { cached: 7 },
},
});
const { pricing, sourceMap } = await settingsDb.getPricingWithSources();
assert.deepEqual(pricing["layer-source"]["model-litellm"], {
prompt: 1,
completion: 2,
});
assert.deepEqual(pricing["layer-source"]["model-modelsdev"], {
prompt: 4,
completion: 5,
});
assert.deepEqual(pricing["layer-source"]["model-user"], {
prompt: 3,
completion: 6,
cached: 7,
});
assert.equal(sourceMap["layer-source"]["model-litellm"], "litellm");
assert.equal(sourceMap["layer-source"]["model-modelsdev"], "modelsDev");
assert.equal(sourceMap["layer-source"]["model-user"], "user");
assert.equal(sourceMap.openai["gpt-4o"], "default");
});
test("LKGP values can be set, read and cleared", async () => {
assert.equal(await settingsDb.getLKGP("combo-a", "model-a"), null);
@@ -225,48 +272,50 @@ test("settings and pricing readers skip malformed rows while merging surviving l
};
}
if (text.includes("namespace = 'pricing_synced'")) {
if (text === "SELECT key, value FROM key_value WHERE namespace = ?") {
return {
all: () => [
123,
{ key: 456, value: JSON.stringify({ ignored: true }) },
{
key: "layered-provider",
value: JSON.stringify({
"model-a": { prompt: 1, completion: 2 },
}),
},
],
};
}
all: (namespace) => {
if (namespace === "pricing_synced") {
return [
123,
{ key: 456, value: JSON.stringify({ ignored: true }) },
{
key: "layered-provider",
value: JSON.stringify({
"model-a": { prompt: 1, completion: 2 },
}),
},
];
}
if (text.includes("namespace = 'models_dev_pricing'")) {
return {
all: () => [
{ key: "broken-provider", value: "{bad" },
{ key: "missing-value", value: null },
{
key: "layered-provider",
value: JSON.stringify({
"model-a": { cached: 3 },
}),
},
],
};
}
if (namespace === "models_dev_pricing") {
return [
{ key: "broken-provider", value: "{bad" },
{ key: "missing-value", value: null },
{
key: "layered-provider",
value: JSON.stringify({
"model-a": { cached: 3 },
}),
},
];
}
if (text.includes("namespace = 'pricing'")) {
return {
all: () => [
{
key: "layered-provider",
value: JSON.stringify({
"model-a": { prompt: 9, custom: 42 },
"model-b": { prompt: 7 },
}),
},
{ key: null, value: JSON.stringify({ ignored: true }) },
],
if (namespace === "pricing") {
return [
{
key: "layered-provider",
value: JSON.stringify({
"model-a": { prompt: 9, custom: 42 },
"model-b": { prompt: 7 },
}),
},
{ key: null, value: JSON.stringify({ ignored: true }) },
];
}
return originalPrepare(sql).all(namespace);
},
};
}

View File

@@ -98,3 +98,76 @@ test("scorecard keeps only the latest run per suite and target scope", () => {
assert.equal(scorecard.totalPassed, 3);
assert.equal(scorecard.overallPassRate, 75);
});
test("custom eval suites persist cases and support update/delete", () => {
const created = evalsDb.saveCustomEvalSuite({
name: "Support Regression",
description: "Checks refund phrasing",
cases: [
{
name: "Refund policy",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "Explain the refund policy" }],
},
expected: {
strategy: "contains",
value: "refund",
},
tags: ["support", "billing"],
},
],
});
assert.ok(created.id);
assert.equal(created.source, "custom");
assert.equal(created.caseCount, 1);
assert.equal(created.cases[0]?.expected.strategy, "contains");
assert.deepEqual(created.cases[0]?.tags, ["support", "billing"]);
const updated = evalsDb.saveCustomEvalSuite({
id: created.id,
name: "Support Regression v2",
description: "Checks refund and escalation phrasing",
cases: [
{
id: created.cases[0]?.id,
name: "Refund policy",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "Explain the refund policy" }],
},
expected: {
strategy: "contains",
value: "refund",
},
tags: ["support"],
},
{
name: "Escalation path",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "How do I escalate a billing issue?" }],
},
expected: {
strategy: "regex",
value: "support|billing",
},
tags: ["billing"],
},
],
});
assert.equal(updated.id, created.id);
assert.equal(updated.name, "Support Regression v2");
assert.equal(updated.caseCount, 2);
assert.equal(updated.cases[1]?.expected.strategy, "regex");
const listed = evalsDb.listCustomEvalSuites();
assert.equal(listed.length, 1);
assert.equal(listed[0]?.id, created.id);
assert.equal(evalsDb.getCustomEvalSuite(created.id)?.cases.length, 2);
assert.equal(evalsDb.deleteCustomEvalSuite(created.id), true);
assert.equal(evalsDb.getCustomEvalSuite(created.id), null);
});

View File

@@ -24,6 +24,8 @@ const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const evalsRoute = await import("../../src/app/api/evals/route.ts");
const evalsScorecardRoute = await import("../../src/app/api/evals/scorecard/route.ts");
const evalSuitesRoute = await import("../../src/app/api/evals/suites/route.ts");
const evalSuiteByIdRoute = await import("../../src/app/api/evals/suites/[suiteId]/route.ts");
function resetDb() {
core.resetDbInstance();
@@ -42,6 +44,24 @@ test.after(() => {
test("evals GET returns suites, target options, api key metadata, and persisted history", async () => {
const apiKey = await localDb.createApiKey("Dashboard Key", "machine-test");
const customSuite = localDb.saveCustomEvalSuite({
name: "Support Regression",
description: "Checks support answers",
cases: [
{
name: "Refund policy",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "Explain the refund policy" }],
},
expected: {
strategy: "contains",
value: "refund",
},
tags: ["support"],
},
],
});
localDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
@@ -65,6 +85,13 @@ test("evals GET returns suites, target options, api key metadata, and persisted
assert.equal(payload.apiKeys[0].name, "Dashboard Key");
assert.equal(payload.apiKeys[0].key, undefined);
assert.equal(payload.recentRuns[0].target.key, "combo:cost-optimized");
assert.equal(
payload.suites.some(
(entry: { id?: string; source?: string; cases?: unknown[] }) =>
entry.id === customSuite.id && entry.source === "custom" && entry.cases?.length === 1
),
true
);
assert.equal(
payload.targets.some((entry) => entry.type === "suite-default"),
true
@@ -93,3 +120,99 @@ test("eval scorecard route exposes stored runs and aggregated pass rate", async
assert.equal(Array.isArray(payload.runs), true);
assert.equal(payload.runs.length, 1);
});
test("eval suite routes create, update, fetch, and delete custom suites", async () => {
const createResponse = await evalSuitesRoute.POST(
new Request("http://localhost/api/evals/suites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Support Regression",
description: "Checks support responses",
cases: [
{
name: "Refund policy",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "Explain the refund policy" }],
},
expected: {
strategy: "contains",
value: "refund",
},
tags: ["support"],
},
],
}),
})
);
assert.equal(createResponse.status, 201);
const createPayload = (await createResponse.json()) as { suite: { id: string; source: string } };
assert.equal(createPayload.suite.source, "custom");
const suiteId = createPayload.suite.id;
const getResponse = await evalSuiteByIdRoute.GET(
new Request(`http://localhost/api/evals/suites/${suiteId}`),
{ params: Promise.resolve({ suiteId }) }
);
assert.equal(getResponse.status, 200);
const updateResponse = await evalSuiteByIdRoute.PUT(
new Request(`http://localhost/api/evals/suites/${suiteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Support Regression v2",
description: "Checks refund and escalation responses",
cases: [
{
name: "Refund policy",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "Explain the refund policy" }],
},
expected: {
strategy: "contains",
value: "refund",
},
tags: ["support"],
},
{
name: "Escalation path",
model: "gpt-4o-mini",
input: {
messages: [{ role: "user", content: "How do I escalate a billing issue?" }],
},
expected: {
strategy: "regex",
value: "support|billing",
},
tags: ["billing"],
},
],
}),
}),
{ params: Promise.resolve({ suiteId }) }
);
assert.equal(updateResponse.status, 200);
const updatePayload = (await updateResponse.json()) as {
suite: { id: string; name: string; cases: unknown[] };
};
assert.equal(updatePayload.suite.id, suiteId);
assert.equal(updatePayload.suite.name, "Support Regression v2");
assert.equal(updatePayload.suite.cases.length, 2);
const deleteResponse = await evalSuiteByIdRoute.DELETE(
new Request(`http://localhost/api/evals/suites/${suiteId}`, { method: "DELETE" }),
{ params: Promise.resolve({ suiteId }) }
);
assert.equal(deleteResponse.status, 200);
const missingResponse = await evalSuiteByIdRoute.GET(
new Request(`http://localhost/api/evals/suites/${suiteId}`),
{ params: Promise.resolve({ suiteId }) }
);
assert.equal(missingResponse.status, 404);
});

View File

@@ -0,0 +1,86 @@
import test 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 TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pricing-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
interface PricingWithSourcesPayload {
pricing: Record<string, Record<string, Record<string, number>>>;
sourceMap: Record<string, Record<string, "default" | "litellm" | "modelsDev" | "user">>;
}
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const pricingRoute = await import("../../src/app/api/pricing/route.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("pricing GET keeps legacy payload by default and exposes source metadata on demand", async () => {
const db = core.getDbInstance();
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"pricing_synced",
"route-provider",
JSON.stringify({
"model-litellm": { prompt: 1 },
"model-user": { prompt: 2 },
})
);
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"models_dev_pricing",
"route-provider",
JSON.stringify({
"model-modelsdev": { prompt: 3 },
"model-user": { completion: 4 },
})
);
await settingsDb.updatePricing({
"route-provider": {
"model-user": { cached: 5 },
},
});
const legacyResponse = await pricingRoute.GET(new Request("http://localhost/api/pricing"));
assert.equal(legacyResponse.status, 200);
const legacyPayload = await legacyResponse.json();
assert.equal("sourceMap" in legacyPayload, false);
assert.deepEqual(legacyPayload["route-provider"]["model-user"], {
prompt: 2,
completion: 4,
cached: 5,
});
const sourceResponse = await pricingRoute.GET(
new Request("http://localhost/api/pricing?includeSources=1")
);
assert.equal(sourceResponse.status, 200);
const payload = (await sourceResponse.json()) as PricingWithSourcesPayload;
assert.deepEqual(payload.pricing["route-provider"]["model-user"], {
prompt: 2,
completion: 4,
cached: 5,
});
assert.equal(payload.sourceMap["route-provider"]["model-litellm"], "litellm");
assert.equal(payload.sourceMap["route-provider"]["model-modelsdev"], "modelsDev");
assert.equal(payload.sourceMap["route-provider"]["model-user"], "user");
assert.equal(payload.sourceMap.openai["gpt-4o"], "default");
});

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { transformChatCompletionSseToResponses } from "../../src/lib/translator/streamTransform.ts";
const SIMPLE_SSE = `data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}
data: [DONE]
`;
test("transformChatCompletionSseToResponses emits Responses API events", async () => {
const transformed = await transformChatCompletionSseToResponses(SIMPLE_SSE);
assert.match(transformed, /event: response\.output_item\.added/);
assert.match(transformed, /event: response\.output_text\.delta/);
assert.match(transformed, /event: response\.output_text\.done/);
assert.match(transformed, /event: response\.completed/);
assert.match(transformed, /data: \[DONE\]/);
});