From 01dd72cf1fe44e03bf38603a0c1a96d509904731 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 23 Apr 2026 16:30:49 -0300 Subject: [PATCH] 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. --- src/app/(dashboard)/dashboard/agents/page.tsx | 8 +- .../cli-tools/CLIToolsPageClient.tsx | 17 + .../cli-tools/components/CustomCliCard.tsx | 372 +++++++++++ .../cli-tools/components/customCliConfig.ts | 128 ++++ .../dashboard/cli-tools/components/index.tsx | 1 + .../dashboard/costs/CostOverviewTab.tsx | 501 +++++++++++++++ src/app/(dashboard)/dashboard/costs/page.tsx | 13 +- .../dashboard/providers/[id]/page.tsx | 431 ++++++------- .../settings/components/PricingTab.tsx | 608 +++++++++++++----- .../translator/TranslatorPageClient.tsx | 64 +- .../translator/components/LiveMonitorMode.tsx | 80 ++- .../components/StreamTransformerMode.tsx | 295 +++++++++ .../translator/components/TestBenchMode.tsx | 12 + .../dashboard/usage/components/EvalsTab.tsx | 538 +++++++++++++++- src/app/api/evals/suites/[suiteId]/route.ts | 87 +++ src/app/api/evals/suites/route.ts | 40 ++ src/app/api/pricing/route.ts | 15 +- src/app/api/translator/history/route.ts | 30 +- .../api/translator/transform-stream/route.ts | 47 ++ src/i18n/messages/en.json | 263 +++++++- src/i18n/messages/pt-BR.json | 302 ++++++++- src/lib/db/evals.ts | 446 ++++++++++++- .../db/migrations/031_create_eval_suites.sql | 27 + src/lib/db/settings.ts | 143 ++-- src/lib/evals/evalRunner.ts | 29 +- src/lib/localDb.ts | 9 + src/lib/translator/streamTransform.ts | 27 + src/shared/constants/cliTools.ts | 9 + src/shared/validation/schemas.ts | 27 + tests/unit/custom-cli-config.test.ts | 65 ++ tests/unit/db-settings-crud.test.ts | 127 ++-- tests/unit/evals-history.test.ts | 73 +++ tests/unit/evals-route.test.ts | 123 ++++ tests/unit/pricing-route-sources.test.ts | 86 +++ .../unit/translator-stream-transform.test.ts | 23 + 35 files changed, 4492 insertions(+), 574 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts create mode 100644 src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx create mode 100644 src/app/(dashboard)/dashboard/translator/components/StreamTransformerMode.tsx create mode 100644 src/app/api/evals/suites/[suiteId]/route.ts create mode 100644 src/app/api/evals/suites/route.ts create mode 100644 src/app/api/translator/transform-stream/route.ts create mode 100644 src/lib/db/migrations/031_create_eval_suites.sql create mode 100644 src/lib/translator/streamTransform.ts create mode 100644 tests/unit/custom-cli-config.test.ts create mode 100644 tests/unit/pricing-route-sources.test.ts create mode 100644 tests/unit/translator-stream-transform.test.ts diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 4bc021f94d..84dfc56d57 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -451,14 +451,14 @@ export default function AgentsPage() {
setNewAgent({ ...newAgent, name: e.target.value })} required /> setNewAgent({ ...newAgent, binary: e.target.value })} required @@ -467,13 +467,13 @@ export default function AgentsPage() {
setNewAgent({ ...newAgent, versionCommand: e.target.value })} /> setNewAgent({ ...newAgent, spawnArgs: e.target.value })} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index 39ef5d0de5..6eadda323e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -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 ( + + ); 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} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx new file mode 100644 index 0000000000..1726de6025 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx @@ -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) => { + try { + const translated = t(key, values); + return translated === key || translated === `cliTools.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t] + ); + const [copiedField, setCopiedField] = useState(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([]); + 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 ( + +
+
+
+ {tool.icon || "terminal"} +
+
+
+

{tool.name}

+ + + {translateOrFallback("custom", "Custom")} + +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+
+ + tips_and_updates + +
+

+ {translateOrFallback("customCliBuilderTitle", "OpenAI-compatible CLI builder")} +

+

+ {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." + )} +

+
+
+ + {!hasActiveProviders && ( +
+ warning +
+

+ {translateOrFallback("noActiveProviders", "No active providers")} +

+

+ {translateOrFallback( + "customCliNoModels", + "Connect at least one provider to populate the model selectors." + )} +

+
+
+ )} + +
+
+
+ + 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" + /> +
+ +
+ + +

+ {translateOrFallback( + "customCliDefaultModelHelp", + "Use any OmniRoute model ID or combo. Most OpenAI-compatible CLIs only need the /v1 base URL plus a model string." + )} +

+
+ +
+ + {apiKeys && apiKeys.length > 0 ? ( + + ) : ( +
+ {keyToUse} +
+ )} +

+ {translateOrFallback( + "customCliKeyHelper", + "For local installs OmniRoute can use sk_omniroute. In cloud mode, pick one of your management API keys." + )} +

+
+
+ +
+
+
+

+ {translateOrFallback("customCliAliasMappingsLabel", "Alias mappings")} +

+

+ {translateOrFallback( + "customCliAliasMappingsHelp", + "Optional helper aliases for wrapper scripts or config files that want stable shorthand names." + )} +

+
+ +
+ + {aliasMappings.length === 0 ? ( +
+ {translateOrFallback( + "customCliNoMappings", + "No alias mappings yet. Add one if your wrapper or team scripts use stable short names." + )} +
+ ) : ( +
+ {aliasMappings.map((mapping) => ( +
+ 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" + /> + + +
+ ))} +
+ )} +
+
+ +
+

+ {translateOrFallback("customCliEndpointHintLabel", "How to wire the endpoint")} +

+

+ {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 } + )} +

+
+ +
+
+
+

+ {translateOrFallback("customCliEnvBlockTitle", "Env / shell snippet")} +

+ +
+
{envScript}
+
+ +
+
+

+ {translateOrFallback("customCliJsonBlockTitle", "Provider JSON block")} +

+ +
+
{jsonConfig}
+
+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts b/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts new file mode 100644 index 0000000000..82488084a2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts @@ -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); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/index.tsx index dfab5ec0cc..ed2c1845f6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.tsx @@ -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"; diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx new file mode 100644 index 0000000000..496d38c67f --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -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("30d"); + const [analytics, setAnalytics] = useState(null); + const [presetCosts, setPresetCosts] = useState>({ + "1d": 0, + "7d": 0, + "30d": 0, + }); + const [loading, setLoading] = useState(true); + const [summaryLoading, setSummaryLoading] = useState(true); + const [error, setError] = useState(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 ; + } + + if (error && !analytics) { + return ( + + + + ); + } + + return ( +
+ +
+
+

{t("overviewTitle")}

+

{t("overviewDescription")}

+
+ ({ + value: option.value, + label: t(option.labelKey), + }))} + value={range} + onChange={(value) => setRange(value as CostRange)} + /> +
+
+ +
+ + + + +
+ + +
+ + + + +
+
+ + {summary.totalCost <= 0 ? ( + + + + ) : ( + <> +
+ + +
+ +
+ + +
+ + )} +
+ ); +} + +function MetricCard({ + label, + value, + subValue, + color = "text-text-main", + loading = false, +}: { + label: string; + value: string; + subValue?: string; + color?: string; + loading?: boolean; +}) { + return ( + +

{label}

+

{loading ? "…" : value}

+ {subValue ?

{subValue}

: null} +
+ ); +} + +function CompactMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +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 ( + +

+ {title} +

+
+
+ + + + {chartRows.map((entry) => ( + + ))} + + currencyFormatter.format(value || 0)} + contentStyle={{ + background: "var(--surface)", + border: "1px solid rgba(255,255,255,0.1)", + borderRadius: "12px", + }} + /> + + +
+
+ {chartRows.map((row) => ( +
+
+ + {row.name} +
+ + {currencyFormatter.format(row.value)} + +
+ ))} +
+
+
+ ); +} + +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 ( + +

+ {title} +

+
+ + + + + currencyFormatter.format(value).replace(".00", "")} + width={48} + /> + currencyFormatter.format(value || 0)} + contentStyle={{ + background: "var(--surface)", + border: "1px solid rgba(255,255,255,0.1)", + borderRadius: "12px", + }} + /> + + + +
+
+ ); +} + +function TopListCard({ + title, + rows, + nameKey, + valueKey, + locale, +}: { + title: string; + rows: Array>; + nameKey: string; + valueKey: string; + locale: string; +}) { + const currencyFormatter = createCurrencyFormatter(locale); + + return ( + +

+ {title} +

+
+ {rows.slice(0, 6).map((row) => ( +
+ {String(row[nameKey])} + + {currencyFormatter.format(Number(row[valueKey] || 0))} + +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/page.tsx b/src/app/(dashboard)/dashboard/costs/page.tsx index 96bf9c7028..3e79b09e9b 100644 --- a/src/app/(dashboard)/dashboard/costs/page.tsx +++ b/src/app/(dashboard)/dashboard/costs/page.tsx @@ -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 (
+
+

+ payments + {t("title")} +

+

{t("pageDescription")}

+
+ + {activeTab === "overview" && } {activeTab === "budget" && } {activeTab === "pricing" && }
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index b8d45573a9..2c1940090b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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() {

{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() {
-

Managed via Upstream Proxy Settings

-

- 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. -

+

{t("upstreamProxyManagedTitle")}

+

{t("upstreamProxyManagedDescription")}

terminal - Open CLI Tools + {t("openCliTools")} settings - Open Settings + {t("openSettings")}
@@ -3094,41 +3088,24 @@ export default function ProviderDetailPage() { {/* Search provider info */} {isSearchProvider && ( -

{t("searchProvider") || "Search Provider"}

-

- {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."} -

+

{t("searchProvider")}

+

{t("searchProviderDesc")}

{providerId === "perplexity-search" && (
link -

- Uses the same API key as Perplexity (chat provider). If you already - have Perplexity configured, no additional setup is needed. -

+

{t("perplexitySearchSharedKeyInfo")}

)} {providerId === "google-pse-search" && (
tune -

- Google Programmable Search requires two values: your API key and the Search Engine - ID (cx) from the Programmable Search Engine dashboard. -

+

{t("googlePseInfo")}

)} {providerId === "searxng-search" && (
dns -

- 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 - - . -

+

{t("searxngInfo")}

)}
@@ -3210,7 +3187,7 @@ export default function ProviderDetailPage() { @@ -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")}

)} @@ -4119,7 +4096,9 @@ function CustomModelsSection({
- Supported Endpoints + + {t("supportedEndpointsLabel")} +
{["chat", "embeddings", "images", "audio"].map((ep) => ( ))}
@@ -4187,22 +4166,22 @@ function CustomModelsSection({ {model.apiFormat === "responses" && ( - Responses + {t("responses")} )} {model.supportedEndpoints?.includes("embeddings") && ( - 📐 Embed + {`📐 ${t("supportedEndpointEmbeddings")}`} )} {model.supportedEndpoints?.includes("images") && ( - 🖼️ Images + {`🖼️ ${t("imagesShortLabel")}`} )} {model.supportedEndpoints?.includes("audio") && ( - 🔊 Audio + {`🔊 ${t("audioShortLabel")}`} )} {anyNormalizeCompatBadge(model.id, customMap, overrideMap) && ( @@ -4235,7 +4214,9 @@ function CustomModelsSection({
- + 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} />
)} {isCompatible && (

{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" > 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")} /> 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")} /> 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")} /> {provider === "bailian-coding-plan" && ( 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" /> )}

)} setFormData({ ...formData, validationModelId: e.target.value })} - hint="Usado como fallback se a listagem de models não estiver disponível" + hint={t("validationModelIdHint")} /> {usesBaseUrl && ( setFormData({ ...formData, baseUrl: e.target.value })} placeholder={getProviderBaseUrlPlaceholder(provider)} - hint={getProviderBaseUrlHint(provider)} + hint={getProviderBaseUrlHint(provider, t)} /> )} {isVertex && ( 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 && ( 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 && (
- + -

- Select the endpoint region for API access and quota tracking. -

+

{t("apiRegionHint")}

)}
@@ -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")} /> 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")} /> 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")} /> 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 && (
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" />
@@ -6415,11 +6386,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
{isGooglePse && ( 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" > 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")} /> {connection.provider === "bailian-coding-plan" && ( 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" /> )}
)} setFormData({ ...formData, validationModelId: e.target.value })} - hint="Usado como fallback se a listagem de models não estiver disponível" + hint={t("validationModelIdHint")} /> )} {usesBaseUrl && ( setFormData({ ...formData, baseUrl: e.target.value })} placeholder={getProviderBaseUrlPlaceholder(connection.provider)} - hint={getProviderBaseUrlHint(connection.provider)} + hint={getProviderBaseUrlHint(connection.provider, t)} /> )} {isVertex && ( 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 && ( 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 && (
- + -

- Select the endpoint region for API access and quota tracking. -

+

{t("apiRegionHint")}

)} @@ -6534,9 +6505,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec {!isOAuth && (
{extraApiKeys.length > 0 && ( @@ -6544,12 +6515,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec {extraApiKeys.map((key, idx) => (
- {`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`} + {t("extraApiKeyMasked", { + index: idx + 2, + prefix: key.slice(0, 6), + suffix: key.slice(-4), + })} @@ -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")}
{extraApiKeys.length > 0 && (

- {extraApiKeys.length + 1} keys total — rotating round-robin on each request. + {t("totalKeysRotating", { count: extraApiKeys.length + 1 })}

)}
@@ -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} >
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")} /> 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 && ( 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 && (
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 && ( = { 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>({}); + const [pricingData, setPricingData] = useState< + Record>> + >({}); + const [pricingSources, setPricingSources] = useState< + Record> + >({}); + const [syncStatus, setSyncStatus] = useState(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(null); + const [expandedProviders, setExpandedProviders] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); - const [editedProviders, setEditedProviders] = useState(new Set()); + const [editedProviders, setEditedProviders] = useState>(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); + } + + if (pricingRes.ok) { + const pricingPayload = (await pricingRes.json()) as { + pricing?: Record>>; + sourceMap?: Record>; + }; + 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 (
- {/* Header + Stats */}

{t("modelPricing")}

{t("modelPricingDesc")}

-
-
{t("providers")}
-
{stats.providers}
-
-
-
{t("registry")}
-
{stats.totalModels}
-
-
-
{t("priced")}
-
{stats.pricedCount as number}
-
+ + + +
- {/* Save Status */} - {saveStatus && ( -
- {saveStatus} + +
+
+

+ {t("pricingSyncTitle")} +

+

{t("pricingSyncDescription")}

+
+
+ + +
+
+ +
+ + + + +
+
+ + {statusMessage && ( +
+ {statusMessage.message}
)} - {/* Search + Provider Filter */}
@@ -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" />
@@ -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" > close - {selectedProvider.toUpperCase()} — {t("showAll")} + {selectedProvider.toUpperCase()} - {t("showAll")} )}
- {/* Provider Pills (quick filter) */}
- {allProviders.map((p) => ( + {allProviders.map((provider) => ( ))}
- {/* Provider Sections */}
{displayProviders.map((provider) => ( 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() { )}
- {/* Info Box */}

info @@ -303,11 +522,28 @@ export default function PricingTab() { ); } -// ── Provider Section (collapsible) ────────────────────────────────────── +function StatPill({ label, value, accent }: { label: string; value: number; accent?: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function SyncMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} function ProviderSection({ provider, pricingData, + sourceMap, isExpanded, isEdited, onToggle, @@ -315,10 +551,30 @@ function ProviderSection({ onSave, onReset, saving, + getSourceLabel, +}: { + provider: PricingCatalogProvider; + pricingData: Record>; + sourceMap: Record; + 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 + ); 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) */} - {/* Expanded: models table */} {isExpanded && (
- {/* Actions bar */}
{provider.modelCount} {t("models")} • {pricedCount} {t("withPricing")}
- {/* Table */}
@@ -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; + 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 ( @@ -459,6 +740,9 @@ function ModelRow({ model, pricing, onPricingChange }) { {t("custom")} )} + + {getSourceLabel(source)} + {model.id} @@ -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" /> diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx index 0a4c073adf..1d359d4fce 100644 --- a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx +++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx @@ -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 = { - 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" && } {mode === "chat-tester" && } {mode === "test-bench" && } + {mode === "stream-transformer" && } {mode === "live-monitor" && } ); diff --git a/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.tsx b/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.tsx index 7d535efaae..e75f91c57b 100644 --- a/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.tsx @@ -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) => { + 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() { {/* Stats Cards */} -
+
+ +
{/* Controls */} @@ -165,8 +191,8 @@ export default function LiveMonitorMode() {
+ - @@ -194,19 +220,37 @@ export default function LiveMonitorMode() { ? new Date(event.timestamp).toLocaleTimeString() : notAvailable} + -
{t("time")}{translateOrFallback("routeDetails", "Route")} {t("source")} {t("target")} {t("model")} {t("status")} +
+
+ + {event.routeProvider || event.provider || notAvailable} + + {event.routeCombo ? ( + + {translateOrFallback("comboBadge", "Combo")}: {event.routeCombo} + + ) : null} +
+
+ + {translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "} + {event.routeEndpoint || event.endpoint || notAvailable} + + {event.routeConnectionShortId ? ( + + {translateOrFallback("routeConnectionLabel", "Conn")}:{" "} + {event.routeConnectionShortId} + + ) : null} +
+
+
{srcMeta.label} - - {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 (
-
+