Skip to content
39 changes: 39 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ export interface CronExecutionsParams {
export interface ProviderStatus {
anthropic: boolean;
openai: boolean;
openai_chatgpt: boolean;
openrouter: boolean;
zhipu: boolean;
groq: boolean;
Expand All @@ -669,10 +670,12 @@ export interface ProviderStatus {
deepseek: boolean;
xai: boolean;
mistral: boolean;
gemini: boolean;
ollama: boolean;
opencode_zen: boolean;
nvidia: boolean;
minimax: boolean;
minimax_cn: boolean;
moonshot: boolean;
zai_coding_plan: boolean;
}
Expand All @@ -695,6 +698,20 @@ export interface ProviderModelTestResponse {
sample: string | null;
}

export interface OpenAiOAuthBrowserStartResponse {
success: boolean;
message: string;
authorization_url: string | null;
state: string | null;
}

export interface OpenAiOAuthBrowserStatusResponse {
found: boolean;
done: boolean;
success: boolean;
message: string | null;
}

// -- Model Types --

export interface ModelInfo {
Expand Down Expand Up @@ -1153,6 +1170,28 @@ export const api = {
}
return response.json() as Promise<ProviderModelTestResponse>;
},
startOpenAiOAuthBrowser: async (params: {model: string}) => {
const response = await fetch(`${API_BASE}/providers/openai/oauth/browser/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: params.model,
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<OpenAiOAuthBrowserStartResponse>;
},
openAiOAuthBrowserStatus: async (state: string) => {
const response = await fetch(
`${API_BASE}/providers/openai/oauth/browser/status?state=${encodeURIComponent(state)}`,
);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<OpenAiOAuthBrowserStatusResponse>;
},
removeProvider: async (provider: string) => {
const response = await fetch(`${API_BASE}/providers/${encodeURIComponent(provider)}`, {
method: "DELETE",
Expand Down
2 changes: 2 additions & 0 deletions interface/src/components/ModelSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PROVIDER_LABELS: Record<string, string> = {
anthropic: "Anthropic",
openrouter: "OpenRouter",
openai: "OpenAI",
"openai-chatgpt": "ChatGPT Plus (OAuth)",
deepseek: "DeepSeek",
xai: "xAI",
mistral: "Mistral",
Expand Down Expand Up @@ -129,6 +130,7 @@ export function ModelSelect({
"openrouter",
"anthropic",
"openai",
"openai-chatgpt",
"ollama",
"deepseek",
"xai",
Expand Down
1 change: 1 addition & 0 deletions interface/src/lib/providerIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24
const iconMap: Record<string, React.ComponentType<IconProps>> = {
anthropic: Anthropic,
openai: OpenAI,
"openai-chatgpt": OpenAI,
openrouter: OpenRouter,
groq: Groq,
mistral: Mistral,
Expand Down
189 changes: 169 additions & 20 deletions interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ const PROVIDERS = [
},
] as const;

const CHATGPT_OAUTH_DEFAULT_MODEL = "openai-chatgpt/gpt-5.3-codex";

export function Settings() {
const queryClient = useQueryClient();
const navigate = useNavigate();
Expand All @@ -236,6 +238,11 @@ export function Settings() {
message: string;
sample?: string | null;
} | null>(null);
const [isPollingOpenAiBrowserOAuth, setIsPollingOpenAiBrowserOAuth] = useState(false);
const [openAiBrowserOAuthMessage, setOpenAiBrowserOAuthMessage] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
const [message, setMessage] = useState<{
text: string;
type: "success" | "error";
Expand Down Expand Up @@ -287,6 +294,9 @@ export function Settings() {
mutationFn: ({ provider, apiKey, model }: { provider: string; apiKey: string; model: string }) =>
api.testProviderModel(provider, apiKey, model),
});
const startOpenAiBrowserOAuthMutation = useMutation({
mutationFn: (params: { model: string }) => api.startOpenAiOAuthBrowser(params),
});

const removeMutation = useMutation({
mutationFn: (provider: string) => api.removeProvider(provider),
Expand Down Expand Up @@ -347,6 +357,79 @@ export function Settings() {
});
};

const monitorOpenAiBrowserOAuth = async (stateToken: string, popup: Window | null) => {
setIsPollingOpenAiBrowserOAuth(true);
setOpenAiBrowserOAuthMessage(null);
try {
for (let attempt = 0; attempt < 180; attempt += 1) {
const status = await api.openAiOAuthBrowserStatus(stateToken);
if (status.done) {
if (status.success) {
setOpenAiBrowserOAuthMessage({
text: status.message || "ChatGPT OAuth configured.",
type: "success",
});
queryClient.invalidateQueries({queryKey: ["providers"]});
setTimeout(() => {
queryClient.invalidateQueries({queryKey: ["agents"]});
queryClient.invalidateQueries({queryKey: ["overview"]});
}, 3000);
} else {
setOpenAiBrowserOAuthMessage({
text: status.message || "Browser sign-in failed.",
type: "error",
});
}
return;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
setOpenAiBrowserOAuthMessage({
text: "Browser sign-in timed out. Please try again.",
type: "error",
});
} catch (error: any) {
setOpenAiBrowserOAuthMessage({
text: `Failed to verify browser sign-in: ${error.message}`,
type: "error",
});
} finally {
setIsPollingOpenAiBrowserOAuth(false);
if (popup && !popup.closed) {
popup.close();
}
}
};

const handleStartChatGptOAuth = async () => {
setOpenAiBrowserOAuthMessage(null);
try {
const result = await startOpenAiBrowserOAuthMutation.mutateAsync({
model: CHATGPT_OAUTH_DEFAULT_MODEL,
});
if (!result.success || !result.authorization_url || !result.state) {
setOpenAiBrowserOAuthMessage({
text: result.message || "Failed to start browser sign-in",
type: "error",
});
return;
}

const popup = window.open(
result.authorization_url,
"spacebot-openai-oauth",
"popup=true,width=560,height=780,noopener,noreferrer",
);
setOpenAiBrowserOAuthMessage({
text: "Complete sign-in in the browser window. Waiting for callback...",
type: "success",
});
void monitorOpenAiBrowserOAuth(result.state, popup);
} catch (error: any) {
setOpenAiBrowserOAuthMessage({text: `Failed: ${error.message}`, type: "error"});
}
};

const handleClose = () => {
setEditingProvider(null);
setKeyInput("");
Expand Down Expand Up @@ -419,24 +502,36 @@ export function Settings() {
) : (
<div className="flex flex-col gap-3">
{PROVIDERS.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider.id}
name={provider.name}
description={provider.description}
configured={isConfigured(provider.id)}
defaultModel={provider.defaultModel}
onEdit={() => {
setEditingProvider(provider.id);
setKeyInput("");
setModelInput(provider.defaultModel ?? "");
setTestedSignature(null);
setTestResult(null);
setMessage(null);
}}
onRemove={() => removeMutation.mutate(provider.id)}
removing={removeMutation.isPending}
/>
[
<ProviderCard
key={provider.id}
provider={provider.id}
name={provider.name}
description={provider.description}
configured={isConfigured(provider.id)}
defaultModel={provider.defaultModel}
onEdit={() => {
setEditingProvider(provider.id);
setKeyInput("");
setModelInput(provider.defaultModel ?? "");
setTestedSignature(null);
setTestResult(null);
setMessage(null);
}}
onRemove={() => removeMutation.mutate(provider.id)}
removing={removeMutation.isPending}
/>,
provider.id === "openai" ? (
<ChatGptOAuthCard
key="openai-chatgpt"
configured={isConfigured("openai-chatgpt")}
defaultModel={CHATGPT_OAUTH_DEFAULT_MODEL}
isPolling={isPollingOpenAiBrowserOAuth}
message={openAiBrowserOAuthMessage}
onSignIn={handleStartChatGptOAuth}
/>
) : null,
]
))}
</div>
)}
Expand Down Expand Up @@ -483,6 +578,8 @@ export function Settings() {
<DialogDescription>
{editingProvider === "ollama"
? `Enter your ${editingProviderData?.name} base URL. It will be saved to your instance config.`
: editingProvider === "openai"
? "Enter an OpenAI API key. The model below will be applied to routing."
: `Enter your ${editingProviderData?.name} API key. It will be saved to your instance config.`}
</DialogDescription>
</DialogHeader>
Expand Down Expand Up @@ -1470,8 +1567,9 @@ function ProviderCard({ provider, name, description, configured, defaultModel, o
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink">{name}</span>
{configured && (
<span className="text-tiny text-green-400">
● Configured
<span className="inline-flex items-center">
<span className="h-2 w-2 rounded-full bg-green-400" aria-hidden="true" />
<span className="sr-only">Configured</span>
</span>
)}
</div>
Expand All @@ -1494,3 +1592,54 @@ function ProviderCard({ provider, name, description, configured, defaultModel, o
</div>
);
}

interface ChatGptOAuthCardProps {
configured: boolean;
defaultModel: string;
isPolling: boolean;
message: { text: string; type: "success" | "error" } | null;
onSignIn: () => void;
}

function ChatGptOAuthCard({ configured, defaultModel, isPolling, message, onSignIn }: ChatGptOAuthCardProps) {
return (
<div className="rounded-lg border border-app-line bg-app-box p-4">
<div className="flex items-center gap-3">
<ProviderIcon provider="openai-chatgpt" size={32} />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink">ChatGPT Plus (OAuth)</span>
{configured && (
<span className="inline-flex items-center">
<span className="h-2 w-2 rounded-full bg-green-400" aria-hidden="true" />
<span className="sr-only">Configured</span>
</span>
)}
</div>
<p className="mt-0.5 text-sm text-ink-dull">
Sign in with your ChatGPT Plus account in the browser.
</p>
<p className="mt-1 text-tiny text-ink-faint">
Default model: <span className="text-ink-dull">{defaultModel}</span>
</p>
{message && (
<p className={`mt-1 text-tiny ${message.type === "success" ? "text-green-400" : "text-red-400"}`}>
{message.text}
</p>
)}
</div>
<div className="flex gap-2">
<Button
onClick={onSignIn}
disabled={isPolling}
loading={isPolling}
variant="outline"
size="sm"
>
Sign in with ChatGPT Plus
</Button>
</div>
</div>
</div>
);
}
Loading