mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-23 05:34:41 +00:00
feat(ui): show gateway URL and agent setup instructions after creating a key (#47199)
* feat(ui): show gateway URL and agent setup instructions after creating a key Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(ui): make the selected agent button visibly distinct in dark mode Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(ui): hide agent instructions by default and use a generic title Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(ui): use one shared agent instructions card with Claude Code and Codex logos Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
d5a8c5fd51
commit
4a8b47542c
5 changed files with 197 additions and 2 deletions
|
|
@ -10,6 +10,7 @@ import { chooseSelectOption, renderWithProviders as render } from "../../../../.
|
|||
vi.mock("@/components/networking", () => ({
|
||||
apiClient: { get: vi.fn() },
|
||||
createAgentCall: vi.fn(),
|
||||
getProxyBaseUrl: vi.fn(() => "http://gw.test:4000/"),
|
||||
getAgentCreateMetadata: vi.fn(),
|
||||
getAgentsList: vi.fn(),
|
||||
keyCreateForAgentCall: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { AgentCreateInfo } from "@/components/networking";
|
|||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createAgentCall: vi.fn(),
|
||||
getProxyBaseUrl: vi.fn(() => "http://gw.test:4000/"),
|
||||
getAgentCreateMetadata: vi.fn(),
|
||||
getAgentsList: vi.fn(),
|
||||
keyCreateForAgentCall: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -1914,7 +1914,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
|
||||
{apiKey && (
|
||||
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<div className="grid grid-cols-1 gap-2 w-full">
|
||||
<DialogTitle className="text-lg font-medium text-foreground">Save your Key</DialogTitle>
|
||||
{apiKey != null ? (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,43 @@
|
|||
import { cloneElement, type MouseEventHandler, type ReactElement, type ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import CreatedKeyDisplay from "./CreatedKeyDisplay";
|
||||
import CreatedKeyDisplay, { buildAgentInstructions } from "./CreatedKeyDisplay";
|
||||
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const mockCopy = vi.hoisted(() => vi.fn<(text: string) => void>());
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: vi.fn(() => "http://gw.test:4000/"),
|
||||
}));
|
||||
|
||||
vi.mock("react-copy-to-clipboard", () => ({
|
||||
CopyToClipboard: ({
|
||||
text,
|
||||
onCopy,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
onCopy?: (text: string, result: boolean) => void;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const child = children as ReactElement<{ onClick?: MouseEventHandler<HTMLButtonElement> }>;
|
||||
return cloneElement(child, {
|
||||
onClick: (event) => {
|
||||
child.props.onClick?.(event);
|
||||
mockCopy(text);
|
||||
onCopy?.(text, true);
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CreatedKeyDisplay", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockCopy.mockReset();
|
||||
vi.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -69,4 +99,87 @@ describe("CreatedKeyDisplay", () => {
|
|||
|
||||
expect(screen.getByRole("button", { name: /copy virtual key/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the gateway URL without its trailing slash and copies it", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<CreatedKeyDisplay apiKey="sk-test-123" />);
|
||||
|
||||
expect(screen.getByText("http://gw.test:4000")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Copy URL" }));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith("http://gw.test:4000");
|
||||
expect(toast.success).toHaveBeenCalledWith("Gateway URL copied to clipboard");
|
||||
});
|
||||
|
||||
it("shows one shared instruction card with both agent logos and expands on demand", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<CreatedKeyDisplay apiKey="sk-test-123" />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Claude Code" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Codex" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Agent instructions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Set up your agent")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Claude Code" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Codex" })).not.toBeInTheDocument();
|
||||
|
||||
const showButton = screen.getByRole("button", { name: "Show" });
|
||||
expect(showButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText(/POST http:\/\/gw\.test:4000\/v1\/responses/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(showButton);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Hide" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText(/POST http:\/\/gw\.test:4000\/v1\/responses/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ANTHROPIC_BASE_URL=http:\/\/gw\.test:4000/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/OPENAI_BASE_URL=http:\/\/gw\.test:4000\/v1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies complete shared instructions while the card is collapsed", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<CreatedKeyDisplay apiKey="sk-test-123" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Show" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText(/POST http:\/\/gw\.test:4000\/v1\/responses/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Copy instructions" }));
|
||||
|
||||
const copiedInstructions = mockCopy.mock.calls.at(-1)?.[0] ?? "";
|
||||
expect(copiedInstructions).toContain("Gateway URL: http://gw.test:4000");
|
||||
expect(copiedInstructions).toContain("Virtual key: sk-test-123");
|
||||
expect(copiedInstructions).toContain("POST http://gw.test:4000/v1/chat/completions");
|
||||
expect(copiedInstructions).toContain("POST http://gw.test:4000/v1/responses");
|
||||
expect(copiedInstructions).toContain("POST http://gw.test:4000/v1/messages");
|
||||
expect(copiedInstructions).toContain("GET http://gw.test:4000/v1/models");
|
||||
expect(copiedInstructions).toContain(
|
||||
"To run Claude Code through this gateway, set ANTHROPIC_BASE_URL=http://gw.test:4000 and ANTHROPIC_AUTH_TOKEN=sk-test-123.",
|
||||
);
|
||||
expect(copiedInstructions).toContain(
|
||||
"To run Codex through this gateway, set OPENAI_BASE_URL=http://gw.test:4000/v1 and OPENAI_API_KEY=sk-test-123.",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
|
||||
expect(toast.success).toHaveBeenCalledWith("Instructions copied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentInstructions", () => {
|
||||
it("builds shared instructions with both agent settings and a normalized URL", () => {
|
||||
expect(buildAgentInstructions("http://gw.test:4000/", "sk-test-123")).toBe(
|
||||
[
|
||||
"Use this LiteLLM AI gateway for model inference.",
|
||||
"",
|
||||
"Gateway URL: http://gw.test:4000",
|
||||
"Virtual key: sk-test-123",
|
||||
"",
|
||||
"LiteLLM is a gateway that exposes OpenAI and Anthropic compatible APIs. Send the virtual key as a Bearer token (Authorization: Bearer sk-test-123) to:",
|
||||
"- POST http://gw.test:4000/v1/chat/completions (OpenAI Chat Completions)",
|
||||
"- POST http://gw.test:4000/v1/responses (OpenAI Responses)",
|
||||
"- POST http://gw.test:4000/v1/messages (Anthropic Messages)",
|
||||
"- GET http://gw.test:4000/v1/models (models this key can use)",
|
||||
"",
|
||||
"To run Claude Code through this gateway, set ANTHROPIC_BASE_URL=http://gw.test:4000 and ANTHROPIC_AUTH_TOKEN=sk-test-123.",
|
||||
"To run Codex through this gateway, set OPENAI_BASE_URL=http://gw.test:4000/v1 and OPENAI_API_KEY=sk-test-123.",
|
||||
"",
|
||||
"Docs for everything else LiteLLM supports: https://docs.litellm.ai/docs/",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useState } from "react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import anthropicLogo from "../../../public/assets/logos/anthropic.svg";
|
||||
import openaiLogo from "../../../public/assets/logos/openai_small.svg";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -7,12 +11,41 @@ interface CreatedKeyDisplayProps {
|
|||
apiKey: string;
|
||||
}
|
||||
|
||||
export const buildAgentInstructions = (baseUrl: string, apiKey: string): string => {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
const endpointIntroduction =
|
||||
"LiteLLM is a gateway that exposes OpenAI and Anthropic compatible APIs. " +
|
||||
`Send the virtual key as a Bearer token (Authorization: Bearer ${apiKey}) to:`;
|
||||
|
||||
return [
|
||||
"Use this LiteLLM AI gateway for model inference.",
|
||||
"",
|
||||
`Gateway URL: ${normalizedBaseUrl}`,
|
||||
`Virtual key: ${apiKey}`,
|
||||
"",
|
||||
endpointIntroduction,
|
||||
`- POST ${normalizedBaseUrl}/v1/chat/completions (OpenAI Chat Completions)`,
|
||||
`- POST ${normalizedBaseUrl}/v1/responses (OpenAI Responses)`,
|
||||
`- POST ${normalizedBaseUrl}/v1/messages (Anthropic Messages)`,
|
||||
`- GET ${normalizedBaseUrl}/v1/models (models this key can use)`,
|
||||
"",
|
||||
`To run Claude Code through this gateway, set ANTHROPIC_BASE_URL=${normalizedBaseUrl} and ANTHROPIC_AUTH_TOKEN=${apiKey}.`,
|
||||
`To run Codex through this gateway, set OPENAI_BASE_URL=${normalizedBaseUrl}/v1 and OPENAI_API_KEY=${apiKey}.`,
|
||||
"",
|
||||
"Docs for everything else LiteLLM supports: https://docs.litellm.ai/docs/",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared component for displaying a newly-created virtual key.
|
||||
* Used on the Virtual Keys page and in the Add Agent wizard.
|
||||
*/
|
||||
const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [instructionsCopied, setInstructionsCopied] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const baseUrl = getProxyBaseUrl().replace(/\/+$/, "");
|
||||
const instructions = buildAgentInstructions(baseUrl, apiKey);
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true);
|
||||
|
|
@ -20,6 +53,12 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
|
|||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleCopyInstructions = () => {
|
||||
setInstructionsCopied(true);
|
||||
toast.success("Instructions copied");
|
||||
setTimeout(() => setInstructionsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
|
|
@ -36,6 +75,47 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
|
|||
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
|
||||
<Button className="mt-3">{copied ? "Copied!" : "Copy Virtual Key"}</Button>
|
||||
</CopyToClipboard>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-1">Gateway URL:</p>
|
||||
<div className="bg-muted rounded-md p-2.5 mb-2.5 flex items-center justify-between gap-2">
|
||||
<pre className="m-0 whitespace-normal break-words text-foreground">{baseUrl}</pre>
|
||||
<CopyToClipboard text={baseUrl} onCopy={() => toast.success("Gateway URL copied to clipboard")}>
|
||||
<Button type="button" variant="outline" className="shrink-0">
|
||||
Copy URL
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">Agent instructions</p>
|
||||
<img src={anthropicLogo.src} alt="Claude Code" title="Claude Code" className="size-5 object-contain" />
|
||||
<img src={openaiLogo.src} alt="Codex" title="Codex" className="size-5 object-contain" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyToClipboard text={instructions} onCopy={handleCopyInstructions}>
|
||||
<Button type="button">{instructionsCopied ? "Copied!" : "Copy instructions"}</Button>
|
||||
</CopyToClipboard>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-expanded={showInstructions}
|
||||
onClick={() => setShowInstructions((expanded) => !expanded)}
|
||||
>
|
||||
{showInstructions ? "Hide" : "Show"}
|
||||
{showInstructions ? (
|
||||
<ChevronUp aria-hidden="true" className="size-4" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showInstructions && (
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words text-sm text-foreground">{instructions}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue