feat(provider): support Codex subscriptions with ChatGPT sign-in (#2513)

* feat(provider): support Codex subscriptions with ChatGPT sign-in

* style: format Codex live integration test

* fix(provider): preserve Codex identity in temporary model tests

* fix(web): portal provider selector without dialog overflow

* fix(web): allow native scrolling in provider dropdown

* fix(provider): surface safe Codex quota and upstream errors

* fix(web): provide reliable Codex copy feedback in dialogs

* feat(provider): confirm cascade deletion from edit dialog

* fix(persistence): discard connections after failed commit

* fix(web): polish provider loading and confirmation motion

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-09-06 23:31:02 +08:00
committed by GitHub
parent ec63978ecf
commit 0f216a0d4d
52 changed files with 5490 additions and 360 deletions
@@ -486,6 +486,7 @@ export default function ModelsPanel({
// Get the provider info
const provider = providers.find((p) => p.uuid === providerUuid);
const providerData = {
uuid: providerUuid,
requester: provider?.requester || '',
base_url: provider?.base_url || '',
api_keys: provider?.api_keys || [],
@@ -495,7 +496,7 @@ export default function ModelsPanel({
await httpClient.testLLMModel('_', {
uuid: '',
name,
provider_uuid: '',
provider_uuid: providerUuid,
provider: providerData,
abilities,
reasoning_config: reasoningConfig,
@@ -505,7 +506,7 @@ export default function ModelsPanel({
await httpClient.testEmbeddingModel('_', {
uuid: '',
name,
provider_uuid: '',
provider_uuid: providerUuid,
provider: providerData,
extra_args: extraArgsObj,
} as never);
@@ -513,7 +514,7 @@ export default function ModelsPanel({
await httpClient.testRerankModel('_', {
uuid: '',
name,
provider_uuid: '',
provider_uuid: providerUuid,
provider: providerData,
extra_args: extraArgsObj,
} as never);
@@ -536,6 +537,29 @@ export default function ModelsPanel({
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
}
async function handleProviderDeleted(providerUuid: string) {
setProviders((prev) =>
prev.filter((provider) => provider.uuid !== providerUuid),
);
setProviderModels((prev) => {
const next = { ...prev };
delete next[providerUuid];
return next;
});
setExpandedProviders((prev) => {
const next = new Set(prev);
next.delete(providerUuid);
return next;
});
await Promise.all([
loadProviders(),
...Array.from(expandedProviders)
.filter((uuid) => uuid !== providerUuid)
.map((uuid) => loadProviderModels(uuid)),
]);
setProviderFormOpen(false);
}
function renderProviderCard(
provider: ModelProvider,
isLangBotModels: boolean = false,
@@ -666,8 +690,14 @@ export default function ModelsPanel({
)}
</PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
<Dialog
open={providerFormOpen}
onOpenChange={(open) => {
if (!open) handleFormClose();
else setProviderFormOpen(true);
}}
>
<DialogContent className="w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto p-4 sm:max-w-[600px] sm:p-6">
<DialogHeader>
<DialogTitle>
{editingProviderId
@@ -675,11 +705,15 @@ export default function ModelsPanel({
: t('models.addProvider')}
</DialogTitle>
</DialogHeader>
<ProviderForm
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={() => setProviderFormOpen(false)}
/>
{providerFormOpen && (
<ProviderForm
key={editingProviderId || 'new'}
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={handleFormClose}
onProviderDeleted={canManage ? handleProviderDeleted : undefined}
/>
)}
</DialogContent>
</Dialog>
</>
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { copyToClipboard } from '@/app/utils/clipboard';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import type { useCodexLogin } from './useCodexLogin';
export default function CodexAccountSection({
login,
providerId,
}: {
login: ReturnType<typeof useCodexLogin>;
providerId?: string;
}) {
const { t } = useTranslation();
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const { phase, device } = login;
const copyGeneration = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const generation = copyGeneration;
setCopied(false);
setCopyFailed(false);
return () => {
generation.current++;
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, [providerId, device?.authorization_id, device?.user_code, phase]);
const handleCopy = async () => {
if (!device) return;
const generation = ++copyGeneration.current;
if (copyTimer.current) clearTimeout(copyTimer.current);
let ok = false;
try {
ok = await copyToClipboard(device.user_code);
} catch {
// Clipboard failures are recoverable; never log device codes.
}
if (generation !== copyGeneration.current) return;
setCopied(ok);
setCopyFailed(!ok);
if (ok) {
toast.success(t('common.copySuccess'));
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} else {
toast.error(t('common.copyFailed'));
}
};
const waiting = ['starting', 'loading', 'canceling'].includes(phase);
return (
<section
data-testid="codex-account"
aria-label={t('models.codex.account')}
className="min-w-0 rounded-lg border p-3 space-y-3 text-sm"
>
<div>
<h3 className="font-medium">{t('models.codex.account')}</h3>
<p className="mt-1 text-muted-foreground">
{t('models.codex.description')}
</p>
</div>
<p
role={phase === 'error' ? 'alert' : 'status'}
aria-live="polite"
className={
phase === 'error' ? 'text-destructive' : 'text-muted-foreground'
}
>
{t(`models.codex.${phase}`)}
</p>
{device && phase === 'pending' && (
<div className="space-y-3">
<p className="text-muted-foreground">
{t('models.codex.instructions')}
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="select-all break-all rounded border bg-muted px-3 py-2 text-base font-semibold tracking-wider">
{device.user_code}
</code>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{t(copied ? 'models.codex.copied' : 'models.codex.copyCode')}
</Button>
</div>
{copyFailed && (
<p role="status" className="text-muted-foreground">
{t('models.codex.copyManually')}
</p>
)}
<a
href={device.verification_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex text-sm font-medium underline underline-offset-4"
>
{t('models.codex.continueAtOpenAI')}
</a>
<p className="text-xs text-muted-foreground">
{t('models.codex.expiresAt', {
time: new Date(device.expires_at * 1000).toLocaleTimeString(),
})}
</p>
{login.retrying && (
<p role="status" className="text-xs text-muted-foreground">
{t('models.codex.retrying')}
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => providerId && void login.cancel(providerId)}
>
{t('models.codex.cancelSignIn')}
</Button>
</div>
)}
{providerId && !waiting && phase !== 'pending' && (
<div className="flex flex-wrap gap-2">
{phase !== 'connected' && (
<Button type="submit" size="sm" variant="outline">
{t(
phase === 'error' || phase === 'expired'
? 'models.codex.tryAgain'
: 'models.codex.signIn',
)}
</Button>
)}
{phase === 'connected' && (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setConfirmDisconnect(false);
void login.start(providerId);
}}
>
{t('models.codex.reconnect')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setConfirmDisconnect(true)}
>
{t('models.codex.disconnect')}
</Button>
</>
)}
</div>
)}
{confirmDisconnect && phase === 'connected' && (
<div className="space-y-2 border-t pt-3">
<p>{t('models.codex.disconnectConfirm')}</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
setConfirmDisconnect(false);
if (providerId) void login.disconnect(providerId);
}}
>
{t('models.codex.confirmDisconnect')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirmDisconnect(false)}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
</section>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useEffect, useState, useRef } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,12 +16,31 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { DialogFooter } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, Search } from 'lucide-react';
import { providerPayload } from './codexPolicy';
import { useCodexLogin } from './useCodexLogin';
import CodexAccountSection from './CodexAccountSection';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -35,12 +54,14 @@ interface ProviderFormProps {
providerId?: string;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
}
export default function ProviderForm({
providerId,
onFormSubmit,
onFormCancel,
onProviderDeleted,
}: ProviderFormProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
@@ -54,7 +75,31 @@ export default function ProviderForm({
api_key: '',
},
});
const { setValue } = form;
const { reset } = form;
const isCodex = form.watch('requester') === 'openai-codex';
const [savedProviderId, setSavedProviderId] = useState(providerId);
const savedId = useRef(providerId);
const submitting = useRef(false);
const deleting = useRef(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteError, setDeleteError] = useState('');
const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>(
'loading',
);
const [loadAttempt, setLoadAttempt] = useState(0);
const mounted = useRef(true);
const login = useCodexLogin(isCodex, providerId);
const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes(
login.phase,
);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [requesterList, setRequesterList] = useState<
{
@@ -68,72 +113,59 @@ export default function ProviderForm({
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const loadRequesters = useCallback(async () => {
const resp = await httpClient.getProviderRequesters();
setRequesterList(
resp.requesters
.filter((item) => item.name !== 'space-chat-completions')
.map((item) => ({
label: extractI18nObject(item.label),
value: item.name,
category: item.spec.provider_category || 'manufacturer',
defaultUrl:
item.spec.config
.find((c) => c.name === 'base_url')
?.default?.toString() || '',
description: extractI18nObject(item.description),
alias: item.spec.alias || '',
})),
);
}, []);
const loadProvider = useCallback(
async (id: string) => {
const resp = await httpClient.getModelProvider(id);
const provider = resp.provider;
setValue('name', provider.name);
setValue('requester', provider.requester);
setValue('base_url', provider.base_url);
setValue('api_key', provider.api_keys?.[0] || '');
},
[setValue],
);
useEffect(() => {
// Ignore both success and failure from a closed form or superseded attempt.
let canceled = false;
setLoadState('loading');
setMutableProviderLoaded(false);
async function init() {
await loadRequesters();
if (providerId) {
await loadProvider(providerId);
try {
const [requesters, detail] = await Promise.all([
httpClient.getProviderRequesters(),
providerId ? httpClient.getModelProvider(providerId) : null,
]);
if (canceled) return;
setRequesterList(
requesters.requesters
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
.map((item) => ({
label: extractI18nObject(item.label),
value: item.name,
category: item.spec.provider_category || 'manufacturer',
defaultUrl:
item.spec.config
.find((c) => c.name === 'base_url')
?.default?.toString() || '',
description: extractI18nObject(item.description),
alias: item.spec.alias || '',
})),
);
if (detail) {
const provider = detail.provider;
reset({
name: provider.name,
requester: provider.requester,
base_url: provider.base_url,
api_key: provider.api_keys?.[0] || '',
});
setMutableProviderLoaded(
provider.uuid === providerId &&
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
}
setLoadState('ready');
} catch {
if (!canceled) setLoadState('error');
}
}
init();
}, [providerId, loadProvider, loadRequesters]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
void init();
return () => {
canceled = true;
};
}, [providerId, reset, loadAttempt]);
// Filter requesters based on search query
const filteredRequesters = requesterList.filter(
@@ -163,29 +195,105 @@ export default function ProviderForm({
};
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
const data = {
name: values.name,
requester: values.requester,
base_url: values.base_url,
api_keys: values.api_key ? [values.api_key] : [],
};
if (
loadState !== 'ready' ||
submitting.current ||
deleting.current ||
(isCodex && loginActive)
)
return;
submitting.current = true;
const data = providerPayload(values);
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
if (savedId.current) {
await httpClient.updateModelProvider(savedId.current, data);
} else {
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
savedId.current = response.uuid;
if (mounted.current) setSavedProviderId(response.uuid);
}
if (!mounted.current) return;
if (isCodex && login.phase !== 'connected') {
await login.start(savedId.current);
} else {
toast.success(t('models.providerSaved'));
await onFormSubmit(savedId.current);
}
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
if (mounted.current)
toast.error(
t('models.providerSaveError') + (err as CustomApiError).msg,
);
} finally {
submitting.current = false;
}
}
async function handleDelete() {
if (
loadState !== 'ready' ||
!providerId ||
!mutableProviderLoaded ||
!onProviderDeleted ||
deleting.current ||
submitting.current ||
(isCodex && loginActive)
)
return;
deleting.current = true;
setIsDeleting(true);
setDeleteError('');
try {
await httpClient.deleteModelProvider(providerId, true);
} catch (err) {
const detail =
(err as CustomApiError | null)?.msg ||
(err instanceof Error ? err.message : '');
setDeleteError(t('models.providerDeleteError') + detail);
deleting.current = false;
setIsDeleting(false);
return;
}
toast.success(t('models.providerDeleted'));
await onProviderDeleted(providerId);
}
if (loadState !== 'ready') {
return (
<>
{loadState === 'loading' ? (
<div
role="status"
aria-label={t('common.loading')}
className="flex justify-center py-8"
>
<LoadingSpinner text={t('common.loading')} />
</div>
) : (
<p role="alert" className="py-8 text-sm text-destructive">
{t('models.loadError')}
</p>
)}
<DialogFooter>
{loadState === 'error' && (
<Button
type="button"
onClick={() => {
setLoadState('loading');
setLoadAttempt((attempt) => attempt + 1);
}}
>
{t('common.retry')}
</Button>
)}
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
</DialogFooter>
</>
);
}
return (
<Form {...form}>
<form
@@ -202,7 +310,12 @@ export default function ProviderForm({
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
<Input
{...field}
disabled={
form.formState.isSubmitting || (isCodex && loginActive)
}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -222,45 +335,65 @@ export default function ProviderForm({
{t('models.requester')}
<span className="text-red-500">*</span>
</FormLabel>
<div ref={dropdownRef} className="relative">
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setSearchQuery('');
}}
>
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'ring-2 ring-ring ring-offset-2',
)}
>
{selectedRequester ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
selectedRequester.value,
)}
alt={selectedRequester.label}
className="h-5 w-5 rounded"
/>
<span>{selectedRequester.label}</span>
</div>
) : (
<span className="text-muted-foreground">
{t('models.selectRequester')}
</span>
)}
<ChevronDown
<PopoverTrigger asChild>
<button
type="button"
disabled={
form.formState.isSubmitting ||
(isCodex && (!!savedProviderId || loginActive))
}
aria-expanded={isOpen}
className={cn(
'h-4 w-4 opacity-50 transition-transform',
isOpen && 'rotate-180',
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'ring-2 ring-ring ring-offset-2',
)}
/>
</button>
>
{selectedRequester ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
selectedRequester.value,
)}
alt={selectedRequester.label}
className="h-5 w-5 rounded"
/>
<span>{selectedRequester.label}</span>
</div>
) : (
<span className="text-muted-foreground">
{t('models.selectRequester')}
</span>
)}
<ChevronDown
className={cn(
'h-4 w-4 opacity-50 transition-transform',
isOpen && 'rotate-180',
)}
/>
</button>
</PopoverTrigger>
{/* Dropdown */}
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
<PopoverContent
align="start"
collisionPadding={8}
className="flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-16px)] flex-col overflow-hidden p-0"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchInputRef.current?.focus();
}}
>
{/* Search input */}
<div className="flex items-center border-b px-3">
<div className="flex shrink-0 items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
@@ -275,7 +408,13 @@ export default function ProviderForm({
</div>
{/* Options list */}
<div className="max-h-[300px] overflow-y-auto p-1">
<div
className="min-h-0 max-h-[300px] overflow-y-auto overscroll-contain p-1"
// The dialog's document-level scroll lock treats this portal as outside.
// Keep native list scrolling without forwarding gestures to that lock.
onWheel={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
>
{Object.entries(groupedRequesters).map(
([category, items]) => {
if (items.length === 0) return null;
@@ -288,6 +427,11 @@ export default function ProviderForm({
<button
key={r.value}
type="button"
disabled={
!!providerId &&
r.value === 'openai-codex' &&
!isCodex
}
onClick={() => {
field.onChange(r.value);
const req = requesterList.find(
@@ -337,9 +481,9 @@ export default function ProviderForm({
</div>
)}
</div>
</div>
</PopoverContent>
)}
</div>
</Popover>
<FormMessage />
{selectedRequester?.description && (
<p className="text-sm text-muted-foreground">
@@ -351,40 +495,131 @@ export default function ProviderForm({
}}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isCodex ? (
<CodexAccountSection login={login} providerId={savedProviderId} />
) : (
<>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={
form.formState.isSubmitting || (isCodex && loginActive)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter>
<Button type="submit">{t('common.save')}</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
{providerId && mutableProviderLoaded && onProviderDeleted && (
<Button
type="button"
variant="destructive"
disabled={
isDeleting ||
form.formState.isSubmitting ||
(isCodex && loginActive)
}
onClick={() => {
setDeleteError('');
setDeleteConfirmOpen(true);
}}
>
{t('common.delete')}
</Button>
)}
<div className="ml-auto flex flex-col gap-2 sm:flex-row">
{(!isCodex || !savedProviderId || login.phase === 'connected') && (
<Button
type="submit"
disabled={
isDeleting ||
form.formState.isSubmitting ||
(isCodex && loginActive)
}
>
{isCodex
? t(
login.phase === 'connected'
? 'models.codex.done'
: 'models.codex.saveAndSignIn',
)
: t('common.save')}
</Button>
)}
<Button
type="button"
variant="outline"
disabled={isDeleting}
onClick={onFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!deleting.current) setDeleteConfirmOpen(open);
}}
>
{deleteConfirmOpen && (
<AlertDialogContent className="max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>{t('common.delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('models.deleteProviderCascadeConfirmation')}
</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && (
<p
role="alert"
className="text-sm text-destructive break-words"
>
{deleteError}
</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('common.cancel')}
</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={isDeleting}
aria-busy={isDeleting}
onClick={handleDelete}
>
{t('common.delete')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialog>
</form>
</Form>
);
@@ -0,0 +1,26 @@
/** Subscription credentials are server-owned, never API-key form values. */
export function providerPayload(values: {
name: string;
requester: string;
base_url: string;
api_key?: string;
}) {
const subscription = values.requester === 'openai-codex';
return {
name: values.name,
requester: values.requester,
base_url: subscription
? 'https://chatgpt.com/backend-api/codex'
: values.base_url,
api_keys: subscription ? [] : values.api_key ? [values.api_key] : [],
};
}
export function pollDelay(interval: number, failures = 0): number {
const seconds = Number.isFinite(interval) && interval > 0 ? interval : 5;
return Math.max(seconds, Math.min(60, seconds * 2 ** failures)) * 1000;
}
export function isCodexVerificationUri(uri: string): boolean {
return uri === 'https://auth.openai.com/codex/device';
}
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { CodexDeviceAuthorization } from '@/app/infra/entities/codex';
import { isCodexVerificationUri, pollDelay } from './codexPolicy';
type Phase =
| 'disconnected'
| 'loading'
| 'starting'
| 'pending'
| 'connected'
| 'expired'
| 'error'
| 'canceling';
/** One in-memory authorization, sequential polls, and stale-response fencing. */
export function useCodexLogin(enabled: boolean, providerId?: string) {
const [phase, setPhase] = useState<Phase>('disconnected');
const [device, setDevice] = useState<CodexDeviceAuthorization | null>(null);
const [retrying, setRetrying] = useState(false);
const generation = useRef(0);
const busy = useRef(false);
const attempt = useRef<{ uuid: string; authorizationId: string } | null>(
null,
);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const deadline = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const request = useRef<AbortController | null>(null);
const stop = useCallback(() => {
generation.current++;
clearTimeout(timer.current);
clearTimeout(deadline.current);
request.current?.abort();
busy.current = false;
const pending = attempt.current;
attempt.current = null;
return pending;
}, []);
const clearPending = useCallback(async () => {
const pending = stop();
if (pending)
await httpClient.cancelCodexDeviceLogin(
pending.uuid,
pending.authorizationId,
);
}, [stop]);
const loadStatus = useCallback(async (uuid: string) => {
const version = generation.current;
request.current = new AbortController();
setPhase('loading');
try {
const status = await httpClient.getCodexAuthStatus(
uuid,
request.current.signal,
);
if (version === generation.current) setPhase(status.status);
} catch {
if (version === generation.current) setPhase('error');
}
}, []);
useEffect(() => {
setDevice(null);
setPhase('disconnected');
if (enabled && providerId) void loadStatus(providerId);
return () => {
// Device creation is deliberately not aborted: its late response must be
// canceled server-side even if this form has already unmounted.
void clearPending().catch(() => {});
};
}, [enabled, providerId, loadStatus, clearPending]);
async function start(uuid: string) {
if (busy.current) return;
const old = stop();
busy.current = true;
const version = generation.current;
setPhase('starting');
setDevice(null);
setRetrying(false);
try {
if (old)
await httpClient.cancelCodexDeviceLogin(old.uuid, old.authorizationId);
if (version !== generation.current) return;
const authorization = await httpClient.startCodexDeviceLogin(uuid);
if (version !== generation.current) {
await httpClient.cancelCodexDeviceLogin(
uuid,
authorization.authorization_id,
);
return;
}
attempt.current = {
uuid,
authorizationId: authorization.authorization_id,
};
if (
!isCodexVerificationUri(authorization.verification_uri) ||
!Number.isFinite(authorization.expires_at)
) {
await clearPending();
setPhase('error');
return;
}
setDevice(authorization);
setPhase('pending');
let interval = authorization.interval;
let failures = 0;
request.current = new AbortController();
const signal = request.current.signal;
const expire = () => {
if (version !== generation.current) return;
void clearPending().catch(() => {});
setDevice(null);
setPhase('expired');
};
deadline.current = setTimeout(
expire,
Math.max(0, authorization.expires_at * 1000 - Date.now()),
);
const poll = async () => {
if (version !== generation.current) return;
if (Date.now() >= authorization.expires_at * 1000) {
expire();
return;
}
try {
const result = await httpClient.pollCodexDeviceLogin(
uuid,
authorization.authorization_id,
signal,
);
if (version !== generation.current) return;
if (result.status !== 'pending') {
attempt.current = null;
stop();
setDevice(null);
setPhase(result.status);
return;
}
interval = result.interval ?? interval;
failures = 0;
setRetrying(false);
} catch (error) {
if (version !== generation.current) return;
const code = (error as { code?: number }).code;
if (
(code === -1 || (code !== undefined && code >= 500)) &&
failures < 3
) {
failures++;
setRetrying(true);
} else {
void clearPending().catch(() => {});
setDevice(null);
setPhase('error');
return;
}
}
timer.current = setTimeout(poll, pollDelay(interval, failures));
};
timer.current = setTimeout(poll, pollDelay(interval));
} catch {
if (version === generation.current) {
busy.current = false;
setPhase('error');
}
}
}
async function cancel(uuid: string) {
setPhase('canceling');
setDevice(null);
const pending = clearPending();
const version = generation.current;
try {
await pending;
if (version === generation.current) await loadStatus(uuid);
} catch {
if (version === generation.current) setPhase('error');
}
}
async function disconnect(uuid: string) {
if (busy.current) return;
busy.current = true;
setPhase('loading');
const version = generation.current;
try {
await httpClient.disconnectCodex(uuid);
if (version === generation.current) await loadStatus(uuid);
} catch {
if (version === generation.current) setPhase('error');
} finally {
busy.current = false;
}
}
return { phase, device, retrying, start, cancel, disconnect, loadStatus };
}