Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading