mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
feat(ui): sign in to a ChatGPT subscription from LLM Credentials
Choosing ChatGPT in the credential form now asks for the owning team or Key ID and runs the device-code sign-in in a dialog that shows the code, polls until the user approves, and lets the admin retry or start over when the code expires. Editing a ChatGPT credential offers "Sign in again" to rotate it, and the generated API types include the two new endpoints
This commit is contained in:
parent
9a1e20f674
commit
ae96eae705
13 changed files with 1043 additions and 21 deletions
|
|
@ -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 (
|
||||
<>
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<span id={OWNER_KIND_LABEL_ID} className="text-sm font-medium">
|
||||
{labelWithHint("Owner:", "Only the team or virtual key you pick can use this ChatGPT subscription.")}
|
||||
</span>
|
||||
<RadioGroup
|
||||
aria-labelledby={OWNER_KIND_LABEL_ID}
|
||||
value={ownerKind}
|
||||
onValueChange={(value) => onOwnerKindChange(value as ChatGPTOwnerKind)}
|
||||
className="flex w-auto items-center gap-4"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="team" />
|
||||
Team
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="key" />
|
||||
Virtual key
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{ownerKind === "team" ? (
|
||||
<MountedFormField
|
||||
key={CHATGPT_OWNER_FIELDS.team}
|
||||
label="Team:"
|
||||
name={CHATGPT_OWNER_FIELDS.team}
|
||||
required
|
||||
defaultValue={storedOwnerTeamId}
|
||||
rules={{ validate: { required: requiredRule("Pick the team that owns this login") } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<TeamDropdown
|
||||
id={control.id}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
onChange={control.onChange}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
) : (
|
||||
<MountedFormField
|
||||
key={CHATGPT_OWNER_FIELDS.key}
|
||||
label="Key ID:"
|
||||
name={CHATGPT_OWNER_FIELDS.key}
|
||||
required
|
||||
defaultValue={storedOwnerKeyHash}
|
||||
rules={{ validate: { required: requiredRule("Enter the ID of the key that owns this login") } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
id={control.id}
|
||||
value={typeof control.value === "string" ? control.value : ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="The Key ID shown on the Virtual Keys page"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<ChatGPTSignInModal credentialName="team chatgpt" request={REQUEST} {...props} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in with ChatGPT</DialogTitle>
|
||||
<DialogDescription>Credential: {credentialName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{state.kind === "starting" && <p className="text-sm">Requesting a sign-in code from ChatGPT...</p>}
|
||||
|
||||
{state.kind === "awaiting" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">
|
||||
Open{" "}
|
||||
<a
|
||||
href={state.verificationUri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{state.verificationUri}
|
||||
</a>{" "}
|
||||
and enter this code:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code data-testid="chatgpt-user-code" className="rounded-md bg-muted px-3 py-2 font-mono text-lg">
|
||||
{state.userCode}
|
||||
</code>
|
||||
<CopyButton value={state.userCode} label="Copy code" copiedLabel="Code copied" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Waiting for you to finish signing in on ChatGPT...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === "connected" && <p className="text-sm">Signed in with ChatGPT.</p>}
|
||||
|
||||
{state.kind === "failed" && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{signInFailureMessage(state)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{state.kind === "failed" && (
|
||||
<Button variant="outline" onClick={() => dispatch({ type: "restarted" })}>
|
||||
Start again
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>{state.kind === "connected" ? "Done" : "Close"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div data-testid="chatgpt-sign-in">
|
||||
<button onClick={props.onConnected}>Finish ChatGPT sign-in</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/team_dropdown", () => ({
|
||||
default: ({ id, value, onChange }: { id?: string; value?: string | null; onChange?: (v: string | null) => void }) => (
|
||||
<select id={id} value={value ?? ""} onChange={(event) => onChange?.(event.target.value || null)}>
|
||||
<option value="">No team</option>
|
||||
<option value="team-1">Team One</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<IdentitySourceId>(storedIdentitySource);
|
||||
const selection = selectionFor(federatedProviderOf(selectedProvider), authMethod, identitySource);
|
||||
const storedOwner = storedChatGPTOwner(existingCredential);
|
||||
const [chatGPTOwnerKind, setChatGPTOwnerKind] = useState<ChatGPTOwnerKind>(storedOwner.kind);
|
||||
const [chatGPTSignIn, setChatGPTSignIn] = useState<ChatGPTSignIn | null>(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({
|
|||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
void (signsInWithChatGPT ? startChatGPTSignIn() : handleSubmit());
|
||||
}}
|
||||
>
|
||||
<MountedFormField
|
||||
|
|
@ -216,7 +247,7 @@ export default function CredentialModal({
|
|||
inputId={control.id}
|
||||
placeholder="Select a provider"
|
||||
options={providerOptions}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
value={typeof control.value === "string" ? providerEnumKeyOf(control.value) ?? control.value : null}
|
||||
disabled={providerLocked}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
|
|
@ -253,12 +284,21 @@ export default function CredentialModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ProviderSpecificFields
|
||||
selectedProvider={selectedProvider}
|
||||
hiddenFieldKeys={selection.authMethod === "federation" ? API_KEY_FIELDS : NO_HIDDEN_FIELDS}
|
||||
initialAuthTypeId={initialAuthTypeId}
|
||||
fieldValidators={providerFieldValidators(selection)}
|
||||
/>
|
||||
{signsInWithChatGPT ? (
|
||||
<ChatGPTOwnerFields
|
||||
ownerKind={chatGPTOwnerKind}
|
||||
onOwnerKindChange={setChatGPTOwnerKind}
|
||||
storedOwnerTeamId={storedOwner.teamId}
|
||||
storedOwnerKeyHash={storedOwner.keyHash}
|
||||
/>
|
||||
) : (
|
||||
<ProviderSpecificFields
|
||||
selectedProvider={selectedProvider}
|
||||
hiddenFieldKeys={selection.authMethod === "federation" ? API_KEY_FIELDS : NO_HIDDEN_FIELDS}
|
||||
initialAuthTypeId={initialAuthTypeId}
|
||||
fieldValidators={providerFieldValidators(selection)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection.authMethod === "federation" && (
|
||||
<FederationFields
|
||||
|
|
@ -281,12 +321,20 @@ export default function CredentialModal({
|
|||
<Button variant="outline" className="mr-2.5" onClick={closeAndReset}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
|
||||
<Button type="submit">{credentialSubmitLabel(signsInWithChatGPT, mode)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
{chatGPTSignIn && (
|
||||
<ChatGPTSignInModal
|
||||
credentialName={chatGPTSignIn.credentialName}
|
||||
request={chatGPTSignIn.request}
|
||||
onClose={() => setChatGPTSignIn(null)}
|
||||
onConnected={finishChatGPTSignIn}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</button>
|
||||
<button data-testid={`credential-modal-${mode}-chatgpt-signed-in`} onClick={() => onSignedIn?.()}>
|
||||
finish {mode} ChatGPT sign-in
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<CredentialModal
|
||||
mode="add"
|
||||
onSubmit={handleAddCredential}
|
||||
onSignedIn={handleChatGPTSignedIn}
|
||||
open={isAddModalOpen}
|
||||
onCancel={() => 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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<ChatGPTOwnerKind, string>> = {
|
||||
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<Record<string, unknown>>,
|
||||
): 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<SignInFlowState, { kind: "awaiting" }>,
|
||||
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<SignInFlowState, { kind: "failed" }>): 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.";
|
||||
}
|
||||
};
|
||||
|
|
@ -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<void> =>
|
||||
apiClient.delete<void>(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<ChatGPTLoginStartResponse> =>
|
||||
apiClient.post<ChatGPTLoginStartResponse>(`${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<ChatGPTLoginPollResponse> =>
|
||||
apiClient.post<ChatGPTLoginPollResponse>(`${chatGPTLoginPath(credentialName)}/poll`, {
|
||||
accessToken,
|
||||
body: { flow_handle: flowHandle },
|
||||
});
|
||||
|
||||
export const credentialUpdateCall = async (
|
||||
accessToken: string,
|
||||
credentialName: string,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -440,6 +440,15 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
|
|||
[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 };
|
||||
|
|
|
|||
133
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
133
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue