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 (
-
+