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"
+ >
+
+
+ Team
+
+
+
+ Virtual key
+
+
+
+ {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" && (
+ dispatch({ type: "restarted" })}>
+ Start again
+
+ )}
+ {state.kind === "connected" ? "Done" : "Close"}
+
+
+
+ );
+}
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 (
+
+ Finish ChatGPT sign-in
+
+ );
+ },
+}));
+
+vi.mock("../common_components/team_dropdown", () => ({
+ default: ({ id, value, onChange }: { id?: string; value?: string | null; onChange?: (v: string | null) => void }) => (
+ onChange?.(event.target.value || null)}>
+ No team
+ Team One
+
+ ),
+}));
+
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({
+ {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
+ onSignedIn?.()}>
+ finish {mode} ChatGPT sign-in
+
>
);
},
@@ -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;