-
+
{!hasData &&
No data available
}
{hasData && gaugeGeometry && (
<>
@@ -79,7 +79,7 @@ function ExternalCacheTokenMeterChartImpl({ data }: ExternalCacheTokenMeterChart
{hasData && (
-
+
{percentage.toFixed(1)}%
of input tokens cached by provider
@@ -98,7 +98,7 @@ function ExternalCacheTokenMeterChartImpl({ data }: ExternalCacheTokenMeterChart
-
+
Cached: {formatTokenCount(totalCachedRead)}
@@ -115,4 +115,4 @@ function ExternalCacheTokenMeterChartImpl({ data }: ExternalCacheTokenMeterChart
);
}
-export default memo(ExternalCacheTokenMeterChartImpl);
+export default memo(ExternalCacheTokenMeterChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/latencyChart.tsx b/ui/app/workspace/dashboard/components/charts/latencyChart.tsx
index 02623f2ef9..0c4afe86c8 100644
--- a/ui/app/workspace/dashboard/components/charts/latencyChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/latencyChart.tsx
@@ -202,4 +202,4 @@ function LatencyChartImpl({ data, chartType, startTime, endTime }: LatencyChartP
);
}
-export const LatencyChart = memo(LatencyChartImpl);
+export const LatencyChart = memo(LatencyChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/localCacheTokenMeterChart.tsx b/ui/app/workspace/dashboard/components/charts/localCacheTokenMeterChart.tsx
index 8d67360bbd..ea09ccd1d2 100644
--- a/ui/app/workspace/dashboard/components/charts/localCacheTokenMeterChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/localCacheTokenMeterChart.tsx
@@ -99,4 +99,4 @@ function LocalCacheTokenMeterChartImpl({ data }: LocalCacheTokenMeterChartProps)
);
}
-export default memo(LocalCacheTokenMeterChartImpl);
+export default memo(LocalCacheTokenMeterChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
index 289021d154..f0c78c1599 100644
--- a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
@@ -1,221 +1,172 @@
import type { LogsHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
-import {
- Area,
- AreaChart,
- Bar,
- BarChart,
- CartesianGrid,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
-import {
- CHART_COLORS,
- formatFullTimestamp,
- formatTimestamp,
-} from "../../utils/chartUtils";
+import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
interface LogVolumeChartProps {
- data: LogsHistogramResponse | null;
- chartType: ChartType;
- startTime: number;
- endTime: number;
+ data: LogsHistogramResponse | null;
+ chartType: ChartType;
+ startTime: number;
+ endTime: number;
}
type LogVolumeDataPoint = {
- timestamp: string;
- count: number;
- success: number;
- error: number;
- index: number;
- formattedTime: string;
+ timestamp: string;
+ count: number;
+ success: number;
+ error: number;
+ index: number;
+ formattedTime: string;
};
interface CustomTooltipProps {
- active?: boolean;
- payload?: Array<{ payload?: LogVolumeDataPoint }>;
+ active?: boolean;
+ payload?: Array<{ payload?: LogVolumeDataPoint }>;
}
function CustomTooltip({ active, payload }: CustomTooltipProps) {
- if (!active || !payload || !payload.length) return null;
+ if (!active || !payload || !payload.length) return null;
- const data = payload[0]?.payload;
- if (!data) return null;
+ const data = payload[0]?.payload;
+ if (!data) return null;
- return (
-
-
- {formatFullTimestamp(data.timestamp)}
-
-
-
-
-
- Success
-
-
- {data.success.toLocaleString()}
-
-
-
-
-
- Error
-
-
- {data.error.toLocaleString()}
-
-
-
-
- );
+ return (
+
+
{formatFullTimestamp(data.timestamp)}
+
+
+
+
+ Success
+
+ {data.success.toLocaleString()}
+
+
+
+
+ Error
+
+ {data.error.toLocaleString()}
+
+
+
+ );
}
-function LogVolumeChartImpl({
- data,
- chartType,
- startTime,
- endTime,
-}: LogVolumeChartProps) {
- const chartData = useMemo(() => {
- if (!data?.buckets || !data.bucket_size_seconds) {
- return [];
- }
+function LogVolumeChartImpl({ data, chartType, startTime, endTime }: LogVolumeChartProps) {
+ const chartData = useMemo(() => {
+ if (!data?.buckets || !data.bucket_size_seconds) {
+ return [];
+ }
- return data.buckets.map((bucket, index) => ({
- ...bucket,
- index,
- formattedTime: formatTimestamp(
- bucket.timestamp,
- data.bucket_size_seconds,
- ),
- }));
- }, [data]);
+ return data.buckets.map((bucket, index) => ({
+ ...bucket,
+ index,
+ formattedTime: formatTimestamp(bucket.timestamp, data.bucket_size_seconds),
+ }));
+ }, [data]);
- if (!data?.buckets || chartData.length === 0) {
- return (
-
- No data available
-
- );
- }
+ if (!data?.buckets || chartData.length === 0) {
+ return No data available
;
+ }
- const commonProps = {
- data: chartData,
- margin: { top: 6, right: 4, left: 12, bottom: 0 },
- };
+ const commonProps = {
+ data: chartData,
+ margin: { top: 6, right: 4, left: 12, bottom: 0 },
+ };
- return (
-
-
- {chartType === "bar" ? (
-
-
-
- chartData[Math.round(idx)]?.formattedTime || ""
- }
- interval="preserveStartEnd"
- />
- v.toLocaleString()}
- domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
- allowDataOverflow={false}
- />
- }
- cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
- />
-
-
-
- ) : (
-
-
-
- chartData[Math.round(idx)]?.formattedTime || ""
- }
- interval="preserveStartEnd"
- />
- v.toLocaleString()}
- domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
- allowDataOverflow={false}
- />
- } />
-
-
-
- )}
-
-
- );
+ return (
+
+
+ {chartType === "bar" ? (
+
+
+ chartData[Math.round(idx)]?.formattedTime || ""}
+ interval="preserveStartEnd"
+ />
+ v.toLocaleString()}
+ domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
+ allowDataOverflow={false}
+ />
+ } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} />
+
+
+
+ ) : (
+
+
+ chartData[Math.round(idx)]?.formattedTime || ""}
+ interval="preserveStartEnd"
+ />
+ v.toLocaleString()}
+ domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
+ allowDataOverflow={false}
+ />
+ } />
+
+
+
+ )}
+
+
+ );
}
-export const LogVolumeChart = memo(LogVolumeChartImpl);
+export const LogVolumeChart = memo(LogVolumeChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/mcpCostChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpCostChart.tsx
index e39a3d67a8..8c445dd518 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpCostChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpCostChart.tsx
@@ -128,4 +128,4 @@ function MCPCostChartImpl({ data, chartType, startTime, endTime }: MCPCostChartP
);
}
-export const MCPCostChart = memo(MCPCostChartImpl);
+export const MCPCostChart = memo(MCPCostChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
index 752b0d0895..a221c70ea4 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
@@ -78,4 +78,4 @@ function MCPTopToolsChartImpl({ data }: MCPTopToolsChartProps) {
);
}
-export const MCPTopToolsChart = memo(MCPTopToolsChartImpl);
+export const MCPTopToolsChart = memo(MCPTopToolsChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
index aa323a951d..515cd112a2 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
@@ -159,4 +159,4 @@ function MCPVolumeChartImpl({ data, chartType, startTime, endTime }: MCPVolumeCh
);
}
-export const MCPVolumeChart = memo(MCPVolumeChartImpl);
+export const MCPVolumeChart = memo(MCPVolumeChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx b/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
index 51b31ff309..db171a50dd 100644
--- a/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
@@ -46,13 +46,8 @@ function CustomTooltip({ active, payload, selectedModel, displayModels }: any) {
return (
-
-
- {isOther ? OTHER_SERIES_LABEL : model}
-
+
+ {isOther ? OTHER_SERIES_LABEL : model}
{total.toLocaleString()}
diff --git a/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx b/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx
index 5a21f2cc45..abd31e1229 100644
--- a/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx
@@ -36,15 +36,12 @@ function CustomTooltip({ active, payload, selectedProvider, displayProviders }:
<>
{displayProviders.map((provider: string, idx: number) => {
const isOther = provider === OTHER_SERIES_KEY;
- const cost = isOther ? (data[OTHER_SERIES_KEY] ?? 0) : (data.by_provider?.[provider] || 0);
+ const cost = isOther ? (data[OTHER_SERIES_KEY] ?? 0) : data.by_provider?.[provider] || 0;
if (cost === 0) return null;
return (
-
+
{isOther ? OTHER_SERIES_LABEL : provider}
@@ -103,8 +100,7 @@ function ProviderCostChartImpl({ data, chartType, startTime, endTime, selectedPr
item[OTHER_SERIES_KEY] = otherSum;
}
providers.forEach((provider, idx) => {
- item[`provider_${idx}`] =
- provider === OTHER_SERIES_KEY ? (item[OTHER_SERIES_KEY] ?? 0) : (bucket.by_provider?.[provider] ?? 0);
+ item[`provider_${idx}`] = provider === OTHER_SERIES_KEY ? (item[OTHER_SERIES_KEY] ?? 0) : (bucket.by_provider?.[provider] ?? 0);
});
return item;
});
@@ -185,9 +181,7 @@ function ProviderCostChartImpl({ data, chartType, startTime, endTime, selectedPr
domain={[0, (dataMax: number) => Math.max(dataMax, 0.01)]}
allowDataOverflow={false}
/>
- }
- />
+ } />
{displayProviders.map((provider, idx) => {
const color = provider === OTHER_SERIES_KEY ? OTHER_SERIES_COLOR : getModelColor(idx);
return (
diff --git a/ui/app/workspace/dashboard/components/charts/providerLatencyChart.tsx b/ui/app/workspace/dashboard/components/charts/providerLatencyChart.tsx
index 219b613806..e41f9f8052 100644
--- a/ui/app/workspace/dashboard/components/charts/providerLatencyChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/providerLatencyChart.tsx
@@ -1,14 +1,7 @@
import type { ProviderLatencyHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import {
- formatFullTimestamp,
- formatLatency,
- formatTimestamp,
- getModelColor,
- LATENCY_COLORS,
- pickTopSeries,
-} from "../../utils/chartUtils";
+import { formatFullTimestamp, formatLatency, formatTimestamp, getModelColor, LATENCY_COLORS, pickTopSeries } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
@@ -206,7 +199,10 @@ function ProviderLatencyChartImpl({ data, chartType, startTime, endTime, selecte
>
) : (
<>
- } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} />
+ }
+ cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
+ />
{displayProviders.map((provider, idx) => (
{displayProviders.map((provider: string, idx: number) => {
const isOther = provider === OTHER_SERIES_KEY;
- const tokens = isOther ? (data[OTHER_SERIES_KEY] ?? 0) : (data.by_provider?.[provider]?.total_tokens || 0);
+ const tokens = isOther ? (data[OTHER_SERIES_KEY] ?? 0) : data.by_provider?.[provider]?.total_tokens || 0;
if (tokens === 0) return null;
return (
-
-
- {isOther ? OTHER_SERIES_LABEL : provider}
-
+
+ {isOther ? OTHER_SERIES_LABEL : provider}
{formatTokens(tokens)}
@@ -132,9 +127,7 @@ function ProviderTokenChartImpl({ data, chartType, startTime, endTime, selectedP
}
providers.forEach((provider, idx) => {
item[`provider_${idx}`] =
- provider === OTHER_SERIES_KEY
- ? (item[OTHER_SERIES_KEY] ?? 0)
- : (bucket.by_provider?.[provider]?.total_tokens ?? 0);
+ provider === OTHER_SERIES_KEY ? (item[OTHER_SERIES_KEY] ?? 0) : (bucket.by_provider?.[provider]?.total_tokens ?? 0);
});
}
@@ -202,7 +195,10 @@ function ProviderTokenChartImpl({ data, chartType, startTime, endTime, selectedP
>
) : (
<>
- } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} />
+ }
+ cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
+ />
{displayProviders.map((provider, idx) => (
);
}
-export const TokenUsageChart = memo(TokenUsageChartImpl);
+export const TokenUsageChart = memo(TokenUsageChartImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/mcpTab.tsx b/ui/app/workspace/dashboard/components/mcpTab.tsx
index 7c57e7fe4b..489442fdd4 100644
--- a/ui/app/workspace/dashboard/components/mcpTab.tsx
+++ b/ui/app/workspace/dashboard/components/mcpTab.tsx
@@ -106,4 +106,4 @@ function MCPTabImpl({
);
}
-export const MCPTab = memo(MCPTabImpl);
+export const MCPTab = memo(MCPTabImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
index 43ff18e407..9a6b08bc33 100644
--- a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
+++ b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
@@ -130,13 +130,8 @@ function UsageShareTooltip({ active, payload, models }: any) {
return (
-
-
+
+
{displayModelLabel(model)}
@@ -218,7 +213,7 @@ function TopModelsChart({
}, [rankingsData, displayModels]);
return (
-
+
{chartData.length > 0 ? (
@@ -445,4 +440,4 @@ function ModelRankingsTabImpl({ rankingsData, loading, modelData, loadingModels,
);
}
-export const ModelRankingsTab = memo(ModelRankingsTabImpl);
+export const ModelRankingsTab = memo(ModelRankingsTabImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/overviewTab.tsx b/ui/app/workspace/dashboard/components/overviewTab.tsx
index ba618c4614..0326d93f41 100644
--- a/ui/app/workspace/dashboard/components/overviewTab.tsx
+++ b/ui/app/workspace/dashboard/components/overviewTab.tsx
@@ -367,4 +367,4 @@ function OverviewTabImpl({
>
);
}
-export const OverviewTab = memo(OverviewTabImpl);
+export const OverviewTab = memo(OverviewTabImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/components/providerUsageTab.tsx b/ui/app/workspace/dashboard/components/providerUsageTab.tsx
index b85f34c63f..64a36f84e4 100644
--- a/ui/app/workspace/dashboard/components/providerUsageTab.tsx
+++ b/ui/app/workspace/dashboard/components/providerUsageTab.tsx
@@ -344,4 +344,4 @@ function ProviderUsageTabImpl({
);
}
-export const ProviderUsageTab = memo(ProviderUsageTabImpl);
+export const ProviderUsageTab = memo(ProviderUsageTabImpl);
\ No newline at end of file
diff --git a/ui/app/workspace/governance/layout.tsx b/ui/app/workspace/governance/layout.tsx
index 9b265425c4..3822cb9224 100644
--- a/ui/app/workspace/governance/layout.tsx
+++ b/ui/app/workspace/governance/layout.tsx
@@ -30,4 +30,4 @@ function RouteComponent() {
export const Route = createFileRoute("/workspace/governance")({
component: RouteComponent,
-});
+});
\ No newline at end of file
diff --git a/ui/app/workspace/governance/teams/page.tsx b/ui/app/workspace/governance/teams/page.tsx
index 0c526eaaf4..4eb479daa6 100644
--- a/ui/app/workspace/governance/teams/page.tsx
+++ b/ui/app/workspace/governance/teams/page.tsx
@@ -1,5 +1,5 @@
-import { TeamsView } from "@enterprise/components/user-groups/teamsView"
+import { TeamsView } from "@enterprise/components/user-groups/teamsView";
export default function GovernanceTeamsPage() {
- return
-}
+ return ;
+}
\ No newline at end of file
diff --git a/ui/app/workspace/governance/users/page.tsx b/ui/app/workspace/governance/users/page.tsx
index a456f67436..e20f1394d7 100644
--- a/ui/app/workspace/governance/users/page.tsx
+++ b/ui/app/workspace/governance/users/page.tsx
@@ -2,7 +2,7 @@ import UsersView from "@enterprise/components/user-groups/usersView";
export default function GovernanceUsersPage() {
return (
-
+
);
diff --git a/ui/app/workspace/governance/views/customerTable.tsx b/ui/app/workspace/governance/views/customerTable.tsx
index c3982f2455..a51ab10784 100644
--- a/ui/app/workspace/governance/views/customerTable.tsx
+++ b/ui/app/workspace/governance/views/customerTable.tsx
@@ -500,4 +500,4 @@ export default function CustomersTable({
>
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/governance/views/teamDialog.tsx b/ui/app/workspace/governance/views/teamDialog.tsx
index 61af12322b..2ec59cbeea 100644
--- a/ui/app/workspace/governance/views/teamDialog.tsx
+++ b/ui/app/workspace/governance/views/teamDialog.tsx
@@ -1,48 +1,24 @@
import FormFooter from "@/components/formFooter";
import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
} from "@/components/ui/alertDialog";
import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import NumberAndSelect from "@/components/ui/numberAndSelect";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
-import {
- resetDurationOptions,
- supportsCalendarAlignment,
-} from "@/lib/constants/governance";
-import {
- getErrorMessage,
- useCreateTeamMutation,
- useUpdateTeamMutation,
-} from "@/lib/store";
-import {
- CreateTeamRequest,
- Customer,
- Team,
- UpdateTeamRequest,
-} from "@/lib/types/governance";
+import { resetDurationOptions, supportsCalendarAlignment } from "@/lib/constants/governance";
+import { getErrorMessage, useCreateTeamMutation, useUpdateTeamMutation } from "@/lib/store";
+import { CreateTeamRequest, Customer, Team, UpdateTeamRequest } from "@/lib/types/governance";
import { formatCurrency } from "@/lib/utils/governance";
import { Validator } from "@/lib/utils/validation";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
@@ -53,10 +29,10 @@ import { toast } from "sonner";
import { v4 as uuid } from "uuid";
interface TeamDialogProps {
- team?: Team | null;
- customers: Customer[];
- onSave: () => void;
- onCancel: () => void;
+ team?: Team | null;
+ customers: Customer[];
+ onSave: () => void;
+ onCancel: () => void;
}
// One editable budget row; teams own multiple, each keyed by reset_duration
@@ -65,696 +41,550 @@ interface TeamDialogProps {
// used as the React key and for matching against `team.budgets` when we need
// to distinguish "already persisted" from "just added in the form".
interface TeamBudgetRow {
- id: string;
- maxLimit: number | undefined;
- resetDuration: string;
- calendarAligned: boolean;
+ id: string;
+ maxLimit: number | undefined;
+ resetDuration: string;
+ calendarAligned: boolean;
}
interface TeamFormData {
- name: string;
- customerId: string;
- // Multi-budget: each row has a unique reset_duration on submit
- budgets: TeamBudgetRow[];
- // Rate Limit
- tokenMaxLimit: number | undefined;
- tokenResetDuration: string;
- requestMaxLimit: number | undefined;
- requestResetDuration: string;
- isDirty: boolean;
+ name: string;
+ customerId: string;
+ // Multi-budget: each row has a unique reset_duration on submit
+ budgets: TeamBudgetRow[];
+ // Rate Limit
+ tokenMaxLimit: number | undefined;
+ tokenResetDuration: string;
+ requestMaxLimit: number | undefined;
+ requestResetDuration: string;
+ isDirty: boolean;
}
// Helper function to create initial state
-const createInitialState = (
- team?: Team | null,
-): Omit
=> {
- return {
- name: team?.name || "",
- customerId: team?.customer_id || "",
- budgets:
- team?.budgets?.map((b) => ({
- id: b.id,
- maxLimit: b.max_limit,
- resetDuration: b.reset_duration,
- calendarAligned: b.calendar_aligned ?? false,
- })) ?? [],
- // Rate Limit
- tokenMaxLimit: team?.rate_limit?.token_max_limit ?? undefined,
- tokenResetDuration: team?.rate_limit?.token_reset_duration || "1h",
- requestMaxLimit: team?.rate_limit?.request_max_limit ?? undefined,
- requestResetDuration: team?.rate_limit?.request_reset_duration || "1h",
- };
+const createInitialState = (team?: Team | null): Omit => {
+ return {
+ name: team?.name || "",
+ customerId: team?.customer_id || "",
+ budgets:
+ team?.budgets?.map((b) => ({
+ id: b.id,
+ maxLimit: b.max_limit,
+ resetDuration: b.reset_duration,
+ calendarAligned: b.calendar_aligned ?? false,
+ })) ?? [],
+ // Rate Limit
+ tokenMaxLimit: team?.rate_limit?.token_max_limit ?? undefined,
+ tokenResetDuration: team?.rate_limit?.token_reset_duration || "1h",
+ requestMaxLimit: team?.rate_limit?.request_max_limit ?? undefined,
+ requestResetDuration: team?.rate_limit?.request_reset_duration || "1h",
+ };
};
-export default function TeamDialog({
- team,
- customers,
- onSave,
- onCancel,
-}: TeamDialogProps) {
- const isEditing = !!team;
- const [initialState, setInitialState] = useState<
- Omit
- >(createInitialState(team));
- const [formData, setFormData] = useState({
- ...initialState,
- isDirty: false,
- });
-
- useEffect(() => {
- const nextInitial = createInitialState(team);
- setInitialState(nextInitial);
- setFormData({ ...nextInitial, isDirty: false });
- setPendingCalendarAlignIdx(null);
- }, [team]);
-
- const hasCreateAccess = useRbac(RbacResource.Teams, RbacOperation.Create);
- const hasUpdateAccess = useRbac(RbacResource.Teams, RbacOperation.Update);
- const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess;
-
- // RTK Query hooks
- const [createTeam, { isLoading: isCreating }] = useCreateTeamMutation();
- const [updateTeam, { isLoading: isUpdating }] = useUpdateTeamMutation();
- const loading = isCreating || isUpdating;
-
- // Tracks which row (by index) is awaiting calendar-align confirmation.
- const [pendingCalendarAlignIdx, setPendingCalendarAlignIdx] = useState<
- number | null
- >(null);
- const showCalendarAlignWarning = pendingCalendarAlignIdx !== null;
-
- const updateBudgetRow = (idx: number, patch: Partial) => {
- setFormData((prev) => {
- const next = prev.budgets.map((row, i) =>
- i === idx ? { ...row, ...patch } : row,
- );
- return { ...prev, budgets: next };
- });
- };
-
- const addBudgetRow = () => {
- setFormData((prev) => ({
- ...prev,
- budgets: [
- ...prev.budgets,
- {
- id: uuid(),
- maxLimit: undefined,
- resetDuration: "1M",
- calendarAligned: false,
- },
- ],
- }));
- };
-
- const removeBudgetRow = (idx: number) => {
- setFormData((prev) => ({
- ...prev,
- budgets: prev.budgets.filter((_, i) => i !== idx),
- }));
- };
-
- const handleCalendarAlignedChange = (idx: number, checked: boolean) => {
- // Match the persisted budget by stable row id — for seeded rows this equals
- // the server-side budget id; for newly-added rows it's a client-only UUID
- // that won't match anything in team.budgets (correctly: no warning for new rows).
- // Avoids the reset_duration-duplicate ambiguity before validation resolves.
- const rowId = formData.budgets[idx]?.id;
- const existingBudget = team?.budgets?.find((b) => b.id === rowId);
- if (checked && isEditing && existingBudget && !existingBudget.calendar_aligned) {
- setPendingCalendarAlignIdx(idx);
- } else {
- updateBudgetRow(idx, { calendarAligned: checked });
- }
- };
-
- // Track isDirty state
- useEffect(() => {
- const currentData: Omit = {
- name: formData.name,
- customerId: formData.customerId,
- budgets: formData.budgets,
- tokenMaxLimit: formData.tokenMaxLimit,
- tokenResetDuration: formData.tokenResetDuration,
- requestMaxLimit: formData.requestMaxLimit,
- requestResetDuration: formData.requestResetDuration,
- };
- setFormData((prev) => ({
- ...prev,
- isDirty: !isEqual(initialState, currentData),
- }));
- }, [
- formData.name,
- formData.customerId,
- formData.budgets,
- formData.tokenMaxLimit,
- formData.tokenResetDuration,
- formData.requestMaxLimit,
- formData.requestResetDuration,
- initialState,
- ]);
-
- const tokenMaxLimitNum = formData.tokenMaxLimit;
- const requestMaxLimitNum = formData.requestMaxLimit;
-
- // Validation
- const validator = useMemo(() => {
- // Per-row budget validation plus cross-row uniqueness on reset_duration.
- const budgetValidators = formData.budgets.flatMap((row, idx) => {
- if (row.maxLimit === undefined || row.maxLimit === null) return [];
- return [
- Validator.minValue(
- row.maxLimit,
- 0.01,
- `Budget #${idx + 1} max limit must be greater than $0.01`,
- ),
- Validator.required(
- row.resetDuration,
- `Budget #${idx + 1} reset duration is required`,
- ),
- ];
- });
- const populatedDurations = formData.budgets
- .filter((r) => r.maxLimit !== undefined && r.maxLimit !== null)
- .map((r) => r.resetDuration);
- const uniqueDurations = new Set(populatedDurations).size;
-
- return new Validator([
- Validator.required(formData.name.trim(), "Team name is required"),
- Validator.custom(formData.isDirty, "No changes to save"),
- ...budgetValidators,
- Validator.custom(
- uniqueDurations === populatedDurations.length,
- "Each budget must have a distinct reset duration",
- ),
-
- // Rate limit validation - token limits
- ...(formData.tokenMaxLimit !== undefined &&
- formData.tokenMaxLimit !== null
- ? [
- Validator.minValue(
- tokenMaxLimitNum || 0,
- 1,
- "Token max limit must be at least 1",
- ),
- Validator.required(
- formData.tokenResetDuration,
- "Token reset duration is required",
- ),
- ]
- : []),
-
- // Rate limit validation - request limits
- ...(formData.requestMaxLimit !== undefined &&
- formData.requestMaxLimit !== null
- ? [
- Validator.minValue(
- requestMaxLimitNum || 0,
- 1,
- "Request max limit must be at least 1",
- ),
- Validator.required(
- formData.requestResetDuration,
- "Request reset duration is required",
- ),
- ]
- : []),
- ]);
- }, [formData, tokenMaxLimitNum, requestMaxLimitNum]);
-
- const updateField = (
- field: K,
- value: TeamFormData[K],
- ) => {
- setFormData((prev) => ({ ...prev, [field]: value }));
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!validator.isValid()) {
- toast.error(validator.getFirstError());
- return;
- }
-
- // Serialize budget rows whose max_limit was filled in — rows left blank
- // are silently dropped (the backend treats the slice as authoritative).
- const submittableBudgets = formData.budgets
- .filter((r) => r.maxLimit !== undefined && r.maxLimit !== null)
- .map((r) => ({
- max_limit: r.maxLimit as number,
- reset_duration: r.resetDuration,
- calendar_aligned: r.calendarAligned,
- }));
-
- try {
- if (isEditing && team) {
- // Update existing team
- const updateData: UpdateTeamRequest = {
- name: formData.name,
- customer_id: formData.customerId || undefined,
- // Always send: backend treats `budgets` as a full replacement.
- budgets: submittableBudgets,
- };
-
- // Detect rate limit changes using had/has pattern
- const hadRateLimit = !!team.rate_limit;
- const hasRateLimit =
- (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
- (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null);
- if (hasRateLimit) {
- updateData.rate_limit = {
- token_max_limit: tokenMaxLimitNum,
- token_reset_duration:
- tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null
- ? formData.tokenResetDuration
- : undefined,
- request_max_limit: requestMaxLimitNum,
- request_reset_duration:
- requestMaxLimitNum !== undefined && requestMaxLimitNum !== null
- ? formData.requestResetDuration
- : undefined,
- };
- } else if (hadRateLimit) {
- updateData.rate_limit = {} as UpdateTeamRequest["rate_limit"];
- }
-
- await updateTeam({ teamId: team.id, data: updateData }).unwrap();
- toast.success("Team updated successfully");
- } else {
- // Create new team
- const createData: CreateTeamRequest = {
- name: formData.name,
- customer_id: formData.customerId || undefined,
- budgets:
- submittableBudgets.length > 0 ? submittableBudgets : undefined,
- };
-
- // Add rate limit if enabled (token or request limits)
- if (
- (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
- (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null)
- ) {
- createData.rate_limit = {
- token_max_limit: tokenMaxLimitNum,
- token_reset_duration:
- tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null
- ? formData.tokenResetDuration
- : undefined,
- request_max_limit: requestMaxLimitNum,
- request_reset_duration:
- requestMaxLimitNum !== undefined && requestMaxLimitNum !== null
- ? formData.requestResetDuration
- : undefined,
- };
- }
-
- await createTeam(createData).unwrap();
- toast.success("Team created successfully");
- }
-
- onSave();
- } catch (error) {
- toast.error(getErrorMessage(error));
- }
- };
-
- return (
-
-
-
-
- {isEditing ? "Edit Team" : "Create Team"}
-
-
- {isEditing
- ? "Update the team information and settings."
- : "Create a new team to organize users and manage shared resources."}
-
-
-
-
-
-
- );
-}
+export default function TeamDialog({ team, customers, onSave, onCancel }: TeamDialogProps) {
+ const isEditing = !!team;
+ const [initialState, setInitialState] = useState>(createInitialState(team));
+ const [formData, setFormData] = useState({
+ ...initialState,
+ isDirty: false,
+ });
+
+ useEffect(() => {
+ const nextInitial = createInitialState(team);
+ setInitialState(nextInitial);
+ setFormData({ ...nextInitial, isDirty: false });
+ setPendingCalendarAlignIdx(null);
+ }, [team]);
+
+ const hasCreateAccess = useRbac(RbacResource.Teams, RbacOperation.Create);
+ const hasUpdateAccess = useRbac(RbacResource.Teams, RbacOperation.Update);
+ const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess;
+
+ // RTK Query hooks
+ const [createTeam, { isLoading: isCreating }] = useCreateTeamMutation();
+ const [updateTeam, { isLoading: isUpdating }] = useUpdateTeamMutation();
+ const loading = isCreating || isUpdating;
+
+ // Tracks which row (by index) is awaiting calendar-align confirmation.
+ const [pendingCalendarAlignIdx, setPendingCalendarAlignIdx] = useState(null);
+ const showCalendarAlignWarning = pendingCalendarAlignIdx !== null;
+
+ const updateBudgetRow = (idx: number, patch: Partial) => {
+ setFormData((prev) => {
+ const next = prev.budgets.map((row, i) => (i === idx ? { ...row, ...patch } : row));
+ return { ...prev, budgets: next };
+ });
+ };
+
+ const addBudgetRow = () => {
+ setFormData((prev) => ({
+ ...prev,
+ budgets: [
+ ...prev.budgets,
+ {
+ id: uuid(),
+ maxLimit: undefined,
+ resetDuration: "1M",
+ calendarAligned: false,
+ },
+ ],
+ }));
+ };
+
+ const removeBudgetRow = (idx: number) => {
+ setFormData((prev) => ({
+ ...prev,
+ budgets: prev.budgets.filter((_, i) => i !== idx),
+ }));
+ };
+
+ const handleCalendarAlignedChange = (idx: number, checked: boolean) => {
+ // Match the persisted budget by stable row id — for seeded rows this equals
+ // the server-side budget id; for newly-added rows it's a client-only UUID
+ // that won't match anything in team.budgets (correctly: no warning for new rows).
+ // Avoids the reset_duration-duplicate ambiguity before validation resolves.
+ const rowId = formData.budgets[idx]?.id;
+ const existingBudget = team?.budgets?.find((b) => b.id === rowId);
+ if (checked && isEditing && existingBudget && !existingBudget.calendar_aligned) {
+ setPendingCalendarAlignIdx(idx);
+ } else {
+ updateBudgetRow(idx, { calendarAligned: checked });
+ }
+ };
+
+ // Track isDirty state
+ useEffect(() => {
+ const currentData: Omit = {
+ name: formData.name,
+ customerId: formData.customerId,
+ budgets: formData.budgets,
+ tokenMaxLimit: formData.tokenMaxLimit,
+ tokenResetDuration: formData.tokenResetDuration,
+ requestMaxLimit: formData.requestMaxLimit,
+ requestResetDuration: formData.requestResetDuration,
+ };
+ setFormData((prev) => ({
+ ...prev,
+ isDirty: !isEqual(initialState, currentData),
+ }));
+ }, [
+ formData.name,
+ formData.customerId,
+ formData.budgets,
+ formData.tokenMaxLimit,
+ formData.tokenResetDuration,
+ formData.requestMaxLimit,
+ formData.requestResetDuration,
+ initialState,
+ ]);
+
+ const tokenMaxLimitNum = formData.tokenMaxLimit;
+ const requestMaxLimitNum = formData.requestMaxLimit;
+
+ // Validation
+ const validator = useMemo(() => {
+ // Per-row budget validation plus cross-row uniqueness on reset_duration.
+ const budgetValidators = formData.budgets.flatMap((row, idx) => {
+ if (row.maxLimit === undefined || row.maxLimit === null) return [];
+ return [
+ Validator.minValue(row.maxLimit, 0.01, `Budget #${idx + 1} max limit must be greater than $0.01`),
+ Validator.required(row.resetDuration, `Budget #${idx + 1} reset duration is required`),
+ ];
+ });
+ const populatedDurations = formData.budgets.filter((r) => r.maxLimit !== undefined && r.maxLimit !== null).map((r) => r.resetDuration);
+ const uniqueDurations = new Set(populatedDurations).size;
+
+ return new Validator([
+ Validator.required(formData.name.trim(), "Team name is required"),
+ Validator.custom(formData.isDirty, "No changes to save"),
+ ...budgetValidators,
+ Validator.custom(uniqueDurations === populatedDurations.length, "Each budget must have a distinct reset duration"),
+
+ // Rate limit validation - token limits
+ ...(formData.tokenMaxLimit !== undefined && formData.tokenMaxLimit !== null
+ ? [
+ Validator.minValue(tokenMaxLimitNum || 0, 1, "Token max limit must be at least 1"),
+ Validator.required(formData.tokenResetDuration, "Token reset duration is required"),
+ ]
+ : []),
+
+ // Rate limit validation - request limits
+ ...(formData.requestMaxLimit !== undefined && formData.requestMaxLimit !== null
+ ? [
+ Validator.minValue(requestMaxLimitNum || 0, 1, "Request max limit must be at least 1"),
+ Validator.required(formData.requestResetDuration, "Request reset duration is required"),
+ ]
+ : []),
+ ]);
+ }, [formData, tokenMaxLimitNum, requestMaxLimitNum]);
+
+ const updateField = (field: K, value: TeamFormData[K]) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validator.isValid()) {
+ toast.error(validator.getFirstError());
+ return;
+ }
+
+ // Serialize budget rows whose max_limit was filled in — rows left blank
+ // are silently dropped (the backend treats the slice as authoritative).
+ const submittableBudgets = formData.budgets
+ .filter((r) => r.maxLimit !== undefined && r.maxLimit !== null)
+ .map((r) => ({
+ max_limit: r.maxLimit as number,
+ reset_duration: r.resetDuration,
+ calendar_aligned: r.calendarAligned,
+ }));
+
+ try {
+ if (isEditing && team) {
+ // Update existing team
+ const updateData: UpdateTeamRequest = {
+ name: formData.name,
+ customer_id: formData.customerId || undefined,
+ // Always send: backend treats `budgets` as a full replacement.
+ budgets: submittableBudgets,
+ };
+
+ // Detect rate limit changes using had/has pattern
+ const hadRateLimit = !!team.rate_limit;
+ const hasRateLimit =
+ (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
+ (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null);
+ if (hasRateLimit) {
+ updateData.rate_limit = {
+ token_max_limit: tokenMaxLimitNum,
+ token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined,
+ request_max_limit: requestMaxLimitNum,
+ request_reset_duration:
+ requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined,
+ };
+ } else if (hadRateLimit) {
+ updateData.rate_limit = {} as UpdateTeamRequest["rate_limit"];
+ }
+
+ await updateTeam({ teamId: team.id, data: updateData }).unwrap();
+ toast.success("Team updated successfully");
+ } else {
+ // Create new team
+ const createData: CreateTeamRequest = {
+ name: formData.name,
+ customer_id: formData.customerId || undefined,
+ budgets: submittableBudgets.length > 0 ? submittableBudgets : undefined,
+ };
+
+ // Add rate limit if enabled (token or request limits)
+ if (
+ (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
+ (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null)
+ ) {
+ createData.rate_limit = {
+ token_max_limit: tokenMaxLimitNum,
+ token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined,
+ request_max_limit: requestMaxLimitNum,
+ request_reset_duration:
+ requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined,
+ };
+ }
+
+ await createTeam(createData).unwrap();
+ toast.success("Team created successfully");
+ }
+
+ onSave();
+ } catch (error) {
+ toast.error(getErrorMessage(error));
+ }
+ };
+
+ return (
+
+
+
+ {isEditing ? "Edit Team" : "Create Team"}
+
+ {isEditing ? "Update the team information and settings." : "Create a new team to organize users and manage shared resources."}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/workspace/governance/views/teamsTable.tsx b/ui/app/workspace/governance/views/teamsTable.tsx
index 98c3eae6b9..506ae61b0d 100644
--- a/ui/app/workspace/governance/views/teamsTable.tsx
+++ b/ui/app/workspace/governance/views/teamsTable.tsx
@@ -11,12 +11,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdownMenu";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -102,16 +97,13 @@ function TeamActionsMenu({
Delete Team
- Are you sure you want to delete "{team.name}"? This will also unassign any virtual keys from this team. This action cannot be undone.
+ Are you sure you want to delete "{team.name}"? This will also unassign any virtual keys from this team. This action
+ cannot be undone.
Cancel
- onDelete(team.id)}
- disabled={isDeleting}
- className="bg-red-600 hover:bg-red-700"
- >
+ onDelete(team.id)} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
{isDeleting ? "Deleting..." : "Delete"}
@@ -155,9 +147,7 @@ export default function TeamsTable({
onDialogClose,
}: TeamsTableProps) {
const showTeamDialog = selectedTeamId !== null && selectedTeamId !== "";
- const editingTeam = selectedTeamId && selectedTeamId !== "new"
- ? teams.find((t) => t.id === selectedTeamId) ?? null
- : null;
+ const editingTeam = selectedTeamId && selectedTeamId !== "new" ? (teams.find((t) => t.id === selectedTeamId) ?? null) : null;
// If a team ID is in the URL but can't be resolved (deleted or filtered out),
// clear it so we don't silently open the dialog in "create" mode.
@@ -211,9 +201,7 @@ export default function TeamsTable({
return (
<>
- {showTeamDialog && (
-
- )}
+ {showTeamDialog && }
>
@@ -223,9 +211,7 @@ export default function TeamsTable({
return (
<>
- {showTeamDialog && (
-
- )}
+ {showTeamDialog && }
@@ -279,9 +265,7 @@ export default function TeamsTable({
// Budget calculations — any of the team's budgets exhausted
const teamBudgets = team.budgets ?? [];
- const isBudgetExhausted = teamBudgets.some(
- (b) => b.max_limit > 0 && b.current_usage >= b.max_limit,
- );
+ const isBudgetExhausted = teamBudgets.some((b) => b.max_limit > 0 && b.current_usage >= b.max_limit);
// Rate limit calculations
const isTokenLimitExhausted =
@@ -329,8 +313,7 @@ export default function TeamsTable({
{teamBudgets.length > 0 ? (
{teamBudgets.map((b) => {
- const budgetPercentage =
- b.max_limit > 0 ? Math.min((b.current_usage / b.max_limit) * 100, 100) : 0;
+ const budgetPercentage = b.max_limit > 0 ? Math.min((b.current_usage / b.max_limit) * 100, 100) : 0;
const isExhausted = b.max_limit > 0 && b.current_usage >= b.max_limit;
return (
@@ -338,9 +321,7 @@ export default function TeamsTable({
{formatCurrency(b.max_limit)}
-
- {formatResetDuration(b.reset_duration)}
-
+ {formatResetDuration(b.reset_duration)}
{formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)}
-
- Resets {formatResetDuration(b.reset_duration)}
-
+ Resets {formatResetDuration(b.reset_duration)}
);
@@ -463,7 +442,9 @@ export default function TeamsTable({
-
)}
-
+
>
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/governance/virtual-keys/page.tsx b/ui/app/workspace/governance/virtual-keys/page.tsx
index e057e00263..af0e611c3e 100644
--- a/ui/app/workspace/governance/virtual-keys/page.tsx
+++ b/ui/app/workspace/governance/virtual-keys/page.tsx
@@ -1,12 +1,7 @@
import VirtualKeysTable from "@/app/workspace/virtual-keys/views/virtualKeysTable";
import FullPageLoader from "@/components/fullPageLoader";
import { useDebouncedValue } from "@/hooks/useDebounce";
-import {
- getErrorMessage,
- useGetCustomersQuery,
- useGetTeamsQuery,
- useGetVirtualKeysQuery,
-} from "@/lib/store";
+import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useEffect, useRef } from "react";
@@ -16,136 +11,135 @@ const POLLING_INTERVAL = 5000;
const PAGE_SIZE = 25;
export default function GovernanceVirtualKeysPage() {
- const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
- const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
- const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
- const shownErrorsRef = useRef(new Set());
-
- const [urlState, setUrlState] = useQueryStates(
- {
- search: parseAsString.withDefault(""),
- customer_id: parseAsString.withDefault(""),
- team_id: parseAsString.withDefault(""),
- offset: parseAsInteger.withDefault(0),
- sort_by: parseAsString.withDefault(""),
- order: parseAsString.withDefault(""),
- },
- { history: "push" },
- );
-
- const debouncedSearch = useDebouncedValue(urlState.search, 300);
-
- const {
- data: virtualKeysData,
- error: vkError,
- isLoading: vkLoading,
- } = useGetVirtualKeysQuery(
- {
- limit: PAGE_SIZE,
- offset: urlState.offset,
- search: debouncedSearch || undefined,
- customer_id: urlState.customer_id || undefined,
- team_id: urlState.team_id || undefined,
- sort_by: (urlState.sort_by as "name" | "budget_spent" | "created_at" | "status") || undefined,
- order: (urlState.order as "asc" | "desc") || undefined,
- },
- {
- skip: !hasVirtualKeysAccess,
- pollingInterval: POLLING_INTERVAL,
- },
- );
-
- const {
- data: teamsData,
- error: teamsError,
- isLoading: teamsLoading,
- } = useGetTeamsQuery(undefined, {
- skip: !hasTeamsAccess,
- pollingInterval: POLLING_INTERVAL,
- });
-
- const {
- data: customersData,
- error: customersError,
- isLoading: customersLoading,
- } = useGetCustomersQuery(undefined, {
- skip: !hasCustomersAccess,
- pollingInterval: POLLING_INTERVAL,
- });
-
- const vkTotal = virtualKeysData?.total_count ?? 0;
-
- // Snap offset back when total shrinks past current page (e.g. delete last item on last page)
- useEffect(() => {
- if (!virtualKeysData || urlState.offset < vkTotal) return;
- setUrlState({ offset: vkTotal === 0 ? 0 : Math.floor((vkTotal - 1) / PAGE_SIZE) * PAGE_SIZE });
- }, [vkTotal, urlState.offset]);
-
- const isLoading = vkLoading || teamsLoading || customersLoading;
-
- useEffect(() => {
- if (!vkError && !teamsError && !customersError) {
- shownErrorsRef.current.clear();
- return;
- }
- const errorKey = `${!!vkError}-${!!teamsError}-${!!customersError}`;
- if (shownErrorsRef.current.has(errorKey)) return;
- shownErrorsRef.current.add(errorKey);
- if (vkError && teamsError && customersError) {
- toast.error("Failed to load governance data.");
- } else {
- if (vkError) toast.error(`Failed to load virtual keys: ${getErrorMessage(vkError)}`);
- if (teamsError) toast.error(`Failed to load teams: ${getErrorMessage(teamsError)}`);
- if (customersError)
- toast.error(`Failed to load customers: ${getErrorMessage(customersError)}`);
- }
- }, [vkError, teamsError, customersError]);
-
- if (isLoading) {
- return ;
- }
-
- const handleSearchChange = (value: string) => {
- setUrlState({ search: value || null, offset: 0 });
- };
-
- const handleCustomerFilterChange = (value: string) => {
- setUrlState({ customer_id: value || null, offset: 0 });
- };
-
- const handleTeamFilterChange = (value: string) => {
- setUrlState({ team_id: value || null, offset: 0 });
- };
-
- const handleOffsetChange = (newOffset: number) => {
- setUrlState({ offset: newOffset });
- };
-
- const handleSortChange = (newSortBy: string, newOrder: string) => {
- setUrlState({ sort_by: newSortBy || null, order: newOrder || null, offset: 0 });
- };
-
- return (
-
-
-
- );
+ const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
+ const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
+ const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
+ const shownErrorsRef = useRef(new Set());
+
+ const [urlState, setUrlState] = useQueryStates(
+ {
+ search: parseAsString.withDefault(""),
+ customer_id: parseAsString.withDefault(""),
+ team_id: parseAsString.withDefault(""),
+ offset: parseAsInteger.withDefault(0),
+ sort_by: parseAsString.withDefault(""),
+ order: parseAsString.withDefault(""),
+ },
+ { history: "push" },
+ );
+
+ const debouncedSearch = useDebouncedValue(urlState.search, 300);
+
+ const {
+ data: virtualKeysData,
+ error: vkError,
+ isLoading: vkLoading,
+ } = useGetVirtualKeysQuery(
+ {
+ limit: PAGE_SIZE,
+ offset: urlState.offset,
+ search: debouncedSearch || undefined,
+ customer_id: urlState.customer_id || undefined,
+ team_id: urlState.team_id || undefined,
+ sort_by: (urlState.sort_by as "name" | "budget_spent" | "created_at" | "status") || undefined,
+ order: (urlState.order as "asc" | "desc") || undefined,
+ },
+ {
+ skip: !hasVirtualKeysAccess,
+ pollingInterval: POLLING_INTERVAL,
+ },
+ );
+
+ const {
+ data: teamsData,
+ error: teamsError,
+ isLoading: teamsLoading,
+ } = useGetTeamsQuery(undefined, {
+ skip: !hasTeamsAccess,
+ pollingInterval: POLLING_INTERVAL,
+ });
+
+ const {
+ data: customersData,
+ error: customersError,
+ isLoading: customersLoading,
+ } = useGetCustomersQuery(undefined, {
+ skip: !hasCustomersAccess,
+ pollingInterval: POLLING_INTERVAL,
+ });
+
+ const vkTotal = virtualKeysData?.total_count ?? 0;
+
+ // Snap offset back when total shrinks past current page (e.g. delete last item on last page)
+ useEffect(() => {
+ if (!virtualKeysData || urlState.offset < vkTotal) return;
+ setUrlState({ offset: vkTotal === 0 ? 0 : Math.floor((vkTotal - 1) / PAGE_SIZE) * PAGE_SIZE });
+ }, [vkTotal, urlState.offset]);
+
+ const isLoading = vkLoading || teamsLoading || customersLoading;
+
+ useEffect(() => {
+ if (!vkError && !teamsError && !customersError) {
+ shownErrorsRef.current.clear();
+ return;
+ }
+ const errorKey = `${!!vkError}-${!!teamsError}-${!!customersError}`;
+ if (shownErrorsRef.current.has(errorKey)) return;
+ shownErrorsRef.current.add(errorKey);
+ if (vkError && teamsError && customersError) {
+ toast.error("Failed to load governance data.");
+ } else {
+ if (vkError) toast.error(`Failed to load virtual keys: ${getErrorMessage(vkError)}`);
+ if (teamsError) toast.error(`Failed to load teams: ${getErrorMessage(teamsError)}`);
+ if (customersError) toast.error(`Failed to load customers: ${getErrorMessage(customersError)}`);
+ }
+ }, [vkError, teamsError, customersError]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ const handleSearchChange = (value: string) => {
+ setUrlState({ search: value || null, offset: 0 });
+ };
+
+ const handleCustomerFilterChange = (value: string) => {
+ setUrlState({ customer_id: value || null, offset: 0 });
+ };
+
+ const handleTeamFilterChange = (value: string) => {
+ setUrlState({ team_id: value || null, offset: 0 });
+ };
+
+ const handleOffsetChange = (newOffset: number) => {
+ setUrlState({ offset: newOffset });
+ };
+
+ const handleSortChange = (newSortBy: string, newOrder: string) => {
+ setUrlState({ sort_by: newSortBy || null, order: newOrder || null, offset: 0 });
+ };
+
+ return (
+
+
+
+ );
}
\ No newline at end of file
diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx
index 46363a4636..f9c5831d5a 100644
--- a/ui/app/workspace/logs/page.tsx
+++ b/ui/app/workspace/logs/page.tsx
@@ -133,18 +133,20 @@ export default function LogsPage() {
cache_hit_types: urlState.cache_hit_types,
metadata_filters: urlState.metadata_filters
? (() => {
- try {
- return JSON.parse(urlState.metadata_filters);
- } catch {
- return undefined;
- }
- })()
+ try {
+ return JSON.parse(urlState.metadata_filters);
+ } catch {
+ return undefined;
+ }
+ })()
: undefined,
// Use a period if present
- ...(urlState.period ? { period: urlState.period } : {
- start_time: dateUtils.toISOString(urlState.start_time),
- end_time: dateUtils.toISOString(urlState.end_time),
- })
+ ...(urlState.period
+ ? { period: urlState.period }
+ : {
+ start_time: dateUtils.toISOString(urlState.start_time),
+ end_time: dateUtils.toISOString(urlState.end_time),
+ }),
}),
// Only re-derive filters when filter-related URL params change (not pagination)
[
@@ -246,7 +248,7 @@ export default function LogsPage() {
start_time: startTime,
end_time: endTime,
offset: 0,
- polling: false
+ polling: false,
});
},
[setUrlState],
@@ -310,7 +312,7 @@ export default function LogsPage() {
refetch: refetchHistogram,
} = useGetLogsHistogramQuery(
{
- filters
+ filters,
},
{
pollingInterval: polling ? 10000 : 0,
@@ -379,7 +381,7 @@ export default function LogsPage() {
setUrlState({
period: p,
offset: 0,
- polling: true
+ polling: true,
});
} else if (from && to) {
setUrlState({
@@ -387,7 +389,7 @@ export default function LogsPage() {
end_time: Math.floor(to.getTime() / 1000),
offset: 0,
polling: false,
- period: ""
+ period: "",
});
}
},
@@ -734,4 +736,4 @@ export default function LogsPage() {
)}
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx
index 67c83a8430..ec581d7810 100644
--- a/ui/app/workspace/logs/sheets/logDetailView.tsx
+++ b/ui/app/workspace/logs/sheets/logDetailView.tsx
@@ -1,67 +1,39 @@
+import { formatCost, formatLatency, formatTokens } from "@/app/workspace/dashboard/utils/chartUtils";
import {
- formatCost,
- formatLatency,
- formatTokens,
-} from "@/app/workspace/dashboard/utils/chartUtils";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
} from "@/components/ui/alertDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CodeEditor } from "@/components/ui/codeEditor";
import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
} from "@/components/ui/dropdownMenu";
import { DottedSeparator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
-import {
- ProviderIconType,
- RenderProviderIcon,
- RoutingEngineUsedIcons,
-} from "@/lib/constants/icons";
-import {
- RequestTypeColors,
- RequestTypeLabels,
- RoutingEngineUsedColors,
- RoutingEngineUsedLabels,
- Status
-} from "@/lib/constants/logs";
+import { ProviderIconType, RenderProviderIcon, RoutingEngineUsedIcons } from "@/lib/constants/icons";
+import { RequestTypeColors, RequestTypeLabels, RoutingEngineUsedColors, RoutingEngineUsedLabels, Status } from "@/lib/constants/logs";
import { ContentBlock, LogEntry, ResponsesMessage } from "@/lib/types/logs";
import { cn } from "@/lib/utils";
import { downloadAsJson } from "@/lib/utils/browser-download";
import { isJson } from "@/lib/utils/validation";
import { Link } from "@tanstack/react-router";
import { addMilliseconds, format } from "date-fns";
-import {
- AlertCircle,
- ChevronDown,
- Clipboard,
- Copy,
- Download,
- Loader2,
- MoreVertical,
- Trash2,
- Wrench,
-} from "lucide-react";
+import { AlertCircle, ChevronDown, Clipboard, Copy, Download, Loader2, MoreVertical, Trash2, Wrench } from "lucide-react";
import { useState, type ReactNode } from "react";
import { toast } from "sonner";
import BlockHeader from "../views/blockHeader";
@@ -76,2758 +48,2079 @@ import TranscriptionView from "../views/transcriptionView";
import VideoView from "../views/videoView";
const extractResponsesText = (msg: ResponsesMessage): string => {
- if (msg.type === "reasoning") {
- const summaryText = (msg.summary ?? [])
- .map((s) => s.text)
- .filter(Boolean)
- .join("\n")
- .trim();
- if (summaryText) return summaryText;
- if (msg.encrypted_content) return msg.encrypted_content;
- }
- if (typeof msg.content === "string") return msg.content;
- if (Array.isArray(msg.content)) {
- return msg.content
- .filter(
- (b: any) =>
- b &&
- b.text &&
- (b.type === "input_text" ||
- b.type === "output_text" ||
- b.type === "reasoning_text" ||
- b.type === "refusal"),
- )
- .map((b: any) => b.text as string)
- .join("\n");
- }
- if (typeof (msg as any).arguments === "string")
- return (msg as any).arguments as string;
- return "";
+ if (msg.type === "reasoning") {
+ const summaryText = (msg.summary ?? [])
+ .map((s) => s.text)
+ .filter(Boolean)
+ .join("\n")
+ .trim();
+ if (summaryText) return summaryText;
+ if (msg.encrypted_content) return msg.encrypted_content;
+ }
+ if (typeof msg.content === "string") return msg.content;
+ if (Array.isArray(msg.content)) {
+ return msg.content
+ .filter(
+ (b: any) =>
+ b && b.text && (b.type === "input_text" || b.type === "output_text" || b.type === "reasoning_text" || b.type === "refusal"),
+ )
+ .map((b: any) => b.text as string)
+ .join("\n");
+ }
+ if (typeof (msg as any).arguments === "string") return (msg as any).arguments as string;
+ return "";
};
type ReasoningParts = {
- summaries: string[];
- encrypted?: string;
- signatures: string[];
- contentText?: string;
+ summaries: string[];
+ encrypted?: string;
+ signatures: string[];
+ contentText?: string;
};
const collectReasoningFromBlocks = (blocks: any[]): { text: string; signatures: string[] } => {
- const texts: string[] = [];
- const signatures: string[] = [];
- for (const b of blocks) {
- if (!b || typeof b !== "object") continue;
- const isReasoningish =
- b.type === "input_text" ||
- b.type === "output_text" ||
- b.type === "reasoning_text" ||
- b.type === "refusal" ||
- !b.type;
- if (isReasoningish && typeof b.text === "string" && b.text.trim()) {
- texts.push(b.text);
- }
- if (typeof b.signature === "string" && b.signature.trim()) {
- signatures.push(b.signature.trim());
- }
- }
- return { text: texts.join("\n"), signatures };
+ const texts: string[] = [];
+ const signatures: string[] = [];
+ for (const b of blocks) {
+ if (!b || typeof b !== "object") continue;
+ const isReasoningish =
+ b.type === "input_text" || b.type === "output_text" || b.type === "reasoning_text" || b.type === "refusal" || !b.type;
+ if (isReasoningish && typeof b.text === "string" && b.text.trim()) {
+ texts.push(b.text);
+ }
+ if (typeof b.signature === "string" && b.signature.trim()) {
+ signatures.push(b.signature.trim());
+ }
+ }
+ return { text: texts.join("\n"), signatures };
};
const extractReasoningParts = (msg: ResponsesMessage): ReasoningParts => {
- const summaries = (msg.summary ?? [])
- .map((s) => (s?.text ?? "").trim())
- .filter(Boolean);
- const encryptedRaw = (msg as any).encrypted_content?.trim?.();
- const encrypted = encryptedRaw ? encryptedRaw : undefined;
- const signatures: string[] = [];
- let contentText = "";
- if (typeof msg.content === "string") {
- contentText = msg.content;
- } else if (Array.isArray(msg.content)) {
- const fromContent = collectReasoningFromBlocks(msg.content as any[]);
- contentText = fromContent.text;
- signatures.push(...fromContent.signatures);
- }
- // Some providers stash reasoning under `output` instead of `content`
- const out = (msg as any).output;
- if (out !== undefined) {
- if (typeof out === "string" && out.trim() && !contentText) {
- contentText = out;
- } else if (Array.isArray(out)) {
- const fromOutput = collectReasoningFromBlocks(out as any[]);
- if (!contentText && fromOutput.text) contentText = fromOutput.text;
- signatures.push(...fromOutput.signatures);
- }
- }
- // Defensive: top-level text-bearing fields some variants use
- if (!contentText) {
- const topText =
- (typeof (msg as any).text === "string" && (msg as any).text) ||
- (typeof (msg as any).thinking === "string" && (msg as any).thinking) ||
- "";
- if (topText.trim()) contentText = topText;
- }
- return {
- summaries,
- encrypted,
- signatures,
- contentText: contentText || undefined,
- };
+ const summaries = (msg.summary ?? []).map((s) => (s?.text ?? "").trim()).filter(Boolean);
+ const encryptedRaw = (msg as any).encrypted_content?.trim?.();
+ const encrypted = encryptedRaw ? encryptedRaw : undefined;
+ const signatures: string[] = [];
+ let contentText = "";
+ if (typeof msg.content === "string") {
+ contentText = msg.content;
+ } else if (Array.isArray(msg.content)) {
+ const fromContent = collectReasoningFromBlocks(msg.content as any[]);
+ contentText = fromContent.text;
+ signatures.push(...fromContent.signatures);
+ }
+ // Some providers stash reasoning under `output` instead of `content`
+ const out = (msg as any).output;
+ if (out !== undefined) {
+ if (typeof out === "string" && out.trim() && !contentText) {
+ contentText = out;
+ } else if (Array.isArray(out)) {
+ const fromOutput = collectReasoningFromBlocks(out as any[]);
+ if (!contentText && fromOutput.text) contentText = fromOutput.text;
+ signatures.push(...fromOutput.signatures);
+ }
+ }
+ // Defensive: top-level text-bearing fields some variants use
+ if (!contentText) {
+ const topText =
+ (typeof (msg as any).text === "string" && (msg as any).text) ||
+ (typeof (msg as any).thinking === "string" && (msg as any).thinking) ||
+ "";
+ if (topText.trim()) contentText = topText;
+ }
+ return {
+ summaries,
+ encrypted,
+ signatures,
+ contentText: contentText || undefined,
+ };
};
const extractChatReasoning = (message: any): string => {
- if (!message) return "";
- if (typeof message.reasoning === "string" && message.reasoning.trim()) {
- return message.reasoning;
- }
- if (Array.isArray(message.reasoning_details)) {
- const parts = (message.reasoning_details as any[])
- .map((d) => (typeof d?.text === "string" ? d.text : d?.summary ?? ""))
- .map((t: string) => (typeof t === "string" ? t.trim() : ""))
- .filter(Boolean);
- if (parts.length > 0) return parts.join("\n");
- }
- return "";
+ if (!message) return "";
+ if (typeof message.reasoning === "string" && message.reasoning.trim()) {
+ return message.reasoning;
+ }
+ if (Array.isArray(message.reasoning_details)) {
+ const parts = (message.reasoning_details as any[])
+ .map((d) => (typeof d?.text === "string" ? d.text : (d?.summary ?? "")))
+ .map((t: string) => (typeof t === "string" ? t.trim() : ""))
+ .filter(Boolean);
+ if (parts.length > 0) return parts.join("\n");
+ }
+ return "";
};
const getResponsesRole = (msg: ResponsesMessage): MessageRole => {
- if (msg.type === "reasoning") return "reasoning";
- if (
- msg.type &&
- (msg.type.endsWith("_call") ||
- msg.type.endsWith("_call_output") ||
- msg.type === "mcp_list_tools" ||
- msg.type === "mcp_approval_request" ||
- msg.type === "mcp_approval_responses")
- ) {
- return "tool";
- }
- const r = msg.role;
- if (r === "user") return "user";
- if (r === "assistant") return "assistant";
- if (r === "system" || r === "developer") return "system";
- return "assistant";
+ if (msg.type === "reasoning") return "reasoning";
+ if (
+ msg.type &&
+ (msg.type.endsWith("_call") ||
+ msg.type.endsWith("_call_output") ||
+ msg.type === "mcp_list_tools" ||
+ msg.type === "mcp_approval_request" ||
+ msg.type === "mcp_approval_responses")
+ ) {
+ return "tool";
+ }
+ const r = msg.role;
+ if (r === "user") return "user";
+ if (r === "assistant") return "assistant";
+ if (r === "system" || r === "developer") return "system";
+ return "assistant";
};
const isPlainAssistantResponsesMessage = (m: ResponsesMessage): boolean => {
- if (m.type && m.type !== "message") return false;
- return getResponsesRole(m) === "assistant";
+ if (m.type && m.type !== "message") return false;
+ return getResponsesRole(m) === "assistant";
};
-const isReasoningResponsesMessage = (m: ResponsesMessage): boolean =>
- m.type === "reasoning";
+const isReasoningResponsesMessage = (m: ResponsesMessage): boolean => m.type === "reasoning";
// Streaming providers can emit a single logical assistant turn (or reasoning
// item) as many small messages. Collapse adjacent ones so the UI shows one
// bubble per turn instead of N "1 line" bubbles.
-const coalesceResponsesMessages = (
- msgs: ResponsesMessage[],
-): ResponsesMessage[] => {
- const out: ResponsesMessage[] = [];
- for (const m of msgs) {
- const last = out[out.length - 1];
- if (
- last &&
- isPlainAssistantResponsesMessage(last) &&
- isPlainAssistantResponsesMessage(m)
- ) {
- const merged = extractResponsesText(last) + extractResponsesText(m);
- out[out.length - 1] = {
- ...last,
- content: [{ type: "output_text", text: merged } as any],
- };
- continue;
- }
- if (
- last &&
- isReasoningResponsesMessage(last) &&
- isReasoningResponsesMessage(m)
- ) {
- const aSum = last.summary ?? [];
- const bSum = m.summary ?? [];
- const aEnc = (last as any).encrypted_content ?? "";
- const bEnc = (m as any).encrypted_content ?? "";
- const joinedEnc = `${aEnc}${bEnc}`;
- out[out.length - 1] = {
- ...last,
- summary: [...aSum, ...bSum],
- encrypted_content: joinedEnc ? joinedEnc : undefined,
- } as ResponsesMessage;
- continue;
- }
- out.push(m);
- }
- return out.filter((m) => {
- if (!isPlainAssistantResponsesMessage(m)) return true;
- return extractResponsesText(m).length > 0;
- });
+const coalesceResponsesMessages = (msgs: ResponsesMessage[]): ResponsesMessage[] => {
+ const out: ResponsesMessage[] = [];
+ for (const m of msgs) {
+ const last = out[out.length - 1];
+ if (last && isPlainAssistantResponsesMessage(last) && isPlainAssistantResponsesMessage(m)) {
+ const merged = extractResponsesText(last) + extractResponsesText(m);
+ out[out.length - 1] = {
+ ...last,
+ content: [{ type: "output_text", text: merged } as any],
+ };
+ continue;
+ }
+ if (last && isReasoningResponsesMessage(last) && isReasoningResponsesMessage(m)) {
+ const aSum = last.summary ?? [];
+ const bSum = m.summary ?? [];
+ const aEnc = (last as any).encrypted_content ?? "";
+ const bEnc = (m as any).encrypted_content ?? "";
+ const joinedEnc = `${aEnc}${bEnc}`;
+ out[out.length - 1] = {
+ ...last,
+ summary: [...aSum, ...bSum],
+ encrypted_content: joinedEnc ? joinedEnc : undefined,
+ } as ResponsesMessage;
+ continue;
+ }
+ out.push(m);
+ }
+ return out.filter((m) => {
+ if (!isPlainAssistantResponsesMessage(m)) return true;
+ return extractResponsesText(m).length > 0;
+ });
};
const extractMessageText = (message: any): string => {
- if (!message || message.content == null) return "";
- if (typeof message.content === "string") return message.content;
- if (Array.isArray(message.content)) {
- return message.content
- .filter(
- (block: any) =>
- block &&
- (block.type === "text" ||
- block.type === "input_text" ||
- block.type === "output_text") &&
- block.text,
- )
- .map((block: any) => block.text)
- .join("\n");
- }
- return "";
+ if (!message || message.content == null) return "";
+ if (typeof message.content === "string") return message.content;
+ if (Array.isArray(message.content)) {
+ return message.content
+ .filter((block: any) => block && (block.type === "text" || block.type === "input_text" || block.type === "output_text") && block.text)
+ .map((block: any) => block.text)
+ .join("\n");
+ }
+ return "";
};
const formatJsonSafe = (str: string | undefined): string => {
- try {
- return JSON.stringify(JSON.parse(str || ""), null, 2);
- } catch {
- return str || "";
- }
+ try {
+ return JSON.stringify(JSON.parse(str || ""), null, 2);
+ } catch {
+ return str || "";
+ }
};
const formatToolChoice = (value: unknown): string => {
- if (typeof value === "string") return value;
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
+ if (typeof value === "string") return value;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
};
// Helper to detect passthrough operations
-const isPassthroughOperation = (object: string) =>
- object === "passthrough" || object === "passthrough_stream";
+const isPassthroughOperation = (object: string) => object === "passthrough" || object === "passthrough_stream";
// Helper to detect container operations (for hiding irrelevant fields like Model/Tokens)
const isContainerOperation = (object: string) => {
- const containerTypes = [
- "container_create",
- "container_list",
- "container_retrieve",
- "container_delete",
- "container_file_create",
- "container_file_list",
- "container_file_retrieve",
- "container_file_content",
- "container_file_delete",
- ];
- return containerTypes.includes(object?.toLowerCase());
+ const containerTypes = [
+ "container_create",
+ "container_list",
+ "container_retrieve",
+ "container_delete",
+ "container_file_create",
+ "container_file_list",
+ "container_file_retrieve",
+ "container_file_content",
+ "container_file_delete",
+ ];
+ return containerTypes.includes(object?.toLowerCase());
};
const statusPillStyles: Record = {
- success:
- "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900",
- error:
- "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900",
- processing:
- "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900",
- cancelled:
- "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-800",
+ success: "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900",
+ error: "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900",
+ processing: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900",
+ cancelled: "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-800",
};
const statusDotStyles: Record = {
- success: "bg-green-500",
- error: "bg-red-500",
- processing: "bg-blue-500",
- cancelled: "bg-gray-400",
+ success: "bg-green-500",
+ error: "bg-red-500",
+ processing: "bg-blue-500",
+ cancelled: "bg-gray-400",
};
function StatusPill({ status }: { status: Status }) {
- return (
-
-
- {status}
-
- );
+ return (
+
+
+ {status}
+
+ );
}
function HeroStat({
- label,
- value,
- sub,
- mono = false,
- valueClass,
- hasRightBorder = false,
+ label,
+ value,
+ sub,
+ mono = false,
+ valueClass,
+ hasRightBorder = false,
}: {
- label: string;
- value: ReactNode;
- sub?: ReactNode;
- mono?: boolean;
- valueClass?: string;
- hasRightBorder?: boolean;
+ label: string;
+ value: ReactNode;
+ sub?: ReactNode;
+ mono?: boolean;
+ valueClass?: string;
+ hasRightBorder?: boolean;
}) {
- return (
-
-
- {label}
-
-
- {value}
-
- {sub ? (
-
- {sub}
-
- ) : null}
-
- );
+ return (
+
+
{label}
+
+ {value}
+
+ {sub ?
{sub}
: null}
+
+ );
}
function CopyInlineButton({ text, testId }: { text: string; testId?: string }) {
- const { copy } = useCopyToClipboard({ successMessage: "Copied" });
- return (
- {
- e.stopPropagation();
- copy(text);
- }}
- className="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-6 w-6 items-center justify-center rounded-sm transition"
- aria-label="Copy"
- data-testid={testId}
- >
-
-
- );
+ const { copy } = useCopyToClipboard({ successMessage: "Copied" });
+ return (
+ {
+ e.stopPropagation();
+ copy(text);
+ }}
+ className="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-6 w-6 items-center justify-center rounded-sm transition"
+ aria-label="Copy"
+ data-testid={testId}
+ >
+
+
+ );
}
type MessageRole = "system" | "user" | "assistant" | "reasoning" | "tool";
const messageToneClass: Record = {
- system: "bg-zinc-50 border-zinc-200 dark:bg-zinc-900/40 dark:border-zinc-800",
- user: "bg-blue-50/60 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900",
- assistant: "bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800",
- reasoning:
- "bg-violet-50/70 border-violet-200 dark:bg-violet-950/30 dark:border-violet-900",
- tool: "bg-amber-50/70 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900",
+ system: "bg-zinc-50 border-zinc-200 dark:bg-zinc-900/40 dark:border-zinc-800",
+ user: "bg-blue-50/60 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900",
+ assistant: "bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800",
+ reasoning: "bg-violet-50/70 border-violet-200 dark:bg-violet-950/30 dark:border-violet-900",
+ tool: "bg-amber-50/70 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900",
};
const messageDotClass: Record = {
- system: "bg-zinc-400",
- user: "bg-blue-500",
- assistant: "bg-zinc-900 dark:bg-zinc-100",
- reasoning: "bg-violet-500",
- tool: "bg-amber-500",
+ system: "bg-zinc-400",
+ user: "bg-blue-500",
+ assistant: "bg-zinc-900 dark:bg-zinc-100",
+ reasoning: "bg-violet-500",
+ tool: "bg-amber-500",
};
const messageRoleLabel: Record = {
- system: "System",
- user: "User",
- assistant: "Assistant",
- reasoning: "Reasoning",
- tool: "Tool Result",
+ system: "System",
+ user: "User",
+ assistant: "Assistant",
+ reasoning: "Reasoning",
+ tool: "Tool Result",
};
function RoutingDecisionLogs({ logs }: { logs: string }) {
- const { copy } = useCopyToClipboard({ successMessage: "Copied" });
- return (
-
-
-
Routing Decision Logs
-
copy(logs)}
- className="text-muted-foreground mx-2 flex h-6 items-center rounded px-1 py-1 hover:text-black dark:hover:text-white"
- >
-
-
-
-
- {logs
- .split("\n")
- .filter((l) => l.trim())
- .map((line, i) => {
- const m = line.match(/^\[(\d+)\]\s+\[([^\]]+)\]\s+-\s+(.*)$/);
- const ts = m ? Number(m[1]) : null;
- const scope = m ? m[2] : null;
- const message = m ? m[3] : line;
- return (
-
- {ts != null ? (
-
- {format(new Date(ts), "HH:mm:ss.SSS")}
-
- ) : null}
- {scope ? (
-
- {RoutingEngineUsedLabels[
- scope as keyof typeof RoutingEngineUsedLabels
- ] ?? scope}
-
- ) : null}
- {message}
-
- );
- })}
-
-
- );
+ const { copy } = useCopyToClipboard({ successMessage: "Copied" });
+ return (
+
+
+
Routing Decision Logs
+
copy(logs)}
+ className="text-muted-foreground mx-2 flex h-6 items-center rounded px-1 py-1 hover:text-black dark:hover:text-white"
+ >
+
+
+
+
+ {logs
+ .split("\n")
+ .filter((l) => l.trim())
+ .map((line, i) => {
+ const m = line.match(/^\[(\d+)\]\s+\[([^\]]+)\]\s+-\s+(.*)$/);
+ const ts = m ? Number(m[1]) : null;
+ const scope = m ? m[2] : null;
+ const message = m ? m[3] : line;
+ return (
+
+ {ts != null ? {format(new Date(ts), "HH:mm:ss.SSS")} : null}
+ {scope ? (
+
+ {RoutingEngineUsedLabels[scope as keyof typeof RoutingEngineUsedLabels] ?? scope}
+
+ ) : null}
+ {message}
+
+ );
+ })}
+
+
+ );
}
-function EncryptedReveal({
- text,
- label,
-}: {
- text: string;
- label: string;
-}) {
- const [open, setOpen] = useState(false);
- return (
-
-
setOpen((o) => !o)}
- className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-[10.5px] font-semibold tracking-wider uppercase"
- >
-
- {label}
- {!open ? (
-
- {text.length} chars
-
- ) : null}
-
- {open ? (
-
- {text}
-
- ) : null}
-
- );
+function EncryptedReveal({ text, label }: { text: string; label: string }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
setOpen((o) => !o)}
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-[10.5px] font-semibold tracking-wider uppercase"
+ >
+
+ {label}
+ {!open ? (
+ {text.length} chars
+ ) : null}
+
+ {open ?
{text} : null}
+
+ );
}
-function CollapsibleCode({
- text,
- preview = 3,
- lang,
- mono = true,
-}: {
- text: string;
- preview?: number;
- lang?: string;
- mono?: boolean;
-}) {
- const [open, setOpen] = useState(false);
- const lines = text.split("\n");
- const shown = open ? lines : lines.slice(0, preview);
- const hasMore = lines.length > preview;
- const moreCount = lines.length - preview;
- return (
- <>
- {mono ? (
-
- {shown.join("\n")}
-
- ) : (
-
- {shown.join("\n")}
-
- )}
- {hasMore && (
-
- setOpen((o) => !o)}
- className="text-primary inline-flex items-center gap-1 text-[11.5px] font-medium hover:underline"
- >
- {open ? "Show less" : `Show ${moreCount} more lines`}
-
-
-
- {lines.length} lines{lang ? ` · ${lang}` : ""}
-
-
- )}
- >
- );
+function CollapsibleCode({ text, preview = 3, lang, mono = true }: { text: string; preview?: number; lang?: string; mono?: boolean }) {
+ const [open, setOpen] = useState(false);
+ const lines = text.split("\n");
+ const shown = open ? lines : lines.slice(0, preview);
+ const hasMore = lines.length > preview;
+ const moreCount = lines.length - preview;
+ return (
+ <>
+ {mono ? (
+ {shown.join("\n")}
+ ) : (
+ {shown.join("\n")}
+ )}
+ {hasMore && (
+
+ setOpen((o) => !o)}
+ className="text-primary inline-flex items-center gap-1 text-[11.5px] font-medium hover:underline"
+ >
+ {open ? "Show less" : `Show ${moreCount} more lines`}
+
+
+
+ {lines.length} lines{lang ? ` · ${lang}` : ""}
+
+
+ )}
+ >
+ );
}
-function MessageRow({
- role,
- meta,
- children,
- last = false,
-}: {
- role: MessageRole;
- meta?: string;
- children: ReactNode;
- last?: boolean;
-}) {
- return (
-
-
-
-
-
- {messageRoleLabel[role]}
-
- {meta ? (
- {meta}
- ) : null}
-
-
- {children}
-
-
-
- );
+function MessageRow({ role, meta, children, last = false }: { role: MessageRole; meta?: string; children: ReactNode; last?: boolean }) {
+ return (
+
+
+
+
+ {messageRoleLabel[role]}
+ {meta ? {meta} : null}
+
+
{children}
+
+
+ );
}
interface LogDetailViewProps {
- log: LogEntry | null;
- resolvedSelectedPromptName?: string; // Current prompt name from prompt-repo when `selected_prompt_id` is set; falls back to stored log name
- loading?: boolean;
- handleDelete?: (log: LogEntry) => void;
- onClose?: () => void;
- headerAction?: ReactNode;
- onFilterByParentRequestId?: (parentRequestId: string) => void;
+ log: LogEntry | null;
+ resolvedSelectedPromptName?: string; // Current prompt name from prompt-repo when `selected_prompt_id` is set; falls back to stored log name
+ loading?: boolean;
+ handleDelete?: (log: LogEntry) => void;
+ onClose?: () => void;
+ headerAction?: ReactNode;
+ onFilterByParentRequestId?: (parentRequestId: string) => void;
}
export function LogDetailView({
- log,
- resolvedSelectedPromptName,
- loading = false,
- handleDelete,
- onClose,
- headerAction,
- onFilterByParentRequestId,
+ log,
+ resolvedSelectedPromptName,
+ loading = false,
+ handleDelete,
+ onClose,
+ headerAction,
+ onFilterByParentRequestId,
}: LogDetailViewProps) {
- const { copy: copyBody } = useCopyToClipboard({
- successMessage: "Request body copied to clipboard",
- errorMessage: "Failed to copy request body",
- });
- const allRoles: MessageRole[] = ["system", "user", "assistant", "tool", "reasoning"];
- const [visibleRoles, setVisibleRoles] = useState>(new Set(allRoles));
+ const { copy: copyBody } = useCopyToClipboard({
+ successMessage: "Request body copied to clipboard",
+ errorMessage: "Failed to copy request body",
+ });
+ const allRoles: MessageRole[] = ["system", "user", "assistant", "tool", "reasoning"];
+ const [visibleRoles, setVisibleRoles] = useState>(new Set(allRoles));
- if (!log) return null;
+ if (!log) return null;
- const selectedPromptDisplayName =
- resolvedSelectedPromptName ?? log.selected_prompt_name ?? "";
+ const selectedPromptDisplayName = resolvedSelectedPromptName ?? log.selected_prompt_name ?? "";
- const isContainer = isContainerOperation(log.object);
- const showTabs = !isContainer;
- const isPassthrough = isPassthroughOperation(log.object);
- const passthroughParams = isPassthrough
- ? (log.params as {
- method?: string;
- path?: string;
- raw_query?: string;
- status_code?: number;
- })
- : null;
+ const isContainer = isContainerOperation(log.object);
+ const showTabs = !isContainer;
+ const isPassthrough = isPassthroughOperation(log.object);
+ const passthroughParams = isPassthrough
+ ? (log.params as {
+ method?: string;
+ path?: string;
+ raw_query?: string;
+ status_code?: number;
+ })
+ : null;
- let toolsParameter = null;
- if (log.params?.tools) {
- try {
- toolsParameter = JSON.stringify(log.params.tools, null, 2);
- } catch { }
- }
+ let toolsParameter = null;
+ if (log.params?.tools) {
+ try {
+ toolsParameter = JSON.stringify(log.params.tools, null, 2);
+ } catch {}
+ }
- const audioFormat =
- (log.params as any)?.audio?.format ||
- (log.params as any)?.extra_params?.audio?.format ||
- undefined;
- const rawRequest = log.raw_request;
- const rawResponse = log.raw_response;
- const passthroughRequestBody = log.passthrough_request_body;
- const passthroughResponseBody = log.passthrough_response_body;
- const videoOutput =
- log.video_generation_output ||
- log.video_retrieve_output ||
- log.video_download_output;
- const videoListOutput = log.video_list_output;
- const pluginLogCount = (() => {
- if (!log.plugin_logs) return 0;
- try {
- const parsed = JSON.parse(log.plugin_logs);
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
- return Object.values(parsed).reduce((sum, v) => sum + (Array.isArray(v) ? v.length : 0), 0);
- }
- } catch { }
- return 0;
- })();
+ const audioFormat = (log.params as any)?.audio?.format || (log.params as any)?.extra_params?.audio?.format || undefined;
+ const rawRequest = log.raw_request;
+ const rawResponse = log.raw_response;
+ const passthroughRequestBody = log.passthrough_request_body;
+ const passthroughResponseBody = log.passthrough_response_body;
+ const videoOutput = log.video_generation_output || log.video_retrieve_output || log.video_download_output;
+ const videoListOutput = log.video_list_output;
+ const pluginLogCount = (() => {
+ if (!log.plugin_logs) return 0;
+ try {
+ const parsed = JSON.parse(log.plugin_logs);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return Object.values(parsed).reduce((sum, v) => sum + (Array.isArray(v) ? v.length : 0), 0);
+ }
+ } catch {}
+ return 0;
+ })();
- return loading ? (
-
-
-
- ) : (
- <>
- {/* Breadcrumb header with actions */}
-
-
- {headerAction}
- Request details
-
- {onClose ? (
-
-
-
-
-
-
-
-
- {!isPassthrough && (
- copyRequestBody(log, copyBody)}
- data-testid="logdetails-copy-request-body-button"
- >
-
- Copy request body
-
- )}
- downloadAsJson(log, `log-${log.id ?? "export"}.json`)}
- data-testid="logdetails-export-log-button"
- >
-
- Export as JSON
-
+ return loading ? (
+
+
+
+ ) : (
+ <>
+ {/* Breadcrumb header with actions */}
+
+
+ {headerAction}
+ Request details
+
+ {onClose ? (
+
+
+
+
+
+
+
+
+ {!isPassthrough && (
+ copyRequestBody(log, copyBody)} data-testid="logdetails-copy-request-body-button">
+
+ Copy request body
+
+ )}
+ downloadAsJson(log, `log-${log.id ?? "export"}.json`)}
+ data-testid="logdetails-export-log-button"
+ >
+
+ Export as JSON
+
- {handleDelete ? <>
-
-
- Delete log
-
- > : null
- }
-
-
-
-
-
- Are you sure you want to delete this log?
-
-
- This action cannot be undone. This will permanently delete the
- log entry.
-
-
-
-
- Cancel
-
- {
- if (handleDelete) handleDelete(log);
- onClose();
- }}
- >
- Delete
-
-
-
-
- ) : null}
-
-
-
-
-
-
-
- {RequestTypeLabels[
- log.object as keyof typeof RequestTypeLabels
- ] ?? log.object}
-
- {log.routing_rule && (
-
- rule: {log.routing_rule.name}
-
- )}
- {log.metadata?.isAsyncRequest ? (
-
- Async
-
- ) : null}
- {log.cache_debug?.hit_type === "direct" ? (
-
- Direct Cache
-
- ) : null}
- {log.cache_debug?.hit_type === "semantic" ? (
-
- Semantic Cache
-
- ) : null}
- {(log.is_large_payload_request ||
- log.is_large_payload_response) && (
-
- Large Payload
-
- )}
-
-
-
- Request
-
-
- {log.id || "—"}
-
- {log.id ?
: null}
-
- {log.cache_debug?.cache_id && (
-
-
- Cache {log.cache_debug.cache_hit ? "(hit)" : "(miss)"}
-
-
- {log.cache_debug.cache_id}
-
-
-
- )}
- {log.routing_rule && (
-
-
- Rule
-
-
- “{log.routing_rule.name}”
-
-
- )}
- {log.selected_key && (
-
-
- Key
-
-
- {log.selected_key.name}
-
-
- )}
-
-
-
- {log.provider}
-
-
-
- {
- if (!log.timestamp) return "";
- const start = new Date(log.timestamp);
- if (isNaN(start.getTime())) return "";
- const startStr = format(start, "HH:mm:ss");
- if (log.latency == null || isNaN(log.latency)) return startStr;
- return `${startStr} → ${format(addMilliseconds(start, log.latency), "HH:mm:ss")}`;
- })()}
- hasRightBorder
- />
-
-
-
-
-
-
-
-
- More details
-
-
- timings, request meta, tokens, caching, metadata
-
-
-
-
-
-
-
-
- {
- const d = log.timestamp ? new Date(log.timestamp) : null;
- return d && !isNaN(d.getTime())
- ? format(d, "yyyy-MM-dd hh:mm:ss aa")
- : "N/A";
- })()}
- />
- {
- const d = log.timestamp ? new Date(log.timestamp) : null;
- return d && !isNaN(d.getTime())
- ? format(
- addMilliseconds(d, log.latency || 0),
- "yyyy-MM-dd hh:mm:ss aa",
- )
- : "N/A";
- })()}
- />
- {log.latency.toFixed(2)}ms
- )
- }
- />
-
-
-
-
-
-
-
-
- {log.provider}
-
- }
- />
- {!isContainer && (
-
- )}
- {!isContainer && log.alias && (
-
- )}
-
- {RequestTypeLabels[
- log.object as keyof typeof RequestTypeLabels
- ] ??
- log.object ??
- "unknown"}
-
- }
- />
- {log.stop_reason && (
-
- {log.stop_reason}
-
- }
- />
- )}
- {log.parent_request_id && (
-
-
-
- onFilterByParentRequestId(
- log.parent_request_id as string,
- )
- }
- >
- {log.parent_request_id}
-
-
-
- Filter this session
-
-
- ) : (
-
- {log.parent_request_id}
-
- )
- }
- />
- )}
- {log.selected_key && (
-
- )}
- {(log.selected_prompt_id ||
- log.selected_prompt_name ||
- log.selected_prompt_version) && (
-
- {selectedPromptDisplayName}
- {selectedPromptDisplayName && log.selected_prompt_version
- ? " · "
- : ""}
- {log.selected_prompt_version ? (
- <>v{log.selected_prompt_version}>
- ) : null}
-
- }
- />
- )}
- {log.number_of_retries > 0 && (
-
- )}
- {log.team_id && (
-
- {log.team_name || log.team_id}
-
- }
- />
- )}
- {log.customer_id && (
-
- {log.customer_name || log.customer_id}
-
- }
- />
- )}
- {log.business_unit_id && (
-
- {log.business_unit_name || log.business_unit_id}
-
- }
- />
- )}
- {log.user_id && (
-
-
-
- {log.user_name || log.user_id}
-
-
-
- {log.user_name ? log.user_id : "Filter by user"}
-
-
- }
- />
- )}
- {log.fallback_index > 0 && (
-
- )}
- {log.virtual_key && (
-
- )}
- {log.routing_engines_used &&
- log.routing_engines_used.length > 0 && (
-
- {log.routing_engines_used.map((engine) => (
-
-
- {RoutingEngineUsedIcons[
- engine as keyof typeof RoutingEngineUsedIcons
- ]?.()}
-
- {RoutingEngineUsedLabels[
- engine as keyof typeof RoutingEngineUsedLabels
- ] ?? engine}
-
-
-
- ))}
-
- }
- />
- )}
- {log.routing_rule && (
-
- )}
+ {handleDelete ? (
+ <>
+
+
+
+
+ Delete log
+
+ {" "}
+ >
+ ) : null}
+
+
+
+
+ Are you sure you want to delete this log?
+ This action cannot be undone. This will permanently delete the log entry.
+
+
+ Cancel
+ {
+ if (handleDelete) handleDelete(log);
+ onClose();
+ }}
+ >
+ Delete
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {RequestTypeLabels[log.object as keyof typeof RequestTypeLabels] ?? log.object}
+
+ {log.routing_rule && (
+
+ rule: {log.routing_rule.name}
+
+ )}
+ {log.metadata?.isAsyncRequest ? (
+
+ Async
+
+ ) : null}
+ {log.cache_debug?.hit_type === "direct" ? (
+
+ Direct Cache
+
+ ) : null}
+ {log.cache_debug?.hit_type === "semantic" ? (
+
+ Semantic Cache
+
+ ) : null}
+ {(log.is_large_payload_request || log.is_large_payload_response) && (
+
+ Large Payload
+
+ )}
+
+
+
Request
+
{log.id || "—"}
+ {log.id ?
: null}
+
+ {log.cache_debug?.cache_id && (
+
+
+ Cache {log.cache_debug.cache_hit ? "(hit)" : "(miss)"}
+
+
{log.cache_debug.cache_id}
+
+
+ )}
+ {log.routing_rule && (
+
+
Rule
+
“{log.routing_rule.name}”
+
+ )}
+ {log.selected_key && (
+
+
Key
+
{log.selected_key.name}
+
+ )}
+
+
+
+ {log.provider}
+
+
+
+ {
+ if (!log.timestamp) return "";
+ const start = new Date(log.timestamp);
+ if (isNaN(start.getTime())) return "";
+ const startStr = format(start, "HH:mm:ss");
+ if (log.latency == null || isNaN(log.latency)) return startStr;
+ return `${startStr} → ${format(addMilliseconds(start, log.latency), "HH:mm:ss")}`;
+ })()}
+ hasRightBorder
+ />
+
+
+
+
+
+
+
+
+ More details
+
+ timings, request meta, tokens, caching, metadata
+
+
+
+
+
+
+
+ {
+ const d = log.timestamp ? new Date(log.timestamp) : null;
+ return d && !isNaN(d.getTime()) ? format(d, "yyyy-MM-dd hh:mm:ss aa") : "N/A";
+ })()}
+ />
+ {
+ const d = log.timestamp ? new Date(log.timestamp) : null;
+ return d && !isNaN(d.getTime()) ? format(addMilliseconds(d, log.latency || 0), "yyyy-MM-dd hh:mm:ss aa") : "N/A";
+ })()}
+ />
+ {log.latency.toFixed(2)}ms
}
+ />
+
+
+
+
+
+
+
+
+ {log.provider}
+
+ }
+ />
+ {!isContainer && }
+ {!isContainer && log.alias && }
+
+ {RequestTypeLabels[log.object as keyof typeof RequestTypeLabels] ?? log.object ?? "unknown"}
+
+ }
+ />
+ {log.stop_reason && (
+
+ {log.stop_reason}
+
+ }
+ />
+ )}
+ {log.parent_request_id && (
+
+
+ onFilterByParentRequestId(log.parent_request_id as string)}
+ >
+ {log.parent_request_id}
+
+
+ Filter this session
+
+ ) : (
+ {log.parent_request_id}
+ )
+ }
+ />
+ )}
+ {log.selected_key && }
+ {(log.selected_prompt_id || log.selected_prompt_name || log.selected_prompt_version) && (
+
+ {selectedPromptDisplayName}
+ {selectedPromptDisplayName && log.selected_prompt_version ? " · " : ""}
+ {log.selected_prompt_version ? <>v{log.selected_prompt_version}> : null}
+
+ }
+ />
+ )}
+ {log.number_of_retries > 0 && (
+
+ )}
+ {log.team_id && (
+
+ {log.team_name || log.team_id}
+
+ }
+ />
+ )}
+ {log.customer_id && (
+
+ {log.customer_name || log.customer_id}
+
+ }
+ />
+ )}
+ {log.business_unit_id && (
+
+ {log.business_unit_name || log.business_unit_id}
+
+ }
+ />
+ )}
+ {log.user_id && (
+
+
+
+ {log.user_name || log.user_id}
+
+
+ {log.user_name ? log.user_id : "Filter by user"}
+
+ }
+ />
+ )}
+ {log.fallback_index > 0 && }
+ {log.virtual_key && }
+ {log.routing_engines_used && log.routing_engines_used.length > 0 && (
+
+ {log.routing_engines_used.map((engine) => (
+
+
+ {RoutingEngineUsedIcons[engine as keyof typeof RoutingEngineUsedIcons]?.()}
+ {RoutingEngineUsedLabels[engine as keyof typeof RoutingEngineUsedLabels] ?? engine}
+
+
+ ))}
+
+ }
+ />
+ )}
+ {log.routing_rule && }
- {(log.params as any)?.audio && (
- <>
- {(log.params as any).audio.format && (
-
- )}
- {(log.params as any).audio.voice && (
-
- )}
- >
- )}
+ {(log.params as any)?.audio && (
+ <>
+ {(log.params as any).audio.format && (
+
+ )}
+ {(log.params as any).audio.voice && (
+
+ )}
+ >
+ )}
- {passthroughParams && (
- <>
- {passthroughParams.method && (
-
- )}
- {passthroughParams.path && (
-
- )}
- {passthroughParams.raw_query && (
-
- )}
- {(passthroughParams.status_code ?? 0) !== 0 && (
-
- )}
- >
- )}
+ {passthroughParams && (
+ <>
+ {passthroughParams.method && }
+ {passthroughParams.path && }
+ {passthroughParams.raw_query && (
+
+ )}
+ {(passthroughParams.status_code ?? 0) !== 0 && (
+
+ )}
+ >
+ )}
- {log.params &&
- Object.keys(log.params).length > 0 &&
- Object.entries(log.params)
- .filter(([key]) => {
- const passthroughKeys = [
- "method",
- "path",
- "raw_query",
- "status_code",
- ];
- return (
- key !== "tools" &&
- key !== "instructions" &&
- key !== "audio" &&
- !(isPassthrough && passthroughKeys.includes(key))
- );
- })
- .filter(
- ([_, value]) =>
- typeof value === "boolean" ||
- typeof value === "number" ||
- typeof value === "string",
- )
- .map(([key, value]) => (
-
- ))}
-
-
- {log.status === "success" && !isContainer && !isPassthrough && (
- <>
-
-
-
-
-
-
-
-
- {log.token_usage?.prompt_tokens_details && (
- <>
- {log.token_usage.prompt_tokens_details
- .cached_read_tokens && (
-
- )}
- {log.token_usage.prompt_tokens_details
- .cached_write_tokens && (
-
- )}
- {log.token_usage.prompt_tokens_details.audio_tokens && (
-
- )}
- >
- )}
- {log.token_usage?.completion_tokens_details && (
- <>
- {log.token_usage.completion_tokens_details
- .reasoning_tokens && (
-
- )}
- {log.token_usage.completion_tokens_details
- .audio_tokens && (
-
- )}
- {log.token_usage.completion_tokens_details
- .accepted_prediction_tokens && (
-
- )}
- {log.token_usage.completion_tokens_details
- .rejected_prediction_tokens && (
-
- )}
- >
- )}
-
-
- {(() => {
- const params = log.params as any;
- const reasoning = params?.reasoning;
- if (
- !reasoning ||
- typeof reasoning !== "object" ||
- Object.keys(reasoning).length === 0
- ) {
- return null;
- }
- return (
- <>
-
-
-
-
- {reasoning.effort && (
-
- {reasoning.effort}
-
- }
- />
- )}
- {reasoning.summary && (
-
- {reasoning.summary}
-
- }
- />
- )}
- {reasoning.generate_summary && (
-
- {reasoning.generate_summary}
-
- }
- />
- )}
- {reasoning.max_tokens && (
-
- )}
-
-
- >
- );
- })()}
- {log.cache_debug && (
- <>
-
-
-
-
- {log.cache_debug.cache_hit ? (
- <>
-
- {log.cache_debug.hit_type}
-
- }
- />
- {log.cache_debug.hit_type === "semantic" && (
- <>
- {log.cache_debug.provider_used && (
-
- {log.cache_debug.provider_used}
-
- }
- />
- )}
- {log.cache_debug.model_used && (
-
- )}
- {log.cache_debug.threshold && (
-
- )}
- {log.cache_debug.similarity && (
-
- )}
- {log.cache_debug.input_tokens && (
-
- )}
- >
- )}
- >
- ) : (
- <>
- {log.cache_debug.provider_used && (
-
- {log.cache_debug.provider_used}
-
- }
- />
- )}
- {log.cache_debug.model_used && (
-
- )}
- {log.cache_debug.input_tokens && (
-
- )}
- >
- )}
-
-
- >
- )}
- {log.metadata &&
- Object.keys(log.metadata).filter((k) => k !== "isAsyncRequest")
- .length > 0 && (
- <>
-
-
-
-
- {Object.entries(log.metadata)
- .filter(([key]) => key !== "isAsyncRequest")
- .map(([key, value]) => (
-
- ))}
-
-
- >
- )}
- >
- )}
-
-
-
-
- {showTabs && (
-
- Messages
- {log.input_history?.length ? (
-
- {log.input_history.length + (log.output_message ? 1 : 0)}
-
- ) : null}
-
- )}
+ {log.params &&
+ Object.keys(log.params).length > 0 &&
+ Object.entries(log.params)
+ .filter(([key]) => {
+ const passthroughKeys = ["method", "path", "raw_query", "status_code"];
+ return (
+ key !== "tools" && key !== "instructions" && key !== "audio" && !(isPassthrough && passthroughKeys.includes(key))
+ );
+ })
+ .filter(([_, value]) => typeof value === "boolean" || typeof value === "number" || typeof value === "string")
+ .map(([key, value]) => )}
+
+
+ {log.status === "success" && !isContainer && !isPassthrough && (
+ <>
+
+
+
+
+
+
+
+
+ {log.token_usage?.prompt_tokens_details && (
+ <>
+ {log.token_usage.prompt_tokens_details.cached_read_tokens && (
+
+ )}
+ {log.token_usage.prompt_tokens_details.cached_write_tokens && (
+
+ )}
+ {log.token_usage.prompt_tokens_details.audio_tokens && (
+
+ )}
+ >
+ )}
+ {log.token_usage?.completion_tokens_details && (
+ <>
+ {log.token_usage.completion_tokens_details.reasoning_tokens && (
+
+ )}
+ {log.token_usage.completion_tokens_details.audio_tokens && (
+
+ )}
+ {log.token_usage.completion_tokens_details.accepted_prediction_tokens && (
+
+ )}
+ {log.token_usage.completion_tokens_details.rejected_prediction_tokens && (
+
+ )}
+ >
+ )}
+
+
+ {(() => {
+ const params = log.params as any;
+ const reasoning = params?.reasoning;
+ if (!reasoning || typeof reasoning !== "object" || Object.keys(reasoning).length === 0) {
+ return null;
+ }
+ return (
+ <>
+
+
+
+
+ {reasoning.effort && (
+
+ {reasoning.effort}
+
+ }
+ />
+ )}
+ {reasoning.summary && (
+
+ {reasoning.summary}
+
+ }
+ />
+ )}
+ {reasoning.generate_summary && (
+
+ {reasoning.generate_summary}
+
+ }
+ />
+ )}
+ {reasoning.max_tokens && }
+
+
+ >
+ );
+ })()}
+ {log.cache_debug && (
+ <>
+
+
+
+
+ {log.cache_debug.cache_hit ? (
+ <>
+
+ {log.cache_debug.hit_type}
+
+ }
+ />
+ {log.cache_debug.hit_type === "semantic" && (
+ <>
+ {log.cache_debug.provider_used && (
+
+ {log.cache_debug.provider_used}
+
+ }
+ />
+ )}
+ {log.cache_debug.model_used && (
+
+ )}
+ {log.cache_debug.threshold && (
+
+ )}
+ {log.cache_debug.similarity && (
+
+ )}
+ {log.cache_debug.input_tokens && (
+
+ )}
+ >
+ )}
+ >
+ ) : (
+ <>
+ {log.cache_debug.provider_used && (
+
+ {log.cache_debug.provider_used}
+
+ }
+ />
+ )}
+ {log.cache_debug.model_used && (
+
+ )}
+ {log.cache_debug.input_tokens && (
+
+ )}
+ >
+ )}
+
+
+ >
+ )}
+ {log.metadata && Object.keys(log.metadata).filter((k) => k !== "isAsyncRequest").length > 0 && (
+ <>
+
+
+
+
+ {Object.entries(log.metadata)
+ .filter(([key]) => key !== "isAsyncRequest")
+ .map(([key, value]) => (
+
+ ))}
+
+
+ >
+ )}
+ >
+ )}
+
+
+
+
+ {showTabs && (
+
+ Messages
+ {log.input_history?.length ? (
+
+ {log.input_history.length + (log.output_message ? 1 : 0)}
+
+ ) : null}
+
+ )}
- {showTabs && !isPassthrough && !log.list_models_output && (
-
- Tools
- {log.params?.tools?.length ? (
-
- {log.params.tools.length}
-
- ) : null}
-
- )}
- {showTabs && (
-
- Routing
- {log.routing_engine_logs ? (
-
- {log.routing_engine_logs.split("\n").filter(Boolean).length}
-
- ) : null}
-
- )}
-
- Plugin Logs
- {pluginLogCount > 0 ? (
-
- {pluginLogCount}
-
- ) : null}
-
- {!isPassthrough && (
-
- Raw JSON
-
- )}
-
+ {showTabs && !isPassthrough && !log.list_models_output && (
+
+ Tools
+ {log.params?.tools?.length ? (
+
+ {log.params.tools.length}
+
+ ) : null}
+
+ )}
+ {showTabs && (
+
+ Routing
+ {log.routing_engine_logs ? (
+
+ {log.routing_engine_logs.split("\n").filter(Boolean).length}
+
+ ) : null}
+
+ )}
+
+ Plugin Logs
+ {pluginLogCount > 0 ? (
+
+ {pluginLogCount}
+
+ ) : null}
+
+ {!isPassthrough && (
+
+ Raw JSON
+
+ )}
+
-
-
-
-
-
- Messages
- {visibleRoles.size < allRoles.length && (
-
- {visibleRoles.size}/{allRoles.length}
-
- )}
-
-
-
-
-
- setVisibleRoles(checked ? new Set(allRoles) : new Set())
- }
- >
- Show all messages
-
-
- {(
- [
- ["system", "System"],
- ["user", "User"],
- ["assistant", "Assistant"],
- ["tool", "Tool"],
- ["reasoning", "Reasoning"],
- ] as [MessageRole, string][]
- ).map(([role, label]) => (
-
- setVisibleRoles((prev) => {
- const next = new Set(prev);
- checked ? next.add(role) : next.delete(role);
- return next;
- })
- }
- >
-
- {label}
-
- ))}
-
- setVisibleRoles(new Set())}
- className="text-muted-foreground justify-center text-[12px]"
- >
- Clear all
-
-
-
-
- {(log.ocr_input || log.ocr_output) && (
-
- )}
- {(log.speech_input || log.speech_output) && (
-
- )}
- {(log.transcription_input || log.transcription_output) && (
-
- )}
- {(log.image_generation_input ||
- log.image_edit_input ||
- log.image_variation_input ||
- log.image_generation_output) && (
-
- )}
- {(log.video_generation_input || videoOutput || videoListOutput) && (
-
- )}
+
+
+
+
+
+ Messages
+ {visibleRoles.size < allRoles.length && (
+
+ {visibleRoles.size}/{allRoles.length}
+
+ )}
+
+
+
+
+ setVisibleRoles(checked ? new Set(allRoles) : new Set())}
+ >
+ Show all messages
+
+
+ {(
+ [
+ ["system", "System"],
+ ["user", "User"],
+ ["assistant", "Assistant"],
+ ["tool", "Tool"],
+ ["reasoning", "Reasoning"],
+ ] as [MessageRole, string][]
+ ).map(([role, label]) => (
+
+ setVisibleRoles((prev) => {
+ const next = new Set(prev);
+ checked ? next.add(role) : next.delete(role);
+ return next;
+ })
+ }
+ >
+
+ {label}
+
+ ))}
+
+ setVisibleRoles(new Set())} className="text-muted-foreground justify-center text-[12px]">
+ Clear all
+
+
+
+
+ {(log.ocr_input || log.ocr_output) && }
+ {(log.speech_input || log.speech_output) && (
+
+ )}
+ {(log.transcription_input || log.transcription_output) && (
+
+ )}
+ {(log.image_generation_input || log.image_edit_input || log.image_variation_input || log.image_generation_output) && (
+
+ )}
+ {(log.video_generation_input || videoOutput || videoListOutput) && (
+
+ )}
- {isPassthrough && passthroughRequestBody && (
- {
- try {
- return JSON.stringify(
- JSON.parse(passthroughRequestBody || ""),
- null,
- 2,
- );
- } catch {
- return passthroughRequestBody || "";
- }
- }}
- >
- {
- try {
- return JSON.stringify(
- JSON.parse(passthroughRequestBody || ""),
- null,
- 2,
- );
- } catch {
- return passthroughRequestBody || "";
- }
- })()}
- lang="json"
- readonly={true}
- options={{
- scrollBeyondLastLine: false,
- lineNumbers: "off",
- alwaysConsumeMouseWheel: false,
- }}
- />
-
- )}
- {isPassthrough &&
- passthroughResponseBody &&
- log.status !== "processing" && (
- {
- try {
- return JSON.stringify(
- JSON.parse(passthroughResponseBody || ""),
- null,
- 2,
- );
- } catch {
- return passthroughResponseBody || "";
- }
- }}
- >
- {
- try {
- return JSON.stringify(
- JSON.parse(passthroughResponseBody || ""),
- null,
- 2,
- );
- } catch {
- return passthroughResponseBody || "";
- }
- })()}
- lang="json"
- readonly={true}
- options={{
- scrollBeyondLastLine: false,
- lineNumbers: "off",
- alwaysConsumeMouseWheel: false,
- }}
- />
-
- )}
+ {isPassthrough && passthroughRequestBody && (
+ {
+ try {
+ return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2);
+ } catch {
+ return passthroughRequestBody || "";
+ }
+ }}
+ >
+ {
+ try {
+ return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2);
+ } catch {
+ return passthroughRequestBody || "";
+ }
+ })()}
+ lang="json"
+ readonly={true}
+ options={{
+ scrollBeyondLastLine: false,
+ lineNumbers: "off",
+ alwaysConsumeMouseWheel: false,
+ }}
+ />
+
+ )}
+ {isPassthrough && passthroughResponseBody && log.status !== "processing" && (
+ {
+ try {
+ return JSON.stringify(JSON.parse(passthroughResponseBody || ""), null, 2);
+ } catch {
+ return passthroughResponseBody || "";
+ }
+ }}
+ >
+ {
+ try {
+ return JSON.stringify(JSON.parse(passthroughResponseBody || ""), null, 2);
+ } catch {
+ return passthroughResponseBody || "";
+ }
+ })()}
+ lang="json"
+ readonly={true}
+ options={{
+ scrollBeyondLastLine: false,
+ lineNumbers: "off",
+ alwaysConsumeMouseWheel: false,
+ }}
+ />
+
+ )}
- {!isPassthrough && ((log.input_history && log.input_history.length > 0) ||
- (log.output_message && !log.error_details?.error.message) ||
- (log.stop_reason === "refusal" || log.stop_reason === "content_filter" || log.stop_reason === "safety")) && (
-
- {(visibleRoles.size < allRoles.length
- ? log.input_history?.filter((m) => {
- const mainRole = ((m.role as string) || "user") as MessageRole;
- const hasReasoning = !!extractChatReasoning(m);
- return (
- visibleRoles.has(mainRole) ||
- (hasReasoning && visibleRoles.has("reasoning"))
- );
- })
- : log.input_history
- )?.flatMap((message, index) => {
- const role = ((message.role as string) ||
- "user") as MessageRole;
- const text = extractMessageText(message);
- const reasoningText = extractChatReasoning(message);
- const showAll = visibleRoles.size === allRoles.length;
- const showMain = showAll || visibleRoles.has(role);
- const showReasoning =
- !!reasoningText && (showAll || visibleRoles.has("reasoning"));
- const hasToolCalls =
- Array.isArray(message.tool_calls) &&
- message.tool_calls.length > 0;
- const isOverallLast =
- index === (log.input_history?.length ?? 0) - 1 &&
- !log.output_message &&
- !log.error_details?.error.message;
- const lineCount = text ? text.split("\n").length : 0;
- const approxTokens = text
- ? Math.max(1, Math.round(text.length / 4))
- : 0;
- const reasoningTokens = reasoningText
- ? Math.max(1, Math.round(reasoningText.length / 4))
- : 0;
- const meta = text
- ? role === "system" || role === "tool"
- ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens`
- : `${lineCount} line${lineCount === 1 ? "" : "s"}`
- : hasToolCalls
- ? `${message.tool_calls!.length} tool call${message.tool_calls!.length === 1 ? "" : "s"}`
- : undefined;
- const usePlainText = role === "user" || role === "assistant";
- const rows: ReactNode[] = [];
- if (showReasoning) {
- rows.push(
-
-
-
- );
- }
- if (showMain) {
- rows.push(
-
- {text ? (
- usePlainText && isJson(text) ? (
- {
- try {
- return JSON.stringify(JSON.parse(text), null, 2);
- } catch {
- return text;
- }
- })()}
- lang="json"
- readonly
- autoResize
- options={{
- showIndentLines: false,
- disableHover: true,
- }}
- />
- ) : usePlainText ? (
-
- ) : (
-
- )
- ) : (
-
- )}
- {text &&
- Array.isArray(message.content) &&
- (message.content as ContentBlock[])
- .filter((b) => b.type === "image_url")
- .map((b, i) => {
- const src = b.image_url?.url;
- if (!src) return null;
- return (
-
- );
- })}
- {hasToolCalls && text ? (
-
- {message.tool_calls!
- .map((tc) => tc.function?.name)
- .filter(Boolean)
- .join(", ") ||
- `${message.tool_calls!.length} tool call${message.tool_calls!.length === 1 ? "" : "s"}`}
-
- ) : null}
-
- );
- }
- return rows;
- })}
- {log.output_message &&
- !log.error_details?.error.message &&
- (() => {
- const reasoningText = extractChatReasoning(log.output_message);
- const showReasoning =
- !!reasoningText &&
- (visibleRoles.size === allRoles.length ||
- visibleRoles.has("reasoning"));
- const showAssistant = visibleRoles.has("assistant");
- if (!showReasoning && !showAssistant) return null;
- const text = extractMessageText(log.output_message);
- const refusalText = log.output_message.refusal;
- const isStopReasonRefusal =
- log.stop_reason === "refusal" ||
- log.stop_reason === "content_filter" ||
- log.stop_reason === "safety";
- const showRefusal = refusalText || (!text && isStopReasonRefusal);
- const lineCount = text ? text.split("\n").length : 0;
- const tokenMeta = log.token_usage?.completion_tokens
- ? `${log.token_usage.completion_tokens} tokens`
- : undefined;
- const meta = text
- ? tokenMeta
- ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ${tokenMeta}`
- : `${lineCount} line${lineCount === 1 ? "" : "s"}`
- : showRefusal
- ? "refusal"
- : tokenMeta;
- const reasoningTokens = reasoningText
- ? log.token_usage?.completion_tokens_details
- ?.reasoning_tokens ||
- Math.max(1, Math.round(reasoningText.length / 4))
- : 0;
- return (
- <>
- {showReasoning ? (
-
-
-
- ) : null}
- {showAssistant ? (
-
- {showRefusal ? (
-
-
- {refusalText && (
-
- {refusalText}
-
- )}
-
- ) : text ? (
- isJson(text) ? (
- {
- try {
- return JSON.stringify(JSON.parse(text), null, 2);
- } catch {
- return text;
- }
- })()}
- lang="json"
- readonly
- autoResize
- options={{
- showIndentLines: false,
- disableHover: true,
- }}
- />
- ) : (
-
- )
- ) : (
-
- )}
-
- ) : null}
- >
- );
- })()}
- {!log.output_message &&
- !log.error_details?.error.message &&
- (log.stop_reason === "refusal" ||
- log.stop_reason === "content_filter" ||
- log.stop_reason === "safety") && (
-
-
-
- )}
-
- )}
+ {!isPassthrough &&
+ ((log.input_history && log.input_history.length > 0) ||
+ (log.output_message && !log.error_details?.error.message) ||
+ log.stop_reason === "refusal" ||
+ log.stop_reason === "content_filter" ||
+ log.stop_reason === "safety") && (
+
+ {(visibleRoles.size < allRoles.length
+ ? log.input_history?.filter((m) => {
+ const mainRole = ((m.role as string) || "user") as MessageRole;
+ const hasReasoning = !!extractChatReasoning(m);
+ return visibleRoles.has(mainRole) || (hasReasoning && visibleRoles.has("reasoning"));
+ })
+ : log.input_history
+ )?.flatMap((message, index) => {
+ const role = ((message.role as string) || "user") as MessageRole;
+ const text = extractMessageText(message);
+ const reasoningText = extractChatReasoning(message);
+ const showAll = visibleRoles.size === allRoles.length;
+ const showMain = showAll || visibleRoles.has(role);
+ const showReasoning = !!reasoningText && (showAll || visibleRoles.has("reasoning"));
+ const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
+ const isOverallLast =
+ index === (log.input_history?.length ?? 0) - 1 && !log.output_message && !log.error_details?.error.message;
+ const lineCount = text ? text.split("\n").length : 0;
+ const approxTokens = text ? Math.max(1, Math.round(text.length / 4)) : 0;
+ const reasoningTokens = reasoningText ? Math.max(1, Math.round(reasoningText.length / 4)) : 0;
+ const meta = text
+ ? role === "system" || role === "tool"
+ ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens`
+ : `${lineCount} line${lineCount === 1 ? "" : "s"}`
+ : hasToolCalls
+ ? `${message.tool_calls!.length} tool call${message.tool_calls!.length === 1 ? "" : "s"}`
+ : undefined;
+ const usePlainText = role === "user" || role === "assistant";
+ const rows: ReactNode[] = [];
+ if (showReasoning) {
+ rows.push(
+
+
+ ,
+ );
+ }
+ if (showMain) {
+ rows.push(
+
+ {text ? (
+ usePlainText && isJson(text) ? (
+ {
+ try {
+ return JSON.stringify(JSON.parse(text), null, 2);
+ } catch {
+ return text;
+ }
+ })()}
+ lang="json"
+ readonly
+ autoResize
+ options={{
+ showIndentLines: false,
+ disableHover: true,
+ }}
+ />
+ ) : usePlainText ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ {text &&
+ Array.isArray(message.content) &&
+ (message.content as ContentBlock[])
+ .filter((b) => b.type === "image_url")
+ .map((b, i) => {
+ const src = b.image_url?.url;
+ if (!src) return null;
+ return ;
+ })}
+ {hasToolCalls && text ? (
+
+ {message
+ .tool_calls!.map((tc) => tc.function?.name)
+ .filter(Boolean)
+ .join(", ") || `${message.tool_calls!.length} tool call${message.tool_calls!.length === 1 ? "" : "s"}`}
+
+ ) : null}
+ ,
+ );
+ }
+ return rows;
+ })}
+ {log.output_message &&
+ !log.error_details?.error.message &&
+ (() => {
+ const reasoningText = extractChatReasoning(log.output_message);
+ const showReasoning = !!reasoningText && (visibleRoles.size === allRoles.length || visibleRoles.has("reasoning"));
+ const showAssistant = visibleRoles.has("assistant");
+ if (!showReasoning && !showAssistant) return null;
+ const text = extractMessageText(log.output_message);
+ const refusalText = log.output_message.refusal;
+ const isStopReasonRefusal =
+ log.stop_reason === "refusal" || log.stop_reason === "content_filter" || log.stop_reason === "safety";
+ const showRefusal = refusalText || (!text && isStopReasonRefusal);
+ const lineCount = text ? text.split("\n").length : 0;
+ const tokenMeta = log.token_usage?.completion_tokens ? `${log.token_usage.completion_tokens} tokens` : undefined;
+ const meta = text
+ ? tokenMeta
+ ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ${tokenMeta}`
+ : `${lineCount} line${lineCount === 1 ? "" : "s"}`
+ : showRefusal
+ ? "refusal"
+ : tokenMeta;
+ const reasoningTokens = reasoningText
+ ? log.token_usage?.completion_tokens_details?.reasoning_tokens || Math.max(1, Math.round(reasoningText.length / 4))
+ : 0;
+ return (
+ <>
+ {showReasoning ? (
+
+
+
+ ) : null}
+ {showAssistant ? (
+
+ {showRefusal ? (
+
+
+ {refusalText && (
+
+ {refusalText}
+
+ )}
+
+ ) : text ? (
+ isJson(text) ? (
+ {
+ try {
+ return JSON.stringify(JSON.parse(text), null, 2);
+ } catch {
+ return text;
+ }
+ })()}
+ lang="json"
+ readonly
+ autoResize
+ options={{
+ showIndentLines: false,
+ disableHover: true,
+ }}
+ />
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+ ) : null}
+ >
+ );
+ })()}
+ {!log.output_message &&
+ !log.error_details?.error.message &&
+ (log.stop_reason === "refusal" || log.stop_reason === "content_filter" || log.stop_reason === "safety") && (
+
+
+
+ )}
+
+ )}
- {(() => {
- const rawInput = log.responses_input_history ?? [];
- const inputMsgs = visibleRoles.size < allRoles.length
- ? rawInput.filter((m) => visibleRoles.has(getResponsesRole(m)))
- : rawInput;
- const rawOutput =
- log.status !== "processing" && !log.error_details?.error.message
- ? (log.responses_output ?? [])
- : [];
- const outputMsgs = visibleRoles.size < allRoles.length
- ? rawOutput.filter((m) => visibleRoles.has(getResponsesRole(m)))
- : rawOutput;
- const all: ResponsesMessage[] = coalesceResponsesMessages([
- ...inputMsgs,
- ...outputMsgs,
- ]);
- if (all.length === 0) return null;
- return (
-
- {all.map((msg, index) => {
- const role = getResponsesRole(msg);
- const isLast = index === all.length - 1;
- const reasoningParts =
- role === "reasoning" ? extractReasoningParts(msg) : null;
- const reasoningHasAny =
- !!reasoningParts &&
- (reasoningParts.summaries.length > 0 ||
- !!reasoningParts.encrypted ||
- !!reasoningParts.contentText ||
- reasoningParts.signatures.length > 0);
- const text =
- role === "reasoning" ? "" : extractResponsesText(msg);
- const lineCount = text ? text.split("\n").length : 0;
- const approxTokens = text
- ? Math.max(1, Math.round(text.length / 4))
- : 0;
- let meta: string | undefined;
- if (role === "reasoning" && reasoningParts) {
- const totalLen =
- reasoningParts.summaries.reduce(
- (acc, s) => acc + s.length,
- 0,
- ) +
- (reasoningParts.contentText?.length ?? 0) +
- (reasoningParts.encrypted?.length ?? 0);
- const totalApprox = totalLen
- ? Math.max(1, Math.round(totalLen / 4))
- : 0;
- const hasOpaqueOnly =
- (!!reasoningParts.encrypted ||
- reasoningParts.signatures.length > 0) &&
- reasoningParts.summaries.length === 0 &&
- !reasoningParts.contentText;
- meta = totalApprox
- ? `~${totalApprox} tokens${hasOpaqueOnly ? " · encrypted" : ""}`
- : hasOpaqueOnly
- ? "encrypted"
- : undefined;
- } else {
- meta = text
- ? role === "system" || role === "tool"
- ? msg.name
- ? `${msg.name} · ${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens`
- : `${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens`
- : `${lineCount} line${lineCount === 1 ? "" : "s"}`
- : msg.name
- ? msg.name
- : msg.type === "function_call_output" && msg.call_id
- ? msg.call_id
- : msg.type || undefined;
- }
- const usePlainText = role === "user" || role === "assistant";
- return (
-
- {role === "reasoning" ? (
- reasoningHasAny && reasoningParts ? (
-
- {reasoningParts.contentText ? (
-
- ) : null}
- {reasoningParts.summaries.map((s, i) => (
-
- {reasoningParts.summaries.length > 1 ? (
-
- Summary {i + 1}
-
- ) : null}
-
-
- ))}
- {reasoningParts.encrypted ? (
-
- ) : null}
- {reasoningParts.signatures.length > 0 ? (
-
1
- ? "Encrypted signatures"
- : "Encrypted signature"
- }
- />
- ) : null}
-
- ) : (
-
- No reasoning content available
-
- )
- ) : text ? (
- usePlainText ? (
-
- ) : (
-
- )
- ) : msg.output !== undefined ? (
-
- ) : (
-
- No content
-
- )}
- {Array.isArray(msg.content) &&
- msg.content
- .filter((b) => b?.type === "input_image" && b.image_url)
- .map((b, i) => (
-
- ))}
-
- );
- })}
-
- );
- })()}
+ {(() => {
+ const rawInput = log.responses_input_history ?? [];
+ const inputMsgs =
+ visibleRoles.size < allRoles.length ? rawInput.filter((m) => visibleRoles.has(getResponsesRole(m))) : rawInput;
+ const rawOutput = log.status !== "processing" && !log.error_details?.error.message ? (log.responses_output ?? []) : [];
+ const outputMsgs =
+ visibleRoles.size < allRoles.length ? rawOutput.filter((m) => visibleRoles.has(getResponsesRole(m))) : rawOutput;
+ const all: ResponsesMessage[] = coalesceResponsesMessages([...inputMsgs, ...outputMsgs]);
+ if (all.length === 0) return null;
+ return (
+
+ {all.map((msg, index) => {
+ const role = getResponsesRole(msg);
+ const isLast = index === all.length - 1;
+ const reasoningParts = role === "reasoning" ? extractReasoningParts(msg) : null;
+ const reasoningHasAny =
+ !!reasoningParts &&
+ (reasoningParts.summaries.length > 0 ||
+ !!reasoningParts.encrypted ||
+ !!reasoningParts.contentText ||
+ reasoningParts.signatures.length > 0);
+ const text = role === "reasoning" ? "" : extractResponsesText(msg);
+ const lineCount = text ? text.split("\n").length : 0;
+ const approxTokens = text ? Math.max(1, Math.round(text.length / 4)) : 0;
+ let meta: string | undefined;
+ if (role === "reasoning" && reasoningParts) {
+ const totalLen =
+ reasoningParts.summaries.reduce((acc, s) => acc + s.length, 0) +
+ (reasoningParts.contentText?.length ?? 0) +
+ (reasoningParts.encrypted?.length ?? 0);
+ const totalApprox = totalLen ? Math.max(1, Math.round(totalLen / 4)) : 0;
+ const hasOpaqueOnly =
+ (!!reasoningParts.encrypted || reasoningParts.signatures.length > 0) &&
+ reasoningParts.summaries.length === 0 &&
+ !reasoningParts.contentText;
+ meta = totalApprox
+ ? `~${totalApprox} tokens${hasOpaqueOnly ? " · encrypted" : ""}`
+ : hasOpaqueOnly
+ ? "encrypted"
+ : undefined;
+ } else {
+ meta = text
+ ? role === "system" || role === "tool"
+ ? msg.name
+ ? `${msg.name} · ${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens`
+ : `${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens`
+ : `${lineCount} line${lineCount === 1 ? "" : "s"}`
+ : msg.name
+ ? msg.name
+ : msg.type === "function_call_output" && msg.call_id
+ ? msg.call_id
+ : msg.type || undefined;
+ }
+ const usePlainText = role === "user" || role === "assistant";
+ return (
+
+ {role === "reasoning" ? (
+ reasoningHasAny && reasoningParts ? (
+
+ {reasoningParts.contentText ? (
+
+ ) : null}
+ {reasoningParts.summaries.map((s, i) => (
+
+ {reasoningParts.summaries.length > 1 ? (
+
+ Summary {i + 1}
+
+ ) : null}
+
+
+ ))}
+ {reasoningParts.encrypted ? (
+
+ ) : null}
+ {reasoningParts.signatures.length > 0 ? (
+
1 ? "Encrypted signatures" : "Encrypted signature"}
+ />
+ ) : null}
+
+ ) : (
+ No reasoning content available
+ )
+ ) : text ? (
+ usePlainText ? (
+
+ ) : (
+
+ )
+ ) : msg.output !== undefined ? (
+
+ ) : (
+ No content
+ )}
+ {Array.isArray(msg.content) &&
+ msg.content
+ .filter((b) => b?.type === "input_image" && b.image_url)
+ .map((b, i) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+ })()}
- {log.is_large_payload_request &&
- !log.input_history?.length &&
- !log.responses_input_history?.length && (
-
- Large payload request — input content was streamed directly to
- the provider and is not available for display.
- {log.raw_request &&
- " A truncated preview is available in the Raw JSON tab."}
-
- )}
- {log.is_large_payload_response &&
- !log.output_message &&
- !log.responses_output?.length &&
- log.status !== "processing" && (
-
- Large payload response — response content was streamed directly
- to the client and is not available for display.
- {log.raw_response &&
- " A truncated preview is available in the Raw JSON tab."}
-
- )}
+ {log.is_large_payload_request && !log.input_history?.length && !log.responses_input_history?.length && (
+
+ Large payload request — input content was streamed directly to the provider and is not available for display.
+ {log.raw_request && " A truncated preview is available in the Raw JSON tab."}
+
+ )}
+ {log.is_large_payload_response && !log.output_message && !log.responses_output?.length && log.status !== "processing" && (
+
+ Large payload response — response content was streamed directly to the client and is not available for display.
+ {log.raw_response && " A truncated preview is available in the Raw JSON tab."}
+
+ )}
- {log.status !== "processing" &&
- log.embedding_output &&
- log.embedding_output.length > 0 &&
- !log.error_details?.error.message && (
-
-
Embedding
-
embedding.embedding,
- ),
- null,
- 2,
- ),
- }}
- />
-
- )}
- {log.status !== "processing" &&
- log.rerank_output &&
- !log.error_details?.error.message && (
- JSON.stringify(log.rerank_output, null, 2)}
- >
-
-
- )}
+ {log.status !== "processing" && log.embedding_output && log.embedding_output.length > 0 && !log.error_details?.error.message && (
+
+
Embedding
+
embedding.embedding),
+ null,
+ 2,
+ ),
+ }}
+ />
+
+ )}
+ {log.status !== "processing" && log.rerank_output && !log.error_details?.error.message && (
+ JSON.stringify(log.rerank_output, null, 2)}>
+
+
+ )}
- {log.list_models_output && (
- JSON.stringify(log.list_models_output, null, 2)}
- >
-
-
- )}
+ {log.list_models_output && (
+ JSON.stringify(log.list_models_output, null, 2)}
+ >
+
+
+ )}
- {(log.error_details?.error.message ||
- log.error_details?.error.error != null) && (
-
-
-
-
Error
- {log.error_details?.error.message ? (
-
- ) : null}
-
- {log.error_details?.error.message ? (
-
- {log.error_details.error.message}
-
- ) : null}
- {log.error_details?.error.error != null ? (
-
-
- Details
-
-
-
- {typeof log.error_details.error.error === "string"
- ? log.error_details.error.error
- : JSON.stringify(log.error_details.error.error, null, 2)}
-
-
- ) : null}
-
- )}
-
+ {(log.error_details?.error.message || log.error_details?.error.error != null) && (
+
+
+
+
Error
+ {log.error_details?.error.message ?
: null}
+
+ {log.error_details?.error.message ? (
+
+ {log.error_details.error.message}
+
+ ) : null}
+ {log.error_details?.error.error != null ? (
+
+
+ Details
+
+
+
+ {typeof log.error_details.error.error === "string"
+ ? log.error_details.error.error
+ : JSON.stringify(log.error_details.error.error, null, 2)}
+
+
+ ) : null}
+
+ )}
+
-
- {toolsParameter ? (
-
-
- {log.params?.tools?.length ?? 0} tools exposed to the model
- {(log.params as any)?.tool_choice != null ? (
- <>
- {" "}
- · tool_choice ={" "}
-
- {formatToolChoice((log.params as any).tool_choice)}
-
- >
- ) : null}
-
-
- {(log.params?.tools as any[]).map((tool, i) => {
- const name =
- tool?.function?.name ?? tool?.name ?? `tool_${i}`;
- const description =
- tool?.function?.description ?? tool?.description ?? "";
- const schema =
- tool?.function?.parameters ??
- tool?.input_schema ??
- tool?.parameters ??
- null;
- const schemaJson =
- schema != null ? JSON.stringify(schema, null, 2) : "";
- return (
-
-
-
-
-
-
-
- {name}
-
- {description ? (
-
- {description}
-
- ) : null}
-
-
-
- {schemaJson ? (
-
-
- Parameters
-
-
-
- {schemaJson}
-
-
- ) : (
-
- No parameter schema.
-
- )}
-
- );
- })}
-
-
- ) : null}
- {log.params?.instructions && (
- log.params?.instructions || ""}
- >
-
- {log.params.instructions}
-
-
- )}
- {!toolsParameter && !log.params?.instructions && (
-
- No tools or instructions on this request.
-
- )}
-
+
+ {toolsParameter ? (
+
+
+ {log.params?.tools?.length ?? 0} tools exposed to the model
+ {(log.params as any)?.tool_choice != null ? (
+ <>
+ {" "}
+ · tool_choice ={" "}
+ {formatToolChoice((log.params as any).tool_choice)}
+ >
+ ) : null}
+
+
+ {(log.params?.tools as any[]).map((tool, i) => {
+ const name = tool?.function?.name ?? tool?.name ?? `tool_${i}`;
+ const description = tool?.function?.description ?? tool?.description ?? "";
+ const schema = tool?.function?.parameters ?? tool?.input_schema ?? tool?.parameters ?? null;
+ const schemaJson = schema != null ? JSON.stringify(schema, null, 2) : "";
+ return (
+
+
+
+
+
+
+
{name}
+ {description ?
{description}
: null}
+
+
+
+ {schemaJson ? (
+
+
+ Parameters
+
+
+
+ {schemaJson}
+
+
+ ) : (
+ No parameter schema.
+ )}
+
+ );
+ })}
+
+
+ ) : null}
+ {log.params?.instructions && (
+ log.params?.instructions || ""}>
+
+ {log.params.instructions}
+
+
+ )}
+ {!toolsParameter && !log.params?.instructions && (
+
+ No tools or instructions on this request.
+
+ )}
+
-
- {log.attempt_trail && log.attempt_trail.length > 1 && (
- JSON.stringify(log.attempt_trail, null, 2)}
- >
-
-
-
-
- #
- Key
- Result
-
-
-
- {log.attempt_trail.map((record) => (
-
-
- {record.attempt + 1}
-
-
- {record.key_name || record.key_id}
-
-
- {record.fail_reason ? (
-
- {record.fail_reason}
-
- ) : (
-
- success
-
- )}
-
-
- ))}
-
-
-
-
- )}
- {log.routing_engine_logs ? (
-
- ) : (
-
- No routing logs for this request.
-
- )}
-
+
+ {log.attempt_trail && log.attempt_trail.length > 1 && (
+ JSON.stringify(log.attempt_trail, null, 2)}
+ >
+
+
+
+
+ #
+ Key
+ Result
+
+
+
+ {log.attempt_trail.map((record) => (
+
+ {record.attempt + 1}
+ {record.key_name || record.key_id}
+
+ {record.fail_reason ? (
+ {record.fail_reason}
+ ) : (
+ success
+ )}
+
+
+ ))}
+
+
+
+
+ )}
+ {log.routing_engine_logs ? (
+
+ ) : (
+
+ No routing logs for this request.
+
+ )}
+
-
- {log.plugin_logs ? (
-
- ) : (
-
- No plugin logs for this request.
-
- )}
-
+
+ {log.plugin_logs ? (
+
+ ) : (
+
+ No plugin logs for this request.
+
+ )}
+
-
- {rawRequest && (
- <>
-
- Raw Request sent to{" "}
-
- {log.provider}
-
- {log.is_large_payload_request && (
-
- (truncated preview)
-
- )}
-
- formatJsonSafe(rawRequest)}
- >
-
-
- >
- )}
- {rawResponse && log.status !== "processing" && (
- <>
-
- Raw Response from{" "}
-
- {log.provider}
-
- {log.is_large_payload_response && (
-
- (truncated preview)
-
- )}
-
- formatJsonSafe(rawResponse)}
- >
-
-
- >
- )}
- {!rawRequest &&
- !rawResponse &&
- !passthroughRequestBody &&
- !passthroughResponseBody && (
-
- No raw JSON available.
-
- )}
-
-
- >
- );
+
+ {rawRequest && (
+ <>
+
+ Raw Request sent to {log.provider}
+ {log.is_large_payload_request && (
+ (truncated preview)
+ )}
+
+ formatJsonSafe(rawRequest)}
+ >
+
+
+ >
+ )}
+ {rawResponse && log.status !== "processing" && (
+ <>
+
+ Raw Response from {log.provider}
+ {log.is_large_payload_response && (
+ (truncated preview)
+ )}
+
+ formatJsonSafe(rawResponse)}
+ >
+
+
+ >
+ )}
+ {!rawRequest && !rawResponse && !passthroughRequestBody && !passthroughResponseBody && (
+ No raw JSON available.
+ )}
+
+
+ >
+ );
}
-const copyRequestBody = async (
- log: LogEntry,
- copy: (text: string) => Promise
,
-) => {
- try {
- const isChat =
- log.object === "chat.completion" ||
- log.object === "chat_completion" ||
- log.object === "chat.completion.chunk";
- const isResponses =
- log.object === "response" || log.object === "response.completion.chunk";
- const isRealtimeTurn = log.object === "realtime.turn";
- const isSpeech =
- log.object === "audio.speech" || log.object === "audio.speech.chunk";
- const isTextCompletion =
- log.object === "text.completion" ||
- log.object === "text.completion.chunk";
- const isEmbedding = log.object === "list";
+const copyRequestBody = async (log: LogEntry, copy: (text: string) => Promise) => {
+ try {
+ const isChat = log.object === "chat.completion" || log.object === "chat_completion" || log.object === "chat.completion.chunk";
+ const isResponses = log.object === "response" || log.object === "response.completion.chunk";
+ const isRealtimeTurn = log.object === "realtime.turn";
+ const isSpeech = log.object === "audio.speech" || log.object === "audio.speech.chunk";
+ const isTextCompletion = log.object === "text.completion" || log.object === "text.completion.chunk";
+ const isEmbedding = log.object === "list";
- const extractTextFromMessage = (message: any): string => {
- if (!message || !message.content) {
- return "";
- }
- if (typeof message.content === "string") {
- return message.content;
- }
- if (Array.isArray(message.content)) {
- return message.content
- .filter((block: any) => block && block.type === "text" && block.text)
- .map((block: any) => block.text)
- .join("\n");
- }
- return "";
- };
+ const extractTextFromMessage = (message: any): string => {
+ if (!message || !message.content) {
+ return "";
+ }
+ if (typeof message.content === "string") {
+ return message.content;
+ }
+ if (Array.isArray(message.content)) {
+ return message.content
+ .filter((block: any) => block && block.type === "text" && block.text)
+ .map((block: any) => block.text)
+ .join("\n");
+ }
+ return "";
+ };
- const extractTextsFromMessage = (message: any): string[] => {
- if (!message || !message.content) {
- return [];
- }
- if (typeof message.content === "string") {
- return message.content ? [message.content] : [];
- }
- if (Array.isArray(message.content)) {
- return message.content
- .filter((block: any) => block && block.type === "text" && block.text)
- .map((block: any) => block.text);
- }
- return [];
- };
+ const extractTextsFromMessage = (message: any): string[] => {
+ if (!message || !message.content) {
+ return [];
+ }
+ if (typeof message.content === "string") {
+ return message.content ? [message.content] : [];
+ }
+ if (Array.isArray(message.content)) {
+ return message.content.filter((block: any) => block && block.type === "text" && block.text).map((block: any) => block.text);
+ }
+ return [];
+ };
- const isSupportedType =
- isChat ||
- isResponses ||
- isRealtimeTurn ||
- isSpeech ||
- isTextCompletion ||
- isEmbedding;
- if (!isSupportedType) {
- if (
- log.object === "audio.transcription" ||
- log.object === "audio.transcription.chunk"
- ) {
- toast.error(
- "Copy request body is not available for transcription requests",
- );
- } else {
- toast.error(
- "Copy request body is only available for chat, responses, speech, text completion, and embedding requests",
- );
- }
- return;
- }
+ const isSupportedType = isChat || isResponses || isRealtimeTurn || isSpeech || isTextCompletion || isEmbedding;
+ if (!isSupportedType) {
+ if (log.object === "audio.transcription" || log.object === "audio.transcription.chunk") {
+ toast.error("Copy request body is not available for transcription requests");
+ } else {
+ toast.error("Copy request body is only available for chat, responses, speech, text completion, and embedding requests");
+ }
+ return;
+ }
- const requestBody: any = {
- model:
- log.provider && log.model
- ? `${log.provider}/${log.model}`
- : log.model || "",
- };
+ const requestBody: any = {
+ model: log.provider && log.model ? `${log.provider}/${log.model}` : log.model || "",
+ };
- if (isRealtimeTurn) {
- if (log.input_history && log.input_history.length > 0) {
- requestBody.messages = log.input_history;
- }
- if (log.output_message) {
- requestBody.output = log.output_message;
- }
- } else if (isChat && log.input_history && log.input_history.length > 0) {
- requestBody.messages = log.input_history;
- } else if (
- isResponses &&
- log.responses_input_history &&
- log.responses_input_history.length > 0
- ) {
- requestBody.input = log.responses_input_history;
- } else if (isSpeech && log.speech_input) {
- requestBody.input = log.speech_input.input;
- } else if (
- isTextCompletion &&
- log.input_history &&
- log.input_history.length > 0
- ) {
- const firstMessage = log.input_history[0];
- const prompt = extractTextFromMessage(firstMessage);
- if (prompt) {
- requestBody.prompt = prompt;
- }
- } else if (
- isEmbedding &&
- log.input_history &&
- log.input_history.length > 0
- ) {
- const texts: string[] = [];
- for (const message of log.input_history) {
- const messageTexts = extractTextsFromMessage(message);
- texts.push(...messageTexts);
- }
- if (texts.length > 0) {
- requestBody.input = texts.length === 1 ? texts[0] : texts;
- }
- }
+ if (isRealtimeTurn) {
+ if (log.input_history && log.input_history.length > 0) {
+ requestBody.messages = log.input_history;
+ }
+ if (log.output_message) {
+ requestBody.output = log.output_message;
+ }
+ } else if (isChat && log.input_history && log.input_history.length > 0) {
+ requestBody.messages = log.input_history;
+ } else if (isResponses && log.responses_input_history && log.responses_input_history.length > 0) {
+ requestBody.input = log.responses_input_history;
+ } else if (isSpeech && log.speech_input) {
+ requestBody.input = log.speech_input.input;
+ } else if (isTextCompletion && log.input_history && log.input_history.length > 0) {
+ const firstMessage = log.input_history[0];
+ const prompt = extractTextFromMessage(firstMessage);
+ if (prompt) {
+ requestBody.prompt = prompt;
+ }
+ } else if (isEmbedding && log.input_history && log.input_history.length > 0) {
+ const texts: string[] = [];
+ for (const message of log.input_history) {
+ const messageTexts = extractTextsFromMessage(message);
+ texts.push(...messageTexts);
+ }
+ if (texts.length > 0) {
+ requestBody.input = texts.length === 1 ? texts[0] : texts;
+ }
+ }
- if (log.params) {
- const paramsCopy = { ...log.params };
- delete paramsCopy.tools;
- delete paramsCopy.instructions;
- Object.assign(requestBody, paramsCopy);
- }
+ if (log.params) {
+ const paramsCopy = { ...log.params };
+ delete paramsCopy.tools;
+ delete paramsCopy.instructions;
+ Object.assign(requestBody, paramsCopy);
+ }
- if (
- (isChat || isResponses || isRealtimeTurn) &&
- log.params?.tools &&
- Array.isArray(log.params.tools) &&
- log.params.tools.length > 0
- ) {
- requestBody.tools = log.params.tools;
- }
- if ((isResponses || isRealtimeTurn) && log.params?.instructions) {
- requestBody.instructions = log.params.instructions;
- }
+ if ((isChat || isResponses || isRealtimeTurn) && log.params?.tools && Array.isArray(log.params.tools) && log.params.tools.length > 0) {
+ requestBody.tools = log.params.tools;
+ }
+ if ((isResponses || isRealtimeTurn) && log.params?.instructions) {
+ requestBody.instructions = log.params.instructions;
+ }
- const requestBodyJson = JSON.stringify(requestBody, null, 2);
- await copy(requestBodyJson);
- } catch {
- toast.error("Failed to copy request body");
- }
-};
+ const requestBodyJson = JSON.stringify(requestBody, null, 2);
+ await copy(requestBodyJson);
+ } catch {
+ toast.error("Failed to copy request body");
+ }
+};
\ No newline at end of file
diff --git a/ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx b/ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
index 4e81276f12..dd3699c896 100644
--- a/ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
+++ b/ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
@@ -179,7 +179,6 @@ export function SessionDetailsSheet({
loadSessionPage(0, true);
}, [open, sessionId, sortOrder, loadSessionPage]);
-
return (
diff --git a/ui/app/workspace/logs/views/columns.tsx b/ui/app/workspace/logs/views/columns.tsx
index e887e144c1..4a2381157c 100644
--- a/ui/app/workspace/logs/views/columns.tsx
+++ b/ui/app/workspace/logs/views/columns.tsx
@@ -1,526 +1,384 @@
-import {
- formatCost,
- formatLatency,
- formatTokens,
-} from "@/app/workspace/dashboard/utils/chartUtils";
+import { formatCost, formatLatency, formatTokens } from "@/app/workspace/dashboard/utils/chartUtils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdownMenu";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
-import {
- getProviderLabel,
- ProviderName,
- RequestTypeColors,
- RequestTypeLabels,
- Status,
- StatusBarColors,
-} from "@/lib/constants/logs";
-import {
- ChatMessageContent,
- LogEntry,
- ResponsesMessageContentBlock,
-} from "@/lib/types/logs";
+import { getProviderLabel, ProviderName, RequestTypeColors, RequestTypeLabels, Status, StatusBarColors } from "@/lib/constants/logs";
+import { ChatMessageContent, LogEntry, ResponsesMessageContentBlock } from "@/lib/types/logs";
import { cn } from "@/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { ArrowUpDown, MoreHorizontal, Trash2 } from "lucide-react";
function getAssistantToolCallSummary(log?: LogEntry): string {
- const toolCalls = log?.output_message?.tool_calls || [];
- return toolCalls
- .map((toolCall) => {
- const name = toolCall?.function?.name;
- if (!name) {
- return "";
- }
- const argumentsText = toolCall?.function?.arguments?.trim();
- return argumentsText ? `${name}(${argumentsText})` : name;
- })
- .filter(Boolean)
- .join("\n");
+ const toolCalls = log?.output_message?.tool_calls || [];
+ return toolCalls
+ .map((toolCall) => {
+ const name = toolCall?.function?.name;
+ if (!name) {
+ return "";
+ }
+ const argumentsText = toolCall?.function?.arguments?.trim();
+ return argumentsText ? `${name}(${argumentsText})` : name;
+ })
+ .filter(Boolean)
+ .join("\n");
}
function getMessageFromContent(content?: ChatMessageContent): string {
- if (content == undefined) {
- return "";
- }
- if (typeof content === "string") {
- return content;
- }
- let lastTextContentBlock = "";
- for (const block of content) {
- if (
- (block.type === "text" ||
- block.type === "input_text" ||
- block.type === "output_text") &&
- block.text
- ) {
- lastTextContentBlock = block.text;
- }
- }
- return lastTextContentBlock;
+ if (content == undefined) {
+ return "";
+ }
+ if (typeof content === "string") {
+ return content;
+ }
+ let lastTextContentBlock = "";
+ for (const block of content) {
+ if ((block.type === "text" || block.type === "input_text" || block.type === "output_text") && block.text) {
+ lastTextContentBlock = block.text;
+ }
+ }
+ return lastTextContentBlock;
}
export function getRealtimeTurnMessages(log?: LogEntry): {
- tool?: string;
- user?: string;
- assistant?: string;
- assistantToolCall?: string;
+ tool?: string;
+ user?: string;
+ assistant?: string;
+ assistantToolCall?: string;
} {
- const toolMessages =
- log?.input_history?.filter((message) => message.role === "tool") || [];
- const userMessages =
- log?.input_history?.filter((message) => message.role === "user") || [];
- return {
- tool:
- toolMessages
- .map((m) => getMessageFromContent(m.content))
- .filter(Boolean)
- .join("\n") || "",
- user:
- userMessages
- .map((m) => getMessageFromContent(m.content))
- .filter(Boolean)
- .join("\n") || "",
- assistant: log?.output_message
- ? getMessageFromContent(log.output_message.content)
- : "",
- assistantToolCall: getAssistantToolCallSummary(log),
- };
+ const toolMessages = log?.input_history?.filter((message) => message.role === "tool") || [];
+ const userMessages = log?.input_history?.filter((message) => message.role === "user") || [];
+ return {
+ tool:
+ toolMessages
+ .map((m) => getMessageFromContent(m.content))
+ .filter(Boolean)
+ .join("\n") || "",
+ user:
+ userMessages
+ .map((m) => getMessageFromContent(m.content))
+ .filter(Boolean)
+ .join("\n") || "",
+ assistant: log?.output_message ? getMessageFromContent(log.output_message.content) : "",
+ assistantToolCall: getAssistantToolCallSummary(log),
+ };
}
export function getMessage(log?: LogEntry) {
- if (log?.object === "list_models") {
- return "N/A";
- }
- if (log?.object === "realtime.turn") {
- const messages = getRealtimeTurnMessages(log);
- const parts = [
- messages.tool ? `Tool Result: ${messages.tool}` : "",
- messages.user ? `User: ${messages.user}` : "",
- messages.assistantToolCall
- ? `Assistant Tool Call: ${messages.assistantToolCall}`
- : "",
- messages.assistant ? `Assistant: ${messages.assistant}` : "",
- ].filter(Boolean);
- if (parts.length > 0) {
- return parts.join("\n");
- }
- return "";
- }
- if (log?.input_history && log.input_history.length > 0) {
- return getMessageFromContent(
- log.input_history[log.input_history.length - 1].content,
- );
- } else if (
- log?.responses_input_history &&
- log.responses_input_history.length > 0
- ) {
- let lastMessage =
- log.responses_input_history[log.responses_input_history.length - 1];
- let lastMessageContent = lastMessage.content;
- if (typeof lastMessageContent === "string") {
- return lastMessageContent;
- }
- let lastTextContentBlock = "";
- for (const block of (lastMessageContent ??
- []) as ResponsesMessageContentBlock[]) {
- if (block.text && block.text !== "") {
- lastTextContentBlock = block.text;
- }
- }
- // If no content found in content field, check output field for Responses API
- if (!lastTextContentBlock && lastMessage.output) {
- // Handle output field - it could be a string, an array of content blocks, or a computer tool call output data
- if (typeof lastMessage.output === "string") {
- return lastMessage.output;
- } else if (Array.isArray(lastMessage.output)) {
- return lastMessage.output.map((block) => block.text).join("\n");
- } else if (
- lastMessage.output.type &&
- lastMessage.output.type === "computer_screenshot"
- ) {
- return lastMessage.output.image_url;
- }
- }
- return lastTextContentBlock ?? "";
- } else if (log?.output_message) {
- return getMessageFromContent(log.output_message.content);
- } else if (log?.speech_input) {
- return log.speech_input.input;
- } else if (log?.transcription_input) {
- return "Audio file";
- } else if (log?.image_generation_input?.prompt) {
- return log.image_generation_input.prompt;
- }
- const obj = log?.object as string | undefined;
- if (
- obj === "image_edit" ||
- obj === "image_edit_stream" ||
- obj === "image_variation"
- ) {
- return "Image file";
- }
- if (log?.content_summary) {
- return log.content_summary;
- }
- return "";
+ if (log?.object === "list_models") {
+ return "N/A";
+ }
+ if (log?.object === "realtime.turn") {
+ const messages = getRealtimeTurnMessages(log);
+ const parts = [
+ messages.tool ? `Tool Result: ${messages.tool}` : "",
+ messages.user ? `User: ${messages.user}` : "",
+ messages.assistantToolCall ? `Assistant Tool Call: ${messages.assistantToolCall}` : "",
+ messages.assistant ? `Assistant: ${messages.assistant}` : "",
+ ].filter(Boolean);
+ if (parts.length > 0) {
+ return parts.join("\n");
+ }
+ return "";
+ }
+ if (log?.input_history && log.input_history.length > 0) {
+ return getMessageFromContent(log.input_history[log.input_history.length - 1].content);
+ } else if (log?.responses_input_history && log.responses_input_history.length > 0) {
+ let lastMessage = log.responses_input_history[log.responses_input_history.length - 1];
+ let lastMessageContent = lastMessage.content;
+ if (typeof lastMessageContent === "string") {
+ return lastMessageContent;
+ }
+ let lastTextContentBlock = "";
+ for (const block of (lastMessageContent ?? []) as ResponsesMessageContentBlock[]) {
+ if (block.text && block.text !== "") {
+ lastTextContentBlock = block.text;
+ }
+ }
+ // If no content found in content field, check output field for Responses API
+ if (!lastTextContentBlock && lastMessage.output) {
+ // Handle output field - it could be a string, an array of content blocks, or a computer tool call output data
+ if (typeof lastMessage.output === "string") {
+ return lastMessage.output;
+ } else if (Array.isArray(lastMessage.output)) {
+ return lastMessage.output.map((block) => block.text).join("\n");
+ } else if (lastMessage.output.type && lastMessage.output.type === "computer_screenshot") {
+ return lastMessage.output.image_url;
+ }
+ }
+ return lastTextContentBlock ?? "";
+ } else if (log?.output_message) {
+ return getMessageFromContent(log.output_message.content);
+ } else if (log?.speech_input) {
+ return log.speech_input.input;
+ } else if (log?.transcription_input) {
+ return "Audio file";
+ } else if (log?.image_generation_input?.prompt) {
+ return log.image_generation_input.prompt;
+ }
+ const obj = log?.object as string | undefined;
+ if (obj === "image_edit" || obj === "image_edit_stream" || obj === "image_variation") {
+ return "Image file";
+ }
+ if (log?.content_summary) {
+ return log.content_summary;
+ }
+ return "";
}
-export function LogMessageCell({
- log,
- contentClassName = "max-w-full",
-}: {
- log: LogEntry;
- contentClassName?: string;
-}) {
- const input = getMessage(log);
- const isLargePayload =
- log.is_large_payload_request || log.is_large_payload_response;
- const realtimeMessages =
- log.object === "realtime.turn" ? getRealtimeTurnMessages(log) : null;
+export function LogMessageCell({ log, contentClassName = "max-w-full" }: { log: LogEntry; contentClassName?: string }) {
+ const input = getMessage(log);
+ const isLargePayload = log.is_large_payload_request || log.is_large_payload_response;
+ const realtimeMessages = log.object === "realtime.turn" ? getRealtimeTurnMessages(log) : null;
- return (
-
- {isLargePayload && (
-
- LP
-
- )}
- {realtimeMessages &&
- (realtimeMessages.tool ||
- realtimeMessages.user ||
- realtimeMessages.assistantToolCall ||
- realtimeMessages.assistant) ? (
-
- {realtimeMessages.tool ? (
-
Tool Result: {realtimeMessages.tool}
- ) : null}
- {realtimeMessages.user ? (
-
User: {realtimeMessages.user}
- ) : null}
- {realtimeMessages.assistantToolCall ? (
-
- Assistant Tool Call: {realtimeMessages.assistantToolCall}
-
- ) : null}
- {realtimeMessages.assistant ? (
-
- Assistant: {realtimeMessages.assistant}
-
- ) : null}
-
- ) : (
-
- {input ||
- (isLargePayload
- ? `Large payload ${log.is_large_payload_request && log.is_large_payload_response ? "request & response" : log.is_large_payload_request ? "request" : "response"}`
- : "-")}
-
- )}
-
- );
+ return (
+
+ {isLargePayload && (
+
+ LP
+
+ )}
+ {realtimeMessages &&
+ (realtimeMessages.tool || realtimeMessages.user || realtimeMessages.assistantToolCall || realtimeMessages.assistant) ? (
+
+ {realtimeMessages.tool ?
Tool Result: {realtimeMessages.tool}
: null}
+ {realtimeMessages.user ?
User: {realtimeMessages.user}
: null}
+ {realtimeMessages.assistantToolCall ? (
+
Assistant Tool Call: {realtimeMessages.assistantToolCall}
+ ) : null}
+ {realtimeMessages.assistant ?
Assistant: {realtimeMessages.assistant}
: null}
+
+ ) : (
+
+ {input ||
+ (isLargePayload
+ ? `Large payload ${log.is_large_payload_request && log.is_large_payload_response ? "request & response" : log.is_large_payload_request ? "request" : "response"}`
+ : "-")}
+
+ )}
+
+ );
}
export const createColumns = (
- onDelete: (log: LogEntry) => void,
- hasDeleteAccess = true,
- metadataKeys: string[] = [],
+ onDelete: (log: LogEntry) => void,
+ hasDeleteAccess = true,
+ metadataKeys: string[] = [],
): ColumnDef[] => {
- const baseColumns: ColumnDef[] = [
- {
- accessorKey: "status",
- header: "",
- size: 8,
- maxSize: 8,
- cell: ({ row }) => {
- const status = row.original.status as Status;
- return (
-
- );
- },
- },
- {
- accessorKey: "timestamp",
- header: ({ column }) => (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Time
-
-
- ),
- size: 130,
- cell: ({ row }) => {
- const timestamp = row.original.timestamp;
- const date = timestamp ? new Date(timestamp) : null;
- const isValid = date && date.toString() !== "Invalid Date";
- if (!isValid) {
- return N/A
;
- }
- return (
-
-
- {format(date, "MMM dd HH:mm:ss")}
-
-
- {formatDistanceToNow(date, { addSuffix: true })}
-
-
- );
- },
- },
- {
- id: "request_type",
- header: "Type",
- size: 150,
- cell: ({ row }) => {
- return (
-
- {
- RequestTypeLabels[
- row.original.object as keyof typeof RequestTypeLabels
- ]
- }
-
- );
- },
- },
- {
- accessorKey: "input",
- header: "Message",
- size: 350,
- cell: ({ row }) => ,
- },
- {
- accessorKey: "model",
- header: "Model",
- size: 190,
- cell: ({ row }) => {
- const provider = row.original.provider as ProviderName | undefined;
- const model = row.original.model;
- return (
-
- {provider ? (
-
- ) : null}
-
-
- {model || "N/A"}
-
-
- {provider ? getProviderLabel(provider) : "N/A"}
-
-
-
- );
- },
- },
- {
- accessorKey: "latency",
- header: ({ column }) => (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Latency
-
-
- ),
- size: 170,
- cell: ({ row }) => {
- const latency = row.original.latency;
- if (latency === undefined || latency === null) {
- return N/A
;
- }
- const tone =
- latency >= 5000
- ? "bg-red-500"
- : latency >= 2000
- ? "bg-amber-500"
- : "bg-emerald-500";
- const pct = Math.min(100, (latency / 5000) * 100);
- return (
-
-
- {formatLatency(latency)}
-
-
-
- );
- },
- },
- {
- accessorKey: "tokens",
- header: ({ column }) => (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Tokens
-
-
- ),
- size: 190,
- cell: ({ row }) => {
- const tokenUsage = row.original.token_usage;
- if (!tokenUsage) {
- return N/A
;
- }
- const prompt = tokenUsage.prompt_tokens ?? 0;
- const completion = tokenUsage.completion_tokens ?? 0;
- const total = tokenUsage.total_tokens ?? 0;
- const hasSplit =
- tokenUsage.completion_tokens != null &&
- tokenUsage.prompt_tokens != null;
- const splitBase = prompt + completion || 1;
- const inPct = (prompt / splitBase) * 100;
- return (
-
-
-
- {formatTokens(total)}
-
- {hasSplit && (
-
- )}
-
- {hasSplit && (
-
- {formatTokens(prompt)}
- /
-
- {formatTokens(completion)}
-
-
- )}
-
- );
- },
- },
- {
- accessorKey: "cost",
- header: ({ column }) => (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Cost
-
-
- ),
- size: 120,
- cell: ({ row }) => {
- if (row.original.cost == null) {
- return N/A
;
- }
- return (
-
- {formatCost(row.original.cost)}
-
- );
- },
- },
- ];
+ const baseColumns: ColumnDef[] = [
+ {
+ accessorKey: "status",
+ header: "",
+ size: 8,
+ maxSize: 8,
+ cell: ({ row }) => {
+ const status = row.original.status as Status;
+ return
;
+ },
+ },
+ {
+ accessorKey: "timestamp",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}>
+ Time
+
+
+ ),
+ size: 130,
+ cell: ({ row }) => {
+ const timestamp = row.original.timestamp;
+ const date = timestamp ? new Date(timestamp) : null;
+ const isValid = date && date.toString() !== "Invalid Date";
+ if (!isValid) {
+ return N/A
;
+ }
+ return (
+
+ {format(date, "MMM dd HH:mm:ss")}
+ {formatDistanceToNow(date, { addSuffix: true })}
+
+ );
+ },
+ },
+ {
+ id: "request_type",
+ header: "Type",
+ size: 150,
+ cell: ({ row }) => {
+ return (
+
+ {RequestTypeLabels[row.original.object as keyof typeof RequestTypeLabels]}
+
+ );
+ },
+ },
+ {
+ accessorKey: "input",
+ header: "Message",
+ size: 350,
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "model",
+ header: "Model",
+ size: 190,
+ cell: ({ row }) => {
+ const provider = row.original.provider as ProviderName | undefined;
+ const model = row.original.model;
+ return (
+
+ {provider ?
: null}
+
+ {model || "N/A"}
+ {provider ? getProviderLabel(provider) : "N/A"}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "latency",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}>
+ Latency
+
+
+ ),
+ size: 170,
+ cell: ({ row }) => {
+ const latency = row.original.latency;
+ if (latency === undefined || latency === null) {
+ return N/A
;
+ }
+ const tone = latency >= 5000 ? "bg-red-500" : latency >= 2000 ? "bg-amber-500" : "bg-emerald-500";
+ const pct = Math.min(100, (latency / 5000) * 100);
+ return (
+
+
{formatLatency(latency)}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "tokens",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}>
+ Tokens
+
+
+ ),
+ size: 190,
+ cell: ({ row }) => {
+ const tokenUsage = row.original.token_usage;
+ if (!tokenUsage) {
+ return N/A
;
+ }
+ const prompt = tokenUsage.prompt_tokens ?? 0;
+ const completion = tokenUsage.completion_tokens ?? 0;
+ const total = tokenUsage.total_tokens ?? 0;
+ const hasSplit = tokenUsage.completion_tokens != null && tokenUsage.prompt_tokens != null;
+ const splitBase = prompt + completion || 1;
+ const inPct = (prompt / splitBase) * 100;
+ return (
+
+
+
{formatTokens(total)}
+ {hasSplit && (
+
+ )}
+
+ {hasSplit && (
+
+ {formatTokens(prompt)}
+ /
+ {formatTokens(completion)}
+
+ )}
+
+ );
+ },
+ },
+ {
+ accessorKey: "cost",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}>
+ Cost
+
+
+ ),
+ size: 120,
+ cell: ({ row }) => {
+ if (row.original.cost == null) {
+ return N/A
;
+ }
+ return {formatCost(row.original.cost)}
;
+ },
+ },
+ ];
- const metadataColumns: ColumnDef[] = metadataKeys.map((key) => ({
- id: `metadata_${key}`,
- header: key.charAt(0).toUpperCase() + key.slice(1),
- size: 126,
- cell: ({ row }) => {
- const value = row.original.metadata?.[key];
- return (
-
- {value ?? "-"}
-
- );
- },
- }));
+ const metadataColumns: ColumnDef[] = metadataKeys.map((key) => ({
+ id: `metadata_${key}`,
+ header: key.charAt(0).toUpperCase() + key.slice(1),
+ size: 126,
+ cell: ({ row }) => {
+ const value = row.original.metadata?.[key];
+ return {value ?? "-"}
;
+ },
+ }));
- const actionsColumn: ColumnDef[] = hasDeleteAccess
- ? [
- {
- id: "actions",
- header: "",
- size: 56,
- cell: ({ row }) => {
- const log = row.original;
- return (
-
-
- event.stopPropagation()}>
-
-
-
-
-
- {
- event.stopPropagation();
- onDelete(log);
- }}
- >
-
- Delete
-
-
-
-
- );
- },
- },
- ]
- : [];
+ const actionsColumn: ColumnDef[] = hasDeleteAccess
+ ? [
+ {
+ id: "actions",
+ header: "",
+ size: 56,
+ cell: ({ row }) => {
+ const log = row.original;
+ return (
+
+
+ event.stopPropagation()}>
+
+
+
+
+
+ {
+ event.stopPropagation();
+ onDelete(log);
+ }}
+ >
+
+ Delete
+
+
+
+
+ );
+ },
+ },
+ ]
+ : [];
- return [...baseColumns, ...metadataColumns, ...actionsColumn];
-};
+ return [...baseColumns, ...metadataColumns, ...actionsColumn];
+};
\ No newline at end of file
diff --git a/ui/app/workspace/logs/views/logChatMessageView.tsx b/ui/app/workspace/logs/views/logChatMessageView.tsx
index 2efa98be10..597ba825d2 100644
--- a/ui/app/workspace/logs/views/logChatMessageView.tsx
+++ b/ui/app/workspace/logs/views/logChatMessageView.tsx
@@ -174,7 +174,12 @@ export default function LogChatMessageView({ message, audioFormat }: LogChatMess
{message.tool_calls.map((toolCall, index) => {
const jsonContent = JSON.stringify(toolCall, null, 2);
return (
- jsonContent} collapsedHeight={100}>
+ jsonContent}
+ collapsedHeight={100}
+ >
!["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(key),
+ (key) =>
+ !["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
+ key,
+ ),
) && (
- !["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
- key,
- ),
+ ![
+ "id",
+ "type",
+ "status",
+ "role",
+ "content",
+ "call_id",
+ "name",
+ "arguments",
+ "summary",
+ "encrypted_content",
+ "output",
+ ].includes(key),
),
),
null,
@@ -414,9 +427,19 @@ function MessageView({ message, index }: { message: ResponsesMessage; index: num
Object.fromEntries(
Object.entries(message).filter(
([key]) =>
- !["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
- key,
- ),
+ ![
+ "id",
+ "type",
+ "status",
+ "role",
+ "content",
+ "call_id",
+ "name",
+ "arguments",
+ "summary",
+ "encrypted_content",
+ "output",
+ ].includes(key),
),
),
null,
diff --git a/ui/app/workspace/logs/views/logsTable.tsx b/ui/app/workspace/logs/views/logsTable.tsx
index 08c5b1e04e..9cb08210ec 100644
--- a/ui/app/workspace/logs/views/logsTable.tsx
+++ b/ui/app/workspace/logs/views/logsTable.tsx
@@ -177,10 +177,12 @@ export function LogsDataTable({
- {loading ? <>
-
- Loading logs...
- > : polling ? (
+ {loading ? (
+ <>
+
+ Loading logs...
+ >
+ ) : polling ? (
<>
Waiting for new logs...
@@ -279,4 +281,4 @@ export function LogsDataTable({
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/logs/views/logsVolumeChart.tsx b/ui/app/workspace/logs/views/logsVolumeChart.tsx
index 48fbfc389d..2138c36813 100644
--- a/ui/app/workspace/logs/views/logsVolumeChart.tsx
+++ b/ui/app/workspace/logs/views/logsVolumeChart.tsx
@@ -1,566 +1,462 @@
import { Card } from "@/components/ui/card";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Skeleton } from "@/components/ui/skeleton";
import type { HistogramBucket, LogsHistogramResponse, MCPHistogramResponse } from "@/lib/types/logs";
import { getUnixRangeForPeriod } from "@/lib/utils/timeRange";
import { ChevronDown, RotateCcw } from "lucide-react";
-import {
- Component,
- type ErrorInfo,
- type ReactNode,
- useCallback,
- useMemo,
- useRef,
- useState,
-} from "react";
-import {
- Bar,
- BarChart,
- CartesianGrid,
- ReferenceArea,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
+import { Component, type ErrorInfo, type ReactNode, useCallback, useMemo, useRef, useState } from "react";
+import { Bar, BarChart, CartesianGrid, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
const requestFormatter = new Intl.NumberFormat("en-US", {
- notation: "compact",
- maximumFractionDigits: 1,
+ notation: "compact",
+ maximumFractionDigits: 1,
});
function formatRequest(requests: number): string {
- return requestFormatter.format(requests);
+ return requestFormatter.format(requests);
}
// Empty chart placeholder when data fails to render
function EmptyChart() {
- return (
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+ );
}
// Error boundary to catch Recharts rendering errors
-class ChartErrorBoundary extends Component<
- { children: ReactNode; resetKey?: string },
- { hasError: boolean }
-> {
- constructor(props: { children: ReactNode; resetKey?: string }) {
- super(props);
- this.state = { hasError: false };
- }
-
- static getDerivedStateFromError(_: Error) {
- return { hasError: true };
- }
-
- static getDerivedStateFromProps(
- props: { resetKey?: string },
- state: { hasError: boolean; prevResetKey?: string },
- ) {
- // Reset error state when resetKey changes
- if (props.resetKey !== state.prevResetKey) {
- return { hasError: false, prevResetKey: props.resetKey };
- }
- return null;
- }
-
- componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
- console.warn("Chart rendering error:", error.message);
- }
-
- render() {
- if (this.state.hasError) {
- return
;
- }
- return this.props.children;
- }
+class ChartErrorBoundary extends Component<{ children: ReactNode; resetKey?: string }, { hasError: boolean }> {
+ constructor(props: { children: ReactNode; resetKey?: string }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(_: Error) {
+ return { hasError: true };
+ }
+
+ static getDerivedStateFromProps(props: { resetKey?: string }, state: { hasError: boolean; prevResetKey?: string }) {
+ // Reset error state when resetKey changes
+ if (props.resetKey !== state.prevResetKey) {
+ return { hasError: false, prevResetKey: props.resetKey };
+ }
+ return null;
+ }
+
+ componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
+ console.warn("Chart rendering error:", error.message);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return
;
+ }
+ return this.props.children;
+ }
}
interface LogsVolumeChartProps {
- data: LogsHistogramResponse | MCPHistogramResponse | null;
- loading?: boolean;
- onTimeRangeChange: (startTime: number, endTime: number) => void;
- onResetZoom?: () => void;
- isZoomed?: boolean;
- startTime: number; // Unix timestamp in seconds
- endTime: number; // Unix timestamp in seconds
- isOpen: boolean;
- period?: string,
- onOpenChange: (open: boolean) => void;
+ data: LogsHistogramResponse | MCPHistogramResponse | null;
+ loading?: boolean;
+ onTimeRangeChange: (startTime: number, endTime: number) => void;
+ onResetZoom?: () => void;
+ isZoomed?: boolean;
+ startTime: number; // Unix timestamp in seconds
+ endTime: number; // Unix timestamp in seconds
+ isOpen: boolean;
+ period?: string;
+ onOpenChange: (open: boolean) => void;
}
// Format timestamp based on bucket size
function formatTimestamp(timestamp: string, bucketSizeSeconds: number): string {
- const date = new Date(timestamp);
-
- if (bucketSizeSeconds >= 86400) {
- // Daily buckets: "Jan 20"
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
- } else if (bucketSizeSeconds >= 3600) {
- // Hourly buckets: "10:00"
- return date.toLocaleTimeString("en-US", {
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- });
- } else {
- // Sub-hourly: "10:15"
- return date.toLocaleTimeString("en-US", {
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- });
- }
+ const date = new Date(timestamp);
+
+ if (bucketSizeSeconds >= 86400) {
+ // Daily buckets: "Jan 20"
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ } else if (bucketSizeSeconds >= 3600) {
+ // Hourly buckets: "10:00"
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ } else {
+ // Sub-hourly: "10:15"
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ }
}
// Format full timestamp for tooltip
function formatFullTimestamp(timestamp: string): string {
- const date = new Date(timestamp);
- return date.toLocaleString("en-US", {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- });
+ const date = new Date(timestamp);
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
}
type LogVolumeDataPoint = HistogramBucket & {
- formattedTime: string;
- index?: number;
+ formattedTime: string;
+ index?: number;
};
interface CustomTooltipProps {
- active?: boolean;
- payload?: Array<{ payload?: LogVolumeDataPoint }>;
+ active?: boolean;
+ payload?: Array<{ payload?: LogVolumeDataPoint }>;
}
type ChartMouseEvent = { activeTooltipIndex?: number | string | null };
// Custom tooltip component
function CustomTooltip({ active, payload }: CustomTooltipProps) {
- if (!active || !payload || !payload.length) return null;
-
- const data = payload[0]?.payload;
- if (!data) return null;
-
- return (
-
-
- {formatFullTimestamp(data.timestamp)}
-
-
-
-
-
- Total
-
- {data.count.toLocaleString()}
-
-
-
-
- Success
-
-
- {data.success.toLocaleString()}
-
-
-
-
-
- Error
-
-
- {data.error.toLocaleString()}
-
-
-
-
- );
+ if (!active || !payload || !payload.length) return null;
+
+ const data = payload[0]?.payload;
+ if (!data) return null;
+
+ return (
+
+
{formatFullTimestamp(data.timestamp)}
+
+
+
+
+ Total
+
+ {data.count.toLocaleString()}
+
+
+
+
+ Success
+
+ {data.success.toLocaleString()}
+
+
+
+
+ Error
+
+ {data.error.toLocaleString()}
+
+
+
+ );
}
export function LogsVolumeChart({
- data,
- loading,
- onTimeRangeChange,
- onResetZoom,
- isZoomed,
- startTime,
- endTime,
- isOpen,
- period,
- onOpenChange,
+ data,
+ loading,
+ onTimeRangeChange,
+ onResetZoom,
+ isZoomed,
+ startTime,
+ endTime,
+ isOpen,
+ period,
+ onOpenChange,
}: LogsVolumeChartProps) {
- // State for drag selection
- const [refAreaLeft, setRefAreaLeft] = useState
(null);
- const [refAreaRight, setRefAreaRight] = useState(null);
- const [isSelecting, setIsSelecting] = useState(false);
- // Suppress the Bar onClick that fires immediately after a drag-select mouseUp,
- // otherwise Recharts overwrites the dragged range with a single-bucket zoom.
- const suppressNextBarClickRef = useRef(false);
-
- const effectingTimeRange = useMemo(() => {
- if (period) {
- const { start, end } = getUnixRangeForPeriod(period)
- return { startTime: start, endTime: end }
- }
-
- return { startTime, endTime }
- }, [period, startTime, endTime])
-
- // Transform data for chart, filling in empty buckets for the full time range
- const chartData = useMemo(() => {
- // Need bucket_size_seconds and valid time range
- if (
- !data?.bucket_size_seconds ||
- !effectingTimeRange.startTime ||
- !effectingTimeRange.endTime ||
- effectingTimeRange.startTime >= effectingTimeRange.endTime
- ) {
- return [];
- }
-
- const bucketSizeMs = data.bucket_size_seconds * 1000;
-
- // Align start time to bucket boundary
- const minTime =
- Math.floor((effectingTimeRange.startTime * 1000) / bucketSizeMs) * bucketSizeMs;
- const maxTime = effectingTimeRange.endTime * 1000;
-
- // Safety: limit maximum number of buckets to prevent performance issues
- const maxBuckets = 500;
- const estimatedBuckets = Math.ceil((maxTime - minTime) / bucketSizeMs);
-
- if (estimatedBuckets > maxBuckets) {
- // If too many buckets, just return the original data without filling
- const result = (data.buckets || []).map((bucket, index) => ({
- ...bucket,
- index,
- formattedTime: formatTimestamp(
- bucket.timestamp,
- data.bucket_size_seconds,
- ),
- }));
- // Ensure at least 2 data points for Recharts
- if (result.length === 1) {
- const nextTimestamp = new Date(
- new Date(result[0].timestamp).getTime() + bucketSizeMs,
- ).toISOString();
- result.push({
- timestamp: nextTimestamp,
- count: 0,
- success: 0,
- error: 0,
- index: 1,
- formattedTime: formatTimestamp(
- nextTimestamp,
- data.bucket_size_seconds,
- ),
- });
- }
- return result;
- }
-
- // First, create all empty buckets for the time range
- const filledBuckets: Array<
- HistogramBucket & { formattedTime: string; index: number }
- > = [];
- for (
- let time = minTime, idx = 0;
- time < maxTime;
- time += bucketSizeMs, idx++
- ) {
- const timestamp = new Date(time).toISOString();
- filledBuckets.push({
- timestamp,
- count: 0,
- success: 0,
- error: 0,
- index: idx,
- formattedTime: formatTimestamp(timestamp, data.bucket_size_seconds),
- });
- }
-
- // Then, place API buckets at their correct positions using index calculation
- // This is more robust than exact timestamp matching
- for (const bucket of data.buckets || []) {
- const bucketTime = new Date(bucket.timestamp).getTime();
- // Calculate the index for this bucket based on its offset from minTime
- const bucketIndex = Math.round((bucketTime - minTime) / bucketSizeMs);
-
- if (bucketIndex >= 0 && bucketIndex < filledBuckets.length) {
- filledBuckets[bucketIndex] = {
- ...bucket,
- index: bucketIndex,
- formattedTime: formatTimestamp(
- bucket.timestamp,
- data.bucket_size_seconds,
- ),
- };
- }
- }
-
- // Ensure at least 2 data points for Recharts
- if (filledBuckets.length === 1) {
- const nextTimestamp = new Date(
- new Date(filledBuckets[0].timestamp).getTime() + bucketSizeMs,
- ).toISOString();
- filledBuckets.push({
- timestamp: nextTimestamp,
- count: 0,
- success: 0,
- error: 0,
- index: 1,
- formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds),
- });
- }
-
- return filledBuckets;
- }, [data, effectingTimeRange.startTime, effectingTimeRange.endTime]);
-
- // Handle mouse down on chart (start selection)
- const handleMouseDown = useCallback((e: ChartMouseEvent) => {
- if (typeof e?.activeTooltipIndex === "number") {
- setRefAreaLeft(e.activeTooltipIndex);
- setIsSelecting(true);
- }
- }, []);
-
- // Handle mouse move on chart (during selection)
- const handleMouseMove = useCallback(
- (e: ChartMouseEvent) => {
- if (isSelecting && typeof e?.activeTooltipIndex === "number") {
- setRefAreaRight(e.activeTooltipIndex);
- }
- },
- [isSelecting],
- );
-
- // Handle mouse up on chart (end selection)
- const handleMouseUp = useCallback(() => {
- if (
- refAreaLeft === null ||
- refAreaRight === null ||
- !data?.bucket_size_seconds ||
- chartData.length === 0
- ) {
- setRefAreaLeft(null);
- setRefAreaRight(null);
- setIsSelecting(false);
- return;
- }
-
- // Get the buckets by index
- const leftBucket = chartData[refAreaLeft];
- const rightBucket = chartData[refAreaRight];
-
- if (leftBucket && rightBucket) {
- const leftTime = new Date(leftBucket.timestamp).getTime() / 1000;
- const rightTime = new Date(rightBucket.timestamp).getTime() / 1000;
-
- // Ensure left < right; the end edge is one bucket past the later timestamp
- const selectionStart = Math.min(leftTime, rightTime);
- const selectionEnd =
- Math.max(leftTime, rightTime) + data.bucket_size_seconds;
-
- // Only trigger a range change for real drags (more than one bucket).
- // For single-bucket gestures, let the trailing Bar onClick own the zoom
- // so we don't fire onTimeRangeChange twice with the same range.
- if (
- refAreaLeft !== refAreaRight &&
- selectionEnd - selectionStart >= data.bucket_size_seconds
- ) {
- suppressNextBarClickRef.current = true;
- onTimeRangeChange(selectionStart, selectionEnd);
- }
- }
-
- setRefAreaLeft(null);
- setRefAreaRight(null);
- setIsSelecting(false);
- }, [refAreaLeft, refAreaRight, data, chartData, onTimeRangeChange]);
-
- // Handle click on a bar (zoom into that bucket)
- const handleBarClick = useCallback(
- (barData: LogVolumeDataPoint | undefined) => {
- if (suppressNextBarClickRef.current) {
- suppressNextBarClickRef.current = false;
- return;
- }
- if (!data || !barData?.timestamp) return;
-
- const startTime = new Date(barData.timestamp).getTime() / 1000;
- const endTime = startTime + data.bucket_size_seconds;
-
- onTimeRangeChange(startTime, endTime);
- },
- [data, onTimeRangeChange],
- );
-
- // Check if we have valid data for the chart
- const hasValidData = data && effectingTimeRange.startTime && effectingTimeRange.endTime && chartData.length >= 2;
-
- return (
-
-
-
-
-
-
- Request Volume
-
-
-
- {isOpen && (
-
-
-
- Success
-
-
-
- Error
-
-
- )}
- {isZoomed && onResetZoom && (
-
-
- Reset zoom
-
- )}
-
-
-
-
- {loading ? (
-
- ) : hasValidData ? (
-
-
-
-
-
- chartData[Math.round(idx)]?.formattedTime || ""
- }
- interval="preserveStartEnd"
- />
- formatRequest(v)}
- domain={[0, (dataMax: number) => Math.max(dataMax, 5)]}
- allowDataOverflow={false}
- />
- }
- cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
- />
- handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
- />
- handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
- />
- {refAreaLeft !== null &&
- refAreaRight !== null &&
- chartData[refAreaLeft] &&
- chartData[refAreaRight] && (
-
- )}
-
-
-
- ) : (
-
- )}
-
-
-
-
- );
-}
+ // State for drag selection
+ const [refAreaLeft, setRefAreaLeft] = useState(null);
+ const [refAreaRight, setRefAreaRight] = useState(null);
+ const [isSelecting, setIsSelecting] = useState(false);
+ // Suppress the Bar onClick that fires immediately after a drag-select mouseUp,
+ // otherwise Recharts overwrites the dragged range with a single-bucket zoom.
+ const suppressNextBarClickRef = useRef(false);
+
+ const effectingTimeRange = useMemo(() => {
+ if (period) {
+ const { start, end } = getUnixRangeForPeriod(period);
+ return { startTime: start, endTime: end };
+ }
+
+ return { startTime, endTime };
+ }, [period, startTime, endTime]);
+
+ // Transform data for chart, filling in empty buckets for the full time range
+ const chartData = useMemo(() => {
+ // Need bucket_size_seconds and valid time range
+ if (
+ !data?.bucket_size_seconds ||
+ !effectingTimeRange.startTime ||
+ !effectingTimeRange.endTime ||
+ effectingTimeRange.startTime >= effectingTimeRange.endTime
+ ) {
+ return [];
+ }
+
+ const bucketSizeMs = data.bucket_size_seconds * 1000;
+
+ // Align start time to bucket boundary
+ const minTime = Math.floor((effectingTimeRange.startTime * 1000) / bucketSizeMs) * bucketSizeMs;
+ const maxTime = effectingTimeRange.endTime * 1000;
+
+ // Safety: limit maximum number of buckets to prevent performance issues
+ const maxBuckets = 500;
+ const estimatedBuckets = Math.ceil((maxTime - minTime) / bucketSizeMs);
+
+ if (estimatedBuckets > maxBuckets) {
+ // If too many buckets, just return the original data without filling
+ const result = (data.buckets || []).map((bucket, index) => ({
+ ...bucket,
+ index,
+ formattedTime: formatTimestamp(bucket.timestamp, data.bucket_size_seconds),
+ }));
+ // Ensure at least 2 data points for Recharts
+ if (result.length === 1) {
+ const nextTimestamp = new Date(new Date(result[0].timestamp).getTime() + bucketSizeMs).toISOString();
+ result.push({
+ timestamp: nextTimestamp,
+ count: 0,
+ success: 0,
+ error: 0,
+ index: 1,
+ formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds),
+ });
+ }
+ return result;
+ }
+
+ // First, create all empty buckets for the time range
+ const filledBuckets: Array = [];
+ for (let time = minTime, idx = 0; time < maxTime; time += bucketSizeMs, idx++) {
+ const timestamp = new Date(time).toISOString();
+ filledBuckets.push({
+ timestamp,
+ count: 0,
+ success: 0,
+ error: 0,
+ index: idx,
+ formattedTime: formatTimestamp(timestamp, data.bucket_size_seconds),
+ });
+ }
+
+ // Then, place API buckets at their correct positions using index calculation
+ // This is more robust than exact timestamp matching
+ for (const bucket of data.buckets || []) {
+ const bucketTime = new Date(bucket.timestamp).getTime();
+ // Calculate the index for this bucket based on its offset from minTime
+ const bucketIndex = Math.round((bucketTime - minTime) / bucketSizeMs);
+
+ if (bucketIndex >= 0 && bucketIndex < filledBuckets.length) {
+ filledBuckets[bucketIndex] = {
+ ...bucket,
+ index: bucketIndex,
+ formattedTime: formatTimestamp(bucket.timestamp, data.bucket_size_seconds),
+ };
+ }
+ }
+
+ // Ensure at least 2 data points for Recharts
+ if (filledBuckets.length === 1) {
+ const nextTimestamp = new Date(new Date(filledBuckets[0].timestamp).getTime() + bucketSizeMs).toISOString();
+ filledBuckets.push({
+ timestamp: nextTimestamp,
+ count: 0,
+ success: 0,
+ error: 0,
+ index: 1,
+ formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds),
+ });
+ }
+
+ return filledBuckets;
+ }, [data, effectingTimeRange.startTime, effectingTimeRange.endTime]);
+
+ // Handle mouse down on chart (start selection)
+ const handleMouseDown = useCallback((e: ChartMouseEvent) => {
+ if (typeof e?.activeTooltipIndex === "number") {
+ setRefAreaLeft(e.activeTooltipIndex);
+ setIsSelecting(true);
+ }
+ }, []);
+
+ // Handle mouse move on chart (during selection)
+ const handleMouseMove = useCallback(
+ (e: ChartMouseEvent) => {
+ if (isSelecting && typeof e?.activeTooltipIndex === "number") {
+ setRefAreaRight(e.activeTooltipIndex);
+ }
+ },
+ [isSelecting],
+ );
+
+ // Handle mouse up on chart (end selection)
+ const handleMouseUp = useCallback(() => {
+ if (refAreaLeft === null || refAreaRight === null || !data?.bucket_size_seconds || chartData.length === 0) {
+ setRefAreaLeft(null);
+ setRefAreaRight(null);
+ setIsSelecting(false);
+ return;
+ }
+
+ // Get the buckets by index
+ const leftBucket = chartData[refAreaLeft];
+ const rightBucket = chartData[refAreaRight];
+
+ if (leftBucket && rightBucket) {
+ const leftTime = new Date(leftBucket.timestamp).getTime() / 1000;
+ const rightTime = new Date(rightBucket.timestamp).getTime() / 1000;
+
+ // Ensure left < right; the end edge is one bucket past the later timestamp
+ const selectionStart = Math.min(leftTime, rightTime);
+ const selectionEnd = Math.max(leftTime, rightTime) + data.bucket_size_seconds;
+
+ // Only trigger a range change for real drags (more than one bucket).
+ // For single-bucket gestures, let the trailing Bar onClick own the zoom
+ // so we don't fire onTimeRangeChange twice with the same range.
+ if (refAreaLeft !== refAreaRight && selectionEnd - selectionStart >= data.bucket_size_seconds) {
+ suppressNextBarClickRef.current = true;
+ onTimeRangeChange(selectionStart, selectionEnd);
+ }
+ }
+
+ setRefAreaLeft(null);
+ setRefAreaRight(null);
+ setIsSelecting(false);
+ }, [refAreaLeft, refAreaRight, data, chartData, onTimeRangeChange]);
+
+ // Handle click on a bar (zoom into that bucket)
+ const handleBarClick = useCallback(
+ (barData: LogVolumeDataPoint | undefined) => {
+ if (suppressNextBarClickRef.current) {
+ suppressNextBarClickRef.current = false;
+ return;
+ }
+ if (!data || !barData?.timestamp) return;
+
+ const startTime = new Date(barData.timestamp).getTime() / 1000;
+ const endTime = startTime + data.bucket_size_seconds;
+
+ onTimeRangeChange(startTime, endTime);
+ },
+ [data, onTimeRangeChange],
+ );
+
+ // Check if we have valid data for the chart
+ const hasValidData = data && effectingTimeRange.startTime && effectingTimeRange.endTime && chartData.length >= 2;
+
+ return (
+
+
+
+
+
+ Request Volume
+
+
+ {isOpen && (
+
+
+
+ Success
+
+
+
+ Error
+
+
+ )}
+ {isZoomed && onResetZoom && (
+
+
+ Reset zoom
+
+ )}
+
+
+
+
+ {loading ? (
+
+ ) : hasValidData ? (
+
+
+
+
+ chartData[Math.round(idx)]?.formattedTime || ""}
+ interval="preserveStartEnd"
+ />
+ formatRequest(v)}
+ domain={[0, (dataMax: number) => Math.max(dataMax, 5)]}
+ allowDataOverflow={false}
+ />
+ } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} />
+ handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
+ />
+ handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
+ />
+ {refAreaLeft !== null && refAreaRight !== null && chartData[refAreaLeft] && chartData[refAreaRight] && (
+
+ )}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/workspace/logs/views/ocrView.tsx b/ui/app/workspace/logs/views/ocrView.tsx
index 551b4c402b..9c746aea4a 100644
--- a/ui/app/workspace/logs/views/ocrView.tsx
+++ b/ui/app/workspace/logs/views/ocrView.tsx
@@ -97,7 +97,9 @@ export default function OCRView({ ocrInput, ocrOutput }: OCRViewProps) {
DIMENSIONS
-
{currentPage.dimensions.width} × {currentPage.dimensions.height}px
+
+ {currentPage.dimensions.width} × {currentPage.dimensions.height}px
+
DPI
@@ -146,13 +148,27 @@ export default function OCRView({ ocrInput, ocrOutput }: OCRViewProps) {
{totalPages > 1 && (
-
+
Page {currentIndex + 1} / {totalPages}
-
+
@@ -164,4 +180,4 @@ export default function OCRView({ ocrInput, ocrOutput }: OCRViewProps) {
)}
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx
index 03112b8d04..b00579ec99 100644
--- a/ui/app/workspace/mcp-logs/page.tsx
+++ b/ui/app/workspace/mcp-logs/page.tsx
@@ -5,37 +5,21 @@ import { useColumnConfig } from "@/components/table";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Card, CardContent } from "@/components/ui/card";
import {
- getErrorMessage,
- useDeleteMCPLogsMutation,
- useGetMCPHistogramQuery,
- useGetMCPLogsQuery,
- useGetMCPLogsStatsQuery,
+ getErrorMessage,
+ useDeleteMCPLogsMutation,
+ useGetMCPHistogramQuery,
+ useGetMCPLogsQuery,
+ useGetMCPLogsStatsQuery,
} from "@/lib/store";
import { useLazyGetMCPLogsQuery } from "@/lib/store/apis/mcpLogsApi";
-import type {
- MCPToolLogEntry,
- MCPToolLogFilters,
- Pagination,
-} from "@/lib/types/logs";
+import type { MCPToolLogEntry, MCPToolLogFilters, Pagination } from "@/lib/types/logs";
import { dateUtils } from "@/lib/types/logs";
import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import NumberFlow from "@number-flow/react";
import { useLocation } from "@tanstack/react-router";
-import {
- AlertCircle,
- CheckCircle,
- Clock,
- DollarSign,
- Hash,
-} from "lucide-react";
-import {
- parseAsArrayOf,
- parseAsBoolean,
- parseAsInteger,
- parseAsString,
- useQueryStates,
-} from "nuqs";
+import { AlertCircle, CheckCircle, Clock, DollarSign, Hash } from "lucide-react";
+import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createMCPColumns } from "./views/columns";
import { MCPEmptyState } from "./views/emptyState";
@@ -44,583 +28,492 @@ import { MCPLogDetailSheet } from "./views/mcpLogDetailsSheet";
import { MCPLogsDataTable } from "./views/mcpLogsTable";
export default function MCPLogsPage() {
- const [error, setError] = useState
(null);
- const [showEmptyState, setShowEmptyState] = useState(false);
- const hasCheckedEmptyState = useRef(false);
- const hasDeleteAccess = useRbac(RbacResource.MCPLogs, RbacOperation.Delete);
-
- const [deleteLogs] = useDeleteMCPLogsMutation();
- // Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand)
- const [triggerGetLogs] = useLazyGetMCPLogsQuery();
-
- // Track if user has manually modified the time range
- const userModifiedTimeRange = useRef(false);
-
- const defaultTimeRange = useMemo(() => dateUtils.getDefaultTimeRange(), []);
-
- const { search } = useLocation();
- const hasExplicitTimeRange =
- (search as Record)?.start_time &&
- (search as Record)?.end_time;
-
- // URL state management
- const [urlState, setUrlState] = useQueryStates(
- {
- tool_names: parseAsArrayOf(parseAsString).withDefault([]),
- server_labels: parseAsArrayOf(parseAsString).withDefault([]),
- status: parseAsArrayOf(parseAsString).withDefault([]),
- virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]),
- content_search: parseAsString.withDefault(""),
- start_time: parseAsInteger.withDefault(defaultTimeRange.startTime),
- end_time: parseAsInteger.withDefault(defaultTimeRange.endTime),
- limit: parseAsInteger.withDefault(50),
- offset: parseAsInteger.withDefault(0),
- sort_by: parseAsString.withDefault("timestamp"),
- order: parseAsString.withDefault("desc"),
- polling: parseAsBoolean
- .withDefault(true)
- .withOptions({ clearOnDefault: false }),
- period: parseAsString
- .withDefault(hasExplicitTimeRange ? "" : "1h")
- .withOptions({ clearOnDefault: false }),
- selected_log: parseAsString.withDefault(""),
- },
- {
- history: "push",
- shallow: false,
- },
- );
-
- const selectedLogId = urlState.selected_log || null;
- const polling = urlState.polling;
- const [isChartOpen, setIsChartOpen] = useState(true);
-
- // Convert URL state to filters and pagination for API calls.
- // When period is set, send it to the backend so the server computes the time window fresh
- // on every request. For custom absolute ranges (period === "") use the stored timestamps.
- const filters: MCPToolLogFilters = useMemo(
- () => ({
- tool_names: urlState.tool_names,
- server_labels: urlState.server_labels,
- status: urlState.status,
- virtual_key_ids: urlState.virtual_key_ids,
- content_search: urlState.content_search,
- ...(urlState.period
- ? { period: urlState.period }
- : {
- start_time: dateUtils.toISOString(urlState.start_time),
- end_time: dateUtils.toISOString(urlState.end_time),
- }),
- }),
- [
- urlState.tool_names,
- urlState.server_labels,
- urlState.status,
- urlState.virtual_key_ids,
- urlState.content_search,
- urlState.period,
- urlState.start_time,
- urlState.end_time,
- ],
- );
-
- const pagination: Pagination = useMemo(
- () => ({
- limit: urlState.limit,
- offset: urlState.offset,
- sort_by: urlState.sort_by as "timestamp" | "latency",
- order: urlState.order as "asc" | "desc",
- }),
- [urlState.limit, urlState.offset, urlState.sort_by, urlState.order],
- );
-
- const {
- data: logsData,
- isLoading: logsIsLoading,
- isFetching: logsIsFetching,
- error: logsError,
- refetch: refetchLogs,
- } = useGetMCPLogsQuery(
- { filters, pagination },
- {
- pollingInterval: showEmptyState || polling ? 10000 : 0,
- skipPollingIfUnfocused: true,
- },
- );
-
- const {
- data: statsData,
- isFetching: statsIsFetching,
- refetch: refetchStats,
- } = useGetMCPLogsStatsQuery(
- { filters },
- {
- pollingInterval: polling ? 10000 : 0,
- skipPollingIfUnfocused: true,
- },
- );
-
- const {
- data: histogram,
- isLoading: histogramIsLoading,
- refetch: refetchHistogram,
- } = useGetMCPHistogramQuery(
- { filters },
- {
- pollingInterval: polling ? 10000 : 0,
- skipPollingIfUnfocused: true,
- },
- );
-
- const refreshAllData = useCallback(() => {
- refetchLogs();
- refetchStats();
- refetchHistogram();
- }, [refetchLogs, refetchStats, refetchHistogram]);
-
- const handleTimeRangeChange = useCallback(
- (startTime: number, endTime: number) => {
- userModifiedTimeRange.current = true;
- setUrlState({
- period: "",
- start_time: startTime,
- end_time: endTime,
- offset: 0,
- polling: false,
- });
- },
- [setUrlState],
- );
-
- const handleResetZoom = useCallback(() => {
- const now = Math.floor(Date.now() / 1000);
- const oneHour = now - 1 * 60 * 60;
- setUrlState({
- period: "1h",
- start_time: oneHour,
- end_time: now,
- offset: 0,
- polling: true,
- });
- }, [setUrlState]);
-
- const isZoomed = useMemo(() => {
- if (urlState.period) return false;
- const currentRange = urlState.end_time - urlState.start_time;
- const defaultRange = 1 * 60 * 60;
- return currentRange < defaultRange * 0.9;
- }, [urlState.start_time, urlState.end_time, urlState.period]);
-
- // Derive data directly from RTK
- const logs = logsData?.logs ?? [];
- const totalItems = logsData?.stats?.total_executions ?? 0;
-
- const selectedLog = useMemo(
- () =>
- selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null,
- [selectedLogId, logs],
- );
-
- // Set showEmptyState on first response; clear it as soon as logs appear.
- useEffect(() => {
- if (!logsData) return;
- if (!hasCheckedEmptyState.current) {
- setShowEmptyState(!logsData.has_logs);
- hasCheckedEmptyState.current = true;
- } else if (showEmptyState && logsData.has_logs) {
- setShowEmptyState(false);
- }
- }, [logsData, showEmptyState]);
-
- // Helper to update filters in URL
- const setFilters = useCallback(
- (newFilters: MCPToolLogFilters) => {
- const timeChanged =
- newFilters.start_time !== undefined ||
- newFilters.end_time !== undefined;
- if (timeChanged) userModifiedTimeRange.current = true;
-
- setUrlState({
- ...(timeChanged && { period: "" }),
- tool_names: newFilters.tool_names || [],
- server_labels: newFilters.server_labels || [],
- status: newFilters.status || [],
- virtual_key_ids: newFilters.virtual_key_ids || [],
- content_search: newFilters.content_search || "",
- start_time: newFilters.start_time
- ? dateUtils.toUnixTimestamp(new Date(newFilters.start_time))
- : undefined,
- end_time: newFilters.end_time
- ? dateUtils.toUnixTimestamp(new Date(newFilters.end_time))
- : undefined,
- offset: 0,
- });
- },
- [setUrlState],
- );
-
- // Helper to update pagination in URL
- const setPagination = useCallback(
- (newPagination: Pagination) => {
- setUrlState({
- limit: newPagination.limit,
- offset: newPagination.offset,
- sort_by: newPagination.sort_by,
- order: newPagination.order,
- });
- },
- [setUrlState],
- );
-
- const handleDelete = useCallback(
- async (log: MCPToolLogEntry) => {
- if (!hasDeleteAccess) throw new Error("No delete access");
- try {
- await deleteLogs({ ids: [log.id] }).unwrap();
- if (urlState.selected_log === log.id) {
- setUrlState({ selected_log: "" });
- }
- refreshAllData();
- } catch (err) {
- const errorMessage = getErrorMessage(err);
- setError(errorMessage);
- throw new Error(errorMessage);
- }
- },
- [
- deleteLogs,
- hasDeleteAccess,
- urlState.selected_log,
- setUrlState,
- refreshAllData,
- ],
- );
-
- const handlePeriodChange = useCallback(
- (p?: string, from?: Date, to?: Date) => {
- if (p) {
- setUrlState({
- period: p,
- offset: 0,
- polling: true,
- });
- } else if (from && to) {
- setUrlState({
- start_time: Math.floor(from.getTime() / 1000),
- end_time: Math.floor(to.getTime() / 1000),
- offset: 0,
- polling: false,
- period: "",
- });
- }
- },
- [setUrlState],
- );
-
- const handlePollToggle = useCallback(
- (enabled: boolean) => {
- setUrlState({ polling: enabled });
- if (enabled) refreshAllData();
- },
- [setUrlState, refreshAllData],
- );
-
- const statCards = useMemo(
- () => [
- {
- title: "Total Executions",
- value: (
-
- ),
- icon: ,
- },
- {
- title: "Success Rate",
- value: (
-
- ),
- icon: ,
- },
- {
- title: "Avg Latency",
- value: (
-
- ),
- icon: ,
- },
- {
- title: "Total Cost",
- value: (
-
- ),
- icon: ,
- },
- ],
- [statsData],
- );
-
- const columns = useMemo(
- () => createMCPColumns(handleDelete, hasDeleteAccess),
- [handleDelete, hasDeleteAccess],
- );
-
- const columnIds = useMemo(
- () =>
- columns
- .map((col) =>
- "id" in col && col.id
- ? col.id
- : "accessorKey" in col
- ? String(col.accessorKey)
- : "",
- )
- .filter(Boolean),
- [columns],
- );
-
- const {
- entries: columnEntries,
- columnOrder,
- columnVisibility,
- columnPinning,
- toggleVisibility: toggleColumnVisibility,
- togglePin: toggleColumnPin,
- reorder: reorderColumns,
- reset: resetColumns,
- } = useColumnConfig({
- columnIds,
- paramName: "mcp_cols",
- fixedColumns: hasDeleteAccess ? { right: ["actions"] } : undefined,
- });
-
- const MCP_COLUMN_LABELS: Record = useMemo(
- () => ({
- timestamp: "Time",
- tool_name: "Tool Name",
- server_label: "Server",
- latency: "Latency",
- cost: "Cost",
- }),
- [],
- );
-
- const selectedLogIndex = useMemo(
- () => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1),
- [selectedLogId, logs],
- );
-
- const handleLogNavigate = useCallback(
- (direction: "prev" | "next") => {
- const replaceHistory = { history: "replace" as const };
- const currentLogId = selectedLogId || "";
- if (direction === "prev") {
- if (selectedLogIndex > 0) {
- setUrlState(
- { selected_log: logs[selectedLogIndex - 1].id },
- replaceHistory,
- );
- } else if (pagination.offset > 0) {
- const newOffset = Math.max(0, pagination.offset - pagination.limit);
- setUrlState({ offset: newOffset, selected_log: "" }, replaceHistory);
- triggerGetLogs({
- filters,
- pagination: { ...pagination, offset: newOffset },
- }).then((result) => {
- const pageLogs = result.data?.logs;
- if (pageLogs?.length) {
- setUrlState(
- { selected_log: pageLogs[pageLogs.length - 1].id },
- replaceHistory,
- );
- } else if (result.error) {
- setUrlState(
- { offset: pagination.offset, selected_log: currentLogId },
- replaceHistory,
- );
- setError(getErrorMessage(result.error));
- }
- });
- }
- } else {
- if (selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1) {
- setUrlState(
- { selected_log: logs[selectedLogIndex + 1].id },
- replaceHistory,
- );
- } else if (pagination.offset + pagination.limit < totalItems) {
- const newOffset = pagination.offset + pagination.limit;
- setUrlState({ offset: newOffset, selected_log: "" }, replaceHistory);
- triggerGetLogs({
- filters,
- pagination: { ...pagination, offset: newOffset },
- }).then((result) => {
- const pageLogs = result.data?.logs;
- if (pageLogs?.length) {
- setUrlState({ selected_log: pageLogs[0].id }, replaceHistory);
- } else if (result.error) {
- setUrlState(
- { offset: pagination.offset, selected_log: currentLogId },
- replaceHistory,
- );
- setError(getErrorMessage(result.error));
- }
- });
- }
- }
- },
- [
- selectedLogId,
- selectedLogIndex,
- logs,
- pagination,
- totalItems,
- filters,
- setUrlState,
- triggerGetLogs,
- ],
- );
-
- const displayError =
- error ??
- (logsError
- ? getErrorMessage(logsError as Parameters[0])
- : null);
-
- return (
-
- {logsIsLoading ? (
-
- ) : showEmptyState ? (
-
- ) : (
-
- {/* Sidebar Filters */}
-
-
- {/* Main Content */}
-
-
-
-
- {/* Quick Stats */}
-
-
- {statCards.map((card) => (
-
-
-
-
- {card.title}
-
-
- {card.value}
-
-
-
-
- ))}
-
-
-
-
-
-
- {displayError && (
-
-
- {displayError}
-
- )}
-
-
-
{
- if (columnId === "actions") return;
- setUrlState({ selected_log: row.id }, { history: "replace" });
- }}
- onRefresh={refreshAllData}
- polling={polling}
- columnEntries={columnEntries}
- columnOrder={columnOrder}
- columnVisibility={columnVisibility}
- columnPinning={columnPinning}
- onToggleColumnVisibility={toggleColumnVisibility}
- onTogglePin={toggleColumnPin}
- onReorderColumns={reorderColumns}
- />
-
-
- {/* Log Detail Sheet */}
-
- !open && setUrlState({ selected_log: "" }, { history: "replace" })
- }
- handleDelete={hasDeleteAccess ? handleDelete : undefined}
- onNavigate={handleLogNavigate}
- hasPrev={
- selectedLogIndex > 0 ||
- (selectedLogIndex !== -1 && pagination.offset > 0)
- }
- hasNext={
- selectedLogIndex !== -1 &&
- (selectedLogIndex < logs.length - 1 ||
- pagination.offset + pagination.limit < totalItems)
- }
- />
-
- )}
-
- );
-}
+ const [error, setError] = useState(null);
+ const [showEmptyState, setShowEmptyState] = useState(false);
+ const hasCheckedEmptyState = useRef(false);
+ const hasDeleteAccess = useRbac(RbacResource.MCPLogs, RbacOperation.Delete);
+
+ const [deleteLogs] = useDeleteMCPLogsMutation();
+ // Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand)
+ const [triggerGetLogs] = useLazyGetMCPLogsQuery();
+
+ // Track if user has manually modified the time range
+ const userModifiedTimeRange = useRef(false);
+
+ const defaultTimeRange = useMemo(() => dateUtils.getDefaultTimeRange(), []);
+
+ const { search } = useLocation();
+ const hasExplicitTimeRange = (search as Record)?.start_time && (search as Record)?.end_time;
+
+ // URL state management
+ const [urlState, setUrlState] = useQueryStates(
+ {
+ tool_names: parseAsArrayOf(parseAsString).withDefault([]),
+ server_labels: parseAsArrayOf(parseAsString).withDefault([]),
+ status: parseAsArrayOf(parseAsString).withDefault([]),
+ virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]),
+ content_search: parseAsString.withDefault(""),
+ start_time: parseAsInteger.withDefault(defaultTimeRange.startTime),
+ end_time: parseAsInteger.withDefault(defaultTimeRange.endTime),
+ limit: parseAsInteger.withDefault(50),
+ offset: parseAsInteger.withDefault(0),
+ sort_by: parseAsString.withDefault("timestamp"),
+ order: parseAsString.withDefault("desc"),
+ polling: parseAsBoolean.withDefault(true).withOptions({ clearOnDefault: false }),
+ period: parseAsString.withDefault(hasExplicitTimeRange ? "" : "1h").withOptions({ clearOnDefault: false }),
+ selected_log: parseAsString.withDefault(""),
+ },
+ {
+ history: "push",
+ shallow: false,
+ },
+ );
+
+ const selectedLogId = urlState.selected_log || null;
+ const polling = urlState.polling;
+ const [isChartOpen, setIsChartOpen] = useState(true);
+
+ // Convert URL state to filters and pagination for API calls.
+ // When period is set, send it to the backend so the server computes the time window fresh
+ // on every request. For custom absolute ranges (period === "") use the stored timestamps.
+ const filters: MCPToolLogFilters = useMemo(
+ () => ({
+ tool_names: urlState.tool_names,
+ server_labels: urlState.server_labels,
+ status: urlState.status,
+ virtual_key_ids: urlState.virtual_key_ids,
+ content_search: urlState.content_search,
+ ...(urlState.period
+ ? { period: urlState.period }
+ : {
+ start_time: dateUtils.toISOString(urlState.start_time),
+ end_time: dateUtils.toISOString(urlState.end_time),
+ }),
+ }),
+ [
+ urlState.tool_names,
+ urlState.server_labels,
+ urlState.status,
+ urlState.virtual_key_ids,
+ urlState.content_search,
+ urlState.period,
+ urlState.start_time,
+ urlState.end_time,
+ ],
+ );
+
+ const pagination: Pagination = useMemo(
+ () => ({
+ limit: urlState.limit,
+ offset: urlState.offset,
+ sort_by: urlState.sort_by as "timestamp" | "latency",
+ order: urlState.order as "asc" | "desc",
+ }),
+ [urlState.limit, urlState.offset, urlState.sort_by, urlState.order],
+ );
+
+ const {
+ data: logsData,
+ isLoading: logsIsLoading,
+ isFetching: logsIsFetching,
+ error: logsError,
+ refetch: refetchLogs,
+ } = useGetMCPLogsQuery(
+ { filters, pagination },
+ {
+ pollingInterval: showEmptyState || polling ? 10000 : 0,
+ skipPollingIfUnfocused: true,
+ },
+ );
+
+ const {
+ data: statsData,
+ isFetching: statsIsFetching,
+ refetch: refetchStats,
+ } = useGetMCPLogsStatsQuery(
+ { filters },
+ {
+ pollingInterval: polling ? 10000 : 0,
+ skipPollingIfUnfocused: true,
+ },
+ );
+
+ const {
+ data: histogram,
+ isLoading: histogramIsLoading,
+ refetch: refetchHistogram,
+ } = useGetMCPHistogramQuery(
+ { filters },
+ {
+ pollingInterval: polling ? 10000 : 0,
+ skipPollingIfUnfocused: true,
+ },
+ );
+
+ const refreshAllData = useCallback(() => {
+ refetchLogs();
+ refetchStats();
+ refetchHistogram();
+ }, [refetchLogs, refetchStats, refetchHistogram]);
+
+ const handleTimeRangeChange = useCallback(
+ (startTime: number, endTime: number) => {
+ userModifiedTimeRange.current = true;
+ setUrlState({
+ period: "",
+ start_time: startTime,
+ end_time: endTime,
+ offset: 0,
+ polling: false,
+ });
+ },
+ [setUrlState],
+ );
+
+ const handleResetZoom = useCallback(() => {
+ const now = Math.floor(Date.now() / 1000);
+ const oneHour = now - 1 * 60 * 60;
+ setUrlState({
+ period: "1h",
+ start_time: oneHour,
+ end_time: now,
+ offset: 0,
+ polling: true,
+ });
+ }, [setUrlState]);
+
+ const isZoomed = useMemo(() => {
+ if (urlState.period) return false;
+ const currentRange = urlState.end_time - urlState.start_time;
+ const defaultRange = 1 * 60 * 60;
+ return currentRange < defaultRange * 0.9;
+ }, [urlState.start_time, urlState.end_time, urlState.period]);
+
+ // Derive data directly from RTK
+ const logs = logsData?.logs ?? [];
+ const totalItems = logsData?.stats?.total_executions ?? 0;
+
+ const selectedLog = useMemo(() => (selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null), [selectedLogId, logs]);
+
+ // Set showEmptyState on first response; clear it as soon as logs appear.
+ useEffect(() => {
+ if (!logsData) return;
+ if (!hasCheckedEmptyState.current) {
+ setShowEmptyState(!logsData.has_logs);
+ hasCheckedEmptyState.current = true;
+ } else if (showEmptyState && logsData.has_logs) {
+ setShowEmptyState(false);
+ }
+ }, [logsData, showEmptyState]);
+
+ // Helper to update filters in URL
+ const setFilters = useCallback(
+ (newFilters: MCPToolLogFilters) => {
+ const timeChanged = newFilters.start_time !== undefined || newFilters.end_time !== undefined;
+ if (timeChanged) userModifiedTimeRange.current = true;
+
+ setUrlState({
+ ...(timeChanged && { period: "" }),
+ tool_names: newFilters.tool_names || [],
+ server_labels: newFilters.server_labels || [],
+ status: newFilters.status || [],
+ virtual_key_ids: newFilters.virtual_key_ids || [],
+ content_search: newFilters.content_search || "",
+ start_time: newFilters.start_time ? dateUtils.toUnixTimestamp(new Date(newFilters.start_time)) : undefined,
+ end_time: newFilters.end_time ? dateUtils.toUnixTimestamp(new Date(newFilters.end_time)) : undefined,
+ offset: 0,
+ });
+ },
+ [setUrlState],
+ );
+
+ // Helper to update pagination in URL
+ const setPagination = useCallback(
+ (newPagination: Pagination) => {
+ setUrlState({
+ limit: newPagination.limit,
+ offset: newPagination.offset,
+ sort_by: newPagination.sort_by,
+ order: newPagination.order,
+ });
+ },
+ [setUrlState],
+ );
+
+ const handleDelete = useCallback(
+ async (log: MCPToolLogEntry) => {
+ if (!hasDeleteAccess) throw new Error("No delete access");
+ try {
+ await deleteLogs({ ids: [log.id] }).unwrap();
+ if (urlState.selected_log === log.id) {
+ setUrlState({ selected_log: "" });
+ }
+ refreshAllData();
+ } catch (err) {
+ const errorMessage = getErrorMessage(err);
+ setError(errorMessage);
+ throw new Error(errorMessage);
+ }
+ },
+ [deleteLogs, hasDeleteAccess, urlState.selected_log, setUrlState, refreshAllData],
+ );
+
+ const handlePeriodChange = useCallback(
+ (p?: string, from?: Date, to?: Date) => {
+ if (p) {
+ setUrlState({
+ period: p,
+ offset: 0,
+ polling: true,
+ });
+ } else if (from && to) {
+ setUrlState({
+ start_time: Math.floor(from.getTime() / 1000),
+ end_time: Math.floor(to.getTime() / 1000),
+ offset: 0,
+ polling: false,
+ period: "",
+ });
+ }
+ },
+ [setUrlState],
+ );
+
+ const handlePollToggle = useCallback(
+ (enabled: boolean) => {
+ setUrlState({ polling: enabled });
+ if (enabled) refreshAllData();
+ },
+ [setUrlState, refreshAllData],
+ );
+
+ const statCards = useMemo(
+ () => [
+ {
+ title: "Total Executions",
+ value: ,
+ icon: ,
+ },
+ {
+ title: "Success Rate",
+ value: (
+
+ ),
+ icon: ,
+ },
+ {
+ title: "Avg Latency",
+ value: (
+
+ ),
+ icon: ,
+ },
+ {
+ title: "Total Cost",
+ value: (
+
+ ),
+ icon: ,
+ },
+ ],
+ [statsData],
+ );
+
+ const columns = useMemo(() => createMCPColumns(handleDelete, hasDeleteAccess), [handleDelete, hasDeleteAccess]);
+
+ const columnIds = useMemo(
+ () => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean),
+ [columns],
+ );
+
+ const {
+ entries: columnEntries,
+ columnOrder,
+ columnVisibility,
+ columnPinning,
+ toggleVisibility: toggleColumnVisibility,
+ togglePin: toggleColumnPin,
+ reorder: reorderColumns,
+ reset: resetColumns,
+ } = useColumnConfig({
+ columnIds,
+ paramName: "mcp_cols",
+ fixedColumns: hasDeleteAccess ? { right: ["actions"] } : undefined,
+ });
+
+ const MCP_COLUMN_LABELS: Record = useMemo(
+ () => ({
+ timestamp: "Time",
+ tool_name: "Tool Name",
+ server_label: "Server",
+ latency: "Latency",
+ cost: "Cost",
+ }),
+ [],
+ );
+
+ const selectedLogIndex = useMemo(() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1), [selectedLogId, logs]);
+
+ const handleLogNavigate = useCallback(
+ (direction: "prev" | "next") => {
+ const replaceHistory = { history: "replace" as const };
+ const currentLogId = selectedLogId || "";
+ if (direction === "prev") {
+ if (selectedLogIndex > 0) {
+ setUrlState({ selected_log: logs[selectedLogIndex - 1].id }, replaceHistory);
+ } else if (pagination.offset > 0) {
+ const newOffset = Math.max(0, pagination.offset - pagination.limit);
+ setUrlState({ offset: newOffset, selected_log: "" }, replaceHistory);
+ triggerGetLogs({
+ filters,
+ pagination: { ...pagination, offset: newOffset },
+ }).then((result) => {
+ const pageLogs = result.data?.logs;
+ if (pageLogs?.length) {
+ setUrlState({ selected_log: pageLogs[pageLogs.length - 1].id }, replaceHistory);
+ } else if (result.error) {
+ setUrlState({ offset: pagination.offset, selected_log: currentLogId }, replaceHistory);
+ setError(getErrorMessage(result.error));
+ }
+ });
+ }
+ } else {
+ if (selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1) {
+ setUrlState({ selected_log: logs[selectedLogIndex + 1].id }, replaceHistory);
+ } else if (pagination.offset + pagination.limit < totalItems) {
+ const newOffset = pagination.offset + pagination.limit;
+ setUrlState({ offset: newOffset, selected_log: "" }, replaceHistory);
+ triggerGetLogs({
+ filters,
+ pagination: { ...pagination, offset: newOffset },
+ }).then((result) => {
+ const pageLogs = result.data?.logs;
+ if (pageLogs?.length) {
+ setUrlState({ selected_log: pageLogs[0].id }, replaceHistory);
+ } else if (result.error) {
+ setUrlState({ offset: pagination.offset, selected_log: currentLogId }, replaceHistory);
+ setError(getErrorMessage(result.error));
+ }
+ });
+ }
+ }
+ },
+ [selectedLogId, selectedLogIndex, logs, pagination, totalItems, filters, setUrlState, triggerGetLogs],
+ );
+
+ const displayError = error ?? (logsError ? getErrorMessage(logsError as Parameters[0]) : null);
+
+ return (
+
+ {logsIsLoading ? (
+
+ ) : showEmptyState ? (
+
+ ) : (
+
+ {/* Sidebar Filters */}
+
+
+ {/* Main Content */}
+
+
+
+
+ {/* Quick Stats */}
+
+
+ {statCards.map((card) => (
+
+
+
+
{card.title}
+
{card.value}
+
+
+
+ ))}
+
+
+
+
+
+
+ {displayError && (
+
+
+ {displayError}
+
+ )}
+
+
+
{
+ if (columnId === "actions") return;
+ setUrlState({ selected_log: row.id }, { history: "replace" });
+ }}
+ onRefresh={refreshAllData}
+ polling={polling}
+ columnEntries={columnEntries}
+ columnOrder={columnOrder}
+ columnVisibility={columnVisibility}
+ columnPinning={columnPinning}
+ onToggleColumnVisibility={toggleColumnVisibility}
+ onTogglePin={toggleColumnPin}
+ onReorderColumns={reorderColumns}
+ />
+
+
+ {/* Log Detail Sheet */}
+
!open && setUrlState({ selected_log: "" }, { history: "replace" })}
+ handleDelete={hasDeleteAccess ? handleDelete : undefined}
+ onNavigate={handleLogNavigate}
+ hasPrev={selectedLogIndex > 0 || (selectedLogIndex !== -1 && pagination.offset > 0)}
+ hasNext={selectedLogIndex !== -1 && (selectedLogIndex < logs.length - 1 || pagination.offset + pagination.limit < totalItems)}
+ />
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-logs/views/columns.tsx b/ui/app/workspace/mcp-logs/views/columns.tsx
index 4308e9d9bc..9e5290e3ac 100644
--- a/ui/app/workspace/mcp-logs/views/columns.tsx
+++ b/ui/app/workspace/mcp-logs/views/columns.tsx
@@ -21,83 +21,83 @@ export const createMCPColumns = (
handleDelete: (log: MCPToolLogEntry) => Promise,
hasDeleteAccess: boolean,
): ColumnDef[] => [
- {
- accessorKey: "status",
- header: "",
- size: 8,
- maxSize: 8,
- cell: ({ row }) => {
- const status = getValidatedStatus(row.original.status);
- return
;
- },
+ {
+ accessorKey: "status",
+ header: "",
+ size: 8,
+ maxSize: 8,
+ cell: ({ row }) => {
+ const status = getValidatedStatus(row.original.status);
+ return
;
},
- {
- accessorKey: "timestamp",
- header: ({ column }) => (
- column.toggleSorting(column.getIsSorted() === "asc")}>
- Time
-
-
- ),
- size: 230,
- cell: ({ row }) => {
- const timestamp = row.original.timestamp;
- const date = new Date(timestamp);
- return {isValid(date) ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "Invalid date"}
;
- },
+ },
+ {
+ accessorKey: "timestamp",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}>
+ Time
+
+
+ ),
+ size: 230,
+ cell: ({ row }) => {
+ const timestamp = row.original.timestamp;
+ const date = new Date(timestamp);
+ return {isValid(date) ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "Invalid date"}
;
},
- {
- accessorKey: "tool_name",
- header: "Tool Name",
- size: 300,
- cell: ({ row }) => {
- const toolName = row.getValue("tool_name") as string;
- return {toolName} ;
- },
+ },
+ {
+ accessorKey: "tool_name",
+ header: "Tool Name",
+ size: 300,
+ cell: ({ row }) => {
+ const toolName = row.getValue("tool_name") as string;
+ return {toolName} ;
},
- {
- accessorKey: "server_label",
- header: "Server",
- size: 150,
- cell: ({ row }) => {
- const serverLabel = row.getValue("server_label") as string;
- return serverLabel ? (
-
- {serverLabel}
-
- ) : (
- -
- );
- },
+ },
+ {
+ accessorKey: "server_label",
+ header: "Server",
+ size: 150,
+ cell: ({ row }) => {
+ const serverLabel = row.getValue("server_label") as string;
+ return serverLabel ? (
+
+ {serverLabel}
+
+ ) : (
+ -
+ );
},
- {
- accessorKey: "latency",
- header: ({ column }) => (
- column.toggleSorting(column.getIsSorted() === "asc")}>
- Latency
-
-
- ),
- size: 120,
- cell: ({ row }) => {
- const latency = row.original.latency;
- return (
- {latency === undefined || latency === null ? "N/A" : `${latency.toLocaleString()}ms`}
- );
- },
+ },
+ {
+ accessorKey: "latency",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}>
+ Latency
+
+
+ ),
+ size: 120,
+ cell: ({ row }) => {
+ const latency = row.original.latency;
+ return (
+ {latency === undefined || latency === null ? "N/A" : `${latency.toLocaleString()}ms`}
+ );
},
- {
- accessorKey: "cost",
- header: "Cost",
- size: 120,
- cell: ({ row }) => {
- const cost = row.original.cost;
- const isValidNumber = typeof cost === "number" && Number.isFinite(cost);
- return {isValidNumber ? `${cost.toFixed(4)}` : "N/A"}
;
- },
+ },
+ {
+ accessorKey: "cost",
+ header: "Cost",
+ size: 120,
+ cell: ({ row }) => {
+ const cost = row.original.cost;
+ const isValidNumber = typeof cost === "number" && Number.isFinite(cost);
+ return {isValidNumber ? `${cost.toFixed(4)}` : "N/A"}
;
},
- ...(hasDeleteAccess
- ? [
+ },
+ ...(hasDeleteAccess
+ ? [
{
id: "actions",
header: "",
@@ -132,5 +132,5 @@ export const createMCPColumns = (
},
},
]
- : []),
- ];
+ : []),
+];
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx b/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx
index 0c9d2e1cd0..8507db73c9 100644
--- a/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx
+++ b/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx
@@ -250,4 +250,4 @@ export function MCPLogsDataTable({
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx b/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx
index c373cd157b..545da211f6 100644
--- a/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx
+++ b/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx
@@ -148,7 +148,10 @@ const ClientForm: React.FC
= ({ open, onClose, onSaved }) => {
authType === "oauth" || authType === "per_user_oauth"
? {
client_id: data.oauth_config?.client_id ?? emptyEnvVar,
- client_secret: data.oauth_config?.client_secret?.value || data.oauth_config?.client_secret?.from_env ? data.oauth_config.client_secret : undefined,
+ client_secret:
+ data.oauth_config?.client_secret?.value || data.oauth_config?.client_secret?.from_env
+ ? data.oauth_config.client_secret
+ : undefined,
authorize_url: data.oauth_config?.authorize_url || undefined,
token_url: data.oauth_config?.token_url || undefined,
registration_url: data.oauth_config?.registration_url || undefined,
@@ -314,7 +317,12 @@ const ClientForm: React.FC = ({ open, onClose, onSaved }) => {
-
+
)}
/>
@@ -438,7 +446,12 @@ const ClientForm: React.FC
= ({ open, onClose, onSaved }) => {
-
+
Will be auto-generated via dynamic registration if left empty and provider supports it
@@ -456,7 +469,14 @@ const ClientForm: React.FC = ({ open, onClose, onSaved }) => {
OAuth Client Secret (optional for PKCE)
-
+
Leave empty for public clients using PKCE
@@ -674,4 +694,4 @@ const ClientForm: React.FC = ({ open, onClose, onSaved }) => {
);
};
-export default ClientForm;
+export default ClientForm;
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx
index 80f8ea2f88..7ff78a2d6e 100644
--- a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx
+++ b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx
@@ -481,8 +481,8 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
- When enabled, the client's connection, health monitor, and tool syncer are shut down. Tools from this
- client will not be available for inference until it is re-enabled.
+ When enabled, the client's connection, health monitor, and tool syncer are shut down. Tools from this client
+ will not be available for inference until it is re-enabled.
@@ -598,9 +598,9 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
onBlur={() => {
const parsed = allowedExtraHeadersRaw.trim()
? allowedExtraHeadersRaw
- .split(",")
- .map((h) => h.trim())
- .filter(Boolean)
+ .split(",")
+ .map((h) => h.trim())
+ .filter(Boolean)
: [];
field.onChange(parsed);
field.onBlur();
@@ -621,9 +621,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
{isDisabled ? (
-
- OAuth credentials cannot be rotated while the client is disabled. Re-enable the client to update credentials.
-
+
OAuth credentials cannot be rotated while the client is disabled. Re-enable the client to update credentials.
) : (
@@ -647,9 +645,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
/>
{!isDisabled && (
-
- Leave empty to keep existing credentials unchanged.
-
+ Leave empty to keep existing credentials unchanged.
)}
@@ -1123,4 +1119,4 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
)}
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx
index 889c62788c..21745397de 100644
--- a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx
+++ b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx
@@ -11,12 +11,7 @@ import {
} from "@/components/ui/alertDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdownMenu";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -188,7 +183,8 @@ export default function MCPClientsTable({
Remove MCP Server
- Are you sure you want to remove MCP server {clientToDelete?.config.name}? You will need to reconnect the server to continue using it.
+ Are you sure you want to remove MCP server {clientToDelete?.config.name}? You will need to reconnect the server to continue
+ using it.
@@ -210,7 +206,13 @@ export default function MCPClientsTable({
MCP Server Catalog
Manage servers that can connect to the MCP Tools endpoint.
-
+
New MCP Server
@@ -310,9 +312,7 @@ export default function MCPClientsTable({
{c.state}
-
- {c.config.disabled ? "Disabled" : "Enabled"}
-
+ {c.config.disabled ? "Disabled" : "Enabled"}
setFormOpen(false)} onSaved={handleSaved} />}
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx b/ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx
index 6b9cd445cd..d03b2e880e 100644
--- a/ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx
+++ b/ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx
@@ -286,21 +286,23 @@ export const OAuth2Authorizer: React.FC = ({
>
)}
- {(status === "pending" || status === "blocked") && (
- <>
-
- {status === "blocked"
- ? "Your browser blocked the authorization window. Open it manually to continue."
- : "Open the authorization window to sign in and complete the connection."}
-
-
-
- Cancel
-
- Open Authorization Window
-
- >
- )}
+ {(status === "pending" || status === "blocked") && (
+ <>
+
+ {status === "blocked"
+ ? "Your browser blocked the authorization window. Open it manually to continue."
+ : "Open the authorization window to sign in and complete the connection."}
+
+
+
+ Cancel
+
+
+ Open Authorization Window
+
+
+ >
+ )}
{status === "polling" && (
<>
@@ -345,4 +347,4 @@ export const OAuth2Authorizer: React.FC = ({
);
-};
+};
\ No newline at end of file
diff --git a/ui/app/workspace/mcp-tool-groups/layout.tsx b/ui/app/workspace/mcp-tool-groups/layout.tsx
index a5954962b3..9a9b4c18d4 100644
--- a/ui/app/workspace/mcp-tool-groups/layout.tsx
+++ b/ui/app/workspace/mcp-tool-groups/layout.tsx
@@ -13,4 +13,4 @@ function RouteComponent() {
export const Route = createFileRoute("/workspace/mcp-tool-groups")({
component: RouteComponent,
-});
+});
\ No newline at end of file
diff --git a/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx b/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx
index a326995bf1..ef37bcecf3 100644
--- a/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx
+++ b/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx
@@ -204,4 +204,4 @@ function ModelsUsedCell({ models: rawModels }: { models: string[] }) {
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/model-catalog/views/modelCatalogView.tsx b/ui/app/workspace/model-catalog/views/modelCatalogView.tsx
index 58a6f8ef6d..4985fc994a 100644
--- a/ui/app/workspace/model-catalog/views/modelCatalogView.tsx
+++ b/ui/app/workspace/model-catalog/views/modelCatalogView.tsx
@@ -54,14 +54,14 @@ export default function ModelCatalogView() {
() =>
[
p.name,
- {
- total_requests: 0,
- success_rate: 0,
- user_facing_success_rate: 0,
- average_latency: 0,
- user_facing_total_requests:0,
- total_tokens: 0,
- total_cost: 0
+ {
+ total_requests: 0,
+ success_rate: 0,
+ user_facing_success_rate: 0,
+ average_latency: 0,
+ user_facing_total_requests: 0,
+ total_tokens: 0,
+ total_cost: 0,
},
] as const,
),
diff --git a/ui/app/workspace/model-limits/views/modelLimitSheet.tsx b/ui/app/workspace/model-limits/views/modelLimitSheet.tsx
index a0cfb9c9bd..518ce35de1 100644
--- a/ui/app/workspace/model-limits/views/modelLimitSheet.tsx
+++ b/ui/app/workspace/model-limits/views/modelLimitSheet.tsx
@@ -386,9 +386,7 @@ export default function ModelLimitSheet({ modelConfig, onSave, onCancel }: Model
)}
/>
- {form.formState.errors.root && (
- {form.formState.errors.root.message}
- )}
+ {form.formState.errors.root && {form.formState.errors.root.message}
}
{/* Current Usage Display (for editing) */}
@@ -437,11 +435,7 @@ export default function ModelLimitSheet({ modelConfig, onSave, onCancel }: Model
Cancel
-
+
{isLoading ? "Saving..." : isEditing ? "Save Changes" : "Create Limit"}
diff --git a/ui/app/workspace/model-limits/views/modelLimitsTable.tsx b/ui/app/workspace/model-limits/views/modelLimitsTable.tsx
index 0af00a4a6d..f3e94c2f12 100644
--- a/ui/app/workspace/model-limits/views/modelLimitsTable.tsx
+++ b/ui/app/workspace/model-limits/views/modelLimitsTable.tsx
@@ -454,4 +454,4 @@ export default function ModelLimitsTable({
>
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/observability/fragments/maximFormFragment.tsx b/ui/app/workspace/observability/fragments/maximFormFragment.tsx
index 0172360e2c..123b42b94a 100644
--- a/ui/app/workspace/observability/fragments/maximFormFragment.tsx
+++ b/ui/app/workspace/observability/fragments/maximFormFragment.tsx
@@ -160,15 +160,11 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting
-
+
Save Maxim Configuration
- {(!form.formState.isDirty) && (
+ {!form.formState.isDirty && (
{!form.formState.isDirty
diff --git a/ui/app/workspace/observability/fragments/otelFormFragment.tsx b/ui/app/workspace/observability/fragments/otelFormFragment.tsx
index c3c2dd2e2d..85e0d1af62 100644
--- a/ui/app/workspace/observability/fragments/otelFormFragment.tsx
+++ b/ui/app/workspace/observability/fragments/otelFormFragment.tsx
@@ -429,15 +429,11 @@ export function OtelFormFragment({
-
+
Save OTEL Configuration
- {(!form.formState.isDirty) && (
+ {!form.formState.isDirty && (
{!form.formState.isDirty && !form.formState.isValid
diff --git a/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx b/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx
index 57e5b686fc..5e513ac4f3 100644
--- a/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx
+++ b/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx
@@ -1,651 +1,533 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
-import {
- prometheusFormSchema,
- type PrometheusFormSchema,
-} from "@/lib/types/schemas";
+import { prometheusFormSchema, type PrometheusFormSchema } from "@/lib/types/schemas";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
-import {
- AlertTriangle,
- Copy,
- Eye,
- EyeOff,
- Info,
- Plus,
- Trash,
- Trash2,
-} from "lucide-react";
+import { AlertTriangle, Copy, Eye, EyeOff, Info, Plus, Trash, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm, type Resolver } from "react-hook-form";
interface PrometheusFormFragmentProps {
- currentConfig?: {
- metrics_enabled?: boolean;
- push_gateway_enabled?: boolean;
- push_gateway_url?: string;
- job_name?: string;
- instance_id?: string;
- push_interval?: number;
- basic_auth?: {
- username?: string;
- password?: string;
- };
- };
- onSave: (config: PrometheusFormSchema) => Promise;
- onDelete?: () => void;
- isDeleting?: boolean;
- isLoading?: boolean;
- metricsEndpoint?: string;
+ currentConfig?: {
+ metrics_enabled?: boolean;
+ push_gateway_enabled?: boolean;
+ push_gateway_url?: string;
+ job_name?: string;
+ instance_id?: string;
+ push_interval?: number;
+ basic_auth?: {
+ username?: string;
+ password?: string;
+ };
+ };
+ onSave: (config: PrometheusFormSchema) => Promise;
+ onDelete?: () => void;
+ isDeleting?: boolean;
+ isLoading?: boolean;
+ metricsEndpoint?: string;
}
-const buildDefaults = (
- initialConfig?: PrometheusFormFragmentProps["currentConfig"],
-): PrometheusFormSchema => ({
- metrics_enabled: initialConfig?.metrics_enabled ?? true,
- push_gateway_enabled: initialConfig?.push_gateway_enabled ?? false,
- prometheus_config: {
- push_gateway_url: initialConfig?.push_gateway_url ?? "",
- job_name: initialConfig?.job_name ?? "bifrost",
- instance_id: initialConfig?.instance_id ?? "",
- push_interval: initialConfig?.push_interval ?? 15,
- basic_auth_username: initialConfig?.basic_auth?.username ?? "",
- basic_auth_password: initialConfig?.basic_auth?.password ?? "",
- },
+const buildDefaults = (initialConfig?: PrometheusFormFragmentProps["currentConfig"]): PrometheusFormSchema => ({
+ metrics_enabled: initialConfig?.metrics_enabled ?? true,
+ push_gateway_enabled: initialConfig?.push_gateway_enabled ?? false,
+ prometheus_config: {
+ push_gateway_url: initialConfig?.push_gateway_url ?? "",
+ job_name: initialConfig?.job_name ?? "bifrost",
+ instance_id: initialConfig?.instance_id ?? "",
+ push_interval: initialConfig?.push_interval ?? 15,
+ basic_auth_username: initialConfig?.basic_auth?.username ?? "",
+ basic_auth_password: initialConfig?.basic_auth?.password ?? "",
+ },
});
// Field paths considered "owned" by each tab — used for per-tab Reset and to
// gate the per-tab Save button on whether *this* tab has unsaved changes.
const PULL_FIELDS = ["metrics_enabled"] as const;
const PUSH_FIELDS = [
- "push_gateway_enabled",
- "prometheus_config.push_gateway_url",
- "prometheus_config.job_name",
- "prometheus_config.instance_id",
- "prometheus_config.push_interval",
- "prometheus_config.basic_auth_username",
- "prometheus_config.basic_auth_password",
+ "push_gateway_enabled",
+ "prometheus_config.push_gateway_url",
+ "prometheus_config.job_name",
+ "prometheus_config.instance_id",
+ "prometheus_config.push_interval",
+ "prometheus_config.basic_auth_username",
+ "prometheus_config.basic_auth_password",
] as const;
export function PrometheusFormFragment({
- currentConfig: initialConfig,
- onSave,
- onDelete,
- isDeleting = false,
- isLoading = false,
- metricsEndpoint,
+ currentConfig: initialConfig,
+ onSave,
+ onDelete,
+ isDeleting = false,
+ isLoading = false,
+ metricsEndpoint,
}: PrometheusFormFragmentProps) {
- const hasPrometheusAccess = useRbac(
- RbacResource.Observability,
- RbacOperation.Update,
- );
- const [showPassword, setShowPassword] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const { copy, copied } = useCopyToClipboard();
- const [showBasicAuth, setShowBasicAuth] = useState(
- !!(
- initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password
- ),
- );
- const [activeTab, setActiveTab] = useState<"pull" | "push">("pull");
+ const hasPrometheusAccess = useRbac(RbacResource.Observability, RbacOperation.Update);
+ const [showPassword, setShowPassword] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const { copy, copied } = useCopyToClipboard();
+ const [showBasicAuth, setShowBasicAuth] = useState(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password));
+ const [activeTab, setActiveTab] = useState<"pull" | "push">("pull");
- const form = useForm({
- resolver: zodResolver(prometheusFormSchema) as Resolver<
- PrometheusFormSchema,
- any,
- PrometheusFormSchema
- >,
- mode: "onChange",
- reValidateMode: "onChange",
- defaultValues: buildDefaults(initialConfig),
- });
+ const form = useForm({
+ resolver: zodResolver(prometheusFormSchema) as Resolver,
+ mode: "onChange",
+ reValidateMode: "onChange",
+ defaultValues: buildDefaults(initialConfig),
+ });
- const onSubmit = async (data: PrometheusFormSchema) => {
- setIsSaving(true);
- try {
- await onSave(data);
- } finally {
- setIsSaving(false);
- }
- };
+ const onSubmit = async (data: PrometheusFormSchema) => {
+ setIsSaving(true);
+ try {
+ await onSave(data);
+ } finally {
+ setIsSaving(false);
+ }
+ };
- useEffect(() => {
- form.reset(buildDefaults(initialConfig));
- setShowBasicAuth(
- !!(
- initialConfig?.basic_auth?.username ||
- initialConfig?.basic_auth?.password
- ),
- );
- }, [form, initialConfig]);
+ useEffect(() => {
+ form.reset(buildDefaults(initialConfig));
+ setShowBasicAuth(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password));
+ }, [form, initialConfig]);
- const handleCopyEndpoint = () => {
- if (metricsEndpoint) {
- copy(metricsEndpoint);
- }
- };
+ const handleCopyEndpoint = () => {
+ if (metricsEndpoint) {
+ copy(metricsEndpoint);
+ }
+ };
- const handleRemoveBasicAuth = () => {
- form.setValue("prometheus_config.basic_auth_username", "", {
- shouldDirty: true,
- shouldValidate: true,
- });
- form.setValue("prometheus_config.basic_auth_password", "", {
- shouldDirty: true,
- shouldValidate: true,
- });
- setShowBasicAuth(false);
- };
+ const handleRemoveBasicAuth = () => {
+ form.setValue("prometheus_config.basic_auth_username", "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("prometheus_config.basic_auth_password", "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ setShowBasicAuth(false);
+ };
- // Reset only the fields belonging to the given tab. The other tab's pending
- // edits are preserved so a Reset on one tab feels scoped.
- const resetPullTab = () => {
- const defaults = buildDefaults(initialConfig);
- form.setValue("metrics_enabled", defaults.metrics_enabled, {
- shouldDirty: true,
- shouldValidate: true,
- });
- };
+ // Reset only the fields belonging to the given tab. The other tab's pending
+ // edits are preserved so a Reset on one tab feels scoped.
+ const resetPullTab = () => {
+ const defaults = buildDefaults(initialConfig);
+ form.setValue("metrics_enabled", defaults.metrics_enabled, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ };
- const resetPushTab = () => {
- const defaults = buildDefaults(initialConfig);
- form.setValue("push_gateway_enabled", defaults.push_gateway_enabled, {
- shouldDirty: true,
- shouldValidate: true,
- });
- form.setValue(
- "prometheus_config.push_gateway_url",
- defaults.prometheus_config.push_gateway_url,
- {
- shouldDirty: true,
- shouldValidate: true,
- },
- );
- form.setValue(
- "prometheus_config.job_name",
- defaults.prometheus_config.job_name,
- { shouldDirty: true, shouldValidate: true },
- );
- form.setValue(
- "prometheus_config.instance_id",
- defaults.prometheus_config.instance_id ?? "",
- { shouldDirty: true, shouldValidate: true },
- );
- form.setValue(
- "prometheus_config.push_interval",
- defaults.prometheus_config.push_interval,
- { shouldDirty: true, shouldValidate: true },
- );
- form.setValue(
- "prometheus_config.basic_auth_username",
- defaults.prometheus_config.basic_auth_username ?? "",
- { shouldDirty: true, shouldValidate: true },
- );
- form.setValue(
- "prometheus_config.basic_auth_password",
- defaults.prometheus_config.basic_auth_password ?? "",
- { shouldDirty: true, shouldValidate: true },
- );
- setShowBasicAuth(
- !!(
- initialConfig?.basic_auth?.username ||
- initialConfig?.basic_auth?.password
- ),
- );
- };
+ const resetPushTab = () => {
+ const defaults = buildDefaults(initialConfig);
+ form.setValue("push_gateway_enabled", defaults.push_gateway_enabled, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("prometheus_config.push_gateway_url", defaults.prometheus_config.push_gateway_url, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("prometheus_config.job_name", defaults.prometheus_config.job_name, { shouldDirty: true, shouldValidate: true });
+ form.setValue("prometheus_config.instance_id", defaults.prometheus_config.instance_id ?? "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("prometheus_config.push_interval", defaults.prometheus_config.push_interval, { shouldDirty: true, shouldValidate: true });
+ form.setValue("prometheus_config.basic_auth_username", defaults.prometheus_config.basic_auth_username ?? "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("prometheus_config.basic_auth_password", defaults.prometheus_config.basic_auth_password ?? "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ setShowBasicAuth(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password));
+ };
- // Tabs can independently report whether *their* fields differ from the
- // last-saved state. Both Save buttons submit the entire form (single API
- // shape) — gating per-tab just avoids surfacing a Save when nothing in
- // the visible tab changed.
- const dirtyFields = form.formState.dirtyFields as Record;
- const isPullDirty = PULL_FIELDS.some((path) => dirtyFields[path]);
- const isPushDirty = PUSH_FIELDS.some((path) => {
- const segments = path.split(".");
- let cursor: any = dirtyFields;
- for (const seg of segments) {
- if (cursor == null) return false;
- cursor = cursor[seg];
- }
- return !!cursor;
- });
+ // Tabs can independently report whether *their* fields differ from the
+ // last-saved state. Both Save buttons submit the entire form (single API
+ // shape) — gating per-tab just avoids surfacing a Save when nothing in
+ // the visible tab changed.
+ const dirtyFields = form.formState.dirtyFields as Record;
+ const isPullDirty = PULL_FIELDS.some((path) => dirtyFields[path]);
+ const isPushDirty = PUSH_FIELDS.some((path) => {
+ const segments = path.split(".");
+ let cursor: any = dirtyFields;
+ for (const seg of segments) {
+ if (cursor == null) return false;
+ cursor = cursor[seg];
+ }
+ return !!cursor;
+ });
- // Whole-form validity. Save is a single API call covering both tabs, so an
- // invalid field on the *other* tab silently blocks handleSubmit. We disable
- // Save when invalid and surface where the error lives so the user isn't
- // hunting through a tab they can't see.
- const formIsInvalid = !form.formState.isValid;
- const errors = form.formState.errors as Record;
- const hasPullErrors = !!errors.metrics_enabled;
- const hasPushErrors =
- !!errors.push_gateway_enabled || !!errors.prometheus_config;
+ // Whole-form validity. Save is a single API call covering both tabs, so an
+ // invalid field on the *other* tab silently blocks handleSubmit. We disable
+ // Save when invalid and surface where the error lives so the user isn't
+ // hunting through a tab they can't see.
+ const formIsInvalid = !form.formState.isValid;
+ const errors = form.formState.errors as Record;
+ const hasPullErrors = !!errors.metrics_enabled;
+ const hasPushErrors = !!errors.push_gateway_enabled || !!errors.prometheus_config;
- const renderActions = (
- tabKey: "pull" | "push",
- tabDirty: boolean,
- onResetTab: () => void,
- ) => {
- const thisTabHasErrors = tabKey === "pull" ? hasPullErrors : hasPushErrors;
- const otherTabHasErrors = tabKey === "pull" ? hasPushErrors : hasPullErrors;
- const otherTabLabel = tabKey === "pull" ? "Push-based" : "Pull-based";
- const saveDisabled = !hasPrometheusAccess || !tabDirty || formIsInvalid;
- let tooltipMsg = "";
- if (!tabDirty) {
- tooltipMsg = "No changes made in this tab";
- } else if (formIsInvalid && otherTabHasErrors && !thisTabHasErrors) {
- tooltipMsg = `Fix validation errors in the ${otherTabLabel} tab before saving`;
- } else if (formIsInvalid) {
- tooltipMsg = "Fix validation errors before saving";
- }
+ const renderActions = (tabKey: "pull" | "push", tabDirty: boolean, onResetTab: () => void) => {
+ const thisTabHasErrors = tabKey === "pull" ? hasPullErrors : hasPushErrors;
+ const otherTabHasErrors = tabKey === "pull" ? hasPushErrors : hasPullErrors;
+ const otherTabLabel = tabKey === "pull" ? "Push-based" : "Pull-based";
+ const saveDisabled = !hasPrometheusAccess || !tabDirty || formIsInvalid;
+ let tooltipMsg = "";
+ if (!tabDirty) {
+ tooltipMsg = "No changes made in this tab";
+ } else if (formIsInvalid && otherTabHasErrors && !thisTabHasErrors) {
+ tooltipMsg = `Fix validation errors in the ${otherTabLabel} tab before saving`;
+ } else if (formIsInvalid) {
+ tooltipMsg = "Fix validation errors before saving";
+ }
- return (
-
-
- {onDelete && (
-
-
-
- )}
-
- Reset
-
-
-
-
-
- Save Prometheus Configuration
-
-
- {tooltipMsg && (
-
- {tooltipMsg}
-
- )}
-
-
-
-
- );
- };
+ return (
+
+
+ {onDelete && (
+
+
+
+ )}
+
+ Reset
+
+
+
+
+
+ Save Prometheus Configuration
+
+
+ {tooltipMsg && (
+
+ {tooltipMsg}
+
+ )}
+
+
+
+
+ );
+ };
- return (
-
- );
-}
+ {renderActions("push", isPushDirty, resetPushTab)}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/workspace/plugins/sheets/addNewPluginSheet.tsx b/ui/app/workspace/plugins/sheets/addNewPluginSheet.tsx
index 35c36c5809..fb4cb1084b 100644
--- a/ui/app/workspace/plugins/sheets/addNewPluginSheet.tsx
+++ b/ui/app/workspace/plugins/sheets/addNewPluginSheet.tsx
@@ -165,7 +165,7 @@ export default function AddNewPluginSheet({ open, onClose, onCreate, plugin }: A
-
+
Cancel
diff --git a/ui/app/workspace/providers/dialogs/addNewCustomProviderSheet.tsx b/ui/app/workspace/providers/dialogs/addNewCustomProviderSheet.tsx
index 39d68405ac..f3728bc32a 100644
--- a/ui/app/workspace/providers/dialogs/addNewCustomProviderSheet.tsx
+++ b/ui/app/workspace/providers/dialogs/addNewCustomProviderSheet.tsx
@@ -227,16 +227,11 @@ export function AddCustomProviderSheetContent({ show = true, onClose, onSave }:
disabled={!hasProviderCreateAccess}
/>
-
+
Cancel
-
+
Add
diff --git a/ui/app/workspace/providers/fragments/apiStructureFormFragment.tsx b/ui/app/workspace/providers/fragments/apiStructureFormFragment.tsx
index d98a3f3a9d..af922a9f1f 100644
--- a/ui/app/workspace/providers/fragments/apiStructureFormFragment.tsx
+++ b/ui/app/workspace/providers/fragments/apiStructureFormFragment.tsx
@@ -166,11 +166,7 @@ export function ApiStructureFormFragment({ provider }: Props) {
-
+
Save API Structure Configuration
diff --git a/ui/app/workspace/providers/fragments/governanceFormFragment.tsx b/ui/app/workspace/providers/fragments/governanceFormFragment.tsx
index c9b44fd062..d5f30260f5 100644
--- a/ui/app/workspace/providers/fragments/governanceFormFragment.tsx
+++ b/ui/app/workspace/providers/fragments/governanceFormFragment.tsx
@@ -117,11 +117,11 @@ export function GovernanceFormFragment({ provider }: GovernanceFormFragmentProps
let rateLimitPayload:
| {
- token_max_limit?: number | null;
- token_reset_duration?: string | null;
- request_max_limit?: number | null;
- request_reset_duration?: string | null;
- }
+ token_max_limit?: number | null;
+ token_reset_duration?: string | null;
+ request_max_limit?: number | null;
+ request_reset_duration?: string | null;
+ }
| undefined;
if (hasRateLimit) {
rateLimitPayload = {
@@ -276,7 +276,7 @@ export function GovernanceFormFragment({ provider }: GovernanceFormFragmentProps
)}
{/* Form Actions */}
-
+
Remove configuration
-
+
Save Governance Configuration
diff --git a/ui/app/workspace/providers/fragments/networkFormFragment.tsx b/ui/app/workspace/providers/fragments/networkFormFragment.tsx
index 315cb3e981..57331a47f6 100644
--- a/ui/app/workspace/providers/fragments/networkFormFragment.tsx
+++ b/ui/app/workspace/providers/fragments/networkFormFragment.tsx
@@ -501,11 +501,7 @@ export function NetworkFormFragment({ provider }: NetworkFormFragmentProps) {
-
+
Save Network Configuration
diff --git a/ui/app/workspace/providers/fragments/performanceFormFragment.tsx b/ui/app/workspace/providers/fragments/performanceFormFragment.tsx
index 7cfc74e691..10b3bb04c6 100644
--- a/ui/app/workspace/providers/fragments/performanceFormFragment.tsx
+++ b/ui/app/workspace/providers/fragments/performanceFormFragment.tsx
@@ -144,7 +144,7 @@ export function PerformanceFormFragment({ provider }: PerformanceFormFragmentPro
{/* Form Actions */}
-
+
{/* Form Actions */}
-
+
)}
- {hasProviderCreateAccess ?
-
setShowCustomProviderSheet(true)}
- />
- : null}
+ {hasProviderCreateAccess ? (
+
+
setShowCustomProviderSheet(true)}
+ />
+
+ ) : null}
diff --git a/ui/app/workspace/providers/views/modelProviderKeysTableView.tsx b/ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
index 8454c89549..e6f4e91868 100644
--- a/ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
+++ b/ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
@@ -158,7 +158,7 @@ export default function ModelProviderKeysTableView({ provider, className, header
key={key.id}
data-testid={`key-row-${key.name}`}
className="text-sm transition-colors hover:bg-white"
- onClick={() => { }}
+ onClick={() => {}}
>
@@ -264,7 +264,7 @@ export default function ModelProviderKeysTableView({ provider, className, header
- {hasUpdateProviderAccess || hasDeleteProviderAccess ?
+ {hasUpdateProviderAccess || hasDeleteProviderAccess ? (
e.stopPropagation()} variant="ghost">
@@ -292,8 +292,8 @@ export default function ModelProviderKeysTableView({ provider, className, header
Delete
- : null
- }
+
+ ) : null}
@@ -305,4 +305,4 @@ export default function ModelProviderKeysTableView({ provider, className, header
)}
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/providers/views/providerKeyForm.tsx b/ui/app/workspace/providers/views/providerKeyForm.tsx
index 9580fccbea..0d564a2bdc 100644
--- a/ui/app/workspace/providers/views/providerKeyForm.tsx
+++ b/ui/app/workspace/providers/views/providerKeyForm.tsx
@@ -98,14 +98,14 @@ export default function ProviderKeyForm({ provider, keyId, onCancel, onSave }: P
}
const mutation = isEditing
? updateProviderKey({
- provider: provider.name,
- keyId: currentKey!.id,
- key,
- })
+ provider: provider.name,
+ keyId: currentKey!.id,
+ key,
+ })
: createProviderKey({
- provider: provider.name,
- key,
- });
+ provider: provider.name,
+ key,
+ });
mutation
.unwrap()
@@ -121,8 +121,8 @@ export default function ProviderKeyForm({ provider, keyId, onCancel, onSave }: P
return (
-
-
+
+
{isEditing && currentKey?.config_hash &&
}
diff --git a/ui/app/workspace/routing-rules/layout.tsx b/ui/app/workspace/routing-rules/layout.tsx
index 18e709b82b..12ceac9a8d 100644
--- a/ui/app/workspace/routing-rules/layout.tsx
+++ b/ui/app/workspace/routing-rules/layout.tsx
@@ -14,4 +14,4 @@ function RouteComponent() {
export const Route = createFileRoute("/workspace/routing-rules")({
component: RouteComponent,
-});
+});
\ No newline at end of file
diff --git a/ui/app/workspace/routing-rules/views/routingRuleSheet.tsx b/ui/app/workspace/routing-rules/views/routingRuleSheet.tsx
index 860068830d..ae19662ed0 100644
--- a/ui/app/workspace/routing-rules/views/routingRuleSheet.tsx
+++ b/ui/app/workspace/routing-rules/views/routingRuleSheet.tsx
@@ -235,9 +235,9 @@ export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }:
const submitPromise =
isEditing && editingRule
? updateRoutingRule({
- id: editingRule.id,
- data: payload,
- }).unwrap()
+ id: editingRule.id,
+ data: payload,
+ }).unwrap()
: createRoutingRule(payload).unwrap();
submitPromise
@@ -401,10 +401,10 @@ export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }:
{((scope === "team" && teamsData.teams.length === 0) ||
(scope === "customer" && customersData.customers.length === 0) ||
(scope === "virtual_key" && vksData.virtual_keys.length === 0)) && (
-
- No {scope === "team" ? "teams" : scope === "customer" ? "customers" : "virtual keys"} available
-
- )}
+
+ No {scope === "team" ? "teams" : scope === "customer" ? "customers" : "virtual keys"} available
+
+ )}
{errors.scope_id && {errors.scope_id.message}
}
)}
@@ -486,7 +486,8 @@ export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }:
-
Fallbacks
+ Fallbacks {" "}
+
Provider is required, but model is optional. Leave model empty to use the incoming request value.
@@ -579,7 +580,6 @@ export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }:
Fallbacks will be used in the order they are defined
-
{/* Action Buttons */}
diff --git a/ui/app/workspace/routing-rules/views/routingRulesTable.tsx b/ui/app/workspace/routing-rules/views/routingRulesTable.tsx
index b61ac1f892..728d6fc30b 100644
--- a/ui/app/workspace/routing-rules/views/routingRulesTable.tsx
+++ b/ui/app/workspace/routing-rules/views/routingRulesTable.tsx
@@ -313,4 +313,4 @@ function TargetsSummary({ targets }: { targets: RoutingTarget[] }) {
)}
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/scim/page.tsx b/ui/app/workspace/scim/page.tsx
index 7eba6f6af1..eda1936e09 100644
--- a/ui/app/workspace/scim/page.tsx
+++ b/ui/app/workspace/scim/page.tsx
@@ -2,7 +2,7 @@ import SCIMView from "@enterprise/components/scim/scimView";
export default function SCIMPage() {
return (
-
+
);
diff --git a/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts b/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts
index 26dc405f9e..9ea7ebad9b 100644
--- a/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts
+++ b/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts
@@ -80,4 +80,4 @@ export function useVirtualKeyUsage(vk: VirtualKey | null | undefined): {
displayRateLimit.request_current_usage >= displayRateLimit.request_max_limit);
return { assignedUsers, isManagedByProfile, managingProfile, hasApRateLimit, displayBudgets, displayRateLimit, isExhausted };
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx
index 499a7ab84f..3668873f4c 100644
--- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx
+++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx
@@ -29,7 +29,12 @@ function UsageLine({ current, max, format }: { current: number; max: number; for
{format(current)} / {format(max)}
-
80 ? "text-amber-500" : "text-muted-foreground")}>
+ 80 ? "text-amber-500" : "text-muted-foreground",
+ )}
+ >
{pct}%
@@ -250,8 +255,10 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
Resets {parseResetPeriod(config.rate_limit.token_reset_duration || "")}
{config.rate_limit.token_last_reset ? (
- Last reset {formatDistanceToNow(new Date(config.rate_limit.token_last_reset), { addSuffix: true })}
- ) : null}
+
+ Last reset {formatDistanceToNow(new Date(config.rate_limit.token_last_reset), { addSuffix: true })}
+
+ ) : null}
) : null}
@@ -268,8 +275,11 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
Resets {parseResetPeriod(config.rate_limit.request_reset_duration || "")}
{config.rate_limit.request_last_reset ? (
- Last reset {formatDistanceToNow(new Date(config.rate_limit.request_last_reset), { addSuffix: true })}
- ) : null}
+
+ Last reset{" "}
+ {formatDistanceToNow(new Date(config.rate_limit.request_last_reset), { addSuffix: true })}
+
+ ) : null}
) : null}
@@ -350,16 +360,14 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
{displayBudgets && displayBudgets.length > 0 ? (
{displayBudgets.map((b, bIdx) => (
-
+
Resets {parseResetPeriod(b.reset_duration)}
{virtualKey.calendar_aligned && " (calendar)"}
- {b.last_reset ? (
- Last reset {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })}
- ) : null}
+ {b.last_reset ? Last reset {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })} : null}
))}
@@ -382,7 +390,7 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
{/* Token Limits */}
{displayRateLimit.token_max_limit != null ? (
-
+
Token Limits
+
Request Limits
- Creating this virtual key under team{" "}
- {attachedTeam?.name ?? attachedTeamId}
- . Team assignment is pre-set — all other fields are editable.
+ Creating this virtual key under team {attachedTeam?.name ?? attachedTeamId} . Team
+ assignment is pre-set — all other fields are editable.
)}
@@ -808,7 +807,7 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, defaultT
? "No models (deny all)"
: config.provider
? ModelPlaceholders[config.provider as keyof typeof ModelPlaceholders] ||
- ModelPlaceholders.default
+ ModelPlaceholders.default
: ModelPlaceholders.default
}
className="min-h-10 max-w-[500px] min-w-[200px]"
@@ -846,16 +845,16 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, defaultT
const selectedProviderKeys = hasWildcard
? [allKeyOptions[0]]
: providerKeys
- .filter((key) => configKeyIds.includes(key.key_id))
- .map((key) => ({
- label: key.name,
- value: key.key_id,
- description:
- key.models == null || key.models.includes("*")
- ? "All models"
- : key.models.filter((m) => m !== "*").join(", ") || "No models (deny all)",
- provider: key.provider,
- }));
+ .filter((key) => configKeyIds.includes(key.key_id))
+ .map((key) => ({
+ label: key.name,
+ value: key.key_id,
+ description:
+ key.models == null || key.models.includes("*")
+ ? "All models"
+ : key.models.filter((m) => m !== "*").join(", ") || "No models (deny all)",
+ provider: key.provider,
+ }));
return (
@@ -955,9 +954,9 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, defaultT
lines={
config.budgets && config.budgets.length > 0
? config.budgets.map((b) => ({
- max_limit: b.max_limit,
- reset_duration: b.reset_duration || "1M",
- }))
+ max_limit: b.max_limit,
+ reset_duration: b.reset_duration || "1M",
+ }))
: []
}
onChange={(lines) => {
@@ -1308,8 +1307,8 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, defaultT
Reassign to a different team?
- This key is currently assigned to another team. Reassigning it will move budget tracking to this
- team — future requests through this key will count against this team’s budget, not the previous one.
+ This key is currently assigned to another team. Reassigning it will move budget tracking to this team — future
+ requests through this key will count against this team’s budget, not the previous one.
diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
index 88b3d873e8..4b51ad7a43 100644
--- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
+++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
@@ -14,12 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ComboboxSelect } from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdownMenu";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -165,7 +160,13 @@ function VKActionsMenu({
<>
-
+
@@ -187,11 +188,7 @@ function VKActionsMenu({
className="cursor-pointer"
disabled={!hasDeleteAccess || isManagedByProfile}
data-testid={`vk-delete-btn-${vk.name}`}
- title={
- isManagedByProfile
- ? "This virtual key is managed by an access profile and can't be deleted here."
- : undefined
- }
+ title={isManagedByProfile ? "This virtual key is managed by an access profile and can't be deleted here." : undefined}
onSelect={(e) => {
e.preventDefault();
setDeleteOpen(true);
@@ -207,7 +204,8 @@ function VKActionsMenu({
Delete Virtual Key
- Are you sure you want to delete "{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name}"? This action cannot be undone.
+ Are you sure you want to delete "{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name}"? This action cannot
+ be undone.
@@ -418,7 +416,6 @@ export default function VirtualKeysTable({
);
};
-
// True empty state: no VKs at all (not just filtered to zero)
if (totalCount === 0 && !hasActiveFilters) {
return (
@@ -580,7 +577,7 @@ export default function VirtualKeysTable({
value={customerFilter || null}
onValueChange={(val) => onCustomerFilterChange(val ?? "")}
placeholder="All Customers"
- className="w-[180px] h-9"
+ className="h-9 w-[180px]"
/>
{customerFilter && teamFilter && or }
onTeamFilterChange(val ?? "")}
placeholder="All Teams"
- className="w-[180px] h-9"
+ className="h-9 w-[180px]"
/>
-
+
@@ -635,11 +632,15 @@ export default function VirtualKeysTable({
{vk.team ? (
- Team: {vk.team.name}
+
+ Team: {vk.team.name}
+
) : vk.customer ? (
- Customer: {vk.customer.name}
+
+ Customer: {vk.customer.name}
+
) : (
- -
+ -
)}
e.stopPropagation()}>
@@ -677,7 +678,7 @@ export default function VirtualKeysTable({
e.stopPropagation()}
>
>
);
-}
+}
\ No newline at end of file
diff --git a/ui/components/filters/logsFilterSidebar.tsx b/ui/components/filters/logsFilterSidebar.tsx
index 7127ec2822..f26e76b72d 100644
--- a/ui/components/filters/logsFilterSidebar.tsx
+++ b/ui/components/filters/logsFilterSidebar.tsx
@@ -365,10 +365,11 @@ function StopReasonFilter({ filters, onFiltersChange, defaultOpen }: FilterCompo
const hasActive = (filters.stop_reasons || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["stop_reasons"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["stop_reasons"] }, { skip: !opened && !hasActive });
const availableStopReasons = filterData?.stop_reasons || [];
const items = useMemo(() => {
const seen = new Set(availableStopReasons);
@@ -477,10 +478,11 @@ function ModelsFilter({ filters, onFiltersChange, defaultOpen }: FilterComponent
const hasActive = (filters.models || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["models"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["models"] }, { skip: !opened && !hasActive });
const availableModels = filterData?.models || [];
// Merge selected-but-unavailable values so user-typed custom models still
// render with a checkbox they can untick.
@@ -525,10 +527,11 @@ function AliasesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponen
const hasActive = (filters.aliases || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["aliases"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["aliases"] }, { skip: !opened && !hasActive });
const availableAliases = filterData?.aliases || [];
const items = useMemo(() => {
const seen = new Set(availableAliases);
@@ -571,10 +574,11 @@ function SelectedKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterCom
const hasActive = (filters.selected_key_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["selected_keys"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["selected_keys"] }, { skip: !opened && !hasActive });
const availableSelectedKeys = filterData?.selected_keys || [];
const nameToIds = useMemo(() => groupByName(availableSelectedKeys), [availableSelectedKeys]);
@@ -624,10 +628,11 @@ function VirtualKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComp
const hasActive = (filters.virtual_key_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["virtual_keys"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["virtual_keys"] }, { skip: !opened && !hasActive });
const availableVirtualKeys = filterData?.virtual_keys || [];
const nameToIds = useMemo(() => groupByName(availableVirtualKeys), [availableVirtualKeys]);
@@ -677,10 +682,11 @@ function RoutingEnginesFilter({ filters, onFiltersChange, defaultOpen }: FilterC
const hasActive = (filters.routing_engine_used || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["routing_engines"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["routing_engines"] }, { skip: !opened && !hasActive });
const availableRoutingEngines = filterData?.routing_engines || [];
if (!isUninitialized && !isLoading && availableRoutingEngines.length === 0 && !hasActive && !opened) return null;
@@ -720,10 +726,11 @@ function RoutingRulesFilter({ filters, onFiltersChange, defaultOpen }: FilterCom
const hasActive = (filters.routing_rule_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["routing_rules"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["routing_rules"] }, { skip: !opened && !hasActive });
const availableRoutingRules = filterData?.routing_rules || [];
const nameToIds = useMemo(() => groupByName(availableRoutingRules), [availableRoutingRules]);
@@ -842,9 +849,7 @@ function LocalCachingFilter({ filters, onFiltersChange, defaultOpen }: FilterCom
checked={(filters.cache_hit_types || []).includes(option.key)}
onCheckedChange={() => {
const current = filters.cache_hit_types || [];
- const next = current.includes(option.key)
- ? current.filter((t) => t !== option.key)
- : [...current, option.key];
+ const next = current.includes(option.key) ? current.filter((t) => t !== option.key) : [...current, option.key];
onFiltersChange({ ...filters, cache_hit_types: next });
}}
testId={`local-caching-filter-checkbox-${option.key}`}
@@ -861,10 +866,11 @@ function LocalCachingFilter({ filters, onFiltersChange, defaultOpen }: FilterCom
function MetadataFilters({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = !!filters.metadata_filters && Object.keys(filters.metadata_filters).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
- const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(
- { dimensions: ["metadata_keys"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["metadata_keys"] }, { skip: !opened && !hasActive });
const availableMetadataKeys = filterData?.metadata_keys || {};
const [customInputs, setCustomInputs] = useState>({});
diff --git a/ui/components/filters/mcpFilterSidebar.tsx b/ui/components/filters/mcpFilterSidebar.tsx
index 3dfe1a7c65..2247d38f46 100644
--- a/ui/components/filters/mcpFilterSidebar.tsx
+++ b/ui/components/filters/mcpFilterSidebar.tsx
@@ -316,10 +316,11 @@ function ToolNamesFilter({ filters, onFiltersChange, defaultOpen }: FilterCompon
const hasActive = (filters.tool_names || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(
- { dimensions: ["tool_names"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetMCPLogsFilterDataQuery({ dimensions: ["tool_names"] }, { skip: !opened && !hasActive });
const availableToolNames = filterData?.tool_names || [];
const items = useMemo(() => {
const seen = new Set(availableToolNames);
@@ -355,10 +356,11 @@ function ServersFilter({ filters, onFiltersChange, defaultOpen }: FilterComponen
const hasActive = (filters.server_labels || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(
- { dimensions: ["server_labels"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetMCPLogsFilterDataQuery({ dimensions: ["server_labels"] }, { skip: !opened && !hasActive });
const availableServerLabels = filterData?.server_labels || [];
const items = useMemo(() => {
const seen = new Set(availableServerLabels);
@@ -394,10 +396,11 @@ function VirtualKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComp
const hasActive = (filters.virtual_key_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
- const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(
- { dimensions: ["virtual_keys"] },
- { skip: !opened && !hasActive },
- );
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetMCPLogsFilterDataQuery({ dimensions: ["virtual_keys"] }, { skip: !opened && !hasActive });
const availableVirtualKeys = filterData?.virtual_keys || [];
const nameToId = useMemo(() => new Map(availableVirtualKeys.map((key) => [key.name, key.id])), [availableVirtualKeys]);
diff --git a/ui/components/prompts/fragments/settingsPanel.tsx b/ui/components/prompts/fragments/settingsPanel.tsx
index d7b75c2994..613dd76bca 100644
--- a/ui/components/prompts/fragments/settingsPanel.tsx
+++ b/ui/components/prompts/fragments/settingsPanel.tsx
@@ -228,15 +228,13 @@ export function SettingsPanel() {
{requiredHeaders.map((name) => (
-
+
{name}
- setCustomHeaders((prev) => ({ ...prev, [name]: e.target.value }))
- }
+ onChange={(e) => setCustomHeaders((prev) => ({ ...prev, [name]: e.target.value }))}
placeholder="value"
className="h-8 flex-1"
/>
diff --git a/ui/components/prompts/sheets/promptSheet.tsx b/ui/components/prompts/sheets/promptSheet.tsx
index 15d927db7e..f1b059375b 100644
--- a/ui/components/prompts/sheets/promptSheet.tsx
+++ b/ui/components/prompts/sheets/promptSheet.tsx
@@ -77,7 +77,7 @@ export function PromptSheet({ open, onOpenChange, prompt, folderId, onSaved }: P
document.getElementById("name")?.focus();
}}
>
-
+
{isEditing ? "Rename Prompt" : "Create Prompt"}
@@ -85,8 +85,8 @@ export function PromptSheet({ open, onOpenChange, prompt, folderId, onSaved }: P
-
-
+
+
Name
-
+
onOpenChange(false)}>
Cancel
diff --git a/ui/components/provider.tsx b/ui/components/provider.tsx
index 1064da35ee..95e53b5748 100644
--- a/ui/components/provider.tsx
+++ b/ui/components/provider.tsx
@@ -10,7 +10,7 @@ interface ProviderProps {
export default function Provider({ provider, size = 16, className }: ProviderProps) {
return (
-
+
{getProviderLabel(provider)}
diff --git a/ui/components/rateLimitDisplay.tsx b/ui/components/rateLimitDisplay.tsx
index 62780cf1c6..20b457ccf6 100644
--- a/ui/components/rateLimitDisplay.tsx
+++ b/ui/components/rateLimitDisplay.tsx
@@ -37,7 +37,13 @@ function LimitText({ label, max, resetDuration }: { label: string; max: number;
);
}
-function Bar({ label, current, max, resetDuration, compact }: {
+function Bar({
+ label,
+ current,
+ max,
+ resetDuration,
+ compact,
+}: {
label: string;
current: number;
max: number;
@@ -46,11 +52,7 @@ function Bar({ label, current, max, resetDuration, compact }: {
}) {
const pct = max > 0 ? Math.min((current / max) * 100, 100) : 0;
const isExhausted = max > 0 && current >= max;
- const barClass = isExhausted
- ? "[&>div]:bg-red-500/70"
- : pct > 80
- ? "[&>div]:bg-amber-500/70"
- : "[&>div]:bg-emerald-500/70";
+ const barClass = isExhausted ? "[&>div]:bg-red-500/70" : pct > 80 ? "[&>div]:bg-amber-500/70" : "[&>div]:bg-emerald-500/70";
return (
@@ -69,9 +71,7 @@ function Bar({ label, current, max, resetDuration, compact }: {
{current.toLocaleString()} / {max.toLocaleString()} {label}
- {resetDuration ? (
- Resets {formatResetDuration(resetDuration)}
- ) : null}
+ {resetDuration ? Resets {formatResetDuration(resetDuration)}
: null}
);
@@ -119,4 +119,4 @@ export function RateLimitDisplay({ rateLimits, compact, limitOnly }: RateLimitDi
) : null}
);
-}
+}
\ No newline at end of file
diff --git a/ui/components/sidebar.tsx b/ui/components/sidebar.tsx
index 67a8598e49..5fe827871f 100644
--- a/ui/components/sidebar.tsx
+++ b/ui/components/sidebar.tsx
@@ -1,82 +1,69 @@
import {
- ArrowUpRight,
- BookUser,
- Boxes,
- BoxIcon,
- BugIcon,
- Building,
- Building2,
- ChartColumnBig,
- ChevronsLeftRightEllipsis,
- Construction,
- DatabaseZap,
- FlaskConical,
- FolderGit,
- Globe,
- KeyRound,
- Landmark,
- LayoutGrid,
- LogOut,
- Logs,
- Network,
- PanelLeftClose,
- PanelLeftOpen,
- Plug,
- Puzzle,
- ScrollText,
- Search,
- SearchCheck,
- Settings,
- Settings2Icon,
- ShieldCheck,
- Shuffle,
- SlidersHorizontal,
- Telescope,
- ToolCase,
- TrendingUp,
- User,
- UserRoundCheck,
- Users,
- Wallet,
- WalletCards,
+ ArrowUpRight,
+ BookUser,
+ Boxes,
+ BoxIcon,
+ BugIcon,
+ Building,
+ Building2,
+ ChartColumnBig,
+ ChevronsLeftRightEllipsis,
+ Construction,
+ DatabaseZap,
+ FlaskConical,
+ FolderGit,
+ Globe,
+ KeyRound,
+ Landmark,
+ LayoutGrid,
+ LogOut,
+ Logs,
+ Network,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Plug,
+ Puzzle,
+ ScrollText,
+ Search,
+ SearchCheck,
+ Settings,
+ Settings2Icon,
+ ShieldCheck,
+ Shuffle,
+ SlidersHorizontal,
+ Telescope,
+ ToolCase,
+ TrendingUp,
+ User,
+ UserRoundCheck,
+ Users,
+ Wallet,
+ WalletCards,
} from "lucide-react";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import {
- Sidebar,
- SidebarContent,
- SidebarGroup,
- SidebarGroupContent,
- SidebarHeader,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarMenuSub,
- SidebarMenuSubButton,
- SidebarMenuSubItem,
- useSidebar,
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ useSidebar,
} from "@/components/ui/sidebar";
import { useWebSocket } from "@/hooks/useWebSocket";
import { IS_ENTERPRISE } from "@/lib/constants/config";
-import {
- useGetCoreConfigQuery,
- useGetLatestReleaseQuery,
- useGetVersionQuery,
- useLogoutMutation,
-} from "@/lib/store";
+import { useGetCoreConfigQuery, useGetLatestReleaseQuery, useGetVersionQuery, useLogoutMutation } from "@/lib/store";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import type { UserInfo } from "@enterprise/lib/store/utils/tokenManager";
import { getUserInfo } from "@enterprise/lib/store/utils/tokenManager";
-import {
- BooksIcon,
- DiscordLogoIcon,
- GithubLogoIcon,
-} from "@phosphor-icons/react";
+import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { useTheme } from "next-themes";
@@ -91,1530 +78,1304 @@ const PRODUCTION_SETUP_DISMISSED_COOKIE = "bifrost_production_setup_dismissed";
// Custom MCP Icon Component
const MCPIcon = ({ className }: { className?: string }) => (
-
- MCP clients icon
-
-
-
+
+ MCP clients icon
+
+
+
);
// Main navigation items
// External links
const externalLinks = [
- {
- title: "Discord Server",
- url: "https://discord.gg/exN5KAydbU",
- icon: DiscordLogoIcon,
- },
- {
- title: "GitHub Repository",
- url: "https://github.com/maximhq/bifrost",
- icon: GithubLogoIcon,
- },
- {
- title: "Report a bug",
- url: "https://github.com/maximhq/bifrost/issues/new?title=[Bug Report]&labels=bug&type=bug&projects=maximhq/1",
- icon: BugIcon,
- strokeWidth: 1.5,
- },
- {
- title: "Full Documentation",
- url: "https://docs.getbifrost.ai",
- icon: BooksIcon,
- strokeWidth: 1,
- },
+ {
+ title: "Discord Server",
+ url: "https://discord.gg/exN5KAydbU",
+ icon: DiscordLogoIcon,
+ },
+ {
+ title: "GitHub Repository",
+ url: "https://github.com/maximhq/bifrost",
+ icon: GithubLogoIcon,
+ },
+ {
+ title: "Report a bug",
+ url: "https://github.com/maximhq/bifrost/issues/new?title=[Bug Report]&labels=bug&type=bug&projects=maximhq/1",
+ icon: BugIcon,
+ strokeWidth: 1.5,
+ },
+ {
+ title: "Full Documentation",
+ url: "https://docs.getbifrost.ai",
+ icon: BooksIcon,
+ strokeWidth: 1,
+ },
];
// Base promotional card (memoized outside component to prevent recreation)
const productionSetupHelpCard = {
- id: "production-setup",
- title: "Need help with production setup?",
- description: (
- <>
- We offer help with production setup including custom integrations and
- dedicated support.
-
-
- Book a demo with our team{" "}
-
- here
-
- .
- >
- ),
- dismissible: true,
+ id: "production-setup",
+ title: "Need help with production setup?",
+ description: (
+ <>
+ We offer help with production setup including custom integrations and dedicated support.
+
+
+ Book a demo with our team{" "}
+
+ here
+
+ .
+ >
+ ),
+ dismissible: true,
};
// Sidebar item interface
interface SidebarItem {
- title: string;
- url: string;
- icon: React.ComponentType<{ className?: string }>;
- description: string;
- isAllowed?: boolean;
- hasAccess: boolean;
- subItems?: SidebarItem[];
- tag?: string;
- isExternal?: boolean;
- queryParam?: string; // Optional: for tab-based subitems (e.g., "client-settings")
+ title: string;
+ url: string;
+ icon: React.ComponentType<{ className?: string }>;
+ description: string;
+ isAllowed?: boolean;
+ hasAccess: boolean;
+ subItems?: SidebarItem[];
+ tag?: string;
+ isExternal?: boolean;
+ queryParam?: string; // Optional: for tab-based subitems (e.g., "client-settings")
}
const getSidebarItemHref = (item: Pick) => {
- return item.queryParam ? `${item.url}?tab=${item.queryParam}` : item.url;
+ return item.queryParam ? `${item.url}?tab=${item.queryParam}` : item.url;
};
const slug = (s: string) => s.toLowerCase().replace(/\s+/g, "-");
-const TIME_FILTER_PAGES = new Set([
- "/workspace/dashboard",
- "/workspace/logs",
- "/workspace/mcp-logs",
-]);
+const TIME_FILTER_PAGES = new Set(["/workspace/dashboard", "/workspace/logs", "/workspace/mcp-logs"]);
const SidebarItemView = ({
- item,
- isActive,
- isExternal,
- isWebSocketConnected,
- isExpanded,
- onToggle,
- pathname,
- search,
- isSidebarCollapsed,
- expandSidebar,
- highlightedUrl,
+ item,
+ isActive,
+ isExternal,
+ isWebSocketConnected,
+ isExpanded,
+ onToggle,
+ pathname,
+ search,
+ isSidebarCollapsed,
+ expandSidebar,
+ highlightedUrl,
}: {
- item: SidebarItem;
- isActive: boolean;
- isExternal?: boolean;
- isWebSocketConnected: boolean;
- isExpanded?: boolean;
- onToggle?: () => void;
- pathname: string;
- search: string;
- isSidebarCollapsed: boolean;
- expandSidebar: () => void;
- highlightedUrl?: string;
+ item: SidebarItem;
+ isActive: boolean;
+ isExternal?: boolean;
+ isWebSocketConnected: boolean;
+ isExpanded?: boolean;
+ onToggle?: () => void;
+ pathname: string;
+ search: string;
+ isSidebarCollapsed: boolean;
+ expandSidebar: () => void;
+ highlightedUrl?: string;
}) => {
- const [flyoutOpen, setFlyoutOpen] = useState(false);
- const flyoutCloseTimer = useRef | null>(null);
- const openFlyout = () => {
- if (flyoutCloseTimer.current) clearTimeout(flyoutCloseTimer.current);
- setFlyoutOpen(true);
- };
- const closeFlyout = () => {
- if (flyoutCloseTimer.current) clearTimeout(flyoutCloseTimer.current);
- flyoutCloseTimer.current = setTimeout(() => {
- setFlyoutOpen(false);
- flyoutCloseTimer.current = null;
- }, 80);
- };
- useEffect(() => {
- return () => {
- if (flyoutCloseTimer.current) clearTimeout(flyoutCloseTimer.current);
- };
- }, []);
- const hasSubItems =
- "subItems" in item && item.subItems && item.subItems.length > 0;
- const isRouteMatch = (url: string) => {
- if (url === "/workspace/custom-pricing") return pathname === url;
- return pathname.startsWith(url);
- };
- const isAnySubItemActive =
- hasSubItems &&
- item.subItems?.some((subItem) => {
- return isRouteMatch(subItem.url);
- });
+ const [flyoutOpen, setFlyoutOpen] = useState(false);
+ const flyoutCloseTimer = useRef | null>(null);
+ const openFlyout = () => {
+ if (flyoutCloseTimer.current) clearTimeout(flyoutCloseTimer.current);
+ setFlyoutOpen(true);
+ };
+ const closeFlyout = () => {
+ if (flyoutCloseTimer.current) clearTimeout(flyoutCloseTimer.current);
+ flyoutCloseTimer.current = setTimeout(() => {
+ setFlyoutOpen(false);
+ flyoutCloseTimer.current = null;
+ }, 80);
+ };
+ useEffect(() => {
+ return () => {
+ if (flyoutCloseTimer.current) clearTimeout(flyoutCloseTimer.current);
+ };
+ }, []);
+ const hasSubItems = "subItems" in item && item.subItems && item.subItems.length > 0;
+ const isRouteMatch = (url: string) => {
+ if (url === "/workspace/custom-pricing") return pathname === url;
+ return pathname.startsWith(url);
+ };
+ const isAnySubItemActive =
+ hasSubItems &&
+ item.subItems?.some((subItem) => {
+ return isRouteMatch(subItem.url);
+ });
- const handleClick = (e: React.MouseEvent) => {
- if (hasSubItems && item.hasAccess) {
- e.preventDefault();
- // If sidebar is collapsed, expand it first then toggle the submenu
- if (isSidebarCollapsed) {
- expandSidebar();
- // Small delay to allow sidebar to expand before toggling submenu
- setTimeout(() => {
- if (onToggle) onToggle();
- }, 100);
- } else if (onToggle) {
- onToggle();
- }
- }
- };
+ const handleClick = (e: React.MouseEvent) => {
+ if (hasSubItems && item.hasAccess) {
+ e.preventDefault();
+ // If sidebar is collapsed, expand it first then toggle the submenu
+ if (isSidebarCollapsed) {
+ expandSidebar();
+ // Small delay to allow sidebar to expand before toggling submenu
+ setTimeout(() => {
+ if (onToggle) onToggle();
+ }, 100);
+ } else if (onToggle) {
+ onToggle();
+ }
+ }
+ };
- const isHighlighted = !hasSubItems && highlightedUrl === item.url;
+ const isHighlighted = !hasSubItems && highlightedUrl === item.url;
- const buttonClassName = `relative h-7.5 cursor-pointer rounded-sm border px-3 transition-all duration-200 ${isHighlighted
- ? "bg-sidebar-accent text-accent-foreground border-primary/20"
- : isActive || isAnySubItemActive
- ? "bg-sidebar-accent text-primary border-primary/20"
- : item.hasAccess
- ? "hover:bg-sidebar-accent hover:text-accent-foreground border-transparent text-slate-500 dark:text-zinc-400"
- : "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
- } `;
+ const buttonClassName = `relative h-7.5 cursor-pointer rounded-sm border px-3 transition-all duration-200 ${
+ isHighlighted
+ ? "bg-sidebar-accent text-accent-foreground border-primary/20"
+ : isActive || isAnySubItemActive
+ ? "bg-sidebar-accent text-primary border-primary/20"
+ : item.hasAccess
+ ? "hover:bg-sidebar-accent hover:text-accent-foreground border-transparent text-slate-500 dark:text-zinc-400"
+ : "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
+ } `;
- const innerContent = (
-
-
-
-
- {item.title}
-
- {item.tag && (
-
- {item.tag}
-
- )}
-
- {hasSubItems && (
-
- )}
- {!hasSubItems && item.url === "/logs" && isWebSocketConnected && (
-
- )}
- {isExternal && (
-
- )}
-
- );
+ const innerContent = (
+
+
+
+
+ {item.title}
+
+ {item.tag && (
+
+ {item.tag}
+
+ )}
+
+ {hasSubItems && (
+
+ )}
+ {!hasSubItems && item.url === "/logs" && isWebSocketConnected && (
+
+ )}
+ {isExternal &&
}
+
+ );
- // Render strategy:
- // - Items with sub-items: (toggle, not navigation)
- // - Leaf items, no access: (disabled-style, non-clickable)
- // - Leaf items, external:
- // - Leaf items, internal: TanStack with preload-on-hover
- let menuButton: React.ReactNode;
- if (hasSubItems) {
- menuButton = (
-
- {innerContent}
-
- );
- } else if (!item.hasAccess) {
- menuButton = (
-
- {innerContent}
-
- );
- } else if (isExternal) {
- menuButton = (
-
- e.stopPropagation()
- : undefined
- }
- >
- {innerContent}
-
-
- );
- } else {
- menuButton = (
-
- e.stopPropagation()
- : undefined
- }
- >
- {innerContent}
-
-
- );
- }
+ // Render strategy:
+ // - Items with sub-items: (toggle, not navigation)
+ // - Leaf items, no access: (disabled-style, non-clickable)
+ // - Leaf items, external:
+ // - Leaf items, internal: TanStack with preload-on-hover
+ let menuButton: React.ReactNode;
+ if (hasSubItems) {
+ menuButton = (
+
+ {innerContent}
+
+ );
+ } else if (!item.hasAccess) {
+ menuButton = (
+
+ {innerContent}
+
+ );
+ } else if (isExternal) {
+ menuButton = (
+
+ e.stopPropagation() : undefined}
+ >
+ {innerContent}
+
+
+ );
+ } else {
+ menuButton = (
+
+ e.stopPropagation() : undefined}
+ >
+ {innerContent}
+
+
+ );
+ }
- return (
-
- {isSidebarCollapsed && hasSubItems ? (
-
-
-
- {menuButton}
-
-
-
-
- {item.title}
-
- {item.subItems?.map((subItem) => {
- const href = getSidebarItemHref(subItem);
- const isSubItemActive = subItem.queryParam
- ? pathname === subItem.url
- : isRouteMatch(subItem.url);
- const SubItemIcon = subItem.icon;
- const subSlug = slug(subItem.title);
- const inner = (
-
- {SubItemIcon && (
-
- )}
-
- {subItem.title}
-
- {subItem.tag && (
-
- {subItem.tag}
-
- )}
-
- );
- return (
- setFlyoutOpen(false)}
- >
- {subItem.hasAccess === false ? (
-
- {inner}
-
- ) : (
-
- {inner}
-
- )}
-
- );
- })}
-
-
- ) : (
- menuButton
- )}
- {hasSubItems && isExpanded && (
-
- {item.subItems?.map((subItem: SidebarItem) => {
- const baseHref = getSidebarItemHref(subItem);
- const subItemHref = (() => {
- if (
- TIME_FILTER_PAGES.has(subItem.url) &&
- TIME_FILTER_PAGES.has(pathname)
- ) {
- const currentParams = new URLSearchParams(search);
- const startTime = currentParams.get("start_time");
- const endTime = currentParams.get("end_time");
- const period = currentParams.get("period");
- if ((startTime && endTime) || period) {
- const params = new URLSearchParams();
- if (startTime) params.set("start_time", startTime);
- if (endTime) params.set("end_time", endTime);
- if (period) params.set("period", period);
- const sep = baseHref.includes("?") ? "&" : "?";
- return `${baseHref}${sep}${params.toString()}`;
- }
- }
- return baseHref;
- })();
- // For query param based subitems, check if tab matches
- const isSubItemActive = subItem.queryParam
- ? pathname === subItem.url
- : isRouteMatch(subItem.url);
- const isSubItemHighlighted = highlightedUrl
- ? subItemHref.startsWith(highlightedUrl)
- : false;
- const SubItemIcon = subItem.icon;
- const subItemClassName = `h-7 cursor-pointer rounded-sm px-2 transition-all duration-200 ${isSubItemHighlighted
- ? "bg-sidebar-accent text-accent-foreground"
- : isSubItemActive
- ? "bg-sidebar-accent text-primary font-medium"
- : subItem.hasAccess === false
- ? "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
- : "hover:bg-sidebar-accent hover:text-accent-foreground text-slate-500 dark:text-zinc-400"
- }`;
- const subInner = (
-
- {SubItemIcon && (
-
- )}
-
- {subItem.title}
-
- {subItem.tag && (
-
- {subItem.tag}
-
- )}
-
- );
- return (
-
- {subItem.hasAccess === false ? (
-
- {subInner}
-
- ) : (
-
-
- {subInner}
-
-
- )}
-
- );
- })}
-
- )}
-
- );
+ return (
+
+ {isSidebarCollapsed && hasSubItems ? (
+
+
+ {menuButton}
+
+
+ {item.title}
+ {item.subItems?.map((subItem) => {
+ const href = getSidebarItemHref(subItem);
+ const isSubItemActive = subItem.queryParam ? pathname === subItem.url : isRouteMatch(subItem.url);
+ const SubItemIcon = subItem.icon;
+ const subSlug = slug(subItem.title);
+ const inner = (
+
+ {SubItemIcon && }
+
+ {subItem.title}
+
+ {subItem.tag && (
+
+ {subItem.tag}
+
+ )}
+
+ );
+ return (
+ setFlyoutOpen(false)}>
+ {subItem.hasAccess === false ? (
+
+ {inner}
+
+ ) : (
+
+ {inner}
+
+ )}
+
+ );
+ })}
+
+
+ ) : (
+ menuButton
+ )}
+ {hasSubItems && isExpanded && (
+
+ {item.subItems?.map((subItem: SidebarItem) => {
+ const baseHref = getSidebarItemHref(subItem);
+ const subItemHref = (() => {
+ if (TIME_FILTER_PAGES.has(subItem.url) && TIME_FILTER_PAGES.has(pathname)) {
+ const currentParams = new URLSearchParams(search);
+ const startTime = currentParams.get("start_time");
+ const endTime = currentParams.get("end_time");
+ const period = currentParams.get("period");
+ if ((startTime && endTime) || period) {
+ const params = new URLSearchParams();
+ if (startTime) params.set("start_time", startTime);
+ if (endTime) params.set("end_time", endTime);
+ if (period) params.set("period", period);
+ const sep = baseHref.includes("?") ? "&" : "?";
+ return `${baseHref}${sep}${params.toString()}`;
+ }
+ }
+ return baseHref;
+ })();
+ // For query param based subitems, check if tab matches
+ const isSubItemActive = subItem.queryParam ? pathname === subItem.url : isRouteMatch(subItem.url);
+ const isSubItemHighlighted = highlightedUrl ? subItemHref.startsWith(highlightedUrl) : false;
+ const SubItemIcon = subItem.icon;
+ const subItemClassName = `h-7 cursor-pointer rounded-sm px-2 transition-all duration-200 ${
+ isSubItemHighlighted
+ ? "bg-sidebar-accent text-accent-foreground"
+ : isSubItemActive
+ ? "bg-sidebar-accent text-primary font-medium"
+ : subItem.hasAccess === false
+ ? "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
+ : "hover:bg-sidebar-accent hover:text-accent-foreground text-slate-500 dark:text-zinc-400"
+ }`;
+ const subInner = (
+
+ {SubItemIcon && }
+ {subItem.title}
+ {subItem.tag && (
+
+ {subItem.tag}
+
+ )}
+
+ );
+ return (
+
+ {subItem.hasAccess === false ? (
+
+ {subInner}
+
+ ) : (
+
+
+ {subInner}
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
};
// Helper function to compare semantic versions
const compareVersions = (v1: string, v2: string): number => {
- // Remove 'v' prefix if present
- const cleanV1 = v1.startsWith("v") ? v1.slice(1) : v1;
- const cleanV2 = v2.startsWith("v") ? v2.slice(1) : v2;
+ // Remove 'v' prefix if present
+ const cleanV1 = v1.startsWith("v") ? v1.slice(1) : v1;
+ const cleanV2 = v2.startsWith("v") ? v2.slice(1) : v2;
- // Split into main version and prerelease
- const [mainV1, prereleaseV1] = cleanV1.split("-");
- const [mainV2, prereleaseV2] = cleanV2.split("-");
+ // Split into main version and prerelease
+ const [mainV1, prereleaseV1] = cleanV1.split("-");
+ const [mainV2, prereleaseV2] = cleanV2.split("-");
- // Compare main version numbers (major.minor.patch)
- const partsV1 = mainV1.split(".").map(Number);
- const partsV2 = mainV2.split(".").map(Number);
+ // Compare main version numbers (major.minor.patch)
+ const partsV1 = mainV1.split(".").map(Number);
+ const partsV2 = mainV2.split(".").map(Number);
- for (let i = 0; i < Math.max(partsV1.length, partsV2.length); i++) {
- const num1 = partsV1[i] || 0;
- const num2 = partsV2[i] || 0;
+ for (let i = 0; i < Math.max(partsV1.length, partsV2.length); i++) {
+ const num1 = partsV1[i] || 0;
+ const num2 = partsV2[i] || 0;
- if (num1 > num2) return 1;
- if (num1 < num2) return -1;
- }
+ if (num1 > num2) return 1;
+ if (num1 < num2) return -1;
+ }
- // If main versions are equal, check prerelease
- // Version without prerelease is higher than version with prerelease
- if (!prereleaseV1 && prereleaseV2) return 1;
- if (prereleaseV1 && !prereleaseV2) return -1;
+ // If main versions are equal, check prerelease
+ // Version without prerelease is higher than version with prerelease
+ if (!prereleaseV1 && prereleaseV2) return 1;
+ if (prereleaseV1 && !prereleaseV2) return -1;
- // Both have prereleases, compare them
- if (prereleaseV1 && prereleaseV2) {
- // Extract prerelease number (e.g., "prerelease1" -> 1)
- const prereleaseNum1 = parseInt(prereleaseV1.replace(/\D/g, "")) || 0;
- const prereleaseNum2 = parseInt(prereleaseV2.replace(/\D/g, "")) || 0;
+ // Both have prereleases, compare them
+ if (prereleaseV1 && prereleaseV2) {
+ // Extract prerelease number (e.g., "prerelease1" -> 1)
+ const prereleaseNum1 = parseInt(prereleaseV1.replace(/\D/g, "")) || 0;
+ const prereleaseNum2 = parseInt(prereleaseV2.replace(/\D/g, "")) || 0;
- if (prereleaseNum1 > prereleaseNum2) return 1;
- if (prereleaseNum1 < prereleaseNum2) return -1;
- }
- return 0;
+ if (prereleaseNum1 > prereleaseNum2) return 1;
+ if (prereleaseNum1 < prereleaseNum2) return -1;
+ }
+ return 0;
};
export default function AppSidebar() {
- const pathname = useLocation({ select: (l) => l.pathname });
- const search = useLocation({ select: (l) => l.searchStr ?? "" });
- const tsNavigate = useNavigate();
- // Wrapper that accepts arbitrary string URLs (TanStack Router's `to` is
- // strictly typed, but our sidebar items come from a runtime config).
- const navigate = useCallback(
- (url: string) => tsNavigate({ to: url as string }),
- [tsNavigate],
- );
- const [mounted, setMounted] = useState(false);
- const [expandedItems, setExpandedItems] = useState>(new Set());
- const [areCardsEmpty, setAreCardsEmpty] = useState(false);
- const [userPopoverOpen, setUserPopoverOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [focusedIndex, setFocusedIndex] = useState(-1);
- const searchInputRef = useRef(null);
- const [cookies, setCookie] = useCookies([PRODUCTION_SETUP_DISMISSED_COOKIE]);
- const isProductionSetupDismissed =
- !!cookies[PRODUCTION_SETUP_DISMISSED_COOKIE];
- const { data: latestRelease } = useGetLatestReleaseQuery(undefined, {
- skip: !mounted, // Only fetch after component is mounted
- });
- const hasLogsAccess = useRbac(RbacResource.Logs, RbacOperation.View);
- const hasObservabilityAccess = useRbac(
- RbacResource.Observability,
- RbacOperation.View,
- );
- const hasModelProvidersAccess = useRbac(
- RbacResource.ModelProvider,
- RbacOperation.View,
- );
- const hasMCPGatewayAccess = useRbac(
- RbacResource.MCPGateway,
- RbacOperation.View,
- );
- const hasMCPToolGroupsAccess = useRbac(
- RbacResource.MCPToolGroups,
- RbacOperation.View,
- );
- const hasMCPLogsAccess = useRbac(RbacResource.MCPLogs, RbacOperation.View);
- const hasPluginsAccess = useRbac(RbacResource.Plugins, RbacOperation.View);
- const hasUsersAccess = useRbac(RbacResource.Users, RbacOperation.View);
- const hasUserProvisioningAccess = useRbac(
- RbacResource.UserProvisioning,
- RbacOperation.View,
- );
- const hasAuditLogsAccess = useRbac(
- RbacResource.AuditLogs,
- RbacOperation.View,
- );
- const hasCustomersAccess = useRbac(
- RbacResource.Customers,
- RbacOperation.View,
- );
- const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
- const hasBusinessUnitsAccess = useRbac(
- RbacResource.UserProvisioning,
- RbacOperation.View,
- );
- const hasRbacAccess = useRbac(RbacResource.RBAC, RbacOperation.View);
- const hasVirtualKeysAccess = useRbac(
- RbacResource.VirtualKeys,
- RbacOperation.View,
- );
- const hasGovernanceLegacyAccess = useRbac(
- RbacResource.Governance,
- RbacOperation.View,
- );
- const hasRoutingRulesAccess = useRbac(
- RbacResource.RoutingRules,
- RbacOperation.View,
- );
- const hasGuardrailsProvidersAccess = useRbac(
- RbacResource.GuardrailsProviders,
- RbacOperation.View,
- );
- const hasGuardrailsConfigAccess = useRbac(
- RbacResource.GuardrailsConfig,
- RbacOperation.View,
- );
- const hasClusterConfigAccess = useRbac(
- RbacResource.Cluster,
- RbacOperation.View,
- );
- const isAdaptiveRoutingAllowed = useRbac(
- RbacResource.AdaptiveRouter,
- RbacOperation.View,
- );
- const hasSettingsAccess = useRbac(RbacResource.Settings, RbacOperation.View);
- const hasAPIKeyAccess = useRbac(RbacResource.APIKeys, RbacOperation.View);
- const hasPromptRepositoryAccess = useRbac(
- RbacResource.PromptRepository,
- RbacOperation.View,
- );
- const hasAccessProfilesAccess = useRbac(
- RbacResource.AccessProfiles,
- RbacOperation.View,
- );
- const hasAnyGovernanceAccess =
- hasVirtualKeysAccess ||
- hasTeamsAccess ||
- hasUsersAccess ||
- hasCustomersAccess ||
- hasBusinessUnitsAccess ||
- hasRbacAccess ||
- hasAccessProfilesAccess ||
- hasGovernanceLegacyAccess;
- const { data: coreConfig } = useGetCoreConfigQuery({});
- const isDbConnected = coreConfig?.is_db_connected ?? false;
+ const pathname = useLocation({ select: (l) => l.pathname });
+ const search = useLocation({ select: (l) => l.searchStr ?? "" });
+ const tsNavigate = useNavigate();
+ // Wrapper that accepts arbitrary string URLs (TanStack Router's `to` is
+ // strictly typed, but our sidebar items come from a runtime config).
+ const navigate = useCallback((url: string) => tsNavigate({ to: url as string }), [tsNavigate]);
+ const [mounted, setMounted] = useState(false);
+ const [expandedItems, setExpandedItems] = useState>(new Set());
+ const [areCardsEmpty, setAreCardsEmpty] = useState(false);
+ const [userPopoverOpen, setUserPopoverOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+ const searchInputRef = useRef(null);
+ const [cookies, setCookie] = useCookies([PRODUCTION_SETUP_DISMISSED_COOKIE]);
+ const isProductionSetupDismissed = !!cookies[PRODUCTION_SETUP_DISMISSED_COOKIE];
+ const { data: latestRelease } = useGetLatestReleaseQuery(undefined, {
+ skip: !mounted, // Only fetch after component is mounted
+ });
+ const hasLogsAccess = useRbac(RbacResource.Logs, RbacOperation.View);
+ const hasObservabilityAccess = useRbac(RbacResource.Observability, RbacOperation.View);
+ const hasModelProvidersAccess = useRbac(RbacResource.ModelProvider, RbacOperation.View);
+ const hasMCPGatewayAccess = useRbac(RbacResource.MCPGateway, RbacOperation.View);
+ const hasMCPToolGroupsAccess = useRbac(RbacResource.MCPToolGroups, RbacOperation.View);
+ const hasMCPLogsAccess = useRbac(RbacResource.MCPLogs, RbacOperation.View);
+ const hasPluginsAccess = useRbac(RbacResource.Plugins, RbacOperation.View);
+ const hasUsersAccess = useRbac(RbacResource.Users, RbacOperation.View);
+ const hasUserProvisioningAccess = useRbac(RbacResource.UserProvisioning, RbacOperation.View);
+ const hasAuditLogsAccess = useRbac(RbacResource.AuditLogs, RbacOperation.View);
+ const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
+ const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
+ const hasBusinessUnitsAccess = useRbac(RbacResource.UserProvisioning, RbacOperation.View);
+ const hasRbacAccess = useRbac(RbacResource.RBAC, RbacOperation.View);
+ const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
+ const hasGovernanceLegacyAccess = useRbac(RbacResource.Governance, RbacOperation.View);
+ const hasRoutingRulesAccess = useRbac(RbacResource.RoutingRules, RbacOperation.View);
+ const hasGuardrailsProvidersAccess = useRbac(RbacResource.GuardrailsProviders, RbacOperation.View);
+ const hasGuardrailsConfigAccess = useRbac(RbacResource.GuardrailsConfig, RbacOperation.View);
+ const hasClusterConfigAccess = useRbac(RbacResource.Cluster, RbacOperation.View);
+ const isAdaptiveRoutingAllowed = useRbac(RbacResource.AdaptiveRouter, RbacOperation.View);
+ const hasSettingsAccess = useRbac(RbacResource.Settings, RbacOperation.View);
+ const hasAPIKeyAccess = useRbac(RbacResource.APIKeys, RbacOperation.View);
+ const hasPromptRepositoryAccess = useRbac(RbacResource.PromptRepository, RbacOperation.View);
+ const hasAccessProfilesAccess = useRbac(RbacResource.AccessProfiles, RbacOperation.View);
+ const hasAnyGovernanceAccess =
+ hasVirtualKeysAccess ||
+ hasTeamsAccess ||
+ hasUsersAccess ||
+ hasCustomersAccess ||
+ hasBusinessUnitsAccess ||
+ hasRbacAccess ||
+ hasAccessProfilesAccess ||
+ hasGovernanceLegacyAccess;
+ const { data: coreConfig } = useGetCoreConfigQuery({});
+ const isDbConnected = coreConfig?.is_db_connected ?? false;
- const items = useMemo(
- () => [
- {
- title: "Observability",
- url: "/workspace/logs",
- icon: Telescope,
- description: "Request logs & monitoring",
- hasAccess: hasLogsAccess,
- subItems: [
- {
- title: "Dashboard",
- url: "/workspace/dashboard",
- icon: ChartColumnBig,
- description: "Dashboard",
- hasAccess: hasObservabilityAccess,
- },
- {
- title: "LLM Logs",
- url: "/workspace/logs",
- icon: Logs,
- description: "LLM request logs & monitoring",
- hasAccess: hasLogsAccess,
- },
- {
- title: "MCP Logs",
- url: "/workspace/mcp-logs",
- icon: MCPIcon,
- description: "MCP tool execution logs",
- hasAccess: hasMCPLogsAccess,
- },
- {
- title: "Connectors",
- url: "/workspace/observability",
- icon: ChevronsLeftRightEllipsis,
- description: "Log connectors",
- hasAccess: hasObservabilityAccess,
- },
- {
- title: "Logs Settings",
- url: "/workspace/config/logging",
- icon: Settings,
- description: "Logs configuration",
- hasAccess: hasSettingsAccess,
- },
- ],
- },
- {
- title: "Models",
- url: "/workspace/providers",
- icon: BoxIcon,
- description: "Configure models",
- hasAccess: true,
- subItems: [
- {
- title: "Model Catalog",
- url: "/workspace/model-catalog",
- icon: LayoutGrid,
- description: "Overview of providers, keys, and usage",
- hasAccess: hasModelProvidersAccess,
- },
- {
- title: "Model Providers",
- url: "/workspace/providers",
- icon: Boxes,
- description: "Configure models",
- hasAccess: hasModelProvidersAccess,
- },
- {
- title: "Budgets & Limits",
- url: "/workspace/model-limits",
- icon: Wallet,
- description: "Model limits",
- hasAccess: hasGovernanceLegacyAccess,
- },
- {
- title: "Routing Rules",
- url: "/workspace/routing-rules",
- icon: Network,
- description: "Intelligent routing rules",
- hasAccess: hasRoutingRulesAccess,
- },
- {
- title: "Pricing Overrides",
- url: "/workspace/custom-pricing/overrides",
- icon: SlidersHorizontal,
- description: "Scoped pricing overrides",
- hasAccess: hasSettingsAccess,
- },
- {
- title: "Model Settings",
- url: "/workspace/custom-pricing",
- icon: Settings,
- description: "Model and routing configuration",
- hasAccess: hasSettingsAccess,
- },
- ],
- },
- {
- title: "MCP Gateway",
- icon: MCPIcon,
- description: "MCP configuration",
- url: "/workspace/mcp-gateway",
- hasAccess: hasMCPGatewayAccess || hasMCPToolGroupsAccess,
- subItems: [
- {
- title: "MCP Catalog",
- url: "/workspace/mcp-registry",
- icon: LayoutGrid,
- description: "MCP tool catalog",
- hasAccess: hasMCPGatewayAccess,
- },
- {
- title: "Tool Groups",
- url: "/workspace/mcp-tool-groups",
- icon: ToolCase,
- description: "Tool Groups",
- hasAccess: hasMCPToolGroupsAccess,
- },
- {
- title: "MCP Settings",
- url: "/workspace/mcp-settings",
- icon: Settings,
- description: "MCP configuration",
- hasAccess: hasMCPGatewayAccess,
- },
- ],
- },
- {
- title: "Plugins",
- url: "/workspace/plugins",
- icon: Puzzle,
- description: "Manage custom plugins",
- hasAccess: hasPluginsAccess,
- },
- {
- title: "Governance",
- url: "/workspace/governance",
- icon: Landmark,
- description: "Virtual keys, users, teams, customers & roles",
- hasAccess: hasAnyGovernanceAccess,
- subItems: [
- {
- title: "Virtual Keys",
- url: "/workspace/governance/virtual-keys",
- icon: KeyRound,
- description: "Manage virtual keys & access",
- hasAccess: hasVirtualKeysAccess,
- },
- {
- title: "Users",
- url: "/workspace/governance/users",
- icon: Users,
- description: "Manage users",
- hasAccess: hasUsersAccess,
- },
- {
- title: "Teams",
- url: "/workspace/governance/teams",
- icon: Building,
- description: "Manage teams",
- hasAccess: hasTeamsAccess,
- },
- {
- title: "Business Units",
- url: "/workspace/governance/business-units",
- icon: Building2,
- description: "Manage business units",
- hasAccess: hasBusinessUnitsAccess,
- },
- {
- title: "Customers",
- url: "/workspace/governance/customers",
- icon: WalletCards,
- description: "Manage customers",
- hasAccess: hasCustomersAccess,
- },
- {
- title: "User Provisioning",
- url: "/workspace/scim",
- icon: BookUser,
- description: "User management and provisioning",
- hasAccess: hasUserProvisioningAccess,
- },
- {
- title: "Roles & Permissions",
- url: "/workspace/governance/rbac",
- icon: UserRoundCheck,
- description: "User roles and permissions",
- hasAccess: hasRbacAccess,
- },
- {
- title: "Access Profiles",
- url: "/workspace/governance/access-profiles",
- icon: ShieldCheck,
- description: "Manage access profiles for roles",
- hasAccess: hasAccessProfilesAccess,
- },
- {
- title: "Audit Logs",
- url: "/workspace/audit-logs",
- icon: ScrollText,
- description: "Audit logs and compliance",
- hasAccess: hasAuditLogsAccess,
- },
- ],
- },
- {
- title: "Guardrails",
- url: "/workspace/guardrails",
- icon: Construction,
- description: "Guardrails configuration",
- hasAccess: hasGuardrailsConfigAccess || hasGuardrailsProvidersAccess,
- subItems: [
- {
- title: "Rules",
- url: "/workspace/guardrails/configuration",
- icon: SearchCheck,
- description: "Guardrail rules",
- hasAccess: hasGuardrailsConfigAccess,
- },
- {
- title: "Providers",
- url: "/workspace/guardrails/providers",
- icon: Boxes,
- description: "Guardrail providers configuration",
- hasAccess: hasGuardrailsProvidersAccess,
- },
- ],
- },
- {
- title: "Cluster Config",
- url: "/workspace/cluster",
- icon: Network,
- description: "Manage Bifrost cluster",
- hasAccess: hasClusterConfigAccess,
- },
- {
- title: "Adaptive Routing",
- url: "/workspace/adaptive-routing",
- icon: Shuffle,
- description: "Manage adaptive load balancer",
- hasAccess: isAdaptiveRoutingAllowed,
- },
- ...(isDbConnected
- ? [
- {
- title: "Prompt Repository",
- url: "/workspace/prompt-repo",
- icon: FolderGit,
- description: "Prompt repository",
- hasAccess: hasPromptRepositoryAccess,
- },
- ]
- : []),
- {
- title: "Evals",
- url: "https://www.getmaxim.ai",
- icon: FlaskConical,
- isExternal: true,
- description: "Evaluations",
- hasAccess: true,
- },
- {
- title: "Settings",
- url: "/workspace/config",
- icon: Settings2Icon,
- description: "Bifrost settings",
- hasAccess:
- hasSettingsAccess || hasAuditLogsAccess || hasUserProvisioningAccess,
- subItems: [
- {
- title: "Client Settings",
- url: "/workspace/config/client-settings",
- icon: Settings,
- description: "Client configuration settings",
- hasAccess: hasSettingsAccess,
- },
- {
- title: "Compatibility",
- url: "/workspace/config/compatibility",
- icon: Plug,
- description: "Compatibility conversion settings",
- hasAccess: hasSettingsAccess,
- },
- {
- title: "Caching",
- url: "/workspace/config/caching",
- icon: DatabaseZap,
- description: "Caching configuration",
- hasAccess: hasSettingsAccess,
- },
- {
- title: "Security",
- url: "/workspace/config/security",
- icon: ShieldCheck,
- description: "Security settings",
- hasAccess: hasSettingsAccess,
- },
- ...(IS_ENTERPRISE
- ? [
- {
- title: "Proxy",
- url: "/workspace/config/proxy",
- icon: Globe,
- description: "Proxy configuration",
- hasAccess: hasSettingsAccess,
- },
- ]
- : []),
- {
- title: "API Keys",
- url: "/workspace/config/api-keys",
- icon: KeyRound,
- description: "API keys management",
- hasAccess: hasAPIKeyAccess,
- },
- {
- title: "Performance Tuning",
- url: "/workspace/config/performance-tuning",
- icon: TrendingUp,
- description: "Performance tuning settings",
- hasAccess: hasSettingsAccess,
- },
- ],
- },
- ],
- [
- hasLogsAccess,
- hasAPIKeyAccess,
- hasObservabilityAccess,
- hasModelProvidersAccess,
- hasMCPGatewayAccess,
- hasMCPToolGroupsAccess,
- hasMCPLogsAccess,
- hasPluginsAccess,
- hasUsersAccess,
- hasUserProvisioningAccess,
- hasAuditLogsAccess,
- hasCustomersAccess,
- hasTeamsAccess,
- hasBusinessUnitsAccess,
- hasRbacAccess,
- hasVirtualKeysAccess,
- hasGovernanceLegacyAccess,
- hasAnyGovernanceAccess,
- hasRoutingRulesAccess,
- hasGuardrailsProvidersAccess,
- hasGuardrailsConfigAccess,
- hasClusterConfigAccess,
- isAdaptiveRoutingAllowed,
- hasSettingsAccess,
- hasPromptRepositoryAccess,
- hasAccessProfilesAccess,
- isDbConnected,
- ],
- );
+ const items = useMemo(
+ () => [
+ {
+ title: "Observability",
+ url: "/workspace/logs",
+ icon: Telescope,
+ description: "Request logs & monitoring",
+ hasAccess: hasLogsAccess,
+ subItems: [
+ {
+ title: "Dashboard",
+ url: "/workspace/dashboard",
+ icon: ChartColumnBig,
+ description: "Dashboard",
+ hasAccess: hasObservabilityAccess,
+ },
+ {
+ title: "LLM Logs",
+ url: "/workspace/logs",
+ icon: Logs,
+ description: "LLM request logs & monitoring",
+ hasAccess: hasLogsAccess,
+ },
+ {
+ title: "MCP Logs",
+ url: "/workspace/mcp-logs",
+ icon: MCPIcon,
+ description: "MCP tool execution logs",
+ hasAccess: hasMCPLogsAccess,
+ },
+ {
+ title: "Connectors",
+ url: "/workspace/observability",
+ icon: ChevronsLeftRightEllipsis,
+ description: "Log connectors",
+ hasAccess: hasObservabilityAccess,
+ },
+ {
+ title: "Logs Settings",
+ url: "/workspace/config/logging",
+ icon: Settings,
+ description: "Logs configuration",
+ hasAccess: hasSettingsAccess,
+ },
+ ],
+ },
+ {
+ title: "Models",
+ url: "/workspace/providers",
+ icon: BoxIcon,
+ description: "Configure models",
+ hasAccess: true,
+ subItems: [
+ {
+ title: "Model Catalog",
+ url: "/workspace/model-catalog",
+ icon: LayoutGrid,
+ description: "Overview of providers, keys, and usage",
+ hasAccess: hasModelProvidersAccess,
+ },
+ {
+ title: "Model Providers",
+ url: "/workspace/providers",
+ icon: Boxes,
+ description: "Configure models",
+ hasAccess: hasModelProvidersAccess,
+ },
+ {
+ title: "Budgets & Limits",
+ url: "/workspace/model-limits",
+ icon: Wallet,
+ description: "Model limits",
+ hasAccess: hasGovernanceLegacyAccess,
+ },
+ {
+ title: "Routing Rules",
+ url: "/workspace/routing-rules",
+ icon: Network,
+ description: "Intelligent routing rules",
+ hasAccess: hasRoutingRulesAccess,
+ },
+ {
+ title: "Pricing Overrides",
+ url: "/workspace/custom-pricing/overrides",
+ icon: SlidersHorizontal,
+ description: "Scoped pricing overrides",
+ hasAccess: hasSettingsAccess,
+ },
+ {
+ title: "Model Settings",
+ url: "/workspace/custom-pricing",
+ icon: Settings,
+ description: "Model and routing configuration",
+ hasAccess: hasSettingsAccess,
+ },
+ ],
+ },
+ {
+ title: "MCP Gateway",
+ icon: MCPIcon,
+ description: "MCP configuration",
+ url: "/workspace/mcp-gateway",
+ hasAccess: hasMCPGatewayAccess || hasMCPToolGroupsAccess,
+ subItems: [
+ {
+ title: "MCP Catalog",
+ url: "/workspace/mcp-registry",
+ icon: LayoutGrid,
+ description: "MCP tool catalog",
+ hasAccess: hasMCPGatewayAccess,
+ },
+ {
+ title: "Tool Groups",
+ url: "/workspace/mcp-tool-groups",
+ icon: ToolCase,
+ description: "Tool Groups",
+ hasAccess: hasMCPToolGroupsAccess,
+ },
+ {
+ title: "MCP Settings",
+ url: "/workspace/mcp-settings",
+ icon: Settings,
+ description: "MCP configuration",
+ hasAccess: hasMCPGatewayAccess,
+ },
+ ],
+ },
+ {
+ title: "Plugins",
+ url: "/workspace/plugins",
+ icon: Puzzle,
+ description: "Manage custom plugins",
+ hasAccess: hasPluginsAccess,
+ },
+ {
+ title: "Governance",
+ url: "/workspace/governance",
+ icon: Landmark,
+ description: "Virtual keys, users, teams, customers & roles",
+ hasAccess: hasAnyGovernanceAccess,
+ subItems: [
+ {
+ title: "Virtual Keys",
+ url: "/workspace/governance/virtual-keys",
+ icon: KeyRound,
+ description: "Manage virtual keys & access",
+ hasAccess: hasVirtualKeysAccess,
+ },
+ {
+ title: "Users",
+ url: "/workspace/governance/users",
+ icon: Users,
+ description: "Manage users",
+ hasAccess: hasUsersAccess,
+ },
+ {
+ title: "Teams",
+ url: "/workspace/governance/teams",
+ icon: Building,
+ description: "Manage teams",
+ hasAccess: hasTeamsAccess,
+ },
+ {
+ title: "Business Units",
+ url: "/workspace/governance/business-units",
+ icon: Building2,
+ description: "Manage business units",
+ hasAccess: hasBusinessUnitsAccess,
+ },
+ {
+ title: "Customers",
+ url: "/workspace/governance/customers",
+ icon: WalletCards,
+ description: "Manage customers",
+ hasAccess: hasCustomersAccess,
+ },
+ {
+ title: "User Provisioning",
+ url: "/workspace/scim",
+ icon: BookUser,
+ description: "User management and provisioning",
+ hasAccess: hasUserProvisioningAccess,
+ },
+ {
+ title: "Roles & Permissions",
+ url: "/workspace/governance/rbac",
+ icon: UserRoundCheck,
+ description: "User roles and permissions",
+ hasAccess: hasRbacAccess,
+ },
+ {
+ title: "Access Profiles",
+ url: "/workspace/governance/access-profiles",
+ icon: ShieldCheck,
+ description: "Manage access profiles for roles",
+ hasAccess: hasAccessProfilesAccess,
+ },
+ {
+ title: "Audit Logs",
+ url: "/workspace/audit-logs",
+ icon: ScrollText,
+ description: "Audit logs and compliance",
+ hasAccess: hasAuditLogsAccess,
+ },
+ ],
+ },
+ {
+ title: "Guardrails",
+ url: "/workspace/guardrails",
+ icon: Construction,
+ description: "Guardrails configuration",
+ hasAccess: hasGuardrailsConfigAccess || hasGuardrailsProvidersAccess,
+ subItems: [
+ {
+ title: "Rules",
+ url: "/workspace/guardrails/configuration",
+ icon: SearchCheck,
+ description: "Guardrail rules",
+ hasAccess: hasGuardrailsConfigAccess,
+ },
+ {
+ title: "Providers",
+ url: "/workspace/guardrails/providers",
+ icon: Boxes,
+ description: "Guardrail providers configuration",
+ hasAccess: hasGuardrailsProvidersAccess,
+ },
+ ],
+ },
+ {
+ title: "Cluster Config",
+ url: "/workspace/cluster",
+ icon: Network,
+ description: "Manage Bifrost cluster",
+ hasAccess: hasClusterConfigAccess,
+ },
+ {
+ title: "Adaptive Routing",
+ url: "/workspace/adaptive-routing",
+ icon: Shuffle,
+ description: "Manage adaptive load balancer",
+ hasAccess: isAdaptiveRoutingAllowed,
+ },
+ ...(isDbConnected
+ ? [
+ {
+ title: "Prompt Repository",
+ url: "/workspace/prompt-repo",
+ icon: FolderGit,
+ description: "Prompt repository",
+ hasAccess: hasPromptRepositoryAccess,
+ },
+ ]
+ : []),
+ {
+ title: "Evals",
+ url: "https://www.getmaxim.ai",
+ icon: FlaskConical,
+ isExternal: true,
+ description: "Evaluations",
+ hasAccess: true,
+ },
+ {
+ title: "Settings",
+ url: "/workspace/config",
+ icon: Settings2Icon,
+ description: "Bifrost settings",
+ hasAccess: hasSettingsAccess || hasAuditLogsAccess || hasUserProvisioningAccess,
+ subItems: [
+ {
+ title: "Client Settings",
+ url: "/workspace/config/client-settings",
+ icon: Settings,
+ description: "Client configuration settings",
+ hasAccess: hasSettingsAccess,
+ },
+ {
+ title: "Compatibility",
+ url: "/workspace/config/compatibility",
+ icon: Plug,
+ description: "Compatibility conversion settings",
+ hasAccess: hasSettingsAccess,
+ },
+ {
+ title: "Caching",
+ url: "/workspace/config/caching",
+ icon: DatabaseZap,
+ description: "Caching configuration",
+ hasAccess: hasSettingsAccess,
+ },
+ {
+ title: "Security",
+ url: "/workspace/config/security",
+ icon: ShieldCheck,
+ description: "Security settings",
+ hasAccess: hasSettingsAccess,
+ },
+ ...(IS_ENTERPRISE
+ ? [
+ {
+ title: "Proxy",
+ url: "/workspace/config/proxy",
+ icon: Globe,
+ description: "Proxy configuration",
+ hasAccess: hasSettingsAccess,
+ },
+ ]
+ : []),
+ {
+ title: "API Keys",
+ url: "/workspace/config/api-keys",
+ icon: KeyRound,
+ description: "API keys management",
+ hasAccess: hasAPIKeyAccess,
+ },
+ {
+ title: "Performance Tuning",
+ url: "/workspace/config/performance-tuning",
+ icon: TrendingUp,
+ description: "Performance tuning settings",
+ hasAccess: hasSettingsAccess,
+ },
+ ],
+ },
+ ],
+ [
+ hasLogsAccess,
+ hasAPIKeyAccess,
+ hasObservabilityAccess,
+ hasModelProvidersAccess,
+ hasMCPGatewayAccess,
+ hasMCPToolGroupsAccess,
+ hasMCPLogsAccess,
+ hasPluginsAccess,
+ hasUsersAccess,
+ hasUserProvisioningAccess,
+ hasAuditLogsAccess,
+ hasCustomersAccess,
+ hasTeamsAccess,
+ hasBusinessUnitsAccess,
+ hasRbacAccess,
+ hasVirtualKeysAccess,
+ hasGovernanceLegacyAccess,
+ hasAnyGovernanceAccess,
+ hasRoutingRulesAccess,
+ hasGuardrailsProvidersAccess,
+ hasGuardrailsConfigAccess,
+ hasClusterConfigAccess,
+ isAdaptiveRoutingAllowed,
+ hasSettingsAccess,
+ hasPromptRepositoryAccess,
+ hasAccessProfilesAccess,
+ isDbConnected,
+ ],
+ );
- const accessibleItems: SidebarItem[] = useMemo(() => {
- return items
- .map((item) => {
- const hadSubItems = !!item.subItems?.length;
- if (hadSubItems) {
- const visibleSubItems = item.subItems!.filter(
- (sub) => sub.hasAccess !== false,
- );
- if (visibleSubItems.length === 0) return null;
- return { ...item, subItems: visibleSubItems, hasAccess: true };
- }
- if (item.hasAccess === false) return null;
- return item;
- })
- .filter(Boolean) as SidebarItem[];
- }, [items]);
+ const accessibleItems: SidebarItem[] = useMemo(() => {
+ return items
+ .map((item) => {
+ const hadSubItems = !!item.subItems?.length;
+ if (hadSubItems) {
+ const visibleSubItems = item.subItems!.filter((sub) => sub.hasAccess !== false);
+ if (visibleSubItems.length === 0) return null;
+ return { ...item, subItems: visibleSubItems, hasAccess: true };
+ }
+ if (item.hasAccess === false) return null;
+ return item;
+ })
+ .filter(Boolean) as SidebarItem[];
+ }, [items]);
- const filteredItems: SidebarItem[] = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!query) return accessibleItems;
+ const filteredItems: SidebarItem[] = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase();
+ if (!query) return accessibleItems;
- return accessibleItems
- .map((item) => {
- const parentMatches = item.title.toLowerCase().includes(query);
- if (parentMatches) return item;
+ return accessibleItems
+ .map((item) => {
+ const parentMatches = item.title.toLowerCase().includes(query);
+ if (parentMatches) return item;
- if (item.subItems) {
- const matchingSubItems = item.subItems.filter((sub) =>
- sub.title.toLowerCase().includes(query),
- );
- if (matchingSubItems.length > 0) {
- return { ...item, subItems: matchingSubItems };
- }
- }
- return null;
- })
- .filter(Boolean) as SidebarItem[];
- }, [accessibleItems, searchQuery]);
+ if (item.subItems) {
+ const matchingSubItems = item.subItems.filter((sub) => sub.title.toLowerCase().includes(query));
+ if (matchingSubItems.length > 0) {
+ return { ...item, subItems: matchingSubItems };
+ }
+ }
+ return null;
+ })
+ .filter(Boolean) as SidebarItem[];
+ }, [accessibleItems, searchQuery]);
- const { data: version } = useGetVersionQuery();
- const { resolvedTheme } = useTheme();
- const [logout] = useLogoutMutation();
+ const { data: version } = useGetVersionQuery();
+ const { resolvedTheme } = useTheme();
+ const [logout] = useLogoutMutation();
- // Get user info from localStorage (for enterprise SCIM OAuth)
- const [userInfo, setUserInfo] = useState(null);
+ // Get user info from localStorage (for enterprise SCIM OAuth)
+ const [userInfo, setUserInfo] = useState(null);
- useEffect(() => {
- if (IS_ENTERPRISE) {
- const info = getUserInfo();
- setUserInfo(info);
- }
- }, []);
+ useEffect(() => {
+ if (IS_ENTERPRISE) {
+ const info = getUserInfo();
+ setUserInfo(info);
+ }
+ }, []);
- const showNewReleaseBanner = useMemo(() => {
- if (IS_ENTERPRISE) return false;
- if (latestRelease && version) {
- return compareVersions(latestRelease.name, version) > 0;
- }
- return false;
- }, [latestRelease, version]);
- const isAuthEnabled = coreConfig?.auth_config?.is_enabled || false;
+ const showNewReleaseBanner = useMemo(() => {
+ if (IS_ENTERPRISE) return false;
+ if (latestRelease && version) {
+ return compareVersions(latestRelease.name, version) > 0;
+ }
+ return false;
+ }, [latestRelease, version]);
+ const isAuthEnabled = coreConfig?.auth_config?.is_enabled || false;
- useEffect(() => {
- setMounted(true);
- }, []);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
- // Auto-expand items when their subitems are active
- useEffect(() => {
- const newExpandedItems = new Set();
- const isRouteMatch = (url: string) => {
- if (url === "/workspace/custom-pricing") return pathname === url;
- return pathname.startsWith(url);
- };
- items.forEach((item) => {
- if (item.subItems?.some((subItem) => isRouteMatch(subItem.url))) {
- newExpandedItems.add(item.title);
- }
- });
- if (newExpandedItems.size > 0) {
- setExpandedItems((prev) => new Set([...prev, ...newExpandedItems]));
- }
- }, [pathname, items]);
+ // Auto-expand items when their subitems are active
+ useEffect(() => {
+ const newExpandedItems = new Set();
+ const isRouteMatch = (url: string) => {
+ if (url === "/workspace/custom-pricing") return pathname === url;
+ return pathname.startsWith(url);
+ };
+ items.forEach((item) => {
+ if (item.subItems?.some((subItem) => isRouteMatch(subItem.url))) {
+ newExpandedItems.add(item.title);
+ }
+ });
+ if (newExpandedItems.size > 0) {
+ setExpandedItems((prev) => new Set([...prev, ...newExpandedItems]));
+ }
+ }, [pathname, items]);
- // Auto-expand parents when search matches their subItems
- useEffect(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!query) return;
- const toExpand = new Set();
- items.forEach((item) => {
- if (!item.subItems?.length) return;
- const parentMatches = item.title.toLowerCase().includes(query);
- if (parentMatches) return;
- const hasMatchingChild = item.subItems.some((sub) =>
- sub.title.toLowerCase().includes(query),
- );
- if (hasMatchingChild) {
- toExpand.add(item.title);
- }
- });
- if (toExpand.size > 0) {
- setExpandedItems((prev) => {
- const hasAll = [...toExpand].every((t) => prev.has(t));
- if (hasAll) return prev;
- return new Set([...prev, ...toExpand]);
- });
- }
- }, [searchQuery, items]);
+ // Auto-expand parents when search matches their subItems
+ useEffect(() => {
+ const query = searchQuery.trim().toLowerCase();
+ if (!query) return;
+ const toExpand = new Set();
+ items.forEach((item) => {
+ if (!item.subItems?.length) return;
+ const parentMatches = item.title.toLowerCase().includes(query);
+ if (parentMatches) return;
+ const hasMatchingChild = item.subItems.some((sub) => sub.title.toLowerCase().includes(query));
+ if (hasMatchingChild) {
+ toExpand.add(item.title);
+ }
+ });
+ if (toExpand.size > 0) {
+ setExpandedItems((prev) => {
+ const hasAll = [...toExpand].every((t) => prev.has(t));
+ if (hasAll) return prev;
+ return new Set([...prev, ...toExpand]);
+ });
+ }
+ }, [searchQuery, items]);
- // Cmd+K to focus search input
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
- event.preventDefault();
- searchInputRef.current?.focus();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, []);
+ // Cmd+K to focus search input
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
- // Flat list of navigable items for keyboard navigation
- const navigableItems = useMemo(() => {
- const result: {
- title: string;
- url: string;
- queryParam?: string;
- isExternal?: boolean;
- }[] = [];
- for (const item of filteredItems) {
- if (item.isExternal) {
- if (item.hasAccess)
- result.push({ title: item.title, url: item.url, isExternal: true });
- continue;
- }
- const hasSubItems = item.subItems && item.subItems.length > 0;
- if (hasSubItems) {
- // When search is active or parent is expanded, include visible subItems
- if (searchQuery.trim() || expandedItems.has(item.title)) {
- for (const sub of item.subItems!) {
- if (sub.hasAccess === false) continue;
- result.push({
- title: sub.title,
- url: getSidebarItemHref(sub),
- queryParam: sub.queryParam,
- });
- }
- } else {
- // Parent is collapsed - include parent as a toggle target
- if (item.hasAccess) result.push({ title: item.title, url: item.url });
- }
- } else {
- if (item.hasAccess) result.push({ title: item.title, url: item.url });
- }
- }
- return result;
- }, [filteredItems, expandedItems, searchQuery]);
+ // Flat list of navigable items for keyboard navigation
+ const navigableItems = useMemo(() => {
+ const result: {
+ title: string;
+ url: string;
+ queryParam?: string;
+ isExternal?: boolean;
+ }[] = [];
+ for (const item of filteredItems) {
+ if (item.isExternal) {
+ if (item.hasAccess) result.push({ title: item.title, url: item.url, isExternal: true });
+ continue;
+ }
+ const hasSubItems = item.subItems && item.subItems.length > 0;
+ if (hasSubItems) {
+ // When search is active or parent is expanded, include visible subItems
+ if (searchQuery.trim() || expandedItems.has(item.title)) {
+ for (const sub of item.subItems!) {
+ if (sub.hasAccess === false) continue;
+ result.push({
+ title: sub.title,
+ url: getSidebarItemHref(sub),
+ queryParam: sub.queryParam,
+ });
+ }
+ } else {
+ // Parent is collapsed - include parent as a toggle target
+ if (item.hasAccess) result.push({ title: item.title, url: item.url });
+ }
+ } else {
+ if (item.hasAccess) result.push({ title: item.title, url: item.url });
+ }
+ }
+ return result;
+ }, [filteredItems, expandedItems, searchQuery]);
- const handleSearchKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setFocusedIndex((prev) =>
- Math.min(prev + 1, navigableItems.length - 1),
- );
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setFocusedIndex((prev) => Math.max(prev - 1, 0));
- } else if (e.key === "Enter") {
- e.preventDefault();
- const target = navigableItems[focusedIndex];
- if (target) {
- const url = target.url;
- if (target.isExternal || e.metaKey || e.ctrlKey) {
- window.open(url, "_blank", "noopener,noreferrer");
- } else {
- navigate(url);
- }
- setSearchQuery("");
- setFocusedIndex(-1);
- searchInputRef.current?.blur();
- }
- } else if (e.key === "Escape") {
- setSearchQuery("");
- setFocusedIndex(-1);
- searchInputRef.current?.blur();
- }
- },
- [navigableItems, focusedIndex, navigate],
- );
+ const handleSearchKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.min(prev + 1, navigableItems.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const target = navigableItems[focusedIndex];
+ if (target) {
+ const url = target.url;
+ if (target.isExternal || e.metaKey || e.ctrlKey) {
+ window.open(url, "_blank", "noopener,noreferrer");
+ } else {
+ navigate(url);
+ }
+ setSearchQuery("");
+ setFocusedIndex(-1);
+ searchInputRef.current?.blur();
+ }
+ } else if (e.key === "Escape") {
+ setSearchQuery("");
+ setFocusedIndex(-1);
+ searchInputRef.current?.blur();
+ }
+ },
+ [navigableItems, focusedIndex, navigate],
+ );
- // Auto-scroll focused item into view
- useEffect(() => {
- if (focusedIndex < 0) return;
- const url = navigableItems[focusedIndex]?.url;
- if (!url) return;
- const el = document.querySelector(`[data-nav-url="${url}"]`);
- el?.scrollIntoView({ block: "nearest" });
- }, [focusedIndex, navigableItems]);
+ // Auto-scroll focused item into view
+ useEffect(() => {
+ if (focusedIndex < 0) return;
+ const url = navigableItems[focusedIndex]?.url;
+ if (!url) return;
+ const el = document.querySelector(`[data-nav-url="${url}"]`);
+ el?.scrollIntoView({ block: "nearest" });
+ }, [focusedIndex, navigableItems]);
- const toggleItem = (title: string) => {
- setExpandedItems((prev) => {
- const next = new Set(prev);
- if (next.has(title)) {
- next.delete(title);
- } else {
- next.add(title);
- }
- return next;
- });
- };
+ const toggleItem = (title: string) => {
+ setExpandedItems((prev) => {
+ const next = new Set(prev);
+ if (next.has(title)) {
+ next.delete(title);
+ } else {
+ next.add(title);
+ }
+ return next;
+ });
+ };
- const configExceptions = ["/workspace/config/logging"];
+ const configExceptions = ["/workspace/config/logging"];
- const isActiveRoute = (url: string) => {
- if (url === "/" && pathname === "/") return true;
- // Avoid double-highlighting with "/workspace/custom-pricing/overrides"
- if (url === "/workspace/custom-pricing") return pathname === url;
- if (url !== "/" && pathname.startsWith(url)) {
- if (
- url === "/workspace/config" &&
- configExceptions.some((e) => pathname.startsWith(e))
- ) {
- return false;
- }
- return true;
- }
- return false;
- };
+ const isActiveRoute = (url: string) => {
+ if (url === "/" && pathname === "/") return true;
+ // Avoid double-highlighting with "/workspace/custom-pricing/overrides"
+ if (url === "/workspace/custom-pricing") return pathname === url;
+ if (url !== "/" && pathname.startsWith(url)) {
+ if (url === "/workspace/config" && configExceptions.some((e) => pathname.startsWith(e))) {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ };
- // Always render the light theme version for SSR to avoid hydration mismatch
- const logoSrc =
- mounted && resolvedTheme === "dark"
- ? "/bifrost-logo-dark.webp"
- : "/bifrost-logo.webp";
- const iconSrc =
- mounted && resolvedTheme === "dark"
- ? "/bifrost-icon-dark.webp"
- : "/bifrost-icon.webp";
+ // Always render the light theme version for SSR to avoid hydration mismatch
+ const logoSrc = mounted && resolvedTheme === "dark" ? "/bifrost-logo-dark.webp" : "/bifrost-logo.webp";
+ const iconSrc = mounted && resolvedTheme === "dark" ? "/bifrost-icon-dark.webp" : "/bifrost-icon.webp";
- const { isConnected: isWebSocketConnected } = useWebSocket();
+ const { isConnected: isWebSocketConnected } = useWebSocket();
- // New release image - based on theme
- const newReleaseImage =
- mounted && resolvedTheme === "dark"
- ? "/images/new-release-image-dark.webp"
- : "/images/new-release-image.webp";
+ // New release image - based on theme
+ const newReleaseImage = mounted && resolvedTheme === "dark" ? "/images/new-release-image-dark.webp" : "/images/new-release-image.webp";
- // Memoize promo cards array to prevent duplicates and unnecessary re-renders
- const promoCards = useMemo(() => {
- const cards = [];
- // Restart required card - non-dismissible, shown first
- if (coreConfig?.restart_required?.required) {
- cards.push({
- id: "restart-required",
- title: "Restart Required",
- description: (
-
- {coreConfig.restart_required.reason ||
- "Configuration changes require a server restart to take effect."}
-
- ),
- dismissible: false,
- variant: "warning" as const,
- });
- }
- if (showNewReleaseBanner && latestRelease) {
- cards.push({
- id: "new-release",
- title: `${latestRelease.name} is now available.`,
- description: (
-
- ),
- dismissible: true,
- });
- }
- // Only show after mounted to ensure cookie is properly hydrated and avoid flash
- if (!IS_ENTERPRISE && mounted && !isProductionSetupDismissed) {
- cards.push(productionSetupHelpCard);
- }
- return cards;
- }, [
- coreConfig?.restart_required,
- showNewReleaseBanner,
- latestRelease,
- newReleaseImage,
- isProductionSetupDismissed,
- mounted,
- ]);
+ // Memoize promo cards array to prevent duplicates and unnecessary re-renders
+ const promoCards = useMemo(() => {
+ const cards = [];
+ // Restart required card - non-dismissible, shown first
+ if (coreConfig?.restart_required?.required) {
+ cards.push({
+ id: "restart-required",
+ title: "Restart Required",
+ description: (
+
+ {coreConfig.restart_required.reason || "Configuration changes require a server restart to take effect."}
+
+ ),
+ dismissible: false,
+ variant: "warning" as const,
+ });
+ }
+ if (showNewReleaseBanner && latestRelease) {
+ cards.push({
+ id: "new-release",
+ title: `${latestRelease.name} is now available.`,
+ description: (
+
+ ),
+ dismissible: true,
+ });
+ }
+ // Only show after mounted to ensure cookie is properly hydrated and avoid flash
+ if (!IS_ENTERPRISE && mounted && !isProductionSetupDismissed) {
+ cards.push(productionSetupHelpCard);
+ }
+ return cards;
+ }, [coreConfig?.restart_required, showNewReleaseBanner, latestRelease, newReleaseImage, isProductionSetupDismissed, mounted]);
- // Reset areCardsEmpty when promoCards changes
- useEffect(() => {
- if (promoCards.length > 0) {
- setAreCardsEmpty(false);
- }
- }, [promoCards]);
+ // Reset areCardsEmpty when promoCards changes
+ useEffect(() => {
+ if (promoCards.length > 0) {
+ setAreCardsEmpty(false);
+ }
+ }, [promoCards]);
- const hasPromoCards = promoCards.length > 0 && !areCardsEmpty;
- // When cards are present: 13rem (header 3rem + bottom section ~10rem)
- // When no cards: 8rem (header 3rem + bottom section without cards ~5rem)
- const sidebarGroupHeight = hasPromoCards
- ? "h-[calc(100vh-13rem)]"
- : "h-[calc(100vh-8rem)]";
+ const hasPromoCards = promoCards.length > 0 && !areCardsEmpty;
+ // When cards are present: 13rem (header 3rem + bottom section ~10rem)
+ // When no cards: 8rem (header 3rem + bottom section without cards ~5rem)
+ const sidebarGroupHeight = hasPromoCards ? "h-[calc(100vh-13rem)]" : "h-[calc(100vh-8rem)]";
- const handleCardsEmpty = () => {
- setAreCardsEmpty(true);
- };
+ const handleCardsEmpty = () => {
+ setAreCardsEmpty(true);
+ };
- const handlePromoDismiss = useCallback(
- (cardId: string) => {
- if (cardId === "production-setup") {
- const expiryDate = new Date();
- expiryDate.setDate(expiryDate.getDate() + 7);
- setCookie(PRODUCTION_SETUP_DISMISSED_COOKIE, "true", {
- path: "/",
- expires: expiryDate,
- });
- }
- },
- [setCookie],
- );
+ const handlePromoDismiss = useCallback(
+ (cardId: string) => {
+ if (cardId === "production-setup") {
+ const expiryDate = new Date();
+ expiryDate.setDate(expiryDate.getDate() + 7);
+ setCookie(PRODUCTION_SETUP_DISMISSED_COOKIE, "true", {
+ path: "/",
+ expires: expiryDate,
+ });
+ }
+ },
+ [setCookie],
+ );
- const handleLogout = async () => {
- try {
- setUserPopoverOpen(false);
- await logout().unwrap();
- navigate("/login");
- } catch {
- // Even if logout fails on server, redirect to login
- navigate("/login");
- }
- };
+ const handleLogout = async () => {
+ try {
+ setUserPopoverOpen(false);
+ await logout().unwrap();
+ navigate("/login");
+ } catch {
+ // Even if logout fails on server, redirect to login
+ navigate("/login");
+ }
+ };
- const { state: sidebarState, toggleSidebar } = useSidebar();
+ const { state: sidebarState, toggleSidebar } = useSidebar();
- return (
-
-
- {/* Expanded state: horizontal layout */}
-
-
-
-
-
-
-
-
- {/* Collapsed state: vertical layout */}
-
-
-
-
-
-
-
- {
- setSearchQuery(e.target.value);
- setFocusedIndex(-1);
- }}
- onKeyDown={handleSearchKeyDown}
- className="border-input text-foreground placeholder:text-shadow-muted-foreground focus:ring-ring h-8 w-full rounded-sm border bg-transparent pr-14 pl-8 text-sm outline-none focus:bg-transparent"
- />
-
-
- ⌘
-
-
- K
-
-
-
-
-
-
-
-
- {filteredItems.map((item) => {
- const isActive = isActiveRoute(item.url);
+ return (
+
+
+ {/* Expanded state: horizontal layout */}
+
+
+
+
+
+
+
+
+ {/* Collapsed state: vertical layout */}
+
+
+
+
+
+
+
+ {
+ setSearchQuery(e.target.value);
+ setFocusedIndex(-1);
+ }}
+ onKeyDown={handleSearchKeyDown}
+ className="border-input text-foreground placeholder:text-shadow-muted-foreground focus:ring-ring h-8 w-full rounded-sm border bg-transparent pr-14 pl-8 text-sm outline-none focus:bg-transparent"
+ />
+
+ ⌘
+ K
+
+
+
+
+
+
+
+ {filteredItems.map((item) => {
+ const isActive = isActiveRoute(item.url);
- const highlightedUrl =
- focusedIndex >= 0
- ? navigableItems[focusedIndex]?.url
- : undefined;
- return (
- toggleItem(item.title)}
- pathname={pathname}
- search={search}
- isSidebarCollapsed={sidebarState === "collapsed"}
- expandSidebar={() => toggleSidebar()}
- highlightedUrl={highlightedUrl}
- />
- );
- })}
-
-
-
-
-
-
-
- {externalLinks.map((item, index) => (
-
-
-
-
-
- ))}
-
- {IS_ENTERPRISE &&
- userInfo &&
- (userInfo.name || userInfo.email) ? (
-
-
-
-
-
-
-
-
-
-
- {userInfo.name || userInfo.email || "User"}
-
-
-
-
-
- Logout
-
-
-
-
- ) : isAuthEnabled ? (
-
-
-
-
-
- ) : null}
-
-
-
-
-
-
-
- );
-}
+ const highlightedUrl = focusedIndex >= 0 ? navigableItems[focusedIndex]?.url : undefined;
+ return (
+ toggleItem(item.title)}
+ pathname={pathname}
+ search={search}
+ isSidebarCollapsed={sidebarState === "collapsed"}
+ expandSidebar={() => toggleSidebar()}
+ highlightedUrl={highlightedUrl}
+ />
+ );
+ })}
+
+
+
+
+
+
+
+ {externalLinks.map((item, index) => (
+
+
+
+
+
+ ))}
+
+ {IS_ENTERPRISE && userInfo && (userInfo.name || userInfo.email) ? (
+
+
+
+
+
+
+
+
+
+
{userInfo.name || userInfo.email || "User"}
+
+
+
+
+ Logout
+
+
+
+
+ ) : isAuthEnabled ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/components/trialExpiryBanner.tsx b/ui/components/trialExpiryBanner.tsx
index e913e7f932..694cc2b8a8 100644
--- a/ui/components/trialExpiryBanner.tsx
+++ b/ui/components/trialExpiryBanner.tsx
@@ -11,9 +11,7 @@ export default function TrialExpiryBanner() {
if (!expired && daysRemaining > 7) return null;
const critical = !expired && daysRemaining <= 3;
- const subject = expired
- ? "I need help with my expired enterprise trial"
- : "I need help extending my enterprise trial";
+ const subject = expired ? "I need help with my expired enterprise trial" : "I need help extending my enterprise trial";
const supportHref = `mailto:contact@getmaxim.ai?subject=${encodeURIComponent(subject)}`;
return (
@@ -21,9 +19,7 @@ export default function TrialExpiryBanner() {
id="trial-notification-banner"
className={cn(
"sticky top-0 z-10 flex w-full items-center justify-center gap-2 rounded-tl-md rounded-tr-md px-4 py-2 text-xs font-medium",
- expired || critical
- ? "bg-red-500/10 text-red-700 dark:text-red-400"
- : "bg-amber-500/10 text-amber-700 dark:text-amber-400",
+ expired || critical ? "bg-red-500/10 text-red-700 dark:text-red-400" : "bg-amber-500/10 text-amber-700 dark:text-amber-400",
)}
role="status"
>
@@ -47,4 +43,4 @@ export default function TrialExpiryBanner() {
)}
);
-}
+}
\ No newline at end of file
diff --git a/ui/components/ui/asyncMultiselect.tsx b/ui/components/ui/asyncMultiselect.tsx
index bfd80068af..4eea3a5ce3 100644
--- a/ui/components/ui/asyncMultiselect.tsx
+++ b/ui/components/ui/asyncMultiselect.tsx
@@ -593,7 +593,7 @@ function CustomDropdownIndicator
(
if (props.selectProps.hideDropdownIndicator) {
return null;
}
- return ;
+ return ;
}
function CustomMultiValueRemove(props: MultiValueRemoveProps> & { selectProps: CustomComponentsProps }) {
@@ -654,7 +654,7 @@ function CustomClearIndicator(props: ClearIndicatorProps> & { selec
diff --git a/ui/components/ui/badge.tsx b/ui/components/ui/badge.tsx
index 385d44d3a8..985930abee 100644
--- a/ui/components/ui/badge.tsx
+++ b/ui/components/ui/badge.tsx
@@ -5,44 +5,34 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-sm border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
- {
- variants: {
- variant: {
- default:
- "border-transparent bg-primary/10 border-primary/50 text-primary [a&]:hover:bg-primary/90 [a&]:hover:text-primary-foreground",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
- destructive:
- "border-transparent bg-destructive/10 border-destructive/50 text-black dark:text-destructive-foreground [a&]:hover:bg-destructive/90 [a&]:hover:text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
- outline:
- "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- success:
- "border-transparent bg-green-100 border-green-500 text-black [a&]:hover:bg-green-700/90 [a&]:hover:text-white",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
+ "inline-flex items-center justify-center rounded-sm border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary/10 border-primary/50 text-primary [a&]:hover:bg-primary/90 [a&]:hover:text-primary-foreground",
+ secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive/10 border-destructive/50 text-black dark:text-destructive-foreground [a&]:hover:bg-destructive/90 [a&]:hover:text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ success: "border-transparent bg-green-100 border-green-500 text-black [a&]:hover:bg-green-700/90 [a&]:hover:text-white",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
);
function Badge({
- className,
- variant,
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> &
- VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span";
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
- return (
-
- );
+ return ;
}
-export { Badge, badgeVariants };
+export { Badge, badgeVariants };
\ No newline at end of file
diff --git a/ui/components/ui/combobox.tsx b/ui/components/ui/combobox.tsx
index d7cbcd879b..3cda97d167 100644
--- a/ui/components/ui/combobox.tsx
+++ b/ui/components/ui/combobox.tsx
@@ -319,7 +319,7 @@ function ComboboxSelect(props: ComboboxSelectProps) {
disableSearch = false,
className,
emptyMessage = "No results found.",
- noPortal
+ noPortal,
} = props;
const [open, setOpen] = React.useState(false);
@@ -518,8 +518,7 @@ export {
ComboboxLabel,
ComboboxList,
ComboboxSelect,
- ComboboxSeparator
+ ComboboxSeparator,
};
-export type { ComboboxSelectOption, ComboboxSelectProps };
-
+export type { ComboboxSelectOption, ComboboxSelectProps };
\ No newline at end of file
diff --git a/ui/components/ui/custom/celBuilder/valueEditor.tsx b/ui/components/ui/custom/celBuilder/valueEditor.tsx
index 55c579a2ac..75c5dfef40 100644
--- a/ui/components/ui/custom/celBuilder/valueEditor.tsx
+++ b/ui/components/ui/custom/celBuilder/valueEditor.tsx
@@ -142,7 +142,7 @@ export function ValueEditor({
} else if (typeof parsedValue === "string") {
valueToUse = parsedValue;
}
- } catch(error) {}
+ } catch (error) {}
}
// For single operators (=, !=), use single select
diff --git a/ui/components/ui/dialog.tsx b/ui/components/ui/dialog.tsx
index 66b9fb32a8..c29bd0a3c7 100644
--- a/ui/components/ui/dialog.tsx
+++ b/ui/components/ui/dialog.tsx
@@ -102,5 +102,5 @@ export {
DialogOverlay,
DialogPortal,
DialogTitle,
- DialogTrigger
-};
+ DialogTrigger,
+};
\ No newline at end of file
diff --git a/ui/components/ui/envVarInput.tsx b/ui/components/ui/envVarInput.tsx
index a6447208a8..248ec09ff7 100644
--- a/ui/components/ui/envVarInput.tsx
+++ b/ui/components/ui/envVarInput.tsx
@@ -79,16 +79,14 @@ export const EnvVarInput = React.forwardRef"
- : maskNonEnvValue && !showBadge && !hasChanged.current
- ? maskValue(rawValue, maskVisiblePrefix, maskVisibleSuffix)
- : rawValue;
+ : maskNonEnvValue && !showBadge && !hasChanged.current
+ ? maskValue(rawValue, maskVisiblePrefix, maskVisibleSuffix)
+ : rawValue;
const handleChange = (e: React.ChangeEvent) => {
const inputValue = e.target.value;
const isMaskedOrPlaceholder =
- !hasChanged.current &&
- displayValue !== rawValue &&
- (displayValue === "" || (displayValue.length > 0 && !showBadge));
+ !hasChanged.current && displayValue !== rawValue && (displayValue === "" || (displayValue.length > 0 && !showBadge));
let newValue = inputValue;
if (isMaskedOrPlaceholder) {
if (inputValue === displayValue) {
diff --git a/ui/components/ui/multibudgets.tsx b/ui/components/ui/multibudgets.tsx
index 90fdc39048..ba309c5c72 100644
--- a/ui/components/ui/multibudgets.tsx
+++ b/ui/components/ui/multibudgets.tsx
@@ -6,146 +6,126 @@ import { Plus, RotateCcw, Trash2 } from "lucide-react";
import { useMemo } from "react";
export interface BudgetLineEntry {
- max_limit?: number;
- reset_duration: string;
+ max_limit?: number;
+ reset_duration: string;
}
interface MultiBudgetLinesProps {
- id?: string;
- "data-testid"?: string;
- label?: string;
- lines: BudgetLineEntry[];
- onChange: (lines: BudgetLineEntry[]) => void;
- options?: { label: string; value: string }[];
- onReset?: () => void;
- showReset?: boolean;
+ id?: string;
+ "data-testid"?: string;
+ label?: string;
+ lines: BudgetLineEntry[];
+ onChange: (lines: BudgetLineEntry[]) => void;
+ options?: { label: string; value: string }[];
+ onReset?: () => void;
+ showReset?: boolean;
}
export default function MultiBudgetLines({
- id,
- "data-testid": testId,
- label = "Budget Configuration",
- lines,
- onChange,
- options = resetDurationOptions,
- onReset,
- showReset,
+ id,
+ "data-testid": testId,
+ label = "Budget Configuration",
+ lines,
+ onChange,
+ options = resetDurationOptions,
+ onReset,
+ showReset,
}: MultiBudgetLinesProps) {
- // Track which reset durations are already used (for duplicate detection)
- const usedDurations = useMemo(() => {
- const counts = new Map();
- for (const line of lines) {
- counts.set(
- line.reset_duration,
- (counts.get(line.reset_duration) || 0) + 1,
- );
- }
- return counts;
- }, [lines]);
+ // Track which reset durations are already used (for duplicate detection)
+ const usedDurations = useMemo(() => {
+ const counts = new Map();
+ for (const line of lines) {
+ counts.set(line.reset_duration, (counts.get(line.reset_duration) || 0) + 1);
+ }
+ return counts;
+ }, [lines]);
- function addLine() {
- // Pick the first unused duration, falling back to the first option value
- const usedSet = new Set(lines.map((l) => l.reset_duration));
- const available = options.find((o) => !usedSet.has(o.value));
- onChange([
- ...lines,
- {
- max_limit: undefined,
- reset_duration: available?.value ?? options[0]?.value ?? "",
- },
- ]);
- }
+ function addLine() {
+ // Pick the first unused duration, falling back to the first option value
+ const usedSet = new Set(lines.map((l) => l.reset_duration));
+ const available = options.find((o) => !usedSet.has(o.value));
+ onChange([
+ ...lines,
+ {
+ max_limit: undefined,
+ reset_duration: available?.value ?? options[0]?.value ?? "",
+ },
+ ]);
+ }
- function removeLine(index: number) {
- onChange(lines.filter((_, i) => i !== index));
- }
+ function removeLine(index: number) {
+ onChange(lines.filter((_, i) => i !== index));
+ }
- function updateMaxLimit(index: number, value: number | undefined) {
- const updated = [...lines];
- updated[index] = { ...updated[index], max_limit: value };
- onChange(updated);
- }
+ function updateMaxLimit(index: number, value: number | undefined) {
+ const updated = [...lines];
+ updated[index] = { ...updated[index], max_limit: value };
+ onChange(updated);
+ }
- function updateResetDuration(index: number, value: string) {
- const updated = [...lines];
- updated[index] = { ...updated[index], reset_duration: value };
- onChange(updated);
- }
+ function updateResetDuration(index: number, value: string) {
+ const updated = [...lines];
+ updated[index] = { ...updated[index], reset_duration: value };
+ onChange(updated);
+ }
- return (
-
-
-
{label}
-
- {onReset && (showReset ?? true) && (
-
-
- Reset
-
- )}
-
-
- Add Budget
-
-
-
+ return (
+
+
+
{label}
+
+ {onReset && (showReset ?? true) && (
+
+
+ Reset
+
+ )}
+
+
+ Add Budget
+
+
+
- {lines.length === 0 && (
-
- No budget limits configured.
-
- )}
+ {lines.length === 0 && (
+
No budget limits configured.
+ )}
- {lines.map((line, index) => {
- const isDuplicate = (usedDurations.get(line.reset_duration) || 0) > 1;
- return (
-
-
-
- updateMaxLimit(index, value)}
- onChangeSelect={(value) => updateResetDuration(index, value)}
- options={options}
- />
-
-
removeLine(index)}
- >
-
-
-
- {isDuplicate && (
-
- Duplicate reset period — each budget line must use a different
- interval.
-
- )}
-
- );
- })}
-
- );
-}
+ {lines.map((line, index) => {
+ const isDuplicate = (usedDurations.get(line.reset_duration) || 0) > 1;
+ return (
+
+
+
+ updateMaxLimit(index, value)}
+ onChangeSelect={(value) => updateResetDuration(index, value)}
+ options={options}
+ />
+
+
removeLine(index)}
+ >
+
+
+
+ {isDuplicate && (
+
Duplicate reset period — each budget line must use a different interval.
+ )}
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/ui/components/ui/popover.tsx b/ui/components/ui/popover.tsx
index de89d4a2c5..e554443d82 100644
--- a/ui/components/ui/popover.tsx
+++ b/ui/components/ui/popover.tsx
@@ -11,7 +11,14 @@ function PopoverTrigger({ ...props }: React.ComponentProps ;
}
-function PopoverContent({ className, align = "center", sideOffset = 4, noPortal, onWheel, ...props }: React.ComponentProps & { noPortal?: boolean }) {
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ noPortal,
+ onWheel,
+ ...props
+}: React.ComponentProps & { noPortal?: boolean }) {
// react-remove-scroll (used by Sheet/Dialog) intercepts wheel events on elements outside
// the modal's DOM subtree. Portaled popovers render into document.body, so their wheel
// events get cancelled before the scroll container can act on them — the scrollbar appears
@@ -57,4 +64,4 @@ function PopoverAnchor({ ...props }: React.ComponentProps ;
}
-export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
+export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
\ No newline at end of file
diff --git a/ui/components/ui/select.tsx b/ui/components/ui/select.tsx
index 4fd6759242..fdea2e4e1b 100644
--- a/ui/components/ui/select.tsx
+++ b/ui/components/ui/select.tsx
@@ -158,5 +158,5 @@ export {
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
- SelectValue
-};
+ SelectValue,
+};
\ No newline at end of file
diff --git a/ui/components/ui/sheet.tsx b/ui/components/ui/sheet.tsx
index 0bfc8c45f5..e93d101f63 100644
--- a/ui/components/ui/sheet.tsx
+++ b/ui/components/ui/sheet.tsx
@@ -105,14 +105,14 @@ function SheetContent({
className={cn(
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out custom-scrollbar fixed z-50 flex flex-col shadow-lg transition-all ease-in-out overscroll-none data-[state=closed]:duration-100 data-[state=open]:duration-100",
side === "right" &&
- "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right top-2 right-0 bottom-2 h-auto w-3/4 rounded-l-lg border-l",
+ "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right top-2 right-0 bottom-2 h-auto w-3/4 rounded-l-lg border-l",
side === "right" && (!expandable || !expanded) && "sm:max-w-2xl",
side === "right" && expandable && expanded && "sm:max-w-5xl",
side === "left" &&
- "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left top-2 bottom-2 left-0 h-auto w-3/4 rounded-r-lg border-r sm:max-w-sm",
+ "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left top-2 bottom-2 left-0 h-auto w-3/4 rounded-r-lg border-r sm:max-w-sm",
side === "top" && "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
- "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
+ "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
@@ -175,4 +175,4 @@ function SheetDescription({ className, ...props }: React.ComponentProps ;
}
-export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
+export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
\ No newline at end of file
diff --git a/ui/components/ui/tooltip.tsx b/ui/components/ui/tooltip.tsx
index 8df69bf4fa..7cfdf25ea6 100644
--- a/ui/components/ui/tooltip.tsx
+++ b/ui/components/ui/tooltip.tsx
@@ -3,52 +3,38 @@ import * as React from "react";
import { cn } from "@/lib/utils";
-function TooltipProvider({
- delayDuration = 0,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
+function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) {
+ return ;
}
function Tooltip({ ...props }: React.ComponentProps) {
- return (
-
-
-
- );
+ return (
+
+
+
+ );
}
function TooltipTrigger({ ...props }: React.ComponentProps) {
- return ;
+ return ;
}
-function TooltipContent({
- className,
- sideOffset = 8,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
- );
+function TooltipContent({ className, sideOffset = 8, children, ...props }: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+ );
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
\ No newline at end of file
diff --git a/ui/lib/constants/icons.tsx b/ui/lib/constants/icons.tsx
index 0ada28866e..c108453faf 100644
--- a/ui/lib/constants/icons.tsx
+++ b/ui/lib/constants/icons.tsx
@@ -4,222 +4,205 @@ import { cn } from "../utils";
type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | number;
type IconProps = {
- size?: IconSize;
- className?: string;
- theme?: string;
+ size?: IconSize;
+ className?: string;
+ theme?: string;
};
// Size mapping in pixels
const sizeMap: Record = {
- xs: 20,
- sm: 32,
- md: 40,
- lg: 48,
- xl: 64,
+ xs: 20,
+ sm: 32,
+ md: 40,
+ lg: 48,
+ xl: 64,
};
// Function to resolve size value
const resolveSize = (size: IconSize): number => {
- if (typeof size === "number") return size;
- return sizeMap[size] || sizeMap.md;
+ if (typeof size === "number") return size;
+ return sizeMap[size] || sizeMap.md;
};
// Provider Icons with theme awareness where applicable
export const ProviderIcons = {
- anthropic: ({ size = "md", className = "", theme }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return theme === "light" ? (
-
-
-
- ) : (
-
-
-
- );
- },
-
- azure: ({ className = "" }: IconProps) => {
- return (
-
- );
- },
- bedrock: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return (
-
-
-
-
-
-
-
-
-
-
- );
- },
-
- cerebras: ({ size = "md", className = "", theme }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return theme === "light" ? (
-
- Cerebras
-
-
-
- ) : (
-
- Cerebras
-
-
-
- );
- },
-
- cohere: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return (
-
-
-
-
-
- );
- },
-
- elevenlabs: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
-
-
-
-
- );
- },
-
- groq: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- {
+ const resolvedSize = resolveSize(size);
+ return theme === "light" ? (
+
+
+
+ ) : (
+
+
+
+ );
+ },
+
+ azure: ({ className = "" }: IconProps) => {
+ return ;
+ },
+ bedrock: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+
+ cerebras: ({ size = "md", className = "", theme }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return theme === "light" ? (
+
+ Cerebras
+
+
+
+ ) : (
+
+ Cerebras
+
+
+
+ );
+ },
+
+ cohere: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+ return (
+
+
+
+
+
+ );
+ },
+
+ elevenlabs: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+
+
+
+
+ );
+ },
+
+ groq: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+
-
-
- );
- },
-
- mistral: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
-
-
-
-
-
-
- );
- },
-
- ollama: ({ size = "md", className = "", theme }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return theme === "light" ? (
-
-
-
- ) : (
-
-
-
- );
- },
-
- parasail: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return (
-
-
-
-
-
- );
- },
-
- perplexity: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return (
-
- Perplexity
-
-
- );
- },
-
- sgl: ({ className = "" }: IconProps) => {
- return (
-
- );
- },
- openai: ({ size = "md", className = "", theme }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return theme === "light" ? (
-
-
-
- ) : (
-
-
-
- );
- },
-
- vertex: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- },
-
- gemini: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- Gemini
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- },
-
- openrouter: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- OpenRouter
-
-
- );
- },
-
- huggingface: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- HuggingFace
-
-
-
-
-
-
-
- );
- },
- nebius: ({ className = "" }: IconProps) => {
- return (
-
- );
- },
- xai: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- Grok
-
-
- );
- },
- replicate: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- Replicate
-
-
- );
- },
- vllm: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
- return (
-
- vLLM
-
-
-
- );
- },
- runway: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- Runway
-
-
- );
- },
- fireworks: ({ size = "md", className = "" }: IconProps) => {
- const resolvedSize = resolveSize(size);
-
- return (
-
- Fireworks AI
-
-
- );
- },
+ />
+
+ );
+ },
+
+ mistral: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+
+
+
+
+
+
+ );
+ },
+
+ ollama: ({ size = "md", className = "", theme }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+ return theme === "light" ? (
+
+
+
+ ) : (
+
+
+
+ );
+ },
+
+ parasail: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+ return (
+
+
+
+
+
+ );
+ },
+
+ perplexity: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+ return (
+
+ Perplexity
+
+
+ );
+ },
+
+ sgl: ({ className = "" }: IconProps) => {
+ return ;
+ },
+ openai: ({ size = "md", className = "", theme }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return theme === "light" ? (
+
+
+
+ ) : (
+
+
+
+ );
+ },
+
+ vertex: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+
+ gemini: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ Gemini
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+
+ openrouter: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ OpenRouter
+
+
+ );
+ },
+
+ huggingface: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ HuggingFace
+
+
+
+
+
+
+
+ );
+ },
+ nebius: ({ className = "" }: IconProps) => {
+ return ;
+ },
+ xai: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ Grok
+
+
+ );
+ },
+ replicate: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ Replicate
+
+
+ );
+ },
+ vllm: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+ return (
+
+ vLLM
+
+
+
+ );
+ },
+ runway: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ Runway
+
+
+ );
+ },
+ fireworks: ({ size = "md", className = "" }: IconProps) => {
+ const resolvedSize = resolveSize(size);
+
+ return (
+
+ Fireworks AI
+
+
+ );
+ },
} as const;
// Routing Engine Icons
export const RoutingEngineUsedIcons = {
- "routing-rule": ({
- className = "h-5 w-5 text-blue-800",
- }: { className?: string } = {}) => ,
- governance: ({
- className = "h-5 w-5 text-green-800",
- }: { className?: string } = {}) => ,
- loadbalancing: ({
- className = "h-5 w-5 text-red-800",
- }: { className?: string } = {}) => ,
- "model-catalog": ({
- className = "h-5 w-5 text-purple-800",
- }: { className?: string } = {}) => ,
+ "routing-rule": ({ className = "h-5 w-5 text-blue-800" }: { className?: string } = {}) => ,
+ governance: ({ className = "h-5 w-5 text-green-800" }: { className?: string } = {}) => ,
+ loadbalancing: ({ className = "h-5 w-5 text-red-800" }: { className?: string } = {}) => ,
+ "model-catalog": ({ className = "h-5 w-5 text-purple-800" }: { className?: string } = {}) => ,
} as const;
export type RoutingEngineType = keyof typeof RoutingEngineUsedIcons;
// Helper component to render provider icons
-export const RenderProviderIcon = ({
- provider,
- ...props
-}: IconProps & { provider: keyof typeof ProviderIcons }) => {
- const { resolvedTheme } = useTheme();
- const IconComponent = ProviderIcons[provider];
- return IconComponent
- ? IconComponent({ ...props, theme: resolvedTheme, className: cn("w-5 h-5 shrink-0", props.className) })
- : null;
+export const RenderProviderIcon = ({ provider, ...props }: IconProps & { provider: keyof typeof ProviderIcons }) => {
+ const { resolvedTheme } = useTheme();
+ const IconComponent = ProviderIcons[provider];
+ return IconComponent ? IconComponent({ ...props, theme: resolvedTheme, className: cn("w-5 h-5 shrink-0", props.className) }) : null;
};
export type ProviderIconType = keyof typeof ProviderIcons;
-export default ProviderIcons;
+export default ProviderIcons;
\ No newline at end of file
diff --git a/ui/lib/store/apis/devApi.ts b/ui/lib/store/apis/devApi.ts
index 355b320fdc..6d92780018 100644
--- a/ui/lib/store/apis/devApi.ts
+++ b/ui/lib/store/apis/devApi.ts
@@ -1,109 +1,103 @@
-import { baseApi } from './baseApi'
+import { baseApi } from "./baseApi";
// Memory statistics at a point in time
export interface MemoryStats {
- alloc: number
- total_alloc: number
- heap_inuse: number
- heap_objects: number
- sys: number
+ alloc: number;
+ total_alloc: number;
+ heap_inuse: number;
+ heap_objects: number;
+ sys: number;
}
// CPU statistics
export interface CPUStats {
- usage_percent: number
- user_time: number
- system_time: number
+ usage_percent: number;
+ user_time: number;
+ system_time: number;
}
// Runtime statistics
export interface RuntimeStats {
- num_goroutine: number
- num_gc: number
- gc_pause_ns: number
- num_cpu: number
- gomaxprocs: number
+ num_goroutine: number;
+ num_gc: number;
+ gc_pause_ns: number;
+ num_cpu: number;
+ gomaxprocs: number;
}
// Allocation info for top allocations
export interface AllocationInfo {
- function: string
- file: string
- line: number
- bytes: number
- count: number
- stack: string[]
+ function: string;
+ file: string;
+ line: number;
+ bytes: number;
+ count: number;
+ stack: string[];
}
// Single point in the metrics history
export interface HistoryPoint {
- timestamp: string
- alloc: number
- heap_inuse: number
- goroutines: number
- gc_pause_ns: number
- cpu_percent: number
+ timestamp: string;
+ alloc: number;
+ heap_inuse: number;
+ goroutines: number;
+ gc_pause_ns: number;
+ cpu_percent: number;
}
// Complete pprof data response
export interface PprofData {
- timestamp: string
- memory: MemoryStats
- cpu: CPUStats
- runtime: RuntimeStats
- top_allocations: AllocationInfo[]
- inuse_allocations: AllocationInfo[]
- history: HistoryPoint[]
+ timestamp: string;
+ memory: MemoryStats;
+ cpu: CPUStats;
+ runtime: RuntimeStats;
+ top_allocations: AllocationInfo[];
+ inuse_allocations: AllocationInfo[];
+ history: HistoryPoint[];
}
// Goroutine group representing goroutines with same stack trace
export interface GoroutineGroup {
- count: number
- state: string
- wait_reason?: string
- wait_minutes?: number
- top_func: string
- stack: string[]
- category: 'background' | 'per-request' | 'unknown'
+ count: number;
+ state: string;
+ wait_reason?: string;
+ wait_minutes?: number;
+ top_func: string;
+ stack: string[];
+ category: "background" | "per-request" | "unknown";
}
// Goroutine health summary
export interface GoroutineSummary {
- background: number
- per_request: number
- long_waiting: number
- potentially_stuck: number
+ background: number;
+ per_request: number;
+ long_waiting: number;
+ potentially_stuck: number;
}
// Goroutine profile response
export interface GoroutineProfile {
- timestamp: string
- total_goroutines: number
- groups: GoroutineGroup[]
- summary: GoroutineSummary
+ timestamp: string;
+ total_goroutines: number;
+ groups: GoroutineGroup[];
+ summary: GoroutineSummary;
}
export const devApi = baseApi.injectEndpoints({
- endpoints: (builder) => ({
- // Get dev pprof data - polls every 10 seconds
- getDevPprof: builder.query({
- query: () => ({
- url: '/dev/pprof',
- }),
- }),
- // Get goroutine profile for leak detection
- getDevGoroutines: builder.query({
- query: () => ({
- url: '/dev/pprof/goroutines',
- }),
- }),
- }),
-})
-
-export const {
- useGetDevPprofQuery,
- useLazyGetDevPprofQuery,
- useGetDevGoroutinesQuery,
- useLazyGetDevGoroutinesQuery,
-} = devApi
+ endpoints: (builder) => ({
+ // Get dev pprof data - polls every 10 seconds
+ getDevPprof: builder.query({
+ query: () => ({
+ url: "/dev/pprof",
+ }),
+ }),
+ // Get goroutine profile for leak detection
+ getDevGoroutines: builder.query({
+ query: () => ({
+ url: "/dev/pprof/goroutines",
+ }),
+ }),
+ }),
+});
+export const { useGetDevPprofQuery, useLazyGetDevPprofQuery, useGetDevGoroutinesQuery, useLazyGetDevGoroutinesQuery } = devApi;
\ No newline at end of file
diff --git a/ui/lib/types/logs.ts b/ui/lib/types/logs.ts
index 68ec412578..e4da903a27 100644
--- a/ui/lib/types/logs.ts
+++ b/ui/lib/types/logs.ts
@@ -1227,4 +1227,4 @@ export const dateUtils = {
const startTime = Math.floor(date.getTime() / 1000);
return { startTime, endTime };
},
-};
+};
\ No newline at end of file
diff --git a/ui/lib/types/mcp.ts b/ui/lib/types/mcp.ts
index aecee5507c..eefc4cd44d 100644
--- a/ui/lib/types/mcp.ts
+++ b/ui/lib/types/mcp.ts
@@ -40,7 +40,7 @@ export interface MCPClientConfig {
stdio_config?: MCPStdioConfig;
auth_type?: MCPAuthType;
oauth_config_id?: string;
- oauth_client_id?: EnvVar; // Redacted existing client ID (populated on GET for oauth clients)
+ oauth_client_id?: EnvVar; // Redacted existing client ID (populated on GET for oauth clients)
oauth_client_secret?: EnvVar; // Redacted existing client secret (populated on GET for oauth clients)
tools_to_execute?: string[];
tools_to_auto_execute?: string[];
diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts
index 5fe0e5dc3b..044db14ac4 100644
--- a/ui/lib/types/schemas.ts
+++ b/ui/lib/types/schemas.ts
@@ -4,1123 +4,1031 @@ import { z } from "zod";
// Global error map - turns Zod's default messages into readable, human-friendly ones.
// Individual schemas can still override by passing their own message.
z.config({
- customError: (issue) => {
- if (issue.code === "invalid_type") {
- // Field is missing / undefined
- if (issue.input === undefined || issue.input === null) {
- return "This field is required";
- }
- const expected = issue.expected;
- const received = typeof issue.input;
- if (expected === "number") return "Must be a valid number";
- if (expected === "string") return "Must be a valid text value";
- if (expected === "boolean") return "Must be true or false";
- return `Expected ${expected}, received ${received}`;
- }
- if (issue.code === "too_small") {
- if (issue.origin === "string" && issue.minimum === 1) {
- return "This field is required";
- }
- if (issue.origin === "number") {
- return `Must be at least ${issue.minimum}`;
- }
- if (issue.origin === "array" && issue.minimum === 1) {
- return "At least one item is required";
- }
- }
- if (issue.code === "too_big") {
- if (issue.origin === "number") {
- return `Must be at most ${issue.maximum}`;
- }
- if (issue.origin === "string") {
- return `Must be at most ${issue.maximum} characters`;
- }
- }
- if (issue.code === "invalid_format") {
- if (issue.format === "url") return "Must be a valid URL";
- if (issue.format === "email") return "Must be a valid email";
- }
- return undefined; // fall back to Zod default
- },
+ customError: (issue) => {
+ if (issue.code === "invalid_type") {
+ // Field is missing / undefined
+ if (issue.input === undefined || issue.input === null) {
+ return "This field is required";
+ }
+ const expected = issue.expected;
+ const received = typeof issue.input;
+ if (expected === "number") return "Must be a valid number";
+ if (expected === "string") return "Must be a valid text value";
+ if (expected === "boolean") return "Must be true or false";
+ return `Expected ${expected}, received ${received}`;
+ }
+ if (issue.code === "too_small") {
+ if (issue.origin === "string" && issue.minimum === 1) {
+ return "This field is required";
+ }
+ if (issue.origin === "number") {
+ return `Must be at least ${issue.minimum}`;
+ }
+ if (issue.origin === "array" && issue.minimum === 1) {
+ return "At least one item is required";
+ }
+ }
+ if (issue.code === "too_big") {
+ if (issue.origin === "number") {
+ return `Must be at most ${issue.maximum}`;
+ }
+ if (issue.origin === "string") {
+ return `Must be at most ${issue.maximum} characters`;
+ }
+ }
+ if (issue.code === "invalid_format") {
+ if (issue.format === "url") return "Must be a valid URL";
+ if (issue.format === "email") return "Must be a valid email";
+ }
+ return undefined; // fall back to Zod default
+ },
});
// Base Zod schemas matching the TypeScript types
// Known provider schema
-export const knownProviderSchema = z.enum(
- KnownProvidersNames as unknown as [string, ...string[]],
-);
+export const knownProviderSchema = z.enum(KnownProvidersNames as unknown as [string, ...string[]]);
// Custom provider name schema (branded type simulation)
-export const customProviderNameSchema = z
- .string()
- .min(1, "Custom provider name is required");
+export const customProviderNameSchema = z.string().min(1, "Custom provider name is required");
// Model provider name schema (union of known and custom providers)
-export const modelProviderNameSchema = z.union([
- knownProviderSchema,
- customProviderNameSchema,
-]);
+export const modelProviderNameSchema = z.union([knownProviderSchema, customProviderNameSchema]);
// EnvVar schema - matches the Go EnvVar type from schemas/env.go
export const _envVarBase = z.object({
- value: z.string().optional(),
- env_var: z.string().optional(),
- from_env: z.boolean().optional(),
+ value: z.string().optional(),
+ env_var: z.string().optional(),
+ from_env: z.boolean().optional(),
});
// Extending the base schema
export const envVarSchema = Object.assign(_envVarBase, {
- required: (message: string) =>
- _envVarBase.refine(
- (v) => !!v?.value?.trim() || !!v?.env_var?.trim(),
- message,
- ),
+ required: (message: string) => _envVarBase.refine((v) => !!v?.value?.trim() || !!v?.env_var?.trim(), message),
});
// Helper to check if an envVar field has a value or env reference
-function isEnvVarSet(
- v: { value?: string; env_var?: string } | undefined,
-): boolean {
- if (!v) return false;
- return !!v.value?.trim() || !!v.env_var?.trim();
+function isEnvVarSet(v: { value?: string; env_var?: string } | undefined): boolean {
+ if (!v) return false;
+ return !!v.value?.trim() || !!v.env_var?.trim();
}
// Azure key config schema
export const azureKeyConfigSchema = z
- .object({
- _auth_type: z
- .enum(["api_key", "entra_id", "default_credential"])
- .optional(),
- endpoint: envVarSchema.optional(),
- api_version: envVarSchema.optional(),
- client_id: envVarSchema.optional(),
- client_secret: envVarSchema.optional(),
- tenant_id: envVarSchema.optional(),
- scopes: z.array(z.string()).optional(),
- })
- .refine((data) => isEnvVarSet(data.endpoint), {
- message: "Endpoint is required",
- path: ["endpoint"],
- })
- .refine(
- (data) => {
- // When using Entra ID, all three fields are required
- if (data._auth_type === "entra_id") {
- return (
- isEnvVarSet(data.client_id) &&
- isEnvVarSet(data.client_secret) &&
- isEnvVarSet(data.tenant_id)
- );
- }
- // Otherwise, if any Entra ID field is set, all three must be set
- const hasClientId = isEnvVarSet(data.client_id);
- const hasClientSecret = isEnvVarSet(data.client_secret);
- const hasTenantId = isEnvVarSet(data.tenant_id);
- const anyEntraField = hasClientId || hasClientSecret || hasTenantId;
- if (!anyEntraField) return true;
- return hasClientId && hasClientSecret && hasTenantId;
- },
- {
- message:
- "Client ID, Client Secret, and Tenant ID are all required for Entra ID authentication",
- path: ["client_id"],
- },
- );
+ .object({
+ _auth_type: z.enum(["api_key", "entra_id", "default_credential"]).optional(),
+ endpoint: envVarSchema.optional(),
+ api_version: envVarSchema.optional(),
+ client_id: envVarSchema.optional(),
+ client_secret: envVarSchema.optional(),
+ tenant_id: envVarSchema.optional(),
+ scopes: z.array(z.string()).optional(),
+ })
+ .refine((data) => isEnvVarSet(data.endpoint), {
+ message: "Endpoint is required",
+ path: ["endpoint"],
+ })
+ .refine(
+ (data) => {
+ // When using Entra ID, all three fields are required
+ if (data._auth_type === "entra_id") {
+ return isEnvVarSet(data.client_id) && isEnvVarSet(data.client_secret) && isEnvVarSet(data.tenant_id);
+ }
+ // Otherwise, if any Entra ID field is set, all three must be set
+ const hasClientId = isEnvVarSet(data.client_id);
+ const hasClientSecret = isEnvVarSet(data.client_secret);
+ const hasTenantId = isEnvVarSet(data.tenant_id);
+ const anyEntraField = hasClientId || hasClientSecret || hasTenantId;
+ if (!anyEntraField) return true;
+ return hasClientId && hasClientSecret && hasTenantId;
+ },
+ {
+ message: "Client ID, Client Secret, and Tenant ID are all required for Entra ID authentication",
+ path: ["client_id"],
+ },
+ );
// Vertex key config schema
export const vertexKeyConfigSchema = z
- .object({
- _auth_type: z
- .enum(["service_account", "service_account_json", "api_key"])
- .optional(),
- project_id: envVarSchema.optional(),
- project_number: envVarSchema.optional(),
- region: envVarSchema.optional(),
- auth_credentials: envVarSchema.optional(),
- })
- .refine((data) => isEnvVarSet(data.project_id), {
- message: "Project ID is required",
- path: ["project_id"],
- })
- .refine((data) => isEnvVarSet(data.region), {
- message: "Region is required",
- path: ["region"],
- })
- .refine(
- (data) => {
- // When using service_account_json auth, auth_credentials is required
- if (data._auth_type === "service_account_json") {
- return isEnvVarSet(data.auth_credentials);
- }
- return true;
- },
- {
- message:
- "Auth Credentials is required for service account JSON authentication",
- path: ["auth_credentials"],
- },
- );
+ .object({
+ _auth_type: z.enum(["service_account", "service_account_json", "api_key"]).optional(),
+ project_id: envVarSchema.optional(),
+ project_number: envVarSchema.optional(),
+ region: envVarSchema.optional(),
+ auth_credentials: envVarSchema.optional(),
+ })
+ .refine((data) => isEnvVarSet(data.project_id), {
+ message: "Project ID is required",
+ path: ["project_id"],
+ })
+ .refine((data) => isEnvVarSet(data.region), {
+ message: "Region is required",
+ path: ["region"],
+ })
+ .refine(
+ (data) => {
+ // When using service_account_json auth, auth_credentials is required
+ if (data._auth_type === "service_account_json") {
+ return isEnvVarSet(data.auth_credentials);
+ }
+ return true;
+ },
+ {
+ message: "Auth Credentials is required for service account JSON authentication",
+ path: ["auth_credentials"],
+ },
+ );
// S3 bucket configuration for Bedrock batch operations
export const s3BucketConfigSchema = z.object({
- bucket_name: z.string().min(1, "Bucket name is required"),
- prefix: z.string().optional(),
- is_default: z.boolean().optional(),
+ bucket_name: z.string().min(1, "Bucket name is required"),
+ prefix: z.string().optional(),
+ is_default: z.boolean().optional(),
});
export const batchS3ConfigSchema = z.object({
- buckets: z.array(s3BucketConfigSchema).optional(),
+ buckets: z.array(s3BucketConfigSchema).optional(),
});
// Bedrock key config schema
export const bedrockKeyConfigSchema = z
- .object({
- _auth_type: z.enum(["iam_role", "explicit", "api_key"]).optional(),
- access_key: envVarSchema.optional(),
- secret_key: envVarSchema.optional(),
- session_token: envVarSchema.optional(),
- region: envVarSchema.optional(),
- role_arn: envVarSchema.optional(),
- external_id: envVarSchema.optional(),
- session_name: envVarSchema.optional(),
- arn: envVarSchema.optional(),
- batch_s3_config: batchS3ConfigSchema.optional(),
- })
- .refine(
- (data) => {
- // Region is required for Bedrock
- return isEnvVarSet(data.region);
- },
- {
- message: "Region is required",
- path: ["region"],
- },
- )
- .refine(
- (data) => {
- // When using explicit credentials, both access_key and secret_key are required
- if (data._auth_type === "explicit") {
- return isEnvVarSet(data.access_key) && isEnvVarSet(data.secret_key);
- }
- // Otherwise, if either is set both must be set
- const hasAccessKey = isEnvVarSet(data.access_key);
- const hasSecretKey = isEnvVarSet(data.secret_key);
- if (!hasAccessKey && !hasSecretKey) return true;
- return hasAccessKey && hasSecretKey;
- },
- {
- message:
- "Both Access Key and Secret Key are required for explicit credentials",
- path: ["access_key"],
- },
- );
+ .object({
+ _auth_type: z.enum(["iam_role", "explicit", "api_key"]).optional(),
+ access_key: envVarSchema.optional(),
+ secret_key: envVarSchema.optional(),
+ session_token: envVarSchema.optional(),
+ region: envVarSchema.optional(),
+ role_arn: envVarSchema.optional(),
+ external_id: envVarSchema.optional(),
+ session_name: envVarSchema.optional(),
+ arn: envVarSchema.optional(),
+ batch_s3_config: batchS3ConfigSchema.optional(),
+ })
+ .refine(
+ (data) => {
+ // Region is required for Bedrock
+ return isEnvVarSet(data.region);
+ },
+ {
+ message: "Region is required",
+ path: ["region"],
+ },
+ )
+ .refine(
+ (data) => {
+ // When using explicit credentials, both access_key and secret_key are required
+ if (data._auth_type === "explicit") {
+ return isEnvVarSet(data.access_key) && isEnvVarSet(data.secret_key);
+ }
+ // Otherwise, if either is set both must be set
+ const hasAccessKey = isEnvVarSet(data.access_key);
+ const hasSecretKey = isEnvVarSet(data.secret_key);
+ if (!hasAccessKey && !hasSecretKey) return true;
+ return hasAccessKey && hasSecretKey;
+ },
+ {
+ message: "Both Access Key and Secret Key are required for explicit credentials",
+ path: ["access_key"],
+ },
+ );
// VLLM key config schema
export const vllmKeyConfigSchema = z
- .object({
- url: envVarSchema.optional(),
- model_name: z.string().trim().min(1, "Model name is required"),
- })
- .refine((data) => isEnvVarSet(data.url), {
- message: "Server URL is required",
- path: ["url"],
- });
+ .object({
+ url: envVarSchema.optional(),
+ model_name: z.string().trim().min(1, "Model name is required"),
+ })
+ .refine((data) => isEnvVarSet(data.url), {
+ message: "Server URL is required",
+ path: ["url"],
+ });
export const replicateKeyConfigSchema = z.object({
- use_deployments_endpoint: z.boolean(),
+ use_deployments_endpoint: z.boolean(),
});
// Ollama key config schema
export const ollamaKeyConfigSchema = z
- .object({
- url: envVarSchema.optional(),
- })
- .refine((data) => isEnvVarSet(data.url), {
- message: "Server URL is required",
- path: ["url"],
- });
+ .object({
+ url: envVarSchema.optional(),
+ })
+ .refine((data) => isEnvVarSet(data.url), {
+ message: "Server URL is required",
+ path: ["url"],
+ });
// SGL key config schema
export const sglKeyConfigSchema = z
- .object({
- url: envVarSchema.optional(),
- })
- .refine((data) => isEnvVarSet(data.url), {
- message: "Server URL is required",
- path: ["url"],
- });
+ .object({
+ url: envVarSchema.optional(),
+ })
+ .refine((data) => isEnvVarSet(data.url), {
+ message: "Server URL is required",
+ path: ["url"],
+ });
// Model provider key schema
export const modelProviderKeySchema = z
- .object({
- id: z.string().min(1, "Id is required"),
- name: z.string().min(1, "Name is required"),
- value: envVarSchema.optional(),
- models: z.array(z.string()).optional().default(["*"]),
- blacklisted_models: z.array(z.string()).default([]).optional(),
- weight: z
- .union([z.number(), z.string()])
- .transform((val, ctx) => {
- if (typeof val === "number") return val;
- if (val.trim() === "") return 1.0;
- // Use Number() rather than parseFloat() so that strings like "0.5abc"
- // are rejected outright instead of silently parsing to 0.5.
- const num = Number(val);
- if (!Number.isFinite(num)) {
- ctx.addIssue({
- code: "custom",
- message: "Weight must be a valid number between 0 and 1",
- });
- return z.NEVER;
- }
- return num;
- })
- .pipe(
- z
- .number()
- .min(0, "Weight must be equal to or greater than 0")
- .max(1, "Weight must be equal to or less than 1"),
- ),
- aliases: z.record(z.string(), z.string()).optional(),
- azure_key_config: azureKeyConfigSchema.optional(),
- vertex_key_config: vertexKeyConfigSchema.optional(),
- bedrock_key_config: bedrockKeyConfigSchema.optional(),
- vllm_key_config: vllmKeyConfigSchema.optional(),
- replicate_key_config: replicateKeyConfigSchema.optional(),
- ollama_key_config: ollamaKeyConfigSchema.optional(),
- sgl_key_config: sglKeyConfigSchema.optional(),
- use_for_batch_api: z.boolean().optional(),
- enabled: z.boolean().optional(),
- })
- .refine(
- (data) => {
- // Providers with dedicated config that never need a top-level API key
- if (
- data.vllm_key_config ||
- data.replicate_key_config ||
- data.ollama_key_config ||
- data.sgl_key_config
- ) {
- return true;
- }
- // Azure requires API key only when using api_key auth
- if (data.azure_key_config) {
- if (data.azure_key_config._auth_type === "api_key") {
- return isEnvVarSet(data.value);
- }
- return true;
- }
- // Bedrock only requires API key when using api_key auth
- if (data.bedrock_key_config) {
- if (data.bedrock_key_config._auth_type === "api_key") {
- return isEnvVarSet(data.value);
- }
- return true;
- }
- // Vertex requires API key only when using api_key auth
- if (data.vertex_key_config) {
- if (data.vertex_key_config._auth_type === "api_key") {
- return isEnvVarSet(data.value);
- }
- return true;
- }
- // Otherwise, value is required
- return isEnvVarSet(data.value);
- },
- {
- message: "API Key is required",
- path: ["value"],
- },
- );
+ .object({
+ id: z.string().min(1, "Id is required"),
+ name: z.string().min(1, "Name is required"),
+ value: envVarSchema.optional(),
+ models: z.array(z.string()).optional().default(["*"]),
+ blacklisted_models: z.array(z.string()).default([]).optional(),
+ weight: z
+ .union([z.number(), z.string()])
+ .transform((val, ctx) => {
+ if (typeof val === "number") return val;
+ if (val.trim() === "") return 1.0;
+ // Use Number() rather than parseFloat() so that strings like "0.5abc"
+ // are rejected outright instead of silently parsing to 0.5.
+ const num = Number(val);
+ if (!Number.isFinite(num)) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Weight must be a valid number between 0 and 1",
+ });
+ return z.NEVER;
+ }
+ return num;
+ })
+ .pipe(z.number().min(0, "Weight must be equal to or greater than 0").max(1, "Weight must be equal to or less than 1")),
+ aliases: z.record(z.string(), z.string()).optional(),
+ azure_key_config: azureKeyConfigSchema.optional(),
+ vertex_key_config: vertexKeyConfigSchema.optional(),
+ bedrock_key_config: bedrockKeyConfigSchema.optional(),
+ vllm_key_config: vllmKeyConfigSchema.optional(),
+ replicate_key_config: replicateKeyConfigSchema.optional(),
+ ollama_key_config: ollamaKeyConfigSchema.optional(),
+ sgl_key_config: sglKeyConfigSchema.optional(),
+ use_for_batch_api: z.boolean().optional(),
+ enabled: z.boolean().optional(),
+ })
+ .refine(
+ (data) => {
+ // Providers with dedicated config that never need a top-level API key
+ if (data.vllm_key_config || data.replicate_key_config || data.ollama_key_config || data.sgl_key_config) {
+ return true;
+ }
+ // Azure requires API key only when using api_key auth
+ if (data.azure_key_config) {
+ if (data.azure_key_config._auth_type === "api_key") {
+ return isEnvVarSet(data.value);
+ }
+ return true;
+ }
+ // Bedrock only requires API key when using api_key auth
+ if (data.bedrock_key_config) {
+ if (data.bedrock_key_config._auth_type === "api_key") {
+ return isEnvVarSet(data.value);
+ }
+ return true;
+ }
+ // Vertex requires API key only when using api_key auth
+ if (data.vertex_key_config) {
+ if (data.vertex_key_config._auth_type === "api_key") {
+ return isEnvVarSet(data.value);
+ }
+ return true;
+ }
+ // Otherwise, value is required
+ return isEnvVarSet(data.value);
+ },
+ {
+ message: "API Key is required",
+ path: ["value"],
+ },
+ );
// Network config schema
export const networkConfigSchema = z
- .object({
- base_url: z
- .union([z.string().url("Must be a valid URL"), z.string().length(0)])
- .optional(),
- extra_headers: z.record(z.string(), z.string()).optional(),
- default_request_timeout_in_seconds: z
- .number()
- .min(1, "Timeout must be greater than 0 seconds")
- .max(3600, "Timeout must be less than 3600 seconds"),
- max_retries: z
- .number()
- .min(0, "Max retries must be greater than 0")
- .max(10, "Max retries must be less than 10"),
- retry_backoff_initial: z.number().min(100),
- retry_backoff_max: z.number().min(100),
- insecure_skip_verify: z.boolean().optional(),
- ca_cert_pem: envVarSchema.optional(),
- stream_idle_timeout_in_seconds: z
- .number()
- .int("Stream idle timeout must be a whole number of seconds")
- .min(5, "Stream idle timeout must be at least 5 seconds")
- .max(
- 3600,
- "Stream idle timeout must be at most 3600 seconds i.e. 60 minutes",
- )
- .optional(),
- max_conns_per_host: z
- .number()
- .int("Max connections must be a whole number")
- .min(1, "Max connections must be at least 1")
- .max(10000, "Max connections must be at most 10000")
- .optional(),
- enforce_http2: z.boolean().optional(),
- })
- .refine((d) => d.retry_backoff_initial <= d.retry_backoff_max, {
- message: "retry_backoff_initial must be <= retry_backoff_max",
- path: ["retry_backoff_initial"],
- });
+ .object({
+ base_url: z.union([z.string().url("Must be a valid URL"), z.string().length(0)]).optional(),
+ extra_headers: z.record(z.string(), z.string()).optional(),
+ default_request_timeout_in_seconds: z
+ .number()
+ .min(1, "Timeout must be greater than 0 seconds")
+ .max(3600, "Timeout must be less than 3600 seconds"),
+ max_retries: z.number().min(0, "Max retries must be greater than 0").max(10, "Max retries must be less than 10"),
+ retry_backoff_initial: z.number().min(100),
+ retry_backoff_max: z.number().min(100),
+ insecure_skip_verify: z.boolean().optional(),
+ ca_cert_pem: envVarSchema.optional(),
+ stream_idle_timeout_in_seconds: z
+ .number()
+ .int("Stream idle timeout must be a whole number of seconds")
+ .min(5, "Stream idle timeout must be at least 5 seconds")
+ .max(3600, "Stream idle timeout must be at most 3600 seconds i.e. 60 minutes")
+ .optional(),
+ max_conns_per_host: z
+ .number()
+ .int("Max connections must be a whole number")
+ .min(1, "Max connections must be at least 1")
+ .max(10000, "Max connections must be at most 10000")
+ .optional(),
+ enforce_http2: z.boolean().optional(),
+ })
+ .refine((d) => d.retry_backoff_initial <= d.retry_backoff_max, {
+ message: "retry_backoff_initial must be <= retry_backoff_max",
+ path: ["retry_backoff_initial"],
+ });
// Network form schema - more lenient for form inputs
export const networkFormConfigSchema = z
- .object({
- base_url: z
- .union([
- z
- .string()
- .url("Must be a valid URL")
- .refine(
- (url) => url.startsWith("https://") || url.startsWith("http://"),
- {
- message: "Must be a valid HTTP or HTTPS URL",
- },
- ),
- z.string().length(0),
- ])
- .optional(),
- extra_headers: z.record(z.string(), z.string()).optional(),
- default_request_timeout_in_seconds: z.coerce
- .number("Timeout must be a number")
- .min(1, "Timeout must be greater than 0 seconds")
- .max(172800, "Timeout must be less than 172800 seconds i.e. 48 hours"),
- max_retries: z.coerce
- .number("Max retries must be a number")
- .min(0, "Max retries must be greater than 0")
- .max(10, "Max retries must be less than 10"),
- retry_backoff_initial: z.coerce
- .number("Retry backoff initial must be a number")
- .min(100, "Retry backoff initial must be at least 100ms")
- .max(1000000, "Retry backoff initial must be at most 1000000ms"),
- retry_backoff_max: z.coerce
- .number("Retry backoff max must be a number")
- .min(100, "Retry backoff max must be at least 100ms")
- .max(1000000, "Retry backoff max must be at most 1000000ms"),
- insecure_skip_verify: z.boolean().optional(),
- ca_cert_pem: envVarSchema.optional(),
- stream_idle_timeout_in_seconds: z.coerce
- .number("Stream idle timeout must be a number")
- .int("Stream idle timeout must be a whole number of seconds")
- .min(5, "Stream idle timeout must be at least 5 seconds")
- .max(
- 3600,
- "Stream idle timeout must be at most 3600 seconds i.e. 60 minutes",
- )
- .optional(),
- max_conns_per_host: z.coerce
- .number("Max connections must be a number")
- .int("Max connections must be a whole number")
- .min(1, "Max connections must be at least 1")
- .max(10000, "Max connections must be at most 10000")
- .optional(),
- enforce_http2: z.boolean().optional(),
- })
- .refine((d) => d.retry_backoff_initial <= d.retry_backoff_max, {
- message: "Initial backoff must be less than or equal to max backoff",
- path: ["retry_backoff_initial"],
- });
+ .object({
+ base_url: z
+ .union([
+ z
+ .string()
+ .url("Must be a valid URL")
+ .refine((url) => url.startsWith("https://") || url.startsWith("http://"), {
+ message: "Must be a valid HTTP or HTTPS URL",
+ }),
+ z.string().length(0),
+ ])
+ .optional(),
+ extra_headers: z.record(z.string(), z.string()).optional(),
+ default_request_timeout_in_seconds: z.coerce
+ .number("Timeout must be a number")
+ .min(1, "Timeout must be greater than 0 seconds")
+ .max(172800, "Timeout must be less than 172800 seconds i.e. 48 hours"),
+ max_retries: z.coerce
+ .number("Max retries must be a number")
+ .min(0, "Max retries must be greater than 0")
+ .max(10, "Max retries must be less than 10"),
+ retry_backoff_initial: z.coerce
+ .number("Retry backoff initial must be a number")
+ .min(100, "Retry backoff initial must be at least 100ms")
+ .max(1000000, "Retry backoff initial must be at most 1000000ms"),
+ retry_backoff_max: z.coerce
+ .number("Retry backoff max must be a number")
+ .min(100, "Retry backoff max must be at least 100ms")
+ .max(1000000, "Retry backoff max must be at most 1000000ms"),
+ insecure_skip_verify: z.boolean().optional(),
+ ca_cert_pem: envVarSchema.optional(),
+ stream_idle_timeout_in_seconds: z.coerce
+ .number("Stream idle timeout must be a number")
+ .int("Stream idle timeout must be a whole number of seconds")
+ .min(5, "Stream idle timeout must be at least 5 seconds")
+ .max(3600, "Stream idle timeout must be at most 3600 seconds i.e. 60 minutes")
+ .optional(),
+ max_conns_per_host: z.coerce
+ .number("Max connections must be a number")
+ .int("Max connections must be a whole number")
+ .min(1, "Max connections must be at least 1")
+ .max(10000, "Max connections must be at most 10000")
+ .optional(),
+ enforce_http2: z.boolean().optional(),
+ })
+ .refine((d) => d.retry_backoff_initial <= d.retry_backoff_max, {
+ message: "Initial backoff must be less than or equal to max backoff",
+ path: ["retry_backoff_initial"],
+ });
// Concurrency and buffer size schema
export const concurrencyAndBufferSizeSchema = z.object({
- concurrency: z
- .number()
- .min(1, "Concurrency must be greater than 0")
- .max(100, "Concurrency must be less than or equal to 100"),
- buffer_size: z
- .number()
- .min(1, "Buffer size must be greater than 0")
- .max(1000, "Buffer size must be less than or equal to 1000"),
+ concurrency: z.number().min(1, "Concurrency must be greater than 0").max(100, "Concurrency must be less than or equal to 100"),
+ buffer_size: z.number().min(1, "Buffer size must be greater than 0").max(1000, "Buffer size must be less than or equal to 1000"),
});
// Proxy type schema
-export const proxyTypeSchema = z.enum([
- "none",
- "http",
- "socks5",
- "environment",
-]);
+export const proxyTypeSchema = z.enum(["none", "http", "socks5", "environment"]);
// Proxy config schema
export const proxyConfigSchema = z
- .object({
- type: proxyTypeSchema,
- url: envVarSchema.optional(),
- username: envVarSchema.optional(),
- password: envVarSchema.optional(),
- ca_cert_pem: envVarSchema.optional(),
- })
- .refine(
- (data) =>
- !(data.type === "http" || data.type === "socks5") ||
- data.url?.from_env === true ||
- (data.url?.value && data.url.value.trim().length > 0),
- {
- message: "Proxy URL is required when using HTTP or SOCKS5 proxy",
- path: ["url"],
- },
- )
- .refine(
- (data) => {
- if (
- (data.type === "http" || data.type === "socks5") &&
- data.url?.value?.trim()
- ) {
- if (data.url.from_env || data.url.env_var?.startsWith("env.")) {
- return true;
- }
- try {
- new URL(data.url.value);
- return true;
- } catch {
- return false;
- }
- }
- return true;
- },
- {
- message: "Must be a valid URL (e.g., http://proxy.example.com:8080)",
- path: ["url"],
- },
- );
+ .object({
+ type: proxyTypeSchema,
+ url: envVarSchema.optional(),
+ username: envVarSchema.optional(),
+ password: envVarSchema.optional(),
+ ca_cert_pem: envVarSchema.optional(),
+ })
+ .refine(
+ (data) =>
+ !(data.type === "http" || data.type === "socks5") ||
+ data.url?.from_env === true ||
+ (data.url?.value && data.url.value.trim().length > 0),
+ {
+ message: "Proxy URL is required when using HTTP or SOCKS5 proxy",
+ path: ["url"],
+ },
+ )
+ .refine(
+ (data) => {
+ if ((data.type === "http" || data.type === "socks5") && data.url?.value?.trim()) {
+ if (data.url.from_env || data.url.env_var?.startsWith("env.")) {
+ return true;
+ }
+ try {
+ new URL(data.url.value);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return true;
+ },
+ {
+ message: "Must be a valid URL (e.g., http://proxy.example.com:8080)",
+ path: ["url"],
+ },
+ );
// Proxy form schema - more lenient for form inputs with conditional validation
export const proxyFormConfigSchema = z
- .object({
- type: proxyTypeSchema,
- url: envVarSchema.optional(),
- username: envVarSchema.optional(),
- password: envVarSchema.optional(),
- ca_cert_pem: envVarSchema.optional(),
- })
- .refine(
- (data) => {
- if (data.type === "none") {
- return true;
- }
- // URL is required when proxy type is http or socks5
- if (data.type === "http" || data.type === "socks5") {
- // Env-backed URLs may have empty resolved value before env resolution.
- if (data.url?.from_env || data.url?.env_var?.startsWith("env."))
- return true;
- // Literal URLs must be non-empty.
- if (!data.url?.value || data.url.value.trim().length === 0)
- return false;
- }
- return true;
- },
- {
- message: "Proxy URL is required when using HTTP or SOCKS5 proxy",
- path: ["url"],
- },
- )
- .refine(
- (data) => {
- // URL must be valid format when provided and proxy type requires it
- if (
- (data.type === "http" || data.type === "socks5") &&
- data.url?.value &&
- data.url.value.trim().length > 0
- ) {
- if (data.url.from_env || data.url.env_var?.startsWith("env.")) {
- return true;
- }
- try {
- new URL(data.url.value);
- return true;
- } catch {
- return false;
- }
- }
- return true;
- },
- {
- message: "Must be a valid URL (e.g., http://proxy.example.com:8080)",
- path: ["url"],
- },
- );
+ .object({
+ type: proxyTypeSchema,
+ url: envVarSchema.optional(),
+ username: envVarSchema.optional(),
+ password: envVarSchema.optional(),
+ ca_cert_pem: envVarSchema.optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.type === "none") {
+ return true;
+ }
+ // URL is required when proxy type is http or socks5
+ if (data.type === "http" || data.type === "socks5") {
+ // Env-backed URLs may have empty resolved value before env resolution.
+ if (data.url?.from_env || data.url?.env_var?.startsWith("env.")) return true;
+ // Literal URLs must be non-empty.
+ if (!data.url?.value || data.url.value.trim().length === 0) return false;
+ }
+ return true;
+ },
+ {
+ message: "Proxy URL is required when using HTTP or SOCKS5 proxy",
+ path: ["url"],
+ },
+ )
+ .refine(
+ (data) => {
+ // URL must be valid format when provided and proxy type requires it
+ if ((data.type === "http" || data.type === "socks5") && data.url?.value && data.url.value.trim().length > 0) {
+ if (data.url.from_env || data.url.env_var?.startsWith("env.")) {
+ return true;
+ }
+ try {
+ new URL(data.url.value);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return true;
+ },
+ {
+ message: "Must be a valid URL (e.g., http://proxy.example.com:8080)",
+ path: ["url"],
+ },
+ );
// OpenAI Config tab
export const openaiConfigFormSchema = z.object({
- disable_store: z.boolean(),
+ disable_store: z.boolean(),
});
export type OpenAIConfigFormSchema = z.infer;
// Allowed requests schema
export const allowedRequestsSchema = z.object({
- text_completion: z.boolean(),
- text_completion_stream: z.boolean(),
- chat_completion: z.boolean(),
- chat_completion_stream: z.boolean(),
- responses: z.boolean(),
- responses_stream: z.boolean(),
- embedding: z.boolean(),
- speech: z.boolean(),
- speech_stream: z.boolean(),
- transcription: z.boolean(),
- transcription_stream: z.boolean(),
- image_generation: z.boolean(),
- image_generation_stream: z.boolean(),
- image_edit: z.boolean(),
- image_edit_stream: z.boolean(),
- image_variation: z.boolean(),
- ocr: z.boolean().optional(),
- ocr_stream: z.boolean().optional(),
- rerank: z.boolean(),
- video_generation: z.boolean(),
- video_retrieve: z.boolean(),
- video_download: z.boolean(),
- video_delete: z.boolean(),
- video_list: z.boolean(),
- video_remix: z.boolean(),
- count_tokens: z.boolean(),
- list_models: z.boolean(),
- websocket_responses: z.boolean(),
- realtime: z.boolean(),
+ text_completion: z.boolean(),
+ text_completion_stream: z.boolean(),
+ chat_completion: z.boolean(),
+ chat_completion_stream: z.boolean(),
+ responses: z.boolean(),
+ responses_stream: z.boolean(),
+ embedding: z.boolean(),
+ speech: z.boolean(),
+ speech_stream: z.boolean(),
+ transcription: z.boolean(),
+ transcription_stream: z.boolean(),
+ image_generation: z.boolean(),
+ image_generation_stream: z.boolean(),
+ image_edit: z.boolean(),
+ image_edit_stream: z.boolean(),
+ image_variation: z.boolean(),
+ ocr: z.boolean().optional(),
+ ocr_stream: z.boolean().optional(),
+ rerank: z.boolean(),
+ video_generation: z.boolean(),
+ video_retrieve: z.boolean(),
+ video_download: z.boolean(),
+ video_delete: z.boolean(),
+ video_list: z.boolean(),
+ video_remix: z.boolean(),
+ count_tokens: z.boolean(),
+ list_models: z.boolean(),
+ websocket_responses: z.boolean(),
+ realtime: z.boolean(),
});
// Custom provider config schema
export const customProviderConfigSchema = z
- .object({
- base_provider_type: knownProviderSchema,
- is_key_less: z.boolean().optional(),
- allowed_requests: allowedRequestsSchema.optional(),
- request_path_overrides: z
- .record(z.string(), z.string().optional())
- .optional(),
- })
- .refine(
- (data) => {
- if (data.base_provider_type === "bedrock") {
- return !data.is_key_less;
- }
- return true;
- },
- {
- message: "Is keyless is not allowed for Bedrock",
- path: ["is_key_less"],
- },
- );
+ .object({
+ base_provider_type: knownProviderSchema,
+ is_key_less: z.boolean().optional(),
+ allowed_requests: allowedRequestsSchema.optional(),
+ request_path_overrides: z.record(z.string(), z.string().optional()).optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.base_provider_type === "bedrock") {
+ return !data.is_key_less;
+ }
+ return true;
+ },
+ {
+ message: "Is keyless is not allowed for Bedrock",
+ path: ["is_key_less"],
+ },
+ );
// Form-specific custom provider config schema
export const formCustomProviderConfigSchema = z
- .object({
- base_provider_type: z.string().min(1, "Base provider type is required"),
- is_key_less: z.boolean().optional(),
- allowed_requests: allowedRequestsSchema.optional(),
- request_path_overrides: z
- .record(z.string(), z.string().optional())
- .optional(),
- })
- .refine(
- (data) => {
- if (data.base_provider_type === "bedrock") {
- return !data.is_key_less;
- }
- return true;
- },
- {
- message: "Is keyless is not allowed for Bedrock",
- path: ["is_key_less"],
- },
- );
+ .object({
+ base_provider_type: z.string().min(1, "Base provider type is required"),
+ is_key_less: z.boolean().optional(),
+ allowed_requests: allowedRequestsSchema.optional(),
+ request_path_overrides: z.record(z.string(), z.string().optional()).optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.base_provider_type === "bedrock") {
+ return !data.is_key_less;
+ }
+ return true;
+ },
+ {
+ message: "Is keyless is not allowed for Bedrock",
+ path: ["is_key_less"],
+ },
+ );
// Full model provider config schema
export const modelProviderConfigSchema = z.object({
- keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
- network_config: networkConfigSchema.optional(),
- concurrency_and_buffer_size: concurrencyAndBufferSizeSchema.optional(),
- proxy_config: proxyConfigSchema.optional(),
- send_back_raw_request: z.boolean().optional(),
- send_back_raw_response: z.boolean().optional(),
- store_raw_request_response: z.boolean().optional(),
- custom_provider_config: customProviderConfigSchema.optional(),
+ keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
+ network_config: networkConfigSchema.optional(),
+ concurrency_and_buffer_size: concurrencyAndBufferSizeSchema.optional(),
+ proxy_config: proxyConfigSchema.optional(),
+ send_back_raw_request: z.boolean().optional(),
+ send_back_raw_response: z.boolean().optional(),
+ store_raw_request_response: z.boolean().optional(),
+ custom_provider_config: customProviderConfigSchema.optional(),
});
// Model provider schema
export const modelProviderSchema = modelProviderConfigSchema.extend({
- name: modelProviderNameSchema,
+ name: modelProviderNameSchema,
});
// Form-specific model provider config schema
export const formModelProviderConfigSchema = z.object({
- keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
- network_config: networkConfigSchema.optional(),
- concurrency_and_buffer_size: concurrencyAndBufferSizeSchema.optional(),
- proxy_config: proxyConfigSchema.optional(),
- send_back_raw_request: z.boolean().optional(),
- send_back_raw_response: z.boolean().optional(),
- store_raw_request_response: z.boolean().optional(),
- custom_provider_config: formCustomProviderConfigSchema.optional(),
+ keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
+ network_config: networkConfigSchema.optional(),
+ concurrency_and_buffer_size: concurrencyAndBufferSizeSchema.optional(),
+ proxy_config: proxyConfigSchema.optional(),
+ send_back_raw_request: z.boolean().optional(),
+ send_back_raw_response: z.boolean().optional(),
+ store_raw_request_response: z.boolean().optional(),
+ custom_provider_config: formCustomProviderConfigSchema.optional(),
});
// Flexible model provider schema for form data - allows any string for name
export const formModelProviderSchema = formModelProviderConfigSchema.extend({
- name: z.string().min(1, "Provider name is required"),
+ name: z.string().min(1, "Provider name is required"),
});
// Add provider request schema
export const addProviderRequestSchema = z.object({
- provider: modelProviderNameSchema,
- keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
- network_config: networkConfigSchema.optional(),
- concurrency_and_buffer_size: concurrencyAndBufferSizeSchema.optional(),
- proxy_config: proxyConfigSchema.optional(),
- send_back_raw_request: z.boolean().optional(),
- send_back_raw_response: z.boolean().optional(),
- store_raw_request_response: z.boolean().optional(),
- custom_provider_config: customProviderConfigSchema.optional(),
- openai_config: openaiConfigFormSchema.optional(),
+ provider: modelProviderNameSchema,
+ keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
+ network_config: networkConfigSchema.optional(),
+ concurrency_and_buffer_size: concurrencyAndBufferSizeSchema.optional(),
+ proxy_config: proxyConfigSchema.optional(),
+ send_back_raw_request: z.boolean().optional(),
+ send_back_raw_response: z.boolean().optional(),
+ store_raw_request_response: z.boolean().optional(),
+ custom_provider_config: customProviderConfigSchema.optional(),
+ openai_config: openaiConfigFormSchema.optional(),
});
// Update provider request schema
export const updateProviderRequestSchema = z.object({
- keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
- network_config: networkConfigSchema,
- concurrency_and_buffer_size: concurrencyAndBufferSizeSchema,
- proxy_config: proxyConfigSchema,
- send_back_raw_request: z.boolean().optional(),
- send_back_raw_response: z.boolean().optional(),
- store_raw_request_response: z.boolean().optional(),
- custom_provider_config: customProviderConfigSchema.optional(),
- openai_config: openaiConfigFormSchema.optional(),
+ keys: z.array(modelProviderKeySchema).min(1, "At least one key is required"),
+ network_config: networkConfigSchema,
+ concurrency_and_buffer_size: concurrencyAndBufferSizeSchema,
+ proxy_config: proxyConfigSchema,
+ send_back_raw_request: z.boolean().optional(),
+ send_back_raw_response: z.boolean().optional(),
+ store_raw_request_response: z.boolean().optional(),
+ custom_provider_config: customProviderConfigSchema.optional(),
+ openai_config: openaiConfigFormSchema.optional(),
});
// Cache config schema
const baseCacheConfigSchema = z.object({
- ttl: z.number().int().min(1).default(3600),
- threshold: z.number().min(0).max(1).default(0.8),
- conversation_history_threshold: z.number().int().min(0).optional(),
- exclude_system_prompt: z.boolean().optional(),
- cache_by_model: z.boolean().default(false),
- cache_by_provider: z.boolean().default(false),
- vector_store_namespace: z.string().min(1).optional(),
- default_cache_key: z.string().min(1).optional(),
- created_at: z.string().optional(),
- updated_at: z.string().optional(),
+ ttl: z.number().int().min(1).default(3600),
+ threshold: z.number().min(0).max(1).default(0.8),
+ conversation_history_threshold: z.number().int().min(0).optional(),
+ exclude_system_prompt: z.boolean().optional(),
+ cache_by_model: z.boolean().default(false),
+ cache_by_provider: z.boolean().default(false),
+ vector_store_namespace: z.string().min(1).optional(),
+ default_cache_key: z.string().min(1).optional(),
+ created_at: z.string().optional(),
+ updated_at: z.string().optional(),
});
const directCacheConfigSchema = baseCacheConfigSchema
- .extend({
- dimension: z.literal(1),
- keys: z.array(modelProviderKeySchema).optional(),
- })
- .strict();
+ .extend({
+ dimension: z.literal(1),
+ keys: z.array(modelProviderKeySchema).optional(),
+ })
+ .strict();
const providerBackedCacheConfigSchema = baseCacheConfigSchema
- .extend({
- provider: modelProviderNameSchema,
- keys: z.array(modelProviderKeySchema).optional(),
- embedding_model: z.string().min(1, "Embedding model is required"),
- dimension: z
- .number()
- .int()
- .min(
- 2,
- "Dimension must be greater than 1 for provider-backed semantic cache",
- ),
- })
- .strict();
-
-export const cacheConfigSchema = z.union([
- directCacheConfigSchema,
- providerBackedCacheConfigSchema,
-]);
+ .extend({
+ provider: modelProviderNameSchema,
+ keys: z.array(modelProviderKeySchema).optional(),
+ embedding_model: z.string().min(1, "Embedding model is required"),
+ dimension: z.number().int().min(2, "Dimension must be greater than 1 for provider-backed semantic cache"),
+ })
+ .strict();
+
+export const cacheConfigSchema = z.union([directCacheConfigSchema, providerBackedCacheConfigSchema]);
// Core config schema
export const coreConfigSchema = z.object({
- drop_excess_requests: z.boolean().default(false),
- initial_pool_size: z.number().min(1).default(10),
- prometheus_labels: z.array(z.string()).default([]),
- enable_logging: z.boolean().default(true),
- disable_content_logging: z.boolean().default(false),
- enforce_auth_on_inference: z.boolean().default(false),
- hide_deleted_virtual_keys_in_filters: z.boolean().default(false),
- allowed_origins: z.array(z.string()).default(["*"]),
- max_request_body_size_mb: z.number().min(1).default(100),
- mcp_agent_depth: z.number().min(1).default(10),
- mcp_tool_execution_timeout: z.number().min(1).default(30),
- mcp_code_mode_binding_level: z.enum(["server", "tool"]).default("server"),
- mcp_disable_auto_tool_inject: z.boolean().default(false),
+ drop_excess_requests: z.boolean().default(false),
+ initial_pool_size: z.number().min(1).default(10),
+ prometheus_labels: z.array(z.string()).default([]),
+ enable_logging: z.boolean().default(true),
+ disable_content_logging: z.boolean().default(false),
+ enforce_auth_on_inference: z.boolean().default(false),
+ hide_deleted_virtual_keys_in_filters: z.boolean().default(false),
+ allowed_origins: z.array(z.string()).default(["*"]),
+ max_request_body_size_mb: z.number().min(1).default(100),
+ mcp_agent_depth: z.number().min(1).default(10),
+ mcp_tool_execution_timeout: z.number().min(1).default(30),
+ mcp_code_mode_binding_level: z.enum(["server", "tool"]).default("server"),
+ mcp_disable_auto_tool_inject: z.boolean().default(false),
});
// Bifrost config schema
export const bifrostConfigSchema = z.object({
- client_config: coreConfigSchema,
- is_db_connected: z.boolean(),
- is_cache_connected: z.boolean(),
- is_logs_connected: z.boolean(),
+ client_config: coreConfigSchema,
+ is_db_connected: z.boolean(),
+ is_cache_connected: z.boolean(),
+ is_logs_connected: z.boolean(),
});
// Network and proxy form schema - combined for the NetworkFormFragment
export const networkAndProxyFormSchema = z.object({
- network_config: networkFormConfigSchema.optional(),
- proxy_config: proxyFormConfigSchema.optional(),
+ network_config: networkFormConfigSchema.optional(),
+ proxy_config: proxyFormConfigSchema.optional(),
});
// Proxy-only form schema for the ProxyFormFragment
export const proxyOnlyFormSchema = z.object({
- proxy_config: proxyFormConfigSchema.optional(),
+ proxy_config: proxyFormConfigSchema.optional(),
});
// Network-only form schema for the NetworkFormFragment
export const networkOnlyFormSchema = z.object({
- network_config: networkFormConfigSchema.optional(),
+ network_config: networkFormConfigSchema.optional(),
});
// Performance form schema for the PerformanceFormFragment (concurrency/buffer only; raw request/response are in Debugging tab)
export const performanceFormSchema = z.object({
- concurrency_and_buffer_size: z
- .object({
- concurrency: z
- .number({ error: "Concurrency must be a number" })
- .min(1, "Concurrency must be greater than 0")
- .max(100000, "Concurrency must be less than 100000"),
- buffer_size: z
- .number({ error: "Buffer size must be a number" })
- .min(1, "Buffer size must be greater than 0")
- .max(100000, "Buffer size must be less than 100000"),
- })
- .refine((data) => data.concurrency <= data.buffer_size, {
- message: "Concurrency must be less than or equal to buffer size",
- path: ["concurrency"],
- }),
+ concurrency_and_buffer_size: z
+ .object({
+ concurrency: z
+ .number({ error: "Concurrency must be a number" })
+ .min(1, "Concurrency must be greater than 0")
+ .max(100000, "Concurrency must be less than 100000"),
+ buffer_size: z
+ .number({ error: "Buffer size must be a number" })
+ .min(1, "Buffer size must be greater than 0")
+ .max(100000, "Buffer size must be less than 100000"),
+ })
+ .refine((data) => data.concurrency <= data.buffer_size, {
+ message: "Concurrency must be less than or equal to buffer size",
+ path: ["concurrency"],
+ }),
});
// Debugging tab (raw request/response toggles)
export const debuggingFormSchema = z.object({
- send_back_raw_request: z.boolean(),
- send_back_raw_response: z.boolean(),
- store_raw_request_response: z.boolean(),
+ send_back_raw_request: z.boolean(),
+ send_back_raw_response: z.boolean(),
+ store_raw_request_response: z.boolean(),
});
export type DebuggingFormSchema = z.infer;
// Beta Headers tab
export const betaHeadersFormSchema = z.object({
- beta_header_overrides: z.record(z.string(), z.boolean()).optional(),
+ beta_header_overrides: z.record(z.string(), z.boolean()).optional(),
});
export type BetaHeadersFormSchema = z.infer;
// OTEL Configuration Schema
export const otelConfigSchema = z
- .object({
- service_name: z.string().optional(),
- collector_url: z.string().default(""),
- trace_type: z
- .enum(["genai_extension", "vercel", "open_inference"], {
- message: "Please select a trace type",
- })
- .default("genai_extension"),
- headers: z.record(z.string(), z.string()).optional(),
- protocol: z
- .enum(["http", "grpc"], {
- message: "Please select a protocol",
- })
- .default("http"),
- // TLS configuration
- tls_ca_cert: z.string().optional(),
- insecure: z.boolean().default(true),
- // Metrics push configuration
- metrics_enabled: z.boolean().default(false),
- metrics_endpoint: z.string().optional(),
- metrics_push_interval: z.number().int().min(1).max(300).default(15),
- })
- .superRefine((data, ctx) => {
- const protocol = data.protocol;
- const hostPortRegex =
- /^(?!https?:\/\/)([a-zA-Z0-9.-]+|\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/;
-
- // Helper to validate URL format
- const validateHttpUrl = (url: string, path: string[]) => {
- try {
- const u = new URL(url);
- if (!(u.protocol === "http:" || u.protocol === "https:")) {
- ctx.addIssue({
- code: "custom",
- path,
- message: "Must be a valid HTTP or HTTPS URL",
- });
- return false;
- }
- return true;
- } catch {
- ctx.addIssue({
- code: "custom",
- path,
- message: "Must be a valid HTTP or HTTPS URL",
- });
- return false;
- }
- };
-
- // Helper to validate host:port format
- const validateHostPort = (
- value: string,
- path: string[],
- example: string,
- ) => {
- const match = value.match(hostPortRegex);
- if (!match) {
- ctx.addIssue({
- code: "custom",
- path,
- message: `Must be in the format : for gRPC (e.g. ${example})`,
- });
- return false;
- }
- const port = Number(match[2]);
- if (!(port >= 1 && port <= 65535)) {
- ctx.addIssue({
- code: "custom",
- path,
- message: "Port must be between 1 and 65535",
- });
- return false;
- }
- return true;
- };
-
- // Validate collector_url format (emptiness check is at form level, gated by enabled)
- const collectorUrl = (data.collector_url || "").trim();
- if (collectorUrl && protocol === "http") {
- validateHttpUrl(collectorUrl, ["collector_url"]);
- } else if (collectorUrl && protocol === "grpc") {
- validateHostPort(collectorUrl, ["collector_url"], "otel-collector:4317");
- }
-
- // Validate metrics_endpoint when metrics_enabled is true
- if (data.metrics_enabled) {
- const metricsEndpoint = (data.metrics_endpoint || "").trim();
- if (!metricsEndpoint) {
- ctx.addIssue({
- code: "custom",
- path: ["metrics_endpoint"],
- message: "Metrics endpoint is required when metrics push is enabled",
- });
- } else if (protocol === "http") {
- validateHttpUrl(metricsEndpoint, ["metrics_endpoint"]);
- } else if (protocol === "grpc") {
- validateHostPort(
- metricsEndpoint,
- ["metrics_endpoint"],
- "otel-collector:4317",
- );
- }
- }
- });
+ .object({
+ service_name: z.string().optional(),
+ collector_url: z.string().default(""),
+ trace_type: z
+ .enum(["genai_extension", "vercel", "open_inference"], {
+ message: "Please select a trace type",
+ })
+ .default("genai_extension"),
+ headers: z.record(z.string(), z.string()).optional(),
+ protocol: z
+ .enum(["http", "grpc"], {
+ message: "Please select a protocol",
+ })
+ .default("http"),
+ // TLS configuration
+ tls_ca_cert: z.string().optional(),
+ insecure: z.boolean().default(true),
+ // Metrics push configuration
+ metrics_enabled: z.boolean().default(false),
+ metrics_endpoint: z.string().optional(),
+ metrics_push_interval: z.number().int().min(1).max(300).default(15),
+ })
+ .superRefine((data, ctx) => {
+ const protocol = data.protocol;
+ const hostPortRegex = /^(?!https?:\/\/)([a-zA-Z0-9.-]+|\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/;
+
+ // Helper to validate URL format
+ const validateHttpUrl = (url: string, path: string[]) => {
+ try {
+ const u = new URL(url);
+ if (!(u.protocol === "http:" || u.protocol === "https:")) {
+ ctx.addIssue({
+ code: "custom",
+ path,
+ message: "Must be a valid HTTP or HTTPS URL",
+ });
+ return false;
+ }
+ return true;
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ path,
+ message: "Must be a valid HTTP or HTTPS URL",
+ });
+ return false;
+ }
+ };
+
+ // Helper to validate host:port format
+ const validateHostPort = (value: string, path: string[], example: string) => {
+ const match = value.match(hostPortRegex);
+ if (!match) {
+ ctx.addIssue({
+ code: "custom",
+ path,
+ message: `Must be in the format : for gRPC (e.g. ${example})`,
+ });
+ return false;
+ }
+ const port = Number(match[2]);
+ if (!(port >= 1 && port <= 65535)) {
+ ctx.addIssue({
+ code: "custom",
+ path,
+ message: "Port must be between 1 and 65535",
+ });
+ return false;
+ }
+ return true;
+ };
+
+ // Validate collector_url format (emptiness check is at form level, gated by enabled)
+ const collectorUrl = (data.collector_url || "").trim();
+ if (collectorUrl && protocol === "http") {
+ validateHttpUrl(collectorUrl, ["collector_url"]);
+ } else if (collectorUrl && protocol === "grpc") {
+ validateHostPort(collectorUrl, ["collector_url"], "otel-collector:4317");
+ }
+
+ // Validate metrics_endpoint when metrics_enabled is true
+ if (data.metrics_enabled) {
+ const metricsEndpoint = (data.metrics_endpoint || "").trim();
+ if (!metricsEndpoint) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["metrics_endpoint"],
+ message: "Metrics endpoint is required when metrics push is enabled",
+ });
+ } else if (protocol === "http") {
+ validateHttpUrl(metricsEndpoint, ["metrics_endpoint"]);
+ } else if (protocol === "grpc") {
+ validateHostPort(metricsEndpoint, ["metrics_endpoint"], "otel-collector:4317");
+ }
+ }
+ });
// OTEL form schema for the OtelFormFragment
export const otelFormSchema = z
- .object({
- enabled: z.boolean().default(true),
- otel_config: otelConfigSchema,
- })
- .superRefine((data, ctx) => {
- if (data.enabled) {
- const collectorUrl = (data.otel_config.collector_url || "").trim();
- if (!collectorUrl) {
- ctx.addIssue({
- code: "custom",
- path: ["otel_config", "collector_url"],
- message: "Collector address is required",
- });
- }
- }
- });
+ .object({
+ enabled: z.boolean().default(true),
+ otel_config: otelConfigSchema,
+ })
+ .superRefine((data, ctx) => {
+ if (data.enabled) {
+ const collectorUrl = (data.otel_config.collector_url || "").trim();
+ if (!collectorUrl) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["otel_config", "collector_url"],
+ message: "Collector address is required",
+ });
+ }
+ }
+ });
// Maxim Configuration Schema
export const maximConfigSchema = z.object({
- api_key: z.string().default(""),
- log_repo_id: z.string().optional(),
+ api_key: z.string().default(""),
+ log_repo_id: z.string().optional(),
});
// Maxim form schema for the MaximFormFragment
export const maximFormSchema = z
- .object({
- enabled: z.boolean().default(true),
- maxim_config: maximConfigSchema,
- })
- .superRefine((data, ctx) => {
- if (data.enabled) {
- const apiKey = (data.maxim_config.api_key || "").trim();
- if (!apiKey) {
- ctx.addIssue({
- code: "custom",
- path: ["maxim_config", "api_key"],
- message: "API key is required",
- });
- } else if (!apiKey.startsWith("sk_mx_")) {
- ctx.addIssue({
- code: "custom",
- path: ["maxim_config", "api_key"],
- message: "API key must start with 'sk_mx_'",
- });
- }
- }
- });
+ .object({
+ enabled: z.boolean().default(true),
+ maxim_config: maximConfigSchema,
+ })
+ .superRefine((data, ctx) => {
+ if (data.enabled) {
+ const apiKey = (data.maxim_config.api_key || "").trim();
+ if (!apiKey) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["maxim_config", "api_key"],
+ message: "API key is required",
+ });
+ } else if (!apiKey.startsWith("sk_mx_")) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["maxim_config", "api_key"],
+ message: "API key must start with 'sk_mx_'",
+ });
+ }
+ }
+ });
// Prometheus Push Gateway Configuration Schema
export const prometheusConfigSchema = z
- .object({
- push_gateway_url: z.string().optional(),
- job_name: z.string().default("bifrost"),
- instance_id: z.string().optional(),
- push_interval: z.number().min(1).max(300).default(15),
- basic_auth_username: z.string().optional(),
- basic_auth_password: z.string().optional(),
- })
- .superRefine((data, ctx) => {
- // Validate push_gateway_url format
- const url = (data.push_gateway_url || "").trim();
- if (url) {
- try {
- const u = new URL(url);
- if (!(u.protocol === "http:" || u.protocol === "https:")) {
- ctx.addIssue({
- code: "custom",
- path: ["push_gateway_url"],
- message: "Must be a valid HTTP or HTTPS URL",
- });
- }
- } catch {
- ctx.addIssue({
- code: "custom",
- path: ["push_gateway_url"],
- message: "Must be a valid URL (e.g., http://pushgateway:9091)",
- });
- }
- }
-
- // Validate basic auth: if one credential is provided, both must be provided
- const hasUsername = !!data.basic_auth_username?.trim();
- const hasPassword = !!data.basic_auth_password?.trim();
- if (hasUsername && !hasPassword) {
- ctx.addIssue({
- code: "custom",
- path: ["basic_auth_password"],
- message: "Password is required when username is provided",
- });
- }
- if (hasPassword && !hasUsername) {
- ctx.addIssue({
- code: "custom",
- path: ["basic_auth_username"],
- message: "Username is required when password is provided",
- });
- }
- });
+ .object({
+ push_gateway_url: z.string().optional(),
+ job_name: z.string().default("bifrost"),
+ instance_id: z.string().optional(),
+ push_interval: z.number().min(1).max(300).default(15),
+ basic_auth_username: z.string().optional(),
+ basic_auth_password: z.string().optional(),
+ })
+ .superRefine((data, ctx) => {
+ // Validate push_gateway_url format
+ const url = (data.push_gateway_url || "").trim();
+ if (url) {
+ try {
+ const u = new URL(url);
+ if (!(u.protocol === "http:" || u.protocol === "https:")) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["push_gateway_url"],
+ message: "Must be a valid HTTP or HTTPS URL",
+ });
+ }
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ path: ["push_gateway_url"],
+ message: "Must be a valid URL (e.g., http://pushgateway:9091)",
+ });
+ }
+ }
+
+ // Validate basic auth: if one credential is provided, both must be provided
+ const hasUsername = !!data.basic_auth_username?.trim();
+ const hasPassword = !!data.basic_auth_password?.trim();
+ if (hasUsername && !hasPassword) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["basic_auth_password"],
+ message: "Password is required when username is provided",
+ });
+ }
+ if (hasPassword && !hasUsername) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["basic_auth_username"],
+ message: "Username is required when password is provided",
+ });
+ }
+ });
// Prometheus form schema for the PrometheusFormFragment.
export const prometheusFormSchema = z
- .object({
- metrics_enabled: z.boolean().default(true),
- push_gateway_enabled: z.boolean().default(false),
- prometheus_config: prometheusConfigSchema,
- })
- .superRefine((data, ctx) => {
- if (data.push_gateway_enabled) {
- const url = (data.prometheus_config.push_gateway_url || "").trim();
- if (!url) {
- ctx.addIssue({
- code: "custom",
- path: ["prometheus_config", "push_gateway_url"],
- message: "Push Gateway URL is required when the push gateway is enabled",
- });
- }
- }
- });
+ .object({
+ metrics_enabled: z.boolean().default(true),
+ push_gateway_enabled: z.boolean().default(false),
+ prometheus_config: prometheusConfigSchema,
+ })
+ .superRefine((data, ctx) => {
+ if (data.push_gateway_enabled) {
+ const url = (data.prometheus_config.push_gateway_url || "").trim();
+ if (!url) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["prometheus_config", "push_gateway_url"],
+ message: "Push Gateway URL is required when the push gateway is enabled",
+ });
+ }
+ }
+ });
// MCP Client update schema
export const mcpClientUpdateSchema = z.object({
- is_code_mode_client: z.boolean().optional(),
- is_ping_available: z.boolean().optional(),
- allow_on_all_virtual_keys: z.boolean().optional(),
- disabled: z.boolean().optional(),
- name: z
- .string()
- .min(1, "Name is required")
- .refine((val) => !val.includes("-"), {
- message: "Client name cannot contain hyphens",
- })
- .refine((val) => !val.includes(" "), {
- message: "Client name cannot contain spaces",
- })
- .refine((val) => !/^[0-9]/.test(val), {
- message: "Client name cannot start with a number",
- }),
- headers: z.record(z.string(), envVarSchema).optional().nullable(),
- tools_to_execute: z
- .array(z.string())
- .optional()
- .refine(
- (tools) => {
- if (!tools || tools.length === 0) return true;
- const hasWildcard = tools.includes("*");
- return !hasWildcard || tools.length === 1;
- },
- { message: "Wildcard '*' cannot be combined with other tool names" },
- )
- .refine(
- (tools) => {
- if (!tools) return true;
- return tools.length === new Set(tools).size;
- },
- { message: "Duplicate tool names are not allowed" },
- ),
- tools_to_auto_execute: z
- .array(z.string())
- .optional()
- .refine(
- (tools) => {
- if (!tools || tools.length === 0) return true;
- const hasWildcard = tools.includes("*");
- return !hasWildcard || tools.length === 1;
- },
- { message: "Wildcard '*' cannot be combined with other tool names" },
- )
- .refine(
- (tools) => {
- if (!tools) return true;
- return tools.length === new Set(tools).size;
- },
- { message: "Duplicate tool names are not allowed" },
- ),
- tool_pricing: z
- .record(z.string(), z.number().min(0, "Cost must be non-negative"))
- .optional(),
- tool_sync_interval: z.number().optional(), // -1 = disabled, 0 = use global, >0 = custom interval in minutes
- allowed_extra_headers: z
- .array(z.string())
- .optional()
- .refine(
- (headers) => {
- if (!headers || headers.length === 0) return true;
- const hasWildcard = headers.includes("*");
- return !hasWildcard || headers.length === 1;
- },
- { message: "Wildcard '*' cannot be combined with specific header names" },
- ),
- oauth_config: z
- .object({
- client_id: envVarSchema.optional(),
- client_secret: envVarSchema.optional(),
- })
- .optional(),
+ is_code_mode_client: z.boolean().optional(),
+ is_ping_available: z.boolean().optional(),
+ allow_on_all_virtual_keys: z.boolean().optional(),
+ disabled: z.boolean().optional(),
+ name: z
+ .string()
+ .min(1, "Name is required")
+ .refine((val) => !val.includes("-"), {
+ message: "Client name cannot contain hyphens",
+ })
+ .refine((val) => !val.includes(" "), {
+ message: "Client name cannot contain spaces",
+ })
+ .refine((val) => !/^[0-9]/.test(val), {
+ message: "Client name cannot start with a number",
+ }),
+ headers: z.record(z.string(), envVarSchema).optional().nullable(),
+ tools_to_execute: z
+ .array(z.string())
+ .optional()
+ .refine(
+ (tools) => {
+ if (!tools || tools.length === 0) return true;
+ const hasWildcard = tools.includes("*");
+ return !hasWildcard || tools.length === 1;
+ },
+ { message: "Wildcard '*' cannot be combined with other tool names" },
+ )
+ .refine(
+ (tools) => {
+ if (!tools) return true;
+ return tools.length === new Set(tools).size;
+ },
+ { message: "Duplicate tool names are not allowed" },
+ ),
+ tools_to_auto_execute: z
+ .array(z.string())
+ .optional()
+ .refine(
+ (tools) => {
+ if (!tools || tools.length === 0) return true;
+ const hasWildcard = tools.includes("*");
+ return !hasWildcard || tools.length === 1;
+ },
+ { message: "Wildcard '*' cannot be combined with other tool names" },
+ )
+ .refine(
+ (tools) => {
+ if (!tools) return true;
+ return tools.length === new Set(tools).size;
+ },
+ { message: "Duplicate tool names are not allowed" },
+ ),
+ tool_pricing: z.record(z.string(), z.number().min(0, "Cost must be non-negative")).optional(),
+ tool_sync_interval: z.number().optional(), // -1 = disabled, 0 = use global, >0 = custom interval in minutes
+ allowed_extra_headers: z
+ .array(z.string())
+ .optional()
+ .refine(
+ (headers) => {
+ if (!headers || headers.length === 0) return true;
+ const hasWildcard = headers.includes("*");
+ return !hasWildcard || headers.length === 1;
+ },
+ { message: "Wildcard '*' cannot be combined with specific header names" },
+ ),
+ oauth_config: z
+ .object({
+ client_id: envVarSchema.optional(),
+ client_secret: envVarSchema.optional(),
+ })
+ .optional(),
});
// Global proxy type schema
@@ -1128,102 +1036,88 @@ export const globalProxyTypeSchema = z.enum(["http", "socks5", "tcp"]);
// Global proxy configuration schema
export const globalProxyConfigSchema = z
- .object({
- enabled: z.boolean(),
- type: globalProxyTypeSchema,
- url: z.string(),
- username: z.string().optional(),
- password: z.string().optional(),
- ca_cert_pem: z.string().optional(),
- no_proxy: z.string().optional(),
- timeout: z.number().min(0).optional(),
- skip_tls_verify: z.boolean().optional(),
- enable_for_scim: z.boolean(),
- enable_for_inference: z.boolean(),
- enable_for_api: z.boolean(),
- })
- .refine(
- (data) => {
- // URL is required when proxy is enabled
- if (data.enabled && (!data.url || data.url.trim().length === 0)) {
- return false;
- }
- return true;
- },
- {
- message: "Proxy URL is required when proxy is enabled",
- path: ["url"],
- },
- )
- .refine(
- (data) => {
- // Validate URL format when provided and enabled
- if (data.enabled && data.url && data.url.trim().length > 0) {
- try {
- new URL(data.url);
- return true;
- } catch {
- return false;
- }
- }
- return true;
- },
- {
- message: "Must be a valid URL (e.g., http://proxy.example.com:8080)",
- path: ["url"],
- },
- );
+ .object({
+ enabled: z.boolean(),
+ type: globalProxyTypeSchema,
+ url: z.string(),
+ username: z.string().optional(),
+ password: z.string().optional(),
+ ca_cert_pem: z.string().optional(),
+ no_proxy: z.string().optional(),
+ timeout: z.number().min(0).optional(),
+ skip_tls_verify: z.boolean().optional(),
+ enable_for_scim: z.boolean(),
+ enable_for_inference: z.boolean(),
+ enable_for_api: z.boolean(),
+ })
+ .refine(
+ (data) => {
+ // URL is required when proxy is enabled
+ if (data.enabled && (!data.url || data.url.trim().length === 0)) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Proxy URL is required when proxy is enabled",
+ path: ["url"],
+ },
+ )
+ .refine(
+ (data) => {
+ // Validate URL format when provided and enabled
+ if (data.enabled && data.url && data.url.trim().length > 0) {
+ try {
+ new URL(data.url);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return true;
+ },
+ {
+ message: "Must be a valid URL (e.g., http://proxy.example.com:8080)",
+ path: ["url"],
+ },
+ );
// Global proxy form schema for the ProxyView
export const globalProxyFormSchema = z.object({
- proxy_config: globalProxyConfigSchema,
+ proxy_config: globalProxyConfigSchema,
});
// Global header filter configuration schema
// Controls which headers with the x-bf-eh-* prefix are forwarded to LLM providers
export const globalHeaderFilterConfigSchema = z.object({
- allowlist: z.array(z.string()).optional(), // If non-empty, only these headers are allowed
- denylist: z.array(z.string()).optional(), // Headers to always block
+ allowlist: z.array(z.string()).optional(), // If non-empty, only these headers are allowed
+ denylist: z.array(z.string()).optional(), // Headers to always block
});
// Global header filter form schema for the HeaderFilterView
export const globalHeaderFilterFormSchema = z.object({
- header_filter_config: globalHeaderFilterConfigSchema,
+ header_filter_config: globalHeaderFilterConfigSchema,
});
// Routing rule creation schema
export const routingRuleSchema = z
- .object({
- name: z
- .string()
- .min(1, "Rule name is required")
- .max(255, "Rule name must be less than 255 characters"),
- description: z
- .string()
- .max(1000, "Description must be less than 1000 characters")
- .optional(),
- cel_expression: z.string().optional(),
- provider: z.string().min(1, "Provider is required"),
- model: z.string().optional(),
- fallbacks: z.array(z.string()).optional().default([]),
- scope: z.enum(["global", "team", "customer", "virtual_key"]),
- scope_id: z.string().optional(),
- priority: z
- .number()
- .min(0, "Priority must be 0 or greater")
- .max(1000, "Priority must be 1000 or less"),
- enabled: z.boolean().default(true),
- chain_rule: z.boolean().default(false),
- })
- .refine(
- (data) =>
- data.scope === "global" ||
- (data.scope_id != null && data.scope_id.trim() !== ""),
- {
- message: "Scope ID is required when scope is not global",
- path: ["scope_id"],
- },
- );
+ .object({
+ name: z.string().min(1, "Rule name is required").max(255, "Rule name must be less than 255 characters"),
+ description: z.string().max(1000, "Description must be less than 1000 characters").optional(),
+ cel_expression: z.string().optional(),
+ provider: z.string().min(1, "Provider is required"),
+ model: z.string().optional(),
+ fallbacks: z.array(z.string()).optional().default([]),
+ scope: z.enum(["global", "team", "customer", "virtual_key"]),
+ scope_id: z.string().optional(),
+ priority: z.number().min(0, "Priority must be 0 or greater").max(1000, "Priority must be 1000 or less"),
+ enabled: z.boolean().default(true),
+ chain_rule: z.boolean().default(false),
+ })
+ .refine((data) => data.scope === "global" || (data.scope_id != null && data.scope_id.trim() !== ""), {
+ message: "Scope ID is required when scope is not global",
+ path: ["scope_id"],
+ });
// Export type inference helpers
export type EnvVar = z.infer;
@@ -1232,9 +1126,7 @@ export type ModelProviderKeySchema = z.infer;
export type NetworkConfigSchema = z.infer;
export type NetworkFormConfigSchema = z.infer;
export type ProxyFormConfigSchema = z.infer;
-export type NetworkAndProxyFormSchema = z.infer<
- typeof networkAndProxyFormSchema
->;
+export type NetworkAndProxyFormSchema = z.infer;
export type ProxyOnlyFormSchema = z.infer;
export type OtelConfigSchema = z.infer;
export type OtelFormSchema = z.infer;
@@ -1244,15 +1136,9 @@ export type PrometheusConfigSchema = z.infer;
export type PrometheusFormSchema = z.infer;
export type NetworkOnlyFormSchema = z.infer;
export type PerformanceFormSchema = z.infer;
-export type CustomProviderConfigSchema = z.infer<
- typeof customProviderConfigSchema
->;
+export type CustomProviderConfigSchema = z.infer;
export type GlobalProxyConfigSchema = z.infer;
export type GlobalProxyFormSchema = z.infer;
-export type GlobalHeaderFilterConfigSchema = z.infer<
- typeof globalHeaderFilterConfigSchema
->;
-export type GlobalHeaderFilterFormSchema = z.infer<
- typeof globalHeaderFilterFormSchema
->;
-export type RoutingRuleSchema = z.infer;
+export type GlobalHeaderFilterConfigSchema = z.infer;
+export type GlobalHeaderFilterFormSchema = z.infer;
+export type RoutingRuleSchema = z.infer;
\ No newline at end of file
diff --git a/ui/lib/utils.ts b/ui/lib/utils.ts
index a5ef193506..08501bf695 100644
--- a/ui/lib/utils.ts
+++ b/ui/lib/utils.ts
@@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
+ return twMerge(clsx(inputs));
+}
\ No newline at end of file
diff --git a/ui/lib/utils/browser-download.ts b/ui/lib/utils/browser-download.ts
index d8067f0bed..805e123ef1 100644
--- a/ui/lib/utils/browser-download.ts
+++ b/ui/lib/utils/browser-download.ts
@@ -1,32 +1,32 @@
const safeStringify = (value: unknown, space: number): string => {
- try {
- return JSON.stringify(value, null, space);
- } catch {
- const seen = new WeakSet();
- return JSON.stringify(
- value,
- (_key, val) => {
- if (typeof val === "bigint") return val.toString();
- if (typeof val === "object" && val !== null) {
- if (seen.has(val)) return "[Circular]";
- seen.add(val);
- }
- return val;
- },
- space
- );
- }
+ try {
+ return JSON.stringify(value, null, space);
+ } catch {
+ const seen = new WeakSet();
+ return JSON.stringify(
+ value,
+ (_key, val) => {
+ if (typeof val === "bigint") return val.toString();
+ if (typeof val === "object" && val !== null) {
+ if (seen.has(val)) return "[Circular]";
+ seen.add(val);
+ }
+ return val;
+ },
+ space,
+ );
+ }
};
export const downloadAsJson = (data: unknown, filename: string) => {
- const json = safeStringify(data, 2);
- const blob = new Blob([json], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- setTimeout(() => URL.revokeObjectURL(url), 0);
-};
+ const json = safeStringify(data, 2);
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+};
\ No newline at end of file
diff --git a/ui/lib/utils/envVarForm.ts b/ui/lib/utils/envVarForm.ts
index 570986b834..27223f186f 100644
--- a/ui/lib/utils/envVarForm.ts
+++ b/ui/lib/utils/envVarForm.ts
@@ -30,4 +30,4 @@ export const toOptionalEnvVarPayload = (field?: { value?: string; env_var?: stri
env_var: envVar || "",
from_env: field?.from_env ?? false,
};
-};
+};
\ No newline at end of file
diff --git a/ui/lib/utils/governance.ts b/ui/lib/utils/governance.ts
index 5c3ad946ca..e257ac2b1b 100644
--- a/ui/lib/utils/governance.ts
+++ b/ui/lib/utils/governance.ts
@@ -59,12 +59,17 @@ const shortDurationLabels: Record = {
* Formats rate limit into compact display lines.
* e.g. ["10K tokens/hr", "100 req/hr"]
*/
-export function formatRateLimitLines(rateLimits: {
- token_max_limit?: number | null;
- token_reset_duration?: string | null;
- request_max_limit?: number | null;
- request_reset_duration?: string | null;
-} | null | undefined): string[] {
+export function formatRateLimitLines(
+ rateLimits:
+ | {
+ token_max_limit?: number | null;
+ token_reset_duration?: string | null;
+ request_max_limit?: number | null;
+ request_reset_duration?: string | null;
+ }
+ | null
+ | undefined,
+): string[] {
if (!rateLimits) return [];
const lines: string[] = [];
if (rateLimits.token_max_limit != null) {
diff --git a/ui/lib/utils/routingRuleGroupQuery.ts b/ui/lib/utils/routingRuleGroupQuery.ts
index 79a4e13215..c3b740d2d0 100644
--- a/ui/lib/utils/routingRuleGroupQuery.ts
+++ b/ui/lib/utils/routingRuleGroupQuery.ts
@@ -14,12 +14,9 @@ export function isValidRuleGroupType(q: unknown): q is RuleGroupType {
return false;
}
const candidate = q as RuleGroupType;
- return (
- (candidate.combinator === "and" || candidate.combinator === "or") &&
- Array.isArray(candidate.rules)
- );
+ return (candidate.combinator === "and" || candidate.combinator === "or") && Array.isArray(candidate.rules);
}
export function normalizeRoutingRuleGroupQuery(q: unknown): RuleGroupType {
return isValidRuleGroupType(q) ? q : EMPTY_ROUTING_RULE_GROUP;
-}
+}
\ No newline at end of file
From 00cddd268debc198a5372b0a9a3b29ca28abab7c Mon Sep 17 00:00:00 2001
From: Akshay Deo
Date: Thu, 14 May 2026 18:19:56 +0530
Subject: [PATCH 32/81] send last n messages helm upgrade (#3490)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds a `max_turns_to_send` field to guardrail rules, allowing operators to cap how many of the latest conversation turns are forwarded to the guardrail provider when a rule is applied. A value of `0` (the default) preserves existing behavior by sending all turns.
Also corrects the `timeout` field description from "milliseconds" to "seconds" in both the Helm chart and transport config schemas.
## Changes
- Added `max_turns_to_send` (integer, minimum 0) to guardrail rule definitions in `values.schema.json` and `config.schema.json`
- Wired `max_turns_to_send` into `_helpers.tpl` so it renders into `guardrails_config.guardrail_rules[].max_turns_to_send` in the generated config
- Added `max_turns_to_send: 0` as a commented-out example in `values.yaml`
- Fixed the `timeout` field description from "milliseconds" to "seconds" in both schema files (rule-level and provider-level)
- Bumped chart version to `2.1.17` and updated the Helm index accordingly
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [x] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
```sh
# Render the Helm chart with a guardrail rule that sets max_turns_to_send
helm template bifrost ./helm-charts/bifrost \
--set 'bifrost.guardrails.rules[0].id=1' \
--set 'bifrost.guardrails.rules[0].max_turns_to_send=5' \
| grep max_turns_to_send
# Expected: max_turns_to_send: 5
# Validate the chart against the schema
helm lint ./helm-charts/bifrost
# Confirm default (0) omits the field or renders 0 as expected
helm template bifrost ./helm-charts/bifrost \
--set 'bifrost.guardrails.rules[0].id=1' \
--set 'bifrost.guardrails.rules[0].max_turns_to_send=0' \
| grep -E 'max_turns_to_send|guardrail'
```
New config field for guardrail rules:
| Field | Type | Default | Description |
|---|---|---|---|
| `max_turns_to_send` | integer (≥ 0) | `0` | Max number of latest conversation turns sent to the guardrail provider; `0` sends all turns |
## Screenshots/Recordings
N/A
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
## Security considerations
No new secrets or PII handling introduced. The field controls how much conversation history is forwarded to a guardrail provider, which may reduce data exposure when set to a low value.
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
examples/mcps/temperature/package-lock.json | 1019 ++++++++++++++++-
examples/mcps/temperature/package.json | 4 +-
.../mcps/test-tools-server/package-lock.json | 1019 ++++++++++++++++-
examples/mcps/test-tools-server/package.json | 4 +-
helm-charts/bifrost/Chart.yaml | 2 +-
helm-charts/bifrost/README.md | 6 +-
helm-charts/bifrost/templates/_helpers.tpl | 1 +
helm-charts/bifrost/values.schema.json | 9 +-
helm-charts/bifrost/values.yaml | 1 +
helm-charts/index.yaml | 25 +-
plugins/prompts/go.mod | 1 -
tests/integrations/python/pyproject.toml | 2 +-
tests/integrations/python/uv.lock | 18 +-
.../integrations/typescript/package-lock.json | 24 +-
tests/integrations/typescript/package.json | 2 +-
transports/config.schema.json | 9 +-
16 files changed, 2094 insertions(+), 52 deletions(-)
diff --git a/examples/mcps/temperature/package-lock.json b/examples/mcps/temperature/package-lock.json
index dd0f524b85..c567f600df 100644
--- a/examples/mcps/temperature/package-lock.json
+++ b/examples/mcps/temperature/package-lock.json
@@ -8,7 +8,7 @@
"name": "temperature-mcp-server",
"version": "1.0.0",
"dependencies": {
- "@modelcontextprotocol/sdk": "1.0.4",
+ "@modelcontextprotocol/sdk": "1.29.0",
"zod": "3.24.1"
},
"devDependencies": {
@@ -16,15 +16,75 @@
"typescript": "5.9.3"
}
},
+ "node_modules/@hono/node-server": {
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.4.tgz",
- "integrity": "sha512-C+jw1lF6HSGzs7EZpzHbXfzz9rj9him4BaoumlTciW/IDDgIpweF/qiCWKlP02QKg5PPcgY6xY2WCt5y2tpYow==",
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT",
"dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
- "zod": "^3.23.8"
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
}
},
"node_modules/@types/node": {
@@ -34,6 +94,76 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -43,6 +173,48 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
@@ -52,6 +224,72 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -61,6 +299,316 @@
"node": ">= 0.8"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.18",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
+ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -103,6 +651,243 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -118,18 +903,181 @@
"node": ">= 0.10"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -148,6 +1096,37 @@
"node": ">=0.6"
}
},
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -171,6 +1150,36 @@
"node": ">= 0.8"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
"node_modules/zod": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
diff --git a/examples/mcps/temperature/package.json b/examples/mcps/temperature/package.json
index 615d3373c7..e7dd5b482e 100644
--- a/examples/mcps/temperature/package.json
+++ b/examples/mcps/temperature/package.json
@@ -10,7 +10,7 @@
"dev": "tsc && node dist/index.js"
},
"dependencies": {
- "@modelcontextprotocol/sdk": "1.0.4",
+ "@modelcontextprotocol/sdk": "1.29.0",
"zod": "3.24.1"
},
"devDependencies": {
@@ -18,6 +18,6 @@
"typescript": "5.9.3"
},
"overrides": {
- "hono": "4.12.14"
+ "hono": "4.12.18"
}
}
diff --git a/examples/mcps/test-tools-server/package-lock.json b/examples/mcps/test-tools-server/package-lock.json
index c3a73fe92e..76c4cf4d9d 100644
--- a/examples/mcps/test-tools-server/package-lock.json
+++ b/examples/mcps/test-tools-server/package-lock.json
@@ -8,7 +8,7 @@
"name": "test-tools-server",
"version": "1.0.0",
"dependencies": {
- "@modelcontextprotocol/sdk": "1.0.4",
+ "@modelcontextprotocol/sdk": "1.29.0",
"zod": "3.24.1"
},
"bin": {
@@ -19,15 +19,75 @@
"typescript": "5.3.3"
}
},
+ "node_modules/@hono/node-server": {
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.4.tgz",
- "integrity": "sha512-C+jw1lF6HSGzs7EZpzHbXfzz9rj9him4BaoumlTciW/IDDgIpweF/qiCWKlP02QKg5PPcgY6xY2WCt5y2tpYow==",
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT",
"dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
- "zod": "^3.23.8"
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
}
},
"node_modules/@types/node": {
@@ -40,6 +100,76 @@
"undici-types": "~5.26.4"
}
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -49,6 +179,48 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
@@ -58,6 +230,72 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -67,6 +305,316 @@
"node": ">= 0.8"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.18",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
+ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -109,6 +657,243 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -124,18 +909,181 @@
"node": ">= 0.10"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -154,6 +1102,37 @@
"node": ">=0.6"
}
},
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/typescript": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
@@ -184,6 +1163,36 @@
"node": ">= 0.8"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
"node_modules/zod": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
diff --git a/examples/mcps/test-tools-server/package.json b/examples/mcps/test-tools-server/package.json
index 2bf18d26c5..f84982abe6 100644
--- a/examples/mcps/test-tools-server/package.json
+++ b/examples/mcps/test-tools-server/package.json
@@ -11,7 +11,7 @@
"prepare": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/sdk": "1.0.4",
+ "@modelcontextprotocol/sdk": "1.29.0",
"zod": "3.24.1"
},
"devDependencies": {
@@ -19,6 +19,6 @@
"typescript": "5.3.3"
},
"overrides": {
- "hono": "4.12.14"
+ "hono": "4.12.18"
}
}
diff --git a/helm-charts/bifrost/Chart.yaml b/helm-charts/bifrost/Chart.yaml
index 24c3ab040e..1000db3fa9 100644
--- a/helm-charts/bifrost/Chart.yaml
+++ b/helm-charts/bifrost/Chart.yaml
@@ -2,7 +2,7 @@ apiVersion: v2
name: bifrost
description: A Helm chart for deploying Bifrost - AI Gateway with unified interface for multiple providers
type: application
-version: 2.1.16
+version: 2.1.17
appVersion: "1.5.0"
keywords:
- ai
diff --git a/helm-charts/bifrost/README.md b/helm-charts/bifrost/README.md
index 7b9d5f860c..691e7c3c2c 100644
--- a/helm-charts/bifrost/README.md
+++ b/helm-charts/bifrost/README.md
@@ -4,10 +4,14 @@
Official Helm charts for deploying [Bifrost](https://github.com/maximhq/bifrost) - a high-performance AI gateway with unified interface for multiple providers.
-**Latest Version:** 2.1.14
+**Latest Version:** 2.1.17
## Changelog
+### 2.1.17
+
+- Added `max_turns_to_send` to guardrail rules. The integer caps how many historical conversation turns are sent to the guardrail provider on apply; the latest message is always included on top, and `0` (default) sends all turns. Wired into `values.schema.json`, `config.schema.json`, and `templates/_helpers.tpl` so it renders into `guardrails_config.guardrail_rules[].max_turns_to_send`.
+
### 2.1.14
- Removed the obsolete `bifrost.client.allowDirectKeys` assertion from `validate-helm-config-fields.sh`. The field was deleted from the chart schema and codebase in a prior release, so the test was rendering an invalid values file and helm was rejecting it via `additionalProperties: false`.
diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl
index 1e5c6312ed..b1d66c13ec 100644
--- a/helm-charts/bifrost/templates/_helpers.tpl
+++ b/helm-charts/bifrost/templates/_helpers.tpl
@@ -647,6 +647,7 @@ false
{{- if hasKey . "query" }}{{- $_ := set $rule "query" .query }}{{- end }}
{{- if .sampling_rate }}{{- $_ := set $rule "sampling_rate" .sampling_rate }}{{- end }}
{{- if .timeout }}{{- $_ := set $rule "timeout" .timeout }}{{- end }}
+{{- if hasKey . "max_turns_to_send" }}{{- $_ := set $rule "max_turns_to_send" .max_turns_to_send }}{{- end }}
{{- if .provider_config_ids }}{{- $_ := set $rule "provider_config_ids" .provider_config_ids }}{{- end }}
{{- $rules = append $rules $rule }}
{{- end }}
diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json
index bf5ad7a822..23d45b0f95 100644
--- a/helm-charts/bifrost/values.schema.json
+++ b/helm-charts/bifrost/values.schema.json
@@ -2016,7 +2016,12 @@
"timeout": {
"type": "integer",
"minimum": 0,
- "description": "Timeout in milliseconds for rule execution"
+ "description": "Timeout in seconds for rule execution"
+ },
+ "max_turns_to_send": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of historical conversation turns to send to the guardrail provider; the latest message is always included on top. 0 sends all turns."
},
"provider_config_ids": {
"type": "array",
@@ -2061,7 +2066,7 @@
"timeout": {
"type": "integer",
"minimum": 0,
- "description": "Timeout in milliseconds for provider execution"
+ "description": "Timeout in seconds for provider execution"
},
"config": {
"type": "object",
diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml
index 65690fb1f8..051d90bd02 100644
--- a/helm-charts/bifrost/values.yaml
+++ b/helm-charts/bifrost/values.yaml
@@ -648,6 +648,7 @@ bifrost:
# apply_to: "input"
# sampling_rate: 100
# timeout: 1000
+ # max_turns_to_send: 0
providers: []
# - id: 1
# provider_name: "bedrock"
diff --git a/helm-charts/index.yaml b/helm-charts/index.yaml
index c90b9cec92..f56233f2a1 100644
--- a/helm-charts/index.yaml
+++ b/helm-charts/index.yaml
@@ -1,6 +1,29 @@
apiVersion: v1
entries:
bifrost:
+ - apiVersion: v2
+ appVersion: 1.5.0
+ created: "2026-05-14T00:00:00Z"
+ description: A Helm chart for deploying Bifrost - AI Gateway with unified interface
+ for multiple providers
+ home: https://www.getmaxim.ai/bifrost
+ icon: https://www.getbifrost.ai/favicon.png
+ keywords:
+ - ai
+ - gateway
+ - llm
+ - openai
+ - anthropic
+ maintainers:
+ - email: support@getbifrost.ai
+ name: Bifrost Team
+ name: bifrost
+ sources:
+ - https://github.com/maximhq/bifrost
+ type: application
+ urls:
+ - https://maximhq.github.io/bifrost/helm-charts/bifrost-2.1.17.tgz
+ version: 2.1.17
- apiVersion: v2
appVersion: 1.5.0
created: "2026-05-12T19:31:15.185424+05:30"
@@ -892,4 +915,4 @@ entries:
urls:
- https://maximhq.github.io/bifrost/helm-charts/bifrost-1.3.36.tgz
version: 1.3.36
-generated: "2026-05-12T19:31:15.183068+05:30"
+generated: "2026-05-14T00:00:00Z"
diff --git a/plugins/prompts/go.mod b/plugins/prompts/go.mod
index 75958716dc..cd9ae12d1d 100644
--- a/plugins/prompts/go.mod
+++ b/plugins/prompts/go.mod
@@ -45,7 +45,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
- github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.2 // indirect
diff --git a/tests/integrations/python/pyproject.toml b/tests/integrations/python/pyproject.toml
index 8d49b81278..4b5c0f9662 100644
--- a/tests/integrations/python/pyproject.toml
+++ b/tests/integrations/python/pyproject.toml
@@ -123,4 +123,4 @@ exclude_lines = [
[tool.uv]
-exclude-newer = "2026-04-08"
\ No newline at end of file
+exclude-newer = "2026-05-08T00:00:00Z"
diff --git a/tests/integrations/python/uv.lock b/tests/integrations/python/uv.lock
index dbb9a8254d..58803d2464 100644
--- a/tests/integrations/python/uv.lock
+++ b/tests/integrations/python/uv.lock
@@ -217,14 +217,14 @@ wheels = [
[[package]]
name = "authlib"
-version = "1.6.11"
+version = "1.6.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359 }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469 },
+ { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473 },
]
[[package]]
@@ -2091,7 +2091,7 @@ wheels = [
[[package]]
name = "langchain-classic"
-version = "1.0.0"
+version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
@@ -2102,9 +2102,9 @@ dependencies = [
{ name = "requests" },
{ name = "sqlalchemy" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d9/b1/a66babeccb2c05ed89690a534296688c0349bee7a71641e91ecc2afd72fd/langchain_classic-1.0.0.tar.gz", hash = "sha256:a63655609254ebc36d660eb5ad7c06c778b2e6733c615ffdac3eac4fbe2b12c5", size = 10514930 }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/78/84b5065816f348c39fefa4316f209f0135e8410216340a953bec17d9e4e4/langchain_classic-1.0.7.tar.gz", hash = "sha256:debbec8065e69b95108d2652e8d5c44f4516e19aa8d716c02ed2211c3aee099d", size = 10554118 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/74/74/246f809a3741c21982f985ca0113ec92d3c84896308561cc4414823f6951/langchain_classic-1.0.0-py3-none-any.whl", hash = "sha256:97f71f150c10123f5511c08873f030e35ede52311d729a7688c721b4e1e01f33", size = 1040701 },
+ { url = "https://files.pythonhosted.org/packages/f5/78/2d9980d028ff0523eea503a77c200e2ff252a3a75eb6e7842bcf5f9c979b/langchain_classic-1.0.7-py3-none-any.whl", hash = "sha256:d9d9be38f7aa534ed0259c2410432e34a1f80b1d491e686749bb55af56479be3", size = 1041386 },
]
[[package]]
@@ -2307,7 +2307,7 @@ wheels = [
[[package]]
name = "langsmith"
-version = "0.7.32"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -2320,9 +2320,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518 }
+sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272 },
+ { url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268 },
]
[[package]]
diff --git a/tests/integrations/typescript/package-lock.json b/tests/integrations/typescript/package-lock.json
index daa9896cbd..c254ff97d6 100644
--- a/tests/integrations/typescript/package-lock.json
+++ b/tests/integrations/typescript/package-lock.json
@@ -16,7 +16,7 @@
"@langchain/core": "^1.1.39",
"@langchain/google-genai": "^2.1.26",
"@langchain/openai": "^1.4.4",
- "langsmith": "^0.5.19",
+ "langsmith": "0.6.0",
"openai": "^6.15.0",
"yaml": "^2.6.0",
"zod": "^3.24.0"
@@ -4225,13 +4225,12 @@
}
},
"node_modules/langsmith": {
- "version": "0.5.19",
- "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.19.tgz",
- "integrity": "sha512-5tFoETuFMvGkbPGsINNlIE4Ab86CsPhdPOQZCGwNt/NX0h5NDKQLKOWS/G2XcRUBOQl4mCNbrayUvUTWaIRsCg==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.0.tgz",
+ "integrity": "sha512-GGaj5IMRfLv2HXXFzGk9diISMYLTpSTh6fzCZGKxWYW/NqEztIFtnXLq6G/RVhzFRmCykLap1fuC67LVKoQLcg==",
"license": "MIT",
"dependencies": {
- "p-queue": "6.6.2",
- "uuid": "10.0.0"
+ "p-queue": "6.6.2"
},
"peerDependencies": {
"@opentelemetry/api": "*",
@@ -4258,19 +4257,6 @@
}
}
},
- "node_modules/langsmith/node_modules/uuid": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
- "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
diff --git a/tests/integrations/typescript/package.json b/tests/integrations/typescript/package.json
index f88fce6a8f..7b6705e0e4 100644
--- a/tests/integrations/typescript/package.json
+++ b/tests/integrations/typescript/package.json
@@ -31,7 +31,7 @@
"@langchain/core": "1.1.39",
"@langchain/google-genai": "2.1.26",
"@langchain/openai": "1.4.4",
- "langsmith": "0.5.19",
+ "langsmith": "0.6.0",
"openai": "6.15.0",
"yaml": "2.6.0",
"zod": "3.24.0"
diff --git a/transports/config.schema.json b/transports/config.schema.json
index e159208e13..c138ce2823 100644
--- a/transports/config.schema.json
+++ b/transports/config.schema.json
@@ -3762,7 +3762,12 @@
"timeout": {
"type": "integer",
"minimum": 0,
- "description": "Timeout in milliseconds for rule execution"
+ "description": "Timeout in seconds for rule execution"
+ },
+ "max_turns_to_send": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of historical conversation turns to send to the guardrail provider; the latest message is always included on top. 0 sends all turns."
},
"provider_config_ids": {
"type": "array",
@@ -3801,7 +3806,7 @@
"timeout": {
"type": "integer",
"minimum": 0,
- "description": "Timeout in milliseconds for provider execution"
+ "description": "Timeout in seconds for provider execution"
},
"config": {
"type": "object",
From b1a7d705c614139542356eb635b513b296755982 Mon Sep 17 00:00:00 2001
From: Dan Piths <85949566+danpiths@users.noreply.github.com>
Date: Thu, 14 May 2026 19:29:04 +0530
Subject: [PATCH 33/81] fix: wrap Makefile subshell cd commands in parentheses
(#3333)
## Summary
Wraps `cd ui && ...` commands in subshells `(cd ui && ...)` in the `dev` and
`dev-pulse` Make targets to prevent the `cd` from leaking into the parent shell
process when backgrounded with `&`.
## Changes
- Wrapped 4 `cd ui && npm run dev` invocations in parentheses for both `dev` and
`dev-pulse` targets
- Without parentheses, `cd ui` changes the working directory of the parent shell
when the command is backgrounded, which can cause subsequent commands in the
recipe to run from the wrong directory
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
```sh
make dev
# Verify both UI dev server and API server start correctly
```
## Screenshots/Recordings
N/A
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
N/A
## Security considerations
None.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
---
Makefile | 47 +++++++++++++++++++++++++++++++++++------------
1 file changed, 35 insertions(+), 12 deletions(-)
diff --git a/Makefile b/Makefile
index 767e8c91c8..11adfa9de9 100644
--- a/Makefile
+++ b/Makefile
@@ -153,15 +153,33 @@ install-junit-viewer: ## Install junit-viewer for HTML report generation (if not
dev: install-ui install-air setup-workspace $(if $(DEBUG),install-delve) ## Start complete development environment (UI + API with proxy)
@$(EXPOSE_ENV); \
- set -m; \
+ set +m; \
+ ui_pid=""; \
+ api_pid=""; \
cleanup() { \
+ $(ECHO) "$(YELLOW)[make dev] cleanup started; ui_pid=$$ui_pid api_pid=$$api_pid$(NC)"; \
trap - EXIT INT TERM HUP; \
- kill %1 %2 2>/dev/null || true; \
+ for pid in "$$ui_pid" "$$api_pid"; do \
+ if [ -n "$$pid" ]; then \
+ children="$$(pgrep -P "$$pid" 2>/dev/null || true)"; \
+ $(ECHO) "$(YELLOW)[make dev] sending TERM to pid $$pid and children: $${children:-none}$(NC)"; \
+ kill -TERM $$children "$$pid" 2>/dev/null || true; \
+ fi; \
+ done; \
sleep 1; \
- kill -KILL %1 %2 2>/dev/null || true; \
+ for pid in "$$ui_pid" "$$api_pid"; do \
+ if [ -n "$$pid" ]; then \
+ children="$$(pgrep -P "$$pid" 2>/dev/null || true)"; \
+ $(ECHO) "$(YELLOW)[make dev] sending KILL to pid $$pid and remaining children: $${children:-none}$(NC)"; \
+ kill -KILL $$children "$$pid" 2>/dev/null || true; \
+ fi; \
+ done; \
+ $(ECHO) "$(YELLOW)[make dev] waiting for background jobs to exit...$(NC)"; \
wait 2>/dev/null || true; \
+ $(ECHO) "$(GREEN)[make dev] cleanup completed.$(NC)"; \
}; \
stop_dev() { \
+ $(ECHO) "$(YELLOW)[make dev] received shutdown signal; starting cleanup...$(NC)"; \
cleanup; \
exit 130; \
}; \
@@ -184,33 +202,38 @@ dev: install-ui install-air setup-workspace $(if $(DEBUG),install-delve) ## Star
$(ECHO) "$(YELLOW)Starting UI development server...$(NC)"; \
$(USE_NODE); if [ -n "$(DISABLE_PROFILER)" ]; then \
$(ECHO) "$(CYAN)DevProfiler disabled for testing$(NC)"; \
- cd ui && BIFROST_DISABLE_PROFILER=1 npm run dev & \
+ (cd ui && BIFROST_DISABLE_PROFILER=1 npm run dev) & \
else \
- cd ui && npm run dev & \
+ (cd ui && npm run dev) & \
fi; \
+ ui_pid="$$!"; \
+ $(ECHO) "$(YELLOW)[make dev] UI dev server started with pid $$ui_pid$(NC)"; \
sleep 3; \
$(ECHO) "$(YELLOW)Starting API server with UI proxy...$(NC)"; \
$(MAKE) setup-workspace >/dev/null; \
if [ -n "$(DEBUG)" ]; then \
$(ECHO) "$(CYAN)Starting with air + delve debugger on port 2345...$(NC)"; \
$(ECHO) "$(YELLOW)Attach your debugger to localhost:2345$(NC)"; \
- cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.debug.toml -- \
+ (cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.debug.toml -- \
-host "$(HOST)" \
-port "$(PORT)" \
-log-style "$(LOG_STYLE)" \
-log-level "$(LOG_LEVEL)" \
$(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") \
- $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))") & \
+ $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))")) & \
else \
- cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.toml -- \
+ (cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.toml -- \
-host "$(HOST)" \
-port "$(PORT)" \
-log-style "$(LOG_STYLE)" \
-log-level "$(LOG_LEVEL)" \
$(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") \
- $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))") & \
+ $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))")) & \
fi; \
- while [ "$$(jobs -r | wc -l | tr -d ' ')" -eq 2 ]; do sleep 1; done; \
+ api_pid="$$!"; \
+ $(ECHO) "$(YELLOW)[make dev] API dev server started with pid $$api_pid$(NC)"; \
+ while kill -0 "$$ui_pid" 2>/dev/null && kill -0 "$$api_pid" 2>/dev/null; do sleep 1; done; \
+ $(ECHO) "$(YELLOW)[make dev] one of the dev processes exited; running cleanup...$(NC)"; \
cleanup; \
exit 1
@@ -247,9 +270,9 @@ dev-pulse: install-ui install-pulse setup-workspace $(if $(DEBUG),install-delve)
$(ECHO) "$(YELLOW)Starting UI development server...$(NC)"; \
$(USE_NODE); if [ -n "$(DISABLE_PROFILER)" ]; then \
$(ECHO) "$(CYAN)DevProfiler disabled for testing$(NC)"; \
- cd ui && BIFROST_DISABLE_PROFILER=1 npm run dev & \
+ (cd ui && BIFROST_DISABLE_PROFILER=1 npm run dev) & \
else \
- cd ui && npm run dev & \
+ (cd ui && npm run dev) & \
fi; \
sleep 3; \
$(ECHO) "$(YELLOW)Starting API server with UI proxy...$(NC)"; \
From d9edc3db9ec112f5f6caabd73dcee1bed5b9567c Mon Sep 17 00:00:00 2001
From: Dan Piths <85949566+danpiths@users.noreply.github.com>
Date: Thu, 14 May 2026 19:30:42 +0530
Subject: [PATCH 34/81] feat: add Azure realtime provider and nested model
normalization (#3334)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds Azure as a realtime provider (WebSocket, WebRTC, client secrets) and
introduces nested model prefix stripping so Bifrost-style `provider/model`
strings in session configs (e.g. `openai/whisper-1` in
`input_audio_transcription.model`) are normalized to bare model names before
forwarding upstream.
## Changes
- **`core/providers/azure/realtime.go` (new)**: Full Azure realtime
implementation covering `RealtimeProvider` (WebSocket),
`RealtimeWebRTCProvider` (SDP exchange), and `RealtimeSessionProvider` (client
secrets only — legacy `/sessions` returns a clear error). Reuses OpenAI event
converters since Azure uses the same wire protocol. Key Azure-specific
behavior:
- URLs use `/openai/v1/realtime` prefix with preview `api-version` query param
- Auth uses `api-key` header for API keys, `Authorization: Bearer` for
ephemeral tokens (`ek_*`)
- Model value maps to the Azure deployment name (resolved via key aliases
upstream)
- **`core/providers/openai/realtime.go`**: Exported `StripNestedModelPrefixes`
and `ExtractNestedVoice` for reuse by Azure and the transport handlers. Added
`StripNestedModelPrefixes` calls in `normalizeRealtimeClientSecretsRequest`
and `normalizeRealtimeSessionsRequest` to strip provider prefixes from nested
model fields in both old format (`input_audio_transcription.model`) and new
format (`audio.input.transcription.model`)
- **`core/providers/openai/realtime_test.go`**: Updated test expectations for
exported helpers
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [x] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
```sh
# Provider tests
go test ./core/providers/azure/ -count=1 -v
go test ./core/providers/openai/ -count=1 -v
# Full build
make build LOCAL=1
```
Manual verification:
1. Configure an Azure key with a realtime deployment alias
2. Connect via WebSocket to `/v1/realtime?model=`
3. Connect via WebRTC with ephemeral token from `/v1/realtime/client_secrets`
4. Send a session config with `openai/whisper-1` as transcription model — verify
it's normalized to `whisper-1` upstream
5. POST to `/v1/realtime/sessions` routed to Azure — verify clear error message
about using `/client_secrets` instead
## Screenshots/Recordings
N/A
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
N/A
## Security considerations
Ephemeral token detection uses `ek_` prefix check to switch from `api-key` to
`Authorization: Bearer` header. This is consistent with how OpenAI ephemeral
tokens work and doesn't expose any additional auth surface.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
---
core/internal/llmtests/realtime.go | 5 +-
core/providers/azure/realtime.go | 383 ++++++++++++++++++
core/providers/elevenlabs/realtime.go | 4 +-
core/providers/openai/realtime.go | 92 ++++-
core/providers/openai/realtime_test.go | 12 +-
core/schemas/realtime.go | 2 +-
.../bifrost-http/handlers/wsrealtime.go | 7 +-
7 files changed, 489 insertions(+), 16 deletions(-)
create mode 100644 core/providers/azure/realtime.go
diff --git a/core/internal/llmtests/realtime.go b/core/internal/llmtests/realtime.go
index 400f5f9cda..e024ffb8c5 100644
--- a/core/internal/llmtests/realtime.go
+++ b/core/internal/llmtests/realtime.go
@@ -49,7 +49,10 @@ func RunRealtimeTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context,
}
wsURL := rtProvider.RealtimeWebSocketURL(key, testConfig.RealtimeModel)
- hdrs := rtProvider.RealtimeHeaders(key)
+ hdrs, headerErr := rtProvider.RealtimeHeaders(bfCtx, key)
+ if headerErr != nil {
+ t.Fatalf("failed to build realtime headers for provider %s: %v", testConfig.Provider, headerErr)
+ }
httpHeaders := http.Header{}
for k, v := range hdrs {
diff --git a/core/providers/azure/realtime.go b/core/providers/azure/realtime.go
new file mode 100644
index 0000000000..ae19471a00
--- /dev/null
+++ b/core/providers/azure/realtime.go
@@ -0,0 +1,383 @@
+package azure
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "strings"
+
+ openaiProvider "github.com/maximhq/bifrost/core/providers/openai"
+ providerUtils "github.com/maximhq/bifrost/core/providers/utils"
+ "github.com/maximhq/bifrost/core/schemas"
+ "github.com/valyala/fasthttp"
+)
+
+// openAIEventHelper is a zero-value OpenAI provider used solely to delegate
+// event conversion calls. Azure uses the exact same Realtime wire protocol as
+// OpenAI, so all event parsing, serialisation, usage extraction, turn detection,
+// and output extraction can be reused without modification.
+var openAIEventHelper = &openaiProvider.OpenAIProvider{}
+
+// ---------------------------------------------------------------------------
+// RealtimeProvider interface
+// ---------------------------------------------------------------------------
+
+func (provider *AzureProvider) SupportsRealtimeAPI() bool {
+ return true
+}
+
+func (provider *AzureProvider) RealtimeWebSocketURL(key schemas.Key, model string) string {
+ endpoint := strings.TrimRight(key.AzureKeyConfig.Endpoint.GetValue(), "/")
+ endpoint = strings.Replace(endpoint, "https://", "wss://", 1)
+ endpoint = strings.Replace(endpoint, "http://", "ws://", 1)
+
+ apiVersion := azureRealtimeAPIVersion(key)
+
+ return fmt.Sprintf("%s/openai/v1/realtime?model=%s&api-version=%s",
+ endpoint, url.QueryEscape(model), url.QueryEscape(apiVersion))
+}
+
+func (provider *AzureProvider) RealtimeHeaders(ctx *schemas.BifrostContext, key schemas.Key) (map[string]string, *schemas.BifrostError) {
+ value := key.Value.GetValue()
+
+ // Ephemeral tokens from /client_secrets use Bearer auth.
+ if strings.HasPrefix(value, "ek_") {
+ headers := map[string]string{
+ "Authorization": "Bearer " + value,
+ }
+ for k, v := range provider.networkConfig.ExtraHeaders {
+ headers[k] = v
+ }
+ return headers, nil
+ }
+
+ headers, authErr := provider.getAzureAuthHeaders(ctx, key, false)
+ if authErr != nil {
+ return nil, authErr
+ }
+ for k, v := range provider.networkConfig.ExtraHeaders {
+ headers[k] = v
+ }
+ return headers, nil
+}
+
+func (provider *AzureProvider) SupportsRealtimeWebRTC() bool {
+ return true
+}
+
+func (provider *AzureProvider) ExchangeRealtimeWebRTCSDP(
+ ctx *schemas.BifrostContext,
+ key schemas.Key,
+ model string,
+ sdp string,
+ session json.RawMessage,
+) (string, *schemas.BifrostError) {
+ endpoint := strings.TrimRight(key.AzureKeyConfig.Endpoint.GetValue(), "/")
+ apiVersion := azureRealtimeAPIVersion(key)
+
+ upstreamURL := fmt.Sprintf("%s/openai/v1/realtime?model=%s&api-version=%s",
+ endpoint, url.QueryEscape(model), url.QueryEscape(apiVersion))
+
+ // Build multipart body: sdp + optional session
+ bodyBuf := &bytes.Buffer{}
+ writer := multipart.NewWriter(bodyBuf)
+ if err := writer.WriteField("sdp", sdp); err != nil {
+ return "", newAzureRealtimeError(fasthttp.StatusInternalServerError, "server_error", "failed to encode upstream SDP body", err)
+ }
+ if session != nil {
+ if err := writer.WriteField("session", string(session)); err != nil {
+ return "", newAzureRealtimeError(fasthttp.StatusInternalServerError, "server_error", "failed to encode upstream session body", err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ return "", newAzureRealtimeError(fasthttp.StatusInternalServerError, "server_error", "failed to finalize upstream SDP body", err)
+ }
+
+ req := fasthttp.AcquireRequest()
+ resp := fasthttp.AcquireResponse()
+ defer fasthttp.ReleaseRequest(req)
+ defer fasthttp.ReleaseResponse(resp)
+
+ req.SetRequestURI(upstreamURL)
+ req.Header.SetMethod(http.MethodPost)
+ req.Header.SetContentType(writer.FormDataContentType())
+
+ // Ephemeral tokens (ek_*) need Bearer auth; regular API keys use api-key header.
+ value := key.Value.GetValue()
+ if strings.HasPrefix(value, "ek_") {
+ req.Header.Set("Authorization", "Bearer "+value)
+ } else {
+ authHeaders, authErr := provider.getAzureAuthHeaders(ctx, key, false)
+ if authErr != nil {
+ return "", authErr
+ }
+ for k, v := range authHeaders {
+ req.Header.Set(k, v)
+ }
+ }
+
+ for k, v := range provider.networkConfig.ExtraHeaders {
+ req.Header.Set(k, v)
+ }
+ req.SetBody(bodyBuf.Bytes())
+
+ _, bifrostErr, wait := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp)
+ defer wait()
+ if bifrostErr != nil {
+ return "", bifrostErr
+ }
+
+ answerBody := resp.Body()
+ if resp.StatusCode() < fasthttp.StatusOK || resp.StatusCode() >= fasthttp.StatusMultipleChoices {
+ return "", provider.realtimeWebRTCUpstreamError(ctx, resp.StatusCode(), answerBody)
+ }
+
+ return string(answerBody), nil
+}
+
+// ---------------------------------------------------------------------------
+// Event conversion — delegates to OpenAI (same wire protocol)
+// ---------------------------------------------------------------------------
+
+func (provider *AzureProvider) ToBifrostRealtimeEvent(providerEvent json.RawMessage) (*schemas.BifrostRealtimeEvent, error) {
+ return openAIEventHelper.ToBifrostRealtimeEvent(providerEvent)
+}
+
+func (provider *AzureProvider) ToProviderRealtimeEvent(bifrostEvent *schemas.BifrostRealtimeEvent) (json.RawMessage, error) {
+ return openAIEventHelper.ToProviderRealtimeEvent(bifrostEvent)
+}
+
+// ---------------------------------------------------------------------------
+// Turn lifecycle — delegates to OpenAI
+// ---------------------------------------------------------------------------
+
+func (provider *AzureProvider) ShouldStartRealtimeTurn(event *schemas.BifrostRealtimeEvent) bool {
+ return openAIEventHelper.ShouldStartRealtimeTurn(event)
+}
+
+func (provider *AzureProvider) RealtimeTurnFinalEvent() schemas.RealtimeEventType {
+ return openAIEventHelper.RealtimeTurnFinalEvent()
+}
+
+func (provider *AzureProvider) ShouldForwardRealtimeEvent(event *schemas.BifrostRealtimeEvent) bool {
+ return true
+}
+
+func (provider *AzureProvider) ShouldAccumulateRealtimeOutput(eventType schemas.RealtimeEventType) bool {
+ return openAIEventHelper.ShouldAccumulateRealtimeOutput(eventType)
+}
+
+func (provider *AzureProvider) RealtimeWebRTCDataChannelLabel() string {
+ return "oai-events"
+}
+
+func (provider *AzureProvider) RealtimeWebSocketSubprotocol() string {
+ return "realtime"
+}
+
+// ---------------------------------------------------------------------------
+// RealtimeUsageExtractor — delegates to OpenAI
+// ---------------------------------------------------------------------------
+
+func (provider *AzureProvider) ExtractRealtimeTurnUsage(terminalEventRaw []byte) *schemas.BifrostLLMUsage {
+ return openAIEventHelper.ExtractRealtimeTurnUsage(terminalEventRaw)
+}
+
+func (provider *AzureProvider) ExtractRealtimeTurnOutput(terminalEventRaw []byte) *schemas.ChatMessage {
+ return openAIEventHelper.ExtractRealtimeTurnOutput(terminalEventRaw)
+}
+
+// ---------------------------------------------------------------------------
+// RealtimeSessionProvider — client_secrets only (not legacy /sessions)
+// ---------------------------------------------------------------------------
+
+func (provider *AzureProvider) CreateRealtimeClientSecret(
+ ctx *schemas.BifrostContext,
+ key schemas.Key,
+ endpointType schemas.RealtimeSessionEndpointType,
+ rawRequest json.RawMessage,
+) (*schemas.BifrostPassthroughResponse, *schemas.BifrostError) {
+ // Azure does not support the legacy /sessions endpoint.
+ if endpointType == schemas.RealtimeSessionEndpointSessions {
+ return nil, &schemas.BifrostError{
+ IsBifrostError: true,
+ StatusCode: schemas.Ptr(fasthttp.StatusBadRequest),
+ Error: &schemas.ErrorField{
+ Type: schemas.Ptr("invalid_request_error"),
+ Message: "Azure does not support the legacy /sessions endpoint; use /v1/realtime/client_secrets instead",
+ },
+ ExtraFields: schemas.BifrostErrorExtraFields{
+ RequestType: schemas.RealtimeRequest,
+ Provider: provider.GetProviderKey(),
+ },
+ }
+ }
+
+ normalizedBody, _, bifrostErr := openaiProvider.NormalizeRealtimeClientSecretRequest(rawRequest, schemas.Azure, endpointType)
+ if bifrostErr != nil {
+ return nil, bifrostErr
+ }
+
+ endpoint := strings.TrimRight(key.AzureKeyConfig.Endpoint.GetValue(), "/")
+ apiVersion := azureRealtimeAPIVersion(key)
+ upstreamURL := fmt.Sprintf("%s/openai/v1/realtime/client_secrets?api-version=%s",
+ endpoint, url.QueryEscape(apiVersion))
+
+ req := fasthttp.AcquireRequest()
+ resp := fasthttp.AcquireResponse()
+ defer fasthttp.ReleaseRequest(req)
+ defer fasthttp.ReleaseResponse(resp)
+
+ req.SetRequestURI(upstreamURL)
+ req.Header.SetMethod(http.MethodPost)
+ req.Header.SetContentType("application/json")
+
+ authHeaders, authErr := provider.getAzureAuthHeaders(ctx, key, false)
+ if authErr != nil {
+ return nil, authErr
+ }
+ for k, v := range authHeaders {
+ req.Header.Set(k, v)
+ }
+ for k, v := range provider.networkConfig.ExtraHeaders {
+ req.Header.Set(k, v)
+ }
+ req.SetBody(normalizedBody)
+
+ latency, bifrostErr, wait := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp)
+ defer wait()
+ if bifrostErr != nil {
+ return nil, bifrostErr
+ }
+
+ headers := providerUtils.ExtractProviderResponseHeaders(resp)
+ ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers)
+
+ if resp.StatusCode() < fasthttp.StatusOK || resp.StatusCode() >= fasthttp.StatusMultipleChoices {
+ return nil, provider.parseRealtimeClientSecretError(ctx, resp)
+ }
+
+ body, err := providerUtils.CheckAndDecodeBody(resp)
+ if err != nil {
+ return nil, providerUtils.NewBifrostOperationError("failed to decode response body", err)
+ }
+
+ out := &schemas.BifrostPassthroughResponse{
+ StatusCode: resp.StatusCode(),
+ Headers: headers,
+ Body: body,
+ ExtraFields: schemas.BifrostResponseExtraFields{
+ Latency: latency.Milliseconds(),
+ ProviderResponseHeaders: headers,
+ },
+ }
+ if providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest) {
+ providerUtils.ParseAndSetRawRequestIfJSON(req, &out.ExtraFields)
+ }
+
+ return out, nil
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func (provider *AzureProvider) realtimeWebRTCUpstreamError(ctx *schemas.BifrostContext, statusCode int, body []byte) *schemas.BifrostError {
+ message := fmt.Sprintf("upstream realtime handshake failed for %s", provider.GetProviderKey())
+ var parsed struct {
+ Error struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if json.Unmarshal(body, &parsed) == nil && parsed.Error.Message != "" {
+ message = parsed.Error.Message
+ }
+
+ bifrostErr := &schemas.BifrostError{
+ IsBifrostError: false,
+ StatusCode: schemas.Ptr(statusCode),
+ Error: &schemas.ErrorField{
+ Type: schemas.Ptr("upstream_error"),
+ Message: message,
+ },
+ ExtraFields: schemas.BifrostErrorExtraFields{
+ RequestType: schemas.RealtimeRequest,
+ Provider: provider.GetProviderKey(),
+ },
+ }
+ if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) {
+ bifrostErr.ExtraFields.RawResponse = map[string]any{
+ "status": statusCode,
+ "body": string(body),
+ }
+ }
+ return bifrostErr
+}
+
+func newAzureRealtimeError(status int, errorType, message string, err error) *schemas.BifrostError {
+ bifrostErr := &schemas.BifrostError{
+ IsBifrostError: true,
+ StatusCode: schemas.Ptr(status),
+ Error: &schemas.ErrorField{
+ Type: schemas.Ptr(errorType),
+ Message: message,
+ },
+ ExtraFields: schemas.BifrostErrorExtraFields{
+ RequestType: schemas.RealtimeRequest,
+ Provider: schemas.Azure,
+ },
+ }
+ if err != nil {
+ bifrostErr.Error.Error = err
+ }
+ return bifrostErr
+}
+
+// azureRealtimeAPIVersion returns the API version to use for realtime endpoints.
+// Realtime requires a preview API version. If the key has an explicit version
+// configured we honour it; otherwise we fall back to the preview version rather
+// than the stable default (which does not support realtime).
+func azureRealtimeAPIVersion(key schemas.Key) string {
+ if key.AzureKeyConfig != nil && key.AzureKeyConfig.APIVersion != nil {
+ if apiVersion := key.AzureKeyConfig.APIVersion.GetValue(); apiVersion != "" {
+ return apiVersion
+ }
+ }
+ return AzureAPIVersionPreview
+}
+
+func (provider *AzureProvider) parseRealtimeClientSecretError(ctx *schemas.BifrostContext, resp *fasthttp.Response) *schemas.BifrostError {
+ body, _ := providerUtils.CheckAndDecodeBody(resp)
+ var parsed struct {
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ msg := string(body)
+ if json.Unmarshal(body, &parsed) == nil && parsed.Error.Message != "" {
+ msg = parsed.Error.Message
+ }
+ bifrostErr := &schemas.BifrostError{
+ IsBifrostError: false,
+ StatusCode: schemas.Ptr(resp.StatusCode()),
+ Error: &schemas.ErrorField{
+ Type: schemas.Ptr("upstream_error"),
+ Message: msg,
+ },
+ ExtraFields: schemas.BifrostErrorExtraFields{
+ RequestType: schemas.RealtimeRequest,
+ Provider: provider.GetProviderKey(),
+ },
+ }
+ if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) {
+ bifrostErr.ExtraFields.RawResponse = map[string]any{
+ "status": resp.StatusCode(),
+ "body": string(body),
+ }
+ }
+ return bifrostErr
+}
diff --git a/core/providers/elevenlabs/realtime.go b/core/providers/elevenlabs/realtime.go
index a18e1cd514..20ef26da26 100644
--- a/core/providers/elevenlabs/realtime.go
+++ b/core/providers/elevenlabs/realtime.go
@@ -26,7 +26,7 @@ func (provider *ElevenlabsProvider) RealtimeWebSocketURL(key schemas.Key, model
}
// RealtimeHeaders returns the headers required for the ElevenLabs Conversational AI WebSocket.
-func (provider *ElevenlabsProvider) RealtimeHeaders(key schemas.Key) map[string]string {
+func (provider *ElevenlabsProvider) RealtimeHeaders(_ *schemas.BifrostContext, key schemas.Key) (map[string]string, *schemas.BifrostError) {
headers := map[string]string{
"xi-api-key": key.Value.GetValue(),
}
@@ -36,7 +36,7 @@ func (provider *ElevenlabsProvider) RealtimeHeaders(key schemas.Key) map[string]
}
headers[k] = v
}
- return headers
+ return headers, nil
}
// SupportsRealtimeWebRTC returns false — ElevenLabs WebRTC SDP exchange is not yet implemented.
diff --git a/core/providers/openai/realtime.go b/core/providers/openai/realtime.go
index 1a2e46bf34..65cddb4dd3 100644
--- a/core/providers/openai/realtime.go
+++ b/core/providers/openai/realtime.go
@@ -30,14 +30,14 @@ func (provider *OpenAIProvider) RealtimeWebSocketURL(key schemas.Key, model stri
}
// RealtimeHeaders returns the headers required for the OpenAI Realtime WebSocket connection.
-func (provider *OpenAIProvider) RealtimeHeaders(key schemas.Key) map[string]string {
+func (provider *OpenAIProvider) RealtimeHeaders(_ *schemas.BifrostContext, key schemas.Key) (map[string]string, *schemas.BifrostError) {
headers := map[string]string{
"Authorization": "Bearer " + key.Value.GetValue(),
}
for k, v := range provider.networkConfig.ExtraHeaders {
headers[k] = v
}
- return headers
+ return headers, nil
}
// SupportsRealtimeWebRTC reports that OpenAI supports WebRTC SDP exchange.
@@ -217,7 +217,7 @@ func (provider *OpenAIProvider) CreateRealtimeClientSecret(
return nil, err
}
- normalizedBody, _, bifrostErr := normalizeRealtimeClientSecretRequest(rawRequest, provider.GetProviderKey(), endpointType)
+ normalizedBody, _, bifrostErr := NormalizeRealtimeClientSecretRequest(rawRequest, provider.GetProviderKey(), endpointType)
if bifrostErr != nil {
return nil, bifrostErr
}
@@ -226,7 +226,8 @@ func (provider *OpenAIProvider) CreateRealtimeClientSecret(
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
- req.SetRequestURI(provider.buildRequestURL(ctx, realtimeSessionUpstreamPath(endpointType), schemas.RealtimeRequest))
+ upstreamURL := provider.buildRequestURL(ctx, realtimeSessionUpstreamPath(endpointType), schemas.RealtimeRequest)
+ req.SetRequestURI(upstreamURL)
req.Header.SetMethod(http.MethodPost)
req.Header.SetContentType("application/json")
for k, v := range provider.realtimeSessionHeaders(key, endpointType) {
@@ -268,7 +269,11 @@ func (provider *OpenAIProvider) CreateRealtimeClientSecret(
return out, nil
}
-func normalizeRealtimeClientSecretRequest(
+// NormalizeRealtimeClientSecretRequest normalizes a realtime client secret request body
+// by parsing the model string, resolving the provider, and restructuring the body
+// to match the upstream provider's expected format. Exported for reuse by providers
+// that share the same OpenAI-compatible Realtime protocol (e.g. Azure).
+func NormalizeRealtimeClientSecretRequest(
rawRequest json.RawMessage,
defaultProvider schemas.ModelProvider,
endpointType schemas.RealtimeSessionEndpointType,
@@ -316,6 +321,7 @@ func normalizeRealtimeClientSecretsRequest(
return nil, "", newRealtimeClientSecretError(fasthttp.StatusInternalServerError, "server_error", "failed to encode normalized model", marshalErr)
}
session["model"] = modelJSON
+ StripNestedModelPrefixes(session)
if _, ok := session["type"]; !ok {
typeJSON, marshalErr := json.Marshal("realtime")
if marshalErr != nil {
@@ -361,6 +367,7 @@ func normalizeRealtimeSessionsRequest(
}
root["model"] = modelJSON
delete(root, "session")
+ StripNestedModelPrefixes(root)
normalizedBody, marshalErr := json.Marshal(root)
if marshalErr != nil {
@@ -370,6 +377,68 @@ func normalizeRealtimeSessionsRequest(
return normalizedBody, normalizedModel, nil
}
+// StripNestedModelPrefixes removes provider prefixes (e.g. "openai/whisper-1" → "whisper-1")
+// from known nested model fields in the realtime session config. This prevents forwarding
+// Bifrost-style "provider/model" strings to upstream providers that expect bare model names.
+func StripNestedModelPrefixes(session map[string]json.RawMessage) {
+ // Old format: input_audio_transcription.model
+ stripModelInNestedObject(session, "input_audio_transcription")
+
+ // New format: audio.input.transcription.model
+ if audioRaw, ok := session["audio"]; ok {
+ var audio map[string]json.RawMessage
+ if json.Unmarshal(audioRaw, &audio) == nil {
+ if inputRaw, ok := audio["input"]; ok {
+ var input map[string]json.RawMessage
+ if json.Unmarshal(inputRaw, &input) == nil {
+ if stripModelInNestedObject(input, "transcription") {
+ if updated, err := json.Marshal(input); err == nil {
+ audio["input"] = updated
+ if updatedAudio, err := json.Marshal(audio); err == nil {
+ session["audio"] = updatedAudio
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// stripModelInNestedObject strips the provider prefix from a "model" field inside a nested
+// object at session[key]. Returns true if any change was made.
+func stripModelInNestedObject(parent map[string]json.RawMessage, key string) bool {
+ objRaw, ok := parent[key]
+ if !ok || len(objRaw) == 0 || bytes.Equal(objRaw, []byte("null")) {
+ return false
+ }
+ var obj map[string]json.RawMessage
+ if json.Unmarshal(objRaw, &obj) != nil {
+ return false
+ }
+ modelRaw, ok := obj["model"]
+ if !ok {
+ return false
+ }
+ var modelStr string
+ if json.Unmarshal(modelRaw, &modelStr) != nil {
+ return false
+ }
+ // Strip provider prefix if present (e.g. "openai/whisper-1" → "whisper-1")
+ _, bareModel := schemas.ParseModelString(modelStr, "")
+ if bareModel == modelStr {
+ return false // no prefix to strip
+ }
+ if updated, err := json.Marshal(bareModel); err == nil {
+ obj["model"] = updated
+ if updatedObj, err := json.Marshal(obj); err == nil {
+ parent[key] = updatedObj
+ return true
+ }
+ }
+ return false
+}
+
func (provider *OpenAIProvider) realtimeSessionHeaders(
key schemas.Key,
endpointType schemas.RealtimeSessionEndpointType,
@@ -965,3 +1034,16 @@ func isRealtimeDeltaEvent(eventType string) bool {
}
return false
}
+
+// ExtractNestedVoice digs into the new session.audio.output.voice path.
+func ExtractNestedVoice(audioRaw json.RawMessage) string {
+ var audio struct {
+ Output struct {
+ Voice string `json:"voice"`
+ } `json:"output"`
+ }
+ if err := json.Unmarshal(audioRaw, &audio); err == nil && audio.Output.Voice != "" {
+ return audio.Output.Voice
+ }
+ return ""
+}
diff --git a/core/providers/openai/realtime_test.go b/core/providers/openai/realtime_test.go
index 5710230b9b..9c5d1f0d2c 100644
--- a/core/providers/openai/realtime_test.go
+++ b/core/providers/openai/realtime_test.go
@@ -11,13 +11,13 @@ import (
func TestNormalizeRealtimeClientSecretRequest(t *testing.T) {
t.Parallel()
- body, model, bifrostErr := normalizeRealtimeClientSecretRequest(
+ body, model, bifrostErr := NormalizeRealtimeClientSecretRequest(
json.RawMessage(`{"model":"openai/gpt-4o-realtime-preview","voice":"alloy"}`),
schemas.OpenAI,
schemas.RealtimeSessionEndpointClientSecrets,
)
if bifrostErr != nil {
- t.Fatalf("normalizeRealtimeClientSecretRequest() error = %v", bifrostErr)
+ t.Fatalf("NormalizeRealtimeClientSecretRequest() error = %v", bifrostErr)
}
if model != "gpt-4o-realtime-preview" {
t.Fatalf("model = %q, want %q", model, "gpt-4o-realtime-preview")
@@ -46,13 +46,13 @@ func TestNormalizeRealtimeClientSecretRequest(t *testing.T) {
func TestNormalizeRealtimeClientSecretRequestUsesDefaultProvider(t *testing.T) {
t.Parallel()
- body, model, bifrostErr := normalizeRealtimeClientSecretRequest(
+ body, model, bifrostErr := NormalizeRealtimeClientSecretRequest(
json.RawMessage(`{"session":{"model":"gpt-4o-realtime-preview"}}`),
schemas.OpenAI,
schemas.RealtimeSessionEndpointClientSecrets,
)
if bifrostErr != nil {
- t.Fatalf("normalizeRealtimeClientSecretRequest() error = %v", bifrostErr)
+ t.Fatalf("NormalizeRealtimeClientSecretRequest() error = %v", bifrostErr)
}
if model != "gpt-4o-realtime-preview" {
t.Fatalf("model = %q, want %q", model, "gpt-4o-realtime-preview")
@@ -78,13 +78,13 @@ func TestNormalizeRealtimeClientSecretRequestUsesDefaultProvider(t *testing.T) {
func TestNormalizeRealtimeSessionsRequest(t *testing.T) {
t.Parallel()
- body, model, bifrostErr := normalizeRealtimeClientSecretRequest(
+ body, model, bifrostErr := NormalizeRealtimeClientSecretRequest(
json.RawMessage(`{"session":{"model":"openai/gpt-4o-realtime-preview","voice":"alloy"}}`),
schemas.OpenAI,
schemas.RealtimeSessionEndpointSessions,
)
if bifrostErr != nil {
- t.Fatalf("normalizeRealtimeClientSecretRequest() error = %v", bifrostErr)
+ t.Fatalf("NormalizeRealtimeClientSecretRequest() error = %v", bifrostErr)
}
if model != "gpt-4o-realtime-preview" {
t.Fatalf("model = %q, want %q", model, "gpt-4o-realtime-preview")
diff --git a/core/schemas/realtime.go b/core/schemas/realtime.go
index ec4fd6789d..cb4004582a 100644
--- a/core/schemas/realtime.go
+++ b/core/schemas/realtime.go
@@ -181,7 +181,7 @@ type RealtimeSessionRoute struct {
type RealtimeProvider interface {
SupportsRealtimeAPI() bool
RealtimeWebSocketURL(key Key, model string) string
- RealtimeHeaders(key Key) map[string]string
+ RealtimeHeaders(ctx *BifrostContext, key Key) (map[string]string, *BifrostError)
// SupportsRealtimeWebRTC reports whether the provider supports WebRTC SDP exchange.
SupportsRealtimeWebRTC() bool
// ExchangeRealtimeWebRTCSDP performs the provider-specific SDP signaling exchange.
diff --git a/transports/bifrost-http/handlers/wsrealtime.go b/transports/bifrost-http/handlers/wsrealtime.go
index 6f488103af..81aec44314 100644
--- a/transports/bifrost-http/handlers/wsrealtime.go
+++ b/transports/bifrost-http/handlers/wsrealtime.go
@@ -197,11 +197,16 @@ func (h *WSRealtimeHandler) runRealtimeSession(
model = key.Aliases.Resolve(model)
wsURL := rtProvider.RealtimeWebSocketURL(key, model)
+ realtimeHeaders, headerErr := rtProvider.RealtimeHeaders(bifrostCtx, key)
+ if headerErr != nil {
+ clientConn.writeRealtimeError(headerErr)
+ return
+ }
upstream, err := h.pool.Get(bfws.PoolKey{
Provider: providerKey,
KeyID: key.ID,
Endpoint: wsURL,
- }, mapToHTTPHeader(rtProvider.RealtimeHeaders(key)))
+ }, mapToHTTPHeader(realtimeHeaders))
if err != nil {
clientConn.writeRealtimeError(newRealtimeWireBifrostError(502, "server_error", err.Error()))
return
From 11f11a0a102ef7530158833fcba0bb51a7099137 Mon Sep 17 00:00:00 2001
From: Dan Piths <85949566+danpiths@users.noreply.github.com>
Date: Thu, 14 May 2026 19:31:42 +0530
Subject: [PATCH 35/81] feat: enrich realtime routing, logging, cost, and
session tracking (#3335)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes multiple gaps in realtime observability so that realtime turns have the
same logging richness as normal LLM calls: routing engine logs, tool
definitions, voice/transport metadata, cost calculation, and proper context
propagation through the turn lifecycle.
## Changes
- **Governance routing for realtime** (`plugins/governance/main.go`): Added
`governRealtimeQueryParam` so realtime WebSocket requests participate in
routing rules and virtual key governance via query param model resolution
- **WebSocket middleware context propagation** (`handlers/wsrealtime.go`): Added
`snapshotRealtimeMiddlewareValues` / `applyRealtimeMiddlewareValues` to
capture governance context values from the HTTP middleware and propagate them
to the WebSocket session context. Processes model catalog resolution to
generate routing engine log entries. Computes raw storage flag via
`ComputeRawStorageForProvider`
- **WebRTC middleware context propagation** (`handlers/webrtc_realtime.go`):
Same propagation for WebRTC relay. Added nested model prefix stripping for
legacy `/v1/realtime` POST path. Added model catalog auto-resolution for bare
models
- **Client secrets operational logging**
(`handlers/realtime_client_secrets.go`): Added model catalog auto-resolution
and operational logs (Info for requests/success, Error for failures)
- **Session tool and voice tracking** (`websocket/session.go`): Added
`realtimeSessionTools` and `realtimeVoice` fields with getters/setters.
Replaced `strings.Contains` dedup with replace-with-latest strategy for tool
output events. Added close guards on mutating methods. `Close()` now nils all
accumulated data
- **Turn pipeline enrichment** (`handlers/realtime_turn_pipeline.go`): Added
`updateRealtimeSessionFromEvent` to track tools, voice, and strip nested model
prefixes from session events. Updated `buildRealtimeTurnPreRequest` to attach
session tools. Added `restoreRealtimeTurnTraceContext` and
`applyRealtimeRawStorageContext` for context propagation. Rewrote
`buildRealtimeTurnOutputMessages` to merge provider extractor output with raw
`response.done` payload so both text and tool calls appear
- **Logging plugin fixes** (`plugins/logging/main.go`): Added nil check for
`ResponsesRequest.Params` before iterating tools (fixed panic). Moved
realtime-specific PostLLMHook enrichment to run after `routingEngineLogs`
extraction so routing engine decision logs are populated
- **Voice/transport metadata** (`plugins/logging/utils.go`):
`mergeRealtimeMetadata` now writes `realtime_voice` and `realtime_transport`
- **Cost calculation** (`framework/modelcatalog/pricing.go`): Added
`RealtimeRequest` to `calculateBaseCost`. `computeTextCost` now subtracts
audio tokens from text counts and prices them at
`InputCostPerAudioToken`/`OutputCostPerAudioToken` rates. Added `AudioTokens`
mapping for output token details
- **Core exports** (`core/bifrost.go`, `core/schemas/bifrost.go`): Added
`ComputeRawStorageForProvider` method and
`BifrostContextKeyRealtimeVoice`/`BifrostContextKeyRealtimeTransport` context
keys
- **Tests**: Added dedup behavior tests, output message merging tests, updated
existing test expectations
## Type of change
- [x] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [x] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
```sh
# Handler tests (covers turn pipeline, dedup, output merging)
go test ./transports/bifrost-http/handlers/ -count=1 -v
# Logging plugin tests
go test ./plugins/logging/ -count=1 -v
# Cost calculation tests
go test ./framework/modelcatalog/ -count=1 -v
# Full build
make build LOCAL=1
```
Manual verification:
1. Send a bare model like `gpt-4o-realtime-preview-2025-06-03` to `/v1/realtime`
— verify routing engine logs show model catalog resolution in the Logs UI
2. Use a virtual key — verify virtual key info appears in the log entry
3. Have the model make a tool call alongside a text response — verify both
appear in the log output
4. Check the cost column shows non-zero values for realtime turns
5. Verify tool definitions from `session.update` appear in the log's "Tools"
field
## Screenshots/Recordings
N/A
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
N/A
## Security considerations
`ComputeRawStorageForProvider` is a read-only method that checks provider
config. No new auth surfaces or secret handling.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
---
core/bifrost.go | 25 +++
core/schemas/bifrost.go | 2 +
framework/modelcatalog/pricing.go | 35 +++-
plugins/governance/main.go | 96 ++++++++-
plugins/logging/main.go | 22 ++-
plugins/logging/utils.go | 2 +
.../handlers/realtime_client_secrets.go | 24 ++-
.../handlers/realtime_client_secrets_test.go | 6 +-
.../handlers/realtime_logging_test.go | 64 ++++++
.../handlers/realtime_turn_pipeline.go | 174 ++++++++++++++++-
.../bifrost-http/handlers/webrtc_realtime.go | 89 ++++++++-
.../handlers/webrtc_realtime_test.go | 14 +-
.../bifrost-http/handlers/wsrealtime.go | 183 ++++++++++++++++--
transports/bifrost-http/websocket/session.go | 82 ++++++--
14 files changed, 764 insertions(+), 54 deletions(-)
diff --git a/core/bifrost.go b/core/bifrost.go
index 5fc49bef91..a696c15235 100644
--- a/core/bifrost.go
+++ b/core/bifrost.go
@@ -4051,6 +4051,31 @@ func (bifrost *Bifrost) SelectKeyForProviderRequestType(ctx *schemas.BifrostCont
return bifrost.keySelector(ctx, supportedKeys, providerKey, model)
}
+// ComputeRawStorageForProvider determines whether raw request/response payloads should be
+// captured and stored in log records for the given provider. This is the same computation
+// performed inside executeRequest (lines 5675-5713), exported for callers that bypass
+// the normal inference path (e.g. realtime WebSocket/WebRTC sessions).
+func (bifrost *Bifrost) ComputeRawStorageForProvider(ctx *schemas.BifrostContext, providerKey schemas.ModelProvider) bool {
+ if ctx == nil {
+ ctx = bifrost.ctx
+ }
+ if ctx == nil {
+ return false
+ }
+ config, err := bifrost.account.GetConfigForProvider(providerKey)
+ if err != nil || config == nil {
+ return false
+ }
+ effectiveStore := config.StoreRawRequestResponse
+ allowStorageOverride, _ := ctx.Value(schemas.BifrostContextKeyAllowPerRequestStorageOverride).(bool)
+ if allowStorageOverride {
+ if override, ok := ctx.Value(schemas.BifrostContextKeyStoreRawRequestResponse).(bool); ok {
+ effectiveStore = override
+ }
+ }
+ return effectiveStore
+}
+
// WSStreamHooks holds the post-hook runner and cleanup function returned by RunStreamPreHooks.
// Call PostHookRunner for each streaming chunk, setting StreamEndIndicator on the final chunk.
// Call Cleanup when done to release the pipeline back to the pool.
diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go
index 8abd0390f0..5d60f58650 100644
--- a/core/schemas/bifrost.go
+++ b/core/schemas/bifrost.go
@@ -251,6 +251,8 @@ const (
BifrostContextKeyRealtimeProviderSessionID BifrostContextKey = "bifrost-realtime-provider-session-id" // string
BifrostContextKeyRealtimeSource BifrostContextKey = "bifrost-realtime-source" // string ("ei" or "lm")
BifrostContextKeyRealtimeEventType BifrostContextKey = "bifrost-realtime-event-type" // string
+ BifrostContextKeyRealtimeTransport BifrostContextKey = "bifrost-realtime-transport" // string ("websocket" or "webrtc")
+ BifrostContextKeyRealtimeVoice BifrostContextKey = "bifrost-realtime-voice" // string
BifrostIsAsyncRequest BifrostContextKey = "bifrost-is-async-request" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - whether the request is an async request (only used in gateway)
BifrostContextKeyRequestHeaders BifrostContextKey = "bifrost-request-headers" // map[string]string (all request headers with lowercased keys)
BifrostContextKeyAllowPerRequestStorageOverride BifrostContextKey = "bifrost-allow-per-request-storage-override" // bool (set by transport from config — gates whether x-bf-disable-content-logging and x-bf-store-raw-request-response per-request overrides are honored)
diff --git a/framework/modelcatalog/pricing.go b/framework/modelcatalog/pricing.go
index 68b6551be5..f3c13ff907 100644
--- a/framework/modelcatalog/pricing.go
+++ b/framework/modelcatalog/pricing.go
@@ -322,7 +322,7 @@ func (mc *ModelCatalog) calculateBaseCost(result *schemas.BifrostResponse, scope
// Route to the appropriate compute function
switch requestType {
- case schemas.ChatCompletionRequest, schemas.TextCompletionRequest, schemas.ResponsesRequest:
+ case schemas.ChatCompletionRequest, schemas.TextCompletionRequest, schemas.ResponsesRequest, schemas.RealtimeRequest:
return computeTextCost(pricing, input.usage, input.tier)
case schemas.EmbeddingRequest:
return computeEmbeddingCost(pricing, input.usage, input.tier)
@@ -457,6 +457,7 @@ func responsesUsageToBifrostUsage(u *schemas.ResponsesResponseUsage) *schemas.Bi
if u.OutputTokensDetails != nil {
usage.CompletionTokensDetails = &schemas.ChatCompletionTokensDetails{
ReasoningTokens: u.OutputTokensDetails.ReasoningTokens,
+ AudioTokens: u.OutputTokensDetails.AudioTokens,
}
if u.OutputTokensDetails.NumSearchQueries != nil {
usage.CompletionTokensDetails.NumSearchQueries = u.OutputTokensDetails.NumSearchQueries
@@ -561,13 +562,43 @@ func computeTextCost(pricing *configstoreTables.TableModelPricing, usage *schema
outputCost := float64(completionTokens) * outputRate
+ // Audio token cost: when token details include audio tokens, price them
+ // at the dedicated audio rate and subtract from the text token costs above.
+ // Realtime and audio-enabled chat models report audio tokens in details.
+ audioCost := 0.0
+ inputAudioTokens := 0
+ outputAudioTokens := 0
+ if usage.PromptTokensDetails != nil {
+ inputAudioTokens = usage.PromptTokensDetails.AudioTokens
+ }
+ if usage.CompletionTokensDetails != nil {
+ outputAudioTokens = usage.CompletionTokensDetails.AudioTokens
+ }
+ if inputAudioTokens < 0 {
+ inputAudioTokens = 0
+ } else if inputAudioTokens > promptTokens {
+ inputAudioTokens = promptTokens
+ }
+ if outputAudioTokens < 0 {
+ outputAudioTokens = 0
+ } else if outputAudioTokens > completionTokens {
+ outputAudioTokens = completionTokens
+ }
+ if inputAudioTokens > 0 && pricing.InputCostPerAudioToken != nil {
+ // Subtract audio tokens charged at text rate, add at audio rate.
+ audioCost += float64(inputAudioTokens) * (*pricing.InputCostPerAudioToken - inputRate)
+ }
+ if outputAudioTokens > 0 && pricing.OutputCostPerAudioToken != nil {
+ audioCost += float64(outputAudioTokens) * (*pricing.OutputCostPerAudioToken - outputRate)
+ }
+
// Search query cost
searchCost := 0.0
if pricing.SearchContextCostPerQuery != nil && usage.CompletionTokensDetails != nil && usage.CompletionTokensDetails.NumSearchQueries != nil {
searchCost = float64(*usage.CompletionTokensDetails.NumSearchQueries) * *pricing.SearchContextCostPerQuery
}
- return inputCost + outputCost + searchCost
+ return inputCost + outputCost + audioCost + searchCost
}
// computeEmbeddingCost handles embedding requests (input-only).
diff --git a/plugins/governance/main.go b/plugins/governance/main.go
index d87dcb0e93..4374f16d09 100644
--- a/plugins/governance/main.go
+++ b/plugins/governance/main.go
@@ -355,8 +355,15 @@ func (p *GovernancePlugin) HTTPTransportPreHook(ctx *schemas.BifrostContext, req
return nil, nil
}
- // If no body, check if large payload mode is active for read-only governance
+ // If no body, check if the request carries a model via query params (e.g. realtime
+ // WebSocket upgrades: GET /v1/realtime?model=... or Azure preview ?deployment=...)
+ // or if large payload mode is active.
+ // For query-param-based models we build a synthetic payload so routing rules and VK
+ // load-balancing can rewrite provider/model, then propagate changes back to the query.
if len(req.Body) == 0 {
+ if modelParam := realtimeModelQueryParam(req); modelParam != "" {
+ return p.governRealtimeQueryParam(ctx, req, virtualKeyValue, hasRoutingRules)
+ }
isLargePayload, _ := ctx.Value(schemas.BifrostContextKeyLargePayloadMode).(bool)
if !isLargePayload {
return nil, nil
@@ -572,6 +579,93 @@ func (p *GovernancePlugin) governLargePayload(ctx *schemas.BifrostContext, req *
return nil, nil
}
+// realtimeModelQueryParam returns the query parameter used as the realtime model selector.
+// Azure preview realtime uses `deployment`, while GA/OpenAI-compatible paths use `model`.
+func realtimeModelQueryParam(req *schemas.HTTPRequest) string {
+ if req == nil || req.Query == nil {
+ return ""
+ }
+ if modelParam := req.Query["model"]; modelParam != "" {
+ return modelParam
+ }
+ return req.Query["deployment"]
+}
+
+// governRealtimeQueryParam handles governance for bodyless realtime requests
+// (e.g. WebSocket upgrade GET /v1/realtime?model=... or Azure preview
+// /realtime?deployment=...) where the model lives in a query parameter instead
+// of the JSON body. We build a synthetic payload so routing rules and VK
+// load-balancing can evaluate normally, then propagate any model rewrite back
+// to the original query param for the downstream handler to pick up.
+func (p *GovernancePlugin) governRealtimeQueryParam(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, virtualKeyValue *string, hasRoutingRules bool) (*schemas.HTTPResponse, error) {
+ modelQueryKey := "model"
+ modelParam := req.Query[modelQueryKey]
+ if modelParam == "" {
+ modelQueryKey = "deployment"
+ modelParam = req.Query[modelQueryKey]
+ }
+ if modelParam == "" {
+ return nil, nil
+ }
+
+ payload := map[string]any{
+ "model": modelParam,
+ }
+ originalModel := modelParam
+
+ // Process virtual key if provided
+ var virtualKey *configstoreTables.TableVirtualKey
+ if virtualKeyValue != nil {
+ vk, ok := p.store.GetVirtualKey(ctx, *virtualKeyValue)
+ if !ok || vk == nil || !vk.IsActiveValue() {
+ return nil, nil
+ }
+ virtualKey = vk
+ }
+
+ // Attaching team and customer based on the virtual key
+ if virtualKey != nil {
+ if virtualKey.TeamID != nil {
+ ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamID, *virtualKey.TeamID)
+ }
+ if virtualKey.Team != nil {
+ ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamName, virtualKey.Team.Name)
+ }
+ if virtualKey.CustomerID != nil {
+ ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, *virtualKey.CustomerID)
+ }
+ if virtualKey.Customer != nil {
+ ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerName, virtualKey.Customer.Name)
+ }
+ }
+
+ // Apply routing rules
+ if hasRoutingRules {
+ var err error
+ payload, _, err = p.applyRoutingRules(ctx, req, payload, virtualKey)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Process virtual key: load balance provider
+ if virtualKey != nil {
+ var err error
+ payload, err = p.loadBalanceProvider(ctx, req, payload, virtualKey)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Propagate model changes back to the original query param so the downstream
+ // realtime handler sees the routed/load-balanced model.
+ if newModel, ok := payload["model"].(string); ok && newModel != originalModel {
+ req.Query[modelQueryKey] = newModel
+ }
+
+ return nil, nil
+}
+
// HTTPTransportPostHook intercepts requests after they are processed (governance decision point)
// It modifies the response in-place and returns nil to continue
func (p *GovernancePlugin) HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error {
diff --git a/plugins/logging/main.go b/plugins/logging/main.go
index 0180ada0b4..02ecc07aef 100644
--- a/plugins/logging/main.go
+++ b/plugins/logging/main.go
@@ -528,6 +528,13 @@ func (p *LoggerPlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.Bifr
case schemas.RealtimeRequest:
if req.ResponsesRequest != nil {
initialData.Params = req.ResponsesRequest.Params
+ if req.ResponsesRequest.Params != nil {
+ var tools []schemas.ChatTool
+ for _, tool := range req.ResponsesRequest.Params.Tools {
+ tools = append(tools, *tool.ToChatTool())
+ }
+ initialData.Tools = tools
+ }
}
case schemas.EmbeddingRequest:
initialData.Params = req.EmbeddingRequest.Params
@@ -790,11 +797,6 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas.
}
pending := pendingVal.(*PendingLogData)
- if requestType == schemas.RealtimeRequest {
- if resolvedRealtimeSessionID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyRealtimeSessionID); resolvedRealtimeSessionID != "" {
- pending.ParentRequestID = resolvedRealtimeSessionID
- }
- }
// Should never happen, but just in case
// Fallback to request type from pending data if request type is not set
@@ -827,6 +829,16 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas.
}
// Extract routing engine logs from context before entering goroutine
routingEngineLogs := formatRoutingEngineLogs(ctx.GetRoutingEngineLogs())
+ if requestType == schemas.RealtimeRequest {
+ if resolvedRealtimeSessionID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyRealtimeSessionID); resolvedRealtimeSessionID != "" {
+ pending.ParentRequestID = resolvedRealtimeSessionID
+ }
+ pending.InitialData.Metadata = mergeRealtimeMetadata(pending.InitialData.Metadata, ctx)
+ if routingEngines, ok := ctx.Value(schemas.BifrostContextKeyRoutingEnginesUsed).([]string); ok {
+ pending.InitialData.RoutingEngineUsed = routingEngines
+ pending.RoutingEnginesUsed = routingEngines
+ }
+ }
// Build the complete log entry with input (from PreLLMHook) + output (from PostLLMHook)
entry := buildCompleteLogEntryFromPending(pending)
diff --git a/plugins/logging/utils.go b/plugins/logging/utils.go
index df9da1e573..b4a73bde1e 100644
--- a/plugins/logging/utils.go
+++ b/plugins/logging/utils.go
@@ -752,6 +752,8 @@ func mergeRealtimeMetadata(metadata map[string]interface{}, ctx *schemas.Bifrost
set("provider_session_id", schemas.BifrostContextKeyRealtimeProviderSessionID)
set("realtime_source", schemas.BifrostContextKeyRealtimeSource)
set("realtime_event_type", schemas.BifrostContextKeyRealtimeEventType)
+ set("realtime_transport", schemas.BifrostContextKeyRealtimeTransport)
+ set("realtime_voice", schemas.BifrostContextKeyRealtimeVoice)
if bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyRealtimeSessionID) != "" {
if metadata == nil {
metadata = make(map[string]interface{})
diff --git a/transports/bifrost-http/handlers/realtime_client_secrets.go b/transports/bifrost-http/handlers/realtime_client_secrets.go
index 9fe07dd61e..6b8f680e15 100644
--- a/transports/bifrost-http/handlers/realtime_client_secrets.go
+++ b/transports/bifrost-http/handlers/realtime_client_secrets.go
@@ -80,12 +80,15 @@ func (h *RealtimeClientSecretsHandler) handleRequest(ctx *fasthttp.RequestCtx) {
return
}
- providerKey, model, normalizedBody, err := resolveRealtimeClientSecretTarget(route, body)
+ providerKey, model, normalizedBody, err := resolveRealtimeClientSecretTarget(ctx, h.config, route, body)
if err != nil {
SendBifrostError(ctx, err)
return
}
+ logger.Info("[realtime-client-secrets] request: path=%s provider=%s model=%s endpoint_type=%s",
+ string(ctx.Path()), providerKey, model, route.EndpointType)
+
bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore)
defer cancel()
bifrostCtx.SetValue(schemas.BifrostContextKeyHTTPRequestType, schemas.RealtimeRequest)
@@ -150,9 +153,14 @@ func (h *RealtimeClientSecretsHandler) handleRequest(ctx *fasthttp.RequestCtx) {
resp, bifrostErr := sessionProvider.CreateRealtimeClientSecret(bifrostCtx, key, route.EndpointType, normalizedBody)
if bifrostErr != nil {
+ logger.Error("[realtime-client-secrets] upstream error: provider=%s model=%s error=%s",
+ providerKey, model, bifrostErr.Error)
SendBifrostError(ctx, bifrostErr)
return
}
+
+ logger.Info("[realtime-client-secrets] upstream success: provider=%s model=%s status=%d",
+ providerKey, model, resp.StatusCode)
cacheRealtimeEphemeralKeyMapping(
h.handlerStore.GetKVStore(),
resp.Body,
@@ -208,7 +216,7 @@ func (h *RealtimeClientSecretsHandler) realtimeSessionRoutes() []schemas.Realtim
return routes
}
-func resolveRealtimeClientSecretTarget(route schemas.RealtimeSessionRoute, body []byte) (schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
+func resolveRealtimeClientSecretTarget(ctx *fasthttp.RequestCtx, config *lib.Config, route schemas.RealtimeSessionRoute, body []byte) (schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
root, err := schemas.ParseRealtimeClientSecretBody(body)
if err != nil {
return "", "", nil, err
@@ -221,6 +229,18 @@ func resolveRealtimeClientSecretTarget(route schemas.RealtimeSessionRoute, body
defaultProvider := route.DefaultProvider
providerKey, model := schemas.ParseModelString(rawModel, defaultProvider)
+ // Model catalog auto-resolution for bare model names on /v1 client secret routes
+ if defaultProvider == "" && providerKey == "" && model != "" {
+ providers := config.GetProvidersForModel(model)
+ if len(providers) > 0 {
+ ctx.SetUserValue(lib.FastHTTPUserValueModelCatalogResolution, &lib.ModelCatalogResolution{
+ Model: model,
+ ResolvedProvider: providers[0],
+ AllProviders: providers,
+ })
+ providerKey = providers[0]
+ }
+ }
if defaultProvider == "" && providerKey == "" {
return "", "", nil, newRealtimeClientSecretHandlerError(
fasthttp.StatusBadRequest,
diff --git a/transports/bifrost-http/handlers/realtime_client_secrets_test.go b/transports/bifrost-http/handlers/realtime_client_secrets_test.go
index 8029622921..8c1b83dfa8 100644
--- a/transports/bifrost-http/handlers/realtime_client_secrets_test.go
+++ b/transports/bifrost-http/handlers/realtime_client_secrets_test.go
@@ -65,7 +65,8 @@ func TestResolveRealtimeClientSecretTarget(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- gotProvider, gotModel, _, err := resolveRealtimeClientSecretTarget(tt.route, tt.body)
+ var ctx fasthttp.RequestCtx
+ gotProvider, gotModel, _, err := resolveRealtimeClientSecretTarget(&ctx, &lib.Config{}, tt.route, tt.body)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
@@ -118,7 +119,8 @@ func TestResolveRealtimeClientSecretTarget_NormalizesModel(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- _, _, normalizedBody, err := resolveRealtimeClientSecretTarget(tt.route, []byte(tt.body))
+ var ctx fasthttp.RequestCtx
+ _, _, normalizedBody, err := resolveRealtimeClientSecretTarget(&ctx, &lib.Config{}, tt.route, []byte(tt.body))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
diff --git a/transports/bifrost-http/handlers/realtime_logging_test.go b/transports/bifrost-http/handlers/realtime_logging_test.go
index 054f2ea0e9..b94307664d 100644
--- a/transports/bifrost-http/handlers/realtime_logging_test.go
+++ b/transports/bifrost-http/handlers/realtime_logging_test.go
@@ -255,6 +255,27 @@ func TestPendingRealtimeToolOutputUpdate(t *testing.T) {
}
}
+func TestRealtimeSessionDedupeNestedRawEvents(t *testing.T) {
+ t.Parallel()
+
+ session := bfws.NewSession(nil)
+ firstRaw := `{"type":"conversation.item.created","item":{"id":"item_tool_123","type":"function_call_output","output":"{\"nextResponse\":\"tool result\"}"}}`
+ laterRaw := `{"type":"conversation.item.done","item":{"id":"item_tool_123","type":"function_call_output","output":"{\"nextResponse\":\"tool result\"}"}}`
+
+ session.RecordRealtimeToolOutput("item_tool_123", `{"nextResponse":"tool result"}`, firstRaw)
+ session.RecordRealtimeToolOutput("item_tool_123", `{"nextResponse":"tool result"}`, laterRaw)
+
+ inputs := session.ConsumeRealtimeTurnInputs()
+ if len(inputs) != 1 {
+ t.Fatalf("len(inputs) = %d, want 1", len(inputs))
+ }
+ // Same-itemID updates replace raw with the latest event — later events
+ // (e.g. conversation.item.done) carry the same or more complete data.
+ if inputs[0].Raw != laterRaw {
+ t.Fatalf("Raw = %q, want latest raw event", inputs[0].Raw)
+ }
+}
+
func TestBuildRealtimeTurnPostResponseUsesFullResponseDonePayload(t *testing.T) {
rawRequest := `{"type":"conversation.item.input_audio_transcription.completed","transcript":""}`
rawResponse := []byte(`{
@@ -314,6 +335,49 @@ func TestBuildRealtimeTurnPostResponseUsesFullResponseDonePayload(t *testing.T)
}
}
+func TestBuildRealtimeTurnPostResponseMergesTextAndToolCalls(t *testing.T) {
+ rawResponse := []byte(`{
+ "type":"response.done",
+ "response":{
+ "output":[
+ {
+ "id":"item_message_123",
+ "type":"message",
+ "content":[{"type":"text","text":"assistant text"}]
+ },
+ {
+ "id":"item_call_123",
+ "type":"function_call",
+ "call_id":"call_123",
+ "name":"lookup_weather",
+ "arguments":"{\"city\":\"SF\"}"
+ }
+ ]
+ }
+ }`)
+
+ resp := buildRealtimeTurnPostResponse(&openai.OpenAIProvider{}, schemas.OpenAI, "gpt-realtime", "", rawResponse, "", 123)
+ if resp == nil || resp.ResponsesResponse == nil {
+ t.Fatal("expected realtime post response")
+ }
+ if len(resp.ResponsesResponse.Output) != 2 {
+ t.Fatalf("len(Output) = %d, want 2", len(resp.ResponsesResponse.Output))
+ }
+ if resp.ResponsesResponse.Output[0].Type == nil || *resp.ResponsesResponse.Output[0].Type != schemas.ResponsesMessageTypeMessage {
+ t.Fatalf("Output[0].Type = %#v, want message", resp.ResponsesResponse.Output[0].Type)
+ }
+ toolOutput := resp.ResponsesResponse.Output[1]
+ if toolOutput.Type == nil || *toolOutput.Type != schemas.ResponsesMessageTypeFunctionCall {
+ t.Fatalf("Output[1].Type = %#v, want function_call", toolOutput.Type)
+ }
+ if toolOutput.ResponsesToolMessage == nil || toolOutput.ResponsesToolMessage.Name == nil || *toolOutput.ResponsesToolMessage.Name != "lookup_weather" {
+ t.Fatalf("tool name = %#v, want lookup_weather", toolOutput.ResponsesToolMessage)
+ }
+ if toolOutput.CallID == nil || *toolOutput.CallID != "call_123" {
+ t.Fatalf("CallID = %#v, want call_123", toolOutput.CallID)
+ }
+}
+
func TestFinalizeRealtimeTurnHooksWithErrorCompletesActiveHooks(t *testing.T) {
t.Parallel()
diff --git a/transports/bifrost-http/handlers/realtime_turn_pipeline.go b/transports/bifrost-http/handlers/realtime_turn_pipeline.go
index 91095e5843..947185ee97 100644
--- a/transports/bifrost-http/handlers/realtime_turn_pipeline.go
+++ b/transports/bifrost-http/handlers/realtime_turn_pipeline.go
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
bifrost "github.com/maximhq/bifrost/core"
+ openaiProvider "github.com/maximhq/bifrost/core/providers/openai"
"github.com/maximhq/bifrost/core/schemas"
bfws "github.com/maximhq/bifrost/transports/bifrost-http/websocket"
)
@@ -71,6 +72,29 @@ func newRealtimeTurnContext(
return ctx
}
+func applyRealtimeRawStorageContext(ctx *schemas.BifrostContext, storeRaw bool) {
+ if ctx == nil {
+ return
+ }
+ // Realtime turn logging captures raw payloads only for log storage. There is
+ // no client-facing raw send-back path for synthetic realtime turn responses.
+ sendBackRawRequest := false
+ sendBackRawResponse := false
+ ctx.SetValue(schemas.BifrostContextKeyShouldStoreRawInLogs, storeRaw)
+ ctx.SetValue(schemas.BifrostContextKeyCaptureRawRequest, storeRaw || sendBackRawRequest)
+ ctx.SetValue(schemas.BifrostContextKeyCaptureRawResponse, storeRaw || sendBackRawResponse)
+ ctx.SetValue(schemas.BifrostContextKeyDropRawRequestFromClient, storeRaw && !sendBackRawRequest)
+ ctx.SetValue(schemas.BifrostContextKeyDropRawResponseFromClient, storeRaw && !sendBackRawResponse)
+}
+
+func shouldStoreRealtimeRawPayloads(ctx *schemas.BifrostContext) bool {
+ if ctx == nil {
+ return false
+ }
+ storeRaw, _ := ctx.Value(schemas.BifrostContextKeyShouldStoreRawInLogs).(bool)
+ return storeRaw
+}
+
func applyRealtimeTurnContextValues(ctx *schemas.BifrostContext, values map[any]any) {
if ctx == nil || len(values) == 0 {
return
@@ -93,6 +117,18 @@ func applyRealtimeTurnContextValues(ctx *schemas.BifrostContext, values map[any]
}
}
+func restoreRealtimeTurnTraceContext(ctx *schemas.BifrostContext, traceID string, values map[any]any) {
+ if ctx == nil {
+ return
+ }
+ if strings.TrimSpace(traceID) != "" {
+ ctx.SetValue(schemas.BifrostContextKeyTraceID, strings.TrimSpace(traceID))
+ }
+ if tracer, ok := values[schemas.BifrostContextKeyTracer].(schemas.Tracer); ok && tracer != nil {
+ ctx.SetValue(schemas.BifrostContextKeyTracer, tracer)
+ }
+}
+
func setRealtimeTurnStreamContext(ctx *schemas.BifrostContext, startedAt time.Time, isFinal bool) {
if ctx == nil {
return
@@ -106,7 +142,52 @@ func setRealtimeTurnStreamContext(ctx *schemas.BifrostContext, startedAt time.Ti
}
}
-func buildRealtimeTurnPreRequest(provider schemas.ModelProvider, model string, turnInputs []bfws.RealtimeTurnInput) *schemas.BifrostRequest {
+// sanitizeRealtimeSessionEventForProvider mutates outbound session events before provider
+// serialization. It must not persist session state; rejected session.update events should
+// not affect later turn logs.
+func sanitizeRealtimeSessionEventForProvider(event *schemas.BifrostRealtimeEvent) {
+ if event == nil || event.Session == nil {
+ return
+ }
+ switch event.Type {
+ case schemas.RTEventSessionUpdate,
+ schemas.RTEventSessionCreated,
+ schemas.RTEventSessionUpdated:
+ if event.Session.ExtraParams != nil {
+ openaiProvider.StripNestedModelPrefixes(event.Session.ExtraParams)
+ }
+ }
+}
+
+// updateRealtimeSessionFromEvent updates the session's tracked tool
+// definitions and voice whenever a session.update, session.created, or
+// session.updated event carries them.
+func updateRealtimeSessionFromEvent(session *bfws.Session, event *schemas.BifrostRealtimeEvent) {
+ if event == nil || event.Session == nil {
+ return
+ }
+ switch event.Type {
+ case schemas.RTEventSessionUpdate,
+ schemas.RTEventSessionCreated,
+ schemas.RTEventSessionUpdated:
+ // Only update if the event explicitly carries tools (even an empty array
+ // means "clear tools"). A nil/absent tools field means "not changed".
+ if event.Session.Tools != nil {
+ session.SetRealtimeSessionTools(event.Session.Tools)
+ }
+ if event.Session.Voice != "" {
+ session.SetRealtimeVoice(event.Session.Voice)
+ } else if audioRaw, ok := event.Session.ExtraParams["audio"]; ok {
+ // New API format nests voice under session.audio.output.voice
+ // instead of the legacy top-level session.voice.
+ if voice := openaiProvider.ExtractNestedVoice(audioRaw); voice != "" {
+ session.SetRealtimeVoice(voice)
+ }
+ }
+ }
+}
+
+func buildRealtimeTurnPreRequest(provider schemas.ModelProvider, model string, turnInputs []bfws.RealtimeTurnInput, sessionTools json.RawMessage) *schemas.BifrostRequest {
input := make([]schemas.ResponsesMessage, 0, len(turnInputs))
for _, turnInput := range turnInputs {
summary := strings.TrimSpace(turnInput.Summary)
@@ -134,12 +215,21 @@ func buildRealtimeTurnPreRequest(provider schemas.ModelProvider, model string, t
}
}
+ var params *schemas.ResponsesParameters
+ if len(sessionTools) > 0 {
+ var tools []schemas.ResponsesTool
+ if json.Unmarshal(sessionTools, &tools) == nil && len(tools) > 0 {
+ params = &schemas.ResponsesParameters{Tools: tools}
+ }
+ }
+
return &schemas.BifrostRequest{
RequestType: schemas.RealtimeRequest,
ResponsesRequest: &schemas.BifrostResponsesRequest{
Provider: provider,
Model: model,
Input: input,
+ Params: params,
},
}
}
@@ -180,12 +270,15 @@ func buildRealtimeTurnPostResponse(
func buildRealtimeTurnOutputMessages(rtProvider schemas.RealtimeProvider, rawResponse []byte, contentOverride string) []schemas.ResponsesMessage {
outputs := make([]schemas.ResponsesMessage, 0)
+ seenFunctionCalls := make(map[string]struct{})
if outputMessage := extractRealtimeTurnOutputMessage(rtProvider, rawResponse, contentOverride); outputMessage != nil {
outputs = append(outputs, buildRealtimeResponsesMessagesFromChat(outputMessage, contentOverride)...)
- }
-
- if len(outputs) > 0 {
- return outputs
+ for _, output := range outputs {
+ if output.Type == nil || *output.Type != schemas.ResponsesMessageTypeFunctionCall {
+ continue
+ }
+ seenFunctionCalls[realtimeResponsesFunctionCallKey(output)] = struct{}{}
+ }
}
var parsed realtimeResponseDoneEnvelope
@@ -193,6 +286,9 @@ func buildRealtimeTurnOutputMessages(rtProvider schemas.RealtimeProvider, rawRes
for _, item := range parsed.Response.Output {
switch item.Type {
case "message":
+ if realtimeOutputsContainMessage(outputs) {
+ continue
+ }
content := strings.TrimSpace(contentOverride)
if content == "" {
content = extractRealtimeResponseDoneContentText(item.Content)
@@ -227,6 +323,11 @@ func buildRealtimeTurnOutputMessages(rtProvider schemas.RealtimeProvider, rawRes
if strings.TrimSpace(item.CallID) != "" {
msg.CallID = schemas.Ptr(strings.TrimSpace(item.CallID))
}
+ key := realtimeResponsesFunctionCallKey(msg)
+ if _, exists := seenFunctionCalls[key]; exists {
+ continue
+ }
+ seenFunctionCalls[key] = struct{}{}
outputs = append(outputs, msg)
}
}
@@ -246,6 +347,35 @@ func buildRealtimeTurnOutputMessages(rtProvider schemas.RealtimeProvider, rawRes
return outputs
}
+func realtimeOutputsContainMessage(outputs []schemas.ResponsesMessage) bool {
+ for _, output := range outputs {
+ if output.Type != nil && *output.Type == schemas.ResponsesMessageTypeMessage {
+ return true
+ }
+ }
+ return false
+}
+
+func realtimeResponsesFunctionCallKey(message schemas.ResponsesMessage) string {
+ if message.CallID != nil && strings.TrimSpace(*message.CallID) != "" {
+ return "call_id:" + strings.TrimSpace(*message.CallID)
+ }
+ if message.ID != nil && strings.TrimSpace(*message.ID) != "" {
+ return "id:" + strings.TrimSpace(*message.ID)
+ }
+
+ var parts []string
+ if message.ResponsesToolMessage != nil {
+ if message.ResponsesToolMessage.Name != nil {
+ parts = append(parts, strings.TrimSpace(*message.ResponsesToolMessage.Name))
+ }
+ if message.ResponsesToolMessage.Arguments != nil {
+ parts = append(parts, strings.TrimSpace(*message.ResponsesToolMessage.Arguments))
+ }
+ }
+ return strings.Join(parts, "\x00")
+}
+
func buildRealtimeResponsesMessagesFromChat(message *schemas.ChatMessage, contentOverride string) []schemas.ResponsesMessage {
if message == nil {
return nil
@@ -488,9 +618,14 @@ func startRealtimeTurnHooks(
}()
startedAt := time.Now()
+ storeRaw := shouldStoreRealtimeRawPayloads(baseCtx)
turnCtx := newRealtimeTurnContext(baseCtx, "", session.ID(), session.ProviderSessionID(), realtimeTurnSourceEI, startEventType, key)
+ applyRealtimeRawStorageContext(turnCtx, storeRaw)
+ if voice := session.RealtimeVoice(); voice != "" {
+ turnCtx.SetValue(schemas.BifrostContextKeyRealtimeVoice, voice)
+ }
setRealtimeTurnStreamContext(turnCtx, startedAt, false)
- req := buildRealtimeTurnPreRequest(provider, model, session.PeekRealtimeTurnInputs())
+ req := buildRealtimeTurnPreRequest(provider, model, session.PeekRealtimeTurnInputs(), session.RealtimeSessionTools())
hooks, bifrostErr := client.RunRealtimeTurnPreHooks(turnCtx, req)
if bifrostErr != nil {
// RunRealtimeTurnPreHooks already executed post-hooks and flushed the trace
@@ -502,12 +637,15 @@ func startRealtimeTurnHooks(
}
requestID, _ := turnCtx.Value(schemas.BifrostContextKeyRequestID).(string)
+ traceID, _ := turnCtx.Value(schemas.BifrostContextKeyTraceID).(string)
session.SetRealtimeTurnHooks(&bfws.RealtimeTurnPluginState{
PostHookRunner: hooks.PostHookRunner,
Cleanup: hooks.Cleanup,
RequestID: requestID,
StartedAt: startedAt,
PreHookValues: turnCtx.GetUserValues(),
+ TraceID: traceID,
+ RawStore: storeRaw,
})
committed = true
return nil
@@ -548,6 +686,8 @@ func finalizeRealtimeTurnHooks(
)
postCtx := newRealtimeTurnContext(baseCtx, activeHooks.RequestID, session.ID(), session.ProviderSessionID(), realtimeTurnSourceLM, rtProvider.RealtimeTurnFinalEvent(), key)
applyRealtimeTurnContextValues(postCtx, activeHooks.PreHookValues)
+ restoreRealtimeTurnTraceContext(postCtx, activeHooks.TraceID, activeHooks.PreHookValues)
+ applyRealtimeRawStorageContext(postCtx, activeHooks.RawStore)
setRealtimeTurnStreamContext(postCtx, activeHooks.StartedAt, true)
_, bifrostErr := activeHooks.PostHookRunner(postCtx, postResponse, nil)
completeRealtimeTurnTrace(postCtx)
@@ -555,18 +695,22 @@ func finalizeRealtimeTurnHooks(
}
startedAt := time.Now()
+ storeRaw := shouldStoreRealtimeRawPayloads(baseCtx)
preCtx := newRealtimeTurnContext(baseCtx, "", session.ID(), session.ProviderSessionID(), realtimeTurnSourceEI, "", key)
+ applyRealtimeRawStorageContext(preCtx, storeRaw)
setRealtimeTurnStreamContext(preCtx, startedAt, false)
- preReq := buildRealtimeTurnPreRequest(provider, model, turnInputs)
+ preReq := buildRealtimeTurnPreRequest(provider, model, turnInputs, session.RealtimeSessionTools())
hooks, bifrostErr := client.RunRealtimeTurnPreHooks(preCtx, preReq)
if bifrostErr != nil {
return bifrostErr
}
+ preHookValues := preCtx.GetUserValues()
if hooks.Cleanup != nil {
defer hooks.Cleanup()
}
requestID, _ := preCtx.Value(schemas.BifrostContextKeyRequestID).(string)
+ traceID, _ := preCtx.Value(schemas.BifrostContextKeyTraceID).(string)
postResponse := buildRealtimeTurnPostResponse(
rtProvider,
provider,
@@ -577,7 +721,9 @@ func finalizeRealtimeTurnHooks(
time.Since(startedAt).Milliseconds(),
)
postCtx := newRealtimeTurnContext(baseCtx, requestID, session.ID(), session.ProviderSessionID(), realtimeTurnSourceLM, rtProvider.RealtimeTurnFinalEvent(), key)
- applyRealtimeTurnContextValues(postCtx, preCtx.GetUserValues())
+ applyRealtimeTurnContextValues(postCtx, preHookValues)
+ restoreRealtimeTurnTraceContext(postCtx, traceID, preHookValues)
+ applyRealtimeRawStorageContext(postCtx, storeRaw)
setRealtimeTurnStreamContext(postCtx, startedAt, true)
_, bifrostErr = hooks.PostHookRunner(postCtx, postResponse, nil)
completeRealtimeTurnTrace(postCtx)
@@ -618,6 +764,8 @@ func finalizeRealtimeTurnHooksWithError(
)
postCtx := newRealtimeTurnContext(baseCtx, activeHooks.RequestID, session.ID(), session.ProviderSessionID(), realtimeTurnSourceLM, eventType, key)
applyRealtimeTurnContextValues(postCtx, activeHooks.PreHookValues)
+ restoreRealtimeTurnTraceContext(postCtx, activeHooks.TraceID, activeHooks.PreHookValues)
+ applyRealtimeRawStorageContext(postCtx, activeHooks.RawStore)
setRealtimeTurnStreamContext(postCtx, activeHooks.StartedAt, true)
_, hookErr := activeHooks.PostHookRunner(postCtx, nil, postErr)
completeRealtimeTurnTrace(postCtx)
@@ -633,18 +781,22 @@ func finalizeRealtimeTurnHooksWithError(
}
startedAt := time.Now()
+ storeRaw := shouldStoreRealtimeRawPayloads(baseCtx)
preCtx := newRealtimeTurnContext(baseCtx, "", session.ID(), session.ProviderSessionID(), realtimeTurnSourceEI, "", key)
+ applyRealtimeRawStorageContext(preCtx, storeRaw)
setRealtimeTurnStreamContext(preCtx, startedAt, false)
- preReq := buildRealtimeTurnPreRequest(provider, model, turnInputs)
+ preReq := buildRealtimeTurnPreRequest(provider, model, turnInputs, session.RealtimeSessionTools())
hooks, hookPreErr := client.RunRealtimeTurnPreHooks(preCtx, preReq)
if hookPreErr != nil {
return hookPreErr
}
+ preHookValues := preCtx.GetUserValues()
if hooks.Cleanup != nil {
defer hooks.Cleanup()
}
requestID, _ := preCtx.Value(schemas.BifrostContextKeyRequestID).(string)
+ traceID, _ := preCtx.Value(schemas.BifrostContextKeyTraceID).(string)
postErr := buildRealtimeTurnPostError(
provider,
model,
@@ -653,7 +805,9 @@ func finalizeRealtimeTurnHooksWithError(
bifrostErr,
)
postCtx := newRealtimeTurnContext(baseCtx, requestID, session.ID(), session.ProviderSessionID(), realtimeTurnSourceLM, eventType, key)
- applyRealtimeTurnContextValues(postCtx, preCtx.GetUserValues())
+ applyRealtimeTurnContextValues(postCtx, preHookValues)
+ restoreRealtimeTurnTraceContext(postCtx, traceID, preHookValues)
+ applyRealtimeRawStorageContext(postCtx, storeRaw)
setRealtimeTurnStreamContext(postCtx, startedAt, true)
_, hookErr := hooks.PostHookRunner(postCtx, nil, postErr)
completeRealtimeTurnTrace(postCtx)
diff --git a/transports/bifrost-http/handlers/webrtc_realtime.go b/transports/bifrost-http/handlers/webrtc_realtime.go
index da10f0a9cb..6119ee4a44 100644
--- a/transports/bifrost-http/handlers/webrtc_realtime.go
+++ b/transports/bifrost-http/handlers/webrtc_realtime.go
@@ -13,6 +13,7 @@ import (
"github.com/fasthttp/router"
bifrost "github.com/maximhq/bifrost/core"
+ "github.com/maximhq/bifrost/core/providers/openai"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/transports/bifrost-http/integrations"
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
@@ -62,6 +63,10 @@ func (h *WebRTCRealtimeHandler) RegisterRoutes(r *router.Router, middlewares ...
// Base bifrost route — GA /calls format (multipart sdp + session)
r.POST("/v1/realtime/calls", handler)
+ // Base bifrost route — legacy format (raw SDP or multipart on /v1/realtime)
+ h.legacyRoutes["/v1/realtime"] = ""
+ r.POST("/v1/realtime", handler)
+
// OpenAI integration routes — /calls variants (GA format)
for _, path := range integrations.OpenAIRealtimeWebRTCCallsPaths("/openai") {
r.POST(path, handler)
@@ -105,7 +110,7 @@ func (h *WebRTCRealtimeHandler) handleRequest(ctx *fasthttp.RequestCtx) {
// Raw SDP bodies (application/sdp) fall back to ?model= for the legacy
// raw-SDP path only; the multipart contract has no ?model= fallback.
func (h *WebRTCRealtimeHandler) handleCallsRequest(ctx *fasthttp.RequestCtx) {
- sdpOffer, providerKey, model, normalizedSession, bifrostErr := parseCallsWebRTCRequest(ctx)
+ sdpOffer, providerKey, model, normalizedSession, bifrostErr := parseCallsWebRTCRequest(ctx, h.config)
if bifrostErr != nil {
SendBifrostError(ctx, bifrostErr)
return
@@ -124,7 +129,7 @@ func (h *WebRTCRealtimeHandler) handleCallsRequest(ctx *fasthttp.RequestCtx) {
h.runWebRTCRelay(ctx, rtProvider, providerKey, model, sdpOffer, exchangeSDP)
}
-func parseCallsWebRTCRequest(ctx *fasthttp.RequestCtx) (string, schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
+func parseCallsWebRTCRequest(ctx *fasthttp.RequestCtx, config *lib.Config) (string, schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
contentType := strings.ToLower(string(ctx.Request.Header.ContentType()))
path := string(ctx.Path())
if strings.HasPrefix(contentType, "multipart/form-data") {
@@ -142,7 +147,7 @@ func parseCallsWebRTCRequest(ctx *fasthttp.RequestCtx) (string, schemas.ModelPro
if strings.TrimSpace(sessionField) == "" {
return "", "", "", nil, newRealtimeWebRTCError(fasthttp.StatusBadRequest, "invalid_request_error", "session form field is required", nil)
}
- providerKey, model, normalizedSession, bifrostErr := resolveRealtimeSDPTarget(path, []byte(sessionField))
+ providerKey, model, normalizedSession, bifrostErr := resolveRealtimeSDPTarget(ctx, config, path, []byte(sessionField))
if bifrostErr != nil {
return "", "", "", nil, bifrostErr
}
@@ -160,6 +165,18 @@ func parseCallsWebRTCRequest(ctx *fasthttp.RequestCtx) (string, schemas.ModelPro
}
providerKey, model := schemas.ParseModelString(rawModel, realtimeDefaultProviderForPath(path))
+ // Model catalog auto-resolution for bare model names on base /v1 routes
+ if providerKey == "" && strings.TrimSpace(model) != "" {
+ providers := config.GetProvidersForModel(model)
+ if len(providers) > 0 {
+ ctx.SetUserValue(lib.FastHTTPUserValueModelCatalogResolution, &lib.ModelCatalogResolution{
+ Model: model,
+ ResolvedProvider: providers[0],
+ AllProviders: providers,
+ })
+ providerKey = providers[0]
+ }
+ }
if providerKey == "" || strings.TrimSpace(model) == "" {
if realtimeDefaultProviderForPath(path) == "" {
return "", "", "", nil, newRealtimeWebRTCError(fasthttp.StatusBadRequest, "invalid_request_error", "model must use provider/model on /v1 realtime routes", nil)
@@ -180,6 +197,18 @@ func (h *WebRTCRealtimeHandler) handleLegacyRequest(ctx *fasthttp.RequestCtx, de
}
providerKey, model := schemas.ParseModelString(rawModel, defaultProvider)
+ // Model catalog auto-resolution for bare model names on base /v1 routes
+ if providerKey == "" && strings.TrimSpace(model) != "" {
+ providers := h.config.GetProvidersForModel(model)
+ if len(providers) > 0 {
+ ctx.SetUserValue(lib.FastHTTPUserValueModelCatalogResolution, &lib.ModelCatalogResolution{
+ Model: model,
+ ResolvedProvider: providers[0],
+ AllProviders: providers,
+ })
+ providerKey = providers[0]
+ }
+ }
if providerKey == "" || model == "" {
SendBifrostError(ctx, newRealtimeWebRTCError(fasthttp.StatusBadRequest, "invalid_request_error", "invalid model: "+rawModel, nil))
return
@@ -197,6 +226,16 @@ func (h *WebRTCRealtimeHandler) handleLegacyRequest(ctx *fasthttp.RequestCtx, de
return
}
+ // Strip provider prefixes from nested model fields (e.g. input_audio_transcription.model)
+ if sessionJSON != nil {
+ if root, parseErr := schemas.ParseRealtimeClientSecretBody(sessionJSON); parseErr == nil {
+ openai.StripNestedModelPrefixes(root)
+ if updated, marshalErr := json.Marshal(root); marshalErr == nil {
+ sessionJSON = updated
+ }
+ }
+ }
+
exchangeSDP := func(rCtx *schemas.BifrostContext, key schemas.Key, upstreamOffer string) (string, *schemas.BifrostError) {
return legacyProvider.ExchangeLegacyRealtimeWebRTCSDP(rCtx, key, upstreamOffer, sessionJSON, model)
}
@@ -254,6 +293,10 @@ func (h *WebRTCRealtimeHandler) runWebRTCRelay(
) {
bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore)
defer cancel()
+ // Apply governance/routing values from the transport middleware.
+ // ConvertToBifrostContext creates a fresh context that doesn't carry the user
+ // values the middleware stored on the fasthttp RequestCtx via SetUserValue.
+ applyRealtimeMiddlewareValues(bifrostCtx, snapshotRealtimeMiddlewareValues(ctx))
bifrostCtx.SetValue(schemas.BifrostContextKeyHTTPRequestType, schemas.RealtimeRequest)
if strings.HasPrefix(string(ctx.Path()), "/openai") {
bifrostCtx.SetValue(schemas.BifrostContextKeyIntegrationType, "openai")
@@ -272,6 +315,11 @@ func (h *WebRTCRealtimeHandler) runWebRTCRelay(
model = authKey.Aliases.Resolve(model)
}
+ // Compute raw storage flag from provider config + per-request header overrides.
+ // Normal inference computes this inside bifrost.executeRequest, which is bypassed
+ // for realtime WebRTC connections.
+ applyRealtimeRawStorageContext(bifrostCtx, h.client.ComputeRawStorageForProvider(bifrostCtx, providerKey))
+
boundExchange := func(rCtx *schemas.BifrostContext, upstreamOffer string) (string, *schemas.BifrostError) {
return exchangeSDP(rCtx, authKey, upstreamOffer)
}
@@ -792,6 +840,7 @@ func (r *webrtcRealtimeRelay) handleDownstreamMessage(msg webrtc.DataChannelMess
}
}
+ sanitizeRealtimeSessionEventForProvider(event)
providerEvent, err := r.provider.ToProviderRealtimeEvent(event)
if err != nil {
if startsTurn {
@@ -816,6 +865,9 @@ func (r *webrtcRealtimeRelay) handleDownstreamMessage(msg webrtc.DataChannelMess
r.sendUpstream(msg.Data, msg.IsString)
return
}
+ // Track session metadata only after provider translation succeeds. Rejected
+ // session.update events must not affect later turn logs.
+ updateRealtimeSessionFromEvent(r.session, event)
r.sendUpstream(providerEvent, msg.IsString)
}
@@ -844,6 +896,8 @@ func (r *webrtcRealtimeRelay) handleUpstreamMessage(msg webrtc.DataChannelMessag
if event.Session != nil && event.Session.ID != "" {
r.session.SetProviderSessionID(event.Session.ID)
}
+ // Track session tool definitions from session.created/session.updated (server→client).
+ updateRealtimeSessionFromEvent(r.session, event)
inputItemID, inputSummary := pendingRealtimeInputUpdate(event)
if inputSummary != "" {
r.session.RecordRealtimeInput(inputItemID, inputSummary, string(msg.Data))
@@ -1062,12 +1116,26 @@ func newRealtimeRelayContext(requestCtx *schemas.BifrostContext) (*schemas.Bifro
schemas.BifrostContextKeySelectedKeyID,
schemas.BifrostContextKeySelectedKeyName,
schemas.BifrostContextKeyIsEnterprise,
+ schemas.BifrostContextKeyRoutingEnginesUsed,
+ schemas.BifrostContextKeyRoutingEngineLogs,
+ schemas.BifrostContextKeyShouldStoreRawInLogs,
+ schemas.BifrostContextKeyAllowPerRequestStorageOverride,
+ schemas.BifrostContextKeyAllowPerRequestRawOverride,
+ schemas.BifrostContextKeyStoreRawRequestResponse,
+ schemas.BifrostContextKeyDisableContentLogging,
+ schemas.BifrostContextKeyCaptureRawRequest,
+ schemas.BifrostContextKeyCaptureRawResponse,
+ schemas.BifrostContextKeyDropRawRequestFromClient,
+ schemas.BifrostContextKeyDropRawResponseFromClient,
} {
if value := requestCtx.Value(key); value != nil {
relayCtx.SetValue(key, value)
}
}
+ // Tag the relay context with transport type for downstream logging/metadata.
+ relayCtx.SetValue(schemas.BifrostContextKeyRealtimeTransport, "webrtc")
+
return relayCtx, cancel
}
@@ -1149,7 +1217,7 @@ func sendDataChannelMessage(dc *webrtc.DataChannel, payload []byte, isString boo
}
}
-func resolveRealtimeSDPTarget(path string, sessionJSON []byte) (schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
+func resolveRealtimeSDPTarget(ctx *fasthttp.RequestCtx, config *lib.Config, path string, sessionJSON []byte) (schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
root, err := schemas.ParseRealtimeClientSecretBody(sessionJSON)
if err != nil {
return "", "", nil, err
@@ -1166,6 +1234,18 @@ func resolveRealtimeSDPTarget(path string, sessionJSON []byte) (schemas.ModelPro
}
providerKey, model := schemas.ParseModelString(strings.TrimSpace(rawModel), realtimeDefaultProviderForPath(path))
+ // Model catalog auto-resolution for bare model names in session body
+ if providerKey == "" && strings.TrimSpace(model) != "" {
+ providers := config.GetProvidersForModel(model)
+ if len(providers) > 0 {
+ ctx.SetUserValue(lib.FastHTTPUserValueModelCatalogResolution, &lib.ModelCatalogResolution{
+ Model: model,
+ ResolvedProvider: providers[0],
+ AllProviders: providers,
+ })
+ providerKey = providers[0]
+ }
+ }
if providerKey == "" || strings.TrimSpace(model) == "" {
if realtimeDefaultProviderForPath(path) == "" {
return "", "", nil, newRealtimeWebRTCError(fasthttp.StatusBadRequest, "invalid_request_error", "session.model must use provider/model on /v1 realtime routes", nil)
@@ -1178,6 +1258,7 @@ func resolveRealtimeSDPTarget(path string, sessionJSON []byte) (schemas.ModelPro
return "", "", nil, newRealtimeWebRTCError(fasthttp.StatusInternalServerError, "server_error", "failed to encode normalized session model", marshalErr)
}
root["model"] = normalizedModel
+ openai.StripNestedModelPrefixes(root)
normalizedSession, marshalErr := json.Marshal(root)
if marshalErr != nil {
return "", "", nil, newRealtimeWebRTCError(fasthttp.StatusInternalServerError, "server_error", "failed to encode normalized realtime session", marshalErr)
diff --git a/transports/bifrost-http/handlers/webrtc_realtime_test.go b/transports/bifrost-http/handlers/webrtc_realtime_test.go
index 8ed36bd040..a2636c9b11 100644
--- a/transports/bifrost-http/handlers/webrtc_realtime_test.go
+++ b/transports/bifrost-http/handlers/webrtc_realtime_test.go
@@ -33,7 +33,9 @@ func (s testHandlerStore) GetMCPExternalServerURL() string { re
func (s testHandlerStore) GetMCPExternalClientURL() string { return "" }
func TestResolveRealtimeSDPTarget_BaseRouteRequiresProviderPrefix(t *testing.T) {
- _, _, _, err := resolveRealtimeSDPTarget("/v1/realtime", []byte(`{"model":"gpt-4o-realtime-preview"}`))
+ var ctx fasthttp.RequestCtx
+ cfg := &lib.Config{}
+ _, _, _, err := resolveRealtimeSDPTarget(&ctx, cfg, "/v1/realtime", []byte(`{"model":"gpt-4o-realtime-preview"}`))
if err == nil {
t.Fatal("expected provider/model validation error")
}
@@ -43,7 +45,9 @@ func TestResolveRealtimeSDPTarget_BaseRouteRequiresProviderPrefix(t *testing.T)
}
func TestResolveRealtimeSDPTarget_BaseRouteNormalizesModel(t *testing.T) {
- provider, model, normalized, err := resolveRealtimeSDPTarget("/v1/realtime", []byte(`{"model":"openai/gpt-4o-realtime-preview","voice":"alloy"}`))
+ var ctx fasthttp.RequestCtx
+ cfg := &lib.Config{}
+ provider, model, normalized, err := resolveRealtimeSDPTarget(&ctx, cfg, "/v1/realtime", []byte(`{"model":"openai/gpt-4o-realtime-preview","voice":"alloy"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -68,7 +72,9 @@ func TestResolveRealtimeSDPTarget_BaseRouteNormalizesModel(t *testing.T) {
}
func TestResolveRealtimeSDPTarget_OpenAIRouteDefaultsProvider(t *testing.T) {
- provider, model, _, err := resolveRealtimeSDPTarget("/openai/v1/realtime", []byte(`{"model":"gpt-4o-realtime-preview"}`))
+ var ctx fasthttp.RequestCtx
+ cfg := &lib.Config{}
+ provider, model, _, err := resolveRealtimeSDPTarget(&ctx, cfg, "/openai/v1/realtime", []byte(`{"model":"gpt-4o-realtime-preview"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -87,7 +93,7 @@ func TestParseCallsWebRTCRequest_RawSDPKeepsGARoute(t *testing.T) {
ctx.Request.Header.SetContentType("application/sdp")
ctx.Request.SetBodyString("v=0\r\n")
- sdpOffer, provider, model, session, err := parseCallsWebRTCRequest(&ctx)
+ sdpOffer, provider, model, session, err := parseCallsWebRTCRequest(&ctx, &lib.Config{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
diff --git a/transports/bifrost-http/handlers/wsrealtime.go b/transports/bifrost-http/handlers/wsrealtime.go
index 81aec44314..81edd1496f 100644
--- a/transports/bifrost-http/handlers/wsrealtime.go
+++ b/transports/bifrost-http/handlers/wsrealtime.go
@@ -2,6 +2,7 @@ package handlers
import (
"errors"
+ "fmt"
"io"
"net"
"net/http"
@@ -77,7 +78,7 @@ func (h *WSRealtimeHandler) handleUpgrade(ctx *fasthttp.RequestCtx) {
}
}
- providerKey, model, err := resolveRealtimeTarget(path, modelParam, deploymentParam)
+ providerKey, model, err := resolveRealtimeTarget(ctx, h.config, path, modelParam, deploymentParam)
if err != nil {
upgrader := h.websocketUpgrader("")
upgradeErr := upgrader.Upgrade(ctx, func(conn *ws.Conn) {
@@ -106,6 +107,13 @@ func (h *WSRealtimeHandler) handleUpgrade(ctx *fasthttp.RequestCtx) {
return
}
+ // Capture governance/routing values set by the transport middleware.
+ // TransportInterceptorMiddleware copies BifrostContext user values to individual
+ // fasthttp UserValue slots after HTTPTransportPreHook runs. We snapshot them now
+ // because the fasthttp RequestCtx is recycled after the handler returns — the
+ // WebSocket session outlives it.
+ middlewareContextValues := snapshotRealtimeMiddlewareValues(ctx)
+
upgrader := h.websocketUpgrader(rtProvider.RealtimeWebSocketSubprotocol())
err = upgrader.Upgrade(ctx, func(conn *ws.Conn) {
defer conn.Close()
@@ -118,7 +126,7 @@ func (h *WSRealtimeHandler) handleUpgrade(ctx *fasthttp.RequestCtx) {
}
defer h.sessions.Remove(conn)
- h.runRealtimeSession(clientConn, session, auth, path, providerKey, model)
+ h.runRealtimeSession(clientConn, session, auth, path, providerKey, model, middlewareContextValues)
})
if err != nil {
logger.Warn("websocket upgrade failed for %s: %v", path, err)
@@ -150,6 +158,7 @@ func (h *WSRealtimeHandler) runRealtimeSession(
path string,
providerKey schemas.ModelProvider,
model string,
+ middlewareValues map[any]any,
) {
clientConn.startHeartbeat()
defer clientConn.stopHeartbeat()
@@ -161,6 +170,12 @@ func (h *WSRealtimeHandler) runRealtimeSession(
}
defer cancel()
+ // Restore governance and routing values from the transport middleware context.
+ // These include routing rule ID/name, virtual key ID/name, routing engines,
+ // routing engine logs, raw-storage header overrides, and other values set by
+ // HTTPTransportPreHook plugins (governance, prompts, etc.).
+ applyRealtimeMiddlewareValues(bifrostCtx, middlewareValues)
+
// Resolve ephemeral key mapping to restore virtual key context.
token := extractRealtimeBearerTokenFromHeader(auth.authorization)
if isRealtimeEphemeralToken(token) {
@@ -196,6 +211,15 @@ func (h *WSRealtimeHandler) runRealtimeSession(
// Resolve model alias so the provider receives the actual model identifier.
model = key.Aliases.Resolve(model)
+ // Compute raw storage flag from provider config + per-request header overrides.
+ // Normal inference computes this inside bifrost.executeRequest, which is bypassed
+ // for realtime WebSocket connections. Setting it on the session context ensures
+ // turn-level hooks can read it via shouldStoreRealtimeRawPayloads().
+ applyRealtimeRawStorageContext(bifrostCtx, h.client.ComputeRawStorageForProvider(bifrostCtx, providerKey))
+
+ // Tag the session context with transport type for downstream logging/metadata.
+ bifrostCtx.SetValue(schemas.BifrostContextKeyRealtimeTransport, "websocket")
+
wsURL := rtProvider.RealtimeWebSocketURL(key, model)
realtimeHeaders, headerErr := rtProvider.RealtimeHeaders(bifrostCtx, key)
if headerErr != nil {
@@ -293,6 +317,7 @@ func (h *WSRealtimeHandler) relayClientToRealtimeProvider(
}
}
+ sanitizeRealtimeSessionEventForProvider(event)
providerEvent, err := provider.ToProviderRealtimeEvent(event)
if err != nil {
if startsTurn {
@@ -315,6 +340,10 @@ func (h *WSRealtimeHandler) relayClientToRealtimeProvider(
continue
}
+ // Track session metadata only after provider translation succeeds. Rejected
+ // session.update events must not affect later turn logs.
+ updateRealtimeSessionFromEvent(session, event)
+
// Record tool output / input only after the event passed validation.
if !startsTurn {
if toolSummary != "" {
@@ -407,6 +436,8 @@ func (h *WSRealtimeHandler) relayRealtimeProviderToClient(
if event.Session != nil && event.Session.ID != "" {
session.SetProviderSessionID(event.Session.ID)
}
+ // Track session tool definitions from session.created/session.updated.
+ updateRealtimeSessionFromEvent(session, event)
if event.Delta != nil && provider.ShouldAccumulateRealtimeOutput(event.Type) {
session.AppendRealtimeOutputText(event.Delta.Text)
session.AppendRealtimeOutputText(event.Delta.Transcript)
@@ -486,25 +517,41 @@ func (h *WSRealtimeHandler) relayRealtimeProviderToClient(
}
}
-func resolveRealtimeTarget(path, modelParam, deploymentParam string) (schemas.ModelProvider, string, error) {
+func resolveRealtimeTarget(ctx *fasthttp.RequestCtx, config *lib.Config, path, modelParam, deploymentParam string) (schemas.ModelProvider, string, error) {
defaultProvider := realtimeDefaultProviderForPath(path)
+ var rawParam string
switch {
case strings.TrimSpace(modelParam) != "":
- provider, model := schemas.ParseModelString(strings.TrimSpace(modelParam), defaultProvider)
- if provider == "" || strings.TrimSpace(model) == "" {
- return "", "", errRealtimeModelFormat
- }
- return provider, strings.TrimSpace(model), nil
+ rawParam = strings.TrimSpace(modelParam)
case strings.TrimSpace(deploymentParam) != "":
- provider, model := schemas.ParseModelString(strings.TrimSpace(deploymentParam), defaultProvider)
- if provider == "" || strings.TrimSpace(model) == "" {
- return "", "", errRealtimeDeploymentFormat
- }
- return provider, strings.TrimSpace(model), nil
+ rawParam = strings.TrimSpace(deploymentParam)
default:
return "", "", errRealtimeModelRequired
}
+
+ provider, model := schemas.ParseModelString(rawParam, defaultProvider)
+ if strings.TrimSpace(model) == "" {
+ return "", "", errRealtimeModelFormat
+ }
+
+ // Model catalog auto-resolution: when no provider prefix is present and the
+ // path doesn't imply a default provider, look up the model catalog — same
+ // logic as resolveModelAndProvider in inference.go.
+ if provider == "" {
+ providers := config.GetProvidersForModel(model)
+ if len(providers) == 0 {
+ return "", "", errRealtimeModelFormat
+ }
+ ctx.SetUserValue(lib.FastHTTPUserValueModelCatalogResolution, &lib.ModelCatalogResolution{
+ Model: model,
+ ResolvedProvider: providers[0],
+ AllProviders: providers,
+ })
+ provider = providers[0]
+ }
+
+ return provider, model, nil
}
func realtimeDefaultProviderForPath(path string) schemas.ModelProvider {
@@ -678,3 +725,113 @@ func newRealtimeWireBifrostError(status int, code, message string) *schemas.Bifr
},
}
}
+
+// applyRealtimeMiddlewareValues copies governance and routing values from the transport
+// middleware BifrostContext (populated by HTTPTransportPreHook plugins) to the long-lived
+// WebSocket session context. Without this, values set by the governance plugin during
+// the HTTP upgrade (routing rule ID/name, VK ID/name, routing engines, routing engine
+// logs, raw-storage overrides) would be lost because the WebSocket handler creates a
+// fresh BifrostContext that outlives the fasthttp request.
+//
+// Values already explicitly set by createBifrostContextFromAuth (VK, parent request ID,
+// request headers, extra headers) are preserved — middleware values do not overwrite them
+// since createBifrostContextFromAuth runs first.
+// realtimeMiddlewareKeys lists the BifrostContext keys that TransportInterceptorMiddleware
+// copies from the governance plugin's context onto individual fasthttp UserValue slots.
+// We snapshot exactly these keys before the WebSocket upgrade so the long-lived session
+// has access to routing rule info, virtual key resolution, routing engine logs, etc.
+var realtimeMiddlewareKeys = []any{
+ schemas.BifrostContextKeyGovernanceVirtualKeyID,
+ schemas.BifrostContextKeyGovernanceVirtualKeyName,
+ schemas.BifrostContextKeyGovernanceRoutingRuleID,
+ schemas.BifrostContextKeyGovernanceRoutingRuleName,
+ schemas.BifrostContextKeyGovernanceCustomerID,
+ schemas.BifrostContextKeyGovernanceCustomerName,
+ schemas.BifrostContextKeyGovernanceTeamID,
+ schemas.BifrostContextKeyGovernanceTeamName,
+ schemas.BifrostContextKeyGovernanceBusinessUnitID,
+ schemas.BifrostContextKeyGovernanceBusinessUnitName,
+ schemas.BifrostContextKeyGovernanceIncludeOnlyKeys,
+ schemas.BifrostContextKeyGovernancePluginName,
+ schemas.BifrostContextKeyRoutingEnginesUsed,
+ schemas.BifrostContextKeyRoutingEngineLogs,
+ schemas.BifrostContextKeyShouldStoreRawInLogs,
+ schemas.BifrostContextKeyCaptureRawRequest,
+ schemas.BifrostContextKeyCaptureRawResponse,
+ schemas.BifrostContextKeyDropRawRequestFromClient,
+ schemas.BifrostContextKeyDropRawResponseFromClient,
+ schemas.BifrostContextKeyUserID,
+ schemas.BifrostContextKeyUserName,
+ schemas.BifrostContextKeyAPIKeyID,
+ schemas.BifrostContextKeyAPIKeyName,
+ schemas.BifrostContextKeySelectedKeyID,
+ schemas.BifrostContextKeySelectedKeyName,
+ schemas.BifrostContextKeyTraceID,
+ schemas.BifrostContextKeyTransportPluginLogs,
+}
+
+// snapshotRealtimeMiddlewareValues reads governance/routing values from the fasthttp
+// context's UserValue store. TransportInterceptorMiddleware copies them there as
+// individual key-value pairs (not inside a BifrostContext).
+//
+// It also processes FastHTTPUserValueModelCatalogResolution, which is set by
+// resolveRealtimeTarget when a bare model name is auto-resolved via the model
+// catalog. ConvertToBifrostContext normally handles this for regular inference,
+// but WebSocket handlers use createBifrostContextFromAuth instead, so we do the
+// same log/engine enrichment here.
+func snapshotRealtimeMiddlewareValues(ctx *fasthttp.RequestCtx) map[any]any {
+ result := make(map[any]any)
+ for _, key := range realtimeMiddlewareKeys {
+ if value := ctx.UserValue(key); value != nil {
+ result[key] = value
+ }
+ }
+
+ // Model catalog auto-resolution: replicate the routing engine log that
+ // ConvertToBifrostContext would normally emit (see lib/ctx.go).
+ if res, ok := ctx.UserValue(lib.FastHTTPUserValueModelCatalogResolution).(*lib.ModelCatalogResolution); ok && res != nil {
+ providerStrs := make([]string, len(res.AllProviders))
+ for i, p := range res.AllProviders {
+ providerStrs[i] = string(p)
+ }
+ logEntry := schemas.RoutingEngineLogEntry{
+ Engine: schemas.RoutingEngineModelCatalog,
+ Level: schemas.LogLevelInfo,
+ Message: fmt.Sprintf("No provider specified for model %s, found %d options in model catalog: [%s], selecting first: %s", res.Model, len(res.AllProviders), strings.Join(providerStrs, ", "), res.ResolvedProvider),
+ Timestamp: time.Now().UnixMilli(),
+ }
+ // Merge with any existing routing engine logs from governance middleware.
+ if existing, ok := result[schemas.BifrostContextKeyRoutingEngineLogs].([]schemas.RoutingEngineLogEntry); ok {
+ result[schemas.BifrostContextKeyRoutingEngineLogs] = append(existing, logEntry)
+ } else {
+ result[schemas.BifrostContextKeyRoutingEngineLogs] = []schemas.RoutingEngineLogEntry{logEntry}
+ }
+ if existing, ok := result[schemas.BifrostContextKeyRoutingEnginesUsed].([]string); ok {
+ result[schemas.BifrostContextKeyRoutingEnginesUsed] = append(existing, schemas.RoutingEngineModelCatalog)
+ } else {
+ result[schemas.BifrostContextKeyRoutingEnginesUsed] = []string{schemas.RoutingEngineModelCatalog}
+ }
+ }
+
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
+
+func applyRealtimeMiddlewareValues(ctx *schemas.BifrostContext, middlewareValues map[any]any) {
+ if ctx == nil || len(middlewareValues) == 0 {
+ return
+ }
+ for key, value := range middlewareValues {
+ if value == nil {
+ continue
+ }
+ // Skip values already set by createBifrostContextFromAuth to avoid overwriting
+ // auth-resolved values with stale middleware copies.
+ if existing := ctx.Value(key); existing != nil {
+ continue
+ }
+ ctx.SetValue(key, value)
+ }
+}
diff --git a/transports/bifrost-http/websocket/session.go b/transports/bifrost-http/websocket/session.go
index e10180280e..3d95977f83 100644
--- a/transports/bifrost-http/websocket/session.go
+++ b/transports/bifrost-http/websocket/session.go
@@ -1,6 +1,7 @@
package websocket
import (
+ "encoding/json"
"strings"
"sync"
"time"
@@ -47,6 +48,14 @@ type Session struct {
// attached to a persisted turn, so late transcript updates do not pollute later turns.
realtimeConsumedTurnItemIDs map[string]struct{}
+ // realtimeSessionTools holds the latest session tool definitions from
+ // session.created / session.updated / session.update events, so that
+ // each turn log can record which tools were available.
+ realtimeSessionTools json.RawMessage
+
+ // realtimeVoice holds the voice from the latest session configuration.
+ realtimeVoice string
+
// realtimeTurnHooks tracks the active turn-scoped plugin pipeline between
// response.create and response.done.
realtimeTurnHooks *RealtimeTurnPluginState
@@ -73,6 +82,8 @@ type RealtimeTurnPluginState struct {
RequestID string
StartedAt time.Time
PreHookValues map[any]any
+ TraceID string
+ RawStore bool
}
// NewSession creates a new session for a client WebSocket connection.
@@ -170,6 +181,42 @@ func (s *Session) ProviderSessionID() string {
return s.providerSessionID
}
+// SetRealtimeSessionTools updates the tracked session tool definitions.
+// Called when session.created, session.updated, or session.update events
+// carry a tools array.
+func (s *Session) SetRealtimeSessionTools(tools json.RawMessage) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return
+ }
+ s.realtimeSessionTools = tools
+}
+
+// RealtimeSessionTools returns the latest session tool definitions, or nil.
+func (s *Session) RealtimeSessionTools() json.RawMessage {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.realtimeSessionTools
+}
+
+// SetRealtimeVoice updates the tracked voice from session configuration.
+func (s *Session) SetRealtimeVoice(voice string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return
+ }
+ s.realtimeVoice = voice
+}
+
+// RealtimeVoice returns the current session voice, or empty string.
+func (s *Session) RealtimeVoice() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.realtimeVoice
+}
+
// AppendRealtimeOutputText appends provider output content for the current realtime turn.
func (s *Session) AppendRealtimeOutputText(text string) {
if text == "" {
@@ -177,6 +224,9 @@ func (s *Session) AppendRealtimeOutputText(text string) {
}
s.mu.Lock()
defer s.mu.Unlock()
+ if s.closed {
+ return
+ }
s.realtimeOutputText += text
}
@@ -236,11 +286,16 @@ func (s *Session) recordRealtimeTurnInput(itemID, role, summary, raw string) {
s.mu.Lock()
defer s.mu.Unlock()
+ if s.closed {
+ return
+ }
itemID = strings.TrimSpace(itemID)
if itemID != "" {
- if _, consumed := s.realtimeConsumedTurnItemIDs[itemID]; consumed {
- return
+ if s.realtimeConsumedTurnItemIDs != nil {
+ if _, consumed := s.realtimeConsumedTurnItemIDs[itemID]; consumed {
+ return
+ }
}
for idx := range s.realtimeTurnInputs {
if s.realtimeTurnInputs[idx].ItemID != itemID || s.realtimeTurnInputs[idx].Role != role {
@@ -250,15 +305,11 @@ func (s *Session) recordRealtimeTurnInput(itemID, role, summary, raw string) {
s.realtimeTurnInputs[idx].Summary = summary
}
if strings.TrimSpace(raw) != "" {
- existingRaw := strings.TrimSpace(s.realtimeTurnInputs[idx].Raw)
- incomingRaw := strings.TrimSpace(raw)
- switch {
- case existingRaw == "":
- s.realtimeTurnInputs[idx].Raw = raw
- case incomingRaw == "" || existingRaw == incomingRaw:
- default:
- s.realtimeTurnInputs[idx].Raw = existingRaw + "\n\n" + incomingRaw
- }
+ // Same item ID + role: replace raw with the latest event.
+ // Later events (e.g. conversation.item.created after
+ // conversation.item.create) carry the same or more complete
+ // data, so the newest version is always preferred.
+ s.realtimeTurnInputs[idx].Raw = raw
}
return
}
@@ -377,6 +428,15 @@ func (s *Session) Close() {
s.realtimeTurnHooks = nil
}
s.realtimeTurnBusy = false
+
+ // Release accumulated turn data so GC can reclaim memory even if a
+ // goroutine briefly holds a reference to this session after close.
+ s.realtimeTurnInputs = nil
+ s.realtimeConsumedTurnItemIDs = nil
+ s.realtimeSessionTools = nil
+ s.realtimeVoice = ""
+ s.realtimeOutputText = ""
+
if s.clientConn != nil {
_ = s.clientConn.Close()
}
From a50c8e9602b323be32fab090c0694a664b46da81 Mon Sep 17 00:00:00 2001
From: Dan Piths <85949566+danpiths@users.noreply.github.com>
Date: Thu, 14 May 2026 19:32:38 +0530
Subject: [PATCH 36/81] feat: improve realtime log detail UI with voice,
transport, and audio tokens (#3336)
## Summary
Improves the realtime log detail sheet view with voice, transport, session
metadata, and audio token breakdown for realtime turns.
## Changes
- **Realtime header badges**: Shows `WebSocket` or `WebRTC` transport badge and
voice name badge (e.g. "marin", "alloy") when viewing realtime turn logs
- **Voice hero stat**: The 5th hero stat card shows the configured voice and
transport source for realtime turns instead of "Tools available"
- **Realtime session details**: New section in Request Details shows Bifrost
session ID, provider session ID (both with copy buttons), transport source,
and trigger event type in mono font
- **Audio token breakdown**: For realtime turns, the Tokens section always shows
Input Text / Input Audio / Output Text / Output Audio token split with
defaults of 0, plus reasoning tokens when > 0
- **Turn source labels**: Raw `"ei"` / `"lm"` metadata values now display as
"Event Initiated" / "Language Model"
- **Metadata deduplication**: Realtime-specific keys are excluded from the
generic Metadata section to avoid showing them twice
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
1. Open the Bifrost UI and go to Logs
2. Open a realtime turn log entry
3. Verify: transport badge (WebSocket/WebRTC) and voice badge appear in the
header
4. Verify: hero stat shows voice name with transport as sub-label
5. Verify: "Realtime Session" section shows session IDs, source, and trigger
event
6. Verify: Tokens section shows audio/text token split
7. Verify: "More Details" metadata section doesn't duplicate realtime keys
## Screenshots/Recordings
N/A
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
N/A
## Security considerations
No security implications. UI-only changes reading existing metadata fields.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
---
.../workspace/logs/sheets/logDetailView.tsx | 229 ++++++++++++++++--
1 file changed, 208 insertions(+), 21 deletions(-)
diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx
index ec581d7810..e6bda63ae6 100644
--- a/ui/app/workspace/logs/sheets/logDetailView.tsx
+++ b/ui/app/workspace/logs/sheets/logDetailView.tsx
@@ -47,6 +47,41 @@ import SpeechView from "../views/speechView";
import TranscriptionView from "../views/transcriptionView";
import VideoView from "../views/videoView";
+const formatRealtimeTransport = (value: unknown): string => {
+ const transport = String(value ?? "").trim();
+ switch (transport.toLowerCase()) {
+ case "websocket":
+ return "WebSocket";
+ case "webrtc":
+ return "WebRTC";
+ default:
+ return transport || "Unknown";
+ }
+};
+
+const getRealtimeTransportBadgeClass = (value: unknown): string => {
+ switch (String(value ?? "").toLowerCase()) {
+ case "websocket":
+ return "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-600 dark:bg-indigo-950 dark:text-indigo-300";
+ case "webrtc":
+ return "border-purple-300 bg-purple-50 text-purple-700 dark:border-purple-600 dark:bg-purple-950 dark:text-purple-300";
+ default:
+ return "border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-300";
+ }
+};
+
+const formatRealtimeSource = (value: unknown): string => {
+ const source = String(value ?? "").trim();
+ switch (source.toLowerCase()) {
+ case "ei":
+ return "Event Initiated";
+ case "lm":
+ return "Language Model";
+ default:
+ return source || "Unknown";
+ }
+};
+
const extractResponsesText = (msg: ResponsesMessage): string => {
if (msg.type === "reasoning") {
const summaryText = (msg.summary ?? [])
@@ -503,6 +538,7 @@ export function LogDetailView({
const isContainer = isContainerOperation(log.object);
const showTabs = !isContainer;
const isPassthrough = isPassthroughOperation(log.object);
+ const isRealtimeTurn = log.object === "realtime.turn";
const passthroughParams = isPassthrough
? (log.params as {
method?: string;
@@ -651,6 +687,22 @@ export function LogDetailView({
Large Payload
)}
+ {isRealtimeTurn && log.metadata?.realtime_transport && (
+
+ {formatRealtimeTransport(log.metadata.realtime_transport)}
+
+ )}
+ {isRealtimeTurn && log.metadata?.realtime_voice && (
+
+ {log.metadata.realtime_voice}
+
+ )}
Request
@@ -736,11 +788,19 @@ export function LogDetailView({
}
hasRightBorder
/>
-
+ {isRealtimeTurn ? (
+
+ ) : (
+
+ )}
@@ -971,6 +1031,65 @@ export function LogDetailView({
>
)}
+ {isRealtimeTurn && (
+ <>
+ {log.metadata?.realtime_session_id && (
+
+ {log.metadata.realtime_session_id}
+
+
+ }
+ />
+ )}
+ {log.metadata?.provider_session_id && (
+
+ {log.metadata.provider_session_id}
+
+
+ }
+ />
+ )}
+ {log.metadata?.realtime_transport && (
+
+ )}
+ {log.metadata?.realtime_voice && (
+
+ )}
+ {log.metadata?.realtime_source && (
+
+ )}
+ {log.metadata?.realtime_event_type && (
+ {log.metadata.realtime_event_type}}
+ />
+ )}
+ >
+ )}
+
{passthroughParams && (
<>
{passthroughParams.method && }
@@ -1011,7 +1130,42 @@ export function LogDetailView({
label="Cost"
value={log.cost != null ? `$${parseFloat(log.cost.toFixed(6))}` : "-"}
/>
- {log.token_usage?.prompt_tokens_details && (
+ {isRealtimeTurn && (
+ <>
+
+
+
+
+ {(log.token_usage?.completion_tokens_details?.reasoning_tokens ?? 0) > 0 && (
+
+ )}
+ >
+ )}
+ {!isRealtimeTurn && log.token_usage?.prompt_tokens_details && (
<>
{log.token_usage.prompt_tokens_details.cached_read_tokens && (
)}
- {log.token_usage?.completion_tokens_details && (
+ {!isRealtimeTurn && log.token_usage?.completion_tokens_details && (
<>
{log.token_usage.completion_tokens_details.reasoning_tokens && (
>
)}
- {log.metadata && Object.keys(log.metadata).filter((k) => k !== "isAsyncRequest").length > 0 && (
- <>
-
-
-
-
- {Object.entries(log.metadata)
- .filter(([key]) => key !== "isAsyncRequest")
- .map(([key, value]) => (
-
- ))}
+ {log.metadata &&
+ Object.keys(log.metadata).filter((k) => {
+ if (k === "isAsyncRequest") return false;
+ if (
+ isRealtimeTurn &&
+ [
+ "realtime_session_id",
+ "provider_session_id",
+ "realtime_source",
+ "realtime_event_type",
+ "realtime_transport",
+ "realtime_voice",
+ "realtime",
+ ].includes(k)
+ )
+ return false;
+ return true;
+ }).length > 0 && (
+ <>
+
+
+
+
+ {Object.entries(log.metadata)
+ .filter(([key]) => {
+ if (key === "isAsyncRequest") return false;
+ if (
+ isRealtimeTurn &&
+ [
+ "realtime_session_id",
+ "provider_session_id",
+ "realtime_source",
+ "realtime_event_type",
+ "realtime_transport",
+ "realtime_voice",
+ "realtime",
+ ].includes(key)
+ )
+ return false;
+ return true;
+ })
+ .map(([key, value]) => (
+
+ ))}
+
-
- >
- )}
+ >
+ )}
>
)}
From 23f64ce0f5339315a9269380f113c5fb860442ef Mon Sep 17 00:00:00 2001
From: Tejas Ghatte <64637256+TejasGhatte@users.noreply.github.com>
Date: Thu, 14 May 2026 19:39:29 +0530
Subject: [PATCH 37/81] fix: max tokens and thinking budget value (#3498)
## Summary
Both Anthropic and Bedrock enforce a strict `budget_tokens < max_tokens` requirement for extended thinking. Previously, when reasoning effort was set to `"max"` (ratio = 1.0), the calculated budget could equal `max_tokens`, causing API rejections with the error `"max_tokens must be greater than thinking.budget_tokens"`. This PR adds a cap to ensure `budget_tokens` is always strictly less than `max_tokens`.
## Changes
- Added a guard in `GetBudgetTokensFromReasoningEffort` that clamps the computed budget to `maxTokens - 1` whenever it would otherwise equal or exceed `maxTokens`.
- Added `TestGetBudgetTokensFromReasoningEffort` in `utils_test.go` covering all effort levels, the error case where `minBudgetTokens > maxTokens`, and the specific `"max"` effort cap behavior.
- Added `TestBudgetTokensNeverExceedsMaxTokens` and `TestBudgetTokensMaxEffortCapsBelowMaxTokens` in the Anthropic provider test file to verify the invariant holds across all effort levels and a range of `max_tokens` values.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [x] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
```sh
go test ./core/providers/utils/...
go test ./core/providers/anthropic/...
```
Expected: all tests pass, including the new `TestBudgetTokensNeverExceedsMaxTokens` and `TestBudgetTokensMaxEffortCapsBelowMaxTokens` cases confirming that `budget_tokens` is always strictly less than `max_tokens` for every effort level.
## Breaking changes
- [ ] Yes
- [x] No
## Security considerations
None.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
core/providers/anthropic/utils_test.go | 53 +++++++++++++
core/providers/utils/utils.go | 5 ++
core/providers/utils/utils_test.go | 103 +++++++++++++++++++++++++
3 files changed, 161 insertions(+)
diff --git a/core/providers/anthropic/utils_test.go b/core/providers/anthropic/utils_test.go
index a5f1728d6c..cf7ce0373a 100644
--- a/core/providers/anthropic/utils_test.go
+++ b/core/providers/anthropic/utils_test.go
@@ -2828,3 +2828,56 @@ func TestIsClaudeCodeRequest(t *testing.T) {
})
}
}
+
+// TestBudgetTokensNeverExceedsMaxTokens verifies the strict budget_tokens < max_tokens
+// invariant required by both Anthropic and Bedrock for all effort levels.
+func TestBudgetTokensNeverExceedsMaxTokens(t *testing.T) {
+ const minBudget = MinimumReasoningMaxTokens // 1024
+ maxTokensValues := []int{1025, 4096, 16000, 32000, 64000, 128000}
+ efforts := []string{"minimal", "low", "medium", "high", "xhigh", "max"}
+
+ for _, maxTok := range maxTokensValues {
+ for _, effort := range efforts {
+ t.Run(fmt.Sprintf("effort=%s/maxTokens=%d", effort, maxTok), func(t *testing.T) {
+ budget, err := providerUtils.GetBudgetTokensFromReasoningEffort(effort, minBudget, maxTok)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if budget >= maxTok {
+ t.Errorf("effort=%q maxTokens=%d: budget_tokens=%d violates strict budget_tokens < max_tokens",
+ effort, maxTok, budget)
+ }
+ })
+ }
+ }
+}
+
+// TestBudgetTokensMaxEffortCapsBelowMaxTokens specifically pins the "max" effort
+// behavior: ratio=1.0 would produce budget==maxTokens without the cap, which both
+// Anthropic and Bedrock reject ("max_tokens must be greater than thinking.budget_tokens").
+func TestBudgetTokensMaxEffortCapsBelowMaxTokens(t *testing.T) {
+ const minBudget = MinimumReasoningMaxTokens
+
+ cases := []struct {
+ maxTokens int
+ wantBudget int
+ }{
+ {maxTokens: 16000, wantBudget: 15999},
+ {maxTokens: 32000, wantBudget: 31999},
+ {maxTokens: 64000, wantBudget: 63999},
+ {maxTokens: 128000, wantBudget: 127999},
+ }
+
+ for _, tc := range cases {
+ t.Run(fmt.Sprintf("maxTokens=%d", tc.maxTokens), func(t *testing.T) {
+ budget, err := providerUtils.GetBudgetTokensFromReasoningEffort("max", minBudget, tc.maxTokens)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if budget != tc.wantBudget {
+ t.Errorf("max effort with maxTokens=%d: got budget=%d, want %d",
+ tc.maxTokens, budget, tc.wantBudget)
+ }
+ })
+ }
+}
diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go
index b46100b0ab..fb31d93e5d 100644
--- a/core/providers/utils/utils.go
+++ b/core/providers/utils/utils.go
@@ -2729,6 +2729,11 @@ func GetBudgetTokensFromReasoningEffort(
budget := minBudgetTokens + int(ratio*float64(maxTokens-minBudgetTokens))
+ // Both Anthropic and Bedrock require budget_tokens < max_tokens (strict).
+ if budget >= maxTokens {
+ budget = maxTokens - 1
+ }
+
return budget, nil
}
diff --git a/core/providers/utils/utils_test.go b/core/providers/utils/utils_test.go
index 223d341509..a5d6ae7d0c 100644
--- a/core/providers/utils/utils_test.go
+++ b/core/providers/utils/utils_test.go
@@ -1439,3 +1439,106 @@ func TestShouldSendBackRawResponse(t *testing.T) {
})
}
}
+
+func TestGetBudgetTokensFromReasoningEffort(t *testing.T) {
+ const min = 1024
+ const max = 16000
+
+ tests := []struct {
+ effort string
+ wantErr bool
+ check func(t *testing.T, budget int)
+ }{
+ {
+ effort: "none",
+ check: func(t *testing.T, budget int) { assertEqual(t, 0, budget, "none effort") },
+ },
+ {
+ effort: "minimal",
+ check: func(t *testing.T, budget int) {
+ assertRange(t, min, max-1, budget, "minimal")
+ },
+ },
+ {
+ effort: "low",
+ check: func(t *testing.T, budget int) {
+ assertRange(t, min, max-1, budget, "low")
+ },
+ },
+ {
+ effort: "medium",
+ check: func(t *testing.T, budget int) {
+ assertRange(t, min, max-1, budget, "medium")
+ },
+ },
+ {
+ effort: "high",
+ check: func(t *testing.T, budget int) {
+ assertRange(t, min, max-1, budget, "high")
+ },
+ },
+ {
+ effort: "xhigh",
+ check: func(t *testing.T, budget int) {
+ assertRange(t, min, max-1, budget, "xhigh")
+ },
+ },
+ {
+ // "max" with ratio=1.0 would produce budget==maxTokens without the cap.
+ // Bedrock and Anthropic both require budget_tokens < max_tokens (strict).
+ effort: "max",
+ check: func(t *testing.T, budget int) {
+ if budget >= max {
+ t.Errorf("max effort: budget %d must be < maxTokens %d", budget, max)
+ }
+ assertEqual(t, max-1, budget, "max effort caps at maxTokens-1")
+ },
+ },
+ {
+ effort: "unknown",
+ check: func(t *testing.T, budget int) {
+ assertRange(t, min, max-1, budget, "unknown effort uses safe default")
+ },
+ },
+ {
+ // minBudgetTokens > maxTokens — always an error
+ effort: "high",
+ wantErr: true,
+ },
+ }
+
+ for i, tt := range tests {
+ t.Run(fmt.Sprintf("%d_%s", i, tt.effort), func(t *testing.T) {
+ maxTokens := max
+ minTokens := min
+ if tt.wantErr {
+ minTokens = max + 1
+ }
+ budget, err := GetBudgetTokensFromReasoningEffort(tt.effort, minTokens, maxTokens)
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("expected error when minBudgetTokens > maxTokens, got none")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ tt.check(t, budget)
+ })
+ }
+}
+
+func assertEqual(t *testing.T, want, got int, label string) {
+ t.Helper()
+ if got != want {
+ t.Errorf("%s: got %d, want %d", label, got, want)
+ }
+}
+
+func assertRange(t *testing.T, low, high, got int, label string) {
+ t.Helper()
+ if got < low || got > high {
+ t.Errorf("%s: got %d, want in [%d, %d]", label, got, low, high)
+ }
+}
From 4837b8cd344954a67768a654812632a732a92a39 Mon Sep 17 00:00:00 2001
From: Tejas Ghatte <64637256+TejasGhatte@users.noreply.github.com>
Date: Thu, 14 May 2026 19:48:01 +0530
Subject: [PATCH 38/81] fix: include blob fields of azure in batch responses
(#3469)
## Summary
Adds support for Azure Blob Storage URLs in batch API responses. When using Azure's batch API with blob storage for input/output, the response now includes the relevant blob URLs instead of only file IDs.
## Changes
- Added `InputBlob`, `OutputBlob`, and `ErrorBlob` optional fields to `OpenAIBatchResponse` to capture Azure-returned blob storage URLs
- Propagated these fields through `ToBifrostBatchCreateResponse` and `ToBifrostBatchRetrieveResponse` conversion methods
- Added the same `InputBlob`, `OutputBlob`, and `ErrorBlob` fields to `BifrostBatchCreateResponse` and `BifrostBatchRetrieveResponse` schemas so callers can access blob URLs from both create and retrieve operations
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [x] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
Submit or retrieve a batch job via the Azure OpenAI provider configured with blob storage input/output. The response should include populated `input_blob`, `output_blob`, and/or `error_blob` fields.
```sh
go test ./...
```
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
## Security considerations
Blob storage URLs may contain SAS tokens or other credentials. Ensure these values are not logged or exposed unintentionally in downstream systems.
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
core/providers/azure/azure.go | 168 +++++++++++++++++++++++++++++----
core/providers/openai/batch.go | 11 +++
core/schemas/batch.go | 10 ++
3 files changed, 171 insertions(+), 18 deletions(-)
diff --git a/core/providers/azure/azure.go b/core/providers/azure/azure.go
index 6e2caa8d29..a52feb8c40 100644
--- a/core/providers/azure/azure.go
+++ b/core/providers/azure/azure.go
@@ -33,6 +33,9 @@ const AzureAuthorizationTokenKey schemas.BifrostContextKey = "azure-authorizatio
// DefaultAzureScope is the default scope for Azure authentication.
const DefaultAzureScope = "https://cognitiveservices.azure.com/.default"
+// DefaultAzureSorageScope is the default scope for Azure storage.
+const DefaultAzureStorageScope = "https://storage.azure.com/.default"
+
// AzureProvider implements the Provider interface for Azure's API.
type AzureProvider struct {
logger schemas.Logger // Logger for provider operations
@@ -2188,7 +2191,6 @@ func (provider *AzureProvider) BatchCreate(ctx *schemas.BifrostContext, key sche
inputFileID = uploadResp.ID
}
-
// Validate that we have a file ID (either provided or uploaded)
if inputFileID == "" && request.InputBlob == nil {
return nil, providerUtils.NewBifrostOperationError("either input_file_id, input_blob, or requests array is required for Azure batch API", nil)
@@ -2614,10 +2616,128 @@ func (provider *AzureProvider) BatchDelete(ctx *schemas.BifrostContext, keys []s
return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchDeleteRequest, schemas.Azure)
}
-// BatchResults retrieves batch results from Azure OpenAI by trying each key until successful.
-// For Azure (like OpenAI), batch results are obtained by downloading the output_file_id.
+// getBlobStorageTokenForKey returns a Bearer token scoped to Azure Blob Storage for a single key.
+func (provider *AzureProvider) getBlobStorageTokenForKey(ctx *schemas.BifrostContext, key schemas.Key) (string, *schemas.BifrostError) {
+ if key.AzureKeyConfig == nil {
+ return "", nil
+ }
+ cfg := key.AzureKeyConfig
+
+ if cfg.ClientID != nil && cfg.ClientSecret != nil && cfg.TenantID != nil &&
+ cfg.ClientID.GetValue() != "" && cfg.ClientSecret.GetValue() != "" && cfg.TenantID.GetValue() != "" {
+ cred, err := provider.getOrCreateAuth(cfg.TenantID.GetValue(), cfg.ClientID.GetValue(), cfg.ClientSecret.GetValue())
+ if err != nil {
+ return "", providerUtils.NewProviderAPIError("failed to acquire Azure SP credentials for blob storage", err, http.StatusUnauthorized, nil, nil)
+ }
+ token, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{DefaultAzureStorageScope}})
+ if err != nil {
+ return "", providerUtils.NewProviderAPIError("failed to get Azure SP token for blob storage", err, http.StatusUnauthorized, nil, nil)
+ }
+ if token.Token == "" {
+ return "", providerUtils.NewProviderAPIError("Azure SP token for blob storage is empty", nil, http.StatusUnauthorized, nil, nil)
+ }
+ return token.Token, nil
+ }
+
+ // No SP credentials: try DefaultAzureCredential (managed identity, workload identity, env vars, etc.).
+ // Failure is silent — ambient auth simply not available for this key.
+ cred, err := provider.getOrCreateDefaultAzureCredential()
+ if err != nil {
+ return "", nil
+ }
+ token, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{DefaultAzureStorageScope}})
+ if err != nil || token.Token == "" {
+ return "", nil
+ }
+ return token.Token, nil
+}
+
+// isTrustedAzureBlobHost returns true if the host is a recognized Azure Blob Storage domain.
+func isTrustedAzureBlobHost(host string) bool {
+ return strings.HasSuffix(host, ".blob.core.windows.net") ||
+ strings.HasSuffix(host, ".dfs.core.windows.net")
+}
+
+// downloadBlobURL fetches the content of an Azure Blob Storage URL, trying each key's
+// credentials in sequence until a download succeeds — mirroring how FileContent loops keys.
+// SAS URLs (containing "sig=") are fetched in a single unauthenticated attempt since the
+// token in the URL already grants access.
+func (provider *AzureProvider) downloadBlobURL(ctx *schemas.BifrostContext, blobURL string, keys []schemas.Key) ([]byte, int64, *schemas.BifrostError) {
+ // Validate host for all blob URLs before any outbound request
+ parsed, parseErr := url.Parse(blobURL)
+ if parseErr != nil || parsed.Scheme != "https" || !isTrustedAzureBlobHost(parsed.Hostname()) {
+ return nil, 0, providerUtils.NewBifrostOperationError(
+ fmt.Sprintf("blob URL is not a trusted Azure Blob Storage endpoint: %s", blobURL), nil,
+ )
+ }
+
+ // SAS URL: credentials are embedded
+ if strings.Contains(blobURL, "sig=") {
+ return provider.doGetBlob(ctx, blobURL, "")
+ }
+
+ // Plain URL: try each key's storage credentials until one succeeds.
+ var lastErr *schemas.BifrostError
+ for _, key := range keys {
+ token, tokenErr := provider.getBlobStorageTokenForKey(ctx, key)
+ if tokenErr != nil {
+ lastErr = tokenErr
+ continue
+ }
+ if token == "" {
+ continue
+ }
+ content, latency, err := provider.doGetBlob(ctx, blobURL, token)
+ if err == nil {
+ return content, latency, nil
+ }
+ lastErr = err
+ }
+
+ if lastErr != nil {
+ return nil, 0, lastErr
+ }
+ return nil, 0, providerUtils.NewBifrostOperationError("no Azure keys available for blob download", nil)
+}
+
+// doGetBlob performs a single GET request to a blob URL, optionally adding a Bearer token.
+func (provider *AzureProvider) doGetBlob(ctx *schemas.BifrostContext, blobURL string, bearerToken string) ([]byte, int64, *schemas.BifrostError) {
+ req := fasthttp.AcquireRequest()
+ resp := fasthttp.AcquireResponse()
+ defer fasthttp.ReleaseRequest(req)
+ defer fasthttp.ReleaseResponse(resp)
+
+ req.SetRequestURI(blobURL)
+ req.Header.SetMethod(http.MethodGet)
+ if bearerToken != "" {
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearerToken))
+ req.Header.Set("x-ms-version", "2020-04-08")
+ }
+
+ latency, bifrostErr, wait := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp)
+ defer wait()
+ if bifrostErr != nil {
+ return nil, 0, bifrostErr
+ }
+
+ if resp.StatusCode() != fasthttp.StatusOK {
+ return nil, 0, providerUtils.NewBifrostOperationError(
+ fmt.Sprintf("blob download failed with status %d", resp.StatusCode()), nil,
+ )
+ }
+
+ body, err := providerUtils.CheckAndDecodeBody(resp)
+ if err != nil {
+ return nil, 0, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err)
+ }
+
+ return append([]byte(nil), body...), latency.Milliseconds(), nil
+}
+
+// BatchResults retrieves batch results from Azure OpenAI.
+// For file-based batches it downloads via output_file_id using the Files API.
+// For blob-based batches it fetches the output_blob URL directly using Azure Storage credentials.
func (provider *AzureProvider) BatchResults(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostBatchResultsRequest) (*schemas.BifrostBatchResultsResponse, *schemas.BifrostError) {
- // First, retrieve the batch to get the output_file_id (using all keys)
batchResp, bifrostErr := provider.BatchRetrieve(ctx, keys, &schemas.BifrostBatchRetrieveRequest{
Provider: request.Provider,
BatchID: request.BatchID,
@@ -2626,23 +2746,35 @@ func (provider *AzureProvider) BatchResults(ctx *schemas.BifrostContext, keys []
return nil, bifrostErr
}
- if batchResp.OutputFileID == nil || *batchResp.OutputFileID == "" {
- return nil, providerUtils.NewBifrostOperationError("batch results not available: output_file_id is empty (batch may not be completed)", nil)
- }
+ var content []byte
+ var latencyMs int64
- // Download the output file content (using all keys)
- fileContentResp, bifrostErr := provider.FileContent(ctx, keys, &schemas.BifrostFileContentRequest{
- Provider: request.Provider,
- FileID: *batchResp.OutputFileID,
- })
- if bifrostErr != nil {
- return nil, bifrostErr
+ switch {
+ case batchResp.OutputFileID != nil && *batchResp.OutputFileID != "":
+ fileContentResp, err := provider.FileContent(ctx, keys, &schemas.BifrostFileContentRequest{
+ Provider: request.Provider,
+ FileID: *batchResp.OutputFileID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ content = fileContentResp.Content
+ latencyMs = fileContentResp.ExtraFields.Latency
+
+ case batchResp.OutputBlob != nil && *batchResp.OutputBlob != "":
+ blobContent, blobLatency, err := provider.downloadBlobURL(ctx, *batchResp.OutputBlob, keys)
+ if err != nil {
+ return nil, err
+ }
+ content = blobContent
+ latencyMs = blobLatency
+
+ default:
+ return nil, providerUtils.NewBifrostOperationError("batch results not available: neither output_file_id nor output_blob is set (batch may not be completed yet)", nil)
}
- // Parse JSONL content - each line is a separate result
var results []schemas.BatchResultItem
-
- parseResult := providerUtils.ParseJSONL(fileContentResp.Content, func(line []byte) error {
+ parseResult := providerUtils.ParseJSONL(content, func(line []byte) error {
var resultItem schemas.BatchResultItem
if err := sonic.Unmarshal(line, &resultItem); err != nil {
provider.logger.Warn("failed to parse batch result line: %v", err)
@@ -2656,7 +2788,7 @@ func (provider *AzureProvider) BatchResults(ctx *schemas.BifrostContext, keys []
BatchID: request.BatchID,
Results: results,
ExtraFields: schemas.BifrostResponseExtraFields{
- Latency: fileContentResp.ExtraFields.Latency,
+ Latency: latencyMs,
},
}
diff --git a/core/providers/openai/batch.go b/core/providers/openai/batch.go
index 25b4f0a1b5..e4dc35b20d 100644
--- a/core/providers/openai/batch.go
+++ b/core/providers/openai/batch.go
@@ -42,6 +42,11 @@ type OpenAIBatchResponse struct {
CancelledAt *int64 `json:"cancelled_at,omitempty"`
RequestCounts *OpenAIBatchRequestCounts `json:"request_counts,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
+
+ // Azure Blob Storage URLs (returned by Azure when using blob storage input/output)
+ InputBlob *string `json:"input_blob,omitempty"`
+ OutputBlob *string `json:"output_blob,omitempty"`
+ ErrorBlob *string `json:"error_blob,omitempty"`
}
// OpenAIBatchRequestCounts represents the request counts for a batch.
@@ -97,6 +102,9 @@ func (r *OpenAIBatchResponse) ToBifrostBatchCreateResponse(latency time.Duration
CreatedAt: r.CreatedAt,
OutputFileID: r.OutputFileID,
ErrorFileID: r.ErrorFileID,
+ InputBlob: r.InputBlob,
+ OutputBlob: r.OutputBlob,
+ ErrorBlob: r.ErrorBlob,
ExtraFields: schemas.BifrostResponseExtraFields{
Latency: latency.Milliseconds(),
},
@@ -146,6 +154,9 @@ func (r *OpenAIBatchResponse) ToBifrostBatchRetrieveResponse(latency time.Durati
OutputFileID: r.OutputFileID,
ErrorFileID: r.ErrorFileID,
Errors: r.Errors,
+ InputBlob: r.InputBlob,
+ OutputBlob: r.OutputBlob,
+ ErrorBlob: r.ErrorBlob,
ExtraFields: schemas.BifrostResponseExtraFields{
Latency: latency.Milliseconds(),
},
diff --git a/core/schemas/batch.go b/core/schemas/batch.go
index dbd9fe580b..0f7bdd2f6c 100644
--- a/core/schemas/batch.go
+++ b/core/schemas/batch.go
@@ -127,6 +127,11 @@ type BifrostBatchCreateResponse struct {
// Gemini-specific (operation response)
OperationName *string `json:"operation_name,omitempty"`
+ // Azure-specific Blob Storage URLs (returned when using blob storage input/output)
+ InputBlob *string `json:"input_blob,omitempty"`
+ OutputBlob *string `json:"output_blob,omitempty"`
+ ErrorBlob *string `json:"error_blob,omitempty"`
+
ExtraFields BifrostResponseExtraFields `json:"extra_fields"`
}
@@ -214,6 +219,11 @@ type BifrostBatchRetrieveResponse struct {
Done *bool `json:"done,omitempty"`
Progress *int `json:"progress,omitempty"` // Percentage progress
+ // Azure-specific Blob Storage URLs (returned when using blob storage input/output)
+ InputBlob *string `json:"input_blob,omitempty"`
+ OutputBlob *string `json:"output_blob,omitempty"`
+ ErrorBlob *string `json:"error_blob,omitempty"`
+
ExtraFields BifrostResponseExtraFields `json:"extra_fields"`
}
From 63c465449fa6797915854c16da62694d4d4455f8 Mon Sep 17 00:00:00 2001
From: Suresh Chaudhary <83772622+impoiler@users.noreply.github.com>
Date: Thu, 14 May 2026 19:57:30 +0530
Subject: [PATCH 39/81] feat: add animated totals/averages to dashboard chart
card headers (#3499)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds aggregate totals and a weighted average latency metric to each dashboard chart card header, giving users an at-a-glance summary of the data currently displayed in each chart.
## Changes
- Replaced the single `headerActions` prop on `ChartCard` with separate `controls`, `legend`, and `total`/`totalLabel` props, and extracted a `Header` sub-component and `TotalChip` sub-component to render them in a structured layout (title row → total + controls row → legend row).
- Each dashboard tab (Overview, MCP, Provider Usage, Model Rankings) now computes its own aggregate value via `useMemo`:
- Request Volume, Token Usage, Model Usage, MCP Tool Calls, MCP Top Tools, Provider Token Usage → sum of counts/tokens across all buckets.
- Cost (MCP, Overview, Provider) → sum of `total_cost` across buckets, respecting the active model/provider filter.
- Latency (Overview, Provider) → request-count-weighted average of `avg_latency` across buckets.
- Totals are rendered using `NumberFlow` with `COMPACT_NUMBER_FORMAT` for counts, currency formatting for costs, and a fixed two-decimal `ms` suffix for latency averages.
- Y-axis tick formatters across `logVolumeChart`, `mcpVolumeChart`, `mcpTopToolsChart`, and `modelUsageChart` switched from `v.toLocaleString()` to the shared `formatTokens` utility, and axis widths were normalised to 44px.
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```sh
cd ui
pnpm i
pnpm build
```
1. Open the dashboard and navigate to the Overview, MCP, Provider Usage, and Model Rankings tabs.
2. Verify each chart card header shows a labelled total (e.g. "Total 1.2k", "Total $0.04", "Avg 312.50ms").
3. Change the model or provider filter on Cost / Model Usage / Provider charts and confirm the displayed total updates to reflect the filtered data.
4. Toggle between bar and area chart types and confirm the total remains consistent.
5. Verify the loading skeleton state still renders correctly without the total chip.
## Screenshots/Recordings
[Screen Recording 2026-05-14 at 6.52.34 PM.mov (uploaded via Graphite) ](https://app.graphite.com/user-attachments/video/24c91fe3-6582-42b0-8d98-1aa46f80607c.mov)
## Breaking changes
- [x] No
The `headerActions` prop on `ChartCard` has been removed and replaced with `controls`, `legend`, `total`, and `totalLabel`. Any consumers outside this PR that pass `headerActions` will need to be updated.
## Related issues
## Security considerations
None.
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
.../dashboard/components/charts/chartCard.tsx | 70 ++-
.../components/charts/logVolumeChart.tsx | 10 +-
.../components/charts/mcpTopToolsChart.tsx | 4 +-
.../components/charts/mcpVolumeChart.tsx | 10 +-
.../components/charts/modelUsageChart.tsx | 9 +-
.../workspace/dashboard/components/mcpTab.tsx | 91 ++--
.../dashboard/components/modelRankingsTab.tsx | 22 +-
.../dashboard/components/overviewTab.tsx | 419 ++++++++--------
.../dashboard/components/providerUsageTab.tsx | 448 ++++++++++--------
9 files changed, 629 insertions(+), 454 deletions(-)
diff --git a/ui/app/workspace/dashboard/components/charts/chartCard.tsx b/ui/app/workspace/dashboard/components/charts/chartCard.tsx
index b9f5f074a2..b93974db2d 100644
--- a/ui/app/workspace/dashboard/components/charts/chartCard.tsx
+++ b/ui/app/workspace/dashboard/components/charts/chartCard.tsx
@@ -6,24 +6,65 @@ import type { ReactNode } from "react";
interface ChartCardProps {
title: string;
children: ReactNode;
- headerActions?: ReactNode;
+ controls?: ReactNode;
+ legend?: ReactNode;
loading?: boolean;
testId?: string;
className?: string;
+ total?: ReactNode;
+ totalLabel?: string;
}
-export function ChartCard({ title, children, headerActions, loading, testId, className }: ChartCardProps) {
+function TotalChip({ total, totalLabel, testId }: { total: ReactNode; totalLabel?: string; testId?: string }) {
+ return (
+
+ {totalLabel && {totalLabel} }
+ {total}
+
+ );
+}
+
+function Header({
+ title,
+ controls,
+ legend,
+ total,
+ totalLabel,
+ testId,
+}: {
+ title: string;
+ controls?: ReactNode;
+ legend?: ReactNode;
+ total?: ReactNode;
+ totalLabel?: string;
+ testId?: string;
+}) {
+ const hasTotal = total !== undefined && total !== null;
+ const hasActionRow = hasTotal || controls;
+ return (
+
+
+ {title}
+
+ {hasActionRow && (
+
+ {hasTotal ?
:
}
+ {controls &&
{controls}
}
+
+ )}
+ {legend &&
{legend}
}
+
+ );
+}
+
+export function ChartCard({ title, children, controls, legend, loading, testId, className, total, totalLabel }: ChartCardProps) {
if (loading) {
return (
-
-
{title}
- {headerActions && (
-
- {headerActions}
-
- )}
-
+
@@ -33,14 +74,7 @@ export function ChartCard({ title, children, headerActions, loading, testId, cla
return (
-
-
{title}
- {headerActions && (
-
- {headerActions}
-
- )}
-
+
{children}
);
diff --git a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
index f0c78c1599..dc5238c87a 100644
--- a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
@@ -1,7 +1,7 @@
import type { LogsHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils";
+import { CHART_COLORS, formatFullTimestamp, formatTimestamp, formatTokens } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
@@ -97,8 +97,8 @@ function LogVolumeChartImpl({ data, chartType, startTime, endTime }: LogVolumeCh
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- width={56}
- tickFormatter={(v) => v.toLocaleString()}
+ width={44}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -139,8 +139,8 @@ function LogVolumeChartImpl({ data, chartType, startTime, endTime }: LogVolumeCh
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- width={56}
- tickFormatter={(v) => v.toLocaleString()}
+ width={44}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
index a221c70ea4..4058c106de 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
@@ -1,7 +1,7 @@
import type { MCPTopToolsResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { formatCost, getModelColor } from "../../utils/chartUtils";
+import { formatCost, formatTokens, getModelColor } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
interface MCPTopToolsChartProps {
@@ -54,7 +54,7 @@ function MCPTopToolsChartImpl({ data }: MCPTopToolsChartProps) {
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- tickFormatter={(v) => v.toLocaleString()}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
index 515cd112a2..73e9935267 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
@@ -1,7 +1,7 @@
import type { MCPHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils";
+import { CHART_COLORS, formatFullTimestamp, formatTimestamp, formatTokens } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
@@ -87,8 +87,8 @@ function MCPVolumeChartImpl({ data, chartType, startTime, endTime }: MCPVolumeCh
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- width={40}
- tickFormatter={(v) => v.toLocaleString()}
+ width={44}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -129,8 +129,8 @@ function MCPVolumeChartImpl({ data, chartType, startTime, endTime }: MCPVolumeCh
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- width={40}
- tickFormatter={(v) => v.toLocaleString()}
+ width={44}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx b/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
index db171a50dd..644b3380a0 100644
--- a/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
@@ -5,6 +5,7 @@ import {
CHART_COLORS,
formatFullTimestamp,
formatTimestamp,
+ formatTokens,
getModelColor,
OTHER_SERIES_COLOR,
OTHER_SERIES_KEY,
@@ -161,8 +162,8 @@ function ModelUsageChartImpl({ data, chartType, startTime, endTime, selectedMode
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- width={40}
- tickFormatter={(v) => v.toLocaleString()}
+ width={44}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -220,8 +221,8 @@ function ModelUsageChartImpl({ data, chartType, startTime, endTime, selectedMode
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- width={40}
- tickFormatter={(v) => v.toLocaleString()}
+ width={44}
+ tickFormatter={formatTokens}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/mcpTab.tsx b/ui/app/workspace/dashboard/components/mcpTab.tsx
index 489442fdd4..9d9356be90 100644
--- a/ui/app/workspace/dashboard/components/mcpTab.tsx
+++ b/ui/app/workspace/dashboard/components/mcpTab.tsx
@@ -1,6 +1,8 @@
import type { MCPCostHistogramResponse, MCPHistogramResponse, MCPTopToolsResponse } from "@/lib/types/logs";
-import { memo } from "react";
-import { CHART_COLORS, CHART_HEADER_ACTIONS_CLASS, CHART_HEADER_CONTROLS_CLASS, CHART_HEADER_LEGEND_CLASS } from "../utils/chartUtils";
+import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
+import NumberFlow from "@number-flow/react";
+import { memo, useMemo } from "react";
+import { CHART_COLORS, CHART_HEADER_LEGEND_CLASS } from "../utils/chartUtils";
import { ChartCard } from "./charts/chartCard";
import { type ChartType, ChartTypeToggle } from "./charts/chartTypeToggle";
import { MCPCostChart } from "./charts/mcpCostChart";
@@ -45,6 +47,21 @@ function MCPTabImpl({
onMcpVolumeChartToggle,
onMcpCostChartToggle,
}: MCPTabProps) {
+ const mcpVolumeTotal = useMemo(() => {
+ if (!mcpHistogramData?.buckets) return null;
+ return mcpHistogramData.buckets.reduce((sum, b) => sum + (b.count ?? 0), 0);
+ }, [mcpHistogramData]);
+
+ const mcpCostTotal = useMemo(() => {
+ if (!mcpCostData?.buckets) return null;
+ return mcpCostData.buckets.reduce((sum, b) => sum + (b.total_cost ?? 0), 0);
+ }, [mcpCostData]);
+
+ const mcpTopToolsTotal = useMemo(() => {
+ if (!mcpTopToolsData?.tools) return null;
+ return mcpTopToolsData.tools.reduce((sum, t) => sum + (t.count ?? 0), 0);
+ }, [mcpTopToolsData]);
+
return (
{/* MCP Tool Calls Volume */}
@@ -52,27 +69,27 @@ function MCPTabImpl({
title="MCP Tool Calls"
loading={loadingMcpHistogram}
testId="chart-mcp-volume"
- headerActions={
-
-
-
-
- Success
-
-
-
- Error
-
-
-
-
-
+ totalLabel="Total"
+ total={mcpVolumeTotal !== null ?
: undefined}
+ legend={
+
+
+
+ Success
+
+
+
+ Error
+
}
+ controls={
+
+ }
>
@@ -82,25 +99,33 @@ function MCPTabImpl({
title="MCP Cost"
loading={loadingMcpCost}
testId="chart-mcp-cost"
- headerActions={
-
-
-
-
- Cost
-
-
-
-
-
+ totalLabel="Total"
+ total={
+ mcpCostTotal !== null ? (
+
+ ) : undefined
+ }
+ legend={
+
+
+
+ Cost
+
}
+ controls={
}
>
{/* Top 10 MCP Tools */}
-
+ : undefined}
+ >
diff --git a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
index 9a6b08bc33..f6fedcfea2 100644
--- a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
+++ b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
@@ -4,6 +4,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import ProviderIcons, { type ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import type { ModelHistogramResponse, ModelRankingEntry, ModelRankingsResponse } from "@/lib/types/logs";
import { formatCompactNumber as formatNumber } from "@/lib/utils/governance";
+import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
+import NumberFlow from "@number-flow/react";
import { ArrowDown, ArrowUp, ArrowUpDown, Minus } from "lucide-react";
import { memo, useCallback, useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
@@ -193,6 +195,17 @@ function TopModelsChart({
return { chartData: processed, displayModels: models };
}, [modelData]);
+ const grandTotal = useMemo(() => {
+ if (!modelData?.buckets) return null;
+ let sum = 0;
+ const models = modelData.models || [];
+ for (const b of modelData.buckets) {
+ if (!b.by_model) continue;
+ for (const m of models) sum += b.by_model[m]?.total ?? 0;
+ }
+ return sum;
+ }, [modelData]);
+
// Compute totals per model for the ranked legend (aggregate across providers)
const modelTotals = useMemo(() => {
if (!rankingsData?.rankings) return [];
@@ -213,7 +226,14 @@ function TopModelsChart({
}, [rankingsData, displayModels]);
return (
-
+ : undefined}
+ >
{chartData.length > 0 ? (
diff --git a/ui/app/workspace/dashboard/components/overviewTab.tsx b/ui/app/workspace/dashboard/components/overviewTab.tsx
index 0326d93f41..ff59408e0d 100644
--- a/ui/app/workspace/dashboard/components/overviewTab.tsx
+++ b/ui/app/workspace/dashboard/components/overviewTab.tsx
@@ -1,5 +1,6 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { memo } from "react";
+import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
+import NumberFlow from "@number-flow/react";
import type {
CostHistogramResponse,
LatencyHistogramResponse,
@@ -8,20 +9,14 @@ import type {
ModelHistogramResponse,
TokenHistogramResponse,
} from "@/lib/types/logs";
-import {
- CHART_COLORS,
- CHART_HEADER_ACTIONS_CLASS,
- CHART_HEADER_CONTROLS_CLASS,
- CHART_HEADER_LEGEND_CLASS,
- LATENCY_COLORS,
- getModelColor,
-} from "../utils/chartUtils";
-import ExternalCacheTokenMeterChart from "./charts/externalCacheTokenMeterChart";
-import LocalCacheTokenMeterChart from "./charts/localCacheTokenMeterChart";
+import { memo, useMemo } from "react";
+import { CHART_COLORS, CHART_HEADER_LEGEND_CLASS, LATENCY_COLORS, getModelColor } from "../utils/chartUtils";
import { ChartCard } from "./charts/chartCard";
import { type ChartType, ChartTypeToggle } from "./charts/chartTypeToggle";
import { CostChart } from "./charts/costChart";
+import ExternalCacheTokenMeterChart from "./charts/externalCacheTokenMeterChart";
import { LatencyChart } from "./charts/latencyChart";
+import LocalCacheTokenMeterChart from "./charts/localCacheTokenMeterChart";
import { LogVolumeChart } from "./charts/logVolumeChart";
import { ModelFilterSelect } from "./charts/modelFilterSelect";
import { ModelUsageChart } from "./charts/modelUsageChart";
@@ -109,6 +104,50 @@ function OverviewTabImpl({
onCostModelChange,
onUsageModelChange,
}: OverviewTabProps) {
+ const volumeTotal = useMemo(() => {
+ if (!histogramData?.buckets) return null;
+ return histogramData.buckets.reduce((sum, b) => sum + (b.count ?? 0), 0);
+ }, [histogramData]);
+
+ const tokenTotal = useMemo(() => {
+ if (!tokenData?.buckets) return null;
+ return tokenData.buckets.reduce((sum, b) => sum + (b.total_tokens ?? 0), 0);
+ }, [tokenData]);
+
+ const costTotal = useMemo(() => {
+ if (!costData?.buckets) return null;
+ if (costModel === "all") {
+ return costData.buckets.reduce((sum, b) => sum + (b.total_cost ?? 0), 0);
+ }
+ return costData.buckets.reduce((sum, b) => sum + (b.by_model?.[costModel] ?? 0), 0);
+ }, [costData, costModel]);
+
+ const modelUsageTotal = useMemo(() => {
+ if (!modelData?.buckets) return null;
+ if (usageModel === "all") {
+ let sum = 0;
+ for (const b of modelData.buckets) {
+ if (!b.by_model) continue;
+ for (const m of modelData.models) sum += b.by_model[m]?.total ?? 0;
+ }
+ return sum;
+ }
+ return modelData.buckets.reduce((sum, b) => sum + (b.by_model?.[usageModel]?.total ?? 0), 0);
+ }, [modelData, usageModel]);
+
+ const latencyAvg = useMemo(() => {
+ if (!latencyData?.buckets || latencyData.buckets.length === 0) return null;
+ let weighted = 0;
+ let count = 0;
+ for (const b of latencyData.buckets) {
+ const reqs = b.total_requests ?? 0;
+ if (reqs === 0) continue;
+ weighted += (b.avg_latency ?? 0) * reqs;
+ count += reqs;
+ }
+ return count > 0 ? weighted / count : null;
+ }, [latencyData]);
+
return (
<>
{/* Charts Grid */}
@@ -118,23 +157,21 @@ function OverviewTabImpl({
title="Request Volume"
loading={loadingHistogram}
testId="chart-log-volume"
- headerActions={
-
-
-
-
- Success
-
-
-
- Error
-
-
-
-
-
+ totalLabel="Total"
+ total={volumeTotal !== null ?
: undefined}
+ legend={
+
+
+
+ Success
+
+
+
+ Error
+
}
+ controls={
}
>
@@ -144,27 +181,25 @@ function OverviewTabImpl({
title="Token Usage"
loading={loadingTokens}
testId="chart-token-usage"
- headerActions={
-
-
-
-
- Input
-
-
-
- Output
-
-
-
- Cached
-
-
-
-
-
+ totalLabel="Total"
+ total={tokenTotal !== null ?
: undefined}
+ legend={
+
+
+
+ Input
+
+
+
+ Output
+
+
+
+ Cached
+
}
+ controls={
}
>
@@ -184,70 +219,76 @@ function OverviewTabImpl({
title="Cost"
loading={loadingCost}
testId="chart-cost-total"
- headerActions={
-
-
- {costModel === "all" ? (
- costModels.length > 0 && (
- <>
+ totalLabel="Total"
+ total={
+ costTotal !== null ? (
+
+ ) : undefined
+ }
+ legend={
+
+ {costModel === "all" ? (
+ costModels.length > 0 && (
+ <>
+
+
+
+
+ {costModels[0]}
+
+
+ {costModels[0]}
+
+ {costModels.length > 1 && (
-
-
- {costModels[0]}
+
+ +{costModels.length - 1} more
- {costModels[0]}
+
+
+ {costModels.slice(1).map((model, idx) => (
+
+
+ {model}
+
+ ))}
+
+
- {costModels.length > 1 && (
-
-
-
- +{costModels.length - 1} more
-
-
-
-
- {costModels.slice(1).map((model, idx) => (
-
-
- {model}
-
- ))}
-
-
-
- )}
- >
- )
- ) : (
-
-
-
-
- {costModel}
-
-
- {costModel}
-
- )}
-
-
-
-
-
+ )}
+ >
+ )
+ ) : (
+
+
+
+
+ {costModel}
+
+
+ {costModel}
+
+ )}
}
+ controls={
+ <>
+
+
+ >
+ }
>
@@ -257,71 +298,73 @@ function OverviewTabImpl({
title="Model Usage"
loading={loadingModels}
testId="chart-model-usage"
- headerActions={
-
-
- {usageModel === "all" ? (
- usageModels.length > 0 && (
- <>
+ totalLabel="Total"
+ total={modelUsageTotal !== null ?
: undefined}
+ legend={
+
+ {usageModel === "all" ? (
+ usageModels.length > 0 && (
+ <>
+
+
+
+
+ {usageModels[0]}
+
+
+ {usageModels[0]}
+
+ {usageModels.length > 1 && (
-
-
- {usageModels[0]}
+
+ +{usageModels.length - 1} more
- {usageModels[0]}
+
+
+ {usageModels.slice(1).map((model, idx) => (
+
+
+ {model}
+
+ ))}
+
+
- {usageModels.length > 1 && (
-
-
-
- +{usageModels.length - 1} more
-
-
-
-
- {usageModels.slice(1).map((model, idx) => (
-
-
- {model}
-
- ))}
-
-
-
- )}
- >
- )
- ) : (
- <>
-
-
- Success
-
-
-
- Error
-
+ )}
>
- )}
-
-
-
-
-
+ )
+ ) : (
+ <>
+
+
+ Success
+
+
+
+ Error
+
+ >
+ )}
}
+ controls={
+ <>
+
+
+ >
+ }
>
@@ -331,35 +374,39 @@ function OverviewTabImpl({
title="Latency"
loading={loadingLatency}
testId="chart-latency"
- headerActions={
-
-
-
-
- Avg
-
-
-
- P90
-
-
-
- P95
-
-
-
- P99
-
-
-
-
-
+ totalLabel="Avg"
+ total={
+ latencyAvg !== null ? (
+
+ ) : undefined
+ }
+ legend={
+
+
+
+ Avg
+
+
+
+ P90
+
+
+
+ P95
+
+
+
+ P99
+
}
+ controls={
+
+ }
>
diff --git a/ui/app/workspace/dashboard/components/providerUsageTab.tsx b/ui/app/workspace/dashboard/components/providerUsageTab.tsx
index 64a36f84e4..c19df8868b 100644
--- a/ui/app/workspace/dashboard/components/providerUsageTab.tsx
+++ b/ui/app/workspace/dashboard/components/providerUsageTab.tsx
@@ -1,14 +1,9 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { memo } from "react";
+import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
+import NumberFlow from "@number-flow/react";
+import { memo, useMemo } from "react";
import type { ProviderCostHistogramResponse, ProviderLatencyHistogramResponse, ProviderTokenHistogramResponse } from "@/lib/types/logs";
-import {
- CHART_COLORS,
- CHART_HEADER_ACTIONS_CLASS,
- CHART_HEADER_CONTROLS_CLASS,
- CHART_HEADER_LEGEND_CLASS,
- LATENCY_COLORS,
- getModelColor,
-} from "../utils/chartUtils";
+import { CHART_COLORS, CHART_HEADER_LEGEND_CLASS, LATENCY_COLORS, getModelColor } from "../utils/chartUtils";
import { ChartCard } from "./charts/chartCard";
import { type ChartType, ChartTypeToggle } from "./charts/chartTypeToggle";
import { ProviderCostChart } from "./charts/providerCostChart";
@@ -84,6 +79,45 @@ function ProviderUsageTabImpl({
onProviderTokenProviderChange,
onProviderLatencyProviderChange,
}: ProviderUsageTabProps) {
+ const providerCostTotal = useMemo(() => {
+ if (!providerCostData?.buckets) return null;
+ if (providerCostProvider === "all") {
+ return providerCostData.buckets.reduce((sum, b) => sum + (b.total_cost ?? 0), 0);
+ }
+ return providerCostData.buckets.reduce((sum, b) => sum + (b.by_provider?.[providerCostProvider] ?? 0), 0);
+ }, [providerCostData, providerCostProvider]);
+
+ const providerTokenTotal = useMemo(() => {
+ if (!providerTokenData?.buckets) return null;
+ let sum = 0;
+ for (const b of providerTokenData.buckets) {
+ if (!b.by_provider) continue;
+ if (providerTokenProvider === "all") {
+ for (const p of providerTokenData.providers) sum += b.by_provider[p]?.total_tokens ?? 0;
+ } else {
+ sum += b.by_provider[providerTokenProvider]?.total_tokens ?? 0;
+ }
+ }
+ return sum;
+ }, [providerTokenData, providerTokenProvider]);
+
+ const providerLatencyAvg = useMemo(() => {
+ if (!providerLatencyData?.buckets) return null;
+ let weighted = 0;
+ let count = 0;
+ for (const b of providerLatencyData.buckets) {
+ if (!b.by_provider) continue;
+ const providers = providerLatencyProvider === "all" ? providerLatencyData.providers : [providerLatencyProvider];
+ for (const p of providers) {
+ const s = b.by_provider[p];
+ if (!s || !s.total_requests) continue;
+ weighted += (s.avg_latency ?? 0) * s.total_requests;
+ count += s.total_requests;
+ }
+ }
+ return count > 0 ? weighted / count : null;
+ }, [providerLatencyData, providerLatencyProvider]);
+
return (
{/* Provider Cost Chart */}
@@ -91,73 +125,79 @@ function ProviderUsageTabImpl({
title="Provider Cost"
loading={loadingProviderCost}
testId="chart-provider-cost"
- headerActions={
-
-
- {providerCostProvider === "all" ? (
- providerCostProviders.length > 0 && (
- <>
+ totalLabel="Total"
+ total={
+ providerCostTotal !== null ? (
+
+ ) : undefined
+ }
+ legend={
+
+ {providerCostProvider === "all" ? (
+ providerCostProviders.length > 0 && (
+ <>
+
+
+
+
+ {providerCostProviders[0]}
+
+
+ {providerCostProviders[0]}
+
+ {providerCostProviders.length > 1 && (
-
-
- {providerCostProviders[0]}
-
+
+ +{providerCostProviders.length - 1} more
+
- {providerCostProviders[0]}
+
+
+ {providerCostProviders.slice(1).map((provider, idx) => (
+
+
+ {provider}
+
+ ))}
+
+
- {providerCostProviders.length > 1 && (
-
-
-
- +{providerCostProviders.length - 1} more
-
-
-
-
- {providerCostProviders.slice(1).map((provider, idx) => (
-
-
- {provider}
-
- ))}
-
-
-
- )}
- >
- )
- ) : (
-
-
-
-
- {providerCostProvider}
-
-
- {providerCostProvider}
-
- )}
-
-
+ )}
+ >
+ )
+ ) : (
+
+
+
+
+ {providerCostProvider}
+
+
+ {providerCostProvider}
+
+ )}
}
+ controls={
+ <>
+
+
+ >
+ }
>
-
- {providerTokenProvider === "all" ? (
- providerTokenProviders.length > 0 && (
- <>
+ totalLabel="Total"
+ total={providerTokenTotal !== null ?
: undefined}
+ legend={
+
+ {providerTokenProvider === "all" ? (
+ providerTokenProviders.length > 0 && (
+ <>
+
+
+
+
+ {providerTokenProviders[0]}
+
+
+ {providerTokenProviders[0]}
+
+ {providerTokenProviders.length > 1 && (
-
-
- {providerTokenProviders[0]}
-
+
+ +{providerTokenProviders.length - 1} more
+
- {providerTokenProviders[0]}
+
+
+ {providerTokenProviders.slice(1).map((provider, idx) => (
+
+
+ {provider}
+
+ ))}
+
+
- {providerTokenProviders.length > 1 && (
-
-
-
- +{providerTokenProviders.length - 1} more
-
-
-
-
- {providerTokenProviders.slice(1).map((provider, idx) => (
-
-
- {provider}
-
- ))}
-
-
-
- )}
- >
- )
- ) : (
- <>
-
-
- Input
-
-
-
- Output
-
+ )}
>
- )}
-
-
+ )
+ ) : (
+ <>
+
+
+ Input
+
+
+
+ Output
+
+ >
+ )}
}
+ controls={
+ <>
+
+
+ >
+ }
>
-
- {providerLatencyProvider === "all" ? (
- providerLatencyProviders.length > 0 && (
- <>
+ totalLabel="Avg"
+ total={
+ providerLatencyAvg !== null ? (
+
+ ) : undefined
+ }
+ legend={
+
+ {providerLatencyProvider === "all" ? (
+ providerLatencyProviders.length > 0 && (
+ <>
+
+
+
+
+ {providerLatencyProviders[0]}
+
+
+ {providerLatencyProviders[0]}
+
+ {providerLatencyProviders.length > 1 && (
-
-
- {providerLatencyProviders[0]}
-
+
+ +{providerLatencyProviders.length - 1} more
+
- {providerLatencyProviders[0]}
+
+
+ {providerLatencyProviders.slice(1).map((provider, idx) => (
+
+
+ {provider}
+
+ ))}
+
+
- {providerLatencyProviders.length > 1 && (
-
-
-
- +{providerLatencyProviders.length - 1} more
-
-
-
-
- {providerLatencyProviders.slice(1).map((provider, idx) => (
-
-
- {provider}
-
- ))}
-
-
-
- )}
- >
- )
- ) : (
- <>
-
-
- Avg
-
-
-
- P90
-
-
-
- P95
-
-
-
- P99
-
+ )}
>
- )}
-
-
+ )
+ ) : (
+ <>
+
+
+ Avg
+
+
+
+ P90
+
+
+
+ P95
+
+
+
+ P99
+
+ >
+ )}
}
+ controls={
+ <>
+
+
+ >
+ }
>
Date: Thu, 14 May 2026 20:00:46 +0530
Subject: [PATCH 40/81] fix: set idle stream timeouts in streaming requests
(#3495)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
`SetStreamIdleTimeoutIfEmpty` was being called in the provider-level stream methods before delegating to the shared `Handle*` functions. This meant the timeout was applied at the wrong layer — before the shared handler had a chance to run — and the exported `Handle*` functions had no way to apply the timeout themselves when called directly (e.g., from Vertex or Azure which reuse OpenAI/Anthropic handlers). This moves the call into each `Handle*` function so the timeout is consistently applied regardless of the call path.
## Changes
- Removed `providerUtils.SetStreamIdleTimeoutIfEmpty` calls from provider-level stream methods (`ChatCompletionStream`, `TextCompletionStream`, `ResponsesStream`, `SpeechStream`, `TranscriptionStream`, `ImageGenerationStream`, `ImageEditStream`)
- Added `streamIdleTimeoutInSeconds int` as an explicit parameter to all exported `Handle*` streaming functions across OpenAI and Anthropic providers
- Moved `SetStreamIdleTimeoutIfEmpty` to the top of each `Handle*` function body so it is always invoked at the correct point in execution
- For `PassthroughStream` (OpenAI), which has no shared handler, the call remains in the provider method but is now correctly placed after request validation
- All providers that delegate to OpenAI or Anthropic shared handlers (Azure, Vertex, Cerebras, Fireworks, Groq, HuggingFace, Mistral, Nebius, Ollama, OpenRouter, Parasail, Perplexity, SGL, VLLM, XAI) now pass `StreamIdleTimeoutInSeconds` through to the handler
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [x] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
Verify that stream idle timeouts are respected when calling streaming endpoints directly via the exported `Handle*` functions (e.g., from Azure or Vertex routing through OpenAI/Anthropic handlers), and that the timeout is not applied prematurely before the handler executes.
```sh
go test ./...
```
Configure a provider with a non-zero `StreamIdleTimeoutInSeconds` and confirm that a streaming request that stalls mid-stream is terminated after the configured duration.
## Breaking changes
- [x] Yes
- [ ] No
All exported `Handle*` streaming functions now require an additional `streamIdleTimeoutInSeconds int` argument. Any callers outside this repository that invoke these functions directly will need to add the new parameter.
## Security considerations
No security implications. This change only affects timeout enforcement for streaming connections.
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
core/providers/anthropic/anthropic.go | 10 +++--
core/providers/azure/azure.go | 7 ++++
core/providers/cerebras/cerebras.go | 2 +
core/providers/fireworks/fireworks.go | 3 ++
core/providers/groq/groq.go | 1 +
core/providers/huggingface/huggingface.go | 1 +
core/providers/mistral/mistral.go | 1 +
core/providers/nebius/nebius.go | 2 +
core/providers/ollama/ollama.go | 2 +
core/providers/openai/openai.go | 22 +++++++++++
core/providers/openrouter/openrouter.go | 3 ++
core/providers/parasail/parasail.go | 1 +
core/providers/perplexity/perplexity.go | 1 +
core/providers/sgl/sgl.go | 2 +
core/providers/utils/utils.go | 47 ++++++++++++++++++++---
core/providers/vertex/vertex.go | 3 ++
core/providers/vllm/vllm.go | 2 +
core/providers/xai/xai.go | 3 ++
18 files changed, 103 insertions(+), 10 deletions(-)
diff --git a/core/providers/anthropic/anthropic.go b/core/providers/anthropic/anthropic.go
index e6f8ca62dd..fa86ca4ba1 100644
--- a/core/providers/anthropic/anthropic.go
+++ b/core/providers/anthropic/anthropic.go
@@ -564,8 +564,6 @@ func (provider *AnthropicProvider) ChatCompletionStream(ctx *schemas.BifrostCont
headers["x-api-key"] = key.Value.GetValue()
}
- providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds)
-
// Use shared Anthropic streaming logic
return HandleAnthropicChatCompletionStreaming(
ctx,
@@ -574,6 +572,7 @@ func (provider *AnthropicProvider) ChatCompletionStream(ctx *schemas.BifrostCont
jsonData,
headers,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
provider.networkConfig.BetaHeaderOverrides,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
@@ -594,6 +593,7 @@ func HandleAnthropicChatCompletionStreaming(
jsonBody []byte,
headers map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
betaHeaderOverrides map[string]bool,
sendBackRawRequest bool,
sendBackRawResponse bool,
@@ -603,6 +603,7 @@ func HandleAnthropicChatCompletionStreaming(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
resp.StreamBody = true // Initialize for streaming
@@ -1030,8 +1031,6 @@ func (provider *AnthropicProvider) ResponsesStream(ctx *schemas.BifrostContext,
headers["x-api-key"] = key.Value.GetValue()
}
- providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds)
-
return HandleAnthropicResponsesStream(
ctx,
provider.streamingClient,
@@ -1039,6 +1038,7 @@ func (provider *AnthropicProvider) ResponsesStream(ctx *schemas.BifrostContext,
jsonBody,
headers,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
provider.networkConfig.BetaHeaderOverrides,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
@@ -1059,6 +1059,7 @@ func HandleAnthropicResponsesStream(
jsonBody []byte,
headers map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
betaHeaderOverrides map[string]bool,
sendBackRawRequest bool,
sendBackRawResponse bool,
@@ -1068,6 +1069,7 @@ func HandleAnthropicResponsesStream(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
resp.StreamBody = true
diff --git a/core/providers/azure/azure.go b/core/providers/azure/azure.go
index a52feb8c40..c73e180c38 100644
--- a/core/providers/azure/azure.go
+++ b/core/providers/azure/azure.go
@@ -494,6 +494,7 @@ func (provider *AzureProvider) TextCompletionStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -640,6 +641,7 @@ func (provider *AzureProvider) ChatCompletionStream(ctx *schemas.BifrostContext,
jsonData,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
provider.networkConfig.BetaHeaderOverrides,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
@@ -668,6 +670,7 @@ func (provider *AzureProvider) ChatCompletionStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -795,6 +798,7 @@ func (provider *AzureProvider) ResponsesStream(ctx *schemas.BifrostContext, post
jsonData,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
provider.networkConfig.BetaHeaderOverrides,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
@@ -819,6 +823,7 @@ func (provider *AzureProvider) ResponsesStream(ctx *schemas.BifrostContext, post
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -1337,6 +1342,7 @@ func (provider *AzureProvider) ImageGenerationStream(
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -1409,6 +1415,7 @@ func (provider *AzureProvider) ImageEditStream(ctx *schemas.BifrostContext, post
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
false,
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/cerebras/cerebras.go b/core/providers/cerebras/cerebras.go
index 45292d6d24..b2f38ea38f 100644
--- a/core/providers/cerebras/cerebras.go
+++ b/core/providers/cerebras/cerebras.go
@@ -116,6 +116,7 @@ func (provider *CerebrasProvider) TextCompletionStream(ctx *schemas.BifrostConte
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -163,6 +164,7 @@ func (provider *CerebrasProvider) ChatCompletionStream(ctx *schemas.BifrostConte
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.Cerebras,
diff --git a/core/providers/fireworks/fireworks.go b/core/providers/fireworks/fireworks.go
index 827d1777df..646acbb3cf 100644
--- a/core/providers/fireworks/fireworks.go
+++ b/core/providers/fireworks/fireworks.go
@@ -112,6 +112,7 @@ func (provider *FireworksProvider) TextCompletionStream(ctx *schemas.BifrostCont
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -159,6 +160,7 @@ func (provider *FireworksProvider) ChatCompletionStream(ctx *schemas.BifrostCont
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.Fireworks,
@@ -204,6 +206,7 @@ func (provider *FireworksProvider) ResponsesStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/groq/groq.go b/core/providers/groq/groq.go
index 9667b989ff..f5671b8d63 100644
--- a/core/providers/groq/groq.go
+++ b/core/providers/groq/groq.go
@@ -132,6 +132,7 @@ func (provider *GroqProvider) ChatCompletionStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.Groq,
diff --git a/core/providers/huggingface/huggingface.go b/core/providers/huggingface/huggingface.go
index 38ddd84e9f..4ae855746d 100644
--- a/core/providers/huggingface/huggingface.go
+++ b/core/providers/huggingface/huggingface.go
@@ -577,6 +577,7 @@ func (provider *HuggingFaceProvider) ChatCompletionStream(ctx *schemas.BifrostCo
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/mistral/mistral.go b/core/providers/mistral/mistral.go
index b833f8b7cc..a399cf1cd9 100644
--- a/core/providers/mistral/mistral.go
+++ b/core/providers/mistral/mistral.go
@@ -208,6 +208,7 @@ func (provider *MistralProvider) ChatCompletionStream(ctx *schemas.BifrostContex
provider.normalizeChatRequestForConversion(request),
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/nebius/nebius.go b/core/providers/nebius/nebius.go
index 13e2cb4e33..cb249d873e 100644
--- a/core/providers/nebius/nebius.go
+++ b/core/providers/nebius/nebius.go
@@ -119,6 +119,7 @@ func (provider *NebiusProvider) TextCompletionStream(ctx *schemas.BifrostContext
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -178,6 +179,7 @@ func (provider *NebiusProvider) ChatCompletionStream(ctx *schemas.BifrostContext
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/ollama/ollama.go b/core/providers/ollama/ollama.go
index 1bc620e947..ae2785afe2 100644
--- a/core/providers/ollama/ollama.go
+++ b/core/providers/ollama/ollama.go
@@ -160,6 +160,7 @@ func (provider *OllamaProvider) TextCompletionStream(ctx *schemas.BifrostContext
request,
nil,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -211,6 +212,7 @@ func (provider *OllamaProvider) ChatCompletionStream(ctx *schemas.BifrostContext
request,
nil,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.Ollama,
diff --git a/core/providers/openai/openai.go b/core/providers/openai/openai.go
index 5a8e63c004..f1615b6aa4 100644
--- a/core/providers/openai/openai.go
+++ b/core/providers/openai/openai.go
@@ -401,6 +401,7 @@ func (provider *OpenAIProvider) TextCompletionStream(ctx *schemas.BifrostContext
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -422,6 +423,7 @@ func HandleOpenAITextCompletionStreaming(
request *schemas.BifrostTextCompletionRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawRequest bool,
sendBackRawResponse bool,
providerName schemas.ModelProvider,
@@ -432,6 +434,7 @@ func HandleOpenAITextCompletionStreaming(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
headers := map[string]string{
"Content-Type": "application/json",
"Accept": "text/event-stream",
@@ -917,6 +920,7 @@ func (provider *OpenAIProvider) ChatCompletionStream(ctx *schemas.BifrostContext
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -940,6 +944,7 @@ func HandleOpenAIChatCompletionStreaming(
request *schemas.BifrostChatRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawRequest bool,
sendBackRawResponse bool,
providerName schemas.ModelProvider,
@@ -952,6 +957,7 @@ func HandleOpenAIChatCompletionStreaming(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
// Check if the request is a redirect from ResponsesStream to ChatCompletionStream
isResponsesToChatCompletionsFallback := false
var responsesStreamState *schemas.ChatToResponsesStreamState
@@ -1525,6 +1531,7 @@ func (provider *OpenAIProvider) ResponsesStream(ctx *schemas.BifrostContext, pos
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -1547,6 +1554,7 @@ func HandleOpenAIResponsesStreaming(
request *schemas.BifrostResponsesRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawRequest bool,
sendBackRawResponse bool,
providerName schemas.ModelProvider,
@@ -1558,6 +1566,7 @@ func HandleOpenAIResponsesStreaming(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
// Prepare SGL headers (SGL typically doesn't require authorization, but we include it if provided)
headers := map[string]string{
"Content-Type": "application/json",
@@ -2128,6 +2137,7 @@ func (provider *OpenAIProvider) SpeechStream(ctx *schemas.BifrostContext, postHo
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -2148,6 +2158,7 @@ func HandleOpenAISpeechStreamRequest(
request *schemas.BifrostSpeechRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawRequest bool,
sendBackRawResponse bool,
providerName schemas.ModelProvider,
@@ -2157,6 +2168,7 @@ func HandleOpenAISpeechStreamRequest(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
// Create HTTP request for streaming
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
@@ -2568,6 +2580,7 @@ func (provider *OpenAIProvider) TranscriptionStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
false,
provider.GetProviderKey(),
@@ -2589,6 +2602,7 @@ func HandleOpenAITranscriptionStreamRequest(
request *schemas.BifrostTranscriptionRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawResponse bool,
accumulateText bool,
providerName schemas.ModelProvider,
@@ -2599,6 +2613,7 @@ func HandleOpenAITranscriptionStreamRequest(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
// Use centralized converter
reqBody := ToOpenAITranscriptionRequest(request)
if reqBody == nil {
@@ -2997,6 +3012,7 @@ func (provider *OpenAIProvider) ImageGenerationStream(
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -3016,6 +3032,7 @@ func HandleOpenAIImageGenerationStreaming(
request *schemas.BifrostImageGenerationRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawRequest bool,
sendBackRawResponse bool,
providerName schemas.ModelProvider,
@@ -3026,6 +3043,7 @@ func HandleOpenAIImageGenerationStreaming(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
// Set headers
headers := map[string]string{
"Content-Type": "application/json",
@@ -4241,6 +4259,7 @@ func (provider *OpenAIProvider) ImageEditStream(ctx *schemas.BifrostContext, pos
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
false,
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -4260,6 +4279,7 @@ func HandleOpenAIImageEditStreamRequest(
request *schemas.BifrostImageEditRequest,
authHeader map[string]string,
extraHeaders map[string]string,
+ streamIdleTimeoutInSeconds int,
sendBackRawRequest bool,
sendBackRawResponse bool,
providerName schemas.ModelProvider,
@@ -4270,6 +4290,7 @@ func HandleOpenAIImageEditStreamRequest(
logger schemas.Logger,
postHookSpanFinalizer func(context.Context),
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, streamIdleTimeoutInSeconds)
reqBody := ToOpenAIImageEditRequest(request)
if reqBody == nil {
return nil, providerUtils.NewBifrostOperationError("image edit input is not provided", nil)
@@ -6912,6 +6933,7 @@ func (provider *OpenAIProvider) PassthroughStream(
return nil, err
}
+ providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds)
path := req.Path
if after, ok := strings.CutPrefix(path, "/v1"); ok {
path = after
diff --git a/core/providers/openrouter/openrouter.go b/core/providers/openrouter/openrouter.go
index 36e4ff0566..2bc5162d7c 100644
--- a/core/providers/openrouter/openrouter.go
+++ b/core/providers/openrouter/openrouter.go
@@ -294,6 +294,7 @@ func (provider *OpenRouterProvider) TextCompletionStream(ctx *schemas.BifrostCon
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -342,6 +343,7 @@ func (provider *OpenRouterProvider) ChatCompletionStream(ctx *schemas.BifrostCon
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.OpenRouter,
@@ -388,6 +390,7 @@ func (provider *OpenRouterProvider) ResponsesStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/parasail/parasail.go b/core/providers/parasail/parasail.go
index ae4cb22ab7..e0d6a84c13 100644
--- a/core/providers/parasail/parasail.go
+++ b/core/providers/parasail/parasail.go
@@ -128,6 +128,7 @@ func (provider *ParasailProvider) ChatCompletionStream(ctx *schemas.BifrostConte
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.Parasail,
diff --git a/core/providers/perplexity/perplexity.go b/core/providers/perplexity/perplexity.go
index addb6a5fb8..52d498e198 100644
--- a/core/providers/perplexity/perplexity.go
+++ b/core/providers/perplexity/perplexity.go
@@ -202,6 +202,7 @@ func (provider *PerplexityProvider) ChatCompletionStream(ctx *schemas.BifrostCon
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.Perplexity,
diff --git a/core/providers/sgl/sgl.go b/core/providers/sgl/sgl.go
index f47c7b34e4..e35885983b 100644
--- a/core/providers/sgl/sgl.go
+++ b/core/providers/sgl/sgl.go
@@ -163,6 +163,7 @@ func (provider *SGLProvider) TextCompletionStream(ctx *schemas.BifrostContext, p
request,
nil,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -216,6 +217,7 @@ func (provider *SGLProvider) ChatCompletionStream(ctx *schemas.BifrostContext, p
request,
nil,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.SGL,
diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go
index fb31d93e5d..2d36d4bf02 100644
--- a/core/providers/utils/utils.go
+++ b/core/providers/utils/utils.go
@@ -2054,6 +2054,10 @@ func SetupStreamCancellation(ctx context.Context, bodyStream io.Reader, logger s
if err := closer.Close(); err != nil {
getLogger().Debug(fmt.Sprintf("Error closing body stream on context done: %v", err))
}
+ } else if wce, ok := bodyStream.(streamCloserWithError); ok {
+ if err := wce.CloseWithError(ctx.Err()); err != nil {
+ getLogger().Debug(fmt.Sprintf("Error closing body stream on context done: %v", err))
+ }
}
case <-done:
// If context was also cancelled (race between done and ctx.Done),
@@ -2063,6 +2067,10 @@ func SetupStreamCancellation(ctx context.Context, bodyStream io.Reader, logger s
if err := closer.Close(); err != nil {
getLogger().Debug(fmt.Sprintf("Error closing body stream on done with cancelled context: %v", err))
}
+ } else if wce, ok := bodyStream.(streamCloserWithError); ok {
+ if err := wce.CloseWithError(ctx.Err()); err != nil {
+ getLogger().Debug(fmt.Sprintf("Error closing body stream on done with cancelled context: %v", err))
+ }
}
}
}
@@ -2102,6 +2110,23 @@ func GetStreamIdleTimeout(ctx *schemas.BifrostContext) time.Duration {
return DefaultStreamIdleTimeout
}
+// streamCloserWithError is implemented by fasthttp's streaming body reader.
+// Calling CloseWithError with a non-nil error closes the underlying TCP
+// connection, interrupting any blocked Read.
+type streamCloserWithError interface {
+ CloseWithError(err error) error
+}
+
+// closeBodyStream closes bodyStream using whatever interface it supports:
+// io.Closer for net/http responses, streamCloserWithError for fasthttp.
+func closeBodyStream(bodyStream io.Reader, err error) {
+ if closer, ok := bodyStream.(io.Closer); ok {
+ closer.Close()
+ } else if wce, ok := bodyStream.(streamCloserWithError); ok {
+ wce.CloseWithError(err)
+ }
+}
+
// idleTimeoutReader wraps an io.Reader and closes the underlying body stream
// if no data arrives within the configured timeout. This unblocks any pending
// Read() call on the wrapped reader.
@@ -2111,12 +2136,16 @@ type idleTimeoutReader struct {
timeout time.Duration
timer *time.Timer
once sync.Once
+ fired atomic.Bool // set true when the idle timer fires
}
// NewIdleTimeoutReader wraps reader with idle detection. If reader.Read() returns
// no data for the given timeout duration, bodyStream is closed to unblock the read.
-// bodyStream must implement io.Closer for the timeout to take effect; if it does not,
-// the wrapper still functions but cannot force-close the stream.
+// Supports both io.Closer and fasthttp's CloseWithError interface — the latter
+// closes the underlying TCP connection when called with a non-nil error, which is
+// required to interrupt a blocked Read on fasthttp streaming responses.
+// When the timer fires, any subsequent error from Read is translated to
+// ErrStreamIdleTimeout so callers do not need per-handler error checks.
// Returns the wrapped reader and a cleanup function that MUST be called (via defer)
// when streaming is complete, to stop the timer and prevent premature closure.
func NewIdleTimeoutReader(reader io.Reader, bodyStream io.Reader, timeout time.Duration) (io.Reader, func()) {
@@ -2130,9 +2159,8 @@ func NewIdleTimeoutReader(reader io.Reader, bodyStream io.Reader, timeout time.D
}
r.timer = time.AfterFunc(timeout, func() {
r.once.Do(func() {
- if closer, ok := r.bodyStream.(io.Closer); ok {
- closer.Close()
- }
+ r.fired.Store(true)
+ closeBodyStream(r.bodyStream, ErrStreamIdleTimeout)
})
})
return r, func() { r.timer.Stop() }
@@ -2143,9 +2171,16 @@ func (r *idleTimeoutReader) Read(p []byte) (int, error) {
if n > 0 {
r.timer.Reset(r.timeout)
}
+ if err != nil && err != io.EOF && r.fired.Load() {
+ return n, ErrStreamIdleTimeout
+ }
return n, err
}
+// ErrStreamIdleTimeout is returned when no data is received within the configured
+// stream_idle_timeout_in_seconds window.
+var ErrStreamIdleTimeout = errors.New("stream idle timeout: no data received within configured window")
+
// HandleStreamCancellation should be called when a streaming goroutine exits
// due to context cancellation. It ensures proper cleanup by:
// 1. Checking if StreamEndIndicator was already set (to avoid duplicate handling)
@@ -2376,7 +2411,7 @@ func ProviderIsResponsesAPINative(providerName schemas.ModelProvider) bool {
func ReleaseStreamingResponse(resp *fasthttp.Response) {
defer func() {
if r := recover(); r != nil {
- getLogger().Error("recovered panic in ReleaseStreamingResponse: %v", r)
+ getLogger().Debug("stream already closed before drain in ReleaseStreamingResponse: %v\n", r)
}
// Always release the response to prevent leaks, even after a panic
fasthttp.ReleaseResponse(resp)
diff --git a/core/providers/vertex/vertex.go b/core/providers/vertex/vertex.go
index 4f37abda6b..2e485cc4e1 100644
--- a/core/providers/vertex/vertex.go
+++ b/core/providers/vertex/vertex.go
@@ -844,6 +844,7 @@ func (provider *VertexProvider) ChatCompletionStream(ctx *schemas.BifrostContext
jsonData,
headers,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
provider.networkConfig.BetaHeaderOverrides,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
@@ -975,6 +976,7 @@ func (provider *VertexProvider) ChatCompletionStream(ctx *schemas.BifrostContext
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
providerName,
@@ -1305,6 +1307,7 @@ func (provider *VertexProvider) ResponsesStream(ctx *schemas.BifrostContext, pos
jsonBody,
headers,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
provider.networkConfig.BetaHeaderOverrides,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
diff --git a/core/providers/vllm/vllm.go b/core/providers/vllm/vllm.go
index 7952e161cd..bf04ecfa37 100644
--- a/core/providers/vllm/vllm.go
+++ b/core/providers/vllm/vllm.go
@@ -158,6 +158,7 @@ func (provider *VLLMProvider) TextCompletionStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -211,6 +212,7 @@ func (provider *VLLMProvider) ChatCompletionStream(ctx *schemas.BifrostContext,
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
diff --git a/core/providers/xai/xai.go b/core/providers/xai/xai.go
index e787f307fd..1c9d777a97 100644
--- a/core/providers/xai/xai.go
+++ b/core/providers/xai/xai.go
@@ -113,6 +113,7 @@ func (provider *XAIProvider) TextCompletionStream(ctx *schemas.BifrostContext, p
request,
nil,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
@@ -160,6 +161,7 @@ func (provider *XAIProvider) ChatCompletionStream(ctx *schemas.BifrostContext, p
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
schemas.XAI,
@@ -205,6 +207,7 @@ func (provider *XAIProvider) ResponsesStream(ctx *schemas.BifrostContext, postHo
request,
authHeader,
provider.networkConfig.ExtraHeaders,
+ provider.networkConfig.StreamIdleTimeoutInSeconds,
providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest),
providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse),
provider.GetProviderKey(),
From 359fb6e1513d80ec2d256102c42a4cb88aa88078 Mon Sep 17 00:00:00 2001
From: Suresh Chaudhary <83772622+impoiler@users.noreply.github.com>
Date: Thu, 14 May 2026 20:02:09 +0530
Subject: [PATCH 41/81] feat: add tooltip with full precision values to chart
card totals and consolidate number formatting utilities (#3501)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Consolidates number formatting utilities across the dashboard and logs UI, and adds tooltip support to chart card totals so users can see the full unrounded value on hover.
## Changes
- Added a `totalTooltip` prop to `ChartCard` (and its internal `TotalChip`/`Header` components) that, when provided, wraps the total chip in a `Tooltip` so hovering reveals the precise value. All chart tabs (Overview, MCP, Provider Usage, Model Rankings) now pass full-precision tooltips for token counts, costs, and latency averages.
- Removed the local `formatTokens` function from `chartUtils.ts` and replaced all usages across chart components (`tokenUsageChart`, `modelUsageChart`, `logVolumeChart`, `mcpVolumeChart`, `mcpTopToolsChart`, `providerTokenChart`) with the shared `formatCompactNumber` from `lib/utils/numbers`.
- Added `formatCurrencyNumber` to `lib/utils/numbers` and replaced the ad-hoc `formatCost` calls used as Y-axis tick formatters in `costChart` and `providerCostChart` with it.
- Removed the duplicate `formatCompactNumber` implementation from `lib/utils/governance.ts`, replacing it with a re-export from `lib/utils/numbers`. Updated `rateLimitDisplay` and `modelRankingsTab` to import from the canonical location.
- Fixed a missing `$0` case in `formatCost` for zero values.
- Token display in the logs table columns and log detail view now uses `formatCompactNumber` instead of the removed `formatTokens`.
## Type of change
- [ ] Bug fix
- [ ] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```sh
cd ui
pnpm i || npm i
pnpm test || npm test
pnpm build || npm run build
```
1. Open the dashboard and navigate to the Overview, MCP, Provider Usage, and Model Rankings tabs.
2. Hover over any chart card's total chip (e.g. "Total 1.2M") — a tooltip should appear showing the full unrounded number (e.g. "1,234,567").
3. For cost totals, the tooltip should show the full currency value with up to 6 decimal places.
4. For latency averages, the tooltip should show the value in milliseconds with up to 6 decimal places.
5. Verify Y-axis tick labels on token and cost charts render correctly.
6. Verify token counts in the logs table and log detail sheet display correctly.
## Screenshots/Recordings
Before: Chart card totals show compact numbers with no way to see the exact value.
After: Hovering the total chip reveals a tooltip with the full-precision number.
## Breaking changes
- [x] No
## Related issues
## Security considerations
None.
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
.../dashboard/components/charts/chartCard.tsx | 72 +++++++++++++++++--
.../dashboard/components/charts/costChart.tsx | 5 +-
.../charts/externalCacheTokenMeterChart.tsx | 11 +--
.../components/charts/logVolumeChart.tsx | 7 +-
.../components/charts/mcpTopToolsChart.tsx | 5 +-
.../components/charts/mcpVolumeChart.tsx | 7 +-
.../components/charts/modelUsageChart.tsx | 6 +-
.../components/charts/providerCostChart.tsx | 3 +-
.../components/charts/providerTokenChart.tsx | 14 ++--
.../components/charts/tokenUsageChart.tsx | 7 +-
.../workspace/dashboard/components/mcpTab.tsx | 11 ++-
.../dashboard/components/modelRankingsTab.tsx | 4 +-
.../dashboard/components/overviewTab.tsx | 23 +++---
.../dashboard/components/providerUsageTab.tsx | 9 +++
.../workspace/dashboard/utils/chartUtils.ts | 14 +---
.../workspace/logs/sheets/logDetailView.tsx | 9 +--
ui/app/workspace/logs/views/columns.tsx | 9 +--
ui/components/rateLimitDisplay.tsx | 2 +-
ui/lib/utils/governance.ts | 17 +----
ui/lib/utils/numbers.ts | 13 ++++
20 files changed, 164 insertions(+), 84 deletions(-)
diff --git a/ui/app/workspace/dashboard/components/charts/chartCard.tsx b/ui/app/workspace/dashboard/components/charts/chartCard.tsx
index b93974db2d..ce0552595f 100644
--- a/ui/app/workspace/dashboard/components/charts/chartCard.tsx
+++ b/ui/app/workspace/dashboard/components/charts/chartCard.tsx
@@ -1,5 +1,6 @@
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
@@ -13,10 +14,21 @@ interface ChartCardProps {
className?: string;
total?: ReactNode;
totalLabel?: string;
+ totalTooltip?: ReactNode;
}
-function TotalChip({ total, totalLabel, testId }: { total: ReactNode; totalLabel?: string; testId?: string }) {
- return (
+function TotalChip({
+ total,
+ totalLabel,
+ totalTooltip,
+ testId,
+}: {
+ total: ReactNode;
+ totalLabel?: string;
+ totalTooltip?: ReactNode;
+ testId?: string;
+}) {
+ const chip = (
{total}
);
+
+ if (totalTooltip === undefined || totalTooltip === null) {
+ return chip;
+ }
+
+ return (
+
+
+
+ {chip}
+
+
+ {totalTooltip}
+
+ );
}
function Header({
@@ -33,6 +60,7 @@ function Header({
legend,
total,
totalLabel,
+ totalTooltip,
testId,
}: {
title: string;
@@ -40,6 +68,7 @@ function Header({
legend?: ReactNode;
total?: ReactNode;
totalLabel?: string;
+ totalTooltip?: ReactNode;
testId?: string;
}) {
const hasTotal = total !== undefined && total !== null;
@@ -51,7 +80,11 @@ function Header({
{hasActionRow && (
- {hasTotal ?
:
}
+ {hasTotal ? (
+
+ ) : (
+
+ )}
{controls &&
{controls}
}
)}
@@ -60,11 +93,30 @@ function Header({
);
}
-export function ChartCard({ title, children, controls, legend, loading, testId, className, total, totalLabel }: ChartCardProps) {
+export function ChartCard({
+ title,
+ children,
+ controls,
+ legend,
+ loading,
+ testId,
+ className,
+ total,
+ totalLabel,
+ totalTooltip,
+}: ChartCardProps) {
if (loading) {
return (
-
+
@@ -74,7 +126,15 @@ export function ChartCard({ title, children, controls, legend, loading, testId,
return (
-
+
{children}
);
diff --git a/ui/app/workspace/dashboard/components/charts/costChart.tsx b/ui/app/workspace/dashboard/components/charts/costChart.tsx
index 71b3d40597..0dd2491eab 100644
--- a/ui/app/workspace/dashboard/components/charts/costChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/costChart.tsx
@@ -1,4 +1,5 @@
import type { CostHistogramResponse } from "@/lib/types/logs";
+import { formatCurrencyNumber } from "@/lib/utils/numbers";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
@@ -136,7 +137,7 @@ function CostChartImpl({ data, chartType, startTime, endTime, selectedModel }: C
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={(v) => formatCost(v)}
+ tickFormatter={(v) => formatCurrencyNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 0.01)]}
allowDataOverflow={false}
/>
@@ -172,7 +173,7 @@ function CostChartImpl({ data, chartType, startTime, endTime, selectedModel }: C
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={(v) => formatCost(v)}
+ tickFormatter={(v) => formatCurrencyNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 0.01)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/externalCacheTokenMeterChart.tsx b/ui/app/workspace/dashboard/components/charts/externalCacheTokenMeterChart.tsx
index 0788f3fc56..0836097b90 100644
--- a/ui/app/workspace/dashboard/components/charts/externalCacheTokenMeterChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/externalCacheTokenMeterChart.tsx
@@ -1,5 +1,6 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { TokenHistogramResponse } from "@/lib/types/logs";
+import { formatCompactNumber } from "@/lib/utils/numbers";
import { Info } from "lucide-react";
import { memo, useMemo } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
@@ -12,12 +13,6 @@ interface ExternalCacheTokenMeterChartProps {
const METER_COLORS = { cached: "#06b6d4", input: "#3b82f6" };
-const formatTokenCount = (count: number): string => {
- if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
- if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
- return count.toLocaleString();
-};
-
function ExternalCacheTokenMeterChartImpl({ data }: ExternalCacheTokenMeterChartProps) {
const { ref, width, height } = useGaugeSize();
@@ -101,11 +96,11 @@ function ExternalCacheTokenMeterChartImpl({ data }: ExternalCacheTokenMeterChart
- Cached: {formatTokenCount(totalCachedRead)}
+ Cached: {formatCompactNumber(totalCachedRead)}
- Input: {formatTokenCount(totalPromptTokens)}
+ Input: {formatCompactNumber(totalPromptTokens)}
diff --git a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
index dc5238c87a..2e1a0d8e98 100644
--- a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx
@@ -1,7 +1,8 @@
import type { LogsHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { CHART_COLORS, formatFullTimestamp, formatTimestamp, formatTokens } from "../../utils/chartUtils";
+import { formatCompactNumber } from "@/lib/utils/numbers";
+import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
@@ -98,7 +99,7 @@ function LogVolumeChartImpl({ data, chartType, startTime, endTime }: LogVolumeCh
tickLine={false}
axisLine={false}
width={44}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -140,7 +141,7 @@ function LogVolumeChartImpl({ data, chartType, startTime, endTime }: LogVolumeCh
tickLine={false}
axisLine={false}
width={44}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
index 4058c106de..a0ee9b88c8 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpTopToolsChart.tsx
@@ -1,7 +1,8 @@
import type { MCPTopToolsResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { formatCost, formatTokens, getModelColor } from "../../utils/chartUtils";
+import { formatCompactNumber } from "@/lib/utils/numbers";
+import { formatCost, getModelColor } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
interface MCPTopToolsChartProps {
@@ -54,7 +55,7 @@ function MCPTopToolsChartImpl({ data }: MCPTopToolsChartProps) {
tick={{ fontSize: 11, className: "fill-zinc-500" }}
tickLine={false}
axisLine={false}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
index 73e9935267..9f88059841 100644
--- a/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/mcpVolumeChart.tsx
@@ -1,7 +1,8 @@
import type { MCPHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { CHART_COLORS, formatFullTimestamp, formatTimestamp, formatTokens } from "../../utils/chartUtils";
+import { formatCompactNumber } from "@/lib/utils/numbers";
+import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
@@ -88,7 +89,7 @@ function MCPVolumeChartImpl({ data, chartType, startTime, endTime }: MCPVolumeCh
tickLine={false}
axisLine={false}
width={44}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -130,7 +131,7 @@ function MCPVolumeChartImpl({ data, chartType, startTime, endTime }: MCPVolumeCh
tickLine={false}
axisLine={false}
width={44}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx b/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
index 644b3380a0..a6667922c1 100644
--- a/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/modelUsageChart.tsx
@@ -1,11 +1,11 @@
import type { ModelHistogramResponse } from "@/lib/types/logs";
+import { formatCompactNumber } from "@/lib/utils/numbers";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
CHART_COLORS,
formatFullTimestamp,
formatTimestamp,
- formatTokens,
getModelColor,
OTHER_SERIES_COLOR,
OTHER_SERIES_KEY,
@@ -163,7 +163,7 @@ function ModelUsageChartImpl({ data, chartType, startTime, endTime, selectedMode
tickLine={false}
axisLine={false}
width={44}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -222,7 +222,7 @@ function ModelUsageChartImpl({ data, chartType, startTime, endTime, selectedMode
tickLine={false}
axisLine={false}
width={44}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx b/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx
index abd31e1229..378d3f18c5 100644
--- a/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/providerCostChart.tsx
@@ -1,4 +1,5 @@
import type { ProviderCostHistogramResponse } from "@/lib/types/logs";
+import { formatCurrencyNumber } from "@/lib/utils/numbers";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
@@ -138,7 +139,7 @@ function ProviderCostChartImpl({ data, chartType, startTime, endTime, selectedPr
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={(v) => formatCost(v)}
+ tickFormatter={(v) => formatCurrencyNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 0.01)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/providerTokenChart.tsx b/ui/app/workspace/dashboard/components/charts/providerTokenChart.tsx
index 07c77a3a8a..a5092cc876 100644
--- a/ui/app/workspace/dashboard/components/charts/providerTokenChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/providerTokenChart.tsx
@@ -1,11 +1,11 @@
import type { ProviderTokenHistogramResponse } from "@/lib/types/logs";
+import { formatCompactNumber } from "@/lib/utils/numbers";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
CHART_COLORS,
formatFullTimestamp,
formatTimestamp,
- formatTokens,
getModelColor,
OTHER_SERIES_COLOR,
OTHER_SERIES_KEY,
@@ -43,7 +43,7 @@ function AllProvidersTooltip({ active, payload, displayProviders }: any) {
{isOther ? OTHER_SERIES_LABEL : provider}
-
{formatTokens(tokens)}
+
{formatCompactNumber(tokens)}
);
})}
@@ -70,18 +70,18 @@ function SingleProviderTooltip({ active, payload, provider }: any) {
Input
-
{formatTokens(stats.prompt_tokens || 0)}
+
{formatCompactNumber(stats.prompt_tokens || 0)}
Output
- {formatTokens(stats.completion_tokens || 0)}
+ {formatCompactNumber(stats.completion_tokens || 0)}
Total
- {formatTokens(stats.total_tokens || 0)}
+ {formatCompactNumber(stats.total_tokens || 0)}
@@ -167,7 +167,7 @@ function ProviderTokenChartImpl({ data, chartType, startTime, endTime, selectedP
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -232,7 +232,7 @@ function ProviderTokenChartImpl({ data, chartType, startTime, endTime, selectedP
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/charts/tokenUsageChart.tsx b/ui/app/workspace/dashboard/components/charts/tokenUsageChart.tsx
index 5c10ced626..4233369e46 100644
--- a/ui/app/workspace/dashboard/components/charts/tokenUsageChart.tsx
+++ b/ui/app/workspace/dashboard/components/charts/tokenUsageChart.tsx
@@ -1,7 +1,8 @@
import type { TokenHistogramResponse } from "@/lib/types/logs";
import { memo, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
-import { CHART_COLORS, formatFullTimestamp, formatTimestamp, formatTokens } from "../../utils/chartUtils";
+import { formatCompactNumber } from "@/lib/utils/numbers";
+import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils";
import { ChartErrorBoundary } from "./chartErrorBoundary";
import type { ChartType } from "./chartTypeToggle";
@@ -98,7 +99,7 @@ function TokenUsageChartImpl({ data, chartType, startTime, endTime }: TokenUsage
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
@@ -149,7 +150,7 @@ function TokenUsageChartImpl({ data, chartType, startTime, endTime }: TokenUsage
tickLine={false}
axisLine={false}
width={50}
- tickFormatter={formatTokens}
+ tickFormatter={(v) => formatCompactNumber(v)}
domain={[0, (dataMax: number) => Math.max(dataMax, 1)]}
allowDataOverflow={false}
/>
diff --git a/ui/app/workspace/dashboard/components/mcpTab.tsx b/ui/app/workspace/dashboard/components/mcpTab.tsx
index 9d9356be90..83d9d534f1 100644
--- a/ui/app/workspace/dashboard/components/mcpTab.tsx
+++ b/ui/app/workspace/dashboard/components/mcpTab.tsx
@@ -71,6 +71,7 @@ function MCPTabImpl({
testId="chart-mcp-volume"
totalLabel="Total"
total={mcpVolumeTotal !== null ?
: undefined}
+ totalTooltip={mcpVolumeTotal !== null ? mcpVolumeTotal.toLocaleString("en-US") : undefined}
legend={
@@ -105,6 +106,11 @@ function MCPTabImpl({
) : undefined
}
+ totalTooltip={
+ mcpCostTotal !== null
+ ? mcpCostTotal.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 })
+ : undefined
+ }
legend={
@@ -113,7 +119,9 @@ function MCPTabImpl({
}
- controls={ }
+ controls={
+
+ }
>
@@ -125,6 +133,7 @@ function MCPTabImpl({
testId="chart-mcp-top-tools"
totalLabel="Total"
total={mcpTopToolsTotal !== null ? : undefined}
+ totalTooltip={mcpTopToolsTotal !== null ? mcpTopToolsTotal.toLocaleString("en-US") : undefined}
>
diff --git a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
index f6fedcfea2..670fa37a37 100644
--- a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
+++ b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx
@@ -3,8 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import ProviderIcons, { type ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import type { ModelHistogramResponse, ModelRankingEntry, ModelRankingsResponse } from "@/lib/types/logs";
-import { formatCompactNumber as formatNumber } from "@/lib/utils/governance";
-import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
+import { COMPACT_NUMBER_FORMAT, formatCompactNumber as formatNumber } from "@/lib/utils/numbers";
import NumberFlow from "@number-flow/react";
import { ArrowDown, ArrowUp, ArrowUpDown, Minus } from "lucide-react";
import { memo, useCallback, useMemo, useState } from "react";
@@ -233,6 +232,7 @@ function TopModelsChart({
className="z-[1] h-full"
totalLabel="Total"
total={grandTotal !== null ? : undefined}
+ totalTooltip={grandTotal !== null ? grandTotal.toLocaleString("en-US") : undefined}
>
{chartData.length > 0 ? (
diff --git a/ui/app/workspace/dashboard/components/overviewTab.tsx b/ui/app/workspace/dashboard/components/overviewTab.tsx
index ff59408e0d..ebcb7d8065 100644
--- a/ui/app/workspace/dashboard/components/overviewTab.tsx
+++ b/ui/app/workspace/dashboard/components/overviewTab.tsx
@@ -1,6 +1,4 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
-import NumberFlow from "@number-flow/react";
import type {
CostHistogramResponse,
LatencyHistogramResponse,
@@ -9,6 +7,8 @@ import type {
ModelHistogramResponse,
TokenHistogramResponse,
} from "@/lib/types/logs";
+import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
+import NumberFlow from "@number-flow/react";
import { memo, useMemo } from "react";
import { CHART_COLORS, CHART_HEADER_LEGEND_CLASS, LATENCY_COLORS, getModelColor } from "../utils/chartUtils";
import { ChartCard } from "./charts/chartCard";
@@ -159,6 +159,7 @@ function OverviewTabImpl({
testId="chart-log-volume"
totalLabel="Total"
total={volumeTotal !== null ?
: undefined}
+ totalTooltip={volumeTotal !== null ? volumeTotal.toLocaleString("en-US") : undefined}
legend={
@@ -171,7 +172,9 @@ function OverviewTabImpl({
}
- controls={
}
+ controls={
+
+ }
>
@@ -183,6 +186,7 @@ function OverviewTabImpl({
testId="chart-token-usage"
totalLabel="Total"
total={tokenTotal !== null ?
: undefined}
+ totalTooltip={tokenTotal !== null ? tokenTotal.toLocaleString("en-US") : undefined}
legend={
@@ -225,6 +229,11 @@ function OverviewTabImpl({
) : undefined
}
+ totalTooltip={
+ costTotal !== null
+ ? costTotal.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 })
+ : undefined
+ }
legend={
{costModel === "all" ? (
@@ -300,6 +309,7 @@ function OverviewTabImpl({
testId="chart-model-usage"
totalLabel="Total"
total={modelUsageTotal !== null ?
: undefined}
+ totalTooltip={modelUsageTotal !== null ? modelUsageTotal.toLocaleString("en-US") : undefined}
legend={
{usageModel === "all" ? (
@@ -380,6 +390,7 @@ function OverviewTabImpl({
) : undefined
}
+ totalTooltip={latencyAvg !== null ? `${latencyAvg.toLocaleString("en-US", { maximumFractionDigits: 6 })}ms` : undefined}
legend={
@@ -401,11 +412,7 @@ function OverviewTabImpl({
}
controls={
-
+
}
>
diff --git a/ui/app/workspace/dashboard/components/providerUsageTab.tsx b/ui/app/workspace/dashboard/components/providerUsageTab.tsx
index c19df8868b..dd1034fda4 100644
--- a/ui/app/workspace/dashboard/components/providerUsageTab.tsx
+++ b/ui/app/workspace/dashboard/components/providerUsageTab.tsx
@@ -131,6 +131,11 @@ function ProviderUsageTabImpl({
) : undefined
}
+ totalTooltip={
+ providerCostTotal !== null
+ ? providerCostTotal.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 })
+ : undefined
+ }
legend={
{providerCostProvider === "all" ? (
@@ -215,6 +220,7 @@ function ProviderUsageTabImpl({
testId="chart-provider-tokens"
totalLabel="Total"
total={providerTokenTotal !== null ?
: undefined}
+ totalTooltip={providerTokenTotal !== null ? providerTokenTotal.toLocaleString("en-US") : undefined}
legend={
{providerTokenProvider === "all" ? (
@@ -304,6 +310,9 @@ function ProviderUsageTabImpl({
) : undefined
}
+ totalTooltip={
+ providerLatencyAvg !== null ? `${providerLatencyAvg.toLocaleString("en-US", { maximumFractionDigits: 6 })}ms` : undefined
+ }
legend={
{providerLatencyProvider === "all" ? (
diff --git a/ui/app/workspace/dashboard/utils/chartUtils.ts b/ui/app/workspace/dashboard/utils/chartUtils.ts
index 3e967beb21..12019a3ce7 100644
--- a/ui/app/workspace/dashboard/utils/chartUtils.ts
+++ b/ui/app/workspace/dashboard/utils/chartUtils.ts
@@ -30,23 +30,15 @@ export function formatFullTimestamp(timestamp: string): string {
// Format cost values
export function formatCost(cost: number): string {
+ if (cost === 0) {
+ return `$0`;
+ }
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
-// Format token values
-export function formatTokens(tokens: number): string {
- if (tokens >= 1000000) {
- return `${(tokens / 1000000).toFixed(1)}M`;
- }
- if (tokens >= 1000) {
- return `${(tokens / 1000).toFixed(1)}K`;
- }
- return tokens.toLocaleString();
-}
-
// Color palette for models. Length governs TOP_SERIES_LIMIT (top-N rollup cap),
// so colors and named-series count stay coupled — adding a color expands top-N.
export const MODEL_COLORS = [
diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx
index e6bda63ae6..03eb7c9173 100644
--- a/ui/app/workspace/logs/sheets/logDetailView.tsx
+++ b/ui/app/workspace/logs/sheets/logDetailView.tsx
@@ -1,4 +1,5 @@
-import { formatCost, formatLatency, formatTokens } from "@/app/workspace/dashboard/utils/chartUtils";
+import { formatCost, formatLatency } from "@/app/workspace/dashboard/utils/chartUtils";
+import { formatCompactNumber } from "@/lib/utils/numbers";
import {
AlertDialog,
AlertDialogAction,
@@ -764,14 +765,14 @@ export function LogDetailView({
mono
value={
log.token_usage
- ? `${formatTokens(log.token_usage.prompt_tokens ?? 0)} / ${formatTokens(log.token_usage.completion_tokens ?? 0)}`
+ ? `${formatCompactNumber(log.token_usage.prompt_tokens ?? 0)} / ${formatCompactNumber(log.token_usage.completion_tokens ?? 0)}`
: "—"
}
sub={
log.token_usage
- ? `total ${formatTokens(log.token_usage.total_tokens ?? 0)}${
+ ? `total ${formatCompactNumber(log.token_usage.total_tokens ?? 0)}${
log.token_usage.completion_tokens_details?.reasoning_tokens
- ? ` · reasoning ${formatTokens(log.token_usage.completion_tokens_details.reasoning_tokens)}`
+ ? ` · reasoning ${formatCompactNumber(log.token_usage.completion_tokens_details.reasoning_tokens)}`
: ""
}`
: "—"
diff --git a/ui/app/workspace/logs/views/columns.tsx b/ui/app/workspace/logs/views/columns.tsx
index 4a2381157c..fb9a7f1367 100644
--- a/ui/app/workspace/logs/views/columns.tsx
+++ b/ui/app/workspace/logs/views/columns.tsx
@@ -1,4 +1,5 @@
-import { formatCost, formatLatency, formatTokens } from "@/app/workspace/dashboard/utils/chartUtils";
+import { formatCost, formatLatency } from "@/app/workspace/dashboard/utils/chartUtils";
+import { formatCompactNumber } from "@/lib/utils/numbers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
@@ -295,7 +296,7 @@ export const createColumns = (
return (
-
{formatTokens(total)}
+
{formatCompactNumber(total)}
{hasSplit && (
@@ -305,9 +306,9 @@ export const createColumns = (
{hasSplit && (
- {formatTokens(prompt)}
+ {formatCompactNumber(prompt)}
/
- {formatTokens(completion)}
+ {formatCompactNumber(completion)}
)}
diff --git a/ui/components/rateLimitDisplay.tsx b/ui/components/rateLimitDisplay.tsx
index 20b457ccf6..1ba294a0c2 100644
--- a/ui/components/rateLimitDisplay.tsx
+++ b/ui/components/rateLimitDisplay.tsx
@@ -2,7 +2,7 @@ import { Progress } from "@/components/ui/progress";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { resetDurationLabels } from "@/lib/constants/governance";
import { cn } from "@/lib/utils";
-import { formatCompactNumber } from "@/lib/utils/governance";
+import { formatCompactNumber } from "@/lib/utils/numbers";
interface RateLimitShape {
token_max_limit?: number | null;
diff --git a/ui/lib/utils/governance.ts b/ui/lib/utils/governance.ts
index e257ac2b1b..578aba4c28 100644
--- a/ui/lib/utils/governance.ts
+++ b/ui/lib/utils/governance.ts
@@ -24,25 +24,12 @@ export function parseResetPeriod(duration: string): string {
return `${timeValue} ${unitName}`;
}
+import { formatCompactNumber } from "./numbers";
+
export function formatCurrency(dollars: number) {
return `$${dollars.toFixed(2)}`;
}
-/**
- * Formats a number compactly (e.g. 10000 → "10K", 1500000 → "1.5M").
- * Uses Intl.NumberFormat so boundary values promote correctly (999,950 → "1M", not "1000K")
- * and trailing zeros are dropped (10,000 → "10K", not "10.0K").
- */
-const compactNumberFormatter = new Intl.NumberFormat(undefined, {
- notation: "compact",
- maximumFractionDigits: 1,
-});
-
-export function formatCompactNumber(n: number): string {
- if (Math.abs(n) >= 1_000) return compactNumberFormatter.format(n);
- return n.toLocaleString();
-}
-
const shortDurationLabels: Record
= {
"1m": "/min",
"5m": "/5min",
diff --git a/ui/lib/utils/numbers.ts b/ui/lib/utils/numbers.ts
index 11fa4d2442..22fceec249 100644
--- a/ui/lib/utils/numbers.ts
+++ b/ui/lib/utils/numbers.ts
@@ -10,4 +10,17 @@ export function formatCompactNumber(value: number, maximumFractionDigits = 2): s
...COMPACT_NUMBER_FORMAT,
maximumFractionDigits,
}).format(value);
+}
+
+export function formatCurrencyNumber(value: number, maximumFractionDigits = 2): string {
+ if (!Number.isFinite(value)) return "$0";
+ if (value !== 0 && Math.abs(value) < 0.01) {
+ return `$${value.toFixed(4)}`;
+ }
+ return new Intl.NumberFormat("en-US", {
+ ...COMPACT_NUMBER_FORMAT,
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits,
+ }).format(value);
}
\ No newline at end of file
From fff90c208d00ee1726275a0b4c340d67677e524e Mon Sep 17 00:00:00 2001
From: Samyabrata Maji
Date: Thu, 14 May 2026 20:03:13 +0530
Subject: [PATCH 42/81] fix: trim trailing whitespaces for anthropic and
bedrock anthropic provider (#3496)
## Summary
Trailing whitespace (spaces, newlines, carriage returns, tabs) in the last assistant message is stripped before sending requests to Anthropic and Bedrock (Anthropic models). This prevents API errors that can occur when assistant prefill messages end with whitespace, which Anthropic's API rejects.
## Changes
- For the Anthropic provider, all text content blocks in the final assistant message are right-trimmed of whitespace before the request is sent, applied in both `chat.go` and `responses.go`.
- For the Bedrock provider, the same trimming is applied to text blocks in the final assistant message, but only when the target model is an Anthropic model (since prefill is an Anthropic-specific concept), applied in both `chat.go` and `responses.go`.
## Type of change
- [x] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [x] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
Send a chat completion request where the last message has `role: assistant` and its content ends with trailing whitespace or newlines.
### chat completions
```
curl --location 'http://localhost:8080/v1/chat/completions' \
--header 'Content-Type: application/json' \
--data '{
// "model": "anthropic/claude-haiku-4-5-20251001",
// "model": "anthropic/claude-opus-4-1-20250805",
// "model": "anthropic/claude-opus-4-20250514",
// "model": "anthropic/claude-opus-4-5-20251101",
// "model": "anthropic/claude-opus-4-6",
// "model": "anthropic/claude-opus-4-7",
// "model": "anthropic/claude-sonnet-4-20250514",
// "model": "anthropic/claude-sonnet-4-5-20250929",
// "model": "anthropic/claude-sonnet-4-6",
// "model": "bedrock/us.anthropic.claude-3-haiku-20240307-v1:0",
// "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
// "model": "bedrock/us.anthropic.claude-opus-4-1-20250805-v1:0",
// "model": "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0",
// "model": "bedrock/us.anthropic.claude-opus-4-6-v1",
// "model": "bedrock/us.anthropic.claude-opus-4-7",
// "model": "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
// "model": "bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"model": "bedrock/us.anthropic.claude-sonnet-4-6",
"messages": [
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": "Hello " // trailing whitespace
}
]
}'
```
### anthropic integration (v1/messages)
```
curl --location 'http://localhost:8080/anthropic/v1/messages' \
--header 'Content-Type: application/json' \
--data '{
// "model": "anthropic/claude-haiku-4-5-20251001",
// "model": "anthropic/claude-opus-4-1-20250805",
// "model": "anthropic/claude-opus-4-20250514",
// "model": "anthropic/claude-opus-4-5-20251101",
// "model": "anthropic/claude-opus-4-6",
// "model": "anthropic/claude-opus-4-7",
// "model": "anthropic/claude-sonnet-4-20250514",
// "model": "anthropic/claude-sonnet-4-5-20250929",
// "model": "anthropic/claude-sonnet-4-6",
// "model": "bedrock/us.anthropic.claude-3-haiku-20240307-v1:0",
// "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
// "model": "bedrock/us.anthropic.claude-opus-4-1-20250805-v1:0",
// "model": "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0",
// "model": "bedrock/us.anthropic.claude-opus-4-6-v1",
// "model": "bedrock/us.anthropic.claude-opus-4-7",
// "model": "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
// "model": "bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"model": "bedrock/us.anthropic.claude-sonnet-4-6",
"messages": [
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": "Hey "
]
}'
```
```
"messages": [
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Hey "
}
]
}
]
```
## Breaking changes
- [ ] Yes
- [x] No
## Related issues
## Security considerations
No security implications. This change only modifies outbound message content formatting before it reaches the provider API.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
---
core/providers/anthropic/chat.go | 14 ++++++++++++++
core/providers/anthropic/responses.go | 16 +++++++++++++++-
core/providers/bedrock/chat.go | 14 ++++++++++++++
core/providers/bedrock/responses.go | 13 +++++++++++++
4 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/core/providers/anthropic/chat.go b/core/providers/anthropic/chat.go
index 199d55b296..d9431bba07 100644
--- a/core/providers/anthropic/chat.go
+++ b/core/providers/anthropic/chat.go
@@ -745,6 +745,20 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif
anthropicReq.Messages = anthropicMessages
anthropicReq.System = systemContent
+ // Trim trailing whitespace from the last assistant message text blocks
+ // ContentStr is converted to a single text ContentBlock during message conversion
+ // so we trim the text of that block instead.
+ lastMsgIndex := len(anthropicReq.Messages) - 1
+ if lastMsgIndex >= 0 && anthropicReq.Messages[lastMsgIndex].Role == AnthropicMessageRoleAssistant {
+ blocks := anthropicReq.Messages[lastMsgIndex].Content.ContentBlocks
+ for j := len(blocks) - 1; j >= 0; j-- {
+ if blocks[j].Type == AnthropicContentBlockTypeText && blocks[j].Text != nil {
+ anthropicReq.Messages[lastMsgIndex].Content.ContentBlocks[j].Text = schemas.Ptr(strings.TrimRight(*blocks[j].Text, " \n\r\t"))
+ break
+ }
+ }
+ }
+
// Strip request- and tool-level fields the target Anthropic-family
// provider does not support. Fail-closed tool validation stays in
// ValidateToolsForProvider; this is strip-silently for additive fields.
diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go
index ce90ab2c2b..5277a1ca9b 100644
--- a/core/providers/anthropic/responses.go
+++ b/core/providers/anthropic/responses.go
@@ -3344,6 +3344,20 @@ func ConvertBifrostMessagesToAnthropicMessages(ctx *schemas.BifrostContext, bifr
// Flush any remaining pending tool calls (with tracking)
flushPendingToolCallsWithTracking()
+ // Trim trailing whitespace from the last assistant message
+ // ContentStr is converted to a single text ContentBlock during message conversion
+ // so we trim the text of that block instead.
+ lastMsgIndex := len(anthropicMessages) - 1
+ if isRequestMessage && lastMsgIndex >= 0 && anthropicMessages[lastMsgIndex].Role == AnthropicMessageRoleAssistant {
+ blocks := anthropicMessages[lastMsgIndex].Content.ContentBlocks
+ for j := len(blocks) - 1; j >= 0; j-- {
+ if blocks[j].Type == AnthropicContentBlockTypeText && blocks[j].Text != nil {
+ anthropicMessages[lastMsgIndex].Content.ContentBlocks[j].Text = schemas.Ptr(strings.TrimRight(*blocks[j].Text, " \n\r\t"))
+ break
+ }
+ }
+ }
+
return anthropicMessages, systemContent
}
@@ -5957,4 +5971,4 @@ func generateSyntheticInputJSONDeltas(argumentsJSON string, contentIndex *int) [
}
return events
-}
\ No newline at end of file
+}
diff --git a/core/providers/bedrock/chat.go b/core/providers/bedrock/chat.go
index df631e9f61..36ef976679 100644
--- a/core/providers/bedrock/chat.go
+++ b/core/providers/bedrock/chat.go
@@ -3,6 +3,7 @@ package bedrock
import (
"context"
"fmt"
+ "strings"
"time"
"github.com/google/uuid"
@@ -42,6 +43,19 @@ func ToBedrockChatCompletionRequest(ctx *schemas.BifrostContext, bifrostReq *sch
bedrockReq.System = systemMessages
}
+ // Trim trailing whitespace from the last assistant message text blocks
+ // (only for Anthropic models which use text-based prefill)
+ lastMsgIndex := len(bedrockReq.Messages) - 1
+ if schemas.IsAnthropicModel(bifrostReq.Model) && lastMsgIndex >= 0 && bedrockReq.Messages[lastMsgIndex].Role == BedrockMessageRoleAssistant {
+ blocks := bedrockReq.Messages[lastMsgIndex].Content
+ for j := len(blocks) - 1; j >= 0; j-- {
+ if blocks[j].Text != nil {
+ bedrockReq.Messages[lastMsgIndex].Content[j].Text = schemas.Ptr(strings.TrimRight(*blocks[j].Text, " \n\r\t"))
+ break
+ }
+ }
+ }
+
// Convert parameters and configurations
if err := convertChatParameters(ctx, bifrostReq, bedrockReq); err != nil {
return nil, fmt.Errorf("failed to convert chat parameters: %w", err)
diff --git a/core/providers/bedrock/responses.go b/core/providers/bedrock/responses.go
index 43a5c8eb62..be1d32f415 100644
--- a/core/providers/bedrock/responses.go
+++ b/core/providers/bedrock/responses.go
@@ -1682,6 +1682,19 @@ func ToBedrockResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.
}
}
}
+
+ // Trim trailing whitespace from the last assistant message text blocks
+ // (only for Anthropic models which use text-based prefill)
+ lastMsgIndex := len(bedrockReq.Messages) - 1
+ if schemas.IsAnthropicModel(bifrostReq.Model) && lastMsgIndex >= 0 && bedrockReq.Messages[lastMsgIndex].Role == BedrockMessageRoleAssistant {
+ blocks := bedrockReq.Messages[lastMsgIndex].Content
+ for j := len(blocks) - 1; j >= 0; j-- {
+ if blocks[j].Text != nil {
+ bedrockReq.Messages[lastMsgIndex].Content[j].Text = schemas.Ptr(strings.TrimRight(*blocks[j].Text, " \n\r\t"))
+ break
+ }
+ }
+ }
}
var responsesStructuredOutputTool *BedrockTool
From 8d195dbd821436597094496f8b58126b00ae72ac Mon Sep 17 00:00:00 2001
From: Akshay Deo
Date: Thu, 14 May 2026 20:31:38 +0530
Subject: [PATCH 43/81] harness updates (#3466)
---
Makefile | 108 +++-
tests/e2e/api/runners/filter-collection.mjs | 24 +-
tests/e2e/api/runners/harness-monitor.mjs | 561 ++++++++++++++++++++
tests/e2e/api/runners/pick-features.mjs | 181 +++++++
4 files changed, 853 insertions(+), 21 deletions(-)
create mode 100644 tests/e2e/api/runners/harness-monitor.mjs
create mode 100644 tests/e2e/api/runners/pick-features.mjs
diff --git a/Makefile b/Makefile
index 11adfa9de9..356a97e81e 100644
--- a/Makefile
+++ b/Makefile
@@ -1714,6 +1714,11 @@ run-provider-harness-test: $(if $(HELP),,install-newman) ## Run the Bifrost prov
VIEWER_PORT_VAL="$(or $(VIEWER_PORT),8090)"; \
STARTED_BY_US=0; \
cleanup() { \
+ if [ -f tmp/harness-monitor.pid ]; then \
+ MPID=$$(cat tmp/harness-monitor.pid); \
+ kill $$MPID 2>/dev/null; \
+ rm -f tmp/harness-monitor.pid; \
+ fi; \
if [ -f tmp/harness-viewer.pid ]; then \
VPID=$$(cat tmp/harness-viewer.pid); \
kill $$VPID 2>/dev/null; \
@@ -1744,6 +1749,25 @@ run-provider-harness-test: $(if $(HELP),,install-newman) ## Run the Bifrost prov
fi; \
}; \
trap cleanup EXIT INT TERM HUP; \
+ PICKED_FEATURES=""; \
+ if [ -t 0 ] && [ -t 1 ] && [ -z "$$CI" ] && [ -z "$(CI)" ] \
+ && [ -z "$(PROVIDER)" ] && [ -z "$(FEATURE)" ] && [ -z "$(FOLDER)" ] \
+ && [ -z "$(RERUN_FAILED)" ]; then \
+ $(USE_NODE); \
+ PICKED_FEATURES=$$(node tests/e2e/api/runners/pick-features.mjs); \
+ PICK_RC=$$?; \
+ case $$PICK_RC in \
+ 0) ;; \
+ 1) $(ECHO) "$(YELLOW)Cancelled.$(NC)"; exit 1 ;; \
+ 2) ;; \
+ *) exit $$PICK_RC ;; \
+ esac; \
+ if [ -n "$$PICKED_FEATURES" ]; then \
+ $(ECHO) "$(GREEN)Modalities: $$PICKED_FEATURES$(NC)"; \
+ else \
+ $(ECHO) "$(GREEN)Modalities: all (no filter)$(NC)"; \
+ fi; \
+ fi; \
if curl -fsS --max-time 2 "$$BASE_URL_VAL/health" > /dev/null 2>&1; then \
$(ECHO) "$(GREEN)Bifrost already running at $$BASE_URL_VAL$(NC)"; \
else \
@@ -1764,13 +1788,16 @@ run-provider-harness-test: $(if $(HELP),,install-newman) ## Run the Bifrost prov
fi; \
fi; \
COLLECTION_FILE="tests/e2e/api/collections/provider-harness.json"; \
- if [ -n "$(PROVIDER)" ] || [ -n "$(FEATURE)" ] || [ -n "$(RERUN_FAILED)" ]; then \
- $(ECHO) "$(CYAN)Filtering collection (provider=$(PROVIDER), feature=$(FEATURE), rerun-failed=$(RERUN_FAILED))...$(NC)"; \
+ FEATURE_ANY_FLAG=""; \
+ if [ -n "$$PICKED_FEATURES" ]; then FEATURE_ANY_FLAG="--feature-any $$PICKED_FEATURES"; fi; \
+ if [ -n "$(PROVIDER)" ] || [ -n "$(FEATURE)" ] || [ -n "$(RERUN_FAILED)" ] || [ -n "$$PICKED_FEATURES" ]; then \
+ $(ECHO) "$(CYAN)Filtering collection (provider=$(PROVIDER), feature=$(FEATURE), feature-any=$$PICKED_FEATURES, rerun-failed=$(RERUN_FAILED))...$(NC)"; \
$(USE_NODE); node tests/e2e/api/runners/filter-collection.mjs \
--source tests/e2e/api/collections/provider-harness.json \
--out tmp/harness-filtered.json \
$(if $(PROVIDER),--provider $(PROVIDER),) \
$(if $(FEATURE),--feature "$(FEATURE)",) \
+ $$FEATURE_ANY_FLAG \
$(if $(RERUN_FAILED),--rerun-failed --report tmp/newman-report.json,) || { $(ECHO) "$(RED)Filter step failed$(NC)"; exit 1; }; \
COLLECTION_FILE="tmp/harness-filtered.json"; \
fi; \
@@ -1814,20 +1841,36 @@ run-provider-harness-test: $(if $(HELP),,install-newman) ## Run the Bifrost prov
$(ECHO) "$(RED)No provider runs were launched. Check PROVIDER/FEATURE/FOLDER filters.$(NC)"; \
exit 1; \
fi; \
+ if [ -t 1 ] && [ -z "$$CI" ] && [ -z "$(CI)" ]; then \
+ $(USE_NODE); node tests/e2e/api/runners/harness-monitor.mjs \
+ --mode parallel \
+ --providers "$$PROVIDERS" \
+ --tmp-dir tmp \
+ --status-file tmp/parallel-status \
+ --launched $$LAUNCHED \
+ < /dev/null > /dev/tty 2>&1 & \
+ echo $$! > tmp/harness-monitor.pid; \
+ fi; \
PFAILED=0; \
while read pidp; do \
pid="$${pidp%%:*}"; \
p="$${pidp#*:}"; \
if wait "$$pid"; then \
echo "$$p:pass" >> tmp/parallel-status; \
- $(ECHO) "$(GREEN)[$$p] passed$(NC)"; \
+ if [ ! -f tmp/harness-monitor.pid ]; then $(ECHO) "$(GREEN)[$$p] passed$(NC)"; fi; \
else \
echo "$$p:fail" >> tmp/parallel-status; \
- $(ECHO) "$(RED)[$$p] failed$(NC)"; \
+ if [ ! -f tmp/harness-monitor.pid ]; then $(ECHO) "$(RED)[$$p] failed$(NC)"; fi; \
PFAILED=$$((PFAILED+1)); \
fi; \
- tail -n 20 "tmp/newman-cli-$$p.log" 2>/dev/null; \
+ if [ ! -f tmp/harness-monitor.pid ]; then tail -n 20 "tmp/newman-cli-$$p.log" 2>/dev/null; fi; \
done < tmp/parallel-pids; \
+ if [ -f tmp/harness-monitor.pid ]; then \
+ MPID=$$(cat tmp/harness-monitor.pid); \
+ kill -TERM $$MPID 2>/dev/null; \
+ wait $$MPID 2>/dev/null || true; \
+ rm -f tmp/harness-monitor.pid; \
+ fi; \
$(ECHO) "$(CYAN)Merging per-provider reports into tmp/newman-report.json...$(NC)"; \
if command -v jq >/dev/null 2>&1 && ls tmp/newman-report-*.json >/dev/null 2>&1; then \
jq -s '{collection: (.[0].collection // {}), environment: (.[0].environment // {}), run: {executions: [.[].run.executions[]?], failures: [.[].run.failures[]?], stats: {iterations: {total: 1, pending: 0, failed: 0}, items: {total: ([.[].run.stats.items.total // 0] | add)}, requests: {total: ([.[].run.stats.requests.total // 0] | add), failed: ([.[].run.stats.requests.failed // 0] | add)}}, timings: (.[0].run.timings // {})}}' tmp/newman-report-*.json > tmp/newman-report.json || $(ECHO) "$(YELLOW)Report merge failed; per-provider reports remain at tmp/newman-report-*.json$(NC)"; \
@@ -1847,18 +1890,49 @@ run-provider-harness-test: $(if $(HELP),,install-newman) ## Run the Bifrost prov
done < tmp/parallel-status; \
NEWMAN_EXIT=$$PFAILED; \
else \
- newman run "$$COLLECTION_FILE" \
- --env-var "baseUrl=$$BASE_URL_VAL" \
- $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \
- $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \
- $(if $(ENV_FILE),--environment $(ENV_FILE),) \
- $(if $(FOLDER),--folder "$(FOLDER)",) \
- --reporters cli,json,htmlextra \
- --reporter-json-export tmp/newman-report.json \
- --reporter-htmlextra-export tmp/newman-report.html \
- --reporter-htmlextra-title "Bifrost Provider Harness" \
- --reporter-htmlextra-darkTheme 2>&1 | tee tmp/newman-cli.log; \
- NEWMAN_EXIT=$$?; \
+ SEQ_PROVIDERS="$(PROVIDER)"; \
+ if [ -z "$$SEQ_PROVIDERS" ]; then SEQ_PROVIDERS="openai anthropic bedrock gemini vertex azure passthrough"; fi; \
+ if [ -t 1 ] && [ -z "$$CI" ] && [ -z "$(CI)" ]; then \
+ : > tmp/newman-cli.log; \
+ $(USE_NODE); node tests/e2e/api/runners/harness-monitor.mjs \
+ --mode sequential \
+ --providers "$$SEQ_PROVIDERS" \
+ --tmp-dir tmp \
+ --log tmp/newman-cli.log \
+ < /dev/null > /dev/tty 2>&1 & \
+ echo $$! > tmp/harness-monitor.pid; \
+ newman run "$$COLLECTION_FILE" \
+ --env-var "baseUrl=$$BASE_URL_VAL" \
+ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \
+ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \
+ $(if $(ENV_FILE),--environment $(ENV_FILE),) \
+ $(if $(FOLDER),--folder "$(FOLDER)",) \
+ --reporters cli,json,htmlextra \
+ --reporter-json-export tmp/newman-report.json \
+ --reporter-htmlextra-export tmp/newman-report.html \
+ --reporter-htmlextra-title "Bifrost Provider Harness" \
+ --reporter-htmlextra-darkTheme > tmp/newman-cli.log 2>&1; \
+ NEWMAN_EXIT=$$?; \
+ if [ -f tmp/harness-monitor.pid ]; then \
+ MPID=$$(cat tmp/harness-monitor.pid); \
+ kill -TERM $$MPID 2>/dev/null; \
+ wait $$MPID 2>/dev/null || true; \
+ rm -f tmp/harness-monitor.pid; \
+ fi; \
+ else \
+ newman run "$$COLLECTION_FILE" \
+ --env-var "baseUrl=$$BASE_URL_VAL" \
+ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \
+ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \
+ $(if $(ENV_FILE),--environment $(ENV_FILE),) \
+ $(if $(FOLDER),--folder "$(FOLDER)",) \
+ --reporters cli,json,htmlextra \
+ --reporter-json-export tmp/newman-report.json \
+ --reporter-htmlextra-export tmp/newman-report.html \
+ --reporter-htmlextra-title "Bifrost Provider Harness" \
+ --reporter-htmlextra-darkTheme 2>&1 | tee tmp/newman-cli.log; \
+ NEWMAN_EXIT=$$?; \
+ fi; \
fi; \
$(ECHO) "$(GREEN)Newman finished. Reports: tmp/newman-report.{json,html} + tmp/newman-cli.log$(NC)"; \
$(ECHO) "$(CYAN)Analyzing failures...$(NC)"; \
diff --git a/tests/e2e/api/runners/filter-collection.mjs b/tests/e2e/api/runners/filter-collection.mjs
index 562c34f04a..0352c1de4d 100644
--- a/tests/e2e/api/runners/filter-collection.mjs
+++ b/tests/e2e/api/runners/filter-collection.mjs
@@ -29,6 +29,9 @@ const SOURCE = args.source;
const OUT = args.out;
const PROVIDER = (args.provider || "").toLowerCase();
const FEATURE_PARTS = (args.feature || "").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
+// --feature-any is the OR-of-keywords counterpart of --feature (which ANDs). Item passes
+// if it matches at least one keyword. Combines with --feature/--provider via AND.
+const FEATURE_ANY_PARTS = (args["feature-any"] || "").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
const RERUN_FAILED = args["rerun-failed"] === "true";
const REPORT = args.report || "tmp/newman-report.json";
@@ -36,8 +39,8 @@ if (!SOURCE || !OUT) {
console.error("[filter-collection] --source and --out are required");
process.exit(2);
}
-if (!PROVIDER && !FEATURE_PARTS.length && !RERUN_FAILED) {
- console.error("[filter-collection] need at least one of: --provider, --feature, --rerun-failed");
+if (!PROVIDER && !FEATURE_PARTS.length && !FEATURE_ANY_PARTS.length && !RERUN_FAILED) {
+ console.error("[filter-collection] need at least one of: --provider, --feature, --feature-any, --rerun-failed");
process.exit(2);
}
@@ -93,6 +96,16 @@ const itemMatchesFeature = (item, ancestorNames) => {
});
};
+const itemMatchesFeatureAny = (item, ancestorNames) => {
+ if (!FEATURE_ANY_PARTS.length) return true;
+ const haystack = buildHaystack(item, ancestorNames);
+ return FEATURE_ANY_PARTS.some((p) => {
+ const structural = STRUCTURAL_KEYWORDS[p];
+ if (structural) return structural(item) || haystack.includes(p);
+ return haystack.includes(p);
+ });
+};
+
let failedNames = null;
const itemMatchesRerunFailed = (item) => {
if (!RERUN_FAILED) return true;
@@ -115,7 +128,10 @@ const itemMatchesRerunFailed = (item) => {
const passes = (item, ancestorNames) => {
if (!item.request) return true; // folders pass; we filter their items below
- return itemMatchesProvider(item, ancestorNames) && itemMatchesFeature(item, ancestorNames) && itemMatchesRerunFailed(item);
+ return itemMatchesProvider(item, ancestorNames) &&
+ itemMatchesFeature(item, ancestorNames) &&
+ itemMatchesFeatureAny(item, ancestorNames) &&
+ itemMatchesRerunFailed(item);
};
const filterTree = (items, ancestorNames = []) => {
@@ -135,4 +151,4 @@ const collection = JSON.parse(readFileSync(SOURCE, "utf8"));
const filtered = { ...collection, item: filterTree(collection.item || []) };
const totalAfter = JSON.stringify(filtered).match(/"request":/g)?.length || 0;
writeFileSync(OUT, JSON.stringify(filtered, null, 2));
-console.error(`[filter-collection] wrote ${OUT} with ${totalAfter} requests after filter (provider=${PROVIDER || "-"}, feature=${FEATURE_PARTS.join("+") || "-"}, rerun-failed=${RERUN_FAILED})`);
+console.error(`[filter-collection] wrote ${OUT} with ${totalAfter} requests after filter (provider=${PROVIDER || "-"}, feature=${FEATURE_PARTS.join("+") || "-"}, feature-any=${FEATURE_ANY_PARTS.join("|") || "-"}, rerun-failed=${RERUN_FAILED})`);
diff --git a/tests/e2e/api/runners/harness-monitor.mjs b/tests/e2e/api/runners/harness-monitor.mjs
new file mode 100644
index 0000000000..118c9a9fd6
--- /dev/null
+++ b/tests/e2e/api/runners/harness-monitor.mjs
@@ -0,0 +1,561 @@
+#!/usr/bin/env node
+// Live terminal progress monitor for `make run-provider-harness-test`.
+//
+// Tails per-provider newman CLI logs (parallel mode) or the merged CLI log
+// (sequential mode), aggregates pass/fail/% per provider with folder breakdown,
+// elapsed time + ETA, and most-recent failure text. Renders an in-place table.
+//
+// Usage:
+// node harness-monitor.mjs \
+// --mode parallel \
+// --providers "openai anthropic bedrock gemini vertex azure passthrough" \
+// --tmp-dir tmp \
+// --status-file tmp/parallel-status \
+// --launched 7
+//
+// node harness-monitor.mjs \
+// --mode sequential \
+// --providers "openai anthropic" \
+// --tmp-dir tmp \
+// --log tmp/newman-cli.log
+
+import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from "node:fs";
+import { join } from "node:path";
+
+const args = Object.fromEntries(
+ process.argv.slice(2).reduce((acc, cur, i, arr) => {
+ if (cur.startsWith("--")) {
+ const key = cur.slice(2);
+ const next = arr[i + 1];
+ acc.push([key, next && !next.startsWith("--") ? next : "true"]);
+ }
+ return acc;
+ }, [])
+);
+
+const MODE = args.mode === "sequential" ? "sequential" : "parallel";
+const PROVIDERS = (args.providers || "").trim().split(/\s+/).filter(Boolean);
+const TMP_DIR = args["tmp-dir"] || "tmp";
+const STATUS_FILE = args["status-file"] || join(TMP_DIR, "parallel-status");
+const LAUNCHED = parseInt(args.launched || String(PROVIDERS.length), 10);
+const SEQ_LOG = args.log || join(TMP_DIR, "newman-cli.log");
+const TAIL_INTERVAL_MS = 250;
+const RENDER_INTERVAL_MS = 1000;
+const IDLE_EXIT_MS = 3000;
+
+if (PROVIDERS.length === 0) {
+ console.error("[harness-monitor] --providers is required");
+ process.exit(2);
+}
+
+// Mirror filter-collection.mjs PROVIDER_KEYWORDS. Used only in sequential mode
+// to route folder/request lines (which lack a [provider] prefix) to a provider.
+const PROVIDER_KEYWORDS = {
+ openai: ["openai", "gpt-", "o3", "o1"],
+ anthropic: ["anthropic", "claude-"],
+ bedrock: ["bedrock"],
+ gemini: ["gemini", "genai", "googlesearch"],
+ vertex: ["vertex"],
+ azure: ["azure", "deployments"],
+ passthrough: ["_passthrough", "passthrough"],
+};
+
+const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
+const stripAnsi = (s) => s.replace(ANSI_RE, "");
+
+// State per provider. status transitions: pending -> running -> pass/fail/skipped.
+const state = {
+ startedAt: Date.now(),
+ mode: MODE,
+ providers: Object.fromEntries(
+ PROVIDERS.map((p) => [
+ p,
+ {
+ status: "pending",
+ totalRequests: 0,
+ doneRequests: 0,
+ pass: 0,
+ fail: 0,
+ folders: {},
+ folderOrder: [],
+ currentFolder: null,
+ currentRequest: null,
+ currentRequestDone: false,
+ currentRequestHadFail: false,
+ currentRequestFolder: null,
+ lastFailure: null,
+ },
+ ])
+ ),
+};
+let lastByteAt = Date.now();
+let lastRenderLines = 0;
+
+// ----- Denominator: walk the filtered collection per provider. ----------------
+
+function countLeaves(items, perFolder, topFolder) {
+ if (!Array.isArray(items)) return 0;
+ let total = 0;
+ for (const node of items) {
+ if (Array.isArray(node.item)) {
+ const next = topFolder ?? node.name ?? "(root)";
+ total += countLeaves(node.item, perFolder, next);
+ } else if (node.request) {
+ total += 1;
+ const folder = topFolder ?? "(root)";
+ if (!perFolder[folder]) perFolder[folder] = { total: 0, pass: 0, fail: 0 };
+ perFolder[folder].total += 1;
+ }
+ }
+ return total;
+}
+
+function loadDenominators() {
+ for (const p of PROVIDERS) {
+ const ps = state.providers[p];
+ // Parallel mode writes tmp/harness-filtered-.json per provider.
+ // Sequential mode writes tmp/harness-filtered.json once, or falls back to the source collection.
+ const candidates =
+ MODE === "parallel"
+ ? [join(TMP_DIR, `harness-filtered-${p}.json`)]
+ : [
+ join(TMP_DIR, "harness-filtered.json"),
+ "tests/e2e/api/collections/provider-harness.json",
+ ];
+ for (const path of candidates) {
+ if (!existsSync(path)) continue;
+ try {
+ const data = JSON.parse(readFileSync(path, "utf8"));
+ const folders = {};
+ const total = countLeaves(data.item || [], folders, null);
+ ps.totalRequests = total;
+ ps.folders = folders;
+ ps.folderOrder = Object.keys(folders);
+ break;
+ } catch {
+ // ignore - try next candidate
+ }
+ }
+ }
+}
+
+// ----- Tail: poll-based incremental read of newman CLI logs. ------------------
+
+const tails = new Map(); // path -> { provider, offset, buf }
+
+function ensureTail(path, provider) {
+ if (!tails.has(path)) tails.set(path, { provider, offset: 0, buf: "" });
+}
+
+function readNewBytes() {
+ for (const [path, h] of tails) {
+ let st;
+ try {
+ st = statSync(path);
+ } catch {
+ continue;
+ }
+ if (st.size <= h.offset) continue;
+ const len = st.size - h.offset;
+ const buf = Buffer.alloc(len);
+ let fd;
+ try {
+ fd = openSync(path, "r");
+ readSync(fd, buf, 0, len, h.offset);
+ } catch {
+ if (fd != null) try { closeSync(fd); } catch {}
+ continue;
+ }
+ closeSync(fd);
+ h.offset = st.size;
+ h.buf += buf.toString("utf8");
+ const lines = h.buf.split("\n");
+ h.buf = lines.pop();
+ for (const raw of lines) handleLine(stripAnsi(raw), h.provider);
+ lastByteAt = Date.now();
+ }
+}
+
+// ----- Parsing ----------------------------------------------------------------
+
+const RE_PREFIX = /^\[([a-z]+)\]\s?(.*)$/;
+const RE_FOLDER = /^❏\s+(.+?)\s*$/;
+const RE_REQUEST = /^↳\s+(.+?)\s*$/;
+const RE_REQUEST_DONE = /\[\s*\d+(?:\s+[A-Za-z]+)?,\s*[\d.]+\s*[kMG]?B,\s*[\d.]+\s*m?s\s*\]/;
+const RE_ASSERT_FAIL = /^\s*\d+\.\s+(.+?)$/;
+
+function inferProviderFromLine(line) {
+ const lower = line.toLowerCase();
+ for (const p of PROVIDERS) {
+ const kws = PROVIDER_KEYWORDS[p] || [p];
+ for (const k of kws) if (lower.includes(k)) return p;
+ }
+ return null;
+}
+
+// Newman emits per-request lines in this order: ↳ start, then the [size,duration]
+// summary, then ✓ pass-assertions, then numbered fail lines. So we can't commit
+// pass/fail at the summary line - we'd miss subsequent fail lines. Instead we
+// defer commit until the next ↳ / ❏ / finalizeAll().
+function finalizeRequest(ps) {
+ if (!ps.currentRequest) return;
+ if (ps.currentRequestDone) {
+ if (ps.currentRequestHadFail) ps.fail += 1;
+ else ps.pass += 1;
+ const f = ps.currentRequestFolder;
+ if (f && ps.folders[f]) {
+ if (ps.currentRequestHadFail) ps.folders[f].fail += 1;
+ else ps.folders[f].pass += 1;
+ }
+ }
+ ps.currentRequest = null;
+ ps.currentRequestDone = false;
+ ps.currentRequestHadFail = false;
+ ps.currentRequestFolder = null;
+}
+
+function finalizeAll() {
+ for (const p of PROVIDERS) finalizeRequest(state.providers[p]);
+}
+
+function handleLine(line, taggedProvider) {
+ let provider = taggedProvider;
+ let body = line;
+
+ if (MODE === "parallel") {
+ const m = line.match(RE_PREFIX);
+ if (m && state.providers[m[1]]) {
+ provider = m[1];
+ body = m[2];
+ } else if (!provider) {
+ return;
+ }
+ }
+
+ const ps = state.providers[provider];
+ if (!ps) return;
+ if (ps.status === "pending") ps.status = "running";
+
+ const trimmed = body.trimStart();
+
+ let m;
+ if ((m = trimmed.match(RE_FOLDER))) {
+ finalizeRequest(ps);
+ const folder = m[1].trim();
+ ps.currentFolder = folder;
+ if (!ps.folders[folder]) {
+ ps.folders[folder] = { total: 0, pass: 0, fail: 0 };
+ ps.folderOrder.push(folder);
+ }
+ return;
+ }
+ if ((m = trimmed.match(RE_REQUEST))) {
+ finalizeRequest(ps);
+ ps.currentRequest = m[1].trim();
+ ps.currentRequestDone = false;
+ ps.currentRequestHadFail = false;
+ ps.currentRequestFolder = ps.currentFolder;
+ return;
+ }
+ // Disambiguate request-done summary from assertion-fail; check done first.
+ if (RE_REQUEST_DONE.test(trimmed)) {
+ if (ps.currentRequest && !ps.currentRequestDone) {
+ ps.currentRequestDone = true;
+ ps.doneRequests += 1;
+ }
+ return;
+ }
+ if ((m = trimmed.match(RE_ASSERT_FAIL)) && ps.currentRequest) {
+ ps.currentRequestHadFail = true;
+ ps.lastFailure = { folder: ps.currentRequestFolder, text: m[1].trim() };
+ return;
+ }
+}
+
+// ----- Status file: pick up final pass/fail verdicts in parallel mode. --------
+
+function readStatusFile() {
+ if (MODE !== "parallel") return { lines: 0 };
+ if (!existsSync(STATUS_FILE)) return { lines: 0 };
+ let content;
+ try {
+ content = readFileSync(STATUS_FILE, "utf8");
+ } catch {
+ return { lines: 0 };
+ }
+ const lines = content.trim().split("\n").filter(Boolean);
+ for (const ln of lines) {
+ const [p, v] = ln.split(":");
+ const ps = state.providers[p];
+ if (!ps) continue;
+ if (v === "pass") ps.status = "pass";
+ else if (v === "fail") ps.status = "fail";
+ }
+ return { lines: lines.length };
+}
+
+// ----- Render -----------------------------------------------------------------
+
+const C = {
+ reset: "\x1b[0m",
+ bold: "\x1b[1m",
+ dim: "\x1b[2m",
+ red: "\x1b[31m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ cyan: "\x1b[36m",
+ gray: "\x1b[90m",
+};
+
+function fmtDuration(ms) {
+ if (!isFinite(ms) || ms < 0) return "--:--";
+ const s = Math.floor(ms / 1000);
+ const m = Math.floor(s / 60);
+ const r = s % 60;
+ return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
+}
+
+function truncate(s, n) {
+ if (!s) return "";
+ return s.length <= n ? s : s.slice(0, n - 1) + "…";
+}
+
+function padRight(s, n) {
+ const str = String(s);
+ return str.length >= n ? str.slice(0, n) : str + " ".repeat(n - str.length);
+}
+function padLeft(s, n) {
+ const str = String(s);
+ return str.length >= n ? str.slice(0, n) : " ".repeat(n - str.length) + str;
+}
+
+function statusGlyph(status) {
+ switch (status) {
+ case "pass": return `${C.green}✓${C.reset}`;
+ case "fail": return `${C.red}✗${C.reset}`;
+ case "running": return `${C.cyan}●${C.reset}`;
+ case "skipped": return `${C.gray}-${C.reset}`;
+ default: return `${C.gray}·${C.reset}`;
+ }
+}
+
+function renderFrame() {
+ const cols = process.stdout.columns || 120;
+
+ // Aggregate totals.
+ let aggDone = 0, aggTotal = 0, aggPass = 0, aggFail = 0;
+ for (const p of PROVIDERS) {
+ const ps = state.providers[p];
+ aggDone += ps.doneRequests;
+ aggTotal += ps.totalRequests;
+ aggPass += ps.pass;
+ aggFail += ps.fail;
+ }
+ const elapsed = Date.now() - state.startedAt;
+ const eta =
+ aggDone > 0 && aggTotal > aggDone ? elapsed * (aggTotal / aggDone - 1) : NaN;
+
+ const out = [];
+ out.push(
+ `${C.bold}Bifrost Provider Harness - live${C.reset}` +
+ ` ${C.dim}Elapsed${C.reset} ${fmtDuration(elapsed)}` +
+ ` ${C.dim}ETA${C.reset} ${fmtDuration(eta)}` +
+ ` ${C.dim}Mode${C.reset} ${state.mode}`
+ );
+
+ // Table width math: each cell consumes (width + 3) chars (" content │"),
+ // plus 1 leading "│". So total = 1 + 3N + sum(widths). Compute the failure
+ // column from terminal width to guarantee no row wraps. Drop the column
+ // entirely if there isn't even 20 chars left for it.
+ const fixed = [1, 12, 9, 5, 5, 5];
+ const fixedSum = fixed.reduce((a, b) => a + b, 0);
+ const overheadWith7 = 1 + 3 * 7; // 22
+ const overheadWith6 = 1 + 3 * 6; // 19
+ const targetWidth = Math.max(40, cols - 1);
+ const failColWidth = targetWidth - overheadWith7 - fixedSum;
+ const showFailureCol = failColWidth >= 20;
+ const headers = showFailureCol
+ ? ["", "Provider", "Done", "Pass", "Fail", "%", "Last failure"]
+ : ["", "Provider", "Done", "Pass", "Fail", "%"];
+ const widths = showFailureCol ? [...fixed, failColWidth] : fixed;
+
+ const sep = (left, mid, right, fill = "─") => {
+ let line = left;
+ for (let i = 0; i < widths.length; i++) {
+ line += fill.repeat(widths[i] + 2);
+ line += i === widths.length - 1 ? right : mid;
+ }
+ return line;
+ };
+
+ const row = (cells) => {
+ let line = "│";
+ for (let i = 0; i < cells.length; i++) {
+ line += " " + padRight(cells[i], widths[i]) + " │";
+ }
+ return line;
+ };
+
+ out.push(sep("┌", "┬", "┐"));
+ out.push(row(headers));
+ out.push(sep("├", "┼", "┤"));
+
+ for (const p of PROVIDERS) {
+ const ps = state.providers[p];
+ const pct = ps.totalRequests ? Math.floor((100 * ps.doneRequests) / ps.totalRequests) : 0;
+ const doneCell = `${padLeft(ps.doneRequests, 3)}/${padRight(ps.totalRequests, 3)}`;
+ const failCellRaw = ps.fail > 0 ? `${C.red}${padLeft(ps.fail, widths[4])}${C.reset}` : padLeft(ps.fail, widths[4]);
+ const cells = [
+ statusGlyph(ps.status),
+ p,
+ doneCell,
+ padLeft(ps.pass, widths[3]),
+ // failCell: pre-padded so the row() pad-right is a no-op for this cell
+ failCellRaw,
+ `${pct}%`,
+ ];
+ if (showFailureCol) {
+ cells.push(truncate(ps.lastFailure?.text || (ps.currentRequest || "-"), widths[6]));
+ }
+ out.push(rowWithRawCells(cells, widths));
+ }
+
+ out.push(sep("├", "┼", "┤"));
+ const totalPct = aggTotal ? Math.floor((100 * aggDone) / aggTotal) : 0;
+ const totalCells = [
+ "",
+ `${C.bold}TOTAL${C.reset}`,
+ `${padLeft(aggDone, 3)}/${padRight(aggTotal, 3)}`,
+ padLeft(aggPass, widths[3]),
+ aggFail > 0 ? `${C.red}${padLeft(aggFail, widths[4])}${C.reset}` : padLeft(aggFail, widths[4]),
+ `${totalPct}%`,
+ ];
+ if (showFailureCol) totalCells.push("");
+ out.push(rowWithRawCells(totalCells, widths));
+ out.push(sep("└", "┴", "┘"));
+
+ // Folder breakdown: show each running provider's currentFolder + last few folders.
+ out.push("");
+ out.push(`${C.bold}Current folders${C.reset}`);
+ for (const p of PROVIDERS) {
+ const ps = state.providers[p];
+ if (ps.totalRequests === 0) continue;
+ const cur = ps.currentFolder;
+ if (!cur) {
+ out.push(` ${padRight(p, 12)} ${C.gray}(waiting)${C.reset}`);
+ continue;
+ }
+ const f = ps.folders[cur] || { total: 0, pass: 0, fail: 0 };
+ const doneInFolder = f.pass + f.fail;
+ out.push(
+ ` ${padRight(p, 12)} ${C.cyan}${truncate(cur, 40)}${C.reset} ` +
+ `${doneInFolder}/${f.total} ` +
+ `(${C.green}✓ ${f.pass}${C.reset}, ${f.fail > 0 ? C.red : C.dim}✗ ${f.fail}${C.reset})`
+ );
+ }
+
+ return out;
+}
+
+// Cell may contain ANSI escapes; padRight in row() would break alignment. So
+// compute visible length, then pad with spaces externally.
+function rowWithRawCells(cells, widths) {
+ let line = "│";
+ for (let i = 0; i < cells.length; i++) {
+ const raw = String(cells[i]);
+ const visible = raw.replace(ANSI_RE, "");
+ const w = widths[i];
+ const padded = visible.length >= w ? raw : raw + " ".repeat(w - visible.length);
+ line += " " + padded + " │";
+ }
+ return line;
+}
+
+function draw() {
+ const lines = renderFrame();
+ const rows = process.stdout.rows || lines.length;
+ // Clamp to terminal height so we don't push the title off the top.
+ const visible = lines.slice(0, Math.max(1, rows - 1));
+ let out = "\x1b[H"; // cursor home (alt screen, so this is the buffer origin)
+ for (const ln of visible) out += ln + "\x1b[K\n";
+ out += "\x1b[J"; // clear from cursor to end-of-screen (wipes prior taller frame's tail)
+ process.stdout.write(out);
+ lastRenderLines = visible.length;
+}
+
+// ----- Lifecycle --------------------------------------------------------------
+
+function setupTails() {
+ if (MODE === "parallel") {
+ for (const p of PROVIDERS) {
+ ensureTail(join(TMP_DIR, `newman-cli-${p}.log`), p);
+ }
+ } else {
+ // Sequential: one shared log, provider inferred per-line.
+ ensureTail(SEQ_LOG, null);
+ }
+}
+
+function shouldExit() {
+ if (MODE === "parallel") {
+ const { lines } = readStatusFile();
+ if (lines >= LAUNCHED && Date.now() - lastByteAt > IDLE_EXIT_MS) return true;
+ } else {
+ // Sequential mode: rely on signals from the Makefile. Also exit when the
+ // log shows the newman "failures" summary block AND we've been idle.
+ if (Date.now() - lastByteAt > IDLE_EXIT_MS * 2 && lastRenderLines > 0) {
+ const allDone = PROVIDERS.every(
+ (p) => state.providers[p].totalRequests === 0 ||
+ state.providers[p].doneRequests >= state.providers[p].totalRequests
+ );
+ if (allDone) return true;
+ }
+ }
+ return false;
+}
+
+function teardown(code = 0) {
+ // Drain any pending bytes the tail timer hasn't picked up yet, then commit
+ // the trailing in-flight request before the final frame.
+ readNewBytes();
+ finalizeAll();
+ draw();
+ // Snapshot the final frame to stderr so it persists on the main screen
+ // after we leave the alt buffer (otherwise the user sees the table vanish).
+ const finalLines = renderFrame();
+ // Leave alt screen, restore cursor, then print the persistent snapshot.
+ process.stdout.write("\x1b[?25h\x1b[?1049l");
+ process.stderr.write(finalLines.join("\n") + "\n");
+ process.exit(code);
+}
+
+process.on("SIGTERM", () => teardown(0));
+process.on("SIGINT", () => teardown(130));
+process.on("SIGHUP", () => teardown(0));
+
+// Enter alt screen buffer + hide cursor + clear it. This gives us a fresh
+// canvas with a known origin so cursor-home redraws are deterministic and
+// the preamble (boot logs, launch messages) is preserved on the main screen.
+process.stdout.write("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25l");
+
+// Initial denominator pass; retry once a second until at least one provider has totals.
+loadDenominators();
+const denomTimer = setInterval(() => {
+ const haveAny = PROVIDERS.some((p) => state.providers[p].totalRequests > 0);
+ if (!haveAny) loadDenominators();
+ else clearInterval(denomTimer);
+}, 1000);
+
+setupTails();
+setInterval(() => {
+ readNewBytes();
+ readStatusFile();
+}, TAIL_INTERVAL_MS);
+
+setInterval(() => {
+ draw();
+ if (shouldExit()) teardown(0);
+}, RENDER_INTERVAL_MS);
+
+// Draw a first frame immediately so the user sees something.
+draw();
diff --git a/tests/e2e/api/runners/pick-features.mjs b/tests/e2e/api/runners/pick-features.mjs
new file mode 100644
index 0000000000..b65edf36f6
--- /dev/null
+++ b/tests/e2e/api/runners/pick-features.mjs
@@ -0,0 +1,181 @@
+#!/usr/bin/env node
+// Interactive multi-select picker for harness modalities (criss-cross matrix).
+//
+// Designed to be invoked via $(node pick-features.mjs) - we read keys from
+// stdin (raw mode), render the menu to stderr, and write only the final
+// selection (comma-separated, lowercase) to stdout so the Makefile can
+// capture it cleanly.
+//
+// Exit codes:
+// 0 - selection confirmed (stdout = comma-separated keywords; empty when all selected)
+// 1 - user cancelled (Esc / Ctrl+C / q)
+// 2 - not running on an interactive TTY (no menu shown; stdout empty)
+
+const FEATURES = [
+ { key: "chat", label: "Chat completions (text-only)" },
+ { key: "streaming", label: "Streaming responses (SSE)" },
+ { key: "embeddings", label: "Embeddings" },
+ { key: "audio", label: "Audio (speech / transcription)" },
+ { key: "image-gen", label: "Image generation" },
+ { key: "tools", label: "Tool / function calling" },
+ { key: "vision", label: "Vision (image input)" },
+ { key: "json", label: "Structured output (JSON mode / schema)" },
+ { key: "reasoning", label: "Reasoning / thinking" },
+];
+
+// Need a TTY on both stdin (for keys) and stderr (for menu render). stdout is
+// intentionally NOT required - it's the result channel, often piped via $(...).
+if (!process.stdin.isTTY || !process.stderr.isTTY) {
+ process.exit(2);
+}
+
+const writeUI = (s) => process.stderr.write(s);
+
+const selected = new Set(FEATURES.map((f) => f.key));
+let cursor = 0;
+let firstFrame = true;
+let lastLines = 0;
+
+const C = {
+ reset: "\x1b[0m",
+ bold: "\x1b[1m",
+ dim: "\x1b[2m",
+ cyan: "\x1b[36m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ gray: "\x1b[90m",
+};
+
+function render() {
+ const lines = [];
+ lines.push(`${C.bold}Bifrost harness - pick modalities${C.reset} ${C.dim}(space toggles, a=all, n=none, enter runs, q=cancel)${C.reset}`);
+ lines.push("");
+ for (let i = 0; i < FEATURES.length; i++) {
+ const f = FEATURES[i];
+ const box = selected.has(f.key) ? `${C.green}[x]${C.reset}` : "[ ]";
+ const arrow = i === cursor ? `${C.cyan}>${C.reset}` : " ";
+ const label = i === cursor ? `${C.bold}${f.label}${C.reset}` : f.label;
+ lines.push(` ${arrow} ${box} ${label} ${C.gray}${f.key}${C.reset}`);
+ }
+ lines.push("");
+ const n = selected.size;
+ const summary = n === FEATURES.length
+ ? `${C.dim}All modalities selected (no filter) - all providers will run${C.reset}`
+ : n === 0
+ ? `${C.yellow}No modalities selected - press space or 'a' to choose at least one${C.reset}`
+ : `${C.dim}${n} of ${FEATURES.length} selected: ${[...selected].join(", ")}${C.reset}`;
+ lines.push(summary);
+
+ let out = "";
+ if (firstFrame) {
+ writeUI("\x1b[?25l");
+ firstFrame = false;
+ } else if (lastLines > 0) {
+ out += `\x1b[${lastLines}A\x1b[0J`;
+ }
+ out += lines.join("\n") + "\n";
+ writeUI(out);
+ lastLines = lines.length;
+}
+
+function restoreTty() {
+ try { process.stdin.setRawMode(false); } catch {}
+ writeUI("\x1b[?25h");
+}
+
+function commit() {
+ restoreTty();
+ process.stdin.pause();
+ if (selected.size === 0) process.exit(1);
+ // Emit empty when all are selected so the Makefile takes the no-filter path.
+ if (selected.size < FEATURES.length) {
+ process.stdout.write([...selected].join(","));
+ }
+ process.exit(0);
+}
+
+function cancel() {
+ restoreTty();
+ process.stdin.pause();
+ process.stderr.write("\n[pick-features] cancelled\n");
+ process.exit(1);
+}
+
+process.on("SIGINT", cancel);
+process.on("SIGTERM", cancel);
+
+process.stdin.setRawMode(true);
+process.stdin.resume();
+process.stdin.setEncoding("utf8");
+
+process.stdin.on("data", (chunk) => {
+ for (const key of splitKeys(chunk)) handleKey(key);
+});
+
+function handleKey(key) {
+ if (key === "\x1b[A" || key === "k") {
+ cursor = (cursor - 1 + FEATURES.length) % FEATURES.length;
+ render();
+ return;
+ }
+ if (key === "\x1b[B" || key === "j") {
+ cursor = (cursor + 1) % FEATURES.length;
+ render();
+ return;
+ }
+ if (key === " ") {
+ const k = FEATURES[cursor].key;
+ if (selected.has(k)) selected.delete(k);
+ else selected.add(k);
+ render();
+ return;
+ }
+ if (key === "a") {
+ for (const f of FEATURES) selected.add(f.key);
+ render();
+ return;
+ }
+ if (key === "n") {
+ selected.clear();
+ render();
+ return;
+ }
+ if (key === "\r" || key === "\n") {
+ if (selected.size === 0) return;
+ commit();
+ return;
+ }
+ if (key === "\x1b" || key === "q" || key === "\x03") {
+ cancel();
+ return;
+ }
+}
+
+// Terminals batch fast keypresses into a single data chunk. Split on ESC
+// boundaries so an arrow-key escape sequence stays atomic.
+function splitKeys(chunk) {
+ const out = [];
+ let i = 0;
+ while (i < chunk.length) {
+ const ch = chunk[i];
+ if (ch === "\x1b") {
+ // CSI sequence: ESC [
+ if (chunk[i + 1] === "[") {
+ let j = i + 2;
+ while (j < chunk.length && !/[A-Za-z~]/.test(chunk[j])) j++;
+ out.push(chunk.slice(i, j + 1));
+ i = j + 1;
+ continue;
+ }
+ // bare ESC
+ out.push("\x1b");
+ i++;
+ continue;
+ }
+ out.push(ch);
+ i++;
+ }
+ return out;
+}
+
+render();
From 9a1af9d20ecb8e57b76286c753f077fdcc94d335 Mon Sep 17 00:00:00 2001
From: Madhu Shantan
Date: Thu, 14 May 2026 22:50:18 +0530
Subject: [PATCH 44/81] [docs]: docs for patronus ai guardrail provider (#3508)
## Summary
Updates the Patronus AI guardrail integration docs to reflect the current configuration schema, replacing the old `environment`/`sampling_rate` fields with the evaluator-based model. Also rewrites the Patronus AI integration page from a stub into full reference documentation, and corrects the guardrails management API examples to use the current endpoint paths and camelCase field names.
## Changes
- Replaced `environment` and `sampling_rate` config fields with `evaluators` (array), `base_url`, and `capture` across all Patronus AI config examples in the config.json and Helm deployment guides
- Rewrote `docs/integrations/guardrails/patronus-ai.mdx` from a short capability list into a complete reference page covering how the integration works, all configuration fields, capture modes, explain strategies, built-in UI presets, and full configuration examples for Web UI, API, config.json, and Helm
- Updated the enterprise guardrails API examples to use the correct endpoint paths (`/api/guardrails/rules`, `/api/guardrails/{provider}` instead of `/api/enterprise/guardrails/...`), camelCase request fields (`celExpression`, `applyTo`, `samplingRate`, `selectedGuardrailProfiles`), and the correct `PUT`/`DELETE` request shapes
- Corrected the profile listing response format to show the grouped-by-provider array structure
- Added a note that `PUT` on rules requires the full rule body, not a partial patch
- Added a screenshot asset for the Patronus AI UI configuration panel
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [x] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [x] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [x] Docs
## How to test
Verify the documentation renders correctly and that all code examples match the live API behavior:
1. Confirm `POST /api/guardrails/patronus-ai` accepts the `evaluators` array with `evaluator`, `criteria`, and `explain_strategy` fields.
2. Confirm `GET /api/guardrails` returns the grouped-by-provider array format shown in the updated example.
3. Confirm `POST /api/guardrails/rules` accepts `selectedGuardrailProfiles` as `":"` strings.
4. Confirm `PUT /api/guardrails/rules/{id}` requires the full rule body.
5. Confirm `DELETE /api/guardrails/{provider}` accepts a JSON body with `{"id": }`.
## Screenshots/Recordings
A new screenshot (`docs/media/ui-patronus-config.png`) has been added showing the Patronus AI configuration panel in the Bifrost dashboard.
## Breaking changes
- [x] Yes
- [ ] No
The Patronus AI provider config schema has changed. Existing configurations using `environment` and `sampling_rate` fields must be migrated to use the `evaluators` array and `capture` field. The `api_endpoint` field is replaced by `base_url`.
## Related issues
## Security considerations
The `api_key` field supports `env.PATRONUS_API_KEY` to avoid embedding secrets directly in configuration files. No new secret handling paths are introduced.
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable
---
.../config-json/guardrails.mdx | 25 +-
docs/deployment-guides/helm/guardrails.mdx | 20 +-
docs/enterprise/guardrails.mdx | 120 +++++----
docs/integrations/guardrails/patronus-ai.mdx | 235 +++++++++++++++++-
docs/media/ui-patronus-config.png | Bin 0 -> 382813 bytes
5 files changed, 336 insertions(+), 64 deletions(-)
create mode 100644 docs/media/ui-patronus-config.png
diff --git a/docs/deployment-guides/config-json/guardrails.mdx b/docs/deployment-guides/config-json/guardrails.mdx
index efb68a0edb..c5b2cbbb9b 100644
--- a/docs/deployment-guides/config-json/guardrails.mdx
+++ b/docs/deployment-guides/config-json/guardrails.mdx
@@ -199,8 +199,19 @@ For `auth_type: "default_credential"` (managed identity / Azure CLI - no credent
"timeout": 30,
"config": {
"api_key": "env.PATRONUS_API_KEY",
- "environment": "production",
- "sampling_rate": 100
+ "base_url": "https://api.patronus.ai",
+ "evaluators": [
+ {
+ "evaluator": "pii",
+ "explain_strategy": "on-fail"
+ },
+ {
+ "evaluator": "judge",
+ "criteria": "patronus:is-concise",
+ "explain_strategy": "on-fail"
+ }
+ ],
+ "capture": "none"
}
}
]
@@ -301,9 +312,13 @@ Any field marked **env.\* supported** accepts a bare `"env.VAR_NAME"` string in
| Field | Required | env.\* supported | Notes |
|-------|----------|-----------------|-------|
| `api_key` | Yes | Yes | Patronus AI API key |
-| `environment` | No | Yes | `"production"` (default) \| `"development"` |
-| `sampling_rate` | No | **Plain only** | `0`–`100`; percentage of requests to evaluate (default: `100`) |
-| `timeout` | No | **Plain only** | Execution timeout in seconds |
+| `base_url` | No | Yes | Custom Patronus API base URL. Defaults to `https://api.patronus.ai` |
+| `evaluators` | Yes | **Plain only** | Array of Patronus evaluator objects |
+| `evaluators[].evaluator` | Yes | **Plain only** | Patronus evaluator name, such as `pii`, `toxicity-perspective-api`, `judge`, or a custom evaluator ID |
+| `evaluators[].criteria` | No | **Plain only** | Criteria/profile name for evaluators that require one, such as `patronus:is-concise` |
+| `evaluators[].explain_strategy` | No | **Plain only** | `never` \| `on-fail` \| `on-success` \| `always` |
+| `capture` | No | **Plain only** | `none` \| `fails-only` \| `all`; defaults to `none` |
+| `timeout` | No | **Plain only** | Provider execution timeout in seconds |
### Gray Swan
diff --git a/docs/deployment-guides/helm/guardrails.mdx b/docs/deployment-guides/helm/guardrails.mdx
index ca7073c9d1..cd2cb05543 100644
--- a/docs/deployment-guides/helm/guardrails.mdx
+++ b/docs/deployment-guides/helm/guardrails.mdx
@@ -167,8 +167,14 @@ bifrost:
timeout: 30
config:
api_key: "env.PATRONUS_API_KEY"
- environment: "production" # production | development (env.* supported)
- sampling_rate: 100
+ base_url: "https://api.patronus.ai" # optional custom endpoint (env.* supported)
+ evaluators:
+ - evaluator: "pii"
+ explain_strategy: "on-fail"
+ - evaluator: "judge"
+ criteria: "patronus:is-concise"
+ explain_strategy: "on-fail"
+ capture: "none" # none | fails-only | all
```
@@ -249,9 +255,13 @@ Any field marked **env.\* supported** below accepts a bare `"env.VAR_NAME"` stri
| Field | Required | env.\* supported | Notes |
|-------|----------|-----------------|-------|
| `api_key` | Yes | Yes | Patronus AI API key |
-| `environment` | No | Yes | `"production"` (default) \| `"development"` |
-| `sampling_rate` | No | **Plain only** | `0`–`100`; percentage of requests to evaluate (default: `100`) |
-| `timeout` | No | **Plain only** | Execution timeout in seconds |
+| `base_url` | No | Yes | Custom Patronus API base URL. Defaults to `https://api.patronus.ai` |
+| `evaluators` | Yes | **Plain only** | Array of Patronus evaluator objects |
+| `evaluators[].evaluator` | Yes | **Plain only** | Patronus evaluator name, such as `pii`, `toxicity-perspective-api`, `judge`, or a custom evaluator ID |
+| `evaluators[].criteria` | No | **Plain only** | Criteria/profile name for evaluators that require one, such as `patronus:is-concise` |
+| `evaluators[].explain_strategy` | No | **Plain only** | `never` \| `on-fail` \| `on-success` \| `always` |
+| `capture` | No | **Plain only** | `none` \| `fails-only` \| `all`; defaults to `none` |
+| `timeout` | No | **Plain only** | Provider execution timeout in seconds |
### Gray Swan
diff --git a/docs/enterprise/guardrails.mdx b/docs/enterprise/guardrails.mdx
index b4def12dfe..1259cc6d8b 100644
--- a/docs/enterprise/guardrails.mdx
+++ b/docs/enterprise/guardrails.mdx
@@ -184,59 +184,71 @@ Guardrail Rules are custom policies that define when and how content validation
+The HTTP API uses camelCase field names (`celExpression`, `applyTo`, `samplingRate`, `selectedGuardrailProfiles`). Profiles are referenced as `":"` strings (for example, `"regex:1"`, `"patronus-ai:6"`).
+
**Create a Guardrail Rule:**
```bash
-curl -X POST http://localhost:8080/api/enterprise/guardrails/rules \
+curl -X POST http://localhost:8080/api/guardrails/rules \
-H "Content-Type: application/json" \
-d '{
- "id": 1,
"name": "Block PII in Prompts",
"description": "Prevent PII from being sent to LLM providers",
"enabled": true,
- "cel_expression": "request.messages.exists(m, m.role == \"user\")",
- "apply_to": "input",
- "sampling_rate": 100,
+ "celExpression": "request.messages.exists(m, m.role == \"user\")",
+ "applyTo": "input",
+ "samplingRate": 100,
"timeout": 5000,
- "provider_config_ids": [1, 2]
+ "selectedGuardrailProfiles": ["regex:1", "bedrock:2"]
}'
```
**List All Rules:**
```bash
-curl -X GET http://localhost:8080/api/enterprise/guardrails/rules \
+curl -X GET http://localhost:8080/api/guardrails/rules \
-H "Content-Type: application/json"
# Response
{
+ "count": 1,
+ "limit": 1,
+ "offset": 0,
"rules": [
{
"id": 1,
"name": "Block PII in Prompts",
"description": "Prevent PII from being sent to LLM providers",
"enabled": true,
- "cel_expression": "request.messages.exists(m, m.role == \"user\")",
- "apply_to": "input",
- "sampling_rate": 100,
+ "celExpression": "request.messages.exists(m, m.role == \"user\")",
+ "applyTo": "input",
+ "samplingRate": 100,
"timeout": 5000,
- "provider_config_ids": [1, 2]
+ "selectedGuardrailProfiles": ["regex:1", "bedrock:2"]
}
]
}
```
**Update a Rule:**
+
+`PUT` revalidates against the full rule schema. Send the complete rule body (same shape as `POST`), not a patch.
```bash
-curl -X PUT http://localhost:8080/api/enterprise/guardrails/rules/1 \
+curl -X PUT http://localhost:8080/api/guardrails/rules/1 \
-H "Content-Type: application/json" \
-d '{
+ "name": "Block PII in Prompts",
+ "description": "Prevent PII from being sent to LLM providers",
"enabled": false,
- "sampling_rate": 50
+ "celExpression": "request.messages.exists(m, m.role == \"user\")",
+ "applyTo": "input",
+ "samplingRate": 50,
+ "timeout": 5000,
+ "selectedGuardrailProfiles": ["regex:1", "bedrock:2"]
}'
```
**Delete a Rule:**
```bash
-curl -X DELETE http://localhost:8080/api/enterprise/guardrails/rules/1
+curl -X DELETE http://localhost:8080/api/guardrails/rules/1
```
@@ -411,14 +423,14 @@ Profiles are reusable configurations for guardrail providers. External providers
+Profiles are managed per provider type at `/api/guardrails/{provider}`, where `{provider}` is one of `secrets`, `regex`, `bedrock`, `azure`, `grayswan`, or `patronus-ai`. The API assigns the configuration ID after creation.
+
**Create a Profile:**
```bash
-curl -X POST http://localhost:8080/api/enterprise/guardrails/providers \
+curl -X POST http://localhost:8080/api/guardrails/bedrock \
-H "Content-Type: application/json" \
-d '{
- "id": 1,
- "provider_name": "bedrock",
- "policy_name": "PII Detection Profile",
+ "name": "PII Detection Profile",
"enabled": true,
"config": {
"access_key": "env.AWS_ACCESS_KEY_ID",
@@ -430,42 +442,46 @@ curl -X POST http://localhost:8080/api/enterprise/guardrails/providers \
}'
```
-**List All Profiles:**
+**List All Profiles (grouped by provider):**
```bash
-curl -X GET http://localhost:8080/api/enterprise/guardrails/providers \
+curl -X GET http://localhost:8080/api/guardrails \
-H "Content-Type: application/json"
# Response
-{
- "providers": [
- {
- "id": 1,
- "provider_name": "bedrock",
- "policy_name": "PII Detection Profile",
- "enabled": true
- },
- {
- "id": 2,
- "provider_name": "azure",
- "policy_name": "Content Safety Profile",
- "enabled": true
- }
- ]
-}
+[
+ {
+ "name": "regex",
+ "configs": [
+ { "id": 1, "name": "PII Detection", "enabled": true, "patterns": [...] }
+ ]
+ },
+ {
+ "name": "bedrock",
+ "configs": [
+ { "id": 2, "name": "PII Detection Profile", "enabled": true, "guardrail_arn": "...", "region": "us-east-1" }
+ ]
+ }
+]
```
+To list only a single provider's profiles, hit the provider path directly: `GET /api/guardrails/bedrock`.
+
**Update a Profile:**
```bash
-curl -X PUT http://localhost:8080/api/enterprise/guardrails/providers/1 \
+curl -X PUT http://localhost:8080/api/guardrails/bedrock \
-H "Content-Type: application/json" \
-d '{
+ "id": 1,
+ "name": "PII Detection Profile",
"enabled": false
}'
```
**Delete a Profile:**
```bash
-curl -X DELETE http://localhost:8080/api/enterprise/guardrails/providers/1
+curl -X DELETE http://localhost:8080/api/guardrails/bedrock \
+ -H "Content-Type: application/json" \
+ -d '{"id": 1}'
```
@@ -541,11 +557,23 @@ curl -X DELETE http://localhost:8080/api/enterprise/guardrails/providers/1
{
"id": 6,
"provider_name": "patronus-ai",
- "policy_name": "Hallucination Detection",
+ "policy_name": "Patronus Quality Checks",
"enabled": true,
"config": {
"api_key": "env.PATRONUS_API_KEY",
- "api_endpoint": "https://api.patronus.ai/v1"
+ "base_url": "https://api.patronus.ai",
+ "evaluators": [
+ {
+ "evaluator": "pii",
+ "explain_strategy": "on-fail"
+ },
+ {
+ "evaluator": "judge",
+ "criteria": "patronus:is-concise",
+ "explain_strategy": "on-fail"
+ }
+ ],
+ "capture": "none"
}
}
]
@@ -615,10 +643,18 @@ guardrails_config:
professional_tone: "Ensure responses maintain a professional tone"
- id: 6
provider_name: "patronus-ai"
- policy_name: "Hallucination Detection"
+ policy_name: "Patronus Quality Checks"
enabled: true
config:
- api_endpoint: "https://api.patronus.ai/v1"
+ api_key: "env.PATRONUS_API_KEY"
+ base_url: "https://api.patronus.ai"
+ evaluators:
+ - evaluator: "pii"
+ explain_strategy: "on-fail"
+ - evaluator: "judge"
+ criteria: "patronus:is-concise"
+ explain_strategy: "on-fail"
+ capture: "none"
```
diff --git a/docs/integrations/guardrails/patronus-ai.mdx b/docs/integrations/guardrails/patronus-ai.mdx
index 3c2f2bfe32..b5f41471ba 100644
--- a/docs/integrations/guardrails/patronus-ai.mdx
+++ b/docs/integrations/guardrails/patronus-ai.mdx
@@ -4,23 +4,234 @@ description: "Integrate Patronus AI with Bifrost for LLM security and safety inc
icon: "brain"
---
-Bifrost integrates with **Patronus AI** to provide specialized LLM security and safety with advanced evaluation capabilities. This page covers the configuration and capabilities of the Patronus AI guardrail provider.
+## Overview
+
+Bifrost Enterprise supports [**Patronus AI**](https://www.patronus.ai/) as a third-party guardrail provider for evaluating LLM request and response text with Patronus evaluators.
+
+Use it when you want evaluator-based checks such as PII detection, toxicity screening, prompt-injection checks, response quality criteria, or custom evaluators from your Patronus account.
+
+## How It Works
+
+You'll need a Patronus API key to authenticate with their Evaluate API - grab one from the [Patronus dashboard](https://app.patronus.ai/experiments).
+
+1. You create a guardrail provider with `provider_name: "patronus-ai"` and your Patronus API key.
+2. You configure one or more Patronus evaluators.
+3. You attach that provider to a guardrail rule.
+4. The rule decides when to run the provider and whether to evaluate `input`, `output`, or `both`.
+5. Bifrost calls the Patronus Evaluate API at `/v1/evaluate`.
+6. If any evaluator returns `pass: false`, Bifrost returns `GUARDRAIL_INTERVENED`.
+
+The Patronus evaluator flow supported here is text-based: Bifrost sends selected request or response text as the evaluation input.
## Capabilities
-- **Hallucination Detection**: Identify factually incorrect responses
-- **PII Detection**: Comprehensive personal data identification
-- **Toxicity Screening**: Multi-language toxic content detection
-- **Prompt Injection Defense**: Advanced attack pattern recognition
-- **Custom Evaluators**: Build organization-specific safety checks
-- **Real-Time Monitoring**: Continuous safety validation
+- **PII Detection**: Identify personally identifiable information using Patronus evaluators
+- **Toxicity Screening**: Evaluate text for toxic or unsafe content
+- **Prompt Injection Checks**: Use Patronus judge criteria such as `patronus:prompt-injection`
+- **Response Quality Checks**: Evaluate outputs for criteria such as conciseness, helpfulness, politeness, JSON validity, code validity, or CSV validity
+- **Bias Checks**: Use Patronus criteria for age, gender, and racial bias checks
+- **Custom Evaluators**: Use evaluator IDs and criteria configured in your Patronus account
+
+## Configuration Fields
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `api_key` | string | Yes | - | Patronus API key. Supports `env.PATRONUS_API_KEY`. |
+| `base_url` | string | No | `https://api.patronus.ai` | Custom Patronus API base URL. Bifrost appends `/v1/evaluate`. |
+| `evaluators` | array | Yes | - | Patronus evaluator entries to run. At least one is required. |
+| `capture` | enum | No | `none` | Controls whether Patronus stores evaluation results: `none`, `fails-only`, or `all`. |
+| `timeout` | integer | No | `30` | Provider execution timeout in seconds. |
+
+### Evaluator Fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `evaluator` | string | Yes | Patronus evaluator name, such as `pii`, `toxicity-perspective-api`, `judge`, or a custom evaluator ID. |
+| `criteria` | string | No | Criteria/profile name for evaluators that require one, for example `patronus:is-concise`. |
+| `explain_strategy` | enum | No | When to include evaluator explanations: `never`, `on-fail`, `on-success`, or `always`. |
+
+### Capture Modes
+
+Captured evaluation results appear under the **Traces** section in the Patronus dashboard.
+
+| Value | Meaning |
+|-------|---------|
+| `none` | Do not capture evaluation results in Patronus. |
+| `fails-only` | Capture only failed evaluator results in Patronus. |
+| `all` | Capture all evaluator results in Patronus. |
+
+### Explanation Response Modes
+
+| Value | Meaning |
+|-------|---------|
+| `never` | Do not request evaluator explanations. |
+| `on-fail` | Request explanations for failed evaluator results. |
+| `on-success` | Request explanations for passed evaluator results. |
+| `always` | Request explanations for all evaluator results. |
+
+## Built-In UI Presets
+
+The Bifrost dashboard exposes common Patronus evaluator presets:
+
+| Preset | Evaluator | Criteria |
+|--------|-----------|----------|
+| Detect PII | `pii` | - |
+| Detect Toxicity | `toxicity-perspective-api` | - |
+| Prompt Injection | `judge` | `patronus:prompt-injection` |
+| Answer Refusal | `judge` | `patronus:answer-refusal` |
+| Is Concise | `judge` | `patronus:is-concise` |
+| Is Helpful | `judge` | `patronus:is-helpful` |
+| Is Polite | `judge` | `patronus:is-polite` |
+| No Apologies | `judge` | `patronus:no-apologies` |
+| No OpenAI Reference | `judge` | `patronus:no-openai-reference` |
+| No Age Bias | `judge` | `patronus:no-age-bias` |
+| No Gender Bias | `judge` | `patronus:no-gender-bias` |
+| No Racial Bias | `judge` | `patronus:no-racial-bias` |
+| Is JSON | `judge` | `patronus:is-json` |
+| Is Code | `judge` | `patronus:is-code` |
+| Is CSV | `judge` | `patronus:is-csv` |
+
+You can also select **Custom evaluator** and provide your own `evaluator` and optional `criteria`.
+
+## Configuration
+
+
+
+
+1. Go to **Guardrails** > **Providers**.
+2. Select **Patronus AI**.
+3. Click **Add Configuration**.
+
+
+
+
+
+4. Enter a descriptive **Name**.
+5. Set your **API Key** directly or through an environment variable.
+6. Leave **Base URL** empty or use the default `https://api.patronus.ai`, or set a custom Patronus endpoint.
+7. Add one or more evaluators.
+8. Choose a **Capture** mode. Bifrost defaults to **None**.
+9. Set the timeout and save the configuration.
+10. Attach the configuration to a guardrail rule under **Guardrails** > **Configuration**.
+
+
+
+
+Create the Patronus AI provider configuration directly with the management API. The Enterprise backend registers guardrail provider APIs at `/api/guardrails/{provider}`; the provider type is the path segment (`patronus-ai`), and the API assigns the configuration ID after creation.
+
+```bash
+curl -X POST http://localhost:8080/api/guardrails/patronus-ai \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "patronus-quality-checks",
+ "enabled": true,
+ "config": {
+ "api_key": "env.PATRONUS_API_KEY",
+ "base_url": "https://api.patronus.ai",
+ "evaluators": [
+ {
+ "evaluator": "pii",
+ "explain_strategy": "on-fail"
+ },
+ {
+ "evaluator": "judge",
+ "criteria": "patronus:is-concise",
+ "explain_strategy": "on-fail"
+ }
+ ],
+ "capture": "none",
+ "timeout": 30
+ }
+ }'
+```
+
+To attach it to a rule, fetch the generated config ID with `GET /api/guardrails/patronus-ai`, then reference it in `selectedGuardrailProfiles` (in the form `patronus-ai:`) on `POST /api/guardrails/rules`.
+
+
+
+
+```json
+{
+ "guardrails_config": {
+ "guardrail_providers": [
+ {
+ "id": 40,
+ "provider_name": "patronus-ai",
+ "policy_name": "patronus-quality-checks",
+ "enabled": true,
+ "timeout": 30,
+ "config": {
+ "api_key": "env.PATRONUS_API_KEY",
+ "base_url": "https://api.patronus.ai",
+ "evaluators": [
+ {
+ "evaluator": "pii",
+ "explain_strategy": "on-fail"
+ },
+ {
+ "evaluator": "judge",
+ "criteria": "patronus:is-concise",
+ "explain_strategy": "on-fail"
+ }
+ ],
+ "capture": "none"
+ }
+ }
+ ],
+ "guardrail_rules": [
+ {
+ "id": 401,
+ "name": "patronus-openai-output",
+ "description": "Run Patronus checks on OpenAI responses",
+ "enabled": true,
+ "cel_expression": "provider == 'openai'",
+ "apply_to": "output",
+ "sampling_rate": 100,
+ "timeout": 30,
+ "provider_config_ids": [40]
+ }
+ ]
+ }
+}
+```
+
+
+
+
+```yaml
+bifrost:
+ guardrails:
+ providers:
+ - id: 40
+ provider_name: "patronus-ai"
+ policy_name: "patronus-quality-checks"
+ enabled: true
+ timeout: 30
+ config:
+ api_key: "env.PATRONUS_API_KEY"
+ base_url: "https://api.patronus.ai"
+ evaluators:
+ - evaluator: "pii"
+ explain_strategy: "on-fail"
+ - evaluator: "judge"
+ criteria: "patronus:is-concise"
+ explain_strategy: "on-fail"
+ capture: "none"
-## Advanced Features
+ rules:
+ - id: 401
+ name: "patronus-openai-output"
+ description: "Run Patronus checks on OpenAI responses"
+ enabled: true
+ cel_expression: "provider == 'openai'"
+ apply_to: "output"
+ sampling_rate: 100
+ timeout: 30
+ provider_config_ids: [40]
+```
-- Context-aware evaluation
-- Multi-turn conversation analysis
-- Custom policy templates
-- Integration with existing safety workflows
+
+
## Provider Capabilities Comparison
diff --git a/docs/media/ui-patronus-config.png b/docs/media/ui-patronus-config.png
new file mode 100644
index 0000000000000000000000000000000000000000..c03dcb17cdeb74154e37e3e1886ae9eb0129ed38
GIT binary patch
literal 382813
zcmbSz2|Sc*`@cPsREm%#LY63NC0nA9B@$vt_I1i`EJG@aED<65o_$}(Xtj==!B{H0
zu`e?iX8yOcyr=j5b
z1;?<$UU?ug0OBI^T{qgZRRDP}-?HRu8$ug$>sxnC6K^pJV
znvlvjM;}Sw6mV!B>A!YSoXVuK_B&f(|IPSXO
zoQu3lRd?a6eE8EjPQ4AvNAteqlsONXGGmV#gITAtP*c`9!azpuOgXs%-aA?us#$Al
zk_my=v}9D|Y-H5n6*>5kCujfHYgO{|WR$-hrywH>wj-nZ;~XvU`{2Jw@N@8;-+xo2
zQv7)aeN`&upRZ}$4o(#^j5`K?(LK6l5M4`14J4w%Hym{mId;Eu+V-
z-O#E3WFB%b(4TJq;BDkZih=j~ar~O%boSbBc|Sh?&l7$>Uon>~a4C)FHt%n5k7X$8
z>b|~Pcpp_Y7~p{#)KdD@u(^AvBrhpvsJ^ml4(G76*RBZqg!}c?0|XQw)ZhH@#}q$f
zx}~L6N^h$utF-j2JVWaL-gJQCJY9dWnHg?*Od$9uhY;dT8UNqSN+bnU70ZdRncqjA
z8({5Ftjjn2@QnOB%EQ07G8qM(piZ^(b3g32WVvCwT0h5=zdVbU`x_Olqvweu$A4Sx
z^G_A0+w>hBQ2oh?iN+y(b}xT1*Wk8kGc)hLsi?SGX3-qpYjoD(mlFpj^W|B^2R@fp
z4Nv~IvOf;rEixHi-izYBDI`ka6aI^9M|M-#+1YszTwJ^+A6(};FMagahFAdB@g42&
zlW|gsTToD}L{28I3CFWq`b!I^qd&*Tr)F>O)Y^JQWOq>M*K-E!Xb#M?!0z#fgx~D%
zgl?*Cmbyp1i>oV>3t{N|FIFe;t6Jqy}RkbzC1(|>1Dzj8}IA4`=yxD!pM!DqiPuRq81H={WC
z#xZ_ivZv`z{lh8$bd@YY@O3-;a?yWUoL>+8;2VJ#$tf_OZsq>M_x?P{zq`PBP4IOM
zy+rr3|8$RkKeVTG$A&mnuT%fp^8Rsc!9^AA4j&sbO&BSl|Cbs5-HrZoeB8GKns66S
z2>t(z^cNRU*8^Yg?YD;f{rLWI1m+a_AMMp^e#9C(3x1cHa#!%*^NN@CEIM<=CG+lJ
zv-Lb`OAlE8Cu`+q2G(YAN9ac4a|ZUdkmJ0W_bct6844B8H~JAQ
zXFHS1oG{Wx<<_qXOlozHa$G&Br=b7-Zj`*=?yZ$^MAp5+`&ALG(E{xspV3|`0wcxt
z)#19^s;bW`oX76(Zea!3=Hwj)MdB}6Jm+9E<0)1R>OI83W=V;GRCz7Uc1U!cWbjIn
zu+geeaq@$~>W;8W-@aevDCq269boN;R`mJ1Q1Z{~s-sJx#y4KKkuFD5%mRtGv3O~l8zt(ly
zVi~YuhyS;!p*@%y^1@V=9~`trtOFoRWR#;t+XEqNz59a%s^>qX~mvZetv6d
zq>@)ugYSRx3p#8R`sx;ZM0XLRvd;=eJd>i`_136_*NKVjk2wwE7-JW1!#p(3%~hQ&q4;S%$pGxENM($1r&^XS}FL2{s`b#?xntQ$A1^MY~J?R&+h
zOYcwf1T-;H3eu=PDH1**VyfI)cD{qQD+fNBZx76fcyq@4q!t}ehlg-;!@9`z?va5Kh|1yR3fu5Q25;&3s|2h(H3Db)%8E>kxm<+a+&!8x1dbUm>)Fpm*zz;Iv
z9*8BTldqZi?_@E&M0d0VGjfSuG&TY7V;?fKwz#+$LcV3rkarNE9-h3VS-50nKT+!%
zTwqw*A>u8WXm$U7-3|oZhO2U!cG)0Y5dNB6X~btYTvmC;(Nf>Le}ZH3AY
z1SuwlyPjEKo4aPdJx-%l;y1_wEtD0xzCKn78j*1}S>gBl
z)X?O?U_VjwIH*{@s)th!hIw8m3Ov6=hJG%Y(qDKgK(U94)>$lpUE{x}A2)fk+v8Qd
zTWac{6Y_A~Nf(c&LmX7ca^F($Mb(y_+bwu|Glyrz-FtgnCn|!A@Hf
z#r#*hYks2m3^n90a^_vz8{yEqhn?rg5kAx3J_LPGml!B9SDx?B)8nqMr}^5IAf@3L
z&|nuG8_N%=_o#H*tG6o3)t|*dHGMya2^6N03bVo9Lic=l_-ARp?Z_
z|9rVxRE%HHwP;5>s2eyiVF{bgm?ca##)x{q*nZ%>G&dxuS1NUEt5}J~*@SS}WjjO!
zQgt_5E6KiEs=|!H5sEHKVhCeQnoaTeuy=o!(X#t71!iA2>;SsF;6Ao~(ul|L1m@3}
zJFTdQv{>~>b9ux)O#R=Y<9WV77xtlLM*eWic}DASNn`;Q3{arZxcVp+1S@^PsO*Mh
z$TeNMF@Yq}mb1DFrTumj#4Wp~YNBltjeE-DudH_@VhIE^j;%?-cl(j-CVn1j)2w+r
zz$pK2{yh{8$^qxi;GA;JyX;2$x1#+4fcQJFVhchrOm&T!ZQg-cdj+Qv$({9fu~5so
z6|@3pmG)hbOs7%x_DP%*c5q0GO}j*~Lw~G48OiASJ5EWAvZW+h&$ieL#uVbhd1OAOM2|Zwmv)&9-h6r7@AHO%tzQOz6`N3fyI2M~*eL|>zO)!MuI-_+
z5+$(U=TiNu`NzfMop_6$S5B%wOWQN0KUv}mGT
z{nXIm3D>QK0Tp=qsWB+3cRXE%etxvJ9HqVtt+!}Cx-*v%VF}V(;==V;Se3Y-LyRLG
z3v~}%T*_pYuevymRveXLhbghpZE`iRMNZ^sbG?wtMJNX!b=jTI@o65ZSIm^zN1;gI
zNb54BetI?PQW+q;PdHl;cNH)ljq~8)jDpW$4t%3wiYs>FUm85!%vG`qnxU)X(
z@~w%yp7@-BO}%^uHJa{`%c5TAHqX-@d!d~1q-~aHy-gP<)7?bRwU(vo>LrEE)Mh&p
zao?j&p_sdwi`fS3A9-@PPW?SBrLd(Fp8V4N+vGf$P`Z{Me*~b&Vz46IWpZXb);zy&
zHbpwRRgQBfall;Ogo@T(thjoy5Pt;Z5yBuz6<5edv0LN}5oiNdZ*zZRnj5RHHhKJI
zTG~iA>8f4hm4{tPT?(Y@<31~X(W!Qhf%p*$dV_!$iO|N@@nF~b-Kl8pb^|lNY5sP%
z?eD={w}Ou@A)IO^!)2!xNZy@^gWmAHNv?)@26pMT6*#naNkVIczhu*L{y5?B^aROYb+g>HP4LrHyiKyl
z08ZXJ_-GQsPW1<`PWKLHN@X|wzoos$>J>jyS2Z`zH{WXq*_*P!HWw1A^bBo%!
zCMtU0M&xhklvDQrIp-NxbIx%LK^{P-<0jH1Z?xX_f*tP
z&2D^oV5V7Atj6c72;^=PB|hHL(?(^q;qm19GG6tanpGgho~B@A2+hx+Ir1AtR7NC|
zZ$`v0O4w4zM@uUlb^BnFUWCsYZ1UKM@Qp9+VcnFL>W-q=R+p+LMsCkys-cXINYfTs
zaHj_e74eJH%u6xkUN6nlVCNhy5c)Lb_c!(8WuVDnmtCJ`qUCm{8m&caK`0Y3sp+1fTr+Y8{7xwp+zPtjmY7A4jzJVOuI@TzQ0(g*B--+l{=N
zWPu++FfjF!i2Vf#a=;;N(*ZbiYq=g8_6|<;o|(-F^x;@D4h^AE(@xrbgU;fgw;QWV
zl0n^iNCz_iJIOxFM$s4Zlsee6$s1trzyJ;t?tOpDl4)Mc(k#r#5|8PBZlTGExjmv)x7SpDl=^;4|A0LdBYq~?sUGKwyE17
z;ZaFxBF!X17qJ6}>ua+(9u<=R2xSqArDNi60eI)5v_W-_QV@-Ke81Eq_}4I2oPgt5
zvuE^*@RfahhsxAO7Qo8mCJb-U1#1awkWiWoLMVMW9=+4-P7LRewd_e(2{hp1*VlU8
z(Q%tyas}FT8HAYv#YE|w(9y^|gk|A8$FHcVz?b}w0DdYVv(Ri|fM=+KhEm4Yo
zjhL()Ly`hyI&bY!sc-m3Ox){Z`g!7GYydsOv^Te)au;!{r_|ymtAc++fl;}tB$@2-
zP-l>a<}Z41UOu+-@rm_4v>zQq%|wM=bgQbkRQ6c!)*N-XpkDR?N<~q}{4O+9@u1
zstq_0m!(Sd+mw8{4B%tiLyp_DVT+O92QIz#qJWbK3hGuFG)Ly3{)~__hG@VgP01i@
z+ZtF2^@<2B+v^pFIBU8rNzge|J+?@8QEu>*@I9FOi)*hz;_r?fWN`c;=Kr|X7e9s%
zyqfeD@O~zqV=EsQ@R%Z;In_GTt&XY7J5G;zP?$=GxQ4Ak
z%#45YkqwWL$3K>p=H#9e_akIjV>ZO;f}P77^{YUN-&-o}-UnzcW!07k{FZ+}he|?(TzI+@i?YdWE#JYxaiN<5obqZ@hwE^ZlKSy2X)7
zhwV{
za}vK;?5_Fg`R*|#b@eg>E+-#lyo|u?qq}-bx2x~0RDxJIL_Bjf#$tsXl_aZuK=2e6
zm+*odDoRhAgVS)aT@3jexxX|XqL$ZBY$PnpFy4}s!e@7`$JTHEp#MaHQHCf-c>Ea-nfSJDO@g}uaX+6ou6TG-$o;ee33_P
zOOPVi+#78l`DBWFuUqbH6~lRbu%_{wb3LCNOa=2^`?Z-#lwnl_5oDRqi{?I2
z0D`fcMN;absn~-HW{sn!M>OQ`?4McY)kCeIT
zI5Ww>Z;(Ar#2O&0y(X`^O!zUjC^O1AVkHf?E77J~Gf3c#U9g4lm{76C7E4%TFHOFK
z&dPv+neYk%--y<_sim;roIX4&wJlLGhj`N^pLjmv+IlX7I%86CpMmL)@Xpvu5#Bn<
zwex&pgKfuKAIcAy5k-OzVaaofL|nm3-x@HU3y#W3@$YS@tU((1p`_5Q^(&3Dn)Oj&
zqTBL|8yESl=6kcmEn5V~E7*I^-2rh|W9N&QKR;U&Tb^w?-&it$1~(Iupkv;%ua0&A
z$|+~mYf_VXoQX|hrZGUXD_JfJ;C!4C1=>6}pR>={c_%1wiD9eec``e&oJ)9yPtgvi
zW=GwGkI1R|?Vf>B=B+dmvtVk5OFerK5qAmp_cxeJeBibC@k@7tMj*0&HL6_^
z8e)jg`mL-s?>MDOqf%i(R`Lq;>dZAH@GEkJ&dt7i@3KrWIsrTNSzW(
zIIAQ^JXJMEeFu`m3_;?G72Fs4OWhOPArj>gXRheJHz;<-j*>QC5JVw$x8gb>i)Cyv
zNZ&6miFwLmP4m)s05`Nq%|l&nV`cSXQ15RFRe`Th(xOBQjhgmV%6~L&4JM`!2W|-S
zs=gBhj#5hLJ2DnvF&<>LN&^jXgWNPvLd=NQ^0+%qj*<8QR7OG=)^Zct`1+B3Y=O>o0
zdM-OsT1n}6_hxo&IAK%r@bNT^oKwZG0g5A*1fQZ{@PFG~u*FtlR_zQSNSkYJ_vhZ3
z1TH9Z`lX(M?rb)Wu(#1QBBOnCL|$$t;o06tdN`Y1Re!!gS8^~+p1I04dkItzT7`=n
zEGM3F*m)xk?QBm*B-}91QB7DH0IpMi+Z7xIhSBH()#pkidcEp#MG
zRIj-~sMHe%od@`v!9GO+-q`W>(kJ?NjAoH+AbYqF*!>N+x#mj}AQPLo9YA=*KDV^{qFHCc
zBpj1gZ)7wu}LsM!~q&0eb!=rIa7!5$GK%yJZ?hMX$=K_ZJN#rCh76A9=QxY!bgQ$e4(w`0vL1
zVHGop>SfEB(OO5`ic_MVEW_)mvo#Z)ITcy$kKpg$^fTnyGTAF*u!xS0J~G7Hp|W}#~S&Xc8Gr>1>h2A}f#dFB<@s85KNKuA3ZueBc!
zSB$K!v%UAxYv#ir_Bq7vSG3Sn5wItmd;t?BomfjhIJneTFk1&_mvIi;B~!Fp38sk$
zkWz_nB!~vKmV2J@^o{$f1eg8!J&@3~f+%JycHd)VTyF7{TNb7oKcG^B^N`hr$W$(eng(S-V}Qxd_f*wk+{m&2aSp~B_I3xOdcEV?+(uCMVpf|RB72^57&x7ZX3k|Lf4{&OVt%LB@f9hcqv#6`61Tq#XwWShgpz<1hHFt>@j0ZQgCtsBsvWrl}`K_+<8sOC|WZLF)@
zM5S#N5iHaB?rC7SXBn@piY3x&*jQRxwpc{S{YVmnP3U%QxOQ=zGAxqhzs}@RyB}gT
zMx6wIu^o@x$H{6+lYCG%CDA*ORWqoJyBWaESWWD$0_g7G#@S8HQ!wcMdV6unY%neh
zO1F)I7D>7zMOHaWtHknjyLZ?lx||w_*-zQd{n$BK_v#P1+#h`3&I!Pe;=I;W4||0h
zOk@p5SXXt^KY@|!2USAs0J+1`u(Z(TGB;7RSEXF;{*)QvW!SF^;%|w{fXL?d?9|kd
zBYnaPh**rB^-)wsFQ8AwKGh?6$GH5m?t^;g;vOIawrwOp#mDMgD*5uszoG9_&rxe#
z{!Uf9RHXnL&Kj#5_ua6?t-E%~HS1%(+oWj5koIP46_aRU9gY$9@uW}r=v1hI$*T3h
zdv9pE`xY$40z2sBES~*6j8$T~tlfVZF+YgxK4q}irXW~ujS`Qq6g!3ROjn84LM87P
zzH=YEBL4V?fV7t(cFX^z^*2rlez9X_Se^scN==@)%><35ks~cZ@d?w*TMK}5U3}H-
zlX){@IZ(73I&l`zv@tCGMG^xWgT|x><81p?XpimH@6t6BSG**6!sh(0!h1Tpc9|D$
zh^$?f*}xQIiwH@NeZ>k|L9I&DV=seI!Zs+ss7_TLaG1qQqqVM@s|3`H6OB`*58m0-
zuYlLM%JI?%Y#dCfojBbIiZe}!yG{rB|KJgcooDm3
z>7x%SfgOu5&QU+S98Pm3=bgAntg}XervEJTntb)zUgD3Nw?1#QBK>|V&HU*v6nB`;
zR|hVY(%k2w556n*;5jpDq{xVBQxIp7Wn1E%S+JaN79s%(XU{*L9E1C{jhne~0D28?
zK>gLd(b}aOt-KJCEC7ot1VP-iq#jNV3AT1d8=Mq1uIf+-ef&2zazN`}R;7mFm-!}}
zkz7MC&FqZ$+I?t`I|IMwu1O=Wxn_J_nU}R57H3?a(&dbtNnn5%$J{iSFZVpxNlDP%!Cs4
zaD~y{bBnEGk@P6-M1LQLh69oTWKZxYgb#j%i=k|Q;8aQ_
z>G0JP4mjT|xcJ2)qg{IS1xeQ#k%K=+(f-9D9fUACCiyTBlQ8mbbCFG=9f>kx$NHD8
z-{Vt=ZpK5#he!AJo=3>xyui&uW(upvV~ZPi+i0$0m07W9C@ocGy}v=>eLi++#}cS|
z_f@%jfW{IVAOx`WbHc=AP4Q=iKiZ6QX!r#MAm=DqqWqiZ_P>MdI6ubyfe%<*5UBQ5
zpK%o5n$_vl2
z;DK;IdU~@o=qpbkhH_by_EjkNtDWfO=T}m=t}au2x9NCmE)i4VIIIi6lv__Iy3<6=
z*6ump3!-y)j3)aZ3SFsg)RLzWfY`oNcsF{BZxIuxe*aqC4{n44DS1Hs(fofLOo%
z%?TAkULAPqvsqQp;WCxH@k9N=Lf19sjLEOhHeZDt
zm*42T&UI^*#qYtn`#r&rHt}Xahmav9xVF33Iq`aLl-Z_PsCVb?8G%@ppKDy(K&Z!Q
z)>VVgCe;*ys<$B$79}3*xFmX6wjfCSeYePI*qv5-$Kn{zz*6wB?%O60#A>$E5-7AB
zS$tt#Z3b%A2;005^ThPx-u~2GTNSwjsaA^b`U~%|86#5(NStX+7ARs~qv{BjU(PzW
z8%9v=J^M2|`LA3ji~qsvKHVSOsdxs-pjvB@h?5};_tNNI(i}Z=ULeWs30=aHP|{U*
zaXPnf8n=26=Ulx1b;_)o~Trm&>d)
z9w}^nphg^uOR)-r#Mzd6r6j@_Meoz0?78MmT-xm)AUmNW6?yqD?cPr8?sLo`5TJ_Q
z0;C05z}-u~m6MYjn>csTbmT)P40#uxV{7egaMjILjLY}p2Tf*gL$GE?bNu(eB_cLt
zXE*V+G!fX9*)pNhWaJtVtQTDPpVn&-$V2j??+FYQ*N)c!W}xXyncQ8ErEXi$)KaVEPd6uaOfueb=ohUuV|8cnLg0N69
zKYtw%MOkjn-ANA&d12Ulefg4=)ih}k17s6ETzprq3}cciDkP!T+|J41Z`r<)fh6FI
zoEz5ohwUlm#p*Xth`H7~hl~f=Pc$a&A22?(8=aWx-{`)*Y0iUes=AQ+)l-_P`s(Aw
zka}sy9za*~h*;FiuRa3q{L@J6T@|BuiKe>wTpsUJWkjH(HF@2EOVr-;#&5;y7jZv`$^g2tagk_5|PF^uMjL(~%z~
z&;1=4jOB{3oFgHa?fi|}Cccp=xtq
zl!|*8c@tjjZ}w@kY^|{{7^Ylt&9+UgwLaQoN1{HAs_L$Wn#GG-y@Z#RtleT=-*S&<
zE8%Q45o6nt5V8&4J*)?Wj8k-gGHh=!^{8y^=gDU$!9jqL8j{7o6R04Y)sVW8;FryFN{Mq22*i>PAfig`XYCjZC|$L`aa$}MU8_v
zK^o(R)Va|)7$lt9WhRF|@FC!qEKL1EnJ-LzVKJkv0Hk6{`{x(Ynb&-0C}m^2KF^>M
zDe;LLK9m9eGwX>dUG_YkLJKyRAKGM>706fjR5cG6WNx%BNhtrkUC#R)2vAIM6(26p
zGK3uMWYC&_#^NUg2PLT!31f;vFZ?v`XMS60Qb>*9S3c^IOr{4!vabQ)ZUwSPN@Kko
zEsiSywpjuABn~I~5@5IBlnBEdmRhYV(=j0|FQq~errd++EEieCOhtCzs~Jd!!a&tu
z^BdOL=hE;1A((AX-m>Kw{LR==)DO<3)AnCqfa(dXeeQ90s5zTuG0PfgFXEn&kZTqP
zjJ4WH276EJ`4_}_roX7-e|xn`AI9nm{bu@%f7E-qmgV+?C1L5Ea0g889bTFH{K|gq
zg-B3OXt8EOpHqrbx3-Qhu}2j^*!U!A#|q0+$?VWt!F5P&xvh%*$HOB8pwJDXNje8A
z#9ImWAG6FzyXrdw#YLM7>+D8|`^8FKaau`Ny-`JtyFxoZLdBdu@)e7J3>lZ{-D1hU
zQNDU8{3f=zpi|Qi69^|H$VB)nDDJaM*`Ib|d3hH;{RVlMs?tGv1#RjBNrn@VWwv)N
z%94^i$M0mie|Am7kNZr>KmESzI9&G3)*9}|ot+98*w#S)D_5?}jAPx5Jw^P$!ryuN
z0u=d1s-M)WXagd69mp1HTbp!NNX$4CTV7rdQ~aqJyzT?#^xH85v(jxdUIVA*eb+q_
zj{ln#xCZJnI&xicvCLcCd_qk>^+F(-mOgy&O+DEoKa^#~gK00QWw9yZ24x-ZscN|d
za^>>32bDPDr;!0dTu;aMz8q~9gklu*68Wmyo7W9~sA?y{uKt20*jh2*wPyw*mMAf~
zEDN2NCy!h)|3+74Ie)?-dr3+;L|z4G`zAw-^FJ(b!S_l%8>EcuX}YSNF!ywIbsMbQ
zOe8*GgDBakkU)r#I1CX>1^VDN%ays~w7O{OF&r#uTXl1|7)^&
zt>3AS{`0bzR`Rtg-LZ63LQewx!)H48wSWsfj(biyMgpqa3(6k2S07ms=``L@D`}a5BsPm?A;6<
z552lnp=UK(vvyo&vY_1mM6Ow*ueTx1oMOWw=%dGT4Y)#9JW8T)I8>*qQC$@z-wT(
zZwz?kVC6)bY_W#boY%?8oVoN#Lsb{vo>E9q!4B>i$86TJ){PPE5O{Rjr=EgIn-ssQ
z${qq`S{BgZjQf>$d#fbLZ}sa@h62X9xRs0KWt^%dz$R(?VEKzGV;!S$>Qf^KPErVI?7AAiLka?;6#91Y
z68m2hPfn$NC$%4hobDRQHbAZ42;$&{hzP#g3NFg8?o$3}qy`O1ntgWqSL`~-viux8Z)g~JKEbixX&Vrq#E$Lb(3@bG#
z&+y&sxhbtd#{ABYHkd{&!rs`=FLrMPxR$at2*eC!Bn#((V#ORbu$FSgK=
zqHpb;VXcc*sw_hdl0-a2;m)?|-OBgD>9FKQrn+}<5^QtMdHk5KxvfN^TdP?f`IlX3
zb41EX4R74=Y)6%e2uPw=6U^ZI?(19Vz6QtP@h~pc&218>0n!y7O*~pTH;|v>$J=xe
zN}LX8c*?#srZssPWkMHpw@+Ik`heqb=qxZVK@d8*vZ^Cuy2
zfFE_PgC2-}v9Yg4-+(kkns)~%A|_v_@pdK}WG-)V4M36mRX`|Ha*L43!YpaLOCsVG
zfCj>8NV+C<6Nqnz3l_~84(E0gw;r}UrDIC30t!^F6d-x&m+ttrBl*oH0@ry<9$R>&
zhj)K-U`X)aofpu2_%6^~mNQ|<21rxW6^JhF7i13P1tUj>c5#?G_l>UYB{ONaz0&3<
z(96sHdC?6!GY--pB59@;s*bWt_n$isYYwlUE-$MJrsk{nUOO#n+HkI06F7+}z$3?m
zg*Aw@0vIKQ4=|2rQ?RwB(Pao?&bfP5V=nAYW`^)Qr+-zNriy_&2E(qFj)$czHU
z8S4PIb!fNSQoZgKqrzS)L&?feX^^eXF4W&OtMvqli&bLARP5G0iRPQa0Q@qH3ik!G
zbNVbEXDY$;%HK$XDmAV&UJ?gnfh}Zc7oeD(A`#1M8dd-#`E&@?$T42DiEAaw8B%Gl
z&niA;x$$-BCS_ntAoa^j#}iK9me|bC5Wea^=8kUJLYraVH<$xe<44YWYu2i#=}?P>
zjL%FwhO=-R@1beomDhHr`osY%tGEFQ?#?HY{6q~*)`aKL#l>=mXc(@l`R`+eEqgPy
zr$J~uT0#02L}%`IZox*rw{f2q|#fm5_D&^*wEuX6ciH1fRaVd%U17)6G5Bq_FKuLjY5QtF5xKh{yiwN(rI$pL?H+V-
zcm&Zfl=;1ldpa^XyOQ#cv=ok7GS_{$%qmdk6VTx4i`(~~_nEjp!IrNyBAQPC5tN6;
zbXyPFoB~^*+v$QQzmZ+8Y0vK~m|*K(P$v81LchHrBDjZ&R9o4$aq
zb+ut1EyCY%lZ8BgSRBY&yE9{a_|$<7$F7xHG{>j*PrRUUsy{G3`zO6(r^ZAX*wE=p
z6Mzw_hPJsc?R^5?;YL|m**Up=+f@?8bL=dVFt$}@jbdXjm;j!K#N%PY#CxjB7qCnfrv_`+497sfL%&mK$KMf*&rkQycu3ugy3WyWmbd(C%O3JZJHd8~TQZA-wVNS>4HIPM=Zv$R9KF
z>};FgPmy-=H)gubxB+9~hI%P=ygkPB&u0OMyy$YeM#{dMxshjh==HOo2`z4C3bn`B
z7vVp6Qc33rb8=b4j2z1Y6abh5g=}a@qkTyOb*1dZvDuxC*~HKq(j}JHrlsUqP!j<-
zVSK6FG*^!jWk_1CUr9i(%83F*fRf1tVtT(hhjSCY8=Bq=c{z?vb*5AU0vlUTqL{~?
z1MPhxy@~4$F-v>Wv)1sHCzuqkA9SvPjxP(0>c2W8F*MJAcGZ8?dVYJQ<4;voI|CaA
zkn%yWB-uLXk~@YPS$Z-e)wD$#ZS%}B78
z`jsEMec#r)fo{3jp{Q0%GNk@oC=Jbo_v{#sQ*#bxqEkOET(`U6fZ9)e*Udj(F=Fc0
zlO5+WIHa}EiwUsUe*mdZKb3=-b+o(6{foyHHlnH|INd?ZlX&xvMl
zHH(RD`G`T$nU0+t#&=I+0U8+_=RI2JIa@pkAg<3$;nd1gX~99a28HT1Vxn$TXU;>8
zTg?9^Td$#|B5g6pi3)oN>?YLTd-hZ$S(88Y}=snf4G4-6iD9-%T^XE?3kxB=r?kf7D3M&mM2~Fz+r)(!r
z=tpFvbpYozuMm{@c)1N@;+F>1R|`b`FKmDa00l4Y#CJE#^{~>x&cWj(Z=kc826ekC
zqu$WUkEdA%$u_`K1QH!LNrx?+(O+!3h=Zrctt5D7r_P8=^~h~5A%G{oG+bOPt@M%i
z2A6oqUCFmQ@is)>6{LdnLq)$#E1m&6W`E75&2}^5b=3$?zJdH;Xd=yYevb9b1j45}
zGc)tXLR-FZwKL)fmw&<)ZO)6(E5s;+VqQR?#~?|5VZTY|uCbnc%X0PjimOBg=}ER!
zlW$M@7o6d?M7zW5^O|m3U|%Fy-i5{oQ~I0f7(7cT#?PrnG%6NJL-}jEEiF6)7mJuQ
zy|SR9UhAznHz*Ut5Supq1Y%Hn{22XJ=^{oFkg0G;IdB#vFu3Y(BvE#=l*xb>bJL773fDb&=XMrbwgb_<(R$bZ`yLH#E1V8
zR!4U3##pq7|Fr7o5&{}EbwY{ki1S%B?JQPF&hhFqTwAry>5|W@M_n}R3RFS&9@Gq(
zP1?@e$#&d#-ka6_(ycLOx;BqdYwoe_ctiXcj#a{Xq*}1KjP%7x
zFaO>JcE_S-OF+Tk_#2Zc!EOev*|&mdwwBl{%gckH_s%bSoOJiz7pbu8yPqIw_pr-c
zb2JNDHMpT3U2rPv+=xx%dBU-}FnOv?!okV8hNjfFh-NU3ut;5Q)Dhm2;x8Es{&4>Hgsb$R
zs*r#8VKr}wTxkTOaqw?NB1P|GpsI51j0%qY_w?IB6ZGj@EQORJezT`%EZxB;Ko<+I
z-N5^->eY8wKxy-9VLJ42_aRnE9|DkjIa7YyO5-sY2NlQKnFQ?UxqoBN6jM#;wka|0
z&4-yrml^HRR_#0`h?hlg|J6nGuS&yD=KI&NGxQX@9)}o8zz%Fipuif3dY2Oj>8!(y
zXD)L~+4rx`)JB1>;g+|T@U?3H9upO7nYxc)**ccXit5dk;xWoWBb5=oor6V(GWsQ|
z;_4*=IIbTUac(4qrK1>Ea8lUP^yK9-mf!E=Tp1ZFhG}Q6#Oe3r&l-){`-=)B`T_`>(^@pGtS0TL-KV9dU?88hbxhbB7Y>xG0zm8jS6Di|7htU
zqj;TciWUNB>=45rS14?Hc}jSCz466JiBh3?7ImmfvGt)9DKG8ssD=tj`zYR6$m(T~M~cT2oJAJr%kmnhEz
zwabyB@7Q92cF=2TaX*p2lS|Sz*Qp-1rEXN`rgpo%hxRX0;y+#m>X4%pa~1wm?GOc|
zq_(W0joax>Iszt1;mGYD8m7icqWLGTJb7PG^?d7JOGZmYwON_B8y$P^CJ<`zm)mqn
z1}+mQoZkKe68?{SAg2NViDk5CiHR>4QvIySLqGLj`-G0W#C7&4;1S_ZR^7P@CZc;4aI6os|?CR@!IL*{%p}VP#_a`$u$hv>?M(P7a3k
zhQdPny|Hi3$;ikIsywu3!D1mBgJRbEm)zNH@3xA*GtRW41ef|
zT;4z5XImlYVwuKums$MbM0Ap=fXv(q_?;A8Jv~GdZm#W!
z$%j`BJU?N$+y+K<@Zbv|5gYa%$Tz6o0#7}-7`VxYHU$J_7_fn%GJ>
zNq!jN6L+Apmv(G{CsGJoQv70Jekxb}^=Tj#^JzdD)NcO>{aDpKP#d;v&Y)iVqzR`>
zW8?m(r&YW|1)lrC=2fJ$Pz3OkUbw6%iYl8c(s>M~4ue5tu`N*(0He^fwM9e+)cmX<%=!K~0c@AqG6-%LxwncmC79)ky|`yMS_d
z#}7n^_NFQ+_XO-@@U=SUJ44mg+jlH%eyH|r>
z04_btqFKvB9a97zG+=2m;ryi8;TBls8xjRKg`6?&6Yf_~w~+2w=Dv#8bdG38thdq*
zXd{SxM$hup{W$6d!BuH#c(`5@DQpG$)6(M?!Lt?w0ocOaUcO)Ly^&qw|Jx%C{&}1H
z@##0iV<9K|ml+>;DlIwBr5G?1Za(la$}QLWb2d_s9X#f%x60}NQTEnRQMOzEI06z%
zqk?o}A>AzqDkah&E#1;Boq|Y7r*zlQNOwucfOIqD00RTxi*w%dob$Zvyz94quKB|y
zOV-Ri_r0&ZKegQ(B(1GWB=1>HrNh_y
zz(z8nP0Jw0`apaTy9;fY!syP;ndG^%iu-A%LQk(@vMziV$R7_7VLSjFeg5`cfu#vB
zB3t(M^;N9Av~9FmyymGDakyx-8ZHA5VJX+0!~P~Fa2!>LpBHOZM|FMWG}@cx)=yZ=
zO32H-MbzLm(k$bv9mJS#K`7d8Gx79(Hi!-R+mZHHef-xQ|F54?$e`Z6Tirkdvg`H(
zOPXK6V}HRb-v2v4OAOry2yVA#Dj~egAfN8JT%EE|MgR#ZbuhWrRr$U)_n6-tIlhd2%o?A9cr{S53uWpc|
zc4D1QWg8fyj{e~S{ofz!zkl9e<#g}gzi*hsC+PrFGIV63!RaSAj?_`iq^1V`UtpZr
z4>9lz0`!zhwe%sGt+4m?$B;O24^Y>6{9HHNUh;VWj{+guGB?^47&qHc=YTiTw6ep(
z7>kPClj4Z_w+Hx;uW($?`EqzxC=iq|Q`(^DTH+wV0;Nh5Rv%1WXz_dE>
z-25z1{1QlEK}Yx6?-0qD*bCp&SojQZ8SeERxe?E}-T68thsUS?KH~nrKCA7EdmUus
z`|!cLu_44ZH9ww$%<<<`LA>otPYy8+oIa+*)Vm~6V=Xh-_;3j%{gy{Iu+@1
z{0jKgdvS{^#y~NvXh*|@ODC7cV&YSk9dijb8naD>uY;_S?^bg)@1&%J^L1M?Omkd_
z(QLYoE2?);f%u@Tf9__!K=_k!pwS!Ny*Z%JVfAU5J4JP0YbB>RRDFD214sz6J!r*v)kSXkn!{QGt^558`&n7D3TaJjOX=+%^tsV_>
zxhWA7W2l(^T{yzJzkiuewIGH8yyGhT-hJRFN>#9SfA;%5HJ1}3Y^B4b&$Yuw9lqgy
z1rJF5ziLAi&}xU=msN`d0WWV2u-`jd%~uF~gjlD%(5$Le)&Q4K#ai{RR#B2&AR}gG=(iroA#r}qSpkY8MxzDX0on|1joFz*Pi?fgd(RT@
zKx0kk(l!17esqs)yv6Ob?~PnzJ#bE0L(~$6gZKMmp6b2@Co^E`UUpN)8z-C3h@A}#
z-eqhwyz^>|S`&9RyNW;oW{gjoL`x-}6uZGj{1*0RvFm3ez4fwpSv1$cHjY(m~u
z+8BTKq!e-)X*4nmYWgB#HGA@zaB~4Drpo~d0@O>1+?cf)~+Aq
zmh=HmUk~(q0*fn^kKFz*^+ul*&1AY0#4)3FucVnP2;>6h@a+6!db#Z%-)ZQ9LxHF4
zHXVv-$AB@~QCKKF&v>x0XnS^2x5gT3fCl9bYrv?o=yJrh1yC}q`~j}mXj)9n19Cnp
zv&Y`5&No-!Zk(|y{a9C_u_$#@WboC(yI#S(+^FC9GK-RlSuINHYa*EQ_hi|;9f{K?
z#?cK}UQE`jlm4US@R1~ZBPSON>}@3G_FOVY