mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
8cbd40893e
commit
53740a4426
96 changed files with 4612 additions and 1792 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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] })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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) => (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}%`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "Add Provider Discount" 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 "Add Provider Margin" 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 "Global" 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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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")} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 ?? ""}`}
|
||||
|
|
|
|||
|
|
@ -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 ?? ""}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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'll receive a notification with the new key. The old key will be deactivated
|
||||
after a brief grace period.
|
||||
{t("keyLifecycleSettings.rotationNotice")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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'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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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) });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue