refactor(ui): move remaining models text onto i18n (#50147)

* refactor(ui): move remaining models text onto i18n

Moves the leftover English on the models pages, mostly labels and hints
held in constants, onto react-i18next. Constants now hold message keys and
the code that renders them translates. Where a string is both a value and
a label (tier names, health check sentinels), the English value stays for
comparisons, sorting and payloads, and a separate label key drives display.
Lookups keyed by server values check Object.hasOwn first.

* refactor(ui): translate the tier name in display name inputs
This commit is contained in:
yuneng-jiang 2026-11-05 12:42:26 -08:00 • committed by GitHub
parent 41d45079c7
commit 625353fabe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 471 additions and 221 deletions

View file

@ -1,5 +1,6 @@
"use client";
import type { TFunction } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
@ -7,11 +8,12 @@ import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useMod
import { hydrateTierLabels } from "@/components/add_model/build_complexity_router_config";
import {
TIER_KEYS,
effectiveTierLabel,
effectiveTierDisplayLabel,
type ComplexityTierLabels,
type ComplexityTiers,
} from "@/components/add_model/ComplexityRouterConfig";
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
import i18n from "@/i18n/i18n";
import { chartColorValue, DEFAULT_COLOR_CYCLE, DonutChart } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -35,8 +37,11 @@ const asRecord = (value: unknown): Record<string, unknown> => {
const isComplexityTier = (tier: string): tier is keyof ComplexityTiers =>
(TIER_KEYS as readonly string[]).includes(tier);
export const tierDisplayLabel = (tier: string, tierLabels: ComplexityTierLabels | undefined): string =>
isComplexityTier(tier) ? effectiveTierLabel(tier, tierLabels) : tier;
export const tierDisplayLabel = (
tier: string,
tierLabels: ComplexityTierLabels | undefined,
t: TFunction = i18n.t.bind(i18n),
): string => (isComplexityTier(tier) ? effectiveTierDisplayLabel(tier, tierLabels, t) : tier);
const CONFIG_KEY_BY_ROUTER_TYPE: Record<string, keyof NonNullable<AutoRouterDeployment["litellm_params"]>> = {
complexity: "complexity_router_config",
@ -93,7 +98,7 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
const tierLabels = tierLabelsFor(group.router_name, group.router_type, autoRouters);
const total = entries.reduce((sum, [, turns]) => sum + turns, 0);
const slices = entries.map(([tier, turns]) => ({
tier: tierDisplayLabel(tier, tierLabels),
tier: tierDisplayLabel(tier, tierLabels, t),
turns,
models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
}));

View file

@ -38,10 +38,10 @@ export const WILDCARD_MODEL_GROUP_VALUE = "wildcard";
const MODEL_TABLE_BODY_HEIGHT = 600;
const ALL_PROXY_MODELS_LABEL: ParseKeys = "modelsAndEndpoints.allModelsTable.allProxyModels";
const FILTER_LABELS: Record<string, string> = {
[MODEL_NAME_COLUMN_ID]: "Public Model Name",
[ACCESS_GROUPS_COLUMN_ID]: "Model Access Group",
[ROUTING_STATUS_FILTER_ID]: "Status",
const FILTER_LABELS: Record<string, ParseKeys> = {
[MODEL_NAME_COLUMN_ID]: "modelsAndEndpoints.allModelsTable.filterLabels.publicModelName",
[ACCESS_GROUPS_COLUMN_ID]: "modelsAndEndpoints.allModelsTable.filterLabels.modelAccessGroup",
[ROUTING_STATUS_FILTER_ID]: "modelsAndEndpoints.allModelsTable.filterLabels.status",
};
const ROUTING_STATUS_FILTER_VALUES = {
@ -241,7 +241,9 @@ export function AllModelsTable({
onOpenFilters={() => setFiltersOpen(true)}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
filterLabels={FILTER_LABELS}
filterLabels={Object.fromEntries(
Object.entries(FILTER_LABELS).map(([columnId, labelKey]) => [columnId, t(labelKey)]),
)}
formatFilterValue={formatFilterValue}
>
<Select value={selectedTeamValue} onValueChange={(value) => onTeamChange(String(value))}>

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import {
AutoRouterKind,
@ -7,6 +8,7 @@ import {
} from "@/components/add_model/auto_router_strategies";
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
import { Team } from "@/components/networking";
import i18n from "@/i18n/i18n";
import { type ModelActor, canEditAutoRouter, canModifyModel } from "@/utils/modelPermissions";
export type { AutoRouterKind };
@ -55,19 +57,21 @@ const asStringArray = (value: unknown): string[] =>
const dedupe = (models: string[]): string[] => Array.from(new Set(models));
const COMPLEXITY_TYPE_LABELS: Record<string, string> = {
llm: "LLM Classifier",
jev: "OSS Classifier",
oss_classifier: "OSS Classifier",
capability: "Capability",
llm_v2: "Fuse v2",
heuristic_first: "Heuristic first",
hybrid: "Hybrid",
custom: "Custom classifier",
const COMPLEXITY_TYPE_LABELS: Record<string, ParseKeys> = {
llm: "modelsAndEndpoints.autoRouterRows.types.llm",
jev: "modelsAndEndpoints.autoRouterRows.types.oss",
oss_classifier: "modelsAndEndpoints.autoRouterRows.types.oss",
capability: "modelsAndEndpoints.autoRouterRows.types.capability",
llm_v2: "modelsAndEndpoints.autoRouterRows.types.fuseV2",
heuristic_first: "modelsAndEndpoints.autoRouterRows.types.heuristicFirst",
hybrid: "modelsAndEndpoints.autoRouterRows.types.hybrid",
custom: "modelsAndEndpoints.autoRouterRows.types.custom",
};
export const complexityTypeLabel = (config: Record<string, unknown>): string =>
(typeof config.classifier_type === "string" && COMPLEXITY_TYPE_LABELS[config.classifier_type]) || "Heuristic";
typeof config.classifier_type === "string" && Object.hasOwn(COMPLEXITY_TYPE_LABELS, config.classifier_type)
? i18n.t(COMPLEXITY_TYPE_LABELS[config.classifier_type])
: i18n.t("modelsAndEndpoints.autoRouterRows.types.heuristic");
interface Presentation {
typeLabel: string;
@ -93,10 +97,10 @@ const PRESENTERS: Record<AutoRouterKind, (config: Record<string, unknown>) => Pr
.map((route) => asRecord(route).name)
.filter((name): name is string => typeof name === "string" && name.length > 0),
);
return { typeLabel: "Semantic", targets: routes };
return { typeLabel: i18n.t("modelsAndEndpoints.autoRouterRows.types.semantic"), targets: routes };
},
adaptive: (config) => configManaged("Adaptive", config),
quality: (config) => configManaged("Quality", config),
adaptive: (config) => configManaged(i18n.t("modelsAndEndpoints.autoRouterRows.types.adaptive"), config),
quality: (config) => configManaged(i18n.t("modelsAndEndpoints.autoRouterRows.types.quality"), config),
};
export const toAutoRouterRow = (

View file

@ -746,8 +746,8 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
name="cache_control"
label={
<>
{CACHE_CONTROL_LABEL}
<Hint text={CACHE_CONTROL_TOOLTIP} />
{t(CACHE_CONTROL_LABEL)}
<Hint text={t(CACHE_CONTROL_TOOLTIP)} />
</>
}
orientation="horizontal"

View file

@ -309,7 +309,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
<MountedFormField label={t("addModel.addModelForm.mode")} name="mode" className="mb-1">
{(control) => (
<Select
items={TEST_MODES}
items={TEST_MODES.map((mode) => ({ value: mode.value, label: t(mode.labelKey) }))}
value={(control.value as string | undefined) ?? null}
onValueChange={(value: string | null) => {
control.onChange(value);
@ -326,7 +326,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
<SelectContent>
{TEST_MODES.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
{t(mode.labelKey)}
</SelectItem>
))}
</SelectContent>

View file

@ -83,7 +83,7 @@ export default function AutoRouterSimpleSetup({
aria-pressed={goal === key}
onClick={() => onGoalChange(key as RouterGoal)}
>
{label}
{t(label)}
</Button>
))}
</div>

View file

@ -42,7 +42,7 @@ import {
DEFAULT_CLASSIFIER_TIMEOUT_MS,
DEFAULT_CLASSIFICATION_RUBRIC,
ClassificationRubric,
effectiveTierLabel,
effectiveTierDisplayLabel,
heuristicScoringRole,
heuristicTuningType,
usesHeuristicV2,
@ -119,25 +119,25 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
{scorerRuns && ranges && (
<ul className="mt-2 pl-5 text-[13px] text-muted-foreground">
<li>
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>:{" "}
<strong>{effectiveTierDisplayLabel("SIMPLE", value.tier_labels, t)}</strong>:{" "}
{t("addModel.classificationMethodConfig.howItWorks.scoreBelow", { max: ranges.simpleMedium })}
</li>
<li>
<strong>{effectiveTierLabel("MEDIUM", value.tier_labels)}</strong>:{" "}
<strong>{effectiveTierDisplayLabel("MEDIUM", value.tier_labels, t)}</strong>:{" "}
{t("addModel.classificationMethodConfig.howItWorks.scoreBetween", {
min: ranges.simpleMedium,
max: ranges.mediumComplex,
})}
</li>
<li>
<strong>{effectiveTierLabel("COMPLEX", value.tier_labels)}</strong>:{" "}
<strong>{effectiveTierDisplayLabel("COMPLEX", value.tier_labels, t)}</strong>:{" "}
{t("addModel.classificationMethodConfig.howItWorks.scoreBetween", {
min: ranges.mediumComplex,
max: ranges.complexReasoning,
})}
</li>
<li>
<strong>{effectiveTierLabel("REASONING", value.tier_labels)}</strong>:{" "}
<strong>{effectiveTierDisplayLabel("REASONING", value.tier_labels, t)}</strong>:{" "}
{t("addModel.classificationMethodConfig.howItWorks.scoreAbove", {
min: ranges.complexReasoning,
floor: ranges.reasoningOverrideFloor,
@ -506,7 +506,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<SelectContent>
{HEURISTIC_FIRST_MAX_TIER_KEYS.map((tier) => (
<SelectItem key={tier} value={tier}>
{effectiveTierLabel(tier, value.tier_labels)}
{effectiveTierDisplayLabel(tier, value.tier_labels, t)}
</SelectItem>
))}
</SelectContent>

View file

@ -104,36 +104,27 @@ export const DEFAULT_CLASSIFICATION_RUBRIC: ClassificationRubric = "legacy";
*/
export const NEW_CLASSIFIER_CLASSIFICATION_RUBRIC: ClassificationRubric = "agentic";
export const CLASSIFICATION_RUBRIC_DESCRIPTIONS: Record<ClassificationRubric, { label: string; description: string }> =
{
legacy: {
label: "Legacy (uncalibrated)",
description:
"The rubric as it shipped before calibration examples, with no worked examples at all. Routers created " +
"before this setting existed use it, so their tier decisions and spend are unchanged. It over-routes " +
"ordinary engineering to the most expensive tier.",
},
agentic: {
label: "Agentic",
description:
"Anchors routine installs, builds, multi-file edits, and standard debugging at " +
"Medium, so ordinary engineering does not route to your most expensive tier. Suits agent, terminal, and " +
"coding-assistant traffic, and mixed traffic.",
},
chat: {
label: "Chat",
description:
"Drops the engineering examples, for a router serving only conversational traffic that never sees those " +
"requests.",
},
business: {
label: "Business",
description:
"Business and sales examples plus business-oriented tier definitions: routine drafting and summarizing " +
"stay at Medium, data-determined analysis is Complex, and only decisions under conflicting tradeoffs " +
"reach Reasoning. Suits sales, support, and go-to-market traffic.",
},
};
export const CLASSIFICATION_RUBRIC_DESCRIPTIONS: Record<
ClassificationRubric,
{ labelKey: ParseKeys; descriptionKey: ParseKeys }
> = {
legacy: {
labelKey: "addModel.complexityRouterConfig.rubrics.legacy.label",
descriptionKey: "addModel.complexityRouterConfig.rubrics.legacy.description",
},
agentic: {
labelKey: "addModel.complexityRouterConfig.rubrics.agentic.label",
descriptionKey: "addModel.complexityRouterConfig.rubrics.agentic.description",
},
chat: {
labelKey: "addModel.complexityRouterConfig.rubrics.chat.label",
descriptionKey: "addModel.complexityRouterConfig.rubrics.chat.description",
},
business: {
labelKey: "addModel.complexityRouterConfig.rubrics.business.label",
descriptionKey: "addModel.complexityRouterConfig.rubrics.business.description",
},
};
export const CLASSIFICATION_RUBRIC_KEYS = Object.keys(CLASSIFICATION_RUBRIC_DESCRIPTIONS) as ClassificationRubric[];
@ -204,7 +195,9 @@ const rowOrigin = (row: TierRow, editing: boolean, t: TFunction = i18n.t.bind(i1
: t("addModel.complexityRouterConfig.rowOrigin.custom");
};
const builtInTierInfo = (rowId: string): { label: string; description: ParseKeys; examples: ParseKeys } | undefined => {
const builtInTierInfo = (
rowId: string,
): { labelKey: ParseKeys; description: ParseKeys; examples: ParseKeys } | undefined => {
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId);
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
};
@ -531,30 +524,35 @@ interface ComplexityRouterConfigProps {
export const TIER_DESCRIPTIONS: Record<
keyof ComplexityTiers,
{ label: string; description: ParseKeys; examples: ParseKeys }
{ label: string; labelKey: ParseKeys; description: ParseKeys; examples: ParseKeys }
> = {
NON_REASONING: {
label: "Non-reasoning",
labelKey: "addModel.complexityRouterConfig.tiers.nonReasoning.label",
description: "addModel.complexityRouterConfig.tiers.nonReasoning.description",
examples: "addModel.complexityRouterConfig.tiers.nonReasoning.examples",
},
SIMPLE: {
label: "Simple",
labelKey: "addModel.complexityRouterConfig.tiers.simple.label",
description: "addModel.complexityRouterConfig.tiers.simple.description",
examples: "addModel.complexityRouterConfig.tiers.simple.examples",
},
MEDIUM: {
label: "Medium",
labelKey: "addModel.complexityRouterConfig.tiers.medium.label",
description: "addModel.complexityRouterConfig.tiers.medium.description",
examples: "addModel.complexityRouterConfig.tiers.medium.examples",
},
COMPLEX: {
label: "Complex",
labelKey: "addModel.complexityRouterConfig.tiers.complex.label",
description: "addModel.complexityRouterConfig.tiers.complex.description",
examples: "addModel.complexityRouterConfig.tiers.complex.examples",
},
REASONING: {
label: "Reasoning",
labelKey: "addModel.complexityRouterConfig.tiers.reasoning.label",
description: "addModel.complexityRouterConfig.tiers.reasoning.description",
examples: "addModel.complexityRouterConfig.tiers.reasoning.examples",
},
@ -565,6 +563,12 @@ export const TIER_KEYS = Object.keys(TIER_DESCRIPTIONS) as Array<keyof Complexit
export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: ComplexityTierLabels | undefined): string =>
tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label;
export const effectiveTierDisplayLabel = (
tier: keyof ComplexityTiers,
tierLabels: ComplexityTierLabels | undefined,
t: TFunction = i18n.t.bind(i18n),
): string => tierLabels?.[tier]?.trim() || t(TIER_DESCRIPTIONS[tier].labelKey);
export const DEFAULT_HEURISTIC_FIRST_MAX_TIER = "SIMPLE";
/** What the Hybrid radio starts at. Required by that type, so the form always has a value to send. */
@ -797,10 +801,10 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)
}
placeholder={t("addModel.complexityRouterConfig.displayName.placeholder", {
label: tierInfo.label,
label: t(tierInfo.labelKey),
})}
aria-label={t("addModel.complexityRouterConfig.displayName.aria", {
label: tierInfo.label,
label: t(tierInfo.labelKey),
})}
/>
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
@ -808,7 +812,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<InputGroupButton
size="icon-xs"
aria-label={t("addModel.complexityRouterConfig.displayName.clearAria", {
label: tierInfo.label,
label: t(tierInfo.labelKey),
})}
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
>

View file

@ -3,6 +3,7 @@ import { SearchSelect, SearchSelectOption } from "@/components/shared/SearchSele
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Info } from "lucide-react";
import type { ParseKeys } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
@ -17,7 +18,7 @@ interface CompressionControlsProps {
onChange: (state: AutoRouterCompressionState) => void;
}
const NONE_OPTION: SearchSelectOption = { label: "None (no compression)", value: NO_COMPRESSION };
const NONE_OPTION_LABEL: ParseKeys = "addModel.compressionControls.noneOption";
const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChange }) => {
const { t } = useTranslation();
@ -30,7 +31,7 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
const compressionOptions: SearchSelectOption[] = (data?.guardrails ?? [])
.filter((g) => isCompressionGuardrailProvider(g.litellm_params?.guardrail))
.map((g) => ({ label: g.guardrail_name, value: g.guardrail_name }));
const options: SearchSelectOption[] = [NONE_OPTION, ...compressionOptions];
const options: SearchSelectOption[] = [{ label: t(NONE_OPTION_LABEL), value: NO_COMPRESSION }, ...compressionOptions];
return (
<div className="space-y-4">

View file

@ -10,8 +10,8 @@ import { Slider } from "@/components/ui/slider";
import type { CustomDimensionRow } from "./custom_dimensions";
const SCORING_MODES = [
{ value: "binary", label: "Binary" },
{ value: "match_count", label: "Match count" },
{ value: "binary", labelKey: "addModel.customDimensionRows.scoringModes.binary" },
{ value: "match_count", labelKey: "addModel.customDimensionRows.scoringModes.matchCount" },
] as const;
interface Props {
@ -109,7 +109,7 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
<div className="space-y-1">
<Label htmlFor={`${row.id}-scoring`}>{t("addModel.customDimensionRows.scoring")}</Label>
<Select
items={SCORING_MODES}
items={SCORING_MODES.map((mode) => ({ value: mode.value, label: t(mode.labelKey) }))}
value={row.scoring_mode ?? "binary"}
onValueChange={(mode) => {
if (mode === "binary" || mode === "match_count") update(row.id, { scoring_mode: mode });
@ -121,7 +121,7 @@ export default function CustomDimensionRows({ rows, disabled, onChange, onWeight
<SelectContent>
{SCORING_MODES.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
{t(mode.labelKey)}
</SelectItem>
))}
</SelectContent>

View file

@ -5,6 +5,7 @@ import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import { ClassifierFallback, ClassifierType, ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import i18n from "@/i18n/i18n";
import { DIMENSION_LABELS } from "./heuristic_scoring_knobs";
import { LOADED_SCORER_DEFAULTS_QUERY, SHIPPED_SCORER_DEFAULTS } from "../../../tests/mocks/complexityScorerDefaults";
@ -212,7 +213,7 @@ describe("ClassificationMethodConfig scorer gating", () => {
await render(BASE);
for (const key of Object.keys(SHIPPED_SCORER_DEFAULTS.dimension_weights)) {
expect(screen.getByLabelText(DIMENSION_LABELS[key])).toBeInTheDocument();
expect(screen.getByLabelText(i18n.t(DIMENSION_LABELS[key]))).toBeInTheDocument();
}
});
});

View file

@ -147,8 +147,8 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
{rubricSummary && (
<p className="mb-1 text-xs text-muted-foreground">
{isOverridden
? t("addModel.openingPromptEditor.customOpeningOnRubric", { rubric: rubricSummary.label })
: t("addModel.openingPromptEditor.rubricName", { rubric: rubricSummary.label })}
? t("addModel.openingPromptEditor.customOpeningOnRubric", { rubric: t(rubricSummary.labelKey) })
: t("addModel.openingPromptEditor.rubricName", { rubric: t(rubricSummary.labelKey) })}
</p>
)}
<div className="flex items-center gap-2">
@ -190,7 +190,7 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
<Select
items={Object.entries(CLASSIFICATION_RUBRIC_DESCRIPTIONS).map(([rubric, description]) => ({
value: rubric,
label: description.label,
label: t(description.labelKey),
}))}
value={classificationRubric ?? tierSource.classificationRubric}
onValueChange={(rubric: ClassificationRubric | null) => rubric && setRubricDraft(rubric)}
@ -210,13 +210,13 @@ const OpeningPromptEditor: React.FC<OpeningPromptEditorProps> = ({
>
{Object.entries(CLASSIFICATION_RUBRIC_DESCRIPTIONS).map(([rubric, description]) => (
<SelectItem key={rubric} value={rubric}>
{description.label}
{t(description.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
{tierSource.rubricRestriction ?? draftRubricSummary?.description}
{tierSource.rubricRestriction ?? (draftRubricSummary && t(draftRubricSummary.descriptionKey))}
</p>
</div>
)}

View file

@ -8,7 +8,8 @@ export const restrictedBy = (
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.custom_tier_set)
return { omit: CUSTOM_TIER_RESTRICTIONS[key].omit, reason: t(CUSTOM_TIER_RESTRICTIONS[key].reason) };
if (value.classifier_type === "llm_v2" && key === "adaptive")
return {
omit: ["adaptive", "adaptive_weights", "adaptive_eligible", "tier_distance_penalty"],

View file

@ -1,20 +1,22 @@
import type { ParseKeys } from "i18next";
// Define the available test modes
export const TEST_MODES = [
{ value: "chat", label: "Chat - /chat/completions" },
{ value: "completion", label: "Completion - /completions" },
{ value: "embedding", label: "Embedding - /embeddings" },
{ value: "audio_speech", label: "Audio Speech - /audio/speech" },
{ value: "audio_transcription", label: "Audio Transcription - /audio/transcriptions" },
{ value: "image_generation", label: "Image Generation - /images/generations" },
{ value: "image_edit", label: "Image Edit - /images/edits" },
{ value: "video_generation", label: "Video Generation - /videos" },
{ value: "rerank", label: "Rerank - /rerank" },
{ value: "realtime", label: "Realtime - /realtime" },
{ value: "batch", label: "Batch - /batch" },
{ value: "anthropic_messages", label: "Anthropic Messages - /v1/messages" },
{ value: "ocr", label: "OCR - /ocr" },
{ value: "decisions", label: "Decisions - /v1/decisions" },
{ value: "extraction", label: "Extraction - /v1/extraction" },
export const TEST_MODES: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: "chat", labelKey: "addModel.addModelModes.chat" },
{ value: "completion", labelKey: "addModel.addModelModes.completion" },
{ value: "embedding", labelKey: "addModel.addModelModes.embedding" },
{ value: "audio_speech", labelKey: "addModel.addModelModes.audioSpeech" },
{ value: "audio_transcription", labelKey: "addModel.addModelModes.audioTranscription" },
{ value: "image_generation", labelKey: "addModel.addModelModes.imageGeneration" },
{ value: "image_edit", labelKey: "addModel.addModelModes.imageEdit" },
{ value: "video_generation", labelKey: "addModel.addModelModes.videoGeneration" },
{ value: "rerank", labelKey: "addModel.addModelModes.rerank" },
{ value: "realtime", labelKey: "addModel.addModelModes.realtime" },
{ value: "batch", labelKey: "addModel.addModelModes.batch" },
{ value: "anthropic_messages", labelKey: "addModel.addModelModes.anthropicMessages" },
{ value: "ocr", labelKey: "addModel.addModelModes.ocr" },
{ value: "decisions", labelKey: "addModel.addModelModes.decisions" },
{ value: "extraction", labelKey: "addModel.addModelModes.extraction" },
];
// Define the available auto router routing strategies

View file

@ -58,8 +58,8 @@ const USAGE_COST_FIELDS = [
const REVALIDATED_WHEN_PTU_COUNT_CHANGES = [PTU_RATE_FIELD, PTU_START_FIELD, ...USAGE_COST_FIELDS];
const PRICING_MODEL_ITEMS = [
{ value: "per_token", label: "Per Million Tokens" },
{ value: "per_second", label: "Per Second" },
{ value: "per_token", labelKey: "addModel.advancedSettings.pricingModels.perToken" },
{ value: "per_second", labelKey: "addModel.advancedSettings.pricingModels.perSecond" },
] as const;
const validateNumber = (_: unknown, value: unknown) => {
@ -320,7 +320,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
>
{(control) => (
<Select
items={PRICING_MODEL_ITEMS}
items={PRICING_MODEL_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }))}
value={(control.value as "per_token" | "per_second" | undefined) ?? "per_token"}
onValueChange={handlePricingModelChange(control.onChange)}
>
@ -330,7 +330,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
<SelectContent>
{PRICING_MODEL_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.labelKey)}
</SelectItem>
))}
</SelectContent>
@ -490,7 +490,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
<MountedFormField
name="cache_control"
label={labelWithHint(CACHE_CONTROL_LABEL, CACHE_CONTROL_TOOLTIP)}
label={labelWithHint(t(CACHE_CONTROL_LABEL), t(CACHE_CONTROL_TOOLTIP))}
className="mb-4"
>
{(control) => (

View file

@ -1,4 +1,4 @@
import type { TFunction } from "i18next";
import type { ParseKeys, 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";
@ -14,7 +14,11 @@ export const ROUTER_FAMILIES = {
glm: "GLM",
deepseek: "DeepSeek",
} as const;
export const ROUTER_GOALS = { cheapest: "Cheapest", balanced: "Balanced", quality: "Best quality" } as const;
export const ROUTER_GOALS = {
cheapest: "addModel.autoSetup.goals.cheapest",
balanced: "addModel.autoSetup.goals.balanced",
quality: "addModel.autoSetup.goals.quality",
} as const satisfies Record<string, ParseKeys>;
export type RouterFamily = keyof typeof ROUTER_FAMILIES;
export type RouterGoal = keyof typeof ROUTER_GOALS;
export type FamilyPreference = RouterFamily | "auto";

View file

@ -10,10 +10,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import NumericalInput from "../shared/numerical_input";
export const CACHE_CONTROL_LABEL = "Cache Control Injection Points";
export const CACHE_CONTROL_LABEL: ParseKeys = "addModel.cacheControlSettings.label";
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_TOOLTIP: ParseKeys = "addModel.cacheControlSettings.tooltip";
export const CACHE_CONTROL_DESCRIPTION: ParseKeys = "addModel.cacheControlSettings.description";
@ -31,12 +30,12 @@ export interface CacheControlInjectionPoint {
export const NEW_CACHE_CONTROL_POINT: CacheControlInjectionPoint = { location: "message" };
const LOCATION_ITEMS = [{ value: "message", label: "Message" }] as const;
const LOCATION_ITEMS = [{ value: "message", labelKey: "addModel.cacheControlSettings.locations.message" }] as const;
const ROLE_ITEMS = [
{ value: "user", label: "User" },
{ value: "system", label: "System" },
{ value: "assistant", label: "Assistant" },
{ value: "user", labelKey: "addModel.cacheControlSettings.roles.user" },
{ value: "system", labelKey: "addModel.cacheControlSettings.roles.system" },
{ value: "assistant", labelKey: "addModel.cacheControlSettings.roles.assistant" },
] as const;
const LabelWithHint: React.FC<{ label: string; hint: string }> = ({ label, hint }) => {
@ -89,14 +88,18 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
<div key={index} className="mb-4 flex items-end gap-4">
<div className="w-[180px] space-y-1">
<Label>{t("addModel.cacheControlSettings.type")}</Label>
<Select items={LOCATION_ITEMS} value={point.location} disabled>
<Select
items={LOCATION_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }))}
value={point.location}
disabled
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOCATION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.labelKey)}
</SelectItem>
))}
</SelectContent>
@ -106,7 +109,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
<div className="w-[180px] space-y-1">
<LabelWithHint label={t("addModel.cacheControlSettings.role")} hint={t(CACHE_CONTROL_ROLE_HINT)} />
<Select
items={ROLE_ITEMS}
items={ROLE_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }))}
value={point.role ?? null}
onValueChange={(selected) =>
replaceAt(index, { ...point, role: (selected as CacheControlRole | null) ?? undefined })
@ -119,7 +122,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
<SelectItem value={null}>{t("addModel.cacheControlSettings.noRole")}</SelectItem>
{ROLE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.labelKey)}
</SelectItem>
))}
</SelectContent>

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import i18n from "@/i18n/i18n";
import { serializeCustomDimensions, type CustomDimensionRow } from "./custom_dimensions";
import { heuristicScoringRoleFor, type ClassifierFallback } from "./ComplexityRouterConfig";
@ -43,17 +44,18 @@ export const scorerKnobPayload = (knobs: ScorerKnobInputs) => {
* shipped weights come from the proxy (GET /public/complexity_router/scorer_defaults), so a dimension
* added backend-side still renders, under its raw key until it is given a label here.
*/
export const DIMENSION_LABELS: Record<string, string> = {
codePresence: "Code presence",
reasoningMarkers: "Reasoning markers",
technicalTerms: "Technical terms",
tokenCount: "Token count",
simpleIndicators: "Simple indicators",
multiStepPatterns: "Multi-step patterns",
questionComplexity: "Question complexity",
export const DIMENSION_LABELS: Record<string, ParseKeys> = {
codePresence: "addModel.heuristicScoringKnobs.dimensions.codePresence",
reasoningMarkers: "addModel.heuristicScoringKnobs.dimensions.reasoningMarkers",
technicalTerms: "addModel.heuristicScoringKnobs.dimensions.technicalTerms",
tokenCount: "addModel.heuristicScoringKnobs.dimensions.tokenCount",
simpleIndicators: "addModel.heuristicScoringKnobs.dimensions.simpleIndicators",
multiStepPatterns: "addModel.heuristicScoringKnobs.dimensions.multiStepPatterns",
questionComplexity: "addModel.heuristicScoringKnobs.dimensions.questionComplexity",
};
export const dimensionLabel = (key: string): string => DIMENSION_LABELS[key] ?? key;
export const dimensionLabel = (key: string): string =>
Object.hasOwn(DIMENSION_LABELS, key) ? i18n.t(DIMENSION_LABELS[key]) : key;
const asRecord = (raw: unknown): Record<string, unknown> | undefined =>
typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined;

View file

@ -1,9 +1,10 @@
import type { ParseKeys } from "i18next";
import { Providers } from "../provider_info_helpers";
export interface ProviderAuthType {
readonly id: string;
readonly label: string;
readonly description: string;
readonly labelKey: ParseKeys;
readonly descriptionKey: ParseKeys;
readonly fieldKeys: readonly string[];
readonly requiredFieldKeys: readonly string[];
readonly fixedValues?: Readonly<Record<string, string>>;
@ -20,18 +21,16 @@ export const PROVIDER_AUTH_TYPES: Partial<Record<keyof typeof Providers, readonl
GITHUB_COPILOT: [
{
id: "shared_device_login",
label: "Shared device login",
description:
"One GitHub device login on the proxy host, stored in its token file, is used for every caller of models on this credential.",
labelKey: "addModel.providerAuthTypes.sharedDeviceLogin.label",
descriptionKey: "addModel.providerAuthTypes.sharedDeviceLogin.description",
fieldKeys: ["api_base", "api_key"],
requiredFieldKeys: [],
},
{
id: GITHUB_COPILOT_PER_USER_AUTH_TYPE,
label: "Per-user GitHub OAuth",
labelKey: "addModel.providerAuthTypes.perUserOauth.label",
credentialOnly: true,
description:
"Each LiteLLM user connects their own GitHub account from LLM Credentials, and their requests use their own GitHub Copilot access. Users who have not connected get a 401.",
descriptionKey: "addModel.providerAuthTypes.perUserOauth.description",
fieldKeys: [GITHUB_COPILOT_AUTH_TYPE_KEY, GITHUB_COPILOT_HOST_KEY],
requiredFieldKeys: [GITHUB_COPILOT_AUTH_TYPE_KEY],
fixedValues: { [GITHUB_COPILOT_AUTH_TYPE_KEY]: GITHUB_COPILOT_PER_USER_AUTH_TYPE },
@ -40,10 +39,9 @@ export const PROVIDER_AUTH_TYPES: Partial<Record<keyof typeof Providers, readonl
MICROSOFT_365_COPILOT: [
{
id: "oauth_token_exchange",
label: "OAuth token exchange (on-behalf-of)",
labelKey: "addModel.providerAuthTypes.oauthTokenExchange.label",
credentialOnly: true,
description:
"LiteLLM exchanges each caller's IdP-issued JWT at your IdP's token endpoint for a delegated token. Microsoft Graph only accepts Microsoft Entra tokens, so for Microsoft 365 Copilot the token endpoint must be Entra and callers must send an Entra-issued JWT for this app. Users can still sign in through any IdP federated with Entra.",
descriptionKey: "addModel.providerAuthTypes.oauthTokenExchange.description",
fieldKeys: [
"token_exchange_endpoint",
"token_exchange_profile",
@ -56,8 +54,8 @@ export const PROVIDER_AUTH_TYPES: Partial<Record<keyof typeof Providers, readonl
},
{
id: "static_token",
label: "Static delegated access token",
description: "Sends one pre-acquired delegated Microsoft Graph token for every caller.",
labelKey: "addModel.providerAuthTypes.staticToken.label",
descriptionKey: "addModel.providerAuthTypes.staticToken.description",
fieldKeys: ["api_key"],
requiredFieldKeys: ["api_key"],
},

View file

@ -353,7 +353,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
)}
</label>
<Select
items={availableAuthTypes.map(({ id, label }) => ({ value: id, label }))}
items={availableAuthTypes.map(({ id, labelKey }) => ({ value: id, label: t(labelKey) }))}
value={selectedAuthType.id}
onValueChange={(value) => setSelectedAuthTypeId(value ?? "")}
>
@ -363,12 +363,12 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
<SelectContent>
{availableAuthTypes.map((authType) => (
<SelectItem key={authType.id} value={authType.id}>
{authType.label}
{t(authType.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">{selectedAuthType.description}</p>
<p className="text-sm text-muted-foreground">{t(selectedAuthType.descriptionKey)}</p>
</div>
)}
{credentialOnlySelected ? (

View file

@ -1,4 +1,4 @@
import type { TFunction } from "i18next";
import type { ParseKeys, TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { isForecastClassifier } from "./forecast_classifier_config";
import type { ClassifierType, ComplexityTiers } from "./ComplexityRouterConfig";
@ -126,29 +126,27 @@ export interface TierRestriction {
export const CUSTOM_TIER_RESTRICTIONS = {
displayNames: {
omit: ["tier_labels"],
reason: "Display names rename the built-in tiers, which your tier set replaces. Name each tier directly",
reason: "addModel.tierRows.restrictions.displayNames",
},
escalation: {
omit: ["escalation_keywords"],
reason: "Escalation bumps a request along the built-in tier ladder, which your tier set replaces",
reason: "addModel.tierRows.restrictions.escalation",
},
stallEscalation: {
omit: ["stall_escalation_enabled", "stall_escalation_window", "stall_escalation_repeat_threshold"],
reason: "Stall escalation bumps a request along the built-in tier ladder, which your tier set replaces",
reason: "addModel.tierRows.restrictions.stallEscalation",
},
adaptive: {
omit: ["adaptive", "adaptive_weights", "tier_distance_penalty", "adaptive_eligible"],
reason: "Adaptive routing scores models along the built-in tier ladder, which your tier set replaces",
reason: "addModel.tierRows.restrictions.adaptive",
},
sessionAffinity: {
omit: [],
reason: "Session pinning escalates along the built-in tier ladder, which your tier set replaces",
reason: "addModel.tierRows.restrictions.sessionAffinity",
},
heuristicClassifier: {
omit: ["heuristic_first_max_tier", "hybrid_boundary_margin", "local_heuristic"],
reason:
"The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM or JEV classifier. " +
"Heuristic first and hybrid are out for the same reason: their local scorer decides the traffic it is sure of",
reason: "addModel.tierRows.restrictions.heuristicClassifier",
},
heuristicScoring: {
omit: [
@ -159,17 +157,17 @@ export const CUSTOM_TIER_RESTRICTIONS = {
"reasoning_override_min_score",
"custom_technical_keywords",
],
reason: "The heuristic scorer never runs under an edited tier set, so its inputs have no effect",
reason: "addModel.tierRows.restrictions.heuristicScoring",
},
classificationRubric: {
omit: [],
reason: "The preset calibration examples are written against the built-in tiers, which your tier set replaces",
reason: "addModel.tierRows.restrictions.classificationRubric",
},
classifierFallback: {
omit: ["classifier_fallback"],
reason: "Fallback Tier is where an edited tier set routes when the classifier fails",
reason: "addModel.tierRows.restrictions.classifierFallback",
},
} as const satisfies Record<string, TierRestriction>;
} as const satisfies Record<string, { omit: readonly string[]; reason: ParseKeys }>;
export const CUSTOM_TIER_OMITTED_KEYS: readonly string[] = Object.values(CUSTOM_TIER_RESTRICTIONS).flatMap(
(restriction) => restriction.omit,

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import { Input } from "@/components/ui/input";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
import { SimpleTooltip } from "@/components/ui/tooltip";
@ -54,9 +55,9 @@ const AUTH_METHOD_SELECT_ID = "credential_auth_method";
const API_KEY_FIELDS: readonly string[] = ["api_key"];
const NO_HIDDEN_FIELDS: readonly string[] = [];
const authMethodItems: { value: AuthMethod; label: string }[] = [
{ value: "api_key", label: "API key" },
{ value: "federation", label: "Workload identity federation" },
const authMethodItems: { value: AuthMethod; labelKey: ParseKeys }[] = [
{ value: "api_key", labelKey: "modelAdd.credentialModal.authMethods.apiKey" },
{ value: "federation", labelKey: "modelAdd.credentialModal.authMethods.federation" },
];
interface CredentialModalProps {
@ -242,7 +243,7 @@ export default function CredentialModal({
)}
</label>
<Select
items={authMethodItems}
items={authMethodItems.map((item) => ({ value: item.value, label: t(item.labelKey) }))}
value={authMethod}
onValueChange={(value) => changeAuthMethod(value as AuthMethod)}
>
@ -252,7 +253,7 @@ export default function CredentialModal({
<SelectContent>
{authMethodItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.labelKey)}
</SelectItem>
))}
</SelectContent>

View file

@ -91,7 +91,7 @@ function FederationFieldInput({ field, storedValue, validators }: FederationFiel
const { t } = useTranslation();
return (
<MountedFormField
label={labelWithHint(field.label, field.tooltip)}
label={labelWithHint(t(field.labelKey), t(field.tooltipKey, { ...field.tooltipValues }))}
name={field.key}
required={field.required}
rules={{

View file

@ -1,4 +1,4 @@
import type { TFunction } from "i18next";
import type { ParseKeys, TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { isMaskedSecret } from "@/utils/maskedSecretUtils";
import { isBlank, type FederationField } from "./federation_field";
@ -13,7 +13,7 @@ export type IdentitySourceId =
export interface IdentitySource {
readonly id: IdentitySourceId;
readonly label: string;
readonly labelKey: ParseKeys;
readonly fixedValues: Readonly<Record<string, string>>;
readonly fields: readonly FederationField[];
}
@ -27,34 +27,31 @@ const REJECTED_REFERENCE_PREFIX = "oidc/env_path/";
export const FEDERATION_CORE_FIELDS: readonly FederationField[] = [
{
key: "anthropic_federation_rule_id",
label: "Federation Rule ID",
tooltip:
"The fdrl_ id of the federation rule, from Settings > Workload identity in the Claude Console. Leave empty when the proxy sets ANTHROPIC_FEDERATION_RULE_ID in its environment.",
labelKey: "modelAdd.anthropicFederation.fields.federationRuleId.label",
tooltipKey: "modelAdd.anthropicFederation.fields.federationRuleId.tooltip",
placeholder: "fdrl_...",
required: false,
control: "text",
},
{
key: "anthropic_organization_id",
label: "Organization ID",
tooltip:
"The Anthropic organization the federation rule belongs to, shown on the rule's detail page. Leave empty when the proxy sets ANTHROPIC_ORGANIZATION_ID in its environment.",
labelKey: "modelAdd.anthropicFederation.fields.organizationId.label",
tooltipKey: "modelAdd.anthropicFederation.fields.organizationId.tooltip",
required: false,
control: "text",
},
{
key: "anthropic_service_account_id",
label: "Service Account ID",
tooltip: "The svac_ id the federation rule targets. Anthropic's reference lists it as required.",
labelKey: "modelAdd.anthropicFederation.fields.serviceAccountId.label",
tooltipKey: "modelAdd.anthropicFederation.fields.serviceAccountId.tooltip",
placeholder: "svac_...",
required: false,
control: "text",
},
{
key: "anthropic_federation_workspace_id",
label: "Workspace ID",
tooltip:
"Set this when the federation rule is enabled in more than one workspace: the wrkspc_ id to mint tokens for, or 'default'.",
labelKey: "modelAdd.anthropicFederation.fields.workspaceId.label",
tooltipKey: "modelAdd.anthropicFederation.fields.workspaceId.tooltip",
placeholder: "wrkspc_...",
required: false,
control: "text",
@ -64,14 +61,13 @@ export const FEDERATION_CORE_FIELDS: readonly FederationField[] = [
export const IDENTITY_SOURCES: readonly IdentitySource[] = [
{
id: "token_file",
label: "Identity token file",
labelKey: "modelAdd.anthropicFederation.sources.tokenFile",
fixedValues: {},
fields: [
{
key: "anthropic_identity_token_file",
label: "Identity Token File",
tooltip:
"Absolute path on the proxy host of a file holding the identity token, for example a projected Kubernetes service account token. It must sit under an allowed credential directory.",
labelKey: "modelAdd.anthropicFederation.fields.identityTokenFile.label",
tooltipKey: "modelAdd.anthropicFederation.fields.identityTokenFile.tooltip",
placeholder: "/var/run/secrets/anthropic/token",
required: true,
control: "text",
@ -80,14 +76,13 @@ export const IDENTITY_SOURCES: readonly IdentitySource[] = [
},
{
id: "secret_reference",
label: "Identity token secret reference",
labelKey: "modelAdd.anthropicFederation.sources.secretReference",
fixedValues: {},
fields: [
{
key: "anthropic_identity_token",
label: "Identity Token Reference",
tooltip:
"An oidc/ secret reference the proxy resolves on each exchange, such as oidc/env/VAR_NAME, oidc/github/<audience>, or oidc/google/<audience>. Raw tokens are not accepted.",
labelKey: "modelAdd.anthropicFederation.fields.identityTokenReference.label",
tooltipKey: "modelAdd.anthropicFederation.fields.identityTokenReference.tooltip",
placeholder: "oidc/env/ANTHROPIC_IDENTITY_TOKEN",
required: true,
control: "text",
@ -96,43 +91,43 @@ export const IDENTITY_SOURCES: readonly IdentitySource[] = [
},
{
id: "internal_issuer",
label: "Token signed by LiteLLM (internal issuer)",
labelKey: "modelAdd.anthropicFederation.sources.internalIssuer",
fixedValues: { [IDENTITY_SOURCE_KEY]: "internal_issuer" },
fields: [
{
key: "anthropic_issuer_url",
label: "Issuer URL",
tooltip: "The issuer registered on the federation rule. The proxy puts it in the token's iss claim.",
labelKey: "modelAdd.anthropicFederation.fields.issuerUrl.label",
tooltipKey: "modelAdd.anthropicFederation.fields.issuerUrl.tooltip",
placeholder: "https://litellm.example.com",
required: true,
control: "text",
},
{
key: "anthropic_issuer_subject",
label: "Subject",
tooltip: "The sub claim the federation rule matches.",
labelKey: "modelAdd.anthropicFederation.fields.issuerSubject.label",
tooltipKey: "modelAdd.anthropicFederation.fields.issuerSubject.tooltip",
required: true,
control: "text",
},
{
key: "anthropic_issuer_audience",
label: "Audience",
tooltip: "Optional aud claim, when the federation rule checks one.",
labelKey: "modelAdd.anthropicFederation.fields.issuerAudience.label",
tooltipKey: "modelAdd.anthropicFederation.fields.issuerAudience.tooltip",
required: false,
control: "text",
},
{
key: "anthropic_issuer_ttl_seconds",
label: "Token Lifetime (seconds)",
tooltip: `Optional. How long each signed token is valid, from 1 to ${MAX_ISSUER_TTL_SECONDS} seconds. The proxy uses its default when this is empty.`,
labelKey: "modelAdd.anthropicFederation.fields.issuerTtlSeconds.label",
tooltipKey: "modelAdd.anthropicFederation.fields.issuerTtlSeconds.tooltip",
tooltipValues: { max: MAX_ISSUER_TTL_SECONDS },
required: false,
control: "integer",
},
{
key: "anthropic_issuer_signing_key_ref",
label: "Signing Key Reference",
tooltip:
"A secret reference to the PEM private key the proxy signs with, such as os.environ/VAR_NAME. Enter the reference, never the key itself.",
labelKey: "modelAdd.anthropicFederation.fields.issuerSigningKeyRef.label",
tooltipKey: "modelAdd.anthropicFederation.fields.issuerSigningKeyRef.tooltip",
placeholder: "os.environ/ANTHROPIC_ISSUER_SIGNING_KEY",
required: true,
control: "text",
@ -141,45 +136,44 @@ export const IDENTITY_SOURCES: readonly IdentitySource[] = [
},
{
id: "keycloak",
label: "Keycloak client credentials",
labelKey: "modelAdd.anthropicFederation.sources.keycloak",
fixedValues: { [IDENTITY_SOURCE_KEY]: "keycloak" },
fields: [
{
key: "anthropic_keycloak_token_url",
label: "Keycloak Token URL",
tooltip: "The realm's token endpoint the proxy requests an identity token from.",
labelKey: "modelAdd.anthropicFederation.fields.keycloakTokenUrl.label",
tooltipKey: "modelAdd.anthropicFederation.fields.keycloakTokenUrl.tooltip",
placeholder: "https://keycloak.example.com/realms/<realm>/protocol/openid-connect/token",
required: true,
control: "text",
},
{
key: "anthropic_keycloak_client_id",
label: "Keycloak Client ID",
tooltip: "The client the proxy authenticates as.",
labelKey: "modelAdd.anthropicFederation.fields.keycloakClientId.label",
tooltipKey: "modelAdd.anthropicFederation.fields.keycloakClientId.tooltip",
required: true,
control: "text",
},
{
key: "anthropic_keycloak_client_secret_ref",
label: "Client Secret Reference",
tooltip:
"A secret reference to the client secret, such as os.environ/VAR_NAME. Enter the reference, never the secret itself.",
labelKey: "modelAdd.anthropicFederation.fields.keycloakClientSecretRef.label",
tooltipKey: "modelAdd.anthropicFederation.fields.keycloakClientSecretRef.tooltip",
placeholder: "os.environ/KEYCLOAK_CLIENT_SECRET",
required: true,
control: "text",
},
{
key: "anthropic_keycloak_auth_method",
label: "Client Authentication Method",
tooltip: "Optional. How the client secret is sent to Keycloak. The proxy uses client_secret_basic when unset.",
labelKey: "modelAdd.anthropicFederation.fields.keycloakAuthMethod.label",
tooltipKey: "modelAdd.anthropicFederation.fields.keycloakAuthMethod.tooltip",
required: false,
control: "select",
options: ["client_secret_basic", "client_secret_post"],
},
{
key: "anthropic_keycloak_scope",
label: "Scope",
tooltip: "Optional scope to request with the token.",
labelKey: "modelAdd.anthropicFederation.fields.keycloakScope.label",
tooltipKey: "modelAdd.anthropicFederation.fields.keycloakScope.tooltip",
required: false,
control: "text",
},
@ -187,7 +181,7 @@ export const IDENTITY_SOURCES: readonly IdentitySource[] = [
},
{
id: "environment",
label: "Proxy environment variables",
labelKey: "modelAdd.anthropicFederation.sources.environment",
fixedValues: {},
fields: [],
},
@ -210,7 +204,12 @@ export const ANTHROPIC_FEDERATION_VALUE_KEYS: readonly string[] = [
...IDENTITY_SOURCE_VALUE_KEYS,
];
const UNRECOGNIZED_IDENTITY_SOURCE: IdentitySource = { id: "unrecognized", label: "", fixedValues: {}, fields: [] };
const UNRECOGNIZED_IDENTITY_SOURCE: IdentitySource = {
id: "unrecognized",
labelKey: "modelAdd.anthropicFederation.storedSource",
fixedValues: {},
fields: [],
};
export const identitySourceById = (id: IdentitySourceId): IdentitySource =>
IDENTITY_SOURCES.find((source) => source.id === id) ?? UNRECOGNIZED_IDENTITY_SOURCE;
@ -247,7 +246,7 @@ export const identitySourceOptions = (
},
]
: []),
...IDENTITY_SOURCES.map((source) => ({ value: source.id, label: source.label })),
...IDENTITY_SOURCES.map((source) => ({ value: source.id, label: t(source.labelKey) })),
];
export const validateFederationValueStored =

View file

@ -1,10 +1,12 @@
import type { ParseKeys } from "i18next";
import i18n from "@/i18n/i18n";
import { isMaskedSecret } from "@/utils/maskedSecretUtils";
export interface FederationField {
readonly key: string;
readonly label: string;
readonly tooltip: string;
readonly labelKey: ParseKeys;
readonly tooltipKey: ParseKeys;
readonly tooltipValues?: Readonly<Record<string, number>>;
readonly placeholder?: string;
readonly required: boolean;
readonly control: "text" | "integer" | "select";

View file

@ -6,25 +6,22 @@ const OPENAI_API_HOST = "api.openai.com";
export const OPENAI_FEDERATION_FIELDS: readonly FederationField[] = [
{
key: "openai_identity_provider_id",
label: "Identity Provider ID",
tooltip:
"The workload identity provider registered in your OpenAI organization that trusts the token's issuer. Leave empty when the proxy sets OPENAI_IDENTITY_PROVIDER_ID in its environment.",
labelKey: "modelAdd.openaiFederation.fields.identityProviderId.label",
tooltipKey: "modelAdd.openaiFederation.fields.identityProviderId.tooltip",
required: false,
control: "text",
},
{
key: "openai_service_account_id",
label: "Service Account ID",
tooltip:
"The OpenAI service account the verified identity acts as. Its project decides what the model can reach. Leave empty when the proxy sets OPENAI_SERVICE_ACCOUNT_ID in its environment.",
labelKey: "modelAdd.openaiFederation.fields.serviceAccountId.label",
tooltipKey: "modelAdd.openaiFederation.fields.serviceAccountId.tooltip",
required: false,
control: "text",
},
{
key: "openai_identity_token_file",
label: "Identity Token File",
tooltip:
"Absolute path on the proxy host of a file holding the identity token, for example a projected Kubernetes service account token. Leave empty when the proxy sets OPENAI_IDENTITY_TOKEN_FILE in its environment.",
labelKey: "modelAdd.openaiFederation.fields.identityTokenFile.label",
tooltipKey: "modelAdd.openaiFederation.fields.identityTokenFile.tooltip",
placeholder: "/var/run/secrets/kubernetes.io/serviceaccount/token",
required: false,
control: "text",

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import type { ParseKeys, TFunction } from "i18next";
import { Info, Play, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
@ -53,6 +53,13 @@ const CHECK_IN_PROGRESS = "Check in progress...";
const NEVER_SUCCEEDED = "Never succeeded";
const NONE = "None";
const SENTINEL_LABEL_KEYS: Readonly<Record<string, ParseKeys>> = {
[NONE]: "modelDashboard.healthChecksTableColumns.sentinels.none",
};
const sentinelLabel = (value: string, t: TFunction): string =>
Object.hasOwn(SENTINEL_LABEL_KEYS, value) ? t(SENTINEL_LABEL_KEYS[value]) : value;
function HealthStatusBadge({ status }: { status: string }) {
const tone = HEALTH_STATUS_TONES[status];
if (!tone) {
@ -418,7 +425,9 @@ export const getHealthChecksTableColumns = (
},
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.health_loading ? CHECK_IN_PROGRESS : row.original.last_check}
{row.original.health_loading
? t("modelDashboard.healthChecksTableColumns.sentinels.checkInProgress")
: sentinelLabel(row.original.last_check, t)}
</span>
),
},
@ -444,7 +453,7 @@ export const getHealthChecksTableColumns = (
cell: ({ row }) => {
const modelId = row.original.model_info?.id ?? "";
const lastSuccess = modelHealthStatuses[modelId]?.lastSuccess || NONE;
return <span className="text-sm text-muted-foreground">{lastSuccess}</span>;
return <span className="text-sm text-muted-foreground">{sentinelLabel(lastSuccess, t)}</span>;
},
},
{

View file

@ -16001,6 +16001,41 @@
}
},
"addModel": {
"addModelModes": {
"chat": "Chat - /chat/completions",
"completion": "Completion - /completions",
"embedding": "Embedding - /embeddings",
"audioSpeech": "Audio Speech - /audio/speech",
"audioTranscription": "Audio Transcription - /audio/transcriptions",
"imageGeneration": "Image Generation - /images/generations",
"imageEdit": "Image Edit - /images/edits",
"videoGeneration": "Video Generation - /videos",
"rerank": "Rerank - /rerank",
"realtime": "Realtime - /realtime",
"batch": "Batch - /batch",
"anthropicMessages": "Anthropic Messages - /v1/messages",
"ocr": "OCR - /ocr",
"decisions": "Decisions - /v1/decisions",
"extraction": "Extraction - /v1/extraction"
},
"providerAuthTypes": {
"sharedDeviceLogin": {
"label": "Shared device login",
"description": "One GitHub device login on the proxy host, stored in its token file, is used for every caller of models on this credential."
},
"perUserOauth": {
"label": "Per-user GitHub OAuth",
"description": "Each LiteLLM user connects their own GitHub account from LLM Credentials, and their requests use their own GitHub Copilot access. Users who have not connected get a 401."
},
"oauthTokenExchange": {
"label": "OAuth token exchange (on-behalf-of)",
"description": "LiteLLM exchanges each caller's IdP-issued JWT at your IdP's token endpoint for a delegated token. Microsoft Graph only accepts Microsoft Entra tokens, so for Microsoft 365 Copilot the token endpoint must be Entra and callers must send an Entra-issued JWT for this app. Users can still sign in through any IdP federated with Entra."
},
"staticToken": {
"label": "Static delegated access token",
"description": "Sends one pre-acquired delegated Microsoft Graph token for every caller."
}
},
"classificationMethodConfig": {
"explanations": {
"defaultScoring": "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:",
@ -16374,6 +16409,7 @@
"help": "Time budget for the plugin call. On expiry the fallback path decides the tier."
},
"compressionControls": {
"noneOption": "None (no compression)",
"routing": {
"title": "Routing decision",
"tooltip": "Compression applied to the classifier's own call that picks a tier, separate from the model the request routes to.",
@ -16479,6 +16515,24 @@
}
},
"complexityRouterConfig": {
"rubrics": {
"legacy": {
"label": "Legacy (uncalibrated)",
"description": "The rubric as it shipped before calibration examples, with no worked examples at all. Routers created before this setting existed use it, so their tier decisions and spend are unchanged. It over-routes ordinary engineering to the most expensive tier."
},
"agentic": {
"label": "Agentic",
"description": "Anchors routine installs, builds, multi-file edits, and standard debugging at Medium, so ordinary engineering does not route to your most expensive tier. Suits agent, terminal, and coding-assistant traffic, and mixed traffic."
},
"chat": {
"label": "Chat",
"description": "Drops the engineering examples, for a router serving only conversational traffic that never sees those requests."
},
"business": {
"label": "Business",
"description": "Business and sales examples plus business-oriented tier definitions: routine drafting and summarizing stay at Medium, data-determined analysis is Complex, and only decisions under conflicting tradeoffs reach Reasoning. Suits sales, support, and go-to-market traffic."
}
},
"rowOrigin": {
"builtIn": "built-in",
"custom": "custom"
@ -16516,22 +16570,27 @@
},
"tiers": {
"nonReasoning": {
"label": "Non-reasoning",
"description": "Operational relay work: passing information along with no judgment about it",
"examples": "\"Reformat this tool output\", \"Acknowledge the write succeeded\""
},
"simple": {
"label": "Simple",
"description": "Basic questions, greetings, simple factual queries",
"examples": "\"Hello!\", \"What is Python?\", \"Thanks!\""
},
"medium": {
"label": "Medium",
"description": "Standard queries requiring some reasoning or explanation",
"examples": "\"Explain how REST APIs work\", \"Debug this error\""
},
"complex": {
"label": "Complex",
"description": "Technical, multi-part requests requiring deep knowledge",
"examples": "\"Design a microservices architecture\", \"Implement a rate limiter\""
},
"reasoning": {
"label": "Reasoning",
"description": "Chain-of-thought, analysis, explicit reasoning requests",
"examples": "\"Think step by step...\", \"Analyze the pros and cons...\""
}
@ -16669,6 +16728,10 @@
"tierDistancePenaltyHelp": "Score penalty applied per tier-step away from the classified tier."
},
"customDimensionRows": {
"scoringModes": {
"binary": "Binary",
"matchCount": "Match count"
},
"legend": "Custom dimension {{number}}",
"removeAria": "Remove custom dimension {{number}}",
"remove": "Remove",
@ -16684,6 +16747,11 @@
"matchersHelp": "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."
},
"autoSetup": {
"goals": {
"cheapest": "Cheapest",
"balanced": "Balanced",
"quality": "Best quality"
},
"mixedFamiliesNotice": "A complete pair is unavailable in one family. Using two model families.",
"closestPairNotice": "Using the closest available pair in this family.",
"loadingModels": "Loading deployed models",
@ -16691,6 +16759,17 @@
"selectTwoModels": "Select two different deployed models"
},
"tierRows": {
"restrictions": {
"displayNames": "Display names rename the built-in tiers, which your tier set replaces. Name each tier directly",
"escalation": "Escalation bumps a request along the built-in tier ladder, which your tier set replaces",
"stallEscalation": "Stall escalation bumps a request along the built-in tier ladder, which your tier set replaces",
"adaptive": "Adaptive routing scores models along the built-in tier ladder, which your tier set replaces",
"sessionAffinity": "Session pinning escalates along the built-in tier ladder, which your tier set replaces",
"heuristicClassifier": "The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM or JEV classifier. Heuristic first and hybrid are out for the same reason: their local scorer decides the traffic it is sure of",
"heuristicScoring": "The heuristic scorer never runs under an edited tier set, so its inputs have no effect",
"classificationRubric": "The preset calibration examples are written against the built-in tiers, which your tier set replaces",
"classifierFallback": "Fallback Tier is where an edited tier set routes when the classifier fails"
},
"tierCountRange": "A tier set needs {{min}} to {{max}} tiers",
"nameEveryTier": "Name every tier",
"uniqueNames": "Tier names must be unique, ignoring case",
@ -16713,6 +16792,15 @@
"timeoutHelp": "Keep the original route if the comparison takes too long. Leave blank to use the default of 2000 ms."
},
"heuristicScoringKnobs": {
"dimensions": {
"codePresence": "Code presence",
"reasoningMarkers": "Reasoning markers",
"technicalTerms": "Technical terms",
"tokenCount": "Token count",
"simpleIndicators": "Simple indicators",
"multiStepPatterns": "Multi-step patterns",
"questionComplexity": "Question complexity"
},
"loadDefaultsFirst": "Load the shipped defaults before changing weights",
"invalidExistingWeights": "Existing weights must be finite and nonnegative; custom weights must be greater than 0 and at most 1",
"dimensionUnavailable": "The dimension is no longer available",
@ -16891,6 +16979,10 @@
"close": "Close"
},
"advancedSettings": {
"pricingModels": {
"perToken": "Per Million Tokens",
"perSecond": "Per Second"
},
"invalidPositiveNumber": "Please enter a valid positive number",
"title": "Advanced Settings",
"customPricing": "Custom Pricing",
@ -16984,6 +17076,16 @@
"azureHelp": "Your deployment name will be saved as the public model name, and LiteLLM will use 'azure/deployment-name' internally"
},
"cacheControlSettings": {
"label": "Cache Control Injection Points",
"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.",
"locations": {
"message": "Message"
},
"roles": {
"user": "User",
"system": "System",
"assistant": "Assistant"
},
"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.",
"roleHint": "LiteLLM will mark all messages of this role as cacheable",
"indexHint": "(Optional) If set litellm will mark the message at this index as cacheable",
@ -17150,12 +17252,89 @@
},
"modelAdd": {
"anthropicFederation": {
"fields": {
"federationRuleId": {
"label": "Federation Rule ID",
"tooltip": "The fdrl_ id of the federation rule, from Settings > Workload identity in the Claude Console. Leave empty when the proxy sets ANTHROPIC_FEDERATION_RULE_ID in its environment."
},
"organizationId": {
"label": "Organization ID",
"tooltip": "The Anthropic organization the federation rule belongs to, shown on the rule's detail page. Leave empty when the proxy sets ANTHROPIC_ORGANIZATION_ID in its environment."
},
"serviceAccountId": {
"label": "Service Account ID",
"tooltip": "The svac_ id the federation rule targets. Anthropic's reference lists it as required."
},
"workspaceId": {
"label": "Workspace ID",
"tooltip": "Set this when the federation rule is enabled in more than one workspace: the wrkspc_ id to mint tokens for, or 'default'."
},
"identityTokenFile": {
"label": "Identity Token File",
"tooltip": "Absolute path on the proxy host of a file holding the identity token, for example a projected Kubernetes service account token. It must sit under an allowed credential directory."
},
"identityTokenReference": {
"label": "Identity Token Reference",
"tooltip": "An oidc/ secret reference the proxy resolves on each exchange, such as oidc/env/VAR_NAME, oidc/github/<audience>, or oidc/google/<audience>. Raw tokens are not accepted."
},
"issuerUrl": {
"label": "Issuer URL",
"tooltip": "The issuer registered on the federation rule. The proxy puts it in the token's iss claim."
},
"issuerSubject": {
"label": "Subject",
"tooltip": "The sub claim the federation rule matches."
},
"issuerAudience": {
"label": "Audience",
"tooltip": "Optional aud claim, when the federation rule checks one."
},
"issuerTtlSeconds": {
"label": "Token Lifetime (seconds)",
"tooltip": "Optional. How long each signed token is valid, from 1 to {{max}} seconds. The proxy uses its default when this is empty."
},
"issuerSigningKeyRef": {
"label": "Signing Key Reference",
"tooltip": "A secret reference to the PEM private key the proxy signs with, such as os.environ/VAR_NAME. Enter the reference, never the key itself."
},
"keycloakTokenUrl": {
"label": "Keycloak Token URL",
"tooltip": "The realm's token endpoint the proxy requests an identity token from."
},
"keycloakClientId": {
"label": "Keycloak Client ID",
"tooltip": "The client the proxy authenticates as."
},
"keycloakClientSecretRef": {
"label": "Client Secret Reference",
"tooltip": "A secret reference to the client secret, such as os.environ/VAR_NAME. Enter the reference, never the secret itself."
},
"keycloakAuthMethod": {
"label": "Client Authentication Method",
"tooltip": "Optional. How the client secret is sent to Keycloak. The proxy uses client_secret_basic when unset."
},
"keycloakScope": {
"label": "Scope",
"tooltip": "Optional scope to request with the token."
}
},
"sources": {
"tokenFile": "Identity token file",
"secretReference": "Identity token secret reference",
"internalIssuer": "Token signed by LiteLLM (internal issuer)",
"keycloak": "Keycloak client credentials",
"environment": "Proxy environment variables"
},
"storedSource": "Stored: {{source}}",
"storedValueRequired": "Enter at least one of these ids, or pick an identity source that stores a token. The proxy rejects a credential with no values",
"invalidTokenReference": "Enter an oidc/ secret reference such as oidc/env/VAR_NAME. Raw tokens and oidc/env_path/ references are not accepted",
"invalidTtl": "Enter a whole number of seconds from 1 to {{max}}"
},
"credentialModal": {
"authMethods": {
"apiKey": "API key",
"federation": "Workload identity federation"
},
"editTitle": "Edit Credential",
"addTitle": "Add New Credential",
"credentialName": "Credential Name:",
@ -17255,6 +17434,20 @@
"submit": "Reuse Credentials"
},
"openaiFederation": {
"fields": {
"identityProviderId": {
"label": "Identity Provider ID",
"tooltip": "The workload identity provider registered in your OpenAI organization that trusts the token's issuer. Leave empty when the proxy sets OPENAI_IDENTITY_PROVIDER_ID in its environment."
},
"serviceAccountId": {
"label": "Service Account ID",
"tooltip": "The OpenAI service account the verified identity acts as. Its project decides what the model can reach. Leave empty when the proxy sets OPENAI_SERVICE_ACCOUNT_ID in its environment."
},
"identityTokenFile": {
"label": "Identity Token File",
"tooltip": "Absolute path on the proxy host of a file holding the identity token, for example a projected Kubernetes service account token. Leave empty when the proxy sets OPENAI_IDENTITY_TOKEN_FILE in its environment."
}
},
"apiBaseNotOpenAI": "Workload identity federation only reaches the OpenAI API. Enter https://{{host}}/v1 or a regional *.{{host}} URL, or leave this empty"
},
"internalIssuerJwks": {
@ -17326,6 +17519,21 @@
"dismiss": "Dismiss banner"
},
"modelsAndEndpoints": {
"autoRouterRows": {
"types": {
"llm": "LLM Classifier",
"oss": "OSS Classifier",
"capability": "Capability",
"fuseV2": "Fuse v2",
"heuristicFirst": "Heuristic first",
"hybrid": "Hybrid",
"custom": "Custom classifier",
"heuristic": "Heuristic",
"semantic": "Semantic",
"adaptive": "Adaptive",
"quality": "Quality"
}
},
"autoRoutersPanel": {
"deleted": "Deleted auto router: {{name}}",
"deleteFailed": "Failed to delete auto router: {{error}}",
@ -17402,6 +17610,11 @@
}
},
"allModelsTable": {
"filterLabels": {
"publicModelName": "Public Model Name",
"modelAccessGroup": "Model Access Group",
"status": "Status"
},
"allProxyModels": "All Proxy Models",
"wildcardModels": "Wildcard Models (*)",
"allAccessGroups": "All Model Access Groups",
@ -17855,6 +18068,10 @@
}
},
"healthChecksTableColumns": {
"sentinels": {
"none": "None",
"checkInProgress": "Check in progress..."
},
"columns": {
"modelId": "Model ID",
"modelName": "Model Name",