Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/compassionate-jones-9c0d5c

This commit is contained in:
Yuneng Jiang 2026-08-13 15:02:48 -07:00
commit 2d7581e32e
No known key found for this signature in database
53 changed files with 3242 additions and 2423 deletions

View file

@ -40889,6 +40889,27 @@
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-4.6": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_200k_tokens": 1e-06,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"litellm_provider": "xai",
"max_input_tokens": 500000,
"max_output_tokens": 500000,
"max_tokens": 500000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/developers/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-beta": {
"input_cost_per_token": 5e-06,
"litellm_provider": "xai",

View file

@ -40889,6 +40889,27 @@
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-4.6": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_200k_tokens": 1e-06,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"litellm_provider": "xai",
"max_input_tokens": 500000,
"max_output_tokens": 500000,
"max_tokens": 500000,
"mode": "chat",
"output_cost_per_token": 6e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/developers/models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"xai/grok-beta": {
"input_cost_per_token": 5e-06,
"litellm_provider": "xai",

View file

@ -2943,3 +2943,51 @@ def test_generic_cost_per_token_gemini_37_flash(_local_model_cost_map):
)
assert prompt_cost == pytest.approx(0.00075)
assert completion_cost == pytest.approx(0.001875)
def test_grok_46_launch_pricing(_local_model_cost_map):
model_cost_map = litellm.model_cost["xai/grok-4.6"]
assert model_cost_map["input_cost_per_token"] == 2e-06
assert model_cost_map["output_cost_per_token"] == 6e-06
assert model_cost_map["cache_read_input_token_cost"] == 5e-07
assert model_cost_map["input_cost_per_token_above_200k_tokens"] == 4e-06
assert model_cost_map["output_cost_per_token_above_200k_tokens"] == 1.2e-05
assert model_cost_map["cache_read_input_token_cost_above_200k_tokens"] == 1e-06
assert model_cost_map["mode"] == "chat"
assert model_cost_map["supports_reasoning"] is True
assert model_cost_map["supports_function_calling"] is True
assert model_cost_map["max_input_tokens"] == 500000
def test_generic_cost_per_token_grok_46(_local_model_cost_map):
usage = Usage(
prompt_tokens=1_000,
completion_tokens=500,
total_tokens=1_500,
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=1_000),
)
prompt_cost, completion_cost = generic_cost_per_token(
model="grok-4.6",
usage=usage,
custom_llm_provider="xai",
)
assert prompt_cost == pytest.approx(1_000 * 2e-06)
assert completion_cost == pytest.approx(500 * 6e-06)
def test_generic_cost_per_token_grok_46_long_context(_local_model_cost_map):
usage = Usage(
prompt_tokens=250_000,
completion_tokens=1_000,
total_tokens=251_000,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=50_000, text_tokens=200_000
),
)
prompt_cost, completion_cost = generic_cost_per_token(
model="grok-4.6",
usage=usage,
custom_llm_provider="xai",
)
assert prompt_cost == pytest.approx(200_000 * 4e-06 + 50_000 * 1e-06)
assert completion_cost == pytest.approx(1_000 * 1.2e-05)

View file

@ -341,7 +341,7 @@
},
"src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": {
@ -1046,15 +1046,7 @@
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1070,11 +1062,6 @@
"count": 5
}
},
"src/app/(dashboard)/playground/components/chat_ui/ChatImageUpload.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/ChatImageUtils.test.tsx": {
"max-nested-callbacks": {
"count": 1
@ -1087,48 +1074,26 @@
},
"src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx": {
"local/no-complex-jsx-arrow": {
"count": 2
"count": 1
},
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 7
},
"no-restricted-imports": {
"count": 2
},
"prefer-const": {
"count": 1
"count": 6
},
"react-hooks/set-state-in-effect": {
"count": 4
},
"unused-imports/no-unused-imports": {
"count": 13
}
},
"src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"no-restricted-syntax": {
"count": 2
}
},
"src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterTool.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx": {
"no-nested-ternary": {
"count": 2
@ -1143,21 +1108,6 @@
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/ResponsesImageUpload.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/SearchResultsDisplay.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx": {
"max-lines": {
"count": 1
@ -1295,11 +1245,6 @@
"count": 1
}
},
"src/app/(dashboard)/playground/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/policies/_components/add_attachment_form.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2426,9 +2371,6 @@
"src/components/agent_management/AgentSelector.test.tsx": {
"react/display-name": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/components/agent_management/AgentSelector.tsx": {
@ -2502,21 +2444,6 @@
"count": 2
}
},
"src/components/chat_ui/MCPEventsDisplay.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/chat_ui/ReasoningContent.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/chat_ui/ResponseMetrics.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/chat_ui/mode_endpoint_mapping.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2797,11 +2724,6 @@
"count": 1
}
},
"src/components/guardrails/GuardrailSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/key_info_utils.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3019,9 +2941,6 @@
"src/components/navbar.test.tsx": {
"prefer-const": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/components/navbar.tsx": {
@ -3151,9 +3070,6 @@
"src/components/policies/PolicySelector.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/price_data_reload.tsx": {
@ -3264,7 +3180,7 @@
"count": 3
},
"prefer-const": {
"count": 7
"count": 4
}
},
"src/components/shared/CreatedKeyDisplay.tsx": {
@ -3389,11 +3305,6 @@
"count": 1
}
},
"src/components/tag_management/TagSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/tag_management/types.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3686,16 +3597,6 @@
"count": 1
}
},
"src/components/vector_store_management/VectorStoreSelector.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/vector_store_management/VectorStoreSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/vector_store_management/types.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3770,11 +3671,6 @@
"count": 2
}
},
"src/components/view_logs/LogDetailsDrawer/OutputCard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 2
@ -3785,16 +3681,6 @@
"count": 1
}
},
"src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
"react-hooks/immutability": {
"count": 2
@ -3815,11 +3701,6 @@
"count": 1
}
},
"src/components/view_logs/ToolsSection/ToolsSection.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/VectorStoreViewer.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -1,19 +1,18 @@
// hooks/useHideAgentPlatformBanner.ts
import { useSyncExternalStore } from "react";
import { getLocalStorageItem, LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils";
export const HIDE_AGENT_PLATFORM_BANNER_KEY = "litellmHideAgentPlatformBanner";
export const HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY = "litellmHideAutoRouterAnnouncement";
function subscribe(callback: () => void) {
const onStorage = (e: StorageEvent) => {
if (e.key === HIDE_AGENT_PLATFORM_BANNER_KEY) {
if (e.key === HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY) {
callback();
}
};
const onCustom = (e: Event) => {
const { key } = (e as CustomEvent).detail;
if (key === HIDE_AGENT_PLATFORM_BANNER_KEY) {
if (key === HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY) {
callback();
}
};
@ -28,9 +27,9 @@ function subscribe(callback: () => void) {
}
function getSnapshot() {
return getLocalStorageItem(HIDE_AGENT_PLATFORM_BANNER_KEY) === "true";
return getLocalStorageItem(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY) === "true";
}
export function useHideAgentPlatformBanner() {
export function useHideAutoRouterAnnouncement() {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -1,17 +1,19 @@
import React, { useState } from "react";
import { Tooltip, Button } from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
CopyOutlined,
DownOutlined,
RightOutlined,
LinkOutlined,
FileTextOutlined,
RobotOutlined,
} from "@ant-design/icons";
Bot,
CheckCircle,
ChevronDown,
ChevronRight,
CircleAlert,
Clock,
Copy,
FileText,
Link,
LoaderCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export interface A2ATaskMetadata {
taskId?: string;
@ -21,7 +23,7 @@ export interface A2ATaskMetadata {
timestamp?: string;
message?: string;
};
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
interface A2AMetricsProps {
@ -33,15 +35,15 @@ interface A2AMetricsProps {
const getStatusIcon = (state?: string) => {
switch (state) {
case "completed":
return <CheckCircleOutlined className="text-green-500" />;
return <CheckCircle className="size-3 text-green-500" />;
case "working":
case "submitted":
return <LoadingOutlined className="text-blue-500" />;
return <LoaderCircle className="size-3 animate-spin text-blue-500" />;
case "failed":
case "canceled":
return <ExclamationCircleOutlined className="text-red-500" />;
return <CircleAlert className="size-3 text-red-500" />;
default:
return <ClockCircleOutlined className="text-gray-500" />;
return <Clock className="size-3 text-gray-500" />;
}
};
@ -91,7 +93,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
<div className="a2a-metrics mt-3 pt-2 border-t border-gray-200 text-xs">
{/* A2A Metadata Header */}
<div className="flex items-center mb-2 text-gray-600">
<RobotOutlined className="mr-1.5 text-blue-500" />
<Bot className="mr-1.5 size-4 text-blue-500" />
<span className="font-medium text-gray-700">A2A Metadata</span>
</div>
@ -109,28 +111,33 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{/* Timestamp */}
{formattedTime && (
<Tooltip title={status?.timestamp}>
<span className="flex items-center">
<ClockCircleOutlined className="mr-1" />
<Tooltip>
<TooltipTrigger render={<span className="flex items-center" />}>
<Clock className="mr-1 size-3" />
{formattedTime}
</span>
</TooltipTrigger>
<TooltipContent>{status?.timestamp}</TooltipContent>
</Tooltip>
)}
{/* Latency */}
{totalLatency !== undefined && (
<Tooltip title="Total latency">
<span className="flex items-center text-blue-600">
<ClockCircleOutlined className="mr-1" />
<Tooltip>
<TooltipTrigger render={<span className="flex items-center text-blue-600" />}>
<Clock className="mr-1 size-3" />
{(totalLatency / 1000).toFixed(2)}s
</span>
</TooltipTrigger>
<TooltipContent>Total latency</TooltipContent>
</Tooltip>
)}
{/* Time to first token */}
{timeToFirstToken !== undefined && (
<Tooltip title="Time to first token">
<span className="flex items-center text-green-600">TTFT: {(timeToFirstToken / 1000).toFixed(2)}s</span>
<Tooltip>
<TooltipTrigger render={<span className="flex items-center text-green-600" />}>
TTFT: {(timeToFirstToken / 1000).toFixed(2)}s
</TooltipTrigger>
<TooltipContent>Time to first token</TooltipContent>
</Tooltip>
)}
</div>
@ -139,95 +146,133 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
<div className="flex flex-wrap items-center gap-3 text-gray-500 ml-4 mt-1.5">
{/* Task ID */}
{taskId && (
<Tooltip title={`Click to copy: ${taskId}`}>
<span
className="flex items-center cursor-pointer hover:text-gray-700"
onClick={() => copyToClipboard(taskId)}
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto p-0 font-normal text-gray-500 hover:bg-transparent hover:text-gray-700"
onClick={() => copyToClipboard(taskId)}
aria-label={`Copy task ID ${taskId}`}
/>
}
>
<FileTextOutlined className="mr-1" />
<FileText className="size-3" />
Task: {truncateId(taskId)}
<CopyOutlined className="ml-1 text-gray-400 hover:text-gray-600" />
</span>
<Copy className="size-3 text-gray-400" />
</TooltipTrigger>
<TooltipContent>Click to copy: {taskId}</TooltipContent>
</Tooltip>
)}
{/* Context/Session ID */}
{contextId && (
<Tooltip title={`Click to copy: ${contextId}`}>
<span
className="flex items-center cursor-pointer hover:text-gray-700"
onClick={() => copyToClipboard(contextId)}
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto p-0 font-normal text-gray-500 hover:bg-transparent hover:text-gray-700"
onClick={() => copyToClipboard(contextId)}
aria-label={`Copy session ID ${contextId}`}
/>
}
>
<LinkOutlined className="mr-1" />
<Link className="size-3" />
Session: {truncateId(contextId)}
<CopyOutlined className="ml-1 text-gray-400 hover:text-gray-600" />
</span>
<Copy className="size-3 text-gray-400" />
</TooltipTrigger>
<TooltipContent>Click to copy: {contextId}</TooltipContent>
</Tooltip>
)}
{/* Details toggle */}
{(metadata || status?.message) && (
<Button
type="text"
size="small"
className="text-xs text-blue-500 hover:text-blue-700 p-0 h-auto"
onClick={() => setShowDetails(!showDetails)}
>
{showDetails ? <DownOutlined /> : <RightOutlined />}
<span className="ml-1">Details</span>
</Button>
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleTrigger
render={
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto p-0 text-xs text-blue-500 hover:bg-transparent hover:text-blue-700"
/>
}
>
{showDetails ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
Details
</CollapsibleTrigger>
</Collapsible>
)}
</div>
{/* Expandable details panel */}
{showDetails && (
<div className="mt-2 ml-4 p-3 bg-gray-50 rounded-md text-gray-600 border border-gray-200">
{/* Status message */}
{status?.message && (
<div className="mb-2">
<span className="font-medium text-gray-700">Status Message:</span>
<span className="ml-2">{status.message}</span>
</div>
)}
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleContent>
<div className="mt-2 ml-4 p-3 bg-gray-50 rounded-md text-gray-600 border border-gray-200">
{/* Status message */}
{status?.message && (
<div className="mb-2">
<span className="font-medium text-gray-700">Status Message:</span>
<span className="ml-2">{status.message}</span>
</div>
)}
{/* Full IDs */}
{taskId && (
<div className="mb-1.5 flex items-center">
<span className="font-medium text-gray-700 w-24">Task ID:</span>
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
{taskId}
</code>
<CopyOutlined
className="ml-2 cursor-pointer text-gray-400 hover:text-blue-500"
onClick={() => copyToClipboard(taskId)}
/>
</div>
)}
{/* Full IDs */}
{taskId && (
<div className="mb-1.5 flex items-center">
<span className="font-medium text-gray-700 w-24">Task ID:</span>
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
{taskId}
</code>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-2 text-gray-400 hover:text-blue-500"
onClick={() => copyToClipboard(taskId)}
aria-label={`Copy task ID ${taskId}`}
>
<Copy className="size-3" />
</Button>
</div>
)}
{contextId && (
<div className="mb-1.5 flex items-center">
<span className="font-medium text-gray-700 w-24">Session ID:</span>
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
{contextId}
</code>
<CopyOutlined
className="ml-2 cursor-pointer text-gray-400 hover:text-blue-500"
onClick={() => copyToClipboard(contextId)}
/>
</div>
)}
{contextId && (
<div className="mb-1.5 flex items-center">
<span className="font-medium text-gray-700 w-24">Session ID:</span>
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
{contextId}
</code>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-2 text-gray-400 hover:text-blue-500"
onClick={() => copyToClipboard(contextId)}
aria-label={`Copy session ID ${contextId}`}
>
<Copy className="size-3" />
</Button>
</div>
)}
{/* Metadata fields */}
{metadata && Object.keys(metadata).length > 0 && (
<div className="mt-3">
<span className="font-medium text-gray-700">Custom Metadata:</span>
<pre className="mt-1.5 p-2 bg-white border border-gray-200 rounded-sm text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
)}
</div>
)}
{/* Metadata fields */}
{metadata && Object.keys(metadata).length > 0 && (
<div className="mt-3">
<span className="font-medium text-gray-700">Custom Metadata:</span>
<pre className="mt-1.5 p-2 bg-white border border-gray-200 rounded-sm text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import AdditionalModelSettings from "./AdditionalModelSettings";
@ -128,4 +128,47 @@ describe("AdditionalModelSettings", () => {
expect(onMockTestFallbacksChange).toHaveBeenCalledWith(false);
});
});
it("should keep a half-typed decimal temperature instead of rewriting it", async () => {
const onTemperatureChange = vi.fn();
render(<AdditionalModelSettings useAdvancedParams onTemperatureChange={onTemperatureChange} />);
const temperatureField = screen.getByLabelText("Temperature value") as HTMLInputElement;
fireEvent.change(temperatureField, { target: { value: "0." } });
expect(temperatureField.value).toBe("0.");
fireEvent.change(temperatureField, { target: { value: "0.5" } });
expect(temperatureField.value).toBe("0.5");
expect(onTemperatureChange).toHaveBeenLastCalledWith(0.5);
});
it("should let the max tokens field be cleared instead of snapping to a value", async () => {
const user = userEvent.setup();
const onMaxTokensChange = vi.fn();
render(<AdditionalModelSettings useAdvancedParams onMaxTokensChange={onMaxTokensChange} />);
const maxTokensField = screen.getByLabelText("Max tokens value");
await user.clear(maxTokensField);
expect((maxTokensField as HTMLInputElement).value).toBe("");
});
it("should clamp an out-of-range temperature once the field is left", async () => {
const user = userEvent.setup();
const onTemperatureChange = vi.fn();
render(<AdditionalModelSettings useAdvancedParams onTemperatureChange={onTemperatureChange} />);
const temperatureField = screen.getByLabelText("Temperature value");
await user.clear(temperatureField);
await user.type(temperatureField, "9");
await user.tab();
expect((temperatureField as HTMLInputElement).value).toBe("2");
expect(onTemperatureChange).toHaveBeenLastCalledWith(2);
});
});

View file

@ -1,7 +1,10 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Text } from "@tremor/react";
import { Checkbox, InputNumber, Popover, Slider, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
import { Info } from "lucide-react";
import React, { useEffect, useId, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
interface AdditionalModelSettingsProps {
temperature?: number;
@ -17,6 +20,10 @@ interface AdditionalModelSettingsProps {
showAdvancedParams?: boolean;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
temperature = 1.0,
maxTokens = 2048,
@ -35,30 +42,56 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
externalUseAdvancedParams !== undefined ? externalUseAdvancedParams : internalUseAdvancedParams;
const [localTemperature, setLocalTemperature] = useState(temperature);
const [localMaxTokens, setLocalMaxTokens] = useState(maxTokens);
const [temperatureText, setTemperatureText] = useState(String(temperature));
const [maxTokensText, setMaxTokensText] = useState(String(maxTokens));
const streamingId = useId();
const advancedId = useId();
const fallbacksId = useId();
const temperatureId = useId();
const maxTokensId = useId();
// Sync local state with props when they change
useEffect(() => {
setLocalTemperature(temperature);
setTemperatureText(String(temperature));
}, [temperature]);
useEffect(() => {
setLocalMaxTokens(maxTokens);
setMaxTokensText(String(maxTokens));
}, [maxTokens]);
const handleTemperatureChange = (value: number | null) => {
const newValue = value ?? 1.0;
const handleTemperatureChange = (value: number) => {
const newValue = clamp(Number.isFinite(value) ? value : 1.0, 0, 2);
setLocalTemperature(newValue);
setTemperatureText(String(newValue));
onTemperatureChange?.(newValue);
};
const handleMaxTokensChange = (value: number | null) => {
const newValue = value ?? 1000;
const handleMaxTokensChange = (value: number) => {
const newValue = clamp(Number.isFinite(value) ? Math.round(value) : 1000, 1, 32768);
setLocalMaxTokens(newValue);
setMaxTokensText(String(newValue));
onMaxTokensChange?.(newValue);
};
const disabledOpacity = useAdvancedParams ? 1 : 0.4;
const disabledTextColor = useAdvancedParams ? "text-gray-700" : "text-gray-400";
const handleTemperatureTyped = (raw: string) => {
setTemperatureText(raw);
const parsed = Number(raw);
if (raw.trim() !== "" && Number.isFinite(parsed) && parsed >= 0 && parsed <= 2) {
setLocalTemperature(parsed);
onTemperatureChange?.(parsed);
}
};
const handleMaxTokensTyped = (raw: string) => {
setMaxTokensText(raw);
const parsed = Number(raw);
if (raw.trim() !== "" && Number.isInteger(parsed) && parsed >= 1 && parsed <= 32768) {
setLocalMaxTokens(parsed);
onMaxTokensChange?.(parsed);
}
};
const handleUseAdvancedParamsChange = (checked: boolean) => {
if (onUseAdvancedParamsChange) {
@ -68,129 +101,176 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
}
};
const disabledTextColor = useAdvancedParams ? "text-gray-700" : "text-gray-400";
return (
<div className="space-y-4 p-4 w-80">
<div className="w-80 space-y-4 p-4">
{onStreamingChange && (
<div className="flex items-center gap-1">
<Checkbox checked={streamingEnabled} onChange={(e) => onStreamingChange(e.target.checked)}>
<span className="font-medium">Stream responses</span>
</Checkbox>
<Tooltip title="Streams the answer token by token. Uncheck to send a non-streaming request and render the full response at once.">
<InfoCircleOutlined
className="text-xs text-gray-400 cursor-pointer shrink-0 hover:text-gray-600"
aria-label="Help: Stream responses"
/>
<div className="flex items-center gap-2">
<Checkbox
id={streamingId}
checked={streamingEnabled}
onCheckedChange={(checked) => onStreamingChange(checked === true)}
aria-label="Stream responses"
/>
<label htmlFor={streamingId} className="cursor-pointer text-sm font-medium">
Stream responses
</label>
<Tooltip>
<TooltipTrigger aria-label="Help: Stream responses">
<Info className="size-3 shrink-0 cursor-pointer text-gray-400 hover:text-gray-600" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Streams the answer token by token. Uncheck to send a non-streaming request and render the full response at
once.
</TooltipContent>
</Tooltip>
</div>
)}
{showAdvancedParams && (
<Checkbox checked={useAdvancedParams} onChange={(e) => handleUseAdvancedParamsChange(e.target.checked)}>
<span className="font-medium">Use Advanced Parameters</span>
</Checkbox>
<div className="flex items-center gap-2">
<Checkbox
id={advancedId}
checked={useAdvancedParams}
onCheckedChange={(checked) => handleUseAdvancedParamsChange(checked === true)}
aria-label="Use Advanced Parameters"
/>
<label htmlFor={advancedId} className="cursor-pointer text-sm font-medium">
Use Advanced Parameters
</label>
</div>
)}
{onMockTestFallbacksChange && (
<div className="flex items-center gap-1">
<Checkbox checked={mockTestFallbacks ?? false} onChange={(e) => onMockTestFallbacksChange(e.target.checked)}>
<span className="font-medium">Simulate failure to test fallbacks</span>
</Checkbox>
<Popover
trigger="hover"
placement="right"
content={
<div style={{ maxWidth: 340 }}>
<Typography.Paragraph className="text-sm" style={{ marginBottom: 8 }}>
Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify
your fallback setup.
</Typography.Paragraph>
<Typography.Paragraph className="text-sm" style={{ marginBottom: 0 }}>
Behavior can differ when keys, teams, or router settings are configured.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
Learn more
</a>
</Typography.Paragraph>
</div>
}
>
<InfoCircleOutlined
className="text-xs text-gray-400 cursor-pointer shrink-0 hover:text-gray-600"
aria-label="Help: Simulate failure to test fallbacks"
/>
<div className="flex items-center gap-2">
<Checkbox
id={fallbacksId}
checked={mockTestFallbacks ?? false}
onCheckedChange={(checked) => onMockTestFallbacksChange(checked === true)}
aria-label="Simulate failure to test fallbacks"
/>
<label htmlFor={fallbacksId} className="cursor-pointer text-sm font-medium">
Simulate failure to test fallbacks
</label>
<Popover>
<PopoverTrigger aria-label="Help: Simulate failure to test fallbacks">
<Info className="size-3 shrink-0 cursor-pointer text-gray-400 hover:text-gray-600" />
</PopoverTrigger>
<PopoverContent side="right" className="max-w-[340px] gap-2 p-3 text-sm">
<p>
Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify your
fallback setup.
</p>
<p>
Behavior can differ when keys, teams, or router settings are configured.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
Learn more
</a>
</p>
</PopoverContent>
</Popover>
</div>
)}
{showAdvancedParams && (
<div className="space-y-4 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
<div
className={cn("space-y-4 transition-opacity duration-200", useAdvancedParams ? "opacity-100" : "opacity-40")}
>
<div>
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<Text className={`text-sm ${disabledTextColor}`}>Temperature</Text>
<Tooltip title="Controls randomness. Lower values make output more deterministic, higher values more creative.">
<InfoCircleOutlined className={`text-xs ${disabledTextColor} cursor-help`} />
<label htmlFor={temperatureId} className={cn("text-sm", disabledTextColor)}>
Temperature
</label>
<Tooltip>
<TooltipTrigger aria-label="Help: Temperature">
<Info className={cn("size-3 cursor-help", disabledTextColor)} />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Controls randomness. Lower values make output more deterministic, higher values more creative.
</TooltipContent>
</Tooltip>
</div>
<InputNumber
min={0}
max={2}
step={0.1}
value={localTemperature}
onChange={handleTemperatureChange}
<Input
id={`${temperatureId}-number`}
type="text"
inputMode="decimal"
aria-label="Temperature value"
value={temperatureText}
disabled={!useAdvancedParams}
precision={1}
className="w-20"
className="h-8 w-20"
onChange={(event) => handleTemperatureTyped(event.target.value)}
onBlur={() => handleTemperatureChange(Number(temperatureText))}
/>
</div>
<Slider
<input
id={temperatureId}
type="range"
min={0}
max={2}
step={0.1}
value={localTemperature}
onChange={handleTemperatureChange}
disabled={!useAdvancedParams}
marks={{
0: "0",
1: "1.0",
2: "2.0",
}}
aria-label="Temperature"
className="w-full accent-primary disabled:cursor-not-allowed"
onChange={(event) => handleTemperatureChange(Number(event.target.value))}
/>
<div className="mt-1 flex justify-between text-xs text-gray-400">
<span>0</span>
<span>1.0</span>
<span>2.0</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<Text className={`text-sm ${disabledTextColor}`}>Max Tokens</Text>
<Tooltip title="Maximum number of tokens to generate in the response.">
<InfoCircleOutlined className={`text-xs ${disabledTextColor} cursor-help`} />
<label htmlFor={maxTokensId} className={cn("text-sm", disabledTextColor)}>
Max Tokens
</label>
<Tooltip>
<TooltipTrigger aria-label="Help: Max Tokens">
<Info className={cn("size-3 cursor-help", disabledTextColor)} />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Maximum number of tokens to generate in the response.
</TooltipContent>
</Tooltip>
</div>
<InputNumber
min={1}
max={32768}
step={1}
value={localMaxTokens}
onChange={handleMaxTokensChange}
<Input
id={`${maxTokensId}-number`}
type="text"
inputMode="numeric"
aria-label="Max tokens value"
value={maxTokensText}
disabled={!useAdvancedParams}
className="h-8 w-24"
onChange={(event) => handleMaxTokensTyped(event.target.value)}
onBlur={() => handleMaxTokensChange(Number(maxTokensText))}
/>
</div>
<Slider
<input
id={maxTokensId}
type="range"
min={1}
max={32768}
step={1}
value={localMaxTokens}
onChange={handleMaxTokensChange}
disabled={!useAdvancedParams}
marks={{
1: "1",
32768: "32768",
}}
aria-label="Max Tokens"
className="w-full accent-primary disabled:cursor-not-allowed"
onChange={(event) => handleMaxTokensChange(Number(event.target.value))}
/>
<div className="mt-1 flex justify-between text-xs text-gray-400">
<span>1</span>
<span>32768</span>
</div>
</div>
</div>
)}

View file

@ -0,0 +1,114 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChatComposer } from "./ChatComposer";
const renderComposer = (props: Partial<React.ComponentProps<typeof ChatComposer>> = {}) =>
render(<ChatComposer value="" onChange={vi.fn()} onSubmit={vi.fn()} placeholder="Send a message" {...props} />);
const addonOf = (container: HTMLElement) =>
container.querySelector<HTMLElement>("[data-slot=input-group-addon]") as HTMLElement;
describe("ChatComposer", () => {
it("should submit on Enter", () => {
const onSubmit = vi.fn();
renderComposer({ onSubmit });
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("should not submit on Shift+Enter", () => {
const onSubmit = vi.fn();
renderComposer({ onSubmit });
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
});
it("should not submit while an IME composition is active", () => {
const onSubmit = vi.fn();
renderComposer({ onSubmit });
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", isComposing: true });
expect(onSubmit).not.toHaveBeenCalled();
});
it("should not submit on Enter or click when submitDisabled", () => {
const onSubmit = vi.fn();
renderComposer({ onSubmit, submitDisabled: true });
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" });
fireEvent.click(screen.getByTestId("chat-send-button"));
expect(onSubmit).not.toHaveBeenCalled();
});
it("should submit when the send button is clicked", () => {
const onSubmit = vi.fn();
renderComposer({ onSubmit });
fireEvent.click(screen.getByTestId("chat-send-button"));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("should swap send for a stop button that cancels while loading", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
renderComposer({ onSubmit, onCancel, isLoading: true });
expect(screen.queryByTestId("chat-send-button")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("chat-stop-button"));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
});
it("should render suggestions only when asked and report the chosen one", () => {
const onSuggestionSelect = vi.fn();
const { rerender } = renderComposer({ suggestions: ["Summarize this"], onSuggestionSelect });
expect(screen.queryByTestId("chat-suggested-actions")).not.toBeInTheDocument();
rerender(
<ChatComposer
value=""
onChange={vi.fn()}
onSubmit={vi.fn()}
placeholder="Send a message"
suggestions={["Summarize this"]}
showSuggestions
onSuggestionSelect={onSuggestionSelect}
/>,
);
fireEvent.click(screen.getByText("Summarize this"));
expect(onSuggestionSelect).toHaveBeenCalledWith("Summarize this");
});
it("should not nest a form inside the composer when body renders one", () => {
const { container } = renderComposer({
body: (
<form data-testid="body-form">
<input aria-label="tool argument" />
</form>
),
});
expect(container.querySelectorAll("form")).toHaveLength(1);
expect(screen.getByTestId("body-form")).toBeInTheDocument();
});
it("should focus the message box, not a tool input, when the toolbar gap is clicked", () => {
const { container } = renderComposer({
tools: <input type="file" aria-label="Attach file" />,
});
fireEvent.click(addonOf(container));
expect(document.activeElement).toBe(screen.getByTestId("chat-composer-input"));
});
});

View file

@ -0,0 +1,173 @@
import React from "react";
import { ArrowUp, Code2, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
interface ChatComposerProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel?: () => void;
placeholder: string;
disabled?: boolean;
isLoading?: boolean;
submitDisabled?: boolean;
tools?: React.ReactNode;
body?: React.ReactNode;
suggestions?: string[];
showSuggestions?: boolean;
onSuggestionSelect?: (suggestion: string) => void;
className?: string;
}
export function ChatComposer({
value,
onChange,
onSubmit,
onCancel,
placeholder,
disabled = false,
isLoading = false,
submitDisabled = false,
tools,
body,
suggestions = [],
showSuggestions = false,
onSuggestionSelect,
className,
}: ChatComposerProps) {
const submitIfAllowed = () => {
if (!submitDisabled && !isLoading) {
onSubmit();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
submitIfAllowed();
}
};
return (
<div className={cn("relative flex w-full flex-col gap-3", className)}>
{showSuggestions && suggestions.length > 0 && (
<div
className="flex w-full gap-2 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
data-testid="chat-suggested-actions"
>
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="min-w-[200px] shrink-0 rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground sm:min-w-0 sm:whitespace-normal sm:p-4 sm:text-[13px]"
onClick={() => onSuggestionSelect?.(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
<div className="w-full">
<InputGroup
className={cn(
"h-auto min-h-[7.5rem] flex-col overflow-hidden rounded-2xl border border-border bg-card",
"shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)] ring-1 ring-black/5",
"transition-[box-shadow,border-color,ring] duration-200",
"has-[[data-slot=input-group-control]:focus-visible]:border-ring",
"has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_2px_8px_rgba(0,0,0,0.08),0_12px_32px_rgba(0,0,0,0.12)]",
"has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/40",
)}
>
{body ? (
<div className="max-h-48 min-h-24 w-full overflow-y-auto px-3 pt-3">{body}</div>
) : (
<InputGroupTextarea
data-testid="chat-composer-input"
value={value}
disabled={disabled}
placeholder={placeholder}
rows={1}
className="min-h-24 max-h-48 resize-none overflow-y-auto border-0 bg-transparent px-4 pt-3.5 pb-1.5 text-[13px] leading-relaxed shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 [field-sizing:content]"
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
/>
)}
<InputGroupAddon align="block-end" className="justify-between gap-2 px-3 pb-3 pt-1">
<div className="flex min-w-0 items-center gap-1">{tools}</div>
{isLoading && onCancel ? (
<InputGroupButton
type="button"
size="icon-sm"
aria-label="Stop request"
data-testid="chat-stop-button"
className="size-8 rounded-xl bg-foreground text-background hover:bg-foreground/90"
onClick={onCancel}
>
<Square className="size-3.5 fill-current" />
</InputGroupButton>
) : (
<InputGroupButton
type="button"
size="icon-sm"
aria-label="Send message"
data-testid="chat-send-button"
disabled={submitDisabled || isLoading}
onClick={submitIfAllowed}
className={cn(
"size-8 rounded-xl transition-all duration-200",
!submitDisabled && !isLoading
? "bg-foreground text-background hover:opacity-90 active:scale-95"
: "cursor-not-allowed bg-muted text-muted-foreground/40",
)}
>
<ArrowUp className="size-4" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
</div>
);
}
interface CodeInterpreterToggleProps {
enabled: boolean;
onToggle: () => void;
}
export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
"size-8 rounded-lg border border-border/40",
enabled
? "border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100"
: "text-muted-foreground hover:text-foreground",
)}
aria-label={enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
onClick={onToggle}
/>
}
>
<Code2 className="size-4" />
</TooltipTrigger>
<TooltipContent>
{enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
</TooltipContent>
</Tooltip>
);
}
export default ChatComposer;

View file

@ -1,8 +1,8 @@
import React from "react";
import Image from "next/image";
import { FileText } from "lucide-react";
import { MessageType } from "@/components/chat_ui/types";
import { shouldShowChatAttachedImage } from "./ChatImageUtils";
import { FilePdfOutlined } from "@ant-design/icons";
interface ChatImageRendererProps {
message: MessageType;
@ -18,8 +18,8 @@ const ChatImageRenderer: React.FC<ChatImageRendererProps> = ({ message }) => {
return (
<div className="mb-2">
{isPdf ? (
<div className="w-64 h-32 rounded-md border border-gray-200 bg-red-50 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "48px", color: "#dc2626" }} />
<div className="flex h-32 w-64 items-center justify-center rounded-md border border-gray-200 bg-red-50">
<FileText className="size-12 text-red-600" aria-label="PDF attachment" />
</div>
) : (
<Image

View file

@ -1,43 +1,70 @@
import React from "react";
import { Upload, Tooltip } from "antd";
import { PaperClipOutlined } from "@ant-design/icons";
const { Dragger } = Upload;
import React, { useId, useRef } from "react";
import { Paperclip } from "lucide-react";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CHAT_ATTACHMENT_ACCEPT, validateChatAttachment } from "./uploadValidation";
interface ChatImageUploadProps {
chatUploadedImage: File | null;
chatImagePreviewUrl: string | null;
onImageUpload: (file: File) => false;
onImageUpload: (file: File) => void;
onRemoveImage: () => void;
disabled?: boolean;
}
const ChatImageUpload: React.FC<ChatImageUploadProps> = ({
chatUploadedImage,
chatImagePreviewUrl,
onImageUpload,
onRemoveImage,
}) => {
const ChatImageUpload: React.FC<ChatImageUploadProps> = ({ chatUploadedImage, onImageUpload, disabled = false }) => {
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useId();
if (chatUploadedImage) {
return null;
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
const result = validateChatAttachment(file);
if (!result.ok) {
NotificationsManager.error(result.error);
return;
}
onImageUpload(file);
};
return (
<>
{/* Subtle upload button - only show when no image */}
{!chatUploadedImage && (
<Dragger
beforeUpload={onImageUpload}
accept="image/*,.pdf"
showUploadList={false}
className="inline-block"
style={{ padding: 0, border: "none", background: "none" }}
>
<Tooltip title="Attach image or PDF">
<button
<input
id={inputId}
ref={inputRef}
type="file"
accept={CHAT_ATTACHMENT_ACCEPT}
className="sr-only"
tabIndex={-1}
disabled={disabled}
onChange={handleFileChange}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
className="flex items-center justify-center w-8 h-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
>
<PaperClipOutlined style={{ fontSize: "16px" }} />
</button>
</Tooltip>
</Dragger>
)}
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label="Attach image or PDF"
className="text-gray-400 hover:text-gray-600"
onClick={() => inputRef.current?.click()}
/>
}
>
<Paperclip className="size-4" />
</TooltipTrigger>
<TooltipContent>Attach image or PDF</TooltipContent>
</Tooltip>
</>
);
};

View file

@ -1,4 +1,4 @@
import { RobotOutlined, UserOutlined } from "@ant-design/icons";
import { Bot, User } from "lucide-react";
import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@ -41,9 +41,9 @@ function ChatMessageBubble({
const isUser = message.role === "user";
return (
<div className={`mb-4 ${isUser ? "text-right" : "text-left"}`}>
<div className={`mb-4 min-w-0 ${isUser ? "text-right" : "text-left"}`}>
<div
className="inline-block max-w-[80%] rounded-lg shadow-xs p-3.5 px-4"
className="inline-block min-w-0 max-w-[92%] overflow-hidden rounded-lg p-3 shadow-xs sm:max-w-[85%] sm:px-4"
style={{
backgroundColor: isUser ? "#f0f8ff" : "#ffffff",
border: isUser ? "1px solid #e6f0fa" : "1px solid #f0f0f0",
@ -51,7 +51,7 @@ function ChatMessageBubble({
}}
>
{/* Header: role icon + name + model badge */}
<div className="flex items-center gap-2 mb-1.5">
<div className="mb-1.5 flex min-w-0 items-center gap-2">
<div
className="flex items-center justify-center w-6 h-6 rounded-full mr-1"
style={{
@ -59,14 +59,14 @@ function ChatMessageBubble({
}}
>
{isUser ? (
<UserOutlined style={{ fontSize: "12px", color: "#2563eb" }} />
<User className="size-3 text-blue-600" aria-hidden="true" />
) : (
<RobotOutlined style={{ fontSize: "12px", color: "#4b5563" }} />
<Bot className="size-3 text-gray-600" aria-hidden="true" />
)}
</div>
<strong className="text-sm capitalize">{message.role}</strong>
{message.role === "assistant" && message.model && (
<span className="text-xs px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600 font-normal">
<span className="max-w-48 truncate rounded-sm bg-gray-100 px-2 py-0.5 text-xs font-normal text-gray-600 sm:max-w-80">
{message.model}
</span>
)}

View file

@ -1,11 +1,11 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "@/../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChatUI from "./ChatUI";
import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
// Mock the fetchAvailableModels function
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
@ -14,15 +14,18 @@ vi.mock("@/components/llm_calls/chat_completion", () => ({
makeOpenAIChatCompletionRequest: vi.fn().mockResolvedValue(undefined),
}));
// Mock other networking functions that cause errors
vi.mock("@/components/networking", () => ({
tagListCall: vi.fn().mockResolvedValue({ data: [] }),
tagListCall: vi.fn().mockResolvedValue({}),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
getGuardrailsList: vi.fn().mockResolvedValue({ data: [] }),
getPoliciesList: vi.fn().mockResolvedValue({ data: [] }),
modelHubCall: vi.fn().mockResolvedValue({ data: [] }),
fetchMCPServers: vi.fn().mockResolvedValue([]),
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
callMCPTool: vi.fn(),
}));
// Mock scrollIntoView which is not available in jsdom
beforeEach(() => {
Element.prototype.scrollIntoView = () => {};
});
@ -30,17 +33,27 @@ beforeEach(() => {
const CHAT_REQUEST_ARG_COUNT = 26;
const STREAMING_ENABLED_ARG_INDEX = 25;
async function openComboboxByPlaceholder(placeholder: string) {
const user = userEvent.setup();
const combobox = screen.getByPlaceholderText(placeholder);
await user.click(combobox);
return combobox;
}
async function selectComboboxOption(placeholder: string, optionLabel: string) {
const user = userEvent.setup();
await openComboboxByPlaceholder(placeholder);
const option = await screen.findByText(optionLabel);
await user.click(option);
}
describe("ChatUI", () => {
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
sessionStorage.clear();
// Mock scrollIntoView which is not available in JSDOM
Element.prototype.scrollIntoView = vi.fn();
// Mock the fetchAvailableModels to return test models
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValue([
{ model_group: "Model 1", mode: "chat" },
{ model_group: "Model 2", mode: "chat" },
{ model_group: "Model 3", mode: "chat" },
@ -48,7 +61,7 @@ describe("ChatUI", () => {
});
it("should render the chat UI", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -57,11 +70,11 @@ describe("ChatUI", () => {
disabledPersonalKeyCreation={false}
/>,
);
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
it("should show the voice selector when the endpoint type is audio_speech", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -71,47 +84,20 @@ describe("ChatUI", () => {
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Find the endpoint selector by looking for the "Endpoint Type:" text and its associated Select
const endpointTypeText = getByText("Endpoint Type");
const selectContainer = endpointTypeText.parentElement;
const selectElement = selectContainer?.querySelector(".ant-select-selector");
await selectComboboxOption("Select an endpoint", "/v1/audio/speech");
expect(selectElement).toBeInTheDocument();
// Click on the select to open the dropdown
if (selectElement) {
fireEvent.mouseDown(selectElement);
}
// Wait for the dropdown to appear and find the audio_speech option
await waitFor(() => {
const audioSpeechOption = screen.getByText("/v1/audio/speech");
expect(audioSpeechOption).toBeInTheDocument();
expect(screen.getByText("Voice")).toBeInTheDocument();
expect(screen.getByLabelText("Voice")).toBeInTheDocument();
});
// Click on the audio_speech option
const audioSpeechOption = screen.getByText("/v1/audio/speech");
fireEvent.click(audioSpeechOption);
// Verify the voice selector appears
await waitFor(() => {
expect(getByText("Voice")).toBeInTheDocument();
});
// Verify the voice select component is present
const voiceText = getByText("Voice");
const voiceSelectContainer = voiceText.parentElement;
const voiceSelectElement = voiceSelectContainer?.querySelector(".ant-select");
expect(voiceSelectElement).toBeInTheDocument();
});
it("should allow the user to select a model", async () => {
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -121,35 +107,28 @@ describe("ChatUI", () => {
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Open the "Select Model" dropdown (AntD renders options in a portal)
const selectModelLabel = getByText("Select Model");
// The Select component is a sibling of the Text component, so we need to find it in the parent container
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
fireEvent.mouseDown(modelSelect!);
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
const model1Label = screen.getAllByText("Model 1");
expect(model1Label.length).toBeGreaterThan(0);
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
});
});
it("shows only chat-compatible models when chat endpoint is selected", async () => {
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValueOnce([
it("shows only endpoint-compatible models when chat endpoint is selected", async () => {
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ model_group: "ChatModel", mode: "chat" },
{ model_group: "SpeechModel", mode: "audio_speech" },
{ model_group: "ImageModel", mode: "image_generation" },
{ model_group: "ResponsesModel", mode: "responses" },
{ model_group: "RealtimeModel", mode: "realtime" },
{ model_group: "NoModeModel" },
]);
const { getByText } = render(
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -160,43 +139,30 @@ describe("ChatUI", () => {
);
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Open endpoint selector and explicitly select /v1/chat/completions
const endpointTypeText = getByText("Endpoint Type");
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector");
expect(endpointSelect).toBeTruthy();
act(() => {
fireEvent.mouseDown(endpointSelect!);
fireEvent.click(screen.getByText("/v1/chat/completions"));
});
// Open model selector
const selectModelLabel = getByText("Select Model");
// The Select component is a sibling of the Text component, so we need to find it in the parent container
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
act(() => {
fireEvent.mouseDown(modelSelect!);
});
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
// Chat-compatible: ChatModel should be visible
expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0);
expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0);
expect(screen.queryByText("SpeechModel")).toBeNull();
expect(screen.queryByText("ImageModel")).toBeNull();
expect(screen.queryByText("ResponsesModel")).toBeNull();
expect(screen.queryByText("RealtimeModel")).toBeNull();
});
});
/**
* Tests that the 'Enter custom model' option is available in the model selector dropdown.
* This ensures users can manually enter a model name if it's not in the list.
*/
it("should show 'Enter custom model' option in model selector", async () => {
const { getByText } = render(
it("shows only realtime models when realtime endpoint is selected", async () => {
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ model_group: "ChatModel", mode: "chat" },
{ model_group: "RealtimeModel", mode: "realtime" },
{ model_group: "NoModeModel" },
]);
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
@ -206,24 +172,39 @@ describe("ChatUI", () => {
/>,
);
// Wait for the component to render
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Open the "Select Model" dropdown
const selectModelLabel = getByText("Select Model");
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
fireEvent.mouseDown(modelSelect!);
await selectComboboxOption("Select an endpoint", "/v1/realtime");
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
// Get all options in the dropdown (Ant Design renders these in a portal)
const options = document.querySelectorAll(".ant-select-item-option-content");
expect(options.length).toBeGreaterThan(0);
// Check if the first option is 'Enter custom model'
expect(options[0]).toHaveTextContent("Enter custom model");
expect(screen.getAllByText("RealtimeModel").length).toBeGreaterThan(0);
expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0);
expect(screen.queryByText("ChatModel")).toBeNull();
});
});
it("should show 'Enter custom model' option in model selector", async () => {
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
expect(screen.getByText("Enter custom model")).toBeInTheDocument();
});
});
@ -242,44 +223,23 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const endpointTypeText = screen.getByText("Endpoint Type");
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector") as HTMLElement | null;
expect(endpointSelect).not.toBeNull();
const mcpInput = () => screen.getByLabelText("Select MCP servers");
const selectEndpointOption = async (label: string) => {
act(() => {
fireEvent.mouseDown(endpointSelect!);
});
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
act(() => {
fireEvent.click(screen.getByText(label));
});
};
const getMcpSelect = () =>
screen.getByText("MCP Servers").closest("div")?.querySelector(".ant-select") as HTMLElement | null;
await selectEndpointOption("/v1/embeddings");
const mcpSelect = getMcpSelect();
expect(mcpSelect).not.toBeNull();
await selectComboboxOption("Select an endpoint", "/v1/embeddings");
await waitFor(() => {
expect(mcpSelect).toHaveClass("ant-select-disabled");
expect(mcpInput()).toBeDisabled();
});
await selectEndpointOption("/v1/chat/completions");
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
await waitFor(() => {
expect(mcpSelect).not.toHaveClass("ant-select-disabled");
expect(mcpInput()).not.toBeDisabled();
});
});
it("should show Simulate failure to test fallbacks in Model Settings when chat endpoint is selected", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -294,35 +254,13 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Model Settings button only appears when a chat model is selected; select "Model 1" first
const selectModelLabel = screen.getByText("Select Model");
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await selectComboboxOption("Select a Model", "Model 1");
await waitFor(() => {
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
});
// Ant Design Select options may not have role="option"; click the dropdown option by text
const model1Options = screen.getAllByText("Model 1");
await act(async () => {
fireEvent.click(model1Options[model1Options.length - 1]);
});
await waitFor(() => {
const modelSettingsButton = screen.getByTestId("model-settings-button");
expect(modelSettingsButton).toBeInTheDocument();
});
const modelSettingsButton = screen.getByTestId("model-settings-button");
await act(async () => {
fireEvent.click(modelSettingsButton);
});
await user.click(screen.getByTestId("model-settings-button"));
await waitFor(() => {
expect(screen.getByText("Model Settings")).toBeInTheDocument();
@ -334,9 +272,7 @@ describe("ChatUI", () => {
});
expect(fallbacksCheckbox).not.toBeChecked();
await act(async () => {
fireEvent.click(fallbacksCheckbox);
});
await user.click(fallbacksCheckbox);
await waitFor(() => {
expect(screen.getByRole("checkbox", { name: /Simulate failure to test fallbacks/i })).toBeChecked();
@ -344,6 +280,7 @@ describe("ChatUI", () => {
});
it("should send the chat request non-streaming after Stream responses is unchecked", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -358,35 +295,18 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const selectModelLabel = screen.getByText("Select Model");
const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector");
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await waitFor(() => {
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
});
const model1Options = screen.getAllByText("Model 1");
await act(async () => {
fireEvent.click(model1Options[model1Options.length - 1]);
});
await selectComboboxOption("Select a Model", "Model 1");
await waitFor(() => {
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId("model-settings-button"));
});
await user.click(screen.getByTestId("model-settings-button"));
const streamingCheckbox = await screen.findByRole("checkbox", { name: /Stream responses/i });
expect(streamingCheckbox).toBeChecked();
await act(async () => {
fireEvent.click(streamingCheckbox);
});
await user.click(streamingCheckbox);
await waitFor(() => {
expect(screen.getByRole("checkbox", { name: /Stream responses/i })).not.toBeChecked();
@ -447,7 +367,8 @@ describe("ChatUI", () => {
});
it("should offer the streaming toggle for a responses-only model without advanced params", async () => {
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
const user = userEvent.setup();
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValue([
{ model_group: "ResponsesModel", mode: "responses" },
]);
@ -465,37 +386,14 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const endpointTypeText = screen.getByText("Endpoint Type");
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector");
await act(async () => {
fireEvent.mouseDown(endpointSelect!);
});
await act(async () => {
fireEvent.click(screen.getByText("/v1/responses"));
});
const selectModelLabel = screen.getByText("Select Model");
const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector");
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await waitFor(() => {
expect(screen.getAllByText("ResponsesModel").length).toBeGreaterThan(0);
});
const modelOptions = screen.getAllByText("ResponsesModel");
await act(async () => {
fireEvent.click(modelOptions[modelOptions.length - 1]);
});
await selectComboboxOption("Select an endpoint", "/v1/responses");
await selectComboboxOption("Select a Model", "ResponsesModel");
await waitFor(() => {
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId("model-settings-button"));
});
await user.click(screen.getByTestId("model-settings-button"));
expect(await screen.findByRole("checkbox", { name: /Stream responses/i })).toBeChecked();
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
@ -544,6 +442,7 @@ describe("ChatUI", () => {
});
it("should enable search functionality for MCP server selector", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -558,27 +457,161 @@ describe("ChatUI", () => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const mcpServersText = screen.queryByText("MCP Servers");
expect(mcpServersText).toBeInTheDocument();
expect(screen.getByText("MCP Servers")).toBeInTheDocument();
if (mcpServersText) {
const selectContainer = mcpServersText.parentElement?.nextElementSibling;
const selectElement = selectContainer?.querySelector(".ant-select-selector");
expect(selectElement).toBeInTheDocument();
const mcpInput = screen.getByLabelText("Select MCP servers");
expect(mcpInput).toBeInTheDocument();
expect(mcpInput).not.toBeDisabled();
if (selectElement) {
fireEvent.mouseDown(selectElement);
await user.click(mcpInput);
await waitFor(() => {
const allServersOption = screen.queryByText("All MCP Servers");
if (allServersOption) {
expect(allServersOption).toBeInTheDocument();
}
});
await waitFor(() => {
expect(screen.getByText("All MCP Servers")).toBeInTheDocument();
});
});
const searchInput = document.querySelector(".ant-select-selection-search-input");
expect(searchInput).toBeInTheDocument();
}
}
it("should keep the chosen endpoint when a model that endpoint can serve is picked", async () => {
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ model_group: "ChatModel", mode: "chat" },
]);
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
await selectComboboxOption("Select an endpoint", "/v1/responses");
await selectComboboxOption("Select a Model", "ChatModel");
expect(screen.getByPlaceholderText("Select an endpoint")).toHaveValue("/v1/responses");
});
it("should not offer a model the selected endpoint cannot serve", async () => {
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ model_group: "ChatModel", mode: "chat" },
{ model_group: "SpeechModel", mode: "audio_speech" },
]);
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
await selectComboboxOption("Select an endpoint", "/v1/responses");
await openComboboxByPlaceholder("Select a Model");
await waitFor(() => {
expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0);
});
expect(screen.queryByText("SpeechModel")).toBeNull();
});
it("should attach an audio file dropped on the transcription upload area", async () => {
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
await selectComboboxOption("Select an endpoint", "/v1/audio/transcriptions");
const dropZone = (await screen.findByText("Click or drag audio file to upload")).closest("label");
const file = new File(["clip"], "clip.wav", { type: "audio/wav" });
fireEvent.drop(dropZone as HTMLElement, { dataTransfer: { files: [file] } });
expect(await screen.findByText("clip.wav")).toBeInTheDocument();
});
it("should name the virtual key source options instead of showing raw values", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
const keySourceTrigger = screen.getByLabelText("Virtual Key Source");
expect(keySourceTrigger).toHaveTextContent("Current UI Session");
expect(keySourceTrigger).not.toHaveTextContent("session");
await user.click(keySourceTrigger);
await user.click(await screen.findByRole("option", { name: "Virtual Key" }));
await waitFor(() => {
expect(screen.getByLabelText("Virtual Key Source")).toHaveTextContent("Virtual Key");
});
expect(screen.getByLabelText("Virtual Key Source")).not.toHaveTextContent("custom");
});
it("should re-enable the model selector when the virtual key is cleared mid-load", async () => {
const user = userEvent.setup();
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise(() => {}),
);
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
await user.click(screen.getByLabelText("Virtual Key Source"));
await user.click(await screen.findByRole("option", { name: "Virtual Key" }));
const keyField = await screen.findByPlaceholderText("Enter custom Virtual Key");
await user.type(keyField, "sk-test");
await waitFor(() => {
expect(screen.getByPlaceholderText("Loading models...")).toBeInTheDocument();
});
await user.clear(keyField);
await waitFor(() => {
expect(screen.getByPlaceholderText("Select a Model")).not.toBeDisabled();
});
});
});

View file

@ -1,15 +1,10 @@
import React, { useState, useEffect } from "react";
import { Collapse, Spin } from "antd";
import {
CodeOutlined,
DownloadOutlined,
FileImageOutlined,
FileTextOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import React, { useEffect, useState } from "react";
import { Code, Download, FileImage, FileText, Loader2 } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface ContainerFileCitation {
type: "container_file_citation";
@ -27,48 +22,60 @@ interface CodeInterpreterOutputProps {
accessToken: string;
}
const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
code,
containerId,
annotations = [],
accessToken,
}) => {
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif"] as const;
function isImageFilename(filename: string | undefined): boolean {
if (!filename) {
return false;
}
const lower = filename.toLowerCase();
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, annotations = [], accessToken }) => {
const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
const [codeOpen, setCodeOpen] = useState(false);
const proxyBaseUrl = getProxyBaseUrl();
// Fetch images from container files API
useEffect(() => {
const createdUrls: string[] = [];
let cancelled = false;
const fetchImages = async () => {
for (const annotation of annotations) {
const isImage =
annotation.filename?.toLowerCase().endsWith(".png") ||
annotation.filename?.toLowerCase().endsWith(".jpg") ||
annotation.filename?.toLowerCase().endsWith(".jpeg") ||
annotation.filename?.toLowerCase().endsWith(".gif");
if (!isImageFilename(annotation.filename) || !annotation.container_id || !annotation.file_id) {
continue;
}
if (isImage && annotation.container_id && annotation.file_id) {
if (!cancelled) {
setLoadingImages((prev) => ({ ...prev, [annotation.file_id]: true }));
}
try {
// Fetch image content from container files API
const response = await fetch(
`${proxyBaseUrl}/v1/containers/${annotation.container_id}/files/${annotation.file_id}/content`,
{
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
},
try {
const response = await fetch(
`${proxyBaseUrl}/v1/containers/${annotation.container_id}/files/${annotation.file_id}/content`,
{
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
},
);
},
);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
createdUrls.push(url);
if (!cancelled) {
setImageUrls((prev) => ({ ...prev, [annotation.file_id]: url }));
} else {
URL.revokeObjectURL(url);
}
} catch (error) {
console.error("Error fetching image:", error);
} finally {
}
} catch (error) {
console.error("Error fetching image:", error);
} finally {
if (!cancelled) {
setLoadingImages((prev) => ({ ...prev, [annotation.file_id]: false }));
}
}
@ -76,12 +83,12 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
};
if (annotations.length > 0 && accessToken) {
fetchImages();
void fetchImages();
}
// Cleanup URLs on unmount
return () => {
Object.values(imageUrls).forEach((url) => URL.revokeObjectURL(url));
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [annotations, accessToken, proxyBaseUrl]);
@ -112,22 +119,8 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
}
};
// Separate images and other files
const imageAnnotations = annotations.filter(
(a) =>
a.filename?.toLowerCase().endsWith(".png") ||
a.filename?.toLowerCase().endsWith(".jpg") ||
a.filename?.toLowerCase().endsWith(".jpeg") ||
a.filename?.toLowerCase().endsWith(".gif"),
);
const fileAnnotations = annotations.filter(
(a) =>
!a.filename?.toLowerCase().endsWith(".png") &&
!a.filename?.toLowerCase().endsWith(".jpg") &&
!a.filename?.toLowerCase().endsWith(".jpeg") &&
!a.filename?.toLowerCase().endsWith(".gif"),
);
const imageAnnotations = annotations.filter((a) => isImageFilename(a.filename));
const fileAnnotations = annotations.filter((a) => !isImageFilename(a.filename));
if (!code && annotations.length === 0) {
return null;
@ -135,44 +128,46 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
return (
<div className="mt-3 space-y-3">
{/* Executed Code - Collapsible */}
{code && (
<Collapse
size="small"
items={[
{
key: "code",
label: (
<span className="flex items-center gap-2 text-sm text-gray-600">
<CodeOutlined /> Python Code Executed
</span>
),
children: (
<SyntaxHighlighter
language="python"
style={coy}
customStyle={{
margin: 0,
borderRadius: "6px",
fontSize: "12px",
maxHeight: "300px",
overflow: "auto",
}}
>
{code}
</SyntaxHighlighter>
),
},
]}
/>
<Collapsible open={codeOpen} onOpenChange={setCodeOpen} className="rounded-md border border-gray-200">
<CollapsibleTrigger
render={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-sm text-gray-600"
/>
}
>
<Code className="size-4" />
Python Code Executed
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-gray-200 p-2">
<SyntaxHighlighter
language="python"
style={coy}
customStyle={{
margin: 0,
borderRadius: "6px",
fontSize: "12px",
maxHeight: "300px",
overflow: "auto",
}}
>
{code}
</SyntaxHighlighter>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Generated Images */}
{imageAnnotations.map((annotation) => (
<div key={annotation.file_id} className="rounded-lg border border-gray-200 overflow-hidden">
<div key={annotation.file_id} className="overflow-hidden rounded-lg border border-gray-200">
{loadingImages[annotation.file_id] ? (
<div className="flex items-center justify-center p-8 bg-gray-50">
<Spin indicator={<LoadingOutlined spin />} />
<div className="flex items-center justify-center bg-gray-50 p-8">
<Loader2 className="size-4 animate-spin text-gray-500" aria-hidden="true" />
<span className="ml-2 text-sm text-gray-500">Loading image...</span>
</div>
) : imageUrls[annotation.file_id] ? (
@ -180,42 +175,48 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
<img
src={imageUrls[annotation.file_id]}
alt={annotation.filename || "Generated chart"}
className="max-w-full"
style={{ maxHeight: "400px" }}
className="max-h-[400px] max-w-full"
/>
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t border-gray-200">
<span className="text-xs text-gray-500 flex items-center gap-1">
<FileImageOutlined /> {annotation.filename}
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-3 py-2">
<span className="flex items-center gap-1 text-xs text-gray-500">
<FileImage className="size-3" aria-hidden="true" />
{annotation.filename}
</span>
<button
onClick={() => handleDownload(annotation)}
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto gap-1 px-1 py-0 text-xs text-blue-500 hover:text-blue-700"
onClick={() => void handleDownload(annotation)}
>
<DownloadOutlined /> Download
</button>
<Download className="size-3" />
Download
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center p-4 bg-gray-50">
<div className="flex items-center justify-center bg-gray-50 p-4">
<span className="text-sm text-gray-400">Image not available</span>
</div>
)}
</div>
))}
{/* Download Links for Other Files */}
{fileAnnotations.length > 0 && (
<div className="flex flex-wrap gap-2">
{fileAnnotations.map((annotation) => (
<button
<Button
key={annotation.file_id}
onClick={() => handleDownload(annotation)}
className="flex items-center gap-2 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors"
type="button"
variant="outline"
size="sm"
className="h-auto gap-2 border-gray-200 bg-gray-50 px-3 py-2 hover:bg-gray-100"
onClick={() => void handleDownload(annotation)}
>
<FileTextOutlined className="text-blue-500" />
<FileText className="size-4 text-blue-500" aria-hidden="true" />
<span className="text-sm">{annotation.filename}</span>
<DownloadOutlined className="text-gray-400" />
</button>
<Download className="size-3 text-gray-400" aria-hidden="true" />
</Button>
))}
</div>
)}

View file

@ -1,8 +1,8 @@
import React from "react";
import { Switch, Tooltip } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { CodeOutlined, InfoCircleOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
import { Text } from "@tremor/react";
import { Code, Info, TriangleAlert } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface CodeInterpreterToolProps {
accessToken: string;
@ -49,25 +49,30 @@ const CodeInterpreterTool: React.FC<CodeInterpreterToolProps> = ({
<div className="border border-gray-200 rounded-lg p-3 bg-linear-to-r from-blue-50 to-purple-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CodeOutlined className="text-blue-500" />
<Text className="font-medium text-gray-700">Code Interpreter</Text>
<Tooltip title="Run Python code to generate files, charts, and analyze data. Container is created automatically.">
<InfoCircleOutlined className="text-gray-400 text-xs" />
<Code className="size-4 text-blue-500" />
<span className="font-medium text-gray-700">Code Interpreter</span>
<Tooltip>
<TooltipTrigger aria-label="About Code Interpreter">
<Info className="size-3 text-gray-400" />
</TooltipTrigger>
<TooltipContent>
Run Python code to generate files, charts, and analyze data. Container is created automatically.
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={enabled && isOpenAI}
onChange={handleToggle}
onCheckedChange={handleToggle}
disabled={isDisabled}
size="small"
className={enabled && isOpenAI ? "bg-blue-500" : ""}
size="sm"
aria-label="Enable Code Interpreter"
/>
</div>
{!isOpenAI && (
<div className="mt-2 pt-2 border-t border-gray-200">
<div className="flex items-start gap-2">
<ExclamationCircleOutlined className="text-amber-500 mt-0.5" />
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-500" />
<div className="text-xs text-gray-600">
<span>Code Interpreter is currently only supported for OpenAI models. </span>
<a

View file

@ -7,9 +7,9 @@ import { ENDPOINT_OPTIONS } from "./chatConstants";
describe("EndpointSelector", () => {
Object.values(ENDPOINT_OPTIONS).forEach((endpointType) => {
it(`should render the endpoint selector for ${endpointType.value}`, async () => {
const { getByText } = render(<EndpointSelector endpointType={endpointType.value} onEndpointChange={() => {}} />);
render(<EndpointSelector endpointType={endpointType.value} onEndpointChange={() => {}} />);
await waitFor(() => {
expect(getByText(endpointType.label)).toBeInTheDocument();
expect(screen.getByRole("combobox")).toHaveValue(endpointType.label);
});
});
});
@ -18,10 +18,9 @@ describe("EndpointSelector", () => {
const user = userEvent.setup();
render(<EndpointSelector endpointType={ENDPOINT_OPTIONS[0].value} onEndpointChange={() => {}} />);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
const input = await screen.findByRole("combobox");
const input = screen.getByRole("combobox");
await user.click(input);
await user.clear(input);
await user.type(input, "audio");
expect(await screen.findByText("/v1/audio/speech")).toBeInTheDocument();

View file

@ -1,4 +1,4 @@
import { Select } from "antd";
import { SearchSelect } from "@/components/shared/SearchSelect";
import React from "react";
import { ENDPOINT_OPTIONS } from "./chatConstants";
@ -11,17 +11,11 @@ interface EndpointSelectorProps {
const EndpointSelector: React.FC<EndpointSelectorProps> = ({ endpointType, onEndpointChange, className }) => {
return (
<div className={className}>
<Select
showSearch
<SearchSelect
value={endpointType}
style={{ width: "100%" }}
onChange={onEndpointChange}
onValueChange={onEndpointChange}
options={ENDPOINT_OPTIONS}
className="rounded-md"
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase()) ||
(option?.value ?? "").toLowerCase().includes(input.toLowerCase())
}
placeholder="Select an endpoint"
/>
</div>
);

View file

@ -1,37 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { determineEndpointType } from "./EndpointUtils";
import { determineEndpointType, filterModelsForEndpoint, isModelCompatibleWithEndpoint } from "./EndpointUtils";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
// Mock the getEndpointType function
vi.mock("@/components/chat_ui/mode_endpoint_mapping", () => ({
EndpointType: {
IMAGE: "image",
VIDEO: "video",
CHAT: "chat",
RESPONSES: "responses",
IMAGE_EDITS: "image_edits",
ANTHROPIC_MESSAGES: "anthropic_messages",
EMBEDDINGS: "embeddings",
SPEECH: "speech",
TRANSCRIPTION: "transcription",
A2A_AGENTS: "a2a_agents",
},
getEndpointType: vi.fn(),
ModelMode: {
AUDIO_SPEECH: "audio_speech",
AUDIO_TRANSCRIPTION: "audio_transcription",
IMAGE_GENERATION: "image_generation",
VIDEO_GENERATION: "video_generation",
CHAT: "chat",
RESPONSES: "responses",
IMAGE_EDITS: "image_edits",
ANTHROPIC_MESSAGES: "anthropic_messages",
EMBEDDING: "embedding",
},
}));
vi.mock("@/components/chat_ui/mode_endpoint_mapping", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/chat_ui/mode_endpoint_mapping")>();
return {
...actual,
getEndpointType: vi.fn(actual.getEndpointType),
};
});
// Import the mocked function
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
describe("determineEndpointType", () => {
@ -210,10 +189,71 @@ describe("determineEndpointType", () => {
vi.mocked(getEndpointType).mockReturnValue(EndpointType.CHAT);
// Test with different case - should not match
const result = determineEndpointType("gpt-3.5-turbo", mockModelInfo);
expect(getEndpointType).not.toHaveBeenCalled();
expect(result).toBe(EndpointType.CHAT);
});
});
describe("isModelCompatibleWithEndpoint / filterModelsForEndpoint", () => {
beforeEach(async () => {
const actual = await vi.importActual<typeof import("@/components/chat_ui/mode_endpoint_mapping")>(
"@/components/chat_ui/mode_endpoint_mapping",
);
vi.mocked(getEndpointType).mockImplementation(actual.getEndpointType);
});
it("keeps models with no mode for every endpoint", () => {
const model: ModelGroup = { model_group: "custom-proxy-model" };
expect(isModelCompatibleWithEndpoint(model, EndpointType.CHAT)).toBe(true);
expect(isModelCompatibleWithEndpoint(model, EndpointType.REALTIME)).toBe(true);
expect(isModelCompatibleWithEndpoint(model, EndpointType.SPEECH)).toBe(true);
});
it("keeps chat models for responses, anthropic messages, and interactions", () => {
const chatModel: ModelGroup = { model_group: "gpt-4o", mode: "chat" };
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.RESPONSES)).toBe(true);
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.ANTHROPIC_MESSAGES)).toBe(true);
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.INTERACTIONS)).toBe(true);
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.SPEECH)).toBe(false);
});
it("keeps image models for image_edits", () => {
const imageModel: ModelGroup = { model_group: "dall-e-3", mode: "image_generation" };
expect(isModelCompatibleWithEndpoint(imageModel, EndpointType.IMAGE_EDITS)).toBe(true);
expect(isModelCompatibleWithEndpoint(imageModel, EndpointType.IMAGE)).toBe(true);
expect(isModelCompatibleWithEndpoint(imageModel, EndpointType.CHAT)).toBe(false);
});
it("keeps only realtime models for the realtime endpoint", () => {
const models: ModelGroup[] = [
{ model_group: "gpt-4o", mode: "chat" },
{ model_group: "gpt-realtime", mode: "realtime" },
{ model_group: "no-mode" },
];
expect(filterModelsForEndpoint(models, EndpointType.REALTIME).map((m) => m.model_group)).toEqual([
"gpt-realtime",
"no-mode",
]);
});
it("excludes unknown modes from conversational endpoints", () => {
const batchModel: ModelGroup = { model_group: "batch-job", mode: "batch" };
const rerankModel: ModelGroup = { model_group: "reranker", mode: "rerank" };
expect(isModelCompatibleWithEndpoint(batchModel, EndpointType.CHAT)).toBe(false);
expect(isModelCompatibleWithEndpoint(rerankModel, EndpointType.RESPONSES)).toBe(false);
expect(isModelCompatibleWithEndpoint(batchModel, EndpointType.REALTIME)).toBe(false);
});
it("keeps image-edit models for the image-edits endpoint using the mode the backend sends", () => {
const imageEditModel: ModelGroup = { model_group: "gpt-image-1", mode: "image_edit" };
const imageModel: ModelGroup = { model_group: "dall-e-3", mode: "image_generation" };
expect(isModelCompatibleWithEndpoint(imageEditModel, EndpointType.IMAGE_EDITS)).toBe(true);
expect(
filterModelsForEndpoint([imageEditModel, imageModel], EndpointType.IMAGE_EDITS).map((m) => m.model_group),
).toEqual(["gpt-image-1", "dall-e-3"]);
});
});

View file

@ -1,22 +1,43 @@
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import { EndpointType, getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { EndpointType, getEndpointType, ModelMode } from "@/components/chat_ui/mode_endpoint_mapping";
const KNOWN_MODEL_MODES = new Set<string>(Object.values(ModelMode));
/**
* Determines the appropriate endpoint type based on the selected model
*
* @param selectedModel - The model identifier string
* @param modelInfo - Array of model information
* @returns The appropriate endpoint type
*/
export const determineEndpointType = (selectedModel: string, modelInfo: ModelGroup[]): EndpointType => {
// Find the model information for the selected model
const selectedModelInfo = modelInfo.find((option) => option.model_group === selectedModel);
// If model info is found and it has a mode, determine the endpoint type
if (selectedModelInfo?.mode) {
return getEndpointType(selectedModelInfo.mode);
}
// Default to chat endpoint if no match is found
return EndpointType.CHAT;
};
export const isModelCompatibleWithEndpoint = (model: ModelGroup, endpointType: EndpointType): boolean => {
if (!model.mode) {
return true;
}
if (!KNOWN_MODEL_MODES.has(model.mode)) {
return false;
}
const optionEndpoint = getEndpointType(model.mode);
if (
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
endpointType === EndpointType.INTERACTIONS
) {
return optionEndpoint === endpointType || optionEndpoint === EndpointType.CHAT;
}
if (endpointType === EndpointType.IMAGE_EDITS) {
return optionEndpoint === endpointType || optionEndpoint === EndpointType.IMAGE;
}
return optionEndpoint === endpointType;
};
export const filterModelsForEndpoint = (models: ModelGroup[], endpointType: EndpointType): ModelGroup[] =>
models.filter((model) => isModelCompatibleWithEndpoint(model, endpointType));

View file

@ -1,4 +1,5 @@
import { DeleteOutlined, FilePdfOutlined } from "@ant-design/icons";
import { FileText, X } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FilePreviewCardProps {
file: File;
@ -15,7 +16,7 @@ function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
<div className="relative inline-block">
{isPdf ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
<FileText className="size-4 text-white" aria-hidden="true" />
</div>
) : (
<img
@ -29,12 +30,16 @@ function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
<div className="text-sm font-medium text-gray-900 truncate">{file.name}</div>
<div className="text-xs text-gray-500">{isPdf ? "PDF" : "Image"}</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Remove ${file.name}`}
className="text-gray-400 hover:text-gray-600 hover:bg-gray-200"
onClick={onRemove}
>
<DeleteOutlined style={{ fontSize: "12px" }} />
</button>
<X className="size-3" />
</Button>
</div>
</div>
);

View file

@ -1,7 +1,7 @@
import React from "react";
import { FileText } from "lucide-react";
import { MessageType } from "@/components/chat_ui/types";
import { shouldShowAttachedImage } from "./ResponsesImageUtils";
import { FilePdfOutlined } from "@ant-design/icons";
interface ResponsesImageRendererProps {
message: MessageType;
@ -17,15 +17,14 @@ const ResponsesImageRenderer: React.FC<ResponsesImageRendererProps> = ({ message
return (
<div className="mb-2">
{isPdf ? (
<div className="w-64 h-32 rounded-md border border-gray-200 bg-red-50 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "48px", color: "#dc2626" }} />
<div className="flex h-32 w-64 items-center justify-center rounded-md border border-gray-200 bg-red-50">
<FileText className="size-12 text-red-600" aria-label="PDF attachment" />
</div>
) : (
<img
src={message.imagePreviewUrl}
alt="User uploaded image"
className="max-w-64 rounded-md border border-gray-200 shadow-xs"
style={{ maxHeight: "200px" }}
className="max-h-[200px] max-w-64 rounded-md border border-gray-200 shadow-xs"
/>
)}
</div>

View file

@ -1,43 +1,74 @@
import React from "react";
import { Upload, Tooltip } from "antd";
import { PaperClipOutlined } from "@ant-design/icons";
const { Dragger } = Upload;
import React, { useId, useRef } from "react";
import { Paperclip } from "lucide-react";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CHAT_ATTACHMENT_ACCEPT, validateChatAttachment } from "./uploadValidation";
interface ResponsesImageUploadProps {
responsesUploadedImage: File | null;
responsesImagePreviewUrl: string | null;
onImageUpload: (file: File) => false;
onImageUpload: (file: File) => void;
onRemoveImage: () => void;
disabled?: boolean;
}
const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
responsesUploadedImage,
responsesImagePreviewUrl,
onImageUpload,
onRemoveImage,
disabled = false,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useId();
if (responsesUploadedImage) {
return null;
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
const result = validateChatAttachment(file);
if (!result.ok) {
NotificationsManager.error(result.error);
return;
}
onImageUpload(file);
};
return (
<>
{/* Subtle upload button - only show when no image */}
{!responsesUploadedImage && (
<Dragger
beforeUpload={onImageUpload}
accept="image/*,.pdf"
showUploadList={false}
className="inline-block"
style={{ padding: 0, border: "none", background: "none" }}
>
<Tooltip title="Attach image or PDF">
<button
<input
id={inputId}
ref={inputRef}
type="file"
accept={CHAT_ATTACHMENT_ACCEPT}
className="sr-only"
tabIndex={-1}
disabled={disabled}
onChange={handleFileChange}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
className="flex items-center justify-center w-8 h-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
>
<PaperClipOutlined style={{ fontSize: "16px" }} />
</button>
</Tooltip>
</Dragger>
)}
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label="Attach image or PDF"
className="text-gray-400 hover:text-gray-600"
onClick={() => inputRef.current?.click()}
/>
}
>
<Paperclip className="size-4" />
</TooltipTrigger>
<TooltipContent>Attach image or PDF</TooltipContent>
</Tooltip>
</>
);
};

View file

@ -1,7 +1,8 @@
import React, { useState } from "react";
import { Button } from "antd";
import { VectorStoreSearchResponse } from "@/components/chat_ui/types";
import { DatabaseOutlined, FileTextOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import { ChevronDown, ChevronRight, Database, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface SearchResultsDisplayProps {
searchResults: VectorStoreSearchResponse[];
@ -27,95 +28,94 @@ export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProp
return (
<div className="search-results-content mt-1 mb-2">
<Button
type="text"
className="flex items-center text-xs text-gray-500 hover:text-gray-700"
onClick={() => setIsExpanded(!isExpanded)}
icon={<DatabaseOutlined />}
>
{isExpanded ? "Hide sources" : `Show sources (${totalResults})`}
{isExpanded ? <DownOutlined className="ml-1" /> : <RightOutlined className="ml-1" />}
</Button>
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger
render={
<Button type="button" variant="ghost" size="sm" className="text-xs text-gray-500 hover:text-gray-700" />
}
>
<Database className="size-4" />
{isExpanded ? "Hide sources" : `Show sources (${totalResults})`}
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</CollapsibleTrigger>
{isExpanded && (
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md text-sm">
<div className="space-y-3">
{searchResults.map((resultPage, pageIndex) => (
<div key={pageIndex}>
<div className="text-xs text-gray-600 mb-2 flex items-center gap-2">
<span className="font-medium">Query:</span>
<span className="italic">&quot;{resultPage.search_query}&quot;</span>
<span className="text-gray-400"></span>
<span className="text-gray-500">
{resultPage.data.length} result{resultPage.data.length !== 1 ? "s" : ""}
</span>
</div>
<CollapsibleContent>
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md text-sm">
<div className="space-y-3">
{searchResults.map((resultPage, pageIndex) => (
<div key={pageIndex}>
<div className="text-xs text-gray-600 mb-2 flex items-center gap-2">
<span className="font-medium">Query:</span>
<span className="italic">&quot;{resultPage.search_query}&quot;</span>
<span className="text-gray-400"></span>
<span className="text-gray-500">
{resultPage.data.length} result{resultPage.data.length !== 1 ? "s" : ""}
</span>
</div>
<div className="space-y-2">
{resultPage.data.map((result, resultIndex) => {
const isResultExpanded = expandedResults[`${pageIndex}-${resultIndex}`] || false;
<div className="space-y-2">
{resultPage.data.map((result, resultIndex) => {
const isResultExpanded = expandedResults[`${pageIndex}-${resultIndex}`] || false;
return (
<div key={resultIndex} className="border border-gray-200 rounded-md overflow-hidden bg-white">
<div
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => toggleResult(pageIndex, resultIndex)}
return (
<Collapsible
key={resultIndex}
open={isResultExpanded}
onOpenChange={() => toggleResult(pageIndex, resultIndex)}
className="overflow-hidden rounded-md border border-gray-200 bg-white"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<svg
className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isResultExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<FileTextOutlined className="text-gray-400 shrink-0" style={{ fontSize: "12px" }} />
<span className="text-xs font-medium text-gray-700 truncate">
{result.filename || result.file_id || `Result ${resultIndex + 1}`}
</span>
<span className="text-xs px-2 py-0.5 rounded-sm bg-blue-100 text-blue-700 font-mono shrink-0">
{result.score.toFixed(3)}
</span>
</div>
</div>
{isResultExpanded && (
<div className="border-t border-gray-200 bg-white">
<div className="p-3 space-y-2">
{result.content.map((content, contentIndex) => (
<div key={contentIndex}>
<div className="text-xs font-mono bg-gray-50 p-2 rounded-sm text-gray-800 whitespace-pre-wrap wrap-break-word">
{content.text}
</div>
</div>
))}
{result.attributes && Object.keys(result.attributes).length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-100">
<div className="text-xs text-gray-500 mb-1 font-medium">Metadata:</div>
<div className="space-y-1">
{Object.entries(result.attributes).map(([key, value]) => (
<div key={key} className="text-xs flex gap-2">
<span className="text-gray-500 font-medium">{key}:</span>
<span className="text-gray-700 font-mono break-all">{String(value)}</span>
</div>
))}
</div>
</div>
)}
<CollapsibleTrigger className="flex w-full items-center justify-between p-2 text-left transition-colors hover:bg-gray-50">
<div className="flex items-center gap-2 flex-1 min-w-0">
<ChevronRight
className={`size-4 shrink-0 text-gray-400 transition-transform ${isResultExpanded ? "rotate-90" : ""}`}
/>
<FileText className="size-3 shrink-0 text-gray-400" />
<span className="text-xs font-medium text-gray-700 truncate">
{result.filename || result.file_id || `Result ${resultIndex + 1}`}
</span>
<span className="text-xs px-2 py-0.5 rounded-sm bg-blue-100 text-blue-700 font-mono shrink-0">
{result.score.toFixed(3)}
</span>
</div>
</div>
)}
</div>
);
})}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-gray-200 bg-white">
<div className="p-3 space-y-2">
{result.content.map((content, contentIndex) => (
<div key={contentIndex}>
<div className="text-xs font-mono bg-gray-50 p-2 rounded-sm text-gray-800 whitespace-pre-wrap wrap-break-word">
{content.text}
</div>
</div>
))}
{result.attributes && Object.keys(result.attributes).length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-100">
<div className="text-xs text-gray-500 mb-1 font-medium">Metadata:</div>
<div className="space-y-1">
{Object.entries(result.attributes).map(([key, value]) => (
<div key={key} className="text-xs flex gap-2">
<span className="text-gray-500 font-medium">{key}:</span>
<span className="text-gray-700 font-mono break-all">{String(value)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -1,8 +1,10 @@
import React from "react";
import { Switch, Tooltip } from "antd";
import { InfoCircleOutlined, CopyOutlined } from "@ant-design/icons";
import { Copy, Info } from "lucide-react";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface SessionManagementProps {
endpointType: string;
@ -21,10 +23,14 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
return null;
}
const handleCopySessionId = () => {
const handleCopySessionId = async () => {
if (responsesSessionId) {
navigator.clipboard.writeText(responsesSessionId);
NotificationsManager.success("Response ID copied to clipboard!");
try {
await navigator.clipboard.writeText(responsesSessionId);
NotificationsManager.success("Response ID copied to clipboard!");
} catch {
NotificationsManager.error("Unable to copy response ID");
}
}
};
@ -56,17 +62,26 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">Session Management</span>
<Tooltip title="Choose between LiteLLM API session management (using previous_response_id) or UI-based session management (using chat history)">
<InfoCircleOutlined className="text-gray-400" style={{ fontSize: "12px" }} />
<Tooltip>
<TooltipTrigger aria-label="About session management">
<Info className="size-3 text-gray-400" />
</TooltipTrigger>
<TooltipContent>
Choose between LiteLLM API session management (using previous_response_id) or UI-based session management
(using chat history)
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={useApiSessionManagement}
onChange={onToggleSessionManagement}
checkedChildren="API"
unCheckedChildren="UI"
size="small"
/>
<div className="flex items-center gap-2 text-xs text-gray-600">
<span aria-hidden="true">UI</span>
<Switch
checked={useApiSessionManagement}
onCheckedChange={onToggleSessionManagement}
aria-label="Use API session management"
size="sm"
/>
<span aria-hidden="true">API</span>
</div>
</div>
{/* Session Status Indicator */}
@ -79,12 +94,26 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<InfoCircleOutlined style={{ fontSize: "12px" }} />
<Info className="size-3" />
{getSessionDisplay()}
</div>
{responsesSessionId && (
<Tooltip
title={
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={handleCopySessionId}
aria-label="Copy response ID"
className="ml-2 hover:bg-green-100"
/>
}
>
<Copy className="size-3" />
</TooltipTrigger>
<TooltipContent className="max-w-lg">
<div className="text-xs">
<div className="mb-1">Copy response ID to continue session:</div>
<div className="bg-gray-800 text-gray-100 p-2 rounded-sm font-mono text-xs whitespace-pre-wrap">
@ -99,15 +128,7 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
}'`}
</div>
</div>
}
overlayStyle={{ maxWidth: "500px" }}
>
<button
onClick={handleCopySessionId}
className="ml-2 p-1 hover:bg-green-100 rounded-sm transition-colors"
>
<CopyOutlined style={{ fontSize: "12px" }} />
</button>
</TooltipContent>
</Tooltip>
)}
</div>

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
MAX_AUDIO_BYTES,
MAX_CHAT_ATTACHMENT_BYTES,
MAX_IMAGE_EDIT_COUNT,
validateAudioFile,
validateChatAttachment,
validateImageEditFile,
} from "./uploadValidation";
function makeFile(name: string, type: string, sizeBytes = 1024): File {
const content = new Uint8Array(sizeBytes);
return new File([content], name, { type });
}
describe("validateChatAttachment", () => {
it("accepts supported image types", () => {
expect(validateChatAttachment(makeFile("a.png", "image/png"))).toEqual({ ok: true });
expect(validateChatAttachment(makeFile("a.jpg", "image/jpeg"))).toEqual({ ok: true });
expect(validateChatAttachment(makeFile("a.webp", "image/webp"))).toEqual({ ok: true });
});
it("accepts PDF by mime type or extension", () => {
expect(validateChatAttachment(makeFile("doc.pdf", "application/pdf"))).toEqual({ ok: true });
expect(validateChatAttachment(makeFile("doc.PDF", ""))).toEqual({ ok: true });
});
it("rejects unsupported types", () => {
const result = validateChatAttachment(makeFile("notes.txt", "text/plain"));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("not a supported attachment");
}
});
it("rejects files over the size limit", () => {
const result = validateChatAttachment(makeFile("huge.png", "image/png", MAX_CHAT_ATTACHMENT_BYTES + 1));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("too large");
}
});
});
describe("validateImageEditFile", () => {
it("accepts images under the count limit", () => {
expect(validateImageEditFile(makeFile("a.png", "image/png"), 0)).toEqual({ ok: true });
});
it("rejects when the count limit is reached", () => {
const result = validateImageEditFile(makeFile("a.png", "image/png"), MAX_IMAGE_EDIT_COUNT);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain(`at most ${MAX_IMAGE_EDIT_COUNT}`);
}
});
it("rejects PDFs for image edits", () => {
const result = validateImageEditFile(makeFile("doc.pdf", "application/pdf"), 0);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("not a supported image");
}
});
});
describe("validateAudioFile", () => {
it("accepts common audio types", () => {
expect(validateAudioFile(makeFile("a.mp3", "audio/mpeg"))).toEqual({ ok: true });
expect(validateAudioFile(makeFile("a.wav", "audio/wav"))).toEqual({ ok: true });
expect(validateAudioFile(makeFile("a.webm", ""))).toEqual({ ok: true });
});
it("rejects non-audio files and oversized files", () => {
expect(validateAudioFile(makeFile("a.png", "image/png")).ok).toBe(false);
expect(validateAudioFile(makeFile("a.mp3", "audio/mpeg", MAX_AUDIO_BYTES + 1)).ok).toBe(false);
});
});

View file

@ -0,0 +1,97 @@
export type UploadValidationResult = { ok: true } | { ok: false; error: string };
export const CHAT_ATTACHMENT_ACCEPT = "image/png,image/jpeg,image/jpg,image/gif,image/webp,application/pdf,.pdf";
export const IMAGE_EDIT_ACCEPT = "image/png,image/jpeg,image/jpg,image/gif,image/webp";
export const AUDIO_ACCEPT = "audio/*,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
export const MAX_CHAT_ATTACHMENT_BYTES = 20 * 1024 * 1024;
export const MAX_IMAGE_EDIT_BYTES = 20 * 1024 * 1024;
export const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
export const MAX_IMAGE_EDIT_COUNT = 10;
export const MAX_CHAT_ATTACHMENT_COUNT = 1;
const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"]);
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
const PDF_MIME_TYPES = new Set(["application/pdf"]);
const PDF_EXTENSIONS = new Set([".pdf"]);
const AUDIO_MIME_PREFIX = "audio/";
const AUDIO_EXTENSIONS = new Set([".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm"]);
function getExtension(fileName: string): string {
const dot = fileName.lastIndexOf(".");
if (dot < 0) {
return "";
}
return fileName.slice(dot).toLowerCase();
}
function formatMb(bytes: number): string {
return `${Math.round(bytes / (1024 * 1024))} MB`;
}
function isImageFile(file: File): boolean {
if (IMAGE_MIME_TYPES.has(file.type)) {
return true;
}
return IMAGE_EXTENSIONS.has(getExtension(file.name));
}
function isPdfFile(file: File): boolean {
if (PDF_MIME_TYPES.has(file.type)) {
return true;
}
return PDF_EXTENSIONS.has(getExtension(file.name));
}
function isAudioFile(file: File): boolean {
if (file.type.startsWith(AUDIO_MIME_PREFIX)) {
return true;
}
return AUDIO_EXTENSIONS.has(getExtension(file.name));
}
function validateSize(file: File, maxBytes: number): UploadValidationResult {
if (file.size <= maxBytes) {
return { ok: true };
}
return {
ok: false,
error: `"${file.name}" is too large. Maximum size is ${formatMb(maxBytes)}.`,
};
}
export function validateChatAttachment(file: File): UploadValidationResult {
if (!isImageFile(file) && !isPdfFile(file)) {
return {
ok: false,
error: `"${file.name}" is not a supported attachment. Use PNG, JPEG, GIF, WebP, or PDF.`,
};
}
return validateSize(file, MAX_CHAT_ATTACHMENT_BYTES);
}
export function validateImageEditFile(file: File, currentCount: number): UploadValidationResult {
if (currentCount >= MAX_IMAGE_EDIT_COUNT) {
return {
ok: false,
error: `You can upload at most ${MAX_IMAGE_EDIT_COUNT} images.`,
};
}
if (!isImageFile(file)) {
return {
ok: false,
error: `"${file.name}" is not a supported image. Use PNG, JPEG, GIF, or WebP.`,
};
}
return validateSize(file, MAX_IMAGE_EDIT_BYTES);
}
export function validateAudioFile(file: File): UploadValidationResult {
if (!isAudioFile(file)) {
return {
ok: false,
error: `"${file.name}" is not a supported audio file. Use MP3, MP4, MPEG, MPGA, M4A, WAV, or WEBM.`,
};
}
return validateSize(file, MAX_AUDIO_BYTES);
}

View file

@ -5,10 +5,10 @@ import AgentBuilderView from "@/app/(dashboard)/playground/components/chat_ui/Ag
import ChatUI from "@/app/(dashboard)/playground/components/chat_ui/ChatUI";
import CompareUI from "@/app/(dashboard)/playground/components/compareUI/CompareUI";
import ComplianceUI from "@/app/(dashboard)/playground/components/complianceUI/ComplianceUI";
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchProxySettings } from "@/utils/proxyUtils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface ProxySettings {
PROXY_BASE_URL?: string;
@ -47,45 +47,54 @@ export default function PlaygroundPage() {
}
return (
<div className="h-full w-full flex flex-col">
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<TabList className="mb-0">
<Tab>Chat</Tab>
<Tab>Compare</Tab>
<Tab>Compliance</Tab>
<Tab>Agent Builder (Experimental)</Tab>
</TabList>
<TabPanels className="h-full">
<TabPanel className="h-full">
<ChatUI
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
</TabPanel>
<TabPanel className="h-full">
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<DeprecationBanner featureName="The Playground's Agent Builder" />
<AgentBuilderView
accessToken={accessToken}
token={token}
userID={userId}
userRole={userRole}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
/>
</TabPanel>
</TabPanels>
</TabGroup>
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
<Tabs defaultValue="chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden">
<TabsList
variant="line"
className="mb-0 h-auto w-full shrink-0 justify-start overflow-x-auto rounded-none border-b border-border bg-transparent p-0"
>
<TabsTrigger value="chat" className="flex-none rounded-none px-4 py-2">
Chat
</TabsTrigger>
<TabsTrigger value="compare" className="flex-none rounded-none px-4 py-2">
Compare
</TabsTrigger>
<TabsTrigger value="compliance" className="flex-none rounded-none px-4 py-2">
Compliance
</TabsTrigger>
<TabsTrigger value="agent-builder" className="flex-none rounded-none px-4 py-2">
Agent Builder (Experimental)
</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="mt-0 h-full min-h-0 min-w-0 overflow-hidden data-hidden:hidden">
<ChatUI
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
</TabsContent>
<TabsContent value="compare" className="mt-0 h-full data-hidden:hidden">
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabsContent>
<TabsContent value="compliance" className="mt-0 h-full data-hidden:hidden">
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabsContent>
<TabsContent value="agent-builder" className="mt-0 h-full data-hidden:hidden">
<DeprecationBanner featureName="The Playground's Agent Builder" />
<AgentBuilderView
accessToken={accessToken}
token={token}
userID={userId}
userRole={userRole}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
/>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { renderWithProviders, screen } from "../../../../tests/test-utils";
import { NotificationsBell, AGENT_PLATFORM_URL } from "./NotificationsBell";
import { NotificationsBell, AUTO_ROUTER_DOCS_URL } from "./NotificationsBell";
import React from "react";
import userEvent from "@testing-library/user-event";
@ -8,15 +8,15 @@ describe("NotificationsBell", () => {
localStorage.clear();
});
it("should open notifications with Agent Platform details and GitHub link", async () => {
it("should open notifications with Auto Router details and docs link", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.getByText(/LiteLLM Agent Platform/i)).toBeInTheDocument();
const githubBtn = screen.getByRole("link", { name: /^GitHub$/i });
expect(githubBtn).toHaveAttribute("href", AGENT_PLATFORM_URL);
expect(githubBtn).toHaveAttribute("target", "_blank");
expect(githubBtn).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.getByText(/^LiteLLM Auto Router$/i)).toBeInTheDocument();
const docsBtn = screen.getByRole("link", { name: /^read the docs$/i });
expect(docsBtn).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
expect(docsBtn).toHaveAttribute("target", "_blank");
expect(docsBtn).toHaveAttribute("rel", "noopener noreferrer");
});
it("should offer mark as read when announcement is unread", async () => {
@ -31,13 +31,13 @@ describe("NotificationsBell", () => {
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
expect(localStorage.getItem("litellmHideAgentPlatformBanner")).toBe("true");
expect(localStorage.getItem("litellmHideAutoRouterAnnouncement")).toBe("true");
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
});
it("should not show mark as read when previously dismissed", async () => {
localStorage.setItem("litellmHideAgentPlatformBanner", "true");
localStorage.setItem("litellmHideAutoRouterAnnouncement", "true");
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));

View file

@ -1,38 +1,38 @@
"use client";
import {
HIDE_AGENT_PLATFORM_BANNER_KEY,
useHideAgentPlatformBanner,
} from "@/app/(dashboard)/hooks/useHideAgentPlatformBanner";
HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY,
useHideAutoRouterAnnouncement,
} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
import { BellOutlined } from "@ant-design/icons";
import { Badge, Button, Popover, Typography } from "antd";
import React, { useState } from "react";
export const AGENT_PLATFORM_URL = "https://github.com/BerriAI/litellm-agent-platform";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
export const NotificationsBell: React.FC = () => {
const hidden = useHideAgentPlatformBanner();
const hidden = useHideAutoRouterAnnouncement();
const hasUnread = !hidden;
const [open, setOpen] = useState(false);
const markDismissed = () => {
setLocalStorageItem(HIDE_AGENT_PLATFORM_BANNER_KEY, "true");
emitLocalStorageChange(HIDE_AGENT_PLATFORM_BANNER_KEY);
setLocalStorageItem(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY, "true");
emitLocalStorageChange(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY);
setOpen(false);
};
const content = (
<div className="max-w-[280px]">
<Typography.Title level={5} className="mt-0! mb-2!">
LiteLLM Agent Platform
LiteLLM Auto Router
</Typography.Title>
<Typography.Paragraph type="secondary" className="mb-3! text-sm leading-snug">
Open-source agent infra sandboxes, durable sessions, and workers on AWS Fargate.
Route every request to the cheapest model that can handle it, no prompt changes needed.
</Typography.Paragraph>
<div className="flex flex-wrap items-center gap-2">
<Button type="primary" size="small" href={AGENT_PLATFORM_URL} target="_blank" rel="noopener noreferrer">
GitHub
<Button type="primary" size="small" href={AUTO_ROUTER_DOCS_URL} target="_blank" rel="noopener noreferrer">
Read the docs
</Button>
{hasUnread ? (
<Button type="link" size="small" className="px-1!" onClick={markDismissed}>

View file

@ -1,224 +1,175 @@
import React from "react";
import { Collapse } from "antd";
import React, { useState } from "react";
import { ChevronRight } from "lucide-react";
import type { MCPEvent } from "@/components/mcp_tools/types";
const { Panel } = Collapse;
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/cva.config";
interface MCPEventsDisplayProps {
events: MCPEvent[];
className?: string;
}
function formatArguments(raw: string | undefined): string {
if (!raw) {
return "";
}
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
const MCPEventsDisplay: React.FC<MCPEventsDisplayProps> = ({ events, className }) => {
if (!events || events.length === 0) {
return null;
}
// Find the list tools event
const toolsEvent = events.find(
(event) =>
event.type === "response.output_item.done" &&
event.item?.type === "mcp_list_tools" &&
event.item.tools &&
event.item.tools.length > 0,
);
const isListToolsEvent = (event: MCPEvent): boolean => {
if (event.type !== "response.output_item.done") {
return false;
}
if (event.item?.type !== "mcp_list_tools") {
return false;
}
return Boolean(event.item.tools && event.item.tools.length > 0);
};
// Find MCP call events
const mcpCallEvents = events.filter(
(event) => event.type === "response.output_item.done" && event.item?.type === "mcp_call",
);
const isMcpCallEvent = (event: MCPEvent): boolean =>
event.type === "response.output_item.done" && event.item?.type === "mcp_call";
const toolsEvent = events.find(isListToolsEvent);
const mcpCallEvents = events.filter(isMcpCallEvent);
if (!toolsEvent && mcpCallEvents.length === 0) {
return null;
}
return (
<div className={`mcp-events-display ${className || ""}`}>
<style jsx>{`
.openai-mcp-tools {
position: relative;
margin: 0;
padding: 0;
}
.openai-mcp-tools .ant-collapse {
background: transparent !important;
border: none !important;
}
.openai-mcp-tools .ant-collapse-item {
border: none !important;
background: transparent !important;
}
.openai-mcp-tools .ant-collapse-header {
padding: 0 0 0 20px !important;
background: transparent !important;
border: none !important;
font-size: 14px !important;
color: #9ca3af !important;
font-weight: 400 !important;
line-height: 20px !important;
min-height: 20px !important;
}
.openai-mcp-tools .ant-collapse-header:hover {
background: transparent !important;
color: #6b7280 !important;
}
.openai-mcp-tools .ant-collapse-content {
border: none !important;
background: transparent !important;
}
.openai-mcp-tools .ant-collapse-content-box {
padding: 4px 0 0 20px !important;
}
.openai-mcp-tools .ant-collapse-expand-icon {
position: absolute !important;
left: 2px !important;
top: 2px !important;
color: #9ca3af !important;
font-size: 10px !important;
width: 16px !important;
height: 16px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.openai-mcp-tools .ant-collapse-expand-icon:hover {
color: #6b7280 !important;
}
.openai-vertical-line {
position: absolute;
left: 9px;
top: 18px;
bottom: 0;
width: 0.5px;
background-color: #f3f4f6;
opacity: 0.8;
}
.tool-item {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 13px;
color: #4b5563;
line-height: 18px;
padding: 0;
margin: 0;
background: white;
position: relative;
z-index: 1;
}
.mcp-section {
margin-bottom: 12px;
background: white;
position: relative;
z-index: 1;
}
.mcp-section:last-child {
margin-bottom: 0;
}
.mcp-section-header {
font-size: 13px;
color: #6b7280;
font-weight: 500;
margin-bottom: 4px;
}
.mcp-code-block {
background: #f9fafb;
border: 1px solid #f3f4f6;
border-radius: 6px;
padding: 8px;
font-size: 12px;
}
.mcp-json {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
color: #374151;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.mcp-approved {
display: flex;
align-items: center;
font-size: 13px;
color: #6b7280;
}
.mcp-checkmark {
color: #10b981;
margin-right: 6px;
font-weight: bold;
}
.mcp-response-content {
font-size: 13px;
color: #374151;
line-height: 1.5;
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
}
`}</style>
<div className="openai-mcp-tools">
<div className="openai-vertical-line"></div>
<Collapse
ghost
size="small"
expandIconPosition="start"
defaultActiveKey={toolsEvent ? ["list-tools"] : mcpCallEvents.map((_, index) => `mcp-call-${index}`)}
>
{/* List Tools Panel */}
{toolsEvent && (
<Panel header="List tools" key="list-tools">
<div>
{toolsEvent.item?.tools?.map((tool, index) => (
<div key={index} className="tool-item">
{tool.name}
</div>
))}
</div>
</Panel>
)}
const defaultOpenKeys = new Set<string>(
toolsEvent ? ["list-tools"] : mcpCallEvents.map((_, index) => `mcp-call-${index}`),
);
{/* MCP Call Panels */}
{mcpCallEvents.map((callEvent, index) => (
<Panel header={callEvent.item?.name || "Tool call"} key={`mcp-call-${index}`}>
return (
<div className={cn("mcp-events-display", className)}>
<MCPEventsPanels toolsEvent={toolsEvent} mcpCallEvents={mcpCallEvents} defaultOpenKeys={defaultOpenKeys} />
</div>
);
};
interface MCPEventsPanelsProps {
toolsEvent: MCPEvent | undefined;
mcpCallEvents: MCPEvent[];
defaultOpenKeys: Set<string>;
}
function MCPEventsPanels({ toolsEvent, mcpCallEvents, defaultOpenKeys }: MCPEventsPanelsProps) {
const [openKeys, setOpenKeys] = useState<Set<string>>(defaultOpenKeys);
const toggleKey = (key: string, open: boolean) => {
setOpenKeys((prev) => {
const next = new Set(prev);
if (open) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
};
return (
<div className="relative m-0 p-0">
<div className="absolute bottom-0 left-[9px] top-[18px] w-px bg-gray-100 opacity-80" aria-hidden="true" />
<div className="space-y-1">
{toolsEvent && (
<MCPEventPanel
panelKey="list-tools"
title="List tools"
open={openKeys.has("list-tools")}
onOpenChange={(open) => toggleKey("list-tools", open)}
>
<div>
{toolsEvent.item?.tools?.map((tool, index) => (
<div key={index} className="relative z-[1] bg-white font-mono text-[13px] leading-[18px] text-gray-600">
{tool.name}
</div>
))}
</div>
</MCPEventPanel>
)}
{mcpCallEvents.map((callEvent, index) => {
const key = `mcp-call-${index}`;
return (
<MCPEventPanel
key={key}
panelKey={key}
title={callEvent.item?.name || "Tool call"}
open={openKeys.has(key)}
onOpenChange={(open) => toggleKey(key, open)}
>
<div>
{/* Request section */}
<div className="mcp-section">
<div className="mcp-section-header">Request</div>
<div className="mcp-code-block">
<div className="relative z-[1] mb-3 bg-white last:mb-0">
<div className="mb-1 text-[13px] font-medium text-gray-500">Request</div>
<div className="rounded-md border border-gray-100 bg-gray-50 p-2 text-xs">
{callEvent.item?.arguments && (
<pre className="mcp-json">
{(() => {
try {
return JSON.stringify(JSON.parse(callEvent.item.arguments), null, 2);
} catch (e) {
return callEvent.item.arguments;
}
})()}
<pre className="m-0 whitespace-pre-wrap break-words font-mono text-gray-700">
{formatArguments(callEvent.item.arguments)}
</pre>
)}
</div>
</div>
{/* Approved section */}
<div className="mcp-section">
<div className="mcp-approved">
<span className="mcp-checkmark"></span> Approved
<div className="relative z-[1] mb-3 bg-white last:mb-0">
<div className="flex items-center text-[13px] text-gray-500">
<span className="mr-1.5 font-bold text-emerald-500" aria-hidden="true">
</span>
Approved
</div>
</div>
{/* Response section */}
{callEvent.item?.output && (
<div className="mcp-section">
<div className="mcp-section-header">Response</div>
<div className="mcp-response-content">{callEvent.item.output}</div>
<div className="relative z-[1] mb-3 bg-white last:mb-0">
<div className="mb-1 text-[13px] font-medium text-gray-500">Response</div>
<div className="whitespace-pre-wrap font-mono text-[13px] leading-normal text-gray-700">
{callEvent.item.output}
</div>
</div>
)}
</div>
</Panel>
))}
</Collapse>
</MCPEventPanel>
);
})}
</div>
</div>
);
};
}
interface MCPEventPanelProps {
panelKey: string;
title: string;
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
function MCPEventPanel({ title, open, onOpenChange, children }: MCPEventPanelProps) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger className="relative flex min-h-5 w-full items-center gap-1 pl-5 text-left text-sm font-normal leading-5 text-gray-400 hover:text-gray-500">
<ChevronRight
className={cn("absolute left-0.5 top-0.5 size-4 text-gray-400 transition-transform", open && "rotate-90")}
aria-hidden="true"
/>
{title}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-1 pl-5">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
export default MCPEventsDisplay;

View file

@ -1,9 +1,10 @@
import React, { useState } from "react";
import { Button } from "antd";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { DownOutlined, RightOutlined, BulbOutlined } from "@ant-design/icons";
import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface ReasoningContentProps {
reasoningContent: string;
@ -16,63 +17,65 @@ const ReasoningContent: React.FC<ReasoningContentProps> = ({ reasoningContent })
return (
<div className="reasoning-content mt-1 mb-2">
<Button
type="text"
className="flex items-center text-xs text-gray-500 hover:text-gray-700"
onClick={() => setIsExpanded(!isExpanded)}
icon={<BulbOutlined />}
>
{isExpanded ? "Hide reasoning" : "Show reasoning"}
{isExpanded ? <DownOutlined className="ml-1" /> : <RightOutlined className="ml-1" />}
</Button>
{isExpanded && (
<div
className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-700 max-w-full overflow-x-auto whitespace-pre-wrap break-words"
style={{ wordBreak: "break-word", overflowWrap: "break-word" }}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger
render={
<Button type="button" variant="ghost" size="sm" className="text-xs text-gray-500 hover:text-gray-700" />
}
>
<ReactMarkdown
components={{
code({
node,
inline,
className,
children,
...props
}: React.ComponentPropsWithoutRef<"code"> & {
inline?: boolean;
node?: any;
}) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={coy as any}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
wrapLines={true}
wrapLongLines={true}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className={`${className} px-1.5 py-0.5 rounded-sm bg-gray-100 text-sm font-mono`}
style={{ wordBreak: "break-word" }}
{...props}
>
{children}
</code>
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
}}
<Lightbulb className="size-3.5" />
{isExpanded ? "Hide reasoning" : "Show reasoning"}
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</CollapsibleTrigger>
<CollapsibleContent>
<div
className="mt-2 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700"
style={{ wordBreak: "break-word", overflowWrap: "break-word" }}
>
{reasoningContent}
</ReactMarkdown>
</div>
)}
<ReactMarkdown
components={{
code({
node,
inline,
className,
children,
...props
}: React.ComponentPropsWithoutRef<"code"> & {
inline?: boolean;
node?: unknown;
}) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
language={match[1]}
PreTag="div"
className="my-2 rounded-md"
wrapLines={true}
wrapLongLines={true}
{...props}
style={coy as { [key: string]: React.CSSProperties }}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className={`${className ?? ""} rounded-sm bg-gray-100 px-1.5 py-0.5 font-mono text-sm`}
style={{ wordBreak: "break-word" }}
{...props}
>
{children}
</code>
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
}}
>
{reasoningContent}
</ReactMarkdown>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View file

@ -1,14 +1,6 @@
import React from "react";
import { Tooltip } from "antd";
import {
ClockCircleOutlined,
NumberOutlined,
ImportOutlined,
ExportOutlined,
BulbOutlined,
ToolOutlined,
DollarOutlined,
} from "@ant-design/icons";
import { ArrowDownToLine, ArrowUpFromLine, Clock, DollarSign, Hash, Lightbulb, Wrench } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export interface TokenUsage {
completionTokens?: number;
@ -25,81 +17,102 @@ interface ResponseMetricsProps {
toolName?: string;
}
interface MetricItemProps {
label: string;
tooltip: string;
icon: React.ReactNode;
value: string;
}
function MetricItem({ label, tooltip, icon, value }: MetricItemProps) {
return (
<Tooltip>
<TooltipTrigger render={<div className="flex items-center gap-1" aria-label={`${label}: ${value}`} />}>
{icon}
<span>
{label}: {value}
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, totalLatency, usage, toolName }) => {
if (!timeToFirstToken && !totalLatency && !usage) return null;
return (
<div className="response-metrics mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500 flex flex-wrap gap-3">
<div className="response-metrics mt-2 flex flex-wrap gap-3 border-t border-gray-100 pt-2 text-xs text-gray-500">
{timeToFirstToken !== undefined && (
<Tooltip title="Time to first token">
<div className="flex items-center">
<ClockCircleOutlined className="mr-1" />
<span>TTFT: {(timeToFirstToken / 1000).toFixed(2)}s</span>
</div>
</Tooltip>
<MetricItem
label="TTFT"
tooltip="Time to first token"
icon={<Clock className="size-3" aria-hidden="true" />}
value={`${(timeToFirstToken / 1000).toFixed(2)}s`}
/>
)}
{totalLatency !== undefined && (
<Tooltip title="Total latency">
<div className="flex items-center">
<ClockCircleOutlined className="mr-1" />
<span>Total Latency: {(totalLatency / 1000).toFixed(2)}s</span>
</div>
</Tooltip>
<MetricItem
label="Total Latency"
tooltip="Total latency"
icon={<Clock className="size-3" aria-hidden="true" />}
value={`${(totalLatency / 1000).toFixed(2)}s`}
/>
)}
{usage?.promptTokens !== undefined && (
<Tooltip title="Prompt tokens">
<div className="flex items-center">
<ImportOutlined className="mr-1" />
<span>In: {usage.promptTokens}</span>
</div>
</Tooltip>
<MetricItem
label="In"
tooltip="Prompt tokens"
icon={<ArrowDownToLine className="size-3" aria-hidden="true" />}
value={String(usage.promptTokens)}
/>
)}
{usage?.completionTokens !== undefined && (
<Tooltip title="Completion tokens">
<div className="flex items-center">
<ExportOutlined className="mr-1" />
<span>Out: {usage.completionTokens}</span>
</div>
</Tooltip>
<MetricItem
label="Out"
tooltip="Completion tokens"
icon={<ArrowUpFromLine className="size-3" aria-hidden="true" />}
value={String(usage.completionTokens)}
/>
)}
{usage?.reasoningTokens !== undefined && (
<Tooltip title="Reasoning tokens">
<div className="flex items-center">
<BulbOutlined className="mr-1" />
<span>Reasoning: {usage.reasoningTokens}</span>
</div>
</Tooltip>
<MetricItem
label="Reasoning"
tooltip="Reasoning tokens"
icon={<Lightbulb className="size-3" aria-hidden="true" />}
value={String(usage.reasoningTokens)}
/>
)}
{usage?.totalTokens !== undefined && (
<Tooltip title="Total tokens">
<div className="flex items-center">
<NumberOutlined className="mr-1" />
<span>Total: {usage.totalTokens}</span>
</div>
</Tooltip>
<MetricItem
label="Total"
tooltip="Total tokens"
icon={<Hash className="size-3" aria-hidden="true" />}
value={String(usage.totalTokens)}
/>
)}
{usage?.cost !== undefined && (
<Tooltip title="Cost">
<div className="flex items-center">
<DollarOutlined className="mr-1" />
<span>${usage.cost.toFixed(6)}</span>
</div>
</Tooltip>
<MetricItem
label="Cost"
tooltip="Cost"
icon={<DollarSign className="size-3" aria-hidden="true" />}
value={`$${usage.cost.toFixed(6)}`}
/>
)}
{toolName && (
<Tooltip title="Tool used">
<div className="flex items-center">
<ToolOutlined className="mr-1" />
<span>Tool: {toolName}</span>
</div>
</Tooltip>
<MetricItem
label="Tool"
tooltip="Tool used"
icon={<Wrench className="size-3" aria-hidden="true" />}
value={toolName}
/>
)}
</div>
);

View file

@ -8,10 +8,10 @@ export enum ModelMode {
VIDEO_GENERATION = "video_generation",
CHAT = "chat",
RESPONSES = "responses",
IMAGE_EDITS = "image_edits",
IMAGE_EDITS = "image_edit",
ANTHROPIC_MESSAGES = "anthropic_messages",
EMBEDDING = "embedding",
// add additional modes as needed
REALTIME = "realtime",
}
// Define an enum for the endpoint types your UI calls
@ -42,6 +42,7 @@ export const litellmModeMapping: Record<ModelMode, EndpointType> = {
[ModelMode.AUDIO_SPEECH]: EndpointType.SPEECH,
[ModelMode.AUDIO_TRANSCRIPTION]: EndpointType.TRANSCRIPTION,
[ModelMode.EMBEDDING]: EndpointType.EMBEDDINGS,
[ModelMode.REALTIME]: EndpointType.REALTIME,
};
export const getEndpointType = (mode: string): EndpointType => {

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import { Guardrail } from "./types";
import { getGuardrailsList } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
interface GuardrailSelectorProps {
onChange: (selectedGuardrails: string[]) => void;
@ -40,25 +40,21 @@ const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({ onChange, value,
};
return (
<div>
<Select
mode="multiple"
<div className="min-w-0">
<MultiSelect
disabled={disabled}
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
onChange={handleGuardrailChange}
onValueChange={handleGuardrailChange}
value={value}
loading={loading}
className={className}
allowClear
options={guardrails.map((guardrail) => {
return {
label: `${guardrail.guardrail_name}`,
value: guardrail.guardrail_name,
};
options={guardrails.flatMap((guardrail) => {
const name = guardrail.guardrail_name;
if (name == null || name === "") {
return [];
}
return [{ label: name, value: name }];
})}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
/>
</div>
);

View file

@ -8,6 +8,13 @@ export interface ModelGroup {
mode?: string;
}
interface AvailableModel {
model_group?: string | null;
model_name?: string | null;
id?: string | null;
mode?: string | null;
}
export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise<ModelGroup[]> => {
const response = await modelAvailableCall(accessToken, "", "", false, teamId);
const modelNames: string[] = (response?.data ?? []).map((model: { id: string }) => model.id);
@ -25,14 +32,15 @@ export const fetchAvailableModels = async (accessToken: string): Promise<ModelGr
const fetchedModels = await modelHubCall(accessToken);
if (fetchedModels?.data.length > 0) {
const models: ModelGroup[] = fetchedModels.data.map((item: any) => ({
model_group: item.model_group, // Display the model_group to the user
mode: item?.mode, // Save the mode for auto-selection of endpoint type
}));
const models: ModelGroup[] = fetchedModels.data
.map((item: AvailableModel) => ({
model_group: item.model_group || item.id || item.model_name || "",
mode: item.mode || undefined,
}))
.filter((model: ModelGroup) => model.model_group !== "");
// Sort models alphabetically by label
models.sort((a, b) => a.model_group.localeCompare(b.model_group));
return models;
return Array.from(new Map(models.map((model) => [model.model_group, model])).values());
}
return [];
} catch (error) {

View file

@ -1,8 +1,8 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { Policy } from "./types";
import { getPoliciesList } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
/** Prefix for policy version IDs in request body; must match backend POLICY_VERSION_ID_PREFIX. */
export const POLICY_VERSION_ID_PREFIX = "policy_";
@ -86,22 +86,17 @@ const PolicySelector: React.FC<PolicySelectorProps> = ({
}
return (
<div>
<Select
mode="multiple"
<div className="min-w-0">
<MultiSelect
disabled={disabled}
placeholder={
disabled ? "Setting policies is a premium feature." : "Select policies (production or published versions)"
}
onChange={handlePolicyChange}
onValueChange={handlePolicyChange}
value={value}
loading={loading}
className={className}
allowClear
options={getPolicyOptionEntries(policies)}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
/>
</div>
);

View file

@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
export interface MultiSelectOption {
label: string;
value: string;
description?: string;
}
interface MultiSelectProps {
options: MultiSelectOption[];
value?: string[];
onValueChange: (value: string[]) => void;
placeholder?: string;
emptyText?: string;
disabled?: boolean;
loading?: boolean;
allowCustomValues?: boolean;
className?: string;
}
const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
const normalizedQuery = query.trim().toLowerCase();
return (
!normalizedQuery ||
option.label.toLowerCase().includes(normalizedQuery) ||
option.value.toLowerCase().includes(normalizedQuery) ||
(option.description?.toLowerCase().includes(normalizedQuery) ?? false)
);
};
export function MultiSelect({
options,
value = [],
onValueChange,
placeholder = "Select options",
emptyText = "No options found",
disabled = false,
loading = false,
allowCustomValues = false,
className,
}: MultiSelectProps) {
const [query, setQuery] = useState("");
const safeOptions = options.filter(
(option): option is MultiSelectOption =>
option != null && typeof option.value === "string" && option.value.length > 0,
);
const selectedOptions = value
.filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0)
.map(
(selectedValue) =>
safeOptions.find((option) => option.value === selectedValue) ?? {
label: selectedValue,
value: selectedValue,
},
);
const customOption = query.trim();
const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
const items =
allowCustomValues && customOption && !customOptionExists
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
: safeOptions;
return (
<Combobox
multiple
items={items}
value={selectedOptions}
onValueChange={(selected: MultiSelectOption[]) => {
onValueChange(selected.map((option) => option.value));
setQuery("");
}}
inputValue={query}
onInputValueChange={setQuery}
isItemEqualToValue={(option: MultiSelectOption, selected: MultiSelectOption) => option.value === selected.value}
itemToStringLabel={(option: MultiSelectOption) => option.label}
filter={matchesQuery}
disabled={disabled || loading}
>
<ComboboxChips className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
<ComboboxValue>
{(selected: MultiSelectOption[]) =>
selected.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
placeholder={loading ? "Loading..." : placeholder}
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: MultiSelectOption) => (
<ComboboxItem key={option.value} value={option}>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.description && (
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}

View file

@ -56,9 +56,9 @@ export function SearchSelect({
<ComboboxInput
placeholder={placeholder}
showClear={value != null && value !== ""}
className={`w-full ${className ?? ""}`}
className={`h-8 w-full text-sm ${className ?? ""}`}
/>
<ComboboxContent>
<ComboboxContent side="bottom" collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(item: SearchSelectOption) => (

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import { Tag } from "./types";
import { tagListCall } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
interface TagSelectorProps {
onChange: (selectedTags: string[]) => void;
@ -17,6 +17,7 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
useEffect(() => {
const fetchTags = async () => {
if (!accessToken) return;
setLoading(true);
try {
const response = await tagListCall(accessToken);
setTags(Object.values(response));
@ -31,24 +32,18 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
}, [accessToken]);
return (
<Select
mode="tags"
showSearch
<MultiSelect
placeholder="Select or create tags"
onChange={onChange}
onValueChange={onChange}
value={value}
loading={loading}
className={className}
allowCustomValues
options={tags.map((tag) => ({
label: tag.name,
value: tag.name,
title: tag.description || tag.name,
description: tag.description || undefined,
}))}
optionFilterProp="label"
tokenSeparators={[","]}
maxTagCount="responsive"
allowClear
style={{ width: "100%" }}
/>
);
};

View file

@ -83,10 +83,14 @@ function ComboboxContent({
sideOffset = 6,
align = "start",
alignOffset = 0,
collisionAvoidance,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "collisionAvoidance" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
@ -94,6 +98,7 @@ function ComboboxContent({
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
collisionAvoidance={collisionAvoidance}
anchor={anchor}
className="isolate z-50"
>

View file

@ -53,7 +53,7 @@ function InputGroupAddon({
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
e.currentTarget.parentElement?.querySelector<HTMLElement>("[data-slot=input-group-control]")?.focus();
}}
{...props}
/>

View file

@ -3,78 +3,64 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { VectorStore } from "./types";
import VectorStoreSelector from "./VectorStoreSelector";
// Mock dependencies
const mockVectorStoreListCall = vi.fn();
vi.mock("../networking", () => ({
vectorStoreListCall: (...args: any[]) => mockVectorStoreListCall(...args),
vectorStoreListCall: (...args: unknown[]) => mockVectorStoreListCall(...args),
}));
// Mock antd Select component
vi.mock("antd", () => ({
Select: vi.fn(),
vi.mock("@/components/shared/MultiSelect", () => ({
MultiSelect: vi.fn(),
}));
// Import the mocked Select
import { Select as MockedSelect } from "antd";
import { MultiSelect as MockedMultiSelect } from "@/components/shared/MultiSelect";
// Configure the mock to render a simple div with data attributes
(MockedSelect as any).mockImplementation((props: any) => {
const {
onChange,
value,
placeholder,
loading,
className,
disabled,
options,
mode,
showSearch,
optionFilterProp,
style,
} = props;
(MockedMultiSelect as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(props: {
onValueChange?: (value: string[]) => void;
value?: string[];
placeholder?: string;
loading?: boolean;
className?: string;
disabled?: boolean;
options?: Array<{ value: string; label: string; description?: string }>;
}) => {
const { onValueChange, value, placeholder, loading, className, disabled, options } = props;
return (
<div
data-testid="vector-store-select"
data-loading={loading}
data-disabled={disabled}
data-mode={mode}
data-show-search={showSearch}
data-option-filter-prop={optionFilterProp}
data-placeholder={placeholder}
data-value={value !== undefined ? JSON.stringify(value) : undefined}
data-options={JSON.stringify(options)}
className={className}
style={style}
onClick={(e: any) => {
// For testing purposes, allow simulating different selection behaviors
// The test can control this by setting data attributes on the element
const testSelection = e.target.getAttribute("data-test-selection");
if (testSelection && onChange) {
onChange(JSON.parse(testSelection));
} else if (onChange && options?.length > 0) {
// Default behavior: select first option
onChange([options[0].value]);
}
}}
>
{options?.map((opt: any) => (
<div
key={opt.value}
data-option-value={opt.value}
data-option-label={opt.label}
data-option-title={opt.title}
data-testid={`option-${opt.value}`}
>
{opt.label}
</div>
))}
</div>
);
});
return (
<div
data-testid="vector-store-select"
data-loading={loading}
data-disabled={disabled}
data-placeholder={placeholder}
data-value={value !== undefined ? JSON.stringify(value) : undefined}
data-options={JSON.stringify(options)}
className={className}
onClick={(e) => {
const testSelection = (e.target as HTMLElement).getAttribute("data-test-selection");
if (testSelection && onValueChange) {
onValueChange(JSON.parse(testSelection) as string[]);
} else if (onValueChange && options && options.length > 0) {
onValueChange([options[0].value]);
}
}}
>
{options?.map((opt) => (
<div
key={opt.value}
data-option-value={opt.value}
data-option-label={opt.label}
data-option-description={opt.description}
data-testid={`option-${opt.value}`}
>
{opt.label}
</div>
))}
</div>
);
},
);
// Test helpers
const mockOnChange = vi.fn();
const mockAccessToken = "test-token";
@ -98,7 +84,6 @@ const mockVectorStores: VectorStore[] = [
{
vector_store_id: "store-3",
custom_llm_provider: "pg_vector",
// No vector_store_name to test fallback to vector_store_id
vector_store_description: "Store without name",
created_at: "2024-01-03T00:00:00Z",
updated_at: "2024-01-03T00:00:00Z",
@ -110,7 +95,6 @@ const defaultProps = {
accessToken: mockAccessToken,
};
// Helper functions
const renderComponent = (props = {}) => {
return render(<VectorStoreSelector {...defaultProps} {...props} />);
};
@ -124,7 +108,7 @@ const waitForDataFetch = async () => {
const getSelectElement = () => screen.getByTestId("vector-store-select");
const getOptionElements = () =>
screen.getAllByTestId(/^vector-store-select/).filter((el) => el.hasAttribute("data-option-value"));
screen.queryAllByTestId(/^option-/).filter((el) => el.hasAttribute("data-option-value"));
describe("VectorStoreSelector", () => {
beforeEach(() => {
@ -142,56 +126,27 @@ describe("VectorStoreSelector", () => {
it("should render with default placeholder", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-placeholder", "Select vector stores");
expect(getSelectElement()).toHaveAttribute("data-placeholder", "Select vector stores");
});
it("should render with custom placeholder", () => {
renderComponent({ placeholder: "Choose stores" });
const select = getSelectElement();
expect(select).toHaveAttribute("data-placeholder", "Choose stores");
expect(getSelectElement()).toHaveAttribute("data-placeholder", "Choose stores");
});
it("should apply custom className", () => {
renderComponent({ className: "custom-class" });
const select = getSelectElement();
expect(select).toHaveClass("custom-class");
expect(getSelectElement()).toHaveClass("custom-class");
});
it("should render with disabled state", () => {
renderComponent({ disabled: true });
const select = getSelectElement();
expect(select).toHaveAttribute("data-disabled", "true");
expect(getSelectElement()).toHaveAttribute("data-disabled", "true");
});
it("should render with enabled state by default", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-disabled", "false");
});
it("should render with multiple mode", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-mode", "multiple");
});
it("should render with showSearch enabled", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-show-search", "true");
});
it("should render with optionFilterProp set to label", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-option-filter-prop", "label");
});
it("should render with full width style", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveStyle({ width: "100%" });
expect(getSelectElement()).toHaveAttribute("data-disabled", "false");
});
});
@ -207,10 +162,10 @@ describe("VectorStoreSelector", () => {
const { rerender } = render(<VectorStoreSelector {...defaultProps} accessToken="" />);
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
rerender(<VectorStoreSelector {...defaultProps} accessToken={null as any} />);
rerender(<VectorStoreSelector {...defaultProps} accessToken={null as unknown as string} />);
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
rerender(<VectorStoreSelector {...defaultProps} accessToken={undefined as any} />);
rerender(<VectorStoreSelector {...defaultProps} accessToken={undefined as unknown as string} />);
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
});
@ -228,7 +183,7 @@ describe("VectorStoreSelector", () => {
});
it("should set loading state while fetching", async () => {
let resolvePromise: (value: any) => void;
let resolvePromise: (value: unknown) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
@ -247,8 +202,7 @@ describe("VectorStoreSelector", () => {
it("should clear loading state after successful fetch", async () => {
renderComponent();
await waitForDataFetch();
const select = getSelectElement();
expect(select).toHaveAttribute("data-loading", "false");
expect(getSelectElement()).toHaveAttribute("data-loading", "false");
});
it("should clear loading state after failed fetch", async () => {
@ -258,8 +212,7 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
const select = getSelectElement();
expect(select).toHaveAttribute("data-loading", "false");
expect(getSelectElement()).toHaveAttribute("data-loading", "false");
consoleErrorSpy.mockRestore();
});
});
@ -280,7 +233,7 @@ describe("VectorStoreSelector", () => {
const option1 = screen.getByText("My Store (store-1)");
expect(option1).toBeInTheDocument();
expect(option1).toHaveAttribute("data-option-title", "A test store");
expect(option1).toHaveAttribute("data-option-description", "A test store");
});
it("should fallback to vector_store_id when vector_store_name is missing", async () => {
@ -289,19 +242,18 @@ describe("VectorStoreSelector", () => {
const option3 = screen.getByText("store-3 (store-3)");
expect(option3).toBeInTheDocument();
// When vector_store_name is missing, title uses vector_store_description if available, otherwise vector_store_id
expect(option3).toHaveAttribute("data-option-title", "Store without name");
expect(option3).toHaveAttribute("data-option-description", "Store without name");
});
it("should use vector_store_description as title when available", async () => {
it("should use vector_store_description as description when available", async () => {
renderComponent();
await waitForDataFetch();
const option1 = screen.getByText("My Store (store-1)");
expect(option1).toHaveAttribute("data-option-title", "A test store");
expect(option1).toHaveAttribute("data-option-description", "A test store");
});
it("should fallback to vector_store_id as title when vector_store_description is missing", async () => {
it("should omit description when vector_store_description is missing", async () => {
const storesWithoutDescription: VectorStore[] = [
{
vector_store_id: "store-no-desc",
@ -318,7 +270,7 @@ describe("VectorStoreSelector", () => {
await waitForDataFetch();
const option = screen.getByText("store-no-desc (store-no-desc)");
expect(option).toHaveAttribute("data-option-title", "store-no-desc");
expect(option).not.toHaveAttribute("data-option-description");
});
it("should use vector_store_id as option value", async () => {
@ -337,8 +289,7 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
const options = getOptionElements();
expect(options.length).toBe(0);
expect(getOptionElements().length).toBe(0);
});
it("should handle response without data property", async () => {
@ -347,8 +298,7 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
const options = getOptionElements();
expect(options.length).toBe(0);
expect(getOptionElements().length).toBe(0);
});
});
@ -357,27 +307,21 @@ describe("VectorStoreSelector", () => {
renderComponent({ value: ["store-1", "store-2"] });
await waitForDataFetch();
const select = getSelectElement();
const dataValue = select.getAttribute("data-value");
expect(dataValue).toBe(JSON.stringify(["store-1", "store-2"]));
expect(getSelectElement().getAttribute("data-value")).toBe(JSON.stringify(["store-1", "store-2"]));
});
it("should handle empty value array", async () => {
renderComponent({ value: [] });
await waitForDataFetch();
const select = getSelectElement();
const dataValue = select.getAttribute("data-value");
expect(dataValue).toBe(JSON.stringify([]));
expect(getSelectElement().getAttribute("data-value")).toBe(JSON.stringify([]));
});
it("should handle undefined value", async () => {
renderComponent({ value: undefined });
await waitForDataFetch();
const select = getSelectElement();
const dataValue = select.getAttribute("data-value");
expect(dataValue).toBeNull(); // undefined value results in no data-value attribute
expect(getSelectElement().getAttribute("data-value")).toBeNull();
});
});
@ -387,7 +331,6 @@ describe("VectorStoreSelector", () => {
await waitForDataFetch();
const select = getSelectElement();
// Simulate selecting store-1 by setting test data attribute
select.setAttribute("data-test-selection", '["store-1"]');
fireEvent.click(select);
@ -399,7 +342,6 @@ describe("VectorStoreSelector", () => {
await waitForDataFetch();
const select = getSelectElement();
// Simulate selecting multiple values
select.setAttribute("data-test-selection", '["store-1", "store-2"]');
fireEvent.click(select);
@ -411,7 +353,6 @@ describe("VectorStoreSelector", () => {
await waitForDataFetch();
const select = getSelectElement();
// Simulate deselecting store-1
select.setAttribute("data-test-selection", '["store-2"]');
fireEvent.click(select);
@ -450,7 +391,6 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
// Component should still render
expect(getSelectElement()).toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
@ -474,8 +414,7 @@ describe("VectorStoreSelector", () => {
await waitForDataFetch();
expect(screen.getByText("minimal-store (minimal-store)")).toBeInTheDocument();
const option = screen.getByText("minimal-store (minimal-store)");
expect(option).toHaveAttribute("data-option-title", "minimal-store");
expect(screen.getByText("minimal-store (minimal-store)")).not.toHaveAttribute("data-option-description");
});
it("should handle very long vector store names", async () => {
@ -495,8 +434,7 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
const expectedLabel = `${"A".repeat(200)} (store-long)`;
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
expect(screen.getByText(`${"A".repeat(200)} (store-long)`)).toBeInTheDocument();
});
it("should handle special characters in vector store names", async () => {

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import { VectorStore } from "./types";
import { vectorStoreListCall } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
interface VectorStoreSelectorProps {
onChange: (selectedVectorStores: string[]) => void;
value?: string[];
@ -43,24 +43,19 @@ const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
}, [accessToken]);
return (
<div>
<Select
mode="multiple"
<div className="min-w-0">
<MultiSelect
placeholder={placeholder}
onChange={onChange}
onValueChange={onChange}
value={value}
loading={loading}
className={className}
allowClear
disabled={disabled}
options={vectorStores.map((store) => ({
label: `${store.vector_store_name || store.vector_store_id} (${store.vector_store_id})`,
value: store.vector_store_id,
title: store.vector_store_description || store.vector_store_id,
description: store.vector_store_description || undefined,
}))}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
disabled={disabled}
/>
</div>
);

View file

@ -111,16 +111,12 @@ describe("InputCard", () => {
render(<InputCard messages={messages} />);
const copyButtons = screen.getAllByRole("button");
const copyButton = copyButtons.find((button) => {
const icon = button.querySelector('[aria-label="copy"]');
return icon !== null;
});
const copyButton = screen.getByRole("button", { name: /copy/i });
expect(copyButton).toBeInTheDocument();
await act(async () => {
fireEvent.click(copyButton!);
fireEvent.click(copyButton);
});
await waitFor(() => {
@ -209,11 +205,7 @@ describe("InputCard", () => {
},
];
render(<InputCard messages={messages} />);
const copyButtons = screen.getAllByRole("button");
const copyButton = copyButtons.find((button) => {
const icon = button.querySelector('[aria-label="copy"]');
return icon !== null;
});
const copyButton = screen.getByRole("button", { name: /copy/i });
expect(copyButton).toBeInTheDocument();
});
});

View file

@ -66,16 +66,12 @@ describe("OutputCard", () => {
it("should copy message content when copy button is clicked", async () => {
render(<OutputCard message={mockMessage} />);
const copyButtons = screen.getAllByRole("button");
const copyButton = copyButtons.find((button) => {
const icon = button.querySelector('[aria-label="copy"]');
return icon !== null;
});
const copyButton = screen.getByRole("button", { name: /copy/i });
expect(copyButton).toBeInTheDocument();
await act(async () => {
fireEvent.click(copyButton!);
fireEvent.click(copyButton);
});
await waitFor(() => {
@ -88,14 +84,10 @@ describe("OutputCard", () => {
render(<OutputCard message={null} />);
const copyButtons = screen.getAllByRole("button");
const copyButton = copyButtons.find((button) => {
const icon = button.querySelector('[aria-label="copy"]');
return icon !== null;
});
const copyButton = screen.getByRole("button", { name: /copy/i });
expect(copyButton).toBeInTheDocument();
await user.click(copyButton!);
await user.click(copyButton);
await waitFor(() => {
expect(mockWriteText).not.toHaveBeenCalled();

View file

@ -4,14 +4,12 @@
*/
import { useState } from "react";
import { Typography } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { COLOR_BORDER } from "./constants";
import { ParsedMessage } from "./prettyMessagesTypes";
import { SectionHeader } from "./SectionHeader";
import { SimpleMessageBlock } from "./SimpleMessageBlock";
const { Text } = Typography;
interface OutputCardProps {
message: ParsedMessage | null;
completionTokens?: number;
@ -24,55 +22,12 @@ export function OutputCard({ message, completionTokens, outputCost }: OutputCard
const handleCopy = () => {
if (!message) return;
const content = message.content || "";
navigator.clipboard.writeText(content);
navigator.clipboard.writeText(message.content || "");
MessageManager.success("Output copied");
};
if (!message) {
return (
<div
style={{
border: "1px solid #f0f0f0",
borderRadius: 6,
overflow: "hidden",
}}
>
<SectionHeader
type="output"
tokens={completionTokens}
cost={outputCost}
onCopy={handleCopy}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
/>
<div
style={{
maxHeight: isCollapsed ? "0px" : "10000px",
overflow: "hidden",
transition: "max-height 0.3s ease-out, opacity 0.3s ease-out",
opacity: isCollapsed ? 0 : 1,
}}
>
<div style={{ padding: "12px 16px" }}>
<Text type="secondary" style={{ fontSize: 13, fontStyle: "italic" }}>
No response data available
</Text>
</div>
</div>
</div>
);
}
return (
<div
style={{
border: "1px solid #f0f0f0",
borderRadius: 6,
overflow: "hidden",
}}
>
{/* Datadog-style Header */}
<div className="overflow-hidden rounded-md" style={{ border: `1px solid ${COLOR_BORDER}` }}>
<SectionHeader
type="output"
tokens={completionTokens}
@ -82,17 +37,16 @@ export function OutputCard({ message, completionTokens, outputCost }: OutputCard
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
/>
{/* Content */}
<div
style={{
maxHeight: isCollapsed ? "0px" : "10000px",
overflow: "hidden",
transition: "max-height 0.3s ease-out, opacity 0.3s ease-out",
opacity: isCollapsed ? 0 : 1,
}}
className="overflow-hidden transition-[max-height,opacity] duration-300 ease-out"
style={{ maxHeight: isCollapsed ? "0px" : "10000px", opacity: isCollapsed ? 0 : 1 }}
>
<div style={{ padding: "12px 16px" }}>
<SimpleMessageBlock label="ASSISTANT" content={message.content} toolCalls={message.toolCalls} />
<div className="px-4 py-3">
{message ? (
<SimpleMessageBlock label="ASSISTANT" content={message.content} toolCalls={message.toolCalls} />
) : (
<span className="text-[13px] text-muted-foreground italic">No response data available</span>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,64 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SectionHeader } from "./SectionHeader";
describe("SectionHeader", () => {
it("renders the input label with token, cost and turn metrics", () => {
render(<SectionHeader type="input" tokens={1234} cost={0.000123} turnCount={3} onCopy={vi.fn()} />);
expect(screen.getByText("Input")).toBeInTheDocument();
expect(screen.getByText("Tokens: 1,234")).toBeInTheDocument();
expect(screen.getByText("Cost: $0.000123")).toBeInTheDocument();
expect(screen.getByText("Turns: 3")).toBeInTheDocument();
});
it("renders the output label", () => {
render(<SectionHeader type="output" onCopy={vi.fn()} />);
expect(screen.getByText("Output")).toBeInTheDocument();
});
it("omits metrics that were not provided", () => {
render(<SectionHeader type="input" onCopy={vi.fn()} />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument();
});
it("omits the turn count when there are no turns", () => {
render(<SectionHeader type="input" turnCount={0} onCopy={vi.fn()} />);
expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument();
});
it("copies without toggling the section", async () => {
const onCopy = vi.fn();
const onToggleCollapse = vi.fn();
render(<SectionHeader type="input" onCopy={onCopy} onToggleCollapse={onToggleCollapse} />);
await userEvent.click(screen.getByRole("button", { name: /copy/i }));
expect(onCopy).toHaveBeenCalledTimes(1);
expect(onToggleCollapse).not.toHaveBeenCalled();
});
it("toggles the section when the header is clicked", async () => {
const onToggleCollapse = vi.fn();
render(<SectionHeader type="input" onCopy={vi.fn()} onToggleCollapse={onToggleCollapse} />);
await userEvent.click(screen.getByText("Input"));
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
});
it("stays inert when no toggle handler is given", async () => {
const onCopy = vi.fn();
render(<SectionHeader type="input" onCopy={onCopy} />);
await userEvent.click(screen.getByText("Input"));
expect(onCopy).not.toHaveBeenCalled();
});
});

View file

@ -2,10 +2,10 @@
* SectionHeader - Datadog-style header with icon, label, metrics, and copy
*/
import { Typography, Button, Tooltip } from "antd";
import { MessageOutlined, CopyOutlined, DownOutlined, UpOutlined } from "@ant-design/icons";
const { Text } = Typography;
import { ChevronDown, ChevronUp, Copy, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
interface SectionHeaderProps {
type: "input" | "output";
@ -29,80 +29,57 @@ export function SectionHeader({
return (
<div
onClick={onToggleCollapse}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
borderBottom: isCollapsed ? "none" : "1px solid #f0f0f0",
background: "#fafafa",
cursor: onToggleCollapse ? "pointer" : "default",
transition: "background 0.15s ease",
}}
onMouseEnter={(e) => {
if (onToggleCollapse) {
e.currentTarget.style.background = "#f5f5f5";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#fafafa";
}}
className={cn(
"flex items-center justify-between bg-muted px-4 py-2.5 transition-colors",
isCollapsed ? "border-b-0" : "border-b border-border",
onToggleCollapse ? "cursor-pointer hover:bg-accent" : "cursor-default",
)}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
{/* Collapse Arrow */}
{onToggleCollapse && (
<div style={{ display: "flex", alignItems: "center" }}>
{isCollapsed ? (
<DownOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
) : (
<UpOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
)}
</div>
)}
{/* Icon + Label */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{type === "input" ? (
<MessageOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
<div className="flex items-center gap-4">
{onToggleCollapse &&
(isCollapsed ? (
<ChevronDown className="size-2.5 text-muted-foreground" />
) : (
<span style={{ fontSize: 14, filter: "grayscale(1)", opacity: 0.6 }}></span>
<ChevronUp className="size-2.5 text-muted-foreground" />
))}
<div className="flex items-center gap-2">
{type === "input" ? (
<MessageSquare className="size-3.5 text-muted-foreground" />
) : (
<span className="text-sm opacity-60 grayscale"></span>
)}
<Text style={{ fontWeight: 500, fontSize: 14 }}>{type === "input" ? "Input" : "Output"}</Text>
<span className="text-sm font-medium">{type === "input" ? "Input" : "Output"}</span>
</div>
{/* Tokens */}
{tokens !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
Tokens: {tokens.toLocaleString()}
</Text>
<span className="text-xs text-muted-foreground">Tokens: {tokens.toLocaleString()}</span>
)}
{/* Cost */}
{cost !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
Cost: ${cost.toFixed(6)}
</Text>
)}
{cost !== undefined && <span className="text-xs text-muted-foreground">Cost: ${cost.toFixed(6)}</span>}
{/* Turn count */}
{turnCount !== undefined && turnCount > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
Turns: {turnCount}
</Text>
<span className="text-xs text-muted-foreground">Turns: {turnCount}</span>
)}
</div>
{/* Copy Button */}
<Tooltip title="Copy">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation(); // Prevent triggering collapse
onCopy();
}}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
/>
}
>
<Copy />
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</div>
);

View file

@ -1,7 +1,6 @@
import { Typography, Tooltip } from "antd";
import { DEFAULT_MAX_WIDTH, FONT_FAMILY_MONO, FONT_SIZE_SMALL } from "./constants";
const { Text } = Typography;
import CopyButton from "@/components/shared/CopyButton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { DEFAULT_MAX_WIDTH, FONT_FAMILY_MONO } from "./constants";
interface TruncatedValueProps {
value?: string;
@ -13,23 +12,23 @@ interface TruncatedValueProps {
* Useful for displaying long IDs, URLs, or other text that may overflow.
*/
export function TruncatedValue({ value, maxWidth = DEFAULT_MAX_WIDTH }: TruncatedValueProps) {
if (!value) return <Text type="secondary">-</Text>;
if (!value) return <span className="text-muted-foreground">-</span>;
return (
<Tooltip title={value}>
<Text
copyable={{ text: value, tooltips: ["Copy", "Copied!"] }}
style={{
maxWidth,
display: "inline-block",
verticalAlign: "bottom",
fontFamily: FONT_FAMILY_MONO,
fontSize: FONT_SIZE_SMALL,
}}
ellipsis
>
{value}
</Text>
</Tooltip>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex items-center gap-1 align-bottom">
<span className="truncate text-xs" style={{ maxWidth, fontFamily: FONT_FAMILY_MONO }}>
{value}
</span>
<CopyButton value={value} label="Copy" className="size-4 shrink-0" iconClassName="size-3" />
</span>
}
/>
<TooltipContent>{value}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -2,10 +2,46 @@
* Core tests for Tools section
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { parseToolsFromLog } from "./utils";
import { ToolsSection } from "./ToolsSection";
import { LogEntry } from "../columns";
const logWithTools = (toolNames: string[], calledName?: string): LogEntry => ({
request_id: "render-1",
api_key: "key",
team_id: "team",
model: "gpt-4",
model_id: "gpt-4",
call_type: "completion",
spend: 0.01,
total_tokens: 100,
prompt_tokens: 50,
completion_tokens: 50,
startTime: "2024-01-01T00:00:00Z",
endTime: "2024-01-01T00:00:01Z",
cache_hit: "none",
messages: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "hi" }],
tools: toolNames.map((name) => ({
type: "function",
function: { name, description: `${name} description`, parameters: { type: "object", properties: {} } },
})),
}),
response: JSON.stringify({
choices: [
{
message: calledName
? { tool_calls: [{ id: "call_1", type: "function", function: { name: calledName, arguments: "{}" } }] }
: { content: "done" },
},
],
}),
});
describe("ToolsSection", () => {
it("should parse tools from request and match with response tool calls", () => {
const mockLog: LogEntry = {
@ -115,3 +151,51 @@ describe("ToolsSection", () => {
expect(tools).toHaveLength(0);
});
});
const isShown = (text: string) => screen.queryAllByText(text).some((el) => el.closest("[hidden]") === null);
describe("ToolsSection rendering", () => {
it("summarises how many tools were provided and called", () => {
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
expect(screen.getByText("Tools")).toBeInTheDocument();
expect(screen.getByText("2 provided, 1 called")).toBeInTheDocument();
});
it("previews the first two tool names", () => {
render(<ToolsSection log={logWithTools(["alpha", "beta", "gamma"])} />);
expect(screen.getByText(/alpha, beta/)).toBeInTheDocument();
});
it("renders nothing when the log has no tools", () => {
const { container } = render(<ToolsSection log={logWithTools([])} />);
expect(container).toBeEmptyDOMElement();
});
it("reveals the tool list only after the section is expanded", async () => {
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
expect(isShown("called")).toBe(false);
expect(isShown("not called")).toBe(false);
await userEvent.click(screen.getByText("Tools"));
await waitFor(() => expect(isShown("called")).toBe(true));
expect(isShown("not called")).toBe(true);
});
it("keeps a tool's expanded detail across a close and reopen", async () => {
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
await userEvent.click(screen.getByText("Tools"));
await userEvent.click(await screen.findByText(/1\. get_weather/));
expect(isShown("Description")).toBe(true);
await userEvent.click(screen.getByText("Tools"));
await userEvent.click(screen.getByText("Tools"));
await waitFor(() => expect(isShown("Description")).toBe(true));
});
});

View file

@ -3,18 +3,19 @@
* and indicates which ones were actually called in the response
*/
import { Collapse, Typography } from "antd";
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { LogEntry } from "../columns";
import { parseToolsFromLog } from "./utils";
import { ToolItem } from "./ToolItem";
const { Text } = Typography;
interface ToolsSectionProps {
log: LogEntry;
}
export function ToolsSection({ log }: ToolsSectionProps) {
const [open, setOpen] = useState(false);
const tools = parseToolsFromLog(log);
// Don't render if no tools
@ -32,34 +33,33 @@ export function ToolsSection({ log }: ToolsSectionProps) {
const hasMoreTools = tools.length > 2;
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Collapse
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<h3 className="text-lg font-medium text-gray-900">Tools</h3>
<Text type="secondary" style={{ fontSize: 14 }}>
{totalTools} provided, {calledTools} called
</Text>
<Text type="secondary" style={{ fontSize: 14 }}>
{toolNamePreview}
{hasMoreTools && "..."}
</Text>
</div>
),
children: (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{tools.map((tool) => (
<ToolItem key={tool.name} tool={tool} />
))}
</div>
),
},
]}
/>
<div className="mb-6 w-full max-w-full overflow-hidden rounded-lg bg-background shadow-sm">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted">
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-lg font-medium text-foreground">Tools</h3>
<span className="text-sm text-muted-foreground">
{totalTools} provided, {calledTools} called
</span>
<span className="text-sm text-muted-foreground">
{toolNamePreview}
{hasMoreTools && "..."}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent keepMounted>
<div className="flex flex-col gap-2 px-4 pb-4">
{tools.map((tool) => (
<ToolItem key={tool.name} tool={tool} />
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}