Skip to content

Commit bd04ea8

Browse files
authored
Merge pull request #157 from marijnvdwerf/codex/opencode-research
ChatGPT OAuth browser flow + provider split
2 parents d04d882 + 568f6b2 commit bd04ea8

File tree

14 files changed

+1348
-130
lines changed

14 files changed

+1348
-130
lines changed

interface/src/api/client.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@ export interface CronExecutionsParams {
661661
export interface ProviderStatus {
662662
anthropic: boolean;
663663
openai: boolean;
664+
openai_chatgpt: boolean;
664665
openrouter: boolean;
665666
zhipu: boolean;
666667
groq: boolean;
@@ -669,10 +670,12 @@ export interface ProviderStatus {
669670
deepseek: boolean;
670671
xai: boolean;
671672
mistral: boolean;
673+
gemini: boolean;
672674
ollama: boolean;
673675
opencode_zen: boolean;
674676
nvidia: boolean;
675677
minimax: boolean;
678+
minimax_cn: boolean;
676679
moonshot: boolean;
677680
zai_coding_plan: boolean;
678681
}
@@ -695,6 +698,20 @@ export interface ProviderModelTestResponse {
695698
sample: string | null;
696699
}
697700

701+
export interface OpenAiOAuthBrowserStartResponse {
702+
success: boolean;
703+
message: string;
704+
authorization_url: string | null;
705+
state: string | null;
706+
}
707+
708+
export interface OpenAiOAuthBrowserStatusResponse {
709+
found: boolean;
710+
done: boolean;
711+
success: boolean;
712+
message: string | null;
713+
}
714+
698715
// -- Model Types --
699716

700717
export interface ModelInfo {
@@ -1153,6 +1170,28 @@ export const api = {
11531170
}
11541171
return response.json() as Promise<ProviderModelTestResponse>;
11551172
},
1173+
startOpenAiOAuthBrowser: async (params: {model: string}) => {
1174+
const response = await fetch(`${API_BASE}/providers/openai/oauth/browser/start`, {
1175+
method: "POST",
1176+
headers: { "Content-Type": "application/json" },
1177+
body: JSON.stringify({
1178+
model: params.model,
1179+
}),
1180+
});
1181+
if (!response.ok) {
1182+
throw new Error(`API error: ${response.status}`);
1183+
}
1184+
return response.json() as Promise<OpenAiOAuthBrowserStartResponse>;
1185+
},
1186+
openAiOAuthBrowserStatus: async (state: string) => {
1187+
const response = await fetch(
1188+
`${API_BASE}/providers/openai/oauth/browser/status?state=${encodeURIComponent(state)}`,
1189+
);
1190+
if (!response.ok) {
1191+
throw new Error(`API error: ${response.status}`);
1192+
}
1193+
return response.json() as Promise<OpenAiOAuthBrowserStatusResponse>;
1194+
},
11561195
removeProvider: async (provider: string) => {
11571196
const response = await fetch(`${API_BASE}/providers/${encodeURIComponent(provider)}`, {
11581197
method: "DELETE",

interface/src/components/ModelSelect.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const PROVIDER_LABELS: Record<string, string> = {
1616
anthropic: "Anthropic",
1717
openrouter: "OpenRouter",
1818
openai: "OpenAI",
19+
"openai-chatgpt": "ChatGPT Plus (OAuth)",
1920
deepseek: "DeepSeek",
2021
xai: "xAI",
2122
mistral: "Mistral",
@@ -129,6 +130,7 @@ export function ModelSelect({
129130
"openrouter",
130131
"anthropic",
131132
"openai",
133+
"openai-chatgpt",
132134
"ollama",
133135
"deepseek",
134136
"xai",

interface/src/lib/providerIcons.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24
9999
const iconMap: Record<string, React.ComponentType<IconProps>> = {
100100
anthropic: Anthropic,
101101
openai: OpenAI,
102+
"openai-chatgpt": OpenAI,
102103
openrouter: OpenRouter,
103104
groq: Groq,
104105
mistral: Mistral,

interface/src/routes/Settings.tsx

Lines changed: 169 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ const PROVIDERS = [
210210
},
211211
] as const;
212212

213+
const CHATGPT_OAUTH_DEFAULT_MODEL = "openai-chatgpt/gpt-5.3-codex";
214+
213215
export function Settings() {
214216
const queryClient = useQueryClient();
215217
const navigate = useNavigate();
@@ -236,6 +238,11 @@ export function Settings() {
236238
message: string;
237239
sample?: string | null;
238240
} | null>(null);
241+
const [isPollingOpenAiBrowserOAuth, setIsPollingOpenAiBrowserOAuth] = useState(false);
242+
const [openAiBrowserOAuthMessage, setOpenAiBrowserOAuthMessage] = useState<{
243+
text: string;
244+
type: "success" | "error";
245+
} | null>(null);
239246
const [message, setMessage] = useState<{
240247
text: string;
241248
type: "success" | "error";
@@ -287,6 +294,9 @@ export function Settings() {
287294
mutationFn: ({ provider, apiKey, model }: { provider: string; apiKey: string; model: string }) =>
288295
api.testProviderModel(provider, apiKey, model),
289296
});
297+
const startOpenAiBrowserOAuthMutation = useMutation({
298+
mutationFn: (params: { model: string }) => api.startOpenAiOAuthBrowser(params),
299+
});
290300

291301
const removeMutation = useMutation({
292302
mutationFn: (provider: string) => api.removeProvider(provider),
@@ -347,6 +357,79 @@ export function Settings() {
347357
});
348358
};
349359

360+
const monitorOpenAiBrowserOAuth = async (stateToken: string, popup: Window | null) => {
361+
setIsPollingOpenAiBrowserOAuth(true);
362+
setOpenAiBrowserOAuthMessage(null);
363+
try {
364+
for (let attempt = 0; attempt < 180; attempt += 1) {
365+
const status = await api.openAiOAuthBrowserStatus(stateToken);
366+
if (status.done) {
367+
if (status.success) {
368+
setOpenAiBrowserOAuthMessage({
369+
text: status.message || "ChatGPT OAuth configured.",
370+
type: "success",
371+
});
372+
queryClient.invalidateQueries({queryKey: ["providers"]});
373+
setTimeout(() => {
374+
queryClient.invalidateQueries({queryKey: ["agents"]});
375+
queryClient.invalidateQueries({queryKey: ["overview"]});
376+
}, 3000);
377+
} else {
378+
setOpenAiBrowserOAuthMessage({
379+
text: status.message || "Browser sign-in failed.",
380+
type: "error",
381+
});
382+
}
383+
return;
384+
}
385+
await new Promise((resolve) => setTimeout(resolve, 2000));
386+
}
387+
setOpenAiBrowserOAuthMessage({
388+
text: "Browser sign-in timed out. Please try again.",
389+
type: "error",
390+
});
391+
} catch (error: any) {
392+
setOpenAiBrowserOAuthMessage({
393+
text: `Failed to verify browser sign-in: ${error.message}`,
394+
type: "error",
395+
});
396+
} finally {
397+
setIsPollingOpenAiBrowserOAuth(false);
398+
if (popup && !popup.closed) {
399+
popup.close();
400+
}
401+
}
402+
};
403+
404+
const handleStartChatGptOAuth = async () => {
405+
setOpenAiBrowserOAuthMessage(null);
406+
try {
407+
const result = await startOpenAiBrowserOAuthMutation.mutateAsync({
408+
model: CHATGPT_OAUTH_DEFAULT_MODEL,
409+
});
410+
if (!result.success || !result.authorization_url || !result.state) {
411+
setOpenAiBrowserOAuthMessage({
412+
text: result.message || "Failed to start browser sign-in",
413+
type: "error",
414+
});
415+
return;
416+
}
417+
418+
const popup = window.open(
419+
result.authorization_url,
420+
"spacebot-openai-oauth",
421+
"popup=true,width=560,height=780,noopener,noreferrer",
422+
);
423+
setOpenAiBrowserOAuthMessage({
424+
text: "Complete sign-in in the browser window. Waiting for callback...",
425+
type: "success",
426+
});
427+
void monitorOpenAiBrowserOAuth(result.state, popup);
428+
} catch (error: any) {
429+
setOpenAiBrowserOAuthMessage({text: `Failed: ${error.message}`, type: "error"});
430+
}
431+
};
432+
350433
const handleClose = () => {
351434
setEditingProvider(null);
352435
setKeyInput("");
@@ -419,24 +502,36 @@ export function Settings() {
419502
) : (
420503
<div className="flex flex-col gap-3">
421504
{PROVIDERS.map((provider) => (
422-
<ProviderCard
423-
key={provider.id}
424-
provider={provider.id}
425-
name={provider.name}
426-
description={provider.description}
427-
configured={isConfigured(provider.id)}
428-
defaultModel={provider.defaultModel}
429-
onEdit={() => {
430-
setEditingProvider(provider.id);
431-
setKeyInput("");
432-
setModelInput(provider.defaultModel ?? "");
433-
setTestedSignature(null);
434-
setTestResult(null);
435-
setMessage(null);
436-
}}
437-
onRemove={() => removeMutation.mutate(provider.id)}
438-
removing={removeMutation.isPending}
439-
/>
505+
[
506+
<ProviderCard
507+
key={provider.id}
508+
provider={provider.id}
509+
name={provider.name}
510+
description={provider.description}
511+
configured={isConfigured(provider.id)}
512+
defaultModel={provider.defaultModel}
513+
onEdit={() => {
514+
setEditingProvider(provider.id);
515+
setKeyInput("");
516+
setModelInput(provider.defaultModel ?? "");
517+
setTestedSignature(null);
518+
setTestResult(null);
519+
setMessage(null);
520+
}}
521+
onRemove={() => removeMutation.mutate(provider.id)}
522+
removing={removeMutation.isPending}
523+
/>,
524+
provider.id === "openai" ? (
525+
<ChatGptOAuthCard
526+
key="openai-chatgpt"
527+
configured={isConfigured("openai-chatgpt")}
528+
defaultModel={CHATGPT_OAUTH_DEFAULT_MODEL}
529+
isPolling={isPollingOpenAiBrowserOAuth}
530+
message={openAiBrowserOAuthMessage}
531+
onSignIn={handleStartChatGptOAuth}
532+
/>
533+
) : null,
534+
]
440535
))}
441536
</div>
442537
)}
@@ -483,6 +578,8 @@ export function Settings() {
483578
<DialogDescription>
484579
{editingProvider === "ollama"
485580
? `Enter your ${editingProviderData?.name} base URL. It will be saved to your instance config.`
581+
: editingProvider === "openai"
582+
? "Enter an OpenAI API key. The model below will be applied to routing."
486583
: `Enter your ${editingProviderData?.name} API key. It will be saved to your instance config.`}
487584
</DialogDescription>
488585
</DialogHeader>
@@ -1470,8 +1567,9 @@ function ProviderCard({ provider, name, description, configured, defaultModel, o
14701567
<div className="flex items-center gap-2">
14711568
<span className="text-sm font-medium text-ink">{name}</span>
14721569
{configured && (
1473-
<span className="text-tiny text-green-400">
1474-
● Configured
1570+
<span className="inline-flex items-center">
1571+
<span className="h-2 w-2 rounded-full bg-green-400" aria-hidden="true" />
1572+
<span className="sr-only">Configured</span>
14751573
</span>
14761574
)}
14771575
</div>
@@ -1494,3 +1592,54 @@ function ProviderCard({ provider, name, description, configured, defaultModel, o
14941592
</div>
14951593
);
14961594
}
1595+
1596+
interface ChatGptOAuthCardProps {
1597+
configured: boolean;
1598+
defaultModel: string;
1599+
isPolling: boolean;
1600+
message: { text: string; type: "success" | "error" } | null;
1601+
onSignIn: () => void;
1602+
}
1603+
1604+
function ChatGptOAuthCard({ configured, defaultModel, isPolling, message, onSignIn }: ChatGptOAuthCardProps) {
1605+
return (
1606+
<div className="rounded-lg border border-app-line bg-app-box p-4">
1607+
<div className="flex items-center gap-3">
1608+
<ProviderIcon provider="openai-chatgpt" size={32} />
1609+
<div className="flex-1">
1610+
<div className="flex items-center gap-2">
1611+
<span className="text-sm font-medium text-ink">ChatGPT Plus (OAuth)</span>
1612+
{configured && (
1613+
<span className="inline-flex items-center">
1614+
<span className="h-2 w-2 rounded-full bg-green-400" aria-hidden="true" />
1615+
<span className="sr-only">Configured</span>
1616+
</span>
1617+
)}
1618+
</div>
1619+
<p className="mt-0.5 text-sm text-ink-dull">
1620+
Sign in with your ChatGPT Plus account in the browser.
1621+
</p>
1622+
<p className="mt-1 text-tiny text-ink-faint">
1623+
Default model: <span className="text-ink-dull">{defaultModel}</span>
1624+
</p>
1625+
{message && (
1626+
<p className={`mt-1 text-tiny ${message.type === "success" ? "text-green-400" : "text-red-400"}`}>
1627+
{message.text}
1628+
</p>
1629+
)}
1630+
</div>
1631+
<div className="flex gap-2">
1632+
<Button
1633+
onClick={onSignIn}
1634+
disabled={isPolling}
1635+
loading={isPolling}
1636+
variant="outline"
1637+
size="sm"
1638+
>
1639+
Sign in with ChatGPT Plus
1640+
</Button>
1641+
</div>
1642+
</div>
1643+
</div>
1644+
);
1645+
}

0 commit comments

Comments
 (0)