refactor(ui): move keys, projects and cost text onto i18n (#49994)

Moves the page-authored English on the virtual keys, projects, cost tracking,
cost optimization and budgets pages, plus the create key form and the shared
key and team pickers, into t() and Trans with 1,318 messages under 32 new
top-level keys in en.json. The English on screen is unchanged

key_edit_view.tsx grows past 800 lines, so it gets a max-lines entry in
eslint-suppressions.json instead of a split
This commit is contained in:
yuneng-jiang 2026-11-04 11:30:50 -08:00 • committed by GitHub
parent 8cbd40893e
commit 53740a4426
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 4612 additions and 1792 deletions

View file

@ -2000,6 +2000,9 @@
"src/components/templates/key_edit_view.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"max-lines": {
"count": 1
}
},
"src/components/templates/key_info_view.tsx": {

View file

@ -1,4 +1,5 @@
import React, { useId } from "react";
import { useTranslation } from "react-i18next";
import { Controller } from "react-hook-form";
import { z } from "zod";
import { FormField } from "@/components/shared/form/FormField";
@ -16,6 +17,7 @@ import {
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import i18n from "@/i18n/i18n";
import { useZodForm } from "@/lib/forms/useZodForm";
const budgetShape = {
@ -54,19 +56,23 @@ const numberProps = <T extends { value?: number | null; onChange: (value: number
...field,
type: "number",
value: value ?? "",
placeholder: "No limit",
placeholder: i18n.t("budgets.form.noLimit"),
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onChange(event.target.value === "" ? null : event.target.valueAsNumber),
});
const OptionalBadge = () => <Badge variant="secondary">Optional</Badge>;
const OptionalBadge = () => {
const { t } = useTranslation();
return <Badge variant="secondary">{t("budgets.form.optional")}</Badge>;
};
function SpendLimitField({ form }: { form: BudgetForm }) {
const { t } = useTranslation();
const amountId = useId();
return (
<Field className="gap-2">
<FieldLabel htmlFor={amountId}>
Spend limit
{t("budgets.form.spendLimit")}
<OptionalBadge />
</FieldLabel>
<ButtonGroup className="w-full">
@ -91,7 +97,7 @@ function SpendLimitField({ form }: { form: BudgetForm }) {
value={field.value ?? NO_RESET}
onValueChange={(next) => field.onChange(next === NO_RESET ? null : next)}
>
<SelectTrigger aria-label="Reset period" className="w-32">
<SelectTrigger aria-label={t("budgets.form.resetPeriod")} className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -124,13 +130,14 @@ function RateLimitField({ form, name, label }: { form: BudgetForm; name: NumberF
}
export function BudgetFormFields({ form, isEditing }: { form: BudgetForm; isEditing: boolean }) {
const { t } = useTranslation();
return (
<FieldGroup className="gap-4">
<FormField
control={form.control}
name="budget_id"
label="Budget ID"
description={isEditing ? "Budget ID cannot be changed after creation" : undefined}
label={t("budgets.form.budgetId")}
description={isEditing ? t("budgets.form.budgetIdLocked") : undefined}
className="gap-2"
>
{({ ref, ...field }) => <Input {...field} ref={ref} value={field.value ?? ""} disabled={isEditing} />}
@ -139,15 +146,13 @@ export function BudgetFormFields({ form, isEditing }: { form: BudgetForm; isEdit
<FieldSeparator />
<FieldSet className="gap-3">
<FieldLegend className="mb-4 flex items-center gap-2 text-[15px] leading-[22px] font-semibold">
Rate limits
{t("budgets.form.rateLimits")}
<OptionalBadge />
</FieldLegend>
<RateLimitField form={form} name="tpm_limit" label="Tokens per minute" />
<RateLimitField form={form} name="rpm_limit" label="Requests per minute" />
<RateLimitField form={form} name="tpd_limit" label="Batch tokens per day" />
<FieldDescription>
Batches count against the daily limit instead of the per-minute ones. Provider rate limits still apply.
</FieldDescription>
<RateLimitField form={form} name="tpm_limit" label={t("budgets.form.tpmLimit")} />
<RateLimitField form={form} name="rpm_limit" label={t("budgets.form.rpmLimit")} />
<RateLimitField form={form} name="tpd_limit" label={t("budgets.form.tpdLimit")} />
<FieldDescription>{t("budgets.form.batchNote")}</FieldDescription>
</FieldSet>
</FieldGroup>
);

View file

@ -1,4 +1,5 @@
import React, { useId } from "react";
import { useTranslation } from "react-i18next";
import { useCreateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { applyBudgetPrecision } from "./budgetPrecision";
import { BudgetFormFields, type BudgetFormValues, useBudgetForm } from "./BudgetFormFields";
@ -11,6 +12,7 @@ interface BudgetModalProps {
setIsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
const BudgetModal: React.FC<BudgetModalProps> = ({ isModalVisible, setIsModalVisible }) => {
const { t } = useTranslation();
const formId = useId();
const form = useBudgetForm({ budget_id: "" });
const createBudget = useCreateBudget();
@ -22,14 +24,14 @@ const BudgetModal: React.FC<BudgetModalProps> = ({ isModalVisible, setIsModalVis
const handleCreate = async (formValues: BudgetFormValues) => {
try {
toast.info("Making API Call");
toast.info(t("budgets.createModal.toast.pending"));
await createBudget.mutateAsync(applyBudgetPrecision(formValues));
toast.success("Budget Created");
toast.success(t("budgets.createModal.toast.created"));
form.reset();
setIsModalVisible(false);
} catch (error) {
console.error("Error creating the budget:", error);
toast.fromError(`Error creating the budget: ${error}`);
toast.fromError(t("budgets.createModal.toast.error", { error: String(error) }));
}
};
@ -37,15 +39,17 @@ const BudgetModal: React.FC<BudgetModalProps> = ({ isModalVisible, setIsModalVis
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-[15px] leading-[22px] font-semibold">Create budget</DialogTitle>
<DialogTitle className="text-[15px] leading-[22px] font-semibold">
{t("budgets.createModal.title")}
</DialogTitle>
</DialogHeader>
<form id={formId} onSubmit={form.handleSubmit(handleCreate)} noValidate>
<BudgetFormFields form={form} isEditing={false} />
</form>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<DialogClose render={<Button variant="outline" />}>{t("budgets.createModal.cancel")}</DialogClose>
<Button type="submit" form={formId}>
Create budget
{t("budgets.createModal.submit")}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -6,6 +6,7 @@
import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
import { Plus, Wallet } from "lucide-react";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { prism } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -29,6 +30,7 @@ interface BudgetSettingsPageProps {
}
const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
const { t } = useTranslation();
const syntaxTheme = useSyntaxTheme(prism);
const [isCreateModelVisible, setIsCreateModelVisible] = useState(false);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
@ -65,10 +67,10 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
}
try {
await deleteBudget.mutateAsync(selectedBudget.budget_id);
toast.success("Budget deleted.");
toast.success(t("budgets.panel.toast.deleted"));
} catch (error) {
console.error("Error deleting budget:", error);
toast.fromError("Failed to delete budget");
toast.fromError(t("budgets.panel.toast.deleteFailed"));
} finally {
setIsDeleteModalVisible(false);
setSelectedBudget(null);
@ -85,22 +87,22 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
<PageHeader>
<PageHeaderTitle>
<Wallet />
Budgets
{t("budgets.panel.title")}
</PageHeaderTitle>
<PageHeaderDescription>Spend, TPM and RPM limits you can assign to customers.</PageHeaderDescription>
<PageHeaderDescription>{t("budgets.panel.description")}</PageHeaderDescription>
<PageHeaderControls>
<PageTabsList>
{canModify && (
<>
<Button onClick={() => setIsCreateModelVisible(true)}>
<Plus className="size-4" />
Create Budget
{t("budgets.panel.createButton")}
</Button>
<ToolbarSeparator className="mx-0 h-6" />
</>
)}
<PageTabsTrigger value="budgets">Budgets</PageTabsTrigger>
<PageTabsTrigger value="examples">Examples</PageTabsTrigger>
<PageTabsTrigger value="budgets">{t("budgets.panel.tabs.budgets")}</PageTabsTrigger>
<PageTabsTrigger value="examples">{t("budgets.panel.tabs.examples")}</PageTabsTrigger>
</PageTabsList>
</PageHeaderControls>
</PageHeader>
@ -122,15 +124,15 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
/>
<DeleteResourceModal
isOpen={isDeleteModalVisible}
title="Delete Budget?"
message="Are you sure you want to delete this budget? This action cannot be undone."
resourceInformationTitle="Budget Information"
title={t("budgets.panel.deleteModal.title")}
message={t("budgets.panel.deleteModal.message")}
resourceInformationTitle={t("budgets.panel.deleteModal.informationTitle")}
resourceInformation={[
{ label: "Budget ID", value: selectedBudget?.budget_id, code: true },
{ label: "Max Budget", value: selectedBudget?.max_budget },
{ label: "TPM", value: selectedBudget?.tpm_limit },
{ label: "RPM", value: selectedBudget?.rpm_limit },
{ label: "TPD (batch)", value: selectedBudget?.tpd_limit },
{ label: t("budgets.panel.deleteModal.budgetId"), value: selectedBudget?.budget_id, code: true },
{ label: t("budgets.panel.deleteModal.maxBudget"), value: selectedBudget?.max_budget },
{ label: t("budgets.panel.deleteModal.tpm"), value: selectedBudget?.tpm_limit },
{ label: t("budgets.panel.deleteModal.rpm"), value: selectedBudget?.rpm_limit },
{ label: t("budgets.panel.deleteModal.tpd"), value: selectedBudget?.tpd_limit },
]}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
@ -140,17 +142,17 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
</TabsContent>
<TabsContent value="examples" className="min-h-0 flex-1 overflow-y-auto" keepMounted>
<div className="pt-6">
<p className="text-base text-muted-foreground">How to use budget id</p>
<p className="text-base text-muted-foreground">{t("budgets.panel.examples.intro")}</p>
<Tabs defaultValue="assign-budget">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="assign-budget" className="flex-none rounded-none px-4 py-2">
Assign Budget to Customer
{t("budgets.panel.examples.tabs.assignBudget")}
</TabsTrigger>
<TabsTrigger value="curl" className="flex-none rounded-none px-4 py-2">
Test it (Curl)
{t("budgets.panel.examples.tabs.curl")}
</TabsTrigger>
<TabsTrigger value="openai-sdk" className="flex-none rounded-none px-4 py-2">
Test it (OpenAI SDK)
{t("budgets.panel.examples.tabs.openaiSdk")}
</TabsTrigger>
</TabsList>
<TabsContent value="assign-budget" keepMounted>

View file

@ -2,6 +2,7 @@
import { Inbox, ShieldAlert } from "lucide-react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BUDGET_DURATION_FILTER_OPTIONS,
@ -21,6 +22,7 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import i18n from "@/i18n/i18n";
import { ApiError } from "@/lib/http/client";
import { BUDGET_TABLE_HIDDEN_COLUMNS, getBudgetTableColumns } from "./BudgetTableColumns";
@ -49,11 +51,19 @@ const formatFilterValue = (columnId: string, value: unknown): string => {
}
if (columnId === "max_budget") {
const { min, max, unlimitedOnly } = (value ?? {}) as MaxBudgetFilterValue;
return unlimitedOnly === true ? "Unlimited only" : `${min ? `$${min}` : "any"} to ${max ? `$${max}` : "any"}`;
return unlimitedOnly === true
? i18n.t("budgets.table.filterChips.unlimitedOnly")
: i18n.t("budgets.table.filterChips.maxBudgetRange", {
min: min ? `$${min}` : i18n.t("budgets.table.filterChips.any"),
max: max ? `$${max}` : i18n.t("budgets.table.filterChips.any"),
});
}
if (columnId === "created_at") {
const { from, to } = (value ?? {}) as CreatedAtFilterValue;
return `${from || "any"} to ${to || "any"}`;
return i18n.t("budgets.table.filterChips.createdRange", {
from: from || i18n.t("budgets.table.filterChips.any"),
to: to || i18n.t("budgets.table.filterChips.any"),
});
}
return String(value);
};
@ -81,22 +91,24 @@ const normalizeCreatedAt = (draft: CreatedAtFilterValue): CreatedAtFilterValue |
};
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">{hasQuery ? "No matching budgets" : "No budgets yet"}</div>
<div className="text-sm font-medium text-foreground">
{hasQuery ? t("budgets.table.emptyState.noMatchTitle") : t("budgets.table.emptyState.title")}
</div>
<div className="text-sm text-muted-foreground">
{hasQuery
? "No budget matches your search or filters."
: "Create a budget to set spend, TPM and RPM limits for customers."}
{hasQuery ? t("budgets.table.emptyState.noMatchDescription") : t("budgets.table.emptyState.description")}
</div>
</div>
);
}
function ErrorState({ error }: { error: Error }) {
const { t } = useTranslation();
const forbidden = error instanceof ApiError && error.status === 403;
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -104,10 +116,10 @@ function ErrorState({ error }: { error: Error }) {
<ShieldAlert className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{forbidden ? "You do not have access to budgets" : "Could not load budgets"}
{forbidden ? t("budgets.table.errorState.forbiddenTitle") : t("budgets.table.errorState.title")}
</div>
<div className="text-sm text-muted-foreground">
{forbidden ? "Ask a proxy admin to grant you the admin viewer role." : error.message}
{forbidden ? t("budgets.table.errorState.forbiddenDescription") : error.message}
</div>
</div>
);
@ -141,19 +153,20 @@ function DurationFilter({ selected, onChange }: { selected: string[]; onChange:
}
function BudgetFilterFields({ get, set }: FilterDraft) {
const { t } = useTranslation();
const maxBudget = (get("max_budget") as MaxBudgetFilterValue | undefined) ?? {};
const created = (get("created_at") as CreatedAtFilterValue | undefined) ?? {};
const unlimitedOnly = maxBudget.unlimitedOnly === true;
return (
<>
<DataTableFilterField label="Reset">
<DataTableFilterField label={t("budgets.table.filters.reset")}>
<DurationFilter
selected={(get("budget_duration") as string[] | undefined) ?? []}
onChange={(selected) => set("budget_duration", selected)}
/>
</DataTableFilterField>
<DataTableFilterField label="Max Budget (USD)">
<DataTableFilterField label={t("budgets.table.filters.maxBudget")}>
<div className="flex items-center gap-2">
<Input
type="number"
@ -162,8 +175,8 @@ function BudgetFilterFields({ get, set }: FilterDraft) {
value={maxBudget.min ?? ""}
disabled={unlimitedOnly}
onChange={(event) => set("max_budget", normalizeMaxBudget({ ...maxBudget, min: event.target.value }))}
placeholder="Min"
aria-label="Minimum max budget"
placeholder={t("budgets.table.filters.min")}
aria-label={t("budgets.table.filters.minAriaLabel")}
data-testid="budget-filter-max-budget-min"
/>
<Input
@ -173,8 +186,8 @@ function BudgetFilterFields({ get, set }: FilterDraft) {
value={maxBudget.max ?? ""}
disabled={unlimitedOnly}
onChange={(event) => set("max_budget", normalizeMaxBudget({ ...maxBudget, max: event.target.value }))}
placeholder="Max"
aria-label="Maximum max budget"
placeholder={t("budgets.table.filters.max")}
aria-label={t("budgets.table.filters.maxAriaLabel")}
data-testid="budget-filter-max-budget-max"
/>
</div>
@ -184,23 +197,23 @@ function BudgetFilterFields({ get, set }: FilterDraft) {
onCheckedChange={(checked) => set("max_budget", normalizeMaxBudget({ unlimitedOnly: checked === true }))}
data-testid="budget-filter-max-budget-unlimited"
/>
Unlimited only
{t("budgets.table.filters.unlimitedOnly")}
</Label>
</DataTableFilterField>
<DataTableFilterField label="Created">
<DataTableFilterField label={t("budgets.table.filters.created")}>
<div className="flex items-center gap-2">
<Input
type="date"
value={created.from ?? ""}
onChange={(event) => set("created_at", normalizeCreatedAt({ ...created, from: event.target.value }))}
aria-label="Created from"
aria-label={t("budgets.table.filters.createdFrom")}
data-testid="budget-filter-created-from"
/>
<Input
type="date"
value={created.to ?? ""}
onChange={(event) => set("created_at", normalizeCreatedAt({ ...created, to: event.target.value }))}
aria-label="Created to"
aria-label={t("budgets.table.filters.createdTo")}
data-testid="budget-filter-created-to"
/>
</div>
@ -210,11 +223,12 @@ function BudgetFilterFields({ get, set }: FilterDraft) {
}
const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick, onDeleteClick }) => {
const { t } = useTranslation();
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(
() => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }),
[canModify, onEditClick, onDeleteClick],
() => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }, t),
[canModify, onEditClick, onDeleteClick, t],
);
const hasQuery = list.searchValue.trim() !== "" || list.columnFilters.length > 0;
@ -239,7 +253,7 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
columnFilters={list.columnFilters}
onColumnFiltersChange={list.onColumnFiltersChange}
isLoading={list.isLoading}
loadingMessage="Loading budgets…"
loadingMessage={t("budgets.table.loading")}
noDataMessage={emptyMessage}
size="compact"
toolbar={(table) => (
@ -248,7 +262,7 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
table={table}
searchValue={list.searchValue}
onSearchChange={list.onSearchChange}
searchPlaceholder="Search by budget ID…"
searchPlaceholder={t("budgets.table.searchPlaceholder")}
onOpenFilters={() => setFiltersOpen(true)}
onRefresh={list.refetch}
isRefreshing={list.isFetching}
@ -259,8 +273,8 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down your budgets"
title={t("budgets.table.filters.title")}
description={t("budgets.table.filters.description")}
>
{(draft) => <BudgetFilterFields {...draft} />}
</DataTableFilterDrawer>

View file

@ -1,7 +1,9 @@
"use client";
import { ColumnDef, FilterFn } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
@ -27,15 +29,17 @@ const serverFilter: FilterFn<budgetItem> = () => true;
serverFilter.autoRemove = () => false;
function RateLimitCell({ value }: { value: number | null | undefined }) {
const { t } = useTranslation();
if (value == null) {
return <span className="text-muted-foreground">n/a</span>;
return <span className="text-muted-foreground">{t("budgets.table.cells.notApplicable")}</span>;
}
return <span className="tabular-nums">{value}</span>;
}
function BudgetDurationCell({ value }: { value: string | null | undefined }) {
const { t } = useTranslation();
if (!value) {
return <span className="text-muted-foreground">Not set</span>;
return <span className="text-muted-foreground">{t("budgets.table.cells.notSet")}</span>;
}
return <span className="whitespace-nowrap">{getBudgetDurationLabel(value)}</span>;
}
@ -47,10 +51,11 @@ interface BudgetRowActionsProps {
}
function BudgetRowActions({ budget, onEditClick, onDeleteClick }: BudgetRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open budget actions"
aria-label={t("budgets.table.rowActions.open")}
data-testid={`budget-actions-${budget.budget_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -59,7 +64,7 @@ function BudgetRowActions({ budget, onEditClick, onDeleteClick }: BudgetRowActio
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="budget-action-edit" onClick={() => onEditClick(budget)}>
<Pencil />
Edit budget
{t("budgets.table.rowActions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@ -68,7 +73,7 @@ function BudgetRowActions({ budget, onEditClick, onDeleteClick }: BudgetRowActio
onClick={() => onDeleteClick(budget)}
>
<Trash2 />
Delete budget
{t("budgets.table.rowActions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -87,16 +92,15 @@ interface BudgetTableColumnsDeps {
onDeleteClick: (budget: budgetItem) => void;
}
export const getBudgetTableColumns = ({
canModify,
onEditClick,
onDeleteClick,
}: BudgetTableColumnsDeps): ColumnDef<budgetItem>[] => [
export const getBudgetTableColumns = (
{ canModify, onEditClick, onDeleteClick }: BudgetTableColumnsDeps,
t: TFunction,
): ColumnDef<budgetItem>[] => [
{
id: "budget_id",
accessorKey: "budget_id",
meta: { title: "Budget ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget ID" />,
meta: { title: t("budgets.table.columns.budgetId") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.budgetId")} />,
cell: ({ row }) => (
<IdCell value={row.original.budget_id} variant="plain" truncate={false} copyable className="whitespace-nowrap" />
),
@ -105,32 +109,34 @@ export const getBudgetTableColumns = ({
id: "max_budget",
accessorKey: "max_budget",
filterFn: serverFilter,
meta: { title: "Max Budget", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Max Budget" />,
meta: { title: t("budgets.table.columns.maxBudget"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.maxBudget")} />,
size: 120,
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} showZero emptyText="Unlimited" />,
cell: ({ row }) => (
<MoneyCell value={row.original.max_budget} decimals={2} showZero emptyText={t("budgets.table.cells.unlimited")} />
),
},
{
id: "tpm_limit",
accessorKey: "tpm_limit",
meta: { title: "TPM", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="TPM" />,
meta: { title: t("budgets.table.columns.tpm"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.tpm")} />,
size: 100,
cell: ({ row }) => <RateLimitCell value={row.original.tpm_limit} />,
},
{
id: "rpm_limit",
accessorKey: "rpm_limit",
meta: { title: "RPM", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="RPM" />,
meta: { title: t("budgets.table.columns.rpm"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.rpm")} />,
size: 100,
cell: ({ row }) => <RateLimitCell value={row.original.rpm_limit} />,
},
{
id: "tpd_limit",
accessorKey: "tpd_limit",
meta: { title: "TPD (batch)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="TPD (batch)" />,
meta: { title: t("budgets.table.columns.tpd"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.tpd")} />,
size: 110,
cell: ({ row }) => <RateLimitCell value={row.original.tpd_limit} />,
},
@ -138,9 +144,9 @@ export const getBudgetTableColumns = ({
id: "budget_duration",
accessorKey: "budget_duration",
filterFn: serverFilter,
meta: { title: "Reset" },
meta: { title: t("budgets.table.columns.reset") },
sortDescFirst: false,
header: ({ column }) => <DataTableSortHeader column={column} title="Reset" />,
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.reset")} />,
size: 110,
cell: ({ row }) => <BudgetDurationCell value={row.original.budget_duration} />,
},
@ -148,8 +154,8 @@ export const getBudgetTableColumns = ({
id: "created_at",
accessorKey: "created_at",
filterFn: serverFilter,
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
meta: { title: t("budgets.table.columns.created") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("budgets.table.columns.created")} />,
size: 160,
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
@ -158,7 +164,7 @@ export const getBudgetTableColumns = ({
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{t("budgets.table.columns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,4 +1,5 @@
import React, { useEffect, useId } from "react";
import { useTranslation } from "react-i18next";
import { useUpdateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { applyBudgetPrecision } from "./budgetPrecision";
@ -23,6 +24,7 @@ interface EditBudgetModalProps {
existingBudget: budgetItem;
}
const EditBudgetModal: React.FC<EditBudgetModalProps> = ({ isModalVisible, setIsModalVisible, existingBudget }) => {
const { t } = useTranslation();
const formId = useId();
const form = useBudgetForm(toFormValues(existingBudget));
const { dirtyFields } = form.formState;
@ -39,15 +41,15 @@ const EditBudgetModal: React.FC<EditBudgetModalProps> = ({ isModalVisible, setIs
const handleUpdate = async (formValues: BudgetFormValues) => {
try {
toast.info("Making API Call");
toast.info(t("budgets.editModal.toast.pending"));
const changedValues = pickDirty(formValues, dirtyFields);
await updateBudget.mutateAsync(applyBudgetPrecision({ budget_id: formValues.budget_id, ...changedValues }));
toast.success("Budget Updated");
toast.success(t("budgets.editModal.toast.updated"));
form.reset();
setIsModalVisible(false);
} catch (error) {
console.error("Error updating the budget:", error);
toast.fromError(`Error updating the budget: ${error}`);
toast.fromError(t("budgets.editModal.toast.error", { error: String(error) }));
}
};
@ -55,15 +57,15 @@ const EditBudgetModal: React.FC<EditBudgetModalProps> = ({ isModalVisible, setIs
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-[15px] leading-[22px] font-semibold">Edit budget</DialogTitle>
<DialogTitle className="text-[15px] leading-[22px] font-semibold">{t("budgets.editModal.title")}</DialogTitle>
</DialogHeader>
<form id={formId} onSubmit={form.handleSubmit(handleUpdate)} noValidate>
<BudgetFormFields form={form} isEditing />
</form>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<DialogClose render={<Button variant="outline" />}>{t("budgets.editModal.cancel")}</DialogClose>
<Button type="submit" form={formId}>
Save
{t("budgets.editModal.submit")}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,6 +1,7 @@
"use client";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { useAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
@ -76,20 +77,21 @@ const SpendRow: React.FC<{ label: string; value: string; hint?: string; subdued?
);
const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
const { t } = useTranslation();
const stats = view.stats;
const cheaper = stats.saved_pct != null && stats.saved_pct >= 0;
const classifierCost = stats.baseline_spend == null ? null : stats.savings_estimated_classifier_cost ?? null;
const comparedAll = stats.savings_estimated_turns === stats.turns;
return (
<Card className="overflow-hidden py-0" role="region" aria-label="Auto-router savings">
<Card className="overflow-hidden py-0" role="region" aria-label={t("costOptimization.autoRouter.hero.ariaLabel")}>
<div className="grid md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="flex flex-col items-center justify-center gap-2 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Total estimated savings
{t("costOptimization.autoRouter.hero.totalSavings")}
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<p className="min-w-0 break-all text-center text-4xl font-semibold tracking-tight text-foreground xl:text-6xl">
{stats.saved_spend == null ? "Unavailable" : usd(stats.saved_spend)}
{stats.saved_spend == null ? t("costOptimization.autoRouter.unavailable") : usd(stats.saved_spend)}
</p>
{stats.saved_pct != null && (
<Badge
@ -103,52 +105,63 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
</div>
{stats.baseline_spend != null && !comparedAll && (
<p className="text-center text-xs text-muted-foreground">
Compared on {stats.savings_estimated_turns.toLocaleString()} of {stats.turns.toLocaleString()} requests;
adaptive and quality routers are excluded
{t("costOptimization.autoRouter.hero.comparedOn", {
compared: stats.savings_estimated_turns.toLocaleString(),
total: stats.turns.toLocaleString(),
})}
</p>
)}
{stats.unattributed_saved_spend != null && (
<p className="text-center text-xs text-muted-foreground">
Per-router records differ from recorded savings by {usd(Math.abs(stats.unattributed_saved_spend))}, for
example history from before per-router tracking, so the baseline comparison is unavailable
{t("costOptimization.autoRouter.hero.unattributed", {
amount: usd(Math.abs(stats.unattributed_saved_spend)),
})}
</p>
)}
</div>
<div className="flex flex-col justify-center border-t p-6 md:border-t-0 md:border-l">
<SpendRow
label="Actual auto-router spend"
value={stats.baseline_spend == null ? "Unavailable" : usd(stats.savings_estimated_actual_spend)}
tooltip="Auto-routed spend in the range, including classification costs, for complexity routers. Adaptive and quality routers are excluded. Baseline is this spend plus recorded savings."
label={t("costOptimization.autoRouter.hero.actualSpend.label")}
value={
stats.baseline_spend == null
? t("costOptimization.autoRouter.unavailable")
: usd(stats.savings_estimated_actual_spend)
}
tooltip={t("costOptimization.autoRouter.hero.actualSpend.tooltip")}
/>
<div className="mb-3 border-l-2 pl-4">
<SpendRow
subdued
label="LLM spend"
label={t("costOptimization.autoRouter.hero.llmSpend")}
value={
classifierCost == null ? "Unavailable" : usd(stats.savings_estimated_actual_spend - classifierCost)
classifierCost == null
? t("costOptimization.autoRouter.unavailable")
: usd(stats.savings_estimated_actual_spend - classifierCost)
}
/>
<SpendRow
subdued
label="Classification cost"
value={classifierCost == null ? "Unavailable" : usd(classifierCost)}
label={t("costOptimization.autoRouter.hero.classificationCost")}
value={classifierCost == null ? t("costOptimization.autoRouter.unavailable") : usd(classifierCost)}
hint={
classifierCost == null
? undefined
: classificationRatePer1kTurns(classifierCost, stats.savings_estimated_turns)
: classificationRatePer1kTurns(classifierCost, stats.savings_estimated_turns, t)
}
/>
</div>
{stats.baseline_spend != null && classifierCost == null && (
<p className="mb-3 text-xs text-muted-foreground">
Breakdown unavailable because some usage predates classification-cost tracking.
{t("costOptimization.autoRouter.hero.breakdownUnavailable")}
</p>
)}
<Separator />
<SpendRow
label="Estimated baseline spend"
value={stats.baseline_spend == null ? "Unavailable" : usd(stats.baseline_spend)}
label={t("costOptimization.autoRouter.hero.baselineSpend")}
value={
stats.baseline_spend == null ? t("costOptimization.autoRouter.unavailable") : usd(stats.baseline_spend)
}
/>
</div>
</div>
@ -157,20 +170,24 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
};
const StackedTurnBar: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => {
const { t } = useTranslation();
const segments = buckets.filter((b) => b.turns > 0);
return (
<div className="flex flex-col gap-1">
<div
className={`flex h-2.5 w-full gap-0.5 overflow-hidden rounded-sm ${segments.length === 0 ? "bg-muted" : ""}`}
role="img"
aria-label="Share of turns by bucket"
aria-label={t("costOptimization.autoRouter.caching.shareByBucket")}
>
{segments.map((b) => (
<div
key={b.key}
className={`${b.fill} first:rounded-l-sm last:rounded-r-sm`}
style={{ width: `${b.sharePct}%` }}
title={`${b.label}: ${b.turns.toLocaleString()} turns`}
title={t("costOptimization.autoRouter.caching.bucketTitle", {
label: b.label,
turns: b.turns.toLocaleString(),
})}
/>
))}
</div>
@ -185,47 +202,57 @@ const StackedTurnBar: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => {
);
};
const BucketTable: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => (
<Table className="border-b">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="text-[11px] uppercase tracking-wide">Bucket</TableHead>
<TableHead className="text-right text-[11px] uppercase tracking-wide">Turns</TableHead>
<TableHead className="w-1/2" />
<TableHead className="text-right text-[11px] uppercase tracking-wide">Hit rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{buckets.map((b) => (
<TableRow key={b.key} className="hover:bg-transparent">
<TableCell className="text-foreground">
<span className="flex items-center gap-2">
<span className={`inline-block size-2 shrink-0 rounded-sm ${b.fill}`} aria-hidden />
<span>
{b.label}
<span className="block text-xs font-normal text-muted-foreground">{b.sublabel}</span>
</span>
</span>
</TableCell>
<TableCell className="text-right align-middle tabular-nums text-foreground">
{b.turns.toLocaleString()}
</TableCell>
<TableCell className="align-middle">
<div className="h-1.5 w-full rounded-full bg-muted">
<div className="h-full rounded-full bg-foreground" style={{ width: `${b.hitRatePct}%` }} aria-hidden />
</div>
</TableCell>
<TableCell className="text-right align-middle font-medium tabular-nums text-foreground">
{pctLabel(b.hitRatePct)}
</TableCell>
const BucketTable: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => {
const { t } = useTranslation();
return (
<Table className="border-b">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="text-[11px] uppercase tracking-wide">
{t("costOptimization.autoRouter.caching.columns.bucket")}
</TableHead>
<TableHead className="text-right text-[11px] uppercase tracking-wide">
{t("costOptimization.autoRouter.caching.columns.turns")}
</TableHead>
<TableHead className="w-1/2" />
<TableHead className="text-right text-[11px] uppercase tracking-wide">
{t("costOptimization.autoRouter.caching.columns.hitRate")}
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
);
</TableHeader>
<TableBody>
{buckets.map((b) => (
<TableRow key={b.key} className="hover:bg-transparent">
<TableCell className="text-foreground">
<span className="flex items-center gap-2">
<span className={`inline-block size-2 shrink-0 rounded-sm ${b.fill}`} aria-hidden />
<span>
{b.label}
<span className="block text-xs font-normal text-muted-foreground">{b.sublabel}</span>
</span>
</span>
</TableCell>
<TableCell className="text-right align-middle tabular-nums text-foreground">
{b.turns.toLocaleString()}
</TableCell>
<TableCell className="align-middle">
<div className="h-1.5 w-full rounded-full bg-muted">
<div className="h-full rounded-full bg-foreground" style={{ width: `${b.hitRatePct}%` }} aria-hidden />
</div>
</TableCell>
<TableCell className="text-right align-middle font-medium tabular-nums text-foreground">
{pctLabel(b.hitRatePct)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
const buckets = bucketRows(cache);
const { t } = useTranslation();
const buckets = bucketRows(cache, t);
const total = bucketTurnsTotal(cache);
const expiredMissPct = expiredMissShare(cache);
return (
@ -233,7 +260,7 @@ const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
<div className="grid lg:grid-cols-[1fr_3fr]">
<div className="flex flex-col border-b p-6 lg:border-b-0 lg:border-r">
<div className="flex flex-1 flex-col justify-center gap-3">
<p className="text-sm text-muted-foreground">Cache hit rate</p>
<p className="text-sm text-muted-foreground">{t("costOptimization.autoRouter.caching.hitRate")}</p>
<p className="text-5xl font-semibold tracking-tight text-foreground">{pctLabel(cache.hit_rate_pct)}</p>
</div>
{expiredMissPct === null ? null : (
@ -248,13 +275,12 @@ const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
}
>
<span className="text-sm text-muted-foreground underline decoration-dotted underline-offset-2">
Expired-miss
{t("costOptimization.autoRouter.caching.expiredMiss.label")}
</span>
<span className="font-medium tabular-nums text-foreground">{pctLabel(expiredMissPct)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-64">
share of all measured turns that missed cache because a return to an earlier tier came after its TTL
lapsed
{t("costOptimization.autoRouter.caching.expiredMiss.tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -263,17 +289,27 @@ const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
<div className="flex flex-col gap-3 p-6">
<div className="flex items-baseline justify-between">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Share of turns</p>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
{t("costOptimization.autoRouter.caching.shareOfTurns")}
</p>
<p className="text-xs text-muted-foreground">
<span className="text-lg font-semibold tabular-nums text-foreground">{total.toLocaleString()}</span> turns
measured
<Trans
i18nKey="costOptimization.autoRouter.caching.turnsMeasured"
components={{
total: (
<span className="text-lg font-semibold tabular-nums text-foreground">{total.toLocaleString()}</span>
),
}}
/>
</p>
</div>
<StackedTurnBar buckets={buckets} />
<BucketTable buckets={buckets} />
{cache.unordered_turns > 0 && (
<p className="text-xs text-muted-foreground">
{cache.unordered_turns.toLocaleString()} turns arrived out of order across pods and are not bucketed
{t("costOptimization.autoRouter.caching.unorderedTurns", {
turns: cache.unordered_turns.toLocaleString(),
})}
</p>
)}
</div>
@ -291,13 +327,14 @@ interface BenchmarksBodyProps {
}
const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data, selectedKey, autoRouters }) => {
if (isPending) return <Message>Loading auto-router usage...</Message>;
const { t } = useTranslation();
if (isPending) return <Message>{t("costOptimization.autoRouter.body.loading")}</Message>;
if (error instanceof ApiError && error.status === 403) {
return <Message>Auto-router usage is visible to proxy admin roles only</Message>;
return <Message>{t("costOptimization.autoRouter.body.forbidden")}</Message>;
}
if (error || !data) return <Message>Auto-router usage is unavailable right now</Message>;
if (error || !data) return <Message>{t("costOptimization.autoRouter.body.unavailable")}</Message>;
const view = viewFor(data, selectedKey);
const view = viewFor(data, selectedKey, t);
const stats = view.stats;
return (
<>
@ -305,30 +342,32 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
<TierTurnsChart view={view} autoRouters={autoRouters} />
<p className="text-xs text-muted-foreground">
Savings and spend count requests on the selected UTC days. Actual spend covers every request on complexity
routers, including LLM classification cost. Baseline is actual spend plus recorded savings, so savings can be
zero or negative.
</p>
<p className="text-xs text-muted-foreground">{t("costOptimization.autoRouter.body.savingsNote")}</p>
<p className="text-xs text-muted-foreground">
Session metrics cover every session that overlaps the range, including its turns outside the range.
</p>
<p className="text-xs text-muted-foreground">{t("costOptimization.autoRouter.body.sessionNote")}</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Metric
label="Avg turns per session"
value={stats.avg_turns_per_session == null ? "Unavailable" : stats.avg_turns_per_session.toFixed(1)}
hint={`· ${stats.sessions.toLocaleString()} sessions`}
label={t("costOptimization.autoRouter.metrics.avgTurns")}
value={
stats.avg_turns_per_session == null
? t("costOptimization.autoRouter.unavailable")
: stats.avg_turns_per_session.toFixed(1)
}
hint={t("costOptimization.autoRouter.metrics.sessionsHint", { sessions: stats.sessions.toLocaleString() })}
/>
<Metric
label="Avg session length"
value={stats.avg_session_seconds == null ? "Unavailable" : durationLabel(stats.avg_session_seconds)}
label={t("costOptimization.autoRouter.metrics.avgSessionLength")}
value={
stats.avg_session_seconds == null
? t("costOptimization.autoRouter.unavailable")
: durationLabel(stats.avg_session_seconds)
}
/>
<Metric
label="Avg tokens per session"
label={t("costOptimization.autoRouter.metrics.avgTokens")}
value={
stats.avg_tokens_per_session == null
? "Unavailable"
? t("costOptimization.autoRouter.unavailable")
: formatNumberWithCommas(stats.avg_tokens_per_session, 1, true)
}
/>
@ -338,10 +377,8 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
<div className="space-y-4">
<div className="flex flex-wrap items-baseline gap-2">
<h3 className="text-lg font-semibold text-foreground">Auto-router prompt caching</h3>
<p className="text-xs text-muted-foreground">
every turn falls in exactly one bucket, by what the router did
</p>
<h3 className="text-lg font-semibold text-foreground">{t("costOptimization.autoRouter.caching.title")}</h3>
<p className="text-xs text-muted-foreground">{t("costOptimization.autoRouter.caching.subtitle")}</p>
</div>
<CachingCard cache={stats.cache} />
</div>
@ -362,21 +399,26 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
apiKey,
userId,
}) => {
const { t } = useTranslation();
const { dateValue, onDateChange } = activity;
const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, dateValue, apiKey, userId);
const [selectedKey, setSelectedKey] = useState<string>(ALL_ROUTERS);
const { data: autoRouters } = useAutoRouters();
const groups = data?.groups ?? [];
const selectedLabel = data ? viewFor(data, selectedKey).label : "All auto-routers";
const selectedLabel = data ? viewFor(data, selectedKey, t).label : t("costOptimization.autoRouter.allRouters");
const rangeLabel = formatRangeLabel(dateValue.from, dateValue.to);
return (
<div className="w-full space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-xl font-semibold text-foreground">Auto-router usage</h2>
{rangeLabel && <p className="mt-1 text-sm text-muted-foreground">{rangeLabel} (UTC)</p>}
<h2 className="text-xl font-semibold text-foreground">{t("costOptimization.autoRouter.usageTitle")}</h2>
{rangeLabel && (
<p className="mt-1 text-sm text-muted-foreground">
{t("costOptimization.autoRouter.rangeUtc", { range: rangeLabel })}
</p>
)}
</div>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
@ -386,7 +428,7 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
<SelectValue>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_ROUTERS}>All auto-routers</SelectItem>
<SelectItem value={ALL_ROUTERS}>{t("costOptimization.autoRouter.allRouters")}</SelectItem>
{groups.map((g) => (
<SelectItem key={groupKey(g)} value={groupKey(g)}>
{groupLabel(g, groups)}
@ -398,12 +440,7 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
</div>
</div>
{userId && (
<p className="text-sm text-muted-foreground">
Usage for this user across API keys and JWT-authenticated requests. Older sessions recorded without a user ID
are not included.
</p>
)}
{userId && <p className="text-sm text-muted-foreground">{t("costOptimization.autoRouter.userScopeNote")}</p>}
<BenchmarksBody
isPending={isPending}
error={error}
@ -416,6 +453,7 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
};
const AutoRouterBenchmarksTab: React.FC<AutoRouterBenchmarksTabProps> = ({ accessToken, activity }) => {
const { t } = useTranslation();
const [visitedTabs, setVisitedTabs] = useState<readonly string[]>(["usage"]);
const handleTabChange = (value: unknown) => {
@ -430,10 +468,10 @@ const AutoRouterBenchmarksTab: React.FC<AutoRouterBenchmarksTabProps> = ({ acces
<Tabs defaultValue="usage" onValueChange={handleTabChange} className="w-full gap-4">
<TabsList>
<TabsTrigger value="usage" className="px-3">
Usage
{t("costOptimization.autoRouter.tabs.usage")}
</TabsTrigger>
<TabsTrigger value="shadow-evals" className="px-3">
Shadow Evals
{t("costOptimization.autoRouter.tabs.shadowEvals")}
</TabsTrigger>
</TabsList>

View file

@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@ -10,33 +12,40 @@ interface AutoRouterSummaryTableProps {
}
export default function AutoRouterSummaryTable({ groups, selectedGroup }: AutoRouterSummaryTableProps) {
const { t } = useTranslation();
const visibleGroups = selectedGroup ? [selectedGroup] : groups;
return (
<Card>
<CardHeader>
<CardTitle>Router usage and savings</CardTitle>
<CardDescription>
Selected UTC days. Cost includes LLM calls and classification. Tokens count routed LLM input and output.
</CardDescription>
<CardTitle>{t("costOptimization.routerSummary.title")}</CardTitle>
<CardDescription>{t("costOptimization.routerSummary.description")}</CardDescription>
</CardHeader>
<CardContent>
<Table aria-label="Router usage and savings">
<Table aria-label={t("costOptimization.routerSummary.title")}>
<TableHeader>
<TableRow>
<TableHead>Auto-router</TableHead>
<TableHead className="text-right whitespace-normal">Tokens through router</TableHead>
<TableHead className="text-right whitespace-normal">Cost via router</TableHead>
<TableHead className="text-right whitespace-normal">Cost per 1M tokens</TableHead>
<TableHead className="text-right whitespace-normal">Saved vs. premium model</TableHead>
<TableHead className="text-right">% saved</TableHead>
<TableHead>{t("costOptimization.routerSummary.columns.router")}</TableHead>
<TableHead className="text-right whitespace-normal">
{t("costOptimization.routerSummary.columns.tokens")}
</TableHead>
<TableHead className="text-right whitespace-normal">
{t("costOptimization.routerSummary.columns.cost")}
</TableHead>
<TableHead className="text-right whitespace-normal">
{t("costOptimization.routerSummary.columns.costPerMillion")}
</TableHead>
<TableHead className="text-right whitespace-normal">
{t("costOptimization.routerSummary.columns.saved")}
</TableHead>
<TableHead className="text-right">{t("costOptimization.routerSummary.columns.savedPct")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleGroups.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
No auto-routers in this range
{t("costOptimization.routerSummary.empty")}
</TableCell>
</TableRow>
) : (
@ -44,29 +53,32 @@ export default function AutoRouterSummaryTable({ groups, selectedGroup }: AutoRo
<TableRow key={groupKey(group)}>
<TableCell className="font-medium">{groupLabel(group, groups)}</TableCell>
<TableCell className="text-right tabular-nums">
{group.total_tokens == null ? "Unavailable" : group.total_tokens.toLocaleString()}
{group.total_tokens == null
? t("costOptimization.routerSummary.unavailable")
: group.total_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">{usd(group.spend)}</TableCell>
<TableCell className="text-right tabular-nums">
{group.total_tokens != null && group.total_tokens > 0
? usd((group.spend * 1_000_000) / group.total_tokens)
: "Unavailable"}
: t("costOptimization.routerSummary.unavailable")}
</TableCell>
<TableCell className="text-right tabular-nums">
{group.saved_spend == null ? "Unavailable" : usd(group.saved_spend)}
{group.saved_spend == null
? t("costOptimization.routerSummary.unavailable")
: usd(group.saved_spend)}
</TableCell>
<TableCell className="text-right tabular-nums">
{group.saved_pct == null ? "Unavailable" : pctLabel(group.saved_pct)}
{group.saved_pct == null
? t("costOptimization.routerSummary.unavailable")
: pctLabel(group.saved_pct)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<p className="mt-3 text-xs text-muted-foreground">
Savings are estimated against each router&apos;s premium baseline. Token totals and unit costs are unavailable
for usage recorded before token tracking; unit cost also requires nonzero tokens.
</p>
<p className="mt-3 text-xs text-muted-foreground">{t("costOptimization.routerSummary.footnote")}</p>
</CardContent>
</Card>
);

View file

@ -1,6 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ArrowUpDown, Info } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -67,6 +68,7 @@ const SortableHead = ({
sort: SortState;
onSort: (column: SortColumn) => void;
}) => {
const { t } = useTranslation();
const active = sort.column === column;
const ActiveArrow = sort.dir === "asc" ? ArrowUp : ArrowDown;
const Arrow = active ? ActiveArrow : ArrowUpDown;
@ -76,7 +78,7 @@ const SortableHead = ({
<button
type="button"
onClick={() => onSort(column)}
aria-label={`Sort by ${label}`}
aria-label={t("costOptimization.cacheLeakage.sortBy", { label })}
className="inline-flex items-center gap-1 font-medium hover:text-foreground"
>
{label}
@ -89,6 +91,7 @@ const SortableHead = ({
};
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
const { t } = useTranslation();
const { results, loading } = activity;
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
@ -111,13 +114,22 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
: { column, dir: NATURAL_DIR[column] },
);
const subject = dimension === "model" ? "Models" : "Keys";
const firstColumn = dimension === "model" ? "Model" : "Key";
const emptyNoun = dimension === "model" ? "model" : "key";
const subject =
dimension === "model"
? t("costOptimization.cacheLeakage.subjects.models")
: t("costOptimization.cacheLeakage.subjects.keys");
const firstColumn =
dimension === "model"
? t("costOptimization.cacheLeakage.columns.model")
: t("costOptimization.cacheLeakage.columns.key");
const emptyNoun =
dimension === "model"
? t("costOptimization.cacheLeakage.nouns.model")
: t("costOptimization.cacheLeakage.nouns.key");
const emptyMessage =
dimension === "key" && keyLeakage.failed
? "Could not load key usage for this range."
: `No ${emptyNoun} usage in this range.`;
? t("costOptimization.cacheLeakage.keyLoadFailed")
: t("costOptimization.cacheLeakage.empty", { noun: emptyNoun });
return (
<TooltipProvider delay={300}>
@ -125,25 +137,30 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
<CardHeader>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<CardTitle>Cache leakage by {dimension === "model" ? "model" : "virtual key"}</CardTitle>
<CardTitle>
{t("costOptimization.cacheLeakage.title", {
dimension:
dimension === "model"
? t("costOptimization.cacheLeakage.nouns.model")
: t("costOptimization.cacheLeakage.nouns.virtualKey"),
})}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{subject} sending large volumes of uncached input with a low cache hit rate are likely missing prompt
caching. Potential savings is approximate: uncached input priced at what your cached traffic nets per
cached token, after cache-write premiums.
{t("costOptimization.cacheLeakage.description", { subject })}
</p>
</div>
</div>
<Tabs value={dimension} onValueChange={(value) => setDimension(value === "model" ? "model" : "key")}>
<TabsList>
<TabsTrigger value="key">By virtual key</TabsTrigger>
<TabsTrigger value="model">By model</TabsTrigger>
<TabsTrigger value="key">{t("costOptimization.cacheLeakage.tabs.byKey")}</TabsTrigger>
<TabsTrigger value="model">{t("costOptimization.cacheLeakage.tabs.byModel")}</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{rowsLoading ? "Loading..." : emptyMessage}
{rowsLoading ? t("costOptimization.cacheLeakage.loading") : emptyMessage}
</p>
) : (
<Table>
@ -152,22 +169,22 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
<TableHead>{firstColumn}</TableHead>
<SortableHead
column="uncachedPromptTokens"
label="Uncached input tokens"
info="Input tokens you sent in this range that weren't served from or written to the cache"
label={t("costOptimization.cacheLeakage.columns.uncachedTokens.label")}
info={t("costOptimization.cacheLeakage.columns.uncachedTokens.info")}
sort={sort}
onSort={onSort}
/>
<SortableHead
column="cacheHitRatio"
label="Cache hit rate"
info="Share of your input tokens that were served from the cache"
label={t("costOptimization.cacheLeakage.columns.cacheHitRate.label")}
info={t("costOptimization.cacheLeakage.columns.cacheHitRate.info")}
sort={sort}
onSort={onSort}
/>
<SortableHead
column="potentialSavings"
label="Potential savings"
info="About how much you'd save if this uncached input used prompt caching. Estimated as uncached input tokens times what your cached traffic already nets per cached token (realized cache savings, after write premiums, ÷ cache read and write tokens). Blank when caching is not currently saving anything overall."
label={t("costOptimization.cacheLeakage.columns.potentialSavings.label")}
info={t("costOptimization.cacheLeakage.columns.potentialSavings.info")}
sort={sort}
onSort={onSort}
/>

View file

@ -2,6 +2,7 @@
import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Info, PiggyBank } from "lucide-react";
import useCan from "@/app/(dashboard)/hooks/useCan";
@ -21,6 +22,7 @@ interface CostOptimizationViewProps {
}
const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
const { t } = useTranslation();
const activity = useDailyActivityRange(accessToken, userId, userRole);
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
const [visitedTabs, setVisitedTabs] = React.useState<readonly string[]>(["usage"]);
@ -39,20 +41,19 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
<PageHeader>
<PageHeaderTitle>
<PiggyBank />
Cost Optimization
{t("costOptimization.view.title")}
</PageHeaderTitle>
<PageHeaderDescription>
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
live under Models + Endpoints, on the Auto-Routers tab
</PageHeaderDescription>
<PageHeaderDescription>{t("costOptimization.view.description")}</PageHeaderDescription>
<PageHeaderControls>
<PageTabsList>
<PageTabsTrigger value="usage">Overall</PageTabsTrigger>
<PageTabsTrigger value="usage">{t("costOptimization.view.tabs.overall")}</PageTabsTrigger>
{canViewProxyWideCostData && (
<>
<PageTabsTrigger value="compression">Prompt Compression</PageTabsTrigger>
<PageTabsTrigger value="caching">Prompt Caching</PageTabsTrigger>
<PageTabsTrigger value="autorouter-usage">Auto-Router</PageTabsTrigger>
<PageTabsTrigger value="compression">{t("costOptimization.view.tabs.compression")}</PageTabsTrigger>
<PageTabsTrigger value="caching">{t("costOptimization.view.tabs.caching")}</PageTabsTrigger>
<PageTabsTrigger value="autorouter-usage">
{t("costOptimization.view.tabs.autoRouter")}
</PageTabsTrigger>
</>
)}
</PageTabsList>
@ -64,26 +65,27 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-lg border border-border bg-muted/50 px-4 py-4"
>
<Info className="mt-0.5 size-5 text-primary" aria-hidden="true" />
<p className="font-medium text-foreground">This is an experimental dashboard</p>
<p className="font-medium text-foreground">{t("costOptimization.view.experimental.title")}</p>
<p className="col-start-2 text-sm text-muted-foreground">
Have feedback? Join the discussion{" "}
<a
href="https://github.com/BerriAI/litellm/discussions/32168"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
here
</a>
<Trans
i18nKey="costOptimization.view.experimental.feedback"
components={{
discussion: (
<a
href="https://github.com/BerriAI/litellm/discussions/32168"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
/>
),
}}
/>
</p>
</div>
{activity.failed && (
<Alert variant="error">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the savings below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
<AlertDescription className="text-inherit">{t("costOptimization.view.fetchFailed")}</AlertDescription>
</Alert>
)}
<TabsContent value="usage" keepMounted={visitedTabs.includes("usage")}>

View file

@ -4,6 +4,7 @@ import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "@/components/networking";
import { Button } from "@/components/ui/button";
@ -33,6 +34,7 @@ interface PromptCachingRequestsTableProps {
}
export default function PromptCachingRequestsTable({ accessToken, dateValue }: PromptCachingRequestsTableProps) {
const { t } = useTranslation();
const [filter, setFilter] = useState<RequestFilter>("all");
const [pageSize, setPageSize] = useState(10);
const window = activityWindow(dateValue, new Date());
@ -79,36 +81,38 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
<Card>
<CardHeader className="gap-3">
<div>
<CardTitle>Prompt caching requests</CardTitle>
<CardTitle>{t("costOptimization.promptCachingRequests.title")}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
Requests with recorded LiteLLM injection or provider cache reads or writes. A cache hit alone does not
establish LiteLLM injection; older logs may not record it.
{t("costOptimization.promptCachingRequests.description")}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Net savings are estimated from logged usage and current configured pricing, after cache-write premiums.
Negative values mean caching cost more; unavailable means the request could not be priced.
{t("costOptimization.promptCachingRequests.savingsNote")}
</p>
</div>
<Tabs value={filter} onValueChange={changeFilter}>
<TabsList aria-label="Prompt caching request filters">
<TabsTrigger value="all">All caching</TabsTrigger>
<TabsTrigger value="injected">LiteLLM injected</TabsTrigger>
<TabsTrigger value="hits">Cache hits</TabsTrigger>
<TabsList aria-label={t("costOptimization.promptCachingRequests.filters.label")}>
<TabsTrigger value="all">{t("costOptimization.promptCachingRequests.filters.all")}</TabsTrigger>
<TabsTrigger value="injected">{t("costOptimization.promptCachingRequests.filters.injected")}</TabsTrigger>
<TabsTrigger value="hits">{t("costOptimization.promptCachingRequests.filters.hits")}</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent>
{!enabled && <p className="py-8 text-center text-muted-foreground">Select a date range to view requests</p>}
{!enabled && (
<p className="py-8 text-center text-muted-foreground">
{t("costOptimization.promptCachingRequests.selectDateRange")}
</p>
)}
{enabled && requests.isPending && (
<p role="status" className="py-8 text-center text-muted-foreground">
Loading requests...
{t("costOptimization.promptCachingRequests.loading")}
</p>
)}
{enabled && requests.isError && (
<div role="alert" className="flex items-center justify-center gap-3 py-8">
<p>Could not load prompt caching requests</p>
<p>{t("costOptimization.promptCachingRequests.loadFailed")}</p>
<Button variant="outline" onClick={() => void requests.refetch()} disabled={requests.isFetching}>
Retry
{t("costOptimization.promptCachingRequests.retry")}
</Button>
</div>
)}
@ -116,19 +120,27 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
<>
{requests.data.requests.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No matching prompt caching requests in this range
{t("costOptimization.promptCachingRequests.empty")}
</p>
) : (
<Table aria-label="Prompt caching requests">
<Table aria-label={t("costOptimization.promptCachingRequests.title")}>
<TableHeader>
<TableRow>
<TableHead>Request</TableHead>
<TableHead>Model</TableHead>
<TableHead>LiteLLM injection</TableHead>
<TableHead className="text-right">Cache reads</TableHead>
<TableHead className="text-right">Cache writes</TableHead>
<TableHead className="text-right">Actual cost</TableHead>
<TableHead className="text-right">Net savings</TableHead>
<TableHead>{t("costOptimization.promptCachingRequests.columns.request")}</TableHead>
<TableHead>{t("costOptimization.promptCachingRequests.columns.model")}</TableHead>
<TableHead>{t("costOptimization.promptCachingRequests.columns.injection")}</TableHead>
<TableHead className="text-right">
{t("costOptimization.promptCachingRequests.columns.cacheReads")}
</TableHead>
<TableHead className="text-right">
{t("costOptimization.promptCachingRequests.columns.cacheWrites")}
</TableHead>
<TableHead className="text-right">
{t("costOptimization.promptCachingRequests.columns.actualCost")}
</TableHead>
<TableHead className="text-right">
{t("costOptimization.promptCachingRequests.columns.netSavings")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -151,14 +163,20 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
{request.model}
</span>
</TableCell>
<TableCell>{request.gateway_injected ? "Recorded" : "Not recorded"}</TableCell>
<TableCell>
{request.gateway_injected
? t("costOptimization.promptCachingRequests.injection.recorded")
: t("costOptimization.promptCachingRequests.injection.notRecorded")}
</TableCell>
<TableCell className="text-right">{formatNumberWithCommas(request.cache_read_tokens)}</TableCell>
<TableCell className="text-right">
{formatNumberWithCommas(request.cache_creation_tokens)}
</TableCell>
<TableCell className="text-right">{usd(request.spend)}</TableCell>
<TableCell className="text-right">
{request.net_savings === null ? "Unavailable" : usd(request.net_savings)}
{request.net_savings === null
? t("costOptimization.promptCachingRequests.unavailable")
: usd(request.net_savings)}
</TableCell>
</TableRow>
))}
@ -167,7 +185,7 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<span>{t("costOptimization.promptCachingRequests.pagination.rowsPerPage")}</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
@ -176,7 +194,11 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
}
}}
>
<SelectTrigger size="sm" aria-label="Rows per page" className="w-[4.5rem]">
<SelectTrigger
size="sm"
aria-label={t("costOptimization.promptCachingRequests.pagination.rowsPerPage")}
className="w-[4.5rem]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -189,12 +211,14 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
</Select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground tabular-nums">Page {page}</span>
<span className="text-sm text-muted-foreground tabular-nums">
{t("costOptimization.promptCachingRequests.pagination.page", { page })}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="Go to previous page"
aria-label={t("costOptimization.promptCachingRequests.pagination.previous")}
disabled={page === 1}
onClick={() => setPagination({ scope, cursors: cursors.slice(0, -1) })}
>
@ -203,7 +227,7 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P
<Button
variant="outline"
size="icon-sm"
aria-label="Go to next page"
aria-label={t("costOptimization.promptCachingRequests.pagination.next")}
disabled={!requests.data.has_more || !nextCursor}
onClick={() => nextCursor && setPagination({ scope, cursors: [...cursors, nextCursor] })}
>

View file

@ -1,6 +1,7 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getGeneralSettingsCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
@ -19,6 +20,7 @@ interface PromptCachingTabProps {
}
const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activity }) => {
const { t } = useTranslation();
const [settings, setSettings] = useState<generalSettingsItem[]>([]);
const loadSettings = useCallback(() => {
@ -29,9 +31,9 @@ const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activi
.then((data: generalSettingsItem[]) => setSettings(data))
.catch((error) => {
console.error("Failed to load prompt caching settings:", error);
toast.fromError("Failed to load prompt caching settings");
toast.fromError(t("costOptimization.promptCaching.loadSettingsFailed"));
});
}, [accessToken]);
}, [accessToken, t]);
useEffect(() => {
loadSettings();
@ -51,7 +53,7 @@ const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activi
<div className="w-full space-y-6">
<PromptCachingPanel accessToken={accessToken} settings={settings} onChange={handleChange} />
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">Date range for requests and cache leakage</p>
<p className="text-sm text-muted-foreground">{t("costOptimization.promptCaching.dateRangeLabel")}</p>
<AdvancedDatePicker value={activity.dateValue} onValueChange={activity.onDateChange} />
</div>
<PromptCachingRequestsTable accessToken={accessToken} dateValue={activity.dateValue} />

View file

@ -1,9 +1,11 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CircleHelp } from "lucide-react";
import { z } from "zod";
import i18n from "@/i18n/i18n";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
import { FieldGroup } from "@/components/ui/field";
@ -27,8 +29,8 @@ interface PromptCompressionTabProps {
}
const compressionSchema = z.object({
name: z.string().min(1, "Name is required"),
apiBase: z.string().min(1, "API base is required"),
name: z.string().min(1, { error: () => i18n.t("costOptimization.promptCompression.form.errors.nameRequired") }),
apiBase: z.string().min(1, { error: () => i18n.t("costOptimization.promptCompression.form.errors.apiBaseRequired") }),
defaultOn: z.boolean(),
});
@ -51,6 +53,7 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
);
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken }) => {
const { t } = useTranslation();
const form = useZodForm(compressionSchema, { defaultValues: EMPTY_VALUES });
const [guardrails, setGuardrails] = useState<GuardrailListItem[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
@ -64,10 +67,10 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
.then((response) => setGuardrails(compressionGuardrailsOf(response as GuardrailListResponse)))
.catch((error) => {
console.error("Failed to load compression guardrails:", error);
toast.fromError("Failed to load compression guardrails");
toast.fromError(t("costOptimization.promptCompression.toasts.loadFailed"));
})
.finally(() => setIsLoading(false));
}, [accessToken]);
}, [accessToken, t]);
useEffect(() => {
loadGuardrails();
@ -87,12 +90,12 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
defaultOn: values.defaultOn ?? true,
}),
);
toast.success("Compression guardrail created");
toast.success(t("costOptimization.promptCompression.toasts.created"));
form.reset(EMPTY_VALUES);
await loadGuardrails();
} catch (error) {
console.error("Failed to create compression guardrail:", error);
toast.fromError("Failed to create compression guardrail");
toast.fromError(t("costOptimization.promptCompression.toasts.createFailed"));
} finally {
setIsSaving(false);
}
@ -102,26 +105,29 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>Headroom prompt compression</CardTitle>
<CardTitle>{t("costOptimization.promptCompression.overview.title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
Headroom is a native LiteLLM guardrail that compresses your prompts before they reach the model, so you pay
for fewer input tokens. The tokens it removes are priced and shown on the Usage tab as compression savings.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/headroom"
target="_blank"
rel="noopener noreferrer"
className="text-info underline"
>
Headroom setup docs
</a>
<Trans
i18nKey="costOptimization.promptCompression.overview.description"
components={{
docs: (
<a
href="https://docs.litellm.ai/docs/proxy/headroom"
target="_blank"
rel="noopener noreferrer"
className="text-info underline"
/>
),
}}
/>
</p>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{isLoading && (
<p className="text-sm text-muted-foreground">{t("costOptimization.promptCompression.overview.loading")}</p>
)}
{!isLoading && guardrails.length === 0 && (
<p className="text-sm text-muted-foreground">
No prompt compression guardrails configured yet. Add one below to start saving on input tokens
</p>
<p className="text-sm text-muted-foreground">{t("costOptimization.promptCompression.overview.empty")}</p>
)}
{!isLoading && guardrails.length > 0 && (
<ul className="divide-y divide-border">
@ -138,7 +144,9 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
: "bg-muted text-muted-foreground"
}`}
>
{guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"}
{guardrail.litellm_params?.default_on
? t("costOptimization.promptCompression.overview.alwaysOn")
: t("costOptimization.promptCompression.overview.optIn")}
</span>
</li>
))}
@ -149,27 +157,31 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
<Card>
<CardHeader>
<CardTitle>Add Headroom compression guardrail</CardTitle>
<CardTitle>{t("costOptimization.promptCompression.form.title")}</CardTitle>
</CardHeader>
<CardContent>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleAdd)} noValidate>
<FieldGroup>
<FormField control={form.control} name="name" label="Name">
<FormField control={form.control} name="name" label={t("costOptimization.promptCompression.form.name")}>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="headroom-compression" />}
</FormField>
<FormField
control={form.control}
name="apiBase"
label={labelWithHint(
"Headroom API base",
"Base URL of your Headroom compression service (LiteLLM calls its /v1/compress endpoint)",
t("costOptimization.promptCompression.form.apiBase.label"),
t("costOptimization.promptCompression.form.apiBase.hint"),
)}
description="The URL where your Headroom compression service is hosted"
description={t("costOptimization.promptCompression.form.apiBase.description")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="https://your-headroom-endpoint" />}
</FormField>
<FormField control={form.control} name="defaultOn" label="Apply to all requests">
<FormField
control={form.control}
name="defaultOn"
label={t("costOptimization.promptCompression.form.defaultOn")}
>
{({ value, onChange, ref: _ref, ...field }) => (
<Switch
{...field}
@ -183,22 +195,25 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
</FieldGroup>
<div className="mt-6 mb-4 rounded-lg border border-warning/20 bg-warning/10 p-3">
<p className="text-sm text-warning">
Applying compression to all requests is available to all users. Enabling it selectively per key or
team is a LiteLLM Enterprise feature. Get a trial key{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
here
</a>
<Trans
i18nKey="costOptimization.promptCompression.form.enterpriseNotice"
components={{
pricing: (
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
/>
),
}}
/>
</p>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving && <UiLoadingSpinner className="size-4" />}
Add guardrail
{t("costOptimization.promptCompression.form.submit")}
</Button>
</div>
</form>

View file

@ -1,6 +1,8 @@
"use client";
import React, { useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Badge } from "@/components/ui/badge";
@ -29,8 +31,10 @@ const MIN_TURNS_FOR_CONFIDENCE = 30;
type ShadowEvalDirection = ShadowEvalJob["direction"];
const otherArmLabel = (direction: ShadowEvalDirection): string =>
direction === "reverse" ? "Baseline" : "Current model";
const otherArmLabel = (direction: ShadowEvalDirection, t: TFunction): string =>
direction === "reverse"
? t("costOptimization.shadowEval.arms.baseline")
: t("costOptimization.shadowEval.arms.currentModel");
const routerWinRate = (direction: ShadowEvalDirection, slice: ShadowEvalSlice): number =>
direction === "reverse" ? slice.real_win_rate_pct : slice.shadow_win_rate_pct;
@ -63,8 +67,10 @@ export const shadowedTargetLabel = (target: ShadowEvalJobTarget): string =>
target.key_name ||
(target.target_type === "key" ? `${target.target_id.slice(0, 10)}…` : target.target_id);
const shadowedTargetsLabel = (job: ShadowEvalJob): string =>
job.targets.length === 1 ? shadowedTargetLabel(job.targets[0]) : `${job.targets.length} targets`;
const shadowedTargetsLabel = (job: ShadowEvalJob, t: TFunction): string =>
job.targets.length === 1
? shadowedTargetLabel(job.targets[0])
: t("costOptimization.shadowEval.headline.targetsCount", { targets: job.targets.length });
const totalBudget = (job: ShadowEvalJob): number | null =>
job.targets.reduce<number | null>(
@ -87,37 +93,55 @@ const targetStatus = (job: ShadowEvalJob, target: ShadowEvalJobTarget): string =
const jobRouters = (job: ShadowEvalJob): string => (job.router_names ?? [job.router_name]).join(", ");
const jobModelScope = (job: ShadowEvalJob): React.ReactNode =>
const jobModelScope = (job: ShadowEvalJob, t: TFunction): React.ReactNode =>
job.models && job.models.length > 0 ? (
<>
{" "}
on <span className="font-mono text-xs">{job.models.join(", ")}</span>
<Trans
t={t}
i18nKey="costOptimization.shadowEval.headline.modelScope"
components={{ models: <span className="font-mono text-xs">{job.models.join(", ")}</span> }}
/>
</>
) : null;
const jobHeadline = (job: ShadowEvalJob): React.ReactNode =>
const jobHeadline = (job: ShadowEvalJob, t: TFunction): React.ReactNode =>
job.direction === "reverse" ? (
<>
Comparing <span className="font-mono text-xs">{jobRouters(job)}</span> to{" "}
<span className="font-mono text-xs">{job.baseline_model}</span> on {job.shadow_percentage}% of{" "}
<span className="font-mono text-xs">{shadowedTargetsLabel(job)}</span> traffic{jobModelScope(job)}
</>
<Trans
t={t}
i18nKey="costOptimization.shadowEval.headline.reverse"
components={{
routers: <span className="font-mono text-xs">{jobRouters(job)}</span>,
baseline: <span className="font-mono text-xs">{job.baseline_model}</span>,
percentage: <>{job.shadow_percentage}</>,
targets: <span className="font-mono text-xs">{shadowedTargetsLabel(job, t)}</span>,
scope: <>{jobModelScope(job, t)}</>,
}}
/>
) : (
<>
Shadowing {job.shadow_percentage}% of <span className="font-mono text-xs">{shadowedTargetsLabel(job)}</span>{" "}
traffic{jobModelScope(job)} via <span className="font-mono text-xs">{jobRouters(job)}</span>
</>
<Trans
t={t}
i18nKey="costOptimization.shadowEval.headline.forward"
components={{
percentage: <>{job.shadow_percentage}</>,
targets: <span className="font-mono text-xs">{shadowedTargetsLabel(job, t)}</span>,
scope: <>{jobModelScope(job, t)}</>,
routers: <span className="font-mono text-xs">{jobRouters(job)}</span>,
}}
/>
);
const isActive = (job: ShadowEvalJob): boolean => job.status === "running";
const endsIn = (endsAt: string | null | undefined): string | null => {
const endsIn = (endsAt: string | null | undefined, t: TFunction): string | null => {
if (!endsAt) return null;
const remainingMs = new Date(endsAt).getTime() - Date.now();
if (!Number.isFinite(remainingMs)) return null;
if (remainingMs <= 0) return "ending now";
if (remainingMs <= 0) return t("costOptimization.shadowEval.endsIn.now");
const days = Math.round(remainingMs / 86_400_000);
return days >= 2 ? `ends in ${days} days` : "ends within a day";
return days >= 2
? t("costOptimization.shadowEval.endsIn.days", { days })
: t("costOptimization.shadowEval.endsIn.withinDay");
};
const STATUS_STYLES: Record<string, string> = {
@ -136,58 +160,64 @@ const SliceTable: React.FC<{
groupHeader: string;
direction: ShadowEvalDirection;
slices: readonly ShadowEvalSlice[];
}> = ({ groupHeader, direction, slices }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{groupHeader}</TableHead>
{[
"Judged turns",
"Router wins",
`${otherArmLabel(direction)} wins`,
"Ties",
"Judge confidence",
"Router cost",
`${otherArmLabel(direction)} cost`,
].map((label) => (
<TableHead key={label} className="text-right">
{label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{slices.map((slice) => (
<TableRow key={slice.group}>
<TableCell className="font-medium text-foreground">
{slice.group}
{slice.turn_count < MIN_TURNS_FOR_CONFIDENCE && (
<span className="ml-2 text-xs font-normal text-muted-foreground">(low sample)</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{slice.turn_count.toLocaleString()}</TableCell>
<TableCell className="text-right font-medium tabular-nums text-foreground">
{pct(routerWinRate(direction, slice))}
</TableCell>
<TableCell className="text-right tabular-nums">{pct(otherArmWinRate(direction, slice))}</TableCell>
<TableCell className="text-right tabular-nums">{pct(slice.tie_rate_pct)}</TableCell>
<TableCell className="text-right tabular-nums">{slice.avg_judge_confidence.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">
{routerSliceSpend(direction, slice) > 0 ? usd(routerSliceSpend(direction, slice)) : "-"}
</TableCell>
<TableCell className="text-right tabular-nums">
{otherSliceSpend(direction, slice) > 0 ? usd(otherSliceSpend(direction, slice)) : "-"}
</TableCell>
}> = ({ groupHeader, direction, slices }) => {
const { t } = useTranslation();
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{groupHeader}</TableHead>
{[
t("costOptimization.shadowEval.slices.judgedTurns"),
t("costOptimization.shadowEval.slices.routerWins"),
t("costOptimization.shadowEval.slices.armWins", { arm: otherArmLabel(direction, t) }),
t("costOptimization.shadowEval.slices.ties"),
t("costOptimization.shadowEval.slices.judgeConfidence"),
t("costOptimization.shadowEval.slices.routerCost"),
t("costOptimization.shadowEval.slices.armCost", { arm: otherArmLabel(direction, t) }),
].map((label) => (
<TableHead key={label} className="text-right">
{label}
</TableHead>
))}
</TableRow>
))}
</TableBody>
</Table>
);
</TableHeader>
<TableBody>
{slices.map((slice) => (
<TableRow key={slice.group}>
<TableCell className="font-medium text-foreground">
{slice.group}
{slice.turn_count < MIN_TURNS_FOR_CONFIDENCE && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{t("costOptimization.shadowEval.slices.lowSample")}
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{slice.turn_count.toLocaleString()}</TableCell>
<TableCell className="text-right font-medium tabular-nums text-foreground">
{pct(routerWinRate(direction, slice))}
</TableCell>
<TableCell className="text-right tabular-nums">{pct(otherArmWinRate(direction, slice))}</TableCell>
<TableCell className="text-right tabular-nums">{pct(slice.tie_rate_pct)}</TableCell>
<TableCell className="text-right tabular-nums">{slice.avg_judge_confidence.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">
{routerSliceSpend(direction, slice) > 0 ? usd(routerSliceSpend(direction, slice)) : "-"}
</TableCell>
<TableCell className="text-right tabular-nums">
{otherSliceSpend(direction, slice) > 0 ? usd(otherSliceSpend(direction, slice)) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
const CostComparison: React.FC<{
direction: ShadowEvalDirection;
results: NonNullable<ShadowEvalJob["results"]>;
}> = ({ direction, results }) => {
const { t } = useTranslation();
const routerSpend = routerArmSpend(direction, results);
const otherSpend = otherArmSpend(direction, results);
if (routerSpend <= 0 || otherSpend <= 0) return null;
@ -196,25 +226,31 @@ const CostComparison: React.FC<{
return (
<div className="flex min-w-[240px] flex-1 flex-col gap-1 border-t px-6 py-4 sm:border-l sm:border-t-0">
<p className="flex items-center gap-1 text-[11px] uppercase tracking-wide text-muted-foreground">
Router cost vs {direction === "reverse" ? "the baseline" : "your current model"}
{t("costOptimization.shadowEval.cost.title", {
other:
direction === "reverse"
? t("costOptimization.shadowEval.comparisonTargets.baseline")
: t("costOptimization.shadowEval.comparisonTargets.currentModel"),
})}
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help" />} />
<TooltipContent>
Each arm is priced as its completion plus its own routing classifier call, measured on the same judged
turns; the judge&apos;s cost is excluded from both arms
</TooltipContent>
<TooltipContent>{t("costOptimization.shadowEval.cost.tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
<p
className={`text-3xl font-semibold ${savingsPct != null && savingsPct > 0 ? "text-success" : "text-foreground"}`}
>
{savingsPct != null ? `${savingsPct > 0 ? "-" : "+"}${Math.abs(savingsPct).toFixed(1)}%` : "n/a"}
{savingsPct != null
? `${savingsPct > 0 ? "-" : "+"}${Math.abs(savingsPct).toFixed(1)}%`
: t("costOptimization.shadowEval.cost.notAvailable")}
</p>
<p className="text-xs text-muted-foreground">
{usd(routerSpend)} vs {usd(otherSpend)} on the same judged turns
{cacheHits > 0 ? `; ${cacheHits.toLocaleString()} cache-served turns excluded` : ""}
{t("costOptimization.shadowEval.cost.comparison", { router: usd(routerSpend), other: usd(otherSpend) })}
{cacheHits > 0
? t("costOptimization.shadowEval.cost.cacheExcluded", { turns: cacheHits.toLocaleString() })
: ""}
</p>
</div>
);
@ -224,23 +260,28 @@ const VerdictBar: React.FC<{ direction: ShadowEvalDirection; results: NonNullabl
direction,
results,
}) => {
const { t } = useTranslation();
const ties = results.overall_tie_rate_pct;
const routerWins =
direction === "reverse"
? Math.max(0, 100 - results.overall_shadow_win_rate_pct - ties)
: results.overall_shadow_win_rate_pct;
const segments = [
{ label: "Router won", value: routerWins, fill: "bg-success" },
{ label: "Tie", value: ties, fill: "bg-success/20" },
{ label: t("costOptimization.shadowEval.verdict.routerWon"), value: routerWins, fill: "bg-success" },
{ label: t("costOptimization.shadowEval.verdict.tie"), value: ties, fill: "bg-success/20" },
{
label: `${otherArmLabel(direction)} won`,
label: t("costOptimization.shadowEval.verdict.armWon", { arm: otherArmLabel(direction, t) }),
value: Math.max(0, 100 - routerWins - ties),
fill: "bg-muted-foreground/30",
},
];
return (
<div className="space-y-2 border-b px-6 py-4">
<div className="flex h-2 w-full overflow-hidden rounded-full" role="img" aria-label="Verdict breakdown">
<div
className="flex h-2 w-full overflow-hidden rounded-full"
role="img"
aria-label={t("costOptimization.shadowEval.verdict.ariaLabel")}
>
{segments
.filter((segment) => segment.value > 0)
.map((segment) => (
@ -260,13 +301,18 @@ const VerdictBar: React.FC<{ direction: ShadowEvalDirection; results: NonNullabl
};
const TargetTable: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
const { t } = useTranslation();
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Target</TableHead>
<TableHead>Status</TableHead>
{["Budget used", "Router wins", `${otherArmLabel(job.direction)} wins`].map((label) => (
<TableHead>{t("costOptimization.shadowEval.targets.target")}</TableHead>
<TableHead>{t("costOptimization.shadowEval.targets.status")}</TableHead>
{[
t("costOptimization.shadowEval.targets.budgetUsed"),
t("costOptimization.shadowEval.slices.routerWins"),
t("costOptimization.shadowEval.slices.armWins", { arm: otherArmLabel(job.direction, t) }),
].map((label) => (
<TableHead key={label} className="text-right">
{label}
</TableHead>
@ -290,7 +336,10 @@ const TargetTable: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
<TableCell className="text-right tabular-nums">
{target.max_budget != null
? `${usd(target.spend ?? 0)} / ${usd(target.max_budget)}`
: `${(target.attempt_count ?? slice?.turn_count ?? 0).toLocaleString()} / ${target.max_turns.toLocaleString()} turns`}
: t("costOptimization.shadowEval.targets.turnsUsed", {
used: (target.attempt_count ?? slice?.turn_count ?? 0).toLocaleString(),
max: target.max_turns.toLocaleString(),
})}
</TableCell>
{slice ? (
<>
@ -303,7 +352,7 @@ const TargetTable: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
</>
) : (
<TableCell colSpan={2} className="text-right text-muted-foreground">
No verdicts yet
{t("costOptimization.shadowEval.targets.noVerdicts")}
</TableCell>
)}
</TableRow>
@ -314,14 +363,15 @@ const TargetTable: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
);
};
const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string => {
if (resultsError) return "Results could not be loaded. Retrying.";
if (isActive(job)) return "Collecting verdicts. Results appear as sampled requests are judged.";
if (job.judged_count === 0) return "No verdicts were recorded for this job.";
return "Loading results...";
const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean, t: TFunction): string => {
if (resultsError) return t("costOptimization.shadowEval.results.loadFailed");
if (isActive(job)) return t("costOptimization.shadowEval.results.collecting");
if (job.judged_count === 0) return t("costOptimization.shadowEval.results.noVerdicts");
return t("costOptimization.shadowEval.results.loading");
};
const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({ job, resultsError = false }) => {
const { t } = useTranslation();
const results = job.results;
const hasVerdicts = results != null && (results.by_tier.length > 0 || results.by_current_model.length > 0);
return (
@ -333,19 +383,26 @@ const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({
)}
{/* results == null re-stated for TS narrowing; hasVerdicts alone cannot narrow it */}
{!hasVerdicts || results == null ? (
<p className="px-6 py-8 text-center text-sm text-muted-foreground">{emptyResultsText(job, resultsError)}</p>
<p className="px-6 py-8 text-center text-sm text-muted-foreground">{emptyResultsText(job, resultsError, t)}</p>
) : (
<>
<div className="flex flex-wrap border-b">
<div className="flex min-w-[240px] flex-1 flex-col gap-1 px-6 py-4">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
Router matched or beat {job.direction === "reverse" ? "the baseline" : "your current model"}
{t("costOptimization.shadowEval.results.matchedOrBeat", {
other:
job.direction === "reverse"
? t("costOptimization.shadowEval.comparisonTargets.baseline")
: t("costOptimization.shadowEval.comparisonTargets.currentModel"),
})}
</p>
<p className="text-3xl font-semibold text-foreground">
{pct(routerMatchedOrBeatPct(job.direction, results))}
</p>
<p className="text-xs text-muted-foreground">
of {(job.judged_count ?? 0).toLocaleString()} judged responses
{t("costOptimization.shadowEval.results.ofJudged", {
judged: (job.judged_count ?? 0).toLocaleString(),
})}
</p>
</div>
<CostComparison direction={job.direction} results={results} />
@ -353,19 +410,31 @@ const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({
<VerdictBar direction={job.direction} results={results} />
{(results.by_router ?? []).length > 1 && (
<div className="border-b">
<SliceTable groupHeader="Router" direction={job.direction} slices={results.by_router ?? []} />
<SliceTable
groupHeader={t("costOptimization.shadowEval.groups.router")}
direction={job.direction}
slices={results.by_router ?? []}
/>
</div>
)}
{results.by_current_model.length > 0 && (
<SliceTable
groupHeader={job.direction === "reverse" ? "Router pick" : "Compared against"}
groupHeader={
job.direction === "reverse"
? t("costOptimization.shadowEval.groups.routerPick")
: t("costOptimization.shadowEval.groups.comparedAgainst")
}
direction={job.direction}
slices={results.by_current_model}
/>
)}
{results.by_tier.length > 0 && (
<div className={results.by_current_model.length > 0 ? "border-t" : ""}>
<SliceTable groupHeader="Prompt difficulty" direction={job.direction} slices={results.by_tier} />
<SliceTable
groupHeader={t("costOptimization.shadowEval.groups.promptDifficulty")}
direction={job.direction}
slices={results.by_tier}
/>
</div>
)}
</>
@ -381,32 +450,45 @@ const JobResults: React.FC<{
resultsError?: boolean;
readOnly?: boolean;
}> = ({ job, onStop, stopPending, resultsError = false, readOnly = false }) => {
const { t } = useTranslation();
const active = isActive(job);
const remaining = endsIn(job.ends_at);
const remaining = endsIn(job.ends_at, t);
return (
<Card className="overflow-hidden py-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
<div className="flex items-center gap-3">
<StatusBadge status={job.status} />
<div>
<p className="text-sm font-medium text-foreground">{jobHeadline(job)}</p>
<p className="text-sm font-medium text-foreground">{jobHeadline(job, t)}</p>
<p className="text-xs text-muted-foreground">
{(job.judged_count ?? 0).toLocaleString()} turns judged · {(job.error_count ?? 0).toLocaleString()}{" "}
errored · {usd(totalSpend(job))}
{totalBudget(job) !== null ? ` of ${usd(totalBudget(job) ?? 0)}` : ""} eval spend
{active && remaining ? ` · ${remaining}` : ""}
{t("costOptimization.shadowEval.job.judgedErrored", {
judged: (job.judged_count ?? 0).toLocaleString(),
errored: (job.error_count ?? 0).toLocaleString(),
})}
{t("costOptimization.shadowEval.job.evalSpend", {
spend: usd(totalSpend(job)),
budget:
totalBudget(job) !== null
? t("costOptimization.shadowEval.job.ofBudget", { budget: usd(totalBudget(job) ?? 0) })
: "",
remaining:
active && remaining ? t("costOptimization.shadowEval.job.remainingSuffix", { remaining }) : "",
})}
</p>
</div>
</div>
{active && !readOnly && (
<Button variant="outline" size="sm" onClick={onStop} disabled={stopPending}>
{stopPending ? "Stopping..." : "Stop"}
{stopPending ? t("costOptimization.shadowEval.job.stopping") : t("costOptimization.shadowEval.job.stop")}
</Button>
)}
</div>
{(job.error_count ?? 0) > 0 && job.last_error != null && (
<p className="border-b bg-destructive/10 px-6 py-2 text-xs text-destructive">
Last failure: <span className="font-mono">{job.last_error}</span>
<Trans
i18nKey="costOptimization.shadowEval.job.lastFailure"
components={{ error: <span className="font-mono">{job.last_error}</span> }}
/>
</p>
)}
<ResultsBody job={job} resultsError={resultsError} />
@ -414,13 +496,16 @@ const JobResults: React.FC<{
);
};
const previousSummary = (job: ShadowEvalJob): string => {
const previousSummary = (job: ShadowEvalJob, t: TFunction): string => {
const results = job.results;
if (results) return pct(routerMatchedOrBeatPct(job.direction, results));
return job.judged_count === 0 ? "no verdicts" : "view results";
return job.judged_count === 0
? t("costOptimization.shadowEval.previous.noVerdicts")
: t("costOptimization.shadowEval.previous.viewResults");
};
const PreviousJob: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const { data: detail, isError } = useShadowEvalJob(expanded ? job.job_id : null);
const shown = detail ?? job;
@ -435,15 +520,19 @@ const PreviousJob: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
<div className="flex items-center gap-3">
<StatusBadge status={shown.status} />
<div>
<p className="text-sm font-medium text-foreground">{jobHeadline(shown)}</p>
<p className="text-sm font-medium text-foreground">{jobHeadline(shown, t)}</p>
<p className="text-xs text-muted-foreground">
{shown.judged_count != null &&
`${shown.judged_count.toLocaleString()} judged · ${(shown.error_count ?? 0).toLocaleString()} errored · ${usd(totalSpend(shown))} eval spend · `}
t("costOptimization.shadowEval.previous.stats", {
judged: shown.judged_count.toLocaleString(),
errored: (shown.error_count ?? 0).toLocaleString(),
spend: usd(totalSpend(shown)),
})}
{new Date(shown.created_at).toLocaleDateString()}
</p>
</div>
</div>
<span className="text-sm font-medium text-foreground">{previousSummary(shown)}</span>
<span className="text-sm font-medium text-foreground">{previousSummary(shown, t)}</span>
</button>
{expanded && (
<div className="border-t">
@ -455,6 +544,7 @@ const PreviousJob: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
};
const PreviousJobs: React.FC<{ jobs: readonly ShadowEvalJob[] }> = ({ jobs }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (jobs.length === 0) return null;
return (
@ -465,8 +555,12 @@ const PreviousJobs: React.FC<{ jobs: readonly ShadowEvalJob[] }> = ({ jobs }) =>
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-center justify-between gap-3 px-6 py-3 text-left hover:bg-muted/50"
>
<span className="text-sm font-medium text-foreground">Previous evaluations ({jobs.length})</span>
<span className="text-xs text-muted-foreground">{open ? "Hide" : "Show"}</span>
<span className="text-sm font-medium text-foreground">
{t("costOptimization.shadowEval.previous.title", { total: jobs.length })}
</span>
<span className="text-xs text-muted-foreground">
{open ? t("costOptimization.shadowEval.previous.hide") : t("costOptimization.shadowEval.previous.show")}
</span>
</button>
{open && (
<div className="border-t">
@ -495,6 +589,7 @@ const JobCard: React.FC<{ job: ShadowEvalJob; readOnly: boolean }> = ({ job, rea
};
const ShadowEvalSection: React.FC = () => {
const { t } = useTranslation();
const { data: jobs, error, isPending } = useShadowEvalJobs();
const { isViewOnly } = useAuthorized();
const { showcased, listed } = useMemo(() => {
@ -509,19 +604,15 @@ const ShadowEvalSection: React.FC = () => {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-xl font-semibold text-foreground">Shadow eval</h2>
<p className="text-sm text-muted-foreground">
Blind-judge the auto-router on the real traffic of a key, team, or user (teams and users cover
JWT-authenticated traffic): against the models they use today before switching, or against a fixed baseline
after they have switched.
</p>
<h2 className="text-xl font-semibold text-foreground">{t("costOptimization.shadowEval.title")}</h2>
<p className="text-sm text-muted-foreground">{t("costOptimization.shadowEval.description")}</p>
</div>
{error != null && (
<p className="text-sm text-destructive">Existing evaluations could not be loaded. Refresh the page to retry.</p>
)}
{error != null && <p className="text-sm text-destructive">{t("costOptimization.shadowEval.loadFailed")}</p>}
{isPending && error == null && <p className="text-sm text-muted-foreground">Loading evaluations...</p>}
{isPending && error == null && (
<p className="text-sm text-muted-foreground">{t("costOptimization.shadowEval.loading")}</p>
)}
{showcased.map((job) => (
<JobCard key={job.job_id} job={job} readOnly={isViewOnly} />

View file

@ -1,6 +1,8 @@
"use client";
import React, { useMemo, useState } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
@ -36,11 +38,9 @@ const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; label: string }[
{ value: "reverse", label: "Regression check: router's picks vs a baseline" },
] as const;
const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, string> = {
forward:
"Duplicates a sampled slice of the selected targets' traffic (keys, teams, or users) through the auto-router and has an LLM judge compare both answers blind. Each target gets its own spend budget. The router's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
reverse:
"Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. Each target gets its own spend budget. The baseline's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, ParseKeys> = {
forward: "costOptimization.shadowEval.start.description.forward",
reverse: "costOptimization.shadowEval.start.description.reverse",
};
const DURATION_OPTIONS = [
@ -66,6 +66,7 @@ const Field: React.FC<{ label: string; htmlFor?: string; className?: string; chi
);
const KeySelect: React.FC<{ value: string[]; onChange: (tokens: string[]) => void }> = ({ value, onChange }) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteKeys(50, {
selectedKeyAlias: search || null,
@ -92,14 +93,15 @@ const KeySelect: React.FC<{ value: string[]; onChange: (tokens: string[]) => voi
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isPending}
placeholder="Search keys by alias"
emptyText="No matching keys"
errorText={isError ? "Keys could not be loaded. Refresh the page to retry." : undefined}
placeholder={t("costOptimization.shadowEval.start.keys.placeholder")}
emptyText={t("costOptimization.shadowEval.start.keys.empty")}
errorText={isError ? t("costOptimization.shadowEval.start.keys.loadFailed") : undefined}
/>
);
};
const UserSelect: React.FC<{ value: string[]; onChange: (ids: string[]) => void }> = ({ value, onChange }) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteUsers(
50,
@ -127,9 +129,9 @@ const UserSelect: React.FC<{ value: string[]; onChange: (ids: string[]) => void
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isPending}
placeholder="Search users by email"
emptyText="No matching users"
errorText={isError ? "Users could not be loaded. Refresh the page to retry." : undefined}
placeholder={t("costOptimization.shadowEval.start.users.placeholder")}
emptyText={t("costOptimization.shadowEval.start.users.empty")}
errorText={isError ? t("costOptimization.shadowEval.start.users.loadFailed") : undefined}
/>
);
};
@ -139,28 +141,31 @@ const RouterField: React.FC<{
routerNames: string[];
onChange: (names: string[]) => void;
direction: ShadowEvalDirection;
}> = ({ options, routerNames, onChange, direction }) => (
<Field label="Auto-routers">
<MultiSelect
options={options}
value={routerNames}
onValueChange={onChange}
placeholder="Select up to 4 auto-routers"
emptyText="No auto-routers configured"
/>
{routerNames.length > MAX_ROUTERS && (
<p className="text-xs text-destructive">Pick at most {MAX_ROUTERS} auto-routers</p>
)}
{direction === "reverse" && routerNames.length > 1 && (
<p className="text-xs text-destructive">A regression check compares one router to its baseline</p>
)}
{direction === "forward" && routerNames.length > 1 && (
<p className="text-xs text-muted-foreground">
Every router sees the same sampled requests, judged against the same live responses
</p>
)}
</Field>
);
}> = ({ options, routerNames, onChange, direction }) => {
const { t } = useTranslation();
return (
<Field label={t("costOptimization.shadowEval.start.routers.label")}>
<MultiSelect
options={options}
value={routerNames}
onValueChange={onChange}
placeholder={t("costOptimization.shadowEval.start.routers.placeholder")}
emptyText={t("costOptimization.shadowEval.start.routers.empty")}
/>
{routerNames.length > MAX_ROUTERS && (
<p className="text-xs text-destructive">
{t("costOptimization.shadowEval.start.routers.tooMany", { max: MAX_ROUTERS })}
</p>
)}
{direction === "reverse" && routerNames.length > 1 && (
<p className="text-xs text-destructive">{t("costOptimization.shadowEval.start.routers.reverseSingle")}</p>
)}
{direction === "forward" && routerNames.length > 1 && (
<p className="text-xs text-muted-foreground">{t("costOptimization.shadowEval.start.routers.forwardShared")}</p>
)}
</Field>
);
};
interface StartFormValidityInputs {
accessToken: string | null | undefined;
@ -223,6 +228,7 @@ const buildStartBody = (inputs: StartBodyInputs) => ({
});
export const StartForm: React.FC = () => {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const [apiKeyIds, setApiKeyIds] = useState<string[]>([]);
const [teamIds, setTeamIds] = useState<string[]>([]);
@ -258,9 +264,11 @@ export const StartForm: React.FC = () => {
const judgeOptions = useMemo(
() =>
chatOptions.map((option) =>
recommendedJudgeModels.has(option.value) ? { ...option, sublabel: "Recommended" } : option,
recommendedJudgeModels.has(option.value)
? { ...option, sublabel: t("costOptimization.shadowEval.start.judge.recommended") }
: option,
),
[chatOptions, recommendedJudgeModels],
[chatOptions, recommendedJudgeModels, t],
);
const start = useStartShadowEval();
@ -306,12 +314,14 @@ export const StartForm: React.FC = () => {
return (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm font-medium text-foreground">Start a shadow eval</CardTitle>
<p className="text-xs text-muted-foreground">{START_FORM_DESCRIPTION[direction]}</p>
<CardTitle className="text-sm font-medium text-foreground">
{t("costOptimization.shadowEval.start.title")}
</CardTitle>
<p className="text-xs text-muted-foreground">{t(START_FORM_DESCRIPTION[direction])}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Direction">
<Field label={t("costOptimization.shadowEval.start.direction")}>
<Select
value={direction}
onValueChange={(v: string | null) => setDirection(v === "reverse" ? "reverse" : "forward")}
@ -328,28 +338,34 @@ export const StartForm: React.FC = () => {
</SelectContent>
</Select>
</Field>
<Field label="Keys to shadow" htmlFor="shadow-eval-key">
<Field label={t("costOptimization.shadowEval.start.keys.label")} htmlFor="shadow-eval-key">
<KeySelect value={apiKeyIds} onChange={setApiKeyIds} />
</Field>
<Field label="Teams to shadow">
<TeamMultiSelect value={teamIds} onChange={setTeamIds} placeholder="Search teams by alias" />
<Field label={t("costOptimization.shadowEval.start.teams.label")}>
<TeamMultiSelect
value={teamIds}
onChange={setTeamIds}
placeholder={t("costOptimization.shadowEval.start.teams.placeholder")}
/>
</Field>
<Field label="Users to shadow" htmlFor="shadow-eval-user">
<Field label={t("costOptimization.shadowEval.start.users.label")} htmlFor="shadow-eval-user">
<UserSelect value={userIds} onChange={setUserIds} />
</Field>
{direction === "forward" && (
<Field label="Only on models">
<Field label={t("costOptimization.shadowEval.start.models.label")}>
<MultiSelect
options={modelOptions}
value={models}
onValueChange={setModels}
placeholder="Every model the targets use"
emptyText="No models configured"
placeholder={t("costOptimization.shadowEval.start.models.placeholder")}
emptyText={t("costOptimization.shadowEval.start.models.empty")}
/>
{models.length > MAX_MODELS ? (
<p className="text-xs text-destructive">Pick at most {MAX_MODELS} models</p>
<p className="text-xs text-destructive">
{t("costOptimization.shadowEval.start.models.tooMany", { max: MAX_MODELS })}
</p>
) : (
<p className="text-xs text-muted-foreground">Narrows every target above to requests for these models</p>
<p className="text-xs text-muted-foreground">{t("costOptimization.shadowEval.start.models.hint")}</p>
)}
</Field>
)}
@ -359,7 +375,7 @@ export const StartForm: React.FC = () => {
onChange={setRouterNames}
direction={direction}
/>
<Field label="Traffic sampled" htmlFor="shadow-eval-pct">
<Field label={t("costOptimization.shadowEval.start.percentage.label")} htmlFor="shadow-eval-pct">
<div className="flex items-center gap-2">
<Input
id="shadow-eval-pct"
@ -371,15 +387,17 @@ export const StartForm: React.FC = () => {
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
/>
<span className="text-sm text-muted-foreground">% of traffic</span>
<span className="text-sm text-muted-foreground">
{t("costOptimization.shadowEval.start.percentage.suffix")}
</span>
</div>
<div>
{percentage.trim() !== "" && !percentageValid && (
<p className="text-xs text-destructive">Enter a value from 0.1 to 100</p>
<p className="text-xs text-destructive">{t("costOptimization.shadowEval.start.percentage.invalid")}</p>
)}
</div>
</Field>
<Field label="Duration">
<Field label={t("costOptimization.shadowEval.start.duration")}>
<Select value={durationDays} onValueChange={(v: string | null) => setDurationDays(v ?? "7")}>
<SelectTrigger className="w-full">
<SelectValue>{DURATION_OPTIONS.find((o) => o.value === durationDays)?.label}</SelectValue>
@ -393,7 +411,7 @@ export const StartForm: React.FC = () => {
</SelectContent>
</Select>
</Field>
<Field label="Spend budget">
<Field label={t("costOptimization.shadowEval.start.budget.label")}>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">$</span>
<Input
@ -405,35 +423,39 @@ export const StartForm: React.FC = () => {
value={maxBudget}
onChange={(e) => setMaxBudget(e.target.value)}
/>
<span className="text-sm text-muted-foreground">max shadow + judge spend, per target</span>
<span className="text-sm text-muted-foreground">
{t("costOptimization.shadowEval.start.budget.suffix")}
</span>
</div>
{maxBudget.trim() !== "" && !maxBudgetValid && (
<p className="text-xs text-destructive">Enter a value from 0.01 to 10000</p>
<p className="text-xs text-destructive">{t("costOptimization.shadowEval.start.budget.invalid")}</p>
)}
</Field>
{direction === "reverse" && (
<Field label="Baseline model">
<Field label={t("costOptimization.shadowEval.start.baseline.label")}>
<SearchSelect
options={chatOptions}
value={baselineModel}
onValueChange={setBaselineModel}
placeholder="Select a baseline model"
emptyText="No chat models available"
placeholder={t("costOptimization.shadowEval.start.baseline.placeholder")}
emptyText={t("costOptimization.shadowEval.start.noChatModels")}
/>
</Field>
)}
<Field label="Judge model" className="sm:col-span-2">
<Field label={t("costOptimization.shadowEval.start.judge.label")} className="sm:col-span-2">
<SearchSelect
options={judgeOptions}
value={judgeModel}
onValueChange={setJudgeModel}
placeholder="Select a judge model"
emptyText="No chat models available"
placeholder={t("costOptimization.shadowEval.start.judge.placeholder")}
emptyText={t("costOptimization.shadowEval.start.noChatModels")}
/>
</Field>
</div>
<Button disabled={!valid || start.isPending} onClick={handleStart}>
{start.isPending ? "Starting..." : "Start shadow eval"}
{start.isPending
? t("costOptimization.shadowEval.start.starting")
: t("costOptimization.shadowEval.start.submit")}
</Button>
</CardContent>
</Card>

View file

@ -1,6 +1,7 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { hydrateTierLabels } from "@/components/add_model/build_complexity_router_config";
@ -84,6 +85,7 @@ interface TierTurnsChartProps {
}
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) => {
const { t } = useTranslation();
const group = viewGroup(view);
const entries = Object.entries(group?.tier_turns ?? {}).filter(([, turns]) => turns > 0);
if (!group || entries.length === 0) return null;
@ -100,11 +102,8 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
return (
<Card>
<CardHeader>
<CardTitle>Routing by tier</CardTitle>
<p className="text-sm text-muted-foreground">
Turns each tier served. Turns the classifier sent to the default model belong to no tier and are not counted
here, so this can total less than the router&apos;s turns.
</p>
<CardTitle>{t("costOptimization.tierTurns.title")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("costOptimization.tierTurns.description")}</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 items-center gap-6 lg:grid-cols-2">
@ -116,7 +115,7 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
colors={colors}
valueFormatter={(value) => value.toLocaleString()}
showLabel
label={`${total.toLocaleString()} total turns`}
label={t("costOptimization.tierTurns.totalTurns", { total: total.toLocaleString() })}
/>
<ul className="flex flex-col gap-6">
{slices.map((slice, idx) => (

View file

@ -1,6 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
@ -44,6 +45,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const { t } = useTranslation();
const { dateValue, onDateChange, results, loading } = activity;
const startTime = dateValue.from ?? null;
@ -84,11 +86,13 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
return withStartAnchor(toCumulative(perInterval), startLabel);
}, [accumulation, perInterval, startTime]);
const intervalLabel = "Per day";
const intervalLabel = t("costOptimization.usage.perDay");
const rangeLabel = formatRangeLabel(startTime ?? undefined, endTime ?? undefined);
const savingsSubtitle = [
accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`,
rangeLabel && `${rangeLabel} (UTC)`,
accumulation === "cumulative"
? t("costOptimization.usage.savings.runningTotal")
: t("costOptimization.usage.savings.savedPerInterval", { interval: intervalLabel.toLowerCase() }),
rangeLabel && t("costOptimization.usage.savings.rangeUtc", { range: rangeLabel }),
]
.filter(Boolean)
.join(" \u00b7 ");
@ -126,7 +130,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
return (
<div className="w-full space-y-6">
<div className="flex flex-wrap items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">Spend is bucketed by UTC day</span>
<span className="text-sm text-muted-foreground">{t("costOptimization.usage.bucketedByUtcDay")}</span>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
@ -139,13 +143,13 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
never competes with the controls for width and neither moves when it grows.
The controls wrap within their column instead of pushing past the card */}
<CardHeader>
<CardTitle>Savings</CardTitle>
<CardTitle>{t("costOptimization.usage.savings.title")}</CardTitle>
<CardDescription>{savingsSubtitle}</CardDescription>
<CardAction className="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="cumulative">{t("costOptimization.usage.savings.cumulative")}</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
@ -179,7 +183,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
</Card>
<Card>
<CardHeader>
<CardTitle>Savings by driver</CardTitle>
<CardTitle>{t("costOptimization.usage.savingsByDriver")}</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
@ -199,22 +203,22 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
{canViewProxyWideCostData && (
<Card>
<CardHeader>
<CardTitle>Spend by tool</CardTitle>
<p className="text-sm text-muted-foreground">
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
does not count. A request that invoked multiple tools counts its full spend toward each, so this
attributes rather than partitions spend.
</p>
<CardTitle>{t("costOptimization.usage.toolSpend.title")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("costOptimization.usage.toolSpend.description")}</p>
</CardHeader>
<CardContent>
{topTools.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
{toolSpendLoading
? t("costOptimization.usage.toolSpend.loading")
: t("costOptimization.usage.toolSpend.empty")}
</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
<p className="mb-2 text-sm font-medium text-muted-foreground">
{t("costOptimization.usage.toolSpend.totalByTool")}
</p>
<BarChart
data={topToolsChart}
index="tool_name"
@ -229,7 +233,9 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
<p className="mb-2 text-sm font-medium text-muted-foreground">
{t("costOptimization.usage.toolSpend.dailyByTool")}
</p>
<CustomLegend categories={topToolNames} colors={toolColors} />
<BarChart
data={dailyToolSeries}

View file

@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import type { components } from "@/lib/http/schema";
export type AutoRouterBenchmarksResponse = components["schemas"]["AutoRouterBenchmarksResponse"];
@ -22,10 +24,14 @@ export const groupLabel = (group: AutoRouterBenchmarkGroup, groups: readonly Aut
return duplicated ? `${group.router_name} (${group.router_type})` : group.router_name;
};
export const viewFor = (data: AutoRouterBenchmarksResponse, selectedKey: string): BenchmarkView => {
export const viewFor = (
data: AutoRouterBenchmarksResponse,
selectedKey: string,
t: TFunction = i18n.t.bind(i18n),
): BenchmarkView => {
const group = data.groups.find((g) => groupKey(g) === selectedKey);
if (selectedKey === ALL_ROUTERS || !group) {
return { label: "All auto-routers", stats: data.totals };
return { label: t("costOptimization.autoRouter.allRouters"), stats: data.totals };
}
return { label: groupLabel(group, data.groups), stats: group };
};
@ -45,13 +51,13 @@ export const bucketTurnsTotal = (cache: AutoRouterCacheStats): number =>
const sharePctOf = (turns: number, total: number): number => (total > 0 ? Math.round((100 * turns) / total) : 0);
export const bucketRows = (cache: AutoRouterCacheStats): BucketRow[] => {
export const bucketRows = (cache: AutoRouterCacheStats, t: TFunction = i18n.t.bind(i18n)): BucketRow[] => {
const total = bucketTurnsTotal(cache);
return [
{
key: "same_model",
label: "Same model",
sublabel: "previous turn → same tier",
label: t("costOptimization.autoRouter.buckets.sameModel.label"),
sublabel: t("costOptimization.autoRouter.buckets.sameModel.sublabel"),
turns: cache.same_model.turns,
sharePct: sharePctOf(cache.same_model.turns, total),
hitRatePct: cache.same_model.hit_rate_pct,
@ -59,8 +65,8 @@ export const bucketRows = (cache: AutoRouterCacheStats): BucketRow[] => {
},
{
key: "first_visit",
label: "First visit",
sublabel: "previous turn → a tier not used yet",
label: t("costOptimization.autoRouter.buckets.firstVisit.label"),
sublabel: t("costOptimization.autoRouter.buckets.firstVisit.sublabel"),
turns: cache.first_visit.turns,
sharePct: sharePctOf(cache.first_visit.turns, total),
hitRatePct: cache.first_visit.hit_rate_pct,
@ -68,8 +74,8 @@ export const bucketRows = (cache: AutoRouterCacheStats): BucketRow[] => {
},
{
key: "return_to_tier",
label: "Return to tier",
sublabel: "previous turn → a tier used earlier",
label: t("costOptimization.autoRouter.buckets.returnToTier.label"),
sublabel: t("costOptimization.autoRouter.buckets.returnToTier.sublabel"),
turns: cache.return_to_tier.turns,
sharePct: sharePctOf(cache.return_to_tier.turns, total),
hitRatePct: cache.return_to_tier.hit_rate_pct,

View file

@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking";
@ -11,11 +13,15 @@ export const usd = (value: number): string => {
return `${value < 0 ? "-" : ""}$${formatNumberWithCommas(magnitude, decimals)}`;
};
export const classificationRatePer1kTurns = (classifierCost: number, turns: number): string => {
if (turns <= 0) return `(${usd(0)} / 1K turns)`;
export const classificationRatePer1kTurns = (
classifierCost: number,
turns: number,
t: TFunction = i18n.t.bind(i18n),
): string => {
if (turns <= 0) return t("costOptimization.classificationRate.perThousandTurns", { rate: usd(0) });
const rate = (classifierCost * 1000) / turns;
if (rate > 0 && rate < 0.0001) return "(<$0.0001 / 1K turns)";
return `(${usd(rate)} / 1K turns)`;
if (rate > 0 && rate < 0.0001) return t("costOptimization.classificationRate.belowMinimum");
return t("costOptimization.classificationRate.perThousandTurns", { rate: usd(rate) });
};
export const pct = (ratio: number): string => `${formatNumberWithCommas(ratio * 100, 1)}%`;

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CircleHelp } from "lucide-react";
import { Providers, provider_map } from "@/components/provider_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
@ -75,6 +76,7 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
onFixedAmountChange,
onAddProvider,
}) => {
const { t } = useTranslation();
const providerOptions = buildProviderOptions(marginConfig);
const selectedOption = providerOptions.find((option) => option.value === selectedProvider) ?? null;
@ -84,8 +86,8 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
<Field>
<FieldLabel htmlFor="margin-provider">
{labelWithHint(
"Provider",
"Select 'Global' to apply margin to all providers, or select a specific provider",
t("costTracking.addMarginForm.provider.label"),
t("costTracking.addMarginForm.provider.hint"),
)}
</FieldLabel>
<Combobox
@ -95,9 +97,13 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
itemToStringLabel={(option: ProviderOption) => option.label}
isItemEqualToValue={(option: ProviderOption, selected: ProviderOption) => option.value === selected.value}
>
<ComboboxInput id="margin-provider" placeholder="Select provider or 'Global'" className="w-full" />
<ComboboxInput
id="margin-provider"
placeholder={t("costTracking.addMarginForm.provider.placeholder")}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>No matching providers</ComboboxEmpty>
<ComboboxEmpty>{t("costTracking.addMarginForm.provider.empty")}</ComboboxEmpty>
<ComboboxList>
{(option: ProviderOption) => (
<ComboboxItem key={option.value} value={option}>
@ -116,7 +122,10 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
<Field>
<FieldTitle>
{labelWithHint("Margin Type", "Choose how to apply the margin: percentage-based or fixed amount")}
{labelWithHint(
t("costTracking.addMarginForm.marginType.label"),
t("costTracking.addMarginForm.marginType.hint"),
)}
</FieldTitle>
<RadioGroup
value={marginType}
@ -125,11 +134,11 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
>
<FieldLabel className="font-normal">
<RadioGroupItem value="percentage" />
Percentage-based
{t("costTracking.addMarginForm.marginType.percentage")}
</FieldLabel>
<FieldLabel className="font-normal">
<RadioGroupItem value="fixed" />
Fixed Amount
{t("costTracking.addMarginForm.marginType.fixed")}
</FieldLabel>
</RadioGroup>
</Field>
@ -137,7 +146,10 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
{marginType === "percentage" && (
<Field>
<FieldLabel htmlFor="margin-percentage">
{labelWithHint("Margin Percentage", "Enter a percentage value (e.g., 10 for 10% margin)")}
{labelWithHint(
t("costTracking.addMarginForm.percentage.label"),
t("costTracking.addMarginForm.percentage.hint"),
)}
</FieldLabel>
<div className="flex items-center gap-2">
<Input
@ -155,7 +167,7 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
{marginType === "fixed" && (
<Field>
<FieldLabel htmlFor="margin-fixed-amount">
{labelWithHint("Fixed Margin Amount", "Enter a fixed amount in USD (e.g., 0.001 for $0.001 per request)")}
{labelWithHint(t("costTracking.addMarginForm.fixed.label"), t("costTracking.addMarginForm.fixed.hint"))}
</FieldLabel>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">$</span>
@ -180,7 +192,7 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
(marginType === "fixed" && !fixedAmountValue)
}
>
Add Provider Margin
{t("costTracking.addMarginForm.submit")}
</Button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CircleHelp } from "lucide-react";
import { Logo } from "@/components/molecules/logo/Logo";
@ -65,6 +66,7 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
onModelPatternChange,
onAddProvider,
}) => {
const { t } = useTranslation();
const options = providerOptions();
const selectedOption = selectedProviderOption(selectedProvider);
@ -74,7 +76,10 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
<FieldGroup>
<Field>
<FieldLabel htmlFor={PROVIDER_FIELD_ID}>
{labelWithHint("Provider", "Select the LLM provider you want to configure a discount for")}
{labelWithHint(
t("costTracking.addProviderForm.provider.label"),
t("costTracking.addProviderForm.provider.hint"),
)}
</FieldLabel>
<Combobox
items={options}
@ -83,7 +88,11 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
itemToStringLabel={(option: ProviderOption) => option.label}
isItemEqualToValue={(option: ProviderOption, value: ProviderOption) => option.value === value.value}
>
<ComboboxInput id={PROVIDER_FIELD_ID} placeholder="Select provider" className="w-full">
<ComboboxInput
id={PROVIDER_FIELD_ID}
placeholder={t("costTracking.addProviderForm.provider.placeholder")}
className="w-full"
>
{selectedOption && (
<InputGroupAddon align="inline-start">
<Logo provider={selectedOption.value} label={selectedOption.label} className="w-5 h-5" />
@ -91,7 +100,7 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
)}
</ComboboxInput>
<ComboboxContent>
<ComboboxEmpty>No providers found</ComboboxEmpty>
<ComboboxEmpty>{t("costTracking.addProviderForm.provider.empty")}</ComboboxEmpty>
<ComboboxList>
{(option: ProviderOption) => (
<ComboboxItem key={option.value} value={option}>
@ -109,8 +118,8 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
<Field>
<FieldLabel htmlFor={MODEL_PATTERN_FIELD_ID}>
{labelWithHint(
"Model pattern",
"Leave empty to discount every model from this provider. Use a glob such as claude-* or gemini-2* to discount only matching models",
t("costTracking.addProviderForm.modelPattern.label"),
t("costTracking.addProviderForm.modelPattern.hint"),
)}
</FieldLabel>
<Input
@ -124,7 +133,10 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
<Field>
<FieldLabel htmlFor={DISCOUNT_FIELD_ID}>
{labelWithHint("Discount Percentage", "Enter a percentage value (e.g., 5 for 5% discount)")}
{labelWithHint(
t("costTracking.addProviderForm.discount.label"),
t("costTracking.addProviderForm.discount.hint"),
)}
</FieldLabel>
<div className="flex items-center gap-2">
<Input
@ -141,7 +153,7 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
<Button type="submit" onClick={onAddProvider} disabled={!selectedProvider || !newDiscount}>
Add Provider Discount
{t("costTracking.addProviderForm.submit")}
</Button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import {
AlertDialog,
@ -33,8 +34,11 @@ const DOCS_LINKS = [
];
const REMOVAL_COPY = {
discount: { title: "Remove Provider Discount", noun: "discount" },
margin: { title: "Remove Provider Margin", noun: "margin" },
discount: {
title: "costTracking.settings.removal.discount.title",
noun: "costTracking.settings.removal.discount.noun",
},
margin: { title: "costTracking.settings.removal.margin.title", noun: "costTracking.settings.removal.margin.noun" },
} as const;
interface PendingRemoval {
@ -56,6 +60,7 @@ const SectionHeader: React.FC<{ title: string; description: string }> = ({ title
);
const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, userRole, accessToken }) => {
const { t } = useTranslation();
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
const [newDiscount, setNewDiscount] = useState<string>("");
const [modelPattern, setModelPattern] = useState<string>("");
@ -189,12 +194,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<p className="text-xl font-medium text-foreground">Cost Tracking Settings</p>
<p className="text-xl font-medium text-foreground">{t("costTracking.settings.title")}</p>
<DocsMenu items={DOCS_LINKS} />
</div>
<p className="text-muted-foreground mt-1">
Configure cost discounts and margins for different LLM providers. Changes are saved automatically.
</p>
<p className="text-muted-foreground mt-1">{t("costTracking.settings.description")}</p>
</div>
</div>
@ -204,27 +207,29 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
{isProxyAdmin && (
<Collapsible className="rounded-lg border">
<SectionHeader
title="Provider Discounts"
description="Apply percentage-based discounts to reduce costs for specific providers"
title={t("costTracking.settings.discounts.title")}
description={t("costTracking.settings.discounts.description")}
/>
<CollapsibleContent className="px-0">
<Tabs defaultValue="discounts">
<TabsList variant="line" className="mx-6 mt-4 h-auto justify-start rounded-none border-b p-0">
<TabsTrigger value="discounts" className="flex-none rounded-none px-4 py-2">
Discounts
{t("costTracking.settings.discounts.tabs.discounts")}
</TabsTrigger>
<TabsTrigger value="test-it" className="flex-none rounded-none px-4 py-2">
Test It
{t("costTracking.settings.discounts.tabs.testIt")}
</TabsTrigger>
</TabsList>
<TabsContent value="discounts" keepMounted>
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
<Button onClick={() => setIsModalVisible(true)}>
{t("costTracking.settings.discounts.addButton")}
</Button>
</div>
{isFetching ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">Loading configuration...</p>
<p className="text-muted-foreground">{t("costTracking.settings.loading")}</p>
</div>
) : Object.keys(discountConfig).length > 0 ? (
<ProviderDiscountTable
@ -247,9 +252,11 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-foreground font-medium mb-2">No provider discounts configured</p>
<p className="text-foreground font-medium mb-2">
{t("costTracking.settings.discounts.emptyTitle")}
</p>
<p className="text-muted-foreground text-sm">
Click &quot;Add Provider Discount&quot; to get started
{t("costTracking.settings.discounts.emptyHint")}
</p>
</div>
)}
@ -269,17 +276,19 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
{isProxyAdmin && (
<Collapsible className="rounded-lg border">
<SectionHeader
title="Fee/Price Margin"
description="Add fees or margins to LLM costs for internal billing and cost recovery"
title={t("costTracking.settings.margins.title")}
description={t("costTracking.settings.margins.description")}
/>
<CollapsibleContent className="px-0">
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMarginModalVisible(true)}>+ Add Provider Margin</Button>
<Button onClick={() => setIsMarginModalVisible(true)}>
{t("costTracking.settings.margins.addButton")}
</Button>
</div>
{isFetching ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">Loading configuration...</p>
<p className="text-muted-foreground">{t("costTracking.settings.loading")}</p>
</div>
) : Object.keys(marginConfig).length > 0 ? (
<ProviderMarginTable
@ -302,10 +311,8 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-foreground font-medium mb-2">No provider margins configured</p>
<p className="text-muted-foreground text-sm">
Click &quot;Add Provider Margin&quot; to get started
</p>
<p className="text-foreground font-medium mb-2">{t("costTracking.settings.margins.emptyTitle")}</p>
<p className="text-muted-foreground text-sm">{t("costTracking.settings.margins.emptyHint")}</p>
</div>
)}
</div>
@ -317,17 +324,18 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
{isProxyAdmin && (
<Collapsible className="rounded-lg border">
<SectionHeader
title="Block Unpriced Models"
description="Reject requests for models that have no pricing in the cost map instead of logging them as $0 spend"
title={t("costTracking.settings.blockUnpriced.title")}
description={t("costTracking.settings.blockUnpriced.description")}
/>
<CollapsibleContent className="px-0">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="pr-6">
<p className="text-foreground font-medium">Block requests for models without pricing</p>
<p className="text-foreground font-medium">
{t("costTracking.settings.blockUnpriced.toggleTitle")}
</p>
<p className="text-sm text-muted-foreground mt-1">
When enabled, a request whose resolved model has no cost mapping is rejected with a 403 so an
admin can add pricing for it. Off by default
{t("costTracking.settings.blockUnpriced.toggleDescription")}
</p>
</div>
<Switch
@ -344,8 +352,8 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
{/* Accordion 4: Pricing Calculator - Available to all roles */}
<Collapsible defaultOpen={true} className="rounded-lg border">
<SectionHeader
title="Pricing Calculator"
description="Estimate LLM costs based on expected token usage and request volume"
title={t("costTracking.settings.pricingCalculator.title")}
description={t("costTracking.settings.pricingCalculator.description")}
/>
<CollapsibleContent className="px-0">
<div className="p-6">
@ -359,16 +367,18 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<AlertDialog open onOpenChange={(open) => !open && !isRemoving && setPendingRemoval(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{REMOVAL_COPY[pendingRemoval.kind].title}</AlertDialogTitle>
<AlertDialogTitle>{t(REMOVAL_COPY[pendingRemoval.kind].title)}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "}
{pendingRemoval.displayName}?
{t("costTracking.settings.removal.confirm", {
noun: t(REMOVAL_COPY[pendingRemoval.kind].noun),
name: pendingRemoval.displayName,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isRemoving}>{t("costTracking.settings.removal.cancel")}</AlertDialogCancel>
<Button variant="destructive" onClick={handleConfirmRemoval} disabled={isRemoving}>
{isRemoving ? "Removing…" : "Remove"}
{isRemoving ? t("costTracking.settings.removal.removing") : t("costTracking.settings.removal.remove")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
@ -379,13 +389,14 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[1000px]">
<DialogHeader>
<div className="flex items-center space-x-3 pb-4 border-b border-border">
<DialogTitle className="text-xl font-semibold text-foreground">Add Provider Discount</DialogTitle>
<DialogTitle className="text-xl font-semibold text-foreground">
{t("costTracking.settings.discounts.dialogTitle")}
</DialogTitle>
</div>
</DialogHeader>
<div className="mt-6">
<p className="text-sm text-muted-foreground mb-6">
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5%
discount).
{t("costTracking.settings.discounts.dialogDescription")}
</p>
<form onSubmit={(event) => event.preventDefault()} className="space-y-6">
<AddProviderForm
@ -406,14 +417,13 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[1000px]">
<DialogHeader>
<div className="flex items-center space-x-3 pb-4 border-b border-border">
<DialogTitle className="text-xl font-semibold text-foreground">Add Provider Margin</DialogTitle>
<DialogTitle className="text-xl font-semibold text-foreground">
{t("costTracking.settings.margins.dialogTitle")}
</DialogTitle>
</div>
</DialogHeader>
<div className="mt-6">
<p className="text-sm text-muted-foreground mb-6">
Select a provider (or &quot;Global&quot; for all providers) and configure the margin. You can use
percentage-based or fixed amount.
</p>
<p className="text-sm text-muted-foreground mb-6">{t("costTracking.settings.margins.dialogDescription")}</p>
<form onSubmit={(event) => event.preventDefault()} className="space-y-6">
<AddMarginForm
marginConfig={marginConfig}

View file

@ -1,9 +1,11 @@
import React, { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import CodeBlock from "@/components/CodeBlock";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const HowItWorks: React.FC = () => {
const { t } = useTranslation();
const [responseCost, setResponseCost] = useState("");
const [discountAmount, setDiscountAmount] = useState("");
@ -31,30 +33,28 @@ const HowItWorks: React.FC = () => {
return (
<div className="space-y-4 pt-2">
<div>
<h3 className="mb-1 text-sm font-medium text-foreground">Cost Calculation</h3>
<h3 className="mb-1 text-sm font-medium text-foreground">
{t("costTracking.howItWorks.costCalculation.title")}
</h3>
<p className="text-xs text-muted-foreground">
Discounts are applied to provider costs:{" "}
{t("costTracking.howItWorks.costCalculation.body")}{" "}
<code className="rounded-sm bg-muted px-1.5 py-0.5 text-xs text-foreground">
final_cost = base_cost × (1 - discount%/100)
</code>
</p>
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-foreground">Example</h3>
<p className="text-xs text-muted-foreground">
A 5% discount on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50
</p>
<h3 className="mb-1 text-sm font-medium text-foreground">{t("costTracking.howItWorks.example.title")}</h3>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.example.body")}</p>
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-foreground">Valid Range</h3>
<p className="text-xs text-muted-foreground">Discount percentages must be between 0% and 100%</p>
<h3 className="mb-1 text-sm font-medium text-foreground">{t("costTracking.howItWorks.validRange.title")}</h3>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.validRange.body")}</p>
</div>
<div className="border-t border-border pt-4">
<h3 className="mb-2 text-sm font-medium text-foreground">Validating Discounts</h3>
<p className="mb-3 text-xs text-muted-foreground">
Make a test request and check the response headers to verify discounts are applied:
</p>
<h3 className="mb-2 text-sm font-medium text-foreground">{t("costTracking.howItWorks.validating.title")}</h3>
<p className="mb-3 text-xs text-muted-foreground">{t("costTracking.howItWorks.validating.body")}</p>
<CodeBlock
language="bash"
code={`curl -X POST -i http://your-proxy:4000/chat/completions \\
@ -65,38 +65,36 @@ const HowItWorks: React.FC = () => {
"messages": [{"role": "user", "content": "Hello"}]
}'`}
/>
<p className="mb-2 mt-3 text-xs text-muted-foreground">Look for these headers in the response:</p>
<p className="mb-2 mt-3 text-xs text-muted-foreground">{t("costTracking.howItWorks.validating.lookFor")}</p>
<div className="space-y-1.5">
<div className="flex items-start gap-3">
<code className="whitespace-nowrap rounded-sm bg-muted px-2 py-1 font-mono text-xs text-foreground">
x-litellm-response-cost
</code>
<p className="text-xs text-muted-foreground">Final cost after discount</p>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.headers.responseCost")}</p>
</div>
<div className="flex items-start gap-3">
<code className="whitespace-nowrap rounded-sm bg-muted px-2 py-1 font-mono text-xs text-foreground">
x-litellm-response-cost-original
</code>
<p className="text-xs text-muted-foreground">Original cost before discount</p>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.headers.originalCost")}</p>
</div>
<div className="flex items-start gap-3">
<code className="whitespace-nowrap rounded-sm bg-muted px-2 py-1 font-mono text-xs text-foreground">
x-litellm-response-cost-discount-amount
</code>
<p className="text-xs text-muted-foreground">Amount discounted</p>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.headers.discountAmount")}</p>
</div>
</div>
</div>
<div className="border-t border-border pt-4">
<h3 className="mb-3 text-sm font-medium text-foreground">Discount Calculator</h3>
<p className="mb-3 text-xs text-muted-foreground">
Enter values from your response headers to verify the discount:
</p>
<h3 className="mb-3 text-sm font-medium text-foreground">{t("costTracking.howItWorks.calculator.title")}</h3>
<p className="mb-3 text-xs text-muted-foreground">{t("costTracking.howItWorks.calculator.body")}</p>
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="response-cost" className="mb-1 block text-xs">
Response Cost (x-litellm-response-cost)
{t("costTracking.howItWorks.calculator.responseCostLabel")}
</Label>
<Input
id="response-cost"
@ -108,7 +106,7 @@ const HowItWorks: React.FC = () => {
</div>
<div>
<Label htmlFor="discount-amount" className="mb-1 block text-xs">
Discount Amount (x-litellm-response-cost-discount-amount)
{t("costTracking.howItWorks.calculator.discountAmountLabel")}
</Label>
<Input
id="discount-amount"
@ -122,22 +120,24 @@ const HowItWorks: React.FC = () => {
{calculatedDiscount && (
<div className="rounded-lg border border-border bg-muted/50 p-4">
<p className="mb-2 text-sm font-medium text-foreground">Calculated Results</p>
<p className="mb-2 text-sm font-medium text-foreground">{t("costTracking.howItWorks.results.title")}</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Original Cost:</p>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.results.originalCost")}</p>
<code className="font-mono text-xs text-foreground">${calculatedDiscount.originalCost}</code>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Final Cost:</p>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.results.finalCost")}</p>
<code className="font-mono text-xs text-foreground">${calculatedDiscount.finalCost}</code>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Discount Amount:</p>
<p className="text-xs text-muted-foreground">{t("costTracking.howItWorks.results.discountAmount")}</p>
<code className="font-mono text-xs text-foreground">${calculatedDiscount.discountAmount}</code>
</div>
<div className="flex items-center justify-between border-t border-border pt-2">
<p className="text-xs font-semibold text-foreground">Discount Applied:</p>
<p className="text-xs font-semibold text-foreground">
{t("costTracking.howItWorks.results.discountApplied")}
</p>
<p className="text-sm font-bold text-foreground">{calculatedDiscount.discountPercentage}%</p>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -23,6 +24,7 @@ const createDefaultEntry = (): ModelEntry => ({
});
const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, models }) => {
const { t } = useTranslation();
const [entries, setEntries] = useState<ModelEntry[]>([createDefaultEntry()]);
const [timePeriod, setTimePeriod] = useState<TimePeriod>("month");
const { debouncedFetchForEntry, removeEntry, getMultiModelResult } = useMultiCostEstimate(accessToken);
@ -80,11 +82,11 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="day" />
Per Day
{t("costTracking.pricingCalculator.timePeriod.day")}
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="month" />
Per Month
{t("costTracking.pricingCalculator.timePeriod.month")}
</label>
</RadioGroup>
</div>
@ -92,12 +94,19 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[35%]">Model</TableHead>
<TableHead className="w-[18%]">Input Tokens</TableHead>
<TableHead className="w-[18%]">Output Tokens</TableHead>
<TableHead className="w-[20%]">Requests/{timePeriod === "day" ? "Day" : "Month"}</TableHead>
<TableHead className="w-[35%]">{t("costTracking.pricingCalculator.columns.model")}</TableHead>
<TableHead className="w-[18%]">{t("costTracking.pricingCalculator.columns.inputTokens")}</TableHead>
<TableHead className="w-[18%]">{t("costTracking.pricingCalculator.columns.outputTokens")}</TableHead>
<TableHead className="w-[20%]">
{t("costTracking.pricingCalculator.columns.requestsPer", {
period:
timePeriod === "day"
? t("costTracking.pricingCalculator.columns.periods.day")
: t("costTracking.pricingCalculator.columns.periods.month"),
})}
</TableHead>
<TableHead className="w-[50px]">
<span className="sr-only">Actions</span>
<span className="sr-only">{t("costTracking.pricingCalculator.columns.actions")}</span>
</TableHead>
</TableRow>
</TableHeader>
@ -109,7 +118,7 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
options={modelOptions}
value={record.model || undefined}
onValueChange={(value) => handleEntryChange(record.id, "model", value)}
placeholder="Select a model"
placeholder={t("costTracking.pricingCalculator.modelPlaceholder")}
/>
</TableCell>
<TableCell>
@ -154,7 +163,7 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove model row ${index + 1}`}
aria-label={t("costTracking.pricingCalculator.removeRow", { row: index + 1 })}
onClick={() => handleRemoveEntry(record.id)}
disabled={entries.length === 1}
className="text-destructive"
@ -170,7 +179,7 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
<TableCell colSpan={5}>
<Button variant="outline" onClick={handleAddEntry} className="w-full border-dashed">
<Plus className="size-3.5" />
Add Another Model
{t("costTracking.pricingCalculator.addModel")}
</Button>
</TableCell>
</TableRow>

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -34,7 +35,9 @@ const SingleModelBreakdown: React.FC<{
loading: boolean;
timePeriod: "day" | "month";
}> = ({ result, loading, timePeriod }) => {
const periodLabel = timePeriod === "day" ? "Daily" : "Monthly";
const { t } = useTranslation();
const periodLabel =
timePeriod === "day" ? t("costTracking.costResults.periods.daily") : t("costTracking.costResults.periods.monthly");
const periodCost = timePeriod === "day" ? result.daily_cost : result.monthly_cost;
const periodInputCost = timePeriod === "day" ? result.daily_input_cost : result.monthly_input_cost;
const periodOutputCost = timePeriod === "day" ? result.daily_output_cost : result.monthly_output_cost;
@ -46,25 +49,27 @@ const SingleModelBreakdown: React.FC<{
{loading && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<UiLoadingSpinner className="size-3.5" />
<span>Updating...</span>
<span>{t("costTracking.costResults.breakdown.updating")}</span>
</div>
)}
<div className="grid grid-cols-4 gap-4">
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">Total/Request</p>
<p className="text-xs text-muted-foreground block">
{t("costTracking.costResults.breakdown.totalPerRequest")}
</p>
<p className="text-base font-semibold text-info break-words">{formatCost(result.cost_per_request)}</p>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">Input Cost</p>
<p className="text-xs text-muted-foreground block">{t("costTracking.costResults.breakdown.inputCost")}</p>
<p className="text-sm break-words">{formatCost(result.input_cost_per_request)}</p>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">Output Cost</p>
<p className="text-xs text-muted-foreground block">{t("costTracking.costResults.breakdown.outputCost")}</p>
<p className="text-sm break-words">{formatCost(result.output_cost_per_request)}</p>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">Margin Fee</p>
<p className="text-xs text-muted-foreground block">{t("costTracking.costResults.breakdown.marginFee")}</p>
<p className={`text-sm break-words ${result.margin_cost_per_request > 0 ? "text-warning" : ""}`}>
{formatCost(result.margin_cost_per_request)}
</p>
@ -75,7 +80,10 @@ const SingleModelBreakdown: React.FC<{
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-border">
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">
{periodLabel} Total ({formatRequests(periodRequests)} req)
{t("costTracking.costResults.breakdown.periodTotal", {
period: periodLabel,
requests: formatRequests(periodRequests),
})}
</p>
<p
className={`text-base font-semibold break-words ${timePeriod === "day" ? "text-success" : "text-purple-600 dark:text-purple-300"}`}
@ -84,15 +92,21 @@ const SingleModelBreakdown: React.FC<{
</p>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">{periodLabel} Input</p>
<p className="text-xs text-muted-foreground block">
{t("costTracking.costResults.breakdown.periodInput", { period: periodLabel })}
</p>
<p className="text-sm break-words">{formatCost(periodInputCost)}</p>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">{periodLabel} Output</p>
<p className="text-xs text-muted-foreground block">
{t("costTracking.costResults.breakdown.periodOutput", { period: periodLabel })}
</p>
<p className="text-sm break-words">{formatCost(periodOutputCost)}</p>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground block">{periodLabel} Margin Fee</p>
<p className="text-xs text-muted-foreground block">
{t("costTracking.costResults.breakdown.periodMarginFee", { period: periodLabel })}
</p>
<p className={`text-sm break-words ${(periodMarginCost ?? 0) > 0 ? "text-warning" : ""}`}>
{formatCost(periodMarginCost)}
</p>
@ -102,13 +116,21 @@ const SingleModelBreakdown: React.FC<{
{(result.input_cost_per_token || result.output_cost_per_token) && (
<div className="text-xs text-muted-foreground pt-2 border-t border-border">
Token Pricing:{" "}
{t("costTracking.costResults.breakdown.tokenPricing")}{" "}
{result.input_cost_per_token && (
<span>Input ${formatNumberWithCommas(result.input_cost_per_token * 1_000_000, 2)}/1M</span>
<span>
{t("costTracking.costResults.breakdown.inputPrice", {
price: formatNumberWithCommas(result.input_cost_per_token * 1_000_000, 2),
})}
</span>
)}
{result.input_cost_per_token && result.output_cost_per_token && " | "}
{result.output_cost_per_token && (
<span>Output ${formatNumberWithCommas(result.output_cost_per_token * 1_000_000, 2)}/1M</span>
<span>
{t("costTracking.costResults.breakdown.outputPrice", {
price: formatNumberWithCommas(result.output_cost_per_token * 1_000_000, 2),
})}
</span>
)}
</div>
)}
@ -117,6 +139,7 @@ const SingleModelBreakdown: React.FC<{
};
const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePeriod }) => {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
const validEntries = multiResult.entries.filter((e) => e.result !== null);
@ -130,7 +153,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && !isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center border border-dashed border-border rounded-lg bg-muted">
<p className="text-muted-foreground">Select models above to see cost estimates</p>
<p className="text-muted-foreground">{t("costTracking.costResults.empty")}</p>
</div>
);
}
@ -140,7 +163,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
return (
<div className="py-6 text-center">
<UiLoadingSpinner className="inline-block size-5" />
<p className="text-muted-foreground block mt-2">Calculating costs...</p>
<p className="text-muted-foreground block mt-2">{t("costTracking.costResults.calculating")}</p>
</div>
);
}
@ -151,7 +174,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
<div className="space-y-4">
<Separator className="my-4" />
<div className="flex items-center justify-between">
<p className="text-base font-semibold text-foreground">Cost Estimates</p>
<p className="text-base font-semibold text-foreground">{t("costTracking.costResults.title")}</p>
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
</div>
{/* Error Messages */}
@ -160,7 +183,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
key={e.entry.id}
className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg border border-destructive/20"
>
<span className="font-medium">{e.entry.model || "Unknown model"}: </span>
<span className="font-medium">{e.entry.model || t("costTracking.costResults.unknownModel")}: </span>
{e.error}
</div>
))}
@ -182,7 +205,8 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
const hasMargin = multiResult.totals.margin_per_request > 0;
const periodLabel = timePeriod === "day" ? "Daily" : "Monthly";
const periodLabel =
timePeriod === "day" ? t("costTracking.costResults.periods.daily") : t("costTracking.costResults.periods.monthly");
// Include both valid results and errors in the table data
const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model);
@ -204,7 +228,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
<Separator className="my-4" />
<div className="flex items-center justify-between">
<p className="text-base font-semibold text-foreground">Cost Estimates</p>
<p className="text-base font-semibold text-foreground">{t("costTracking.costResults.title")}</p>
<div className="flex items-center gap-2">
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
<MultiExportDropdown multiResult={multiResult} />
@ -215,13 +239,15 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
<Card size="sm" className="px-4 bg-linear-to-r from-slate-50 to-blue-50 dark:from-slate-900 dark:to-blue-950">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground">Total Per Request</span>
<span className="text-xs text-muted-foreground">{t("costTracking.costResults.totals.perRequest")}</span>
<div className="text-lg font-mono text-info break-words">
{formatCost(multiResult.totals.cost_per_request)}
</div>
</div>
<div className="min-w-0">
<span className="text-xs text-muted-foreground">Total {periodLabel}</span>
<span className="text-xs text-muted-foreground">
{t("costTracking.costResults.totals.period", { period: periodLabel })}
</span>
<div
className={`text-lg font-mono break-words ${timePeriod === "day" ? "text-success" : "text-purple-600 dark:text-purple-300"}`}
>
@ -232,13 +258,17 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
{hasMargin && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-3 pt-3 border-t border-border">
<div className="min-w-0">
<div className="text-xs text-muted-foreground">Margin Fee/Request</div>
<div className="text-xs text-muted-foreground">
{t("costTracking.costResults.totals.marginPerRequest")}
</div>
<div className="text-sm font-mono text-warning break-words">
{formatCost(multiResult.totals.margin_per_request)}
</div>
</div>
<div className="min-w-0">
<div className="text-xs text-muted-foreground">{periodLabel} Margin Fee</div>
<div className="text-xs text-muted-foreground">
{t("costTracking.costResults.totals.periodMarginFee", { period: periodLabel })}
</div>
<div className="text-sm font-mono text-warning break-words">
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
</div>
@ -252,12 +282,12 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
<Table className="border border-border rounded-lg">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Per Request</TableHead>
<TableHead className="text-right">Margin Fee</TableHead>
<TableHead>{t("costTracking.costResults.columns.model")}</TableHead>
<TableHead className="text-right">{t("costTracking.costResults.columns.perRequest")}</TableHead>
<TableHead className="text-right">{t("costTracking.costResults.columns.marginFee")}</TableHead>
<TableHead className="text-right">{periodLabel}</TableHead>
<TableHead className="w-10">
<span className="sr-only">Cost breakdown</span>
<span className="sr-only">{t("costTracking.costResults.columns.breakdown")}</span>
</TableHead>
</TableRow>
</TableHeader>
@ -287,7 +317,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
)}
{record.hasZeroCost && !record.error && (
<div className="text-xs text-warning bg-warning/10 px-2 py-1 rounded-sm">
⚠️ No pricing data found for this model. Set base_model in config.
⚠️ {t("costTracking.costResults.noPricingData")}
</div>
)}
</div>
@ -323,7 +353,12 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
variant="ghost"
size="icon-xs"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} cost breakdown for ${record.model}`}
aria-label={t("costTracking.costResults.toggleBreakdown", {
action: isExpanded
? t("costTracking.costResults.breakdownActions.hide")
: t("costTracking.costResults.breakdownActions.show"),
model: record.model,
})}
onClick={() => toggleExpanded(record.id)}
className="text-muted-foreground hover:text-foreground"
>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Download, FileSpreadsheet, FileText } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
@ -15,6 +16,7 @@ interface MultiExportDropdownProps {
}
const MultiExportDropdown: React.FC<MultiExportDropdownProps> = ({ multiResult }) => {
const { t } = useTranslation();
const hasResults = multiResult.entries.some((e) => e.result !== null);
if (!hasResults) {
@ -25,16 +27,16 @@ const MultiExportDropdown: React.FC<MultiExportDropdownProps> = ({ multiResult }
<DropdownMenu>
<DropdownMenuTrigger className={buttonVariants({ variant: "secondary", size: "xs" })}>
<Download />
Export
{t("costTracking.exportDropdown.trigger")}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => exportMultiToPDF(multiResult)}>
<FileText />
Export as PDF
{t("costTracking.exportDropdown.pdf")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportMultiToCSV(multiResult)}>
<FileSpreadsheet />
Export as CSV
{t("costTracking.exportDropdown.csv")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { CostEstimateResponse } from "../types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { MultiModelResult } from "./types";
@ -15,45 +17,45 @@ const formatRequestsForExport = (value: number | null | undefined): string => {
return formatNumberWithCommas(value, 0);
};
const generateModelSection = (result: CostEstimateResponse): string => {
const generateModelSection = (result: CostEstimateResponse, t: TFunction): string => {
return `
<div class="model-section">
<h3>${result.model} ${result.provider ? `<span class="provider">(${result.provider})</span>` : ""}</h3>
<div class="meta">
<p><strong>Input Tokens per Request:</strong> ${formatRequestsForExport(result.input_tokens)}</p>
<p><strong>Output Tokens per Request:</strong> ${formatRequestsForExport(result.output_tokens)}</p>
${result.num_requests_per_day ? `<p><strong>Requests per Day:</strong> ${formatRequestsForExport(result.num_requests_per_day)}</p>` : ""}
${result.num_requests_per_month ? `<p><strong>Requests per Month:</strong> ${formatRequestsForExport(result.num_requests_per_month)}</p>` : ""}
<p><strong>${t("costTracking.exportReport.model.inputTokensPerRequest")}</strong> ${formatRequestsForExport(result.input_tokens)}</p>
<p><strong>${t("costTracking.exportReport.model.outputTokensPerRequest")}</strong> ${formatRequestsForExport(result.output_tokens)}</p>
${result.num_requests_per_day ? `<p><strong>${t("costTracking.exportReport.model.requestsPerDay")}</strong> ${formatRequestsForExport(result.num_requests_per_day)}</p>` : ""}
${result.num_requests_per_month ? `<p><strong>${t("costTracking.exportReport.model.requestsPerMonth")}</strong> ${formatRequestsForExport(result.num_requests_per_month)}</p>` : ""}
</div>
<table>
<tr>
<th>Cost Type</th>
<th>Per Request</th>
${result.daily_cost !== null ? "<th>Daily</th>" : ""}
${result.monthly_cost !== null ? "<th>Monthly</th>" : ""}
<th>${t("costTracking.exportReport.model.columns.costType")}</th>
<th>${t("costTracking.exportReport.model.columns.perRequest")}</th>
${result.daily_cost !== null ? `<th>${t("costTracking.exportReport.model.columns.daily")}</th>` : ""}
${result.monthly_cost !== null ? `<th>${t("costTracking.exportReport.model.columns.monthly")}</th>` : ""}
</tr>
<tr>
<td>Input Cost</td>
<td>${t("costTracking.exportReport.model.rows.inputCost")}</td>
<td class="cost-value">${formatCostForExport(result.input_cost_per_request)}</td>
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_input_cost)}</td>` : ""}
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_input_cost)}</td>` : ""}
</tr>
<tr>
<td>Output Cost</td>
<td>${t("costTracking.exportReport.model.rows.outputCost")}</td>
<td class="cost-value">${formatCostForExport(result.output_cost_per_request)}</td>
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_output_cost)}</td>` : ""}
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_output_cost)}</td>` : ""}
</tr>
<tr>
<td>Margin/Fee</td>
<td>${t("costTracking.exportReport.model.rows.marginFee")}</td>
<td class="cost-value">${formatCostForExport(result.margin_cost_per_request)}</td>
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_margin_cost)}</td>` : ""}
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_margin_cost)}</td>` : ""}
</tr>
<tr class="total-row">
<td>Total</td>
<td>${t("costTracking.exportReport.model.rows.total")}</td>
<td class="cost-value">${formatCostForExport(result.cost_per_request)}</td>
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_cost)}</td>` : ""}
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_cost)}</td>` : ""}
@ -63,10 +65,10 @@ const generateModelSection = (result: CostEstimateResponse): string => {
`;
};
export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
export const exportMultiToPDF = (multiResult: MultiModelResult, t: TFunction = i18n.t.bind(i18n)): void => {
const printWindow = window.open("", "_blank");
if (!printWindow) {
alert("Please allow popups to export PDF");
alert(t("costTracking.exportReport.popupBlocked"));
return;
}
@ -77,7 +79,7 @@ export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
<!DOCTYPE html>
<html>
<head>
<title>Multi-Model Cost Estimate Report</title>
<title>${t("costTracking.exportReport.pdf.documentTitle")}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -193,22 +195,22 @@ export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
</style>
</head>
<body>
<h1>LLM Cost Estimate Report</h1>
<p style="color: #666; margin-top: -20px; margin-bottom: 30px;">${modelCount} model${modelCount !== 1 ? "s" : ""} configured</p>
<h1>${t("costTracking.exportReport.pdf.heading")}</h1>
<p style="color: #666; margin-top: -20px; margin-bottom: 30px;">${t("costTracking.exportReport.pdf.modelsConfigured", { count: modelCount })}</p>
<div class="summary-box">
<h2>Combined Totals</h2>
<h2>${t("costTracking.exportReport.pdf.combinedTotals")}</h2>
<div class="summary-grid">
<div class="summary-item">
<div class="label">Total Per Request</div>
<div class="label">${t("costTracking.exportReport.pdf.totalPerRequest")}</div>
<div class="value blue">${formatCostForExport(multiResult.totals.cost_per_request)}</div>
</div>
<div class="summary-item">
<div class="label">Total Daily</div>
<div class="label">${t("costTracking.exportReport.pdf.totalDaily")}</div>
<div class="value green">${formatCostForExport(multiResult.totals.daily_cost)}</div>
</div>
<div class="summary-item">
<div class="label">Total Monthly</div>
<div class="label">${t("costTracking.exportReport.pdf.totalMonthly")}</div>
<div class="value purple">${formatCostForExport(multiResult.totals.monthly_cost)}</div>
</div>
</div>
@ -217,15 +219,15 @@ export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
? `
<div class="summary-grid" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
<div class="summary-item">
<div class="label">Margin/Request</div>
<div class="label">${t("costTracking.exportReport.pdf.marginPerRequest")}</div>
<div class="value" style="color: #faad14;">${formatCostForExport(multiResult.totals.margin_per_request)}</div>
</div>
<div class="summary-item">
<div class="label">Daily Margin</div>
<div class="label">${t("costTracking.exportReport.pdf.dailyMargin")}</div>
<div class="value" style="color: #faad14;">${formatCostForExport(multiResult.totals.daily_margin)}</div>
</div>
<div class="summary-item">
<div class="label">Monthly Margin</div>
<div class="label">${t("costTracking.exportReport.pdf.monthlyMargin")}</div>
<div class="value" style="color: #faad14;">${formatCostForExport(multiResult.totals.monthly_margin)}</div>
</div>
</div>
@ -234,11 +236,11 @@ export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
}
</div>
<h2>Model Breakdown</h2>
${validEntries.map((e) => generateModelSection(e.result!)).join("")}
<h2>${t("costTracking.exportReport.pdf.modelBreakdown")}</h2>
${validEntries.map((e) => generateModelSection(e.result!, t)).join("")}
<div class="footer">
<p>Generated by LiteLLM Pricing Calculator on ${new Date().toLocaleString()}</p>
<p>${t("costTracking.exportReport.pdf.footer", { date: new Date().toLocaleString() })}</p>
</div>
</body>
</html>
@ -251,37 +253,41 @@ export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
};
};
export const exportMultiToCSV = (multiResult: MultiModelResult): void => {
export const exportMultiToCSV = (multiResult: MultiModelResult, t: TFunction = i18n.t.bind(i18n)): void => {
const validEntries = multiResult.entries.filter((e) => e.result !== null);
const rows: string[][] = [["LLM Multi-Model Cost Estimate Report"], ["Generated", new Date().toLocaleString()], [""]];
const rows: string[][] = [
[t("costTracking.exportReport.csv.title")],
[t("costTracking.exportReport.csv.generated"), new Date().toLocaleString()],
[""],
];
// Summary section
rows.push(
["COMBINED TOTALS"],
["Total Per Request", multiResult.totals.cost_per_request.toString()],
["Total Daily", multiResult.totals.daily_cost?.toString() || "-"],
["Total Monthly", multiResult.totals.monthly_cost?.toString() || "-"],
["Margin Per Request", multiResult.totals.margin_per_request.toString()],
["Daily Margin", multiResult.totals.daily_margin?.toString() || "-"],
["Monthly Margin", multiResult.totals.monthly_margin?.toString() || "-"],
[t("costTracking.exportReport.csv.combinedTotals")],
[t("costTracking.exportReport.csv.totalPerRequest"), multiResult.totals.cost_per_request.toString()],
[t("costTracking.exportReport.csv.totalDaily"), multiResult.totals.daily_cost?.toString() || "-"],
[t("costTracking.exportReport.csv.totalMonthly"), multiResult.totals.monthly_cost?.toString() || "-"],
[t("costTracking.exportReport.csv.marginPerRequest"), multiResult.totals.margin_per_request.toString()],
[t("costTracking.exportReport.csv.dailyMargin"), multiResult.totals.daily_margin?.toString() || "-"],
[t("costTracking.exportReport.csv.monthlyMargin"), multiResult.totals.monthly_margin?.toString() || "-"],
[""],
);
// Summary table header
rows.push([
"Model",
"Provider",
"Input Tokens",
"Output Tokens",
"Requests/Day",
"Requests/Month",
"Cost/Request",
"Daily Cost",
"Monthly Cost",
"Input Cost/Req",
"Output Cost/Req",
"Margin/Req",
t("costTracking.exportReport.csv.columns.model"),
t("costTracking.exportReport.csv.columns.provider"),
t("costTracking.exportReport.csv.columns.inputTokens"),
t("costTracking.exportReport.csv.columns.outputTokens"),
t("costTracking.exportReport.csv.columns.requestsPerDay"),
t("costTracking.exportReport.csv.columns.requestsPerMonth"),
t("costTracking.exportReport.csv.columns.costPerRequest"),
t("costTracking.exportReport.csv.columns.dailyCost"),
t("costTracking.exportReport.csv.columns.monthlyCost"),
t("costTracking.exportReport.csv.columns.inputCostPerRequest"),
t("costTracking.exportReport.csv.columns.outputCostPerRequest"),
t("costTracking.exportReport.csv.columns.marginPerRequest"),
]);
// Add each model's data

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { CostEstimateRequest, CostEstimateResponse } from "../types";
import { ModelEntry, MultiModelResult } from "./types";
@ -13,6 +14,7 @@ interface EntryResult {
}
export function useMultiCostEstimate(accessToken: string | null) {
const { t } = useTranslation();
const [entryResults, setEntryResults] = useState<Map<string, EntryResult>>(new Map());
const debounceRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
@ -79,7 +81,8 @@ export function useMultiCostEstimate(accessToken: string | null) {
});
} else {
const errorData = await response.json();
const errorMessage = errorData.detail?.error || errorData.detail || "Failed to estimate cost";
const errorMessage =
errorData.detail?.error || errorData.detail || t("costTracking.costEstimate.errors.estimateFailed");
setEntryResults((prev) => {
const next = new Map(prev);
next.set(entry.id, {
@ -99,13 +102,13 @@ export function useMultiCostEstimate(accessToken: string | null) {
entry,
result: null,
loading: false,
error: "Network error",
error: t("costTracking.costEstimate.errors.network"),
});
return next;
});
}
},
[accessToken],
[accessToken, t],
);
const debouncedFetchForEntry = useCallback(

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, SquarePen, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -33,6 +34,7 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
onDiscountChange,
onRemoveProvider,
}) => {
const { t } = useTranslation();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState<string>("");
@ -82,7 +84,7 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
data={data}
columns={[
{
header: "Provider",
header: t("costTracking.discountTable.columns.provider"),
cell: (row) => {
const { displayName } = getProviderLogoAndName(row.provider);
return (
@ -94,16 +96,16 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
},
},
{
header: "Models",
header: t("costTracking.discountTable.columns.models"),
cell: (row) =>
row.modelPattern ? (
<span className="font-mono text-sm">{row.modelPattern}</span>
) : (
<span className="text-muted-foreground">All models</span>
<span className="text-muted-foreground">{t("costTracking.discountTable.allModels")}</span>
),
},
{
header: "Discount Percentage",
header: t("costTracking.discountTable.columns.discountPercentage"),
numeric: true,
cell: (row) => {
const label = rowLabel(row);
@ -123,7 +125,7 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Save discount for ${label}`}
aria-label={t("costTracking.discountTable.actions.save", { label })}
onClick={() => handleSaveEdit(row.key)}
className="cursor-pointer text-success hover:text-success/80"
>
@ -132,7 +134,7 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing discount for ${label}`}
aria-label={t("costTracking.discountTable.actions.cancel", { label })}
onClick={handleCancelEdit}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
@ -145,7 +147,7 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit discount for ${label}`}
aria-label={t("costTracking.discountTable.actions.edit", { label })}
onClick={() => handleStartEdit(row.key, row.discount)}
className="cursor-pointer text-info hover:text-info/80"
>
@ -159,14 +161,14 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
width: "250px",
},
{
header: "Actions",
header: t("costTracking.discountTable.columns.actions"),
cell: (row) => {
const label = rowLabel(row);
return (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove discount for ${label}`}
aria-label={t("costTracking.discountTable.actions.remove", { label })}
onClick={() => onRemoveProvider(row.key, label)}
className="cursor-pointer hover:text-destructive"
>
@ -178,7 +180,7 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
},
]}
getRowKey={(row) => row.key}
emptyMessage="No provider discounts configured"
emptyMessage={t("costTracking.discountTable.empty")}
/>
);
};

View file

@ -1,4 +1,6 @@
import React, { useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Check, SquarePen, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -18,14 +20,15 @@ interface ProviderMarginRow {
margin: number | { percentage?: number; fixed_amount?: number };
}
const marginRowDisplayName = (provider: string): string =>
provider === "global" ? "Global" : getProviderLogoAndName(provider).displayName;
const marginRowDisplayName = (provider: string, t: TFunction): string =>
provider === "global" ? t("costTracking.marginTable.global") : getProviderLogoAndName(provider).displayName;
const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
marginConfig,
onMarginChange,
onRemoveProvider,
}) => {
const { t } = useTranslation();
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [editPercentage, setEditPercentage] = useState<string>("");
const [editFixedAmount, setEditFixedAmount] = useState<string>("");
@ -103,12 +106,12 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
data={data}
columns={[
{
header: "Provider",
header: t("costTracking.marginTable.columns.provider"),
cell: (row) => {
if (row.provider === "global") {
return (
<div className="flex items-center space-x-2">
<span className="font-medium">Global (All Providers)</span>
<span className="font-medium">{t("costTracking.marginTable.globalAllProviders")}</span>
</div>
);
}
@ -122,10 +125,10 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
},
},
{
header: "Margin",
header: t("costTracking.marginTable.columns.margin"),
numeric: true,
cell: (row) => {
const displayName = marginRowDisplayName(row.provider);
const displayName = marginRowDisplayName(row.provider, t);
return (
<div className="flex items-center justify-end gap-2">
{editingProvider === row.provider ? (
@ -151,7 +154,7 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Save margin for ${displayName}`}
aria-label={t("costTracking.marginTable.actions.save", { name: displayName })}
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-success hover:text-success/80"
>
@ -160,7 +163,7 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing margin for ${displayName}`}
aria-label={t("costTracking.marginTable.actions.cancel", { name: displayName })}
onClick={handleCancelEdit}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
@ -173,7 +176,7 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit margin for ${displayName}`}
aria-label={t("costTracking.marginTable.actions.edit", { name: displayName })}
onClick={() => handleStartEdit(row.provider, row.margin)}
className="cursor-pointer text-info hover:text-info/80"
>
@ -187,14 +190,14 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
width: "350px",
},
{
header: "Actions",
header: t("costTracking.marginTable.columns.actions"),
cell: (row) => {
const displayName = marginRowDisplayName(row.provider);
const displayName = marginRowDisplayName(row.provider, t);
return (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove margin for ${displayName}`}
aria-label={t("costTracking.marginTable.actions.remove", { name: displayName })}
onClick={() => onRemoveProvider(row.provider, displayName)}
className="cursor-pointer hover:text-destructive"
>
@ -206,7 +209,7 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
},
]}
getRowKey={(row) => row.provider}
emptyMessage="No provider margins configured"
emptyMessage={t("costTracking.marginTable.empty")}
/>
);
};

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "@/components/networking";
import { toast } from "@/lib/toast";
@ -20,6 +21,7 @@ interface BlockUnpricedResponse {
const ENDPOINT = "/config/block_requests_for_models_without_pricing";
export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigProps): UseBlockUnpricedConfigReturn {
const { t } = useTranslation();
const [blockUnpriced, setBlockUnpricedState] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
@ -43,8 +45,8 @@ export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigPr
setBlockUnpricedState(Boolean(data?.enabled));
toast.success(
enabled
? "Requests for models without pricing will now be blocked"
: "Requests for models without pricing are now allowed",
? t("costTracking.blockUnpricedConfig.toasts.enabled")
: t("costTracking.blockUnpricedConfig.toasts.disabled"),
);
} catch (error) {
console.error("Error updating block-unpriced-models setting:", error);
@ -53,7 +55,7 @@ export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigPr
setIsUpdating(false);
}
},
[accessToken],
[accessToken, t],
);
return {

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { toast } from "@/lib/toast";
import { DiscountConfig } from "./types";
@ -24,6 +25,7 @@ export interface UseDiscountConfigReturn {
}
export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseDiscountConfigReturn {
const { t } = useTranslation();
const [discountConfig, setDiscountConfig] = useState<DiscountConfig>({});
const fetchDiscountConfig = useCallback(async () => {
@ -47,9 +49,9 @@ export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseD
}
} catch (error) {
console.error("Error fetching discount config:", error);
toast.fromError("Failed to fetch discount configuration");
toast.fromError(t("costTracking.discountConfig.toasts.fetchFailed"));
}
}, [accessToken]);
}, [accessToken, t]);
const saveDiscountConfig = useCallback(
async (config: DiscountConfig) => {
@ -67,31 +69,32 @@ export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseD
});
if (response.ok) {
toast.success("Discount configuration updated successfully");
toast.success(t("costTracking.discountConfig.toasts.updated"));
await fetchDiscountConfig();
} else {
const errorData = await response.json();
const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings";
const errorMessage =
errorData.detail?.error || errorData.detail || t("costTracking.discountConfig.toasts.updateFailedFallback");
toast.fromError(errorMessage);
}
} catch (error) {
console.error("Error updating discount config:", error);
toast.fromError("Failed to update discount configuration");
toast.fromError(t("costTracking.discountConfig.toasts.updateFailed"));
}
},
[accessToken, fetchDiscountConfig],
[accessToken, fetchDiscountConfig, t],
);
const handleAddProvider = useCallback(
async (selectedProvider: string | undefined, newDiscount: string, modelPattern: string): Promise<boolean> => {
if (!selectedProvider || !newDiscount) {
toast.fromError("Please select a provider and enter discount percentage");
toast.fromError(t("costTracking.discountConfig.toasts.missingInput"));
return false;
}
const percentageValue = parseFloat(newDiscount);
if (isNaN(percentageValue) || percentageValue < 0 || percentageValue > 100) {
toast.fromError("Discount must be between 0% and 100%");
toast.fromError(t("costTracking.discountConfig.toasts.outOfRange"));
return false;
}
@ -100,7 +103,7 @@ export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseD
const providerValue = getProviderBackendValue(selectedProvider);
if (!providerValue) {
toast.fromError("Invalid provider selected");
toast.fromError(t("costTracking.discountConfig.toasts.invalidProvider"));
return false;
}
@ -109,7 +112,10 @@ export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseD
if (configKey in discountConfig) {
const displayName = Providers[selectedProvider as keyof typeof Providers];
toast.fromError(
`Discount for ${displayName} (${trimmedPattern || "all models"}) already exists. Edit it in the table above.`,
t("costTracking.discountConfig.toasts.duplicate", {
name: displayName,
models: trimmedPattern || t("costTracking.discountConfig.toasts.allModels"),
}),
);
return false;
}
@ -124,7 +130,7 @@ export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseD
await saveDiscountConfig(updatedConfig);
return true;
},
[discountConfig, saveDiscountConfig],
[discountConfig, saveDiscountConfig, t],
);
const handleRemoveProvider = useCallback(

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { toast } from "@/lib/toast";
import { MarginConfig } from "./types";
@ -30,6 +31,7 @@ export interface AddMarginParams {
}
export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMarginConfigReturn {
const { t } = useTranslation();
const [marginConfig, setMarginConfig] = useState<MarginConfig>({});
const fetchMarginConfig = useCallback(async () => {
@ -53,9 +55,9 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
}
} catch (error) {
console.error("Error fetching margin config:", error);
toast.fromError("Failed to fetch margin configuration");
toast.fromError(t("costTracking.marginConfig.toasts.fetchFailed"));
}
}, [accessToken]);
}, [accessToken, t]);
const saveMarginConfig = useCallback(
async (config: MarginConfig) => {
@ -73,19 +75,20 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
});
if (response.ok) {
toast.success("Margin configuration updated successfully");
toast.success(t("costTracking.marginConfig.toasts.updated"));
await fetchMarginConfig();
} else {
const errorData = await response.json();
const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings";
const errorMessage =
errorData.detail?.error || errorData.detail || t("costTracking.marginConfig.toasts.updateFailedFallback");
toast.fromError(errorMessage);
}
} catch (error) {
console.error("Error updating margin config:", error);
toast.fromError("Failed to update margin configuration");
toast.fromError(t("costTracking.marginConfig.toasts.updateFailed"));
}
},
[accessToken, fetchMarginConfig],
[accessToken, fetchMarginConfig, t],
);
const handleAddMargin = useCallback(
@ -93,7 +96,7 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
const { selectedProvider, marginType, percentageValue, fixedAmountValue } = params;
if (!selectedProvider) {
toast.fromError("Please select a provider");
toast.fromError(t("costTracking.marginConfig.toasts.selectProvider"));
return false;
}
@ -103,7 +106,7 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
} else {
const backendValue = getProviderBackendValue(selectedProvider);
if (!backendValue) {
toast.fromError("Invalid provider selected");
toast.fromError(t("costTracking.marginConfig.toasts.invalidProvider"));
return false;
}
providerValue = backendValue;
@ -111,8 +114,10 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
if (marginConfig[providerValue]) {
const displayName =
providerValue === "global" ? "Global" : Providers[selectedProvider as keyof typeof Providers];
toast.fromError(`Margin for ${displayName} already exists. Edit it in the table above.`);
providerValue === "global"
? t("costTracking.marginConfig.global")
: Providers[selectedProvider as keyof typeof Providers];
toast.fromError(t("costTracking.marginConfig.toasts.duplicate", { name: displayName }));
return false;
}
@ -120,14 +125,14 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
if (marginType === "percentage") {
const percentValue = parseFloat(percentageValue);
if (isNaN(percentValue) || percentValue < 0 || percentValue > 1000) {
toast.fromError("Percentage must be between 0% and 1000%");
toast.fromError(t("costTracking.marginConfig.toasts.percentageOutOfRange"));
return false;
}
marginValue = percentValue / 100;
} else {
const fixedValue = parseFloat(fixedAmountValue);
if (isNaN(fixedValue) || fixedValue < 0) {
toast.fromError("Fixed amount must be non-negative");
toast.fromError(t("costTracking.marginConfig.toasts.fixedNegative"));
return false;
}
marginValue = { fixed_amount: fixedValue };
@ -142,7 +147,7 @@ export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMargi
await saveMarginConfig(updatedConfig);
return true;
},
[marginConfig, saveMarginConfig],
[marginConfig, saveMarginConfig, t],
);
const handleRemoveMargin = useCallback(

View file

@ -1,6 +1,7 @@
"use client";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Combobox,
@ -14,6 +15,7 @@ import {
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import i18n from "@/i18n/i18n";
export interface TagsInputOption {
label: string;
@ -46,12 +48,13 @@ export const TagsInput = ({
onValueChange,
options = [],
placeholder,
emptyText = "No matching options",
emptyText = i18n.t("tagsInput.empty"),
tokenSeparators = [],
loading = false,
disabled = false,
id,
}: TagsInputProps) => {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
@ -118,7 +121,7 @@ export const TagsInput = ({
))}
<ComboboxChipsInput
id={id}
placeholder={loading ? "Loading..." : placeholder}
placeholder={loading ? t("tagsInput.loading") : placeholder}
className="min-w-24"
onBlur={commitPending}
onKeyDown={handleKeyDown}

View file

@ -6,6 +6,7 @@ import { isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { BarChart } from "@/components/shared/charts";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyButton from "@/components/shared/CopyButton";
import { UserReference } from "@/components/shared/UserReference";
import { StatusBadge } from "@/components/shared/table_cells/status_badge";
@ -25,6 +26,7 @@ interface ProjectDetailProps {
const utilisationTone = (percent: number) => (percent >= 90 ? "over" : percent >= 70 ? "warning" : "default");
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { t } = useTranslation();
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamInfo } = useTeam(project?.team_id ?? undefined);
const { userId } = useAuthorized();
@ -51,7 +53,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<div
role="status"
aria-busy="true"
aria-label="Loading"
aria-label={t("projects.detail.loading")}
className="flex min-h-[300px] items-center justify-center"
>
<UiLoadingSpinner className="size-8 text-primary" />
@ -63,10 +65,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
if (!project) {
return (
<div className="p-6 px-12">
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack} className="mb-4">
<Button variant="ghost" size="icon" aria-label={t("projects.detail.back")} onClick={onBack} className="mb-4">
<ArrowLeftIcon className="size-4" />
</Button>
<p className="py-8 text-center text-sm text-muted-foreground">Project not found</p>
<p className="py-8 text-center text-sm text-muted-foreground">{t("projects.detail.notFound")}</p>
</div>
);
}
@ -75,7 +77,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<div className="p-6 px-12">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack}>
<Button variant="ghost" size="icon" aria-label={t("projects.detail.back")} onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
@ -85,47 +87,50 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</h1>
<StatusBadge
tone={project.blocked ? "error" : "success"}
label={project.blocked ? "Blocked" : "Active"}
label={project.blocked ? t("projects.detail.status.blocked") : t("projects.detail.status.active")}
/>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span>ID: {project.project_id}</span>
<CopyButton value={project.project_id} label="Copy project ID" />
<span>
{t("projects.detail.idLabel")}
{project.project_id}
</span>
<CopyButton value={project.project_id} label={t("projects.detail.copyId")} />
</div>
</div>
</div>
{canEditProject && (
<Button onClick={() => setIsEditModalVisible(true)}>
<EditIcon className="size-4" />
Edit Project
{t("projects.detail.editButton")}
</Button>
)}
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Project Details</CardTitle>
<CardTitle>{t("projects.detail.detailsTitle")}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Description</dt>
<dt className="text-muted-foreground">{t("projects.detail.description")}</dt>
<dd className="text-foreground">{project.description || "—"}</dd>
<dt className="text-muted-foreground">Created</dt>
<dt className="text-muted-foreground">{t("projects.detail.created")}</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(project.created_at).toLocaleString()}
{project.created_by && (
<>
<span>by</span>
<span>{t("projects.detail.by")}</span>
<UserReference userId={project.created_by} />
</>
)}
</dd>
<dt className="text-muted-foreground">Last Updated</dt>
<dt className="text-muted-foreground">{t("projects.detail.lastUpdated")}</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(project.updated_at).toLocaleString()}
{project.updated_by && (
<>
<span>by</span>
<span>{t("projects.detail.by")}</span>
<UserReference userId={project.updated_by} />
</>
)}
@ -139,14 +144,16 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSignIcon className="size-4" />
Budget
{t("projects.detail.budget.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div>
<p className="text-[28px] leading-none font-medium text-foreground">${spend.toFixed(2)}</p>
<p className="mt-1 text-sm text-muted-foreground">
{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}
{hasLimit
? t("projects.detail.budget.ofBudget", { amount: maxBudget.toFixed(2) })
: t("projects.detail.budget.noLimit")}
</p>
</div>
{hasLimit && (
@ -157,7 +164,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</MeterTrack>
</Meter>
<p className="mt-1 text-xs text-muted-foreground">
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
{(Math.round(spendPercent * 10) / 10).toFixed(1)}
{t("projects.detail.budget.utilized")}
</p>
</div>
)}
@ -166,7 +174,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<Card className="h-full lg:col-span-2">
<CardHeader>
<CardTitle>Spend by Model</CardTitle>
<CardTitle>{t("projects.detail.spendByModel.title")}</CardTitle>
</CardHeader>
<CardContent>
{modelSpendData.length > 0 ? (
@ -182,7 +190,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">No model spend recorded yet</p>
<p className="py-8 text-center text-sm text-muted-foreground">
{t("projects.detail.spendByModel.empty")}
</p>
)}
</CardContent>
</Card>
@ -195,7 +205,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="size-4" />
Team
{t("projects.detail.team.title")}
</CardTitle>
</CardHeader>
<CardContent>
@ -211,13 +221,16 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<div>
<p className="text-base font-medium text-foreground">{teamInfo.team_alias || teamInfo.team_id}</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>ID: {teamInfo.team_id}</span>
<CopyButton value={teamInfo.team_id} label="Copy team ID" />
<span>
{t("projects.detail.team.idLabel")}
{teamInfo.team_id}
</span>
<CopyButton value={teamInfo.team_id} label={t("projects.detail.team.copyId")} />
</div>
</div>
<div>
<p className="mb-1 text-xs text-muted-foreground">Models</p>
<p className="mb-1 text-xs text-muted-foreground">{t("projects.detail.team.models")}</p>
{(teamInfo.models?.length ?? 0) > 0 ? (
<div className="flex max-h-[60px] flex-wrap gap-1 overflow-hidden">
{teamInfo.models?.map((m: string) => (
@ -227,17 +240,19 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
))}
</div>
) : (
<p className="text-sm text-muted-foreground">All models</p>
<p className="text-sm text-muted-foreground">{t("projects.detail.team.allModels")}</p>
)}
</div>
<div>
<div className="mb-0.5 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Spend</span>
<span className="text-xs text-muted-foreground">{t("projects.detail.team.spend")}</span>
<span className="text-xs text-foreground">
${teamSpend.toFixed(2)}
<span className="text-muted-foreground">
{teamHasLimit ? ` / $${teamBudget.toFixed(2)}` : " (Unlimited)"}
{teamHasLimit
? t("projects.detail.team.ofBudget", { amount: teamBudget.toFixed(2) })
: t("projects.detail.team.unlimited")}
</span>
</span>
</div>
@ -251,7 +266,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Members</span>
<span className="text-xs text-muted-foreground">{t("projects.detail.team.members")}</span>
<span className="text-xs text-foreground">{teamInfo.members_with_roles?.length ?? 0}</span>
</div>
</div>
@ -261,13 +276,13 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<div
role="status"
aria-busy="true"
aria-label="Loading team"
aria-label={t("projects.detail.team.loading")}
className="flex items-center justify-center p-4"
>
<UiLoadingSpinner className="size-5 text-muted-foreground" />
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">No team assigned</p>
<p className="py-8 text-center text-sm text-muted-foreground">{t("projects.detail.team.noTeam")}</p>
)}
</CardContent>
</Card>

View file

@ -1,5 +1,6 @@
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { KeyIcon, SearchIcon, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { ProjectKeysTable } from "./ProjectKeysTable";
@ -10,6 +11,7 @@ interface ProjectKeysSectionProps {
}
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
const { t } = useTranslation();
const {
search: keyAlias,
setSearch: setKeyAlias,
@ -30,7 +32,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyIcon className="size-4" />
Keys
{t("projects.keys.title")}
</CardTitle>
</CardHeader>
<CardContent>
@ -40,13 +42,17 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
<SearchIcon className="size-3.5 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Filter by key name..."
placeholder={t("projects.keys.filterPlaceholder")}
value={keyAlias}
onChange={(e) => setKeyAlias(e.target.value)}
/>
{keyAlias && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear key filter" onClick={() => setKeyAlias("")}>
<InputGroupButton
size="icon-xs"
aria-label={t("projects.keys.clearFilter")}
onClick={() => setKeyAlias("")}
>
<X />
</InputGroupButton>
</InputGroupAddon>

View file

@ -3,6 +3,7 @@
import { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { KeyRound } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { DataTable } from "@/components/shared/DataTable";
@ -20,13 +21,14 @@ interface ProjectKeysTableProps {
}
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<KeyRound className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No keys found</div>
<div className="text-sm text-muted-foreground">Keys created in this project will show up here.</div>
<div className="text-sm font-medium text-foreground">{t("projects.keys.emptyState.title")}</div>
<div className="text-sm text-muted-foreground">{t("projects.keys.emptyState.description")}</div>
</div>
);
}
@ -39,7 +41,8 @@ export function ProjectKeysTable({
pagination,
onPaginationChange,
}: ProjectKeysTableProps) {
const columns = useMemo(() => getProjectKeysTableColumns(), []);
const { t } = useTranslation();
const columns = useMemo(() => getProjectKeysTableColumns(t), [t]);
return (
<DataTable
@ -53,7 +56,7 @@ export function ProjectKeysTable({
pageSizeOptions={PROJECT_KEYS_PAGE_SIZE_OPTIONS}
isLoading={isLoading}
isError={isError}
loadingMessage="Loading keys…"
loadingMessage={t("projects.keys.loading")}
noDataMessage={<EmptyState />}
size="compact"
/>

View file

@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
@ -22,12 +23,12 @@ function OwnerCell({ record }: { record: KeyResponse }) {
);
}
export const getProjectKeysTableColumns = (): ColumnDef<KeyResponse>[] => [
export const getProjectKeysTableColumns = (t: TFunction): ColumnDef<KeyResponse>[] => [
{
id: "key_alias",
accessorKey: "key_alias",
meta: { title: "Key Name" },
header: "Key Name",
meta: { title: t("projects.keys.columns.keyName") },
header: t("projects.keys.columns.keyName"),
enableSorting: false,
cell: ({ row }) => (
<IdentityCell
@ -39,16 +40,16 @@ export const getProjectKeysTableColumns = (): ColumnDef<KeyResponse>[] => [
},
{
id: "owner",
meta: { title: "Owner" },
header: "Owner",
meta: { title: t("projects.keys.columns.owner") },
header: t("projects.keys.columns.owner"),
enableSorting: false,
cell: ({ row }) => <OwnerCell record={row.original} />,
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created" },
header: "Created",
meta: { title: t("projects.keys.columns.created") },
header: t("projects.keys.columns.created"),
size: 130,
enableSorting: false,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
@ -56,10 +57,12 @@ export const getProjectKeysTableColumns = (): ColumnDef<KeyResponse>[] => [
{
id: "last_active",
accessorKey: "last_active",
meta: { title: "Last Active" },
header: "Last Active",
meta: { title: t("projects.keys.columns.lastActive") },
header: t("projects.keys.columns.lastActive"),
size: 130,
enableSorting: false,
cell: ({ row }) => <DateCell value={row.original.last_active} precision="date" fallback="Never" />,
cell: ({ row }) => (
<DateCell value={row.original.last_active} precision="date" fallback={t("projects.keys.columns.never")} />
),
},
];

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderPlus } from "lucide-react";
import { toast } from "@/lib/toast";
@ -19,6 +20,7 @@ interface CreateProjectModalProps {
}
function CreateProjectForm({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const form = useZodForm(projectFormSchema, { defaultValues: emptyProjectFormValues });
const createMutation = useCreateProject();
const [advancedOpen, setAdvancedOpen] = useState(false);
@ -31,12 +33,12 @@ function CreateProjectForm({ onClose }: { onClose: () => void }) {
createMutation.mutate(params, {
onSuccess: () => {
toast.success("Project created successfully");
toast.success(t("projects.createModal.toast.created"));
form.reset(emptyProjectFormValues);
onClose();
},
onError: (error) => {
toast.error(error.message || "Failed to create project");
toast.error(error.message || t("projects.createModal.toast.failed"));
},
});
});
@ -52,11 +54,11 @@ function CreateProjectForm({ onClose }: { onClose: () => void }) {
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
{t("projects.createModal.cancel")}
</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={createMutation.isPending}>
{createMutation.isPending ? <UiLoadingSpinner /> : <FolderPlus />}
Create Project
{t("projects.createModal.submit")}
</Button>
</div>
</form>
@ -64,11 +66,12 @@ function CreateProjectForm({ onClose }: { onClose: () => void }) {
}
export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[720px]">
<DialogHeader>
<DialogTitle className="text-lg">Create New Project</DialogTitle>
<DialogTitle className="text-lg">{t("projects.createModal.title")}</DialogTitle>
</DialogHeader>
<CreateProjectForm onClose={onClose} />
</DialogContent>

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { toast } from "@/lib/toast";
@ -70,6 +71,7 @@ export const toFormValues = (project: ProjectResponse): ProjectFormValues => {
};
function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalProps, "isOpen">) {
const { t } = useTranslation();
const form = useZodForm(projectFormSchema, { defaultValues: toFormValues(project) });
const updateMutation = useUpdateProject();
const [advancedOpen, setAdvancedOpen] = useState(false);
@ -94,12 +96,12 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
{ projectId: project.project_id, params },
{
onSuccess: () => {
toast.success("Project updated successfully");
toast.success(t("projects.editModal.toast.updated"));
onSuccess?.();
onClose();
},
onError: (error) => {
toast.error(error.message || "Failed to update project");
toast.error(error.message || t("projects.editModal.toast.failed"));
},
},
);
@ -111,11 +113,11 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t("projects.editModal.cancel")}
</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={updateMutation.isPending}>
{updateMutation.isPending ? <UiLoadingSpinner /> : <Save />}
Save Changes
{t("projects.editModal.submit")}
</Button>
</div>
</form>
@ -123,11 +125,12 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
}
export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditProjectModalProps) {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[720px]">
<DialogHeader>
<DialogTitle className="text-lg">Edit Project</DialogTitle>
<DialogTitle className="text-lg">{t("projects.editModal.title")}</DialogTitle>
</DialogHeader>
<EditProjectForm key={project.project_id} project={project} onClose={onClose} onSuccess={onSuccess} />
</DialogContent>

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useFieldArray, useWatch, type UseFormReturn } from "react-hook-form";
import { ChevronDown, CircleAlert, Minus, Plus } from "lucide-react";
@ -41,6 +42,7 @@ interface ProjectBaseFormProps {
export type { ProjectFormValues } from "./projectFormSchema";
export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: ProjectBaseFormProps) {
const { t } = useTranslation();
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
@ -108,25 +110,32 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
}));
const modelOptions = [
{ value: ALL_TEAM_MODELS, label: "All Team Models" },
{ value: ALL_TEAM_MODELS, label: t("projects.form.allTeamModels") },
...modelsToPick.map((model) => ({ value: model, label: getModelDisplayName(model) })),
];
const modelsPlaceholder = selectedTeam ? "Select models" : "Select a team first";
const modelsPlaceholder = selectedTeam ? t("projects.form.selectModels") : t("projects.form.selectTeamFirst");
return (
<div className="mt-6">
<p className="text-xs font-semibold tracking-[0.05em] text-foreground uppercase">Basic Information</p>
<p className="text-xs font-semibold tracking-[0.05em] text-foreground uppercase">
{t("projects.form.basicInformation")}
</p>
<Separator className="mt-2 mb-4" />
<FieldGroup>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField control={form.control} name="project_alias" label="Project Name">
<FormField control={form.control} name="project_alias" label={t("projects.form.projectName")}>
{({ ref, ...field }) => (
<Input {...field} value={field.value ?? ""} ref={ref} placeholder="e.g. Customer Support Bot" />
<Input
{...field}
value={field.value ?? ""}
ref={ref}
placeholder={t("projects.form.projectNamePlaceholder")}
/>
)}
</FormField>
<FormField control={form.control} name="team_id" label="Team">
<FormField control={form.control} name="team_id" label={t("projects.form.team")}>
{({ id, value, onChange, ref: _ref, ...field }) => (
<SearchSelect
{...field}
@ -137,21 +146,21 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
onChange(next);
handleTeamChange(next);
}}
placeholder="Search or select a team"
placeholder={t("projects.form.teamPlaceholder")}
allowClear
/>
)}
</FormField>
</div>
<FormField control={form.control} name="description" label="Description">
<FormField control={form.control} name="description" label={t("projects.form.description")}>
{({ ref, ...field }) => (
<Textarea
{...field}
value={field.value ?? ""}
ref={ref}
rows={3}
placeholder="Describe the purpose of this project"
placeholder={t("projects.form.descriptionPlaceholder")}
/>
)}
</FormField>
@ -159,8 +168,8 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
<FormField
control={form.control}
name="models"
label="Allowed Models (scoped to selected team's models)"
description={!selectedTeam ? "Select a team first to see available models" : undefined}
label={t("projects.form.allowedModels")}
description={!selectedTeam ? t("projects.form.allowedModelsHint") : undefined}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
@ -194,7 +203,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
</FormField>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
<FormField control={form.control} name="max_budget" label={t("projects.form.maxBudget")}>
{({ ref, value, onChange, ...field }) => (
<InputGroup>
<InputGroupAddon>
@ -237,13 +246,13 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
<ChevronDown
className={`size-4 text-muted-foreground transition-transform ${advancedOpen ? "" : "-rotate-90"}`}
/>
<span className="text-sm font-semibold text-foreground">Advanced Settings</span>
<span className="text-sm font-semibold text-foreground">{t("projects.form.advancedSettings")}</span>
</button>
}
/>
<CollapsibleContent className="px-4 pb-4">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-foreground">Block Project</span>
<span className="text-sm font-semibold text-foreground">{t("projects.form.blockProject")}</span>
<FormField control={form.control} name="isBlocked" className="w-auto">
{({ id, value, onChange, ref: _ref, ...field }) => (
<Switch {...field} id={id} checked={value} onCheckedChange={onChange} />
@ -254,7 +263,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
{isBlocked ? (
<Alert variant="warning" className="mt-3">
<CircleAlert />
<AlertTitle>All API requests using keys under this project will be rejected.</AlertTitle>
<AlertTitle>{t("projects.form.blockedWarning")}</AlertTitle>
</Alert>
) : null}
@ -263,8 +272,8 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
<FormField
control={form.control}
name="guardrails"
label="Guardrails"
description="Select existing guardrails or enter new ones"
label={t("projects.form.guardrails")}
description={t("projects.form.guardrailsHint")}
>
{({ id, value, onChange }) => (
<TagsInput
@ -272,71 +281,96 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
value={value ?? []}
onValueChange={onChange}
options={guardrailsList.map((name) => ({ label: name, value: name }))}
placeholder="Select or enter guardrails"
placeholder={t("projects.form.guardrailsPlaceholder")}
/>
)}
</FormField>
<Separator className="my-4" />
<p className="mb-3 text-sm font-semibold text-foreground">Model-Specific Limits</p>
<p className="mb-3 text-sm font-semibold text-foreground">{t("projects.form.modelLimits.title")}</p>
{modelLimits.fields.map((field, index) => (
<div
key={field.id}
className="mb-2 grid grid-cols-1 items-start gap-2 sm:grid-cols-2 xl:grid-cols-[minmax(0,2fr)_repeat(4,minmax(0,1fr))_auto]"
>
<FormField control={form.control} name={`modelLimits.${index}.model`} label="Model">
<FormField
control={form.control}
name={`modelLimits.${index}.model`}
label={t("projects.form.modelLimits.model")}
>
{({ ref, ...control }) => (
<Input {...control} value={control.value ?? ""} ref={ref} placeholder="Model name (e.g. gpt-4)" />
<Input
{...control}
value={control.value ?? ""}
ref={ref}
placeholder={t("projects.form.modelLimits.modelPlaceholder")}
/>
)}
</FormField>
<FormField control={form.control} name={`modelLimits.${index}.tpm`} label="TPM Limit">
<FormField
control={form.control}
name={`modelLimits.${index}.tpm`}
label={t("projects.form.modelLimits.tpm")}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
placeholder="TPM Limit"
placeholder={t("projects.form.modelLimits.tpm")}
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
/>
)}
</FormField>
<FormField control={form.control} name={`modelLimits.${index}.rpm`} label="RPM Limit">
<FormField
control={form.control}
name={`modelLimits.${index}.rpm`}
label={t("projects.form.modelLimits.rpm")}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
placeholder="RPM Limit"
placeholder={t("projects.form.modelLimits.rpm")}
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
/>
)}
</FormField>
<FormField control={form.control} name={`modelLimits.${index}.itpm`} label="Input TPM Limit">
<FormField
control={form.control}
name={`modelLimits.${index}.itpm`}
label={t("projects.form.modelLimits.itpm")}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
placeholder="Input TPM Limit"
placeholder={t("projects.form.modelLimits.itpm")}
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
/>
)}
</FormField>
<FormField control={form.control} name={`modelLimits.${index}.otpm`} label="Output TPM Limit">
<FormField
control={form.control}
name={`modelLimits.${index}.otpm`}
label={t("projects.form.modelLimits.otpm")}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
placeholder="Output TPM Limit"
placeholder={t("projects.form.modelLimits.otpm")}
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
/>
@ -348,7 +382,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
size="icon-sm"
className="mt-1 text-destructive"
onClick={() => modelLimits.remove(index)}
aria-label={`Remove model limit ${index + 1}`}
aria-label={t("projects.form.modelLimits.remove", { number: index + 1 })}
>
<Minus />
</Button>
@ -361,22 +395,32 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
onClick={() => modelLimits.append(emptyModelLimit)}
>
<Plus />
Add Model Limit
{t("projects.form.modelLimits.add")}
</Button>
<Separator className="my-4" />
<p className="mb-3 text-sm font-semibold text-foreground">Metadata</p>
<p className="mb-3 text-sm font-semibold text-foreground">{t("projects.form.metadata.title")}</p>
{metadata.fields.map((field, index) => (
<div key={field.id} className="mb-2 flex items-start gap-2">
<FormField control={form.control} name={`metadata.${index}.key`}>
{({ ref, ...control }) => (
<Input {...control} value={control.value ?? ""} ref={ref} placeholder="Key" />
<Input
{...control}
value={control.value ?? ""}
ref={ref}
placeholder={t("projects.form.metadata.key")}
/>
)}
</FormField>
<FormField control={form.control} name={`metadata.${index}.value`}>
{({ ref, ...control }) => (
<Input {...control} value={control.value ?? ""} ref={ref} placeholder="Value" />
<Input
{...control}
value={control.value ?? ""}
ref={ref}
placeholder={t("projects.form.metadata.value")}
/>
)}
</FormField>
<Button
@ -385,7 +429,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
size="icon-sm"
className="mt-1 text-destructive"
onClick={() => metadata.remove(index)}
aria-label={`Remove metadata pair ${index + 1}`}
aria-label={t("projects.form.metadata.remove", { number: index + 1 })}
>
<Minus />
</Button>
@ -398,7 +442,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
onClick={() => metadata.append({ key: "", value: "" })}
>
<Plus />
Add Key-Value Pair
{t("projects.form.metadata.add")}
</Button>
</CollapsibleContent>
</Collapsible>

View file

@ -1,5 +1,7 @@
import { z } from "zod";
import i18n from "@/i18n/i18n";
export const ALL_TEAM_MODELS = "all-team-models";
const repeatsEarlierValue = (values: readonly string[], index: number): boolean =>
@ -39,14 +41,22 @@ export const projectFormSchema = z
const models = (values.modelLimits ?? []).map((entry) => entry.model);
models.forEach((_, index) => {
if (repeatsEarlierValue(models, index)) {
ctx.addIssue({ code: "custom", message: "Duplicate model", path: ["modelLimits", index, "model"] });
ctx.addIssue({
code: "custom",
message: i18n.t("projects.form.errors.duplicateModel"),
path: ["modelLimits", index, "model"],
});
}
});
const keys = (values.metadata ?? []).map((entry) => entry.key);
keys.forEach((_, index) => {
if (repeatsEarlierValue(keys, index)) {
ctx.addIssue({ code: "custom", message: "Duplicate key", path: ["metadata", index, "key"] });
ctx.addIssue({
code: "custom",
message: i18n.t("projects.form.errors.duplicateKey"),
path: ["metadata", index, "key"],
});
}
});
});

View file

@ -7,6 +7,7 @@ import { isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { Folder, Plus, SearchIcon, X } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
@ -16,6 +17,7 @@ import { ProjectsTable } from "./ProjectsTable";
import { useClearProjectKeysTableState, useProjectsTableState } from "./useProjectsUrlState";
export function ProjectsPage() {
const { t } = useTranslation();
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
const { userId } = useAuthorized();
@ -66,14 +68,14 @@ export function ProjectsPage() {
<PageHeader>
<PageHeaderTitle>
<Folder />
Projects
{t("projects.page.title")}
</PageHeaderTitle>
<PageHeaderDescription>Manage projects within your teams</PageHeaderDescription>
<PageHeaderDescription>{t("projects.page.description")}</PageHeaderDescription>
{canCreateProject && (
<PageHeaderControls>
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
{t("projects.page.createButton")}
</Button>
</PageHeaderControls>
)}
@ -86,13 +88,17 @@ export function ProjectsPage() {
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search projects by name, ID, description, or team..."
placeholder={t("projects.page.searchPlaceholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<InputGroupButton
size="icon-xs"
aria-label={t("projects.page.clearSearch")}
onClick={() => setSearchText("")}
>
<X />
</InputGroupButton>
</InputGroupAddon>

View file

@ -3,6 +3,7 @@
import { SortingState } from "@tanstack/react-table";
import { FolderKanban } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { DataTable, DataTablePagination } from "@/components/shared/DataTable";
@ -22,16 +23,17 @@ interface ProjectsTableProps {
const PAGE_SIZE_OPTIONS = [PROJECTS_DEFAULT_PAGE_SIZE, 25, 50];
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<FolderKanban className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{isFiltered ? "No matching projects" : "No projects yet"}
{isFiltered ? t("projects.table.emptyState.noMatchTitle") : t("projects.table.emptyState.title")}
</div>
<div className="text-sm text-muted-foreground">
{isFiltered ? "Try a different search term." : "Create a project to organize keys within your teams."}
{isFiltered ? t("projects.table.emptyState.noMatchDescription") : t("projects.table.emptyState.description")}
</div>
</div>
);
@ -45,14 +47,15 @@ export function ProjectsTable({
teamAliasMap,
isTeamsLoading,
}: ProjectsTableProps) {
const { t } = useTranslation();
const [sorting, setSorting] = useState<SortingState>([]);
const { pagination, onPaginationChange } = useProjectsTableState();
const pageSize = PAGE_SIZE_OPTIONS.includes(pagination.pageSize) ? pagination.pageSize : PROJECTS_DEFAULT_PAGE_SIZE;
const columns = useMemo(() => {
const deps = { onProjectClick, teamAliasMap, isTeamsLoading };
const deps = { onProjectClick, teamAliasMap, isTeamsLoading, t };
return getProjectsTableColumns(deps);
}, [onProjectClick, teamAliasMap, isTeamsLoading]);
}, [onProjectClick, teamAliasMap, isTeamsLoading, t]);
const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1);
const pageIndex = pagination.pageIndex < pageCount ? pagination.pageIndex : 0;
@ -80,7 +83,7 @@ export function ProjectsTable({
/>
)}
isLoading={isLoading}
loadingMessage="Loading projects…"
loadingMessage={t("projects.table.loading")}
noDataMessage={<EmptyState isFiltered={isFiltered} />}
size="compact"
/>

View file

@ -1,7 +1,9 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { LayersIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { DataTableSortHeader } from "@/components/shared/DataTable";
@ -36,10 +38,11 @@ function ProjectTeamCell({
}
function ProjectModelsCell({ project }: { project: ProjectResponse }) {
const { t } = useTranslation();
const models = project.models ?? [];
return (
<CellTooltip
content={models.length > 0 ? models.join(", ") : "No models"}
content={models.length > 0 ? models.join(", ") : t("projects.table.cells.noModels")}
trigger={
<Badge variant="outline" className="cursor-default gap-1.5 font-normal">
<LayersIcon className="size-3.5" />
@ -54,18 +57,20 @@ interface ProjectsTableColumnsDeps {
onProjectClick: (projectId: string) => void;
teamAliasMap: Map<string, string>;
isTeamsLoading: boolean;
t: TFunction;
}
export const getProjectsTableColumns = ({
onProjectClick,
teamAliasMap,
isTeamsLoading,
t,
}: ProjectsTableColumnsDeps): ColumnDef<ProjectResponse>[] => [
{
id: "project_id",
accessorKey: "project_id",
meta: { title: "ID" },
header: "ID",
meta: { title: t("projects.table.columns.id") },
header: t("projects.table.columns.id"),
size: 190,
enableSorting: false,
cell: ({ row }) => (
@ -79,8 +84,8 @@ export const getProjectsTableColumns = ({
{
id: "project_alias",
accessorFn: (row) => row.project_alias ?? "",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
meta: { title: t("projects.table.columns.name") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("projects.table.columns.name")} />,
size: 200,
enableSorting: true,
cell: ({ row }) => (
@ -92,8 +97,8 @@ export const getProjectsTableColumns = ({
{
id: "team",
accessorFn: (row) => teamAliasMap.get(row.team_id ?? "") ?? "",
meta: { title: "Team" },
header: ({ column }) => <DataTableSortHeader column={column} title="Team" />,
meta: { title: t("projects.table.columns.team") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("projects.table.columns.team")} />,
size: 180,
enableSorting: true,
cell: ({ row }) => (
@ -102,8 +107,8 @@ export const getProjectsTableColumns = ({
},
{
id: "models",
meta: { title: "Models", skeleton: "badge" },
header: "Models",
meta: { title: t("projects.table.columns.models"), skeleton: "badge" },
header: t("projects.table.columns.models"),
size: 110,
enableSorting: false,
cell: ({ row }) => <ProjectModelsCell project={row.original} />,
@ -111,14 +116,14 @@ export const getProjectsTableColumns = ({
{
id: "status",
accessorKey: "blocked",
meta: { title: "Status", skeleton: "badge" },
header: "Status",
meta: { title: t("projects.table.columns.status"), skeleton: "badge" },
header: t("projects.table.columns.status"),
size: 110,
enableSorting: false,
cell: ({ row }) => (
<StatusBadge
tone={row.original.blocked ? "error" : "success"}
label={row.original.blocked ? "Blocked" : "Active"}
label={row.original.blocked ? t("projects.table.cells.blocked") : t("projects.table.cells.active")}
/>
),
},
@ -126,8 +131,8 @@ export const getProjectsTableColumns = ({
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
meta: { title: t("projects.table.columns.created") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("projects.table.columns.created")} />,
size: 140,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
@ -135,8 +140,8 @@ export const getProjectsTableColumns = ({
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated" },
header: "Updated",
meta: { title: t("projects.table.columns.updated") },
header: t("projects.table.columns.updated"),
size: 140,
enableSorting: false,
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,

View file

@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { ExternalLink, ChevronDown } from "lucide-react";
import i18n from "@/i18n/i18n";
interface DocMenuItem {
label: string;
@ -12,7 +13,7 @@ interface DocsMenuProps {
className?: string;
}
export const DocsMenu: React.FC<DocsMenuProps> = ({ items, children = "Docs", className = "" }) => {
export const DocsMenu: React.FC<DocsMenuProps> = ({ items, children = i18n.t("docsMenu.trigger"), className = "" }) => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);

View file

@ -26,6 +26,7 @@ import { ColumnFiltersState, functionalUpdate, OnChangeFn } from "@tanstack/reac
import { KeyRound } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import KeyInfoView from "../templates/key_info_view";
@ -89,6 +90,7 @@ const appliedFilter = (filters: ColumnFiltersState, column: FilterColumn): strin
};
export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
const { t } = useTranslation();
const { data: fetchedOrganizations } = useOrganizations();
const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]);
const { data: fetchedTeams } = useAllTeams();
@ -162,7 +164,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
}),
[allTeams, organizations, teamMemberBudgets, setSelectedKeyId, applyUserBudgetToTeamKeys],
);
const columns = useMemo(() => getKeyTableColumns(columnDeps), [columnDeps]);
const columns = useMemo(() => getKeyTableColumns(columnDeps, t), [columnDeps, t]);
const selectedKeyFromList = useMemo(
() => keyList.find((key) => key.token === selectedKeyId),
@ -223,7 +225,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
if (selectedKeyId) {
if (!selectedKey && !selectedKeyLoadFailed) {
return <div className="p-4 text-sm text-muted-foreground">Loading key...</div>;
return <div className="p-4 text-sm text-muted-foreground">{t("apiKeys.virtualKeysTable.loadingKey")}</div>;
}
return (
<div className="w-full h-full overflow-hidden">
@ -244,9 +246,9 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
<PageHeader>
<PageHeaderTitle>
<KeyRound />
Virtual Keys
{t("apiKeys.virtualKeysTable.title")}
</PageHeaderTitle>
<PageHeaderDescription>Every key that authenticates requests to the gateway.</PageHeaderDescription>
<PageHeaderDescription>{t("apiKeys.virtualKeysTable.description")}</PageHeaderDescription>
{headerActions != null && <PageHeaderControls>{headerActions}</PageHeaderControls>}
</PageHeader>
<DataTable
@ -269,8 +271,8 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
columnResizeMode="onChange"
isLoading={isPending || isPlaceholderData}
isError={isError}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
loadingMessage={t("apiKeys.virtualKeysTable.loading")}
noDataMessage={t("apiKeys.virtualKeysTable.empty")}
fillHeight
size="compact"
toolbar={(table) => (
@ -279,7 +281,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
table={table}
searchValue={searchInput}
onSearchChange={setSearch}
searchPlaceholder="Search by key alias or ID…"
searchPlaceholder={t("apiKeys.virtualKeysTable.searchPlaceholder")}
onRefresh={() => refetch?.()}
isRefreshing={isFetching}
onOpenFilters={() => setFiltersOpen(true)}
@ -290,51 +292,51 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down virtual keys"
title={t("apiKeys.virtualKeysTable.filters.title")}
description={t("apiKeys.virtualKeysTable.filters.description")}
>
{({ get, set }) => (
<>
<DataTableFilterField label="Team">
<DataTableFilterField label={t("apiKeys.virtualKeysTable.filters.team")}>
<SearchSelect
options={teamOptions}
value={(get("team_id") as string) || undefined}
onValueChange={(value) => set("team_id", value ?? undefined)}
placeholder="Select a team…"
emptyText="No teams found"
placeholder={t("apiKeys.virtualKeysTable.filters.teamPlaceholder")}
emptyText={t("apiKeys.virtualKeysTable.filters.teamEmpty")}
/>
</DataTableFilterField>
<DataTableFilterField label="Organization">
<DataTableFilterField label={t("apiKeys.virtualKeysTable.filters.organization")}>
<SearchSelect
options={orgOptions}
value={(get("org_id") as string) || undefined}
onValueChange={(value) => set("org_id", value ?? undefined)}
placeholder="Select an organization…"
emptyText="No organizations found"
placeholder={t("apiKeys.virtualKeysTable.filters.organizationPlaceholder")}
emptyText={t("apiKeys.virtualKeysTable.filters.organizationEmpty")}
/>
</DataTableFilterField>
<DataTableFilterField label="User ID">
<DataTableFilterField label={t("apiKeys.virtualKeysTable.filters.userId")}>
<Input
value={(get("user_id") as string) ?? ""}
onChange={(event) => set("user_id", event.target.value)}
placeholder="Enter User ID…"
placeholder={t("apiKeys.virtualKeysTable.filters.userIdPlaceholder")}
/>
</DataTableFilterField>
<DataTableFilterField label="Key ID">
<DataTableFilterField label={t("apiKeys.virtualKeysTable.filters.keyId")}>
<Input
value={(get("key_hash") as string) ?? ""}
onChange={(event) => set("key_hash", event.target.value)}
placeholder="Enter Key ID…"
placeholder={t("apiKeys.virtualKeysTable.filters.keyIdPlaceholder")}
/>
</DataTableFilterField>
<DataTableFilterField label="Status">
<DataTableFilterField label={t("apiKeys.virtualKeysTable.filters.status")}>
<Select
items={STATUS_FILTER_ITEMS}
value={(get("status") as string) || ALL_STATUSES}
onValueChange={(value) => set("status", value === ALL_STATUSES ? undefined : value)}
>
<SelectTrigger className="w-full" aria-label="Status">
<SelectValue placeholder="All statuses" />
<SelectTrigger className="w-full" aria-label={t("apiKeys.virtualKeysTable.filters.status")}>
<SelectValue placeholder={t("apiKeys.virtualKeysTable.filters.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_ITEMS.map((item) => (

View file

@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import { Info } from "lucide-react";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable";
import {
@ -55,32 +56,39 @@ export const KEY_TABLE_SORT_FIELDS: readonly string[] = [
...KEY_RATE_LIMIT_SORT_FIELDS.map((field) => field.id),
];
const getKeyStatus = (key: KeyResponse): KeyStatus => {
const getKeyStatus = (key: KeyResponse, t: TFunction): KeyStatus => {
if (key.deleted_at) {
return {
tone: "neutral",
label: "Deleted",
tooltip: `Deleted ${new Date(key.deleted_at).toLocaleString()}${key.deleted_by ? ` by ${key.deleted_by}` : ""}. Kept for audit and spend history; requests using this key are rejected.`,
label: t("apiKeys.keyTableColumns.status.deleted"),
tooltip: t("apiKeys.keyTableColumns.status.deletedTooltip", {
date: new Date(key.deleted_at).toLocaleString(),
deletedBy: key.deleted_by ? t("apiKeys.keyTableColumns.status.deletedBy", { user: key.deleted_by }) : "",
}),
};
}
if (key.blocked === true) {
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
return {
tone: "error",
label: "Blocked",
label: t("apiKeys.keyTableColumns.status.blocked"),
tooltip: isScimBlocked
? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)."
: "Blocked. Requests using this key will be rejected with 401.",
? t("apiKeys.keyTableColumns.status.scimBlockedTooltip")
: t("apiKeys.keyTableColumns.status.blockedTooltip"),
};
}
const expiresAt = key.expires ? Date.parse(key.expires) : Number.NaN;
if (!Number.isNaN(expiresAt) && expiresAt < Date.now()) {
return { tone: "warning", label: "Expired", tooltip: "This key has passed its expiry date." };
return {
tone: "warning",
label: t("apiKeys.keyTableColumns.status.expired"),
tooltip: t("apiKeys.keyTableColumns.status.expiredTooltip"),
};
}
return {
tone: "success",
label: "Active",
tooltip: "This key is not blocked and has not expired.",
label: t("apiKeys.keyTableColumns.status.active"),
tooltip: t("apiKeys.keyTableColumns.status.activeTooltip"),
};
};
@ -102,18 +110,15 @@ interface KeyTableColumnsDeps {
applyUserBudgetToTeamKeys: boolean;
}
export const getKeyTableColumns = ({
allTeams,
organizations,
teamMemberBudgets,
onSelectKey,
applyUserBudgetToTeamKeys,
}: KeyTableColumnsDeps): ColumnDef<KeyResponse>[] => [
export const getKeyTableColumns = (
{ allTeams, organizations, teamMemberBudgets, onSelectKey, applyUserBudgetToTeamKeys }: KeyTableColumnsDeps,
t: TFunction,
): ColumnDef<KeyResponse>[] => [
{
id: "key_alias",
accessorKey: "key_alias",
meta: {
title: "Key",
title: t("apiKeys.keyTableColumns.columns.key"),
renderSkeleton: () => (
<div className="flex flex-col gap-1 py-1">
<Skeleton className="h-4 w-32" />
@ -124,11 +129,13 @@ export const getKeyTableColumns = ({
</div>
),
},
header: ({ column }) => <DataTableSortHeader column={column} title="Key" variant="header-cycle" />,
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.key")} variant="header-cycle" />
),
size: 260,
enableSorting: true,
cell: ({ row }) => {
const status = getKeyStatus(row.original);
const status = getKeyStatus(row.original, t);
return (
<IdentityCell
title={row.original.key_alias || "-"}
@ -149,8 +156,10 @@ export const getKeyTableColumns = ({
{
id: "token",
accessorKey: "token",
meta: { title: "Key ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
meta: { title: t("apiKeys.keyTableColumns.columns.keyId") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.keyId")} variant="header-cycle" />
),
size: 120,
enableSorting: true,
cell: (info) => <IdCell value={info.getValue() as string | null} onClick={() => onSelectKey(info.row.original)} />,
@ -158,8 +167,8 @@ export const getKeyTableColumns = ({
{
id: "team_alias",
accessorKey: "team_id",
meta: { title: "Team" },
header: "Team",
meta: { title: t("apiKeys.keyTableColumns.columns.team") },
header: t("apiKeys.keyTableColumns.columns.team"),
size: 120,
enableSorting: false,
cell: (info) => {
@ -178,8 +187,8 @@ export const getKeyTableColumns = ({
{
id: "organization_alias",
accessorKey: "org_id",
meta: { title: "Organization" },
header: "Organization",
meta: { title: t("apiKeys.keyTableColumns.columns.organization") },
header: t("apiKeys.keyTableColumns.columns.organization"),
size: 140,
enableSorting: false,
cell: (info) => {
@ -198,9 +207,12 @@ export const getKeyTableColumns = ({
{
id: "user",
accessorKey: "user",
meta: { title: "User" },
meta: { title: t("apiKeys.keyTableColumns.columns.user") },
header: () => (
<InfoHeader label="User" tooltip="Displays the first available value: User Alias, User Email, or User ID." />
<InfoHeader
label={t("apiKeys.keyTableColumns.columns.user")}
tooltip={t("apiKeys.keyTableColumns.columns.userTooltip")}
/>
),
size: 160,
enableSorting: false,
@ -219,8 +231,14 @@ export const getKeyTableColumns = ({
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
meta: { title: t("apiKeys.keyTableColumns.columns.createdAt") },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("apiKeys.keyTableColumns.columns.createdAt")}
variant="header-cycle"
/>
),
size: 120,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
@ -228,8 +246,8 @@ export const getKeyTableColumns = ({
{
id: "created_by",
accessorKey: "created_by",
meta: { title: "Created By" },
header: "Created By",
meta: { title: t("apiKeys.keyTableColumns.columns.createdBy") },
header: t("apiKeys.keyTableColumns.columns.createdBy"),
size: 160,
enableSorting: false,
cell: (info) => {
@ -249,41 +267,67 @@ export const getKeyTableColumns = ({
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" variant="header-cycle" />,
meta: { title: t("apiKeys.keyTableColumns.columns.updatedAt") },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("apiKeys.keyTableColumns.columns.updatedAt")}
variant="header-cycle"
/>
),
size: 120,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
cell: (info) => (
<DateCell
value={info.getValue() as string | null}
precision="date"
fallback={t("apiKeys.keyTableColumns.cells.never")}
/>
),
},
{
id: "last_active",
accessorKey: "last_active",
meta: { title: "Last Active" },
meta: { title: t("apiKeys.keyTableColumns.columns.lastActive") },
header: ({ column }) => (
<InfoHeader
label={<DataTableSortHeader column={column} title="Last Active" />}
tooltip="This is a new field and is not backfilled. Only new key usage will update this value."
label={<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.lastActive")} />}
tooltip={t("apiKeys.keyTableColumns.columns.lastActiveTooltip")}
/>
),
size: 130,
enableSorting: true,
sortDescFirst: false,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
cell: (info) => (
<DateCell
value={info.getValue() as string | null}
precision="date"
fallback={t("apiKeys.keyTableColumns.cells.unknown")}
/>
),
},
{
id: "expires",
accessorKey: "expires",
meta: { title: "Expires" },
header: ({ column }) => <DataTableSortHeader column={column} title="Expires" />,
meta: { title: t("apiKeys.keyTableColumns.columns.expires") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.expires")} />
),
size: 120,
enableSorting: true,
sortDescFirst: false,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
cell: (info) => (
<DateCell
value={info.getValue() as string | null}
precision="date"
fallback={t("apiKeys.keyTableColumns.cells.never")}
/>
),
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
meta: { title: t("apiKeys.keyTableColumns.columns.spendBudget"), skeleton: "meter", numeric: true },
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={SPEND_BUDGET_SORT_FIELDS} />,
size: 240,
enableSorting: true,
@ -321,11 +365,11 @@ export const getKeyTableColumns = ({
{
id: "total_spend",
accessorKey: "total_spend",
meta: { title: "Lifetime Spend" },
meta: { title: t("apiKeys.keyTableColumns.columns.lifetimeSpend") },
header: ({ column }) => (
<InfoHeader
label={<DataTableSortHeader column={column} title="Lifetime Spend" />}
tooltip="Cumulative spend across every budget period. Budget resets do not touch this value. Lifetime tracking started with LiteLLM v1.103.0 on September 19, 2026, so keys created earlier only count spend since that upgrade."
label={<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.lifetimeSpend")} />}
tooltip={t("apiKeys.keyTableColumns.columns.lifetimeSpendTooltip")}
/>
),
size: 130,
@ -336,18 +380,22 @@ export const getKeyTableColumns = ({
{
id: "budget_reset_at",
accessorKey: "budget_reset_at",
meta: { title: "Budget Reset" },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget Reset" />,
meta: { title: t("apiKeys.keyTableColumns.columns.budgetReset") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.budgetReset")} />
),
size: 130,
enableSorting: true,
sortDescFirst: false,
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
cell: (info) => (
<DateCell value={info.getValue() as string | null} fallback={t("apiKeys.keyTableColumns.cells.never")} />
),
},
{
id: "models",
accessorKey: "models",
meta: { title: "Models", skeleton: "chips" },
header: "Models",
meta: { title: t("apiKeys.keyTableColumns.columns.models"), skeleton: "chips" },
header: t("apiKeys.keyTableColumns.columns.models"),
size: 220,
enableSorting: false,
cell: (info) => (
@ -360,7 +408,7 @@ export const getKeyTableColumns = ({
},
{
id: "rate_limits",
meta: { title: "Rate Limits" },
meta: { title: t("apiKeys.keyTableColumns.columns.rateLimits") },
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={KEY_RATE_LIMIT_SORT_FIELDS} />,
size: 140,
enableSorting: false,
@ -368,8 +416,14 @@ export const getKeyTableColumns = ({
const key = row.original;
return (
<div className="text-xs">
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
<div>
{t("apiKeys.keyTableColumns.cells.tpm")}
{key.tpm_limit !== null ? key.tpm_limit : t("apiKeys.keyTableColumns.cells.unlimited")}
</div>
<div>
{t("apiKeys.keyTableColumns.cells.rpm")}
{key.rpm_limit !== null ? key.rpm_limit : t("apiKeys.keyTableColumns.cells.unlimited")}
</div>
</div>
);
},

View file

@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import { getAgentsList } from "../networking";
import i18n from "@/i18n/i18n";
interface Agent {
agent_id: string;
@ -26,9 +28,10 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
value,
className,
accessToken,
placeholder = "Select agents",
placeholder = i18n.t("agentSelector.placeholder"),
disabled = false,
}) => {
const { t } = useTranslation();
const [agents, setAgents] = useState<Agent[]>([]);
const [accessGroups, setAccessGroups] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
@ -65,12 +68,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
...accessGroups.map((group) => ({
label: group,
value: `group:${group}`,
description: "Access Group",
description: t("agentSelector.accessGroup"),
})),
...agents.map((agent) => ({
label: `${agent.agent_name || agent.agent_id}`,
value: agent.agent_id,
description: "Agent",
description: t("agentSelector.agent"),
})),
];
@ -91,7 +94,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
value={selectedValues}
onValueChange={handleChange}
placeholder={placeholder}
emptyText="No agents found"
emptyText={t("agentSelector.empty")}
loading={loading}
disabled={disabled}
className={`w-full ${className ?? ""}`}

View file

@ -1,8 +1,10 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Users } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import { useAccessGroups, AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import i18n from "@/i18n/i18n";
export interface AccessGroupSelectorProps {
value?: string[];
@ -25,13 +27,14 @@ export interface AccessGroupSelectorProps {
const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
value,
onChange,
placeholder = "Select access groups",
placeholder = i18n.t("accessGroupSelector.placeholder"),
disabled = false,
style,
className,
showLabel = false,
labelText = "Access Group",
labelText = i18n.t("accessGroupSelector.label"),
}) => {
const { t } = useTranslation();
const { data: accessGroups, isLoading, isError } = useAccessGroups();
// ── Loading skeleton ─────────────────────────────────────────────────────
@ -69,7 +72,7 @@ const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
value={value}
onValueChange={onChange ?? (() => {})}
placeholder={placeholder}
emptyText={isError ? "Failed to load access groups" : "No access groups found"}
emptyText={isError ? t("accessGroupSelector.loadFailed") : t("accessGroupSelector.empty")}
disabled={disabled}
className={`w-full rounded-md ${className ?? ""}`}
/>

View file

@ -1,5 +1,7 @@
import React, { useState } from "react";
import type { ParseKeys } from "i18next";
import { CircleHelp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@ -10,13 +12,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
const PREDEFINED_INTERVALS = ["7d", "30d", "90d", "180d", "365d"] as const;
const INTERVAL_LABELS: Record<string, string> = {
"7d": "7 days",
"30d": "30 days",
"90d": "90 days",
"180d": "180 days",
"365d": "365 days",
custom: "Custom interval",
const INTERVAL_LABELS: Record<string, ParseKeys> = {
"7d": "keyLifecycleSettings.intervals.sevenDays",
"30d": "keyLifecycleSettings.intervals.thirtyDays",
"90d": "keyLifecycleSettings.intervals.ninetyDays",
"180d": "keyLifecycleSettings.intervals.oneEightyDays",
"365d": "keyLifecycleSettings.intervals.threeSixtyFiveDays",
custom: "keyLifecycleSettings.intervals.custom",
};
interface KeyLifecycleSettingsProps {
@ -54,6 +56,7 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onNeverExpireChange,
id,
}) => {
const { t } = useTranslation();
const isCustomInterval = Boolean(rotationInterval) && !PREDEFINED_INTERVALS.includes(rotationInterval as never);
const [showCustomInput, setShowCustomInput] = useState(isCustomInterval);
@ -87,14 +90,12 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
<TooltipProvider>
<div className="space-y-6">
<div className="space-y-4">
<span className="text-sm font-medium text-foreground">Key Expiry Settings</span>
<span className="text-sm font-medium text-foreground">{t("keyLifecycleSettings.expirySettings")}</span>
<div className="space-y-2">
<div className="flex items-center space-x-1 text-sm font-medium text-foreground">
<label htmlFor={durationId}>Expire Key</label>
{hintIcon(
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.",
)}
<label htmlFor={durationId}>{t("keyLifecycleSettings.expireKey")}</label>
{hintIcon(t("keyLifecycleSettings.expireHint"))}
{!isCreateMode && onNeverExpireChange && (
<span className="ml-2 flex items-center gap-2 text-sm font-normal text-muted-foreground">
<Checkbox
@ -103,7 +104,7 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onCheckedChange={handleNeverExpireChange}
/>
<label htmlFor={`${durationId}-never-expire`} className="cursor-pointer">
Never Expire
{t("keyLifecycleSettings.neverExpire")}
</label>
</span>
)}
@ -112,7 +113,9 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
id={durationId}
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value)}
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
placeholder={
isCreateMode ? t("keyLifecycleSettings.createPlaceholder") : t("keyLifecycleSettings.editPlaceholder")
}
className="w-full"
disabled={!isCreateMode && neverExpire}
/>
@ -122,13 +125,13 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
<Separator />
<div className="space-y-4">
<span className="text-sm font-medium text-foreground">Auto-Rotation Settings</span>
<span className="text-sm font-medium text-foreground">{t("keyLifecycleSettings.rotationSettings")}</span>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="flex items-center space-x-1 text-sm font-medium text-foreground">
<span>Enable Auto-Rotation</span>
{hintIcon("Key will automatically regenerate at the specified interval for enhanced security.")}
<span>{t("keyLifecycleSettings.enableRotation")}</span>
{hintIcon(t("keyLifecycleSettings.enableRotationHint"))}
</label>
<Switch checked={autoRotationEnabled} onCheckedChange={onAutoRotationChange} />
</div>
@ -136,10 +139,8 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
{autoRotationEnabled && (
<div className="space-y-2">
<label className="flex items-center space-x-1 text-sm font-medium text-foreground">
<span>Rotation Interval</span>
{hintIcon(
"How often the key should be automatically rotated. Choose the interval that best fits your security requirements.",
)}
<span>{t("keyLifecycleSettings.rotationInterval")}</span>
{hintIcon(t("keyLifecycleSettings.rotationIntervalHint"))}
</label>
<div className="space-y-2">
<Select
@ -147,13 +148,15 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onValueChange={(next: string | null) => next !== null && handleIntervalChange(next)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select interval">
<SelectValue placeholder={t("keyLifecycleSettings.selectInterval")}>
{(selected: string | null) =>
selected === null ? (
"Select interval"
t("keyLifecycleSettings.selectInterval")
) : (
<span title={INTERVAL_LABELS[selected] ?? selected}>
{INTERVAL_LABELS[selected] ?? selected}
<span
title={Object.hasOwn(INTERVAL_LABELS, selected) ? t(INTERVAL_LABELS[selected]) : selected}
>
{Object.hasOwn(INTERVAL_LABELS, selected) ? t(INTERVAL_LABELS[selected]) : selected}
</span>
)
}
@ -161,12 +164,12 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
</SelectTrigger>
<SelectContent>
{PREDEFINED_INTERVALS.map((interval) => (
<SelectItem key={interval} value={interval} title={INTERVAL_LABELS[interval]}>
{INTERVAL_LABELS[interval]}
<SelectItem key={interval} value={interval} title={t(INTERVAL_LABELS[interval])}>
{t(INTERVAL_LABELS[interval])}
</SelectItem>
))}
<SelectItem value="custom" title={INTERVAL_LABELS.custom}>
{INTERVAL_LABELS.custom}
<SelectItem value="custom" title={t(INTERVAL_LABELS.custom)}>
{t(INTERVAL_LABELS.custom)}
</SelectItem>
</SelectContent>
</Select>
@ -176,11 +179,9 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
<Input
value={customInterval}
onChange={handleCustomIntervalChange}
placeholder="e.g., 1s, 5m, 2h, 14d"
placeholder={t("keyLifecycleSettings.customPlaceholder")}
/>
<div className="text-xs text-muted-foreground">
Supported formats: seconds (s), minutes (m), hours (h), days (d)
</div>
<div className="text-xs text-muted-foreground">{t("keyLifecycleSettings.supportedFormats")}</div>
</div>
)}
</div>
@ -190,8 +191,7 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
{autoRotationEnabled && (
<div className="rounded-md bg-info/10 p-3 text-sm text-info">
When rotation occurs, you&apos;ll receive a notification with the new key. The old key will be deactivated
after a brief grace period.
{t("keyLifecycleSettings.rotationNotice")}
</div>
)}
</div>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useId } from "react";
import { useTranslation } from "react-i18next";
import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline";
import { Button } from "@/components/ui/button";
import { Card, CardTitle } from "@/components/ui/card";
@ -26,6 +27,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
onAliasUpdate,
showExampleConfig = true,
}) => {
const { t } = useTranslation();
const [aliases, setAliases] = useState<AliasItem[]>([]);
const [newAlias, setNewAlias] = useState<{ aliasName: string; targetModel: string | null }>({
aliasName: "",
@ -48,13 +50,13 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
const handleAddAlias = () => {
if (!newAlias.aliasName || !newAlias.targetModel) {
toast.fromError("Please provide both alias name and target model");
toast.fromError(t("modelAliasManager.toasts.missingFields"));
return;
}
// Check for duplicate alias names
if (aliases.some((alias) => alias.aliasName === newAlias.aliasName)) {
toast.fromError("An alias with this name already exists");
toast.fromError(t("modelAliasManager.toasts.duplicate"));
return;
}
@ -78,7 +80,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
onAliasUpdate(aliasObject);
}
toast.success("Alias added successfully");
toast.success(t("modelAliasManager.toasts.added"));
};
const handleEditAlias = (alias: AliasItem) => {
@ -89,13 +91,13 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
if (!editingAlias) return;
if (!editingAlias.aliasName || !editingAlias.targetModel) {
toast.fromError("Please provide both alias name and target model");
toast.fromError(t("modelAliasManager.toasts.missingFields"));
return;
}
// Check for duplicate alias names (excluding current alias)
if (aliases.some((alias) => alias.id !== editingAlias.id && alias.aliasName === editingAlias.aliasName)) {
toast.fromError("An alias with this name already exists");
toast.fromError(t("modelAliasManager.toasts.duplicate"));
return;
}
@ -115,7 +117,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
onAliasUpdate(aliasObject);
}
toast.success("Alias updated successfully");
toast.success(t("modelAliasManager.toasts.updated"));
};
const handleCancelEdit = () => {
@ -136,7 +138,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
onAliasUpdate(aliasObject);
}
toast.success("Alias deleted successfully");
toast.success(t("modelAliasManager.toasts.deleted"));
};
// Convert current aliases to object for config example
@ -151,11 +153,11 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
return (
<div className="mt-4">
<div className="mb-6">
<p className="mb-2 text-sm font-medium text-foreground">Add New Alias</p>
<p className="mb-2 text-sm font-medium text-foreground">{t("modelAliasManager.addTitle")}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label htmlFor={aliasNameId} className="mb-1 block text-xs text-muted-foreground">
Alias Name
{t("modelAliasManager.aliasName")}
</label>
<Input
id={aliasNameId}
@ -167,15 +169,15 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
aliasName: e.target.value,
})
}
placeholder="e.g., gpt-4o"
placeholder={t("modelAliasManager.aliasPlaceholder")}
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Target Model</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("modelAliasManager.targetModel")}</label>
<ModelSelector
accessToken={accessToken}
value={newAlias.targetModel}
placeholder="Select target model"
placeholder={t("modelAliasManager.targetPlaceholder")}
onChange={(value) =>
setNewAlias({
...newAlias,
@ -188,7 +190,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
<div className="flex items-end">
<Button onClick={handleAddAlias} disabled={!newAlias.aliasName || !newAlias.targetModel}>
<PlusCircleIcon className="mr-1 h-4 w-4" />
Add Alias
{t("modelAliasManager.addAlias")}
</Button>
</div>
</div>
@ -200,9 +202,9 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHeader>
<TableRow>
<TableHead className="py-1 h-8">Alias Name</TableHead>
<TableHead className="py-1 h-8">Target Model</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
<TableHead className="py-1 h-8">{t("modelAliasManager.columns.aliasName")}</TableHead>
<TableHead className="py-1 h-8">{t("modelAliasManager.columns.targetModel")}</TableHead>
<TableHead className="py-1 h-8">{t("modelAliasManager.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -213,7 +215,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
<TableCell className="py-0.5">
<Input
type="text"
aria-label="Edit alias name"
aria-label={t("modelAliasManager.editAliasNameAria")}
value={editingAlias.aliasName}
onChange={(e) =>
setEditingAlias({
@ -241,10 +243,10 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
<TableCell className="py-0.5 whitespace-nowrap">
<div className="flex space-x-2">
<Button variant="secondary" size="xs" onClick={handleUpdateAlias}>
Save
{t("modelAliasManager.save")}
</Button>
<Button variant="outline" size="xs" onClick={handleCancelEdit}>
Cancel
{t("modelAliasManager.cancel")}
</Button>
</div>
</TableCell>
@ -258,7 +260,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
<Button
variant="secondary"
size="icon-xs"
aria-label={`Edit ${alias.aliasName}`}
aria-label={t("modelAliasManager.editAria", { name: alias.aliasName })}
onClick={() => handleEditAlias(alias)}
>
<PencilIcon className="h-3 w-3" />
@ -266,7 +268,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
<Button
variant="destructive"
size="icon-xs"
aria-label={`Delete ${alias.aliasName}`}
aria-label={t("modelAliasManager.deleteAria", { name: alias.aliasName })}
onClick={() => deleteAlias(alias.id)}
>
<TrashIcon className="h-3 w-3" />
@ -280,7 +282,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
{aliases.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="py-0.5 text-center text-sm text-muted-foreground">
No aliases added yet. Add a new alias above.
{t("modelAliasManager.empty")}
</TableCell>
</TableRow>
)}
@ -292,8 +294,8 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
{/* Configuration Example */}
{showExampleConfig && (
<Card className="px-6">
<CardTitle className="mb-4">Configuration Example</CardTitle>
<p className="mb-4 text-muted-foreground">Here&apos;s how your current aliases would look in the config:</p>
<CardTitle className="mb-4">{t("modelAliasManager.configExampleTitle")}</CardTitle>
<p className="mb-4 text-muted-foreground">{t("modelAliasManager.configExampleDescription")}</p>
<div className="rounded-lg bg-muted p-4 font-mono text-sm">
<div className="text-foreground">
model_aliases:

View file

@ -1,6 +1,8 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Organization } from "../networking";
import i18n from "@/i18n/i18n";
interface OrganizationDropdownProps {
organizations?: Organization[] | null;
@ -20,9 +22,10 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
disabled,
loading,
style,
placeholder = "All Organizations",
placeholder = i18n.t("organizationDropdown.placeholder"),
id,
}) => {
const { t } = useTranslation();
return (
<div style={{ minWidth: 280, ...style }}>
<SearchSelect
@ -34,7 +37,7 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
value={value}
onValueChange={(organizationId) => onChange?.(organizationId)}
placeholder={placeholder}
emptyText={loading ? "Loading organizations…" : "No organizations found"}
emptyText={loading ? t("organizationDropdown.loading") : t("organizationDropdown.empty")}
disabled={disabled}
inputId={id}
/>

View file

@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import { getPassThroughEndpointsCall } from "../networking";
import i18n from "@/i18n/i18n";
interface PassThroughRoutesSelectorProps {
onChange?: (selectedRoutes: string[]) => void;
@ -27,10 +29,11 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
value,
className,
accessToken,
placeholder = "Select pass through routes",
placeholder = i18n.t("passThroughRoutesSelector.placeholder"),
disabled = false,
teamId,
}) => {
const { t } = useTranslation();
const [passThroughRoutes, setPassThroughRoutes] = useState<MultiSelectOption[]>([]);
const [loading, setLoading] = useState(false);
@ -60,7 +63,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
value={value}
onValueChange={(routes) => onChange?.(routes)}
placeholder={placeholder}
emptyText="No pass through routes found"
emptyText={t("passThroughRoutesSelector.empty")}
loading={loading}
allowCustomValues
disabled={disabled}

View file

@ -1,4 +1,5 @@
import React from "react";
import { Trans } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import LoggingSettings from "../team/LoggingSettings";
@ -30,12 +31,19 @@ export function PremiumLoggingSettings({
</div>
<div className="p-3 bg-muted border border-border rounded-lg">
<p className="text-sm text-muted-foreground">
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
all free users. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
here
</a>
.
<Trans
i18nKey="premiumLoggingSettings.enterpriseNotice"
components={{
docs: (
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
/>
),
}}
/>
</p>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
@ -22,6 +23,7 @@ const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
teamId,
id,
}) => {
const { t } = useTranslation();
const filtered = teamId ? projects?.filter((p) => p.team_id === teamId) : projects;
return (
@ -37,8 +39,8 @@ const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
}
value={value}
onValueChange={(projectId) => onChange?.(projectId)}
placeholder="Search or select a project"
emptyText={loading ? "Loading projects…" : "No projects found"}
placeholder={t("projectDropdown.placeholder")}
emptyText={loading ? t("projectDropdown.loading") : t("projectDropdown.empty")}
disabled={disabled}
inputId={id}
/>

View file

@ -1,5 +1,7 @@
import React from "react";
import type { ParseKeys, TFunction } from "i18next";
import { CircleHelp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -30,38 +32,39 @@ interface RateLimitTypeFormItemProps {
"aria-describedby"?: string | undefined;
}
const rateLimitTypeOptions = (type: RateLimitType): RateLimitTypeOption[] => {
const rateLimitTypeOptions = (type: RateLimitType, t: TFunction): RateLimitTypeOption[] => {
const upper = type.toUpperCase();
const lower = type.toLowerCase();
return [
{
value: "best_effort_throughput",
label: "Default",
description: `Best effort throughput - no error if we're overallocating ${lower} (Team/Key Limits checked at runtime).`,
label: t("rateLimitTypeFormItem.options.default"),
description: t("rateLimitTypeFormItem.options.defaultDescription", { lower }),
},
{
value: "guaranteed_throughput",
label: "Guaranteed throughput",
description: `Guaranteed throughput - raise an error if we're overallocating ${lower} (also checks model-specific limits)`,
label: t("rateLimitTypeFormItem.options.guaranteed"),
description: t("rateLimitTypeFormItem.options.guaranteedDescription", { lower }),
},
{
value: "dynamic",
label: "Dynamic",
description: `If the key has a set ${upper} (e.g. 2 ${upper}) and there are no 429 errors, it can dynamically exceed the limit when the model being called is not erroring.`,
label: t("rateLimitTypeFormItem.options.dynamic"),
description: t("rateLimitTypeFormItem.options.dynamicDescription", { upper }),
},
];
};
const plainLabels: Record<string, string> = {
best_effort_throughput: "Best effort throughput",
guaranteed_throughput: "Guaranteed throughput",
dynamic: "Dynamic",
const plainLabels: Record<string, ParseKeys> = {
best_effort_throughput: "rateLimitTypeFormItem.plainLabels.bestEffort",
guaranteed_throughput: "rateLimitTypeFormItem.plainLabels.guaranteed",
dynamic: "rateLimitTypeFormItem.plainLabels.dynamic",
};
const rateLimitTypeLabelText = (type: RateLimitType): string => `${type.toUpperCase()} Rate Limit Type`;
const rateLimitTypeLabelText = (type: RateLimitType, t: TFunction): string =>
t("rateLimitTypeFormItem.label", { type: type.toUpperCase() });
const rateLimitTypeTooltip = (type: RateLimitType): string =>
`Select 'guaranteed_throughput' to prevent overallocating ${type.toUpperCase()} limit when the key belongs to a Team with specific ${type.toUpperCase()} limits.`;
const rateLimitTypeTooltip = (type: RateLimitType, t: TFunction): string =>
t("rateLimitTypeFormItem.tooltip", { type: type.toUpperCase() });
export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
type,
@ -75,20 +78,21 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}) => {
const { t } = useTranslation();
const controlId = id ?? `rate-limit-type-${name}`;
const options = rateLimitTypeOptions(type);
const options = rateLimitTypeOptions(type, t);
return (
<div className={className}>
<TooltipProvider>
<label htmlFor={controlId} className="mb-2 flex items-center gap-1 text-sm text-foreground">
{rateLimitTypeLabelText(type)}
{rateLimitTypeLabelText(type, t)}
<Tooltip>
<TooltipTrigger
render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />}
aria-label={rateLimitTypeTooltip(type)}
aria-label={rateLimitTypeTooltip(type, t)}
/>
<TooltipContent>{rateLimitTypeTooltip(type)}</TooltipContent>
<TooltipContent>{rateLimitTypeTooltip(type, t)}</TooltipContent>
</Tooltip>
</label>
</TooltipProvider>
@ -98,9 +102,11 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
disabled={disabled}
>
<SelectTrigger id={controlId} className="w-full" aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
<SelectValue placeholder="Select rate limit type">
<SelectValue placeholder={t("rateLimitTypeFormItem.placeholder")}>
{(selected: string | null) =>
selected === null ? "Select rate limit type" : plainLabels[selected] ?? selected
selected !== null && Object.hasOwn(plainLabels, selected)
? t(plainLabels[selected])
: selected ?? t("rateLimitTypeFormItem.placeholder")
}
</SelectValue>
</SelectTrigger>
@ -114,8 +120,8 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
</span>
</SelectItem>
) : (
<SelectItem key={option.value} value={option.value} title={plainLabels[option.value]}>
{plainLabels[option.value]}
<SelectItem key={option.value} value={option.value} title={t(plainLabels[option.value])}>
{t(plainLabels[option.value])}
</SelectItem>
),
)}

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useQuery } from "@tanstack/react-query";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
@ -42,6 +43,7 @@ const PROPAGATE_WAIT_MS = 100;
const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSettingsAccordionProps>(
({ accessToken, value, onChange, modelData, teamId }, ref) => {
const { t } = useTranslation();
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
routerSettings: {},
selectedStrategy: null,
@ -346,8 +348,8 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
<div className="w-full">
<Tabs defaultValue="1" className="w-full">
<TabsList variant="line" className="px-8 pt-4">
<TabsTrigger value="1">Loadbalancing</TabsTrigger>
<TabsTrigger value="2">Fallbacks</TabsTrigger>
<TabsTrigger value="1">{t("routerSettingsAccordion.loadbalancing")}</TabsTrigger>
<TabsTrigger value="2">{t("routerSettingsAccordion.fallbacks")}</TabsTrigger>
</TabsList>
<div className="px-8 py-6">
<TabsContent value="1" keepMounted>

View file

@ -1,4 +1,5 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import { useInfiniteUsers, useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
@ -19,6 +20,7 @@ export const userOptionLabel = (user: Pick<UserInfo, "user_id" | "user_alias" |
};
const UserDropdown: React.FC<UserDropdownProps> = ({ value, onChange, disabled, pageSize = 50, id }) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers(
@ -54,9 +56,9 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ value, onChange, disabled,
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search users by email…"
emptyText="No users found"
loadingText="Loading users…"
placeholder={t("userDropdown.placeholder")}
emptyText={t("userDropdown.empty")}
loadingText={t("userDropdown.loading")}
disabled={disabled}
inputId={id}
/>

View file

@ -1,4 +1,7 @@
import React from "react";
import type { ParseKeys, TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export const NEVER_RESETS_BUDGET_DURATION = "none";
@ -27,9 +30,10 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
onChange,
className = "",
style = {},
placeholder = "Never resets",
placeholder = i18n.t("budgetDurationDropdown.neverResets"),
showNeverResets = false,
}) => {
const { t } = useTranslation();
return (
<Select items={DURATION_LABELS} value={value || null} onValueChange={onChange}>
<SelectTrigger id={id} className={`w-full ${className}`} style={style}>
@ -37,27 +41,29 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>{placeholder}</SelectItem>
{showNeverResets ? <SelectItem value={NEVER_RESETS_BUDGET_DURATION}>Never resets</SelectItem> : null}
<SelectItem value="1h">hourly</SelectItem>
<SelectItem value="24h">daily</SelectItem>
<SelectItem value="7d">weekly</SelectItem>
<SelectItem value="30d">monthly</SelectItem>
{showNeverResets ? (
<SelectItem value={NEVER_RESETS_BUDGET_DURATION}>{t("budgetDurationDropdown.neverResets")}</SelectItem>
) : null}
<SelectItem value="1h">{t("budgetDurationDropdown.periods.hourly")}</SelectItem>
<SelectItem value="24h">{t("budgetDurationDropdown.periods.daily")}</SelectItem>
<SelectItem value="7d">{t("budgetDurationDropdown.periods.weekly")}</SelectItem>
<SelectItem value="30d">{t("budgetDurationDropdown.periods.monthly")}</SelectItem>
</SelectContent>
</Select>
);
};
export const getBudgetDurationLabel = (value: string | null | undefined): string => {
if (!value) return "Not set";
export const getBudgetDurationLabel = (value: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string => {
if (!value) return t("budgetDurationDropdown.notSet");
const budgetDurationMap: Record<string, string> = {
"1h": "hourly",
"24h": "daily",
"7d": "weekly",
"30d": "monthly",
const budgetDurationMap: Record<string, ParseKeys> = {
"1h": "budgetDurationDropdown.periods.hourly",
"24h": "budgetDurationDropdown.periods.daily",
"7d": "budgetDurationDropdown.periods.weekly",
"30d": "budgetDurationDropdown.periods.monthly",
};
return budgetDurationMap[value] || value;
return Object.hasOwn(budgetDurationMap, value) ? t(budgetDurationMap[value]) : value;
};
export default BudgetDurationDropdown;

View file

@ -1,4 +1,5 @@
import React from "react";
import i18n from "@/i18n/i18n";
import {
NUMERIC_CELL_CLASS,
Table,
@ -34,8 +35,8 @@ export function SimpleTable<T>({
data,
columns,
isLoading = false,
loadingMessage = "Loading...",
emptyMessage = "No data",
loadingMessage = i18n.t("simpleTable.loading"),
emptyMessage = i18n.t("simpleTable.empty"),
getRowKey,
}: SimpleTableProps<T>) {
return (

View file

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../key_team_helpers/key_list";
@ -26,6 +27,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
id,
filterTeam,
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, isLoading } = useInfiniteTeams(
@ -95,9 +97,9 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search or select a team"
emptyText="No teams found"
loadingText="Loading teams…"
placeholder={t("teamDropdown.placeholder")}
emptyText={t("teamDropdown.empty")}
loadingText={t("teamDropdown.loading")}
disabled={disabled}
inputId={id}
/>

View file

@ -1,7 +1,9 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect";
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import i18n from "@/i18n/i18n";
interface TeamMultiSelectProps {
value?: readonly string[];
@ -18,8 +20,9 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
disabled,
organizationId,
pageSize = 20,
placeholder = "Search teams by alias...",
placeholder = i18n.t("teamMultiSelect.placeholder"),
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
@ -57,9 +60,9 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder={placeholder}
emptyText="No teams found"
loadingText="Loading teams..."
clearAllLabel="Clear all teams"
emptyText={t("teamMultiSelect.empty")}
loadingText={t("teamMultiSelect.loading")}
clearAllLabel={t("teamMultiSelect.clearAll")}
disabled={disabled}
/>
);

View file

@ -3,6 +3,7 @@ import { SearchSelect } from "@/components/shared/SearchSelect";
import { Button } from "@/components/ui/button";
import { ArrowDown, Plus, X } from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
interface FallbackEntry {
id: string;
@ -36,6 +37,7 @@ const dictToEntries = (dict: Record<string, string[]>): FallbackEntry[] => {
};
export function BudgetFallbacksEditor({ value, onChange, availableModels }: BudgetFallbacksEditorProps) {
const { t } = useTranslation();
const [entries, setEntries] = useState<FallbackEntry[]>(() => dictToEntries(value));
const emitChange = (updated: FallbackEntry[]) => {
@ -61,11 +63,11 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
return (
<div>
<div className="text-xs text-muted-foreground mb-2">
When a model exceeds its per-model budget, requests automatically reroute to fallback models
{t("keyTeamHelpers.budgetFallbacksEditor.description")}
</div>
<Button variant="outline" size="sm" onClick={addEntry}>
<Plus className="w-3 h-3" />
Add Budget Fallback
{t("keyTeamHelpers.budgetFallbacksEditor.addFallback")}
</Button>
</div>
);
@ -73,9 +75,7 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground">
When a model exceeds its per-model budget, requests automatically reroute to fallback models
</div>
<div className="text-xs text-muted-foreground">{t("keyTeamHelpers.budgetFallbacksEditor.description")}</div>
{entries.map((entry) => {
const availablePrimaryOptions = availableModels.filter(
(m) => m === entry.primaryModel || !usedPrimaryModels.has(m),
@ -93,7 +93,9 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
</button>
<div className="mb-3">
<label className="block text-xs font-medium text-muted-foreground mb-1">Primary Model</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{t("keyTeamHelpers.budgetFallbacksEditor.primaryModel")}
</label>
<SearchSelect
options={availablePrimaryOptions.map((m) => ({ label: m, value: m }))}
value={entry.primaryModel}
@ -101,32 +103,38 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
const newFallbacks = entry.fallbackModels.filter((m) => m !== v);
updateEntry(entry.id, { primaryModel: v, fallbackModels: newFallbacks });
}}
placeholder="Select model"
emptyText="No models found"
placeholder={t("keyTeamHelpers.budgetFallbacksEditor.selectModel")}
emptyText={t("keyTeamHelpers.budgetFallbacksEditor.noModels")}
/>
</div>
<div className="flex items-center justify-center -my-1 mb-2">
<div className="bg-warning/10 text-warning px-3 py-0.5 rounded-full text-[10px] font-bold border border-warning/15 flex items-center gap-1">
<ArrowDown className="w-3 h-3" />
IF BUDGET EXCEEDED, TRY
{t("keyTeamHelpers.budgetFallbacksEditor.ifExceeded")}
</div>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Fallback Models</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{t("keyTeamHelpers.budgetFallbacksEditor.fallbackModels")}
</label>
<MultiSelect
options={availableFallbackOptions.map((m) => ({ label: m, value: m }))}
value={entry.fallbackModels}
onValueChange={(values) => updateEntry(entry.id, { fallbackModels: values })}
placeholder={entry.primaryModel ? "Select fallback models" : "Select a primary model first"}
emptyText="No models found"
placeholder={
entry.primaryModel
? t("keyTeamHelpers.budgetFallbacksEditor.selectFallbackModels")
: t("keyTeamHelpers.budgetFallbacksEditor.selectPrimaryFirst")
}
emptyText={t("keyTeamHelpers.budgetFallbacksEditor.noModels")}
disabled={!entry.primaryModel}
className="w-full"
/>
{entry.fallbackModels.length > 1 && (
<div className="text-[10px] text-muted-foreground mt-1 ml-1">
Tried in order; first model still within its own budget is used
{t("keyTeamHelpers.budgetFallbacksEditor.triedInOrder")}
</div>
)}
</div>
@ -135,7 +143,7 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
})}
<Button variant="outline" size="sm" onClick={addEntry}>
<Plus className="w-3 h-3" />
Add Budget Fallback
{t("keyTeamHelpers.budgetFallbacksEditor.addFallback")}
</Button>
</div>
);

View file

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import React from "react";
import { useTranslation } from "react-i18next";
export interface BudgetWindowEntry {
budget_duration: string;
@ -21,6 +22,7 @@ interface BudgetWindowsEditorProps {
}
export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProps) {
const { t } = useTranslation();
const addWindow = () => {
onChange([...value, { budget_duration: "24h", max_budget: null }]);
};
@ -76,7 +78,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
updateWindow(idx, "max_budget", Number(typed.toFixed(2)));
}
}}
placeholder="Max spend ($)"
placeholder={t("keyTeamHelpers.budgetWindowsEditor.maxSpend")}
/>
</InputGroup>
<Button
@ -100,7 +102,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
addWindow();
}}
>
+ Add Budget Window
{t("keyTeamHelpers.budgetWindowsEditor.addWindow")}
</Button>
</div>
);

View file

@ -1,14 +1,14 @@
"use client";
import type { ParseKeys, TFunction } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
import { useBudgetOptions } from "@/app/(dashboard)/hooks/budgets/useBudgetOptions";
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
export const END_USER_BUDGET_HINT =
"Reusable budget applied to every new customer (end user) this key creates via `user` or x-litellm-end-user-id. " +
"Overrides the proxy-wide max_end_user_budget_id; customers that already have their own budget keep it.";
export const END_USER_BUDGET_HINT: ParseKeys = "keyTeamHelpers.endUserBudgetSelect.hint";
interface EndUserBudgetSelectProps {
readonly id?: string;
@ -18,10 +18,12 @@ interface EndUserBudgetSelectProps {
readonly canEdit: boolean;
}
const budgetSublabel = (budget: budgetItem): string | undefined => {
const budgetSublabel = (budget: budgetItem, t: TFunction): string | undefined => {
const parts = [
budget.max_budget != null ? `$${budget.max_budget}` : null,
budget.budget_duration ? `resets ${budget.budget_duration}` : null,
budget.budget_duration
? t("keyTeamHelpers.endUserBudgetSelect.resets", { duration: budget.budget_duration })
: null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? parts.join(", ") : undefined;
};
@ -33,19 +35,20 @@ export const EndUserBudgetSelect: React.FC<EndUserBudgetSelectProps> = ({
onChange,
canEdit,
}) => {
const { t } = useTranslation();
const { data: budgets } = useBudgetOptions(accessToken, canEdit);
const options: SearchSelectOption[] = (budgets ?? []).map((budget) => ({
label: budget.budget_id,
value: budget.budget_id,
sublabel: budgetSublabel(budget),
sublabel: budgetSublabel(budget, t),
}));
return (
<SearchSelect
inputId={id}
aria-label="Default Customer Budget"
placeholder="No default budget"
emptyText="No budgets found. Create one under Budgets."
aria-label={t("keyTeamHelpers.endUserBudgetSelect.ariaLabel")}
placeholder={t("keyTeamHelpers.endUserBudgetSelect.placeholder")}
emptyText={t("keyTeamHelpers.endUserBudgetSelect.empty")}
options={options}
value={value}
onValueChange={onChange}

View file

@ -3,8 +3,10 @@ import { Field, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import i18n from "@/i18n/i18n";
import { Plus, X } from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
export interface ModelBudgetConfig {
budget_limit: number;
@ -92,6 +94,7 @@ export function ModelMaxBudgetEditor({
premiumUser,
usage,
}: ModelMaxBudgetEditorProps) {
const { t } = useTranslation();
const [entries, setEntries] = useState<ModelBudgetEntry[]>(() => modelMaxBudgetToEntries(value));
const incoming = JSON.stringify(value ?? {});
@ -122,9 +125,7 @@ export function ModelMaxBudgetEditor({
const blurb = (
<div className="text-xs text-muted-foreground">
{premiumUser
? "Cap spend per model over its own window. A budget set on the bare model name also covers the provider-prefixed spelling of that model. Pick an access group, or type a pattern like claude-opus*, to share one budget across every model it covers."
: MODEL_MAX_BUDGET_PREMIUM_HINT}
{premiumUser ? t("keyTeamHelpers.modelMaxBudgetEditor.blurb") : MODEL_MAX_BUDGET_PREMIUM_HINT}
</div>
);
@ -134,7 +135,7 @@ export function ModelMaxBudgetEditor({
<div className="mb-2">{blurb}</div>
<Button variant="outline" size="sm" onClick={addEntry} disabled={!premiumUser} title={hintWhenLocked}>
<Plus className="w-3 h-3" />
Add Model Budget
{t("keyTeamHelpers.modelMaxBudgetEditor.addModelBudget")}
</Button>
</div>
);
@ -153,20 +154,22 @@ export function ModelMaxBudgetEditor({
onClick={() => removeEntry(entry.id)}
disabled={!premiumUser}
title={hintWhenLocked}
aria-label="Remove model budget"
aria-label={t("keyTeamHelpers.modelMaxBudgetEditor.removeModelBudget")}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive transition-colors p-1"
>
<X className="w-4 h-4" />
</button>
<div className="mb-3">
<label className="block text-xs font-medium text-muted-foreground mb-1">Model</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{t("keyTeamHelpers.modelMaxBudgetEditor.model")}
</label>
<SearchSelect
options={modelOptions.map((model) => ({ label: model, value: model }))}
value={entry.model}
onValueChange={(model) => updateEntry(entry.id, { model })}
placeholder="Select model"
emptyText="No models found"
placeholder={t("keyTeamHelpers.modelMaxBudgetEditor.selectModel")}
emptyText={t("keyTeamHelpers.modelMaxBudgetEditor.noModels")}
customValue={MODEL_PATTERN_VALUE}
reservedValues={entries.flatMap((other) => (other.id !== entry.id && other.model ? [other.model] : []))}
disabled={!premiumUser}
@ -189,7 +192,7 @@ export function ModelMaxBudgetEditor({
const typed = event.target.valueAsNumber;
updateEntry(entry.id, { budgetLimit: Number.isNaN(typed) ? null : typed });
}}
placeholder="Max spend ($)"
placeholder={t("keyTeamHelpers.modelMaxBudgetEditor.maxSpend")}
disabled={!premiumUser}
/>
</InputGroup>
@ -213,8 +216,10 @@ export function ModelMaxBudgetEditor({
{spent !== undefined && (
<div className="text-[11px] text-muted-foreground mt-2 ml-1">
Current window spend: ${spent}
{entry.budgetLimit !== null && ` of $${entry.budgetLimit}`}
{t("keyTeamHelpers.modelMaxBudgetEditor.currentSpend")}
{spent}
{entry.budgetLimit !== null &&
t("keyTeamHelpers.modelMaxBudgetEditor.ofLimit", { limit: entry.budgetLimit })}
</div>
)}
</div>
@ -222,7 +227,7 @@ export function ModelMaxBudgetEditor({
})}
<Button variant="outline" size="sm" onClick={addEntry} disabled={!premiumUser} title={hintWhenLocked}>
<Plus className="w-3 h-3" />
Add Model Budget
{t("keyTeamHelpers.modelMaxBudgetEditor.addModelBudget")}
</Button>
</div>
);
@ -234,7 +239,11 @@ interface ModelMaxBudgetFieldProps extends ModelMaxBudgetEditorProps {
}
/** The editor with its label, so every form that offers it presents it the same way. */
export function ModelMaxBudgetField({ hint, label = "Per-Model Budgets", ...editorProps }: ModelMaxBudgetFieldProps) {
export function ModelMaxBudgetField({
hint,
label = i18n.t("keyTeamHelpers.modelMaxBudgetEditor.fieldLabel"),
...editorProps
}: ModelMaxBudgetFieldProps) {
return (
<Field>
<FieldLabel>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -56,6 +57,7 @@ interface TagRateLimitEditorProps {
}
export function TagRateLimitEditor({ value, onChange }: TagRateLimitEditorProps) {
const { t } = useTranslation();
const addRow = () => {
onChange([...value, { id: newRowId(), tag: "", rpm_limit: null }]);
};
@ -73,22 +75,27 @@ export function TagRateLimitEditor({ value, onChange }: TagRateLimitEditorProps)
{value.map((row, idx) => (
<div key={row.id} style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 12 }}>
<Input
aria-label="Tag"
aria-label={t("keyTeamHelpers.tagRateLimitEditor.tag")}
value={row.tag}
onChange={(e) => updateRow(idx, "tag", e.target.value)}
placeholder="Tag (e.g. cell-1)"
placeholder={t("keyTeamHelpers.tagRateLimitEditor.tagPlaceholder")}
style={{ width: 180 }}
/>
<Input
aria-label="RPM limit"
aria-label={t("keyTeamHelpers.tagRateLimitEditor.rpmLimit")}
type="number"
min={0}
value={row.rpm_limit ?? ""}
onChange={(e) => updateRow(idx, "rpm_limit", e.target.value === "" ? null : Number(e.target.value))}
placeholder="RPM"
placeholder={t("keyTeamHelpers.tagRateLimitEditor.rpmPlaceholder")}
style={{ width: 120 }}
/>
<Button variant="destructive" size="sm" aria-label="Remove tag limit" onClick={() => removeRow(idx)}>
<Button
variant="destructive"
size="sm"
aria-label={t("keyTeamHelpers.tagRateLimitEditor.removeTagLimit")}
onClick={() => removeRow(idx)}
>
✕
</Button>
</div>
@ -101,7 +108,7 @@ export function TagRateLimitEditor({ value, onChange }: TagRateLimitEditorProps)
addRow();
}}
>
+ Add Tag Limit
{t("keyTeamHelpers.tagRateLimitEditor.addTagLimit")}
</Button>
</div>
);

View file

@ -1,4 +1,5 @@
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import VectorStorePermissions from "./permissions/VectorStorePermissions";
import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
@ -26,6 +27,7 @@ export function ObjectPermissionsView({
className = "",
accessToken,
}: ObjectPermissionsViewProps) {
const { t } = useTranslation();
const vectorStores = objectPermission?.vector_stores || [];
const mcpServers = objectPermission?.mcp_servers || [];
const mcpAccessGroups = objectPermission?.mcp_access_groups || [];
@ -43,8 +45,10 @@ export function ObjectPermissionsView({
if (missingAccessGroupIds.length > 0) {
return (
<p className="mt-1 block text-xs break-words text-destructive">
Private plugin access is suspended: access group {missingAccessGroupIds.join(", ")} not found. Only enabled
(public) Claude Code plugins are visible until the id is removed or the group recreated.
<Trans
i18nKey="objectPermissionsView.skills.suspended"
components={{ groups: <>{missingAccessGroupIds.join(", ")}</> }}
/>
</p>
);
}
@ -52,8 +56,8 @@ export function ObjectPermissionsView({
return (
<p className="mt-1 block text-xs text-muted-foreground">
{showsInheritedSkills
? "No private skills granted. Only enabled (public) Claude Code plugins are visible."
: "No private skills granted directly here. Grants from access groups are not listed on this page; only enabled (public) Claude Code plugins are visible by default."}
? t("objectPermissionsView.skills.noneGranted")
: t("objectPermissionsView.skills.noneGrantedDirectly")}
</p>
);
}
@ -62,9 +66,10 @@ export function ObjectPermissionsView({
{skills.join(", ")}
{skills.length > 0 && inheritedOnlySkills.length > 0 ? ", " : ""}
{inheritedOnlySkills.map((grant, index) => (
<span key={grant.id} title={inheritedGrantTooltip(grant)} className="text-muted-foreground">
<span key={grant.id} title={inheritedGrantTooltip(grant, t)} className="text-muted-foreground">
{index > 0 ? ", " : ""}
{grant.id} (access group)
{grant.id}
{t("objectPermissionsView.skills.accessGroupSuffix")}
</span>
))}
</p>
@ -89,17 +94,17 @@ export function ObjectPermissionsView({
accessToken={accessToken}
/>
<div className="min-w-0 rounded-md border border-border p-4">
<p className="text-sm font-medium text-foreground">Search tools</p>
<p className="text-sm font-medium text-foreground">{t("objectPermissionsView.searchTools.title")}</p>
{searchTools.length === 0 ? (
<p className="mt-1 block text-xs text-muted-foreground">
No restriction — all configured search tools are allowed for this team.
{t("objectPermissionsView.searchTools.noRestriction")}
</p>
) : (
<p className="mt-1 block text-xs break-words text-foreground">{searchTools.join(", ")}</p>
)}
</div>
<div className="min-w-0 rounded-md border border-border p-4">
<p className="text-sm font-medium text-foreground">Skills</p>
<p className="text-sm font-medium text-foreground">{t("objectPermissionsView.skills.title")}</p>
{skillsContent}
</div>
</div>
@ -110,8 +115,8 @@ export function ObjectPermissionsView({
<div className={`@container bg-card border border-border rounded-lg p-6 ${className}`}>
<div className="flex items-center gap-2 mb-6">
<div>
<p className="font-semibold text-foreground">Object Permissions</p>
<p className="text-xs text-muted-foreground">Access control for Vector Stores and MCP Servers</p>
<p className="font-semibold text-foreground">{t("objectPermissionsView.title")}</p>
<p className="text-xs text-muted-foreground">{t("objectPermissionsView.subtitle")}</p>
</div>
</div>
{content}
@ -121,7 +126,7 @@ export function ObjectPermissionsView({
return (
<div className={`${className}`}>
<p className="font-medium text-foreground mb-3">Object Permissions</p>
<p className="font-medium text-foreground mb-3">{t("objectPermissionsView.title")}</p>
{content}
</div>
);

View file

@ -1,4 +1,5 @@
import React, { useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Copy } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
@ -60,6 +61,7 @@ export default function OnboardingModal({
invitationLinkData,
modalType = "invitation",
}: OnboardingProps) {
const { t } = useTranslation();
const linkFieldId = useId();
const copyButtonRef = useRef<HTMLButtonElement>(null);
const isInvitation = modalType === "invitation";
@ -79,32 +81,32 @@ export default function OnboardingModal({
>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl" initialFocus={copyButtonRef}>
<DialogHeader>
<DialogTitle>{isInvitation ? "Invitation Link" : "Reset Password Link"}</DialogTitle>
<DialogTitle>
{isInvitation ? t("onboardingLink.invitationTitle") : t("onboardingLink.resetTitle")}
</DialogTitle>
<DialogDescription>
{isInvitation
? "Copy and send the generated link to onboard this user to the proxy."
: "Copy and send the generated link to the user to reset their password."}
{isInvitation ? t("onboardingLink.invitationDescription") : t("onboardingLink.resetDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted-foreground">User ID</p>
<p className="text-xs text-muted-foreground">{t("onboardingLink.userId")}</p>
<p className="font-mono text-sm break-all">{invitationLinkData?.user_id}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={linkFieldId} className="text-xs font-normal text-muted-foreground">
{isInvitation ? "Invitation link" : "Reset password link"}
{isInvitation ? t("onboardingLink.invitationLinkLabel") : t("onboardingLink.resetLinkLabel")}
</Label>
<InputGroup>
<InputGroupInput id={linkFieldId} readOnly value={invitationUrl} className="font-mono text-xs" />
<InputGroupAddon align="inline-end">
<InputGroupButton
ref={copyButtonRef}
aria-label={isInvitation ? "Copy invitation link" : "Copy password reset link"}
aria-label={isInvitation ? t("onboardingLink.copyInvitationAria") : t("onboardingLink.copyResetAria")}
onClick={() => copyToClipboard(invitationUrl)}
>
<Copy />
Copy
{t("onboardingLink.copy")}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>

View file

@ -5,6 +5,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Check, CircleHelp, Copy, RefreshCw, TriangleAlert } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { ParseKeys, TFunction } from "i18next";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { z } from "zod";
import { KeyResponse } from "../key_team_helpers/key_list";
@ -19,8 +21,8 @@ import { calculateExpiryPreviewFromDuration, formatExpiresUtc, isKeyExpired } fr
import { buildRegenerateKeyPayload, type RegenerateKeyFormValues } from "./regenerateKeyPayload";
const DURATION_PATTERN = /^(\d+(s|m|h|d|w|mo))?$/;
const DURATION_MESSAGE = "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo";
const EXPIRED_DURATION_MESSAGE = "Expiration is required for expired keys";
const DURATION_MESSAGE: ParseKeys = "regenerateKeyModal.validation.duration";
const EXPIRED_DURATION_MESSAGE: ParseKeys = "regenerateKeyModal.validation.expiredDurationRequired";
const EMPTY_VALUES: RegenerateKeyFormValues = {
key_alias: undefined,
@ -31,16 +33,19 @@ const EMPTY_VALUES: RegenerateKeyFormValues = {
grace_period: "",
};
const buildSchema = (keyIsExpired: boolean): z.ZodType<RegenerateKeyFormValues, RegenerateKeyFormValues> => {
const buildSchema = (
keyIsExpired: boolean,
t: TFunction,
): z.ZodType<RegenerateKeyFormValues, RegenerateKeyFormValues> => {
const shape = {
key_alias: z.string().nullish(),
max_budget: z.number().nullish(),
tpm_limit: z.number().nullish(),
rpm_limit: z.number().nullish(),
duration: keyIsExpired
? z.string().min(1, EXPIRED_DURATION_MESSAGE).regex(DURATION_PATTERN, DURATION_MESSAGE)
: z.string().regex(DURATION_PATTERN, DURATION_MESSAGE),
grace_period: z.string().regex(DURATION_PATTERN, DURATION_MESSAGE),
? z.string().min(1, t(EXPIRED_DURATION_MESSAGE)).regex(DURATION_PATTERN, t(DURATION_MESSAGE))
: z.string().regex(DURATION_PATTERN, t(DURATION_MESSAGE)),
grace_period: z.string().regex(DURATION_PATTERN, t(DURATION_MESSAGE)),
};
return z.object(shape);
@ -64,13 +69,14 @@ interface RegenerateKeyModalProps {
}
export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdate }: RegenerateKeyModalProps) {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const [regeneratedKey, setRegeneratedKey] = useState<string | null>(null);
const [isRegenerating, setIsRegenerating] = useState(false);
const [copied, setCopied] = useState(false);
const keyIsExpired = isKeyExpired(selectedToken?.expires);
const schema = useMemo(() => buildSchema(keyIsExpired), [keyIsExpired]);
const schema = useMemo(() => buildSchema(keyIsExpired, t), [keyIsExpired, t]);
const form = useZodForm(schema, { defaultValues: EMPTY_VALUES });
const durationValue = useWatch({ control: form.control, name: "duration" });
@ -97,7 +103,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
try {
const response = await regenerateKeyCall(accessToken, selectedToken.token || selectedToken.token_id, formValues);
setRegeneratedKey(response.key);
toast.success("Virtual Key regenerated successfully");
toast.success(t("regenerateKeyModal.toasts.regenerated"));
// Build the update payload. Spread the API response first so any new
// fields it returns (new token, timestamps, etc.) are captured, then
@ -151,22 +157,24 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Regenerate Virtual Key</DialogTitle>
<DialogTitle>{t("regenerateKeyModal.title")}</DialogTitle>
</DialogHeader>
{regeneratedKey ? (
<div className="flex flex-col gap-4">
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Save it now, you will not see it again</AlertTitle>
<AlertTitle>{t("regenerateKeyModal.result.saveNow")}</AlertTitle>
</Alert>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Key Alias</span>
<span className="text-sm text-foreground">{selectedToken?.key_alias || "No alias set"}</span>
<span className="text-xs text-muted-foreground">{t("regenerateKeyModal.result.keyAlias")}</span>
<span className="text-sm text-foreground">
{selectedToken?.key_alias || t("regenerateKeyModal.result.noAlias")}
</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Virtual Key</span>
<span className="text-xs text-muted-foreground">{t("regenerateKeyModal.result.virtualKey")}</span>
<div className="rounded-md border border-border bg-muted px-4 py-3.5 font-mono text-base break-all text-foreground">
{regeneratedKey}
</div>
@ -176,12 +184,12 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
<TooltipProvider>
<form onSubmit={(event) => event.preventDefault()} noValidate className="mt-1">
<FieldGroup>
<FormField control={form.control} name="key_alias" label="Key Alias">
<FormField control={form.control} name="key_alias" label={t("regenerateKeyModal.form.keyAlias")}>
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} disabled />}
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
<FormField control={form.control} name="max_budget" label={t("regenerateKeyModal.form.maxBudget")}>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
@ -194,7 +202,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
)}
</FormField>
<FormField control={form.control} name="tpm_limit" label="TPM Limit">
<FormField control={form.control} name="tpm_limit" label={t("regenerateKeyModal.form.tpmLimit")}>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
@ -206,7 +214,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
)}
</FormField>
<FormField control={form.control} name="rpm_limit" label="RPM Limit">
<FormField control={form.control} name="rpm_limit" label={t("regenerateKeyModal.form.rpmLimit")}>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
@ -223,30 +231,44 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
<FormField
control={form.control}
name="duration"
label="Expire Key"
label={t("regenerateKeyModal.form.expireKey")}
description={
<span className="flex flex-col gap-0.5 text-xs">
<span className={keyIsExpired ? "text-destructive" : "text-muted-foreground"}>
Current expiry: {selectedToken?.expires ? formatExpiresUtc(selectedToken.expires) : "Never"}
{keyIsExpired && " (expired)"}
{t("regenerateKeyModal.form.currentExpiry")}
{selectedToken?.expires
? formatExpiresUtc(selectedToken.expires)
: t("regenerateKeyModal.form.never")}
{keyIsExpired && t("regenerateKeyModal.form.expiredSuffix")}
</span>
{newExpiryTime && <span className="text-success">New expiry: {newExpiryTime}</span>}
{newExpiryTime && (
<span className="text-success">
{t("regenerateKeyModal.form.newExpiry")}
{newExpiryTime}
</span>
)}
</span>
}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. 30s, 30h, 30d" />}
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder={t("regenerateKeyModal.form.durationPlaceholder")} />
)}
</FormField>
<FormField
control={form.control}
name="grace_period"
label={labelWithHint(
"Grace Period",
"Keep the old key valid for this duration after rotation. Both keys work during this period for seamless cutover. Empty = immediate revoke.",
t("regenerateKeyModal.form.gracePeriod"),
t("regenerateKeyModal.form.gracePeriodHint"),
)}
description={<span className="text-xs">Recommended: 24h to 72h for production keys</span>}
description={
<span className="text-xs">{t("regenerateKeyModal.form.gracePeriodRecommendation")}</span>
}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. 24h, 2d" />}
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder={t("regenerateKeyModal.form.gracePeriodPlaceholder")} />
)}
</FormField>
</div>
</FieldGroup>
@ -257,23 +279,23 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
{regeneratedKey ? (
<>
<Button variant="outline" onClick={handleClose}>
Close
{t("regenerateKeyModal.actions.close")}
</Button>
<CopyToClipboard text={regeneratedKey} onCopy={handleCopyKey}>
<Button>
{copied ? <Check /> : <Copy />}
{copied ? "Copied" : "Copy Key"}
{copied ? t("regenerateKeyModal.actions.copied") : t("regenerateKeyModal.actions.copyKey")}
</Button>
</CopyToClipboard>
</>
) : (
<>
<Button variant="outline" onClick={handleClose}>
Cancel
{t("regenerateKeyModal.actions.cancel")}
</Button>
<Button onClick={handleRegenerateKey} disabled={isRegenerating} aria-busy={isRegenerating}>
<RefreshCw />
Regenerate
{t("regenerateKeyModal.actions.regenerate")}
</Button>
</>
)}

View file

@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import { TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION } from "@/components/team/teamAdminEditAccess";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
@ -50,9 +52,10 @@ export const teamMemberOwnerOptions = (team: TeamMembers | null, viewerId: strin
export const teamMemberOwnerPlaceholder = (
options: readonly SearchSelectOption[],
team: TeamMembers | null,
t: TFunction = i18n.t.bind(i18n),
): string => {
if (options.length > 0) return "Select a member of the team";
return team ? "This team has no other members" : "Select a team first";
if (options.length > 0) return t("createKey.ownerAccess.selectMember");
return team ? t("createKey.ownerAccess.noOtherMembers") : t("createKey.ownerAccess.selectTeamFirst");
};
export const isTeamMemberOwner = (ownerId: unknown, team: TeamMembers | null, viewerId: string | null): boolean =>

View file

@ -1,10 +1,13 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
/**
* Helper function to simplify /key/generate permission errors
* Extracts a user-friendly error message from team member permission errors
* @param error - The error object or message
* @returns A simplified error message string
*/
export const simplifyKeyGenerateError = (error: any): string => {
export const simplifyKeyGenerateError = (error: any, t: TFunction = i18n.t.bind(i18n)): string => {
// Handle plain objects by stringifying them first
let errorString: string;
if (error && typeof error === "object" && !(error instanceof Error)) {
@ -15,7 +18,7 @@ export const simplifyKeyGenerateError = (error: any): string => {
// Check if this is a /key/generate team member permission error
if (!errorString.includes("/key/generate") && !errorString.includes("KeyManagementRoutes.KEY_GENERATE")) {
return `Error creating the key: ${error}`;
return t("createKey.errors.createFailed", { error: String(error) });
}
// Try to parse JSON if the message contains JSON or extract from object
@ -48,9 +51,9 @@ export const simplifyKeyGenerateError = (error: any): string => {
errorMessage.includes("Team member does not have permissions")
) {
// Return the simplified message
return "Team member does not have permission to generate key for this team. Ask your proxy admin to configure the team member permission settings.";
return t("createKey.errors.teamMemberPermission");
}
// If it's not a permission error, return the original error message
return `Error creating the key: ${error}`;
return t("createKey.errors.createFailed", { error: String(error) });
};

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { UserGroupIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -25,6 +26,7 @@ export function AgentPermissions({
inheritedAgents = [],
accessToken,
}: AgentPermissionsProps) {
const { t } = useTranslation();
const [agentDetails, setAgentDetails] = useState<Agent[]>([]);
const inheritedOnlyAgents = inheritedAgents.filter((grant) => !agents.includes(grant.id));
const agentIdCount = agents.length + inheritedOnlyAgents.length;
@ -57,8 +59,16 @@ export function AgentPermissions({
};
const mergedItems = [
...agents.map((agent) => ({ type: "agent", value: agent, tooltip: `Full ID: ${agent}` })),
...inheritedOnlyAgents.map((grant) => ({ type: "agent", value: grant.id, tooltip: inheritedGrantTooltip(grant) })),
...agents.map((agent) => ({
type: "agent",
value: agent,
tooltip: t("permissions.agentPermissions.fullId", { id: agent }),
})),
...inheritedOnlyAgents.map((grant) => ({
type: "agent",
value: grant.id,
tooltip: inheritedGrantTooltip(grant, t),
})),
...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })),
];
const totalCount = mergedItems.length;
@ -67,7 +77,7 @@ export function AgentPermissions({
<div className="space-y-3">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-purple-600" />
<p className="text-sm font-semibold text-foreground">Allowed agents to call</p>
<p className="text-sm font-semibold text-foreground">{t("permissions.agentPermissions.title")}</p>
<Badge variant="secondary">{totalCount}</Badge>
</div>
@ -94,7 +104,7 @@ export function AgentPermissions({
<span className="inline-block w-1.5 h-1.5 bg-success rounded-full shrink-0"></span>
<span className="text-sm font-medium text-foreground truncate">{item.value}</span>
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-success bg-success/10 border border-success/20 rounded-sm uppercase tracking-wide shrink-0">
Group
{t("permissions.agentPermissions.group")}
</span>
</div>
)}
@ -106,7 +116,7 @@ export function AgentPermissions({
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">
<UserGroupIcon className="h-4 w-4 text-muted-foreground" />
<p className="text-muted-foreground text-sm">No agents or access groups configured</p>
<p className="text-muted-foreground text-sm">{t("permissions.agentPermissions.empty")}</p>
</div>
)}
</div>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ServerIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -25,6 +26,7 @@ export function MCPServerPermissions({
inheritedMcpServers = [],
accessToken,
}: MCPServerPermissionsProps) {
const { t } = useTranslation();
const [mcpServerDetails, setMCPServerDetails] = useState<MCPServer[]>([]);
const [toolsetDetails, setToolsetDetails] = useState<MCPToolset[]>([]);
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
@ -121,11 +123,15 @@ export function MCPServerPermissions({
const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL);
const mergedItems = [
...directServerIds.map((server) => ({ type: "server", value: server, tooltip: `Full ID: ${server}` })),
...directServerIds.map((server) => ({
type: "server",
value: server,
tooltip: t("permissions.mcpServerPermissions.fullId", { id: server }),
})),
...inheritedOnlyServers.map((grant) => ({
type: "server",
value: grant.id,
tooltip: inheritedGrantTooltip(grant),
tooltip: inheritedGrantTooltip(grant, t),
})),
...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })),
];
@ -135,23 +141,25 @@ export function MCPServerPermissions({
<div className="space-y-3">
<div className="flex items-center gap-2">
<ServerIcon className="h-4 w-4 text-info" />
<p className="text-sm font-semibold text-foreground">MCP Servers</p>
<p className="text-sm font-semibold text-foreground">{t("permissions.mcpServerPermissions.title")}</p>
<Badge variant={blocksAllMcpServers ? "destructive" : "secondary"}>
{blocksAllMcpServers ? "Blocked" : grantsAllProxyMcpServers ? "All" : totalCount}
{blocksAllMcpServers
? t("permissions.mcpServerPermissions.blocked")
: grantsAllProxyMcpServers
? t("permissions.mcpServerPermissions.all")
: totalCount}
</Badge>
</div>
{blocksAllMcpServers ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20">
<ServerIcon className="h-4 w-4 text-destructive" />
<p className="text-destructive text-sm">
No MCP servers — this key is blocked from all MCP servers, including its team&apos;s servers
</p>
<p className="text-destructive text-sm">{t("permissions.mcpServerPermissions.blockedNotice")}</p>
</div>
) : grantsAllProxyMcpServers ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-info/10 border border-info/20">
<ServerIcon className="h-4 w-4 text-info" />
<p className="text-info text-sm">All Proxy MCP Servers</p>
<p className="text-info text-sm">{t("permissions.mcpServerPermissions.allProxyServers")}</p>
</div>
) : totalCount > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-1">
@ -184,7 +192,7 @@ export function MCPServerPermissions({
<span className="inline-block w-1.5 h-1.5 bg-success rounded-full shrink-0"></span>
<span className="text-sm font-medium text-foreground truncate">{item.value}</span>
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-success bg-success/10 border border-success/20 rounded-sm uppercase tracking-wide shrink-0">
Group
{t("permissions.mcpServerPermissions.group")}
</span>
</div>
)}
@ -194,7 +202,7 @@ export function MCPServerPermissions({
<div className="flex items-center gap-1 shrink-0 whitespace-nowrap">
<span className="text-xs font-medium text-muted-foreground">{toolsForServer.length}</span>
<span className="text-xs text-muted-foreground">
{toolsForServer.length === 1 ? "tool" : "tools"}
{t("permissions.mcpServerPermissions.tools", { count: toolsForServer.length })}
</span>
{isExpanded ? (
<ChevronDownIcon className="h-3.5 w-3.5 text-muted-foreground ml-0.5" />
@ -247,13 +255,15 @@ export function MCPServerPermissions({
{detail?.toolset_name ?? toolsetId}
</span>
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-purple-600 bg-purple-50 border border-purple-200 rounded-sm uppercase tracking-wide shrink-0 dark:text-purple-300 dark:bg-purple-950 dark:border-purple-800">
Toolset
{t("permissions.mcpServerPermissions.toolset")}
</span>
</div>
{toolCount > 0 && (
<div className="flex items-center gap-1 shrink-0 whitespace-nowrap">
<span className="text-xs font-medium text-muted-foreground">{toolCount}</span>
<span className="text-xs text-muted-foreground">{toolCount === 1 ? "tool" : "tools"}</span>
<span className="text-xs text-muted-foreground">
{t("permissions.mcpServerPermissions.tools", { count: toolCount })}
</span>
{isExpanded ? (
<ChevronDownIcon className="h-3.5 w-3.5 text-muted-foreground ml-0.5" />
) : (
@ -285,7 +295,7 @@ export function MCPServerPermissions({
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">
<ServerIcon className="h-4 w-4 text-muted-foreground" />
<p className="text-muted-foreground text-sm">No MCP servers, access groups, or toolsets configured</p>
<p className="text-muted-foreground text-sm">{t("permissions.mcpServerPermissions.empty")}</p>
</div>
)}
</div>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { DatabaseIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { vectorStoreListCall } from "../networking";
@ -14,6 +15,7 @@ interface VectorStorePermissionsProps {
}
export function VectorStorePermissions({ vectorStores, accessToken }: VectorStorePermissionsProps) {
const { t } = useTranslation();
const [vectorStoreDetails, setVectorStoreDetails] = useState<VectorStoreDetails[]>([]);
// Fetch vector store details when component mounts
@ -52,7 +54,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
<div className="space-y-3">
<div className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-info" />
<p className="text-sm font-semibold text-foreground">Vector Stores</p>
<p className="text-sm font-semibold text-foreground">{t("permissions.vectorStorePermissions.title")}</p>
<Badge variant="secondary">{vectorStores.length}</Badge>
</div>
@ -70,7 +72,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">
<DatabaseIcon className="h-4 w-4 text-muted-foreground" />
<p className="text-muted-foreground text-sm">No vector stores configured</p>
<p className="text-muted-foreground text-sm">{t("permissions.vectorStorePermissions.empty")}</p>
</div>
)}
</div>

View file

@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { describeGroups, TeamAccessGroupModelGrant } from "../team/teamModelAccess";
export interface InheritedGrant {
@ -20,7 +22,10 @@ export function computeInheritedGrants(
}));
}
export const inheritedGrantTooltip = (grant: InheritedGrant): string => {
const source = grant.accessGroupNames.length > 0 ? describeGroups(grant.accessGroupNames) : "an access group";
return `Granted via ${source}. Full ID: ${grant.id}`;
export const inheritedGrantTooltip = (grant: InheritedGrant, t: TFunction = i18n.t.bind(i18n)): string => {
const source =
grant.accessGroupNames.length > 0
? describeGroups(grant.accessGroupNames)
: t("permissions.inheritedGrants.anAccessGroup");
return t("permissions.inheritedGrants.tooltip", { source, id: grant.id });
};

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned";
import { Input } from "@/components/ui/input";
@ -21,10 +22,10 @@ const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({
routingStrategyArgs,
ownedByConfig = false,
}) => {
const { t } = useTranslation();
const paramExplanation: { [key: string]: string } = {
ttl: "Sliding window to look back over when calculating the average latency of a deployment. Default - 1 hour (in seconds).",
lowest_latency_buffer:
"Shuffle between deployments within this % of the lowest latency. Default - 0 (i.e. always pick lowest latency).",
ttl: t("routerSettingsForm.latency.ttl"),
lowest_latency_buffer: t("routerSettingsForm.latency.lowestLatencyBuffer"),
};
return (
@ -32,10 +33,10 @@ const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({
<div className="space-y-6">
<div className="max-w-3xl">
<h3 className="text-sm font-medium text-foreground">
Latency-Based Configuration
{t("routerSettingsForm.latency.title")}
{ownedByConfig && <ConfigOwnedBadge />}
</h3>
<p className="text-xs text-muted-foreground mt-1">Fine-tune latency-based routing behavior</p>
<p className="text-xs text-muted-foreground mt-1">{t("routerSettingsForm.latency.description")}</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CONFIG_OWNED_HINT, ConfigOwnedBadge, RouterSettingsSource, isOwnedByConfig } from "./ConfigOwned";
import { Input } from "@/components/ui/input";
@ -13,11 +14,12 @@ const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
routerFieldsMetadata,
routerSettingsSource = {},
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="max-w-3xl">
<h3 className="text-sm font-medium text-foreground">Reliability & Retries</h3>
<p className="text-xs text-muted-foreground mt-1">Configure retry logic and failure handling</p>
<h3 className="text-sm font-medium text-foreground">{t("routerSettingsForm.reliability.title")}</h3>
<p className="text-xs text-muted-foreground mt-1">{t("routerSettingsForm.reliability.description")}</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import LatencyBasedConfiguration from "./LatencyBasedConfiguration";
import { RouterSettingsSource, isOwnedByConfig } from "./ConfigOwned";
import ReliabilityRetriesSection from "./ReliabilityRetriesSection";
@ -28,6 +29,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
routingStrategyDescriptions,
routerSettingsSource = {},
}) => {
const { t } = useTranslation();
const handleStrategyChange = (strategy: string) => {
onChange({
...value,
@ -47,8 +49,8 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
{/* Routing Settings Section */}
<div className="space-y-6">
<div className="max-w-3xl">
<h3 className="text-sm font-medium text-foreground">Routing Settings</h3>
<p className="text-xs text-muted-foreground mt-1">Configure how requests are routed to deployments</p>
<h3 className="text-sm font-medium text-foreground">{t("routerSettingsForm.form.title")}</h3>
<p className="text-xs text-muted-foreground mt-1">{t("routerSettingsForm.form.description")}</p>
</div>
{/* Routing Strategy */}

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -19,11 +20,12 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
onStrategyChange,
ownedByConfig = false,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-2 max-w-3xl">
<div>
<label className="text-xs font-medium text-foreground uppercase tracking-wide">
{routerFieldsMetadata["routing_strategy"]?.ui_field_name || "Routing Strategy"}
{routerFieldsMetadata["routing_strategy"]?.ui_field_name || t("routerSettingsForm.strategy.label")}
</label>
{ownedByConfig && <ConfigOwnedBadge />}
<p className="text-xs text-muted-foreground mt-0.5 mb-2">

View file

@ -1,4 +1,5 @@
import React, { useId } from "react";
import { useTranslation } from "react-i18next";
import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned";
import { Switch } from "@/components/ui/switch";
@ -15,6 +16,7 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({
onToggle,
ownedByConfig = false,
}) => {
const { t } = useTranslation();
const toggleId = useId();
return (
@ -22,7 +24,7 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({
<div className="flex items-start justify-between">
<div className="flex-1">
<label htmlFor={toggleId} className="text-xs font-medium text-foreground uppercase tracking-wide">
{routerFieldsMetadata["enable_tag_filtering"]?.ui_field_name || "Enable Tag Filtering"}
{routerFieldsMetadata["enable_tag_filtering"]?.ui_field_name || t("routerSettingsForm.tagFiltering.label")}
</label>
{ownedByConfig && <ConfigOwnedBadge />}
<p className="text-xs text-muted-foreground mt-0.5">
@ -36,7 +38,7 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({
rel="noopener noreferrer"
className="text-info hover:text-info/80 underline"
>
Learn more
{t("routerSettingsForm.tagFiltering.learnMore")}
</a>
</>
)}

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { toast } from "@/lib/toast";
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
@ -17,6 +18,7 @@ interface routingStrategyArgs {
}
const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole, userID }) => {
const { t } = useTranslation();
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
routerSettings: {},
selectedStrategy: null,
@ -179,9 +181,9 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
try {
await setCallbacksCall(accessToken, payload);
toast.success("router settings updated successfully");
toast.success(t("routerSettingsForm.page.updated"));
} catch (error) {
toast.fromError("Failed to update router settings: " + error);
toast.fromError(t("routerSettingsForm.page.updateFailed") + error);
}
};
@ -203,14 +205,14 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
{/* Actions - Sticky at bottom */}
<div className="border-t border-border pt-6 flex justify-end gap-3">
<Button variant="outline" onClick={() => window.location.reload()}>
Reset
{t("routerSettingsForm.page.reset")}
</Button>
<Button
onClick={handleSaveChanges}
disabled={routerSettingsSource === null}
title={routerSettingsSource === null ? SOURCE_UNKNOWN_HINT : undefined}
>
Save Changes
{t("routerSettingsForm.page.save")}
</Button>
</div>
</div>

View file

@ -1,6 +1,8 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import React from "react";
import { Control, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { ParseKeys, TFunction } from "i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -24,10 +26,22 @@ export const labelWithHint = (label: React.ReactNode, hint: string): React.React
</>
);
const KEY_TYPE_OPTIONS = [
{ value: "default", label: "Full Access", hint: "Can call all routes (AI APIs, Management, and read-only)" },
{ value: "llm_api", label: "AI APIs", hint: "Can call only AI API routes (chat/completions, embeddings, etc.)" },
{ value: "management", label: "Management", hint: "Can call only management routes (user/team/key management)" },
const KEY_TYPE_OPTIONS: { value: string; label: ParseKeys; hint: ParseKeys }[] = [
{
value: "default",
label: "keyDetail.keyEditViewControls.keyType.fullAccess",
hint: "keyDetail.keyEditViewControls.keyType.fullAccessHint",
},
{
value: "llm_api",
label: "keyDetail.keyEditViewControls.keyType.aiApis",
hint: "keyDetail.keyEditViewControls.keyType.aiApisHint",
},
{
value: "management",
label: "keyDetail.keyEditViewControls.keyType.management",
hint: "keyDetail.keyEditViewControls.keyType.managementHint",
},
];
export const KeyTypeSelect = ({
@ -39,11 +53,12 @@ export const KeyTypeSelect = ({
value: string;
onChange: (value: string) => void;
}) => {
const { t } = useTranslation();
const term = useTerminology();
const keyTypeOptions = KEY_TYPE_OPTIONS.map((option) => ({
...option,
label: term(option.label),
hint: term(option.hint),
label: term(t(option.label)),
hint: term(t(option.hint)),
}));
return (
<Select
@ -52,7 +67,7 @@ export const KeyTypeSelect = ({
onValueChange={(next: string | null) => next != null && onChange(next)}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder="Select key type" />
<SelectValue placeholder={t("keyDetail.keyEditViewControls.keyType.placeholder")} />
</SelectTrigger>
<SelectContent>
{keyTypeOptions.map((option) => (
@ -68,53 +83,58 @@ export const KeyTypeSelect = ({
);
};
const SKILLS_HINT =
"Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here.";
const SKILLS_HINT: ParseKeys = "keyDetail.keyEditViewControls.hints.skills";
const TPD_HINT =
"Daily token budget for batch submissions (/v1/batches). When set, batch input files are charged against this 24h window instead of the key's TPM/RPM limits. Online requests keep using TPM/RPM.";
const TPD_HINT: ParseKeys = "keyDetail.keyEditViewControls.hints.tpd";
export const KeyRateLimitFields = ({ control }: { control: Control<KeyEditFormValues> }) => (
<>
<FormField control={control} name="tpm_limit" label="TPM Limit">
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
export const KeyRateLimitFields = ({ control }: { control: Control<KeyEditFormValues> }) => {
const { t } = useTranslation();
return (
<>
<FormField control={control} name="tpm_limit" label={t("keyDetail.keyEditViewControls.rateLimits.tpmLimit")}>
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
<FormField control={control} name="tpm_limit_type">
{({ value, onChange, id }) => (
<RateLimitTypeFormItem
id={id}
type="tpm"
name="tpm_limit_type"
showDetailedDescriptions={false}
value={value as string | null}
onChange={onChange}
/>
)}
</FormField>
<FormField control={control} name="tpm_limit_type">
{({ value, onChange, id }) => (
<RateLimitTypeFormItem
id={id}
type="tpm"
name="tpm_limit_type"
showDetailedDescriptions={false}
value={value as string | null}
onChange={onChange}
/>
)}
</FormField>
<FormField control={control} name="rpm_limit" label="RPM Limit">
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
<FormField control={control} name="rpm_limit" label={t("keyDetail.keyEditViewControls.rateLimits.rpmLimit")}>
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
<FormField control={control} name="rpm_limit_type">
{({ value, onChange, id }) => (
<RateLimitTypeFormItem
id={id}
type="rpm"
name="rpm_limit_type"
showDetailedDescriptions={false}
value={value as string | null}
onChange={onChange}
/>
)}
</FormField>
<FormField control={control} name="rpm_limit_type">
{({ value, onChange, id }) => (
<RateLimitTypeFormItem
id={id}
type="rpm"
name="rpm_limit_type"
showDetailedDescriptions={false}
value={value as string | null}
onChange={onChange}
/>
)}
</FormField>
<FormField control={control} name="tpd_limit" label={labelWithHint("TPD Limit (batch)", TPD_HINT)}>
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
</>
);
<FormField
control={control}
name="tpd_limit"
label={labelWithHint(t("keyDetail.keyEditViewControls.rateLimits.tpdLimit"), t(TPD_HINT))}
>
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
</>
);
};
export const KeyAgentAndSkillFields = ({
control,
@ -122,62 +142,72 @@ export const KeyAgentAndSkillFields = ({
}: {
control: Control<KeyEditFormValues>;
accessToken: string;
}) => (
<>
<FormField control={control} name="agents_and_groups" label="Agents / Access Groups">
{({ value, onChange }) => (
<AgentSelector
onChange={onChange}
value={value as AgentsAndGroups | undefined}
accessToken={accessToken}
placeholder="Select agents or access groups (optional)"
/>
)}
</FormField>
}) => {
const { t } = useTranslation();
return (
<>
<FormField control={control} name="agents_and_groups" label={t("keyDetail.keyEditViewControls.agents.label")}>
{({ value, onChange }) => (
<AgentSelector
onChange={onChange}
value={value as AgentsAndGroups | undefined}
accessToken={accessToken}
placeholder={t("keyDetail.keyEditViewControls.agents.placeholder")}
/>
)}
</FormField>
<FormField control={control} name="skills" label={labelWithHint("Skills", SKILLS_HINT)}>
{({ value, onChange }) => (
<SkillSelector onChange={onChange} value={value as string[] | undefined} accessToken={accessToken} />
)}
</FormField>
</>
);
<FormField
control={control}
name="skills"
label={labelWithHint(t("keyDetail.keyEditViewControls.skills.label"), t(SKILLS_HINT))}
>
{({ value, onChange }) => (
<SkillSelector onChange={onChange} value={value as string[] | undefined} accessToken={accessToken} />
)}
</FormField>
</>
);
};
type KeyEditForm = Pick<
UseFormReturn<KeyEditFormValues, unknown, KeyEditFormValues>,
"control" | "getValues" | "setValue"
>;
export const moveMetadataTagsToTagsField = (form: KeyEditForm): void => {
export const moveMetadataTagsToTagsField = (form: KeyEditForm, t: TFunction): void => {
const moved = moveTagsOutOfMetadataJson(form.getValues("metadata"), form.getValues("tags"));
if (moved === null) return;
form.setValue("metadata", moved.metadata, { shouldDirty: true });
form.setValue("tags", moved.tags, { shouldDirty: true });
if (moved.movedTags.length > 0) {
toast.info(`Moved ${moved.movedTags.join(", ")} from metadata to the Tags field`);
toast.info(t("keyDetail.keyEditViewControls.metadata.movedTags", { tags: moved.movedTags.join(", ") }));
}
};
export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => (
<FormField
control={form.control}
name="metadata"
label="Metadata"
description="Tags are managed by the Tags field above. A tags array typed here is moved to that field."
>
{(field) => (
<Textarea
{...field}
value={(field.value as string | undefined) ?? ""}
rows={10}
onBlur={() => {
field.onBlur();
moveMetadataTagsToTagsField(form);
}}
/>
)}
</FormField>
);
export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => {
const { t } = useTranslation();
return (
<FormField
control={form.control}
name="metadata"
label={t("keyDetail.keyEditViewControls.metadata.label")}
description={t("keyDetail.keyEditViewControls.metadata.description")}
>
{(field) => (
<Textarea
{...field}
value={(field.value as string | undefined) ?? ""}
rows={10}
onBlur={() => {
field.onBlur();
moveMetadataTagsToTagsField(form, t);
}}
/>
)}
</FormField>
);
};
export const KeyBudgetNumberField = ({
control,

View file

@ -1,4 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import {
ArrowLeft,
ArrowLeftRight,
@ -68,12 +70,15 @@ interface KeyInfoHeaderProps {
}
function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null; userEmail: string; userId: string }) {
const { t } = useTranslation();
const labelEl = (
<div className="flex items-center gap-1">
<span className="text-muted-foreground">
<User className="size-3.5" />
</span>
<span className="text-xs uppercase tracking-[0.05em] text-muted-foreground">User</span>
<span className="text-xs uppercase tracking-[0.05em] text-muted-foreground">
{t("keyDetail.keyInfoHeader.user.label")}
</span>
</div>
);
@ -95,9 +100,9 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
const popoverContent = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
{[
{ label: "User Alias", value: userAlias ?? null },
{ label: "User Email", value: userEmail || null },
{ label: "User ID", value: userId || null },
{ label: t("keyDetail.keyInfoHeader.user.alias"), value: userAlias ?? null },
{ label: t("keyDetail.keyInfoHeader.user.email"), value: userEmail || null },
{ label: t("keyDetail.keyInfoHeader.user.id"), value: userId || null },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-muted-foreground">{label}</span>
@ -106,7 +111,11 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={value}>
{value}
</span>
<CopyButton value={value} label={`Copy ${label}`} iconClassName="size-3.5" />
<CopyButton
value={value}
label={t("keyDetail.keyInfoHeader.user.copyField", { label })}
iconClassName="size-3.5"
/>
</div>
) : (
<span className="font-mono">-</span>
@ -169,15 +178,16 @@ export function KeyInfoHeader({
onToggleBlocked,
isBlocked = false,
canModifyKey = true,
backButtonText = "Back to Keys",
backButtonText = i18n.t("keyDetail.keyInfoHeader.backToKeys"),
regenerateDisabled = false,
regenerateTooltip,
}: KeyInfoHeaderProps) {
const { t } = useTranslation();
const regenerateButton = (
<span>
<Button variant="outline" onClick={onRegenerate} disabled={regenerateDisabled}>
<RefreshCw className="size-3.5" />
Regenerate Key
{t("keyDetail.keyInfoHeader.regenerateKey")}
</Button>
</span>
);
@ -188,7 +198,7 @@ export function KeyInfoHeader({
<div style={{ marginBottom: 16 }}>
<Button onClick={onCreateNew}>
<Plus className="size-3.5" />
Create New Key
{t("keyDetail.keyInfoHeader.createNewKey")}
</Button>
</div>
)}
@ -205,18 +215,25 @@ export function KeyInfoHeader({
<div className="flex items-center gap-2">
<h3 className="m-0 flex items-center gap-1 text-2xl font-semibold">
{data.keyName}
<CopyButton value={data.keyName} label="Copy Key Alias" iconClassName="size-4" />
<CopyButton
value={data.keyName}
label={t("keyDetail.keyInfoHeader.copyKeyAlias")}
iconClassName="size-4"
/>
</h3>
{isBlocked && (
<Badge variant="destructive">
<Ban className="size-3" />
Blocked
{t("keyDetail.keyInfoHeader.blocked")}
</Badge>
)}
</div>
<div className="flex min-w-0 items-center gap-1">
<span className="min-w-0 break-words text-muted-foreground">Key ID: {data.keyId}</span>
<CopyButton value={data.keyId} label="Copy Key ID" iconClassName="size-3.5" />
<span className="min-w-0 break-words text-muted-foreground">
{t("keyDetail.keyInfoHeader.keyIdLabel")}
{data.keyId}
</span>
<CopyButton value={data.keyId} label={t("keyDetail.keyInfoHeader.copyKeyId")} iconClassName="size-3.5" />
</div>
</div>
{canModifyKey && (
@ -232,7 +249,9 @@ export function KeyInfoHeader({
regenerateButton
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="More key actions" />}>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon" aria-label={t("keyDetail.keyInfoHeader.moreActions")} />}
>
<MoreVertical className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
@ -240,23 +259,23 @@ export function KeyInfoHeader({
(isBlocked ? (
<DropdownMenuItem onClick={onToggleBlocked}>
<CircleCheck className="size-3.5" />
Unblock Key
{t("keyDetail.keyInfoHeader.actions.unblock")}
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive" onClick={onToggleBlocked}>
<Ban className="size-3.5" />
Block Key
{t("keyDetail.keyInfoHeader.actions.block")}
</DropdownMenuItem>
))}
{onResetSpend && (
<DropdownMenuItem variant="destructive" onClick={onResetSpend}>
<ArrowLeftRight className="size-3.5" />
Reset Spend
{t("keyDetail.keyInfoHeader.actions.resetSpend")}
</DropdownMenuItem>
)}
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="size-3.5" />
Delete Key
{t("keyDetail.keyInfoHeader.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -267,15 +286,23 @@ export function KeyInfoHeader({
<div className="flex items-stretch gap-10" style={{ marginBottom: 40 }}>
<div className="flex min-w-0 flex-col gap-4">
<UserField userAlias={data.userAlias} userEmail={data.userEmail} userId={data.userId} />
<LabeledField label="Expires" value={data.expires} icon={<Timer className="size-3.5" />} />
<LabeledField
label={t("keyDetail.keyInfoHeader.fields.expires")}
value={data.expires}
icon={<Timer className="size-3.5" />}
/>
</div>
<Separator orientation="vertical" />
<div className="flex min-w-0 flex-col gap-4">
<LabeledField label="Created At" value={data.createdAt} icon={<Calendar className="size-3.5" />} />
<LabeledField
label="Created By"
label={t("keyDetail.keyInfoHeader.fields.createdAt")}
value={data.createdAt}
icon={<Calendar className="size-3.5" />}
/>
<LabeledField
label={t("keyDetail.keyInfoHeader.fields.createdBy")}
value={data.createdBy}
icon={<ShieldCheck className="size-3.5" />}
href={data.createdById ? userDetailHref(data.createdById) : undefined}
@ -288,22 +315,30 @@ export function KeyInfoHeader({
<Separator orientation="vertical" />
<div className="flex min-w-0 flex-col gap-4">
<LabeledField label="Last Updated" value={data.lastUpdated} icon={<Clock className="size-3.5" />} />
<LabeledField label="Last Active" value={data.lastActive} icon={<Zap className="size-3.5" />} />
<LabeledField
label={t("keyDetail.keyInfoHeader.fields.lastUpdated")}
value={data.lastUpdated}
icon={<Clock className="size-3.5" />}
/>
<LabeledField
label={t("keyDetail.keyInfoHeader.fields.lastActive")}
value={data.lastActive}
icon={<Zap className="size-3.5" />}
/>
</div>
<Separator orientation="vertical" />
<div className="flex min-w-0 flex-col gap-4">
<LabeledField
label="Team"
label={t("keyDetail.keyInfoHeader.fields.team")}
value={data.teamAlias || data.teamId}
icon={<Users className="size-3.5" />}
href={data.teamId ? teamDetailHref(data.teamId) : undefined}
truncate
/>
<LabeledField
label="Organization"
label={t("keyDetail.keyInfoHeader.fields.organization")}
value={data.orgAlias || data.orgId}
icon={<Building2 className="size-3.5" />}
href={data.orgId ? orgDetailHref(data.orgId) : undefined}

View file

@ -1,6 +1,7 @@
import type { Organization, Team } from "@/components/networking";
import { isOrgAdminForAnyOrg, isProxyAdminRole } from "@/utils/roles";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
@ -15,23 +16,20 @@ type KeyProjectFieldProps = {
};
export function KeyProjectField({ projectId, canDetach, pending, disabled, onToggle }: KeyProjectFieldProps) {
const { t } = useTranslation();
const id = useId();
const { data: projects } = useProjects();
const alias = projects?.find((project) => project.project_id === projectId)?.project_alias;
const display = alias ? `${alias} (${projectId})` : projectId;
return (
<Field>
<FieldLabel htmlFor={id}>Project</FieldLabel>
<FieldLabel htmlFor={id}>{t("keyDetail.keyProjectField.label")}</FieldLabel>
<Input id={id} value={display ?? ""} disabled readOnly />
{canDetach && (
<>
{pending && (
<p className="text-sm text-muted-foreground">
The project will be removed when you save. Team, organization, and key limits will stay the same.
</p>
)}
{pending && <p className="text-sm text-muted-foreground">{t("keyDetail.keyProjectField.detachPending")}</p>}
<Button type="button" variant="outline" disabled={disabled} onClick={onToggle}>
{pending ? "Keep project" : "Detach from project"}
{pending ? t("keyDetail.keyProjectField.keepProject") : t("keyDetail.keyProjectField.detach")}
</Button>
</>
)}

View file

@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import ScopedSavingsTab from "@/components/shared/ScopedSavingsTab";
import { reportingKeyId } from "@/components/UsagePage/keyActivityData";
import { hasProxyWideSpendView, spendScopeUserId } from "@/utils/roles";
@ -13,18 +14,17 @@ interface KeySavingsTabProps {
activity: ActivityDateRange;
}
const KeySavingsTab = ({ accessToken, keyToken, userId, userRole, activity }: KeySavingsTabProps) => (
<ScopedSavingsTab
accessToken={accessToken}
scope={{ userId: spendScopeUserId(userRole, userId), apiKey: reportingKeyId(keyToken) }}
activity={activity}
entityType="key"
scopeNote={
hasProxyWideSpendView(userRole)
? undefined
: "Showing your own requests on this key. A key shared across a team will have spend from other members that is not counted here."
}
/>
);
const KeySavingsTab = ({ accessToken, keyToken, userId, userRole, activity }: KeySavingsTabProps) => {
const { t } = useTranslation();
return (
<ScopedSavingsTab
accessToken={accessToken}
scope={{ userId: spendScopeUserId(userRole, userId), apiKey: reportingKeyId(keyToken) }}
activity={activity}
entityType="key"
scopeNote={hasProxyWideSpendView(userRole) ? undefined : t("keyDetail.keySavingsTab.scopeNote")}
/>
);
};
export default KeySavingsTab;

View file

@ -1,3 +1,6 @@
import type { ParseKeys, TFunction } from "i18next";
import i18n from "@/i18n/i18n";
type Metadata = Record<string, unknown> | null | undefined;
type FormValues = Record<string, unknown>;
@ -31,17 +34,15 @@ export const estimateFields = (metadata: Metadata) => ({
[PER_MODEL_FIELD]: perModelEstimateToText(metadata?.[PER_MODEL_FIELD]),
});
const ADMIN_ONLY_TOOLTIP =
"Only a proxy admin can change this. It sets how many output tokens the rate limiter reserves for a request " +
"that omits max_tokens, which is charged against the team and organization TPM windows.";
const ADMIN_ONLY_TOOLTIP: ParseKeys = "keyDetail.estimatedOutputTokens.adminOnlyTooltip";
export const estimateTooltips = (canEdit: boolean, entity: "key" | "team" = "key") => ({
estimate: canEdit
? `Expected output tokens reserved for TPM limiting when a request omits max_tokens. Overrides the built-in estimate for this ${entity}.`
: ADMIN_ONLY_TOOLTIP,
perModel: canEdit
? `Per-model expected output tokens reserved for TPM limiting when a request omits max_tokens. Takes precedence over the ${entity}-wide estimate.`
: ADMIN_ONLY_TOOLTIP,
export const estimateTooltips = (
canEdit: boolean,
entity: "key" | "team" = "key",
t: TFunction = i18n.t.bind(i18n),
) => ({
estimate: canEdit ? t("keyDetail.estimatedOutputTokens.estimateTooltip", { entity }) : t(ADMIN_ONLY_TOOLTIP),
perModel: canEdit ? t("keyDetail.estimatedOutputTokens.perModelTooltip", { entity }) : t(ADMIN_ONLY_TOOLTIP),
});
export const estimateChecks = {

View file

@ -1,3 +1,6 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
const WORD_FORM_BUDGET_DURATIONS: Record<string, string> = {
hourly: "1h",
daily: "24h",
@ -32,9 +35,11 @@ export const parseAllowedRoutes = (value: unknown): string[] =>
export const modelSentinelOptions = (
keyTeamId: string | null | undefined,
teamLoaded: boolean,
t: TFunction = i18n.t.bind(i18n),
): { value: string; label: string }[] => {
if (keyTeamId == null) return [{ value: "all-proxy-models", label: "All Proxy Models" }];
return teamLoaded ? [{ value: "all-team-models", label: "All Team Models" }] : [];
if (keyTeamId == null)
return [{ value: "all-proxy-models", label: t("keyDetail.keyEditFieldNormalizers.allProxyModels") }];
return teamLoaded ? [{ value: "all-team-models", label: t("keyDetail.keyEditFieldNormalizers.allTeamModels") }] : [];
};
export type MovedMetadataTags = {
@ -77,5 +82,7 @@ export const currentValuePlaceholder = (
emptyHint: string,
): string => {
if (!premiumUser) return premiumHint;
return Array.isArray(current) && current.length > 0 ? `Current: ${current.join(", ")}` : emptyHint;
return Array.isArray(current) && current.length > 0
? i18n.t("keyDetail.keyEditFieldNormalizers.currentValue", { values: current.join(", ") })
: emptyHint;
};

View file

@ -13,6 +13,7 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { SearchSelect, SearchSelectOption } from "@/components/shared/SearchSelect";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { hasCapability } from "../../utils/capabilities";
import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
@ -104,6 +105,7 @@ function KeyEditSession({
userRole,
premiumUser = false,
}: KeyEditViewProps) {
const { t } = useTranslation();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
const canViewPolicies = hasCapability(userRole, "viewPolicies");
const canViewPrompts = hasCapability(userRole, "viewPrompts");
@ -215,11 +217,11 @@ function KeyEditSession({
const response = await tagListCall(accessToken);
setTagsList(response);
} catch (error) {
toast.fromError("Error fetching tags: " + error);
toast.fromError(t("keyDetail.keyEditView.errors.fetchTags") + error);
}
};
fetchTags();
}, [accessToken]);
}, [accessToken, t]);
const handleSubmit = async (values: any) => {
try {
@ -345,7 +347,7 @@ function KeyEditSession({
};
const modelOptions = [
...modelSentinelOptions(keyData.team_id, team != null),
...modelSentinelOptions(keyData.team_id, team != null, t),
...availableModels.map((model) => ({
value: model,
label: model,
@ -373,22 +375,22 @@ function KeyEditSession({
<TooltipProvider>
<form
onSubmit={(event) => {
moveMetadataTagsToTagsField(form);
moveMetadataTagsToTagsField(form, t);
return form.handleSubmit((values) =>
handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })),
)(event);
}}
>
<FieldGroup>
<FormField control={form.control} name="key_alias" label="Key Alias">
<FormField control={form.control} name="key_alias" label={t("keyDetail.keyEditView.fields.keyAlias")}>
{(field) => <Input {...field} value={(field.value as string | undefined) ?? ""} />}
</FormField>
<FormField
control={form.control}
name="models"
label="Models"
description={isModelsDisabled ? "Models field is disabled for this key type" : undefined}
label={t("keyDetail.keyEditView.fields.models")}
description={isModelsDisabled ? t("keyDetail.keyEditView.fields.modelsDisabled") : undefined}
>
{({ value, onChange, id }) => (
<MultiSelect
@ -405,13 +407,13 @@ function KeyEditSession({
}
}}
disabled={isModelsDisabled}
placeholder="Select models"
placeholder={t("keyDetail.keyEditView.fields.modelsPlaceholder")}
/>
)}
</FormField>
<Field>
<FieldLabel htmlFor={keyTypeFieldId}>Key Type</FieldLabel>
<FieldLabel htmlFor={keyTypeFieldId}>{t("keyDetail.keyEditView.fields.keyType")}</FieldLabel>
<KeyTypeSelect
id={keyTypeFieldId}
value={keyTypeFromRoutes(allowedRoutes)}
@ -436,15 +438,15 @@ function KeyEditSession({
control={form.control}
name="allowed_routes"
label={labelWithHint(
"Allowed Routes",
"List of allowed routes for the key (comma-separated). Can be specific routes (e.g., '/chat/completions') or route patterns (e.g., 'llm_api_routes', 'management_routes', '/keys/*'). Leave empty to allow all routes.",
t("keyDetail.keyEditView.fields.allowedRoutes"),
t("keyDetail.keyEditView.fields.allowedRoutesHint"),
)}
>
{(field) => (
<Input
{...field}
value={(field.value as string | undefined) ?? ""}
placeholder="Enter allowed routes (comma-separated). Special values: llm_api_routes, management_routes. Examples: llm_api_routes, /chat/completions, /keys/*. Leave empty to allow all routes"
placeholder={t("keyDetail.keyEditView.fields.allowedRoutesPlaceholder")}
/>
)}
</FormField>
@ -452,24 +454,28 @@ function KeyEditSession({
<KeyBudgetNumberField
control={form.control}
name="max_budget"
label="Max Budget (USD)"
placeholder="Enter a numerical value"
label={t("keyDetail.keyEditView.fields.maxBudget")}
placeholder={t("keyDetail.keyEditView.fields.maxBudgetPlaceholder")}
/>
<KeyBudgetNumberField
control={form.control}
name="soft_budget"
label="Soft Budget (USD)"
placeholder="Get alerts when spend crosses this value, without blocking requests"
label={t("keyDetail.keyEditView.fields.softBudget")}
placeholder={t("keyDetail.keyEditView.fields.softBudgetPlaceholder")}
/>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
<FormField
control={form.control}
name="budget_duration"
label={t("keyDetail.keyEditView.fields.resetBudget")}
>
{({ value, onChange, id }) => (
<BudgetDurationDropdown
id={id}
value={value as string | null}
onChange={(next) => onChange(next ?? null)}
placeholder="Never resets"
placeholder={t("keyDetail.keyEditView.fields.resetBudgetPlaceholder")}
/>
)}
</FormField>
@ -477,8 +483,8 @@ function KeyEditSession({
<Field>
<FieldLabel>
{labelWithHint(
"Budget Windows",
"Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.",
t("keyDetail.keyEditView.fields.budgetWindows"),
t("keyDetail.keyEditView.fields.budgetWindowsHint"),
)}
</FieldLabel>
<BudgetWindowsEditor value={budgetLimits} onChange={setBudgetLimits} />
@ -491,14 +497,14 @@ function KeyEditSession({
onChange={modelBudget.setValue}
availableModels={availableModels}
usage={keyData.model_max_budget_usage}
hint="Cap spend on individual models, each with its own reset window. Enforced across every request this key makes."
hint={t("keyDetail.keyEditView.fields.modelMaxBudgetHint")}
/>
<Field>
<FieldLabel>
{labelWithHint(
"Budget Fallbacks",
"When a model exceeds its per-model budget, requests automatically reroute to fallback models instead of failing",
t("keyDetail.keyEditView.fields.budgetFallbacks"),
t("keyDetail.keyEditView.fields.budgetFallbacksHint"),
)}
</FieldLabel>
<BudgetFallbacksEditor
@ -511,7 +517,7 @@ function KeyEditSession({
{keyOffersEndUserBudget(keyData.metadata) && (
<Field>
<FieldLabel htmlFor={endUserBudgetFieldId}>
{labelWithHint("Default Customer Budget", END_USER_BUDGET_HINT)}
{labelWithHint(t("keyDetail.keyEditView.fields.defaultCustomerBudget"), t(END_USER_BUDGET_HINT))}
</FieldLabel>
<EndUserBudgetSelect
id={endUserBudgetFieldId}
@ -529,8 +535,8 @@ function KeyEditSession({
control={form.control}
name="throttle_on_budget_exceeded"
label={labelWithHint(
"Throttle on budget exceeded",
"When this key exceeds its max budget, throttle its TPM/RPM to the globally configured percentage instead of blocking access entirely. Requires budget_exceeded_throttle_percentage in litellm_settings and a TPM/RPM limit on the key.",
t("keyDetail.keyEditView.fields.throttleOnBudgetExceeded"),
t("keyDetail.keyEditView.fields.throttleOnBudgetExceededHint"),
)}
>
{({ value, onChange, ref: _ref, ...field }) => (
@ -542,8 +548,8 @@ function KeyEditSession({
control={form.control}
name="enable_prompt_caching"
label={labelWithHint(
"Enable Prompt Caching",
"Automatically add prompt caching breakpoints (cache_control markers) to requests made with this key, cutting input cost on repeated prompts. Applies to Anthropic and Bedrock Claude models; requests that already set their own cache_control markers are left untouched.",
t("keyDetail.keyEditView.fields.enablePromptCaching"),
t("keyDetail.keyEditView.fields.enablePromptCachingHint"),
)}
>
{({ value, onChange, ref: _ref, ...field }) => (
@ -551,11 +557,19 @@ function KeyEditSession({
)}
</FormField>
<FormField control={form.control} name="max_parallel_requests" label="Max Parallel Requests">
<FormField
control={form.control}
name="max_parallel_requests"
label={t("keyDetail.keyEditView.fields.maxParallelRequests")}
>
{({ ref: _ref, ...field }) => <NumericalInput {...field} value={field.value ?? ""} min={0} />}
</FormField>
<FormField control={form.control} name="model_tpm_limit" label="Model TPM Limit">
<FormField
control={form.control}
name="model_tpm_limit"
label={t("keyDetail.keyEditView.fields.modelTpmLimit")}
>
{(field) => (
<Textarea
{...field}
@ -566,7 +580,11 @@ function KeyEditSession({
)}
</FormField>
<FormField control={form.control} name="model_rpm_limit" label="Model RPM Limit">
<FormField
control={form.control}
name="model_rpm_limit"
label={t("keyDetail.keyEditView.fields.modelRpmLimit")}
>
{(field) => (
<Textarea
{...field}
@ -580,7 +598,7 @@ function KeyEditSession({
<FormField
control={form.control}
name="default_estimated_output_tokens"
label={labelWithHint("Estimated Output Tokens", estimateTooltip.estimate)}
label={labelWithHint(t("keyDetail.keyEditView.fields.estimatedOutputTokens"), estimateTooltip.estimate)}
>
{({ ref: _ref, ...field }) => (
<NumericalInput {...field} value={field.value ?? ""} min={1} step={1} disabled={!canEditEstimates} />
@ -590,7 +608,10 @@ function KeyEditSession({
<FormField
control={form.control}
name="default_estimated_output_tokens_per_model"
label={labelWithHint("Estimated Output Tokens Per Model", estimateTooltip.perModel)}
label={labelWithHint(
t("keyDetail.keyEditView.fields.estimatedOutputTokensPerModel"),
estimateTooltip.perModel,
)}
>
{(field) => (
<Textarea
@ -606,14 +627,14 @@ function KeyEditSession({
<Field>
<FieldLabel>
{labelWithHint(
"Per-Tag Rate Limits",
"Scope rate limits to a request tag so each tag (e.g. a cell or group) gets its own RPM counter. Requests without a matching tag fall back to the key-level limit.",
t("keyDetail.keyEditView.fields.perTagRateLimits"),
t("keyDetail.keyEditView.fields.perTagRateLimitsHint"),
)}
</FieldLabel>
<TagRateLimitEditor value={tagRateLimits} onChange={setTagRateLimits} />
</Field>
<FormField control={form.control} name="guardrails" label="Guardrails">
<FormField control={form.control} name="guardrails" label={t("keyDetail.keyEditView.fields.guardrails")}>
{({ value, onChange }) =>
accessToken ? (
<GuardrailSelector
@ -633,8 +654,8 @@ function KeyEditSession({
control={form.control}
name="disable_global_guardrails"
label={labelWithHint(
"Disable Global Guardrails",
"When enabled, this key will bypass any guardrails configured to run on every request (global guardrails)",
t("keyDetail.keyEditView.fields.disableGlobalGuardrails"),
t("keyDetail.keyEditView.fields.disableGlobalGuardrailsHint"),
)}
>
{({ value, onChange, ref: _ref, ...field }) => (
@ -647,7 +668,10 @@ function KeyEditSession({
<FormField
control={form.control}
name="policies"
label={labelWithHint("Policies", "Apply policies to this key to control guardrails and other settings")}
label={labelWithHint(
t("keyDetail.keyEditView.fields.policies"),
t("keyDetail.keyEditView.fields.policiesHint"),
)}
>
{({ value, onChange }) =>
accessToken ? (
@ -664,14 +688,14 @@ function KeyEditSession({
</FormField>
)}
<FormField control={form.control} name="tags" label="Tags">
<FormField control={form.control} name="tags" label={t("keyDetail.keyEditView.fields.tags")}>
{({ value, onChange, id }) => (
<TagsInput
id={id}
value={(value as string[] | undefined) ?? []}
onValueChange={onChange}
options={Object.values(tagsList).map((tag) => ({ value: tag.name, label: tag.name }))}
placeholder="Select or enter tags"
placeholder={t("keyDetail.keyEditView.fields.tagsPlaceholder")}
/>
)}
</FormField>
@ -680,7 +704,14 @@ function KeyEditSession({
<FormField
control={form.control}
name="prompts"
label={premiumUser ? "Prompts" : labelWithHint("Prompts", "Setting prompts by key is a premium feature")}
label={
premiumUser
? t("keyDetail.keyEditView.fields.prompts")
: labelWithHint(
t("keyDetail.keyEditView.fields.prompts"),
t("keyDetail.keyEditView.fields.promptsPremiumHint"),
)
}
>
{({ value, onChange, id }) => (
<TagsInput
@ -692,8 +723,8 @@ function KeyEditSession({
placeholder={currentValuePlaceholder(
premiumUser,
keyData.metadata?.prompts,
"Premium feature - Upgrade to set prompts by key",
"Select or enter prompts",
t("keyDetail.keyEditView.fields.promptsPremiumPlaceholder"),
t("keyDetail.keyEditView.fields.promptsPlaceholder"),
)}
/>
)}
@ -704,15 +735,15 @@ function KeyEditSession({
control={form.control}
name="access_group_ids"
label={labelWithHint(
"Access Groups",
"Assign access groups to this key. Access groups control which models, MCP servers, and agents this key can use",
t("keyDetail.keyEditView.fields.accessGroups"),
t("keyDetail.keyEditView.fields.accessGroupsHint"),
)}
>
{({ value, onChange }) => (
<AccessGroupSelector
value={value as string[] | undefined}
onChange={onChange}
placeholder="Select access groups (optional)"
placeholder={t("keyDetail.keyEditView.fields.accessGroupsPlaceholder")}
/>
)}
</FormField>
@ -722,10 +753,10 @@ function KeyEditSession({
name="allowed_passthrough_routes"
label={
premiumUser
? "Allowed Pass Through Routes"
? t("keyDetail.keyEditView.fields.allowedPassThroughRoutes")
: labelWithHint(
"Allowed Pass Through Routes",
"Setting allowed pass through routes by key is a premium feature",
t("keyDetail.keyEditView.fields.allowedPassThroughRoutes"),
t("keyDetail.keyEditView.fields.allowedPassThroughRoutesPremiumHint"),
)
}
>
@ -737,33 +768,37 @@ function KeyEditSession({
placeholder={currentValuePlaceholder(
premiumUser,
keyData.metadata?.allowed_passthrough_routes,
"Premium feature - Upgrade to set allowed pass through routes by key",
"Select or enter allowed pass through routes",
t("keyDetail.keyEditView.fields.allowedPassThroughRoutesPremiumPlaceholder"),
t("keyDetail.keyEditView.fields.allowedPassThroughRoutesPlaceholder"),
)}
disabled={!premiumUser}
/>
)}
</FormField>
<FormField control={form.control} name="vector_stores" label="Vector Stores">
<FormField control={form.control} name="vector_stores" label={t("keyDetail.keyEditView.fields.vectorStores")}>
{({ value, onChange }) => (
<VectorStoreSelector
onChange={onChange}
value={value as string[] | undefined}
accessToken={accessToken || ""}
placeholder="Select vector stores"
placeholder={t("keyDetail.keyEditView.fields.vectorStoresPlaceholder")}
/>
)}
</FormField>
<FormField control={form.control} name="mcp_servers_and_groups" label="MCP Servers / Access Groups">
<FormField
control={form.control}
name="mcp_servers_and_groups"
label={t("keyDetail.keyEditView.fields.mcpServers")}
>
{({ value, onChange }) => (
<MCPServerSelector
onChange={onChange}
value={value as McpServersAndGroups | undefined}
accessToken={accessToken || ""}
{...mcpScope}
placeholder="Select MCP servers or access groups (optional)"
placeholder={t("keyDetail.keyEditView.fields.mcpServersPlaceholder")}
allowNoMcpServers
/>
)}
@ -786,10 +821,10 @@ function KeyEditSession({
control={form.control}
name="organization_id"
label={labelWithHint(
"Organization",
"The organization this key belongs to. Selecting an organization filters the available teams.",
t("keyDetail.keyEditView.fields.organization"),
t("keyDetail.keyEditView.fields.organizationHint"),
)}
description={hasProject ? "Organization is locked because this key belongs to a project" : undefined}
description={hasProject ? t("keyDetail.keyEditView.fields.organizationLocked") : undefined}
>
{({ value, onChange, id }) => (
<OrganizationDropdown
@ -806,8 +841,8 @@ function KeyEditSession({
<FormField
control={form.control}
name="team_id"
label="Team"
description={hasProject ? "Team is locked because this key belongs to a project" : undefined}
label={t("keyDetail.keyEditView.fields.team")}
description={hasProject ? t("keyDetail.keyEditView.fields.teamLocked") : undefined}
>
{({ value, onChange, id }) => (
<SearchSelect
@ -816,8 +851,8 @@ function KeyEditSession({
onValueChange={(teamId) => handleTeamChange(onChange, teamId)}
disabled={hasProject}
inputId={id}
placeholder="Select team"
emptyText="No teams found"
placeholder={t("keyDetail.keyEditView.fields.teamPlaceholder")}
emptyText={t("keyDetail.keyEditView.fields.teamEmpty")}
/>
)}
</FormField>
@ -835,7 +870,7 @@ function KeyEditSession({
)}
<Field>
<FieldLabel>Router Settings</FieldLabel>
<FieldLabel>{t("keyDetail.keyEditView.fields.routerSettings")}</FieldLabel>
<RouterSettingsAccordion
ref={routerSettingsRef}
accessToken={accessToken || ""}
@ -844,7 +879,11 @@ function KeyEditSession({
/>
</Field>
<FormField control={form.control} name="logging_settings" label="Logging Settings">
<FormField
control={form.control}
name="logging_settings"
label={t("keyDetail.keyEditView.fields.loggingSettings")}
>
{({ value, onChange }) => (
<EditLoggingSettings
value={(value as unknown[] | undefined) ?? []}
@ -879,11 +918,11 @@ function KeyEditSession({
<div className="sticky z-chrome bg-background p-4 border-t border-border -bottom-6 -inset-x-6">
<div className="flex justify-end items-center gap-2">
<Button type="button" variant="secondary" onClick={onCancel} disabled={isKeySaving}>
Cancel
{t("keyDetail.keyEditView.actions.cancel")}
</Button>
<Button type="submit" disabled={isKeySaving} aria-busy={isKeySaving}>
{isKeySaving && <UiLoadingSpinner className="size-4" />}
Save Changes
{t("keyDetail.keyEditView.actions.save")}
</Button>
</div>
</div>

View file

@ -22,6 +22,8 @@ import KeySavingsTab from "./KeySavingsTab";
import KeyAutoRouterUsageTab from "./KeyAutoRouterUsageTab";
import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
import { useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import {
hasProxyWideSpendView,
isProxyAdminRole,
@ -101,8 +103,9 @@ export default function KeyInfoView({
teams,
onKeyDataUpdate,
onDelete,
backButtonText = "Back to Keys",
backButtonText = i18n.t("keyDetail.keyInfoView.backToKeys"),
}: KeyInfoViewProps) {
const { t } = useTranslation();
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const accessGroupNames = useModelAccessGroupNames();
const activityDateRange = useActivityDateRange();
@ -201,7 +204,7 @@ export default function KeyInfoView({
<ArrowLeft className="size-4" />
{backButtonText}
</Button>
<p className="text-sm">Key not found</p>
<p className="text-sm">{t("keyDetail.keyInfoView.keyNotFound")}</p>
</div>
);
}
@ -256,7 +259,7 @@ export default function KeyInfoView({
const nextSoftBudget =
formValues.soft_budget === "" || formValues.soft_budget == null ? null : Number(formValues.soft_budget);
if (nextSoftBudget !== null && !Number.isFinite(nextSoftBudget)) {
toast.error("Soft Budget must be a finite number");
toast.error(t("keyDetail.keyInfoView.toasts.softBudgetNotFinite"));
return;
}
if (nextSoftBudget === previousSoftBudget) {
@ -285,7 +288,7 @@ export default function KeyInfoView({
(toolsetId) => !(allMcpToolsets ?? []).some((toolset) => toolset.toolset_id === toolsetId),
);
if (unresolvableSelection && Object.keys(mcpEntitlement.mcp_tool_permissions).length > 0) {
toast.error("MCP server or toolset list is unavailable, so MCP permissions cannot be saved yet. Retry.");
toast.error(t("keyDetail.keyInfoView.toasts.mcpListUnavailable"));
return;
}
formValues.object_permission = {
@ -344,7 +347,7 @@ export default function KeyInfoView({
};
} catch (error) {
console.error("Error parsing metadata JSON:", error);
toast.error("Invalid metadata JSON");
toast.error(t("keyDetail.keyInfoView.toasts.invalidMetadataJson"));
return;
}
} else {
@ -394,7 +397,7 @@ export default function KeyInfoView({
const trimmed = teamAdminMemberKeyPayload(formValues, dirtyFields);
if (trimmed.kind === "blocked") {
toast.error(
`Team admins can only change budget fields on other members' keys, not ${trimmed.fields.join(", ")}`,
t("keyDetail.keyInfoView.toasts.teamAdminBudgetFieldsOnly", { fields: trimmed.fields.join(", ") }),
);
return;
}
@ -411,7 +414,7 @@ export default function KeyInfoView({
if (onKeyDataUpdate) {
onKeyDataUpdate(newKeyValues);
}
toast.success("Key updated successfully");
toast.success(t("keyDetail.keyInfoView.toasts.updated"));
setIsEditing(false);
// Refresh key data here if needed
} catch (error) {
@ -425,7 +428,7 @@ export default function KeyInfoView({
setDeleteLoading(true);
if (!accessToken) return;
await keyDeleteCall(accessToken as string, currentKeyData.token || currentKeyData.token_id);
toast.success("Key deleted successfully");
toast.success(t("keyDetail.keyInfoView.toasts.deleted"));
await queryClient.invalidateQueries({ queryKey: keyKeys.lists() });
if (onDelete) {
onDelete();
@ -485,7 +488,7 @@ export default function KeyInfoView({
minute: "2-digit",
hour12: true,
});
return `${dateStr} at ${timeStr}`;
return t("keyDetail.keyInfoView.timestamp", { date: dateStr, time: timeStr });
};
const canModifyKey =
@ -517,7 +520,7 @@ export default function KeyInfoView({
if (onKeyDataUpdate) {
onKeyDataUpdate({ spend: 0 });
}
toast.success("Key spend reset to $0");
toast.success(t("keyDetail.keyInfoView.toasts.spendReset"));
setIsResetSpendModalOpen(false);
},
onError: (error) => {
@ -539,7 +542,9 @@ export default function KeyInfoView({
if (onKeyDataUpdate) {
onKeyDataUpdate({ blocked });
}
toast.success(blocked ? "Key blocked" : "Key unblocked");
toast.success(
blocked ? t("keyDetail.keyInfoView.toasts.blocked") : t("keyDetail.keyInfoView.toasts.unblocked"),
);
setIsBlockModalOpen(false);
},
onError: (error) => {
@ -567,8 +572,11 @@ export default function KeyInfoView({
);
const budgetDisplay = (() => {
if (hasOwnBudget) return `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}`;
if (memberGate) return `$${formatNumberWithCommas(memberGate.maxBudget, 2)} (team member budget)`;
return "Unlimited";
if (memberGate)
return t("keyDetail.keyInfoView.budget.teamMemberBudget", {
budget: formatNumberWithCommas(memberGate.maxBudget, 2),
});
return t("keyDetail.keyInfoView.budget.unlimited");
})();
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser, memberGate);
@ -576,7 +584,7 @@ export default function KeyInfoView({
<div className="w-full h-full overflow-y-auto p-4">
<KeyInfoHeader
data={{
keyName: currentKeyData.key_alias || "Virtual Key",
keyName: currentKeyData.key_alias || t("keyDetail.keyInfoView.header.defaultKeyName"),
keyId: currentKeyData.token_id || currentKeyData.token,
userId: currentKeyData.user_id || "",
userEmail: currentKeyData.user_email || "",
@ -593,8 +601,12 @@ export default function KeyInfoView({
createdById: currentKeyData.created_by_user?.user_id || currentKeyData.created_by || "",
createdAt: currentKeyData.created_at ? formatTimestamp(currentKeyData.created_at) : "",
lastUpdated: lastConfiguredAt ? formatTimestamp(lastConfiguredAt) : "",
lastActive: currentKeyData.last_active ? formatTimestamp(currentKeyData.last_active) : "Never",
expires: currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never",
lastActive: currentKeyData.last_active
? formatTimestamp(currentKeyData.last_active)
: t("keyDetail.keyInfoView.header.never"),
expires: currentKeyData.expires
? formatTimestamp(currentKeyData.expires)
: t("keyDetail.keyInfoView.header.never"),
}}
onBack={onClose}
onRegenerate={() => setIsRegenerateModalOpen(true)}
@ -605,9 +617,7 @@ export default function KeyInfoView({
canModifyKey={canModifyKey}
backButtonText={backButtonText}
regenerateDisabled={!premiumUser}
regenerateTooltip={
!premiumUser ? "This is a LiteLLM Enterprise feature, and requires a valid key to use." : undefined
}
regenerateTooltip={!premiumUser ? t("keyDetail.keyInfoView.header.enterpriseOnly") : undefined}
/>
{/* Add RegenerateKeyModal */}
@ -621,27 +631,27 @@ export default function KeyInfoView({
{/* Delete Confirmation Modal */}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Key"
alertMessage="This action is irreversible and will immediately revoke access for any applications using this key."
message="Are you sure you want to delete this Virtual Key?"
resourceInformationTitle="Key Information"
title={t("keyDetail.keyInfoView.deleteModal.title")}
alertMessage={t("keyDetail.keyInfoView.deleteModal.alert")}
message={t("keyDetail.keyInfoView.deleteModal.message")}
resourceInformationTitle={t("keyDetail.keyInfoView.deleteModal.informationTitle")}
resourceInformation={[
{
label: "Key Alias",
label: t("keyDetail.keyInfoView.deleteModal.keyAlias"),
value: currentKeyData?.key_alias || "-",
},
{
label: "Key ID",
label: t("keyDetail.keyInfoView.deleteModal.keyId"),
value: currentKeyData?.token_id || currentKeyData?.token || "-",
code: true,
},
{
label: "Team ID",
label: t("keyDetail.keyInfoView.deleteModal.teamId"),
value: currentKeyData?.team_id || "-",
code: true,
},
{
label: "Spend",
label: t("keyDetail.keyInfoView.deleteModal.spend"),
value: currentKeyData?.spend ? `$${formatNumberWithCommas(currentKeyData.spend, 4)}` : "$0.0000",
},
]}
@ -657,22 +667,35 @@ export default function KeyInfoView({
<Dialog open={isResetSpendModalOpen} onOpenChange={(open) => setIsResetSpendModalOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Key Spend</DialogTitle>
<DialogTitle>{t("keyDetail.keyInfoView.resetSpendDialog.title")}</DialogTitle>
</DialogHeader>
<p>
Reset spend for <strong>{currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}</strong> to{" "}
<strong>$0</strong>?
<Trans
i18nKey="keyDetail.keyInfoView.resetSpendDialog.prompt"
components={{
strong: <strong />,
alias: (
<>
{currentKeyData?.key_alias ||
currentKeyData?.token_id ||
t("keyDetail.keyInfoView.resetSpendDialog.thisKey")}
</>
),
}}
/>
</p>
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: 8 }}>
Current spend: <strong>${formatNumberWithCommas(currentKeyData.spend, 4)}</strong>. Spend history is
preserved in logs. This resets the current period spend counter, the same as an automatic budget reset.
<Trans
i18nKey="keyDetail.keyInfoView.resetSpendDialog.summary"
components={{ strong: <strong />, spend: <>{formatNumberWithCommas(currentKeyData.spend, 4)}</> }}
/>
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setIsResetSpendModalOpen(false)}>
Cancel
{t("keyDetail.keyInfoView.resetSpendDialog.cancel")}
</Button>
<Button variant="destructive" onClick={handleResetSpend} disabled={resetSpendLoading}>
Reset
{t("keyDetail.keyInfoView.resetSpendDialog.confirm")}
</Button>
</DialogFooter>
</DialogContent>
@ -681,27 +704,36 @@ export default function KeyInfoView({
<Dialog open={isBlockModalOpen} onOpenChange={(open) => setIsBlockModalOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isBlocked ? "Unblock Key" : "Block Key"}</DialogTitle>
<DialogTitle>
{isBlocked
? t("keyDetail.keyInfoView.blockDialog.unblockTitle")
: t("keyDetail.keyInfoView.blockDialog.blockTitle")}
</DialogTitle>
</DialogHeader>
<p>
{isBlocked ? "Unblock" : "Block"}{" "}
<strong>{currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}</strong>?
{isBlocked ? t("keyDetail.keyInfoView.blockDialog.unblock") : t("keyDetail.keyInfoView.blockDialog.block")}{" "}
<strong>
{currentKeyData?.key_alias || currentKeyData?.token_id || t("keyDetail.keyInfoView.blockDialog.thisKey")}
</strong>
?
</p>
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: 8 }}>
{isBlocked
? "Requests using this key will be accepted again."
: "Requests using this key will be rejected with a 401 error until it is unblocked. The key is not deleted and can be unblocked at any time."}
? t("keyDetail.keyInfoView.blockDialog.unblockDescription")
: t("keyDetail.keyInfoView.blockDialog.blockDescription")}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setIsBlockModalOpen(false)}>
Cancel
{t("keyDetail.keyInfoView.blockDialog.cancel")}
</Button>
<Button
variant={isBlocked ? "default" : "destructive"}
onClick={handleToggleBlocked}
disabled={blockLoading}
>
{isBlocked ? "Unblock" : "Block"}
{isBlocked
? t("keyDetail.keyInfoView.blockDialog.confirmUnblock")
: t("keyDetail.keyInfoView.blockDialog.confirmBlock")}
</Button>
</DialogFooter>
</DialogContent>
@ -710,18 +742,18 @@ export default function KeyInfoView({
<Tabs defaultValue="overview">
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
{t("keyDetail.keyInfoView.tabs.overview")}
</TabsTrigger>
<TabsTrigger value="savings" className="flex-none rounded-none px-4 py-2">
Savings
{t("keyDetail.keyInfoView.tabs.savings")}
</TabsTrigger>
{hasProxyWideSpendView(userRole) && (
<TabsTrigger value="auto-router-usage" className="flex-none rounded-none px-4 py-2">
Auto-router usage
{t("keyDetail.keyInfoView.tabs.autoRouterUsage")}
</TabsTrigger>
)}
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
{t("keyDetail.keyInfoView.tabs.settings")}
</TabsTrigger>
</TabsList>
@ -731,7 +763,7 @@ export default function KeyInfoView({
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p className="text-sm">
Spend
{t("keyDetail.keyInfoView.overview.spend")}
<BudgetPeriodTooltip
spend={currentKeyData.spend}
maxBudget={currentKeyData.max_budget}
@ -743,22 +775,29 @@ export default function KeyInfoView({
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
<p className="text-sm">
{memberGate
? `Team member spend $${formatNumberWithCommas(memberGate.spend ?? 0, 4)} of $${formatNumberWithCommas(memberGate.maxBudget, 2)} (team member budget)`
: `of ${budgetDisplay}`}
? t("keyDetail.keyInfoView.overview.teamMemberSpend", {
spend: formatNumberWithCommas(memberGate.spend ?? 0, 4),
maxBudget: formatNumberWithCommas(memberGate.maxBudget, 2),
})
: t("keyDetail.keyInfoView.overview.ofBudget", { budget: budgetDisplay })}
<InheritedBudgetHint gates={inheritedGates} />
</p>
{currentKeyData.budget_reset_at && (
<p className="text-sm">Resets {formatTimestamp(currentKeyData.budget_reset_at)}</p>
<p className="text-sm">
{t("keyDetail.keyInfoView.overview.resets")}
{formatTimestamp(currentKeyData.budget_reset_at)}
</p>
)}
<p className="text-sm mt-2" data-testid="key-lifetime-spend">
Lifetime spend: ${formatNumberWithCommas(currentKeyData.total_spend ?? 0, 4)}
{t("keyDetail.keyInfoView.overview.lifetimeSpend")}
{formatNumberWithCommas(currentKeyData.total_spend ?? 0, 4)}
{needsLifetimeSpendBackfill(currentKeyData.spend, currentKeyData.total_spend) && (
<HoverCard>
<HoverCardTrigger
render={
<button
type="button"
aria-label="Why lifetime spend is below current spend"
aria-label={t("keyDetail.keyInfoView.overview.lifetimeSpendHintLabel")}
className="inline-flex align-middle ml-1 cursor-help"
data-testid="key-lifetime-spend-backfill-hint"
/>
@ -767,8 +806,7 @@ export default function KeyInfoView({
<Info className="size-3 text-muted-foreground" />
</HoverCardTrigger>
<HoverCardContent className="w-80">
Lifetime tracking started with LiteLLM v1.103.0 on September 19, 2026 and was not backfilled,
so this key&apos;s lifetime spend only counts usage since that upgrade.
{t("keyDetail.keyInfoView.overview.lifetimeSpendHint")}
</HoverCardContent>
</HoverCard>
)}
@ -777,23 +815,32 @@ export default function KeyInfoView({
</Card>
<Card className="block p-6">
<p className="text-sm">Rate Limits</p>
<p className="text-sm">{t("keyDetail.keyInfoView.overview.rateLimits")}</p>
<div className="mt-2">
<p className="text-sm">
TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}
{t("keyDetail.keyInfoView.overview.tpm")}
{currentKeyData.tpm_limit !== null
? currentKeyData.tpm_limit
: t("keyDetail.keyInfoView.overview.unlimited")}
</p>
<p className="text-sm">
RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}
{t("keyDetail.keyInfoView.overview.rpm")}
{currentKeyData.rpm_limit !== null
? currentKeyData.rpm_limit
: t("keyDetail.keyInfoView.overview.unlimited")}
</p>
<p className="text-sm">
{t("keyDetail.keyInfoView.overview.tpd")}
{currentKeyData.tpd_limit ?? t("keyDetail.keyInfoView.overview.unlimited")}
</p>
<p className="text-sm">TPD (batch): {currentKeyData.tpd_limit ?? "Unlimited"}</p>
{Boolean(currentKeyData.metadata?.throttle_on_budget_exceeded) && (
<p className="text-sm">Throttle on budget exceeded: Yes</p>
<p className="text-sm">{t("keyDetail.keyInfoView.overview.throttleOnBudgetExceeded")}</p>
)}
</div>
</Card>
<Card className="block p-6">
<p className="text-sm">Models</p>
<p className="text-sm">{t("keyDetail.keyInfoView.overview.models")}</p>
<div className="mt-2 flex flex-wrap gap-2">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => (
@ -806,7 +853,7 @@ export default function KeyInfoView({
</BadgeLink>
))
) : (
<p className="text-sm">No models specified</p>
<p className="text-sm">{t("keyDetail.keyInfoView.overview.noModels")}</p>
)}
</div>
</Card>
@ -820,7 +867,7 @@ export default function KeyInfoView({
</Card>
<Card className="block p-6">
<p className="text-sm font-medium mb-3">Guardrails</p>
<p className="text-sm font-medium mb-3">{t("keyDetail.keyInfoView.overview.guardrails")}</p>
{Array.isArray(currentKeyData.metadata?.guardrails) && currentKeyData.metadata.guardrails.length > 0 ? (
<div className="flex flex-wrap gap-2">
{currentKeyData.metadata.guardrails.map((guardrail: string, index: number) => (
@ -830,18 +877,20 @@ export default function KeyInfoView({
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No guardrails configured</p>
<p className="text-sm text-muted-foreground">{t("keyDetail.keyInfoView.overview.noGuardrails")}</p>
)}
{typeof currentKeyData.metadata?.disable_global_guardrails === "boolean" &&
currentKeyData.metadata.disable_global_guardrails === true && (
<div className="mt-3 pt-3 border-t border-border">
<Badge variant="destructive">Global Guardrails Disabled</Badge>
<Badge variant="destructive">
{t("keyDetail.keyInfoView.overview.globalGuardrailsDisabled")}
</Badge>
</div>
)}
</Card>
<Card className="block p-6">
<p className="text-sm font-medium mb-3">Policies</p>
<p className="text-sm font-medium mb-3">{t("keyDetail.keyInfoView.overview.policies")}</p>
{Array.isArray(currentKeyData.metadata?.policies) && currentKeyData.metadata.policies.length > 0 ? (
<div className="space-y-4">
{currentKeyData.metadata.policies.map((policy: string, index: number) => (
@ -850,11 +899,17 @@ export default function KeyInfoView({
<Badge variant="secondary" className="min-w-0 break-words">
{policy}
</Badge>
{loadingPolicies && <p className="text-xs text-muted-foreground">Loading guardrails...</p>}
{loadingPolicies && (
<p className="text-xs text-muted-foreground">
{t("keyDetail.keyInfoView.overview.loadingGuardrails")}
</p>
)}
</div>
{!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && (
<div className="ml-4 pl-3 border-l-2 border-border">
<p className="text-xs text-muted-foreground mb-1">Resolved Guardrails:</p>
<p className="text-xs text-muted-foreground mb-1">
{t("keyDetail.keyInfoView.overview.resolvedGuardrails")}
</p>
<div className="flex flex-wrap gap-1">
{policyGuardrails[policy].map((guardrail: string, gIndex: number) => (
<Badge key={gIndex} variant="secondary" className="min-w-0 break-words">
@ -868,7 +923,7 @@ export default function KeyInfoView({
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No policies configured</p>
<p className="text-sm text-muted-foreground">{t("keyDetail.keyInfoView.overview.noPolicies")}</p>
)}
</Card>
@ -919,10 +974,10 @@ export default function KeyInfoView({
<TabsContent value="settings" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Key Settings</h3>
<h3 className="text-lg font-medium">{t("keyDetail.keyInfoView.settings.title")}</h3>
{!isEditing && canModifyKey && (
<Button variant="outline" onClick={() => setIsEditing(true)}>
Edit Settings
{t("keyDetail.keyInfoView.settings.edit")}
</Button>
)}
</div>
@ -941,36 +996,36 @@ export default function KeyInfoView({
) : (
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Key ID</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.keyId")}</p>
<p className="text-sm font-mono">{currentKeyData.token_id || currentKeyData.token}</p>
</div>
<div>
<p className="text-sm font-medium">Key Alias</p>
<p className="text-sm">{currentKeyData.key_alias || "Not Set"}</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.keyAlias")}</p>
<p className="text-sm">{currentKeyData.key_alias || t("keyDetail.keyInfoView.settings.notSet")}</p>
</div>
<div>
<p className="text-sm font-medium">Secret Key</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.secretKey")}</p>
<p className="text-sm font-mono">{currentKeyData.key_name}</p>
</div>
<div>
<p className="text-sm font-medium">Team ID</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.teamId")}</p>
<p className="text-sm">
{currentKeyData.team_id ? (
<EntityLink href={teamDetailHref(currentKeyData.team_id)} className="font-normal">
{currentKeyData.team_id}
</EntityLink>
) : (
"Not Set"
t("keyDetail.keyInfoView.settings.notSet")
)}
</p>
</div>
{enableProjectsUI && (
<div>
<p className="text-sm font-medium">Project</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.project")}</p>
<p className="text-sm">
{currentKeyData.project_id
? (() => {
@ -979,44 +1034,46 @@ export default function KeyInfoView({
? `${project.project_alias} (${currentKeyData.project_id})`
: currentKeyData.project_id;
})()
: "Not Set"}
: t("keyDetail.keyInfoView.settings.notSet")}
</p>
</div>
)}
<div>
<p className="text-sm font-medium">Organization</p>
<p className="text-sm">{keyOrganizationId(currentKeyData) || "Not Set"}</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.organization")}</p>
<p className="text-sm">
{keyOrganizationId(currentKeyData) || t("keyDetail.keyInfoView.settings.notSet")}
</p>
</div>
<div>
<p className="text-sm font-medium">Created</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.created")}</p>
<p className="text-sm">{formatTimestamp(currentKeyData.created_at)}</p>
</div>
{lastRegeneratedAt && (
<div>
<p className="text-sm font-medium">Last Regenerated</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.lastRegenerated")}</p>
<div className="flex items-center gap-2">
<p className="text-sm">{formatTimestamp(lastRegeneratedAt)}</p>
<Badge variant="secondary">Recent</Badge>
<Badge variant="secondary">{t("keyDetail.keyInfoView.settings.recent")}</Badge>
</div>
</div>
)}
<div>
<p className="text-sm font-medium">Expires</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.expires")}</p>
<p className="text-sm">
{currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"}
{currentKeyData.expires
? formatTimestamp(currentKeyData.expires)
: t("keyDetail.keyInfoView.settings.never")}
</p>
</div>
{Boolean(currentKeyData.metadata?.enable_prompt_caching) && (
<div>
<p className="text-sm font-medium">Prompt Caching</p>
<p className="text-sm">
Enabled (auto-injects cache_control markers on Anthropic and Bedrock Claude requests)
</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.promptCaching")}</p>
<p className="text-sm">{t("keyDetail.keyInfoView.settings.promptCachingEnabled")}</p>
</div>
)}
@ -1031,32 +1088,38 @@ export default function KeyInfoView({
/>
<div>
<p className="text-sm font-medium">Spend</p>
<p className="text-sm">${formatNumberWithCommas(currentKeyData.spend, 4)} USD</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.spend")}</p>
<p className="text-sm">
${formatNumberWithCommas(currentKeyData.spend, 4)}
{t("keyDetail.keyInfoView.settings.usd")}
</p>
</div>
<div>
<p className="text-sm font-medium">Lifetime Spend</p>
<p className="text-sm">${formatNumberWithCommas(currentKeyData.total_spend ?? 0, 4)} USD</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.lifetimeSpend")}</p>
<p className="text-sm">
${formatNumberWithCommas(currentKeyData.total_spend ?? 0, 4)}
{t("keyDetail.keyInfoView.settings.usd")}
</p>
</div>
<div>
<p className="text-sm font-medium">Budget</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.budget")}</p>
<p className="text-sm">{budgetDisplay}</p>
</div>
<div>
<p className="text-sm font-medium">Budget Reset</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.budgetReset")}</p>
<p data-testid="budget-reset-value" className="text-sm">
{currentKeyData.budget_reset_at
? `${currentKeyData.budget_duration ? `Every ${currentKeyData.budget_duration}, next ` : ""}${formatTimestamp(currentKeyData.budget_reset_at)}`
: "Never"}
? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${formatTimestamp(currentKeyData.budget_reset_at)}`
: t("keyDetail.keyInfoView.settings.never")}
</p>
</div>
{currentKeyData.budget_fallbacks && Object.keys(currentKeyData.budget_fallbacks).length > 0 && (
<div>
<p className="text-sm font-medium">Budget Fallbacks</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.budgetFallbacks")}</p>
<div className="mt-1 space-y-1">
{Object.entries(currentKeyData.budget_fallbacks).map(([model, fallbacks]) => (
<div key={model} className="text-xs text-muted-foreground">
@ -1071,7 +1134,7 @@ export default function KeyInfoView({
{hasRouterSettings(currentKeyData.router_settings) && (
<div>
<p className="text-sm font-medium">Router Settings</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.routerSettings")}</p>
<div className="mt-1">
<RouterSettingsSummary routerSettings={currentKeyData.router_settings} />
</div>
@ -1079,7 +1142,7 @@ export default function KeyInfoView({
)}
<div>
<p className="text-sm font-medium">Tags</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.tags")}</p>
<div className="flex flex-wrap gap-2 mt-1">
{Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0
? currentKeyData.metadata.tags.map((tag, index) => (
@ -1087,12 +1150,12 @@ export default function KeyInfoView({
{tag}
</span>
))
: "No tags specified"}
: t("keyDetail.keyInfoView.settings.noTags")}
</div>
</div>
<div>
<p className="text-sm font-medium">Prompts</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.prompts")}</p>
<p className="text-sm">
{Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0
? currentKeyData.metadata.prompts.map((prompt, index) => (
@ -1100,12 +1163,12 @@ export default function KeyInfoView({
{prompt}
</span>
))
: "No prompts specified"}
: t("keyDetail.keyInfoView.settings.noPrompts")}
</p>
</div>
<div>
<p className="text-sm font-medium">Allowed Routes</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.allowedRoutes")}</p>
<div className="flex flex-wrap gap-2 mt-1">
{Array.isArray(currentKeyData.allowed_routes) && currentKeyData.allowed_routes.length > 0 ? (
currentKeyData.allowed_routes.map((route, index) => (
@ -1114,13 +1177,15 @@ export default function KeyInfoView({
</span>
))
) : (
<Badge variant="secondary">All routes allowed</Badge>
<Badge variant="secondary">{t("keyDetail.keyInfoView.settings.allRoutesAllowed")}</Badge>
)}
</div>
</div>
<div>
<p className="text-sm font-medium">Allowed Pass Through Routes</p>
<p className="text-sm font-medium">
{t("keyDetail.keyInfoView.settings.allowedPassThroughRoutes")}
</p>
<p className="text-sm">
{Array.isArray(currentKeyData.metadata?.allowed_passthrough_routes) &&
currentKeyData.metadata.allowed_passthrough_routes.length > 0
@ -1129,23 +1194,25 @@ export default function KeyInfoView({
{route}
</span>
))
: "No pass through routes specified"}
: t("keyDetail.keyInfoView.settings.noPassThroughRoutes")}
</p>
</div>
<div>
<p className="text-sm font-medium">Disable Global Guardrails</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.disableGlobalGuardrails")}</p>
<p className="text-sm">
{currentKeyData.metadata?.disable_global_guardrails === true ? (
<Badge variant="destructive">Enabled - Global guardrails bypassed</Badge>
<Badge variant="destructive">
{t("keyDetail.keyInfoView.settings.globalGuardrailsBypassed")}
</Badge>
) : (
<Badge variant="secondary">Disabled - Global guardrails active</Badge>
<Badge variant="secondary">{t("keyDetail.keyInfoView.settings.globalGuardrailsActive")}</Badge>
)}
</p>
</div>
<div>
<p className="text-sm font-medium">Models</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.models")}</p>
<div className="flex flex-wrap gap-2 mt-1">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => (
@ -1158,61 +1225,70 @@ export default function KeyInfoView({
</BadgeLink>
))
) : (
<p className="text-sm">No models specified</p>
<p className="text-sm">{t("keyDetail.keyInfoView.settings.noModels")}</p>
)}
</div>
</div>
<div>
<p className="text-sm font-medium">Rate Limits</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.rateLimits")}</p>
<p className="text-sm">
TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}
{t("keyDetail.keyInfoView.settings.tpm")}
{currentKeyData.tpm_limit !== null
? currentKeyData.tpm_limit
: t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">
RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}
{t("keyDetail.keyInfoView.settings.rpm")}
{currentKeyData.rpm_limit !== null
? currentKeyData.rpm_limit
: t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">TPD (batch): {currentKeyData.tpd_limit ?? "Unlimited"}</p>
<p className="text-sm">
Max Parallel Requests:{" "}
{t("keyDetail.keyInfoView.settings.tpd")}
{currentKeyData.tpd_limit ?? t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">
{t("keyDetail.keyInfoView.settings.maxParallelRequests")}{" "}
{currentKeyData.max_parallel_requests !== null
? currentKeyData.max_parallel_requests
: "Unlimited"}
: t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">
Model TPM Limits:{" "}
{t("keyDetail.keyInfoView.settings.modelTpmLimits")}{" "}
{currentKeyData.metadata?.model_tpm_limit
? JSON.stringify(currentKeyData.metadata.model_tpm_limit)
: "Unlimited"}
: t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">
Model RPM Limits:{" "}
{t("keyDetail.keyInfoView.settings.modelRpmLimits")}{" "}
{currentKeyData.metadata?.model_rpm_limit
? JSON.stringify(currentKeyData.metadata.model_rpm_limit)
: "Unlimited"}
: t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">
Tag RPM Limits:{" "}
{t("keyDetail.keyInfoView.settings.tagRpmLimits")}{" "}
{currentKeyData.metadata?.tag_rpm_limit &&
Object.keys(currentKeyData.metadata.tag_rpm_limit).length > 0
? JSON.stringify(currentKeyData.metadata.tag_rpm_limit)
: "Unlimited"}
: t("keyDetail.keyInfoView.settings.unlimited")}
</p>
<p className="text-sm">
Estimated Output Tokens:{" "}
{t("keyDetail.keyInfoView.settings.estimatedOutputTokens")}{" "}
{currentKeyData.metadata?.default_estimated_output_tokens != null
? String(currentKeyData.metadata.default_estimated_output_tokens)
: "Default"}
: t("keyDetail.keyInfoView.settings.default")}
</p>
<p className="text-sm">
Estimated Output Tokens Per Model:{" "}
{t("keyDetail.keyInfoView.settings.estimatedOutputTokensPerModel")}{" "}
{currentKeyData.metadata?.default_estimated_output_tokens_per_model
? JSON.stringify(currentKeyData.metadata.default_estimated_output_tokens_per_model)
: "Default"}
: t("keyDetail.keyInfoView.settings.default")}
</p>
</div>
<div>
<p className="text-sm font-medium">Metadata</p>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.metadata")}</p>
<pre className="bg-muted p-2 rounded-sm text-xs overflow-auto mt-1">
{formatMetadataForDisplay(stripTagsFromMetadata(currentKeyData.metadata))}
</pre>

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { VectorStore } from "./types";
import { vectorStoreListCall } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
import i18n from "@/i18n/i18n";
interface VectorStoreSelectorProps {
onChange: (selectedVectorStores: string[]) => void;
value?: string[];
@ -16,7 +17,7 @@ const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
value,
className,
accessToken,
placeholder = "Select vector stores",
placeholder = i18n.t("vectorStoreSelector.placeholder"),
disabled = false,
}) => {
const [vectorStores, setVectorStores] = useState<VectorStore[]>([]);

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { hydrateOssClassifier } from "@/components/add_model/jev_classifier_config";
import {
ComplexityRouterConfigPayload,
@ -255,13 +257,14 @@ export const getReferencedModelsError = (
defaultModel?: string;
},
availability: ModelAvailability,
t: TFunction = i18n.t.bind(i18n),
): string | null => {
if (
params.classifierType === "jev" &&
params.decisionDeploymentName &&
!resolveAvailableModel(params.decisionDeploymentName, availability)
) {
return `Model(s) no longer available: ${params.decisionDeploymentName}`;
return t("autorouterPresets.errors.modelsUnavailable", { models: params.decisionDeploymentName });
}
const references = {
tiers: params.tiers,
@ -270,7 +273,7 @@ export const getReferencedModelsError = (
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
};
const missing = getMissingModels(references, availability);
return missing.length > 0 ? `Model(s) no longer available: ${missing.join(", ")}` : null;
return missing.length > 0 ? t("autorouterPresets.errors.modelsUnavailable", { models: missing.join(", ") }) : null;
};
// Every piece of AddAutoRouterTab's config state that a preset (or a reset to Custom) prefills in