diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx index 67ac770a65f..53ce661baaf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx index 5e8ba145396..5b8cb652510 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 30c7a80c456..30ff097a87c 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -1914,7 +1914,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp {apiKey && ( !open && handleCancel()}> - +
Save your Key {apiKey != null ? ( diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx index 0e10d32f8d1..310f69d28bc 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx @@ -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 }>; + 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(); + + 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(); + + 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(); + + 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"), + ); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx index 5dbc17b13bf..d58a1864575 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx @@ -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 = ({ 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 = ({ apiKey }) => { setTimeout(() => setCopied(false), 2000); }; + const handleCopyInstructions = () => { + setInstructionsCopied(true); + toast.success("Instructions copied"); + setTimeout(() => setInstructionsCopied(false), 2000); + }; + return (

@@ -36,6 +75,47 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { + +

Gateway URL:

+
+
{baseUrl}
+ toast.success("Gateway URL copied to clipboard")}> + + +
+ +
+
+
+

Agent instructions

+ Claude Code + Codex +
+
+ + + + +
+
+ {showInstructions && ( +
{instructions}
+ )} +
); };