diff --git a/ui/litellm-dashboard/src/components/model_add/ChatGPTOwnerFields.tsx b/ui/litellm-dashboard/src/components/model_add/ChatGPTOwnerFields.tsx new file mode 100644 index 00000000000..6f8e0e4a24d --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/ChatGPTOwnerFields.tsx @@ -0,0 +1,88 @@ +import TeamDropdown from "@/components/common_components/team_dropdown"; +import { requiredRule } from "@/components/common_components/formRules"; +import { MountedFormField } from "@/components/common_components/MountedFormField"; +import { labelWithHint } from "@/components/shared/form/LabelWithHint"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +import { CHATGPT_OWNER_FIELDS, type ChatGPTOwnerKind } from "./chatgpt_sign_in_flow"; + +const OWNER_KIND_LABEL_ID = "chatgpt_owner_kind_label"; + +interface ChatGPTOwnerFieldsProps { + readonly ownerKind: ChatGPTOwnerKind; + readonly onOwnerKindChange: (ownerKind: ChatGPTOwnerKind) => void; + readonly storedOwnerTeamId: string | null; + readonly storedOwnerKeyHash: string | null; +} + +export default function ChatGPTOwnerFields({ + ownerKind, + onOwnerKindChange, + storedOwnerTeamId, + storedOwnerKeyHash, +}: ChatGPTOwnerFieldsProps) { + return ( + <> +
+ + {labelWithHint("Owner:", "Only the team or virtual key you pick can use this ChatGPT subscription.")} + + onOwnerKindChange(value as ChatGPTOwnerKind)} + className="flex w-auto items-center gap-4" + > + + + +
+ {ownerKind === "team" ? ( + + {(control) => ( + + )} + + ) : ( + + {(control) => ( + + )} + + )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/model_add/ChatGPTSignInModal.integration.test.tsx b/ui/litellm-dashboard/src/components/model_add/ChatGPTSignInModal.integration.test.tsx new file mode 100644 index 00000000000..6c2965cbec7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/ChatGPTSignInModal.integration.test.tsx @@ -0,0 +1,117 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ChatGPTSignInModal from "./ChatGPTSignInModal"; + +const networking = vi.hoisted(() => ({ + chatGPTLoginStartCall: vi.fn(), + chatGPTLoginPollCall: vi.fn(), +})); + +vi.mock("@/components/networking", () => networking); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "session-token" }) })); + +const START_RESPONSE = { + user_code: "WXYZ-9876", + verification_uri: "https://auth.openai.com/codex/device", + expires_in: 900, + interval: 5, + flow_handle: "fh-1", +}; + +const REQUEST = { ownerTeamId: "team-1", ownerKeyHash: null, displayName: "Shared ChatGPT" }; + +const renderModal = () => { + const props = { onClose: vi.fn(), onConnected: vi.fn() }; + render(); + return props; +}; + +const advance = async (seconds: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(seconds * 1000); + }); +}; + +describe("ChatGPTSignInModal", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + networking.chatGPTLoginStartCall.mockReset().mockResolvedValue(START_RESPONSE); + networking.chatGPTLoginPollCall.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the code and link, polls at the returned interval, and calls onConnected once connected", async () => { + networking.chatGPTLoginPollCall + .mockResolvedValueOnce({ status: "pending", account_id: null }) + .mockResolvedValueOnce({ status: "connected", account_id: "acct-1" }); + const props = renderModal(); + await advance(0); + + expect(networking.chatGPTLoginStartCall).toHaveBeenCalledWith("session-token", "team chatgpt", REQUEST); + expect(screen.getByTestId("chatgpt-user-code")).toHaveTextContent("WXYZ-9876"); + expect(screen.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toHaveAttribute( + "href", + "https://auth.openai.com/codex/device", + ); + + await advance(4.9); + expect(networking.chatGPTLoginPollCall).not.toHaveBeenCalled(); + await advance(0.1); + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(1); + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledWith("session-token", "team chatgpt", "fh-1"); + expect(props.onConnected).not.toHaveBeenCalled(); + + await advance(5); + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2); + expect(props.onConnected).toHaveBeenCalledTimes(1); + + await advance(30); + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2); + }); + + it("shows the error from a rejected poll, stops polling, and starts a new flow on request", async () => { + networking.chatGPTLoginPollCall.mockRejectedValue(new Error("Invalid or expired flow handle")); + const props = renderModal(); + await advance(0); + await advance(5); + + expect(screen.getByRole("alert")).toHaveTextContent("Invalid or expired flow handle"); + expect(props.onConnected).not.toHaveBeenCalled(); + await advance(30); + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Start again" })); + await advance(0); + expect(networking.chatGPTLoginStartCall).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("chatgpt-user-code")).toBeInTheDocument(); + }); + + it("stops polling and says the code expired once its lifetime passes", async () => { + networking.chatGPTLoginStartCall.mockResolvedValue({ ...START_RESPONSE, expires_in: 12 }); + networking.chatGPTLoginPollCall.mockResolvedValue({ status: "pending", account_id: null }); + const props = renderModal(); + await advance(0); + await advance(5); + await advance(5); + await advance(5); + + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2); + expect(screen.getByRole("alert")).toHaveTextContent("The sign-in code expired"); + await advance(30); + expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2); + expect(props.onConnected).not.toHaveBeenCalled(); + }); + + it("shows the proxy error when the start request fails", async () => { + networking.chatGPTLoginStartCall.mockRejectedValue(new Error("Team id does not exist")); + renderModal(); + await advance(0); + + expect(screen.getByRole("alert")).toHaveTextContent("Team id does not exist"); + expect(networking.chatGPTLoginPollCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/ChatGPTSignInModal.tsx b/ui/litellm-dashboard/src/components/model_add/ChatGPTSignInModal.tsx new file mode 100644 index 00000000000..0524ba9fdf4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/ChatGPTSignInModal.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useReducer } from "react"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { chatGPTLoginPollCall, chatGPTLoginStartCall, type ChatGPTLoginStartRequest } from "@/components/networking"; +import CopyButton from "@/components/shared/CopyButton"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { extractProxyErrorMessage } from "@/lib/http/client"; + +import { initialSignInFlowState, signInFailureMessage, signInFlowReducer } from "./chatgpt_sign_in_flow"; + +interface ChatGPTSignInModalProps { + readonly credentialName: string; + readonly request: ChatGPTLoginStartRequest; + readonly onClose: () => void; + readonly onConnected: () => void; +} + +export default function ChatGPTSignInModal({ credentialName, request, onClose, onConnected }: ChatGPTSignInModalProps) { + const { accessToken } = useAuthorized(); + const [state, dispatch] = useReducer(signInFlowReducer, initialSignInFlowState); + + useEffect(() => { + if (state.kind !== "starting" || !accessToken) { + return; + } + let cancelled = false; + chatGPTLoginStartCall(accessToken, credentialName, request) + .then((response) => !cancelled && dispatch({ type: "started", response, now: Date.now() })) + .catch((error: unknown) => !cancelled && dispatch({ type: "errored", message: extractProxyErrorMessage(error) })); + return () => { + cancelled = true; + }; + }, [state, accessToken, credentialName, request]); + + useEffect(() => { + if (state.kind !== "awaiting" || !accessToken) { + return; + } + let cancelled = false; + const timer = setTimeout(() => { + if (Date.now() >= state.expiresAt) { + dispatch({ type: "timed_out" }); + return; + } + chatGPTLoginPollCall(accessToken, credentialName, state.flowHandle) + .then((response) => !cancelled && dispatch({ type: "polled", response })) + .catch( + (error: unknown) => !cancelled && dispatch({ type: "errored", message: extractProxyErrorMessage(error) }), + ); + }, state.intervalSeconds * 1000); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [state, accessToken, credentialName]); + + useEffect(() => { + if (state.kind === "connected") { + onConnected(); + } + }, [state.kind, onConnected]); + + return ( + !open && onClose()}> + + + Sign in with ChatGPT + Credential: {credentialName} + + + {state.kind === "starting" &&

Requesting a sign-in code from ChatGPT...

} + + {state.kind === "awaiting" && ( +
+

+ Open{" "} + + {state.verificationUri} + {" "} + and enter this code: +

+
+ + {state.userCode} + + +
+

Waiting for you to finish signing in on ChatGPT...

+
+ )} + + {state.kind === "connected" &&

Signed in with ChatGPT.

} + + {state.kind === "failed" && ( +

+ {signInFailureMessage(state)} +

+ )} + + + {state.kind === "failed" && ( + + )} + + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx index 8d94ae0d213..65aa53949f7 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx @@ -1,10 +1,33 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Providers } from "../provider_info_helpers"; -import { CredentialItem } from "../networking"; +import { CredentialItem, type ChatGPTLoginStartRequest } from "../networking"; import CredentialModal from "./CredentialModal"; +const signInModal = vi.hoisted(() => vi.fn()); + +vi.mock("./ChatGPTSignInModal", () => ({ + default: (props: { credentialName: string; request: ChatGPTLoginStartRequest; onConnected: () => void }) => { + signInModal(props); + return ( +
+ +
+ ); + }, +})); + +vi.mock("../common_components/team_dropdown", () => ({ + default: ({ id, value, onChange }: { id?: string; value?: string | null; onChange?: (v: string | null) => void }) => ( + + ), +})); + vi.mock("../networking", async () => { const actual = await vi.importActual("../networking"); return { @@ -125,4 +148,92 @@ describe("CredentialModal", () => { expect(screen.getByLabelText("Credential Name:")).toBeDisabled(); }); }); + + describe("ChatGPT Subscription", () => { + beforeEach(() => { + signInModal.mockClear(); + }); + + const lastSignIn = () => + signInModal.mock.lastCall?.[0] as { credentialName: string; request: ChatGPTLoginStartRequest } | undefined; + + const chatGPTCredential: CredentialItem = { + credential_name: "key-chatgpt", + display_name: "Key ChatGPT", + credential_values: { chatgpt_access_token: "eyJh****", chatgpt_account_id: "acct-1" }, + credential_info: { custom_llm_provider: "chatgpt", chatgpt_owner_key_hash: "hash-stored" }, + }; + + it("offers sign-in and an owner picker in place of the add button", () => { + renderModal({ mode: "add", initialProvider: "CHATGPT" }); + + expect(screen.getByRole("button", { name: "Sign in with ChatGPT" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add Credential" })).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Team" })).toBeChecked(); + expect(screen.getByLabelText("Team:")).toBeInTheDocument(); + }); + + it("signs in for the entered name and team without calling onSubmit, then reports the sign-in", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const onSignedIn = vi.fn(); + renderModal({ initialProvider: "CHATGPT", onSubmit, onSignedIn }); + + fireEvent.change(screen.getByLabelText("Credential Name:"), { target: { value: "team-chatgpt" } }); + fireEvent.change(screen.getByLabelText("Team:"), { target: { value: "team-1" } }); + await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" })); + + expect(await screen.findByTestId("chatgpt-sign-in")).toBeInTheDocument(); + expect(lastSignIn()).toMatchObject({ + credentialName: "team-chatgpt", + request: { ownerTeamId: "team-1", ownerKeyHash: null, displayName: null }, + }); + + await user.click(screen.getByRole("button", { name: "Finish ChatGPT sign-in" })); + expect(onSignedIn).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("chatgpt-sign-in")).not.toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("requires an owner and sends exactly one of the team or the virtual key", async () => { + const user = userEvent.setup(); + renderModal({ mode: "add", initialProvider: "CHATGPT" }); + fireEvent.change(screen.getByLabelText("Credential Name:"), { target: { value: "key-chatgpt" } }); + + await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" })); + expect(await screen.findByText("Pick the team that owns this login")).toBeInTheDocument(); + expect(signInModal).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText("Team:"), { target: { value: "team-1" } }); + await user.click(screen.getByRole("radio", { name: "Virtual key" })); + await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" })); + expect(await screen.findByText("Enter the ID of the key that owns this login")).toBeInTheDocument(); + expect(signInModal).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText("Key ID:"), { target: { value: " hash-1 " } }); + await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" })); + + expect(await screen.findByTestId("chatgpt-sign-in")).toBeInTheDocument(); + expect(lastSignIn()?.request).toEqual({ ownerTeamId: null, ownerKeyHash: "hash-1", displayName: null }); + }); + + it("recognizes a stored chatgpt credential and signs in again with its saved owner and name", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + renderModal({ mode: "edit", existingCredential: chatGPTCredential, onSubmit }); + + expect(screen.getByRole("combobox", { name: /Provider/ })).toHaveValue(Providers.CHATGPT); + expect(screen.getByRole("radio", { name: "Virtual key" })).toBeChecked(); + expect(screen.getByLabelText("Key ID:")).toHaveValue("hash-stored"); + + await user.click(screen.getByRole("button", { name: "Sign in again" })); + + expect(await screen.findByTestId("chatgpt-sign-in")).toBeInTheDocument(); + expect(lastSignIn()).toMatchObject({ + credentialName: "key-chatgpt", + request: { ownerTeamId: null, ownerKeyHash: "hash-stored", displayName: "Key ChatGPT" }, + }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index d0e28a0e273..c271df77981 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -16,7 +16,7 @@ import { type MountedFormValues, } from "../common_components/MountedFormField"; import { CredentialItem } from "../networking"; -import { Providers } from "../provider_info_helpers"; +import { getProviderLogoAndName, providerEnumKeyOf, Providers } from "../provider_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; import { displayNameChange, @@ -42,6 +42,15 @@ import { type AuthMethod, } from "./credential_federation"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import ChatGPTOwnerFields from "./ChatGPTOwnerFields"; +import ChatGPTSignInModal from "./ChatGPTSignInModal"; +import { + chatGPTSignInFrom, + credentialSubmitLabel, + storedChatGPTOwner, + type ChatGPTOwnerKind, + type ChatGPTSignIn, +} from "./chatgpt_sign_in_flow"; const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({ label: providerDisplayName, @@ -58,10 +67,14 @@ const authMethodItems: { value: AuthMethod; label: string }[] = [ { value: "federation", label: "Workload identity federation" }, ]; +const isChatGPTSubscription = (provider: string | null): boolean => + provider !== null && getProviderLogoAndName(provider).displayName === Providers.CHATGPT; + interface CredentialModalProps { open: boolean; onCancel: () => void; onSubmit: (submission: CredentialSubmission, valuesToDelete: readonly string[]) => void; + onSignedIn?: () => void; mode: "add" | "edit"; existingCredential?: CredentialItem | null; initialProvider?: string | null; @@ -77,6 +90,7 @@ export default function CredentialModal({ open, onCancel, onSubmit, + onSignedIn, mode, existingCredential = null, initialProvider = null, @@ -100,6 +114,10 @@ export default function CredentialModal({ ); const [identitySource, setIdentitySource] = useState(storedIdentitySource); const selection = selectionFor(federatedProviderOf(selectedProvider), authMethod, identitySource); + const storedOwner = storedChatGPTOwner(existingCredential); + const [chatGPTOwnerKind, setChatGPTOwnerKind] = useState(storedOwner.kind); + const [chatGPTSignIn, setChatGPTSignIn] = useState(null); + const signsInWithChatGPT = isChatGPTSubscription(selectedProvider); const initialValues = initialFormValues(existingCredential, initialProvider); @@ -154,6 +172,19 @@ export default function CredentialModal({ onSubmit({ ...meta, credential_values: patch.credential_values }, valuesToDelete); }; + const startChatGPTSignIn = async () => { + const isValid = await form.trigger(registry.mountedNames() as string[]); + if (!isValid) { + return; + } + setChatGPTSignIn(chatGPTSignInFrom(chatGPTOwnerKind, projectMountedValues(registry, form.getValues))); + }; + + const finishChatGPTSignIn = () => { + setChatGPTSignIn(null); + onSignedIn?.(); + }; + const closeAndReset = () => { onCancel(); form.reset(); @@ -170,7 +201,7 @@ export default function CredentialModal({
{ event.preventDefault(); - void handleSubmit(); + void (signsInWithChatGPT ? startChatGPTSignIn() : handleSubmit()); }} > { control.onChange(value); @@ -253,12 +284,21 @@ export default function CredentialModal({ )} - + {signsInWithChatGPT ? ( + + ) : ( + + )} {selection.authMethod === "federation" && ( Cancel - + + {chatGPTSignIn && ( + setChatGPTSignIn(null)} + onConnected={finishChatGPTSignIn} + /> + )} ); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx index a9e98cc5430..13333bf3820 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -37,10 +37,12 @@ vi.mock("./CredentialModal", () => ({ mode, open, onSubmit, + onSignedIn, }: { mode: "add" | "edit"; open: boolean; onSubmit: (submission: CredentialSubmission, valuesToDelete?: readonly string[]) => void; + onSignedIn?: () => void; }) { if (!open) { return null; @@ -80,6 +82,9 @@ vi.mock("./CredentialModal", () => ({ > switch {mode} to federation + ); }, @@ -262,6 +267,57 @@ describe("CredentialsPanel", () => { expect(plainPayload).not.toHaveProperty("credential_values_to_delete"); }); + it.each(["add", "edit"] as const)( + "closes the %s modal and refetches the list after a ChatGPT sign-in", + async (mode) => { + const user = userEvent.setup(); + const refetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch }); + + renderPanel(); + + if (mode === "add") { + await user.click(screen.getByRole("button", { name: /add credential/i })); + } else { + await user.click(screen.getByTestId("credential-actions-openai-key")); + await user.click(await screen.findByTestId("credential-action-edit")); + } + await user.click(screen.getByTestId(`credential-modal-${mode}-chatgpt-signed-in`)); + + expect(refetch).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith("Signed in with ChatGPT"); + expect(screen.queryByTestId(`credential-modal-${mode}-submit`)).not.toBeInTheDocument(); + }, + ); + + it("names a stored chatgpt credential's provider as ChatGPT Subscription in the delete confirmation", async () => { + const user = userEvent.setup(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ + data: { + credentials: [ + { + credential_name: "team-chatgpt", + credential_values: {}, + credential_info: { custom_llm_provider: "chatgpt", chatgpt_owner_team_id: "team-1" }, + }, + ], + }, + isLoading: false, + refetch: vi.fn(), + }); + + renderPanel(); + + await user.click(screen.getByTestId("credential-actions-team-chatgpt")); + await user.click(await screen.findByTestId("credential-action-delete")); + + const confirmation = await screen.findByRole("dialog"); + expect(within(confirmation).getByText("ChatGPT Subscription")).toBeInTheDocument(); + expect(within(confirmation).queryByText("chatgpt")).not.toBeInTheDocument(); + }); + describe("Admin Viewer write-action gating", () => { // Admin Viewer can VIEW credentials but must not add / edit / delete them. it("hides the Add Credential button but still lists credentials", () => { diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx index f0645502161..578f81b1e89 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -12,6 +12,7 @@ import { CredentialItem, credentialUpdateCall, } from "@/components/networking"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { Button } from "@/components/ui/button"; import { extractProxyErrorMessage } from "@/lib/http/client"; import { stripMaskedSecrets } from "@/utils/maskedSecretUtils"; @@ -71,6 +72,13 @@ export default function CredentialsPanel() { } }; + const handleChatGPTSignedIn = () => { + toast.success("Signed in with ChatGPT"); + setIsAddModalOpen(false); + setIsUpdateModalOpen(false); + void refetchCredentials(); + }; + const handleDeleteCredential = async () => { if (!accessToken || !credentialToDelete) { return; @@ -130,6 +138,7 @@ export default function CredentialsPanel() { setIsAddModalOpen(false)} /> @@ -140,6 +149,7 @@ export default function CredentialsPanel() { open={isUpdateModalOpen} existingCredential={selectedCredential} onSubmit={handleUpdateCredential} + onSignedIn={handleChatGPTSignedIn} onCancel={() => setIsUpdateModalOpen(false)} /> )} @@ -153,7 +163,10 @@ export default function CredentialsPanel() { resourceInformationTitle="Credential Information" resourceInformation={[ { label: "Credential Name", value: credentialToDelete?.credential_name }, - { label: "Provider", value: credentialToDelete?.credential_info?.custom_llm_provider || "-" }, + { + label: "Provider", + value: getProviderLogoAndName(credentialToDelete?.credential_info?.custom_llm_provider ?? "").displayName, + }, ]} confirmLoading={isCredentialDeleting} requiredConfirmation={credentialToDelete?.credential_name} diff --git a/ui/litellm-dashboard/src/components/model_add/chatgpt_sign_in_flow.test.ts b/ui/litellm-dashboard/src/components/model_add/chatgpt_sign_in_flow.test.ts new file mode 100644 index 00000000000..f12904818b4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/chatgpt_sign_in_flow.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { + chatGPTStartRequest, + initialSignInFlowState, + signInFailureMessage, + signInFlowReducer, + type SignInFlowState, +} from "./chatgpt_sign_in_flow"; + +const started = (verificationUri = "https://auth.openai.com/codex/device"): SignInFlowState => + signInFlowReducer(initialSignInFlowState, { + type: "started", + response: { + user_code: "WXYZ-9876", + verification_uri: verificationUri, + expires_in: 900, + interval: 5, + flow_handle: "fh-1", + }, + now: 1_000, + }); + +const connected = (): SignInFlowState => + signInFlowReducer(started(), { type: "polled", response: { status: "connected", account_id: "acct-1" } }); + +describe("signInFlowReducer", () => { + it("moves from starting to awaiting with the code, expiry, interval and handle from the start response", () => { + const expected: SignInFlowState = { + kind: "awaiting", + attempt: 0, + pollCount: 0, + userCode: "WXYZ-9876", + verificationUri: "https://auth.openai.com/codex/device", + expiresAt: 901_000, + intervalSeconds: 5, + flowHandle: "fh-1", + }; + expect(started()).toEqual(expected); + }); + + it.each([ + "https://evil.example/codex/device", + "http://auth.openai.com/codex/device", + "https://auth.openai.com.evil.example/codex/device", + "https://user@auth.openai.com/codex/device", + "https://auth.openai.com:8443/codex/device", + "not a url", + ])("falls back to the ChatGPT device page for unsafe verification URL %s", (uri) => { + const state = started(uri); + expect(state.kind === "awaiting" && state.verificationUri).toBe("https://auth.openai.com/codex/device"); + }); + + it("keeps waiting on pending as a new state so the next poll is scheduled", () => { + const awaiting = started(); + const pending = signInFlowReducer(awaiting, { type: "polled", response: { status: "pending", account_id: null } }); + expect(pending).not.toBe(awaiting); + expect(pending).toEqual({ ...awaiting, pollCount: 1 }); + }); + + it("connects with the account id from the poll", () => { + expect(connected()).toEqual({ kind: "connected", attempt: 0, accountId: "acct-1" }); + }); + + it("expires locally once the code lifetime has passed", () => { + const expired = signInFlowReducer(started(), { type: "timed_out" }); + expect(expired).toEqual({ kind: "failed", attempt: 0, reason: "expired" }); + expect(expired.kind === "failed" && signInFailureMessage(expired)).toContain("expired"); + }); + + it("fails with the backend's message when a poll is rejected, then restarts with a new attempt", () => { + const failed = signInFlowReducer(started(), { type: "errored", message: "Invalid or expired flow handle" }); + const expected: SignInFlowState = { + kind: "failed", + attempt: 0, + reason: "error", + message: "Invalid or expired flow handle", + }; + expect(failed).toEqual(expected); + expect(failed.kind === "failed" && signInFailureMessage(failed)).toBe("Invalid or expired flow handle"); + expect(signInFlowReducer(failed, { type: "restarted" })).toEqual({ kind: "starting", attempt: 1 }); + }); + + it("ignores a late poll result or error after the flow connected", () => { + const done = connected(); + expect(signInFlowReducer(done, { type: "polled", response: { status: "pending", account_id: null } })).toBe(done); + expect(signInFlowReducer(done, { type: "errored", message: "late" })).toBe(done); + expect(signInFlowReducer(done, { type: "timed_out" })).toBe(done); + }); + + it("ignores a second start response once the flow is already awaiting", () => { + const awaiting = started(); + const again = signInFlowReducer(awaiting, { + type: "started", + response: { user_code: "OTHER", verification_uri: "", expires_in: 1, interval: 1, flow_handle: "fh-2" }, + now: 0, + }); + expect(again).toBe(awaiting); + }); +}); + +describe("chatGPTStartRequest", () => { + it("sets only the team id for a team owner", () => { + expect(chatGPTStartRequest("team", "team-1", "")).toEqual({ + ownerTeamId: "team-1", + ownerKeyHash: null, + displayName: null, + }); + }); + + it("sets only the key hash for a virtual key owner and trims the display name", () => { + expect(chatGPTStartRequest("key", "hash-1", " Shared ChatGPT ")).toEqual({ + ownerTeamId: null, + ownerKeyHash: "hash-1", + displayName: "Shared ChatGPT", + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/chatgpt_sign_in_flow.ts b/ui/litellm-dashboard/src/components/model_add/chatgpt_sign_in_flow.ts new file mode 100644 index 00000000000..34e08678335 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/chatgpt_sign_in_flow.ts @@ -0,0 +1,159 @@ +import type { + ChatGPTLoginPollResponse, + ChatGPTLoginStartRequest, + ChatGPTLoginStartResponse, + CredentialItem, +} from "@/components/networking"; + +export const CHATGPT_DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device"; +const CHATGPT_DEVICE_LOGIN_HOST = "auth.openai.com"; + +export type ChatGPTOwnerKind = "team" | "key"; + +export const CHATGPT_OWNER_FIELDS: Readonly> = { + team: "chatgpt_owner_team_id", + key: "chatgpt_owner_key_hash", +}; + +export type SignInFailureReason = "expired" | "error"; + +export type SignInFlowState = + | { readonly kind: "starting"; readonly attempt: number } + | { + readonly kind: "awaiting"; + readonly attempt: number; + readonly pollCount: number; + readonly userCode: string; + readonly verificationUri: string; + readonly expiresAt: number; + readonly intervalSeconds: number; + readonly flowHandle: string; + } + | { readonly kind: "connected"; readonly attempt: number; readonly accountId: string | null } + | { + readonly kind: "failed"; + readonly attempt: number; + readonly reason: SignInFailureReason; + readonly message?: string; + }; + +export type SignInFlowEvent = + | { readonly type: "started"; readonly response: ChatGPTLoginStartResponse; readonly now: number } + | { readonly type: "polled"; readonly response: ChatGPTLoginPollResponse } + | { readonly type: "timed_out" } + | { readonly type: "errored"; readonly message: string } + | { readonly type: "restarted" }; + +export const initialSignInFlowState: SignInFlowState = { kind: "starting", attempt: 0 }; + +export const chatGPTStartRequest = ( + ownerKind: ChatGPTOwnerKind, + ownerId: string, + displayName: string, +): ChatGPTLoginStartRequest => ({ + ownerTeamId: ownerKind === "team" ? ownerId : null, + ownerKeyHash: ownerKind === "key" ? ownerId : null, + displayName: displayName.trim() === "" ? null : displayName.trim(), +}); + +export interface StoredChatGPTOwner { + readonly teamId: string | null; + readonly keyHash: string | null; + readonly kind: ChatGPTOwnerKind; +} + +export const storedChatGPTOwner = (credential: CredentialItem | null): StoredChatGPTOwner => { + const teamId = credential?.credential_info.chatgpt_owner_team_id ?? null; + const keyHash = credential?.credential_info.chatgpt_owner_key_hash ?? null; + return { teamId, keyHash, kind: keyHash ? "key" : "team" }; +}; + +export interface ChatGPTSignIn { + readonly credentialName: string; + readonly request: ChatGPTLoginStartRequest; +} + +export const chatGPTSignInFrom = ( + ownerKind: ChatGPTOwnerKind, + values: Readonly>, +): ChatGPTSignIn => { + const ownerId = values[CHATGPT_OWNER_FIELDS[ownerKind]]; + const displayName = values.display_name; + return { + credentialName: String(values.credential_name), + request: chatGPTStartRequest( + ownerKind, + typeof ownerId === "string" ? ownerId.trim() : "", + typeof displayName === "string" ? displayName : "", + ), + }; +}; + +const SUBMIT_LABELS = { + chatgpt: { add: "Sign in with ChatGPT", edit: "Sign in again" }, + credential: { add: "Add Credential", edit: "Update Credential" }, +} as const; + +export const credentialSubmitLabel = (signsInWithChatGPT: boolean, mode: "add" | "edit"): string => + SUBMIT_LABELS[signsInWithChatGPT ? "chatgpt" : "credential"][mode]; + +const safeVerificationUri = (uri: string): string => { + try { + const url = new URL(uri); + const hasCredentials = url.username !== "" || url.password !== ""; + const isDefaultHttps = url.protocol === "https:" && url.port === ""; + const isSafeUrl = isDefaultHttps && !hasCredentials && url.hostname === CHATGPT_DEVICE_LOGIN_HOST; + return isSafeUrl ? uri : CHATGPT_DEVICE_LOGIN_URL; + } catch { + return CHATGPT_DEVICE_LOGIN_URL; + } +}; + +const afterPoll = ( + state: Extract, + response: ChatGPTLoginPollResponse, +): SignInFlowState => { + switch (response.status) { + case "pending": + return { ...state, pollCount: state.pollCount + 1 }; + case "connected": + return { kind: "connected", attempt: state.attempt, accountId: response.account_id }; + } +}; + +export const signInFlowReducer = (state: SignInFlowState, event: SignInFlowEvent): SignInFlowState => { + switch (event.type) { + case "restarted": + return { kind: "starting", attempt: state.attempt + 1 }; + case "errored": + return state.kind === "connected" + ? state + : { kind: "failed", attempt: state.attempt, reason: "error", message: event.message }; + case "started": + return state.kind !== "starting" + ? state + : { + kind: "awaiting", + attempt: state.attempt, + pollCount: 0, + userCode: event.response.user_code, + verificationUri: safeVerificationUri(event.response.verification_uri), + expiresAt: event.now + event.response.expires_in * 1000, + intervalSeconds: event.response.interval, + flowHandle: event.response.flow_handle, + }; + case "polled": + return state.kind === "awaiting" ? afterPoll(state, event.response) : state; + case "timed_out": + return state.kind === "awaiting" ? { kind: "failed", attempt: state.attempt, reason: "expired" } : state; + } +}; + +export const signInFailureMessage = (state: Extract): string => { + switch (state.reason) { + case "expired": + return "The sign-in code expired before it was used. Start again to get a new code."; + case "error": + return state.message ?? "Something went wrong while signing in to ChatGPT."; + } +}; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 99a9dc7a707..b03218aa8ad 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -351,6 +351,8 @@ export interface CredentialItem { custom_llm_provider?: string; description?: string; required?: boolean; + chatgpt_owner_team_id?: string; + chatgpt_owner_key_hash?: string; }; } @@ -2731,6 +2733,48 @@ export const userConnectionPollCall = ( export const userConnectionDeleteCall = (accessToken: string, credentialName: string): Promise => apiClient.delete(userConnectionPath(credentialName), { accessToken }); +export interface ChatGPTLoginStartRequest { + ownerTeamId: string | null; + ownerKeyHash: string | null; + displayName: string | null; +} + +export interface ChatGPTLoginStartResponse { + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + flow_handle: string; +} + +export interface ChatGPTLoginPollResponse { + status: "pending" | "connected"; + account_id: string | null; +} + +const chatGPTLoginPath = (credentialName: string): string => + `/credentials/${encodeURIComponent(credentialName)}/chatgpt_login`; + +export const chatGPTLoginStartCall = ( + accessToken: string, + credentialName: string, + { ownerTeamId, ownerKeyHash, displayName }: ChatGPTLoginStartRequest, +): Promise => + apiClient.post(`${chatGPTLoginPath(credentialName)}/start`, { + accessToken, + body: { owner_team_id: ownerTeamId, owner_key_hash: ownerKeyHash, display_name: displayName }, + }); + +export const chatGPTLoginPollCall = ( + accessToken: string, + credentialName: string, + flowHandle: string, +): Promise => + apiClient.post(`${chatGPTLoginPath(credentialName)}/poll`, { + accessToken, + body: { flow_handle: flowHandle }, + }); + export const credentialUpdateCall = async ( accessToken: string, credentialName: string, diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 810b010f25e..f7ee17a9a75 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -145,6 +145,11 @@ describe("provider_info_helpers", () => { expect(fromEnumKey.logo).toContain("openai_small"); }); + it("should resolve an exact enum key to its own provider even when it lowercases to another provider's slug", () => { + expect(getProviderLogoAndName("SageMaker").displayName).toBe(Providers.SageMaker); + expect(getProviderLogoAndName("sagemaker").displayName).toBe(Providers.SAGEMAKER_LEGACY); + }); + it("should handle provider values case-insensitively", () => { const result = getProviderLogoAndName("OPENAI"); expect(result.displayName).toBe(Providers.OpenAI); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 1ed32c160e6..1f8a7fd6245 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -440,6 +440,15 @@ export const providerLogoMap: Partial> = { [Providers.XINFERENCE]: xinferenceLogo.src, }; +export const providerEnumKeyOf = (providerValue: string): string | undefined => { + const lowered = providerValue.toLowerCase(); + const enumKeys = Object.keys(provider_map); + return Object.hasOwn(provider_map, providerValue) + ? providerValue + : enumKeys.find((key) => provider_map[key].toLowerCase() === lowered) ?? + enumKeys.find((key) => key.toLowerCase() === lowered); +}; + export const getProviderLogoAndName = (providerValue: string): { logo: string; displayName: string } => { if (!providerValue) { return { logo: "", displayName: "-" }; @@ -452,12 +461,7 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d return { logo, displayName }; } - // Resolve by the litellm provider slug (e.g. "bedrock_mantle"); fall back to - // the enum key (e.g. "BedrockMantle") for callers like the Add Model dropdown - // that pass the key instead of the slug. - const enumKey = - Object.keys(provider_map).find((key) => provider_map[key].toLowerCase() === providerValue.toLowerCase()) ?? - Object.keys(provider_map).find((key) => key.toLowerCase() === providerValue.toLowerCase()); + const enumKey = providerEnumKeyOf(providerValue); if (!enumKey) { return { logo: "", displayName: providerValue }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f114c1c73d2..a32fb4cb8b3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -3864,6 +3864,46 @@ export interface paths { patch: operations["update_credential_credentials__credential_name__patch"]; trace?: never; }; + "/credentials/{credential_name}/chatgpt_login/poll": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Poll Chatgpt Login + * @description Poll a ChatGPT sign-in once and save the login on the credential when it completes. Proxy admin only. + */ + post: operations["poll_chatgpt_login_credentials__credential_name__chatgpt_login_poll_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/credentials/{credential_name}/chatgpt_login/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start Chatgpt Login + * @description Start a ChatGPT device-code sign-in whose login only the given team or virtual key can use. Proxy admin only. + */ + post: operations["start_chatgpt_login_credentials__credential_name__chatgpt_login_start_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/credentials/{credential_name}/jwks": { parameters: { query?: never; @@ -29135,6 +29175,27 @@ export interface components { /** Url */ url: string; }; + /** ChatGPTLoginPollResponse */ + ChatGPTLoginPollResponse: { + /** Account Id */ + account_id?: string | null; + /** Interval */ + interval?: number | null; + /** + * Status + * @enum {string} + */ + status: "pending" | "connected"; + }; + /** ChatGPTLoginStartRequest */ + ChatGPTLoginStartRequest: { + /** Display Name */ + display_name?: string | null; + /** Owner Key Hash */ + owner_key_hash?: string | null; + /** Owner Team Id */ + owner_team_id?: string | null; + }; /** ChoiceAnswer */ ChoiceAnswer: { /** Choice */ @@ -57120,6 +57181,78 @@ export interface operations { }; }; }; + poll_chatgpt_login_credentials__credential_name__chatgpt_login_poll_post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The credential name, percent-decoded */ + credential_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserConnectionPollRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChatGPTLoginPollResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + start_chatgpt_login_credentials__credential_name__chatgpt_login_start_post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The credential name, percent-decoded */ + credential_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGPTLoginStartRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserConnectionStartResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_credential_internal_issuer_jwks_credentials__credential_name__jwks_get: { parameters: { query?: never;