feat(i18n): comprehensive pt-BR localization and UI refactoring

This commit is contained in:
Ronaldo Davi
2026-05-22 14:46:33 -03:00
parent 1ea91b6c71
commit 3980690e2e
22 changed files with 5287 additions and 3542 deletions

View File

@@ -20,6 +20,8 @@ function ServiceToggle({
onToggle: () => void;
toggling: boolean;
}) {
const t = useTranslations("a2aDashboard");
const tCommon = useTranslations("common");
const online = enabled && status.online;
const loading = enabled && status.loading;
@@ -52,7 +54,7 @@ function ServiceToggle({
animation: online ? "pulse 2s infinite" : "none",
}}
/>
{loading ? "..." : online ? "Online" : "Offline"}
{loading ? "..." : online ? t("online") : t("offline")}
</div>
<button
@@ -65,7 +67,7 @@ function ServiceToggle({
opacity: toggling ? 0.6 : 1,
cursor: toggling ? "wait" : "pointer",
}}
title={enabled ? `Disable ${label}` : `Enable ${label}`}
title={enabled ? t("disableLabel", { label }) : t("enableLabel", { label })}
>
<span
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
@@ -80,13 +82,14 @@ function ServiceToggle({
className="text-xs font-medium min-w-[24px]"
style={{ color: enabled ? "rgb(34,197,94)" : "var(--color-text-muted)" }}
>
{toggling ? "..." : enabled ? "ON" : "OFF"}
{toggling ? "..." : enabled ? tCommon("on") : tCommon("off")}
</span>
</div>
);
}
function DisabledPanel() {
const t = useTranslations("a2aDashboard");
return (
<Card className="p-6">
<div className="flex items-start gap-3">
@@ -107,10 +110,10 @@ function DisabledPanel() {
</div>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
A2A is disabled
{t("a2aDisabledTitle")}
</h2>
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
Enable A2A above to view task telemetry, agent details, and validation tools.
{t("a2aDisabledDesc")}
</p>
</div>
</div>
@@ -183,24 +186,29 @@ export default function A2APage() {
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Agent2Agent JSON-RPC 2.0 endpoint send tasks, stream responses, cancel in-flight
jobs.
{t("a2aIntro")}
</p>
<ol
className="mt-2 text-sm space-y-0.5 list-decimal list-inside"
style={{ color: "var(--color-text-muted)" }}
>
<li>
Discover the agent card at <code className="text-xs">/.well-known/agent.json</code>.
{t.rich("a2aStep1", {
code: (chunks) => <code className="text-xs">{t("agentCardPath")}</code>,
})}
</li>
<li>
Send JSON-RPC to <code className="text-xs">{t("rpcEndpoint")}</code> using{" "}
<code className="text-xs">{t("rpcMethodSend")}</code> or{" "}
<code className="text-xs">{t("rpcMethodStream")}</code>.
{t.rich("a2aStep2", {
code1: (chunks) => <code className="text-xs">{t("rpcEndpoint")}</code>,
code2: (chunks) => <code className="text-xs">{t("rpcMethodSend")}</code>,
code3: (chunks) => <code className="text-xs">{t("rpcMethodStream")}</code>,
})}
</li>
<li>
Track and cancel tasks with <code className="text-xs">{t("rpcMethodGet")}</code> and{" "}
<code className="text-xs">{t("rpcMethodCancel")}</code>.
{t.rich("a2aStep3", {
code1: (chunks) => <code className="text-xs">{t("rpcMethodGet")}</code>,
code2: (chunks) => <code className="text-xs">{t("rpcMethodCancel")}</code>,
})}
</li>
</ol>
</div>

View File

@@ -126,20 +126,20 @@ export default function AgentSkillsPage() {
</div>
<ol className="space-y-1 text-xs text-text-muted">
<li>
1. Click <strong className="text-text-main">{t("copyUrl")}</strong> on the skill you
want your agent to know about.
1.{" "}
{t.rich("howToUseStep1", {
copyUrl: t("copyUrl"),
bold: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</li>
<li>
2. In your AI agent (Claude, Cursor, Cline), say:
2. {t("howToUseStep2")}
<br />
<code className="mt-1 block rounded border border-border bg-bg px-2 py-1 font-mono text-[11px]">
Use the skill at &lt;pasted-url&gt;
{t("howToUseStep2Code")}
</code>
</li>
<li>
3. The agent fetches the SKILL.md and learns OmniRoute&apos;s API or CLI no manual
docs needed.
</li>
<li>3. {t("howToUseStep3")}</li>
</ol>
<a
href={AGENT_SKILLS_REPO_URL}
@@ -156,13 +156,13 @@ export default function AgentSkillsPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkillSection
title={t("apiSkills")}
subtitle={`${apiSkills.length} skills — control OmniRoute via REST / HTTP`}
subtitle={t("apiSkillsSubtitle", { count: apiSkills.length })}
icon="api"
skills={apiSkills}
/>
<SkillSection
title={t("cliSkills")}
subtitle={`${cliSkills.length} skills — control OmniRoute via the omniroute terminal binary`}
subtitle={t("cliSkillsSubtitle", { count: cliSkills.length })}
icon="terminal"
skills={cliSkills}
/>

View File

@@ -78,7 +78,7 @@ export default function CachePerformance({
<div data-testid="cache-performance" className="p-5 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="font-medium text-sm">Performance</h2>
<h2 className="font-medium text-sm">{t("performanceTitle")}</h2>
</div>
{/* Error state */}
@@ -91,7 +91,7 @@ export default function CachePerformance({
className="self-start text-xs px-3 py-1.5 rounded bg-surface border border-border/50 hover:bg-surface/80 transition-colors"
aria-label={t("cachePerformanceRetry")}
>
Retry
{t("retry")}
</button>
)}
</div>
@@ -127,15 +127,15 @@ export default function CachePerformance({
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
<div>
<div className="text-lg font-semibold tabular-nums text-green-500">{hits}</div>
<div className="text-xs text-text-muted mt-0.5">Hits</div>
<div className="text-xs text-text-muted mt-0.5">{t("hits")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums text-red-400">{misses}</div>
<div className="text-xs text-text-muted mt-0.5">Misses</div>
<div className="text-xs text-text-muted mt-0.5">{t("misses")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums">{totalRequests}</div>
<div className="text-xs text-text-muted mt-0.5">Total</div>
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
</div>
</div>

View File

@@ -39,17 +39,6 @@ interface ReasoningCacheData {
// ──────────────── Helpers ────────────────
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatChars(chars: number): string {
if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`;
if (chars >= 1_000) return `${(chars / 1_000).toFixed(1)}K`;
@@ -142,6 +131,17 @@ export default function ReasoningCacheTab() {
const [clearing, setClearing] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const timeAgo = (dateStr: string): string => {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return t("justNow");
if (minutes < 60) return t("minutesAgo", { minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("hoursAgo", { hours });
const days = Math.floor(hours / 24);
return t("daysAgo", { days });
};
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/cache/reasoning");
@@ -281,10 +281,10 @@ export default function ReasoningCacheTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
<th className="px-4 py-3">Provider</th>
<th className="px-4 py-3">{t("tableProvider")}</th>
<th className="px-4 py-3">{t("reasoningEntries")}</th>
<th className="px-4 py-3">{t("reasoningChars")}</th>
<th className="px-4 py-3">Share</th>
<th className="px-4 py-3">{t("tableShare")}</th>
</tr>
</thead>
<tbody>
@@ -334,7 +334,7 @@ export default function ReasoningCacheTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
<th className="px-4 py-3">Model</th>
<th className="px-4 py-3">{t("tableModel")}</th>
<th className="px-4 py-3">{t("reasoningEntries")}</th>
<th className="px-4 py-3">{t("reasoningAvgChars")}</th>
<th className="px-4 py-3">{t("reasoningChars")}</th>
@@ -379,8 +379,8 @@ export default function ReasoningCacheTab() {
<div className="overflow-hidden rounded-2xl border border-border/20 bg-surface/35">
<div className="grid grid-cols-[minmax(120px,1fr)_100px_minmax(100px,1fr)_80px_80px_60px] gap-3 border-b border-border/20 px-4 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-text-muted">
<span>{t("reasoningToolCallId")}</span>
<span>Provider</span>
<span>Model</span>
<span>{t("tableProvider")}</span>
<span>{t("tableModel")}</span>
<span>{t("reasoningChars")}</span>
<span>{t("reasoningAge")}</span>
<span />
@@ -433,19 +433,20 @@ export default function ReasoningCacheTab() {
</pre>
<div className="mt-3 flex flex-wrap gap-4 text-xs text-text-muted">
<span>
Provider: <span className="text-text-main">{entry.provider}</span>
{t("tableProvider")}:{" "}
<span className="text-text-main">{entry.provider}</span>
</span>
<span>
Model: <span className="text-text-main">{entry.model}</span>
{t("tableModel")}: <span className="text-text-main">{entry.model}</span>
</span>
<span>
Created:{" "}
{t("created")}:{" "}
<span className="text-text-main">
{new Date(entry.createdAt).toLocaleString()}
</span>
</span>
<span>
Expires:{" "}
{t("expires")}:{" "}
<span className="text-text-main">
{new Date(entry.expiresAt).toLocaleString()}
</span>

View File

@@ -481,7 +481,7 @@ export default function CachePage() {
</div>
{loading && (
<div className="grid grid-cols-1 gap-6" aria-busy="true" aria-label="Loading cache">
<div className="grid grid-cols-1 gap-6" aria-busy="true" aria-label={t("loadingCacheAria")}>
<div className="h-96 rounded-3xl bg-surface-raised animate-pulse" />
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -233,10 +233,8 @@ export default function CavemanContextPageClient() {
)}
<section className="rounded-lg border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-text-main">Input compression</h2>
<p className="mt-1 text-xs text-text-muted">
Rewrite chat history with shorter wording. Reduces input tokens by ~50%.
</p>
<h2 className="text-sm font-semibold text-text-main">{t("inputCompressionTitle")}</h2>
<p className="mt-1 text-xs text-text-muted">{t("inputCompressionDesc")}</p>
<div className="mt-3 flex flex-wrap gap-4 text-sm text-text-main">
<label className="flex items-center gap-2">
<input

View File

@@ -181,10 +181,7 @@ export default function RtkContextPageClient() {
{masterEnabled === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<span className="material-symbols-outlined text-[18px]">info</span>
<p>
Token Saver master switch is OFF these settings will not affect requests until you
turn it on from the Endpoint page.
</p>
<p>{t("masterSwitchOffAlert")}</p>
</div>
)}

View File

@@ -22,6 +22,7 @@ function ServiceToggle({
onToggle: () => void;
toggling: boolean;
}) {
const t = useTranslations("mcpDashboard");
const online = enabled && status.online;
const loading = enabled && status.loading;
@@ -54,7 +55,7 @@ function ServiceToggle({
animation: online ? "pulse 2s infinite" : "none",
}}
/>
{loading ? "..." : online ? "Online" : "Offline"}
{loading ? "..." : online ? t("online") : t("offline")}
</div>
<button
@@ -67,7 +68,7 @@ function ServiceToggle({
opacity: toggling ? 0.6 : 1,
cursor: toggling ? "wait" : "pointer",
}}
title={enabled ? `Disable ${label}` : `Enable ${label}`}
title={enabled ? t("disableLabel", { label }) : t("enableLabel", { label })}
>
<span
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
@@ -101,12 +102,12 @@ function TransportSelector({
}) {
const t = useTranslations("mcpDashboard");
const options: { value: McpTransport; label: string; desc: string }[] = [
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
{ value: "stdio", label: "stdio", desc: t("transportStdioDesc") },
{ value: "sse", label: "SSE", desc: t("transportSseDesc") },
{
value: "streamable-http",
label: "Streamable HTTP",
desc: "Remote — Modern bidirectional HTTP",
desc: t("transportStreamableHttpDesc"),
},
];
@@ -129,7 +130,7 @@ function TransportSelector({
swap_horiz
</span>
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
Transport Mode
{t("transportMode")}
</span>
</div>
@@ -185,7 +186,7 @@ function TransportSelector({
onClick={() => void copyToClipboard(urlMap[value])}
title={t("mcpDashboardCopyUrl")}
>
Copy
{t("copy")}
</button>
)}
</div>
@@ -194,6 +195,7 @@ function TransportSelector({
}
function DisabledPanel() {
const t = useTranslations("mcpDashboard");
return (
<Card className="p-6">
<div className="flex items-start gap-3">
@@ -214,10 +216,10 @@ function DisabledPanel() {
</div>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
MCP is disabled
{t("mcpDisabledTitle")}
</h2>
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
Enable MCP above to configure transport mode and view server telemetry.
{t("mcpDisabledDesc")}
</p>
</div>
</div>
@@ -226,6 +228,7 @@ function DisabledPanel() {
}
export default function McpPage() {
const t = useTranslations("mcpDashboard");
const [mcpStatus, setMcpStatus] = useState<ServiceStatus>({ online: false, loading: true });
const [mcpEnabled, setMcpEnabled] = useState(false);
const [mcpToggling, setMcpToggling] = useState(false);
@@ -313,20 +316,23 @@ export default function McpPage() {
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Model Context Protocol 37 tools across 13 scopes, 3 transports (stdio / SSE /
Streamable HTTP).
{t("mcpIntro", { tools: 37, scopes: 13, transports: 3 })}
</p>
<ol
className="mt-2 text-sm space-y-0.5 list-decimal list-inside"
style={{ color: "var(--color-text-muted)" }}
>
<li>
Run via <code className="text-xs">omniroute --mcp</code>
{t.rich("mcpStep1", {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</li>
<li>Configure your MCP client to connect over stdio transport.</li>
<li>{t("mcpStep2")}</li>
<li>
Invoke tools like <code className="text-xs">omniroute_get_health</code> and{" "}
<code className="text-xs">omniroute_list_combos</code>.
{t.rich("mcpStep3", {
code1: (chunks) => <code className="text-xs">omniroute_get_health</code>,
code2: (chunks) => <code className="text-xs">omniroute_list_combos</code>,
})}
</li>
</ol>
</div>

View File

@@ -0,0 +1,471 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge, Button, Card, Input, Modal, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
type TierName = "LOCAL_ONLY" | "ALWAYS_PROTECTED" | "MANAGEMENT" | "CLIENT_API" | "PUBLIC";
interface TierEntry {
name: TierName;
prefixes: string[];
description: string;
bypassable: boolean;
}
interface InventoryPayload {
tiers: TierEntry[];
bypassEnabled: boolean;
bypassPrefixes: string[];
spawnCapablePrefixes: string[];
}
interface StatusMessage {
type: "success" | "error";
message: string;
}
type ErrorCode =
| "PASSWORD_REQUIRED"
| "PASSWORD_MISMATCH"
| "INSUFFICIENT_SCOPE"
| "BYPASS_PREFIX_NOT_ALLOWED"
| "GENERIC";
// ─── helpers ──────────────────────────────────────────────────────────────
function tierBadgeVariant(
tier: TierName,
prefix: string,
spawnCapable: ReadonlyArray<string>,
bypassPrefixes: ReadonlyArray<string>,
bypassEnabled: boolean
): { variant: "default" | "success" | "warning" | "error" | "info"; key: string } {
if (spawnCapable.some((p) => prefix === p || prefix.startsWith(p))) {
return { variant: "error", key: "spawn_capable" };
}
if (tier === "LOCAL_ONLY") {
const isLive = bypassEnabled && bypassPrefixes.some((p) => p === prefix);
return isLive ? { variant: "warning", key: "bypassable" } : { variant: "info", key: "strict" };
}
if (tier === "ALWAYS_PROTECTED") return { variant: "error", key: "always_protected" };
if (tier === "PUBLIC") return { variant: "default", key: "public" };
return { variant: "info", key: "auth_required" };
}
function parseErrorCode(payload: unknown): ErrorCode {
if (!payload || typeof payload !== "object") return "GENERIC";
const errorField = (payload as { error?: unknown }).error;
if (typeof errorField === "string") {
if (errorField.toLowerCase().includes("manage")) return "INSUFFICIENT_SCOPE";
return "GENERIC";
}
if (errorField && typeof errorField === "object") {
const code = (errorField as { code?: unknown }).code;
if (
code === "PASSWORD_REQUIRED" ||
code === "PASSWORD_MISMATCH" ||
code === "INSUFFICIENT_SCOPE" ||
code === "BYPASS_PREFIX_NOT_ALLOWED"
) {
return code;
}
// Zod validation surface (T-011 emits BYPASS_PREFIX_NOT_ALLOWED inside
// `error.details[].message`).
const details = (errorField as { details?: unknown }).details;
if (Array.isArray(details)) {
for (const d of details) {
const m = (d as { message?: unknown }).message;
if (typeof m === "string" && m.includes("BYPASS_PREFIX_NOT_ALLOWED")) {
return "BYPASS_PREFIX_NOT_ALLOWED";
}
}
}
}
return "GENERIC";
}
// ─── component ────────────────────────────────────────────────────────────
export default function AuthzSection() {
const t = useTranslations("settings");
const [inventory, setInventory] = useState<InventoryPayload | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// Draft state — only persisted on Save (security-impacting fields require
// a password re-prompt before the PATCH is fired).
const [draftEnabled, setDraftEnabled] = useState<boolean>(true);
const [draftPrefixes, setDraftPrefixes] = useState<string[]>([]);
const [newPrefixInput, setNewPrefixInput] = useState("");
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [status, setStatus] = useState<StatusMessage | null>(null);
const loadInventory = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const res = await fetch("/api/settings/authz-inventory", {
credentials: "include",
});
if (!res.ok) {
const code = parseErrorCode(await res.json().catch(() => null));
setLoadError(t(`authz.error.${code}`));
return;
}
const data = (await res.json()) as InventoryPayload;
setInventory(data);
setDraftEnabled(data.bypassEnabled);
setDraftPrefixes([...data.bypassPrefixes]);
} catch {
setLoadError(t("authz.loadError"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadInventory();
}, [loadInventory]);
const dirty = useMemo(() => {
if (!inventory) return false;
if (draftEnabled !== inventory.bypassEnabled) return true;
if (draftPrefixes.length !== inventory.bypassPrefixes.length) return true;
const a = [...draftPrefixes].sort();
const b = [...inventory.bypassPrefixes].sort();
return a.some((p, i) => p !== b[i]);
}, [draftEnabled, draftPrefixes, inventory]);
const spawnCapable = useMemo(() => inventory?.spawnCapablePrefixes ?? [], [inventory]);
const isSpawnCapable = useCallback(
(prefix: string) => spawnCapable.some((p) => prefix === p || prefix.startsWith(p)),
[spawnCapable]
);
const handleAddPrefix = () => {
const trimmed = newPrefixInput.trim();
if (!trimmed) return;
if (draftPrefixes.includes(trimmed)) {
setNewPrefixInput("");
return;
}
setDraftPrefixes((prev) => [...prev, trimmed]);
setNewPrefixInput("");
};
const handleRemovePrefix = (prefix: string) => {
if (isSpawnCapable(prefix)) return;
setDraftPrefixes((prev) => prev.filter((p) => p !== prefix));
};
const handleSaveRequest = () => {
if (!dirty) return;
setCurrentPassword("");
setStatus(null);
setPasswordModalOpen(true);
};
const handleSubmit = async () => {
if (!currentPassword) {
setStatus({ type: "error", message: t("authz.error.PASSWORD_REQUIRED") });
return;
}
setSubmitting(true);
setStatus(null);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
localOnlyManageScopeBypassEnabled: draftEnabled,
localOnlyManageScopeBypassPrefixes: draftPrefixes,
currentPassword,
}),
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
const code = parseErrorCode(payload);
setStatus({ type: "error", message: t(`authz.error.${code}`) });
return;
}
setStatus({ type: "success", message: t("authz.saved") });
setPasswordModalOpen(false);
setCurrentPassword("");
await loadInventory();
} catch {
setStatus({ type: "error", message: t("authz.error.GENERIC") });
} finally {
setSubmitting(false);
}
};
// ─── render ─────────────────────────────────────────────────────────────
if (loading) {
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-info/10 text-info">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
shield_lock
</span>
</div>
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
</div>
<p className="text-sm text-text-muted">{t("authz.loading")}</p>
</Card>
);
}
if (loadError || !inventory) {
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-info/10 text-info">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
shield_lock
</span>
</div>
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
</div>
<p className="text-sm text-red-500">{loadError ?? t("authz.loadError")}</p>
</Card>
);
}
return (
<>
{/* Authz header + tier inventory */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-info/10 text-info">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
shield_lock
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
<p className="text-sm text-text-muted">{t("authz.description")}</p>
</div>
</div>
<div className="flex flex-col gap-4">
{inventory.tiers.map((tier) => (
<div
key={tier.name}
className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-4"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h4 className="font-semibold">{t(`authz.tier.${tier.name}`)}</h4>
{tier.bypassable && (
<Badge variant="warning" size="sm">
{t("authz.badge.bypassable")}
</Badge>
)}
</div>
</div>
<p className="text-sm text-text-muted mb-3">{tier.description}</p>
<ul className="flex flex-col gap-2">
{tier.prefixes.map((prefix) => {
const badge = tierBadgeVariant(
tier.name,
prefix,
inventory.spawnCapablePrefixes,
inventory.bypassPrefixes,
inventory.bypassEnabled
);
return (
<li
key={`${tier.name}:${prefix}`}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
>
<code className="text-xs font-mono">{prefix}</code>
<Badge variant={badge.variant} size="sm">
{t(`authz.badge.${badge.key}`)}
</Badge>
</li>
);
})}
</ul>
</div>
))}
</div>
</Card>
{/* Bypass policy editor */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
tune
</span>
</div>
<h3 className="text-lg font-semibold">{t("authz.bypass.section")}</h3>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<p className="font-medium">{t("authz.bypass.kill_switch.label")}</p>
<p className="text-sm text-text-muted">{t("authz.bypass.kill_switch.desc")}</p>
</div>
<Toggle
checked={draftEnabled}
onChange={() => setDraftEnabled((prev) => !prev)}
disabled={submitting}
/>
</div>
<div className="flex flex-col gap-3">
<div>
<p className="font-medium">{t("authz.bypass.prefix.label")}</p>
<p className="text-sm text-text-muted">{t("authz.bypass.prefix.desc")}</p>
</div>
{draftPrefixes.length === 0 && (
<p className="text-sm text-text-muted italic">{t("authz.bypass.prefix.empty")}</p>
)}
<ul className="flex flex-col gap-2">
{draftPrefixes.map((prefix) => {
const locked = isSpawnCapable(prefix);
return (
<li
key={prefix}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
>
<div className="flex flex-col">
<code className="text-xs font-mono">{prefix}</code>
{locked && (
<span className="text-[10px] text-red-500 mt-1">
{t("authz.bypass.cli_tools_runtime_note")}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePrefix(prefix)}
disabled={locked || submitting}
>
Remove
</Button>
</li>
);
})}
{/* Static read-only rows for spawn-capable prefixes that are NOT
in the draft list — surface them so the operator understands
they are intentionally not toggleable. */}
{spawnCapable
.filter((p) => !draftPrefixes.includes(p))
.map((prefix) => (
<li
key={`locked:${prefix}`}
className="flex items-center justify-between gap-3 rounded-md border border-red-500/30 bg-red-500/[0.04] px-3 py-2 opacity-80"
>
<div className="flex flex-col">
<code className="text-xs font-mono">{prefix}</code>
<span className="text-[10px] text-red-500 mt-1">
{t("authz.bypass.cli_tools_runtime_note")}
</span>
</div>
<Badge variant="error" size="sm">
{t("authz.badge.spawn_capable")}
</Badge>
</li>
))}
</ul>
<div className="flex gap-2 items-end pt-2">
<Input
type="text"
label={t("authz.bypass.prefix.add")}
placeholder={t("authz.bypass.prefix.placeholder")}
value={newPrefixInput}
onChange={(e) => setNewPrefixInput(e.target.value)}
disabled={submitting}
/>
<Button
variant="secondary"
onClick={handleAddPrefix}
disabled={!newPrefixInput.trim() || submitting}
>
{t("authz.bypass.prefix.add")}
</Button>
</div>
</div>
{/* Save bar */}
<div className="flex items-center justify-between gap-4 pt-4 mt-4 border-t border-border/50">
<div className="text-sm">
{dirty && (
<span className="text-amber-600 dark:text-amber-400">{t("authz.pending")}</span>
)}
{status && (
<span
className={`ml-3 ${status.type === "error" ? "text-red-500" : "text-green-500"}`}
>
{status.message}
</span>
)}
</div>
<Button variant="primary" onClick={handleSaveRequest} disabled={!dirty || submitting}>
{t("authz.save")}
</Button>
</div>
</Card>
{/* Password re-auth modal — fires for every security-impacting PATCH */}
<Modal
isOpen={passwordModalOpen}
onClose={() => {
if (!submitting) {
setPasswordModalOpen(false);
setCurrentPassword("");
}
}}
title={t("authz.password.prompt.label")}
footer={
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setPasswordModalOpen(false);
setCurrentPassword("");
}}
disabled={submitting}
>
{t("authz.password.cancel")}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
loading={submitting}
disabled={!currentPassword || submitting}
>
{t("authz.password.submit")}
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">{t("authz.password.prompt.desc")}</p>
<Input
type="password"
placeholder={t("authz.password.placeholder")}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoFocus
/>
{status?.type === "error" && <p className="text-sm text-red-500">{status.message}</p>}
</div>
</Modal>
</>
);
}

View File

@@ -31,8 +31,8 @@ function getRuleSectionCount(value: unknown, keys: string[]): number {
return 0;
}
function getErrorMessage(payload: unknown): string {
if (!isObjectRecord(payload)) return "Failed to save payload rules";
function getErrorMessage(payload: unknown, fallback: string): string {
if (!isObjectRecord(payload)) return fallback;
const nestedError = payload.error;
if (typeof nestedError === "string" && nestedError.trim()) {
@@ -52,11 +52,12 @@ function getErrorMessage(payload: unknown): string {
}
}
return "Failed to save payload rules";
return fallback;
}
export default function PayloadRulesTab() {
const t = useTranslations("settings");
const tCommon = useTranslations("common");
const [editorValue, setEditorValue] = useState(EMPTY_PAYLOAD_RULES_TEXT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -66,16 +67,16 @@ export default function PayloadRulesTab() {
try {
const parsed = JSON.parse(editorValue);
if (!isObjectRecord(parsed)) {
return { value: null, error: "Payload rules must be a JSON object." };
return { value: null, error: t("payloadMustBeObject") };
}
return { value: parsed, error: null };
} catch (error) {
return {
value: null,
error: error instanceof Error ? error.message : "Invalid JSON payload.",
error: error instanceof Error ? error.message : t("payloadInvalidJson"),
};
}
}, [editorValue]);
}, [editorValue, t]);
const summary = useMemo(() => {
const source = parsedEditor.value;
@@ -94,19 +95,19 @@ export default function PayloadRulesTab() {
const response = await fetch("/api/settings/payload-rules");
const payload = await response.json();
if (!response.ok) {
throw new Error(getErrorMessage(payload));
throw new Error(getErrorMessage(payload, tCommon("failedToLoad")));
}
setEditorValue(JSON.stringify(payload, null, 2));
} catch (error) {
setMessage({
type: "error",
text: error instanceof Error ? error.message : "Failed to load payload rules",
text: error instanceof Error ? error.message : tCommon("failedToLoad"),
});
} finally {
setLoading(false);
}
}, []);
}, [tCommon]);
useEffect(() => {
loadConfig();
@@ -116,7 +117,7 @@ export default function PayloadRulesTab() {
setEditorValue(EMPTY_PAYLOAD_RULES_TEXT);
setMessage({
type: "info",
text: "Editor reset to the neutral template. Save to apply it.",
text: t("payloadResetInfo"),
});
};
@@ -124,7 +125,7 @@ export default function PayloadRulesTab() {
if (parsedEditor.error || !parsedEditor.value) {
setMessage({
type: "error",
text: parsedEditor.error || "Payload rules must be valid JSON before saving.",
text: parsedEditor.error || t("payloadValidJsonRequired"),
});
return;
}
@@ -139,15 +140,15 @@ export default function PayloadRulesTab() {
});
const payload = await response.json();
if (!response.ok) {
throw new Error(getErrorMessage(payload));
throw new Error(getErrorMessage(payload, tCommon("error")));
}
setEditorValue(JSON.stringify(payload, null, 2));
setMessage({ type: "success", text: "Payload rules saved and hot reloaded." });
setMessage({ type: "success", text: t("payloadSaveSuccess") });
} catch (error) {
setMessage({
type: "error",
text: error instanceof Error ? error.message : "Failed to save payload rules",
text: error instanceof Error ? error.message : tCommon("error"),
});
} finally {
setSaving(false);
@@ -165,53 +166,41 @@ export default function PayloadRulesTab() {
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{t("payloadRulesTitle")}</h3>
<p className="text-sm text-text-muted mt-1">
Configure request payload mutations by model and protocol. Changes are persisted in
settings and hot reloaded into the runtime immediately after save.
</p>
<p className="text-sm text-text-muted mt-1">{t("payloadRulesDesc")}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
<p className="text-sm font-medium">default</p>
<p className="text-xs text-text-muted mt-1">
Applies params only when the target path is missing from the outgoing payload.
</p>
<p className="text-sm font-medium">{t("payloadRuleDefaultTitle")}</p>
<p className="text-xs text-text-muted mt-1">{t("payloadRuleDefaultDesc")}</p>
</div>
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
<p className="text-sm font-medium">override</p>
<p className="text-xs text-text-muted mt-1">
Forces values onto the payload, replacing anything already present at that path.
</p>
<p className="text-sm font-medium">{t("payloadRuleOverrideTitle")}</p>
<p className="text-xs text-text-muted mt-1">{t("payloadRuleOverrideDesc")}</p>
</div>
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
<p className="text-sm font-medium">filter</p>
<p className="text-xs text-text-muted mt-1">
Removes blocked params from the payload before the upstream request is sent.
</p>
<p className="text-sm font-medium">{t("payloadRuleFilterTitle")}</p>
<p className="text-xs text-text-muted mt-1">{t("payloadRuleFilterDesc")}</p>
</div>
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
<p className="text-sm font-medium">defaultRaw</p>
<p className="text-xs text-text-muted mt-1">
Like <code>default</code>, but string values are parsed as JSON first when possible.
The legacy input alias <code>default-raw</code> is also accepted on save.
</p>
<p className="text-sm font-medium">{t("payloadRuleDefaultRawTitle")}</p>
<p className="text-xs text-text-muted mt-1">{t("payloadRuleDefaultRawDesc")}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-text-muted">
<span className="rounded-full border border-border px-2.5 py-1">
default: {summary.default}
{t("payloadRuleDefaultTitle")}: {summary.default}
</span>
<span className="rounded-full border border-border px-2.5 py-1">
override: {summary.override}
{t("payloadRuleOverrideTitle")}: {summary.override}
</span>
<span className="rounded-full border border-border px-2.5 py-1">
filter: {summary.filter}
{t("payloadRuleFilterTitle")}: {summary.filter}
</span>
<span className="rounded-full border border-border px-2.5 py-1">
defaultRaw: {summary.defaultRaw}
{t("payloadRuleDefaultRawTitle")}: {summary.defaultRaw}
</span>
</div>
@@ -239,14 +228,12 @@ export default function PayloadRulesTab() {
<div className="rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-bg-secondary/30">
<div>
<p className="text-sm font-medium">Editor</p>
<p className="text-xs text-text-muted">
Use the runtime schema shape: <code>default</code>, <code>override</code>,{" "}
<code>filter</code>, <code>defaultRaw</code>. The API also accepts the legacy input
key <code>default-raw</code>.
</p>
<p className="text-sm font-medium">{t("payloadEditorTitle")}</p>
<p className="text-xs text-text-muted">{t("payloadEditorDesc")}</p>
</div>
<div className="text-xs text-text-muted">
{loading ? tCommon("loading") : t("payloadEditorReady")}
</div>
<div className="text-xs text-text-muted">{loading ? "Loading..." : "Ready"}</div>
</div>
<textarea
value={editorValue}
@@ -263,19 +250,19 @@ export default function PayloadRulesTab() {
{parsedEditor.error && (
<p className="text-sm text-red-500">
JSON parse error: <span className="font-medium">{parsedEditor.error}</span>
{t("payloadJsonParseError", { error: parsedEditor.error })}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={loadConfig} disabled={loading || saving}>
Reload
{tCommon("refresh")}
</Button>
<Button variant="secondary" onClick={handleReset} disabled={loading || saving}>
Reset Template
{tCommon("reset")}
</Button>
<Button onClick={handleSave} disabled={loading || saving || !!parsedEditor.error}>
{saving ? "Saving..." : "Save Payload Rules"}
{saving ? t("saving") : t("savePayloadRules")}
</Button>
</div>
</div>

View File

@@ -108,26 +108,26 @@ function parseBulkImportText(text: string): {
const lineNum = i + 1;
if (!name) {
errors.push({ line: lineNum, reason: "Missing NAME" });
errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" });
continue;
}
if (!host) {
errors.push({ line: lineNum, reason: "Missing HOST" });
errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" });
continue;
}
const port = Number(portStr);
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
errors.push({ line: lineNum, reason: "Invalid PORT (must be 1-65535)" });
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" });
continue;
}
const normalizedType = (type || "socks5").toLowerCase();
if (!VALID_TYPES.has(normalizedType)) {
errors.push({ line: lineNum, reason: `Invalid TYPE '${type}' (use http, https, or socks5)` });
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
continue;
}
const normalizedStatus = (status || "active").toLowerCase();
if (!VALID_STATUSES.has(normalizedStatus)) {
errors.push({ line: lineNum, reason: `Invalid STATUS '${status}' (use active or inactive)` });
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
continue;
}
@@ -251,7 +251,7 @@ export default function ProxyRegistryManager() {
} finally {
setLoading(false);
}
}, [loadHealth, loadAllUsage]);
}, [loadHealth, loadAllUsage, t]);
useEffect(() => {
void load();
@@ -535,7 +535,7 @@ export default function ProxyRegistryManager() {
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to import proxies");
setError(data?.error?.message || t("errorSaveFailed"));
return;
}
@@ -547,7 +547,7 @@ export default function ProxyRegistryManager() {
await load();
} catch (e: any) {
setError(e?.message || "Failed to import proxies");
setError(e?.message || t("errorSaveFailed"));
} finally {
setBulkImporting(false);
}
@@ -651,7 +651,7 @@ export default function ProxyRegistryManager() {
</td>
<td className="py-2 pr-3">
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
{item.status || "active"}
{item.status === "inactive" ? t("statusInactive") : t("statusActive")}
</span>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
@@ -668,7 +668,7 @@ export default function ProxyRegistryManager() {
</>
) : (
<span className="text-red-400">
{testById[item.id]!.error || "failed"}
{testById[item.id]!.error || t("failed")}
</span>
)
) : health ? (
@@ -755,7 +755,7 @@ export default function ProxyRegistryManager() {
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Type</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelType")}</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.type}
@@ -767,7 +767,7 @@ export default function ProxyRegistryManager() {
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Host</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelHost")}</label>
<input
data-testid="proxy-registry-host-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
@@ -776,7 +776,7 @@ export default function ProxyRegistryManager() {
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Port</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelPort")}</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.port}
@@ -784,26 +784,26 @@ export default function ProxyRegistryManager() {
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Username</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelUsername")}</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.username}
placeholder={editingId ? "Leave blank to keep current username" : ""}
placeholder={editingId ? t("usernamePlaceholderEdit") : ""}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Password</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelPassword")}</label>
<input
type="password"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.password}
placeholder={editingId ? "Leave blank to keep current password" : ""}
placeholder={editingId ? t("passwordPlaceholderEdit") : ""}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Region</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelRegion")}</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.region}
@@ -811,20 +811,20 @@ export default function ProxyRegistryManager() {
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Status</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelStatus")}</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="active">{t("statusActive")}</option>
<option value="inactive">{t("statusInactive")}</option>
</select>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Notes</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelNotes")}</label>
<textarea
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.notes}
@@ -835,10 +835,10 @@ export default function ProxyRegistryManager() {
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setModalOpen(false)}>
Cancel
{t("cancel")}
</Button>
<Button size="sm" icon="save" onClick={handleSave} loading={saving}>
Save
{t("save")}
</Button>
</div>
</form>
@@ -855,20 +855,20 @@ export default function ProxyRegistryManager() {
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted mb-1 block">Scope</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelScope")}</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={bulkScope}
onChange={(e) => setBulkScope(e.target.value)}
>
<option value="global">global</option>
<option value="provider">provider</option>
<option value="account">account</option>
<option value="combo">combo</option>
<option value="global">{t("scopeGlobal")}</option>
<option value="provider">{t("scopeProvider")}</option>
<option value="account">{t("scopeAccount")}</option>
<option value="combo">{t("scopeCombo")}</option>
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Proxy</label>
<label className="text-xs text-text-muted mb-1 block">{t("labelProxy")}</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={bulkProxyId}
@@ -886,23 +886,21 @@ export default function ProxyRegistryManager() {
{bulkScope !== "global" && (
<div>
<label className="text-xs text-text-muted mb-1 block">
Scope IDs (comma or newline)
</label>
<label className="text-xs text-text-muted mb-1 block">{t("bulkLabelScopeIds")}</label>
<textarea
data-testid="proxy-registry-bulk-scopeids-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
rows={5}
value={bulkScopeIds}
onChange={(e) => setBulkScopeIds(e.target.value)}
placeholder="provider-openai,provider-anthropic"
placeholder={t("bulkScopeIdsPlaceholder")}
/>
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setBulkOpen(false)}>
Cancel
{t("cancel")}
</Button>
<Button
size="sm"
@@ -911,7 +909,7 @@ export default function ProxyRegistryManager() {
loading={bulkSaving}
data-testid="proxy-registry-bulk-apply"
>
Apply
{t("bulkApply")}
</Button>
</div>
</div>
@@ -978,7 +976,7 @@ export default function ProxyRegistryManager() {
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
{bulkImportErrors.map((err, idx) => (
<div key={idx} className="text-xs text-red-400">
{t("bulkImportErrorLine", { line: err.line, reason: err.reason })}
{t("bulkImportErrorLine", { line: err.line, reason: t(err.reason as any) })}
</div>
))}
</div>
@@ -990,13 +988,13 @@ export default function ProxyRegistryManager() {
<table className="w-full text-xs">
<thead>
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
<th className="py-1.5 px-2">Name</th>
<th className="py-1.5 px-2">Type</th>
<th className="py-1.5 px-2">Host</th>
<th className="py-1.5 px-2">Port</th>
<th className="py-1.5 px-2">User</th>
<th className="py-1.5 px-2">Region</th>
<th className="py-1.5 px-2">Status</th>
<th className="py-1.5 px-2">{t("tableName")}</th>
<th className="py-1.5 px-2">{t("labelType")}</th>
<th className="py-1.5 px-2">{t("labelHost")}</th>
<th className="py-1.5 px-2">{t("labelPort")}</th>
<th className="py-1.5 px-2">{t("labelUsername")}</th>
<th className="py-1.5 px-2">{t("labelRegion")}</th>
<th className="py-1.5 px-2">{t("labelStatus")}</th>
</tr>
</thead>
<tbody>
@@ -1018,7 +1016,7 @@ export default function ProxyRegistryManager() {
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
}
>
{entry.status}
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
</span>
</td>
</tr>

View File

@@ -192,74 +192,50 @@ type TransformOpKind =
| "obfuscate_words";
const OP_KIND_LABELS: Record<TransformOpKind, string> = {
drop_paragraph_if_contains: "Drop paragraph (contains)",
drop_paragraph_if_starts_with: "Drop paragraph (starts with)",
replace_text: "Replace text",
replace_regex: "Replace regex",
drop_block_if_contains: "Drop block (contains)",
prepend_system_block: "Prepend system block",
append_system_block: "Append system block",
inject_billing_header: "Inject billing header",
obfuscate_words: "Obfuscate words (ZWJ)",
drop_paragraph_if_contains: "routingOpDropParagraphContainsLabel",
drop_paragraph_if_starts_with: "routingOpDropParagraphStartsWithLabel",
replace_text: "routingOpReplaceTextLabel",
replace_regex: "routingOpReplaceRegexLabel",
drop_block_if_contains: "routingOpDropBlockContainsLabel",
prepend_system_block: "routingOpPrependSystemBlockLabel",
append_system_block: "routingOpAppendSystemBlockLabel",
inject_billing_header: "routingOpInjectBillingHeaderLabel",
obfuscate_words: "routingOpObfuscateWordsLabel",
};
// Human-readable description shown above each op's editor. Explains in one
// sentence what the op DOES (transformation effect) and one sentence WHEN
// to use it (the typical fingerprint-sanitization use-case).
const OP_KIND_DESCRIPTIONS: Record<TransformOpKind, string> = {
drop_paragraph_if_contains:
"Removes any paragraph (text block split on blank lines) inside the system prompt whose text contains ANY of the listed substrings. Use to strip third-party client fingerprints like 'github.com/anomalyco/opencode' or 'docs.openwebui.com' that Anthropic's classifier flags.",
drop_paragraph_if_starts_with:
"Removes any paragraph that STARTS WITH one of the listed prefixes. Use for identity lines like 'You are OpenCode' or 'You are Open WebUI' that announce the calling client.",
replace_text:
"Replaces a literal substring with another literal substring. Use for known trigger phrases — e.g. rewrite 'Here is some useful information about the environment you are running in:' to 'Environment context you are running in:' (an empirically-validated trigger phrase).",
replace_regex:
"Replaces text matching a regular expression. Use when you need patterns (character classes, optional whitespace, anchors) instead of literal substrings. A malformed pattern is caught at runtime when the op runs.",
drop_block_if_contains:
"Removes ENTIRE system blocks (not just paragraphs) whose text contains any of the listed substrings. Use when a whole block is fingerprint-bearing and you want it gone — e.g. an injected MCP-server description.",
prepend_system_block:
"Inserts a new text block at the FRONT of the system array. Use to add the SDK identity 'You are a Claude agent, built on Anthropic's Claude Agent SDK.' that Anthropic's classifier expects.",
append_system_block:
"Inserts a new text block at the END of the system array. Use for cosmetic additions that don't need to be at position [0].",
inject_billing_header:
"Prepends the special 'x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=...;' text block that Anthropic's classifier validates. Required for CC bridge relay endpoints; for the native claude provider OmniRoute already injects its own billing line so this op is usually redundant there.",
obfuscate_words:
"Inserts a Zero-Width-Joiner character after the first letter of each listed word, so 'opencode' becomes 'o\u200dpencode'. Reads identical to humans but bypasses classifier word matches. Targets system blocks, user/assistant messages, and tool descriptions.",
drop_paragraph_if_contains: "routingOpDropParagraphContainsDesc",
drop_paragraph_if_starts_with: "routingOpDropParagraphStartsWithDesc",
replace_text: "routingOpReplaceTextDesc",
replace_regex: "routingOpReplaceRegexDesc",
drop_block_if_contains: "routingOpDropBlockContainsDesc",
prepend_system_block: "routingOpPrependSystemBlockDesc",
append_system_block: "routingOpAppendSystemBlockDesc",
inject_billing_header: "routingOpInjectBillingHeaderDesc",
obfuscate_words: "routingOpObfuscateWordsDesc",
};
// Per-field hints rendered under each Input/Select/Toggle inside the
// editor. Short, plain-English. Keep under ~120 chars each.
const FIELD_HINTS = {
needles:
"List of substrings. A paragraph matches if it contains ANY one of them. Add one per line via 'Add entry'.",
prefixes:
"List of strings. A paragraph matches if it starts with any one of them (leading whitespace is trimmed before matching).",
caseSensitive:
"When ON, 'OpenCode' and 'opencode' are different strings. When OFF (default), the comparison ignores case.",
matchLiteral:
"Exact literal substring to find. No regex syntax — special chars like . * ? are treated as themselves.",
replacementText:
"Replacement string. Leave blank to delete the match. The output preserves surrounding text.",
allOccurrences:
"When ON (default), every instance is replaced. When OFF, only the first match is replaced.",
pattern:
"JavaScript regex source. Don't wrap in slashes — just 'foo(.*)bar'. Server rejects patterns that fail to compile.",
regexFlags:
"JavaScript regex flags (g = all matches, i = case-insensitive, s = dot matches newline, m = multiline). Default 'g'.",
blockText:
"Full text of the new system block. Use a literal string; the system block stores text only.",
idempotencyKey:
"Optional. If set, the op skips when a block whose text starts with this key is already present. Prevents double-prepend on retries.",
billingEntrypoint:
"Value injected as 'cc_entrypoint='. Anthropic accepts 'sdk-cli' (Agent SDK), 'cli' (Claude Code CLI), or other documented values.",
billingVersionFormat:
"How the 3-char build hash after cc_version= is computed. 'ex-machina' = sha256 of CCH_SALT+chars-from-first-user-msg+version (per-message). 'omniroute-daystamp' = sha256 of YYYY-MM-DD+version (stable per-day).",
billingCchAlgo:
"How the 5-char cch= token is computed. 'sha256-first-user' = sha256 of first user message text. 'xxhash64-body' = body-level signing fills it later. 'static-zero' = literal '00000' placeholder.",
obfuscateWords:
"Lowercase words to obfuscate. ZWJ insertion is applied case-insensitively, so 'opencode' also matches 'OpenCode' and 'OPENCODE'.",
obfuscateTargets:
"Which body regions to scan for the words: system blocks, user/assistant messages, and/or tool descriptions.",
needles: "routingNeedlesHint",
prefixes: "routingPrefixesHint",
caseSensitive: "routingCaseSensitiveHint",
matchLiteral: "routingMatchLiteralHint",
replacementText: "routingReplacementTextHint",
allOccurrences: "routingAllOccurrencesHint",
pattern: "routingPatternHint",
regexFlags: "routingRegexFlagsHint",
blockText: "routingBlockTextHint",
idempotencyKey: "routingIdempotencyKeyHint",
billingEntrypoint: "routingBillingEntrypointHint",
billingVersionFormat: "routingBillingVersionFormatHint",
billingCchAlgo: "routingBillingCchAlgoHint",
obfuscateWords: "routingObfuscateWordsHint",
obfuscateTargets: "routingObfuscateTargetsHint",
};
function makeDefaultOp(kind: TransformOpKind): any {
@@ -304,6 +280,7 @@ function StringListEditor({
disabled?: boolean;
}) {
const t = useTranslations("settings");
const tCommon = useTranslations("common");
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-text-main">{label}</span>
@@ -342,7 +319,7 @@ function StringListEditor({
onClick={() => onChange([...items, ""])}
className="self-start"
>
Add entry
{tCommon("add") || "Add entry"}
</Button>
</div>
);
@@ -360,7 +337,7 @@ function OpEditor({
const t = useTranslations("settings");
const updateField = (field: string, value: any) => onChange({ ...op, [field]: value });
const kind = op?.kind as TransformOpKind | undefined;
const opDescription = kind ? OP_KIND_DESCRIPTIONS[kind] : null;
const opDescription = kind ? t(OP_KIND_DESCRIPTIONS[kind]) : null;
const wrap = (body: React.ReactNode) => (
<div className="flex flex-col gap-3">
@@ -379,14 +356,14 @@ function OpEditor({
<div className="flex flex-col gap-2">
<StringListEditor
label={t("routingNeedlesSubstrings")}
hint={FIELD_HINTS.needles}
hint={t(FIELD_HINTS.needles)}
items={op.needles || []}
onChange={(next) => updateField("needles", next)}
disabled={disabled}
/>
<Toggle
label={t("routingCaseSensitive")}
description={FIELD_HINTS.caseSensitive}
description={t(FIELD_HINTS.caseSensitive)}
checked={op.caseSensitive !== false}
onChange={(c) => updateField("caseSensitive", c)}
size="sm"
@@ -399,14 +376,14 @@ function OpEditor({
<div className="flex flex-col gap-2">
<StringListEditor
label={t("routingPrefixes")}
hint={FIELD_HINTS.prefixes}
hint={t(FIELD_HINTS.prefixes)}
items={op.prefixes || []}
onChange={(next) => updateField("prefixes", next)}
disabled={disabled}
/>
<Toggle
label={t("routingCaseSensitive")}
description={FIELD_HINTS.caseSensitive}
description={t(FIELD_HINTS.caseSensitive)}
checked={op.caseSensitive !== false}
onChange={(c) => updateField("caseSensitive", c)}
size="sm"
@@ -419,21 +396,21 @@ function OpEditor({
<div className="flex flex-col gap-2">
<Input
label={t("routingMatch")}
hint={FIELD_HINTS.matchLiteral}
hint={t(FIELD_HINTS.matchLiteral)}
value={op.match || ""}
disabled={disabled}
onChange={(e) => updateField("match", e.target.value)}
/>
<Input
label={t("routingReplacement")}
hint={FIELD_HINTS.replacementText}
hint={t(FIELD_HINTS.replacementText)}
value={op.replacement || ""}
disabled={disabled}
onChange={(e) => updateField("replacement", e.target.value)}
/>
<Toggle
label={t("routingReplaceAllOccurrences")}
description={FIELD_HINTS.allOccurrences}
description={t(FIELD_HINTS.allOccurrences)}
checked={op.allOccurrences !== false}
onChange={(c) => updateField("allOccurrences", c)}
size="sm"
@@ -446,21 +423,21 @@ function OpEditor({
<div className="flex flex-col gap-2">
<Input
label={t("routingPatternRegex")}
hint={FIELD_HINTS.pattern}
hint={t(FIELD_HINTS.pattern)}
value={op.pattern || ""}
disabled={disabled}
onChange={(e) => updateField("pattern", e.target.value)}
/>
<Input
label={t("routingFlags")}
hint={FIELD_HINTS.regexFlags}
hint={t(FIELD_HINTS.regexFlags)}
value={op.flags || "g"}
disabled={disabled}
onChange={(e) => updateField("flags", e.target.value)}
/>
<Input
label={t("routingReplacement")}
hint={FIELD_HINTS.replacementText}
hint={t(FIELD_HINTS.replacementText)}
value={op.replacement || ""}
disabled={disabled}
onChange={(e) => updateField("replacement", e.target.value)}
@@ -471,7 +448,7 @@ function OpEditor({
return wrap(
<StringListEditor
label={t("routingNeedles")}
hint={FIELD_HINTS.needles}
hint={t(FIELD_HINTS.needles)}
items={op.needles || []}
onChange={(next) => updateField("needles", next)}
disabled={disabled}
@@ -490,11 +467,11 @@ function OpEditor({
onChange={(e) => updateField("text", e.target.value)}
className="w-full rounded-md border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 px-3 py-2 text-sm text-text-main font-mono focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none transition-all shadow-inner disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-text-muted">{FIELD_HINTS.blockText}</p>
<p className="text-xs text-text-muted">{t(FIELD_HINTS.blockText)}</p>
</div>
<Input
label={t("routingIdempotencyKey")}
hint={FIELD_HINTS.idempotencyKey}
hint={t(FIELD_HINTS.idempotencyKey)}
value={op.idempotencyKey || ""}
disabled={disabled}
onChange={(e) => updateField("idempotencyKey", e.target.value)}
@@ -506,14 +483,14 @@ function OpEditor({
<div className="flex flex-col gap-2">
<Input
label={t("routingEntrypoint")}
hint={FIELD_HINTS.billingEntrypoint}
hint={t(FIELD_HINTS.billingEntrypoint)}
value={op.entrypoint || "sdk-cli"}
disabled={disabled}
onChange={(e) => updateField("entrypoint", e.target.value)}
/>
<Select
label={t("routingVersionFormat")}
hint={FIELD_HINTS.billingVersionFormat}
hint={t(FIELD_HINTS.billingVersionFormat)}
value={op.versionFormat || "ex-machina"}
disabled={disabled}
onChange={(e) => updateField("versionFormat", e.target.value)}
@@ -524,7 +501,7 @@ function OpEditor({
/>
<Select
label={t("routingCchAlgorithm")}
hint={FIELD_HINTS.billingCchAlgo}
hint={t(FIELD_HINTS.billingCchAlgo)}
value={op.cchAlgo || "sha256-first-user"}
disabled={disabled}
onChange={(e) => updateField("cchAlgo", e.target.value)}
@@ -541,14 +518,16 @@ function OpEditor({
<div className="flex flex-col gap-2">
<StringListEditor
label={t("routingWordsToObfuscate")}
hint={FIELD_HINTS.obfuscateWords}
hint={t(FIELD_HINTS.obfuscateWords)}
items={op.words || []}
onChange={(next) => updateField("words", next)}
disabled={disabled}
/>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-text-main">Targets</span>
<p className="text-xs text-text-muted">{FIELD_HINTS.obfuscateTargets}</p>
<span className="text-xs font-medium text-text-main">
{t("routingObfuscateTargetsLabel")}
</span>
<p className="text-xs text-text-muted">{t(FIELD_HINTS.obfuscateTargets)}</p>
<div className="flex flex-wrap gap-4">
{(["system", "messages", "tools"] as const).map((target) => {
const targets: string[] = op.targets || ["system", "messages", "tools"];
@@ -576,26 +555,53 @@ function OpEditor({
}
}
function summarizeTransformOp(op: any): string {
function summarizeTransformOp(op: any, t: any): string {
switch (op?.kind) {
case "drop_paragraph_if_contains":
return `drop paragraphs containing: ${(op.needles || []).slice(0, 3).join(", ")}${(op.needles || []).length > 3 ? "…" : ""}`;
return t("routingSummarizeDropParagraphContains", {
items:
(op.needles || []).slice(0, 3).join(", ") + ((op.needles || []).length > 3 ? "…" : ""),
});
case "drop_paragraph_if_starts_with":
return `drop paragraphs starting with: ${(op.prefixes || []).slice(0, 3).join(", ")}${(op.prefixes || []).length > 3 ? "…" : ""}`;
return t("routingSummarizeDropParagraphStartsWith", {
items:
(op.prefixes || []).slice(0, 3).join(", ") + ((op.prefixes || []).length > 3 ? "…" : ""),
});
case "replace_text":
return `replace "${(op.match || "").slice(0, 40)}${(op.match || "").length > 40 ? "…" : ""}" → "${(op.replacement || "").slice(0, 40)}${(op.replacement || "").length > 40 ? "…" : ""}"`;
return t("routingSummarizeReplaceText", {
match: (op.match || "").slice(0, 40) + ((op.match || "").length > 40 ? "…" : ""),
replacement:
(op.replacement || "").slice(0, 40) + ((op.replacement || "").length > 40 ? "…" : ""),
});
case "replace_regex":
return `regex /${op.pattern}/${op.flags || ""} → "${(op.replacement || "").slice(0, 40)}"`;
return t("routingSummarizeReplaceRegex", {
pattern: op.pattern,
flags: op.flags || "",
replacement: (op.replacement || "").slice(0, 40),
});
case "drop_block_if_contains":
return `drop blocks containing: ${(op.needles || []).slice(0, 3).join(", ")}`;
return t("routingSummarizeDropBlockContains", {
items: (op.needles || []).slice(0, 3).join(", "),
});
case "prepend_system_block":
return `prepend block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`;
return t("routingSummarizePrependSystemBlock", {
text: (op.text || "").slice(0, 60) + ((op.text || "").length > 60 ? "…" : ""),
});
case "append_system_block":
return `append block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`;
return t("routingSummarizeAppendSystemBlock", {
text: (op.text || "").slice(0, 60) + ((op.text || "").length > 60 ? "…" : ""),
});
case "inject_billing_header":
return `inject billing header (entrypoint=${op.entrypoint}, version=${op.versionFormat}, cch=${op.cchAlgo})`;
return t("routingSummarizeInjectBillingHeader", {
entrypoint: op.entrypoint,
versionFormat: op.versionFormat,
cchAlgo: op.cchAlgo,
});
case "obfuscate_words":
return `obfuscate ${(op.words || []).length} word(s) via ZWJ in ${(op.targets || ["system", "messages", "tools"]).join("+")}`;
return t("routingSummarizeObfuscateWords", {
count: (op.words || []).length,
targets: (op.targets || ["system", "messages", "tools"]).join("+"),
});
default:
return JSON.stringify(op);
}
@@ -658,6 +664,7 @@ export default function RoutingTab() {
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
const t = useTranslations("settings");
const tCommon = useTranslations("common");
const notify = useNotificationStore();
useEffect(() => {
@@ -997,10 +1004,7 @@ export default function RoutingTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("routingAntigravitySignatureTitle")}</h3>
<p className="text-sm text-text-muted">
Control whether OmniRoute reuses only stored Gemini thought signatures or accepts
validated client-provided signatures in Antigravity-compatible tool-call flows.
</p>
<p className="text-sm text-text-muted">{t("routingAntigravitySignatureDesc")}</p>
</div>
</div>
@@ -1008,18 +1012,18 @@ export default function RoutingTab() {
{[
{
value: "enabled",
label: "Enabled",
desc: "Current behavior. Ignore client-provided signatures and keep using the stored OmniRoute flow.",
label: t("routingAntigravitySignatureEnabledLabel"),
desc: t("routingAntigravitySignatureEnabledDesc"),
},
{
value: "bypass",
label: "Bypass",
desc: "Accept client-provided signatures after lightweight validation and fall back to the stored signature when invalid.",
label: t("routingAntigravitySignatureBypassLabel"),
desc: t("routingAntigravitySignatureBypassDesc"),
},
{
value: "bypass-strict",
label: "Bypass Strict",
desc: "Require full protobuf validation before accepting a client-provided signature.",
label: t("routingAntigravitySignatureBypassStrictLabel"),
desc: t("routingAntigravitySignatureBypassStrictDesc"),
},
].map((option) => (
<button
@@ -1201,14 +1205,20 @@ export default function RoutingTab() {
<span className="text-sm font-medium">{display.name}</span>
</div>
}
subtitle={`${opCount} op${opCount === 1 ? "" : "s"} · ${enabled ? "enabled" : "disabled"}`}
subtitle={
t("routingOpSummaryCount", { count: opCount }) +
t("routingOpStatusSeparator") +
(enabled ? t("routingOpEnabled") : t("routingOpDisabled"))
}
trailing={
<>
<Toggle
checked={enabled}
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
disabled={loading}
ariaLabel={`Enable ${display.name} transforms`}
ariaLabel={
tCommon("enable") + " " + display.name + " " + t("systemTransforms")
}
/>
{!isBuiltin && (
<Button
@@ -1232,10 +1242,7 @@ export default function RoutingTab() {
>
<span className="font-medium">{t("routingServerRejectedSave")}</span>{" "}
<span className="break-words font-mono">{providerSaveErrors[providerId]}</span>
<p className="mt-1 text-[11px] text-red-200/80">
Your local edits are kept. Fix the field above and the next change will
re-save.
</p>
<p className="mt-1 text-[11px] text-red-200/80">{tCommon("error")}</p>
</div>
)}
@@ -1252,9 +1259,12 @@ export default function RoutingTab() {
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-[10px] font-semibold text-purple-400">
{index + 1}
</span>
<span className="font-mono text-purple-300 text-xs">{op?.kind}</span>
<span className="font-mono text-purple-300 text-xs">
{t(OP_KIND_LABELS[op?.kind as TransformOpKind] || op?.kind)}
</span>
</div>
}
subtitle={summarizeTransformOp(op, t)}
trailing={
<>
<Button
@@ -1313,7 +1323,7 @@ export default function RoutingTab() {
disabled={loading}
options={(Object.keys(OP_KIND_LABELS) as TransformOpKind[]).map((kind) => ({
value: kind,
label: OP_KIND_LABELS[kind],
label: t(OP_KIND_LABELS[kind]),
}))}
/>
<Button
@@ -1323,7 +1333,7 @@ export default function RoutingTab() {
size="sm"
icon="add"
>
Add op
{tCommon("add")}
</Button>
</div>
@@ -1336,12 +1346,14 @@ export default function RoutingTab() {
}
className="text-[11px] text-primary hover:underline"
>
{isJsonOpen ? "▾ Hide JSON editor" : "▸ Import / export JSON"}
{isJsonOpen
? "▾ " + tCommon("hide") + " JSON editor"
: "▸ Import / export JSON"}
</button>
{isJsonOpen && (
<div className="mt-2">
<label className="text-[11px] font-medium text-text-muted block mb-1">
JSON (edit &amp; Apply, or paste to import)
JSON ({tCommon("edit")} &amp; Apply, or paste to import)
</label>
<textarea
value={draft}
@@ -1374,7 +1386,7 @@ export default function RoutingTab() {
size="sm"
icon="restart_alt"
>
Reset to defaults
{tCommon("reset")}
</Button>
)}
</div>
@@ -1401,9 +1413,7 @@ export default function RoutingTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("routingClientCacheControlTitle")}</h3>
<p className="text-sm text-text-muted">
Configure whether OmniRoute preserves client-provided cache_control markers
</p>
<p className="text-sm text-text-muted">{t("routingClientCacheControlDesc")}</p>
</div>
</div>
@@ -1411,18 +1421,18 @@ export default function RoutingTab() {
{[
{
value: "auto",
label: "Auto (Recommended)",
desc: "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.",
label: tCommon("auto") + " (" + tCommon("recommended") + ")",
desc: t("routingClientCacheControlAutoDesc"),
},
{
value: "always",
label: "Always Preserve",
desc: "Always forward client-provided cache_control headers to upstream providers as-is.",
label: t("routingClientCacheControlAlwaysLabel"),
desc: t("routingClientCacheControlAlwaysDesc"),
},
{
value: "never",
label: "Never Preserve",
desc: "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.",
label: t("routingClientCacheControlNeverLabel"),
desc: t("routingClientCacheControlNeverDesc"),
},
].map((option) => (
<button
@@ -1469,11 +1479,7 @@ export default function RoutingTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("routingZeroConfigTitle")}</h3>
<p className="text-sm text-text-muted mt-1">
Enable automatic provider selection using the auto/ prefix. When enabled, requests
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
providers.
</p>
<p className="text-sm text-text-muted mt-1">{t("routingZeroConfigDesc")}</p>
</div>
</div>
<div className="pt-1">
@@ -1493,12 +1499,36 @@ export default function RoutingTab() {
<label className="block text-sm font-medium mb-2">{t("routingDefaultAutoVariant")}</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
{ value: "coding", label: "Coding", desc: "Quality-first for code" },
{ value: "fast", label: "Fast", desc: "Low-latency routing" },
{ value: "cheap", label: "Cheap", desc: "Cost-optimized" },
{ value: "offline", label: "Offline", desc: "High availability" },
{ value: "smart", label: "Smart", desc: "Best discovery (10% explore)" },
{
value: "lkgp",
label: t("routingDefaultAutoVariantLKGP"),
desc: t("routingDefaultAutoVariantLKGPDesc"),
},
{
value: "coding",
label: t("routingDefaultAutoVariantCoding"),
desc: t("routingDefaultAutoVariantCodingDesc"),
},
{
value: "fast",
label: t("routingDefaultAutoVariantFast"),
desc: t("routingDefaultAutoVariantFastDesc"),
},
{
value: "cheap",
label: t("routingDefaultAutoVariantCheap"),
desc: t("routingDefaultAutoVariantCheapDesc"),
},
{
value: "offline",
label: t("routingDefaultAutoVariantOffline"),
desc: t("routingDefaultAutoVariantOfflineDesc"),
},
{
value: "smart",
label: t("routingDefaultAutoVariantSmart"),
desc: t("routingDefaultAutoVariantSmartDesc"),
},
].map((option) => (
<button
key={option.value}

View File

@@ -5,6 +5,7 @@ import { Card, Button, Input, Toggle } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";
import AuthzSection from "./AuthzSection";
import { useTranslations } from "next-intl";
export default function SecurityTab() {
@@ -274,6 +275,7 @@ export default function SecurityTab() {
<SessionInfoCard />
<IPFilterSection />
<AuthzSection />
</div>
);
}

View File

@@ -66,20 +66,15 @@ export default function VisionBridgeSettingsTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("visionBridge")}</h3>
<p className="text-sm text-text-muted">
Run an automatic vision-to-text fallback before routing image requests to text-only
models.
</p>
<p className="text-sm text-text-muted">{t("visionBridgeDesc")}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">Enabled</p>
<p className="text-sm text-text-muted">
Toggle the pre-call bridge that replaces image parts with extracted text.
</p>
<p className="font-medium">{t("visionBridgeEnabledLabel")}</p>
<p className="text-sm text-text-muted">{t("visionBridgeEnabledDesc")}</p>
</div>
<Toggle
checked={settings.visionBridgeEnabled}
@@ -101,9 +96,7 @@ export default function VisionBridgeSettingsTab() {
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
placeholder={t("visionBridgeModelPlaceholder")}
/>
<p className="text-xs text-text-muted mt-1">
Any OmniRoute model ID that supports vision can be used here.
</p>
<p className="text-xs text-text-muted mt-1">{t("visionBridgeModelHint")}</p>
</div>
<div>
@@ -119,10 +112,7 @@ export default function VisionBridgeSettingsTab() {
className="min-h-[100px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
placeholder={t("visionBridgePromptPlaceholder")}
/>
<p className="text-xs text-text-muted mt-1">
Sent to the vision model before the extracted description is injected back into the
original request.
</p>
<p className="text-xs text-text-muted mt-1">{t("visionBridgePromptHint")}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -75,6 +75,7 @@ export default function SkillsPage() {
const [shInstallingId, setShInstallingId] = useState<string | null>(null);
const [skillsProvider, setSkillsProvider] = useState<SkillsProvider>("skillsmp");
const t = useTranslations("skills");
const commonT = useTranslations("common");
const fetchSkills = async (page: number) => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
@@ -168,19 +169,19 @@ export default function SkillsPage() {
});
const data = await res.json();
if (res.ok && data.success) {
setInstallStatus({ type: "success", message: `Skill installed (${data.id})` });
setInstallStatus({ type: "success", message: t("installSuccess", { id: data.id }) });
setInstallJson("");
await refreshSkills();
} else {
setInstallStatus({
type: "error",
message: data.error || data.message || "Install failed",
message: data.error || data.message || t("installError"),
});
}
} catch (err) {
setInstallStatus({
type: "error",
message: err instanceof Error ? err.message : "Invalid JSON",
message: err instanceof Error ? err.message : t("invalidJson"),
});
} finally {
setInstalling(false);
@@ -205,12 +206,12 @@ export default function SkillsPage() {
const res = await fetch(`/api/skills/marketplace?q=${encodeURIComponent(mpQuery)}`);
const data = await res.json();
if (!res.ok) {
setMpError(data.error || "Search failed");
setMpError(data.error || t("marketplaceError"));
} else {
setMpResults(Array.isArray(data) ? data : data.skills || []);
}
} catch (err) {
setMpError(err instanceof Error ? err.message : "Search failed");
setMpError(err instanceof Error ? err.message : t("marketplaceError"));
} finally {
setMpLoading(false);
}
@@ -241,11 +242,11 @@ export default function SkillsPage() {
await refreshSkills();
setMpInstallingId(null);
} else {
setMpError(data.error || "Install failed");
setMpError(data.error || t("installError"));
setMpInstallingId(null);
}
} catch (err) {
setMpError(err instanceof Error ? err.message : "Install failed");
setMpError(err instanceof Error ? err.message : t("installError"));
setMpInstallingId(null);
}
};
@@ -258,12 +259,12 @@ export default function SkillsPage() {
const res = await fetch(`/api/skills/skillssh?q=${encodeURIComponent(shQuery)}`);
const data = await res.json();
if (!res.ok) {
setShError(data.error || "Search failed");
setShError(data.error || t("marketplaceError"));
} else {
setShResults(data.skills || []);
}
} catch (err) {
setShError(err instanceof Error ? err.message : "Search failed");
setShError(err instanceof Error ? err.message : t("marketplaceError"));
} finally {
setShLoading(false);
}
@@ -293,11 +294,11 @@ export default function SkillsPage() {
await refreshSkills();
setShInstallingId(null);
} else {
setShError(data.error || "Install failed");
setShError(data.error || t("installError"));
setShInstallingId(null);
}
} catch (err) {
setShError(err instanceof Error ? err.message : "Install failed");
setShError(err instanceof Error ? err.message : t("installError"));
setShInstallingId(null);
}
};
@@ -310,14 +311,41 @@ export default function SkillsPage() {
);
}
// ── Stats computation ────────────────────────────────────────────────────
const enabledCount = skills.filter((s) => s.enabled).length;
const execSuccessCount = executions.filter((e) => e.status === "success").length;
const successRate =
executions.length > 0 ? Math.round((execSuccessCount / executions.length) * 100) : 0;
return (
<div className="flex flex-col gap-6">
{/* ── Stats Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalSkills")}</p>
<p className="text-2xl font-bold text-text-main mt-1">{skillsTotal}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("enabledSkills")}</p>
<p className="text-2xl font-bold text-emerald-400 mt-1">{enabledCount}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalExecutions")}</p>
<p className="text-2xl font-bold text-violet-400 mt-1">{execTotal}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("successRate")}</p>
<p className="text-2xl font-bold text-amber-400 mt-1">{successRate}%</p>
</Card>
</div>
<div className="flex justify-end">
<button
onClick={() => setShowInstallModal(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
Install Skill
{t("installSkillButton")}
</button>
</div>
@@ -360,7 +388,7 @@ export default function SkillsPage() {
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
Marketplace
{t("marketplaceTab")}
</button>
</div>
@@ -381,9 +409,9 @@ export default function SkillsPage() {
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="all">{t("allModes")}</option>
<option value="on">On</option>
<option value="auto">Auto</option>
<option value="off">Off</option>
<option value="on">{t("onMode")}</option>
<option value="auto">{t("autoMode")}</option>
<option value="off">{t("offMode")}</option>
</select>
<button
onClick={() => {
@@ -392,15 +420,13 @@ export default function SkillsPage() {
}}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
Apply filters
{t("applyFilters")}
</button>
</div>
{popularDefaults.length > 0 && (
<div className="mt-3">
<p className="text-xs text-text-muted mb-2">
Popular by default for selected provider:
</p>
<p className="text-xs text-text-muted mb-2">{t("popularDefaultsLabel")}</p>
<div className="flex flex-wrap gap-2">
{popularDefaults.map((name) => (
<span
@@ -433,7 +459,7 @@ export default function SkillsPage() {
{(skill.sourceProvider || "local").toUpperCase()}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-amber-500/10 text-amber-400">
mode: {skill.mode || (skill.enabled ? "on" : "off")}
{t("mode")}: {skill.mode || (skill.enabled ? "on" : "off")}
</span>
</div>
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
@@ -460,7 +486,7 @@ export default function SkillsPage() {
: "border-border text-text-muted"
}`}
>
ON
{t("onMode")}
</button>
<button
onClick={() => setSkillMode(skill.id, "auto")}
@@ -470,7 +496,7 @@ export default function SkillsPage() {
: "border-border text-text-muted"
}`}
>
AUTO
{t("autoMode")}
</button>
<button
onClick={() => setSkillMode(skill.id, "off")}
@@ -480,14 +506,14 @@ export default function SkillsPage() {
: "border-border text-text-muted"
}`}
>
OFF
{t("offMode")}
</button>
</div>
<button
onClick={() => deleteSkill(skill.id)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-500/10 transition-colors"
>
Uninstall
{t("delete")}
</button>
<button
onClick={() => toggleSkill(skill.id, skill.enabled)}
@@ -510,7 +536,11 @@ export default function SkillsPage() {
)}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
<span className="text-sm text-text-muted">
Page {skillsPage} of {skillsTotalPages} ({skillsTotal} total)
{t("pageInfo", {
page: skillsPage,
totalPages: skillsTotalPages,
total: skillsTotal,
})}
</span>
<div className="flex gap-2">
<button
@@ -522,7 +552,7 @@ export default function SkillsPage() {
disabled={skillsPage === 1}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
Prev
{t("previous")}
</button>
<button
onClick={() => {
@@ -533,7 +563,7 @@ export default function SkillsPage() {
disabled={skillsPage === skillsTotalPages || skillsTotalPages === 0}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
Next
{t("next")}
</button>
</div>
</div>
@@ -588,7 +618,8 @@ export default function SkillsPage() {
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
<span className="text-sm text-text-muted">
Page {execPage} of {execTotalPages} ({execTotal} total)
{t("pageInfo", { page: execPage, totalPages: execTotalPages, total: execTotal }) ||
`Page ${execPage} of ${execTotalPages} (${execTotal} total)`}
</span>
<div className="flex gap-2">
<button
@@ -600,7 +631,7 @@ export default function SkillsPage() {
disabled={execPage === 1}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
Prev
{t("previous") || "Prev"}
</button>
<button
onClick={() => {
@@ -611,7 +642,7 @@ export default function SkillsPage() {
disabled={execPage === execTotalPages || execTotalPages === 0}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
Next
{t("next") || "Next"}
</button>
</div>
</div>
@@ -661,11 +692,11 @@ export default function SkillsPage() {
<Card>
<h3 className="font-semibold mb-2">{t("skillsMarketplace")}</h3>
<p className="text-sm text-text-muted mb-4">
Active provider:{" "}
{t("activeProvider")}{" "}
<span className="font-medium">
{skillsProvider === "skillsmp" ? "SkillsMP" : "skills.sh"}
</span>
. Change this in Settings Memory & Skills.
. {t("changeInSettings")}
</p>
<div className="flex gap-2 mb-4">
<input
@@ -680,9 +711,7 @@ export default function SkillsPage() {
e.key === "Enter" &&
(skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh())
}
placeholder={
skillsProvider === "skillsmp" ? "Search SkillsMP..." : "Search skills.sh..."
}
placeholder={t("searchMarketplacePlaceholder")}
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<button
@@ -694,11 +723,11 @@ export default function SkillsPage() {
>
{skillsProvider === "skillsmp"
? mpLoading
? "Searching..."
: "Search SkillsMP"
? t("searching")
: t("searchMarketplace")
: shLoading
? "Searching..."
: "Search skills.sh"}
? t("searching")
: t("searchMarketplace")}
</button>
</div>
{(skillsProvider === "skillsmp" ? mpError : shError) && (
@@ -722,7 +751,7 @@ export default function SkillsPage() {
disabled={mpInstallingId === skill.name}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{mpInstallingId === skill.name ? "Installing..." : "Install"}
{mpInstallingId === skill.name ? t("installing") : t("installSkillButton")}
</button>
</div>
</Card>
@@ -738,7 +767,7 @@ export default function SkillsPage() {
<div>
<h4 className="font-semibold">{skill.name}</h4>
<p className="text-sm text-text-muted mt-1">
{skill.source} · {skill.installs.toLocaleString()} installs
{skill.source} · {skill.installs.toLocaleString()} {t("installs")}
</p>
</div>
<button
@@ -746,7 +775,7 @@ export default function SkillsPage() {
disabled={shInstallingId === skill.id}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{shInstallingId === skill.id ? "Installing..." : "Install"}
{shInstallingId === skill.id ? t("installing") : t("installSkillButton")}
</button>
</div>
</Card>
@@ -756,16 +785,12 @@ export default function SkillsPage() {
{skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && (
<Card>
<div className="text-center py-8 text-text-muted">
Configure your SkillsMP API key in Settings to browse the marketplace.
</div>
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsMpHint")}</div>
</Card>
)}
{skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
<Card>
<div className="text-center py-8 text-text-muted">
Search the skills.sh open directory to discover and install agent skills.
</div>
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsShHint")}</div>
</Card>
)}
</div>
@@ -775,7 +800,7 @@ export default function SkillsPage() {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{t("installSkill")}</h2>
<h2 className="text-lg font-semibold">{t("installSkillModalTitle")}</h2>
<button
onClick={() => {
setShowInstallModal(false);
@@ -787,13 +812,11 @@ export default function SkillsPage() {
X
</button>
</div>
<p className="text-sm text-text-muted mb-4">
Paste a skill manifest JSON or upload a .json file.
</p>
<p className="text-sm text-text-muted mb-4">{t("installSkillModalDesc")}</p>
<textarea
value={installJson}
onChange={(e) => setInstallJson(e.target.value)}
placeholder='{"name": "my-skill", "version": "1.0.0", "description": "...", "schema": {"input": {}, "output": {}}, "handlerCode": "..."}'
placeholder={t("installJsonPlaceholder")}
className="w-full h-48 p-3 rounded-lg bg-background border border-border text-sm font-mono resize-none focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<div className="flex items-center gap-3 mt-3">
@@ -808,7 +831,7 @@ export default function SkillsPage() {
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
Upload JSON
{t("uploadJson")}
</button>
<div className="flex-1" />
<button
@@ -819,14 +842,14 @@ export default function SkillsPage() {
}}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
{t("cancel")}
</button>
<button
onClick={handleInstall}
disabled={installing || !installJson.trim()}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{installing ? "Installing..." : "Install"}
{installing ? t("installing") : t("installSkillButton")}
</button>
</div>
{installStatus && (

View File

@@ -54,6 +54,8 @@
"none": "None",
"yes": "Yes",
"no": "No",
"on": "ON",
"off": "OFF",
"warning": "Warning",
"note": "Note",
"free": "Free",
@@ -705,7 +707,9 @@
"batchFileDetailFailedToLoad": "Failed to load file contents",
"batchFilesListSearchPlaceholder": "Search by ID or filename…",
"batchFilesListFilesTable": "Files",
"batchPageLoadingMore": "Loading more…"
"batchPageLoadingMore": "Loading more…",
"auto": "Auto",
"recommended": "Recommended"
},
"sidebar": {
"home": "Home",
@@ -842,6 +846,7 @@
"settingsAi": "AI Settings",
"settingsSecurity": "Security",
"settingsFeatureFlags": "Feature Flags",
"settingsAuthz": "Authz",
"settingsRouting": "Routing",
"settingsResilience": "Resilience",
"settingsAdvanced": "Advanced",
@@ -875,6 +880,12 @@
"proxySubtitle": "HTTP proxy settings",
"mitmProxySubtitle": "MITM interception",
"oneProxySubtitle": "Public proxy gateway",
"leaderboard": "Leaderboard",
"profile": "Profile",
"tokens": "Tokens",
"leaderboardSubtitle": "Gamification rankings",
"profileSubtitle": "User achievements",
"tokensSubtitle": "Manage token balance",
"usageSubtitle": "Traffic and usage stats",
"analyticsComboHealthSubtitle": "Combo target reliability",
"analyticsUtilizationSubtitle": "Provider utilization",
@@ -913,6 +924,7 @@
"settingsAdvancedSubtitle": "Power user options",
"settingsSecuritySubtitle": "Auth and encryption",
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
"settingsAuthzSubtitle": "Route inventory and bypass policy",
"docsSubtitle": "Documentation",
"issuesSubtitle": "Report a bug",
"changelogSubtitle": "Release notes"
@@ -1183,6 +1195,81 @@
},
"analytics": {
"title": "Analytics",
"usageAnalyticsTitle": "Usage Analytics",
"diversityScoreTitle": "Diversity Score",
"diversityScoreDesc": "Provider concentration snapshot for the recent traffic window.",
"diversityShannonEntropy": "Shannon entropy",
"diversityWindow": "Window: {count} reqs · Last {mins} mins",
"diversityHealthy": "Healthy Distribution",
"diversityRiskHigh": "High Vendor Lock-in Risk",
"diversityRiskModerate": "Moderate Distribution",
"diversityScoreLabel": "score",
"diversityHigherExplanation": "Higher values mean traffic is spread across multiple providers.",
"diversityNoData": "No recent usage data available.",
"chartRequests": "Requests",
"chartInput": "Input",
"chartOutput": "Output",
"chartTotal": "Total",
"chartCost": "Cost",
"chartShare": "Share",
"chartServiceTier": "Service Tier",
"chartServiceTierSplit": "Fast / Standard cost split",
"chartCostPct": "{pct}% of cost",
"chartUsageDetail": "Usage Detail",
"chartCacheRead": "Cache read",
"chartCostByProvider": "Cost by Provider",
"chartNoCostData": "No cost data",
"chartModelUsageOverTime": "Model Usage Over Time",
"chartNoData": "No data",
"chartWeekly": "Weekly",
"chartModelBreakdown": "Model Breakdown",
"chartModel": "Model",
"chartProvider": "Provider",
"chartProviderBreakdown": "Provider Breakdown",
"filterAllKeys": "All Keys",
"filterSearchKeys": "Search keys…",
"filterNoKeysMatch": "No keys match",
"filterOneKey": "1 key",
"filterMultipleKeys": "{count} keys",
"rangeToday": "Today",
"rangeYesterday": "Yesterday",
"rangeLast3Days": "Last 3 days",
"rangeThisWeek": "This week",
"rangeLast14Days": "Last 14 days",
"rangeThisMonth": "This month",
"rangeQuickSelect": "Quick Select",
"rangeStart": "Start",
"rangeEnd": "End",
"rangeCancel": "Cancel",
"rangeApply": "Apply",
"rangeErrorInvalid": "Start must be before end",
"period1D": "1D",
"period7D": "7D",
"period30D": "30D",
"period90D": "90D",
"periodYTD": "YTD",
"periodAll": "All",
"totalTokens": "Total Tokens",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"estCost": "Est. Cost",
"infraTitle": "Infrastructure",
"infraAccounts": "Accounts",
"infraProviders": "Providers",
"infraApiKeys": "API Keys",
"infraModels": "Models",
"perfTitle": "Performance",
"perfAvgTokens": "Avg Tokens/Req",
"perfCostReq": "Cost/Req",
"perfIoRatio": "I/O Ratio",
"perfFastReq": "Fast Requests",
"highlightsTitle": "Highlights",
"highlightsTopModel": "Top Model",
"highlightsTopProvider": "Top Provider",
"highlightsBusiestDay": "Busiest Day",
"highlightsDiversity": "Diversity",
"highlightsFallbackRate": "Fallback Rate",
"customRange": "Custom",
"overviewDescription": "Monitor your API usage patterns, token consumption, costs, and activity trends across all providers and models.",
"evalsDescription": "Run evaluation suites to test and validate your LLM endpoints. Compare model quality, detect regressions, and benchmark latency.",
"overview": "Overview",
@@ -1288,6 +1375,7 @@
"keyName": "Key Name",
"keyNamePlaceholder": "e.g. Production Key",
"keyNameDesc": "Choose a descriptive name to identify this key's purpose",
"managementAccessDesc": "Allow this API key to manage OmniRoute configuration.",
"keyCreated": "API Key Created",
"keyCreatedSuccess": "Key created successfully!",
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
@@ -1625,6 +1713,7 @@
"amp": "Use when you want Amp shorthand workflows but still need OmniRoute alias and routing rules enforcement.",
"qwen": "Use when you need Alibaba Qwen Code CLI for coding tasks.",
"hermes": "Use when you need a lightweight terminal-native AI assistant for quick tasks.",
"hermes-agent": "Use when you need Hermes Agent (by Nousresearch) with default, delegation, vision and auxiliary models routed through OmniRoute.",
"custom": "Use for custom tool implementations or generic OpenAI-compatible configurations."
},
"toolDescriptions": {
@@ -1644,6 +1733,7 @@
"qwen": "Alibaba Qwen Code CLI",
"amp": "Sourcegraph Amp coding assistant CLI",
"hermes": "Hermes AI Terminal Assistant",
"hermes-agent": "Hermes Agent (by Nousresearch) - Advanced terminal AI with multi-model support (delegation, vision, compression, etc.)",
"custom": "Generic OpenAI-compatible CLI or SDK configuration generator"
},
"guides": {
@@ -2030,6 +2120,17 @@
"recommendationsApplied": "Recommendations applied to this combo.",
"intelligentPanelTitle": "Intelligent Routing Dashboard",
"intelligentPanelDesc": "Real-time scoring and health status for this auto-routing combo.",
"configOnlyStatus": "Configuration View",
"configOnlyHint": "This panel shows routing inputs only. Live breaker state is available on the Health page.",
"routingInputs": "Routing Inputs",
"routingInputsHint": "Mode pack and weighting stay here; breaker runtime state stays on the Health page.",
"emailVisibilityHint": "Account emails here follow the global privacy toggle.",
"emailVisibilityTooltip": "Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens.",
"manualModel": "Manual model",
"manualModelInvalid": "Enter a model as provider/model.",
"manualModelUnknownProvider": "Unknown provider prefix.",
"builderDynamicAccountShort": "Dynamic account",
"builderNeedValidName": "Define a valid combo name before continuing.",
"statusOverview": "Status Overview",
"normalOperation": "Normal Operation",
"allProvidersHealthy": "Providers are reporting healthy routing conditions.",
@@ -2339,7 +2440,8 @@
"previousPeriod": "Previous Half",
"currentPeriod": "Current Half",
"exportCSV": "Export as CSV",
"exportJSON": "Export as JSON"
"exportJSON": "Export as JSON",
"legacyFreeLabel": "Legacy / Free"
},
"endpoint": {
"title": "API Endpoint",
@@ -2572,6 +2674,20 @@
"processStatus": "Process status",
"online": "Online",
"offline": "Offline",
"disableLabel": "Disable {label}",
"enableLabel": "Enable {label}",
"transportMode": "Transport Mode",
"transportStdioDesc": "Local — IDE spawns process via omniroute --mcp",
"transportSseDesc": "Remote — Server-Sent Events over HTTP",
"transportStreamableHttpDesc": "Remote — Modern bidirectional HTTP",
"copy": "Copy",
"mcpDashboardCopyUrl": "Copy URL to clipboard",
"mcpDisabledTitle": "MCP is disabled",
"mcpDisabledDesc": "Enable MCP above to configure transport mode and view server telemetry.",
"mcpIntro": "Model Context Protocol — {tools} tools across {scopes} scopes, {transports} transports (stdio / SSE / Streamable HTTP).",
"mcpStep1": "Run via {code}",
"mcpStep2": "Configure your MCP client to connect over stdio transport.",
"mcpStep3": "Invoke tools like {code1} and {code2}.",
"pid": "PID",
"sessionUptime": "Session uptime",
"lastHeartbeat": "Last heartbeat",
@@ -2654,6 +2770,7 @@
"cancelled": "cancelled"
},
"agentCard": "Agent card",
"agentCardPath": "/.well-known/agent.json",
"version": "Version",
"url": "URL",
"capabilities": "Capabilities",
@@ -2691,7 +2808,17 @@
"rpcMethodStream": "message/stream",
"rpcMethodGet": "tasks/get",
"rpcMethodCancel": "tasks/cancel",
"serviceLabel": "A2A"
"serviceLabel": "A2A",
"online": "Online",
"offline": "Offline",
"disableLabel": "Disable {label}",
"enableLabel": "Enable {label}",
"a2aDisabledTitle": "A2A is disabled",
"a2aDisabledDesc": "Enable A2A above to view task telemetry, agent details, and validation tools.",
"a2aIntro": "Agent2Agent JSON-RPC 2.0 endpoint — send tasks, stream responses, cancel in-flight jobs.",
"a2aStep1": "Discover the agent card at {code}.",
"a2aStep2": "Send JSON-RPC to {code1} using {code2} or {code3}.",
"a2aStep3": "Track and cancel tasks with {code1} and {code2}."
},
"memory": {
"title": "Memory Management",
@@ -2761,8 +2888,42 @@
"q": "Q",
"filterSkillsPlaceholder": "Filter skills by name, description, or tag",
"allModes": "All modes",
"totalSkills": "Total Skills",
"enabledSkills": "Enabled",
"totalExecutions": "Executions",
"successRate": "Success Rate",
"marketplaceTab": "Marketplace",
"applyFilters": "Apply filters",
"popularDefaultsLabel": "Popular by default for selected provider:",
"onMode": "ON",
"offMode": "OFF",
"autoMode": "AUTO",
"installSkillButton": "Install Skill",
"installSkillModalTitle": "Install Skill",
"installJsonPlaceholder": "Paste skill manifest JSON here...",
"installing": "Installing...",
"installSuccess": "Skill installed ({id})",
"installError": "Install failed",
"invalidJson": "Invalid JSON",
"searchMarketplacePlaceholder": "Search skills...",
"searchMarketplace": "Search Marketplace",
"marketplaceEmpty": "No skills found in marketplace",
"marketplaceError": "Search failed",
"installingFromMarketplace": "Installing from marketplace...",
"popularSkills": "Popular Skills",
"skillsMarketplace": "Skills Marketplace",
"installSkill": "Install Skill"
"searching": "Searching...",
"pageInfo": "Page {page} of {totalPages} ({total} total)",
"previous": "Previous",
"next": "Next",
"activeProvider": "Active provider:",
"changeInSettings": "Change this in Settings → Memory & Skills.",
"installs": "installs",
"marketplaceSkillsMpHint": "Configure your SkillsMP API key in Settings to browse the marketplace.",
"marketplaceSkillsShHint": "Search the skills.sh open directory to discover and install agent skills.",
"installSkillModalDesc": "Paste a skill manifest JSON or upload a .json file.",
"uploadJson": "Upload JSON",
"cancel": "Cancel"
},
"health": {
"title": "System Health",
@@ -3622,6 +3783,12 @@
"geminiCliProjectIdLabel": "Google Cloud Project ID",
"geminiCliProjectIdPlaceholder": "my-gcp-project-id",
"antigravityProjectIdHint": "Optional override for Antigravity Cloud Code requests. Leave blank to use the project discovered during Google OAuth.",
"antigravityClientProfileLabel": "Client profile",
"antigravityClientProfileHint": "Choose which Antigravity client identity OmniRoute presents to the API.",
"antigravityClientProfileIde": "IDE",
"antigravityClientProfileHarness": "Harness / CLI",
"codexFastTierActiveChip": "Codex Fast tier is active",
"tierFast": "Fast",
"antigravityProjectIdLabel": "Google Cloud Project ID",
"antigravityProjectIdPlaceholder": "my-gcp-project-id",
"grokWebCookieHint": "Grok Web Cookie Hint",
@@ -3729,6 +3896,65 @@
"cache": "Cache",
"resilience": "Resilience",
"routingSettingsIntro": "Controls how your requests are routed, transformed, and sent to AI providers.",
"routingOpDropParagraphContainsLabel": "Drop paragraph (contains)",
"routingOpDropParagraphStartsWithLabel": "Drop paragraph (starts with)",
"routingOpReplaceTextLabel": "Replace text",
"routingOpReplaceRegexLabel": "Replace regex",
"routingOpDropBlockContainsLabel": "Drop block (contains)",
"routingOpPrependSystemBlockLabel": "Prepend system block",
"routingOpAppendSystemBlockLabel": "Append system block",
"routingOpInjectBillingHeaderLabel": "Inject billing header",
"routingOpObfuscateWordsLabel": "Obfuscate words (ZWJ)",
"routingOpDropParagraphContainsDesc": "Removes any paragraph (text block split on blank lines) inside the system prompt whose text contains ANY of the listed substrings. Use to strip third-party client fingerprints like 'github.com/anomalyco/opencode' or 'docs.openwebui.com' that Anthropic's classifier flags.",
"routingOpDropParagraphStartsWithDesc": "Removes any paragraph that STARTS WITH one of the listed prefixes. Use for identity lines like 'You are OpenCode' or 'You are Open WebUI' that announce the calling client.",
"routingOpReplaceTextDesc": "Replaces a literal substring with another literal substring. Use for known trigger phrases — e.g. rewrite 'Here is some useful information about the environment you are running in:' to 'Environment context you are running in:' (an empirically-validated trigger phrase).",
"routingOpReplaceRegexDesc": "Replaces text matching a regular expression. Use when you need patterns (character classes, optional whitespace, anchors) instead of literal substrings. A malformed pattern is caught at runtime when the op runs.",
"routingOpDropBlockContainsDesc": "Removes ENTIRE system blocks (not just paragraphs) whose text contains any of the listed substrings. Use when a whole block is fingerprint-bearing and you want it gone — e.g. an injected MCP-server description.",
"routingOpPrependSystemBlockDesc": "Inserts a new text block at the FRONT of the system array. Use to add the SDK identity 'You are a Claude agent, built on Anthropic's Claude Agent SDK.' that Anthropic's classifier expects.",
"routingOpAppendSystemBlockDesc": "Inserts a new text block at the END of the system array. Use for cosmetic additions that don't need to be at position [0].",
"routingOpInjectBillingHeaderDesc": "Prepends the special 'x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=...;' text block that Anthropic's classifier validates. Required for CC bridge relay endpoints; for the native claude provider OmniRoute already injects its own billing line so this op is usually redundant there.",
"routingOpObfuscateWordsDesc": "Inserts a Zero-Width-Joiner character after the first letter of each listed word, so 'opencode' becomes 'o\u200dpencode'. Reads identical to humans but bypasses classifier word matches. Targets system blocks, user/assistant messages, and tool descriptions.",
"routingNeedlesHint": "List of substrings. A paragraph matches if it contains ANY one of them. Add one per line via 'Add entry'.",
"routingPrefixesHint": "List of strings. A paragraph matches if it starts with any one of them (leading whitespace is trimmed before matching).",
"routingCaseSensitiveHint": "When ON, 'OpenCode' and 'opencode' are different strings. When OFF (default), the comparison ignores case.",
"routingMatchLiteralHint": "Exact literal substring to find. No regex syntax — special chars like . * ? are treated as themselves.",
"routingReplacementTextHint": "Replacement string. Leave blank to delete the match. The output preserves surrounding text.",
"routingAllOccurrencesHint": "When ON (default), every instance is replaced. When OFF, only the first match is replaced.",
"routingPatternHint": "JavaScript regex source. Don't wrap in slashes — just 'foo(.*)bar'. Server rejects patterns that fail to compile.",
"routingRegexFlagsHint": "JavaScript regex flags (g = all matches, i = case-insensitive, s = dot matches newline, m = multiline). Default 'g'.",
"routingBlockTextHint": "Full text of the new system block. Use a literal string; the system block stores text only.",
"routingIdempotencyKeyHint": "Optional. If set, the op skips when a block whose text starts with this key is already present. Prevents double-prepend on retries.",
"routingBillingEntrypointHint": "Value injected as 'cc_entrypoint='. Anthropic accepts 'sdk-cli' (Agent SDK), 'cli' (Claude Code CLI), or other documented values.",
"routingBillingVersionFormatHint": "How the 3-char build hash after cc_version= is computed. 'ex-machina' = sha256 of CCH_SALT+chars-from-first-user-msg+version (per-message). 'omniroute-daystamp' = sha256 of YYYY-MM-DD+version (stable per-day).",
"routingBillingCchAlgoHint": "How the 5-char cch= token is computed. 'sha256-first-user' = sha256 of first user message text. 'xxhash64-body' = body-level signing fills it later. 'static-zero' = literal '00000' placeholder.",
"routingObfuscateWordsHint": "Lowercase words to obfuscate. ZWJ insertion is applied case-insensitively, so 'opencode' also matches 'OpenCode' and 'OPENCODE'.",
"routingObfuscateTargetsHint": "Which body regions to scan for the words: system blocks, user/assistant messages, and/or tool descriptions.",
"routingObfuscateTargetsLabel": "Targets",
"routingSummarizeDropParagraphContains": "drop paragraphs containing: {items}",
"routingSummarizeDropParagraphStartsWith": "drop paragraphs starting with: {items}",
"routingSummarizeReplaceText": "replace \"{match}\" → \"{replacement}\"",
"routingSummarizeReplaceRegex": "regex /{pattern}/{flags} → \"{replacement}\"",
"routingSummarizeDropBlockContains": "drop blocks containing: {items}",
"routingSummarizePrependSystemBlock": "prepend block: \"{text}\"",
"routingSummarizeAppendSystemBlock": "append block: \"{text}\"",
"routingSummarizeInjectBillingHeader": "inject billing header (entrypoint={entrypoint}, version={versionFormat}, cch={cchAlgo})",
"routingSummarizeObfuscateWords": "obfuscate {count} word(s) via ZWJ in {targets}",
"routingDefaultAutoVariantLKGP": "Last Known Good Provider",
"routingDefaultAutoVariantLKGPDesc": "Last Known Good Provider",
"routingDefaultAutoVariantCoding": "Quality-first for code",
"routingDefaultAutoVariantCodingDesc": "Quality-first for code",
"routingDefaultAutoVariantFast": "Low-latency routing",
"routingDefaultAutoVariantFastDesc": "Low-latency routing",
"routingDefaultAutoVariantCheap": "Cost-optimized",
"routingDefaultAutoVariantCheapDesc": "Cost-optimized",
"routingDefaultAutoVariantOffline": "High availability",
"routingDefaultAutoVariantOfflineDesc": "High availability",
"routingDefaultAutoVariantSmart": "Best discovery (10% explore)",
"routingDefaultAutoVariantSmartDesc": "Best discovery (10% explore)",
"routingOpSummaryCount": "{count, plural, =0 {no ops} one {# op} other {# ops}}",
"routingOpEnabled": "enabled",
"routingOpDisabled": "disabled",
"routingOpStatusSeparator": " · ",
"resilienceSettingsIntro": "Automatic retry, cooldown, and fallback when providers fail.",
"aiSettingsIntro": "AI-specific settings for thinking budget, model behavior, and compression.",
"systemPrompt": "System Prompt",
@@ -4483,33 +4709,51 @@
"oneproxySuccess": "Success",
"oneproxyFailed": "Failed",
"routingAntigravitySignatureTitle": "Antigravity Signature Cache Mode",
"routingAntigravitySignatureDesc": "Control whether OmniRoute reuses only stored Gemini thought signatures or accepts validated client-provided signatures in Antigravity-compatible tool-call flows.",
"routingAntigravitySignatureEnabledLabel": "Enabled",
"routingAntigravitySignatureEnabledDesc": "Current behavior. Ignore client-provided signatures and keep using the stored OmniRoute flow.",
"routingAntigravitySignatureBypassLabel": "Bypass",
"routingAntigravitySignatureBypassDesc": "Accept client-provided signatures after lightweight validation and fall back to the stored signature when invalid.",
"routingAntigravitySignatureBypassStrictLabel": "Bypass Strict",
"routingAntigravitySignatureBypassStrictDesc": "Require full protobuf validation before accepting a client-provided signature.",
"routingHeaderFingerprintTitle": "Header fingerprint (per provider)",
"routingServerRejectedSave": "⚠ Server rejected save:",
"routingAddTransformOp": "Add a transform op",
"routingClientCacheControlTitle": "Client Cache Control",
"visionBridge": "Vision Bridge",
"routingClientCacheControlDesc": "Configure whether OmniRoute preserves client-provided cache_control markers",
"routingClientCacheControlAutoDesc": "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.",
"routingClientCacheControlAlwaysLabel": "Always Preserve",
"routingClientCacheControlAlwaysDesc": "Always forward client-provided cache_control headers to upstream providers as-is.",
"routingClientCacheControlNeverLabel": "Never Preserve",
"routingClientCacheControlNeverDesc": "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.",
"routingZeroConfigTitle": "Zero-Config Auto-Routing",
"routingZeroConfigDesc": "Enable automatic provider selection using the auto/ prefix. When enabled, requests to auto, auto/coding, auto/fast, etc. will dynamically route across all connected providers.",
"routingDefaultAutoVariant": "Default Auto Variant",
"visionBridge": "Vision Bridge",
"visionBridgeDesc": "Run an automatic vision-to-text fallback before routing image requests to text-only models.",
"visionBridgeEnabledLabel": "Enabled",
"visionBridgeEnabledDesc": "Toggle the pre-call bridge that replaces image parts with extracted text.",
"visionBridgeModel": "Bridge Model",
"resilienceMaxBackoffSteps": "Max backoff steps",
"resilienceBaseCooldownLabel": "Base cooldown",
"resilienceUseUpstreamRetryHintsLabel": "Use upstream retry hints",
"resilienceYes": "Yes",
"resilienceNo": "No",
"resilienceUseUpstream429BreakerLabel": "Use upstream 429 hints (breaker)",
"resilienceDefault": "Default",
"resilienceMaxBackoffStepsLabel": "Max backoff steps",
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
"visionBridgeModelHint": "Any OmniRoute model ID that supports vision can be used here.",
"visionBridgePrompt": "Bridge Prompt",
"visionBridgePromptPlaceholder": "Describe this image concisely.",
"visionBridgePromptHint": "Sent to the vision model before the extracted description is injected back into the original request.",
"visionBridgeTimeoutMs": "Timeout (ms)",
"resilienceConnectionCooldownTitle": "Connection Cooldown",
"visionBridgeMaxImagesPerRequest": "Max Images Per Request",
"resilienceMaxBackoffSteps": "Max backoff steps",
"resilienceProviderBreakerTitle": "Circuit Breaker per Provider",
"resilienceFailureThreshold": "Failure threshold",
"resilienceResetTimeout": "Reset timeout",
"resilienceFailureThresholdLabel": "Failure threshold",
"resilienceResetTimeoutLabel": "Reset timeout",
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
"visionBridgePromptPlaceholder": "Describe this image concisely.",
"resilienceProviderBreakerTitle": "Circuit Breaker per Provider",
"resilienceConnectionCooldownTitle": "Connection Cooldown",
"resilienceUseUpstreamRetryHintsLabel": "Use upstream retry hints",
"resilienceUseUpstream429BreakerLabel": "Use upstream 429 hints (breaker)",
"resilienceMaxBackoffStepsLabel": "Max backoff steps",
"resilienceYes": "Yes",
"resilienceNo": "No",
"resilienceDefault": "Default",
"storageDatabaseBackupRetention": "Database backup retention",
"storagePurgeData": "Purge Data",
"retentionQuotaSnapshots": "Quota Snapshots (days)",
@@ -4551,18 +4795,109 @@
"cliproxyapiStatus": "CLIProxyAPI Status",
"cliproxyapiNotDetected": "Not detected",
"payloadRulesTitle": "Payload Rules",
"payloadRulesDesc": "Configure request payload mutations by model and protocol. Changes are persisted in settings and hot reloaded into the runtime immediately after save.",
"payloadRuleDefaultTitle": "default",
"payloadRuleDefaultDesc": "Applies params only when the target path is missing from the outgoing payload.",
"payloadRuleOverrideTitle": "override",
"payloadRuleOverrideDesc": "Forces values onto the payload, replacing anything already present at that path.",
"payloadRuleFilterTitle": "filter",
"payloadRuleFilterDesc": "Removes blocked params from the payload before the upstream request is sent.",
"payloadRuleDefaultRawTitle": "defaultRaw",
"payloadRuleDefaultRawDesc": "Like default, but string values are parsed as JSON first when possible. The legacy input alias default-raw is also accepted on save.",
"payloadEditorTitle": "Editor",
"payloadEditorDesc": "Use the runtime schema shape: default, override, filter, defaultRaw. The API also accepts the legacy input key default-raw.",
"payloadEditorReady": "Ready",
"payloadResetInfo": "Editor reset to the neutral template. Save to apply it.",
"payloadSaveSuccess": "Payload rules saved and hot reloaded.",
"payloadJsonParseError": "JSON parse error: {error}",
"payloadMustBeObject": "Payload rules must be a JSON object.",
"payloadInvalidJson": "Invalid JSON payload.",
"payloadValidJsonRequired": "Payload rules must be valid JSON before saving.",
"savePayloadRules": "Save Payload Rules",
"requestLimitsTitle": "Request Limits",
"requestLimitsDesc": "Configure global request limits and concurrency guards.",
"maxRequestSizeLabel": "Max Request Size (MB)",
"maxRequestSizeDesc": "Maximum allowed size for incoming API requests.",
"maxResponseSizeLabel": "Max Response Size (MB)",
"maxResponseSizeDesc": "Maximum allowed size for outgoing API responses.",
"maxRequestTokensLabel": "Max Request Tokens",
"maxRequestTokensDesc": "Maximum total tokens allowed in a single request.",
"maxResponseTokensLabel": "Max Response Tokens",
"maxResponseTokensDesc": "Maximum total tokens allowed in a single response.",
"modelCooldownsTitle": "Models in cooldown",
"modelCooldownsEmpty": "No models in cooldown right now.",
"codexFastTierTitle": "Codex Fast Tier",
"codexFastTierDesc": "Globally inject service_tier=priority for OpenAI Codex requests.",
"codexFastTierHint": "When enabled, OmniRoute adds service_tier=priority to outbound Codex requests for connections that don't already specify a tier. Priority tier requires an OpenAI Enterprise API key or the ChatGPT-auth Codex path; other key types will receive a tier-related error from OpenAI. Per-connection settings on the Codex provider page take precedence.",
"codexFastTierSaveError": "Failed to update Codex Fast Tier setting",
"codexFastTierTierLabel": "Service tier",
"codexFastTierTierPriority": "Priority",
"codexFastTierTierFlex": "Flex",
"codexFastTierTierDefault": "Default",
"codexFastTierModelsLabel": "Fast-tier models",
"codexFastTierModelsHint": "Only checked models receive service_tier when Fast Tier is enabled.",
"codexFastTierModelCheckbox": "Enable Fast Tier for {model}",
"claudeFastModeTitle": "Claude Fast Mode",
"claudeFastModeDesc": "Opt selected Claude requests into Anthropic Fast Mode (speed:\"fast\").",
"claudeFastModeHint": "Anthropic does not officially support Fast Mode for SDK-style clients. When enabled, OmniRoute forwards an X-CPA-Force-Fast-Mode header so a paired CLIProxyAPI build can opt-in spoof the entrypoint. Only the listed Opus models are gated by Anthropic's client-side check. Subscription tier, Max plan, and Fast Mode credit balance are still enforced server-side — Anthropic may return out_of_credits even when the toggle is on.",
"claudeFastModeModelsLabel": "Applied to models ({count})",
"claudeFastModeModelCheckbox": "Enable Fast Mode for {model}",
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting"
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting",
"authz": {
"title": "Authz Inventory",
"description": "5-tier route classification with live bypass policy. Read shows the full taxonomy; mutations re-prompt for the management password.",
"loading": "Loading inventory…",
"loadError": "Failed to load authz inventory",
"tier": {
"LOCAL_ONLY": "Local only",
"ALWAYS_PROTECTED": "Always protected",
"MANAGEMENT": "Management",
"CLIENT_API": "Client API",
"PUBLIC": "Public"
},
"bypass": {
"section": "Manage-scope bypass",
"kill_switch": {
"label": "Bypass kill-switch",
"desc": "Master toggle. When off, no LOCAL_ONLY prefix is reachable from non-loopback regardless of the per-prefix list."
},
"prefix": {
"label": "Bypassable prefixes",
"desc": "LOCAL_ONLY prefixes that manage-scope API keys (or dashboard sessions) may reach from non-loopback hosts.",
"add": "Add prefix",
"placeholder": "/api/mcp/v2/",
"empty": "No prefixes configured. Bypass effectively off."
},
"cli_tools_runtime_note": "Spawn-capable prefix. Compile-time deny; cannot be made bypassable. Shown read-only."
},
"password": {
"prompt": {
"label": "Current password",
"desc": "Re-confirm to apply security-impacting changes."
},
"placeholder": "Current management password",
"cancel": "Cancel",
"submit": "Apply"
},
"save": "Save changes",
"saved": "Authz settings updated",
"pending": "Unsaved changes",
"badge": {
"bypassable": "Bypassable via manage scope",
"strict": "Strict loopback",
"auth_required": "Auth required",
"public": "Public",
"always_protected": "Always protected",
"spawn_capable": "Spawn-capable"
},
"error": {
"PASSWORD_REQUIRED": "Current password required to apply these changes.",
"PASSWORD_MISMATCH": "Current password is incorrect.",
"INSUFFICIENT_SCOPE": "API key lacks the manage scope.",
"BYPASS_PREFIX_NOT_ALLOWED": "One or more prefixes target spawn-capable routes and cannot be bypassed.",
"GENERIC": "Failed to update authz settings."
}
}
},
"contextRtk": {
"title": "RTK Engine",
@@ -4603,6 +4938,7 @@
"result": "Result",
"previewEmpty": "Run a sample to preview RTK output.",
"detected": "Detected",
"masterSwitchOffAlert": "Token Saver master switch is OFF — these settings will not affect requests until you turn it on from the Endpoint page.",
"tokensFiltered": "Tokens filtered",
"filtersActive": "Filters active",
"requests": "Requests",
@@ -4657,6 +4993,8 @@
"enabled": "Enabled",
"autoDetect": "Auto-detect language",
"rulesCount": "{count} rules",
"inputCompressionTitle": "Input compression",
"inputCompressionDesc": "Rewrite chat history with shorter wording. Reduces input tokens by ~50%.",
"analyticsTitle": "Compression Analytics",
"noAnalytics": "No compression analytics yet.",
"outputMode": "Output Mode",
@@ -4951,6 +5289,19 @@
"runStepDescription": "Execute test cases against your LLM endpoints through OmniRoute. Each case is sent as a real API request.",
"evaluate": "Evaluate",
"evaluateStepDescription": "Responses are compared against expected criteria. See pass/fail for each case with latency metrics and detailed feedback.",
"evalsStrategyContainsLabel": "Contains",
"evalsStrategyExactLabel": "Exact Match",
"evalsStrategyRegexLabel": "Regex",
"evalsStrategyCustomLabel": "Custom Logic",
"evalsStrategyContainsDescription": "Checks if the LLM output contains the expected substring.",
"evalsStrategyExactDescription": "Checks if the LLM output exactly matches the expected value.",
"evalsStrategyRegexDescription": "Validates the LLM output against a regular expression pattern.",
"evalsStrategyCustomDescription": "Custom evaluation logic (configured via JSON).",
"historyColumnSuiteName": "Suite Name",
"historyColumnTarget": "Target",
"historyColumnPassRate": "Pass Rate",
"historyColumnAvgLatencyMs": "Avg Latency",
"historyColumnCreatedAt": "Executed",
"evalSuites": "Evaluation Suites",
"evalSuitesHint": "Click a suite to view test cases, then run to evaluate your LLM endpoints",
"evalsLoading": "Loading eval suites...",
@@ -5058,7 +5409,25 @@
"tierPro": "Pro",
"tierPlus": "Plus",
"tierFree": "Free",
"tierLite": "Lite",
"tierUnknown": "Unknown",
"statTotal": "Total",
"statCritical": "Critical",
"statAlert": "Alert",
"statHealthy": "Healthy",
"filterPurchaseTypeLabel": "Type",
"filterTierLabel": "Tier",
"purchaseAll": "All",
"purchaseOauthSub": "Subscription",
"purchaseOauthFree": "OAuth Free",
"purchaseApiKey": "API Key",
"creditsLabel": "Credits",
"creditBalanceHint": "Remaining balance",
"unlimitedLabel": "Unlimited",
"refreshing": "Refreshing",
"resetsIn": "Resets in",
"editCutoffs": "Edit cutoffs",
"forceRefresh": "Refresh now",
"suiteBuilderSaveFailed": "Failed to save suite",
"clone": "Clone",
"exportSuite": "Export",
@@ -5204,7 +5573,9 @@
"budgetWarnAtPct": "Warn at %",
"quotaAlerts": "Quota alerts",
"quotaTableRefreshing": "⟳ Refreshing...",
"noSpendLast30Days": "No spend in last 30 days"
"noSpendLast30Days": "No spend in last 30 days",
"updatedShort": "Updated",
"lastRefreshed": "Last refreshed"
},
"modals": {
"waitingAuth": "Waiting for Authorization",
@@ -5760,7 +6131,13 @@
"howToUse": "How to use",
"browseAllSkillsOnGithub": "Browse all skills on GitHub",
"apiSkills": "API Skills",
"cliSkills": "CLI Skills"
"cliSkills": "CLI Skills",
"apiSkillsSubtitle": "{count} skills — control OmniRoute via REST / HTTP",
"cliSkillsSubtitle": "{count} skills — control OmniRoute via the omniroute terminal binary",
"howToUseStep1": "Click <bold>{copyUrl}</bold> on the skill you want your agent to know about.",
"howToUseStep2": "In your AI agent (Claude, Cursor, Cline…), say:",
"howToUseStep2Code": "Use the skill at [pasted-url]",
"howToUseStep3": "The agent fetches the SKILL.md and learns OmniRoute's API or CLI — no manual docs needed."
},
"cloudAgents": {
"title": "Cloud Agents",
@@ -5779,6 +6156,28 @@
"tasks": "Tasks",
"taskDetail": "Task Detail",
"noTasks": "No tasks yet. Create one to get started.",
"noTasksTitle": "No tasks yet",
"noTasksDesc": "Create your first task to get started.",
"tasksTab": "Tasks",
"agentsTab": "Agents",
"settingsTab": "Settings",
"agentsEnabled": "Enabled",
"agentsDisabled": "Disabled",
"filterAllProviders": "All Providers",
"filterAll": "All",
"autoRefreshing": "Auto-refreshing",
"viewPR": "View Pull Request",
"connected": "Connected",
"notConnected": "Not connected",
"configure": "Configure",
"settingsTitle": "Cloud Agent Settings",
"settingsDesc": "Configure local preferences for cloud agents.",
"settingEnableAgents": "Enable cloud agents",
"settingEnableAgentsDesc": "Allow OmniRoute to orchestrate autonomous coding agents.",
"settingAutoPR": "Auto-create PR",
"settingAutoPRDesc": "When a task is completed, automatically create a Pull Request with the changes.",
"settingRequireApproval": "Require plan approval",
"settingRequireApprovalDesc": "Always wait for manual approval before an agent executes a proposed plan.",
"untitledTask": "Untitled Task",
"created": "Created",
"conversation": "Conversation",
@@ -5879,6 +6278,7 @@
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"loadingCacheAria": "Loading cache",
"promptCache": "Prompt Cache (Provider-Side)",
"semanticCache": "Semantic Cache",
"promptCacheSectionDesc": "Shows provider-side prompt caching activity from usage history so you can see where cache control is active and how much input reuse you are getting.",
@@ -5920,7 +6320,9 @@
"peakCached": "Peak cached",
"cached": "Cached",
"overview": "Overview",
"entries": "Entries",
"tableProvider": "Provider",
"tableModel": "Model",
"performanceTitle": "Performance",
"semanticCacheSectionDesc": "OmniRoute's own deterministic response cache. When enabled, repeated non-streaming temperature=0 requests can be served locally without hitting the upstream provider.",
"semanticCacheDisabledDesc": "Semantic cache is disabled. OmniRoute will skip local response reuse until you turn it back on in Settings.",
"semanticEntriesDesc": "Persisted semantic cache records currently stored in SQLite. Provider-side prompt cache activity is not listed here.",
@@ -5971,7 +6373,12 @@
"cachePerformanceAvgLatency": "Avg Latency (ms)",
"cachePerformanceP95Latency": "p95 Latency (ms)",
"retry": "Retry",
"reasoningAvgChars": "Avg Chars"
"reasoningAvgChars": "Avg Chars",
"tableShare": "Share",
"justNow": "just now",
"minutesAgo": "{minutes}m ago",
"hoursAgo": "{hours}h ago",
"daysAgo": "{days}d ago"
},
"proxyConfigModal": {
"levelGlobal": "Global",
@@ -6115,7 +6522,20 @@
"bulkClearAssignment": "(Clear assignment)",
"bulkLabelScopeIds": "Scope IDs (comma or newline separated)",
"bulkScopeIdsPlaceholder": "provider-openai,provider-anthropic",
"bulkLabelScopeIds": "Scope IDs (comma or newline separated)",
"bulkScopeIdsPlaceholder": "provider-openai,provider-anthropic",
"bulkApply": "Apply",
"labelScope": "Scope",
"labelProxy": "Proxy",
"scopeGlobal": "global",
"scopeProvider": "provider",
"scopeAccount": "account",
"scopeCombo": "combo",
"bulkImportErrorMissingName": "Missing NAME",
"bulkImportErrorMissingHost": "Missing HOST",
"bulkImportErrorInvalidPort": "Invalid PORT (must be 1-65535)",
"bulkImportErrorInvalidType": "Invalid TYPE (use http, https, or socks5)",
"bulkImportErrorInvalidStatus": "Invalid STATUS (use active or inactive)",
"errorLoadFailed": "Failed to load proxy registry",
"errorNameHostRequired": "Name and host are required",
"errorSaveFailed": "Failed to save proxy",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import Card from "./Card";
import { CardSkeleton } from "./Loading";
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
@@ -28,6 +29,8 @@ import {
// ============================================================================
export default function UsageAnalytics() {
const t = useTranslations("analytics");
const tCommon = useTranslations("common");
const [range, setRange] = useState("30d");
const [analytics, setAnalytics] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -56,7 +59,7 @@ export default function UsageAnalytics() {
params.set("apiKeyIds", selectedApiKeys.join(","));
}
const res = await fetch(`/api/usage/analytics?${params.toString()}`);
if (!res.ok) throw new Error("Failed to fetch");
if (!res.ok) throw new Error(tCommon("error"));
const data = await res.json();
setAnalytics(data);
setError(null);
@@ -66,8 +69,8 @@ export default function UsageAnalytics() {
const seen = new Set<string>();
const keys: { id: string; name: string }[] = [];
for (const k of data.byApiKey) {
const id = k.apiKeyId || k.apiKeyName || "unknown";
const name = k.apiKeyName || k.apiKeyId || "unknown";
const id = k.apiKeyId || k.apiKeyName || tCommon("unknownProvider");
const name = k.apiKeyName || k.apiKeyId || tCommon("unknownProvider");
if (seen.has(id)) continue;
seen.add(id);
keys.push({ id, name });
@@ -79,7 +82,7 @@ export default function UsageAnalytics() {
} finally {
setLoading(false);
}
}, [range, customStart, customEnd, selectedApiKeys]);
}, [range, customStart, customEnd, selectedApiKeys, tCommon]);
useEffect(() => {
fetchAnalytics();
@@ -117,12 +120,12 @@ export default function UsageAnalytics() {
}, [range, customStart, customEnd]);
const ranges = [
{ value: "1d", label: "1D" },
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "90d", label: "90D" },
{ value: "ytd", label: "YTD" },
{ value: "all", label: "All" },
{ value: "1d", label: t("period1D") },
{ value: "7d", label: t("period7D") },
{ value: "30d", label: t("period30D") },
{ value: "90d", label: t("period90D") },
{ value: "ytd", label: t("periodYTD") },
{ value: "all", label: t("periodAll") },
];
const topModel = useMemo(() => {
@@ -167,7 +170,12 @@ export default function UsageAnalytics() {
}, [analytics]);
if (loading && !analytics) return <CardSkeleton />;
if (error) return <Card className="p-6 text-center text-red-500">Error: {error}</Card>;
if (error)
return (
<Card className="p-6 text-center text-red-500">
{tCommon("errorShort")}: {error}
</Card>
);
const s = analytics?.summary || {};
@@ -182,7 +190,7 @@ export default function UsageAnalytics() {
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-xl font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[22px]">analytics</span>
Usage Analytics
{t("usageAnalyticsTitle")}
</h2>
<div className="flex items-center gap-2.5">
{/* API Key Filter */}
@@ -219,7 +227,7 @@ export default function UsageAnalytics() {
}`}
>
<span className="material-symbols-outlined text-[13px]">date_range</span>
{customRangeLabel || "Custom"}
{customRangeLabel || t("customRange")}
{range === "custom" && customRangeLabel && (
<span
role="button"
@@ -253,25 +261,25 @@ export default function UsageAnalytics() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
icon="generating_tokens"
label="Total Tokens"
label={t("totalTokens")}
value={fmt(s.totalTokens)}
subValue={`${fmtFull(s.totalRequests)} requests`}
subValue={`${fmtFull(s.totalRequests)} ${t("chartRequests")}`}
/>
<StatCard
icon="input"
label="Input Tokens"
label={t("inputTokens")}
value={fmt(s.promptTokens)}
color="text-primary"
/>
<StatCard
icon="output"
label="Output Tokens"
label={t("outputTokens")}
value={fmt(s.completionTokens)}
color="text-emerald-500"
/>
<StatCard
icon="payments"
label="Est. Cost"
label={t("estCost")}
value={fmtCost(s.totalCost)}
color="text-amber-500"
/>
@@ -281,59 +289,79 @@ export default function UsageAnalytics() {
<CompactStatGrid
sections={[
{
title: "Infrastructure",
title: t("infraTitle"),
items: [
{ icon: "group", label: "Accounts", value: s.uniqueAccounts || 0 },
{ icon: "dns", label: "Providers", value: providerCount, color: "text-indigo-500" },
{ icon: "vpn_key", label: "API Keys", value: s.uniqueApiKeys || 0 },
{ icon: "model_training", label: "Models", value: s.uniqueModels || 0 },
{ icon: "group", label: t("infraAccounts"), value: s.uniqueAccounts || 0 },
{
icon: "dns",
label: t("infraProviders"),
value: providerCount,
color: "text-indigo-500",
},
{ icon: "vpn_key", label: t("infraApiKeys"), value: s.uniqueApiKeys || 0 },
{ icon: "model_training", label: t("infraModels"), value: s.uniqueModels || 0 },
],
},
{
title: "Performance",
title: t("perfTitle"),
items: [
{
icon: "speed",
label: "Avg Tokens/Req",
label: t("perfAvgTokens"),
value: fmt(avgTokensPerReq),
color: "text-cyan-500",
},
{
icon: "request_quote",
label: "Cost/Req",
label: t("perfCostReq"),
value: fmtCost(costPerReq),
color: "text-orange-500",
},
{
icon: "compare_arrows",
label: "I/O Ratio",
label: t("perfIoRatio"),
value: `${ioRatio}x`,
color: "text-violet-500",
},
{
icon: "bolt",
label: "Fast Requests",
label: t("perfFastReq"),
value: fmt(s.fastRequests || 0),
color: "text-sky-500",
},
],
},
{
title: "Highlights",
title: t("highlightsTitle"),
wideValues: true,
items: [
{ icon: "star", label: "Top Model", value: topModel, color: "text-pink-500" },
{ icon: "cloud", label: "Top Provider", value: topProvider, color: "text-teal-500" },
{ icon: "today", label: "Busiest Day", value: busiestDay, color: "text-rose-500" },
{
icon: "star",
label: t("highlightsTopModel"),
value: topModel,
color: "text-pink-500",
},
{
icon: "cloud",
label: t("highlightsTopProvider"),
value: topProvider,
color: "text-teal-500",
},
{
icon: "today",
label: t("highlightsBusiestDay"),
value: busiestDay,
color: "text-rose-500",
},
{
icon: "network_node",
label: "Diversity",
label: t("highlightsDiversity"),
value: `${providerDiversity.toFixed(1)}%`,
color: "text-sky-500",
},
{
icon: "swap_horiz",
label: "Fallback Rate",
label: t("highlightsFallbackRate"),
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
color: "text-amber-500",
},

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { useTranslations } from "next-intl";
interface ApiKeyInfo {
id: string;
@@ -23,6 +24,7 @@ export default function ApiKeyFilterDropdown({
selected,
onChange,
}: ApiKeyFilterDropdownProps) {
const t = useTranslations("analytics");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ref = useRef<HTMLDivElement>(null);
@@ -76,13 +78,13 @@ export default function ApiKeyFilterDropdown({
}, [onChange]);
const buttonLabel = useMemo(() => {
if (isAllSelected) return "All Keys";
if (isAllSelected) return t("filterAllKeys");
if (selected.length === 1) {
const key = available.find((k) => k.id === selected[0]);
return key ? maskKeyName(key.name) : "1 key";
return key ? maskKeyName(key.name) : t("filterOneKey");
}
return `${selected.length} keys`;
}, [isAllSelected, selected, available]);
return t("filterMultipleKeys", { count: selected.length });
}, [isAllSelected, selected, available, t]);
if (available.length === 0) return null;
@@ -120,7 +122,7 @@ export default function ApiKeyFilterDropdown({
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search keys"
placeholder={t("filterSearchKeys")}
className="w-full rounded-md border border-border/30 bg-black/[0.03] px-2.5 py-1.5 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary dark:bg-white/[0.03]"
autoFocus
/>
@@ -149,7 +151,7 @@ export default function ApiKeyFilterDropdown({
<span className="material-symbols-outlined text-[12px]">check</span>
)}
</span>
All Keys
{t("filterAllKeys")}
<span className="ml-auto text-[10px] text-text-muted font-normal">
{available.length}
</span>
@@ -191,7 +193,9 @@ export default function ApiKeyFilterDropdown({
})}
{filtered.length === 0 && (
<p className="px-2.5 py-3 text-center text-[11px] text-text-muted">No keys match</p>
<p className="px-2.5 py-3 text-center text-[11px] text-text-muted">
{t("filterNoKeysMatch")}
</p>
)}
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface CustomRangePickerProps {
start: string;
@@ -65,6 +66,7 @@ export default function CustomRangePicker({
onApply,
onClose,
}: CustomRangePickerProps) {
const t = useTranslations("analytics");
const [localStart, setLocalStart] = useState(toLocalDatetime(start) || "");
const [localEnd, setLocalEnd] = useState(toLocalDatetime(end) || "");
const ref = useRef<HTMLDivElement>(null);
@@ -104,12 +106,12 @@ export default function CustomRangePicker({
const isValid = localStart && localEnd && new Date(localStart) <= new Date(localEnd);
const presets = [
{ key: "today", label: "Today" },
{ key: "yesterday", label: "Yesterday" },
{ key: "last3d", label: "Last 3 days" },
{ key: "thisWeek", label: "This week" },
{ key: "last14d", label: "Last 14 days" },
{ key: "thisMonth", label: "This month" },
{ key: "today", label: t("rangeToday") },
{ key: "yesterday", label: t("rangeYesterday") },
{ key: "last3d", label: t("rangeLast3Days") },
{ key: "thisWeek", label: t("rangeThisWeek") },
{ key: "last14d", label: t("rangeLast14Days") },
{ key: "thisMonth", label: t("rangeThisMonth") },
];
return (
@@ -121,7 +123,7 @@ export default function CustomRangePicker({
{/* Quick presets */}
<div className="mb-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
Quick Select
{t("rangeQuickSelect")}
</p>
<div className="flex flex-wrap gap-1">
{presets.map((p) => (
@@ -143,7 +145,7 @@ export default function CustomRangePicker({
<div className="flex flex-col gap-2.5">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-1 block">
Start
{t("rangeStart")}
</label>
<input
type="datetime-local"
@@ -154,7 +156,7 @@ export default function CustomRangePicker({
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-1 block">
End
{t("rangeEnd")}
</label>
<input
type="datetime-local"
@@ -167,7 +169,7 @@ export default function CustomRangePicker({
{/* Validation hint */}
{localStart && localEnd && !isValid && (
<p className="mt-1.5 text-[11px] text-error">Start must be before end</p>
<p className="mt-1.5 text-[11px] text-error">{t("rangeErrorInvalid")}</p>
)}
{/* Actions */}
@@ -177,7 +179,7 @@ export default function CustomRangePicker({
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-muted hover:text-text-main transition-colors"
>
Cancel
{t("rangeCancel")}
</button>
<button
type="button"
@@ -185,7 +187,7 @@ export default function CustomRangePicker({
onClick={handleApply}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
>
Apply
{t("rangeApply")}
</button>
</div>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useLocale } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import Card from "../Card";
import { getModelColor } from "@/shared/constants/colors";
import {
@@ -174,6 +174,7 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }
// ── ActivityHeatmap ────────────────────────────────────────────────────────
export function ActivityHeatmap({ activityMap }) {
const t = useTranslations("analytics");
const scrollRef = useRef<HTMLDivElement>(null);
const cells = useMemo(() => {
@@ -259,7 +260,9 @@ export function ActivityHeatmap({ activityMap }) {
return (
<Card className="p-4 h-full min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">Activity</h3>
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
{t("overview")}
</h3>
<span className="text-xs text-text-muted">
{Object.keys(activityMap || {}).length} active days ·{" "}
{fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens
@@ -327,24 +330,25 @@ export function ActivityHeatmap({ activityMap }) {
// ── DailyTrendChart (Recharts) ─────────────────────────────────────────────
export function DailyTrendChart({ dailyTrend }) {
const t = useTranslations("analytics");
const chartData = useMemo(() => {
return (dailyTrend || []).map((d) => ({
date: d.date.slice(5),
Input: d.promptTokens,
Output: d.completionTokens,
Cost: d.cost || 0,
[t("chartInput")]: d.promptTokens,
[t("chartOutput")]: d.completionTokens,
[t("chartCost")]: d.cost || 0,
}));
}, [dailyTrend]);
}, [dailyTrend, t]);
const hasCost = useMemo(() => chartData.some((d) => d.Cost > 0), [chartData]);
const hasCost = useMemo(() => chartData.some((d) => d[t("chartCost")] > 0), [chartData, t]);
if (!chartData.length) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Token Trend
{t("chartModelUsageOverTime")}
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
@@ -352,7 +356,7 @@ export function DailyTrendChart({ dailyTrend }) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Token &amp; Cost Trend
{t("chartModelUsageOverTime")}
</h3>
<ResponsiveContainer width="100%" height={140}>
<ComposedChart
@@ -379,7 +383,7 @@ export function DailyTrendChart({ dailyTrend }) {
)}
<Tooltip content={<CostTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
<Bar
dataKey="Input"
dataKey={t("chartInput")}
stackId="a"
fill="var(--primary)"
opacity={0.7}
@@ -387,7 +391,7 @@ export function DailyTrendChart({ dailyTrend }) {
animationDuration={600}
/>
<Bar
dataKey="Output"
dataKey={t("chartOutput")}
stackId="a"
fill="#10b981"
opacity={0.7}
@@ -398,7 +402,7 @@ export function DailyTrendChart({ dailyTrend }) {
<Line
yAxisId="cost"
type="monotone"
dataKey="Cost"
dataKey={t("chartCost")}
stroke="#f59e0b"
strokeWidth={2}
dot={false}
@@ -409,14 +413,14 @@ export function DailyTrendChart({ dailyTrend }) {
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 text-[10px] text-text-muted">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary/70" /> Input
<span className="w-2 h-2 rounded-full bg-primary/70" /> {t("chartInput")}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-500/70" /> Output
<span className="w-2 h-2 rounded-full bg-emerald-500/70" /> {t("chartOutput")}
</span>
{hasCost && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500/70" /> Cost ($)
<span className="w-2 h-2 rounded-full bg-amber-500/70" /> {t("chartCost")} ($)
</span>
)}
</div>
@@ -435,6 +439,7 @@ function CostTooltip({
payload?: any[];
label?: any;
}) {
const t = useTranslations("analytics");
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
@@ -447,7 +452,7 @@ function CostTooltip({
/>
<span>{entry.name}:</span>
<span className="font-mono font-medium text-text-main">
{entry.name === "Cost" ? fmtCost(entry.value) : fmt(entry.value)}
{entry.name === t("chartCost") ? fmtCost(entry.value) : fmt(entry.value)}
</span>
</div>
))}
@@ -458,6 +463,7 @@ function CostTooltip({
// ── AccountDonut (Recharts) ────────────────────────────────────────────────
export function AccountDonut({ byAccount }) {
const t = useTranslations("analytics");
const data = useMemo(() => byAccount || [], [byAccount]);
const hasData = data.length > 0;
@@ -475,7 +481,7 @@ export function AccountDonut({ byAccount }) {
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By Account
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
@@ -530,6 +536,7 @@ export function AccountDonut({ byAccount }) {
// ── ApiKeyDonut (Recharts) ─────────────────────────────────────────────────
export function ApiKeyDonut({ byApiKey }) {
const t = useTranslations("analytics");
const data = useMemo(() => byApiKey || [], [byApiKey]);
const hasData = data.length > 0;
@@ -548,7 +555,7 @@ export function ApiKeyDonut({ byApiKey }) {
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By API Key
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
@@ -608,6 +615,7 @@ export function ApiKeyDonut({ byApiKey }) {
// ── ApiKeyTable ────────────────────────────────────────────────────────────
export function ApiKeyTable({ byApiKey }) {
const t = useTranslations("analytics");
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
@@ -657,7 +665,7 @@ export function ApiKeyTable({ byApiKey }) {
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
API Key Breakdown
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
@@ -672,7 +680,7 @@ export function ApiKeyTable({ byApiKey }) {
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter API key..."
placeholder={t("filterSearchKeys")}
className="w-full max-w-[220px] px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
/>
</div>
@@ -690,33 +698,35 @@ export function ApiKeyTable({ byApiKey }) {
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
{t("chartRequests")}{" "}
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
{t("chartInput")}{" "}
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
Output{" "}
{t("chartOutput")}{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
{t("chartTotal")}{" "}
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("cost")}
>
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
</th>
</tr>
</thead>
@@ -751,7 +761,7 @@ export function ApiKeyTable({ byApiKey }) {
{sorted.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-text-muted">
No API key matches this filter.
{t("filterNoKeysMatch")}
</td>
</tr>
)}
@@ -765,6 +775,7 @@ export function ApiKeyTable({ byApiKey }) {
// ── WeeklyPattern (Recharts) ───────────────────────────────────────────────
export function WeeklyPattern({ weeklyPattern }) {
const t = useTranslations("analytics");
const chartData = useMemo(() => {
return (weeklyPattern || []).map((w) => ({
day: w.day.slice(0, 3),
@@ -775,7 +786,7 @@ export function WeeklyPattern({ weeklyPattern }) {
return (
<Card className="px-4 py-3">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Weekly
{t("chartWeekly")}
</h3>
<ResponsiveContainer width="100%" height={48}>
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
@@ -869,6 +880,7 @@ export function MostActiveDay7d({ activityMap }) {
// ── WeeklySquares7d ────────────────────────────────────────────────────────
export function WeeklySquares7d({ activityMap }) {
const t = useTranslations("analytics");
const locale = useLocale();
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "short" }),
@@ -912,7 +924,7 @@ export function WeeklySquares7d({ activityMap }) {
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: "var(--color-text-muted)" }}
>
Weekly
{t("chartWeekly")}
</h3>
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
{days.map((d, i) => (
@@ -951,6 +963,7 @@ export function WeeklySquares7d({ activityMap }) {
// ── ModelTable ──────────────────────────────────────────────────────────────
export function ModelTable({ byModel, summary }) {
const t = useTranslations("analytics");
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
@@ -982,7 +995,7 @@ export function ModelTable({ byModel, summary }) {
<Card className="overflow-hidden">
<div className="p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
Model Breakdown
{t("chartModelBreakdown")}
</h3>
</div>
<div className="overflow-x-auto">
@@ -993,40 +1006,44 @@ export function ModelTable({ byModel, summary }) {
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("model")}
>
Model <SortIndicator active={sortBy === "model"} sortOrder={sortOrder} />
{t("chartModel")}{" "}
<SortIndicator active={sortBy === "model"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
{t("chartRequests")}{" "}
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
{t("chartInput")}{" "}
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
Output{" "}
{t("chartOutput")}{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
{t("chartTotal")}{" "}
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("cost")}
>
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
</th>
<th className="px-4 py-2.5 text-right w-36">Share</th>
<th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -1082,6 +1099,7 @@ export function ModelTable({ byModel, summary }) {
}
export function ServiceTierBreakdown({ byServiceTier, summary }) {
const t = useTranslations("analytics");
const data = useMemo(() => byServiceTier || [], [byServiceTier]);
const totalRequests = Number(summary?.totalRequests || 0);
const totalCost = Number(summary?.totalCost || 0);
@@ -1094,9 +1112,9 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
<Card className="overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
Service Tier
{t("chartServiceTier")}
</h3>
<span className="text-[11px] text-text-muted">Fast / Standard cost split</span>
<span className="text-[11px] text-text-muted">{t("chartServiceTierSplit")}</span>
</div>
<div className="divide-y divide-border">
{data.map((tier) => {
@@ -1121,7 +1139,7 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
<div>
<div className="text-sm font-semibold text-text-main">{tier.label}</div>
<div className="text-xs text-text-muted">
{fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
{fmtFull(tier.requests)} {t("chartRequests")} · {fmt(tier.totalTokens)} tokens
</div>
</div>
</div>
@@ -1129,7 +1147,9 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
<div className="font-mono text-sm font-semibold text-amber-500">
{fmtCost(tier.cost)}
</div>
<div className="text-xs text-text-muted">{costPct}% of cost</div>
<div className="text-xs text-text-muted">
{t("chartCostPct", { pct: costPct })}
</div>
</div>
</div>
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/10 overflow-hidden">
@@ -1149,16 +1169,17 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
// ── UsageDetail ────────────────────────────────────────────────────────────
export function UsageDetail({ summary }) {
const t = useTranslations("analytics");
const items = [
{ label: "Input", value: summary?.promptTokens, color: "text-primary" },
{ label: "Cache read", value: 0, color: "text-text-muted" },
{ label: "Output", value: summary?.completionTokens, color: "text-emerald-500" },
{ label: t("chartInput"), value: summary?.promptTokens, color: "text-primary" },
{ label: t("chartCacheRead"), value: 0, color: "text-text-muted" },
{ label: t("chartOutput"), value: summary?.completionTokens, color: "text-emerald-500" },
];
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Usage Detail
{t("chartUsageDetail")}
</h3>
<div className="flex flex-col gap-2">
{items.map((item, i) => (
@@ -1188,6 +1209,7 @@ const PROVIDER_COLORS = [
];
export function ProviderCostDonut({ byProvider }) {
const t = useTranslations("analytics");
const data = useMemo(() => byProvider || [], [byProvider]);
const hasData = data.length > 0 && data.some((p) => p.cost > 0);
@@ -1207,9 +1229,9 @@ export function ProviderCostDonut({ byProvider }) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Cost by Provider
{t("chartCostByProvider")}
</h3>
<div className="text-center text-text-muted text-sm py-8">No cost data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoCostData")}</div>
</Card>
);
}
@@ -1217,7 +1239,7 @@ export function ProviderCostDonut({ byProvider }) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Cost by Provider
{t("chartCostByProvider")}
</h3>
<div className="flex items-center gap-4">
<ResponsiveContainer width={120} height={120}>
@@ -1264,6 +1286,7 @@ export function ProviderCostDonut({ byProvider }) {
// ── ModelOverTimeChart (Stacked Area) ──────────────────────────────────────
export function ModelOverTimeChart({ dailyByModel, modelNames }) {
const t = useTranslations("analytics");
const data = useMemo(() => dailyByModel || [], [dailyByModel]);
const models = useMemo(() => modelNames || [], [modelNames]);
@@ -1284,9 +1307,9 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Model Usage Over Time
{t("chartModelUsageOverTime")}
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
@@ -1294,7 +1317,7 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Model Usage Over Time
{t("chartModelUsageOverTime")}
</h3>
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
@@ -1346,6 +1369,7 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) {
// ── ProviderTable ──────────────────────────────────────────────────────────
export function ProviderTable({ byProvider }) {
const t = useTranslations("analytics");
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
@@ -1380,9 +1404,9 @@ export function ProviderTable({ byProvider }) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Provider Breakdown
{t("chartProviderBreakdown")}
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
@@ -1391,7 +1415,7 @@ export function ProviderTable({ byProvider }) {
<Card className="overflow-hidden">
<div className="p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
Provider Breakdown
{t("chartProviderBreakdown")}
</h3>
</div>
<div className="overflow-x-auto">
@@ -1402,40 +1426,44 @@ export function ProviderTable({ byProvider }) {
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("provider")}
>
Provider <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
{t("chartProvider")}{" "}
<SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
{t("chartRequests")}{" "}
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
{t("chartInput")}{" "}
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
Output{" "}
{t("chartOutput")}{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
{t("chartTotal")}{" "}
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("cost")}
>
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
</th>
<th className="px-4 py-2.5 text-right w-36">Share</th>
<th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">