mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
refactor(ui): migrate transform-request to shadcn (#34303)
* test(ui): characterise transform-request panel behaviour before migration * refactor(ui): migrate transform-request to shadcn * fix(ui): keep transform-request panels within the fixed-height content fold * fix(ui): let transform-request flow naturally so the shell scrolls instead of clipping * test(ui): select the copy button by its accessible name
This commit is contained in:
parent
692e6d48e9
commit
169ba0e287
3 changed files with 226 additions and 144 deletions
|
|
@ -1098,11 +1098,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/transform-request/TransformRequestPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TransformRequestPanel from "./TransformRequestPanel";
|
||||
import { transformRequestCall } from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
transformRequestCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const transformRequestCallMock = vi.mocked(transformRequestCall);
|
||||
const notify = vi.mocked(NotificationsManager);
|
||||
|
||||
const ACCESS_TOKEN = "sk-test-token";
|
||||
|
||||
const getRequestTextarea = () => screen.getByPlaceholderText(/press cmd\/ctrl \+ enter to transform/i);
|
||||
|
||||
const getTransformButton = () => screen.getByRole("button", { name: /transform/i });
|
||||
|
||||
const getCopyButton = () => screen.getByRole("button", { name: /copy to clipboard/i });
|
||||
|
||||
describe("TransformRequestPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders both panels, the prefilled request and the placeholder curl", () => {
|
||||
render(<TransformRequestPanel accessToken={ACCESS_TOKEN} />);
|
||||
|
||||
expect(screen.getByText("Original Request")).toBeInTheDocument();
|
||||
expect(screen.getByText("Transformed Request")).toBeInTheDocument();
|
||||
expect(screen.getByText(/sensitive headers are not shown/i)).toBeInTheDocument();
|
||||
|
||||
expect((getRequestTextarea() as HTMLTextAreaElement).value).toContain('"model": "openai/gpt-4o"');
|
||||
expect(screen.getByText(/https:\/\/api\.openai\.com\/v1\/chat\/completions/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("link", { name: /here/i })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/BerriAI/litellm/issues",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the edited request body as a completion call and renders the returned curl", async () => {
|
||||
const user = userEvent.setup();
|
||||
transformRequestCallMock.mockResolvedValue({
|
||||
raw_request_api_base: "https://api.anthropic.com/v1/messages",
|
||||
raw_request_body: { model: "claude-opus-4-8", max_tokens: 42 },
|
||||
raw_request_headers: { "x-api-key": "redacted" },
|
||||
});
|
||||
|
||||
render(<TransformRequestPanel accessToken={ACCESS_TOKEN} />);
|
||||
|
||||
const textarea = getRequestTextarea();
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, '{{"model": "claude-opus-4-8"}');
|
||||
|
||||
await user.click(getTransformButton());
|
||||
|
||||
await waitFor(() => expect(transformRequestCallMock).toHaveBeenCalledTimes(1));
|
||||
expect(transformRequestCallMock).toHaveBeenCalledWith(ACCESS_TOKEN, {
|
||||
call_type: "completion",
|
||||
request_body: { model: "claude-opus-4-8" },
|
||||
});
|
||||
|
||||
const output = await screen.findByText(/api\.anthropic\.com\/v1\/messages/);
|
||||
expect(output.textContent).toContain("curl -X POST");
|
||||
expect(output.textContent).toContain("-H 'x-api-key: redacted'");
|
||||
expect(output.textContent).toContain('"model": "claude-opus-4-8"');
|
||||
expect(output.textContent).toContain('"max_tokens": 42');
|
||||
expect(notify.success).toHaveBeenCalledWith("Request transformed successfully");
|
||||
});
|
||||
|
||||
it("transforms on Cmd/Ctrl + Enter without clicking the button", async () => {
|
||||
const user = userEvent.setup();
|
||||
transformRequestCallMock.mockResolvedValue({
|
||||
raw_request_api_base: "https://api.openai.com/v1/chat/completions",
|
||||
raw_request_body: { model: "gpt-4o" },
|
||||
raw_request_headers: {},
|
||||
});
|
||||
|
||||
render(<TransformRequestPanel accessToken={ACCESS_TOKEN} />);
|
||||
|
||||
getRequestTextarea().focus();
|
||||
await user.keyboard("{Meta>}{Enter}{/Meta}");
|
||||
|
||||
await waitFor(() => expect(transformRequestCallMock).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("rejects invalid JSON without calling the backend", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TransformRequestPanel accessToken={ACCESS_TOKEN} />);
|
||||
|
||||
const textarea = getRequestTextarea();
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "not json");
|
||||
await user.click(getTransformButton());
|
||||
|
||||
await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("Invalid JSON in request body"));
|
||||
expect(transformRequestCallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call the backend when there is no access token", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TransformRequestPanel accessToken={null} />);
|
||||
|
||||
await user.click(getTransformButton());
|
||||
|
||||
await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("No access token found"));
|
||||
expect(transformRequestCallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed transform and leaves the placeholder curl in place", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
transformRequestCallMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
render(<TransformRequestPanel accessToken={ACCESS_TOKEN} />);
|
||||
|
||||
await user.click(getTransformButton());
|
||||
|
||||
await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("Failed to transform request"));
|
||||
expect(screen.getByText(/https:\/\/api\.openai\.com\/v1\/chat\/completions/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies the transformed request to the clipboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText");
|
||||
transformRequestCallMock.mockResolvedValue({
|
||||
raw_request_api_base: "https://api.anthropic.com/v1/messages",
|
||||
raw_request_body: { model: "claude-opus-4-8" },
|
||||
raw_request_headers: {},
|
||||
});
|
||||
|
||||
render(<TransformRequestPanel accessToken={ACCESS_TOKEN} />);
|
||||
|
||||
await user.click(getTransformButton());
|
||||
await screen.findByText(/api\.anthropic\.com\/v1\/messages/);
|
||||
|
||||
await user.click(getCopyButton());
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(writeText.mock.calls[0]?.[0]).toContain("https://api.anthropic.com/v1/messages");
|
||||
expect(notify.success).toHaveBeenCalledWith("Copied to clipboard");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "antd";
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { Title } from "@tremor/react";
|
||||
import { ArrowRight, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { transformRequestCall } from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
interface TransformRequestPanelProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
|
@ -128,130 +131,50 @@ ${formattedBody}
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full m-2" style={{ overflow: "hidden" }}>
|
||||
<Title>Playground</Title>
|
||||
<p className="text-sm text-gray-500">See how LiteLLM transforms your request for the specified provider.</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "16px",
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
<div className="p-2">
|
||||
<h1 className="text-lg font-medium text-foreground">Playground</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
See how LiteLLM transforms your request for the specified provider.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Original Request Panel */}
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 50%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
overflow: "hidden",
|
||||
maxHeight: "600px",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<h2 style={{ fontSize: "24px", fontWeight: "bold", margin: "0 0 4px 0" }}>Original Request</h2>
|
||||
<p style={{ color: "#666", margin: 0 }}>
|
||||
The request you would send to LiteLLM /chat/completions endpoint.
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Original Request</CardTitle>
|
||||
<CardDescription>The request you would send to LiteLLM /chat/completions endpoint.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<textarea
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
width: "100%",
|
||||
minHeight: "240px",
|
||||
padding: "16px",
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: "6px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
resize: "none",
|
||||
marginBottom: "24px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
value={originalRequestJSON}
|
||||
onChange={(e) => setOriginalRequestJSON(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Press Cmd/Ctrl + Enter to transform"
|
||||
/>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
className="h-72 resize-none p-4 font-mono text-sm field-sizing-fixed"
|
||||
value={originalRequestJSON}
|
||||
onChange={(e) => setOriginalRequestJSON(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Press Cmd/Ctrl + Enter to transform"
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
backgroundColor: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
onClick={handleTransform}
|
||||
loading={isLoading}
|
||||
>
|
||||
<CardFooter className="justify-end">
|
||||
<Button onClick={handleTransform} disabled={isLoading}>
|
||||
<span>Transform</span>
|
||||
<span>→</span>
|
||||
{isLoading ? <UiLoadingSpinner className="size-4" /> : <ArrowRight />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Transformed Request Panel */}
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 50%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
overflow: "hidden",
|
||||
maxHeight: "800px",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<h2 style={{ fontSize: "24px", fontWeight: "bold", margin: "0 0 4px 0" }}>Transformed Request</h2>
|
||||
<p style={{ color: "#666", margin: 0 }}>How LiteLLM transforms your request for the specified provider.</p>
|
||||
<br />
|
||||
<p style={{ color: "#666", margin: 0 }} className="text-xs">
|
||||
Note: Sensitive headers are not shown.
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Transformed Request</CardTitle>
|
||||
<CardDescription>How LiteLLM transforms your request for the specified provider.</CardDescription>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Note: Sensitive headers are not shown.</p>
|
||||
</CardHeader>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: "6px",
|
||||
flex: "1 1 auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
padding: "16px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
margin: 0,
|
||||
overflow: "auto",
|
||||
flex: "1 1 auto",
|
||||
}}
|
||||
>
|
||||
{transformedResponse ||
|
||||
`curl -X POST \\
|
||||
<CardContent>
|
||||
<div className="relative rounded-md bg-muted">
|
||||
<pre className="h-72 overflow-auto p-4 font-mono text-sm">
|
||||
{transformedResponse ||
|
||||
`curl -X POST \\
|
||||
https://api.openai.com/v1/chat/completions \\
|
||||
-H 'Authorization: Bearer sk-xxx' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
|
|
@ -265,29 +188,33 @@ ${formattedBody}
|
|||
],
|
||||
"temperature": 0.7
|
||||
}'`}
|
||||
</pre>
|
||||
</pre>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "8px",
|
||||
top: "8px",
|
||||
}}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(transformedResponse || "");
|
||||
NotificationsManager.success("Copied to clipboard");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy to clipboard"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(transformedResponse || "");
|
||||
NotificationsManager.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="mt-4 text-right w-full">
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="mt-4 text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Found an error? File an issue{" "}
|
||||
<a href="https://github.com/BerriAI/litellm/issues" target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue