mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
refactor(ui): move models text onto i18n (#50112)
Every string the Models + Endpoints, Model Insights and Transform Request pages write themselves now goes through t() or Trans, including the shared auto-router settings those pages render on other routes. 1,651 English messages are added under 28 new top-level keys in en.json. This is a pure text move. The English on screen does not change, option lists and constants keep their shape, and text that could not move without restructuring stays English. ModelInfoEditForm.tsx, ComplexityRouterConfig.tsx and build_complexity_router_config.ts grew past the 800 line limit and get max-lines entries in eslint-suppressions.json instead of being split.
This commit is contained in:
parent
a73e12de56
commit
8404bc094c
125 changed files with 5947 additions and 2501 deletions
|
|
@ -1209,6 +1209,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ModelInfoEditForm.tsx": {
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": {
|
||||
"max-nested-callbacks": {
|
||||
"count": 10
|
||||
|
|
@ -1319,6 +1324,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/ComplexityRouterConfig.tsx": {
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/RouterConfigBuilder.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -1344,6 +1354,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/build_complexity_router_config.ts": {
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/cache_control_settings.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Page } from "@/components/shared/Page";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDownRight, ArrowUpRight, BarChart3, Minus } from "lucide-react";
|
||||
import { StackedUsageChart } from "@/components/shared/charts";
|
||||
|
||||
|
|
@ -27,8 +29,15 @@ import {
|
|||
|
||||
const SCALES = ["linear", "log"] as const;
|
||||
const GRANULARITIES = ["day", "week"] as const;
|
||||
const GRANULARITY_LABELS: Record<Granularity, string> = { day: "Daily", week: "Weekly" };
|
||||
const METRIC_LABELS: Record<Metric, string> = { requests: "requests", spend: "spend", tokens: "tokens" };
|
||||
const GRANULARITY_LABELS: Record<Granularity, ParseKeys> = {
|
||||
day: "modelInsights.modelInsightsView.granularity.day",
|
||||
week: "modelInsights.modelInsightsView.granularity.week",
|
||||
};
|
||||
const METRIC_LABELS: Record<Metric, ParseKeys> = {
|
||||
requests: "modelInsights.modelInsightsView.metrics.requests",
|
||||
spend: "modelInsights.modelInsightsView.metrics.spend",
|
||||
tokens: "modelInsights.modelInsightsView.metrics.tokens",
|
||||
};
|
||||
const RANKING_ROWS = 5;
|
||||
|
||||
type Scale = (typeof SCALES)[number];
|
||||
|
|
@ -52,22 +61,28 @@ const DeltaBadge = ({ value }: { value: number }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const RankingRow = ({ model, rank }: { model: RankedModel; rank: number }) => (
|
||||
<li className="grid grid-cols-[1.5rem_2.5rem_1fr_auto] items-center gap-3 py-2">
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{rank}</span>
|
||||
<ProviderLogo provider={model.provider} className="size-9 rounded-md border p-1" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{model.model_group}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">by {model.provider}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium tabular-nums">{model.share.toFixed(1)}%</p>
|
||||
<DeltaBadge value={model.delta} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
const RankingRow = ({ model, rank }: { model: RankedModel; rank: number }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<li className="grid grid-cols-[1.5rem_2.5rem_1fr_auto] items-center gap-3 py-2">
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{rank}</span>
|
||||
<ProviderLogo provider={model.provider} className="size-9 rounded-md border p-1" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{model.model_group}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{t("modelInsights.modelInsightsView.byProvider", { provider: model.provider })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium tabular-nums">{model.share.toFixed(1)}%</p>
|
||||
<DeltaBadge value={model.delta} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ModelInsightsView({ accessToken }: { accessToken: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const [loaded, setLoaded] = React.useState<{ metric: Metric; response: ModelInsightsResponse } | null>(null);
|
||||
const [metric, setMetric] = React.useState<Metric>("tokens");
|
||||
const [scale, setScale] = React.useState<Scale>("linear");
|
||||
|
|
@ -114,7 +129,7 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
|
|||
return (
|
||||
<div className="p-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not load model insights</AlertTitle>
|
||||
<AlertTitle>{t("modelInsights.modelInsightsView.loadError")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
|
@ -135,19 +150,22 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
|
|||
<PageHeader>
|
||||
<PageHeaderTitle>
|
||||
<BarChart3 />
|
||||
Model Leaderboard
|
||||
{t("modelInsights.modelInsightsView.title")}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
See which models your gateway used from {data.start_date} through {data.end_date}
|
||||
{t("modelInsights.modelInsightsView.description", { start: data.start_date, end: data.end_date })}
|
||||
</PageHeaderDescription>
|
||||
</PageHeader>
|
||||
|
||||
<Card aria-busy={isStale} className={isStale ? "opacity-60 transition-opacity" : "transition-opacity"}>
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Top models</CardTitle>
|
||||
<CardTitle>{t("modelInsights.modelInsightsView.topModels.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
{GRANULARITY_LABELS[granularity]} {METRIC_LABELS[shown]} across your gateway
|
||||
{t("modelInsights.modelInsightsView.topModels.description", {
|
||||
granularity: t(GRANULARITY_LABELS[granularity]),
|
||||
metric: t(METRIC_LABELS[shown]),
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -161,10 +179,10 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
|
|||
</TabsList>
|
||||
</Tabs>
|
||||
<Tabs value={granularity} onValueChange={(value) => setGranularity(value as Granularity)}>
|
||||
<TabsList aria-label="Bucket size">
|
||||
<TabsList aria-label={t("modelInsights.modelInsightsView.bucketSize")}>
|
||||
{GRANULARITIES.map((value) => (
|
||||
<TabsTrigger key={value} value={value}>
|
||||
{GRANULARITY_LABELS[value]}
|
||||
{t(GRANULARITY_LABELS[value])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
|
@ -187,7 +205,7 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
|
|||
xKey="date"
|
||||
scale={scale}
|
||||
format={(value) => formatMetric(value, shown)}
|
||||
totalLabel="Gateway total"
|
||||
totalLabel={t("modelInsights.modelInsightsView.gatewayTotal")}
|
||||
totalFor={(label) => bucketTotals.get(label) ?? 0}
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
@ -195,9 +213,9 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
|
|||
|
||||
<Card aria-busy={isStale} className={isStale ? "opacity-60 transition-opacity" : "transition-opacity"}>
|
||||
<CardHeader>
|
||||
<CardTitle>Leaderboard</CardTitle>
|
||||
<CardTitle>{t("modelInsights.modelInsightsView.leaderboard.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
Share of {METRIC_LABELS[shown]}, with the change between the first and second half of the period
|
||||
{t("modelInsights.modelInsightsView.leaderboard.description", { metric: t(METRIC_LABELS[shown]) })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-x-12 md:grid-cols-2">
|
||||
|
|
@ -216,14 +234,11 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
|
|||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost per session</CardTitle>
|
||||
<CardDescription>Session cost is not estimated from request counts</CardDescription>
|
||||
<CardTitle>{t("modelInsights.modelInsightsView.costPerSession.title")}</CardTitle>
|
||||
<CardDescription>{t("modelInsights.modelInsightsView.costPerSession.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a stable session_id to requests to unlock accurate session-level model comparisons in a future bounded
|
||||
session rollup
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("modelInsights.modelInsightsView.costPerSession.hint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Page>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MoreHorizontal, Trash2, Wallet } from "lucide-react";
|
||||
|
||||
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const budgetDecimals = (maxBudget: number | null | undefined): number =>
|
||||
maxBudget != null && maxBudget > 0 && maxBudget < 0.01 ? 5 : 2;
|
||||
|
|
@ -25,10 +28,14 @@ const budgetDecimals = (maxBudget: number | null | undefined): number =>
|
|||
*/
|
||||
export const isBudgetAddressable = (accessGroup: string): boolean => !accessGroup.includes("/");
|
||||
|
||||
const writeBlockedReason = (accessGroup: ModelAccessGroup, canWrite: boolean): string | undefined => {
|
||||
if (!canWrite) return "Only a proxy admin can change an access group budget";
|
||||
const writeBlockedReason = (
|
||||
accessGroup: ModelAccessGroup,
|
||||
canWrite: boolean,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | undefined => {
|
||||
if (!canWrite) return t("modelsAndEndpoints.accessGroupBudgetColumns.blocked.notAdmin");
|
||||
if (!isBudgetAddressable(accessGroup.access_group)) {
|
||||
return "A budget cannot be set on a group whose name contains a slash";
|
||||
return t("modelsAndEndpoints.accessGroupBudgetColumns.blocked.slashInName");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
|
@ -41,13 +48,16 @@ interface AccessGroupRowActionsProps {
|
|||
}
|
||||
|
||||
function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasBudget = accessGroup.budget != null;
|
||||
const blocked = writeBlockedReason(accessGroup, canWrite);
|
||||
const blocked = writeBlockedReason(accessGroup, canWrite, t);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Open budget actions for ${accessGroup.access_group}`}
|
||||
aria-label={t("modelsAndEndpoints.accessGroupBudgetColumns.actions.open", {
|
||||
accessGroup: accessGroup.access_group,
|
||||
})}
|
||||
data-testid={`access-group-actions-${accessGroup.access_group}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -61,17 +71,21 @@ function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudg
|
|||
onClick={() => onSetBudget(accessGroup)}
|
||||
>
|
||||
<Wallet />
|
||||
{hasBudget ? "Edit budget" : "Set budget"}
|
||||
{hasBudget
|
||||
? t("modelsAndEndpoints.accessGroupBudgetColumns.actions.editBudget")
|
||||
: t("modelsAndEndpoints.accessGroupBudgetColumns.actions.setBudget")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={blocked !== undefined || !hasBudget}
|
||||
data-testid="access-group-action-clear-budget"
|
||||
title={blocked ?? (hasBudget ? undefined : "This access group has no budget to clear")}
|
||||
title={
|
||||
blocked ?? (hasBudget ? undefined : t("modelsAndEndpoints.accessGroupBudgetColumns.actions.nothingToClear"))
|
||||
}
|
||||
onClick={() => onClearBudget(accessGroup)}
|
||||
>
|
||||
<Trash2 />
|
||||
Clear budget
|
||||
{t("modelsAndEndpoints.accessGroupBudgetColumns.actions.clearBudget")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -84,16 +98,20 @@ interface AccessGroupBudgetColumnsDeps {
|
|||
onClearBudget: (accessGroup: ModelAccessGroup) => void;
|
||||
}
|
||||
|
||||
export const getAccessGroupBudgetColumns = ({
|
||||
canWrite,
|
||||
onSetBudget,
|
||||
onClearBudget,
|
||||
}: AccessGroupBudgetColumnsDeps): ColumnDef<ModelAccessGroup>[] => [
|
||||
export const getAccessGroupBudgetColumns = (
|
||||
{ canWrite, onSetBudget, onClearBudget }: AccessGroupBudgetColumnsDeps,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): ColumnDef<ModelAccessGroup>[] => [
|
||||
{
|
||||
id: "access_group",
|
||||
accessorKey: "access_group",
|
||||
meta: { title: "Access Group" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Access Group" />,
|
||||
meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.accessGroup") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("modelsAndEndpoints.accessGroupBudgetColumns.columns.accessGroup")}
|
||||
/>
|
||||
),
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -104,8 +122,8 @@ export const getAccessGroupBudgetColumns = ({
|
|||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.models"), skeleton: "chips" },
|
||||
header: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.models"),
|
||||
size: 280,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.model_names} />,
|
||||
|
|
@ -113,8 +131,13 @@ export const getAccessGroupBudgetColumns = ({
|
|||
{
|
||||
id: "deployment_count",
|
||||
accessorKey: "deployment_count",
|
||||
meta: { title: "Deployments", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Deployments" />,
|
||||
meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.deployments"), numeric: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("modelsAndEndpoints.accessGroupBudgetColumns.columns.deployments")}
|
||||
/>
|
||||
),
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => row.original.deployment_count,
|
||||
|
|
@ -122,8 +145,13 @@ export const getAccessGroupBudgetColumns = ({
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Shared Spend" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Shared Spend" />,
|
||||
meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.sharedSpend") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("modelsAndEndpoints.accessGroupBudgetColumns.columns.sharedSpend")}
|
||||
/>
|
||||
),
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -136,8 +164,8 @@ export const getAccessGroupBudgetColumns = ({
|
|||
},
|
||||
{
|
||||
id: "budget_duration",
|
||||
meta: { title: "Resets" },
|
||||
header: "Resets",
|
||||
meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.resets") },
|
||||
header: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.resets"),
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -149,7 +177,7 @@ export const getAccessGroupBudgetColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("modelsAndEndpoints.accessGroupBudgetColumns.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -32,7 +34,7 @@ const budgetSchema = z
|
|||
budget_duration: z.string().optional(),
|
||||
})
|
||||
.refine(hasAnyBudgetValue, {
|
||||
message: "Set at least one of max budget, soft budget or reset window",
|
||||
error: () => i18n.t("modelsAndEndpoints.accessGroupBudgetModal.validation.atLeastOne"),
|
||||
path: ["max_budget"],
|
||||
});
|
||||
|
||||
|
|
@ -49,6 +51,7 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const budget = accessGroup?.budget ?? null;
|
||||
const form = useZodForm(budgetSchema, { values: accessGroupBudgetFormValues(budget) });
|
||||
|
||||
|
|
@ -57,12 +60,19 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{budget ? "Edit" : "Set"} budget for "{accessGroup?.access_group}"
|
||||
{t("modelsAndEndpoints.accessGroupBudgetModal.title", {
|
||||
action: budget
|
||||
? t("modelsAndEndpoints.accessGroupBudgetModal.edit")
|
||||
: t("modelsAndEndpoints.accessGroupBudgetModal.set"),
|
||||
accessGroup: accessGroup?.access_group,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every key granted this access group by name draws from this one budget. A key that reaches the group's
|
||||
models through a wildcard or <code>all-proxy-models</code> is not charged against it.
|
||||
<Trans
|
||||
i18nKey="modelsAndEndpoints.accessGroupBudgetModal.description"
|
||||
components={{ allProxyModels: <code>all-proxy-models</code> }}
|
||||
/>
|
||||
</p>
|
||||
<form onSubmit={form.handleSubmit((values) => onSubmit(buildAccessGroupBudgetBody(values)))} noValidate>
|
||||
<TooltipProvider>
|
||||
|
|
@ -71,8 +81,8 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
control={form.control}
|
||||
name="max_budget"
|
||||
label={labelWithHint(
|
||||
"Max Budget (USD)",
|
||||
"Total the whole group may spend. Once its shared spend reaches this, every key that draws from the group is refused",
|
||||
t("modelsAndEndpoints.accessGroupBudgetModal.maxBudget.label"),
|
||||
t("modelsAndEndpoints.accessGroupBudgetModal.maxBudget.hint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
|
||||
|
|
@ -82,8 +92,8 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
control={form.control}
|
||||
name="soft_budget"
|
||||
label={labelWithHint(
|
||||
"Soft Budget (USD)",
|
||||
"Fires an alert when the group's spend reaches this. Requests keep succeeding",
|
||||
t("modelsAndEndpoints.accessGroupBudgetModal.softBudget.label"),
|
||||
t("modelsAndEndpoints.accessGroupBudgetModal.softBudget.hint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
|
||||
|
|
@ -93,8 +103,8 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
control={form.control}
|
||||
name="budget_duration"
|
||||
label={labelWithHint(
|
||||
"Reset Budget",
|
||||
"How often the group's spend resets. Leave empty for a budget that never resets",
|
||||
t("modelsAndEndpoints.accessGroupBudgetModal.resetBudget.label"),
|
||||
t("modelsAndEndpoints.accessGroupBudgetModal.resetBudget.hint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
|
|
@ -102,22 +112,24 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
id={id}
|
||||
value={value || null}
|
||||
onChange={(next) => onChange(next ?? undefined)}
|
||||
placeholder="Keep current"
|
||||
placeholder={t("modelsAndEndpoints.accessGroupBudgetModal.resetBudget.keepCurrent")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
A field left blank keeps whatever the budget already has. Use Clear budget to remove the budget itself.
|
||||
{t("modelsAndEndpoints.accessGroupBudgetModal.blankFieldNote")}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("modelsAndEndpoints.accessGroupBudgetModal.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Budget"}
|
||||
{isSaving
|
||||
? t("modelsAndEndpoints.accessGroupBudgetModal.saving")
|
||||
: t("modelsAndEndpoints.accessGroupBudgetModal.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentia
|
|||
import { credentialLabelsByName } from "@/components/shared/credentialOptions";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -18,6 +19,7 @@ import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, Sort
|
|||
import { Info } from "lucide-react";
|
||||
import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { useModelsInfo } from "../../hooks/models/useModels";
|
||||
import { ROUTING_STATUS_FILTER_ID, routingStatusToBlocked } from "../utils/routingStatus";
|
||||
|
|
@ -82,6 +84,7 @@ const AllModelsTab = ({
|
|||
setSelectedModelId,
|
||||
setSelectedTeamId,
|
||||
}: AllModelsTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
|
||||
const { accessToken, userId, userRole, isViewOnly } = useAuthorized();
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
|
@ -240,7 +243,7 @@ const AllModelsTab = ({
|
|||
|
||||
const teamOptions = useMemo(
|
||||
() => [
|
||||
{ value: PERSONAL_TEAM_VALUE, label: "Personal" },
|
||||
{ value: PERSONAL_TEAM_VALUE, label: i18n.t("modelsAndEndpoints.allModelsTab.personalTeam") },
|
||||
...(teams ?? [])
|
||||
.filter((team) => team.team_id)
|
||||
.map((team) => ({ value: team.team_id, label: team.team_alias ? team.team_alias : team.team_id })),
|
||||
|
|
@ -263,7 +266,7 @@ const AllModelsTab = ({
|
|||
try {
|
||||
setDeleteLoading(true);
|
||||
await modelDeleteCall(accessToken, deleteModalModelId);
|
||||
toast.success("Model deleted successfully");
|
||||
toast.success(t("modelsAndEndpoints.allModelsTab.toasts.deleted"));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
refetchModels();
|
||||
} catch (error) {
|
||||
|
|
@ -281,7 +284,11 @@ const AllModelsTab = ({
|
|||
try {
|
||||
setPausingModelId(modelId);
|
||||
await modelPatchUpdateCall(accessToken, { blocked }, modelId);
|
||||
toast.success(blocked ? "Model paused" : "Model resumed");
|
||||
toast.success(
|
||||
blocked
|
||||
? i18n.t("modelsAndEndpoints.allModelsTab.toasts.paused")
|
||||
: i18n.t("modelsAndEndpoints.allModelsTab.toasts.resumed"),
|
||||
);
|
||||
// invalidateQueries already schedules a refetch for active observers
|
||||
// on this key — no need to also call refetchModels() (would double-fetch).
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
|
@ -352,19 +359,22 @@ const AllModelsTab = ({
|
|||
<Info className="mt-0.5 size-3.5 shrink-0" />
|
||||
{selectedTeamValue === PERSONAL_TEAM_VALUE ? (
|
||||
<span>
|
||||
To access these models, create a Virtual Key without selecting a team on the{" "}
|
||||
<a href={uiHref("api-keys")} className="font-medium text-info hover:underline">
|
||||
Virtual Keys page
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="modelsAndEndpoints.allModelsTab.personalHint"
|
||||
components={{
|
||||
keys: <a href={uiHref("api-keys")} className="font-medium text-info hover:underline" />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
To access these models, create a Virtual Key and select Team as "{teamAccessLabel}" on the{" "}
|
||||
<a href={uiHref("api-keys")} className="font-medium text-info hover:underline">
|
||||
Virtual Keys page
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="modelsAndEndpoints.allModelsTab.teamHint"
|
||||
components={{
|
||||
team: <>{teamAccessLabel}</>,
|
||||
keys: <a href={uiHref("api-keys")} className="font-medium text-info hover:underline" />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -373,28 +383,29 @@ const AllModelsTab = ({
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={!!deleteModalModelId}
|
||||
title="Delete Model"
|
||||
alertMessage="This action cannot be undone."
|
||||
message="Are you sure you want to delete this model?"
|
||||
resourceInformationTitle="Model Information"
|
||||
title={t("modelsAndEndpoints.allModelsTab.deleteModal.title")}
|
||||
alertMessage={t("modelsAndEndpoints.allModelsTab.deleteModal.alert")}
|
||||
message={t("modelsAndEndpoints.allModelsTab.deleteModal.message")}
|
||||
resourceInformationTitle={t("modelsAndEndpoints.allModelsTab.deleteModal.informationTitle")}
|
||||
resourceInformation={
|
||||
modelToDelete
|
||||
? [
|
||||
{
|
||||
label: "Model Name",
|
||||
value: modelToDelete.model_name || "Not Set",
|
||||
label: t("modelsAndEndpoints.allModelsTab.deleteModal.modelName"),
|
||||
value: modelToDelete.model_name || t("modelsAndEndpoints.allModelsTab.deleteModal.notSet"),
|
||||
},
|
||||
{
|
||||
label: "LiteLLM Model Name",
|
||||
value: modelToDelete.litellm_model_name || "Not Set",
|
||||
label: t("modelsAndEndpoints.allModelsTab.deleteModal.litellmModelName"),
|
||||
value: modelToDelete.litellm_model_name || t("modelsAndEndpoints.allModelsTab.deleteModal.notSet"),
|
||||
},
|
||||
{
|
||||
label: "Provider",
|
||||
value: modelToDelete.provider || "Not Set",
|
||||
label: t("modelsAndEndpoints.allModelsTab.deleteModal.provider"),
|
||||
value: modelToDelete.provider || t("modelsAndEndpoints.allModelsTab.deleteModal.notSet"),
|
||||
},
|
||||
{
|
||||
label: "Created By",
|
||||
value: modelToDelete.model_info?.created_by || "Not Set",
|
||||
label: t("modelsAndEndpoints.allModelsTab.deleteModal.createdBy"),
|
||||
value:
|
||||
modelToDelete.model_info?.created_by || t("modelsAndEndpoints.allModelsTab.deleteModal.notSet"),
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
import { ROUTING_STATUS_FILTER_ID } from "../utils/routingStatus";
|
||||
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { Search, Settings } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import {
|
||||
|
|
@ -18,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import {
|
||||
ACCESS_GROUPS_COLUMN_ID,
|
||||
|
|
@ -33,7 +36,7 @@ export const ALL_MODEL_GROUPS_VALUE = "all";
|
|||
export const WILDCARD_MODEL_GROUP_VALUE = "wildcard";
|
||||
|
||||
const MODEL_TABLE_BODY_HEIGHT = 600;
|
||||
const ALL_PROXY_MODELS_LABEL = "All Proxy Models";
|
||||
const ALL_PROXY_MODELS_LABEL: ParseKeys = "modelsAndEndpoints.allModelsTable.allProxyModels";
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
[MODEL_NAME_COLUMN_ID]: "Public Model Name",
|
||||
|
|
@ -42,13 +45,13 @@ const FILTER_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
const ROUTING_STATUS_FILTER_VALUES = {
|
||||
all: "All",
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
all: "modelsAndEndpoints.allModelsTable.status.all",
|
||||
active: "modelsAndEndpoints.allModelsTable.status.active",
|
||||
paused: "modelsAndEndpoints.allModelsTable.status.paused",
|
||||
} as const;
|
||||
|
||||
const VIEW_MODE_LABELS: Record<ModelViewMode, string> = {
|
||||
current_team: "Current Team Models",
|
||||
const VIEW_MODE_LABELS: Record<ModelViewMode, ParseKeys> = {
|
||||
current_team: "modelsAndEndpoints.allModelsTable.viewModes.currentTeam",
|
||||
all: ALL_PROXY_MODELS_LABEL,
|
||||
};
|
||||
|
||||
|
|
@ -93,14 +96,17 @@ interface AllModelsTableProps {
|
|||
}
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||
<Search className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-base font-semibold text-foreground">No models found</div>
|
||||
<div className="text-base font-semibold text-foreground">
|
||||
{t("modelsAndEndpoints.allModelsTable.empty.title")}
|
||||
</div>
|
||||
<div className="max-w-80 text-sm text-muted-foreground">
|
||||
No models match your search or filters. Try resetting them.
|
||||
{t("modelsAndEndpoints.allModelsTable.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -140,6 +146,7 @@ export function AllModelsTable({
|
|||
pausingModelId,
|
||||
credentialLabels,
|
||||
}: AllModelsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
|
|
@ -169,8 +176,8 @@ export function AllModelsTable({
|
|||
|
||||
const modelGroupOptions = useMemo(
|
||||
() => [
|
||||
{ label: ALL_PROXY_MODELS_LABEL, value: ALL_MODEL_GROUPS_VALUE },
|
||||
{ label: "Wildcard Models (*)", value: WILDCARD_MODEL_GROUP_VALUE },
|
||||
{ label: i18n.t(ALL_PROXY_MODELS_LABEL), value: ALL_MODEL_GROUPS_VALUE },
|
||||
{ label: i18n.t("modelsAndEndpoints.allModelsTable.wildcardModels"), value: WILDCARD_MODEL_GROUP_VALUE },
|
||||
...availableModelGroups.map((group) => ({ label: group, value: group })),
|
||||
],
|
||||
[availableModelGroups],
|
||||
|
|
@ -178,7 +185,7 @@ export function AllModelsTable({
|
|||
|
||||
const accessGroupOptions = useMemo(
|
||||
() => [
|
||||
{ label: "All Model Access Groups", value: ALL_MODEL_GROUPS_VALUE },
|
||||
{ label: i18n.t("modelsAndEndpoints.allModelsTable.allAccessGroups"), value: ALL_MODEL_GROUPS_VALUE },
|
||||
...availableModelAccessGroups.map((accessGroup) => ({ label: accessGroup, value: accessGroup })),
|
||||
],
|
||||
[availableModelAccessGroups],
|
||||
|
|
@ -187,10 +194,12 @@ export function AllModelsTable({
|
|||
const formatFilterValue = (columnId: string, value: unknown): string => {
|
||||
const raw = String(value);
|
||||
if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) {
|
||||
return "Wildcard Models (*)";
|
||||
return t("modelsAndEndpoints.allModelsTable.wildcardModels");
|
||||
}
|
||||
if (columnId === ROUTING_STATUS_FILTER_ID) {
|
||||
return ROUTING_STATUS_FILTER_VALUES[raw as keyof typeof ROUTING_STATUS_FILTER_VALUES] ?? raw;
|
||||
return Object.hasOwn(ROUTING_STATUS_FILTER_VALUES, raw)
|
||||
? t(ROUTING_STATUS_FILTER_VALUES[raw as keyof typeof ROUTING_STATUS_FILTER_VALUES])
|
||||
: raw;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
|
@ -219,7 +228,7 @@ export function AllModelsTable({
|
|||
enableColumnResizing
|
||||
maxBodyHeight={MODEL_TABLE_BODY_HEIGHT}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading models…"
|
||||
loadingMessage={t("modelsAndEndpoints.allModelsTable.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
|
|
@ -228,7 +237,7 @@ export function AllModelsTable({
|
|||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search model names…"
|
||||
searchPlaceholder={t("modelsAndEndpoints.allModelsTable.searchPlaceholder")}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
|
|
@ -238,7 +247,7 @@ export function AllModelsTable({
|
|||
<Select value={selectedTeamValue} onValueChange={(value) => onTeamChange(String(value))}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label="Current team"
|
||||
aria-label={t("modelsAndEndpoints.allModelsTable.currentTeam")}
|
||||
data-testid="models-team-select"
|
||||
className="gap-2 bg-secondary"
|
||||
>
|
||||
|
|
@ -248,7 +257,7 @@ export function AllModelsTable({
|
|||
selectedTeamValue === PERSONAL_TEAM_VALUE ? "bg-info" : "bg-success",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">Team</span>
|
||||
<span className="text-muted-foreground">{t("modelsAndEndpoints.allModelsTable.team")}</span>
|
||||
<span className="truncate font-semibold">{selectedTeamLabel}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -268,13 +277,18 @@ export function AllModelsTable({
|
|||
</Select>
|
||||
|
||||
<Select value={viewMode} onValueChange={(value) => onViewModeChange(value as ModelViewMode)}>
|
||||
<SelectTrigger size="sm" aria-label="View" data-testid="models-view-select" className="gap-2">
|
||||
<span className="text-muted-foreground">View</span>
|
||||
<span className="truncate">{VIEW_MODE_LABELS[viewMode]}</span>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label={t("modelsAndEndpoints.allModelsTable.view")}
|
||||
data-testid="models-view-select"
|
||||
className="gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">{t("modelsAndEndpoints.allModelsTable.view")}</span>
|
||||
<span className="truncate">{t(VIEW_MODE_LABELS[viewMode])}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current_team">{VIEW_MODE_LABELS.current_team}</SelectItem>
|
||||
<SelectItem value="all">{VIEW_MODE_LABELS.all}</SelectItem>
|
||||
<SelectItem value="current_team">{t(VIEW_MODE_LABELS.current_team)}</SelectItem>
|
||||
<SelectItem value="all">{t(VIEW_MODE_LABELS.all)}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
|
@ -283,8 +297,8 @@ export function AllModelsTable({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Model Settings"
|
||||
title="Model Settings"
|
||||
aria-label={t("modelsAndEndpoints.allModelsTable.modelSettings")}
|
||||
title={t("modelsAndEndpoints.allModelsTable.modelSettings")}
|
||||
data-testid="models-settings-trigger"
|
||||
onClick={onOpenModelSettings}
|
||||
>
|
||||
|
|
@ -295,56 +309,61 @@ export function AllModelsTable({
|
|||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down models + endpoints"
|
||||
resetLabel="Reset Filters"
|
||||
title={t("modelsAndEndpoints.allModelsTable.filters.title")}
|
||||
description={t("modelsAndEndpoints.allModelsTable.filters.description")}
|
||||
resetLabel={t("modelsAndEndpoints.allModelsTable.filters.reset")}
|
||||
onReset={onResetFilters}
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Public Model Name">
|
||||
<DataTableFilterField label={t("modelsAndEndpoints.allModelsTable.filters.publicModelName")}>
|
||||
<SearchSelect
|
||||
options={modelGroupOptions}
|
||||
value={(get(MODEL_NAME_COLUMN_ID) as string) ?? ALL_MODEL_GROUPS_VALUE}
|
||||
onValueChange={(value) =>
|
||||
set(MODEL_NAME_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value ?? undefined)
|
||||
}
|
||||
placeholder="Filter by Public Model Name"
|
||||
emptyText="No models found"
|
||||
placeholder={t("modelsAndEndpoints.allModelsTable.filters.publicModelNamePlaceholder")}
|
||||
emptyText={t("modelsAndEndpoints.allModelsTable.filters.publicModelNameEmpty")}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Status">
|
||||
<DataTableFilterField label={t("modelsAndEndpoints.allModelsTable.filters.status")}>
|
||||
<Select
|
||||
value={(get(ROUTING_STATUS_FILTER_ID) as string) ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
set(ROUTING_STATUS_FILTER_ID, value === "all" ? undefined : (value as "active" | "paused"))
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Filter by status" className="w-full">
|
||||
{ROUTING_STATUS_FILTER_VALUES[
|
||||
(get(ROUTING_STATUS_FILTER_ID) as keyof typeof ROUTING_STATUS_FILTER_VALUES) ?? "all"
|
||||
] ?? ROUTING_STATUS_FILTER_VALUES.all}
|
||||
<SelectTrigger
|
||||
aria-label={t("modelsAndEndpoints.allModelsTable.filters.statusAriaLabel")}
|
||||
className="w-full"
|
||||
>
|
||||
{t(
|
||||
ROUTING_STATUS_FILTER_VALUES[
|
||||
(get(ROUTING_STATUS_FILTER_ID) as keyof typeof ROUTING_STATUS_FILTER_VALUES) ?? "all"
|
||||
] ?? ROUTING_STATUS_FILTER_VALUES.all,
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
Object.keys(ROUTING_STATUS_FILTER_VALUES) as Array<keyof typeof ROUTING_STATUS_FILTER_VALUES>
|
||||
).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{ROUTING_STATUS_FILTER_VALUES[value]}
|
||||
{t(ROUTING_STATUS_FILTER_VALUES[value])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Model Access Group">
|
||||
<DataTableFilterField label={t("modelsAndEndpoints.allModelsTable.filters.modelAccessGroup")}>
|
||||
<SearchSelect
|
||||
options={accessGroupOptions}
|
||||
value={(get(ACCESS_GROUPS_COLUMN_ID) as string) ?? ALL_MODEL_GROUPS_VALUE}
|
||||
onValueChange={(value) =>
|
||||
set(ACCESS_GROUPS_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value ?? undefined)
|
||||
}
|
||||
placeholder="Filter by Model Access Group"
|
||||
emptyText="No model access groups found"
|
||||
placeholder={t("modelsAndEndpoints.allModelsTable.filters.modelAccessGroupPlaceholder")}
|
||||
emptyText={t("modelsAndEndpoints.allModelsTable.filters.modelAccessGroupEmpty")}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAutoRouters, useInvalidateAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
|
@ -35,6 +36,7 @@ export function AutoRoutersPanel({
|
|||
teams,
|
||||
createScope,
|
||||
}: AutoRoutersPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const canCreate = createScope !== "forbidden";
|
||||
const { data: deployments, isLoading } = useAutoRouters();
|
||||
const invalidateAutoRouters = useInvalidateAutoRouters();
|
||||
|
|
@ -61,11 +63,11 @@ export function AutoRoutersPanel({
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await modelDeleteCall(accessToken, deletingRouter.id);
|
||||
toast.success(`Deleted auto router: ${deletingRouter.name}`);
|
||||
toast.success(t("modelsAndEndpoints.autoRoutersPanel.deleted", { name: deletingRouter.name }));
|
||||
setDeletingRouter(null);
|
||||
await invalidateAutoRouters();
|
||||
} catch (error) {
|
||||
toast.fromError(`Failed to delete auto router: ${error}`);
|
||||
toast.fromError(t("modelsAndEndpoints.autoRoutersPanel.deleteFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
@ -75,16 +77,13 @@ export function AutoRoutersPanel({
|
|||
<div className="w-full space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Auto routers</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Auto routers sit above your deployments and pick a model per request. They are called like any other model,
|
||||
so clients keep using a single model name.
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-foreground">{t("modelsAndEndpoints.autoRoutersPanel.title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("modelsAndEndpoints.autoRoutersPanel.description")}</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button onClick={() => setIsCreating(true)} className="shrink-0">
|
||||
<Plus />
|
||||
Add Auto Router
|
||||
{t("modelsAndEndpoints.autoRoutersPanel.addAutoRouter")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -105,8 +104,8 @@ export function AutoRoutersPanel({
|
|||
className="max-h-[90vh] overflow-y-auto sm:max-w-4xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Auto Router</DialogTitle>
|
||||
<DialogDescription>Choose models for your auto router.</DialogDescription>
|
||||
<DialogTitle>{t("modelsAndEndpoints.autoRoutersPanel.addDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("modelsAndEndpoints.autoRoutersPanel.addDialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddAutoRouterTab
|
||||
handleOk={handleCreated}
|
||||
|
|
@ -122,12 +121,12 @@ export function AutoRoutersPanel({
|
|||
{deletingRouter && (
|
||||
<DeleteResourceModal
|
||||
isOpen
|
||||
title="Delete Auto Router"
|
||||
message={`Are you sure you want to delete "${deletingRouter.name}"? Any client still calling this model name will start failing.`}
|
||||
resourceInformationTitle="Auto router"
|
||||
title={t("modelsAndEndpoints.autoRoutersPanel.deleteTitle")}
|
||||
message={t("modelsAndEndpoints.autoRoutersPanel.deleteMessage", { name: deletingRouter.name })}
|
||||
resourceInformationTitle={t("modelsAndEndpoints.autoRoutersPanel.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: deletingRouter.name },
|
||||
{ label: "Type", value: deletingRouter.typeLabel },
|
||||
{ label: t("modelsAndEndpoints.autoRoutersPanel.nameLabel"), value: deletingRouter.name },
|
||||
{ label: t("modelsAndEndpoints.autoRoutersPanel.typeLabel"), value: deletingRouter.typeLabel },
|
||||
{ label: "ID", value: deletingRouter.id },
|
||||
]}
|
||||
onCancel={() => setDeletingRouter(null)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { AutoRouterIcon } from "@/components/shared/table_cells";
|
||||
|
|
@ -25,16 +26,17 @@ const DEFAULT_SORTING: SortingState = [
|
|||
];
|
||||
|
||||
function EmptyState({ canModify }: { canModify: 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">
|
||||
<AutoRouterIcon size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No auto routers yet</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("modelsAndEndpoints.autoRoutersTable.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{canModify
|
||||
? "Create an auto router to pick the right model per request instead of pinning one."
|
||||
: "An auto router picks the right model per request instead of pinning one."}
|
||||
? t("modelsAndEndpoints.autoRoutersTable.emptyCreate")
|
||||
: t("modelsAndEndpoints.autoRoutersTable.emptyReadOnly")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -47,9 +49,10 @@ export function AutoRoutersTable({
|
|||
onRouterClick,
|
||||
onDeleteClick,
|
||||
}: AutoRoutersTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(
|
||||
() => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }),
|
||||
[canModify, onRouterClick, onDeleteClick],
|
||||
() => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }, t),
|
||||
[canModify, onRouterClick, onDeleteClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -62,7 +65,7 @@ export function AutoRoutersTable({
|
|||
paginationMode="client"
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading auto routers…"
|
||||
loadingMessage={t("modelsAndEndpoints.autoRoutersTable.loading")}
|
||||
noDataMessage={<EmptyState canModify={canModify} />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { AutoRouterRow } from "./autoRouterRows";
|
||||
|
|
@ -71,10 +74,11 @@ function AutoRouterRowActions({
|
|||
row: AutoRouterRow;
|
||||
onDeleteClick: (row: AutoRouterRow) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Open actions for ${row.name}`}
|
||||
aria-label={t("modelsAndEndpoints.autoRoutersTableColumns.openActions", { name: row.name })}
|
||||
data-testid={`auto-router-actions-${row.id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -87,7 +91,7 @@ function AutoRouterRowActions({
|
|||
onClick={() => onDeleteClick(row)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete auto router
|
||||
{t("modelsAndEndpoints.autoRoutersTableColumns.deleteAutoRouter")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -100,16 +104,17 @@ interface AutoRoutersTableColumnsDeps {
|
|||
onDeleteClick: (row: AutoRouterRow) => void;
|
||||
}
|
||||
|
||||
export const getAutoRoutersTableColumns = ({
|
||||
canModify,
|
||||
onRouterClick,
|
||||
onDeleteClick,
|
||||
}: AutoRoutersTableColumnsDeps): ColumnDef<AutoRouterRow>[] => [
|
||||
export const getAutoRoutersTableColumns = (
|
||||
{ canModify, onRouterClick, onDeleteClick }: AutoRoutersTableColumnsDeps,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): ColumnDef<AutoRouterRow>[] => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.name") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelsAndEndpoints.autoRoutersTableColumns.name")} />
|
||||
),
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <IdentityCell title={row.original.name || "-"} onClick={() => onRouterClick(row.original)} />,
|
||||
|
|
@ -117,16 +122,16 @@ export const getAutoRoutersTableColumns = ({
|
|||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
meta: { title: "Type" },
|
||||
header: "Type",
|
||||
meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.type") },
|
||||
header: t("modelsAndEndpoints.autoRoutersTableColumns.type"),
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TypeCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "targets",
|
||||
meta: { title: "Routes to" },
|
||||
header: "Routes to",
|
||||
meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.routesTo") },
|
||||
header: t("modelsAndEndpoints.autoRoutersTableColumns.routesTo"),
|
||||
size: 320,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TargetsCell targets={row.original.targets} />,
|
||||
|
|
@ -134,8 +139,8 @@ export const getAutoRoutersTableColumns = ({
|
|||
{
|
||||
id: "defaultModel",
|
||||
accessorKey: "defaultModel",
|
||||
meta: { title: "Default model" },
|
||||
header: "Default model",
|
||||
meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.defaultModel") },
|
||||
header: t("modelsAndEndpoints.autoRoutersTableColumns.defaultModel"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
|
|
@ -150,8 +155,10 @@ export const getAutoRoutersTableColumns = ({
|
|||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.created") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelsAndEndpoints.autoRoutersTableColumns.created")} />
|
||||
),
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
sortingFn: "datetime",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { LoaderCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -53,9 +54,10 @@ const ModelRetrySettingsTab = ({
|
|||
handleSaveRetrySettings,
|
||||
isSaving = false,
|
||||
}: ModelRetrySettingsTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isGlobalScope = selectedModelGroup === "global";
|
||||
const scopeItems = [
|
||||
{ value: "global", label: "Global Default" },
|
||||
{ value: "global", label: t("modelsAndEndpoints.modelRetrySettingsTab.globalDefault") },
|
||||
...availableModelGroups.map((group) => ({ value: group, label: group })),
|
||||
];
|
||||
|
||||
|
|
@ -86,7 +88,7 @@ const ModelRetrySettingsTab = ({
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="retry-policy-scope">Retry Policy Scope:</Label>
|
||||
<Label htmlFor="retry-policy-scope">{t("modelsAndEndpoints.modelRetrySettingsTab.scopeLabel")}</Label>
|
||||
<div className="w-48">
|
||||
<Select
|
||||
items={scopeItems}
|
||||
|
|
@ -109,16 +111,18 @@ const ModelRetrySettingsTab = ({
|
|||
|
||||
{isGlobalScope ? (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Global Retry Policy</h2>
|
||||
<h2 className="text-lg font-semibold">{t("modelsAndEndpoints.modelRetrySettingsTab.globalTitle")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Default retry settings applied to all model groups unless overridden
|
||||
{t("modelsAndEndpoints.modelRetrySettingsTab.globalDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Retry Policy for {selectedModelGroup}</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("modelsAndEndpoints.modelRetrySettingsTab.modelTitle", { modelGroup: selectedModelGroup })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model-specific retry settings. Falls back to global defaults if not set.
|
||||
{t("modelsAndEndpoints.modelRetrySettingsTab.modelDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -134,14 +138,16 @@ const ModelRetrySettingsTab = ({
|
|||
<td className="text-sm">
|
||||
<span>{exceptionType}</span>
|
||||
{!isGlobalScope && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">(Global: {inheritedValue})</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t("modelsAndEndpoints.modelRetrySettingsTab.inheritedValue", { value: inheritedValue })}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-28"
|
||||
type="number"
|
||||
aria-label={`${exceptionType} retry count`}
|
||||
aria-label={t("modelsAndEndpoints.modelRetrySettingsTab.retryCountAriaLabel", { exceptionType })}
|
||||
min={0}
|
||||
step={1}
|
||||
value={isGlobalScope ? inheritedValue : hasOverride ? override : ""}
|
||||
|
|
@ -150,7 +156,7 @@ const ModelRetrySettingsTab = ({
|
|||
/>
|
||||
{!isGlobalScope && hasOverride && (
|
||||
<Button variant="ghost" size="xs" onClick={() => setModelOverride(retryPolicyKey, null)}>
|
||||
Reset
|
||||
{t("modelsAndEndpoints.modelRetrySettingsTab.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -161,7 +167,7 @@ const ModelRetrySettingsTab = ({
|
|||
</table>
|
||||
<Button onClick={handleSaveRetrySettings} disabled={isSaving}>
|
||||
{isSaving && <LoaderCircle className="animate-spin" />}
|
||||
Save
|
||||
{t("modelsAndEndpoints.modelRetrySettingsTab.save")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CredentialIdentity } from "@/components/shared/CredentialIdentity";
|
||||
import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { copyToClipboard, formatPerSecondCost } from "@/utils/dataUtils";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export const MODEL_ID_COLUMN_ID = "model_info_id";
|
||||
export const MODEL_NAME_COLUMN_ID = "model_name";
|
||||
|
|
@ -56,6 +59,7 @@ const formatShortDate = (value: string | null | undefined): string | null => {
|
|||
};
|
||||
|
||||
function ModelInformationCell({ model, displayName }: { model: ModelData; displayName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const litellmModelName = model.litellm_model_name || "-";
|
||||
|
||||
return (
|
||||
|
|
@ -85,26 +89,37 @@ function ModelInformationCell({ model, displayName }: { model: ModelData; displa
|
|||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{model.provider ? <ProviderLogo provider={model.provider} className="size-4 shrink-0" /> : null}
|
||||
<span className="truncate text-xs text-muted-foreground">{model.provider || "Unknown provider"}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{model.provider || t("modelsAndEndpoints.modelsTableColumns.unknownProvider")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Public Model Name</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("modelsAndEndpoints.modelsTableColumns.information.publicModelName")}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">LiteLLM Model Name</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("modelsAndEndpoints.modelsTableColumns.information.litellmModelName")}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate font-mono text-sm text-foreground" title={litellmModelName}>
|
||||
{litellmModelName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy LiteLLM model name"
|
||||
aria-label={t("modelsAndEndpoints.modelsTableColumns.information.copyLitellmModelName")}
|
||||
data-testid={`copy-litellm-model-name-${model.model_info.id}`}
|
||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void copyToClipboard(litellmModelName, "LiteLLM model name copied")}
|
||||
onClick={() =>
|
||||
void copyToClipboard(
|
||||
litellmModelName,
|
||||
t("modelsAndEndpoints.modelsTableColumns.information.litellmModelNameCopied"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -117,15 +132,16 @@ function ModelInformationCell({ model, displayName }: { model: ModelData; displa
|
|||
}
|
||||
|
||||
function CredentialsHeader() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
Credentials
|
||||
{t("modelsAndEndpoints.modelsTableColumns.columns.credentials")}
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About credential types"
|
||||
aria-label={t("modelsAndEndpoints.modelsTableColumns.credentialTypes.aboutAriaLabel")}
|
||||
data-testid="credentials-header-info"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
|
|
@ -135,23 +151,25 @@ function CredentialsHeader() {
|
|||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-80">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-foreground">Credential types</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("modelsAndEndpoints.modelsTableColumns.credentialTypes.title")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-info">
|
||||
<RefreshCw className="size-3.5" />
|
||||
Reusable
|
||||
{t("modelsAndEndpoints.modelsTableColumns.credentialTypes.reusable")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Credentials saved in LiteLLM that can be added to models repeatedly.
|
||||
{t("modelsAndEndpoints.modelsTableColumns.credentialTypes.reusableDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Pencil className="size-3.5" />
|
||||
Manual
|
||||
{t("modelsAndEndpoints.modelsTableColumns.credentialTypes.manual")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Credentials added directly during model creation or defined in the config file.
|
||||
{t("modelsAndEndpoints.modelsTableColumns.credentialTypes.manualDescription")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -162,11 +180,12 @@ function CredentialsHeader() {
|
|||
}
|
||||
|
||||
function CredentialsCell({ credentialName, label }: { credentialName: string | undefined; label: string | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
if (!credentialName) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 font-normal text-muted-foreground">
|
||||
<Pencil className="size-3" />
|
||||
Manual
|
||||
{t("modelsAndEndpoints.modelsTableColumns.credentialTypes.manual")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -182,10 +201,13 @@ function CredentialsCell({ credentialName, label }: { credentialName: string | u
|
|||
}
|
||||
|
||||
function CreatedByCell({ model }: { model: ModelData }) {
|
||||
const { t } = useTranslation();
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const createdAt = formatShortDate(model.model_info.created_at);
|
||||
const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown";
|
||||
const secondaryForDbModel = createdAt ?? "Unknown date";
|
||||
const primary = isConfigModel
|
||||
? t("modelsAndEndpoints.modelsTableColumns.createdBy.definedInConfig")
|
||||
: model.model_info.created_by || t("modelsAndEndpoints.modelsTableColumns.createdBy.unknown");
|
||||
const secondaryForDbModel = createdAt ?? t("modelsAndEndpoints.modelsTableColumns.createdBy.unknownDate");
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
|
|
@ -207,6 +229,7 @@ function CostRow({ label, value }: { label: string; value: string }) {
|
|||
}
|
||||
|
||||
function CostsCell({ model }: { model: ModelData }) {
|
||||
const { t } = useTranslation();
|
||||
const { input_cost: inputCost, output_cost: outputCost, output_cost_per_second: perSecond } = model;
|
||||
const hasPerSecond = perSecond != null;
|
||||
const showInput = inputCost != null && (!hasPerSecond || Number(inputCost) > 0);
|
||||
|
|
@ -218,12 +241,25 @@ function CostsCell({ model }: { model: ModelData }) {
|
|||
|
||||
return (
|
||||
<CellTooltip
|
||||
content={hasPerSecond ? "Cost per 1M tokens; /s is cost per second of output" : "Cost per 1M tokens"}
|
||||
content={
|
||||
hasPerSecond
|
||||
? t("modelsAndEndpoints.modelsTableColumns.costs.perSecondTooltip")
|
||||
: t("modelsAndEndpoints.modelsTableColumns.costs.tooltip")
|
||||
}
|
||||
trigger={
|
||||
<div className="flex flex-col gap-0.5 whitespace-nowrap">
|
||||
{showInput && <CostRow label="IN" value={`$${inputCost}`} />}
|
||||
{showOutput && <CostRow label="OUT" value={`$${outputCost}`} />}
|
||||
{hasPerSecond && <CostRow label="OUT" value={formatPerSecondCost(perSecond)} />}
|
||||
{showInput && (
|
||||
<CostRow label={t("modelsAndEndpoints.modelsTableColumns.costs.input")} value={`$${inputCost}`} />
|
||||
)}
|
||||
{showOutput && (
|
||||
<CostRow label={t("modelsAndEndpoints.modelsTableColumns.costs.output")} value={`$${outputCost}`} />
|
||||
)}
|
||||
{hasPerSecond && (
|
||||
<CostRow
|
||||
label={t("modelsAndEndpoints.modelsTableColumns.costs.output")}
|
||||
value={formatPerSecondCost(perSecond)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
@ -231,6 +267,7 @@ function CostsCell({ model }: { model: ModelData }) {
|
|||
}
|
||||
|
||||
function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!accessGroups || accessGroups.length === 0) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
|
@ -253,7 +290,7 @@ function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) {
|
|||
}
|
||||
trigger={
|
||||
<Badge variant="outline" className="shrink-0 cursor-default font-normal">
|
||||
+{overflow.length} more
|
||||
{t("modelsAndEndpoints.modelsTableColumns.accessGroups.more", { hiddenCount: overflow.length })}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
|
@ -281,6 +318,7 @@ function ModelRowActions({
|
|||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
}: ModelRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const modelId = model.model_info?.id;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const isAdmin = userRole === "Admin" && !isViewOnly;
|
||||
|
|
@ -290,17 +328,19 @@ function ModelRowActions({
|
|||
|
||||
const resolvePauseTooltip = (): string => {
|
||||
if (isConfigModel) {
|
||||
return "Config models cannot be paused from the dashboard. Pause is DB-backed.";
|
||||
return t("modelsAndEndpoints.modelsTableColumns.actions.configPauseTooltip");
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return "Only proxy admins can pause or resume a model.";
|
||||
return t("modelsAndEndpoints.modelsTableColumns.actions.adminOnlyPauseTooltip");
|
||||
}
|
||||
return isBlocked ? "Resume model — restore normal routing." : "Pause model — stop routing requests until resumed.";
|
||||
return isBlocked
|
||||
? t("modelsAndEndpoints.modelsTableColumns.actions.resumeTooltip")
|
||||
: t("modelsAndEndpoints.modelsTableColumns.actions.pauseTooltip");
|
||||
};
|
||||
|
||||
const deleteTooltip = isConfigModel
|
||||
? "Config model cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
: "Delete model";
|
||||
? t("modelsAndEndpoints.modelsTableColumns.actions.configDeleteTooltip")
|
||||
: t("modelsAndEndpoints.modelsTableColumns.actions.delete");
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
|
|
@ -319,7 +359,11 @@ function ModelRowActions({
|
|||
size="sm"
|
||||
checked={!isBlocked}
|
||||
disabled={!isPauseToggleable}
|
||||
aria-label={isBlocked ? "Resume model" : "Pause model"}
|
||||
aria-label={
|
||||
isBlocked
|
||||
? t("modelsAndEndpoints.modelsTableColumns.actions.resume")
|
||||
: t("modelsAndEndpoints.modelsTableColumns.actions.pause")
|
||||
}
|
||||
data-testid={`model-pause-toggle-${modelId}`}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
if (isPauseToggleable && onTogglePauseClick && modelId) {
|
||||
|
|
@ -339,7 +383,7 @@ function ModelRowActions({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete model"
|
||||
aria-label={t("modelsAndEndpoints.modelsTableColumns.actions.delete")}
|
||||
data-testid={`model-delete-${modelId}`}
|
||||
disabled={isConfigModel || !canEditModel}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
|
|
@ -370,22 +414,25 @@ export interface ModelsTableColumnDeps {
|
|||
credentialLabels?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export const getModelsTableColumns = ({
|
||||
userRole,
|
||||
userID,
|
||||
isViewOnly,
|
||||
onModelIdClick,
|
||||
onTeamIdClick,
|
||||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
credentialLabels,
|
||||
}: ModelsTableColumnDeps): ColumnDef<ModelData>[] => [
|
||||
export const getModelsTableColumns = (
|
||||
{
|
||||
userRole,
|
||||
userID,
|
||||
isViewOnly,
|
||||
onModelIdClick,
|
||||
onTeamIdClick,
|
||||
onDeleteClick,
|
||||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
credentialLabels,
|
||||
}: ModelsTableColumnDeps,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
id: MODEL_ID_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.id,
|
||||
meta: { title: "Model ID" },
|
||||
header: "Model ID",
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.modelId") },
|
||||
header: t("modelsAndEndpoints.modelsTableColumns.columns.modelId"),
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 90,
|
||||
|
|
@ -400,8 +447,13 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: MODEL_NAME_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_name ?? "",
|
||||
meta: { title: "Model Information", skeleton: "twoLine" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Model Information" />,
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.modelInformation"), skeleton: "twoLine" },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("modelsAndEndpoints.modelsTableColumns.columns.modelInformation")}
|
||||
/>
|
||||
),
|
||||
enableSorting: true,
|
||||
size: 280,
|
||||
minSize: 160,
|
||||
|
|
@ -412,7 +464,7 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: CREDENTIALS_COLUMN_ID,
|
||||
accessorFn: (row) => row.litellm_params?.litellm_credential_name ?? "",
|
||||
meta: { title: "Credentials" },
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.credentials") },
|
||||
header: () => <CredentialsHeader />,
|
||||
enableSorting: false,
|
||||
size: 180,
|
||||
|
|
@ -430,8 +482,10 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: CREATED_BY_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.created_by ?? "",
|
||||
meta: { title: "Created By", skeleton: "twoLine" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created By" />,
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.createdBy"), skeleton: "twoLine" },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelsAndEndpoints.modelsTableColumns.columns.createdBy")} />
|
||||
),
|
||||
enableSorting: true,
|
||||
size: 180,
|
||||
minSize: 110,
|
||||
|
|
@ -440,8 +494,10 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: UPDATED_AT_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.updated_at ?? "",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.updatedAt") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelsAndEndpoints.modelsTableColumns.columns.updatedAt")} />
|
||||
),
|
||||
enableSorting: true,
|
||||
size: 140,
|
||||
minSize: 100,
|
||||
|
|
@ -450,8 +506,10 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: COSTS_COLUMN_ID,
|
||||
accessorFn: (row) => row.input_cost,
|
||||
meta: { title: "Costs", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Costs" />,
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.costs"), numeric: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelsAndEndpoints.modelsTableColumns.columns.costs")} />
|
||||
),
|
||||
enableSorting: true,
|
||||
size: 130,
|
||||
minSize: 90,
|
||||
|
|
@ -460,8 +518,8 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: TEAM_ID_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.team_id ?? "",
|
||||
meta: { title: "Team ID" },
|
||||
header: "Team ID",
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.teamId") },
|
||||
header: t("modelsAndEndpoints.modelsTableColumns.columns.teamId"),
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 90,
|
||||
|
|
@ -476,8 +534,8 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: ACCESS_GROUPS_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.access_groups ?? [],
|
||||
meta: { title: "Model Access Group", skeleton: "chips" },
|
||||
header: "Model Access Group",
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.modelAccessGroup"), skeleton: "chips" },
|
||||
header: t("modelsAndEndpoints.modelsTableColumns.columns.modelAccessGroup"),
|
||||
enableSorting: false,
|
||||
size: 200,
|
||||
minSize: 120,
|
||||
|
|
@ -486,22 +544,28 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: STATUS_COLUMN_ID,
|
||||
accessorFn: (row) => row.model_info.db_model,
|
||||
meta: { title: "Source", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Source" />,
|
||||
meta: { title: t("modelsAndEndpoints.modelsTableColumns.columns.source"), skeleton: "badge" },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelsAndEndpoints.modelsTableColumns.columns.source")} />
|
||||
),
|
||||
enableSorting: true,
|
||||
size: 140,
|
||||
minSize: 100,
|
||||
cell: ({ row }) =>
|
||||
row.original.model_info.db_model ? (
|
||||
<StatusBadge tone="info" label="DB Model" />
|
||||
<StatusBadge tone="info" label={t("modelsAndEndpoints.modelsTableColumns.source.dbModel")} />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Config Model" />
|
||||
<StatusBadge tone="neutral" label={t("modelsAndEndpoints.modelsTableColumns.source.configModel")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { title: "Actions", className: "text-right", headerClassName: "text-right" },
|
||||
header: "Actions",
|
||||
meta: {
|
||||
title: t("modelsAndEndpoints.modelsTableColumns.columns.actions"),
|
||||
className: "text-right",
|
||||
headerClassName: "text-right",
|
||||
},
|
||||
header: t("modelsAndEndpoints.modelsTableColumns.columns.actions"),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import PriceDataReload from "@/components/price_data_reload";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { modelCostMapKeys } from "../../hooks/models/useModelCostMap";
|
||||
|
||||
const PriceDataManagementTab = () => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -12,17 +14,15 @@ const PriceDataManagementTab = () => {
|
|||
<div>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold">Price Data Management</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage model pricing data and configure automatic reload schedules
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">{t("modelsAndEndpoints.priceDataManagementTab.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("modelsAndEndpoints.priceDataManagementTab.description")}</p>
|
||||
</div>
|
||||
<PriceDataReload
|
||||
accessToken={accessToken}
|
||||
onReloadSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all });
|
||||
}}
|
||||
buttonText="Reload Price Data"
|
||||
buttonText={t("modelsAndEndpoints.priceDataManagementTab.reloadButton")}
|
||||
size="middle"
|
||||
type="primary"
|
||||
className="w-full"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useMemo, useState } from "react";
|
|||
import { RefreshCw } from "lucide-react";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
|
|
@ -49,16 +51,16 @@ const TAB_KEYS = [BASE_TAB_KEY, ...MODEL_TAB_SLUGS] as const;
|
|||
|
||||
const PREFILL_URL_PARAMS = { provider: parseAsString, litellm_model: parseAsString };
|
||||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"auto-routers": "Auto-Routers",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
health: "Health Status",
|
||||
"retry-settings": "Model Retry Settings",
|
||||
"model-group-alias": "Model Group Alias",
|
||||
"access-group-budgets": "Model Access Group Budgets",
|
||||
"price-data": "Price Data Reload",
|
||||
const TAB_LABELS: Record<ModelTabSlug, ParseKeys> = {
|
||||
add: "modelsAndEndpoints.page.tabs.add",
|
||||
"auto-routers": "modelsAndEndpoints.page.tabs.autoRouters",
|
||||
"llm-credentials": "modelsAndEndpoints.page.tabs.llmCredentials",
|
||||
"pass-through": "modelsAndEndpoints.page.tabs.passThrough",
|
||||
health: "modelsAndEndpoints.page.tabs.health",
|
||||
"retry-settings": "modelsAndEndpoints.page.tabs.retrySettings",
|
||||
"model-group-alias": "modelsAndEndpoints.page.tabs.modelGroupAlias",
|
||||
"access-group-budgets": "modelsAndEndpoints.page.tabs.accessGroupBudgets",
|
||||
"price-data": "modelsAndEndpoints.page.tabs.priceData",
|
||||
};
|
||||
|
||||
const renderPanel = (key: string) => {
|
||||
|
|
@ -89,6 +91,7 @@ const renderPanel = (key: string) => {
|
|||
};
|
||||
|
||||
export default function ModelsAndEndpointsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: uiSettings } = useUISettings();
|
||||
|
|
@ -146,17 +149,19 @@ export default function ModelsAndEndpointsPage() {
|
|||
void setPrefillParams({ provider: null, litellm_model: null });
|
||||
};
|
||||
|
||||
const allModelsLabel = isAdmin ? "Deployed Models" : "Your Models";
|
||||
const allModelsLabel = isAdmin
|
||||
? t("modelsAndEndpoints.page.tabs.deployedModels")
|
||||
: t("modelsAndEndpoints.page.tabs.yourModels");
|
||||
const tabLabel = (slug: "" | ModelTabSlug): React.ReactNode => {
|
||||
if (!slug) return allModelsLabel;
|
||||
if (slug === "auto-routers" || slug === "access-group-budgets") {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{TAB_LABELS[slug]} <BetaBadge />
|
||||
{t(TAB_LABELS[slug])} <BetaBadge />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return TAB_LABELS[slug];
|
||||
return t(TAB_LABELS[slug]);
|
||||
};
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
|
|
@ -189,13 +194,11 @@ export default function ModelsAndEndpointsPage() {
|
|||
<div className="mt-2 flex w-full flex-col gap-2 p-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
<h2 className="text-lg font-semibold">{t("modelsAndEndpoints.page.title")}</h2>
|
||||
{isAdmin ? (
|
||||
<p className="text-sm text-muted-foreground">Add and manage models for the proxy</p>
|
||||
<p className="text-sm text-muted-foreground">{t("modelsAndEndpoints.page.adminDescription")}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View your models and manage routers for teams that allow it.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("modelsAndEndpoints.page.userDescription")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -230,9 +233,16 @@ export default function ModelsAndEndpointsPage() {
|
|||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pb-1">
|
||||
{lastRefreshed && (
|
||||
<span className="text-xs text-muted-foreground">Last Refreshed: {lastRefreshed}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("modelsAndEndpoints.page.lastRefreshed", { time: lastRefreshed })}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleRefreshClick} aria-label="Refresh models">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleRefreshClick}
|
||||
aria-label={t("modelsAndEndpoints.page.refreshModels")}
|
||||
>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
|
@ -21,20 +22,24 @@ import { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoi
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }];
|
||||
|
||||
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">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No model access groups yet</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("modelsAndEndpoints.accessGroupBudgetsPanel.empty.title")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Put a deployment in an access group from its model settings, then give the group a shared budget here.
|
||||
{t("modelsAndEndpoints.accessGroupBudgetsPanel.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccessGroupBudgetsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { userRole } = useAuthorized();
|
||||
const { data: accessGroups, isLoading } = useModelAccessGroups();
|
||||
const setBudget = useSetModelAccessGroupBudget();
|
||||
|
|
@ -57,7 +62,7 @@ export default function AccessGroupBudgetsPanel() {
|
|||
{ accessGroup, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Budget saved for "${accessGroup}"`);
|
||||
toast.success(t("modelsAndEndpoints.accessGroupBudgetsPanel.toasts.saved", { accessGroup }));
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
|
|
@ -69,7 +74,7 @@ export default function AccessGroupBudgetsPanel() {
|
|||
const accessGroup = clearing.access_group;
|
||||
clearBudget.mutate(accessGroup, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Budget cleared for "${accessGroup}"`);
|
||||
toast.success(t("modelsAndEndpoints.accessGroupBudgetsPanel.toasts.cleared", { accessGroup }));
|
||||
setClearing(null);
|
||||
},
|
||||
});
|
||||
|
|
@ -77,10 +82,7 @@ export default function AccessGroupBudgetsPanel() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A model access group can carry one budget that every key granted the group by name draws from together. Keys
|
||||
that reach the group's models through a wildcard or all-proxy-models are not charged against it.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("modelsAndEndpoints.accessGroupBudgetsPanel.description")}</p>
|
||||
|
||||
<DataTable
|
||||
data={accessGroups ?? []}
|
||||
|
|
@ -91,7 +93,7 @@ export default function AccessGroupBudgetsPanel() {
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading model access groups…"
|
||||
loadingMessage={t("modelsAndEndpoints.accessGroupBudgetsPanel.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
@ -105,12 +107,19 @@ export default function AccessGroupBudgetsPanel() {
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={clearing !== null}
|
||||
title="Clear Budget"
|
||||
message="Are you sure you want to clear this access group's budget? The recorded shared spend is cleared with it, and the group's models stay available."
|
||||
resourceInformationTitle="Access Group"
|
||||
title={t("modelsAndEndpoints.accessGroupBudgetsPanel.clearModal.title")}
|
||||
message={t("modelsAndEndpoints.accessGroupBudgetsPanel.clearModal.message")}
|
||||
resourceInformationTitle={t("modelsAndEndpoints.accessGroupBudgetsPanel.clearModal.informationTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Access Group", value: clearing?.access_group ?? null, code: true },
|
||||
{ label: "Max Budget", value: clearing?.budget?.max_budget?.toString() ?? null },
|
||||
{
|
||||
label: t("modelsAndEndpoints.accessGroupBudgetsPanel.clearModal.accessGroup"),
|
||||
value: clearing?.access_group ?? null,
|
||||
code: true,
|
||||
},
|
||||
{
|
||||
label: t("modelsAndEndpoints.accessGroupBudgetsPanel.clearModal.maxBudget"),
|
||||
value: clearing?.budget?.max_budget?.toString() ?? null,
|
||||
},
|
||||
]}
|
||||
onCancel={() => setClearing(null)}
|
||||
onOk={handleConfirmClear}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
|
||||
|
|
@ -23,6 +24,7 @@ interface RouterSettings {
|
|||
}
|
||||
|
||||
export default function ModelRetrySettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const { availableModelGroups } = useModelDashboardData();
|
||||
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
|
||||
|
|
@ -69,7 +71,7 @@ export default function ModelRetrySettingsPanel() {
|
|||
{ retry_policy: globalRetryPolicy, model_group_retry_policy: modelGroupRetryPolicy },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Retry settings saved successfully");
|
||||
toast.success(t("modelsAndEndpoints.modelRetrySettingsPanel.toasts.saved"));
|
||||
void fetchRetrySettings().then((routerSettings) => {
|
||||
if (routerSettings) {
|
||||
applyRetrySettings(routerSettings);
|
||||
|
|
@ -77,7 +79,7 @@ export default function ModelRetrySettingsPanel() {
|
|||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.fromError("Failed to save retry settings");
|
||||
toast.fromError(t("modelsAndEndpoints.modelRetrySettingsPanel.toasts.saveFailed"));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { ArrowRight, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -12,6 +13,7 @@ interface TransformRequestPanelProps {
|
|||
}
|
||||
|
||||
const TransformRequestPanel: React.FC<TransformRequestPanelProps> = ({ accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const [originalRequestJSON, setOriginalRequestJSON] = useState(`{
|
||||
"model": "openai/gpt-4o",
|
||||
"messages": [
|
||||
|
|
@ -69,7 +71,7 @@ ${formattedBody}
|
|||
try {
|
||||
requestBody = JSON.parse(originalRequestJSON);
|
||||
} catch (e) {
|
||||
toast.fromError("Invalid JSON in request body");
|
||||
toast.fromError(t("transformRequest.transformRequestPanel.toasts.invalidJson"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -82,7 +84,7 @@ ${formattedBody}
|
|||
|
||||
// Make the API call using fetch
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token found");
|
||||
toast.fromError(t("transformRequest.transformRequestPanel.toasts.noAccessToken"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -100,17 +102,17 @@ ${formattedBody}
|
|||
|
||||
// Update state with the formatted curl command
|
||||
setTransformedResponse(formattedCurl);
|
||||
toast.success("Request transformed successfully");
|
||||
toast.success(t("transformRequest.transformRequestPanel.toasts.transformed"));
|
||||
} else {
|
||||
// Handle the case where the API returns a different format
|
||||
// Try to extract the parts from a string response if needed
|
||||
const rawText = typeof data === "string" ? data : JSON.stringify(data);
|
||||
setTransformedResponse(rawText);
|
||||
toast.info("Transformed request received in unexpected format");
|
||||
toast.info(t("transformRequest.transformRequestPanel.toasts.unexpectedFormat"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error transforming request:", err);
|
||||
toast.fromError("Failed to transform request");
|
||||
toast.fromError(t("transformRequest.transformRequestPanel.toasts.failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -126,16 +128,16 @@ ${formattedBody}
|
|||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h1 className="text-lg font-medium text-foreground">Playground</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
See how LiteLLM transforms your request for the specified provider.
|
||||
</p>
|
||||
<h1 className="text-lg font-medium text-foreground">{t("transformRequest.transformRequestPanel.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("transformRequest.transformRequestPanel.description")}</p>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Original Request Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Original Request</CardTitle>
|
||||
<CardDescription>The request you would send to LiteLLM /chat/completions endpoint.</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{t("transformRequest.transformRequestPanel.original.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("transformRequest.transformRequestPanel.original.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
|
|
@ -144,13 +146,13 @@ ${formattedBody}
|
|||
value={originalRequestJSON}
|
||||
onChange={(e) => setOriginalRequestJSON(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Press Cmd/Ctrl + Enter to transform"
|
||||
placeholder={t("transformRequest.transformRequestPanel.original.placeholder")}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-end">
|
||||
<Button onClick={handleTransform} disabled={isLoading}>
|
||||
<span>Transform</span>
|
||||
<span>{t("transformRequest.transformRequestPanel.original.transform")}</span>
|
||||
{isLoading ? <UiLoadingSpinner className="size-4" /> : <ArrowRight />}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -159,9 +161,13 @@ ${formattedBody}
|
|||
{/* Transformed Request Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Transformed Request</CardTitle>
|
||||
<CardDescription>How LiteLLM transforms your request for the specified provider.</CardDescription>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Note: Sensitive headers are not shown.</p>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{t("transformRequest.transformRequestPanel.transformed.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("transformRequest.transformRequestPanel.transformed.description")}</CardDescription>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("transformRequest.transformRequestPanel.transformed.sensitiveHeaders")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
|
|
@ -187,11 +193,11 @@ ${formattedBody}
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy to clipboard"
|
||||
aria-label={t("transformRequest.transformRequestPanel.transformed.copy")}
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(transformedResponse || "");
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("transformRequest.transformRequestPanel.toasts.copied"));
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
|
|
@ -202,16 +208,19 @@ ${formattedBody}
|
|||
</div>
|
||||
<div className="mt-4 text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Found an error? File an issue{" "}
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="transformRequest.transformRequestPanel.fileIssue"
|
||||
components={{
|
||||
issues: (
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Globe2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -21,6 +22,7 @@ export function GuardrailSettingsView({
|
|||
variant = "card",
|
||||
className = "",
|
||||
}: GuardrailSettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const optedOutSet = new Set(optedOutGlobalGuardrails);
|
||||
const globalsRunning = Array.from(globalGuardrailNames).filter((n) => !optedOutSet.has(n));
|
||||
const nonGlobalOptIns = teamGuardrails.filter((n) => !globalGuardrailNames.has(n));
|
||||
|
|
@ -28,16 +30,16 @@ export function GuardrailSettingsView({
|
|||
const isEmpty = !killSwitchOn && globalsRunning.length === 0 && nonGlobalOptIns.length === 0;
|
||||
|
||||
const content = isEmpty ? (
|
||||
<span className="block text-muted-foreground">No guardrails configured</span>
|
||||
<span className="block text-muted-foreground">{t("guardrailSettingsView.empty")}</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
<Globe2 className="size-4" aria-label="Global guardrail" />
|
||||
Global
|
||||
<Globe2 className="size-4" aria-label={t("guardrailSettingsView.globalAriaLabel")} />
|
||||
{t("guardrailSettingsView.global")}
|
||||
</span>
|
||||
{killSwitchOn ? (
|
||||
<Badge variant="outline">Bypassed for this team</Badge>
|
||||
<Badge variant="outline">{t("guardrailSettingsView.bypassed")}</Badge>
|
||||
) : globalsRunning.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{globalsRunning.map((name) => (
|
||||
|
|
@ -45,11 +47,13 @@ export function GuardrailSettingsView({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="block text-sm text-muted-foreground">None configured</span>
|
||||
<span className="block text-sm text-muted-foreground">{t("guardrailSettingsView.noneConfigured")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium text-foreground">Team-specific</span>
|
||||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t("guardrailSettingsView.teamSpecific")}
|
||||
</span>
|
||||
{nonGlobalOptIns.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{nonGlobalOptIns.map((name) => (
|
||||
|
|
@ -57,7 +61,7 @@ export function GuardrailSettingsView({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="block text-sm text-muted-foreground">None configured</span>
|
||||
<span className="block text-sm text-muted-foreground">{t("guardrailSettingsView.noneConfigured")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,8 +71,8 @@ export function GuardrailSettingsView({
|
|||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Guardrails Settings</CardTitle>
|
||||
<CardDescription>Global and team-specific guardrails applied to this team</CardDescription>
|
||||
<CardTitle>{t("guardrailSettingsView.title")}</CardTitle>
|
||||
<CardDescription>{t("guardrailSettingsView.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
|
|
@ -77,7 +81,7 @@ export function GuardrailSettingsView({
|
|||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<span className="mb-3 block font-medium text-foreground">Guardrails Settings</span>
|
||||
<span className="mb-3 block font-medium text-foreground">{t("guardrailSettingsView.title")}</span>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import * as React from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
|
||||
|
|
@ -21,6 +23,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import CacheControlInjectionPoints, {
|
||||
CACHE_CONTROL_LABEL,
|
||||
|
|
@ -50,17 +53,28 @@ import {
|
|||
|
||||
interface PtuEditField {
|
||||
name: string;
|
||||
label: string;
|
||||
label: ParseKeys;
|
||||
input: "number" | "datetime";
|
||||
placeholder?: string;
|
||||
placeholder?: ParseKeys;
|
||||
isCount?: boolean;
|
||||
}
|
||||
|
||||
const PTU_EDIT_FIELDS: PtuEditField[] = [
|
||||
{ name: PTU_COUNT_FIELD, label: "PTU Count", input: "number", placeholder: "e.g. 15", isCount: true },
|
||||
{ name: PTU_RATE_FIELD, label: "Cost per PTU / Hour (USD)", input: "number", placeholder: "e.g. 2.00" },
|
||||
{ name: PTU_START_FIELD, label: "PTU Effective From (UTC)", input: "datetime" },
|
||||
{ name: PTU_END_FIELD, label: "PTU Effective To (UTC)", input: "datetime" },
|
||||
{
|
||||
name: PTU_COUNT_FIELD,
|
||||
label: "modelInfoEditForm.fields.ptuCount.label",
|
||||
input: "number",
|
||||
placeholder: "modelInfoEditForm.fields.ptuCount.placeholder",
|
||||
isCount: true,
|
||||
},
|
||||
{
|
||||
name: PTU_RATE_FIELD,
|
||||
label: "modelInfoEditForm.fields.ptuRate.label",
|
||||
input: "number",
|
||||
placeholder: "modelInfoEditForm.fields.ptuRate.placeholder",
|
||||
},
|
||||
{ name: PTU_START_FIELD, label: "modelInfoEditForm.fields.ptuEffectiveFrom.label", input: "datetime" },
|
||||
{ name: PTU_END_FIELD, label: "modelInfoEditForm.fields.ptuEffectiveTo.label", input: "datetime" },
|
||||
];
|
||||
|
||||
const PRICING_FIELDS = [
|
||||
|
|
@ -86,10 +100,9 @@ const textish = z.string().optional();
|
|||
const isBlank = (value: string | number | null | undefined): boolean => value == null || String(value).trim() === "";
|
||||
|
||||
export const perMillionToPerToken = scalar
|
||||
.refine(
|
||||
(perMillion) => isBlank(perMillion) || (Number.isFinite(Number(perMillion)) && Number(perMillion) >= 0),
|
||||
"Enter a price of 0 or more",
|
||||
)
|
||||
.refine((perMillion) => isBlank(perMillion) || (Number.isFinite(Number(perMillion)) && Number(perMillion) >= 0), {
|
||||
error: () => i18n.t("modelInfoEditForm.validation.priceNonNegative"),
|
||||
})
|
||||
.transform((perMillion) => (isBlank(perMillion) ? null : Number(perMillion) / 1_000_000));
|
||||
|
||||
const modelEditShape = {
|
||||
|
|
@ -127,13 +140,17 @@ const modelEditShape = {
|
|||
|
||||
type ModelEditFieldName = keyof typeof modelEditShape;
|
||||
|
||||
const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricingField) => boolean) =>
|
||||
const buildSchema = (
|
||||
ptuEnabled: boolean,
|
||||
isFieldTouched: (field: TouchedPricingField) => boolean,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
) =>
|
||||
z.object(modelEditShape).superRefine((values, ctx) => {
|
||||
const reject = (path: ModelEditFieldName, message: string) =>
|
||||
ctx.addIssue({ code: "custom", path: [path], message });
|
||||
|
||||
if (parseModelExtraParams(values.litellm_extra_params) === null) {
|
||||
reject("litellm_extra_params", "Please enter a valid JSON object");
|
||||
reject("litellm_extra_params", t("modelInfoEditForm.validation.invalidJson"));
|
||||
}
|
||||
|
||||
// antd validates only mounted fields, and the PTU block does not render when the flag is off.
|
||||
|
|
@ -142,24 +159,24 @@ const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricing
|
|||
}
|
||||
|
||||
if (!isPositiveWholePtuCount(values.ptu_count)) {
|
||||
reject("ptu_count", `PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`);
|
||||
reject("ptu_count", t("modelInfoEditForm.validation.ptuCountRange", { max: MAX_PTU_COUNT.toLocaleString() }));
|
||||
}
|
||||
if (!isNonNegativePtuRate(values.cost_per_ptu_per_hour)) {
|
||||
reject(
|
||||
"cost_per_ptu_per_hour",
|
||||
`Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`,
|
||||
t("modelInfoEditForm.validation.ptuRateRange", { max: MAX_COST_PER_PTU_PER_HOUR.toLocaleString() }),
|
||||
);
|
||||
}
|
||||
if (isFilledPtuValue(values.ptu_count) !== isFilledPtuValue(values.cost_per_ptu_per_hour)) {
|
||||
const message = "PTU Count and Cost per PTU / Hour must be set together";
|
||||
const message = t("modelInfoEditForm.validation.ptuPair");
|
||||
reject("ptu_count", message);
|
||||
reject("cost_per_ptu_per_hour", message);
|
||||
}
|
||||
if (isFilledPtuValue(values.ptu_count) && !isFilledPtuValue(values.ptu_effective_from)) {
|
||||
reject("ptu_effective_from", "PTU Effective From is required when PTU Count is set");
|
||||
reject("ptu_effective_from", t("modelInfoEditForm.validation.ptuStartRequired"));
|
||||
}
|
||||
if (!ptuWindowIsOrdered(values.ptu_effective_from, values.ptu_effective_to)) {
|
||||
const message = "PTU Effective To must be after PTU Effective From";
|
||||
const message = t("modelInfoEditForm.validation.ptuWindowOrder");
|
||||
reject("ptu_effective_from", message);
|
||||
reject("ptu_effective_to", message);
|
||||
}
|
||||
|
|
@ -172,7 +189,7 @@ const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricing
|
|||
isFilledPtuValue(value) &&
|
||||
Number(value) !== 0
|
||||
) {
|
||||
reject(field, "A PTU deployment bills by reserved capacity, so this cost must be 0 or blank");
|
||||
reject(field, t("modelInfoEditForm.validation.ptuPricing"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -240,10 +257,10 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool
|
|||
team_id: localModelData.model_info?.team_id ?? undefined,
|
||||
});
|
||||
|
||||
const displayCost = (localModelData: any, field: TouchedPricingField): string => {
|
||||
const displayCost = (localModelData: any, field: TouchedPricingField, t: TFunction = i18n.t.bind(i18n)): string => {
|
||||
const key = COST_SOURCES[field];
|
||||
const rate = localModelData?.litellm_params?.[key] ?? localModelData?.model_info?.[key];
|
||||
return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : "Not Set";
|
||||
return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : t("modelInfoEditForm.notSet");
|
||||
};
|
||||
|
||||
interface ModelInfoEditFormProps {
|
||||
|
|
@ -298,8 +315,9 @@ const DocsHint: React.FC<{ text: string; href: string }> = ({ text, href }) => (
|
|||
);
|
||||
|
||||
const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, emptyLabel }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!values) {
|
||||
return <>Not Set</>;
|
||||
return <>{t("modelInfoEditForm.notSet")}</>;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return <>{String(values)}</>;
|
||||
|
|
@ -338,6 +356,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
healthCheckModelOptions,
|
||||
teams,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Neither RHF's blur-based touchedFields nor its resettable dirtyFields matches antd's touched-on-change.
|
||||
const touchedRef = React.useRef<ReadonlySet<string>>(new Set<string>());
|
||||
const isFieldTouched = React.useCallback((field: TouchedPricingField) => touchedRef.current.has(field), []);
|
||||
|
|
@ -347,7 +366,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
|
||||
// react-hook-form refreshes control._options every render, so this rebuild is what the next submit runs.
|
||||
const resolver: Resolver<ModelEditFormValues, unknown, ModelEditSubmitValues> = (values, context, options) =>
|
||||
zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched))(values, context, options);
|
||||
zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched, t))(values, context, options);
|
||||
|
||||
const form = useForm<ModelEditFormValues, unknown, ModelEditSubmitValues>({
|
||||
resolver,
|
||||
|
|
@ -373,7 +392,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
{({ value, ...control }) => <Input {...control} value={(value as string) ?? ""} placeholder={placeholder} />}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{(stored as string) || "Not Set"}</Display>
|
||||
<Display>{(stored as string) || t("modelInfoEditForm.notSet")}</Display>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -386,7 +405,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
{({ value, ...control }) => <NumericalInput {...control} value={value ?? ""} placeholder={placeholder} />}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{(stored as string) || "Not Set"}</Display>
|
||||
<Display>{(stored as string) || t("modelInfoEditForm.notSet")}</Display>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -409,7 +428,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
) : (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Display>{displayCost(localModelData, name)}</Display>
|
||||
<Display>{displayCost(localModelData, name, t)}</Display>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -437,21 +456,34 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
<form onSubmit={submit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{textField("model_name", "Model Name", "Enter model name", localModelData.model_name)}
|
||||
{textField(
|
||||
"model_name",
|
||||
t("modelInfoEditForm.fields.modelName.label"),
|
||||
t("modelInfoEditForm.fields.modelName.placeholder"),
|
||||
localModelData.model_name,
|
||||
)}
|
||||
{textField(
|
||||
"litellm_model_name",
|
||||
"LiteLLM Model Name",
|
||||
"Enter LiteLLM model name",
|
||||
t("modelInfoEditForm.fields.litellmModelName.label"),
|
||||
t("modelInfoEditForm.fields.litellmModelName.placeholder"),
|
||||
localModelData.litellm_model_name,
|
||||
)}
|
||||
|
||||
{pricingField("input_cost", "Input Cost (per 1M tokens)", "Enter input cost")}
|
||||
{pricingField("output_cost", "Output Cost (per 1M tokens)", "Enter output cost")}
|
||||
{pricingField(
|
||||
"input_cost",
|
||||
t("modelInfoEditForm.fields.inputCost.label"),
|
||||
t("modelInfoEditForm.fields.inputCost.placeholder"),
|
||||
)}
|
||||
{pricingField(
|
||||
"output_cost",
|
||||
t("modelInfoEditForm.fields.outputCost.label"),
|
||||
t("modelInfoEditForm.fields.outputCost.placeholder"),
|
||||
)}
|
||||
|
||||
{ptuCostAttributionEnabled &&
|
||||
PTU_EDIT_FIELDS.map((ptuField) => (
|
||||
<div key={ptuField.name}>
|
||||
<FieldLabel htmlFor={ptuField.name}>{ptuField.label}</FieldLabel>
|
||||
<FieldLabel htmlFor={ptuField.name}>{t(ptuField.label)}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name={ptuField.name as ModelEditFieldName}>
|
||||
{({ value, onChange, ...control }) =>
|
||||
|
|
@ -461,7 +493,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
id={ptuField.name}
|
||||
onChange={onChange}
|
||||
value={value ?? ""}
|
||||
placeholder={ptuField.placeholder}
|
||||
placeholder={ptuField.placeholder && t(ptuField.placeholder)}
|
||||
step={ptuField.isCount ? 1 : undefined}
|
||||
min={ptuField.isCount ? 1 : 0}
|
||||
/>
|
||||
|
|
@ -479,7 +511,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
<Display>
|
||||
{(ptuField.input === "datetime"
|
||||
? formatPtuUtcDisplay(localModelData?.model_info?.[ptuField.name])
|
||||
: localModelData?.model_info?.[ptuField.name]) ?? "Not Set"}
|
||||
: localModelData?.model_info?.[ptuField.name]) ?? t("modelInfoEditForm.notSet")}
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -487,68 +519,96 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
|
||||
{pricingField(
|
||||
"cache_read_cost",
|
||||
"Cache Read Cost (per 1M tokens)",
|
||||
"Defaults to Input Cost if blank",
|
||||
"If left blank on save, defaults to Input Cost.",
|
||||
t("modelInfoEditForm.fields.cacheReadCost.label"),
|
||||
t("modelInfoEditForm.fields.cacheReadCost.placeholder"),
|
||||
t("modelInfoEditForm.fields.cacheReadCost.description"),
|
||||
)}
|
||||
{pricingField(
|
||||
"cache_write_cost",
|
||||
"Cache Write Cost (per 1M tokens)",
|
||||
"Defaults to Input Cost if blank",
|
||||
"If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token).",
|
||||
t("modelInfoEditForm.fields.cacheWriteCost.label"),
|
||||
t("modelInfoEditForm.fields.cacheWriteCost.placeholder"),
|
||||
t("modelInfoEditForm.fields.cacheWriteCost.description"),
|
||||
)}
|
||||
{pricingField(
|
||||
"cache_write_cost_1h",
|
||||
"Cache Write Cost (1 hour, per 1M tokens)",
|
||||
"Defaults to the cost map 1 hour rate, else Cache Write Cost",
|
||||
"Rate for prompt cache writes with a 1 hour TTL. If left blank on save, known models use their cost map 1 hour rate, otherwise Cache Write Cost.",
|
||||
t("modelInfoEditForm.fields.cacheWriteCost1h.label"),
|
||||
t("modelInfoEditForm.fields.cacheWriteCost1h.placeholder"),
|
||||
t("modelInfoEditForm.fields.cacheWriteCost1h.description"),
|
||||
)}
|
||||
|
||||
{textField("api_base", "API Base", "Enter API base", localModelData.litellm_params?.api_base)}
|
||||
{textField(
|
||||
"api_base",
|
||||
t("modelInfoEditForm.fields.apiBase.label"),
|
||||
t("modelInfoEditForm.fields.apiBase.placeholder"),
|
||||
localModelData.litellm_params?.api_base,
|
||||
)}
|
||||
{textField(
|
||||
"custom_llm_provider",
|
||||
"Custom LLM Provider",
|
||||
"Enter custom LLM provider",
|
||||
t("modelInfoEditForm.fields.customLlmProvider.label"),
|
||||
t("modelInfoEditForm.fields.customLlmProvider.placeholder"),
|
||||
localModelData.litellm_params?.custom_llm_provider,
|
||||
)}
|
||||
{textField(
|
||||
"organization",
|
||||
"Organization",
|
||||
"Enter organization",
|
||||
t("modelInfoEditForm.fields.organization.label"),
|
||||
t("modelInfoEditForm.fields.organization.placeholder"),
|
||||
localModelData.litellm_params?.organization,
|
||||
)}
|
||||
|
||||
{numberField("tpm", "TPM (Tokens per Minute)", "Enter TPM", localModelData.litellm_params?.tpm)}
|
||||
{numberField("rpm", "RPM (Requests per Minute)", "Enter RPM", localModelData.litellm_params?.rpm)}
|
||||
{numberField("max_retries", "Max Retries", "Enter max retries", localModelData.litellm_params?.max_retries)}
|
||||
{numberField("timeout", "Timeout (seconds)", "Enter timeout", localModelData.litellm_params?.timeout)}
|
||||
{numberField(
|
||||
"tpm",
|
||||
t("modelInfoEditForm.fields.tpm.label"),
|
||||
t("modelInfoEditForm.fields.tpm.placeholder"),
|
||||
localModelData.litellm_params?.tpm,
|
||||
)}
|
||||
{numberField(
|
||||
"rpm",
|
||||
t("modelInfoEditForm.fields.rpm.label"),
|
||||
t("modelInfoEditForm.fields.rpm.placeholder"),
|
||||
localModelData.litellm_params?.rpm,
|
||||
)}
|
||||
{numberField(
|
||||
"max_retries",
|
||||
t("modelInfoEditForm.fields.maxRetries.label"),
|
||||
t("modelInfoEditForm.fields.maxRetries.placeholder"),
|
||||
localModelData.litellm_params?.max_retries,
|
||||
)}
|
||||
{numberField(
|
||||
"timeout",
|
||||
t("modelInfoEditForm.fields.timeout.label"),
|
||||
t("modelInfoEditForm.fields.timeout.placeholder"),
|
||||
localModelData.litellm_params?.timeout,
|
||||
)}
|
||||
{numberField(
|
||||
"stream_timeout",
|
||||
"Stream Timeout (seconds)",
|
||||
"Enter stream timeout",
|
||||
t("modelInfoEditForm.fields.streamTimeout.label"),
|
||||
t("modelInfoEditForm.fields.streamTimeout.placeholder"),
|
||||
localModelData.litellm_params?.stream_timeout,
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Model Access Groups</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.modelAccessGroups.label")}</FieldLabel>
|
||||
{isEditing ? (
|
||||
tagsField(
|
||||
"model_access_group",
|
||||
(modelAccessGroups ?? []).map((group) => ({ value: group, label: group })),
|
||||
"Select existing groups or type to create new ones",
|
||||
t("modelInfoEditForm.fields.modelAccessGroups.placeholder"),
|
||||
)
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList values={localModelData.model_info?.access_groups} emptyLabel="No groups assigned" />
|
||||
<ChipList
|
||||
values={localModelData.model_info?.access_groups}
|
||||
emptyLabel={t("modelInfoEditForm.fields.modelAccessGroups.empty")}
|
||||
/>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>
|
||||
Guardrails
|
||||
{t("modelInfoEditForm.fields.guardrails.label")}
|
||||
<DocsHint
|
||||
text="Apply safety guardrails to this model to filter content or enforce policies"
|
||||
text={t("modelInfoEditForm.fields.guardrails.hint")}
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
|
@ -556,20 +616,23 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
tagsField(
|
||||
"guardrails",
|
||||
guardrailsList.map((name) => ({ value: name, label: name })),
|
||||
"Select existing guardrails or type to create new ones",
|
||||
t("modelInfoEditForm.fields.guardrails.placeholder"),
|
||||
)
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList values={localModelData.litellm_params?.guardrails} emptyLabel="No guardrails assigned" />
|
||||
<ChipList
|
||||
values={localModelData.litellm_params?.guardrails}
|
||||
emptyLabel={t("modelInfoEditForm.fields.guardrails.empty")}
|
||||
/>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>
|
||||
Attached Knowledge Bases (RAG)
|
||||
{t("modelInfoEditForm.fields.knowledgeBases.label")}
|
||||
<DocsHint
|
||||
text="Vector stores used for RAG. Every request to this model will automatically retrieve context from these knowledge bases."
|
||||
text={t("modelInfoEditForm.fields.knowledgeBases.hint")}
|
||||
href="https://docs.litellm.ai/docs/completion/knowledgebase"
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
|
@ -580,7 +643,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
value={value as string[] | undefined}
|
||||
onChange={onChange}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select knowledge bases (optional)"
|
||||
placeholder={t("modelInfoEditForm.fields.knowledgeBases.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -588,36 +651,39 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
<Display>
|
||||
<ChipList
|
||||
values={localModelData.litellm_params?.vector_store_ids}
|
||||
emptyLabel="No knowledge bases attached"
|
||||
emptyLabel={t("modelInfoEditForm.fields.knowledgeBases.empty")}
|
||||
/>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Tags</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.tags.label")}</FieldLabel>
|
||||
{isEditing ? (
|
||||
tagsField(
|
||||
"tags",
|
||||
Object.values(tagsList).map((tag: Tag) => ({ value: tag.name, label: tag.name })),
|
||||
"Select existing tags or type to create new ones",
|
||||
t("modelInfoEditForm.fields.tags.placeholder"),
|
||||
)
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList values={localModelData.litellm_params?.tags} emptyLabel="No tags assigned" />
|
||||
<ChipList
|
||||
values={localModelData.litellm_params?.tags}
|
||||
emptyLabel={t("modelInfoEditForm.fields.tags.empty")}
|
||||
/>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Existing Credentials</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.existingCredentials.label")}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="litellm_credential_name">
|
||||
{({ id, value, onChange }) => (
|
||||
<>
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
placeholder={t("modelInfoEditForm.fields.existingCredentials.placeholder")}
|
||||
options={credentialOptions(credentialsList)}
|
||||
value={(value as string | null) ?? ""}
|
||||
allowClear={false}
|
||||
|
|
@ -644,7 +710,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
|
||||
{isWildcardModel && (
|
||||
<div>
|
||||
<FieldLabel>Health Check Model</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.healthCheckModel.label")}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="health_check_model">
|
||||
{({ id, value, onChange, onBlur }) => (
|
||||
|
|
@ -654,10 +720,10 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full" onBlur={onBlur}>
|
||||
<SelectValue placeholder="Select existing health check model" />
|
||||
<SelectValue placeholder={t("modelInfoEditForm.fields.healthCheckModel.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>None</SelectItem>
|
||||
<SelectItem value={null}>{t("modelInfoEditForm.fields.healthCheckModel.none")}</SelectItem>
|
||||
{healthCheckModelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -668,7 +734,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{localModelData.model_info?.health_check_model || "Not Set"}</Display>
|
||||
<Display>{localModelData.model_info?.health_check_model || t("modelInfoEditForm.notSet")}</Display>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -711,29 +777,34 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
</>
|
||||
) : (
|
||||
<div>
|
||||
<FieldLabel>Cache Control</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.cacheControl.label")}</FieldLabel>
|
||||
<Display>
|
||||
{localModelData.litellm_params?.cache_control_injection_points ? (
|
||||
<div>
|
||||
<p>Enabled</p>
|
||||
<p>{t("modelInfoEditForm.fields.cacheControl.enabled")}</p>
|
||||
<div className="mt-2">
|
||||
{localModelData.litellm_params.cache_control_injection_points.map((point: any, i: number) => (
|
||||
<div key={i} className="mb-1 text-sm text-muted-foreground">
|
||||
Location: {point.location},{point.role && <span> Role: {point.role}</span>}
|
||||
{point.index !== undefined && <span> Index: {point.index}</span>}
|
||||
{t("modelInfoEditForm.fields.cacheControl.location", { location: point.location })}
|
||||
{point.role && (
|
||||
<span> {t("modelInfoEditForm.fields.cacheControl.role", { role: point.role })}</span>
|
||||
)}
|
||||
{point.index !== undefined && (
|
||||
<span> {t("modelInfoEditForm.fields.cacheControl.index", { index: point.index })}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Disabled"
|
||||
t("modelInfoEditForm.fields.cacheControl.disabled")
|
||||
)}
|
||||
</Display>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Model Info</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.modelInfo.label")}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="model_info">
|
||||
{({ value, ...control }) => (
|
||||
|
|
@ -756,9 +827,9 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
|
||||
<div>
|
||||
<FieldLabel>
|
||||
LiteLLM Params
|
||||
{t("modelInfoEditForm.fields.litellmParams.label")}
|
||||
<DocsHint
|
||||
text="Additional parameters for model requests. Settings with dedicated fields above are edited there. Remove a key here to delete its saved value."
|
||||
text={t("modelInfoEditForm.fields.litellmParams.hint")}
|
||||
href="https://docs.litellm.ai/docs/completion/input"
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
|
@ -767,7 +838,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
{({ value, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
aria-label="LiteLLM Params"
|
||||
aria-label={t("modelInfoEditForm.fields.litellmParams.label")}
|
||||
value={(value as string) ?? ""}
|
||||
rows={4}
|
||||
placeholder={'{\n "drop_params": true\n}'}
|
||||
|
|
@ -784,7 +855,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Team</FieldLabel>
|
||||
<FieldLabel>{t("modelInfoEditForm.fields.team.label")}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="team_id">
|
||||
{({ id, value, onChange, onBlur }) => (
|
||||
|
|
@ -795,7 +866,7 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
<Display>
|
||||
{teamAlias
|
||||
? `${teamAlias} (${localModelData.model_info?.team_id})`
|
||||
: localModelData.model_info?.team_id || "Not Set"}
|
||||
: localModelData.model_info?.team_id || t("modelInfoEditForm.notSet")}
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -804,11 +875,11 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
{isEditing && (
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="submit" variant="secondary" onClick={cancel} disabled={isSaving}>
|
||||
Cancel
|
||||
{t("modelInfoEditForm.actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving} aria-busy={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
{t("modelInfoEditForm.actions.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Waypoints } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
|
|
@ -16,13 +17,18 @@ interface PassThroughEndpointsTableProps {
|
|||
}
|
||||
|
||||
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">
|
||||
<Waypoints className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No pass-through endpoints configured</div>
|
||||
<div className="text-sm text-muted-foreground">Add a pass-through endpoint to route custom paths.</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("passThroughSettings.passThroughEndpointsTable.emptyTitle")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("passThroughSettings.passThroughEndpointsTable.emptyDescription")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,9 +39,10 @@ export function PassThroughEndpointsTable({
|
|||
onEndpointClick,
|
||||
onDeleteClick,
|
||||
}: PassThroughEndpointsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(
|
||||
() => getPassThroughEndpointsTableColumns({ onEndpointClick, onDeleteClick }),
|
||||
[onEndpointClick, onDeleteClick],
|
||||
() => getPassThroughEndpointsTableColumns({ onEndpointClick, onDeleteClick }, t),
|
||||
[onEndpointClick, onDeleteClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -45,7 +52,7 @@ export function PassThroughEndpointsTable({
|
|||
columns={columns}
|
||||
getRowId={(endpoint, index) => endpoint.id || endpoint.path || String(index)}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading pass-through endpoints…"
|
||||
loadingMessage={t("passThroughSettings.passThroughEndpointsTable.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { Eye, EyeOff, Info, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CellTooltip, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -14,12 +16,12 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import type { passThroughItem } from "./PassThroughSettings";
|
||||
|
||||
const CONFIG_ENDPOINT_HINT =
|
||||
"This endpoint is defined in the config file and cannot be edited or deleted on the dashboard.";
|
||||
const CONFIG_ENDPOINT_HINT: ParseKeys = "passThroughSettings.passThroughEndpointsTableColumns.configEndpointHint";
|
||||
|
||||
function HeaderWithTooltip({ title, tooltip }: { title: string; tooltip: string }) {
|
||||
return (
|
||||
|
|
@ -31,6 +33,7 @@ function HeaderWithTooltip({ title, tooltip }: { title: string; tooltip: string
|
|||
}
|
||||
|
||||
function HeadersCell({ value }: { value: object }) {
|
||||
const { t } = useTranslation();
|
||||
const [showHeaders, setShowHeaders] = useState(false);
|
||||
const headerString = JSON.stringify(value);
|
||||
|
||||
|
|
@ -40,7 +43,11 @@ function HeadersCell({ value }: { value: object }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHeaders(!showHeaders)}
|
||||
aria-label={showHeaders ? "Hide headers" : "Show headers"}
|
||||
aria-label={
|
||||
showHeaders
|
||||
? t("passThroughSettings.passThroughEndpointsTableColumns.hideHeaders")
|
||||
: t("passThroughSettings.passThroughEndpointsTableColumns.showHeaders")
|
||||
}
|
||||
className="rounded-sm p-1 hover:bg-muted"
|
||||
>
|
||||
{showHeaders ? (
|
||||
|
|
@ -54,8 +61,9 @@ function HeadersCell({ value }: { value: object }) {
|
|||
}
|
||||
|
||||
function MethodsCell({ methods }: { methods: string[] | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
if (!methods || methods.length === 0) {
|
||||
return <Badge variant="secondary">ALL</Badge>;
|
||||
return <Badge variant="secondary">{t("passThroughSettings.passThroughEndpointsTableColumns.allMethods")}</Badge>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
|
@ -75,12 +83,13 @@ interface EndpointRowActionsProps {
|
|||
}
|
||||
|
||||
function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: EndpointRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const endpointId = endpoint.id;
|
||||
const isFromConfig = endpoint.is_from_config ?? false;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open endpoint actions"
|
||||
aria-label={t("passThroughSettings.passThroughEndpointsTableColumns.openActions")}
|
||||
data-testid={`endpoint-actions-${endpointId || endpoint.path}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -93,7 +102,7 @@ function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: Endpoi
|
|||
onClick={() => !isFromConfig && endpointId && onEndpointClick(endpointId)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("passThroughSettings.passThroughEndpointsTableColumns.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
|
@ -103,11 +112,11 @@ function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: Endpoi
|
|||
onClick={() => !isFromConfig && endpointId && onDeleteClick(endpointId)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("passThroughSettings.passThroughEndpointsTableColumns.delete")}
|
||||
</DropdownMenuItem>
|
||||
{isFromConfig && (
|
||||
<div data-testid="endpoint-config-hint" className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{CONFIG_ENDPOINT_HINT}
|
||||
{t(CONFIG_ENDPOINT_HINT)}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -120,15 +129,15 @@ interface PassThroughEndpointsTableColumnsDeps {
|
|||
onDeleteClick: (endpointId: string) => void;
|
||||
}
|
||||
|
||||
export const getPassThroughEndpointsTableColumns = ({
|
||||
onEndpointClick,
|
||||
onDeleteClick,
|
||||
}: PassThroughEndpointsTableColumnsDeps): ColumnDef<passThroughItem>[] => [
|
||||
export const getPassThroughEndpointsTableColumns = (
|
||||
{ onEndpointClick, onDeleteClick }: PassThroughEndpointsTableColumnsDeps,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): ColumnDef<passThroughItem>[] => [
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
meta: { title: "ID" },
|
||||
header: "ID",
|
||||
meta: { title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.id") },
|
||||
header: t("passThroughSettings.passThroughEndpointsTableColumns.columns.id"),
|
||||
size: 190,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -147,20 +156,29 @@ export const getPassThroughEndpointsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "source",
|
||||
meta: { title: "Source", skeleton: "badge" },
|
||||
header: "Source",
|
||||
meta: { title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.source"), skeleton: "badge" },
|
||||
header: t("passThroughSettings.passThroughEndpointsTableColumns.columns.source"),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isFromConfig = row.original.is_from_config ?? false;
|
||||
return <StatusBadge tone={isFromConfig ? "neutral" : "info"} label={isFromConfig ? "Config" : "DB"} />;
|
||||
return (
|
||||
<StatusBadge
|
||||
tone={isFromConfig ? "neutral" : "info"}
|
||||
label={
|
||||
isFromConfig
|
||||
? t("passThroughSettings.passThroughEndpointsTableColumns.source.config")
|
||||
: t("passThroughSettings.passThroughEndpointsTableColumns.source.db")
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "path",
|
||||
accessorKey: "path",
|
||||
meta: { title: "Path" },
|
||||
header: "Path",
|
||||
meta: { title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.path") },
|
||||
header: t("passThroughSettings.passThroughEndpointsTableColumns.columns.path"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -172,8 +190,8 @@ export const getPassThroughEndpointsTableColumns = ({
|
|||
{
|
||||
id: "target",
|
||||
accessorKey: "target",
|
||||
meta: { title: "Target" },
|
||||
header: "Target",
|
||||
meta: { title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.target") },
|
||||
header: t("passThroughSettings.passThroughEndpointsTableColumns.columns.target"),
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -184,8 +202,13 @@ export const getPassThroughEndpointsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "methods",
|
||||
meta: { title: "Methods", skeleton: "chips" },
|
||||
header: () => <HeaderWithTooltip title="Methods" tooltip="HTTP methods supported by this endpoint" />,
|
||||
meta: { title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.methods"), skeleton: "chips" },
|
||||
header: () => (
|
||||
<HeaderWithTooltip
|
||||
title={t("passThroughSettings.passThroughEndpointsTableColumns.columns.methods")}
|
||||
tooltip={t("passThroughSettings.passThroughEndpointsTableColumns.methodsTooltip")}
|
||||
/>
|
||||
),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MethodsCell methods={row.original.methods} />,
|
||||
|
|
@ -193,18 +216,33 @@ export const getPassThroughEndpointsTableColumns = ({
|
|||
{
|
||||
id: "auth",
|
||||
accessorKey: "auth",
|
||||
meta: { title: "Authentication", skeleton: "badge" },
|
||||
header: () => <HeaderWithTooltip title="Authentication" tooltip="LiteLLM Virtual Key required to call endpoint" />,
|
||||
meta: {
|
||||
title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.authentication"),
|
||||
skeleton: "badge",
|
||||
},
|
||||
header: () => (
|
||||
<HeaderWithTooltip
|
||||
title={t("passThroughSettings.passThroughEndpointsTableColumns.columns.authentication")}
|
||||
tooltip={t("passThroughSettings.passThroughEndpointsTableColumns.authenticationTooltip")}
|
||||
/>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge tone={row.original.auth ? "success" : "neutral"} label={row.original.auth ? "Yes" : "No"} />
|
||||
<StatusBadge
|
||||
tone={row.original.auth ? "success" : "neutral"}
|
||||
label={
|
||||
row.original.auth
|
||||
? t("passThroughSettings.passThroughEndpointsTableColumns.auth.yes")
|
||||
: t("passThroughSettings.passThroughEndpointsTableColumns.auth.no")
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "headers",
|
||||
meta: { title: "Headers" },
|
||||
header: "Headers",
|
||||
meta: { title: t("passThroughSettings.passThroughEndpointsTableColumns.columns.headers") },
|
||||
header: t("passThroughSettings.passThroughEndpointsTableColumns.columns.headers"),
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <HeadersCell value={row.original.headers || {}} />,
|
||||
|
|
@ -212,7 +250,9 @@ export const getPassThroughEndpointsTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => (
|
||||
<span className="sr-only">{t("passThroughSettings.passThroughEndpointsTableColumns.columns.actions")}</span>
|
||||
),
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
|
|
@ -37,6 +38,7 @@ export interface passThroughItem {
|
|||
}
|
||||
|
||||
const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken, userRole, userID }) => {
|
||||
const { t } = useTranslation();
|
||||
const [generalSettings, setGeneralSettings] = useState<passThroughItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedEndpointId, setSelectedEndpointId] = useState<string | null>(null);
|
||||
|
|
@ -83,10 +85,10 @@ const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken,
|
|||
const updatedSettings = generalSettings.filter((setting) => setting.id !== endpointToDelete);
|
||||
setGeneralSettings(updatedSettings);
|
||||
|
||||
toast.success("Endpoint deleted successfully.");
|
||||
toast.success(t("passThroughSettings.passThroughSettings.toasts.deleted"));
|
||||
} catch (error) {
|
||||
console.error("Error deleting the endpoint:", error);
|
||||
toast.fromError("Error deleting the endpoint: " + error);
|
||||
toast.fromError(t("passThroughSettings.passThroughSettings.toasts.deleteFailed", { error: String(error) }));
|
||||
}
|
||||
|
||||
setIsDeleteModalOpen(false);
|
||||
|
|
@ -106,7 +108,7 @@ const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken,
|
|||
const selectedEndpoint = generalSettings.find((endpoint) => endpoint.id === selectedEndpointId);
|
||||
|
||||
if (!selectedEndpoint) {
|
||||
return <div>Endpoint not found</div>;
|
||||
return <div>{t("passThroughSettings.passThroughSettings.endpointNotFound")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -123,8 +125,8 @@ const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken,
|
|||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Pass Through Endpoints</h2>
|
||||
<p className="text-sm text-muted-foreground">Configure and manage your pass-through endpoints</p>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("passThroughSettings.passThroughSettings.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("passThroughSettings.passThroughSettings.description")}</p>
|
||||
</div>
|
||||
|
||||
<AddPassThroughEndpoint
|
||||
|
|
@ -143,15 +145,15 @@ const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken,
|
|||
<AlertDialog open={isDeleteModalOpen} onOpenChange={(open) => !open && cancelDelete()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Pass-Through Endpoint</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("passThroughSettings.passThroughSettings.deleteDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this pass-through endpoint? This action cannot be undone.
|
||||
{t("passThroughSettings.passThroughSettings.deleteDialog.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("passThroughSettings.passThroughSettings.deleteDialog.cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
{t("passThroughSettings.passThroughSettings.deleteDialog.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
|
|
@ -31,6 +32,7 @@ const AccessGroupTagsCombobox: React.FC<AccessGroupTagsComboboxProps> = ({
|
|||
ariaInvalid,
|
||||
ariaDescribedBy,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const anchor = useComboboxAnchor();
|
||||
const [query, setQuery] = useState("");
|
||||
const selected = value ?? [];
|
||||
|
|
@ -79,14 +81,14 @@ const AccessGroupTagsCombobox: React.FC<AccessGroupTagsComboboxProps> = ({
|
|||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
placeholder={t("addModel.accessGroupTagsCombobox.placeholder")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No access groups found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("addModel.accessGroupTagsCombobox.empty")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(group: string) => (
|
||||
<ComboboxItem key={group} value={group}>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AdaptiveEligible,
|
||||
ComplexityRouterConfigValue,
|
||||
|
|
@ -18,6 +19,7 @@ interface AdaptiveRoutingConfigProps {
|
|||
}
|
||||
|
||||
const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const adaptiveWeights = value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS;
|
||||
const adaptiveEligible = value.adaptive_eligible ?? "all";
|
||||
const tierDistancePenalty = value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY;
|
||||
|
|
@ -50,21 +52,15 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
|
|||
<>
|
||||
<Label className="mb-2">
|
||||
<Switch checked={value.adaptive ?? false} onCheckedChange={handleAdaptiveToggle} />
|
||||
<strong className="font-semibold">Enable adaptive bandit selection</strong>
|
||||
<strong className="font-semibold">{t("addModel.adaptiveRoutingConfig.enable")}</strong>
|
||||
</Label>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
When disabled, each request always uses the model assigned to its classified tier.
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("addModel.adaptiveRoutingConfig.enableHelp")}</span>
|
||||
|
||||
<Card className="bg-muted mt-4">
|
||||
<CardContent>
|
||||
<strong className="mb-2 block font-semibold">How Adaptive Routing Works</strong>
|
||||
<strong className="mb-2 block font-semibold">{t("addModel.adaptiveRoutingConfig.howItWorks")}</strong>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
It learns from how each conversation actually goes: does the user have to rephrase or correct the model,
|
||||
does it get stuck repeating itself, does it run out of tool calls, does the user seem satisfied. Combined
|
||||
with cost, this live feedback shifts future routing toward the models that are actually working well, and
|
||||
improves as more conversations come in. Until there's enough feedback, it defaults to the classified
|
||||
tier's model.
|
||||
{t("addModel.adaptiveRoutingConfig.howItWorksDescription")}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -73,24 +69,25 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
|
|||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<strong className="mb-1 block font-semibold">
|
||||
Quality vs. Cost ({Math.round(adaptiveWeights.quality * 100)}% quality /{" "}
|
||||
{Math.round(adaptiveWeights.cost * 100)}% cost)
|
||||
{t("addModel.adaptiveRoutingConfig.qualityVsCostSplit", {
|
||||
quality: Math.round(adaptiveWeights.quality * 100),
|
||||
cost: Math.round(adaptiveWeights.cost * 100),
|
||||
})}
|
||||
</strong>
|
||||
<Slider
|
||||
aria-label="Quality vs. Cost"
|
||||
aria-label={t("addModel.adaptiveRoutingConfig.qualityVsCost")}
|
||||
min={0}
|
||||
max={100}
|
||||
value={[Math.round(adaptiveWeights.quality * 100)]}
|
||||
onValueChange={(next) => handleQualityWeightChange(Array.isArray(next) ? next[0] : next)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Higher quality weight favors more capable (pricier) models; higher cost weight favors cheaper models when
|
||||
the bandit has feedback to act on. Recommended: 30% quality / 70% cost split.
|
||||
{t("addModel.adaptiveRoutingConfig.qualityVsCostHelp")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="mb-1 block font-semibold">Eligible Model Pool</strong>
|
||||
<strong className="mb-1 block font-semibold">{t("addModel.adaptiveRoutingConfig.eligiblePool")}</strong>
|
||||
<RadioGroup
|
||||
value={adaptiveEligible}
|
||||
onValueChange={(eligible: unknown) => handleAdaptiveEligibleChange(eligible as AdaptiveEligible)}
|
||||
|
|
@ -100,17 +97,17 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
|
|||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="all" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">All tiers (soft floor)</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
— router can pick across tiers, depending on the best fit for the prompt
|
||||
</span>
|
||||
<strong className="font-semibold">{t("addModel.adaptiveRoutingConfig.allTiers")}</strong>{" "}
|
||||
<span className="text-muted-foreground">{t("addModel.adaptiveRoutingConfig.allTiersHelp")}</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="classified_tier" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Classified tier only</strong>{" "}
|
||||
<span className="text-muted-foreground">— router can only pick models within tier</span>
|
||||
<strong className="font-semibold">{t("addModel.adaptiveRoutingConfig.classifiedTierOnly")}</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{t("addModel.adaptiveRoutingConfig.classifiedTierOnlyHelp")}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -119,7 +116,9 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
|
|||
|
||||
{adaptiveEligible === "all" && (
|
||||
<div>
|
||||
<strong className="mb-1 block font-semibold">Tier Distance Penalty</strong>
|
||||
<strong className="mb-1 block font-semibold">
|
||||
{t("addModel.adaptiveRoutingConfig.tierDistancePenalty")}
|
||||
</strong>
|
||||
<Input
|
||||
type="number"
|
||||
value={tierDistancePenalty}
|
||||
|
|
@ -131,7 +130,7 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
|
|||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Score penalty applied per tier-step away from the classified tier.
|
||||
{t("addModel.adaptiveRoutingConfig.tierDistancePenaltyHelp")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import { requiredRule } from "../common_components/formRules";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
|
|
@ -87,6 +88,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
credentials,
|
||||
decisionCatalog,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [testMode, setTestMode] = useState<string>("chat");
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState<boolean>(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
|
||||
|
|
@ -122,7 +124,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
toast.error(extractProxyErrorMessage(error));
|
||||
return;
|
||||
}
|
||||
toast.success("Credential added successfully");
|
||||
toast.success(t("addModel.addModelForm.credentialAdded"));
|
||||
setIsCredentialModalOpen(false);
|
||||
setIsFederatedCredentialModalOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: credentialsKeys.all });
|
||||
|
|
@ -137,7 +139,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
const rejectMissingCredential = (): boolean => {
|
||||
const missing = selectedAuthType?.credentialOnly === true && !selectedCredentialName;
|
||||
if (missing) {
|
||||
toast.error("Create or select a credential for this auth type");
|
||||
toast.error(t("addModel.addModelForm.credentialRequiredForAuthType"));
|
||||
}
|
||||
return missing;
|
||||
};
|
||||
|
|
@ -198,7 +200,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
const providerMetadataErrorText = providerMetadataError
|
||||
? providerMetadataError instanceof Error
|
||||
? providerMetadataError.message
|
||||
: "Failed to load providers"
|
||||
: t("addModel.addModelForm.failedToLoadProviders")
|
||||
: null;
|
||||
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
|
@ -213,7 +215,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-4 text-2xl font-semibold text-foreground">Add Model</h2>
|
||||
<h2 className="mb-4 text-2xl font-semibold text-foreground">{t("addModel.addModelForm.title")}</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
|
@ -233,10 +235,13 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{requiresTeamScope && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint("Select Team", "Select the team for which you want to add this model")}
|
||||
label={labelWithHint(
|
||||
t("addModel.addModelForm.selectTeam"),
|
||||
t("addModel.addModelForm.teamScopeHint"),
|
||||
)}
|
||||
name="team_id"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please select a team to continue") } }}
|
||||
rules={{ validate: { required: requiredRule(t("addModel.addModelForm.teamScopeRequired")) } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
|
|
@ -252,9 +257,9 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{!teamAdminSelectedTeam && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Team Selection Required</AlertTitle>
|
||||
<AlertTitle>{t("addModel.addModelForm.teamSelectionRequiredTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
As a team admin, you need to select your team first before adding models.
|
||||
{t("addModel.addModelForm.teamSelectionRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -263,18 +268,25 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint("Provider", "E.g. OpenAI, Azure OpenAI, Anthropic, Bedrock, etc.")}
|
||||
label={labelWithHint(
|
||||
t("addModel.addModelForm.provider"),
|
||||
t("addModel.addModelForm.providerHint"),
|
||||
)}
|
||||
name="custom_llm_provider"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Required") } }}
|
||||
rules={{ validate: { required: requiredRule(t("addModel.addModelForm.required")) } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
options={providerOptions}
|
||||
emptyText={providerMetadataErrorText ?? "No providers found"}
|
||||
placeholder={isProviderMetadataLoading ? "Loading providers..." : "Select a provider"}
|
||||
emptyText={providerMetadataErrorText ?? t("addModel.addModelForm.noProvidersFound")}
|
||||
placeholder={
|
||||
isProviderMetadataLoading
|
||||
? t("addModel.addModelForm.loadingProviders")
|
||||
: t("addModel.addModelForm.selectProvider")
|
||||
}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
|
|
@ -294,7 +306,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
<ConditionalPublicModelName />
|
||||
|
||||
{/* Select Mode */}
|
||||
<MountedFormField label="Mode" name="mode" className="mb-1">
|
||||
<MountedFormField label={t("addModel.addModelForm.mode")} name="mode" className="mb-1">
|
||||
{(control) => (
|
||||
<Select
|
||||
items={TEST_MODES}
|
||||
|
|
@ -304,7 +316,11 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
setTestMode(value ?? "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={control.id} className="w-full" aria-label="Mode">
|
||||
<SelectTrigger
|
||||
id={control.id}
|
||||
className="w-full"
|
||||
aria-label={t("addModel.addModelForm.mode")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -321,15 +337,20 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
<div className="col-span-5" />
|
||||
<div className="col-span-5">
|
||||
<p className="text-sm mb-5 mt-1">
|
||||
<strong>Optional</strong> - LiteLLM endpoint to use when health checking this model{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/health#health"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="addModel.addModelForm.healthCheckModeHint"
|
||||
components={{
|
||||
strong: <strong />,
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/health#health"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -337,12 +358,12 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{/* Credentials */}
|
||||
<div className="mb-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Either select existing credentials OR enter new provider credentials below
|
||||
{t("addModel.addModelForm.credentialsChoiceHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<MountedFormField
|
||||
label="Existing Credentials"
|
||||
label={t("addModel.addModelForm.existingCredentials")}
|
||||
name="litellm_credential_name"
|
||||
defaultValue={null}
|
||||
className="mb-4"
|
||||
|
|
@ -350,7 +371,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{(control) => (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
placeholder={t("addModel.addModelForm.existingCredentialsPlaceholder")}
|
||||
options={credentialSelectOptions}
|
||||
value={(control.value as string | null | undefined) ?? ""}
|
||||
onValueChange={(value) => control.onChange(value === "" ? null : value)}
|
||||
|
|
@ -363,7 +384,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
<>
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">OR</span>
|
||||
<span className="px-4 text-muted-foreground text-sm">{t("addModel.addModelForm.or")}</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
<ProviderSpecificFields
|
||||
|
|
@ -375,14 +396,14 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{canCreateFederatedCredential && (
|
||||
<div className="mb-4 flex flex-col items-start gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Workload identity federation is saved as a credential, then attached to this model.
|
||||
{t("addModel.addModelForm.federationHint")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsFederatedCredentialModalOpen(true)}
|
||||
>
|
||||
Use workload identity federation
|
||||
{t("addModel.addModelForm.useFederation")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -390,7 +411,9 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
)}
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">Additional Model Info Settings</span>
|
||||
<span className="px-4 text-muted-foreground text-sm">
|
||||
{t("addModel.addModelForm.additionalModelInfoSettings")}
|
||||
</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
{/* Team-only Model Switch - Only show for proxy admins, not team admins */}
|
||||
|
|
@ -398,16 +421,12 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
<Field className="mb-4">
|
||||
<FieldLabel>
|
||||
{labelWithHint(
|
||||
"Team-BYOK Model",
|
||||
"Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.",
|
||||
t("addModel.addModelForm.teamByokModel"),
|
||||
t("addModel.addModelForm.teamByokModelHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
!premiumUser
|
||||
? "This is an enterprise-only feature. Upgrade to premium to restrict model+credential combinations to a specific team."
|
||||
: ""
|
||||
}
|
||||
content={!premiumUser ? t("addModel.addModelForm.teamByokEnterpriseOnly") : ""}
|
||||
side="top"
|
||||
>
|
||||
<span className="inline-flex">
|
||||
|
|
@ -420,7 +439,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
}
|
||||
}}
|
||||
disabled={!premiumUser}
|
||||
aria-label="Team-BYOK Model"
|
||||
aria-label={t("addModel.addModelForm.teamByokModel")}
|
||||
/>
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
|
|
@ -431,15 +450,15 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
{isTeamOnly && !requiresTeamScope && (
|
||||
<MountedFormField
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Only keys for this team will be able to call this model.",
|
||||
t("addModel.addModelForm.selectTeam"),
|
||||
t("addModel.addModelForm.teamOnlyHint"),
|
||||
)}
|
||||
name="team_id"
|
||||
className="mb-4"
|
||||
required={isTeamOnly && !isAdmin}
|
||||
rules={
|
||||
isTeamOnly && !isAdmin
|
||||
? { validate: { required: requiredRule("Please select a team.") } }
|
||||
? { validate: { required: requiredRule(t("addModel.addModelForm.teamOnlyRequired")) } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
@ -456,8 +475,8 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to give users access to select models, and add new ones to the group over time.",
|
||||
t("addModel.addModelForm.modelAccessGroup"),
|
||||
t("addModel.addModelForm.modelAccessGroupHint"),
|
||||
)}
|
||||
name="model_access_group"
|
||||
className="mb-4"
|
||||
|
|
@ -486,12 +505,12 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
</>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<SimpleTooltip content="Get help on our github">
|
||||
<SimpleTooltip content={t("addModel.addModelForm.helpTooltip")}>
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
{t("addModel.addModelForm.needHelp")}
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
<div className="space-x-2">
|
||||
|
|
@ -502,10 +521,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
disabled={isTestingConnection}
|
||||
aria-busy={isTestingConnection}
|
||||
>
|
||||
Test Connect
|
||||
{t("addModel.addModelForm.testConnect")}
|
||||
</Button>
|
||||
<Button data-testid="add-model-btn" type="submit">
|
||||
Add Model
|
||||
{t("addModel.addModelForm.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -550,7 +569,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connection Test Results</DialogTitle>
|
||||
<DialogTitle>{t("addModel.addModelForm.connectionTestResults")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* Only render the ConnectionErrorDisplay when modal is visible and we have a test ID */}
|
||||
{isResultModalVisible && (
|
||||
|
|
@ -576,7 +595,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
setIsTestingConnection(false);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t("addModel.addModelForm.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -10,6 +11,7 @@ export const AffinityControls: React.FC<{
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [ttlDraft, setTtlDraft] = React.useState<string | null>(null);
|
||||
const commitTtl = (raw: string) => {
|
||||
setTtlDraft(null);
|
||||
|
|
@ -28,17 +30,16 @@ export const AffinityControls: React.FC<{
|
|||
<Switch
|
||||
checked={value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY}
|
||||
onCheckedChange={(deploymentAffinity) => onChange({ ...value, deployment_affinity: deploymentAffinity })}
|
||||
aria-label="Pin one model deployment per tier"
|
||||
aria-label={t("addModel.affinityControls.pinDeployment")}
|
||||
/>
|
||||
<strong className="font-semibold">Pin one model deployment per tier</strong>
|
||||
<strong className="font-semibold">{t("addModel.affinityControls.pinDeployment")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
Reuses the model chosen for each tier and its deployment when available. Requests can still move between tiers.
|
||||
Turn off to select models and load-balance deployments every turn.
|
||||
{t("addModel.affinityControls.pinDeploymentDescription")}
|
||||
</span>
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="session-affinity-ttl">
|
||||
How long a pin survives idle (seconds)
|
||||
{t("addModel.affinityControls.pinTtl")}
|
||||
</label>
|
||||
<Input
|
||||
id="session-affinity-ttl"
|
||||
|
|
@ -49,8 +50,7 @@ export const AffinityControls: React.FC<{
|
|||
onBlur={(event) => commitTtl(event.target.value)}
|
||||
/>
|
||||
<span className="block text-xs mt-1 text-muted-foreground">
|
||||
Refreshes after every request that reuses a pin. Empty tracks the backend default of{" "}
|
||||
{DEFAULT_SESSION_AFFINITY_TTL_SECONDS} seconds.
|
||||
{t("addModel.affinityControls.pinTtlHelp", { seconds: DEFAULT_SESSION_AFFINITY_TTL_SECONDS })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
|
@ -19,6 +22,7 @@ type AvailabilityState = {
|
|||
export const AutoRouterAvailabilityContext = createContext<AvailabilityState>({ isPending: true, isError: false });
|
||||
|
||||
export const useAutoRouterAvailability = (accessToken: string, body: Request, enabled = true) => {
|
||||
const { t } = useTranslation();
|
||||
const serialized = JSON.stringify(body.complexity_router_config ?? null);
|
||||
const [debounced, setDebounced] = useState(serialized);
|
||||
useEffect(() => {
|
||||
|
|
@ -48,8 +52,8 @@ export const useAutoRouterAvailability = (accessToken: string, body: Request, en
|
|||
const isChecking = query.isFetching || query.isPlaceholderData || serialized !== debounced;
|
||||
const saveBlockedReason = () => {
|
||||
if (!enabled) return null;
|
||||
if (query.isPending || isChecking) return "Checking availability";
|
||||
if (query.isError || !query.data) return "Could not check availability. Retry before saving.";
|
||||
if (query.isPending || isChecking) return t("addModel.autoRouterAvailability.checking");
|
||||
if (query.isError || !query.data) return t("addModel.autoRouterAvailability.saveCheckFailed");
|
||||
return query.data.error ?? null;
|
||||
};
|
||||
return {
|
||||
|
|
@ -60,20 +64,27 @@ export const useAutoRouterAvailability = (accessToken: string, body: Request, en
|
|||
};
|
||||
};
|
||||
|
||||
export const allowanceLabel = (allowance?: Allowance): string | null => {
|
||||
if (!allowance?.available) return "Availability unavailable";
|
||||
export const allowanceLabel = (allowance?: Allowance, t: TFunction = i18n.t.bind(i18n)): string | null => {
|
||||
if (!allowance?.available) return t("addModel.autoRouterAvailability.unavailable");
|
||||
if (allowance.limit == null) return null;
|
||||
if (allowance.used_by_this_router) return "Used by this router";
|
||||
return `${allowance.remaining} of ${allowance.limit} available`;
|
||||
if (allowance.used_by_this_router) return t("addModel.autoRouterAvailability.usedByThisRouter");
|
||||
return t("addModel.autoRouterAvailability.remainingOfLimit", {
|
||||
remaining: allowance.remaining,
|
||||
limit: allowance.limit,
|
||||
});
|
||||
};
|
||||
|
||||
const availabilityLabel = (state: AvailabilityState, key: string) => {
|
||||
if (state.isPending || state.isChecking) return "Checking availability";
|
||||
if (state.isError) return "Availability unavailable";
|
||||
return allowanceLabel(state.data?.allowances.find((entry) => entry.key === key));
|
||||
const availabilityLabel = (state: AvailabilityState, key: string, t: TFunction = i18n.t.bind(i18n)) => {
|
||||
if (state.isPending || state.isChecking) return t("addModel.autoRouterAvailability.checking");
|
||||
if (state.isError) return t("addModel.autoRouterAvailability.unavailable");
|
||||
return allowanceLabel(
|
||||
state.data?.allowances.find((entry) => entry.key === key),
|
||||
t,
|
||||
);
|
||||
};
|
||||
|
||||
export const useAllowanceLabel = (key: string) => availabilityLabel(useContext(AutoRouterAvailabilityContext), key);
|
||||
export const useAllowanceLabel = (key: string) =>
|
||||
availabilityLabel(useContext(AutoRouterAvailabilityContext), key, useTranslation().t);
|
||||
|
||||
export const isAllowanceExhausted = (allowance?: Allowance) =>
|
||||
Boolean(allowance?.available && allowance.limit != null && allowance.remaining === 0) &&
|
||||
|
|
@ -82,6 +93,7 @@ export const isAllowanceExhausted = (allowance?: Allowance) =>
|
|||
export const AUTO_ROUTER_CONTACT_URL = "https://calendly.com/tin-berri/litellm-auto-router-pricing-discussion";
|
||||
|
||||
export const AutoRouterContactLink = ({ features, message }: { features?: string[]; message?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const state = useContext(AutoRouterAvailabilityContext);
|
||||
if (state.isPending || state.isError || state.isChecking) return null;
|
||||
const exhausted = state.data?.allowances.some(
|
||||
|
|
@ -97,7 +109,7 @@ export const AutoRouterContactLink = ({ features, message }: { features?: string
|
|||
rel="noopener noreferrer"
|
||||
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
Talk to our team
|
||||
{t("addModel.autoRouterAvailability.talkToTeam")}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
|
|
@ -120,45 +132,40 @@ export const AutoRouterAllowanceNote = ({ feature, label }: { feature: string; l
|
|||
};
|
||||
|
||||
export const AutoRouterLimits = () => {
|
||||
const { t } = useTranslation();
|
||||
const state = useContext(AutoRouterAvailabilityContext);
|
||||
const limits = [
|
||||
["heuristic_v2", "Heuristic v2 routers"],
|
||||
["capability", "Capability routers"],
|
||||
["llm_v2", "Fuse v2 routers"],
|
||||
["tier_or_classifier_prompt", "Custom tiers or prompts"],
|
||||
["heuristic_tuning", "Rule-based tuning"],
|
||||
["heuristic_v2", t("addModel.autoRouterAvailability.limits.heuristicV2")],
|
||||
["capability", t("addModel.autoRouterAvailability.limits.capability")],
|
||||
["llm_v2", t("addModel.autoRouterAvailability.limits.fuseV2")],
|
||||
["tier_or_classifier_prompt", t("addModel.autoRouterAvailability.limits.customTiersOrPrompts")],
|
||||
["heuristic_tuning", t("addModel.autoRouterAvailability.limits.ruleBasedTuning")],
|
||||
];
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className="shrink-0 whitespace-nowrap text-xs font-normal text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
||||
View limits
|
||||
{t("addModel.autoRouterAvailability.limits.view")}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)] gap-3">
|
||||
<PopoverTitle>Routing and customization limits</PopoverTitle>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Rule-based, Complexity, and OSS are unlimited with built-in settings. Choose or change tier models freely.
|
||||
Customization allowances are shared across this proxy.
|
||||
</p>
|
||||
<PopoverTitle>{t("addModel.autoRouterAvailability.limits.title")}</PopoverTitle>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t("addModel.autoRouterAvailability.limits.intro")}</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
{limits.map(([key, label]) => (
|
||||
<div key={key} className="flex items-center justify-between gap-3">
|
||||
<dt>{label}</dt>
|
||||
<dd className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{availabilityLabel(state, key) ?? "Unlimited"}
|
||||
{availabilityLabel(state, key, t) ?? t("addModel.autoRouterAvailability.limits.unlimited")}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Custom tier definitions and written classifier instructions share one allowance. Built-in prompts and
|
||||
display-name changes do not use it.
|
||||
{t("addModel.autoRouterAvailability.limits.sharedAllowance")}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Changing scoring rules, such as weights, thresholds, keywords, or custom dimensions, uses the Rule-based
|
||||
tuning allowance. It also applies to Heuristic first and Hybrid. Recorded settings on existing routers are
|
||||
preserved; new routers start from built-in rules.
|
||||
{t("addModel.autoRouterAvailability.limits.tuningAllowance")}
|
||||
</p>
|
||||
<AutoRouterContactLink message="Need a higher limit?" />
|
||||
<AutoRouterContactLink message={t("addModel.autoRouterAvailability.limits.needHigherLimit")} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useContext, useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -49,6 +50,7 @@ export function ClassifierOption({
|
|||
disabled?: boolean;
|
||||
unlimited?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const state = useContext(AutoRouterAvailabilityContext);
|
||||
const allowance = state.data?.allowances.find((entry) => entry.key === feature);
|
||||
const fresh = !state.isPending && !state.isError && !state.isChecking;
|
||||
|
|
@ -62,7 +64,11 @@ export function ClassifierOption({
|
|||
{feature ? (
|
||||
<AutoRouterAllowanceLabel feature={feature} />
|
||||
) : (
|
||||
unlimited && <span className="shrink-0 text-xs leading-5 text-muted-foreground">Unlimited</span>
|
||||
unlimited && (
|
||||
<span className="shrink-0 text-xs leading-5 text-muted-foreground">
|
||||
{t("addModel.autoRouterClassifierTabs.option.unlimited")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs leading-5 text-muted-foreground ${exhausted ? "pr-28" : ""}`}>{description}</span>
|
||||
|
|
@ -71,10 +77,10 @@ export function ClassifierOption({
|
|||
{fresh && exhausted && (
|
||||
<DropdownMenuItem
|
||||
render={<a href={AUTO_ROUTER_CONTACT_URL} target="_blank" rel="noopener noreferrer" />}
|
||||
aria-label={`Talk to our team about ${label}`}
|
||||
aria-label={t("addModel.autoRouterClassifierTabs.option.talkToTeamAriaLabel", { label })}
|
||||
className="absolute top-9 right-8 cursor-pointer px-0 py-0 text-xs leading-5 font-medium text-blue-600 focus:text-blue-600 hover:underline dark:text-blue-400 dark:focus:text-blue-400"
|
||||
>
|
||||
Talk to our team
|
||||
{t("addModel.autoRouterClassifierTabs.option.talkToTeam")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -98,6 +104,7 @@ export function ClassifierMenu({
|
|||
onValueChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -109,7 +116,7 @@ export function ClassifierMenu({
|
|||
{feature ? (
|
||||
<AutoRouterAllowanceLabel feature={feature} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unlimited</span>
|
||||
<span className="text-xs text-muted-foreground">{t("addModel.autoRouterClassifierTabs.menu.unlimited")}</span>
|
||||
)}
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -137,6 +144,7 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
judgeModel,
|
||||
decisionModel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const availability = useContext(AutoRouterAvailabilityContext);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
|
|
@ -171,19 +179,28 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
jev_classifier_config: { ...defaults, ...value.jev_classifier_config, provider, model: defaults.model },
|
||||
});
|
||||
};
|
||||
const approachLabels: Partial<Record<ClassifierType, string>> = { capability: "Capability", llm_v2: "Fuse v2" };
|
||||
const approachLabels: Partial<Record<ClassifierType, string>> = {
|
||||
capability: t("addModel.autoRouterClassifierTabs.approach.capability"),
|
||||
llm_v2: t("addModel.autoRouterClassifierTabs.approach.fuseV2"),
|
||||
};
|
||||
const approachDescription: Partial<Record<ClassifierType, string>> = {
|
||||
capability: "Use the efficient model when it is likely to succeed",
|
||||
llm_v2: "Use the efficient model when its predicted quality is close enough to the capable model",
|
||||
capability: t("addModel.autoRouterClassifierTabs.approach.capabilityHelp"),
|
||||
llm_v2: t("addModel.autoRouterClassifierTabs.approach.fuseV2Help"),
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<fieldset>
|
||||
<legend className="mb-4 flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">Choose your classifier</h4>
|
||||
<SimpleTooltip content="Choose how requests are evaluated and routed to your tiers">
|
||||
<button type="button" aria-label="About classifiers" className="text-muted-foreground">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">
|
||||
{t("addModel.autoRouterClassifierTabs.title")}
|
||||
</h4>
|
||||
<SimpleTooltip content={t("addModel.autoRouterClassifierTabs.titleTooltip")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("addModel.autoRouterClassifierTabs.aboutClassifiers")}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
|
|
@ -192,12 +209,16 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
</legend>
|
||||
<RadioGroup value={family} onValueChange={changeFamily} className="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ value: "heuristics", label: "Heuristics", description: "Classify locally, with no API call" },
|
||||
{ value: "llm", label: "LLM", description: "Use a judge model to choose a solver" },
|
||||
{
|
||||
value: "heuristics",
|
||||
label: t("addModel.autoRouterClassifierTabs.families.heuristics"),
|
||||
description: t("addModel.autoRouterClassifierTabs.families.heuristicsDescription"),
|
||||
},
|
||||
{ value: "llm", label: "LLM", description: t("addModel.autoRouterClassifierTabs.families.llmDescription") },
|
||||
{
|
||||
value: "jev",
|
||||
label: "Decisions Model",
|
||||
description: "Use a decision model to choose a tier",
|
||||
label: t("addModel.autoRouterClassifierTabs.families.decisionModel"),
|
||||
description: t("addModel.autoRouterClassifierTabs.families.decisionModelDescription"),
|
||||
},
|
||||
].map((option) => (
|
||||
<Label
|
||||
|
|
@ -226,7 +247,7 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
{family === "jev" &&
|
||||
(decisionModel ?? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-decision-model`}>Decision model</Label>
|
||||
<Label htmlFor={`${id}-decision-model`}>{t("addModel.autoRouterClassifierTabs.decisionModelLabel")}</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: "jev", label: "Jev" },
|
||||
|
|
@ -250,15 +271,19 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
</div>
|
||||
))}
|
||||
{family === "custom" && (
|
||||
<p className="text-sm text-muted-foreground">This router uses a custom classifier plugin</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.autoRouterClassifierTabs.customPlugin")}</p>
|
||||
)}
|
||||
{family === "heuristics" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-heuristic`}>Heuristic</Label>
|
||||
<Label htmlFor={`${id}-heuristic`}>{t("addModel.autoRouterClassifierTabs.heuristic.label")}</Label>
|
||||
<ClassifierMenu
|
||||
id={`${id}-heuristic`}
|
||||
label="Heuristic"
|
||||
selectedLabel={classifierType === "heuristic_v2" ? "Heuristic v2" : "Rule-based"}
|
||||
label={t("addModel.autoRouterClassifierTabs.heuristic.label")}
|
||||
selectedLabel={
|
||||
classifierType === "heuristic_v2"
|
||||
? t("addModel.autoRouterClassifierTabs.heuristic.v2")
|
||||
: t("addModel.autoRouterClassifierTabs.heuristic.ruleBased")
|
||||
}
|
||||
feature={classifierType === "heuristic_v2" ? "heuristic_v2" : undefined}
|
||||
value={classifierType}
|
||||
onValueChange={(next) => {
|
||||
|
|
@ -267,31 +292,31 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
>
|
||||
<ClassifierOption
|
||||
value="heuristic"
|
||||
label="Rule-based"
|
||||
description="Score requests with local rules to choose a tier, with no API call"
|
||||
label={t("addModel.autoRouterClassifierTabs.heuristic.ruleBased")}
|
||||
description={t("addModel.autoRouterClassifierTabs.heuristic.ruleBasedDescription")}
|
||||
/>
|
||||
<ClassifierOption
|
||||
value="heuristic_v2"
|
||||
label="Heuristic v2"
|
||||
description="Use calibrated probabilities to match requests to a tier, with no API call"
|
||||
label={t("addModel.autoRouterClassifierTabs.heuristic.v2")}
|
||||
description={t("addModel.autoRouterClassifierTabs.heuristic.v2Description")}
|
||||
feature="heuristic_v2"
|
||||
/>
|
||||
</ClassifierMenu>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{classifierType === "heuristic_v2"
|
||||
? "Use calibrated probabilities to match requests to a tier"
|
||||
: "Match requests using scoring rules. Choose or change tier models freely"}
|
||||
? t("addModel.autoRouterClassifierTabs.heuristic.v2Help")
|
||||
: t("addModel.autoRouterClassifierTabs.heuristic.ruleBasedHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{family === "llm" && judgeModel}
|
||||
{family === "llm" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-approach`}>Routing approach</Label>
|
||||
<Label htmlFor={`${id}-approach`}>{t("addModel.autoRouterClassifierTabs.approach.label")}</Label>
|
||||
<ClassifierMenu
|
||||
id={`${id}-approach`}
|
||||
label="Routing approach"
|
||||
selectedLabel={approachLabels[classifierType] ?? "Complexity"}
|
||||
label={t("addModel.autoRouterClassifierTabs.approach.label")}
|
||||
selectedLabel={approachLabels[classifierType] ?? t("addModel.autoRouterClassifierTabs.approach.complexity")}
|
||||
feature={isForecastClassifier(classifierType) ? classifierType : undefined}
|
||||
value={isForecastClassifier(classifierType) ? classifierType : "llm"}
|
||||
onValueChange={(next) => {
|
||||
|
|
@ -303,22 +328,22 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
>
|
||||
<ClassifierOption
|
||||
value="llm"
|
||||
label="Complexity"
|
||||
description="Match task difficulty to a tier, then use one of that tier's models"
|
||||
label={t("addModel.autoRouterClassifierTabs.approach.complexity")}
|
||||
description={t("addModel.autoRouterClassifierTabs.approach.complexityDescription")}
|
||||
/>
|
||||
{family === "llm" && (
|
||||
<>
|
||||
<ClassifierOption
|
||||
value="capability"
|
||||
label="Capability"
|
||||
description="Use the efficient model when it is likely to succeed; otherwise use the capable model"
|
||||
label={t("addModel.autoRouterClassifierTabs.approach.capability")}
|
||||
description={t("addModel.autoRouterClassifierTabs.approach.capabilityDescription")}
|
||||
feature="capability"
|
||||
disabled={hasCustomTiers}
|
||||
/>
|
||||
<ClassifierOption
|
||||
value="llm_v2"
|
||||
label="Fuse v2"
|
||||
description="Use the efficient model when its predicted quality is close enough to the capable model"
|
||||
label={t("addModel.autoRouterClassifierTabs.approach.fuseV2")}
|
||||
description={t("addModel.autoRouterClassifierTabs.approach.fuseV2Description")}
|
||||
feature="llm_v2"
|
||||
disabled={hasCustomTiers}
|
||||
/>
|
||||
|
|
@ -326,14 +351,12 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
)}
|
||||
</ClassifierMenu>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{approachDescription[classifierType] ?? "Match task difficulty to a tier"}
|
||||
{approachDescription[classifierType] ?? t("addModel.autoRouterClassifierTabs.approach.complexityHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasCustomTiers && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Restore standard tiers to use Heuristics, Capability, or Fuse v2
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.autoRouterClassifierTabs.restoreStandardTiers")}</p>
|
||||
)}
|
||||
{availability.data?.error && !availability.isChecking && (
|
||||
<div role="alert" className="space-y-1">
|
||||
|
|
@ -343,9 +366,9 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
|||
)}
|
||||
{availability.isError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Could not check availability.{" "}
|
||||
{t("addModel.autoRouterClassifierTabs.availabilityCheckFailed")}{" "}
|
||||
<button type="button" className="underline" onClick={() => availability.refetch?.()}>
|
||||
Retry
|
||||
{t("addModel.autoRouterClassifierTabs.retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { type AutoRouterPreset } from "@/lib/autorouter_presets";
|
||||
|
||||
|
|
@ -10,16 +12,16 @@ export type PresetAvailability =
|
|||
|
||||
// Every non-"available" state disables the option. Selection derives from this same function
|
||||
// (see presetAvailability below), so an option a caller can click is always one that can be applied.
|
||||
const presetDisabledHint = (availability: PresetAvailability): string | null => {
|
||||
const presetDisabledHint = (availability: PresetAvailability, t: TFunction): string | null => {
|
||||
switch (availability.kind) {
|
||||
case "available":
|
||||
return null;
|
||||
case "loading":
|
||||
return "Checking model availability...";
|
||||
return t("addModel.autoRouterPresetSelect.checkingAvailability");
|
||||
case "unverifiable":
|
||||
return "Cannot verify these models are available";
|
||||
return t("addModel.autoRouterPresetSelect.cannotVerify");
|
||||
case "missing_models":
|
||||
return `Missing: ${availability.models.join(", ")}`;
|
||||
return t("addModel.autoRouterPresetSelect.missingModels", { models: availability.models.join(", ") });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ export default function AutoRouterPresetSelect({
|
|||
unavailable,
|
||||
onRetry,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const sortedPresetOptions = React.useMemo(
|
||||
() =>
|
||||
presets
|
||||
|
|
@ -57,9 +60,9 @@ export default function AutoRouterPresetSelect({
|
|||
const templateItems = React.useMemo(
|
||||
() => [
|
||||
...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })),
|
||||
{ value: "custom", label: "Start from scratch" },
|
||||
{ value: "custom", label: t("addModel.autoRouterPresetSelect.startFromScratch") },
|
||||
],
|
||||
[sortedPresetOptions],
|
||||
[sortedPresetOptions, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -70,15 +73,21 @@ export default function AutoRouterPresetSelect({
|
|||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => onPresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger aria-label="Use a template" data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Use a template" />
|
||||
<SelectTrigger
|
||||
aria-label={t("addModel.autoRouterPresetSelect.useTemplate")}
|
||||
data-testid="template-selector"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder={t("addModel.autoRouterPresetSelect.useTemplate")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="[&_[data-slot=select-item]>div]:min-w-0 [&_[data-slot=select-item]>div]:shrink [&_[data-slot=select-item]>div]:whitespace-normal [&_[data-slot=select-item]>div]:break-words">
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const disabledHint = presetDisabledHint(presetState, t);
|
||||
const hintClass = isPresetHintAlarming(presetState) ? "text-destructive" : "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null;
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? t("addModel.autoRouterPresetSelect.matchesDeployments")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
|
|
@ -91,7 +100,9 @@ export default function AutoRouterPresetSelect({
|
|||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
<div className="text-xs text-muted-foreground">Replaces classifier and model choices</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("addModel.autoRouterPresetSelect.replacesChoices")}
|
||||
</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
|
|
@ -99,23 +110,27 @@ export default function AutoRouterPresetSelect({
|
|||
);
|
||||
})}
|
||||
<SelectSeparator className="mx-0" />
|
||||
<SelectItem value="custom" label="Start from scratch">
|
||||
<SelectItem value="custom" label={t("addModel.autoRouterPresetSelect.startFromScratch")}>
|
||||
<div>
|
||||
<div className="font-medium">Start from scratch</div>
|
||||
<div className="font-medium">{t("addModel.autoRouterPresetSelect.startFromScratch")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Clears tier models, switches to Heuristics, and resets routing settings
|
||||
{t("addModel.autoRouterPresetSelect.startFromScratchDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Replaces classifier and model choices</p>
|
||||
{pending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.autoRouterPresetSelect.replacesChoices")}</p>
|
||||
{pending && (
|
||||
<div className="text-xs mt-1 text-muted-foreground">
|
||||
{t("addModel.autoRouterPresetSelect.loadingTemplates")}
|
||||
</div>
|
||||
)}
|
||||
{unavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Start from scratch is shown.{" "}
|
||||
{t("addModel.autoRouterPresetSelect.templatesUnavailable")}{" "}
|
||||
<button type="button" className="underline" onClick={onRetry}>
|
||||
Retry
|
||||
{t("addModel.autoRouterPresetSelect.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -29,6 +30,7 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
routerName,
|
||||
teamId,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [prompt, setPrompt] = React.useState<string>("");
|
||||
const [state, setState] = React.useState<TestState>({ status: "idle" });
|
||||
const configError = getHeuristicV2SuccessThresholdError(config.heuristic_v2_success_threshold);
|
||||
|
|
@ -48,15 +50,12 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a prompt through this router's classifier to see which model it would pick, and why. The prompt is
|
||||
only classified: nothing is sent to the model it routes to.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.autoRouterRoutingTest.intro")}</p>
|
||||
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="Paste a prompt an end user would send"
|
||||
placeholder={t("addModel.autoRouterRoutingTest.promptPlaceholder")}
|
||||
rows={4}
|
||||
data-testid="auto-router-routing-test-prompt"
|
||||
/>
|
||||
|
|
@ -67,7 +66,9 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
disabled={prompt.trim().length === 0 || state.status === "running" || Boolean(configError)}
|
||||
data-testid="auto-router-routing-test-send"
|
||||
>
|
||||
{state.status === "running" ? "Routing..." : "Send Test Prompt"}
|
||||
{state.status === "running"
|
||||
? t("addModel.autoRouterRoutingTest.routing")
|
||||
: t("addModel.autoRouterRoutingTest.send")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
data-testid="auto-router-routing-test-error"
|
||||
>
|
||||
<p className="font-medium">Could not route this prompt</p>
|
||||
<p className="font-medium">{t("addModel.autoRouterRoutingTest.failed")}</p>
|
||||
<p>{state.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -86,7 +87,7 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
{state.status === "done" && (
|
||||
<div data-testid="auto-router-routing-test-result">
|
||||
<div className="flex items-center gap-2 py-2 text-sm">
|
||||
<span className="text-muted-foreground">Routed to</span>
|
||||
<span className="text-muted-foreground">{t("addModel.autoRouterRoutingTest.routedTo")}</span>
|
||||
<Badge variant="secondary" data-testid="auto-router-routing-test-routed-model">
|
||||
{state.result.routed_model}
|
||||
</Badge>
|
||||
|
|
@ -96,7 +97,7 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
data-testid="auto-router-routing-test-unconfigured"
|
||||
>
|
||||
<TriangleAlert className="size-3.5" />
|
||||
This proxy has no model group by that name
|
||||
{t("addModel.autoRouterRoutingTest.unconfigured")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -49,11 +50,17 @@ export default function AutoRouterSimpleSetup({
|
|||
onAdvanced,
|
||||
onRetry,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const inventoryReady = !waitingForTeam && !loading;
|
||||
const showPreferences = !inventoryReady || error || hasSuggestions;
|
||||
const sameFamily = sharedFamily(everyday, hard);
|
||||
const familyItems = [
|
||||
{ value: "auto", label: sameFamily ? `Auto (${sameFamily})` : "Auto" },
|
||||
{
|
||||
value: "auto",
|
||||
label: sameFamily
|
||||
? t("addModel.autoRouterSimpleSetup.autoWithFamily", { family: sameFamily })
|
||||
: t("addModel.autoRouterSimpleSetup.auto"),
|
||||
},
|
||||
...Object.entries(ROUTER_FAMILIES).map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
return (
|
||||
|
|
@ -61,8 +68,12 @@ export default function AutoRouterSimpleSetup({
|
|||
{showPreferences && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Optimize for</Label>
|
||||
<div className="grid grid-cols-3 gap-2" role="group" aria-label="Routing goal">
|
||||
<Label>{t("addModel.autoRouterSimpleSetup.optimizeFor")}</Label>
|
||||
<div
|
||||
className="grid grid-cols-3 gap-2"
|
||||
role="group"
|
||||
aria-label={t("addModel.autoRouterSimpleSetup.routingGoal")}
|
||||
>
|
||||
{Object.entries(ROUTER_GOALS).map(([key, label]) => (
|
||||
<Button
|
||||
key={key}
|
||||
|
|
@ -78,7 +89,7 @@ export default function AutoRouterSimpleSetup({
|
|||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto-router-family">Model family</Label>
|
||||
<Label htmlFor="auto-router-family">{t("addModel.autoRouterSimpleSetup.modelFamily")}</Label>
|
||||
<Select
|
||||
items={familyItems}
|
||||
value={family}
|
||||
|
|
@ -86,7 +97,11 @@ export default function AutoRouterSimpleSetup({
|
|||
if (value === "auto" || (value && value in ROUTER_FAMILIES)) onFamilyChange(value as FamilyPreference);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="auto-router-family" aria-label="Model family" className="w-full">
|
||||
<SelectTrigger
|
||||
id="auto-router-family"
|
||||
aria-label={t("addModel.autoRouterSimpleSetup.modelFamily")}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -100,18 +115,20 @@ export default function AutoRouterSimpleSetup({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{waitingForTeam && <p className="text-sm text-muted-foreground">Select a team to see available models.</p>}
|
||||
{waitingForTeam && (
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.autoRouterSimpleSetup.selectTeamToSeeModels")}</p>
|
||||
)}
|
||||
{!waitingForTeam && loading && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
Loading deployed models
|
||||
{t("addModel.autoRouterSimpleSetup.loadingModels")}
|
||||
</p>
|
||||
)}
|
||||
{inventoryReady && error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load deployed models.{" "}
|
||||
{t("addModel.autoRouterSimpleSetup.modelsLoadFailed")}{" "}
|
||||
<Button type="button" variant="link" onClick={onRetry}>
|
||||
Retry
|
||||
{t("addModel.autoRouterSimpleSetup.retry")}
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -119,10 +136,10 @@ export default function AutoRouterSimpleSetup({
|
|||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label>Chosen models</Label>
|
||||
<Label>{t("addModel.autoRouterSimpleSetup.chosenModels")}</Label>
|
||||
{customized && hasSuggestions && (
|
||||
<Button type="button" variant="link" className="h-auto p-0 text-xs" onClick={onReset}>
|
||||
Use suggested models
|
||||
{t("addModel.autoRouterSimpleSetup.useSuggested")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -132,13 +149,18 @@ export default function AutoRouterSimpleSetup({
|
|||
const other = role === "everyday" ? hard : everyday;
|
||||
const choices = models.filter((model) => model.name !== other?.name);
|
||||
const items = choices.map((model) => ({ value: model.name, label: model.name }));
|
||||
const label = role === "everyday" ? "Everyday" : "Hard tasks";
|
||||
const label =
|
||||
role === "everyday"
|
||||
? t("addModel.autoRouterSimpleSetup.everyday")
|
||||
: t("addModel.autoRouterSimpleSetup.hardTasks");
|
||||
return (
|
||||
<div className="min-w-0 space-y-3 rounded-lg border bg-muted/20 p-4" key={role}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`auto-router-${role}`}>{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role === "everyday" ? "Your cheaper model" : "Your stronger model"}
|
||||
{role === "everyday"
|
||||
? t("addModel.autoRouterSimpleSetup.everydayDescription")
|
||||
: t("addModel.autoRouterSimpleSetup.hardDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
|
|
@ -148,8 +170,12 @@ export default function AutoRouterSimpleSetup({
|
|||
if (value) onModelChange(role, value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`auto-router-${role}`} aria-label={`${label} model`} className="w-full">
|
||||
<SelectValue placeholder="Select a deployed model" />
|
||||
<SelectTrigger
|
||||
id={`auto-router-${role}`}
|
||||
aria-label={t("addModel.autoRouterSimpleSetup.roleModel", { label })}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder={t("addModel.autoRouterSimpleSetup.selectModel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{choices.map((model) => (
|
||||
|
|
@ -171,8 +197,8 @@ export default function AutoRouterSimpleSetup({
|
|||
{(!everyday || !hard) && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{models.length < 2
|
||||
? "Two deployed models are needed. Add a model or use Advanced configuration."
|
||||
: "Choose two models to get started."}
|
||||
? t("addModel.autoRouterSimpleSetup.needTwoModels")
|
||||
: t("addModel.autoRouterSimpleSetup.chooseTwoModels")}
|
||||
</p>
|
||||
)}
|
||||
{notice && !customized && (
|
||||
|
|
@ -183,7 +209,7 @@ export default function AutoRouterSimpleSetup({
|
|||
</>
|
||||
)}
|
||||
<Button type="button" variant="link" className="px-0" onClick={onAdvanced}>
|
||||
Advanced configuration
|
||||
{t("addModel.autoRouterSimpleSetup.advancedConfiguration")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { classificationFrequency, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const blockedReason = (value: ComplexityRouterConfigValue): string | null => {
|
||||
if (value.custom_tier_set) return "Use the built-in tiers to enable cache-aware routing.";
|
||||
if (value.adaptive) return "Turn off Adaptive Routing to enable cache-aware routing.";
|
||||
const blockedReason = (value: ComplexityRouterConfigValue, t: TFunction = i18n.t.bind(i18n)): string | null => {
|
||||
if (value.custom_tier_set) return t("addModel.cacheAwareRoutingConfig.blockedByCustomTiers");
|
||||
if (value.adaptive) return t("addModel.cacheAwareRoutingConfig.blockedByAdaptive");
|
||||
if (classificationFrequency(value) !== "every_request") {
|
||||
return 'Set "How often to classify" to every request to enable cache-aware routing.';
|
||||
return t("addModel.cacheAwareRoutingConfig.blockedByFrequency");
|
||||
}
|
||||
if (Object.values(value.tiers).some((models) => models.length > 1)) {
|
||||
return "Choose one model per tier to enable cache-aware routing.";
|
||||
return t("addModel.cacheAwareRoutingConfig.blockedByMultipleModels");
|
||||
}
|
||||
if (
|
||||
Object.values(value.tier_model_params ?? {}).some((models) =>
|
||||
Object.values(models).some((params) => Object.keys(params).length > 0),
|
||||
)
|
||||
) {
|
||||
return "Remove per-model parameter overrides to enable cache-aware routing.";
|
||||
return t("addModel.cacheAwareRoutingConfig.blockedByParamOverrides");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -34,8 +37,9 @@ const CacheAwareRoutingConfig = ({
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const enabled = value.cache_aware_routing ?? false;
|
||||
const reason = blockedReason(value);
|
||||
const reason = blockedReason(value, t);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -43,18 +47,14 @@ const CacheAwareRoutingConfig = ({
|
|||
checked={enabled}
|
||||
disabled={!enabled && reason !== null}
|
||||
onCheckedChange={(next) => onChange({ ...value, cache_aware_routing: next })}
|
||||
aria-label="Cache-aware routing"
|
||||
aria-label={t("addModel.cacheAwareRoutingConfig.toggle")}
|
||||
/>
|
||||
<span className="text-sm font-medium">Consider prompt-cache savings</span>
|
||||
<span className="text-sm font-medium">{t("addModel.cacheAwareRoutingConfig.considerSavings")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disabled by default. Reuse a model with a warm prompt cache when its estimated total cost is lower and it meets
|
||||
the selected tier or higher. Supports native Anthropic Messages with explicit prompt caching; unsupported
|
||||
requests keep their usual route.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.cacheAwareRoutingConfig.description")}</p>
|
||||
{reason && (
|
||||
<p className="text-xs text-muted-foreground" role="status">
|
||||
{enabled && "Cache-aware routing is currently skipped. "}
|
||||
{enabled && t("addModel.cacheAwareRoutingConfig.currentlySkipped")}
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -62,7 +62,7 @@ const CacheAwareRoutingConfig = ({
|
|||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="cache-aware-output-tokens">
|
||||
Expected output tokens
|
||||
{t("addModel.cacheAwareRoutingConfig.outputTokens")}
|
||||
</label>
|
||||
<Input
|
||||
id="cache-aware-output-tokens"
|
||||
|
|
@ -80,12 +80,12 @@ const CacheAwareRoutingConfig = ({
|
|||
aria-describedby="cache-aware-output-help"
|
||||
/>
|
||||
<p id="cache-aware-output-help" className="mt-1 text-xs text-muted-foreground">
|
||||
Used to estimate cost, not to limit the response. Leave blank to use the default of 1024.
|
||||
{t("addModel.cacheAwareRoutingConfig.outputTokensHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="cache-aware-timeout">
|
||||
Prediction timeout (ms)
|
||||
{t("addModel.cacheAwareRoutingConfig.timeout")}
|
||||
</label>
|
||||
<Input
|
||||
id="cache-aware-timeout"
|
||||
|
|
@ -103,7 +103,7 @@ const CacheAwareRoutingConfig = ({
|
|||
aria-describedby="cache-aware-timeout-help"
|
||||
/>
|
||||
<p id="cache-aware-timeout-help" className="mt-1 text-xs text-muted-foreground">
|
||||
Keep the original route if the comparison takes too long. Leave blank to use the default of 2000 ms.
|
||||
{t("addModel.cacheAwareRoutingConfig.timeoutHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { Label } from "@/components/ui/label";
|
|||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ClassifierPromptEditor from "./ClassifierPromptEditor";
|
||||
import OpeningPromptEditor, { type OpeningPromptSelection } from "./OpeningPromptEditor";
|
||||
import { RestrictedSection, restrictedBy } from "./TierRestrictions";
|
||||
|
|
@ -51,14 +53,9 @@ import {
|
|||
effectiveClassifierType,
|
||||
} from "./ComplexityRouterConfig";
|
||||
|
||||
const DEFAULT_SCORING_EXPLANATION =
|
||||
"The router scores each request across 7 built-in dimensions: token count, code presence, reasoning markers, technical " +
|
||||
"terms, simple indicators, multi-step patterns, and question complexity, plus any custom dimensions you add. " +
|
||||
"The weighted score determines the tier:";
|
||||
const DEFAULT_SCORING_EXPLANATION: ParseKeys = "addModel.classificationMethodConfig.explanations.defaultScoring";
|
||||
|
||||
const HEURISTIC_V2_EXPLANATION =
|
||||
"The router estimates success probability for all four tiers with its calibrated model, then selects " +
|
||||
"the first tier that meets the success threshold.";
|
||||
const HEURISTIC_V2_EXPLANATION: ParseKeys = "addModel.classificationMethodConfig.explanations.heuristicV2";
|
||||
|
||||
const CLASSIFIER_TIMEOUT_ID = "classifier-timeout-ms";
|
||||
const CLASSIFIER_CONTEXT_WINDOW_SIZE_ID = "classifier-context-window-size";
|
||||
|
|
@ -94,6 +91,7 @@ const boundaryRanges = (
|
|||
|
||||
const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => {
|
||||
// The shipped boundaries come from the proxy, so this card cannot state ranges the router stopped using.
|
||||
const { t } = useTranslation();
|
||||
const { data: scorerDefaults, isError } = useComplexityScorerDefaults();
|
||||
const scorerRuns = heuristicScoringRole(value) !== "never";
|
||||
const ranges = boundaryRanges(
|
||||
|
|
@ -108,37 +106,48 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
|
|||
return (
|
||||
<Card className="bg-muted mt-4">
|
||||
<CardContent>
|
||||
<strong className="block mb-2 font-semibold">How Classification Works</strong>
|
||||
<strong className="block mb-2 font-semibold">
|
||||
{t("addModel.classificationMethodConfig.howItWorks.title")}
|
||||
</strong>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{usesHeuristicV2(value) ? HEURISTIC_V2_EXPLANATION : DEFAULT_SCORING_EXPLANATION}
|
||||
{usesHeuristicV2(value) ? t(HEURISTIC_V2_EXPLANATION) : t(DEFAULT_SCORING_EXPLANATION)}
|
||||
{usesHeuristicV2(value) &&
|
||||
(isHeuristicChain(value.classifier_type)
|
||||
? " If no tier qualifies, or the local-check policy requires a judge, the judge chooses the tier."
|
||||
: " If none qualify, it selects Reasoning. It runs locally with no classifier API call.")}
|
||||
? t("addModel.classificationMethodConfig.howItWorks.judgeChoosesTier")
|
||||
: t("addModel.classificationMethodConfig.howItWorks.selectsReasoning"))}
|
||||
</span>
|
||||
{scorerRuns && ranges && (
|
||||
<ul className="mt-2 pl-5 text-[13px] text-muted-foreground">
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score < {ranges.simpleMedium}
|
||||
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>:{" "}
|
||||
{t("addModel.classificationMethodConfig.howItWorks.scoreBelow", { max: ranges.simpleMedium })}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("MEDIUM", value.tier_labels)}</strong>: Score {ranges.simpleMedium} -{" "}
|
||||
{ranges.mediumComplex}
|
||||
<strong>{effectiveTierLabel("MEDIUM", value.tier_labels)}</strong>:{" "}
|
||||
{t("addModel.classificationMethodConfig.howItWorks.scoreBetween", {
|
||||
min: ranges.simpleMedium,
|
||||
max: ranges.mediumComplex,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("COMPLEX", value.tier_labels)}</strong>: Score {ranges.mediumComplex} -{" "}
|
||||
{ranges.complexReasoning}
|
||||
<strong>{effectiveTierLabel("COMPLEX", value.tier_labels)}</strong>:{" "}
|
||||
{t("addModel.classificationMethodConfig.howItWorks.scoreBetween", {
|
||||
min: ranges.mediumComplex,
|
||||
max: ranges.complexReasoning,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("REASONING", value.tier_labels)}</strong>: Score >{" "}
|
||||
{ranges.complexReasoning} (or 2+ reasoning markers with a score of at least{" "}
|
||||
{ranges.reasoningOverrideFloor})
|
||||
<strong>{effectiveTierLabel("REASONING", value.tier_labels)}</strong>:{" "}
|
||||
{t("addModel.classificationMethodConfig.howItWorks.scoreAbove", {
|
||||
min: ranges.complexReasoning,
|
||||
floor: ranges.reasoningOverrideFloor,
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
{scorerDefaultsUnavailable && (
|
||||
<span className="text-[13px] block mt-2 text-muted-foreground">
|
||||
The tier score ranges could not be loaded from the proxy.
|
||||
{t("addModel.classificationMethodConfig.howItWorks.rangesUnavailable")}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -165,19 +174,25 @@ export const InactiveHeuristicV2Threshold: React.FC<Pick<ClassificationMethodCon
|
|||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const threshold = value.heuristic_v2_success_threshold;
|
||||
if (usesHeuristicV2(value) || threshold === undefined) return null;
|
||||
const error = getHeuristicV2SuccessThresholdError(threshold);
|
||||
if (effectiveClassifierType(value) === "llm" && !error) return null;
|
||||
return (
|
||||
<section aria-label="Inactive Heuristic v2 threshold" className="mb-4 space-y-2 rounded-md border p-3">
|
||||
<section
|
||||
aria-label={t("addModel.classificationMethodConfig.inactiveThreshold.sectionLabel")}
|
||||
className="mb-4 space-y-2 rounded-md border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
Heuristic v2 success threshold (inactive):{" "}
|
||||
<output aria-label="Retained Heuristic v2 threshold">
|
||||
{Number.isFinite(threshold) ? threshold : "Invalid value"}
|
||||
{t("addModel.classificationMethodConfig.inactiveThreshold.title")}{" "}
|
||||
<output aria-label={t("addModel.classificationMethodConfig.inactiveThreshold.valueLabel")}>
|
||||
{Number.isFinite(threshold)
|
||||
? threshold
|
||||
: t("addModel.classificationMethodConfig.inactiveThreshold.invalidValue")}
|
||||
</output>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Only used when Heuristic v2 is selected</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.classificationMethodConfig.inactiveThreshold.hint")}</p>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
|
@ -189,7 +204,7 @@ export const InactiveHeuristicV2Threshold: React.FC<Pick<ClassificationMethodCon
|
|||
size="sm"
|
||||
onClick={() => onChange({ ...value, heuristic_v2_success_threshold: undefined })}
|
||||
>
|
||||
Clear Heuristic v2 threshold
|
||||
{t("addModel.classificationMethodConfig.inactiveThreshold.clear")}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -208,6 +223,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
jevSection,
|
||||
section,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = React.useState<{ id: string; raw: string } | null>(null);
|
||||
const hasDefaultModel = Boolean(defaultModel);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
|
|
@ -380,12 +396,12 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
)}
|
||||
{showLocalChecks && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto-router-local-checks">Local checks before the judge</Label>
|
||||
<Label htmlFor="auto-router-local-checks">{t("addModel.classificationMethodConfig.localChecks.label")}</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: "llm", label: "Always use the judge" },
|
||||
{ value: "heuristic_first", label: "Heuristic first" },
|
||||
{ value: "hybrid", label: "Hybrid" },
|
||||
{ value: "llm", label: t("addModel.classificationMethodConfig.localChecks.alwaysJudge") },
|
||||
{ value: "heuristic_first", label: t("addModel.classificationMethodConfig.localChecks.heuristicFirst") },
|
||||
{ value: "hybrid", label: t("addModel.classificationMethodConfig.localChecks.hybrid") },
|
||||
]}
|
||||
value={classifierType}
|
||||
onValueChange={(next) => {
|
||||
|
|
@ -396,12 +412,12 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="llm">Always use the judge</SelectItem>
|
||||
<SelectItem value="llm">{t("addModel.classificationMethodConfig.localChecks.alwaysJudge")}</SelectItem>
|
||||
<SelectItem value="heuristic_first" disabled={Boolean(value.custom_tier_set)}>
|
||||
Heuristic first
|
||||
{t("addModel.classificationMethodConfig.localChecks.heuristicFirst")}
|
||||
</SelectItem>
|
||||
<SelectItem value="hybrid" disabled={Boolean(value.custom_tier_set)}>
|
||||
Hybrid
|
||||
{t("addModel.classificationMethodConfig.localChecks.hybrid")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -410,11 +426,17 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
|
||||
{showSelection && isHeuristicChain(classifierType) && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="auto-router-local-heuristic">Heuristic before the judge</Label>
|
||||
<Label htmlFor="auto-router-local-heuristic">
|
||||
{t("addModel.classificationMethodConfig.localHeuristic.label")}
|
||||
</Label>
|
||||
<ClassifierMenu
|
||||
id="auto-router-local-heuristic"
|
||||
label="Heuristic before the judge"
|
||||
selectedLabel={value.local_heuristic === "heuristic_v2" ? "Heuristic v2" : "Heuristic v1 (rule-based)"}
|
||||
label={t("addModel.classificationMethodConfig.localHeuristic.label")}
|
||||
selectedLabel={
|
||||
value.local_heuristic === "heuristic_v2"
|
||||
? t("addModel.classificationMethodConfig.localHeuristic.v2")
|
||||
: t("addModel.classificationMethodConfig.localHeuristic.v1")
|
||||
}
|
||||
feature={value.local_heuristic === "heuristic_v2" ? "heuristic_v2" : undefined}
|
||||
value={value.local_heuristic ?? "heuristic"}
|
||||
onValueChange={(next) => {
|
||||
|
|
@ -423,13 +445,13 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
>
|
||||
<ClassifierOption
|
||||
value="heuristic"
|
||||
label="Heuristic v1 (rule-based)"
|
||||
description="Score requests using configurable weights and keywords"
|
||||
label={t("addModel.classificationMethodConfig.localHeuristic.v1")}
|
||||
description={t("addModel.classificationMethodConfig.localHeuristic.v1Description")}
|
||||
/>
|
||||
<ClassifierOption
|
||||
value="heuristic_v2"
|
||||
label="Heuristic v2"
|
||||
description="Choose a tier using calibrated success probabilities"
|
||||
label={t("addModel.classificationMethodConfig.localHeuristic.v2")}
|
||||
description={t("addModel.classificationMethodConfig.localHeuristic.v2Description")}
|
||||
feature="heuristic_v2"
|
||||
/>
|
||||
</ClassifierMenu>
|
||||
|
|
@ -443,13 +465,13 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
{showHeuristic && heuristic === "heuristic_v2" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor={HEURISTIC_V2_SUCCESS_THRESHOLD_ID} className="block font-semibold">
|
||||
Success threshold
|
||||
{t("addModel.classificationMethodConfig.successThreshold.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id={HEURISTIC_V2_SUCCESS_THRESHOLD_ID}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="Artifact default"
|
||||
placeholder={t("addModel.classificationMethodConfig.successThreshold.placeholder")}
|
||||
value={successThresholdDraft ?? value.heuristic_v2_success_threshold?.toString() ?? ""}
|
||||
onChange={(event) => handleSuccessThresholdChange(event.target.value)}
|
||||
onBlur={() => {
|
||||
|
|
@ -459,8 +481,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
aria-describedby={`${HEURISTIC_V2_SUCCESS_THRESHOLD_ID}-help${successThresholdError ? ` ${HEURISTIC_V2_SUCCESS_THRESHOLD_ID}-error` : ""}`}
|
||||
/>
|
||||
<p id={`${HEURISTIC_V2_SUCCESS_THRESHOLD_ID}-help`} className="text-sm text-muted-foreground">
|
||||
Minimum predicted success probability, from 0 to 1. Higher values favor more capable tiers. Leave blank to
|
||||
use the artifact default
|
||||
{t("addModel.classificationMethodConfig.successThreshold.help")}
|
||||
</p>
|
||||
{successThresholdError && (
|
||||
<p id={`${HEURISTIC_V2_SUCCESS_THRESHOLD_ID}-error`} className="text-sm text-destructive" role="alert">
|
||||
|
|
@ -473,7 +494,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
{showSelection && classifierType === "heuristic_first" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="auto-router-local-max-tier" className="block font-semibold">
|
||||
Decide locally up to
|
||||
{t("addModel.classificationMethodConfig.decideLocally.label")}
|
||||
</Label>
|
||||
<Select
|
||||
value={value.heuristic_first_max_tier}
|
||||
|
|
@ -492,8 +513,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{usesHeuristicV2(value)
|
||||
? "A tier that meets the success threshold and is at or below this limit routes locally. Higher tiers, or requests where no tier qualifies, go to the judge"
|
||||
: "A request the scorer places at or below this tier routes locally. Higher tiers, or requests with no heuristic signal, go to the judge"}
|
||||
? t("addModel.classificationMethodConfig.decideLocally.helpV2")
|
||||
: t("addModel.classificationMethodConfig.decideLocally.helpV1")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -501,7 +522,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
{showSelection && classifierType === "hybrid" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor={HYBRID_BOUNDARY_MARGIN_ID} className="block font-semibold">
|
||||
{usesHeuristicV2(value) ? "Success threshold margin" : "Boundary margin"}
|
||||
{usesHeuristicV2(value)
|
||||
? t("addModel.classificationMethodConfig.hybridMargin.labelV2")
|
||||
: t("addModel.classificationMethodConfig.hybridMargin.labelV1")}
|
||||
</Label>
|
||||
<Input
|
||||
id={HYBRID_BOUNDARY_MARGIN_ID}
|
||||
|
|
@ -518,8 +541,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{usesHeuristicV2(value)
|
||||
? "Use the judge when the selected tier or a lower tier has a success probability within this margin of the success threshold, or no tier meets the threshold"
|
||||
: "A score further than this from every tier boundary routes on the scorer's own tier. A score closer than this, or a request with no heuristic signal, goes to the judge"}
|
||||
? t("addModel.classificationMethodConfig.hybridMargin.helpV2")
|
||||
: t("addModel.classificationMethodConfig.hybridMargin.helpV1")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -537,7 +560,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
/>
|
||||
<div>
|
||||
<Label htmlFor={CLASSIFIER_TIMEOUT_ID} className="block mb-1 font-semibold">
|
||||
Timeout (ms)
|
||||
{t("addModel.classificationMethodConfig.timeout.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id={CLASSIFIER_TIMEOUT_ID}
|
||||
|
|
@ -560,7 +583,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
How long the classifier call has before it fails and the fallback below takes over.
|
||||
{t("addModel.classificationMethodConfig.timeout.help")}
|
||||
</span>
|
||||
</div>
|
||||
<ClassifierCircuitBreakerConfig
|
||||
|
|
@ -573,14 +596,16 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="font-semibold">Classifier Prompt</strong>
|
||||
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic. Pick the rubric, and write your own opening instructions and calibration examples, inside the prompt editor.">
|
||||
<strong className="font-semibold">
|
||||
{t("addModel.classificationMethodConfig.classifierPrompt.title")}
|
||||
</strong>
|
||||
<SimpleTooltip content={t("addModel.classificationMethodConfig.classifierPrompt.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label="Custom instructions and examples share the custom-tier allowance"
|
||||
label={t("addModel.classificationMethodConfig.classifierPrompt.allowance")}
|
||||
/>
|
||||
{!value.custom_tier_set && usesCustomPrompt ? (
|
||||
<ClassifierPromptEditor
|
||||
|
|
@ -613,7 +638,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
)}
|
||||
{showClassifier && usesClassifierContext(classifierType) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<RestrictedSection heading="If the classifier fails" by={restrictedBy(value, "classifierFallback")}>
|
||||
<RestrictedSection
|
||||
heading={t("addModel.classificationMethodConfig.fallback.heading")}
|
||||
by={restrictedBy(value, "classifierFallback")}
|
||||
>
|
||||
<RadioGroup
|
||||
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
|
||||
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
|
||||
|
|
@ -622,8 +650,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" />
|
||||
<span>
|
||||
<span>Score with the heuristic</span>{" "}
|
||||
<span className="text-muted-foreground">— right when the classifier grades complexity too</span>
|
||||
<span>{t("addModel.classificationMethodConfig.fallback.heuristic")}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{t("addModel.classificationMethodConfig.fallback.heuristicHint")}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
|
|
@ -631,14 +661,17 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
<SimpleTooltip
|
||||
content={
|
||||
hasDefaultModel
|
||||
? "Change it from the Default Model select."
|
||||
: "Set a default model on this router to use this option"
|
||||
? t("addModel.classificationMethodConfig.fallback.defaultModelSetTooltip")
|
||||
: t("addModel.classificationMethodConfig.fallback.defaultModelMissingTooltip")
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</span>{" "}
|
||||
<span>
|
||||
{t("addModel.classificationMethodConfig.fallback.defaultModel")}
|
||||
{defaultModel ? ` (${defaultModel})` : ""}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
— right when your prompt grades something other than complexity
|
||||
{t("addModel.classificationMethodConfig.fallback.defaultModelHint")}
|
||||
</span>
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
|
|
@ -646,12 +679,12 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
</RadioGroup>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Applies when the classifier call errors, times out, or returns an unparseable response.
|
||||
{t("addModel.classificationMethodConfig.fallback.help")}
|
||||
</span>
|
||||
</RestrictedSection>
|
||||
<div>
|
||||
<Label htmlFor={CLASSIFIER_CONTEXT_WINDOW_SIZE_ID} className="block mb-1 font-semibold">
|
||||
Context Window Size
|
||||
{t("addModel.classificationMethodConfig.contextWindow.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id={CLASSIFIER_CONTEXT_WINDOW_SIZE_ID}
|
||||
|
|
@ -674,14 +707,12 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders.
|
||||
LLM and decision model classifiers default to 3 turns. Set to 0 to omit conversation history. The current
|
||||
message and selected system text are still sent.
|
||||
{t("addModel.classificationMethodConfig.contextWindow.help")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={CLASSIFIER_CONTEXT_BUDGET_CHARS_ID} className="block mb-1 font-semibold">
|
||||
Context Character Budget
|
||||
{t("addModel.classificationMethodConfig.contextBudget.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id={CLASSIFIER_CONTEXT_BUDGET_CHARS_ID}
|
||||
|
|
@ -704,14 +735,13 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Total characters of prior conversation sent to the classifier. Turns are taken newest first and quoted
|
||||
whole while they fit, so a short conversation is never cut.
|
||||
{t("addModel.classificationMethodConfig.contextBudget.help")}
|
||||
</span>
|
||||
{contextBudgetQuotesNothing && (
|
||||
<span className="block text-xs text-destructive">
|
||||
Under {MIN_QUOTED_CONTEXT_TURN_CHARS} characters there is no room to quote a turn that does not already
|
||||
fit, so a long conversation reaches the classifier with no context at all. Set Context Window Size to 0
|
||||
to turn context off deliberately.
|
||||
{t("addModel.classificationMethodConfig.contextBudget.tooSmall", {
|
||||
min: MIN_QUOTED_CONTEXT_TURN_CHARS,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -721,18 +751,15 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
checked={value.classifier_context_include_assistant_turns ?? false}
|
||||
onCheckedChange={handleClassifierContextIncludeAssistantTurnsChange}
|
||||
size="sm"
|
||||
aria-label="Include Assistant Turns"
|
||||
aria-label={t("addModel.classificationMethodConfig.assistantTurns.label")}
|
||||
/>
|
||||
<strong className="font-semibold">Include Assistant Turns</strong>
|
||||
<SimpleTooltip content="Off by default. Enabling it changes tier decisions, and therefore spend, for an existing router, and sends assistant text to the classifier model, which may be a different provider than the routed model.">
|
||||
<strong className="font-semibold">{t("addModel.classificationMethodConfig.assistantTurns.label")}</strong>
|
||||
<SimpleTooltip content={t("addModel.classificationMethodConfig.assistantTurns.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Let the classifier read the assistant's replies, so difficulty the model stated rather than the user
|
||||
stays visible: a plan the assistant calls complex, approved with "yes", is classified on the
|
||||
work being approved. Context Window Size then counts the last N turns across both roles rather than the
|
||||
last N user turns.
|
||||
{t("addModel.classificationMethodConfig.assistantTurns.help")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -741,14 +768,15 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
{showHeuristic && heuristic === "heuristic" && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="font-semibold">Custom Technical Keywords</strong>
|
||||
<SimpleTooltip content="Domain-specific terms appended to the built-in technical keyword list. Prompts containing these terms score higher on the technical dimension and route to more capable models.">
|
||||
<strong className="font-semibold">
|
||||
{t("addModel.classificationMethodConfig.technicalKeywords.title")}
|
||||
</strong>
|
||||
<SimpleTooltip content={t("addModel.classificationMethodConfig.technicalKeywords.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span className="block mb-2 text-xs text-muted-foreground">
|
||||
Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension.
|
||||
(e.g., udp, kafka, terraform).
|
||||
{t("addModel.classificationMethodConfig.technicalKeywords.help")}
|
||||
</span>
|
||||
<MultiSelect
|
||||
options={(customTechnicalKeywords ?? []).map((keyword) => ({ label: keyword, value: keyword }))}
|
||||
|
|
@ -760,8 +788,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
),
|
||||
)
|
||||
}
|
||||
placeholder="Type a keyword and press Enter"
|
||||
emptyText="Type to add a keyword"
|
||||
placeholder={t("addModel.classificationMethodConfig.technicalKeywords.placeholder")}
|
||||
emptyText={t("addModel.classificationMethodConfig.technicalKeywords.empty")}
|
||||
allowCustomValues
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -771,11 +799,14 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
{showHeuristic && heuristic === "heuristic" && (
|
||||
<>
|
||||
{heuristicScoringRole(value) === "decides" && (
|
||||
<AutoRouterAllowanceNote feature="heuristic_tuning" label="Custom scoring rules" />
|
||||
<AutoRouterAllowanceNote
|
||||
feature="heuristic_tuning"
|
||||
label={t("addModel.classificationMethodConfig.scoringRulesAllowance")}
|
||||
/>
|
||||
)}
|
||||
<HeuristicScoringConfig value={value} onChange={onChange} />
|
||||
<section className="mt-4 space-y-3">
|
||||
<h4 className="font-semibold">Heuristic Keyword Overrides</h4>
|
||||
<h4 className="font-semibold">{t("addModel.classificationMethodConfig.keywordOverridesTitle")}</h4>
|
||||
<HeuristicKeywordOverrides value={value} onChange={onChange} />
|
||||
</section>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ClassifierLLMConfig } from "./ComplexityRouterConfig";
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ interface ClassifierCircuitBreakerConfigProps {
|
|||
}
|
||||
|
||||
const ClassifierCircuitBreakerConfig: React.FC<ClassifierCircuitBreakerConfigProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [draftCooldown, setDraftCooldown] = React.useState<string | null>(null);
|
||||
const enabled = value.circuit_breaker_enabled ?? DEFAULT_CLASSIFIER_CIRCUIT_BREAKER_ENABLED;
|
||||
|
||||
|
|
@ -35,18 +37,15 @@ const ClassifierCircuitBreakerConfig: React.FC<ClassifierCircuitBreakerConfigPro
|
|||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(circuit_breaker_enabled) => onChange({ ...value, circuit_breaker_enabled })}
|
||||
aria-label="Classifier circuit breaker"
|
||||
aria-label={t("addModel.classifierCircuitBreakerConfig.label")}
|
||||
/>
|
||||
<strong className="font-semibold">Classifier circuit breaker</strong>
|
||||
<strong className="font-semibold">{t("addModel.classifierCircuitBreakerConfig.label")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
After one classifier timeout, use the fallback immediately for every session until a recovery probe succeeds.
|
||||
Enabled by default.
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("addModel.classifierCircuitBreakerConfig.help")}</span>
|
||||
{enabled && (
|
||||
<div>
|
||||
<Label htmlFor={COOLDOWN_ID} className="block mb-1 font-semibold">
|
||||
Circuit breaker cooldown (seconds)
|
||||
{t("addModel.classifierCircuitBreakerConfig.cooldown")}
|
||||
</Label>
|
||||
<Input
|
||||
id={COOLDOWN_ID}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
|
@ -17,14 +18,13 @@ const ClassifierPluginTimeoutField: React.FC<ClassifierPluginTimeoutFieldProps>
|
|||
onChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const error = getClassifierPluginTimeoutError("custom", value.classifier_plugin_timeout_ms);
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This router uses a custom classifier plugin set in config.yaml. Pick a classifier below to replace it.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.classifierPluginTimeoutField.intro")}</p>
|
||||
<Label htmlFor={CLASSIFIER_PLUGIN_TIMEOUT_ID} className="block font-semibold">
|
||||
Classifier plugin timeout (ms)
|
||||
{t("addModel.classifierPluginTimeoutField.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id={CLASSIFIER_PLUGIN_TIMEOUT_ID}
|
||||
|
|
@ -39,9 +39,7 @@ const ClassifierPluginTimeoutField: React.FC<ClassifierPluginTimeoutFieldProps>
|
|||
}
|
||||
aria-invalid={Boolean(showValidationErrors && error)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Time budget for the plugin call. On expiry the fallback path decides the tier.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.classifierPluginTimeoutField.help")}</p>
|
||||
{showValidationErrors && error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -26,13 +27,14 @@ export default function ClassifierPrimarySettings({
|
|||
showValidationErrors?: boolean;
|
||||
section?: "all" | "judge" | "frequency";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
const restriction = restrictedBy(value, "sessionAffinity");
|
||||
const frequency = classificationFrequency(value);
|
||||
const frequencyDescription = {
|
||||
every_request: "Choose a model again for every request",
|
||||
user_turn: "Reclassify when the user sends a new message",
|
||||
session: "Keep the same tier for the session. Requires a client session ID",
|
||||
every_request: t("addModel.classifierPrimarySettings.frequency.everyRequestHelp"),
|
||||
user_turn: t("addModel.classifierPrimarySettings.frequency.userTurnHelp"),
|
||||
session: t("addModel.classifierPrimarySettings.frequency.sessionHelp"),
|
||||
}[frequency];
|
||||
const usesJudge = usesLlmClassifier(effectiveClassifierType(value));
|
||||
const missingJudge = showValidationErrors && usesJudge && !value.classifier_llm_config?.model;
|
||||
|
|
@ -48,12 +50,12 @@ export default function ClassifierPrimarySettings({
|
|||
>
|
||||
{section !== "judge" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
|
||||
<Label htmlFor={`${id}-frequency`}>{t("addModel.classifierPrimarySettings.frequency.label")}</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: "every_request", label: "Every request" },
|
||||
{ value: "user_turn", label: "Every new user message" },
|
||||
{ value: "session", label: "Once per session" },
|
||||
{ value: "every_request", label: t("addModel.classifierPrimarySettings.frequency.everyRequest") },
|
||||
{ value: "user_turn", label: t("addModel.classifierPrimarySettings.frequency.userTurn") },
|
||||
{ value: "session", label: t("addModel.classifierPrimarySettings.frequency.session") },
|
||||
]}
|
||||
value={frequency}
|
||||
onValueChange={(frequency) => {
|
||||
|
|
@ -64,10 +66,12 @@ export default function ClassifierPrimarySettings({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every_request">Every request</SelectItem>
|
||||
<SelectItem value="user_turn">Every new user message</SelectItem>
|
||||
<SelectItem value="every_request">
|
||||
{t("addModel.classifierPrimarySettings.frequency.everyRequest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="user_turn">{t("addModel.classifierPrimarySettings.frequency.userTurn")}</SelectItem>
|
||||
<SelectItem value="session" disabled={Boolean(restriction)}>
|
||||
Once per session
|
||||
{t("addModel.classifierPrimarySettings.frequency.session")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -76,13 +80,13 @@ export default function ClassifierPrimarySettings({
|
|||
)}
|
||||
{section !== "frequency" && usesJudge && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-judge`}>Judge model</Label>
|
||||
<Label htmlFor={`${id}-judge`}>{t("addModel.classifierPrimarySettings.judge.label")}</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-judge`}
|
||||
aria-label="Judge model"
|
||||
aria-label={t("addModel.classifierPrimarySettings.judge.label")}
|
||||
options={modelOptions}
|
||||
value={value.classifier_llm_config?.model ?? ""}
|
||||
placeholder="Select the judge model"
|
||||
placeholder={t("addModel.classifierPrimarySettings.judge.placeholder")}
|
||||
allowClear={false}
|
||||
className={missingJudge ? "border-destructive" : undefined}
|
||||
onValueChange={(model) => {
|
||||
|
|
@ -100,7 +104,7 @@ export default function ClassifierPrimarySettings({
|
|||
/>
|
||||
{missingJudge && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
A judge model is required
|
||||
{t("addModel.classifierPrimarySettings.judge.required")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { getAutoRouterClassifierDefaultPromptCall } from "@/components/networking";
|
||||
|
|
@ -24,6 +26,7 @@ const ClassifierPromptEditor: React.FC<ClassifierPromptEditorProps> = ({
|
|||
tierLabels,
|
||||
classificationRubric,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [defaultPrompt, setDefaultPrompt] = useState("");
|
||||
|
|
@ -47,7 +50,7 @@ const ClassifierPromptEditor: React.FC<ClassifierPromptEditorProps> = ({
|
|||
setDefaultPrompt(fetched);
|
||||
setDraft(initialDraftText(systemPrompt, fetched));
|
||||
} catch {
|
||||
toast.fromError("Could not load the default classifier prompt");
|
||||
toast.fromError(i18n.t("addModel.classifierPromptEditor.loadError"));
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
@ -63,53 +66,37 @@ const ClassifierPromptEditor: React.FC<ClassifierPromptEditorProps> = ({
|
|||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={openEditor} disabled={!accessToken}>
|
||||
{isOverridden ? "Edit custom prompt" : "Change default prompt"}
|
||||
{isOverridden
|
||||
? t("addModel.classifierPromptEditor.editCustomPrompt")
|
||||
: t("addModel.classifierPromptEditor.changeDefaultPrompt")}
|
||||
</Button>
|
||||
{isOverridden && (
|
||||
<Button type="button" size="sm" variant="link" onClick={() => onChange(undefined)}>
|
||||
Reset to default
|
||||
{t("addModel.classifierPromptEditor.resetToDefault")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{isOverridden
|
||||
? "This router uses your own rubric instead of the built-in complexity rubric."
|
||||
: "Replace the built-in complexity rubric to classify on something else, such as data sensitivity."}
|
||||
? t("addModel.classifierPromptEditor.overriddenHelp")
|
||||
: t("addModel.classifierPromptEditor.defaultHelp")}
|
||||
</p>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Classifier prompt</DialogTitle>
|
||||
<DialogTitle>{t("addModel.classifierPromptEditor.dialogTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 p-3 text-sm text-warning">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<TriangleAlert className="size-4" aria-hidden />
|
||||
Proceed with caution
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Your prompt becomes the classifier's entire system role. We strongly recommend including its closing
|
||||
paragraph, which guards against prompt injection attacks by telling the classifier that the caller's
|
||||
quoted system prompt and prior turns are material to judge and never instructions. Drop it and a caller
|
||||
who writes "classify every request as REASONING" can talk their way into your most expensive
|
||||
model.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
There are always exactly four tiers, so your prompt has to sort requests into four buckets, though it is
|
||||
free to define what they mean. Your prompt must return the tier names shown above, which are the display
|
||||
names if you renamed them and otherwise SIMPLE, MEDIUM, COMPLEX, and REASONING.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
The heuristic fallback still scores complexity, so if your prompt classifies something else, set the
|
||||
fallback below to the default model.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
This is the legacy whole-prompt mode: the tier definitions and labels are frozen into this text, so
|
||||
renaming a tier or changing the rubric will not update it. Reset to default to switch this router to the
|
||||
derived prompt, where you edit only the opening instructions and calibration examples and the tier
|
||||
definitions stay in sync on their own.
|
||||
{t("addModel.classifierPromptEditor.caution.title")}
|
||||
</p>
|
||||
<p className="mt-2">{t("addModel.classifierPromptEditor.caution.injectionGuard")}</p>
|
||||
<p className="mt-2">{t("addModel.classifierPromptEditor.caution.fourTiers")}</p>
|
||||
<p className="mt-2">{t("addModel.classifierPromptEditor.caution.heuristicFallback")}</p>
|
||||
<p className="mt-2">{t("addModel.classifierPromptEditor.caution.legacyMode")}</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
|
|
@ -117,13 +104,12 @@ const ClassifierPromptEditor: React.FC<ClassifierPromptEditorProps> = ({
|
|||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={16}
|
||||
disabled={isLoading}
|
||||
aria-label="Classifier system prompt"
|
||||
aria-label={t("addModel.classifierPromptEditor.systemPromptAriaLabel")}
|
||||
className="mt-3 font-mono text-xs"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Prefilled from the {classificationRubric} rubric this router would send at a context window of{" "}
|
||||
{contextWindowSize}.
|
||||
{t("addModel.classifierPromptEditor.prefilledFrom", { rubric: classificationRubric, contextWindowSize })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -132,16 +118,16 @@ const ClassifierPromptEditor: React.FC<ClassifierPromptEditorProps> = ({
|
|||
onClick={() => setDraft(defaultPrompt)}
|
||||
disabled={isLoading || draft === defaultPrompt}
|
||||
>
|
||||
Restore default text
|
||||
{t("addModel.classifierPromptEditor.restoreDefaultText")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
{t("addModel.classifierPromptEditor.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={isLoading || !draft.trim()}>
|
||||
Save prompt
|
||||
{t("addModel.classifierPromptEditor.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Info } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { ReasoningEffort } from "./complexity_router_tiers";
|
||||
|
|
@ -29,6 +30,7 @@ const ClassifierReasoningEffortSelect = ({
|
|||
explicitlySupported,
|
||||
onChange,
|
||||
}: ClassifierReasoningEffortSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const status = effortStatusFor(value, explicitlySupported);
|
||||
const options = Array.from(new Set([...(explicitlySupported ?? []), ...(value ? [value] : [])]));
|
||||
|
||||
|
|
@ -40,14 +42,14 @@ const ClassifierReasoningEffortSelect = ({
|
|||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="font-semibold">Reasoning Effort</strong>
|
||||
<SimpleTooltip content="Sent only to the classifier call. Default leaves the classifier deployment or provider setting unchanged.">
|
||||
<strong className="font-semibold">{t("addModel.classifierReasoningEffortSelect.title")}</strong>
|
||||
<SimpleTooltip content={t("addModel.classifierReasoningEffortSelect.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<Select
|
||||
items={[
|
||||
{ value: PROVIDER_DEFAULT, label: "Default" },
|
||||
{ value: PROVIDER_DEFAULT, label: t("addModel.classifierReasoningEffortSelect.default") },
|
||||
...options.map((effort) => ({ value: effort, label: optionLabel(effort) })),
|
||||
]}
|
||||
value={value ?? PROVIDER_DEFAULT}
|
||||
|
|
@ -55,11 +57,14 @@ const ClassifierReasoningEffortSelect = ({
|
|||
effort && onChange(effort === PROVIDER_DEFAULT ? undefined : (effort as ReasoningEffort))
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label={`Reasoning effort for classifier model ${model}`} className="w-full">
|
||||
<SelectTrigger
|
||||
aria-label={t("addModel.classifierReasoningEffortSelect.ariaLabel", { model })}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROVIDER_DEFAULT}>Default</SelectItem>
|
||||
<SelectItem value={PROVIDER_DEFAULT}>{t("addModel.classifierReasoningEffortSelect.default")}</SelectItem>
|
||||
{options.map((effort) => (
|
||||
<SelectItem key={effort} value={effort}>
|
||||
{optionLabel(effort)}
|
||||
|
|
@ -69,15 +74,11 @@ const ClassifierReasoningEffortSelect = ({
|
|||
</Select>
|
||||
{status === "unverified" && (
|
||||
<p className="mt-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
This saved effort cannot be verified for the selected model. Choose Default unless you have confirmed provider
|
||||
support.
|
||||
{t("addModel.classifierReasoningEffortSelect.unverified")}
|
||||
</p>
|
||||
)}
|
||||
{status === "unsupported" && (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This saved effort is not supported by every deployment in the selected model group. Choose Default or a
|
||||
supported value before saving.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-destructive">{t("addModel.classifierReasoningEffortSelect.unsupported")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
|
@ -13,6 +14,7 @@ interface ClassifierTypeRadiosProps {
|
|||
}
|
||||
|
||||
const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, classifierType, onTypeChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const scorerLocked = Boolean(value.custom_tier_set);
|
||||
const scorerLockedReason = restrictedBy(value, "heuristicClassifier")?.reason;
|
||||
return (
|
||||
|
|
@ -26,10 +28,8 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
|
|||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" disabled={scorerLocked} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default), rule-based scoring with no API calls and <1ms latency
|
||||
</span>
|
||||
<strong className="font-semibold">{t("addModel.classifierTypeRadios.heuristic.label")}</strong>{" "}
|
||||
<span className="text-muted-foreground">{t("addModel.classifierTypeRadios.heuristic.description")}</span>
|
||||
</span>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
|
|
@ -37,9 +37,9 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
|
|||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic_v2" className="mt-0.5" disabled={scorerLocked} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic v2</strong>{" "}
|
||||
<strong className="font-semibold">{t("addModel.classifierTypeRadios.heuristicV2.label")}</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
uses bundled calibrated four-tier probabilities with no API call
|
||||
{t("addModel.classifierTypeRadios.heuristicV2.description")}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
|
|
@ -47,24 +47,24 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
|
|||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="llm" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">LLM Classifier</strong>{" "}
|
||||
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
|
||||
<strong className="font-semibold">{t("addModel.classifierTypeRadios.llm.label")}</strong>{" "}
|
||||
<span className="text-muted-foreground">{t("addModel.classifierTypeRadios.llm.description")}</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="jev" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">OSS Classifier</strong>{" "}
|
||||
<span className="text-muted-foreground">uses Jev, Laya, Bespoke, or Databricks to decide the tier</span>
|
||||
<strong className="font-semibold">{t("addModel.classifierTypeRadios.oss.label")}</strong>{" "}
|
||||
<span className="text-muted-foreground">{t("addModel.classifierTypeRadios.oss.description")}</span>
|
||||
</span>
|
||||
</Label>
|
||||
<SimpleTooltip content={scorerLockedReason}>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic_first" className="mt-0.5" disabled={scorerLocked} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic first</strong>{" "}
|
||||
<strong className="font-semibold">{t("addModel.classifierTypeRadios.heuristicFirst.label")}</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
|
||||
{t("addModel.classifierTypeRadios.heuristicFirst.description")}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
|
|
@ -73,11 +73,8 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
|
|||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="hybrid" className="mt-0.5" disabled={scorerLocked} />
|
||||
<span>
|
||||
<strong className="font-semibold">Hybrid</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
keeps the local score at any tier, and only pays for the classifier when that score lands near a tier
|
||||
boundary
|
||||
</span>
|
||||
<strong className="font-semibold">{t("addModel.classifierTypeRadios.hybrid.label")}</strong>{" "}
|
||||
<span className="text-muted-foreground">{t("addModel.classifierTypeRadios.hybrid.description")}</span>
|
||||
</span>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ClassifierLLMConfigWire } from "./build_complexity_router_config";
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ interface ClassifierVisionConfigProps {
|
|||
}
|
||||
|
||||
const ClassifierVisionConfig: React.FC<ClassifierVisionConfigProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [draftMaxImages, setDraftMaxImages] = React.useState<string | null>(null);
|
||||
const enabled = value.vision?.enabled ?? DEFAULT_CLASSIFIER_VISION_ENABLED;
|
||||
|
||||
|
|
@ -49,17 +51,15 @@ const ClassifierVisionConfig: React.FC<ClassifierVisionConfigProps> = ({ value,
|
|||
},
|
||||
});
|
||||
}}
|
||||
aria-label="Use images for classification"
|
||||
aria-label={t("addModel.classifierVisionConfig.label")}
|
||||
/>
|
||||
<strong className="font-semibold">Use images for classification</strong>
|
||||
<strong className="font-semibold">{t("addModel.classifierVisionConfig.label")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Send inline image data to the classifier so it can choose a tier from what the image shows.
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("addModel.classifierVisionConfig.help")}</span>
|
||||
{enabled && (
|
||||
<div>
|
||||
<Label htmlFor={MAX_IMAGES_ID} className="block mb-1 font-semibold">
|
||||
Maximum images per request
|
||||
{t("addModel.classifierVisionConfig.maxImages")}
|
||||
</Label>
|
||||
<Input
|
||||
id={MAX_IMAGES_ID}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -87,6 +88,7 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
tierRows,
|
||||
customTierSet,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
const classificationProps = {
|
||||
advancedOnly: true,
|
||||
|
|
@ -104,7 +106,11 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
? [
|
||||
{
|
||||
key: "classifier",
|
||||
label: <strong className="text-foreground font-semibold">Classifier tuning</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.classifierTuning")}
|
||||
</strong>
|
||||
),
|
||||
children: (
|
||||
<ForecastClassifierConfig
|
||||
section="advanced"
|
||||
|
|
@ -121,7 +127,11 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
? [
|
||||
{
|
||||
key: "classifier",
|
||||
label: <strong className="text-foreground font-semibold">Classification Method</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.classificationMethod")}
|
||||
</strong>
|
||||
),
|
||||
children: <ClassificationMethodConfig {...classificationProps} section="classifier" />,
|
||||
},
|
||||
]
|
||||
|
|
@ -130,78 +140,126 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
? [
|
||||
{
|
||||
key: "heuristic",
|
||||
label: <strong className="text-foreground font-semibold">Heuristic tuning</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.heuristicTuning")}
|
||||
</strong>
|
||||
),
|
||||
children: <ClassificationMethodConfig {...classificationProps} section="heuristic" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "adaptive",
|
||||
label: <strong className="text-foreground font-semibold">Adaptive Routing</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.adaptiveRouting")}
|
||||
</strong>
|
||||
),
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "adaptive")}>
|
||||
<Restricted by={restrictedBy(value, "adaptive", t)}>
|
||||
<AdaptiveRoutingConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "affinity",
|
||||
label: <strong className="text-foreground font-semibold">Affinity</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.affinity")}
|
||||
</strong>
|
||||
),
|
||||
children: <AffinityControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "cache-aware",
|
||||
label: <strong className="text-foreground font-semibold">Cache-aware routing</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.cacheAwareRouting")}
|
||||
</strong>
|
||||
),
|
||||
children: <CacheAwareRoutingConfig value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "modality",
|
||||
label: <strong className="text-foreground font-semibold">Modality Routing</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.modalityRouting")}
|
||||
</strong>
|
||||
),
|
||||
children: <ModalityRoutingControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "plan-mode",
|
||||
label: <strong className="text-foreground font-semibold">Plan-Mode Override</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.planModeOverride")}
|
||||
</strong>
|
||||
),
|
||||
children: (
|
||||
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "housekeeping",
|
||||
label: <strong className="text-foreground font-semibold">Housekeeping Routing</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.housekeepingRouting")}
|
||||
</strong>
|
||||
),
|
||||
children: <HousekeepingRoutingControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "reminder-markers",
|
||||
label: <strong className="text-foreground font-semibold">Ignore Custom Tags</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.ignoreCustomTags")}
|
||||
</strong>
|
||||
),
|
||||
children: <ReminderMarkers value={value} onChange={onChange} showValidationErrors={showValidationErrors} />,
|
||||
},
|
||||
{
|
||||
key: "context-window",
|
||||
label: <strong className="text-foreground font-semibold">Context Window Escalation</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.contextWindowEscalation")}
|
||||
</strong>
|
||||
),
|
||||
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "stall-escalation",
|
||||
label: <strong className="text-foreground font-semibold">Stalled Task Escalation</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.stalledTaskEscalation")}
|
||||
</strong>
|
||||
),
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "stallEscalation")}>
|
||||
<Restricted by={restrictedBy(value, "stallEscalation", t)}>
|
||||
<StallEscalationConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "response",
|
||||
label: <strong className="text-foreground font-semibold">Response Format</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.responseFormat")}
|
||||
</strong>
|
||||
),
|
||||
children: <ResponseFormatControls value={value} onChange={onChange} />,
|
||||
},
|
||||
...(onEscalationKeywordsChange
|
||||
? [
|
||||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Escalation Keywords</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.escalationKeywords")}
|
||||
</strong>
|
||||
),
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "escalation")}>
|
||||
<Restricted by={restrictedBy(value, "escalation", t)}>
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
</Restricted>
|
||||
),
|
||||
|
|
@ -212,7 +270,11 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
? [
|
||||
{
|
||||
key: "compression",
|
||||
label: <strong className="text-foreground font-semibold">Compression</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.compression")}
|
||||
</strong>
|
||||
),
|
||||
children: <CompressionControls value={autoRouterCompression} onChange={onAutoRouterCompressionChange} />,
|
||||
},
|
||||
]
|
||||
|
|
@ -221,7 +283,11 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
? [
|
||||
{
|
||||
key: "keyword-semantic",
|
||||
label: <strong className="text-foreground font-semibold">Keyword/Semantic Matching</strong>,
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{t("addModel.complexityRouterAdvancedSections.sections.keywordSemanticMatching")}
|
||||
</strong>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{onKeywordTierRulesChange && (
|
||||
|
|
@ -253,11 +319,16 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
];
|
||||
|
||||
const groups = [
|
||||
{ label: "Heuristic tuning", keys: ["heuristic"] },
|
||||
{ label: usesLlmClassifier(classifierType) ? "LLM tuning" : "Classifier tuning", keys: ["classifier"] },
|
||||
{ label: "Request preprocessing", keys: ["reminder-markers"] },
|
||||
{ label: t("addModel.complexityRouterAdvancedSections.groups.heuristicTuning"), keys: ["heuristic"] },
|
||||
{
|
||||
label: "Routing rules and recovery",
|
||||
label: usesLlmClassifier(classifierType)
|
||||
? t("addModel.complexityRouterAdvancedSections.groups.llmTuning")
|
||||
: t("addModel.complexityRouterAdvancedSections.groups.classifierTuning"),
|
||||
keys: ["classifier"],
|
||||
},
|
||||
{ label: t("addModel.complexityRouterAdvancedSections.groups.requestPreprocessing"), keys: ["reminder-markers"] },
|
||||
{
|
||||
label: t("addModel.complexityRouterAdvancedSections.groups.routingRulesAndRecovery"),
|
||||
keys: [
|
||||
"modality",
|
||||
"plan-mode",
|
||||
|
|
@ -268,8 +339,11 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
"keyword-semantic",
|
||||
],
|
||||
},
|
||||
{ label: "Sessions and efficiency", keys: ["affinity", "adaptive", "cache-aware", "compression"] },
|
||||
{ label: "Compatibility", keys: ["response"] },
|
||||
{
|
||||
label: t("addModel.complexityRouterAdvancedSections.groups.sessionsAndEfficiency"),
|
||||
keys: ["affinity", "adaptive", "cache-aware", "compression"],
|
||||
},
|
||||
{ label: t("addModel.complexityRouterAdvancedSections.groups.compatibility"), keys: ["response"] },
|
||||
].filter(
|
||||
(group) =>
|
||||
!(hideClassifier && group.keys.includes("classifier")) && sections.some(({ key }) => group.keys.includes(key)),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import RoutingOptions from "./RoutingOptions";
|
||||
import ClassifierPrimarySettings from "./ClassifierPrimarySettings";
|
||||
import { AutoRouterAllowanceNote } from "./AutoRouterAvailability";
|
||||
|
|
@ -194,12 +197,14 @@ export const effectiveClassifierType = (
|
|||
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
|
||||
): ClassifierType => (value.custom_tier_set && value.classifier_type !== "jev" ? "llm" : value.classifier_type);
|
||||
|
||||
const rowOrigin = (row: TierRow, editing: boolean): string => {
|
||||
const rowOrigin = (row: TierRow, editing: boolean, t: TFunction = i18n.t.bind(i18n)): string => {
|
||||
if (!editing) return row.id;
|
||||
return isBuiltInTierName(row.name) ? "built-in" : "custom";
|
||||
return isBuiltInTierName(row.name)
|
||||
? t("addModel.complexityRouterConfig.rowOrigin.builtIn")
|
||||
: t("addModel.complexityRouterConfig.rowOrigin.custom");
|
||||
};
|
||||
|
||||
const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
|
||||
const builtInTierInfo = (rowId: string): { label: string; description: ParseKeys; examples: ParseKeys } | undefined => {
|
||||
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId);
|
||||
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
|
||||
};
|
||||
|
|
@ -213,75 +218,82 @@ const TierSetToolbar: React.FC<{
|
|||
onEditingChange: ((editing: boolean) => void) | undefined;
|
||||
onAdd: () => void;
|
||||
onRestore: () => void;
|
||||
}> = ({ editing, isCustomSet, rowCount, rowsError, keywordRulesError, onEditingChange, onAdd, onRestore }) => (
|
||||
<>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onAdd} disabled={rowCount >= MAX_TIER_COUNT}>
|
||||
<Plus />
|
||||
Add tier
|
||||
</Button>
|
||||
<SimpleTooltip content={rowsError || undefined}>
|
||||
<Button variant="outline" disabled={Boolean(rowsError)} onClick={() => onEditingChange?.(false)}>
|
||||
Done
|
||||
}> = ({ editing, isCustomSet, rowCount, rowsError, keywordRulesError, onEditingChange, onAdd, onRestore }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onAdd} disabled={rowCount >= MAX_TIER_COUNT}>
|
||||
<Plus />
|
||||
{t("addModel.complexityRouterConfig.toolbar.addTier")}
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
{isCustomSet && (
|
||||
<Button variant="outline" size="sm" onClick={onRestore}>
|
||||
Restore defaults
|
||||
<SimpleTooltip content={rowsError || undefined}>
|
||||
<Button variant="outline" disabled={Boolean(rowsError)} onClick={() => onEditingChange?.(false)}>
|
||||
{t("addModel.complexityRouterConfig.toolbar.done")}
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
{isCustomSet && (
|
||||
<Button variant="outline" size="sm" onClick={onRestore}>
|
||||
{t("addModel.complexityRouterConfig.toolbar.restoreDefaults")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onEditingChange && (
|
||||
<Button variant="outline" onClick={() => onEditingChange(true)}>
|
||||
{t("addModel.complexityRouterConfig.toolbar.editTiers")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onEditingChange && (
|
||||
<Button variant="outline" onClick={() => onEditingChange(true)}>
|
||||
Edit tiers
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label={t("addModel.complexityRouterConfig.toolbar.allowanceLabel")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label="Custom tiers and written prompts share this allowance"
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
Add or remove tiers to define your own set. Every custom tier needs a definition the classifier routes on, and
|
||||
an edited set requires the LLM or OSS classification method
|
||||
</span>
|
||||
)}
|
||||
{editing && keywordRulesError && (
|
||||
<span className="block mt-1 text-xs text-destructive">
|
||||
{keywordRulesError}. Edit the rules under Advanced: Keyword/Semantic Matching, or bring the tier back
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{editing && (
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
{t("addModel.complexityRouterConfig.toolbar.editingHint")}
|
||||
</span>
|
||||
)}
|
||||
{editing && keywordRulesError && (
|
||||
<span className="block mt-1 text-xs text-destructive">
|
||||
{t("addModel.complexityRouterConfig.toolbar.keywordRulesError", { error: keywordRulesError })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FallbackTierField: React.FC<{
|
||||
rows: readonly TierRow[];
|
||||
fallbackTierId: string;
|
||||
onValueChange: (rowId: string) => void;
|
||||
}> = ({ rows, fallbackTierId, onValueChange }) => (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Fallback Tier</strong>
|
||||
<SimpleTooltip content="Where requests route when the classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
}> = ({ rows, fallbackTierId, onValueChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{t("addModel.complexityRouterConfig.fallbackTier.title")}</strong>
|
||||
<SimpleTooltip content={t("addModel.complexityRouterConfig.fallbackTier.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<TierRowSelect
|
||||
label={t("addModel.complexityRouterConfig.fallbackTier.label")}
|
||||
options={rows
|
||||
.filter((row) => activeTierName(row))
|
||||
.map((row) => ({ value: row.id, label: activeTierName(row) }))}
|
||||
value={fallbackTierId || null}
|
||||
onValueChange={onValueChange}
|
||||
placeholder={t("addModel.complexityRouterConfig.fallbackTier.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<TierRowSelect
|
||||
label="Fallback tier"
|
||||
options={rows.filter((row) => activeTierName(row)).map((row) => ({ value: row.id, label: activeTierName(row) }))}
|
||||
value={fallbackTierId || null}
|
||||
onValueChange={onValueChange}
|
||||
placeholder="Pick the tier classifier failures route to"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const TierRowHeader: React.FC<{
|
||||
row: TierRow;
|
||||
|
|
@ -292,72 +304,85 @@ const TierRowHeader: React.FC<{
|
|||
editing: boolean;
|
||||
isCustomSet: boolean;
|
||||
onRemove: () => void;
|
||||
}> = ({ row, index, rowCount, label, description, editing, isCustomSet, onRemove }) => (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
row.definition.trim() ||
|
||||
description ||
|
||||
"A tier you defined. The classifier routes requests matching its definition here."
|
||||
}
|
||||
>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {rowCount} · {rowOrigin(row, isCustomSet)}
|
||||
</span>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove the ${activeTierName(row) || `tier ${index + 1}`} tier`}
|
||||
disabled={rowCount <= MIN_TIER_COUNT}
|
||||
onClick={onRemove}
|
||||
}> = ({ row, index, rowCount, label, description, editing, isCustomSet, onRemove }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">
|
||||
{t("addModel.complexityRouterConfig.rowHeader.title", { label })}
|
||||
</strong>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
row.definition.trim() || description || t("addModel.complexityRouterConfig.rowHeader.customTierTooltip")
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("addModel.complexityRouterConfig.rowHeader.position", {
|
||||
number: index + 1,
|
||||
count: rowCount,
|
||||
origin: rowOrigin(row, isCustomSet, t),
|
||||
})}
|
||||
</span>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={t("addModel.complexityRouterConfig.rowHeader.removeAria", {
|
||||
name:
|
||||
activeTierName(row) || t("addModel.complexityRouterConfig.rowHeader.unnamedTier", { number: index + 1 }),
|
||||
})}
|
||||
disabled={rowCount <= MIN_TIER_COUNT}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 />
|
||||
{t("addModel.complexityRouterConfig.rowHeader.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TierRowEditFields: React.FC<{
|
||||
row: TierRow;
|
||||
index: number;
|
||||
definitionMissing: boolean;
|
||||
onPatch: (patch: Partial<Omit<TierRow, "id">>) => void;
|
||||
}> = ({ row, index, definitionMissing, onPatch }) => (
|
||||
<>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) => onPatch({ name: event.target.value })}
|
||||
placeholder="Tier name, e.g. SECURITY_REVIEW"
|
||||
aria-label={`Name for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_NAME_CHARS}
|
||||
className="mb-2"
|
||||
/>
|
||||
<Textarea
|
||||
value={row.definition}
|
||||
onChange={(event) => onPatch({ definition: event.target.value.replace(/[\r\n]+/g, " ") })}
|
||||
placeholder={
|
||||
isBuiltInTierName(row.name)
|
||||
? "Leave blank to keep the built-in definition"
|
||||
: "What belongs in this tier, e.g. requests asking for a security audit"
|
||||
}
|
||||
aria-label={`Definition for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_DEFINITION_CHARS}
|
||||
rows={2}
|
||||
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{definitionMissing && (
|
||||
<span className="mb-2 block text-xs text-destructive">
|
||||
A definition is required: it is the rubric the classifier routes on for this tier
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}> = ({ row, index, definitionMissing, onPatch }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) => onPatch({ name: event.target.value })}
|
||||
placeholder={t("addModel.complexityRouterConfig.editFields.namePlaceholder")}
|
||||
aria-label={t("addModel.complexityRouterConfig.editFields.nameAria", { number: index + 1 })}
|
||||
maxLength={MAX_TIER_NAME_CHARS}
|
||||
className="mb-2"
|
||||
/>
|
||||
<Textarea
|
||||
value={row.definition}
|
||||
onChange={(event) => onPatch({ definition: event.target.value.replace(/[\r\n]+/g, " ") })}
|
||||
placeholder={
|
||||
isBuiltInTierName(row.name)
|
||||
? t("addModel.complexityRouterConfig.editFields.builtInDefinitionPlaceholder")
|
||||
: t("addModel.complexityRouterConfig.editFields.customDefinitionPlaceholder")
|
||||
}
|
||||
aria-label={t("addModel.complexityRouterConfig.editFields.definitionAria", { number: index + 1 })}
|
||||
maxLength={MAX_TIER_DEFINITION_CHARS}
|
||||
rows={2}
|
||||
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{definitionMissing && (
|
||||
<span className="mb-2 block text-xs text-destructive">
|
||||
{t("addModel.complexityRouterConfig.editFields.definitionRequired")}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export type AdaptiveEligible = "all" | "classified_tier";
|
||||
|
||||
|
|
@ -506,32 +531,32 @@ interface ComplexityRouterConfigProps {
|
|||
|
||||
export const TIER_DESCRIPTIONS: Record<
|
||||
keyof ComplexityTiers,
|
||||
{ label: string; description: string; examples: string }
|
||||
{ label: string; description: ParseKeys; examples: ParseKeys }
|
||||
> = {
|
||||
NON_REASONING: {
|
||||
label: "Non-reasoning",
|
||||
description: "Operational relay work: passing information along with no judgment about it",
|
||||
examples: '"Reformat this tool output", "Acknowledge the write succeeded"',
|
||||
description: "addModel.complexityRouterConfig.tiers.nonReasoning.description",
|
||||
examples: "addModel.complexityRouterConfig.tiers.nonReasoning.examples",
|
||||
},
|
||||
SIMPLE: {
|
||||
label: "Simple",
|
||||
description: "Basic questions, greetings, simple factual queries",
|
||||
examples: '"Hello!", "What is Python?", "Thanks!"',
|
||||
description: "addModel.complexityRouterConfig.tiers.simple.description",
|
||||
examples: "addModel.complexityRouterConfig.tiers.simple.examples",
|
||||
},
|
||||
MEDIUM: {
|
||||
label: "Medium",
|
||||
description: "Standard queries requiring some reasoning or explanation",
|
||||
examples: '"Explain how REST APIs work", "Debug this error"',
|
||||
description: "addModel.complexityRouterConfig.tiers.medium.description",
|
||||
examples: "addModel.complexityRouterConfig.tiers.medium.examples",
|
||||
},
|
||||
COMPLEX: {
|
||||
label: "Complex",
|
||||
description: "Technical, multi-part requests requiring deep knowledge",
|
||||
examples: '"Design a microservices architecture", "Implement a rate limiter"',
|
||||
description: "addModel.complexityRouterConfig.tiers.complex.description",
|
||||
examples: "addModel.complexityRouterConfig.tiers.complex.examples",
|
||||
},
|
||||
REASONING: {
|
||||
label: "Reasoning",
|
||||
description: "Chain-of-thought, analysis, explicit reasoning requests",
|
||||
examples: '"Think step by step...", "Analyze the pros and cons..."',
|
||||
description: "addModel.complexityRouterConfig.tiers.reasoning.description",
|
||||
examples: "addModel.complexityRouterConfig.tiers.reasoning.examples",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -569,6 +594,7 @@ const CreationClassifierSettings = ({
|
|||
defaultModel: string | undefined;
|
||||
showValidationErrors: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const jevCreation = isJevCreation(value, creationFlow);
|
||||
return (
|
||||
<>
|
||||
|
|
@ -581,7 +607,10 @@ const CreationClassifierSettings = ({
|
|||
/>
|
||||
{jevCreation && (
|
||||
<div className="mb-6">
|
||||
<RoutingOptions label="Advanced classifier settings" showValidationErrors={showValidationErrors}>
|
||||
<RoutingOptions
|
||||
label={t("addModel.complexityRouterConfig.advancedClassifierSettings")}
|
||||
showValidationErrors={showValidationErrors}
|
||||
>
|
||||
<div className="px-4 pb-4">
|
||||
<ClassificationMethodConfig
|
||||
advancedOnly
|
||||
|
|
@ -627,16 +656,19 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onAutoRouterCompressionChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const forecast = isForecastClassifier(value.classifier_type);
|
||||
const tierHeading = creationFlow ? "Define your tiers" : "Models by tier";
|
||||
const tierHeading = creationFlow
|
||||
? t("addModel.complexityRouterConfig.defineTiers")
|
||||
: t("addModel.complexityRouterConfig.modelsByTier");
|
||||
const customTierSet = value.custom_tier_set;
|
||||
const tierRows = activeTierRows(value);
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet, t) : null;
|
||||
|
||||
const planModeRows = tierRows.filter((row) => row.models.length > 0);
|
||||
const planModeTierOptions = planModeRows.map((row) => ({
|
||||
value: row.id,
|
||||
label: tierRowLabel(row, value.tier_labels),
|
||||
label: tierRowLabel(row, value.tier_labels, t),
|
||||
}));
|
||||
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
|
||||
|
||||
|
|
@ -686,9 +718,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{creationFlow && <Separator className="my-6" />}
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">{forecast ? "Solver models" : tierHeading}</h4>
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">
|
||||
{forecast ? t("addModel.complexityRouterConfig.solverModels") : tierHeading}
|
||||
</h4>
|
||||
{!forecast && (
|
||||
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
|
||||
<SimpleTooltip content={t("addModel.complexityRouterConfig.tierMappingTooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
|
|
@ -722,7 +756,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<CardContent>
|
||||
{tierRows.map((row, index) => {
|
||||
const tierInfo = builtInTierInfo(row.id);
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
const label = tierRowLabel(row, value.tier_labels, t);
|
||||
const tierMissing = showValidationErrors && row.models.length === 0;
|
||||
const needsDefinition =
|
||||
Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
|
||||
|
|
@ -737,13 +771,15 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
index={index}
|
||||
rowCount={tierRows.length}
|
||||
label={label}
|
||||
description={tierInfo?.description}
|
||||
description={tierInfo && t(tierInfo.description)}
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
onRemove={() => removeTierRow(row.id)}
|
||||
/>
|
||||
{tierInfo && !customTierSet && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
<span className="block mb-2 text-xs text-muted-foreground">
|
||||
{t("addModel.complexityRouterConfig.examples", { examples: t(tierInfo.examples) })}
|
||||
</span>
|
||||
)}
|
||||
{editingTiers && (
|
||||
<TierRowEditFields
|
||||
|
|
@ -760,14 +796,20 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onChange={(event) =>
|
||||
handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)
|
||||
}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
placeholder={t("addModel.complexityRouterConfig.displayName.placeholder", {
|
||||
label: tierInfo.label,
|
||||
})}
|
||||
aria-label={t("addModel.complexityRouterConfig.displayName.aria", {
|
||||
label: tierInfo.label,
|
||||
})}
|
||||
/>
|
||||
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
aria-label={t("addModel.complexityRouterConfig.displayName.clearAria", {
|
||||
label: tierInfo.label,
|
||||
})}
|
||||
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
|
||||
>
|
||||
<X />
|
||||
|
|
@ -780,8 +822,10 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
options={modelOptions}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) => setRowModels(row, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
placeholder={t("addModel.complexityRouterConfig.selectModelsPlaceholder", {
|
||||
tier: label.toLowerCase(),
|
||||
})}
|
||||
emptyText={t("addModel.complexityRouterConfig.noModelsFound")}
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
<TierModelEffortRows
|
||||
|
|
@ -799,11 +843,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
/>
|
||||
{row.models.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected: the router randomly picks among them per request (or
|
||||
Thompson-samples within the pool when adaptive routing is on).
|
||||
{t("addModel.complexityRouterConfig.multipleModelsSelected")}
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t("addModel.complexityRouterConfig.tierRequired", { label })}
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -844,9 +891,10 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<RoutingOptions
|
||||
showValidationErrors={showValidationErrors}
|
||||
summary={
|
||||
{ hybrid: "Hybrid local checks enabled", heuristic_first: "Heuristic first enabled" }[
|
||||
value.classifier_type as "hybrid" | "heuristic_first"
|
||||
]
|
||||
{
|
||||
hybrid: t("addModel.complexityRouterConfig.summary.hybrid"),
|
||||
heuristic_first: t("addModel.complexityRouterConfig.summary.heuristicFirst"),
|
||||
}[value.classifier_type as "hybrid" | "heuristic_first"]
|
||||
}
|
||||
>
|
||||
{forecast && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
|
||||
import {
|
||||
AutoRouterCompressionState,
|
||||
|
|
@ -19,6 +20,7 @@ interface CompressionControlsProps {
|
|||
const NONE_OPTION: SearchSelectOption = { label: "None (no compression)", value: NO_COMPRESSION };
|
||||
|
||||
const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const { routing, sameAsRouting, model } = value;
|
||||
const onRoutingChange = (newRouting: string | undefined) => onChange({ ...value, routing: newRouting });
|
||||
const onSameAsRoutingChange = (newSameAsRouting: boolean) => onChange({ ...value, sameAsRouting: newSameAsRouting });
|
||||
|
|
@ -34,8 +36,8 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Routing decision</span>
|
||||
<SimpleTooltip content="Compression applied to the classifier's own call that picks a tier, separate from the model the request routes to.">
|
||||
<span className="text-sm font-medium">{t("addModel.compressionControls.routing.title")}</span>
|
||||
<SimpleTooltip content={t("addModel.compressionControls.routing.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
|
@ -43,15 +45,15 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
|
|||
options={options}
|
||||
value={routing}
|
||||
onValueChange={(value) => onRoutingChange(value ?? undefined)}
|
||||
placeholder="Inherit from the request's own compression guardrails"
|
||||
emptyText="No compression guardrails found"
|
||||
aria-label="Routing decision compression"
|
||||
placeholder={t("addModel.compressionControls.routing.placeholder")}
|
||||
emptyText={t("addModel.compressionControls.empty")}
|
||||
aria-label={t("addModel.compressionControls.routing.ariaLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routing !== undefined && (
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium">Model call</span>
|
||||
<span className="mb-2 block text-sm font-medium">{t("addModel.compressionControls.model.title")}</span>
|
||||
<RadioGroup
|
||||
value={sameAsRouting ? "same" : "different"}
|
||||
onValueChange={(value: unknown) => onSameAsRoutingChange(value === "same")}
|
||||
|
|
@ -60,11 +62,11 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
|
|||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="same" className="mt-0.5" />
|
||||
<span>Same as the routing decision</span>
|
||||
<span>{t("addModel.compressionControls.model.sameAsRouting")}</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="different" className="mt-0.5" />
|
||||
<span>Use a different compression</span>
|
||||
<span>{t("addModel.compressionControls.model.different")}</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
|
@ -75,9 +77,9 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
|
|||
options={options}
|
||||
value={model}
|
||||
onValueChange={(value) => onModelChange(value ?? undefined)}
|
||||
placeholder="None (no compression)"
|
||||
emptyText="No compression guardrails found"
|
||||
aria-label="Model call compression"
|
||||
placeholder={t("addModel.compressionControls.model.placeholder")}
|
||||
emptyText={t("addModel.compressionControls.empty")}
|
||||
aria-label={t("addModel.compressionControls.model.ariaLabel")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const ContextWindowEscalationConfig: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const enabled = value.enable_context_window_escalation ?? false;
|
||||
// A number input renders Number("0.") as "0", so a decimal cannot be typed without a local draft.
|
||||
const [bufferDraft, setBufferDraft] = React.useState<string | null>(null);
|
||||
|
|
@ -26,19 +28,17 @@ const ContextWindowEscalationConfig: React.FC<{
|
|||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => onChange({ ...value, enable_context_window_escalation: next })}
|
||||
aria-label="Escalate oversized prompts to a tier that fits"
|
||||
aria-label={t("addModel.contextWindowEscalationConfig.escalateOversized")}
|
||||
/>
|
||||
<strong className="font-semibold">Escalate oversized prompts to a tier that fits</strong>
|
||||
<strong className="font-semibold">{t("addModel.contextWindowEscalationConfig.escalateOversized")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
When a prompt provably cannot fit the decided tier's context windows, route it to the lowest tier whose
|
||||
window holds it instead of letting the provider reject it. Disabled by default. Off means requests dispatch on
|
||||
complexity alone.
|
||||
{t("addModel.contextWindowEscalationConfig.description")}
|
||||
</span>
|
||||
{enabled && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="context-window-escalation-buffer">
|
||||
Window fit buffer
|
||||
{t("addModel.contextWindowEscalationConfig.windowFitBuffer")}
|
||||
</label>
|
||||
<Input
|
||||
id="context-window-escalation-buffer"
|
||||
|
|
@ -49,8 +49,7 @@ const ContextWindowEscalationConfig: React.FC<{
|
|||
onBlur={(event) => commitBuffer(event.target.value)}
|
||||
/>
|
||||
<span className="block text-xs mt-1 text-muted-foreground">
|
||||
Fraction of a model's window the counted prompt must fit within, above 0 up to 1. Empty tracks the
|
||||
backend default of 0.95.
|
||||
{t("addModel.contextWindowEscalationConfig.windowFitBufferHelp")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -23,6 +24,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function CustomDimensionRows({ rows, disabled, onChange, onWeight, onAdd, onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<{ id: string; raw: string } | null>(null);
|
||||
const update = (id: string, patch: Partial<CustomDimensionRow>) =>
|
||||
onChange(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
|
|
@ -34,23 +36,25 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
|
|||
<div className="space-y-4">
|
||||
{rows.map((row, index) => (
|
||||
<fieldset key={row.id} className="min-w-0 space-y-3 rounded-md border p-3">
|
||||
<legend className="float-left text-sm font-semibold">Custom dimension {index + 1}</legend>
|
||||
<legend className="float-left text-sm font-semibold">
|
||||
{t("addModel.customDimensionRows.legend", { number: index + 1 })}
|
||||
</legend>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove custom dimension ${index + 1}`}
|
||||
aria-label={t("addModel.customDimensionRows.removeAria", { number: index + 1 })}
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(row.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
{t("addModel.customDimensionRows.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${row.id}-name`}>Name</Label>
|
||||
<Label htmlFor={`${row.id}-name`}>{t("addModel.customDimensionRows.name")}</Label>
|
||||
<Input
|
||||
id={`${row.id}-name`}
|
||||
value={row.name}
|
||||
|
|
@ -59,7 +63,7 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
|
|||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Label htmlFor={`${row.id}-weight`}>Weight</Label>
|
||||
<Label htmlFor={`${row.id}-weight`}>{t("addModel.customDimensionRows.weight")}</Label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
|
|
@ -67,7 +71,9 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
|
|||
disabled={disabled}
|
||||
value={[row.weight]}
|
||||
className="min-w-24 flex-1"
|
||||
aria-label={`${row.name || `Custom dimension ${index + 1}`} weight`}
|
||||
aria-label={t("addModel.customDimensionRows.weightAria", {
|
||||
name: row.name || t("addModel.customDimensionRows.legend", { number: index + 1 }),
|
||||
})}
|
||||
onValueChange={(value) => onWeight(row.id, Array.isArray(value) ? value[0] : value)}
|
||||
/>
|
||||
<Input
|
||||
|
|
@ -84,7 +90,10 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
|
|||
{(["keywords", "patterns"] as const).map((field) => (
|
||||
<div key={field} className="min-w-0 space-y-1">
|
||||
<Label htmlFor={`${row.id}-${field}`}>
|
||||
{field === "keywords" ? "Keywords" : "Regex patterns"} (one per line)
|
||||
{field === "keywords"
|
||||
? t("addModel.customDimensionRows.keywords")
|
||||
: t("addModel.customDimensionRows.patterns")}{" "}
|
||||
{t("addModel.customDimensionRows.onePerLine")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`${row.id}-${field}`}
|
||||
|
|
@ -98,7 +107,7 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
|
|||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${row.id}-scoring`}>Scoring</Label>
|
||||
<Label htmlFor={`${row.id}-scoring`}>{t("addModel.customDimensionRows.scoring")}</Label>
|
||||
<Select
|
||||
items={SCORING_MODES}
|
||||
value={row.scoring_mode ?? "binary"}
|
||||
|
|
@ -117,20 +126,14 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Binary uses the full weight for any hit. Match count uses half for one distinct matcher and full weight
|
||||
for two or more.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.customDimensionRows.scoringHelp")}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled || rows.length >= 16} onClick={onAdd}>
|
||||
Add custom dimension
|
||||
{t("addModel.customDimensionRows.add")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keywords match the current ask. Regex scans its first 2,048 characters and permits bounded single-character
|
||||
repeats up to 64. The proxy validates patterns on save.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.customDimensionRows.matchersHelp")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Info } from "lucide-react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import {
|
||||
DECISIONS_DOCS_URL,
|
||||
|
|
@ -23,6 +24,7 @@ const selectedModelNames = (value: unknown): string[] => {
|
|||
};
|
||||
|
||||
export default function DecisionModelNote({ catalog, litellmProvider }: DecisionModelNoteProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<MountedFormValues>();
|
||||
const model = useWatch({ control, name: "model" });
|
||||
|
||||
|
|
@ -31,21 +33,23 @@ export default function DecisionModelNote({ catalog, litellmProvider }: Decision
|
|||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" role="note" aria-label="Decision model notice" className="mb-4">
|
||||
<Alert variant="info" role="note" aria-label={t("addModel.decisionModelNote.ariaLabel")} className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Decision model</AlertTitle>
|
||||
<AlertTitle>{t("addModel.decisionModelNote.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
For decision requests, call <code>/v1/decisions</code> (OpenAI format) or <code>/v1/systemone</code> (System
|
||||
One format).{" "}
|
||||
<Trans
|
||||
i18nKey="addModel.decisionModelNote.callInstructions"
|
||||
components={{ decisions: <code />, systemOne: <code /> }}
|
||||
/>{" "}
|
||||
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
How to call decision models
|
||||
{t("addModel.decisionModelNote.howToCall")}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
After you add it,{" "}
|
||||
{t("addModel.decisionModelNote.afterYouAddIt")}{" "}
|
||||
<a href={uiHref(SYSTEM_ONE_PLAYGROUND_ROUTE)} className="underline">
|
||||
test it in the Decisions playground
|
||||
{t("addModel.decisionModelNote.testInPlayground")}
|
||||
</a>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
|
|
@ -19,6 +20,7 @@ export default function DecisionModelSelect({
|
|||
modelInfo: ModelGroup[];
|
||||
deployments: AutoRouterDeployment[] | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
const { data: modelCostMapData } = useModelCostMap(true, true);
|
||||
const decisionCatalog = React.useMemo(() => buildDecisionCatalog(modelCostMapData), [modelCostMapData]);
|
||||
|
|
@ -38,13 +40,13 @@ export default function DecisionModelSelect({
|
|||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>Decision model</Label>
|
||||
<Label htmlFor={id}>{t("addModel.decisionModelSelect.label")}</Label>
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
aria-label="Decision model"
|
||||
aria-label={t("addModel.decisionModelSelect.label")}
|
||||
options={decisionModels.map((model) => ({ value: model.model_group, label: model.model_group }))}
|
||||
value={value.jev_classifier_config?.deployment_name ?? ""}
|
||||
placeholder="Select the decision model"
|
||||
placeholder={t("addModel.decisionModelSelect.placeholder")}
|
||||
allowClear={false}
|
||||
onValueChange={(deploymentName) => {
|
||||
if (!deploymentName) return;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import React from "react";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
|
@ -12,15 +15,23 @@ interface DefaultModelFieldProps {
|
|||
modelOptions: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
|
||||
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
|
||||
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
|
||||
const defaultModelPlaceholderFor = (
|
||||
derivedDefaultModel: string | undefined,
|
||||
isCustomSet: boolean,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string => {
|
||||
if (derivedDefaultModel) return t("addModel.defaultModelField.placeholders.derived", { model: derivedDefaultModel });
|
||||
return isCustomSet
|
||||
? t("addModel.defaultModelField.placeholders.customSet")
|
||||
: t("addModel.defaultModelField.placeholders.builtIn");
|
||||
};
|
||||
|
||||
const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultModelPlaceholder = defaultModelPlaceholderFor(
|
||||
resolveComplexityDefaultModel(value),
|
||||
Boolean(value.custom_tier_set),
|
||||
t,
|
||||
);
|
||||
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
|
||||
// "track the tiers" everywhere downstream instead of as a blank model name.
|
||||
|
|
@ -29,10 +40,10 @@ const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldP
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 mb-2" role="group" aria-label="Default model configuration">
|
||||
<div className="mt-4 mb-2" role="group" aria-label={t("addModel.defaultModelField.groupAriaLabel")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Default Model</strong>
|
||||
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
|
||||
<strong className="text-base font-semibold">{t("addModel.defaultModelField.title")}</strong>
|
||||
<SimpleTooltip content={t("addModel.defaultModelField.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
|
@ -41,13 +52,13 @@ const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldP
|
|||
value={value.default_model ?? ""}
|
||||
onValueChange={handleDefaultModelChange}
|
||||
placeholder={defaultModelPlaceholder}
|
||||
emptyText="No models found"
|
||||
aria-label="Default model"
|
||||
emptyText={t("addModel.defaultModelField.empty")}
|
||||
aria-label={t("addModel.defaultModelField.ariaLabel")}
|
||||
/>
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
{isForecastClassifier(value.classifier_type)
|
||||
? "Used when routing cannot find a suitable model. Classifier failures route to the capable solver."
|
||||
: 'Used when the tier the request lands in has no model, and when the classifier fails with "Route to the default model" selected.'}
|
||||
? t("addModel.defaultModelField.help.forecast")
|
||||
: t("addModel.defaultModelField.help.standard")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Info } from "lucide-react";
|
|||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const DEFAULT_ESCALATION_KEYWORDS = ["LITELLM ESCALATE"];
|
||||
|
||||
|
|
@ -11,25 +12,22 @@ interface EscalationKeywordsProps {
|
|||
}
|
||||
|
||||
const EscalationKeywords: React.FC<EscalationKeywordsProps> = ({ keywords, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="w-full max-w-none">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">Escalation Keywords</h4>
|
||||
<SimpleTooltip content="Case-sensitive phrases a user can include in their message to force a bump to the next-higher complexity tier when they aren't happy with results. They can force a stronger model, but not choose which one.">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">{t("addModel.escalationKeywords.title")}</h4>
|
||||
<SimpleTooltip content={t("addModel.escalationKeywords.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span className="mb-2 block text-xs text-muted-foreground">
|
||||
Optional: when a user message contains one of these phrases, the request is bumped one tier higher than it would
|
||||
otherwise route to. Matching is case-sensitive, so "LITELLM ESCALATE" only fires on the exact, shouted
|
||||
form. Leave empty to disable.
|
||||
</span>
|
||||
<span className="mb-2 block text-xs text-muted-foreground">{t("addModel.escalationKeywords.description")}</span>
|
||||
<MultiSelect
|
||||
options={keywords.map((keyword) => ({ label: keyword, value: keyword }))}
|
||||
value={keywords}
|
||||
onValueChange={onChange}
|
||||
placeholder="e.g., LITELLM ESCALATE"
|
||||
emptyText="Type to add a phrase"
|
||||
placeholder={t("addModel.escalationKeywords.placeholder")}
|
||||
emptyText={t("addModel.escalationKeywords.typeToAddPhrase")}
|
||||
allowCustomValues
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -82,17 +83,29 @@ export const ForecastSolverModels = ({
|
|||
fastModeByModel,
|
||||
additionalPoolsOnly = false,
|
||||
}: Props & { fastModeByModel: Record<string, boolean>; additionalPoolsOnly?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
const names = forecastTierNames(value);
|
||||
const additionalRows =
|
||||
value.classifier_type === "capability"
|
||||
? activeTierRows(value)
|
||||
.filter((row) => !names.includes(row.id) && row.models.length > 0)
|
||||
.map((row) => ({ tier: row.id, label: `${tierRowLabel(row, value.tier_labels)} routing pool` }))
|
||||
.map((row) => ({
|
||||
tier: row.id,
|
||||
label: t("addModel.forecastClassifierConfig.solvers.routingPool", {
|
||||
tier: tierRowLabel(row, value.tier_labels),
|
||||
}),
|
||||
}))
|
||||
: [];
|
||||
const rows = additionalPoolsOnly
|
||||
? additionalRows
|
||||
: names.map((tier, index) => ({ tier, label: index === 0 ? "Efficient solver" : "Capable solver" }));
|
||||
: names.map((tier, index) => ({
|
||||
tier,
|
||||
label:
|
||||
index === 0
|
||||
? t("addModel.forecastClassifierConfig.solvers.efficient")
|
||||
: t("addModel.forecastClassifierConfig.solvers.capable"),
|
||||
}));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
|
|
@ -110,7 +123,7 @@ export const ForecastSolverModels = ({
|
|||
inputId={`${id}-${tier}`}
|
||||
value={models[0] ?? ""}
|
||||
aria-label={label}
|
||||
placeholder={`Select ${label.toLowerCase()}`}
|
||||
placeholder={t("addModel.forecastClassifierConfig.solvers.selectOne", { label: label.toLowerCase() })}
|
||||
onValueChange={(model) => setModels(model ? [model] : [])}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -119,7 +132,9 @@ export const ForecastSolverModels = ({
|
|||
id={`${id}-${tier}`}
|
||||
value={models}
|
||||
onValueChange={setModels}
|
||||
placeholder={`Select ${label.toLowerCase()} models`}
|
||||
placeholder={t("addModel.forecastClassifierConfig.solvers.selectModels", {
|
||||
label: label.toLowerCase(),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<TierModelEffortRows
|
||||
|
|
@ -150,9 +165,7 @@ export const ForecastSolverModels = ({
|
|||
);
|
||||
})}
|
||||
{!additionalPoolsOnly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Invalid forecasts and classifier failures route to the capable solver
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.forecastClassifierConfig.solvers.failureRouting")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -168,28 +181,32 @@ const CalibrationFields = ({
|
|||
bounded?: boolean;
|
||||
value: { slope: number; intercept: number };
|
||||
onChange: (value: { slope: number; intercept: number }) => void;
|
||||
}) => (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField
|
||||
label={`${label} slope`}
|
||||
value={value.slope}
|
||||
min={0}
|
||||
max={bounded ? 20 : undefined}
|
||||
onChange={(slope) => onChange({ ...value, slope })}
|
||||
/>
|
||||
<NumberField
|
||||
label={`${label} intercept`}
|
||||
value={value.intercept}
|
||||
min={bounded ? -20 : undefined}
|
||||
max={bounded ? 20 : undefined}
|
||||
onChange={(intercept) => onChange({ ...value, intercept })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField
|
||||
label={t("addModel.forecastClassifierConfig.calibrationFields.slope", { label })}
|
||||
value={value.slope}
|
||||
min={0}
|
||||
max={bounded ? 20 : undefined}
|
||||
onChange={(slope) => onChange({ ...value, slope })}
|
||||
/>
|
||||
<NumberField
|
||||
label={t("addModel.forecastClassifierConfig.calibrationFields.intercept", { label })}
|
||||
value={value.intercept}
|
||||
min={bounded ? -20 : undefined}
|
||||
max={bounded ? 20 : undefined}
|
||||
onChange={(intercept) => onChange({ ...value, intercept })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const emptyCoefficients = () => ({ slope: Number.NaN, intercept: Number.NaN });
|
||||
|
||||
const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptionsByModel, section = "all" }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
const isCapability = value.classifier_type === "capability";
|
||||
const capability = value.capability_classifier_config ?? newCapabilitySettings();
|
||||
|
|
@ -205,24 +222,24 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
updateCapability({ ...capability, calibration: { ...capability.calibration, version } });
|
||||
if (!isCapability && fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, version } });
|
||||
};
|
||||
const error = getForecastConfigError(value);
|
||||
const error = getForecastConfigError(value, t);
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isCapability
|
||||
? "Forecasts whether the efficient solver can complete the task using the bundled capability card"
|
||||
: "Forecasts success for both solvers and selects efficient when the estimated quality gap is within your allowance"}
|
||||
? t("addModel.forecastClassifierConfig.description.capability")
|
||||
: t("addModel.forecastClassifierConfig.description.fuse")}
|
||||
</p>
|
||||
{section === "all" && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-judge`}>Judge model</Label>
|
||||
<Label htmlFor={`${id}-judge`}>{t("addModel.forecastClassifierConfig.judge.label")}</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-judge`}
|
||||
aria-label="Judge model"
|
||||
aria-label={t("addModel.forecastClassifierConfig.judge.label")}
|
||||
options={modelOptions}
|
||||
value={llm.model}
|
||||
placeholder="Select the judge model"
|
||||
placeholder={t("addModel.forecastClassifierConfig.judge.placeholder")}
|
||||
onValueChange={(model) => {
|
||||
if (model === llm.model) return;
|
||||
onChange({
|
||||
|
|
@ -239,11 +256,11 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
{isCapability ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Solve probability threshold"
|
||||
label={t("addModel.forecastClassifierConfig.solveThreshold.label")}
|
||||
value={capability.base_threshold}
|
||||
min={0}
|
||||
max={1}
|
||||
help="Minimum estimated chance of whole-task success required to use the efficient solver"
|
||||
help={t("addModel.forecastClassifierConfig.solveThreshold.help")}
|
||||
onChange={(base_threshold) => updateCapability({ ...capability, base_threshold })}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -251,11 +268,11 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
<>
|
||||
<FuseProfilePresets value={fuse} onChange={updateFuse} />
|
||||
<NumberField
|
||||
label="Maximum quality gap"
|
||||
label={t("addModel.forecastClassifierConfig.maxQualityGap.label")}
|
||||
value={fuse.max_quality_gap}
|
||||
min={0}
|
||||
max={1}
|
||||
help="Allowed difference between capable and efficient success probabilities, from 0 to 1. Tune on held-out tasks from your workload; this estimate is not a measured quality guarantee. A gap of 0 still selects efficient on tied or higher forecasts. Route directly to one model to avoid judging when you do not want model selection"
|
||||
help={t("addModel.forecastClassifierConfig.maxQualityGap.help")}
|
||||
onChange={(max_quality_gap) => updateFuse({ ...fuse, max_quality_gap })}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -270,7 +287,7 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
{section === "all" && (
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left font-medium">
|
||||
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
|
||||
Classifier options
|
||||
{t("addModel.forecastClassifierConfig.options.title")}
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 px-4 pb-4">
|
||||
|
|
@ -283,11 +300,11 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Timeout (ms)"
|
||||
label={t("addModel.forecastClassifierConfig.options.timeout")}
|
||||
min={1}
|
||||
step={1}
|
||||
value={llm.timeout_ms}
|
||||
help="Allow enough time for the judge to produce its forecast"
|
||||
help={t("addModel.forecastClassifierConfig.options.timeoutHelp")}
|
||||
onChange={(timeout_ms) => onChange({ ...value, classifier_llm_config: { ...llm, timeout_ms } })}
|
||||
/>
|
||||
<ClassifierCircuitBreakerConfig
|
||||
|
|
@ -300,31 +317,31 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
/>
|
||||
{isCapability && (
|
||||
<NumberField
|
||||
label="Capability boundary step"
|
||||
label={t("addModel.forecastClassifierConfig.options.boundaryStep")}
|
||||
value={capability.threshold_step ?? 0}
|
||||
min={0}
|
||||
max={0.5}
|
||||
help="Added once for uncertain or unmatched tasks and twice for unsupported tasks; the final threshold cannot exceed 1"
|
||||
help={t("addModel.forecastClassifierConfig.options.boundaryStepHelp")}
|
||||
onChange={(threshold_step) => updateCapability({ ...capability, threshold_step })}
|
||||
/>
|
||||
)}
|
||||
<NumberField
|
||||
label="Classifier output token limit"
|
||||
label={t("addModel.forecastClassifierConfig.options.outputTokenLimit")}
|
||||
min={1}
|
||||
step={1}
|
||||
value={config.max_output_tokens ?? (isCapability ? 4096 : 1024)}
|
||||
onChange={(max_output_tokens) => updateTransport({ max_output_tokens })}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-format`}>Forecast response format</Label>
|
||||
<Label htmlFor={`${id}-format`}>{t("addModel.forecastClassifierConfig.options.responseFormat")}</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-format`}
|
||||
aria-label="Forecast response format"
|
||||
aria-label={t("addModel.forecastClassifierConfig.options.responseFormat")}
|
||||
value={config.response_format ?? "json_schema"}
|
||||
allowClear={false}
|
||||
options={[
|
||||
{ value: "json_schema", label: "Strict JSON schema" },
|
||||
{ value: "json_object", label: "JSON object (for judges without strict schema support)" },
|
||||
{ value: "json_schema", label: t("addModel.forecastClassifierConfig.options.strictJsonSchema") },
|
||||
{ value: "json_object", label: t("addModel.forecastClassifierConfig.options.jsonObject") },
|
||||
]}
|
||||
onValueChange={(response_format) => {
|
||||
if (response_format === "json_schema" || response_format === "json_object")
|
||||
|
|
@ -355,14 +372,12 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
})
|
||||
}
|
||||
/>
|
||||
Use fitted calibration
|
||||
{t("addModel.forecastClassifierConfig.calibration.toggle")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional coefficients fitted for your judge, solvers, and harness. Leave off to use raw forecasts
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.forecastClassifierConfig.calibration.help")}</p>
|
||||
{config.calibration && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-version`}>Calibration version</Label>
|
||||
<Label htmlFor={`${id}-version`}>{t("addModel.forecastClassifierConfig.calibration.version")}</Label>
|
||||
<Input
|
||||
id={`${id}-version`}
|
||||
value={config.calibration.version}
|
||||
|
|
@ -373,7 +388,7 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
)}
|
||||
{isCapability && capability.calibration && (
|
||||
<CalibrationFields
|
||||
label="Efficient"
|
||||
label={t("addModel.forecastClassifierConfig.calibration.efficient")}
|
||||
bounded
|
||||
value={capability.calibration}
|
||||
onChange={(next) =>
|
||||
|
|
@ -389,7 +404,11 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
(["efficient", "capable"] as const).map((role) => (
|
||||
<CalibrationFields
|
||||
key={role}
|
||||
label={role === "efficient" ? "Efficient" : "Capable"}
|
||||
label={
|
||||
role === "efficient"
|
||||
? t("addModel.forecastClassifierConfig.calibration.efficient")
|
||||
: t("addModel.forecastClassifierConfig.calibration.capable")
|
||||
}
|
||||
value={fuse.calibration![role]}
|
||||
onChange={(next) => {
|
||||
if (fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, [role]: next } });
|
||||
|
|
@ -403,16 +422,16 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
{section === "all" && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
|
||||
<Label htmlFor={`${id}-frequency`}>{t("addModel.forecastClassifierConfig.frequency.label")}</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-frequency`}
|
||||
aria-label="How often to classify"
|
||||
aria-label={t("addModel.forecastClassifierConfig.frequency.label")}
|
||||
value={classificationFrequency(value)}
|
||||
allowClear={false}
|
||||
options={[
|
||||
{ value: "every_request", label: "Every request" },
|
||||
{ value: "user_turn", label: "Every new user message" },
|
||||
{ value: "session", label: "Once per session" },
|
||||
{ value: "every_request", label: t("addModel.forecastClassifierConfig.frequency.everyRequest") },
|
||||
{ value: "user_turn", label: t("addModel.forecastClassifierConfig.frequency.userTurn") },
|
||||
{ value: "session", label: t("addModel.forecastClassifierConfig.frequency.session") },
|
||||
]}
|
||||
onValueChange={(frequency) => {
|
||||
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
|
||||
|
|
@ -421,9 +440,7 @@ const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptions
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The classifier uses its bundled prompt and always falls back to the capable solver
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.forecastClassifierConfig.bundledPromptNote")}</p>
|
||||
{section !== "advanced" && error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import React from "react";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -20,10 +23,10 @@ const catalogQueryOptions = {
|
|||
refetchOnReconnect: false,
|
||||
};
|
||||
|
||||
const profileLabels: Readonly<Record<FuseProfileField, string>> = {
|
||||
efficient_profile: "Efficient solver profile",
|
||||
capable_profile: "Capable solver profile",
|
||||
harness: "Harness and budget",
|
||||
const profileLabels: Readonly<Record<FuseProfileField, ParseKeys>> = {
|
||||
efficient_profile: "addModel.fuseProfilePresets.profiles.efficient",
|
||||
capable_profile: "addModel.fuseProfilePresets.profiles.capable",
|
||||
harness: "addModel.fuseProfilePresets.profiles.harness",
|
||||
};
|
||||
|
||||
type FusePresetEntry = {
|
||||
|
|
@ -45,10 +48,14 @@ type FieldProps = {
|
|||
setCustomWithoutPreview: React.Dispatch<React.SetStateAction<ReadonlySet<FuseProfileField>>>;
|
||||
};
|
||||
|
||||
const profileSelectionLabel = (awaitingCustomText: boolean, custom: boolean): string => {
|
||||
if (awaitingCustomText) return "Saved preset remains active until replacement text is entered";
|
||||
if (custom) return "Custom text overrides preset";
|
||||
return "Preset";
|
||||
const profileSelectionLabel = (
|
||||
awaitingCustomText: boolean,
|
||||
custom: boolean,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string => {
|
||||
if (awaitingCustomText) return t("addModel.fuseProfilePresets.selection.awaitingCustomText");
|
||||
if (custom) return t("addModel.fuseProfilePresets.selection.customOverridesPreset");
|
||||
return t("addModel.fuseProfilePresets.selection.preset");
|
||||
};
|
||||
|
||||
function FuseProfilePresetField({
|
||||
|
|
@ -61,13 +68,14 @@ function FuseProfilePresetField({
|
|||
customWithoutPreview,
|
||||
setCustomWithoutPreview,
|
||||
}: FieldProps) {
|
||||
const label = profileLabels[field];
|
||||
const { t } = useTranslation();
|
||||
const label = t(profileLabels[field]);
|
||||
const presetId = value[`${field}_preset`];
|
||||
const preset = presets?.find((entry) => entry.id === presetId);
|
||||
const awaitingCustomText = customWithoutPreview.has(field) && value[field] == null && presetId != null;
|
||||
const custom = value[field] != null || presetId == null || awaitingCustomText;
|
||||
const effectiveText = value[field] ?? preset?.text ?? "";
|
||||
const selectionLabel = profileSelectionLabel(awaitingCustomText, custom);
|
||||
const selectionLabel = profileSelectionLabel(awaitingCustomText, custom, t);
|
||||
const chooseProfile = (selected: string | null) => {
|
||||
if (!selected) return;
|
||||
const missingPreview = presetId != null && preset == null && value[field] == null;
|
||||
|
|
@ -93,19 +101,19 @@ function FuseProfilePresetField({
|
|||
};
|
||||
const placeholder =
|
||||
field === "harness"
|
||||
? "Tools, execution environment, verification, and budget available to each solver"
|
||||
: "Describe this solver's strengths, limitations, and settings";
|
||||
? t("addModel.fuseProfilePresets.placeholders.harness")
|
||||
: t("addModel.fuseProfilePresets.placeholders.solver");
|
||||
|
||||
return (
|
||||
<div className="space-y-2 min-w-0">
|
||||
<Label htmlFor={`${id}-${field}-preset`}>{label} preset</Label>
|
||||
<Label htmlFor={`${id}-${field}-preset`}>{t("addModel.fuseProfilePresets.presetLabel", { label })}</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-${field}-preset`}
|
||||
aria-label={`${label} preset`}
|
||||
aria-label={t("addModel.fuseProfilePresets.presetLabel", { label })}
|
||||
value={custom ? "custom" : presetId}
|
||||
allowClear={false}
|
||||
options={[
|
||||
{ value: "custom", label: "Custom" },
|
||||
{ value: "custom", label: t("addModel.fuseProfilePresets.custom") },
|
||||
...(presets ?? []).map((entry) => ({ value: entry.id, label: entry.label, sublabel: entry.id })),
|
||||
]}
|
||||
onValueChange={chooseProfile}
|
||||
|
|
@ -124,10 +132,10 @@ function FuseProfilePresetField({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Keep saved ${label.toLowerCase()} preset`}
|
||||
aria-label={t("addModel.fuseProfilePresets.keepSavedAriaLabel", { label: label.toLowerCase() })}
|
||||
onClick={keepSavedPreset}
|
||||
>
|
||||
Keep saved preset
|
||||
{t("addModel.fuseProfilePresets.keepSaved")}
|
||||
</Button>
|
||||
)}
|
||||
{presetId && (
|
||||
|
|
@ -137,18 +145,18 @@ function FuseProfilePresetField({
|
|||
</p>
|
||||
{preset ? (
|
||||
<>
|
||||
<p>Catalog version: {catalogVersion}</p>
|
||||
{preset.model && <p>Model: {preset.model}</p>}
|
||||
<p>{t("addModel.fuseProfilePresets.catalogVersion", { version: catalogVersion })}</p>
|
||||
{preset.model && <p>{t("addModel.fuseProfilePresets.model", { model: preset.model })}</p>}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{preset.sources.map((source, index) => (
|
||||
<a key={source} href={source} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
Source {index + 1}
|
||||
{t("addModel.fuseProfilePresets.source", { number: index + 1 })}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>Preset preview unavailable. The saved reference is preserved</p>
|
||||
<p>{t("addModel.fuseProfilePresets.previewUnavailable")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -163,6 +171,7 @@ export default function FuseProfilePresets({
|
|||
value: FuseSettings;
|
||||
onChange: (value: FuseSettings) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
const [customWithoutPreview, setCustomWithoutPreview] = React.useState<ReadonlySet<FuseProfileField>>(
|
||||
() => new Set(),
|
||||
|
|
@ -175,19 +184,15 @@ export default function FuseProfilePresets({
|
|||
);
|
||||
return (
|
||||
<div className="space-y-4 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose profiles that match every deployment in each solver group and its actual settings. Profile selection is
|
||||
independent of routing model names. Presets describe the solvers and runtime; they do not set a quality gap or
|
||||
calibration. Validate those separately for your workload, judge, and exact profile versions.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.fuseProfilePresets.intro")}</p>
|
||||
{isPending && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
Loading profile presets. Custom editing is available
|
||||
{t("addModel.fuseProfilePresets.loading")}
|
||||
</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
Profile presets could not be loaded. Saved references are preserved and Custom editing is available
|
||||
{t("addModel.fuseProfilePresets.loadError")}
|
||||
</p>
|
||||
)}
|
||||
{fuseProfileFields.map((field) => (
|
||||
|
|
|
|||
|
|
@ -1,41 +1,42 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const fields = [
|
||||
["code_keywords", "Code keywords"],
|
||||
["reasoning_keywords", "Reasoning keywords"],
|
||||
["technical_keywords", "Technical keywords"],
|
||||
["simple_keywords", "Simple keywords"],
|
||||
["code_keywords", "addModel.heuristicKeywordOverrides.fields.code"],
|
||||
["reasoning_keywords", "addModel.heuristicKeywordOverrides.fields.reasoning"],
|
||||
["technical_keywords", "addModel.heuristicKeywordOverrides.fields.technical"],
|
||||
["simple_keywords", "addModel.heuristicKeywordOverrides.fields.simple"],
|
||||
] as const;
|
||||
|
||||
const HeuristicKeywordOverrides: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each list replaces the built-in keyword list of the same name for the heuristic scorer. Leave a list empty to keep
|
||||
the built-in one. To add technical terms without replacing the list, use custom technical keywords above.
|
||||
</p>
|
||||
{fields.map(([key, label]) => {
|
||||
const keywords = value[key] ?? [];
|
||||
return (
|
||||
<div key={key}>
|
||||
<strong className="mb-1 block font-semibold">{label}</strong>
|
||||
<MultiSelect
|
||||
options={keywords.map((keyword) => ({ label: keyword, value: keyword }))}
|
||||
value={keywords}
|
||||
onValueChange={(next) => onChange({ ...value, [key]: next.length > 0 ? next : undefined })}
|
||||
placeholder={`Add ${label.toLowerCase()}`}
|
||||
emptyText="Type to add a keyword"
|
||||
allowCustomValues
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.heuristicKeywordOverrides.description")}</p>
|
||||
{fields.map(([key, label]) => {
|
||||
const keywords = value[key] ?? [];
|
||||
return (
|
||||
<div key={key}>
|
||||
<strong className="mb-1 block font-semibold">{t(label)}</strong>
|
||||
<MultiSelect
|
||||
options={keywords.map((keyword) => ({ label: keyword, value: keyword }))}
|
||||
value={keywords}
|
||||
onValueChange={(next) => onChange({ ...value, [key]: next.length > 0 ? next : undefined })}
|
||||
placeholder={t("addModel.heuristicKeywordOverrides.addPlaceholder", { label: t(label).toLowerCase() })}
|
||||
emptyText={t("addModel.heuristicKeywordOverrides.typeToAddKeyword")}
|
||||
allowCustomValues
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeuristicKeywordOverrides;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -7,6 +9,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { type ComplexityRouterConfigValue, heuristicScoringRole } from "./ComplexityRouterConfig";
|
||||
import {
|
||||
dimensionLabel,
|
||||
|
|
@ -21,13 +24,13 @@ export type KnobGroup = "tier_boundaries" | "token_thresholds" | "dimension_weig
|
|||
|
||||
interface GroupSpec {
|
||||
group: KnobGroup;
|
||||
title: string;
|
||||
blurb: string;
|
||||
title: ParseKeys;
|
||||
blurb: ParseKeys;
|
||||
min: number;
|
||||
max?: number;
|
||||
step: number;
|
||||
withSlider: boolean;
|
||||
labels: Record<string, string>;
|
||||
labels: Record<string, ParseKeys>;
|
||||
}
|
||||
|
||||
const OVERRIDE_FLOOR_ID = "reasoning-override-min-score";
|
||||
|
|
@ -35,34 +38,34 @@ const OVERRIDE_FLOOR_ID = "reasoning-override-min-score";
|
|||
const GROUPS: GroupSpec[] = [
|
||||
{
|
||||
group: "tier_boundaries",
|
||||
title: "Tier boundaries",
|
||||
blurb:
|
||||
"The weighted score each tier starts at. Scores run from -1 to 1, and short or conversational prompts score below 0, so a negative boundary is a valid way to lift trivial traffic into a higher tier.",
|
||||
title: "addModel.heuristicScoringConfig.groups.tierBoundaries.title",
|
||||
blurb: "addModel.heuristicScoringConfig.groups.tierBoundaries.blurb",
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
withSlider: false,
|
||||
labels: {
|
||||
simple_medium: "Simple to Medium",
|
||||
medium_complex: "Medium to Complex",
|
||||
complex_reasoning: "Complex to Reasoning",
|
||||
simple_medium: "addModel.heuristicScoringConfig.groups.tierBoundaries.labels.simpleMedium",
|
||||
medium_complex: "addModel.heuristicScoringConfig.groups.tierBoundaries.labels.mediumComplex",
|
||||
complex_reasoning: "addModel.heuristicScoringConfig.groups.tierBoundaries.labels.complexReasoning",
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "token_thresholds",
|
||||
title: "Token thresholds",
|
||||
blurb:
|
||||
"Estimated prompt length, in tokens, that pushes the token count dimension to its floor or ceiling. Lengths between the two score neutral.",
|
||||
title: "addModel.heuristicScoringConfig.groups.tokenThresholds.title",
|
||||
blurb: "addModel.heuristicScoringConfig.groups.tokenThresholds.blurb",
|
||||
min: 0,
|
||||
step: 1,
|
||||
withSlider: false,
|
||||
labels: { simple: "Short below", complex: "Long above" },
|
||||
labels: {
|
||||
simple: "addModel.heuristicScoringConfig.groups.tokenThresholds.labels.simple",
|
||||
complex: "addModel.heuristicScoringConfig.groups.tokenThresholds.labels.complex",
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "dimension_weights",
|
||||
title: "Dimension weights",
|
||||
blurb:
|
||||
"Changing a weight rebalances the other built-in and custom weights to total 1.00. Save stores those values. Untouched routers keep their existing weights.",
|
||||
title: "addModel.heuristicScoringConfig.groups.dimensionWeights.title",
|
||||
blurb: "addModel.heuristicScoringConfig.groups.dimensionWeights.blurb",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
|
|
@ -73,14 +76,14 @@ const GROUPS: GroupSpec[] = [
|
|||
|
||||
/** Why a group is currently misconfigured, or null. Never blocks the save: a router written this way in
|
||||
* config.yaml would otherwise be uneditable here for every unrelated change. */
|
||||
const warn = (group: KnobGroup, values: Record<string, number>): string | null => {
|
||||
const warn = (group: KnobGroup, values: Record<string, number>, t: TFunction = i18n.t.bind(i18n)): string | null => {
|
||||
if (
|
||||
group === "tier_boundaries" &&
|
||||
(values.simple_medium > values.medium_complex || values.medium_complex > values.complex_reasoning)
|
||||
)
|
||||
return "These boundaries decrease, so every tier between them is unreachable and its traffic routes elsewhere.";
|
||||
return t("addModel.heuristicScoringConfig.warnings.boundariesDecrease");
|
||||
if (group === "token_thresholds" && values.simple >= values.complex)
|
||||
return "The short threshold is not below the long one, so no prompt length scores neutral on length.";
|
||||
return t("addModel.heuristicScoringConfig.warnings.thresholdsInverted");
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -90,6 +93,7 @@ interface HeuristicScoringConfigProps {
|
|||
}
|
||||
|
||||
const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<{ id: string; raw: string } | null>(null);
|
||||
const { data: defaults, isPending, isError, refetch } = useComplexityScorerDefaults();
|
||||
|
|
@ -100,7 +104,7 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
const customEnabled = heuristicScoringRole(value) === "decides";
|
||||
const customRows = customEnabled ? value.custom_dimensions : undefined;
|
||||
const [weightError, setWeightError] = useState<string | null>(null);
|
||||
const rowError = customDimensionsError(customRows);
|
||||
const rowError = customDimensionsError(customRows, undefined, t);
|
||||
const changeWeights = (edit: WeightEdit) => {
|
||||
const result = rebalanceDimensionWeights(defaults?.dimension_weights, value.dimension_weights, customRows, edit);
|
||||
if (!result.ok) {
|
||||
|
|
@ -148,33 +152,32 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-muted-foreground transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">Advanced scoring</span>
|
||||
<span className="text-sm font-medium">{t("addModel.heuristicScoringConfig.title")}</span>
|
||||
{overrides > 0 && (
|
||||
<Badge variant="secondary" data-testid="advanced-scoring-override-count">
|
||||
{overrides} {overrides === 1 ? "override" : "overrides"}
|
||||
{overrides}{" "}
|
||||
{overrides === 1
|
||||
? t("addModel.heuristicScoringConfig.overrideOne")
|
||||
: t("addModel.heuristicScoringConfig.overrideMany")}
|
||||
</Badge>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 space-y-6 pl-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every knob below is optional. Left untouched, the router follows the shipped defaults, so it picks up any
|
||||
recalibration of them rather than staying pinned to the numbers shown here.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.heuristicScoringConfig.description")}</p>
|
||||
|
||||
{isPending ? (
|
||||
<p className="text-xs text-muted-foreground">Loading the shipped defaults...</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.heuristicScoringConfig.loadingDefaults")}</p>
|
||||
) : (
|
||||
<>
|
||||
{isError && (
|
||||
<div className="flex items-start gap-2" role="alert">
|
||||
<p className="text-xs font-medium text-destructive">
|
||||
Could not load the shipped defaults, so only values this router already overrides are shown. Saving
|
||||
still works, and an untouched knob keeps following the defaults.
|
||||
{t("addModel.heuristicScoringConfig.loadDefaultsFailed")}
|
||||
</p>
|
||||
<Button type="button" variant="link" size="xs" onClick={() => void refetch()}>
|
||||
Retry
|
||||
{t("addModel.heuristicScoringConfig.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -187,7 +190,7 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
const total =
|
||||
Object.values(effective).reduce((sum, weight) => sum + weight, 0) +
|
||||
(customRows ?? []).reduce((sum, row) => sum + row.weight, 0);
|
||||
const problem = warn(spec.group, effective);
|
||||
const problem = warn(spec.group, effective, t);
|
||||
const overridden =
|
||||
value[spec.group] !== undefined || (spec.withSlider && value.custom_dimensions !== undefined);
|
||||
const resettable = spec.withSlider || overridden;
|
||||
|
|
@ -196,12 +199,12 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
<section key={spec.group} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{spec.title}</span>
|
||||
<span className="text-sm font-medium">{t(spec.title)}</span>
|
||||
{/* Only a known dimension set has a meaningful total; summing the overrides
|
||||
alone would state a total that is not the router's. */}
|
||||
{spec.withSlider && defaults !== undefined && (
|
||||
<span className="text-xs text-muted-foreground" data-testid="dimension-weight-total">
|
||||
total {total.toFixed(2)}
|
||||
{t("addModel.heuristicScoringConfig.total", { total: total.toFixed(2) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -220,15 +223,17 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
});
|
||||
}}
|
||||
>
|
||||
{spec.withSlider ? "Restore default weights" : "Reset to defaults"}
|
||||
{spec.withSlider
|
||||
? t("addModel.heuristicScoringConfig.restoreDefaultWeights")
|
||||
: t("addModel.heuristicScoringConfig.resetToDefaults")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{spec.blurb}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(spec.blurb)}</p>
|
||||
|
||||
{Object.keys(effective).map((key) => {
|
||||
const id = `${spec.group}-${key}`;
|
||||
const label = spec.labels[key] ?? dimensionLabel(key);
|
||||
const label = Object.hasOwn(spec.labels, key) ? t(spec.labels[key]) : dimensionLabel(key);
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<Label htmlFor={id} className="w-44 text-xs font-normal">
|
||||
|
|
@ -245,7 +250,7 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
commit(spec, effective, key, String(Array.isArray(next) ? next[0] : next))
|
||||
}
|
||||
className="flex-1"
|
||||
aria-label={`${label} weight`}
|
||||
aria-label={t("addModel.heuristicScoringConfig.weightAria", { label })}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
|
|
@ -298,7 +303,9 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Reasoning override floor</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t("addModel.heuristicScoringConfig.overrideFloor.title")}
|
||||
</span>
|
||||
{value.reasoning_override_min_score !== undefined && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -306,21 +313,22 @@ const HeuristicScoringConfig: React.FC<HeuristicScoringConfigProps> = ({ value,
|
|||
size="xs"
|
||||
onClick={() => onChange({ ...value, reasoning_override_min_score: undefined })}
|
||||
>
|
||||
Reset to defaults
|
||||
{t("addModel.heuristicScoringConfig.resetToDefaults")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Two or more reasoning markers promote a request to the reasoning tier, but only once its weighted
|
||||
score reaches this floor.{" "}
|
||||
{t("addModel.heuristicScoringConfig.overrideFloor.description")}{" "}
|
||||
{trackedFloor === undefined
|
||||
? "Left untouched, it tracks the Simple to Medium boundary."
|
||||
: `Left untouched, it tracks the Simple to Medium boundary, currently ${trackedFloor.toFixed(2)}.`}{" "}
|
||||
Set it to 0 to promote on the markers alone.
|
||||
? t("addModel.heuristicScoringConfig.overrideFloor.tracksBoundary")
|
||||
: t("addModel.heuristicScoringConfig.overrideFloor.tracksBoundaryCurrently", {
|
||||
value: trackedFloor.toFixed(2),
|
||||
})}{" "}
|
||||
{t("addModel.heuristicScoringConfig.overrideFloor.markersAlone")}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor={OVERRIDE_FLOOR_ID} className="w-44 text-xs font-normal">
|
||||
Minimum score
|
||||
{t("addModel.heuristicScoringConfig.overrideFloor.minimumScore")}
|
||||
</Label>
|
||||
<Input
|
||||
id={OVERRIDE_FLOOR_ID}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
|
@ -7,6 +8,7 @@ const HousekeepingRoutingControls: React.FC<{
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const enabled = value.route_housekeeping_to_cheapest_tier ?? true;
|
||||
const patterns = value.housekeeping_patterns ?? [];
|
||||
return (
|
||||
|
|
@ -15,27 +17,29 @@ const HousekeepingRoutingControls: React.FC<{
|
|||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => onChange({ ...value, route_housekeeping_to_cheapest_tier: next })}
|
||||
aria-label="Route housekeeping calls to the cheapest tier"
|
||||
aria-label={t("addModel.housekeepingRoutingControls.routeToCheapest")}
|
||||
/>
|
||||
<strong className="font-semibold">Route housekeeping calls to the cheapest tier</strong>
|
||||
<strong className="font-semibold">{t("addModel.housekeepingRoutingControls.routeToCheapest")}</strong>
|
||||
</div>
|
||||
<span className="mb-3 block text-xs text-muted-foreground">
|
||||
Conversation-title style calls skip the classifier and go to the cheapest tier.
|
||||
{t("addModel.housekeepingRoutingControls.description")}
|
||||
</span>
|
||||
<strong className="mb-1 block font-semibold">Additional housekeeping sentinels</strong>
|
||||
<strong className="mb-1 block font-semibold">
|
||||
{t("addModel.housekeepingRoutingControls.additionalSentinels")}
|
||||
</strong>
|
||||
<MultiSelect
|
||||
options={patterns.map((pattern) => ({ label: pattern, value: pattern }))}
|
||||
value={patterns}
|
||||
onValueChange={(next) => onChange({ ...value, housekeeping_patterns: next.length > 0 ? next : undefined })}
|
||||
placeholder="e.g., conversation title"
|
||||
emptyText="Type to add a sentinel"
|
||||
placeholder={t("addModel.housekeepingRoutingControls.sentinelPlaceholder")}
|
||||
emptyText={t("addModel.housekeepingRoutingControls.typeToAddSentinel")}
|
||||
allowCustomValues
|
||||
disabled={!enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
Case-sensitive literal strings added to the built-in conversation-title sentinels.
|
||||
{!enabled && " Turn housekeeping routing on for these to take effect."}
|
||||
{t("addModel.housekeepingRoutingControls.sentinelsHelp")}
|
||||
{!enabled && t("addModel.housekeepingRoutingControls.turnOnToTakeEffect")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useId } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AutoRouterAllowanceNote } from "./AutoRouterAvailability";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -7,15 +9,13 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { defaultJevClassifierConfig, fixedClassifierModels } from "./jev_classifier_config";
|
||||
import { defaultJevClassifierConfig, fixedClassifierModels, type OssClassifierProvider } from "./jev_classifier_config";
|
||||
|
||||
const providerDescriptions = {
|
||||
jev: "Uses TypeSafe System One Choice evaluation with your configured tiers",
|
||||
laya: "Uses Laya with your configured tiers. Set LAYA_API_BASE on the gateway to connect your Laya server.",
|
||||
bespoke:
|
||||
"Uses Bespoke Nimble with your configured tiers. Set BESPOKE_API_BASE on the gateway to connect your Nimble server.",
|
||||
databricks:
|
||||
"Uses the Databricks ai_decide AI Function, or a decision serving endpoint you name, with your configured tiers. Set DATABRICKS_API_BASE to https://<workspace-host>/serving-endpoints and DATABRICKS_API_KEY on the gateway.",
|
||||
const providerDescriptions: Record<OssClassifierProvider, ParseKeys> = {
|
||||
jev: "addModel.jevClassifierConfig.providerDescriptions.jev",
|
||||
laya: "addModel.jevClassifierConfig.providerDescriptions.laya",
|
||||
bespoke: "addModel.jevClassifierConfig.providerDescriptions.bespoke",
|
||||
databricks: "addModel.jevClassifierConfig.providerDescriptions.databricks",
|
||||
};
|
||||
|
||||
export default function JevClassifierConfig({
|
||||
|
|
@ -27,6 +27,7 @@ export default function JevClassifierConfig({
|
|||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
section?: "all" | "model" | "advanced";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
|
||||
const models = fixedClassifierModels(config.provider);
|
||||
|
|
@ -37,9 +38,9 @@ export default function JevClassifierConfig({
|
|||
<div className={section === "model" ? "space-y-3" : "mt-4 space-y-3"}>
|
||||
{section !== "advanced" && !config.deployment_name && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{providerDescriptions[config.provider ?? "jev"]}</p>
|
||||
<p className="text-sm text-muted-foreground">{t(providerDescriptions[config.provider ?? "jev"])}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
|
||||
<Label htmlFor={`${id}-model`}>{t("addModel.jevClassifierConfig.model.label")}</Label>
|
||||
{models ? (
|
||||
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
|
||||
<SelectTrigger id={`${id}-model`} className="w-full">
|
||||
|
|
@ -59,7 +60,7 @@ export default function JevClassifierConfig({
|
|||
value={config.model}
|
||||
placeholder={
|
||||
config.provider === "databricks"
|
||||
? "ai_decide, or a serving endpoint name such as databricks-openjev-qwen35-4b"
|
||||
? t("addModel.jevClassifierConfig.model.databricksPlaceholder")
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => update({ model: event.target.value })}
|
||||
|
|
@ -71,7 +72,7 @@ export default function JevClassifierConfig({
|
|||
{section !== "model" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-timeout`}>Classifier Timeout (ms)</Label>
|
||||
<Label htmlFor={`${id}-timeout`}>{t("addModel.jevClassifierConfig.timeout")}</Label>
|
||||
<Input
|
||||
id={`${id}-timeout`}
|
||||
type="number"
|
||||
|
|
@ -91,25 +92,23 @@ export default function JevClassifierConfig({
|
|||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
|
||||
<Label htmlFor={`${id}-instructions`}>{t("addModel.jevClassifierConfig.instructions.label")}</Label>
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label="Custom instructions share the custom-tier allowance"
|
||||
label={t("addModel.jevClassifierConfig.instructions.allowance")}
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-instructions`}
|
||||
value={config.instructions ?? ""}
|
||||
placeholder="Leave blank to use the built-in instructions"
|
||||
placeholder={t("addModel.jevClassifierConfig.instructions.placeholder")}
|
||||
onChange={(event) => update({ instructions: event.target.value || undefined })}
|
||||
/>
|
||||
{config.instructions && (
|
||||
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
|
||||
Restore built-in instructions
|
||||
{t("addModel.jevClassifierConfig.instructions.restore")}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Built-in decision model classification is available without a license and uses the shipped tier criteria
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("addModel.jevClassifierConfig.licenseNote")}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
|
||||
import { tierOptions } from "./complexity_router_tiers";
|
||||
|
|
@ -29,6 +30,7 @@ interface KeywordTierRulesProps {
|
|||
// rather than waiting for a submit; the submit button is disabled while one is outstanding, so
|
||||
// there is no failed attempt left to surface it.
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels, tierNames }) => {
|
||||
const { t } = useTranslation();
|
||||
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
|
||||
|
||||
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
|
||||
|
|
@ -51,27 +53,24 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<div className="w-full max-w-none">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">Keyword Tier Overrides</h4>
|
||||
<SimpleTooltip content="Match known terms and force the request straight to a chosen complexity tier, bypassing rule-based scoring.">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">{t("addModel.keywordTierRules.title")}</h4>
|
||||
<SimpleTooltip content={t("addModel.keywordTierRules.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<Button variant="outline" onClick={addRule}>
|
||||
<Plus />
|
||||
Add keyword rule
|
||||
{t("addModel.keywordTierRules.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="mb-4 block text-muted-foreground">
|
||||
Optional: route requests containing specific keywords directly to a tier, e.g. route "invoice, refund,
|
||||
billing" to the medium tier.
|
||||
</span>
|
||||
<span className="mb-4 block text-muted-foreground">{t("addModel.keywordTierRules.description")}</span>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<Card className="bg-muted">
|
||||
<CardContent>
|
||||
<div className="py-2 text-center">
|
||||
<Inbox className="mx-auto mb-2 size-6 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">No keyword tier overrides configured</p>
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.keywordTierRules.empty")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -82,32 +81,37 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<CardContent>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<strong className="mb-2 block font-semibold">Keywords {index + 1}</strong>
|
||||
<strong className="mb-2 block font-semibold">
|
||||
{t("addModel.keywordTierRules.keywordsLabel", { number: index + 1 })}
|
||||
</strong>
|
||||
<MultiSelect
|
||||
options={rule.keywords.map((keyword) => ({ label: keyword, value: keyword }))}
|
||||
value={rule.keywords}
|
||||
onValueChange={replaceKeywords(rule)}
|
||||
placeholder="e.g., invoice, refund, billing"
|
||||
emptyText="Type to add a keyword"
|
||||
placeholder={t("addModel.keywordTierRules.placeholder")}
|
||||
emptyText={t("addModel.keywordTierRules.typeToAddKeyword")}
|
||||
allowCustomValues
|
||||
className={emptyRuleIndexes.has(index) ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
{emptyRuleIndexes.has(index) && (
|
||||
<span className="text-xs text-destructive">At least one keyword is required</span>
|
||||
<span className="text-xs text-destructive">{t("addModel.keywordTierRules.keywordRequired")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: 220 }}>
|
||||
<strong className="mb-2 block font-semibold">Route to tier</strong>
|
||||
<strong className="mb-2 block font-semibold">{t("addModel.keywordTierRules.routeToTier")}</strong>
|
||||
<Select
|
||||
items={tierOptions(tierLabels, tierNames)}
|
||||
items={tierOptions(tierLabels, tierNames, t)}
|
||||
value={rule.tier}
|
||||
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
|
||||
>
|
||||
<SelectTrigger aria-label={`Route keyword rule ${index + 1} to tier`} className="w-full">
|
||||
<SelectTrigger
|
||||
aria-label={t("addModel.keywordTierRules.routeRuleAria", { number: index + 1 })}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tierOptions(tierLabels, tierNames).map((option) => (
|
||||
{tierOptions(tierLabels, tierNames, t).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
|
@ -119,7 +123,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove keyword rule ${index + 1}`}
|
||||
aria-label={t("addModel.keywordTierRules.removeRuleAria", { number: index + 1 })}
|
||||
onClick={() => removeRule(rule.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ export const ModalityRoutingControls: React.FC<{
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const modalityRouting = value.modality_routing ?? false;
|
||||
return (
|
||||
<>
|
||||
|
|
@ -15,27 +17,24 @@ export const ModalityRoutingControls: React.FC<{
|
|||
<Switch
|
||||
checked={modalityRouting}
|
||||
onCheckedChange={(nextModalityRouting) => onChange({ ...value, modality_routing: nextModalityRouting })}
|
||||
aria-label="Route image requests to vision-capable models"
|
||||
aria-label={t("addModel.modalityRoutingControls.routeImages")}
|
||||
/>
|
||||
<strong className="font-semibold">Route image requests to vision-capable models</strong>
|
||||
<strong className="font-semibold">{t("addModel.modalityRoutingControls.routeImages")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
Replaces a routed model that cannot take image input with the nearest higher tier that can, then the default
|
||||
model, instead of failing with a provider 400. Only models explicitly declared supports_vision false are
|
||||
replaced, and a kept session pin still wins unless you turn on the override below.
|
||||
{t("addModel.modalityRoutingControls.routeImagesDescription")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.modality_pin_override ?? false}
|
||||
onCheckedChange={(modalityPinOverride) => onChange({ ...value, modality_pin_override: modalityPinOverride })}
|
||||
disabled={!modalityRouting}
|
||||
aria-label="Override session pin for image requests"
|
||||
aria-label={t("addModel.modalityRoutingControls.overridePin")}
|
||||
/>
|
||||
<strong className="font-semibold">Override session pin for image requests</strong>
|
||||
<strong className="font-semibold">{t("addModel.modalityRoutingControls.overridePin")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Route an image turn to a capable model even when the session is pinned to one that cannot take images. The pin
|
||||
is kept, so the next text turn goes back to it. Needs image routing turned on.
|
||||
{t("addModel.modalityRoutingControls.overridePinDescription")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
|
|
@ -34,6 +35,7 @@ const ModelChoiceCombobox: React.FC<ModelChoiceComboboxProps> = ({
|
|||
ariaInvalid,
|
||||
ariaDescribedBy,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const selected = value ? choices.find((choice) => choice.value === value) ?? { value, label: value } : null;
|
||||
|
||||
return (
|
||||
|
|
@ -53,7 +55,7 @@ const ModelChoiceCombobox: React.FC<ModelChoiceComboboxProps> = ({
|
|||
showClear={value != null && value !== ""}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("addModel.modelChoiceCombobox.empty")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(choice: ModelChoice) => (
|
||||
<ComboboxItem key={choice.value} value={choice}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -10,6 +11,7 @@ const NonReasoningTierToggle: React.FC<{
|
|||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
available: boolean;
|
||||
}> = ({ value, onChange, available }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleToggle = (enabled: boolean): void => {
|
||||
const { NON_REASONING: existingPool, ...keptTiers } = value.tiers;
|
||||
// Turning it off must also release the plan-mode floor, which the backend rejects while it
|
||||
|
|
@ -33,14 +35,13 @@ const NonReasoningTierToggle: React.FC<{
|
|||
checked={value.enable_non_reasoning_tier === true}
|
||||
disabled={!available}
|
||||
onCheckedChange={handleToggle}
|
||||
aria-label="Add a non-reasoning tier"
|
||||
aria-label={t("addModel.nonReasoningTierToggle.addTier")}
|
||||
/>
|
||||
<strong className="font-semibold">Add a non-reasoning tier</strong>
|
||||
<strong className="font-semibold">{t("addModel.nonReasoningTierToggle.addTier")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Adds NON_REASONING below Simple, for operational agent traffic that relays or reformats information rather than
|
||||
reasoning about it. Escalation still moves up out of it when a request needs more.
|
||||
{!available && " Requires the LLM or Jev classification method"}
|
||||
{t("addModel.nonReasoningTierToggle.description")}
|
||||
{!available && t("addModel.nonReasoningTierToggle.requiresClassifier")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { getAutoRouterAssembledPromptCall } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -40,31 +42,21 @@ interface OpeningPromptEditorProps {
|
|||
contextWindowSize: number;
|
||||
}
|
||||
|
||||
const CUSTOM_PLACEHOLDER = `Classify the request into exactly one tier for a payments engineering team.
|
||||
const CUSTOM_PLACEHOLDER: ParseKeys = "addModel.openingPromptEditor.placeholders.custom";
|
||||
|
||||
Weigh what the request actually asks for, not how it is worded.`;
|
||||
|
||||
const BUILT_IN_PLACEHOLDER = `Classify the complexity of a user request into exactly one tier.
|
||||
|
||||
Judge the intellectual difficulty of answering correctly, not how short, long, or technical-sounding the request is.`;
|
||||
const BUILT_IN_PLACEHOLDER: ParseKeys = "addModel.openingPromptEditor.placeholders.builtIn";
|
||||
|
||||
const COPY = {
|
||||
custom: {
|
||||
overridden:
|
||||
"This router opens with your own instructions and calibration examples. Your tier definitions and the injection guard are still appended below them.",
|
||||
default:
|
||||
"Write the opening instructions and your own calibration examples. Your tier definitions and the injection guard are always appended below them.",
|
||||
explainer:
|
||||
"Your text is the opening of the classifier prompt, so it is where calibration examples of your own belong. The router appends your tier definitions and its injection guard underneath, and neither can be edited or removed from here. Edit the definitions themselves with Edit tiers above.",
|
||||
overridden: "addModel.openingPromptEditor.copy.custom.overridden",
|
||||
default: "addModel.openingPromptEditor.copy.custom.default",
|
||||
explainer: "addModel.openingPromptEditor.copy.custom.explainer",
|
||||
placeholder: CUSTOM_PLACEHOLDER,
|
||||
},
|
||||
builtIn: {
|
||||
overridden:
|
||||
"This router opens with your own instructions and calibration examples in place of the base rubric's. Its tier criteria and the injection guard are still appended below them.",
|
||||
default:
|
||||
"The base rubric supplies the opening instructions and calibration examples. Customize them to write your own; the tier criteria and the injection guard are always appended below them.",
|
||||
explainer:
|
||||
"The base rubric decides the tier criteria and, until you write your own, the opening instructions and calibration examples. Your text replaces that opening and those examples. The router appends the four tier criteria and its injection guard underneath, and neither can be edited or removed from here. Rename the tiers with the display names above.",
|
||||
overridden: "addModel.openingPromptEditor.copy.builtIn.overridden",
|
||||
default: "addModel.openingPromptEditor.copy.builtIn.default",
|
||||
explainer: "addModel.openingPromptEditor.copy.builtIn.explainer",
|
||||
placeholder: BUILT_IN_PLACEHOLDER,
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -76,6 +68,7 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
|
|||
tierSource,
|
||||
contextWindowSize,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [instructionDraft, setInstructionDraft] = useState("");
|
||||
|
|
@ -153,12 +146,16 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
|
|||
<div>
|
||||
{rubricSummary && (
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
{isOverridden ? `Custom opening on the ${rubricSummary.label} rubric` : `${rubricSummary.label} rubric`}
|
||||
{isOverridden
|
||||
? t("addModel.openingPromptEditor.customOpeningOnRubric", { rubric: rubricSummary.label })
|
||||
: t("addModel.openingPromptEditor.rubricName", { rubric: rubricSummary.label })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={openEditor}>
|
||||
{isOverridden ? "Edit custom prompt" : "Customize prompt"}
|
||||
{isOverridden
|
||||
? t("addModel.openingPromptEditor.editCustomPrompt")
|
||||
: t("addModel.openingPromptEditor.customizePrompt")}
|
||||
</Button>
|
||||
{isOverridden && (
|
||||
<Button
|
||||
|
|
@ -173,22 +170,22 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
|
|||
})
|
||||
}
|
||||
>
|
||||
Reset to default
|
||||
{t("addModel.openingPromptEditor.resetToDefault")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{isOverridden ? copy.overridden : copy.default}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{isOverridden ? t(copy.overridden) : t(copy.default)}</p>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Classifier prompt</DialogTitle>
|
||||
<DialogTitle>{t("addModel.openingPromptEditor.dialogTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{tierSource.kind === "builtIn" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="base-classification-rubric">
|
||||
Base rubric
|
||||
{t("addModel.openingPromptEditor.baseRubric")}
|
||||
</label>
|
||||
<Select
|
||||
items={Object.entries(CLASSIFICATION_RUBRIC_DESCRIPTIONS).map(([rubric, description]) => ({
|
||||
|
|
@ -199,7 +196,11 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
|
|||
onValueChange={(rubric: ClassificationRubric | null) => rubric && setRubricDraft(rubric)}
|
||||
disabled={Boolean(tierSource.rubricRestriction)}
|
||||
>
|
||||
<SelectTrigger id="base-classification-rubric" aria-label="Base rubric" className="mt-1 w-full">
|
||||
<SelectTrigger
|
||||
id="base-classification-rubric"
|
||||
aria-label={t("addModel.openingPromptEditor.baseRubric")}
|
||||
className="mt-1 w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
|
|
@ -220,60 +221,55 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">{copy.explainer}</p>
|
||||
<p className="text-sm text-muted-foreground">{t(copy.explainer)}</p>
|
||||
|
||||
<div className="mt-3 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="classification-instructions">
|
||||
Classification instructions
|
||||
{t("addModel.openingPromptEditor.instructions.label")}
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Explain what the classifier should judge. Tier definitions are managed separately below.
|
||||
{t("addModel.openingPromptEditor.instructions.help")}
|
||||
</p>
|
||||
<Textarea
|
||||
id="classification-instructions"
|
||||
value={instructionDraft}
|
||||
onChange={(e) => setInstructionDraft(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={copy.placeholder}
|
||||
aria-label="Classification instructions"
|
||||
placeholder={t(copy.placeholder)}
|
||||
aria-label={t("addModel.openingPromptEditor.instructions.label")}
|
||||
className="mt-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="calibration-examples">
|
||||
Calibration examples
|
||||
{t("addModel.openingPromptEditor.examples.label")}
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Show representative requests and the tier they should receive. The router adds these after its tier
|
||||
definitions.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("addModel.openingPromptEditor.examples.help")}</p>
|
||||
<Textarea
|
||||
id="calibration-examples"
|
||||
value={exampleDraft}
|
||||
onChange={(e) => setExampleDraft(e.target.value)}
|
||||
rows={6}
|
||||
placeholder={'- "what is the capital of France?" -> SIMPLE'}
|
||||
aria-label="Calibration examples"
|
||||
aria-label={t("addModel.openingPromptEditor.examples.label")}
|
||||
className="mt-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium">What this router sends</p>
|
||||
<p className="text-xs font-medium">{t("addModel.openingPromptEditor.preview.title")}</p>
|
||||
{preview.status === "loading" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Loading the assembled prompt…</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("addModel.openingPromptEditor.preview.loading")}</p>
|
||||
)}
|
||||
{preview.status === "error" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Could not load the assembled prompt. Your text is still saved as written.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("addModel.openingPromptEditor.preview.error")}</p>
|
||||
)}
|
||||
{preview.status === "ready" && (
|
||||
<pre
|
||||
aria-label="Assembled classifier prompt"
|
||||
aria-label={t("addModel.openingPromptEditor.preview.ariaLabel")}
|
||||
className="mt-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{preview.text}
|
||||
|
|
@ -283,10 +279,10 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
|
|||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
{t("addModel.openingPromptEditor.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave}>
|
||||
Save prompt
|
||||
{t("addModel.openingPromptEditor.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import TierRowSelect from "./TierRowSelect";
|
||||
|
|
@ -8,55 +9,59 @@ const PlanModeOverrideControls: React.FC<{
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
planModeTierOptions: { value: string; label: string }[];
|
||||
}> = ({ value, onChange, planModeTierOptions }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.plan_mode_min_tier !== undefined}
|
||||
disabled={planModeTierOptions.length === 0}
|
||||
onCheckedChange={(enabled) =>
|
||||
onChange({
|
||||
...value,
|
||||
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
|
||||
})
|
||||
}
|
||||
aria-label="Route plan-mode requests to a minimum tier"
|
||||
/>
|
||||
<strong className="font-semibold">Route plan-mode requests to a minimum tier</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier
|
||||
still wins when it picks higher, and the override only lasts while plan mode is active.
|
||||
{planModeTierOptions.length === 0 && " Add models to a tier to enable this."}
|
||||
</span>
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<TierRowSelect
|
||||
label="Plan-mode minimum tier"
|
||||
options={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier ?? null}
|
||||
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
}> = ({ value, onChange, planModeTierOptions }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.plan_mode_min_tier !== undefined}
|
||||
disabled={planModeTierOptions.length === 0}
|
||||
onCheckedChange={(enabled) =>
|
||||
onChange({
|
||||
...value,
|
||||
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
|
||||
})
|
||||
}
|
||||
aria-label={t("addModel.planModeOverrideControls.routeToMinimumTier")}
|
||||
/>
|
||||
<strong className="font-semibold">{t("addModel.planModeOverrideControls.routeToMinimumTier")}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<strong className="mb-1 block font-semibold">Additional plan-mode sentinels</strong>
|
||||
<MultiSelect
|
||||
options={(value.plan_mode_patterns ?? []).map((pattern) => ({ label: pattern, value: pattern }))}
|
||||
value={value.plan_mode_patterns ?? []}
|
||||
onValueChange={(patterns) =>
|
||||
onChange({ ...value, plan_mode_patterns: patterns.length > 0 ? patterns : undefined })
|
||||
}
|
||||
placeholder="e.g., enter plan mode"
|
||||
emptyText="Type to add a sentinel"
|
||||
allowCustomValues
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
Case-sensitive literal strings added to the built-in Claude Code and Copilot plan-mode markers.
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
{t("addModel.planModeOverrideControls.description")}
|
||||
{planModeTierOptions.length === 0 && t("addModel.planModeOverrideControls.addModelsToEnable")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<TierRowSelect
|
||||
label={t("addModel.planModeOverrideControls.minimumTier")}
|
||||
options={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier ?? null}
|
||||
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<strong className="mb-1 block font-semibold">
|
||||
{t("addModel.planModeOverrideControls.additionalSentinels")}
|
||||
</strong>
|
||||
<MultiSelect
|
||||
options={(value.plan_mode_patterns ?? []).map((pattern) => ({ label: pattern, value: pattern }))}
|
||||
value={value.plan_mode_patterns ?? []}
|
||||
onValueChange={(patterns) =>
|
||||
onChange({ ...value, plan_mode_patterns: patterns.length > 0 ? patterns : undefined })
|
||||
}
|
||||
placeholder={t("addModel.planModeOverrideControls.sentinelPlaceholder")}
|
||||
emptyText={t("addModel.planModeOverrideControls.typeToAddSentinel")}
|
||||
allowCustomValues
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{t("addModel.planModeOverrideControls.sentinelsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanModeOverrideControls;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -10,6 +11,7 @@ const ReminderMarkers: React.FC<{
|
|||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
showValidationErrors?: boolean;
|
||||
}> = ({ value, onChange, showValidationErrors = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const markers = value.reminder_markers ?? [];
|
||||
const update = (index: number, patch: Partial<ReminderMarkerPair>) =>
|
||||
onChange({
|
||||
|
|
@ -22,21 +24,21 @@ const ReminderMarkers: React.FC<{
|
|||
const next = markers.filter((_, markerIndex) => markerIndex !== index);
|
||||
onChange({ ...value, reminder_markers: next.length > 0 ? next : undefined });
|
||||
};
|
||||
const error = getReminderMarkersError(value.reminder_markers);
|
||||
const error = getReminderMarkersError(value.reminder_markers, t);
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Auto Router already ignores built-in reminder tags, such as <code><system-reminder></code>, when choosing
|
||||
a model. Leave this empty to keep the defaults. Add custom opening and closing tags to ignore the text between
|
||||
them when routing. Custom pairs replace the defaults, so include any built-in pairs you still need. The selected
|
||||
model still receives the full message. Matching is case-insensitive.
|
||||
<Trans
|
||||
i18nKey="addModel.reminderMarkers.description"
|
||||
components={{ marker: <code><system-reminder></code> }}
|
||||
/>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{markers.map((marker, index) => (
|
||||
<div className="flex items-end gap-2" key={index}>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor={`reminder-marker-${index}-open`}>
|
||||
Opening tag
|
||||
{t("addModel.reminderMarkers.openingTag")}
|
||||
</label>
|
||||
<Input
|
||||
id={`reminder-marker-${index}-open`}
|
||||
|
|
@ -47,7 +49,7 @@ const ReminderMarkers: React.FC<{
|
|||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor={`reminder-marker-${index}-close`}>
|
||||
Closing tag
|
||||
{t("addModel.reminderMarkers.closingTag")}
|
||||
</label>
|
||||
<Input
|
||||
id={`reminder-marker-${index}-close`}
|
||||
|
|
@ -59,7 +61,7 @@ const ReminderMarkers: React.FC<{
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove tag pair ${index + 1}`}
|
||||
aria-label={t("addModel.reminderMarkers.removePairAria", { number: index + 1 })}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 />
|
||||
|
|
@ -73,7 +75,7 @@ const ReminderMarkers: React.FC<{
|
|||
onClick={() => onChange({ ...value, reminder_markers: [...markers, { open: "", close: "" }] })}
|
||||
>
|
||||
<Plus />
|
||||
Add tag pair
|
||||
{t("addModel.reminderMarkers.addPair")}
|
||||
</Button>
|
||||
{showValidationErrors && error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,69 +2,72 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const OUTPUT_TOKEN_POLICIES = [
|
||||
{
|
||||
value: true,
|
||||
label: "Use model ceiling",
|
||||
description: "Replace the caller's output limit with the selected model's ceiling. This is the default.",
|
||||
label: "addModel.responseFormatControls.outputTokenPolicies.modelCeiling.label",
|
||||
description: "addModel.responseFormatControls.outputTokenPolicies.modelCeiling.description",
|
||||
},
|
||||
{
|
||||
value: "clamp",
|
||||
label: "Respect caller budget",
|
||||
description:
|
||||
"Use the smaller of the caller's output limit and the model ceiling. Use the ceiling when no limit is sent.",
|
||||
label: "addModel.responseFormatControls.outputTokenPolicies.callerBudget.label",
|
||||
description: "addModel.responseFormatControls.outputTokenPolicies.callerBudget.description",
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
label: "Pass through caller limit",
|
||||
description: "Forward the caller's output limit unchanged.",
|
||||
label: "addModel.responseFormatControls.outputTokenPolicies.passThrough.label",
|
||||
description: "addModel.responseFormatControls.outputTokenPolicies.passThrough.description",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ResponseFormatControls: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.return_raw_model_name ?? false}
|
||||
onCheckedChange={(returnRawModelName) => onChange({ ...value, return_raw_model_name: returnRawModelName })}
|
||||
aria-label="Return raw model name"
|
||||
/>
|
||||
<strong className="font-semibold">Return raw model name</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Return the resolved underlying model name in responses instead of the autorouter alias.
|
||||
</span>
|
||||
<div className="mt-4 space-y-2">
|
||||
<strong className="font-semibold">Output token limit</strong>
|
||||
<RadioGroup
|
||||
value={value.max_tokens_from_tier_model ?? true}
|
||||
onValueChange={(mode: unknown) => {
|
||||
if (typeof mode === "boolean" || mode === "clamp") {
|
||||
onChange({ ...value, max_tokens_from_tier_model: mode });
|
||||
}
|
||||
}}
|
||||
aria-label="Output token limit"
|
||||
>
|
||||
{OUTPUT_TOKEN_POLICIES.map((policy) => (
|
||||
<Label key={String(policy.value)} className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value={policy.value} className="mt-0.5" />
|
||||
<span>
|
||||
<span className="block font-medium">{policy.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{policy.description}</span>
|
||||
</span>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.return_raw_model_name ?? false}
|
||||
onCheckedChange={(returnRawModelName) => onChange({ ...value, return_raw_model_name: returnRawModelName })}
|
||||
aria-label={t("addModel.responseFormatControls.rawModelName.label")}
|
||||
/>
|
||||
<strong className="font-semibold">{t("addModel.responseFormatControls.rawModelName.label")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Explicit tier output limits take precedence. Unknown model ceilings keep the caller's limit.
|
||||
{t("addModel.responseFormatControls.rawModelName.help")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="mt-4 space-y-2">
|
||||
<strong className="font-semibold">{t("addModel.responseFormatControls.outputTokenLimit.label")}</strong>
|
||||
<RadioGroup
|
||||
value={value.max_tokens_from_tier_model ?? true}
|
||||
onValueChange={(mode: unknown) => {
|
||||
if (typeof mode === "boolean" || mode === "clamp") {
|
||||
onChange({ ...value, max_tokens_from_tier_model: mode });
|
||||
}
|
||||
}}
|
||||
aria-label={t("addModel.responseFormatControls.outputTokenLimit.label")}
|
||||
>
|
||||
{OUTPUT_TOKEN_POLICIES.map((policy) => (
|
||||
<Label key={String(policy.value)} className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value={policy.value} className="mt-0.5" />
|
||||
<span>
|
||||
<span className="block font-medium">{t(policy.label)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t(policy.description)}</span>
|
||||
</span>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("addModel.responseFormatControls.outputTokenLimit.note")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseFormatControls;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, CircleHelp, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
|
@ -53,6 +54,7 @@ interface UtteranceInputProps {
|
|||
}
|
||||
|
||||
const UtteranceInput = ({ value, onChange }: UtteranceInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const addUtterances = (input: string) => {
|
||||
|
|
@ -75,7 +77,7 @@ const UtteranceInput = ({ value, onChange }: UtteranceInputProps) => {
|
|||
<span className="truncate">{utterance}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${utterance}`}
|
||||
aria-label={t("addModel.routerConfigBuilder.removeUtterance", { utterance })}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => onChange(value.filter((item) => item !== utterance))}
|
||||
>
|
||||
|
|
@ -84,7 +86,7 @@ const UtteranceInput = ({ value, onChange }: UtteranceInputProps) => {
|
|||
</Badge>
|
||||
))}
|
||||
<input
|
||||
aria-label="Example Utterances"
|
||||
aria-label={t("addModel.routerConfigBuilder.exampleUtterances")}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={() => draft.trim() && addUtterances(draft)}
|
||||
|
|
@ -103,7 +105,7 @@ const UtteranceInput = ({ value, onChange }: UtteranceInputProps) => {
|
|||
addUtterances(pastedText);
|
||||
}
|
||||
}}
|
||||
placeholder={value.length === 0 ? "Type an utterance and press Enter..." : undefined}
|
||||
placeholder={value.length === 0 ? t("addModel.routerConfigBuilder.utterancePlaceholder") : undefined}
|
||||
className="min-w-48 flex-1 bg-transparent py-0.5 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -128,6 +130,7 @@ const HelpTooltip = ({ content }: { content: string }) => (
|
|||
);
|
||||
|
||||
const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [routes, setRoutes] = useState<Route[]>([]);
|
||||
const [showJsonPreview, setShowJsonPreview] = useState(false);
|
||||
const [expandedRoutes, setExpandedRoutes] = useState<string[]>([]);
|
||||
|
|
@ -207,19 +210,19 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
<div className="w-full space-y-6">
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">Routes Configuration</h3>
|
||||
<HelpTooltip content="Configure routing logic to automatically select the best model based on user input patterns" />
|
||||
<h3 className="text-lg font-semibold">{t("addModel.routerConfigBuilder.title")}</h3>
|
||||
<HelpTooltip content={t("addModel.routerConfigBuilder.titleHint")} />
|
||||
</div>
|
||||
<Button type="button" onClick={addRoute}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Add Route
|
||||
{t("addModel.routerConfigBuilder.addRoute")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{routes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No routes configured. Click "Add Route" to get started.
|
||||
{t("addModel.routerConfigBuilder.empty")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
|
@ -245,12 +248,15 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
className={`size-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
<span className="truncate text-base font-medium">
|
||||
Route {index + 1}: {route.model || "Unnamed"}
|
||||
{t("addModel.routerConfigBuilder.routeTitle", {
|
||||
number: index + 1,
|
||||
name: route.model || t("addModel.routerConfigBuilder.unnamed"),
|
||||
})}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="delete"
|
||||
aria-label={t("addModel.routerConfigBuilder.deleteRoute")}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeRoute(route.id)}
|
||||
|
|
@ -262,30 +268,34 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
<Separator />
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Label>{t("addModel.routerConfigBuilder.model")}</Label>
|
||||
<SearchSelect
|
||||
value={route.model}
|
||||
onValueChange={(model) => updateRoute(route.id, "model", model)}
|
||||
placeholder="Select model"
|
||||
placeholder={t("addModel.routerConfigBuilder.selectModel")}
|
||||
options={modelOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${route.id}-description`}>Description</Label>
|
||||
<Label htmlFor={`${route.id}-description`}>
|
||||
{t("addModel.routerConfigBuilder.description")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`${route.id}-description`}
|
||||
value={route.description}
|
||||
onChange={(event) => updateRoute(route.id, "description", event.target.value)}
|
||||
placeholder="Describe when this route should be used..."
|
||||
placeholder={t("addModel.routerConfigBuilder.descriptionPlaceholder")}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`${route.id}-threshold`}>Score Threshold</Label>
|
||||
<HelpTooltip content="Minimum similarity score to route to this model (0-1)" />
|
||||
<Label htmlFor={`${route.id}-threshold`}>
|
||||
{t("addModel.routerConfigBuilder.scoreThreshold")}
|
||||
</Label>
|
||||
<HelpTooltip content={t("addModel.routerConfigBuilder.scoreThresholdHint")} />
|
||||
</div>
|
||||
<Input
|
||||
id={`${route.id}-threshold`}
|
||||
|
|
@ -303,11 +313,11 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Example Utterances</Label>
|
||||
<HelpTooltip content="Training examples for this route. Type an utterance and press Enter to add it." />
|
||||
<Label>{t("addModel.routerConfigBuilder.exampleUtterances")}</Label>
|
||||
<HelpTooltip content={t("addModel.routerConfigBuilder.exampleUtterancesHint")} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type an utterance and press Enter to add it. You can also paste multiple lines.
|
||||
{t("addModel.routerConfigBuilder.exampleUtterancesHelp")}
|
||||
</p>
|
||||
<UtteranceInput
|
||||
value={route.utterances}
|
||||
|
|
@ -324,9 +334,9 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
|
|||
|
||||
<Separator />
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold">JSON Preview</h3>
|
||||
<h3 className="text-lg font-semibold">{t("addModel.routerConfigBuilder.jsonPreview")}</h3>
|
||||
<Button type="button" variant="link" onClick={() => setShowJsonPreview((visible) => !visible)}>
|
||||
{showJsonPreview ? "Hide" : "Show"}
|
||||
{showJsonPreview ? t("addModel.routerConfigBuilder.hide") : t("addModel.routerConfigBuilder.show")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
interface RoutingOptionsProps {
|
||||
label?: string;
|
||||
|
|
@ -10,7 +11,7 @@ interface RoutingOptionsProps {
|
|||
}
|
||||
|
||||
const RoutingOptions = ({
|
||||
label = "Advanced settings",
|
||||
label = i18n.t("addModel.routingOptions.advancedSettings"),
|
||||
showValidationErrors = false,
|
||||
summary,
|
||||
children,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SearchSelect } from "@/components/shared/SearchSelect";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
const DEFAULT_MATCH_THRESHOLD = 0.5;
|
||||
|
|
@ -29,6 +30,7 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
|
|||
modelInfo,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const embeddingModels = modelInfo.filter((model) => model.mode === "embedding");
|
||||
const modelOptions = Array.from(new Set(embeddingModels.map((model) => model.model_group))).map((model_group) => ({
|
||||
value: model_group,
|
||||
|
|
@ -41,39 +43,48 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
|
|||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Semantic keyword matching</span>
|
||||
<SimpleTooltip content="Recognize related phrasing beyond exact keyword matches by comparing embeddings instead of plain text. Overrides direct keyword matching">
|
||||
<span className="font-medium">{t("addModel.semanticKeywordMatching.title")}</span>
|
||||
<SimpleTooltip content={t("addModel.semanticKeywordMatching.tooltip")}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Uses same keyword-tier pairs as above and overrides direct keyword matching. Adds latency based on embedding
|
||||
model network request.
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{t("addModel.semanticKeywordMatching.help")}</span>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={onEnabledChange} aria-label="Semantic keyword matching" />
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
aria-label={t("addModel.semanticKeywordMatching.title")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="grid gap-4 md:grid-cols-2 mt-4 pt-4 border-t border-border">
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-medium">Embedding model</span>
|
||||
<span className="mb-1 block text-sm font-medium">
|
||||
{t("addModel.semanticKeywordMatching.embeddingModel.label")}
|
||||
</span>
|
||||
<SearchSelect
|
||||
options={modelOptions}
|
||||
value={embeddingModel ?? ""}
|
||||
onValueChange={(model) => {
|
||||
if (model !== null) onEmbeddingModelChange(model);
|
||||
}}
|
||||
placeholder="Select an embedding model"
|
||||
emptyText="No embedding models found"
|
||||
aria-label="Embedding model"
|
||||
placeholder={t("addModel.semanticKeywordMatching.embeddingModel.placeholder")}
|
||||
emptyText={t("addModel.semanticKeywordMatching.embeddingModel.empty")}
|
||||
aria-label={t("addModel.semanticKeywordMatching.embeddingModel.label")}
|
||||
allowClear={false}
|
||||
className={embeddingModelMissing ? "border-destructive" : undefined}
|
||||
/>
|
||||
{embeddingModelMissing && <span className="text-xs text-destructive">An embedding model is required</span>}
|
||||
{embeddingModelMissing && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t("addModel.semanticKeywordMatching.embeddingModel.required")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-medium">Minimum match score</span>
|
||||
<span className="mb-1 block text-sm font-medium">
|
||||
{t("addModel.semanticKeywordMatching.matchScore.label")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={matchThreshold}
|
||||
|
|
@ -86,7 +97,7 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
|
|||
className="w-full"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
Match only at or above this similarity score.
|
||||
{t("addModel.semanticKeywordMatching.matchScore.help")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { type ComplexityRouterConfigValue, classificationFrequency } from "./ComplexityRouterConfig";
|
||||
|
||||
export const DEFAULT_STALL_ESCALATION_WINDOW = 6;
|
||||
|
|
@ -11,12 +14,13 @@ export const DEFAULT_STALL_ESCALATION_REPEAT_THRESHOLD = 3;
|
|||
* routing decision instead of classifying most turns, so detection would never see the tool
|
||||
* calls it reads.
|
||||
*/
|
||||
export const stallEscalationBlockedReason = (value: ComplexityRouterConfigValue): string | null => {
|
||||
export const stallEscalationBlockedReason = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
const frequency = classificationFrequency(value);
|
||||
if (frequency === "session")
|
||||
return 'Set "How often to classify" to every request under Advanced: Classification Method to use this. Scoring once per session replays that model instead of classifying, so a stall never reaches the classifier.';
|
||||
if (frequency === "user_turn")
|
||||
return 'Set "How often to classify" to every request under Advanced: Classification Method to use this. Scoring only new user messages skips the tool-call turns a stall shows up in.';
|
||||
if (frequency === "session") return t("addModel.stallEscalationConfig.blockedBySession");
|
||||
if (frequency === "user_turn") return t("addModel.stallEscalationConfig.blockedByUserTurn");
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -30,8 +34,9 @@ const StallEscalationConfig: React.FC<{
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const enabled = value.stall_escalation_enabled ?? false;
|
||||
const blockedReason = stallEscalationBlockedReason(value);
|
||||
const blockedReason = stallEscalationBlockedReason(value, t);
|
||||
const window = value.stall_escalation_window ?? DEFAULT_STALL_ESCALATION_WINDOW;
|
||||
const threshold = value.stall_escalation_repeat_threshold ?? DEFAULT_STALL_ESCALATION_REPEAT_THRESHOLD;
|
||||
// A threshold above the window can never be reached, and the backend rejects the pair, so the
|
||||
|
|
@ -70,21 +75,19 @@ const StallEscalationConfig: React.FC<{
|
|||
// this back off, since the backend rejects saving both together.
|
||||
disabled={blockedReason !== null && !enabled}
|
||||
onCheckedChange={toggle}
|
||||
aria-label="Escalate a stalled task to a stronger model"
|
||||
aria-label={t("addModel.stallEscalationConfig.escalateStalled")}
|
||||
/>
|
||||
<strong className="font-semibold">Escalate a stalled task to a stronger model</strong>
|
||||
<strong className="font-semibold">{t("addModel.stallEscalationConfig.escalateStalled")}</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
When the model keeps repeating the same tool call, or the same call keeps erroring, bump the request one tier
|
||||
higher for as long as it looks stuck. The automatic counterpart to an escalation keyword: nobody has to notice
|
||||
the loop and ask. Off means a stuck task keeps the model it was classified onto.
|
||||
{t("addModel.stallEscalationConfig.description")}
|
||||
{blockedReason !== null && ` ${blockedReason}`}
|
||||
</span>
|
||||
{enabled && blockedReason === null && (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div style={{ maxWidth: 240 }}>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="stall-escalation-repeat-threshold">
|
||||
Repeats before escalating
|
||||
{t("addModel.stallEscalationConfig.repeatThreshold")}
|
||||
</label>
|
||||
<Input
|
||||
id="stall-escalation-repeat-threshold"
|
||||
|
|
@ -93,12 +96,12 @@ const StallEscalationConfig: React.FC<{
|
|||
onChange={(event) => commitThreshold(event.target.value)}
|
||||
/>
|
||||
<span className="block text-xs mt-1 text-muted-foreground">
|
||||
How many identical or failing calls count as stuck. At least 2; lower reacts sooner and misfires more.
|
||||
{t("addModel.stallEscalationConfig.repeatThresholdHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ maxWidth: 240 }}>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="stall-escalation-window">
|
||||
Recent calls examined
|
||||
{t("addModel.stallEscalationConfig.window")}
|
||||
</label>
|
||||
<Input
|
||||
id="stall-escalation-window"
|
||||
|
|
@ -107,7 +110,7 @@ const StallEscalationConfig: React.FC<{
|
|||
onChange={(event) => commitWindow(event.target.value)}
|
||||
/>
|
||||
<span className="block text-xs mt-1 text-muted-foreground">
|
||||
How far back to look, in tool calls. Never below the repeat count, since that could never be reached.
|
||||
{t("addModel.stallEscalationConfig.windowHelp")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { type ComplexityRouterConfigValue, usesLlmClassifier } from "./ComplexityRouterConfig";
|
||||
import { restrictedBy } from "./TierRestrictions";
|
||||
|
||||
const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => (
|
||||
<div className="mb-4 space-y-2 text-sm text-muted-foreground">
|
||||
<p>Choose one or more models for each tier. Requests use the models in their assigned tier</p>
|
||||
<p className="text-xs">
|
||||
{restrictedBy(value, "displayNames")?.reason ?? "Display names appear in the dashboard and spend logs"}
|
||||
{!value.custom_tier_set &&
|
||||
usesLlmClassifier(value.classifier_type) &&
|
||||
". Your judge model also uses these names to classify requests"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-4 space-y-2 text-sm text-muted-foreground">
|
||||
<p>{t("addModel.tierConfigIntro.chooseModels")}</p>
|
||||
<p className="text-xs">
|
||||
{restrictedBy(value, "displayNames", t)?.reason ?? t("addModel.tierConfigIntro.displayNamesAppear")}
|
||||
{!value.custom_tier_set &&
|
||||
usesLlmClassifier(value.classifier_type) &&
|
||||
t("addModel.tierConfigIntro.judgeUsesNames")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TierConfigIntro;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReasoningEffort, TierModelParams } from "./complexity_router_tiers";
|
||||
|
||||
const PROVIDER_DEFAULT = "__provider_default__";
|
||||
|
|
@ -58,6 +59,7 @@ export const tierEffortRows = ({
|
|||
.filter(({ model, options }) => options.length > 0 || canEditFastMode(model, fastModeByModel, paramsByModel));
|
||||
|
||||
const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { tierLabel, paramsByModel, onEffortChange, fastModeByModel, onFastModeChange } = props;
|
||||
const rows = tierEffortRows(props);
|
||||
if (rows.length === 0) return null;
|
||||
|
|
@ -65,10 +67,10 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
|||
<div className="mt-2 space-y-1">
|
||||
{rows.some(({ options }) => options.length > 0) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Reasoning effort</span>
|
||||
<SimpleTooltip
|
||||
content={`Sent as reasoning_effort on requests this tier routes to the model, overriding the caller's value. Default leaves the request untouched.`}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("addModel.tierModelEffortRows.reasoningEffort")}
|
||||
</span>
|
||||
<SimpleTooltip content={t("addModel.tierModelEffortRows.reasoningEffortTooltip")}>
|
||||
<Info className="size-3 text-muted-foreground/70" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
|
@ -82,7 +84,7 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
|||
{options.length > 0 && (
|
||||
<Select
|
||||
items={[
|
||||
{ value: PROVIDER_DEFAULT, label: "Default" },
|
||||
{ value: PROVIDER_DEFAULT, label: t("addModel.tierModelEffortRows.default") },
|
||||
...options.map((option) => ({ value: option, label: option })),
|
||||
]}
|
||||
value={effort ?? PROVIDER_DEFAULT}
|
||||
|
|
@ -93,12 +95,12 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
|||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-36"
|
||||
aria-label={`Reasoning effort for ${model} in the ${tierLabel} tier`}
|
||||
aria-label={t("addModel.tierModelEffortRows.reasoningEffortAria", { model, tier: tierLabel })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROVIDER_DEFAULT}>Default</SelectItem>
|
||||
<SelectItem value={PROVIDER_DEFAULT}>{t("addModel.tierModelEffortRows.default")}</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
|
|
@ -108,17 +110,17 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
|||
</Select>
|
||||
)}
|
||||
{canEditFastMode(model, fastModeByModel, paramsByModel) && (
|
||||
<SimpleTooltip content="Fast mode has higher pricing and requires an eligible provider account. Off removes this tier's speed override and inherits the request or provider default">
|
||||
<SimpleTooltip content={t("addModel.tierModelEffortRows.fastModeTooltip")}>
|
||||
<label
|
||||
className="flex items-center gap-2 text-xs"
|
||||
aria-label={`Fast mode for ${model} in the ${tierLabel} tier`}
|
||||
aria-label={t("addModel.tierModelEffortRows.fastModeAria", { model, tier: tierLabel })}
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={paramsByModel?.[model]?.speed === "fast"}
|
||||
onCheckedChange={(enabled) => onFastModeChange(model, enabled)}
|
||||
/>
|
||||
Fast mode
|
||||
{t("addModel.tierModelEffortRows.fastMode")}
|
||||
</label>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
|
||||
|
||||
export const restrictedBy = (
|
||||
value: { custom_tier_set?: CustomTierSet; classifier_type?: string },
|
||||
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): TierRestriction | undefined => {
|
||||
if (value.custom_tier_set) return CUSTOM_TIER_RESTRICTIONS[key];
|
||||
if (value.classifier_type === "llm_v2" && key === "adaptive")
|
||||
return {
|
||||
omit: ["adaptive", "adaptive_weights", "adaptive_eligible", "tier_distance_penalty"],
|
||||
reason: "Fuse v2 uses its quality-gap decision directly; adaptive routing is unavailable",
|
||||
reason: t("addModel.tierRestrictions.fuseAdaptiveUnavailable"),
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ClassifierPrimarySettings from "./ClassifierPrimarySettings";
|
|||
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
|
||||
import { getForecastConfigError, isForecastClassifier } from "../add_model/forecast_classifier_config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
|
@ -66,6 +67,7 @@ import {
|
|||
} from "./build_auto_router_routing_test_request";
|
||||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
import { toast } from "@/lib/toast";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import {
|
||||
getMissingModelsInPreset,
|
||||
getReferencedModelsError,
|
||||
|
|
@ -129,7 +131,7 @@ export const getSubmitBlockedReason = (
|
|||
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
|
||||
(effectiveClassifierType(config) === "jev" && !config.jev_classifier_config?.deployment_name
|
||||
? "Please select a decision model"
|
||||
? i18n.t("addModel.addAutoRouterTab.decisionModelRequired")
|
||||
: null) ??
|
||||
getClassifierModelError(config) ??
|
||||
getHeuristicV2SuccessThresholdError(config.heuristic_v2_success_threshold) ??
|
||||
|
|
@ -143,11 +145,13 @@ export const getSubmitBlockedReason = (
|
|||
|
||||
const autoRouterSchema = (requiresTeamScope: boolean) =>
|
||||
z.object({
|
||||
auto_router_name: z.string().min(1, "Auto router name is required"),
|
||||
auto_router_name: z.string().min(1, { error: () => i18n.t("addModel.addAutoRouterTab.nameRequired") }),
|
||||
team_id: z
|
||||
.string()
|
||||
.nullable()
|
||||
.refine((teamId) => !requiresTeamScope || Boolean(teamId), "Please select a team to continue"),
|
||||
.refine((teamId) => !requiresTeamScope || Boolean(teamId), {
|
||||
error: () => i18n.t("addModel.addAutoRouterTab.teamRequired"),
|
||||
}),
|
||||
model_access_group: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
|
|
@ -183,6 +187,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
createScope = "unscoped-ok",
|
||||
teams = null,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const requiresTeamScope = createScope === "team-required";
|
||||
const form = useZodForm(autoRouterSchema(requiresTeamScope), { defaultValues: EMPTY_FORM_VALUES });
|
||||
const watchedName = useWatch({ control: form.control, name: "auto_router_name" });
|
||||
|
|
@ -436,7 +441,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const simpleBlockedReason = advanced ? null : simpleSetupError(modelsLoading, inventoryUnavailable, everyday, hard);
|
||||
const saveBlockedReason =
|
||||
!advanced && requiresTeamScope && !watchedTeamId
|
||||
? "Select a team to see available models."
|
||||
? t("addModel.addAutoRouterTab.selectTeamToSeeModels")
|
||||
: simpleBlockedReason ?? submitBlockedReason ?? routerAvailability.saveBlockedReason;
|
||||
const handleClassifierChange = (config: ComplexityRouterConfigValue) => {
|
||||
setSelectedPreset(undefined);
|
||||
|
|
@ -480,7 +485,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
: (["auto_router_name"] as const);
|
||||
|
||||
if (!(await form.trigger(validatedFields))) {
|
||||
toast.fromError("Please fill in all required fields");
|
||||
toast.fromError(t("addModel.addAutoRouterTab.requiredFieldsMissing"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -524,7 +529,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
if (!name) {
|
||||
setShowValidationErrors(true);
|
||||
void form.trigger("auto_router_name");
|
||||
toast.fromError("Please enter an Auto Router Name");
|
||||
toast.fromError(t("addModel.addAutoRouterTab.enterRouterName"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -551,10 +556,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
}
|
||||
: undefined,
|
||||
};
|
||||
const targets = buildAutoRouterTestTargets(testTargetParams);
|
||||
const targets = buildAutoRouterTestTargets(testTargetParams, t);
|
||||
|
||||
if (targets.length === 0) {
|
||||
toast.fromError("Please select at least one model for a complexity tier");
|
||||
toast.fromError(t("addModel.addAutoRouterTab.selectTierModel"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -612,10 +617,13 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
|
||||
label={labelWithHint(
|
||||
t("addModel.addAutoRouterTab.routerName"),
|
||||
t("addModel.addAutoRouterTab.routerNameHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />
|
||||
<Input {...field} ref={ref} placeholder={t("addModel.addAutoRouterTab.routerNamePlaceholder")} />
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
|
@ -626,8 +634,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
t("addModel.addAutoRouterTab.modelAccessGroup"),
|
||||
t("addModel.addAutoRouterTab.modelAccessGroupHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
|
|
@ -649,8 +657,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
control={form.control}
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
|
||||
t("addModel.addAutoRouterTab.selectTeam"),
|
||||
t("addModel.addAutoRouterTab.selectTeamHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
|
|
@ -679,7 +687,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
error={inventoryUnavailable}
|
||||
notice={
|
||||
Object.keys(solverOverrides).length > 0
|
||||
? "Your model choices are kept."
|
||||
? t("addModel.addAutoRouterTab.choicesKept")
|
||||
: recommended?.notice ?? null
|
||||
}
|
||||
onGoalChange={setGoal}
|
||||
|
|
@ -724,9 +732,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<FieldGroup>
|
||||
{modelsUnverifiable && (
|
||||
<p className="text-sm text-destructive">
|
||||
Could not load available models.{" "}
|
||||
{t("addModel.addAutoRouterTab.modelsLoadFailed")}{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchModels()}>
|
||||
Retry
|
||||
{t("addModel.addAutoRouterTab.retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -736,9 +744,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
)}
|
||||
{!advanced && routerAvailability.isError && (
|
||||
<p className="mt-3 text-sm text-destructive" role="alert">
|
||||
Could not check availability.{" "}
|
||||
{t("addModel.addAutoRouterTab.availabilityCheckFailed")}{" "}
|
||||
<Button type="button" variant="link" onClick={() => void routerAvailability.refetch()}>
|
||||
Retry
|
||||
{t("addModel.addAutoRouterTab.retry")}
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -755,11 +763,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
{t("addModel.addAutoRouterTab.needHelp")}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
<TooltipContent>{t("addModel.addAutoRouterTab.helpTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
|
|
@ -770,7 +778,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Test Routing
|
||||
{t("addModel.addAutoRouterTab.testRouting")}
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<Button
|
||||
|
|
@ -781,7 +789,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
disabled={isTestingConnection}
|
||||
>
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
{t("addModel.addAutoRouterTab.testConnection")}
|
||||
</Button>
|
||||
<BlockedReasonTooltip reason={saveBlockedReason}>
|
||||
<Button
|
||||
|
|
@ -791,7 +799,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
{t("addModel.addAutoRouterTab.addAutoRouter")}
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
</div>
|
||||
|
|
@ -803,7 +811,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<Dialog open={isRoutingTestVisible} onOpenChange={(open) => !open && setIsRoutingTestVisible(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[760px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test Routing</DialogTitle>
|
||||
<DialogTitle>{t("addModel.addAutoRouterTab.testRoutingTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isRoutingTestVisible && (
|
||||
<AutoRouterRoutingTest
|
||||
|
|
@ -820,7 +828,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<DialogFooter>
|
||||
{" "}
|
||||
<Button variant="outline" onClick={() => setIsRoutingTestVisible(false)}>
|
||||
Close
|
||||
{t("addModel.addAutoRouterTab.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -65,7 +67,7 @@ const validateNumber = (_: unknown, value: unknown) => {
|
|||
return Promise.resolve();
|
||||
}
|
||||
if (isNaN(Number(value)) || Number(value) < 0) {
|
||||
return Promise.reject("Please enter a valid positive number");
|
||||
return Promise.reject(i18n.t("addModel.advancedSettings.invalidPositiveNumber"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
@ -83,6 +85,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
tagsList,
|
||||
accessToken,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [customPricing, setCustomPricing] = React.useState(false);
|
||||
const [pricingModel, setPricingModel] = React.useState<"per_token" | "per_second">("per_token");
|
||||
const [showCacheControl, setShowCacheControl] = React.useState(false);
|
||||
|
|
@ -100,12 +103,16 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<>
|
||||
<Collapsible className="mt-2 mb-4 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<b>Advanced Settings</b>
|
||||
<b>{t("addModel.advancedSettings.title")}</b>
|
||||
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-3">
|
||||
<div className="rounded-lg">
|
||||
<MountedFormField name="custom_pricing" label="Custom Pricing" className="mb-4">
|
||||
<MountedFormField
|
||||
name="custom_pricing"
|
||||
label={t("addModel.advancedSettings.customPricing")}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<Switch
|
||||
id={control.id}
|
||||
|
|
@ -122,8 +129,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
name="vector_store_ids"
|
||||
label={
|
||||
<span>
|
||||
Attached Knowledge Bases (RAG){" "}
|
||||
<SimpleTooltip content="Vector stores to use for RAG. Every request to this model will automatically retrieve context from these knowledge bases.">
|
||||
{t("addModel.advancedSettings.knowledgeBases")}{" "}
|
||||
<SimpleTooltip content={t("addModel.advancedSettings.knowledgeBasesTooltip")}>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/knowledgebase"
|
||||
target="_blank"
|
||||
|
|
@ -136,14 +143,14 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
</span>
|
||||
}
|
||||
className="mt-4"
|
||||
help="Select vector stores to attach. Requests to this model will automatically use these for RAG. Set up vector stores in Tools > Vector Stores."
|
||||
help={t("addModel.advancedSettings.knowledgeBasesHelp")}
|
||||
>
|
||||
{(control) => (
|
||||
<VectorStoreSelector
|
||||
onChange={control.onChange}
|
||||
value={control.value as string[] | undefined}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select knowledge bases (optional)"
|
||||
placeholder={t("addModel.advancedSettings.knowledgeBasesPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
@ -152,8 +159,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
name="guardrails"
|
||||
label={
|
||||
<span>
|
||||
Guardrails{" "}
|
||||
<SimpleTooltip content="Apply safety guardrails to this key to filter content or enforce policies">
|
||||
{t("addModel.advancedSettings.guardrails")}{" "}
|
||||
<SimpleTooltip content={t("addModel.advancedSettings.guardrailsTooltip")}>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
|
|
@ -166,13 +173,13 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
</span>
|
||||
}
|
||||
className="mt-4"
|
||||
help="Select existing guardrails. Go to 'Guardrails' tab to create new guardrails."
|
||||
help={t("addModel.advancedSettings.guardrailsHelp")}
|
||||
>
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
id={control.id}
|
||||
placeholder="Select or enter guardrails"
|
||||
emptyText="Type to add a guardrail"
|
||||
placeholder={t("addModel.advancedSettings.guardrailsPlaceholder")}
|
||||
emptyText={t("addModel.advancedSettings.guardrailsEmpty")}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={control.onChange}
|
||||
options={guardrailsList.map((name) => ({ value: name, label: name }))}
|
||||
|
|
@ -181,12 +188,12 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField name="tags" label="Tags" className="mb-4">
|
||||
<MountedFormField name="tags" label={t("addModel.advancedSettings.tags")} className="mb-4">
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
id={control.id}
|
||||
placeholder="Select or enter tags"
|
||||
emptyText="Type to add a tag"
|
||||
placeholder={t("addModel.advancedSettings.tagsPlaceholder")}
|
||||
emptyText={t("addModel.advancedSettings.tagsEmpty")}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={control.onChange}
|
||||
options={Object.values(tagsList).map((tag) => ({
|
||||
|
|
@ -204,8 +211,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name={PTU_COUNT_FIELD}
|
||||
label={labelWithHint(
|
||||
"PTU Count",
|
||||
"Provisioned throughput units for this deployment. Set together with Cost per PTU / Hour and a Team to attribute a flat daily cost.",
|
||||
t("addModel.advancedSettings.ptuCount"),
|
||||
t("addModel.advancedSettings.ptuCountHint"),
|
||||
)}
|
||||
rules={{
|
||||
deps: REVALIDATED_WHEN_PTU_COUNT_CHANGES,
|
||||
|
|
@ -231,8 +238,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name={PTU_RATE_FIELD}
|
||||
label={labelWithHint(
|
||||
"Calculated Cost per PTU / Hour (USD)",
|
||||
"Flat cost = PTU count * this rate * active hours, attributed to the deployment's team.",
|
||||
t("addModel.advancedSettings.ptuRate"),
|
||||
t("addModel.advancedSettings.ptuRateHint"),
|
||||
)}
|
||||
rules={{
|
||||
deps: [PTU_COUNT_FIELD],
|
||||
|
|
@ -258,8 +265,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name={PTU_START_FIELD}
|
||||
label={labelWithHint(
|
||||
"PTU Effective From (UTC)",
|
||||
"Start of the PTU window, required when PTU Count is set. Flat cost accrues by the hour within the window; a window opening at 23:00 charges one hour that day.",
|
||||
t("addModel.advancedSettings.ptuStart"),
|
||||
t("addModel.advancedSettings.ptuStartHint"),
|
||||
)}
|
||||
rules={{
|
||||
deps: [PTU_END_FIELD],
|
||||
|
|
@ -283,8 +290,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name={PTU_END_FIELD}
|
||||
label={labelWithHint(
|
||||
"PTU Effective To (UTC)",
|
||||
"Optional end of the PTU window (exclusive). Leave blank for open-ended.",
|
||||
t("addModel.advancedSettings.ptuEnd"),
|
||||
t("addModel.advancedSettings.ptuEndHint"),
|
||||
)}
|
||||
rules={{
|
||||
deps: [PTU_START_FIELD],
|
||||
|
|
@ -306,7 +313,11 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
|
||||
{customPricing && (
|
||||
<div className="ml-6 pl-4 border-l-2 border-border">
|
||||
<MountedFormField name="pricing_model" label="Pricing Model" className="mb-4">
|
||||
<MountedFormField
|
||||
name="pricing_model"
|
||||
label={t("addModel.advancedSettings.pricingModel")}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
items={PRICING_MODEL_ITEMS}
|
||||
|
|
@ -331,7 +342,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<>
|
||||
<MountedFormField
|
||||
name="input_cost_per_token"
|
||||
label="Input Cost (per 1M tokens)"
|
||||
label={t("addModel.advancedSettings.inputCost")}
|
||||
rules={usageCostRules}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -346,7 +357,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
</MountedFormField>
|
||||
<MountedFormField
|
||||
name="output_cost_per_token"
|
||||
label="Output Cost (per 1M tokens)"
|
||||
label={t("addModel.advancedSettings.outputCost")}
|
||||
rules={usageCostRules}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -361,7 +372,10 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
</MountedFormField>
|
||||
<MountedFormField
|
||||
name="cache_read_input_token_cost"
|
||||
label={labelWithHint("Cache Read Cost (per 1M tokens)", "If left blank, defaults to Input Cost.")}
|
||||
label={labelWithHint(
|
||||
t("addModel.advancedSettings.cacheReadCost"),
|
||||
t("addModel.advancedSettings.cacheReadCostHint"),
|
||||
)}
|
||||
rules={usageCostRules}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -371,15 +385,15 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
value={(control.value as string | undefined) ?? ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="Defaults to Input Cost if blank"
|
||||
placeholder={t("addModel.advancedSettings.defaultsToInputCost")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
name="cache_creation_input_token_cost"
|
||||
label={labelWithHint(
|
||||
"Cache Write Cost (per 1M tokens)",
|
||||
"If left blank, defaults to Input Cost (the backend falls back to input_cost_per_token when no cache-write rate is set).",
|
||||
t("addModel.advancedSettings.cacheWriteCost"),
|
||||
t("addModel.advancedSettings.cacheWriteCostHint"),
|
||||
)}
|
||||
rules={usageCostRules}
|
||||
className="mb-4"
|
||||
|
|
@ -390,15 +404,15 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
value={(control.value as string | undefined) ?? ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="Defaults to Input Cost if blank"
|
||||
placeholder={t("addModel.advancedSettings.defaultsToInputCost")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
name="cache_creation_input_token_cost_above_1hr"
|
||||
label={labelWithHint(
|
||||
"Cache Write Cost (1 hour, per 1M tokens)",
|
||||
"Rate for prompt cache writes with a 1 hour TTL (Anthropic's cache_control ttl 1h). If left blank, known models use their cost map 1 hour rate, otherwise Cache Write Cost.",
|
||||
t("addModel.advancedSettings.cacheWriteCost1h"),
|
||||
t("addModel.advancedSettings.cacheWriteCost1hHint"),
|
||||
)}
|
||||
rules={usageCostRules}
|
||||
className="mb-4"
|
||||
|
|
@ -409,7 +423,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
value={(control.value as string | undefined) ?? ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="Defaults to the cost map 1 hour rate, else Cache Write Cost"
|
||||
placeholder={t("addModel.advancedSettings.cacheWriteCost1hPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
@ -417,7 +431,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
) : (
|
||||
<MountedFormField
|
||||
name="input_cost_per_second"
|
||||
label="Cost Per Second"
|
||||
label={t("addModel.advancedSettings.costPerSecond")}
|
||||
rules={usageCostRules}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -437,17 +451,21 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name="use_in_pass_through"
|
||||
label={labelWithHint(
|
||||
"Use in pass through routes",
|
||||
t("addModel.advancedSettings.useInPassThrough"),
|
||||
<span>
|
||||
Allow using these credentials in pass through routes.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/pass_through/vertex_ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="addModel.advancedSettings.useInPassThroughHint"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/pass_through/vertex_ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>,
|
||||
)}
|
||||
className="mb-4 mt-4"
|
||||
|
|
@ -460,9 +478,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name="kubernetes_pod_discovery"
|
||||
label={labelWithHint(
|
||||
"Kubernetes pod discovery",
|
||||
"Treat the API Base hostname as a Kubernetes headless service: resolve it to its ready pod IPs " +
|
||||
"and spread requests across the pods. LiteLLM must run inside the same cluster.",
|
||||
t("addModel.advancedSettings.kubernetesPodDiscovery"),
|
||||
t("addModel.advancedSettings.kubernetesPodDiscoveryHint"),
|
||||
)}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -501,8 +518,8 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<MountedFormField
|
||||
name="litellm_extra_params"
|
||||
label={labelWithHint(
|
||||
"LiteLLM Params",
|
||||
"Optional litellm params used for making a litellm.completion() call.",
|
||||
t("addModel.advancedSettings.litellmParams"),
|
||||
t("addModel.advancedSettings.litellmParamsHint"),
|
||||
)}
|
||||
className="mb-4 mt-4"
|
||||
rules={{ validate: validatorRules({ validator: formItemValidateJSON }) }}
|
||||
|
|
@ -524,22 +541,26 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
</MountedFormField>
|
||||
<div className="grid grid-cols-24 mb-4">
|
||||
<p className="col-start-11 col-span-10 text-muted-foreground text-sm">
|
||||
Pass JSON of litellm supported params{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/input"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
litellm.completion() call
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="addModel.advancedSettings.litellmParamsDocs"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/input"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<MountedFormField
|
||||
name="model_info_params"
|
||||
label={labelWithHint(
|
||||
"Model Info",
|
||||
"Optional model info params. Returned when calling `/model/info` endpoint.",
|
||||
t("addModel.advancedSettings.modelInfo"),
|
||||
t("addModel.advancedSettings.modelInfoHint"),
|
||||
)}
|
||||
className="mb-0"
|
||||
rules={{ validate: validatorRules({ validator: formItemValidateJSON }) }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleCheck, CircleX, LoaderCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
|
|
@ -31,6 +32,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
|
|||
jevRequest,
|
||||
onTestComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [results, setResults] = React.useState<TargetResult[]>(() => targets.map(() => ({ status: "pending" })));
|
||||
const [jevResult, setJevResult] = React.useState<TargetResult>({ status: "pending" });
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
|
|||
? { status: "success" }
|
||||
: {
|
||||
status: "error",
|
||||
error: `OSS classifier was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
|
||||
error: t("addModel.autoRouterConnectionTest.ossNotReached", { cause: decision.cause ?? "unknown" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -77,25 +79,22 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
|
|||
}, []);
|
||||
|
||||
if (targets.length === 0 && !jevRequest) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No complexity tiers are configured yet, so there is nothing to test.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-muted-foreground">{t("addModel.autoRouterConnectionTest.nothingToTest")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Test Connection sends a minimal request to every configured tier, classifier, default, and embedding model. The
|
||||
classifier probe includes its reasoning effort override.
|
||||
</p>
|
||||
<p className="mb-2 text-sm text-muted-foreground">{t("addModel.autoRouterConnectionTest.intro")}</p>
|
||||
{jevRequest && (
|
||||
<div role="status" aria-label="OSS classifier connection" className="rounded-lg border p-3 text-sm">
|
||||
<strong>OSS Classifier</strong>
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t("addModel.autoRouterConnectionTest.ossConnection")}
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<strong>{t("addModel.autoRouterConnectionTest.ossClassifier")}</strong>
|
||||
<p>
|
||||
{jevResult.status === "pending" && "Testing OSS classification"}
|
||||
{jevResult.status === "success" && "OSS classification succeeded"}
|
||||
{jevResult.status === "pending" && t("addModel.autoRouterConnectionTest.ossTesting")}
|
||||
{jevResult.status === "success" && t("addModel.autoRouterConnectionTest.ossSucceeded")}
|
||||
{jevResult.status === "error" && jevResult.error}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -146,16 +145,17 @@ export function AutoRouterConnectionTestDialog({
|
|||
testId,
|
||||
...props
|
||||
}: AutoRouterConnectionTestProps & { open: boolean; onClose: () => void; testId: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connection Test Results</DialogTitle>
|
||||
<DialogTitle>{t("addModel.autoRouterConnectionTest.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{open && <AutoRouterConnectionTest key={testId} {...props} />}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("addModel.autoRouterConnectionTest.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { isAutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { type WildcardDeployment, type WildcardRoute, wildcardRoutes } from "@/lib/wildcard_routes";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
|
|
@ -219,6 +221,7 @@ export const recommendSolverPair = (
|
|||
models: readonly SolverModel[],
|
||||
goal: RouterGoal,
|
||||
family: FamilyPreference,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): SolverPair | null => {
|
||||
const recognized = models.filter((model): model is RouterModel => model.family !== undefined);
|
||||
const latest = latestLines(recognized);
|
||||
|
|
@ -245,8 +248,8 @@ export const recommendSolverPair = (
|
|||
if (!chosen) return null;
|
||||
const mixed = chosen.everyday.family !== chosen.hard.family;
|
||||
const substituted = chosen.everyday.rank !== low || chosen.hard.rank !== high;
|
||||
const mixedNotice = mixed ? "A complete pair is unavailable in one family. Using two model families." : null;
|
||||
const substituteNotice = substituted ? "Using the closest available pair in this family." : null;
|
||||
const mixedNotice = mixed ? t("addModel.autoSetup.mixedFamiliesNotice") : null;
|
||||
const substituteNotice = substituted ? t("addModel.autoSetup.closestPairNotice") : null;
|
||||
return { ...chosen, notice: mixedNotice ?? substituteNotice };
|
||||
};
|
||||
|
||||
|
|
@ -262,10 +265,10 @@ export const simpleSetupError = (
|
|||
everyday?: SolverModel,
|
||||
hard?: SolverModel,
|
||||
): string | null => {
|
||||
if (loading) return "Loading deployed models";
|
||||
if (failed) return "Could not verify deployed models";
|
||||
if (!everyday || !hard) return "Select two different deployed models";
|
||||
if (everyday.name === hard.name) return "Select two different deployed models";
|
||||
if (loading) return i18n.t("addModel.autoSetup.loadingModels");
|
||||
if (failed) return i18n.t("addModel.autoSetup.couldNotVerifyModels");
|
||||
if (!everyday || !hard) return i18n.t("addModel.autoSetup.selectTwoModels");
|
||||
if (everyday.name === hard.name) return i18n.t("addModel.autoSetup.selectTwoModels");
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { normalizeTierModels } from "./complexity_router_tiers";
|
||||
|
||||
export type AutoRouterTestMode = "chat" | "embedding";
|
||||
|
|
@ -20,13 +22,10 @@ export interface BuildAutoRouterTestTargetsParams {
|
|||
classifier?: { model: string; reasoningEffort?: string };
|
||||
}
|
||||
|
||||
export const buildAutoRouterTestTargets = ({
|
||||
tiers,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel,
|
||||
classifier,
|
||||
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
|
||||
export const buildAutoRouterTestTargets = (
|
||||
{ tiers, semanticMatchingEnabled, embeddingModel, defaultModel, classifier }: BuildAutoRouterTestTargetsParams,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): AutoRouterTestTarget[] => {
|
||||
const tieredByModel = tiers.reduce<Record<string, string[]>>((acc, [tier, models]) => {
|
||||
return models.reduce((tierAcc, rawModel) => {
|
||||
const modelGroup = rawModel?.trim();
|
||||
|
|
@ -41,7 +40,7 @@ export const buildAutoRouterTestTargets = ({
|
|||
const resolvedDefault = defaultModel?.trim();
|
||||
const groupedByModel =
|
||||
resolvedDefault && !(resolvedDefault in tieredByModel)
|
||||
? { ...tieredByModel, [resolvedDefault]: ["Default"] }
|
||||
? { ...tieredByModel, [resolvedDefault]: [t("addModel.buildAutoRouterTestTargets.default")] }
|
||||
: tieredByModel;
|
||||
|
||||
const tierTargets: AutoRouterTestTarget[] = Object.entries(groupedByModel).map(([modelGroup, labels]) => ({
|
||||
|
|
@ -52,14 +51,20 @@ export const buildAutoRouterTestTargets = ({
|
|||
|
||||
const embeddingTarget: AutoRouterTestTarget[] =
|
||||
semanticMatchingEnabled && embeddingModel?.trim()
|
||||
? [{ labels: ["Embedding"], modelGroup: embeddingModel.trim(), mode: "embedding" as const }]
|
||||
? [
|
||||
{
|
||||
labels: [t("addModel.buildAutoRouterTestTargets.embedding")],
|
||||
modelGroup: embeddingModel.trim(),
|
||||
mode: "embedding" as const,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const classifierModel = classifier?.model.trim();
|
||||
const classifierTarget: AutoRouterTestTarget[] = classifierModel
|
||||
? [
|
||||
{
|
||||
labels: ["Classifier"],
|
||||
labels: [t("addModel.buildAutoRouterTestTargets.classifier")],
|
||||
modelGroup: classifierModel,
|
||||
mode: "chat",
|
||||
...(classifier?.reasoningEffort && { requestParams: { reasoning_effort: classifier.reasoningEffort } }),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import {
|
||||
isForecastClassifier,
|
||||
withoutForecastPromptOverrides,
|
||||
|
|
@ -238,8 +240,11 @@ export interface BuildComplexityRouterConfigParams {
|
|||
* message is missing or blank through to the write and back as a raw 400. A transport failure fails
|
||||
* open as `{valid: true}`, which this passes, leaving the write gate authoritative.
|
||||
*/
|
||||
export const dryRunRejection = (verdict: { valid: boolean; error?: string | null }): string | null =>
|
||||
verdict.valid ? null : verdict.error?.trim() || "The proxy rejected this auto-router configuration";
|
||||
export const dryRunRejection = (
|
||||
verdict: { valid: boolean; error?: string | null },
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null =>
|
||||
verdict.valid ? null : verdict.error?.trim() || t("addModel.buildComplexityRouterConfig.proxyRejected");
|
||||
|
||||
export interface TierDefinitionPayload {
|
||||
name: string;
|
||||
|
|
@ -332,18 +337,21 @@ export const hydrateTierLabels = (stored: unknown): ComplexityTierLabels | undef
|
|||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
export const getTierLabelsError = (tierLabels: ComplexityTierLabels | undefined): string | null => {
|
||||
export const getTierLabelsError = (
|
||||
tierLabels: ComplexityTierLabels | undefined,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
const shadowing = TIER_KEYS.filter((tier) => {
|
||||
const label = tierLabels?.[tier]?.trim().toUpperCase() ?? "";
|
||||
return label !== "" && label !== tier && (TIER_KEYS as string[]).includes(label);
|
||||
});
|
||||
if (shadowing.length > 0) {
|
||||
return `A tier's display name can't be another tier's name: ${shadowing.join(", ")}`;
|
||||
return t("addModel.buildComplexityRouterConfig.displayNameShadowsTier", { tiers: shadowing.join(", ") });
|
||||
}
|
||||
const labels = TIER_KEYS.map((tier) => effectiveTierLabel(tier, tierLabels).toLowerCase());
|
||||
const duplicates = Array.from(new Set(labels.filter((label, index) => labels.indexOf(label) !== index)));
|
||||
if (duplicates.length > 0) {
|
||||
return `Tier display names must be unique. Repeated: ${duplicates.join(", ")}`;
|
||||
return t("addModel.buildComplexityRouterConfig.displayNamesUnique", { names: duplicates.join(", ") });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -351,17 +359,23 @@ export const getTierLabelsError = (tierLabels: ComplexityTierLabels | undefined)
|
|||
// Requires every active tier non-empty, so the create form can never reach the
|
||||
// resolveComplexityDefaultModel === undefined case. The edit modal allows a partially filled
|
||||
// built-in set, which is why it keeps its own !defaultModel guard after deriving.
|
||||
export const getMissingTiersError = (rows: readonly TierRow[]): string | null => {
|
||||
export const getMissingTiersError = (rows: readonly TierRow[], t: TFunction = i18n.t.bind(i18n)): string | null => {
|
||||
const missing = rows.filter((row) => row.models.length === 0).map(activeTierName);
|
||||
if (missing.length === 0) return null;
|
||||
return `Select a model for the following tier(s): ${missing.join(", ")}`;
|
||||
return t("addModel.buildComplexityRouterConfig.missingTierModels", { tiers: missing.join(", ") });
|
||||
};
|
||||
|
||||
export const getPlanModeTierError = (planModeMinTier: string | undefined, rows: readonly TierRow[]): string | null => {
|
||||
export const getPlanModeTierError = (
|
||||
planModeMinTier: string | undefined,
|
||||
rows: readonly TierRow[],
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (!planModeMinTier) return null;
|
||||
const floor = tierRowById(rows, planModeMinTier);
|
||||
if (floor && floor.models.length > 0) return null;
|
||||
return `The plan-mode minimum tier (${floor ? activeTierName(floor) : planModeMinTier}) has no models. Add one or turn the override off.`;
|
||||
return t("addModel.buildComplexityRouterConfig.planModeTierEmpty", {
|
||||
tier: floor ? activeTierName(floor) : planModeMinTier,
|
||||
});
|
||||
};
|
||||
|
||||
// The orphan check compares exactly, not casefold: _validate_keyword_rule_tiers is exact
|
||||
|
|
@ -370,28 +384,37 @@ export const getPlanModeTierError = (planModeMinTier: string | undefined, rows:
|
|||
export const getKeywordTierRulesError = (
|
||||
keywordTierRules: KeywordTierRule[],
|
||||
rows: readonly TierRow[],
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
const emptyRows = emptyKeywordTierRuleIndexes(keywordTierRules);
|
||||
if (emptyRows.length > 0)
|
||||
return `Add at least one keyword to keyword rule(s): ${emptyRows.map((index) => index + 1).join(", ")}`;
|
||||
return t("addModel.buildComplexityRouterConfig.keywordRulesEmpty", {
|
||||
rules: emptyRows.map((index) => index + 1).join(", "),
|
||||
});
|
||||
const names = rows.map(activeTierName);
|
||||
const orphaned = keywordTierRules.flatMap((rule, index) => (names.includes(rule.tier) ? [] : [index + 1]));
|
||||
if (orphaned.length === 0) return null;
|
||||
return `Keyword rule(s) ${orphaned.join(", ")} route to a tier this router no longer has`;
|
||||
return t("addModel.buildComplexityRouterConfig.keywordRulesOrphaned", { rules: orphaned.join(", ") });
|
||||
};
|
||||
|
||||
export const getHeuristicV2SuccessThresholdError = (threshold: number | undefined): string | null => {
|
||||
export const getHeuristicV2SuccessThresholdError = (
|
||||
threshold: number | undefined,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (threshold === undefined) return null;
|
||||
const validProbability = Number.isFinite(threshold) && threshold >= 0 && threshold <= 1;
|
||||
return validProbability ? null : "Success threshold must be a number between 0 and 1";
|
||||
return validProbability ? null : t("addModel.buildComplexityRouterConfig.successThresholdRange");
|
||||
};
|
||||
|
||||
export const getReminderMarkersError = (pairs: ReminderMarkerPair[] | undefined): string | null => {
|
||||
export const getReminderMarkersError = (
|
||||
pairs: ReminderMarkerPair[] | undefined,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
for (const [index, pair] of (pairs ?? []).entries()) {
|
||||
const open = pair.open.trim().toLowerCase();
|
||||
const close = pair.close.trim().toLowerCase();
|
||||
if (!open || !close) return `Tag pair ${index + 1} needs both an opening and a closing tag`;
|
||||
if (open === close) return `Tag pair ${index + 1} must use different opening and closing tags`;
|
||||
if (!open || !close) return t("addModel.buildComplexityRouterConfig.tagPairIncomplete", { number: index + 1 });
|
||||
if (open === close) return t("addModel.buildComplexityRouterConfig.tagPairIdentical", { number: index + 1 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -399,11 +422,12 @@ export const getReminderMarkersError = (pairs: ReminderMarkerPair[] | undefined)
|
|||
export const getClassifierPluginTimeoutError = (
|
||||
classifierType: ClassifierType,
|
||||
timeoutMs: number | undefined,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (classifierType !== "custom" || timeoutMs === undefined) return null;
|
||||
return Number.isInteger(timeoutMs) && timeoutMs > 0
|
||||
? null
|
||||
: "Classifier plugin timeout must be a whole number of milliseconds greater than 0";
|
||||
: t("addModel.buildComplexityRouterConfig.pluginTimeoutInvalid");
|
||||
};
|
||||
|
||||
export const getClassifierModelError = (
|
||||
|
|
@ -411,22 +435,22 @@ export const getClassifierModelError = (
|
|||
ComplexityRouterConfigValue,
|
||||
"custom_tier_set" | "classifier_type" | "classifier_llm_config" | "jev_classifier_config"
|
||||
>,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (effectiveClassifierType(config) === "jev") {
|
||||
const parsed = jevClassifierConfigSchema.safeParse(config.jev_classifier_config ?? {});
|
||||
return parsed.success
|
||||
? null
|
||||
: "Enter a valid classifier model, a positive whole-number timeout and a positive cooldown";
|
||||
return parsed.success ? null : t("addModel.buildComplexityRouterConfig.jevConfigInvalid");
|
||||
}
|
||||
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
|
||||
return config.custom_tier_set
|
||||
? "Please select a classifier model: an edited tier set routes with the LLM classifier"
|
||||
: "Please select a classifier model, or switch back to Heuristic";
|
||||
? t("addModel.buildComplexityRouterConfig.selectClassifierForCustomTiers")
|
||||
: t("addModel.buildComplexityRouterConfig.selectClassifierOrHeuristic");
|
||||
};
|
||||
|
||||
export const getClassifierReasoningEffortError = (
|
||||
config: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type" | "classifier_llm_config">,
|
||||
modelInfo: readonly ModelGroup[],
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (!usesLlmClassifier(effectiveClassifierType(config))) return null;
|
||||
const classifierConfig = config.classifier_llm_config;
|
||||
|
|
@ -435,19 +459,23 @@ export const getClassifierReasoningEffortError = (
|
|||
(model) => model.model_group === classifierConfig.model,
|
||||
)?.supported_reasoning_efforts;
|
||||
if (!Array.isArray(supported) || supported.includes(classifierConfig.reasoning_effort)) return null;
|
||||
return `${classifierConfig.reasoning_effort} reasoning effort is not supported by every deployment in ${classifierConfig.model}. Choose Default or a supported value.`;
|
||||
return t("addModel.buildComplexityRouterConfig.reasoningEffortUnsupported", {
|
||||
effort: classifierConfig.reasoning_effort,
|
||||
model: classifierConfig.model,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSemanticConfigError = ({
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
keywordTierRules,
|
||||
}: Pick<BuildComplexityRouterConfigParams, "semanticMatchingEnabled" | "embeddingModel" | "keywordTierRules">):
|
||||
| string
|
||||
| null => {
|
||||
export const getSemanticConfigError = (
|
||||
{
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
keywordTierRules,
|
||||
}: Pick<BuildComplexityRouterConfigParams, "semanticMatchingEnabled" | "embeddingModel" | "keywordTierRules">,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (!semanticMatchingEnabled) return null;
|
||||
if (!embeddingModel) return "Select an embedding model to use semantic keyword matching";
|
||||
if (keywordTierRules.length === 0) return "Add at least one keyword tier rule to use semantic keyword matching";
|
||||
if (!embeddingModel) return t("addModel.buildComplexityRouterConfig.selectEmbeddingModel");
|
||||
if (keywordTierRules.length === 0) return t("addModel.buildComplexityRouterConfig.addKeywordRuleForSemantic");
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { CircleHelp, Minus, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -13,12 +15,11 @@ export const CACHE_CONTROL_LABEL = "Cache Control Injection Points";
|
|||
export const CACHE_CONTROL_TOOLTIP =
|
||||
"Tell litellm where to inject cache control checkpoints. You can specify either by role (to apply to all messages of that role) or by specific message index.";
|
||||
|
||||
export const CACHE_CONTROL_DESCRIPTION =
|
||||
"Providers like Anthropic, Bedrock API require users to specify where to inject cache control checkpoints, litellm can automatically add them for you as a cost saving feature.";
|
||||
export const CACHE_CONTROL_DESCRIPTION: ParseKeys = "addModel.cacheControlSettings.description";
|
||||
|
||||
export const CACHE_CONTROL_ROLE_HINT = "LiteLLM will mark all messages of this role as cacheable";
|
||||
export const CACHE_CONTROL_ROLE_HINT: ParseKeys = "addModel.cacheControlSettings.roleHint";
|
||||
|
||||
export const CACHE_CONTROL_INDEX_HINT = "(Optional) If set litellm will mark the message at this index as cacheable";
|
||||
export const CACHE_CONTROL_INDEX_HINT: ParseKeys = "addModel.cacheControlSettings.indexHint";
|
||||
|
||||
export type CacheControlRole = "user" | "system" | "assistant";
|
||||
|
||||
|
|
@ -38,27 +39,30 @@ const ROLE_ITEMS = [
|
|||
{ value: "assistant", label: "Assistant" },
|
||||
] as const;
|
||||
|
||||
const LabelWithHint: React.FC<{ label: string; hint: string }> = ({ label, hint }) => (
|
||||
<div className="flex items-center">
|
||||
<Label>{label}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label} help`}
|
||||
className="ml-1 inline-flex cursor-help items-center rounded-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp aria-hidden className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs whitespace-normal">{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
const LabelWithHint: React.FC<{ label: string; hint: string }> = ({ label, hint }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Label>{label}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("addModel.cacheControlSettings.labelHelp", { label })}
|
||||
className="ml-1 inline-flex cursor-help items-center rounded-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp aria-hidden className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs whitespace-normal">{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CacheControlInjectionPointsProps {
|
||||
value?: CacheControlInjectionPoint[];
|
||||
|
|
@ -71,6 +75,7 @@ interface CacheControlInjectionPointsProps {
|
|||
* independently; both hand a child exactly `value` and `onChange`.
|
||||
*/
|
||||
const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const points = value ?? [];
|
||||
|
||||
const replaceAt = (index: number, point: CacheControlInjectionPoint) =>
|
||||
|
|
@ -78,12 +83,12 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
|
|||
|
||||
return (
|
||||
<div className="ml-6 border-l-2 border-border pl-4">
|
||||
<p className="mb-4 block text-sm text-muted-foreground">{CACHE_CONTROL_DESCRIPTION}</p>
|
||||
<p className="mb-4 block text-sm text-muted-foreground">{t(CACHE_CONTROL_DESCRIPTION)}</p>
|
||||
|
||||
{points.map((point, index) => (
|
||||
<div key={index} className="mb-4 flex items-end gap-4">
|
||||
<div className="w-[180px] space-y-1">
|
||||
<Label>Type</Label>
|
||||
<Label>{t("addModel.cacheControlSettings.type")}</Label>
|
||||
<Select items={LOCATION_ITEMS} value={point.location} disabled>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
|
|
@ -99,7 +104,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
|
|||
</div>
|
||||
|
||||
<div className="w-[180px] space-y-1">
|
||||
<LabelWithHint label="Role" hint={CACHE_CONTROL_ROLE_HINT} />
|
||||
<LabelWithHint label={t("addModel.cacheControlSettings.role")} hint={t(CACHE_CONTROL_ROLE_HINT)} />
|
||||
<Select
|
||||
items={ROLE_ITEMS}
|
||||
value={point.role ?? null}
|
||||
|
|
@ -108,10 +113,10 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
|
|||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
<SelectValue placeholder={t("addModel.cacheControlSettings.selectRole")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>None</SelectItem>
|
||||
<SelectItem value={null}>{t("addModel.cacheControlSettings.noRole")}</SelectItem>
|
||||
{ROLE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
@ -122,10 +127,10 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
|
|||
</div>
|
||||
|
||||
<div className="w-[180px] space-y-1">
|
||||
<LabelWithHint label="Index" hint={CACHE_CONTROL_INDEX_HINT} />
|
||||
<LabelWithHint label={t("addModel.cacheControlSettings.index")} hint={t(CACHE_CONTROL_INDEX_HINT)} />
|
||||
<NumericalInput
|
||||
type="number"
|
||||
placeholder="Optional"
|
||||
placeholder={t("addModel.cacheControlSettings.optional")}
|
||||
step={1}
|
||||
value={point.index ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
|
|
@ -142,7 +147,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove injection point ${index + 1}`}
|
||||
aria-label={t("addModel.cacheControlSettings.removePoint", { position: index + 1 })}
|
||||
className="text-destructive"
|
||||
onClick={() => onChange?.(points.filter((_, position) => position !== index))}
|
||||
>
|
||||
|
|
@ -159,7 +164,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
|
|||
onClick={() => onChange?.([...points, NEW_CACHE_CONTROL_POINT])}
|
||||
>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Add Injection Point
|
||||
{t("addModel.cacheControlSettings.addPoint")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { ALL_BUILT_IN_TIERS, TIER_ORDER } from "./tier_rows";
|
||||
|
|
@ -153,12 +155,12 @@ export const pruneTierModelParams = (
|
|||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
|
||||
NON_REASONING: "Non-reasoning",
|
||||
SIMPLE: "Simple",
|
||||
MEDIUM: "Medium",
|
||||
COMPLEX: "Complex",
|
||||
REASONING: "Reasoning",
|
||||
export const DEFAULT_TIER_LABELS: Record<ComplexityTier, ParseKeys> = {
|
||||
NON_REASONING: "addModel.complexityRouterTiers.labels.nonReasoning",
|
||||
SIMPLE: "addModel.complexityRouterTiers.labels.simple",
|
||||
MEDIUM: "addModel.complexityRouterTiers.labels.medium",
|
||||
COMPLEX: "addModel.complexityRouterTiers.labels.complex",
|
||||
REASONING: "addModel.complexityRouterTiers.labels.reasoning",
|
||||
};
|
||||
|
||||
const isBuiltInTier = (tier: string): tier is ComplexityTier => (ALL_BUILT_IN_TIERS as string[]).includes(tier);
|
||||
|
|
@ -166,25 +168,28 @@ const isBuiltInTier = (tier: string): tier is ComplexityTier => (ALL_BUILT_IN_TI
|
|||
const builtInTierLabel = (
|
||||
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
|
||||
tier: ComplexityTier,
|
||||
): string => tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier];
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string => tierLabels?.[tier]?.trim() || t(DEFAULT_TIER_LABELS[tier]);
|
||||
|
||||
// What a tier row is called on screen. A row the operator named shows that name; an untouched
|
||||
// built-in row shows its display label. The one owner for every surface that renders a tier.
|
||||
export const tierRowLabel = (
|
||||
row: { id: string; name: string },
|
||||
tierLabels?: Partial<Record<ComplexityTier, string>>,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string => {
|
||||
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === row.id);
|
||||
const named = row.name.trim();
|
||||
if (!builtIn || named !== builtIn) return named || "New";
|
||||
return builtInTierLabel(tierLabels, builtIn);
|
||||
if (!builtIn || named !== builtIn) return named || t("addModel.complexityRouterTiers.newTier");
|
||||
return builtInTierLabel(tierLabels, builtIn, t);
|
||||
};
|
||||
|
||||
export const tierOptions = (
|
||||
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
|
||||
tierNames?: readonly string[],
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): { value: string; label: string }[] =>
|
||||
(tierNames ?? TIER_ORDER).map((tier) => ({
|
||||
value: tier,
|
||||
label: isBuiltInTier(tier) ? builtInTierLabel(tierLabels, tier) : tier,
|
||||
label: isBuiltInTier(tier) ? builtInTierLabel(tierLabels, tier, t) : tier,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useMemo } from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
|
@ -40,18 +41,36 @@ const ANTHROPIC_1M_HEADERS = JSON.stringify({ extra_headers: { "anthropic-beta":
|
|||
|
||||
const publicNameTooltipContent = (
|
||||
<div className="flex flex-col gap-2 text-left font-normal">
|
||||
<div>The name you specify in your API calls to LiteLLM Proxy</div>
|
||||
<div>
|
||||
<strong>Example:</strong> If you name your public model <code className={tooltipCodeClassName}>example-name</code>
|
||||
, and choose <code className={tooltipCodeClassName}>openai/qwen-plus-latest</code> as the LiteLLM model
|
||||
<Trans i18nKey="addModel.conditionalPublicModelName.publicNameDescription" />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Usage:</strong> You make an API call to the LiteLLM proxy with{" "}
|
||||
<code className={tooltipCodeClassName}>model = "example-name"</code>
|
||||
<Trans
|
||||
i18nKey="addModel.conditionalPublicModelName.publicNameExample"
|
||||
components={{
|
||||
strong: <strong />,
|
||||
publicName: <code className={tooltipCodeClassName}>example-name</code>,
|
||||
litellmModel: <code className={tooltipCodeClassName}>openai/qwen-plus-latest</code>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Result:</strong> LiteLLM sends <code className={tooltipCodeClassName}>qwen-plus-latest</code> to the
|
||||
provider
|
||||
<Trans
|
||||
i18nKey="addModel.conditionalPublicModelName.publicNameUsage"
|
||||
components={{
|
||||
strong: <strong />,
|
||||
request: <code className={tooltipCodeClassName}>model = "example-name"</code>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="addModel.conditionalPublicModelName.publicNameResult"
|
||||
components={{
|
||||
strong: <strong />,
|
||||
providerModel: <code className={tooltipCodeClassName}>qwen-plus-latest</code>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -94,7 +113,7 @@ const columns: ColumnDef<ModelMapping>[] = [
|
|||
accessorKey: "public_name",
|
||||
header: () => (
|
||||
<span className="flex items-center">
|
||||
Public Model Name
|
||||
<Trans i18nKey="addModel.conditionalPublicModelName.publicModelName" />
|
||||
<SimpleTooltip content={publicNameTooltipContent} width="500px" />
|
||||
</span>
|
||||
),
|
||||
|
|
@ -105,14 +124,22 @@ const columns: ColumnDef<ModelMapping>[] = [
|
|||
accessorKey: "litellm_model",
|
||||
header: () => (
|
||||
<span className="flex items-center">
|
||||
LiteLLM Model Name
|
||||
<SimpleTooltip content={<div>The model name LiteLLM will send to the LLM API</div>} width="360px" />
|
||||
<Trans i18nKey="addModel.conditionalPublicModelName.litellmModelName" />
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<div>
|
||||
<Trans i18nKey="addModel.conditionalPublicModelName.litellmModelNameTooltip" />
|
||||
</div>
|
||||
}
|
||||
width="360px"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const ConditionalPublicModelName: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext<MountedFormValues>();
|
||||
|
||||
const modelValue = useWatch({ control: form.control, name: "model" }) || [];
|
||||
|
|
@ -205,8 +232,8 @@ const ConditionalPublicModelName: React.FC = () => {
|
|||
name="model_mappings"
|
||||
label={
|
||||
<span className="flex items-center">
|
||||
Model Mappings
|
||||
<SimpleTooltip content="Map public model names to LiteLLM model names for load balancing" />
|
||||
{t("addModel.conditionalPublicModelName.modelMappings")}
|
||||
<SimpleTooltip content={t("addModel.conditionalPublicModelName.modelMappingsTooltip")} />
|
||||
</span>
|
||||
}
|
||||
required
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { z } from "zod";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { DIMENSION_LABELS } from "./heuristic_scoring_knobs";
|
||||
|
||||
const customDimensionShape = {
|
||||
|
|
@ -25,30 +27,28 @@ export const serializeCustomDimensions = (rows: CustomDimensionRow[]): CustomDim
|
|||
export const customDimensionsError = (
|
||||
rows: CustomDimensionRow[] | undefined,
|
||||
builtinNames: string[] = Object.keys(DIMENSION_LABELS),
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (!rows) return null;
|
||||
if (rows.length > 16) return "A router can have at most 16 custom dimensions";
|
||||
if (rows.length > 16) return t("addModel.customDimensions.tooMany");
|
||||
const names = rows.map((row) => row.name.toLowerCase());
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const prefix = `Custom dimension ${index + 1}: `;
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(row.name))
|
||||
return (
|
||||
prefix + "use a name starting with a letter, followed by letters, numbers or underscores (64 characters max)"
|
||||
);
|
||||
const prefix = t("addModel.customDimensions.prefix", { number: index + 1 });
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(row.name)) return prefix + t("addModel.customDimensions.invalidName");
|
||||
if (builtinNames.some((name) => name.toLowerCase() === row.name.toLowerCase()))
|
||||
return prefix + "choose a name that is not already a built-in weight";
|
||||
if (names.indexOf(row.name.toLowerCase()) !== index) return prefix + "names must be unique";
|
||||
return prefix + t("addModel.customDimensions.builtInName");
|
||||
if (names.indexOf(row.name.toLowerCase()) !== index) return prefix + t("addModel.customDimensions.uniqueNames");
|
||||
if (!Number.isFinite(row.weight) || row.weight <= 0 || row.weight > 1)
|
||||
return prefix + "weight must be greater than 0 and at most 1";
|
||||
return prefix + t("addModel.customDimensions.weightRange");
|
||||
const matchers = [...(row.keywords ?? []), ...(row.patterns ?? [])];
|
||||
if (!matchers.length || matchers.some((matcher) => !matcher.trim()))
|
||||
return prefix + "add at least one nonblank keyword or pattern";
|
||||
return prefix + t("addModel.customDimensions.matcherRequired");
|
||||
if (
|
||||
matchers.length > 32 ||
|
||||
matchers.some((matcher) => [...matcher].length > 256) ||
|
||||
matchers.reduce((total, matcher) => total + [...matcher].length, 0) > 4096
|
||||
)
|
||||
return prefix + "use at most 32 matchers, 256 characters each and 4096 characters combined";
|
||||
return prefix + t("addModel.customDimensions.matcherLimits");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { ClassifierType, ComplexityRouterConfigValue, ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import { pruneTierModelParams } from "./complexity_router_tiers";
|
||||
import { tierOrderFor } from "./tier_rows";
|
||||
|
|
@ -171,41 +173,47 @@ export const prepareForecastClassifier = (
|
|||
};
|
||||
};
|
||||
|
||||
export const getForecastConfigError = (value: ComplexityRouterConfigValue): string | null => {
|
||||
export const getForecastConfigError = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
if (!isForecastClassifier(value.classifier_type) || value.custom_tier_set) return null;
|
||||
const timeout = value.classifier_llm_config?.timeout_ms;
|
||||
if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0))
|
||||
return "Enter a positive whole-number classifier timeout";
|
||||
return t("addModel.forecastClassifierConfig.errors.timeout");
|
||||
const [efficient, capable] = forecastTierNames(value);
|
||||
const order: readonly string[] = tierOrderFor(value.enable_non_reasoning_tier);
|
||||
if (!order.includes(efficient) || !order.includes(capable) || order.indexOf(capable) <= order.indexOf(efficient))
|
||||
return "The capable tier must be higher than the efficient tier";
|
||||
return t("addModel.forecastClassifierConfig.errors.tierOrder");
|
||||
const efficientModels = forecastModels(value.tiers, efficient);
|
||||
const capableModels = forecastModels(value.tiers, capable);
|
||||
if (!efficientModels.length || !capableModels.length)
|
||||
return "Select models for both the efficient and capable solvers";
|
||||
return t("addModel.forecastClassifierConfig.errors.solverModels");
|
||||
if (value.classifier_type === "capability") {
|
||||
const result = capabilitySettingsSchema.safeParse(value.capability_classifier_config);
|
||||
if (!result.success)
|
||||
return "Enter a solve threshold between 0 and 1 and valid capability settings, including any calibration coefficients";
|
||||
if (!result.success) return t("addModel.forecastClassifierConfig.errors.capabilitySettings");
|
||||
if (result.data.base_threshold + 2 * (result.data.threshold_step ?? 0) > 1)
|
||||
return "The solve threshold plus twice the boundary step must be at most 1";
|
||||
return t("addModel.forecastClassifierConfig.errors.thresholdStep");
|
||||
return null;
|
||||
}
|
||||
return getFuseConfigError(value, efficient, capable);
|
||||
return getFuseConfigError(value, efficient, capable, t);
|
||||
};
|
||||
|
||||
const getFuseConfigError = (value: ComplexityRouterConfigValue, efficient: string, capable: string): string | null => {
|
||||
const getFuseConfigError = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
efficient: string,
|
||||
capable: string,
|
||||
t: TFunction,
|
||||
): string | null => {
|
||||
const efficientModels = forecastModels(value.tiers, efficient);
|
||||
const capableModels = forecastModels(value.tiers, capable);
|
||||
if (value.adaptive) return "Turn off adaptive routing for Fuse v2";
|
||||
if (value.enable_non_reasoning_tier) return "Fuse v2 does not support the non-reasoning tier";
|
||||
if (value.adaptive) return t("addModel.forecastClassifierConfig.errors.fuseAdaptive");
|
||||
if (value.enable_non_reasoning_tier) return t("addModel.forecastClassifierConfig.errors.fuseNonReasoningTier");
|
||||
if (efficientModels.length !== 1 || capableModels.length !== 1 || efficientModels[0] === capableModels[0])
|
||||
return "Fuse v2 requires one distinct model group for each solver";
|
||||
return t("addModel.forecastClassifierConfig.errors.fuseDistinctModels");
|
||||
if (Object.entries(value.tiers).some(([tier, models]) => models.length > 0 && ![efficient, capable].includes(tier)))
|
||||
return "Fuse v2 supports only its efficient and capable tiers";
|
||||
return t("addModel.forecastClassifierConfig.errors.fuseOnlyItsTiers");
|
||||
const result = fuseSettingsSchema.safeParse(value.llm_v2_config);
|
||||
if (!result.success)
|
||||
return "Complete both solver profiles, the harness, and a quality gap between 0 and 1; any calibration needs valid coefficients and a version";
|
||||
if (!result.success) return t("addModel.forecastClassifierConfig.errors.fuseSettings");
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { modelCreateCall } from "../networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
|
||||
import type { AutoRouterCompressionLitellmParams } from "./buildAutoRouterCompression";
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ export const handleAddAutoRouterSubmit = async (
|
|||
|
||||
await modelCreateCall(accessToken, autoRouterConfig);
|
||||
|
||||
toast.success(`Successfully created Auto Router: ${values.auto_router_name}`);
|
||||
toast.success(i18n.t("addModel.handleAddAutoRouterSubmit.created", { name: values.auto_router_name }));
|
||||
|
||||
resetForm();
|
||||
|
||||
|
|
@ -53,6 +54,6 @@ export const handleAddAutoRouterSubmit = async (
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add auto router:", error);
|
||||
toast.fromError("Failed to add auto router: " + error);
|
||||
toast.fromError(i18n.t("addModel.handleAddAutoRouterSubmit.failed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Model, modelCreateCall } from "../networking";
|
||||
import { resolveLitellmProviderSlug } from "../provider_info_helpers";
|
||||
|
|
@ -134,7 +135,9 @@ export const prepareModelAddRequest = async (formValues: Record<string, any>, ac
|
|||
try {
|
||||
litellmExtraParams = JSON.parse(value);
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to parse LiteLLM Extra Params: " + error);
|
||||
toast.fromError(
|
||||
i18n.t("addModel.handleAddModelSubmit.failedToParseExtraParams", { error: String(error) }),
|
||||
);
|
||||
throw new Error("Failed to parse litellm_extra_params: " + error);
|
||||
}
|
||||
if ("litellm_credential_name" in litellmExtraParams && formValues.litellm_credential_name) {
|
||||
|
|
@ -150,7 +153,9 @@ export const prepareModelAddRequest = async (formValues: Record<string, any>, ac
|
|||
try {
|
||||
modelInfoParams = JSON.parse(value);
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to parse LiteLLM Extra Params: " + error);
|
||||
toast.fromError(
|
||||
i18n.t("addModel.handleAddModelSubmit.failedToParseExtraParams", { error: String(error) }),
|
||||
);
|
||||
throw new Error("Failed to parse litellm_extra_params: " + error);
|
||||
}
|
||||
for (const [key, value] of Object.entries(modelInfoParams)) {
|
||||
|
|
@ -203,7 +208,7 @@ export const prepareModelAddRequest = async (formValues: Record<string, any>, ac
|
|||
|
||||
return deployments;
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to create model: " + error);
|
||||
toast.fromError(i18n.t("addModel.handleAddModelSubmit.failedToCreateModel", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -231,6 +236,6 @@ export const handleAddModelSubmit = async (values: any, accessToken: string, for
|
|||
callback && callback();
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to add model: " + error);
|
||||
toast.fromError(i18n.t("addModel.handleAddModelSubmit.failedToAddModel", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import i18n from "@/i18n/i18n";
|
||||
import { serializeCustomDimensions, type CustomDimensionRow } from "./custom_dimensions";
|
||||
import { heuristicScoringRoleFor, type ClassifierFallback } from "./ComplexityRouterConfig";
|
||||
import { isHeuristicChain, type ClassifierType, type LocalHeuristic } from "./classifier_types";
|
||||
|
|
@ -115,7 +116,7 @@ export const rebalanceDimensionWeights = (
|
|||
edit: WeightEdit,
|
||||
): WeightResult => {
|
||||
if (!defaults || !Object.keys(defaults).length)
|
||||
return { ok: false, error: "Load the shipped defaults before changing weights" };
|
||||
return { ok: false, error: i18n.t("addModel.heuristicScoringKnobs.loadDefaultsFirst") };
|
||||
const builtin = effectiveDimensionWeights(defaults, stored);
|
||||
const rows =
|
||||
edit.type === "add"
|
||||
|
|
@ -128,24 +129,25 @@ export const rebalanceDimensionWeights = (
|
|||
if (!vector.every(weightValid))
|
||||
return {
|
||||
ok: false,
|
||||
error: "Existing weights must be finite and nonnegative; custom weights must be greater than 0 and at most 1",
|
||||
error: i18n.t("addModel.heuristicScoringKnobs.invalidExistingWeights"),
|
||||
};
|
||||
const target = edit.type === "set" ? edit.target : undefined;
|
||||
const pinned = (entry: WeightTarget) =>
|
||||
edit.type === "add"
|
||||
? entry.kind === "custom" && entry.id === edit.row.id
|
||||
: entry.kind === target?.kind && entry.id === target.id;
|
||||
if (edit.type === "set" && !vector.some(pinned)) return { ok: false, error: "The dimension is no longer available" };
|
||||
if (edit.type === "set" && !vector.some(pinned))
|
||||
return { ok: false, error: i18n.t("addModel.heuristicScoringKnobs.dimensionUnavailable") };
|
||||
const requestedWeight = (): number => {
|
||||
if (edit.type === "set") return edit.weight;
|
||||
return edit.type === "add" ? edit.row.weight : 0;
|
||||
};
|
||||
const weight = requestedWeight();
|
||||
if (!weightValid({ kind: target?.kind ?? "builtin", weight }))
|
||||
return { ok: false, error: "Use a weight from 0 to 1; custom dimensions must stay greater than 0" };
|
||||
return { ok: false, error: i18n.t("addModel.heuristicScoringKnobs.weightRange") };
|
||||
const others = vector.filter((entry) => !pinned(entry));
|
||||
const total = others.reduce((sum, entry) => sum + entry.weight, 0);
|
||||
if (!Number.isFinite(total)) return { ok: false, error: "Existing weights are too large to rebalance" };
|
||||
if (!Number.isFinite(total)) return { ok: false, error: i18n.t("addModel.heuristicScoringKnobs.weightsTooLarge") };
|
||||
const remainder = 1 - weight;
|
||||
const builtinCount = others.filter((entry) => entry.kind === "builtin").length;
|
||||
const redistributed = (entry: WeightTarget & { weight: number }): number => {
|
||||
|
|
@ -163,7 +165,7 @@ export const rebalanceDimensionWeights = (
|
|||
);
|
||||
const offBudget = Math.abs(corrected.reduce((sum, entry) => sum + entry.weight, 0) - 1) > 1e-12;
|
||||
if (!corrected.every(weightValid) || offBudget)
|
||||
return { ok: false, error: "Leave a positive share for every custom dimension, or remove it first" };
|
||||
return { ok: false, error: i18n.t("addModel.heuristicScoringKnobs.starvedCustomDimension") };
|
||||
const weights = Object.fromEntries(
|
||||
corrected.filter((entry) => entry.kind === "builtin").map(({ id, weight }) => [id, weight]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { ClassifierType } from "./classifier_types";
|
||||
|
||||
export const OSS_CLASSIFIER_MODELS = {
|
||||
|
|
@ -50,7 +51,7 @@ export const jevClassifierConfigSchema = z
|
|||
.refine(
|
||||
(config) => config.deployment_name !== undefined || isSupportedClassifierModel(config.provider, config.model),
|
||||
{
|
||||
error: "Select a supported classifier model, or enter ai_decide or a bare Databricks serving endpoint name",
|
||||
error: () => i18n.t("addModel.jevClassifierConfig.errors.unsupportedModel"),
|
||||
path: ["model"],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { requiredRule } from "../common_components/formRules";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
|
|
@ -18,6 +19,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
providerModels,
|
||||
getPlaceholder,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext<MountedFormValues>();
|
||||
const modelValue = useWatch({ control: form.control, name: "model" });
|
||||
const selectedModels = Array.isArray(modelValue) ? modelValue : [modelValue];
|
||||
|
|
@ -104,12 +106,17 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
<>
|
||||
<MountedFormField
|
||||
name="model"
|
||||
label={labelWithHint("LiteLLM Model Name(s)", "The model name LiteLLM will send to the LLM API")}
|
||||
label={labelWithHint(t("addModel.litellmModelName.label"), t("addModel.litellmModelName.hint"))}
|
||||
required
|
||||
rules={{
|
||||
validate: {
|
||||
required: requiredRule(
|
||||
`Please enter ${selectedProvider === Providers.Azure ? "a deployment name" : "at least one model"}.`,
|
||||
t("addModel.litellmModelName.required", {
|
||||
target:
|
||||
selectedProvider === Providers.Azure
|
||||
? t("addModel.litellmModelName.requiredDeploymentName")
|
||||
: t("addModel.litellmModelName.requiredAtLeastOneModel"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}}
|
||||
|
|
@ -123,7 +130,11 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
id={control.id}
|
||||
value={(control.value as string | undefined) ?? ""}
|
||||
onBlur={control.onBlur}
|
||||
placeholder={selectedProvider === null ? "Select a provider first" : getPlaceholder(selectedProvider)}
|
||||
placeholder={
|
||||
selectedProvider === null
|
||||
? t("addModel.litellmModelName.selectProviderFirst")
|
||||
: getPlaceholder(selectedProvider)
|
||||
}
|
||||
onChange={(event) => {
|
||||
control.onChange(event);
|
||||
if (selectedProvider === Providers.Azure) {
|
||||
|
|
@ -134,8 +145,8 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
) : providerModels.length > 0 && !(typeof control.value === "string" && control.value !== "") ? (
|
||||
<MultiSelect
|
||||
id={control.id}
|
||||
placeholder="Select models"
|
||||
emptyText="No models found"
|
||||
placeholder={t("addModel.litellmModelName.selectModels")}
|
||||
emptyText={t("addModel.litellmModelName.noModelsFound")}
|
||||
value={Array.isArray(control.value) ? control.value : []}
|
||||
onValueChange={(value: string[]) => {
|
||||
control.onChange(value);
|
||||
|
|
@ -143,11 +154,13 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
}}
|
||||
options={[
|
||||
{
|
||||
label: "Custom Model Name (Enter below)",
|
||||
label: t("addModel.litellmModelName.customModelOption"),
|
||||
value: "custom",
|
||||
},
|
||||
{
|
||||
label: `All ${selectedProvider ?? "provider"} Models (Wildcard)`,
|
||||
label: t("addModel.litellmModelName.wildcardOption", {
|
||||
provider: selectedProvider ?? t("addModel.litellmModelName.providerFallback"),
|
||||
}),
|
||||
value: "all-wildcard",
|
||||
},
|
||||
...providerModels.map((model) => ({
|
||||
|
|
@ -163,7 +176,11 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
value={(control.value as string | undefined) ?? ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder={selectedProvider === null ? "Select a provider first" : getPlaceholder(selectedProvider)}
|
||||
placeholder={
|
||||
selectedProvider === null
|
||||
? t("addModel.litellmModelName.selectProviderFirst")
|
||||
: getPlaceholder(selectedProvider)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -173,7 +190,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
<MountedFormField
|
||||
name="custom_model_name"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please enter a custom model name.") } }}
|
||||
rules={{ validate: { required: requiredRule(t("addModel.litellmModelName.customModelRequired")) } }}
|
||||
className="mt-2"
|
||||
>
|
||||
{(control) => (
|
||||
|
|
@ -182,7 +199,9 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
value={(control.value as string | undefined) ?? ""}
|
||||
onBlur={control.onBlur}
|
||||
placeholder={
|
||||
selectedProvider === Providers.Azure ? "Enter Azure deployment name" : "Enter custom model name"
|
||||
selectedProvider === Providers.Azure
|
||||
? t("addModel.litellmModelName.azureDeploymentPlaceholder")
|
||||
: t("addModel.litellmModelName.customModelPlaceholder")
|
||||
}
|
||||
onChange={(event) => {
|
||||
control.onChange(event);
|
||||
|
|
@ -195,8 +214,8 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
|
|||
<div className="grid grid-cols-24">
|
||||
<p className="col-start-11 col-span-14 text-sm mb-3 mt-1">
|
||||
{selectedProvider === Providers.Azure
|
||||
? "Your deployment name will be saved as the public model name, and LiteLLM will use 'azure/deployment-name' internally"
|
||||
: "The model name LiteLLM will send to the LLM API"}
|
||||
? t("addModel.litellmModelName.azureHelp")
|
||||
: t("addModel.litellmModelName.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { AlertTriangle, CircleCheck, Copy, ExternalLink, Info, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -21,10 +23,11 @@ const ModelConnectionTest: React.FC<ModelConnectionTestProps> = ({
|
|||
formValues,
|
||||
accessToken,
|
||||
testMode: _testMode,
|
||||
modelName = "this model",
|
||||
modelName = i18n.t("addModel.modelConnectionTest.thisModel"),
|
||||
onClose: _onClose,
|
||||
onTestComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = React.useState<Error | string | null>(null);
|
||||
const [rawResponse, setRawResponse] = React.useState<any>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
|
@ -46,8 +49,8 @@ const ModelConnectionTest: React.FC<ModelConnectionTestProps> = ({
|
|||
if (!result?.length) {
|
||||
setError(
|
||||
result
|
||||
? "Required fields are not filled in. Fill them in and test the connection again."
|
||||
: "Failed to prepare model data. Please check your form inputs.",
|
||||
? t("addModel.modelConnectionTest.requiredFieldsMissing")
|
||||
: t("addModel.modelConnectionTest.prepareFailed"),
|
||||
);
|
||||
setIsSuccess(false);
|
||||
setIsLoading(false);
|
||||
|
|
@ -58,11 +61,12 @@ const ModelConnectionTest: React.FC<ModelConnectionTestProps> = ({
|
|||
const response = await testConnectionRequest(accessToken, litellmParamsObj, modelInfoObj, modelInfoObj?.mode);
|
||||
|
||||
if (response.status === "success") {
|
||||
toast.success("Connection test successful!");
|
||||
toast.success(t("addModel.modelConnectionTest.success"));
|
||||
setError(null);
|
||||
setIsSuccess(true);
|
||||
} else {
|
||||
const errorMessage = response.result?.error || response.message || "Unknown error";
|
||||
const errorMessage =
|
||||
response.result?.error || response.message || t("addModel.modelConnectionTest.unknownError");
|
||||
setError(errorMessage);
|
||||
setRawResponse(response.result?.raw_request_typed_dict);
|
||||
setIsSuccess(false);
|
||||
|
|
@ -87,7 +91,7 @@ const ModelConnectionTest: React.FC<ModelConnectionTestProps> = ({
|
|||
}, []);
|
||||
|
||||
const getCleanErrorMessage = (errorMsg: string) => {
|
||||
if (!errorMsg) return "Unknown error";
|
||||
if (!errorMsg) return t("addModel.modelConnectionTest.unknownError");
|
||||
return errorMsg
|
||||
.split("stack trace:")[0]
|
||||
.trim()
|
||||
|
|
@ -99,7 +103,7 @@ const ModelConnectionTest: React.FC<ModelConnectionTestProps> = ({
|
|||
? getCleanErrorMessage(error)
|
||||
: error?.message
|
||||
? getCleanErrorMessage(error.message)
|
||||
: "Unknown error";
|
||||
: t("addModel.modelConnectionTest.unknownError");
|
||||
|
||||
const formatCurlCommand = (
|
||||
apiBase: string,
|
||||
|
|
@ -141,13 +145,13 @@ ${formattedBody}
|
|||
{isLoading ? (
|
||||
<div aria-busy="true" className="flex flex-col items-center justify-center gap-4 px-5 py-8 text-center">
|
||||
<LoaderCircle className="size-8 animate-spin text-primary" />
|
||||
<p className="text-base">Testing connection to {modelName}...</p>
|
||||
<p className="text-base">{t("addModel.modelConnectionTest.testing", { modelName })}</p>
|
||||
</div>
|
||||
) : isSuccess ? (
|
||||
<div className="flex items-center justify-center gap-2.5 px-5 py-8">
|
||||
<CircleCheck className="size-6 text-primary" />
|
||||
<p data-testid="connection-success-msg" className="text-lg font-medium">
|
||||
Connection to {modelName} successful!
|
||||
{t("addModel.modelConnectionTest.succeeded", { modelName })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -155,12 +159,12 @@ ${formattedBody}
|
|||
<div className="mb-5 flex items-center gap-3">
|
||||
<AlertTriangle className="size-6 text-destructive" />
|
||||
<p data-testid="connection-failure-msg" className="text-lg font-medium text-destructive">
|
||||
Connection to {modelName} failed
|
||||
{t("addModel.modelConnectionTest.failed", { modelName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/10 p-4 shadow-xs">
|
||||
<p className="mb-2 font-medium">Error:</p>
|
||||
<p className="mb-2 font-medium">{t("addModel.modelConnectionTest.errorLabel")}</p>
|
||||
<p className="text-sm leading-relaxed text-destructive">{errorMessage}</p>
|
||||
|
||||
{error && (
|
||||
|
|
@ -170,14 +174,16 @@ ${formattedBody}
|
|||
className="mt-3 h-auto px-0"
|
||||
onClick={() => setShowDetails((visible) => !visible)}
|
||||
>
|
||||
{showDetails ? "Hide Details" : "Show Details"}
|
||||
{showDetails
|
||||
? t("addModel.modelConnectionTest.hideDetails")
|
||||
: t("addModel.modelConnectionTest.showDetails")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-sm font-medium">Troubleshooting Details</p>
|
||||
<p className="mb-2 text-sm font-medium">{t("addModel.modelConnectionTest.troubleshootingDetails")}</p>
|
||||
<pre className="max-h-52 overflow-auto rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed">
|
||||
{typeof error === "string" ? error : JSON.stringify(error, null, 2)}
|
||||
</pre>
|
||||
|
|
@ -185,9 +191,9 @@ ${formattedBody}
|
|||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">API Request</p>
|
||||
<p className="mb-2 text-sm font-medium">{t("addModel.modelConnectionTest.apiRequest")}</p>
|
||||
<pre className="max-h-64 overflow-auto rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed">
|
||||
{curlCommand || "No request data available"}
|
||||
{curlCommand || t("addModel.modelConnectionTest.noRequestData")}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -195,11 +201,11 @@ ${formattedBody}
|
|||
className="mt-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(curlCommand || "");
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("addModel.modelConnectionTest.copied"));
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Copy to Clipboard
|
||||
{t("addModel.modelConnectionTest.copyToClipboard")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -213,7 +219,7 @@ ${formattedBody}
|
|||
render={<a href="https://docs.litellm.ai/docs/providers" target="_blank" rel="noopener noreferrer" />}
|
||||
>
|
||||
<Info data-icon="inline-start" />
|
||||
View Documentation
|
||||
{t("addModel.modelConnectionTest.viewDocumentation")}
|
||||
<ExternalLink data-icon="inline-end" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Upload as UploadIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { requiredRule } from "../common_components/formRules";
|
||||
import {
|
||||
MountedFormField,
|
||||
|
|
@ -97,6 +98,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
onAuthTypeChange,
|
||||
fieldValidators,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
|
||||
const form = useFormContext<MountedFormValues>();
|
||||
const authTypes = authTypesFor(selectedProvider);
|
||||
|
|
@ -282,7 +284,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
<>
|
||||
<Button type="button" variant="outline" className="w-fit" onClick={() => credentialsFileRef.current?.click()}>
|
||||
<UploadIcon />
|
||||
Click to Upload
|
||||
{t("addModel.providerSpecificFields.clickToUpload")}
|
||||
</Button>
|
||||
<input
|
||||
ref={credentialsFileRef}
|
||||
|
|
@ -345,7 +347,10 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
{availableAuthTypes.length > 0 && selectedAuthType && (
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<label htmlFor={authTypeSelectId} className="text-sm font-medium">
|
||||
{labelWithHint("Auth Type:", "Select how LiteLLM authenticates to this provider.")}
|
||||
{labelWithHint(
|
||||
t("addModel.providerSpecificFields.authType"),
|
||||
t("addModel.providerSpecificFields.authTypeHint"),
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
items={availableAuthTypes.map(({ id, label }) => ({ value: id, label }))}
|
||||
|
|
@ -368,9 +373,9 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
)}
|
||||
{credentialOnlySelected ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm">This auth type is saved as an LLM credential and attached to this model.</p>
|
||||
<p className="mb-4 text-sm">{t("addModel.providerSpecificFields.credentialOnlyHint")}</p>
|
||||
<Button type="button" onClick={() => onCreateCredential?.(selectedAuthType.id)}>
|
||||
Create credential
|
||||
{t("addModel.providerSpecificFields.createCredential")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -380,10 +385,12 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
</MountedFormField>
|
||||
))
|
||||
)}
|
||||
{isLoading && allFields.length === 0 && <p className="text-sm mb-2">Loading provider fields...</p>}
|
||||
{isLoading && allFields.length === 0 && (
|
||||
<p className="text-sm mb-2">{t("addModel.providerSpecificFields.loadingFields")}</p>
|
||||
)}
|
||||
{loadError && allFields.length === 0 && (
|
||||
<p className="text-sm mb-2 text-destructive">
|
||||
{loadError instanceof Error ? loadError.message : "Failed to load provider credential fields"}
|
||||
{loadError instanceof Error ? loadError.message : t("addModel.providerSpecificFields.failedToLoadFields")}
|
||||
</p>
|
||||
)}
|
||||
{allFields.map((field) => (
|
||||
|
|
@ -394,7 +401,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
required={field.required}
|
||||
rules={{
|
||||
validate: {
|
||||
...(field.required ? { required: requiredRule("Required") } : {}),
|
||||
...(field.required ? { required: requiredRule(t("addModel.providerSpecificFields.required")) } : {}),
|
||||
...(fieldValidators?.[field.key] ? { provider: fieldValidators[field.key] } : {}),
|
||||
},
|
||||
}}
|
||||
|
|
@ -405,22 +412,26 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
|
||||
{/* Special case for Vertex Credentials help text */}
|
||||
{field.key === "vertex_credentials" && (
|
||||
<p className="text-sm mb-3 mt-1">Give a gcp service account(.json file)</p>
|
||||
<p className="text-sm mb-3 mt-1">{t("addModel.providerSpecificFields.vertexCredentialsHelp")}</p>
|
||||
)}
|
||||
|
||||
{/* Special case for Azure Base Model help text */}
|
||||
{field.key === "base_model" && (
|
||||
<div className="grid grid-cols-24">
|
||||
<p className="col-start-11 col-span-10 text-sm mb-2">
|
||||
The actual model your azure deployment uses. Used for accurate cost tracking. Select name from{" "}
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="addModel.providerSpecificFields.baseModelHelp"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { isForecastClassifier } from "./forecast_classifier_config";
|
||||
import type { ClassifierType, ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
|
|
@ -174,15 +176,18 @@ export const CUSTOM_TIER_OMITTED_KEYS: readonly string[] = Object.values(CUSTOM_
|
|||
);
|
||||
|
||||
// Row-shape errors the backend cannot phrase per row. Payload validity is the dry-run's job.
|
||||
export const getCustomTierRowsError = (customTierSet: CustomTierSet): string | null => {
|
||||
export const getCustomTierRowsError = (
|
||||
customTierSet: CustomTierSet,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
const rows = customTierSet.tiers;
|
||||
if (rows.length < MIN_TIER_COUNT || rows.length > MAX_TIER_COUNT)
|
||||
return `A tier set needs ${MIN_TIER_COUNT} to ${MAX_TIER_COUNT} tiers`;
|
||||
if (rows.some((row) => !activeTierName(row))) return "Name every tier";
|
||||
return t("addModel.tierRows.tierCountRange", { min: MIN_TIER_COUNT, max: MAX_TIER_COUNT });
|
||||
if (rows.some((row) => !activeTierName(row))) return t("addModel.tierRows.nameEveryTier");
|
||||
const folded = rows.map((row) => row.name.trim().toLowerCase());
|
||||
if (new Set(folded).size !== folded.length) return "Tier names must be unique, ignoring case";
|
||||
if (new Set(folded).size !== folded.length) return t("addModel.tierRows.uniqueNames");
|
||||
if (rows.some((row) => !row.definition.trim() && !isBuiltInTierName(row.name)))
|
||||
return "Every custom tier needs a definition: it is the rubric the classifier routes on";
|
||||
if (!tierRowById(rows, customTierSet.fallback_tier_id)) return "Pick a Fallback Tier for classifier failures";
|
||||
return t("addModel.tierRows.definitionRequired");
|
||||
if (!tierRowById(rows, customTierSet.fallback_tier_id)) return t("addModel.tierRows.pickFallbackTier");
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, { useState } from "react";
|
|||
import { CircleHelp, Info, Plug } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createPassThroughEndpoint } from "./networking";
|
||||
|
|
@ -25,6 +26,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
const HTTP_METHOD_OPTIONS = HTTP_METHODS.map((method) => ({ label: method, value: method }));
|
||||
|
|
@ -34,11 +36,14 @@ type GuardrailSettings = Record<string, { request_fields?: string[]; response_fi
|
|||
const keyValuePairsSchema = z.array(z.tuple([z.string(), z.string()]));
|
||||
|
||||
const passThroughFormSchema = z.object({
|
||||
path: z.string().min(1, "Path is required").regex(/^\//, "Path is required"),
|
||||
path: z
|
||||
.string()
|
||||
.min(1, { error: () => i18n.t("addPassThrough.validation.pathRequired") })
|
||||
.regex(/^\//, { error: () => i18n.t("addPassThrough.validation.pathRequired") }),
|
||||
target: z
|
||||
.string()
|
||||
.min(1, "Target URL is required")
|
||||
.pipe(z.url({ error: "Please enter a valid URL" })),
|
||||
.min(1, { error: () => i18n.t("addPassThrough.validation.targetRequired") })
|
||||
.pipe(z.url({ error: () => i18n.t("addPassThrough.validation.invalidUrl") })),
|
||||
methods: z.array(z.string()).optional(),
|
||||
include_subpath: z.boolean(),
|
||||
headers: keyValuePairsSchema,
|
||||
|
|
@ -93,6 +98,7 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
setPassThroughItems,
|
||||
passThroughItems,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [guardrails, setGuardrails] = useState<GuardrailSettings>({});
|
||||
|
|
@ -130,12 +136,12 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
|
||||
setPassThroughItems([...passThroughItems, createdEndpoint]);
|
||||
|
||||
toast.success("Pass-through endpoint created successfully");
|
||||
toast.success(t("addPassThrough.toasts.created"));
|
||||
form.reset(emptyFormValues);
|
||||
setGuardrails({});
|
||||
setIsModalVisible(false);
|
||||
} catch (error) {
|
||||
toast.fromError("Error creating pass-through endpoint: " + error);
|
||||
toast.fromError(t("addPassThrough.toasts.createFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -145,39 +151,34 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
<TooltipProvider>
|
||||
<div>
|
||||
<Button className="mx-auto mb-4 mt-4" onClick={() => setIsModalVisible(true)}>
|
||||
+ Add Pass-Through Endpoint
|
||||
{t("addPassThrough.openButton")}
|
||||
</Button>
|
||||
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<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 border-b border-border pb-4">
|
||||
<Plug className="size-5 text-info" />
|
||||
<DialogTitle className="text-xl font-semibold text-foreground">Add Pass-Through Endpoint</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold text-foreground">{t("addPassThrough.title")}</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="mt-6">
|
||||
<Alert variant="info" className="mb-6">
|
||||
<Info />
|
||||
<AlertTitle>What is a Pass-Through Endpoint?</AlertTitle>
|
||||
<AlertDescription>
|
||||
Route requests from your LiteLLM proxy to any external API. Perfect for custom models, image
|
||||
generation APIs, or any service you want to proxy through LiteLLM.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("addPassThrough.intro.title")}</AlertTitle>
|
||||
<AlertDescription>{t("addPassThrough.intro.description")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={form.handleSubmit(addPassThrough)} className="space-y-6">
|
||||
<Card className="block p-5">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Route Configuration</h3>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
Configure how requests to your domain will be forwarded to the target API
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("addPassThrough.route.title")}</h3>
|
||||
<p className="mb-5 text-sm text-muted-foreground">{t("addPassThrough.route.description")}</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
label="Path Prefix"
|
||||
description="Example: /bria, /adobe-photoshop, /elasticsearch"
|
||||
label={t("addPassThrough.route.pathPrefix")}
|
||||
description={t("addPassThrough.route.pathPrefixExample")}
|
||||
>
|
||||
{({ value, onChange, ...field }) => (
|
||||
<Input
|
||||
|
|
@ -195,8 +196,8 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
label="Target URL"
|
||||
description="Example:https://engine.prod.bria-api.com"
|
||||
label={t("addPassThrough.route.targetUrl")}
|
||||
description={t("addPassThrough.route.targetUrlExample")}
|
||||
>
|
||||
{({ value, ...field }) => (
|
||||
<Input {...field} placeholder="https://engine.prod.bria-api.com" value={value ?? ""} />
|
||||
|
|
@ -207,21 +208,23 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
control={form.control}
|
||||
name="methods"
|
||||
label={labelWithHint(
|
||||
"HTTP Methods (Optional)",
|
||||
"Select specific HTTP methods. Leave empty to support all methods (GET, POST, PUT, DELETE, PATCH). Useful when the same path needs different targets for different methods.",
|
||||
t("addPassThrough.route.httpMethods"),
|
||||
t("addPassThrough.route.httpMethodsHint"),
|
||||
)}
|
||||
description={
|
||||
selectedMethods.length === 0
|
||||
? "All HTTP methods supported (default)"
|
||||
: `Only ${selectedMethods.join(", ")} requests will be routed to this endpoint`
|
||||
? t("addPassThrough.route.allMethodsDefault")
|
||||
: t("addPassThrough.route.onlyMethods", { methods: selectedMethods.join(", ") })
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref: _ref, ...field }) => (
|
||||
<Select multiple items={HTTP_METHOD_OPTIONS} value={value ?? []} onValueChange={onChange}>
|
||||
<SelectTrigger {...field} className="w-full">
|
||||
<SelectValue placeholder="Select methods (leave empty for all)">
|
||||
<SelectValue placeholder={t("addPassThrough.route.methodsPlaceholder")}>
|
||||
{(selected: string[]) =>
|
||||
selected.length === 0 ? "Select methods (leave empty for all)" : selected.join(", ")
|
||||
selected.length === 0
|
||||
? t("addPassThrough.route.methodsPlaceholder")
|
||||
: selected.join(", ")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
|
@ -238,9 +241,11 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Include Subpaths</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("addPassThrough.route.includeSubpaths")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
Forward all subpaths to the target API (recommended for REST APIs)
|
||||
{t("addPassThrough.route.includeSubpathsDescription")}
|
||||
</div>
|
||||
</div>
|
||||
<FormField control={form.control} name="include_subpath">
|
||||
|
|
@ -255,22 +260,17 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
<RoutePreview pathValue={pathValue} targetValue={targetValue} includeSubpath={includeSubpath} />
|
||||
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Headers</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Add headers that will be sent with every request to the target API
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("addPassThrough.headers.title")}</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{t("addPassThrough.headers.description")}</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headers"
|
||||
label={labelWithHint(
|
||||
"Headers (Optional)",
|
||||
"Authentication and other headers to forward with requests",
|
||||
)}
|
||||
label={labelWithHint(t("addPassThrough.headers.label"), t("addPassThrough.headers.hint"))}
|
||||
description={
|
||||
<>
|
||||
<span className="mb-1 block font-medium">Add headers only if required by the target API</span>
|
||||
<span className="block">Common examples: auth_token, Authorization, x-api-key</span>
|
||||
<span className="mb-1 block font-medium">{t("addPassThrough.headers.requiredNote")}</span>
|
||||
<span className="block">{t("addPassThrough.headers.examples")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -279,26 +279,19 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
</Card>
|
||||
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Default Query Parameters</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Add query parameters that will be automatically sent with every request to the target API
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{t("addPassThrough.queryParams.title")}
|
||||
</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{t("addPassThrough.queryParams.description")}</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="default_query_params"
|
||||
label={labelWithHint(
|
||||
"Default Query Parameters (Optional)",
|
||||
"Query parameters that will be added to all requests. Clients can override these by providing their own values.",
|
||||
)}
|
||||
label={labelWithHint(t("addPassThrough.queryParams.label"), t("addPassThrough.queryParams.hint"))}
|
||||
description={
|
||||
<>
|
||||
<span className="mb-1 block font-medium">
|
||||
Parameters are sent with all GET, POST, PUT, PATCH requests
|
||||
</span>
|
||||
<span className="block">
|
||||
Client parameters override defaults. Examples: version=v1, format=json, key=default
|
||||
</span>
|
||||
<span className="mb-1 block font-medium">{t("addPassThrough.queryParams.methodsNote")}</span>
|
||||
<span className="block">{t("addPassThrough.queryParams.overrideNote")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -313,19 +306,19 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
<PassThroughGuardrailsSection accessToken={accessToken} value={guardrails} onChange={setGuardrails} />
|
||||
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Performance</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Configure upstream request timeout for this endpoint
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{t("addPassThrough.performance.title")}
|
||||
</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{t("addPassThrough.performance.description")}</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
label={labelWithHint(
|
||||
"Request Timeout (seconds)",
|
||||
"Max time to wait for the upstream API to respond. Leave empty to use general_settings.pass_through_request_timeout (default 600s).",
|
||||
t("addPassThrough.performance.timeoutLabel"),
|
||||
t("addPassThrough.performance.timeoutHint"),
|
||||
)}
|
||||
description="Use a higher value for slow upstream APIs (e.g. 1200 for long-running LLM calls)"
|
||||
description={t("addPassThrough.performance.timeoutDescription")}
|
||||
>
|
||||
{({ value, onChange, ref: _ref, ...field }) => (
|
||||
<NumericalInput
|
||||
|
|
@ -343,17 +336,14 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
</Card>
|
||||
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Billing</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">Optional cost tracking for this endpoint</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("addPassThrough.billing.title")}</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{t("addPassThrough.billing.description")}</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cost_per_request"
|
||||
label={labelWithHint(
|
||||
"Cost Per Request (USD)",
|
||||
"Optional: Track costs for requests to this endpoint",
|
||||
)}
|
||||
description="The cost charged for each request through this endpoint"
|
||||
label={labelWithHint(t("addPassThrough.billing.costLabel"), t("addPassThrough.billing.costHint"))}
|
||||
description={t("addPassThrough.billing.costDescription")}
|
||||
>
|
||||
{({ value, onChange, ref: _ref, ...field }) => (
|
||||
<NumericalInput
|
||||
|
|
@ -372,11 +362,11 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
|
|||
|
||||
<div className="flex items-center justify-end space-x-3 border-t border-border pt-6">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("addPassThrough.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} aria-busy={isLoading}>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
{isLoading ? "Creating..." : "Add Pass-Through Endpoint"}
|
||||
{isLoading ? t("addPassThrough.creating") : t("addPassThrough.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { CircleHelp, Info } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
|
||||
import GuardrailSelector from "../guardrails/GuardrailSelector";
|
||||
|
|
@ -35,6 +36,7 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedGuardrails = Object.keys(value);
|
||||
|
||||
const emit = (next: GuardrailSettings) => {
|
||||
|
|
@ -61,41 +63,38 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Guardrails</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Configure guardrails to enforce policies on requests and responses. Guardrails are opt-in for passthrough
|
||||
endpoints.
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("passThroughGuardrailsSection.title")}</h3>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{t("passThroughGuardrailsSection.description")}</p>
|
||||
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>
|
||||
Field-Level Targeting{" "}
|
||||
{t("passThroughGuardrailsSection.fieldTargeting.title")}{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/pass_through_guardrails#field-level-targeting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
>
|
||||
(Learn More)
|
||||
{t("passThroughGuardrailsSection.fieldTargeting.learnMore")}
|
||||
</a>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
Optionally specify which fields to check. If left empty, the entire request/response is sent to the
|
||||
guardrail.
|
||||
</div>
|
||||
<div>{t("passThroughGuardrailsSection.fieldTargeting.description")}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="font-medium">Common Examples:</div>
|
||||
<div className="font-medium">{t("passThroughGuardrailsSection.fieldTargeting.commonExamples")}</div>
|
||||
<div>
|
||||
• <code className="rounded-sm bg-muted px-1">query</code> - Single field
|
||||
• <code className="rounded-sm bg-muted px-1">query</code> -{" "}
|
||||
{t("passThroughGuardrailsSection.fieldTargeting.singleField")}
|
||||
</div>
|
||||
<div>
|
||||
• <code className="rounded-sm bg-muted px-1">documents[*].text</code> - All text in documents array
|
||||
• <code className="rounded-sm bg-muted px-1">documents[*].text</code> -{" "}
|
||||
{t("passThroughGuardrailsSection.fieldTargeting.allDocumentText")}
|
||||
</div>
|
||||
<div>
|
||||
• <code className="rounded-sm bg-muted px-1">messages[*].content</code> - All message contents
|
||||
• <code className="rounded-sm bg-muted px-1">messages[*].content</code> -{" "}
|
||||
{t("passThroughGuardrailsSection.fieldTargeting.allMessageContents")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -105,8 +104,8 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<Field>
|
||||
<FieldLabel htmlFor="pass-through-guardrails">
|
||||
{labelWithHint(
|
||||
"Select Guardrails",
|
||||
"Choose which guardrails should run on this endpoint. Org/team/key level guardrails will also be included.",
|
||||
t("passThroughGuardrailsSection.selectGuardrails"),
|
||||
t("passThroughGuardrailsSection.selectGuardrailsHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
<GuardrailSelector
|
||||
|
|
@ -120,8 +119,10 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
{selectedGuardrails.length > 0 && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-foreground">Field Targeting (Optional)</div>
|
||||
<div className="text-xs text-muted-foreground">💡 Tip: Leave empty to check entire payload</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("passThroughGuardrailsSection.fieldTargetingOptional")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("passThroughGuardrailsSection.tip")}</div>
|
||||
</div>
|
||||
{selectedGuardrails.map((guardrailName) => (
|
||||
<Card key={guardrailName} className="block bg-muted/50 p-4">
|
||||
|
|
@ -131,11 +132,13 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<div className="flex items-center justify-between">
|
||||
<FieldLabel htmlFor={`${guardrailName}-request-fields`} className="text-xs text-muted-foreground">
|
||||
{labelWithHint(
|
||||
"Request Fields (pre_call)",
|
||||
t("passThroughGuardrailsSection.requestFields.label"),
|
||||
<div>
|
||||
<div className="mb-1 font-medium">Specify which request fields to check</div>
|
||||
<div className="mb-1 font-medium">
|
||||
{t("passThroughGuardrailsSection.requestFields.hint")}
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div>Examples:</div>
|
||||
<div>{t("passThroughGuardrailsSection.examples")}</div>
|
||||
<div>• query</div>
|
||||
<div>• documents[*].text</div>
|
||||
<div>• messages[*].content</div>
|
||||
|
|
@ -166,7 +169,7 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
</div>
|
||||
<TagsInput
|
||||
id={`${guardrailName}-request-fields`}
|
||||
placeholder="Type field name or use + buttons above (e.g., query, documents[*].text)"
|
||||
placeholder={t("passThroughGuardrailsSection.requestFields.placeholder")}
|
||||
value={value[guardrailName]?.request_fields ?? []}
|
||||
onValueChange={(fields) => handleFieldChange(guardrailName, "request_fields", fields)}
|
||||
tokenSeparators={[","]}
|
||||
|
|
@ -180,11 +183,13 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{labelWithHint(
|
||||
"Response Fields (post_call)",
|
||||
t("passThroughGuardrailsSection.responseFields.label"),
|
||||
<div>
|
||||
<div className="mb-1 font-medium">Specify which response fields to check</div>
|
||||
<div className="mb-1 font-medium">
|
||||
{t("passThroughGuardrailsSection.responseFields.hint")}
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div>Examples:</div>
|
||||
<div>{t("passThroughGuardrailsSection.examples")}</div>
|
||||
<div>• results[*].text</div>
|
||||
<div>• choices[*].message.content</div>
|
||||
</div>
|
||||
|
|
@ -205,7 +210,7 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
</div>
|
||||
<TagsInput
|
||||
id={`${guardrailName}-response-fields`}
|
||||
placeholder="Type field name or use + buttons above (e.g., results[*].text)"
|
||||
placeholder={t("passThroughGuardrailsSection.responseFields.placeholder")}
|
||||
value={value[guardrailName]?.response_fields ?? []}
|
||||
onValueChange={(fields) => handleFieldChange(guardrailName, "response_fields", fields)}
|
||||
tokenSeparators={[","]}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -9,13 +10,16 @@ export interface PassThroughSecuritySectionProps {
|
|||
}
|
||||
|
||||
const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({ authEnabled, onAuthChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Security</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key
|
||||
</p>
|
||||
<Switch aria-label="Require LiteLLM authentication" checked={authEnabled} onCheckedChange={onAuthChange} />
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("passThroughSecuritySection.title")}</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("passThroughSecuritySection.description")}</p>
|
||||
<Switch
|
||||
aria-label={t("passThroughSecuritySection.requireAuth")}
|
||||
checked={authEnabled}
|
||||
onCheckedChange={onAuthChange}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { usesClassifierContext } from "../add_model/classifier_types";
|
|||
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
|
||||
import { getForecastConfigError, isForecastClassifier } from "../add_model/forecast_classifier_config";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
complexityRouterSchema,
|
||||
semanticRouterSchema,
|
||||
|
|
@ -285,6 +286,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
userRole,
|
||||
isMemberManaged = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
|
|
@ -335,7 +337,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set) ??
|
||||
getMissingTiersError(activeTierRows(complexityRouterConfig))
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
? t("editAutoRouter.editAutoRouterModal.selectTierModel")
|
||||
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
|
||||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
|
||||
|
|
@ -457,7 +459,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
form.reset(routerFormValues);
|
||||
} catch (error) {
|
||||
console.error("Error parsing auto router config:", error);
|
||||
toast.fromError("Error loading auto router configuration");
|
||||
toast.fromError(t("editAutoRouter.editAutoRouterModal.loadFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -472,7 +474,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const builtInTiersEmpty = Object.values(tiers).every((models) => models.length === 0);
|
||||
const tierSetError = custom_tier_set
|
||||
? getCustomTierRowsError(custom_tier_set) ?? getMissingTiersError(rows)
|
||||
: builtInTiersEmpty && "Please select at least one model for a complexity tier";
|
||||
: builtInTiersEmpty && t("editAutoRouter.editAutoRouterModal.selectTierModel");
|
||||
if (tierSetError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(tierSetError);
|
||||
|
|
@ -527,9 +529,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model);
|
||||
if (!defaultModel) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(
|
||||
"Add a model to the Simple or Medium tier, or pin a default model, so requests have somewhere to route.",
|
||||
);
|
||||
toast.fromError(t("editAutoRouter.editAutoRouterModal.noDefaultDestination"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -583,7 +583,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
modelData.model_info.id,
|
||||
);
|
||||
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
toast.success(t("editAutoRouter.editAutoRouterModal.updated"));
|
||||
const updatedModelData = {
|
||||
...modelData,
|
||||
model_name: values.auto_router_name,
|
||||
|
|
@ -624,7 +624,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
model_info: updatedModelInfo,
|
||||
};
|
||||
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
toast.success(t("editAutoRouter.editAutoRouterModal.updated"));
|
||||
onSuccess(updatedModelData);
|
||||
onCancel();
|
||||
};
|
||||
|
|
@ -633,7 +633,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
try {
|
||||
setLoading(true);
|
||||
await form.handleSubmit(saveValues, () => {
|
||||
toast.fromError("Failed to update auto router configuration");
|
||||
toast.fromError(t("editAutoRouter.editAutoRouterModal.updateFailed"));
|
||||
})();
|
||||
} catch (error) {
|
||||
console.error("Error updating auto router:", error);
|
||||
|
|
@ -645,13 +645,22 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
|
||||
const modelChoices: ModelChoice[] = [
|
||||
...modelInfo.map((model) => ({ value: model.model_group, label: model.model_group })),
|
||||
{ value: "custom", label: "Enter custom model name" },
|
||||
{ value: "custom", label: t("editAutoRouter.editAutoRouterModal.customModelName") },
|
||||
];
|
||||
|
||||
const routerNameField = (
|
||||
<FormField control={form.control} name="auto_router_name" label="Auto Router Name">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
label={t("editAutoRouter.editAutoRouterModal.routerName")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} readOnly={isMemberManaged} placeholder="e.g., auto_router_1, smart_routing" />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
readOnly={isMemberManaged}
|
||||
placeholder={t("editAutoRouter.editAutoRouterModal.routerNamePlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
|
|
@ -662,10 +671,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<TooltipProvider>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Auto Router Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit the auto router configuration including routing logic, default models, and access settings.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("editAutoRouter.editAutoRouterModal.title")}</DialogTitle>
|
||||
<DialogDescription>{t("editAutoRouter.editAutoRouterModal.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
|
|
@ -726,28 +733,36 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="auto_router_default_model" label="Default Model">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_default_model"
|
||||
label={t("editAutoRouter.editAutoRouterModal.defaultModel")}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select a default model"
|
||||
placeholder={t("editAutoRouter.editAutoRouterModal.defaultModelPlaceholder")}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="auto_router_embedding_model" label="Embedding Model">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_embedding_model"
|
||||
label={t("editAutoRouter.editAutoRouterModal.embeddingModel")}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select an embedding model"
|
||||
placeholder={t("editAutoRouter.editAutoRouterModal.embeddingModelPlaceholder")}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
|
|
@ -760,7 +775,10 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint("Model Access Groups", "Control who can access this auto router")}
|
||||
label={labelWithHint(
|
||||
t("editAutoRouter.editAutoRouterModal.modelAccessGroups"),
|
||||
t("editAutoRouter.editAutoRouterModal.modelAccessGroupsHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
|
|
@ -779,19 +797,19 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("editAutoRouter.editAutoRouterModal.cancel")}
|
||||
</Button>
|
||||
{submitBlockedReason === null ? (
|
||||
<Button disabled={loading} onClick={handleSubmit}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
{t("editAutoRouter.editAutoRouterModal.saveChanges")}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button disabled onClick={handleSubmit}>
|
||||
Save Changes
|
||||
{t("editAutoRouter.editAutoRouterModal.saveChanges")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -12,6 +13,7 @@ interface KeyValueInputProps {
|
|||
}
|
||||
|
||||
const KeyValueInput: React.FC<KeyValueInputProps> = ({ value = [], onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleAdd = () => onChange?.([...value, ["", ""]]);
|
||||
|
||||
const handleRemove = (index: number) => onChange?.(value.filter((_, i) => i !== index));
|
||||
|
|
@ -23,14 +25,22 @@ const KeyValueInput: React.FC<KeyValueInputProps> = ({ value = [], onChange }) =
|
|||
<div className="space-y-2">
|
||||
{value.map(([key, val], index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input placeholder="Header Name" value={key} onChange={(e) => handleChange(index, [e.target.value, val])} />
|
||||
<Input placeholder="Header Value" value={val} onChange={(e) => handleChange(index, [key, e.target.value])} />
|
||||
<Input
|
||||
placeholder={t("keyValueInput.headerName")}
|
||||
value={key}
|
||||
onChange={(e) => handleChange(index, [e.target.value, val])}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("keyValueInput.headerValue")}
|
||||
value={val}
|
||||
onChange={(e) => handleChange(index, [key, e.target.value])}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleRemove(index)}
|
||||
aria-label={`Remove header ${index + 1}`}
|
||||
aria-label={t("keyValueInput.removeHeader", { position: index + 1 })}
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
|
|
@ -38,7 +48,7 @@ const KeyValueInput: React.FC<KeyValueInputProps> = ({ value = [], onChange }) =
|
|||
))}
|
||||
<Button type="button" variant="outline" onClick={handleAdd}>
|
||||
<Plus />
|
||||
Add Header
|
||||
{t("keyValueInput.addHeader")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authTypeFieldKeys } from "../add_model/provider_auth_types";
|
||||
import ProviderSpecificFields from "../add_model/provider_specific_fields";
|
||||
import { requiredRule } from "../common_components/formRules";
|
||||
|
|
@ -84,6 +85,7 @@ export default function CredentialModal({
|
|||
initialAuthTypeId,
|
||||
providerLocked = false,
|
||||
}: CredentialModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = mode === "edit";
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(
|
||||
(existingCredential?.credential_info.custom_llm_provider as Providers) ?? initialProvider ?? Providers.OpenAI,
|
||||
|
|
@ -163,7 +165,9 @@ export default function CredentialModal({
|
|||
<Dialog open={open} onOpenChange={(open) => !open && closeAndReset()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Credential" : "Add New Credential"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isEdit ? t("modelAdd.credentialModal.editTitle") : t("modelAdd.credentialModal.addTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
|
|
@ -174,10 +178,10 @@ export default function CredentialModal({
|
|||
}}
|
||||
>
|
||||
<MountedFormField
|
||||
label="Credential Name:"
|
||||
label={t("modelAdd.credentialModal.credentialName")}
|
||||
name="credential_name"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Credential name is required") } }}
|
||||
rules={{ validate: { required: requiredRule(t("modelAdd.credentialModal.credentialNameRequired")) } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
|
|
@ -186,35 +190,38 @@ export default function CredentialModal({
|
|||
value={typeof control.value === "string" ? control.value : ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="Unique name that models reference this credential by"
|
||||
placeholder={t("modelAdd.credentialModal.credentialNamePlaceholder")}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField label="Display Name:" name="display_name" className="mb-4">
|
||||
<MountedFormField label={t("modelAdd.credentialModal.displayName")} name="display_name" className="mb-4">
|
||||
{(control) => (
|
||||
<Input
|
||||
id={control.id}
|
||||
value={typeof control.value === "string" ? control.value : ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="e.g. Production OpenAI"
|
||||
placeholder={t("modelAdd.credentialModal.displayNamePlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField
|
||||
label={labelWithHint("Provider:", "Helper to auto-populate provider specific fields")}
|
||||
label={labelWithHint(
|
||||
t("modelAdd.credentialModal.provider"),
|
||||
t("modelAdd.credentialModal.providerHint"),
|
||||
)}
|
||||
name="custom_llm_provider"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Required") } }}
|
||||
rules={{ validate: { required: requiredRule(t("modelAdd.credentialModal.required")) } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder="Select a provider"
|
||||
placeholder={t("modelAdd.credentialModal.selectProvider")}
|
||||
options={providerOptions}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
disabled={providerLocked}
|
||||
|
|
@ -230,8 +237,8 @@ export default function CredentialModal({
|
|||
<div className="mb-4 flex flex-col gap-2">
|
||||
<label htmlFor={AUTH_METHOD_SELECT_ID} className="text-sm font-medium">
|
||||
{labelWithHint(
|
||||
"Authentication:",
|
||||
"Workload identity federation exchanges an identity token for a short-lived access token from the provider, so no API key is stored.",
|
||||
t("modelAdd.credentialModal.authentication"),
|
||||
t("modelAdd.credentialModal.authenticationHint"),
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
|
|
@ -271,17 +278,19 @@ export default function CredentialModal({
|
|||
<InternalIssuerJwks panel={jwksPanelFor(existingCredential, selection)} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<SimpleTooltip content="Get help on our github">
|
||||
<SimpleTooltip content={t("modelAdd.credentialModal.helpTooltip")}>
|
||||
<a href="https://github.com/BerriAI/litellm/issues" className="text-sm text-primary hover:underline">
|
||||
Need Help?
|
||||
{t("modelAdd.credentialModal.needHelp")}
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" className="mr-2.5" onClick={closeAndReset}>
|
||||
Cancel
|
||||
{t("modelAdd.credentialModal.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{isEdit ? t("modelAdd.credentialModal.updateSubmit") : t("modelAdd.credentialModal.addSubmit")}
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -24,6 +25,7 @@ import CredentialsTable from "./CredentialsTable";
|
|||
import { buildCredential, type CredentialSubmission } from "./credential_form_helpers";
|
||||
|
||||
export default function CredentialsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
// Admin Viewer follows the read-parity rule: see credentials, do not modify.
|
||||
|
|
@ -48,7 +50,7 @@ export default function CredentialsPanel() {
|
|||
...newCredential,
|
||||
...(valuesToDelete.length > 0 ? { credential_values_to_delete: valuesToDelete } : {}),
|
||||
});
|
||||
toast.success("Credential updated successfully");
|
||||
toast.success(t("modelAdd.credentialsPanel.updated"));
|
||||
setIsUpdateModalOpen(false);
|
||||
await refetchCredentials();
|
||||
} catch (error) {
|
||||
|
|
@ -63,7 +65,7 @@ export default function CredentialsPanel() {
|
|||
try {
|
||||
const newCredential = buildCredential(submission, submission.credential_values);
|
||||
await credentialCreateCall(accessToken, newCredential);
|
||||
toast.success("Credential added successfully");
|
||||
toast.success(t("modelAdd.credentialsPanel.added"));
|
||||
setIsAddModalOpen(false);
|
||||
await refetchCredentials();
|
||||
} catch (error) {
|
||||
|
|
@ -78,10 +80,10 @@ export default function CredentialsPanel() {
|
|||
setIsCredentialDeleting(true);
|
||||
try {
|
||||
await credentialDeleteCall(accessToken, credentialToDelete.credential_name);
|
||||
toast.success("Credential deleted successfully");
|
||||
toast.success(t("modelAdd.credentialsPanel.deleted"));
|
||||
await refetchCredentials();
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete credential");
|
||||
toast.error(t("modelAdd.credentialsPanel.deleteFailed"));
|
||||
} finally {
|
||||
setCredentialToDelete(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
|
|
@ -107,13 +109,11 @@ export default function CredentialsPanel() {
|
|||
return (
|
||||
<div className="mx-auto flex w-full flex-auto flex-col gap-4 overflow-y-auto p-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{term("Configured credentials for different AI providers. Add and manage your API credentials.")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{term(t("modelAdd.credentialsPanel.description"))}</p>
|
||||
{canModifyCredentials && (
|
||||
<Button onClick={() => setIsAddModalOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Credential
|
||||
{t("modelAdd.credentialsPanel.addCredential")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -148,12 +148,15 @@ export default function CredentialsPanel() {
|
|||
isOpen={isDeleteModalOpen}
|
||||
onCancel={closeDeleteModal}
|
||||
onOk={handleDeleteCredential}
|
||||
title="Delete Credential?"
|
||||
message="Are you sure you want to delete this credential? This action cannot be undone and may break existing integrations."
|
||||
resourceInformationTitle="Credential Information"
|
||||
title={t("modelAdd.credentialsPanel.deleteTitle")}
|
||||
message={t("modelAdd.credentialsPanel.deleteMessage")}
|
||||
resourceInformationTitle={t("modelAdd.credentialsPanel.deleteInformationTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Credential Name", value: credentialToDelete?.credential_name },
|
||||
{ label: "Provider", value: credentialToDelete?.credential_info?.custom_llm_provider || "-" },
|
||||
{ label: t("modelAdd.credentialsPanel.credentialName"), value: credentialToDelete?.credential_name },
|
||||
{
|
||||
label: t("modelAdd.credentialsPanel.provider"),
|
||||
value: credentialToDelete?.credential_info?.custom_llm_provider || "-",
|
||||
},
|
||||
]}
|
||||
confirmLoading={isCredentialDeleting}
|
||||
requiredConfirmation={credentialToDelete?.credential_name}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTerminology } from "@/contexts/TerminologyContext";
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CredentialItem } from "@/components/networking";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
|
@ -21,14 +22,15 @@ interface CredentialsTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "credential_name", desc: false }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
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 credentials configured</div>
|
||||
<div className="text-sm text-muted-foreground">{term("Add a credential to connect an AI provider.")}</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("modelAdd.credentialsTable.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground">{term(t("modelAdd.credentialsTable.emptyDescription"))}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,11 +42,12 @@ const CredentialsTable: React.FC<CredentialsTableProps> = ({
|
|||
onDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getCredentialsTableColumns({ canModifyCredentials, onEdit, onDelete }),
|
||||
[canModifyCredentials, onEdit, onDelete],
|
||||
() => getCredentialsTableColumns({ canModifyCredentials, onEdit, onDelete }, t),
|
||||
[canModifyCredentials, onEdit, onDelete, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -57,7 +60,7 @@ const CredentialsTable: React.FC<CredentialsTableProps> = ({
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading credentials…"
|
||||
loadingMessage={t("modelAdd.credentialsTable.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { CredentialItem } from "@/components/networking";
|
||||
|
|
@ -19,12 +21,14 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import { inferAuthMethod } from "./credential_federation";
|
||||
|
||||
function CredentialProviderCell({ provider, federated }: { provider: string | undefined; federated: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!provider) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
|
@ -42,7 +46,7 @@ function CredentialProviderCell({ provider, federated }: { provider: string | un
|
|||
/>
|
||||
) : null}
|
||||
<span className="truncate text-sm">{displayName || provider}</span>
|
||||
{federated && <Badge variant="secondary">Workload identity federation</Badge>}
|
||||
{federated && <Badge variant="secondary">{t("modelAdd.credentialsTableColumns.federated")}</Badge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,11 +58,12 @@ interface CredentialRowActionsProps {
|
|||
}
|
||||
|
||||
function CredentialRowActions({ credential, onEdit, onDelete }: CredentialRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const configOwned = credential.source === "config";
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open credential actions"
|
||||
aria-label={t("modelAdd.credentialsTableColumns.openActions")}
|
||||
data-testid={`credential-actions-${credential.credential_name}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -69,7 +74,7 @@ function CredentialRowActions({ credential, onEdit, onDelete }: CredentialRowAct
|
|||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel data-testid="credential-config-owned-hint">
|
||||
Defined in config.yaml. Edit the file to change or delete it.
|
||||
{t("modelAdd.credentialsTableColumns.configOwnedHint")}
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -81,14 +86,16 @@ function CredentialRowActions({ credential, onEdit, onDelete }: CredentialRowAct
|
|||
onClick={() => onEdit(credential)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("modelAdd.credentialsTableColumns.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="credential-action-copy"
|
||||
onClick={() => void copyToClipboard(credential.credential_name, "Credential name copied")}
|
||||
onClick={() =>
|
||||
void copyToClipboard(credential.credential_name, t("modelAdd.credentialsTableColumns.nameCopied"))
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
Copy credential name
|
||||
{t("modelAdd.credentialsTableColumns.copyName")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
|
@ -98,7 +105,7 @@ function CredentialRowActions({ credential, onEdit, onDelete }: CredentialRowAct
|
|||
onClick={() => onDelete(credential)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("modelAdd.credentialsTableColumns.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -111,17 +118,18 @@ interface CredentialsTableColumnsDeps {
|
|||
onDelete: (credential: CredentialItem) => void;
|
||||
}
|
||||
|
||||
export const getCredentialsTableColumns = ({
|
||||
canModifyCredentials,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CredentialsTableColumnsDeps): ColumnDef<CredentialItem>[] => {
|
||||
export const getCredentialsTableColumns = (
|
||||
{ canModifyCredentials, onEdit, onDelete }: CredentialsTableColumnsDeps,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): ColumnDef<CredentialItem>[] => {
|
||||
const dataColumns: ColumnDef<CredentialItem>[] = [
|
||||
{
|
||||
id: "credential_name",
|
||||
accessorFn: credentialLabel,
|
||||
meta: { title: "Credential Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Credential Name" />,
|
||||
meta: { title: t("modelAdd.credentialsTableColumns.credentialName") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("modelAdd.credentialsTableColumns.credentialName")} />
|
||||
),
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -136,8 +144,8 @@ export const getCredentialsTableColumns = ({
|
|||
{
|
||||
id: "provider",
|
||||
accessorKey: "credential_info.custom_llm_provider",
|
||||
meta: { title: "Provider" },
|
||||
header: "Provider",
|
||||
meta: { title: t("modelAdd.credentialsTableColumns.provider") },
|
||||
header: t("modelAdd.credentialsTableColumns.provider"),
|
||||
size: 320,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -150,13 +158,13 @@ export const getCredentialsTableColumns = ({
|
|||
{
|
||||
id: "source",
|
||||
accessorFn: (row) => row.source ?? "db",
|
||||
meta: { title: "Source" },
|
||||
header: "Source",
|
||||
meta: { title: t("modelAdd.credentialsTableColumns.source") },
|
||||
header: t("modelAdd.credentialsTableColumns.source"),
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.source === "config" ? (
|
||||
<StatusBadge tone="neutral" label="Config" />
|
||||
<StatusBadge tone="neutral" label={t("modelAdd.credentialsTableColumns.configSource")} />
|
||||
) : (
|
||||
<StatusBadge tone="info" label="DB" />
|
||||
),
|
||||
|
|
@ -172,7 +180,7 @@ export const getCredentialsTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("modelAdd.credentialsTableColumns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
|
|
@ -36,14 +38,16 @@ const anthropicValidators = (
|
|||
...(field.control === "integer" ? { ttl: validateIssuerTtlSeconds } : {}),
|
||||
});
|
||||
|
||||
const selectItems = (field: FederationField, value: unknown) => [
|
||||
...(isMaskedSecret(value) ? [{ value: value as string, label: `Stored: ${value as string}` }] : []),
|
||||
const selectItems = (field: FederationField, value: unknown, t: TFunction) => [
|
||||
...(isMaskedSecret(value)
|
||||
? [{ value: value as string, label: t("modelAdd.federationFields.storedValue", { value: value as string }) }]
|
||||
: []),
|
||||
...(field.options ?? []).map((option) => ({ value: option, label: option })),
|
||||
];
|
||||
|
||||
const renderControl = (field: FederationField, control: MountedFieldControlProps) => {
|
||||
const renderControl = (field: FederationField, control: MountedFieldControlProps, t: TFunction) => {
|
||||
if (field.control === "select") {
|
||||
const items = selectItems(field, control.value);
|
||||
const items = selectItems(field, control.value, t);
|
||||
return (
|
||||
<Select
|
||||
items={items}
|
||||
|
|
@ -51,10 +55,10 @@ const renderControl = (field: FederationField, control: MountedFieldControlProps
|
|||
onValueChange={(value) => control.onChange(value ?? "")}
|
||||
>
|
||||
<SelectTrigger id={control.id} onBlur={control.onBlur} className="w-full">
|
||||
<SelectValue placeholder="Proxy default" />
|
||||
<SelectValue placeholder={t("modelAdd.federationFields.proxyDefault")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>Proxy default</SelectItem>
|
||||
<SelectItem value={null}>{t("modelAdd.federationFields.proxyDefault")}</SelectItem>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
@ -84,6 +88,7 @@ interface FederationFieldInputProps {
|
|||
}
|
||||
|
||||
function FederationFieldInput({ field, storedValue, validators }: FederationFieldInputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MountedFormField
|
||||
label={labelWithHint(field.label, field.tooltip)}
|
||||
|
|
@ -98,7 +103,7 @@ function FederationFieldInput({ field, storedValue, validators }: FederationFiel
|
|||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => renderControl(field, control)}
|
||||
{(control) => renderControl(field, control, t)}
|
||||
</MountedFormField>
|
||||
);
|
||||
}
|
||||
|
|
@ -114,7 +119,8 @@ function AnthropicFederationFields({
|
|||
onIdentitySourceChange,
|
||||
storedValues,
|
||||
}: AnthropicFederationFieldsProps) {
|
||||
const identitySourceItems = identitySourceOptions(storedValues);
|
||||
const { t } = useTranslation();
|
||||
const identitySourceItems = identitySourceOptions(storedValues, t);
|
||||
const renderField = (field: FederationField) => (
|
||||
<FederationFieldInput
|
||||
key={field.key}
|
||||
|
|
@ -130,8 +136,8 @@ function AnthropicFederationFields({
|
|||
<div className="mb-4 flex flex-col gap-2">
|
||||
<label htmlFor={IDENTITY_SOURCE_SELECT_ID} className="text-sm font-medium">
|
||||
{labelWithHint(
|
||||
"Identity Source",
|
||||
"Where the proxy gets the identity token it exchanges with Anthropic for an access token.",
|
||||
t("modelAdd.federationFields.identitySource"),
|
||||
t("modelAdd.federationFields.identitySourceHint"),
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
|
|
@ -151,15 +157,10 @@ function AnthropicFederationFields({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{identitySource === "unrecognized" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This form does not offer the stored identity source. Saving keeps it and its settings as stored. Pick
|
||||
another source to replace it.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("modelAdd.federationFields.unrecognizedSourceHint")}</p>
|
||||
)}
|
||||
{identitySource === "environment" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The proxy reads ANTHROPIC_IDENTITY_TOKEN_FILE or ANTHROPIC_IDENTITY_TOKEN from its own environment.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("modelAdd.federationFields.environmentSourceHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
{identitySourceById(identitySource).fields.map(renderField)}
|
||||
|
|
@ -168,12 +169,10 @@ function AnthropicFederationFields({
|
|||
}
|
||||
|
||||
function OpenAIFederationFields({ storedValues }: { storedValues: Record<string, unknown> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
The proxy exchanges the identity token for an OpenAI access token only when OPENAI_API_KEY is unset in its
|
||||
environment. Otherwise it sends that key instead.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("modelAdd.federationFields.openaiFederationHint")}</p>
|
||||
{OPENAI_FEDERATION_FIELDS.map((field) => (
|
||||
<FederationFieldInput key={field.key} field={field} storedValue={storedValues[field.key]} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { userConnectionPollCall, userConnectionStartCall } from "@/components/networking";
|
||||
|
|
@ -31,6 +32,7 @@ export default function GithubCopilotConnectModal({
|
|||
onConnected,
|
||||
onDisconnect,
|
||||
}: GithubCopilotConnectModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [state, dispatch] = useReducer(connectFlowReducer, initialConnectFlowState);
|
||||
|
||||
|
|
@ -79,56 +81,77 @@ export default function GithubCopilotConnectModal({
|
|||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Copilot</DialogTitle>
|
||||
<DialogDescription>Credential: {credentialName}</DialogDescription>
|
||||
<DialogTitle>{t("modelAdd.githubCopilotConnectModal.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("modelAdd.githubCopilotConnectModal.credential", { name: credentialName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{state.kind === "starting" && <p className="text-sm">Requesting a device code from GitHub...</p>}
|
||||
{state.kind === "starting" && (
|
||||
<p className="text-sm">{t("modelAdd.githubCopilotConnectModal.requestingCode")}</p>
|
||||
)}
|
||||
|
||||
{state.kind === "awaiting" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">
|
||||
Open{" "}
|
||||
<a
|
||||
href={state.verificationUri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{state.verificationUri}
|
||||
</a>{" "}
|
||||
and enter this code:
|
||||
<Trans
|
||||
i18nKey="modelAdd.githubCopilotConnectModal.openAndEnterCode"
|
||||
components={{
|
||||
verificationLink: (
|
||||
<a
|
||||
href={state.verificationUri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{state.verificationUri}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code data-testid="github-device-code" className="rounded-md bg-muted px-3 py-2 font-mono text-lg">
|
||||
{state.userCode}
|
||||
</code>
|
||||
<CopyButton value={state.userCode} label="Copy code" copiedLabel="Code copied" />
|
||||
<CopyButton
|
||||
value={state.userCode}
|
||||
label={t("modelAdd.githubCopilotConnectModal.copyCode")}
|
||||
copiedLabel={t("modelAdd.githubCopilotConnectModal.codeCopied")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Waiting for you to approve the request on GitHub...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("modelAdd.githubCopilotConnectModal.waitingForApproval")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === "connected" && <p className="text-sm">Connected as @{state.githubLogin}</p>}
|
||||
{state.kind === "connected" && (
|
||||
<p className="text-sm">{t("modelAdd.githubCopilotConnectModal.connectedAs", { login: state.githubLogin })}</p>
|
||||
)}
|
||||
|
||||
{state.kind === "failed" && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{failureMessage(state)}
|
||||
{failureMessage(state, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{state.kind === "failed" && (
|
||||
<Button variant="outline" onClick={() => dispatch({ type: "restarted" })}>
|
||||
Start again
|
||||
{t("modelAdd.githubCopilotConnectModal.startAgain")}
|
||||
</Button>
|
||||
)}
|
||||
{state.kind === "connected" && (
|
||||
<Button variant="destructive" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
{t("modelAdd.githubCopilotConnectModal.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>{state.kind === "connected" ? "Done" : "Close"}</Button>
|
||||
<Button onClick={onClose}>
|
||||
{state.kind === "connected"
|
||||
? t("modelAdd.githubCopilotConnectModal.done")
|
||||
: t("modelAdd.githubCopilotConnectModal.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { $api } from "@/lib/http/api";
|
||||
|
|
@ -5,11 +6,12 @@ import { extractProxyErrorMessage } from "@/lib/http/client";
|
|||
import type { JwksPanel } from "./credential_federation";
|
||||
|
||||
const SavedJwks = ({ credentialName }: { credentialName: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const jwks = $api.useQuery("get", "/credentials/{credential_name}/jwks", {
|
||||
params: { path: { credential_name: credentialName } },
|
||||
});
|
||||
if (jwks.isPending || !jwks.isFetchedAfterMount) {
|
||||
return <p className="text-sm text-muted-foreground">Loading JWKS...</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("modelAdd.internalIssuerJwks.loading")}</p>;
|
||||
}
|
||||
if (jwks.isError) {
|
||||
return (
|
||||
|
|
@ -21,37 +23,36 @@ const SavedJwks = ({ credentialName }: { credentialName: string }) => {
|
|||
const jwksText = JSON.stringify(jwks.data, null, 2);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre aria-label="Public JWKS" className="max-h-48 overflow-auto rounded-md border bg-muted p-3 text-xs">
|
||||
<pre
|
||||
aria-label={t("modelAdd.internalIssuerJwks.publicJwks")}
|
||||
className="max-h-48 overflow-auto rounded-md border bg-muted p-3 text-xs"
|
||||
>
|
||||
{jwksText}
|
||||
</pre>
|
||||
<CopyButton value={jwksText} label="Copy JWKS" variant="action" className="self-start" />
|
||||
<CopyButton
|
||||
value={jwksText}
|
||||
label={t("modelAdd.internalIssuerJwks.copyJwks")}
|
||||
variant="action"
|
||||
className="self-start"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function InternalIssuerJwks({ panel }: { panel: JwksPanel }) {
|
||||
const { t } = useTranslation();
|
||||
switch (panel.kind) {
|
||||
case "hidden":
|
||||
return null;
|
||||
case "after_save":
|
||||
return (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Once saved, reopen this credential from LLM Credentials to copy the public JWKS you register in the Claude
|
||||
Console.
|
||||
</p>
|
||||
);
|
||||
return <p className="mb-4 text-sm text-muted-foreground">{t("modelAdd.internalIssuerJwks.afterSaveHint")}</p>;
|
||||
case "saved":
|
||||
return (
|
||||
<FieldSet className="mb-4 gap-2">
|
||||
<FieldLegend variant="label" className="mb-0">
|
||||
Public JWKS
|
||||
{t("modelAdd.internalIssuerJwks.publicJwks")}
|
||||
</FieldLegend>
|
||||
<FieldDescription>
|
||||
Register this JWKS with the Issuer URL and Subject above as a federation rule under Settings > Workload
|
||||
identity in the Claude Console. Then fill in its Federation Rule ID and Organization ID at the top of this
|
||||
form and update the credential. The JWKS comes from the saved signing key, so update after changing the key
|
||||
and before copying.
|
||||
</FieldDescription>
|
||||
<FieldDescription>{t("modelAdd.internalIssuerJwks.registerHint")}</FieldDescription>
|
||||
<SavedJwks credentialName={panel.credentialName} />
|
||||
</FieldSet>
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue