feat: codex subscription support (#6600)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
Co-authored-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Michael Neale
2026-01-23 17:11:58 +11:00
committed by GitHub
parent e7bfdf8fa2
commit e78a1e7d4e
26 changed files with 1666 additions and 69 deletions
+27
View File
@@ -1325,6 +1325,33 @@
}
}
},
"/config/providers/{name}/oauth": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "configure_provider_oauth",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Provider name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OAuth configuration completed"
},
"400": {
"description": "OAuth configuration failed"
}
}
}
},
"/config/read": {
"post": {
"tags": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26
View File
@@ -2261,6 +2261,32 @@ export type GetProviderModelsResponses = {
export type GetProviderModelsResponse = GetProviderModelsResponses[keyof GetProviderModelsResponses];
export type ConfigureProviderOauthData = {
body?: never;
path: {
/**
* Provider name
*/
name: string;
};
query?: never;
url: '/config/providers/{name}/oauth';
};
export type ConfigureProviderOauthErrors = {
/**
* OAuth configuration failed
*/
400: unknown;
};
export type ConfigureProviderOauthResponses = {
/**
* OAuth configuration completed
*/
200: unknown;
};
export type ReadConfigData = {
body: ConfigKeyQuery;
path?: never;
+7 -8
View File
@@ -73,20 +73,19 @@ export default function ApiKeyTester({ onSuccess, onStartTesting }: ApiKeyTester
</span>
</div>
<div className="w-full p-4 sm:p-6 bg-background-muted border border-background-hover rounded-xl">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<Key className="w-4 h-4 mb-3 text-text-standard" />
<div className="w-full p-3 sm:p-4 bg-background-muted border border-background-hover rounded-xl">
<div className="flex items-center gap-3 mb-3">
<Key className="w-4 h-4 text-text-standard flex-shrink-0" />
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
<h3 className="font-medium text-text-standard text-sm sm:text-base">
Quick Setup with API Key
</h3>
<span className="text-text-muted text-xs sm:text-sm">
Auto-detect your provider
</span>
</div>
</div>
<p className="text-text-muted text-sm sm:text-base mb-4">
Enter your API key and we'll automatically detect which provider it works with.
</p>
<div className="space-y-4">
<div className="flex gap-2 items-stretch">
<input
+99 -6
View File
@@ -4,6 +4,7 @@ import { useConfig } from './ConfigContext';
import { SetupModal } from './SetupModal';
import { startOpenRouterSetup } from '../utils/openRouterSetup';
import { startTetrateSetup } from '../utils/tetrateSetup';
import { startChatGptCodexSetup } from '../utils/chatgptCodexSetup';
import WelcomeGooseLogo from './WelcomeGooseLogo';
import { toastService } from '../toasts';
import { OllamaSetup } from './OllamaSetup';
@@ -19,7 +20,7 @@ import {
trackOnboardingSetupFailed,
} from '../utils/analytics';
import { Goose, OpenRouter, Tetrate } from './icons';
import { Goose, OpenRouter, Tetrate, ChatGPT } from './icons';
interface ProviderGuardProps {
didSelectProvider: boolean;
@@ -69,6 +70,14 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
autoClose?: number;
} | null>(null);
const [chatgptCodexSetupState, setChatgptCodexSetupState] = useState<{
show: boolean;
title: string;
message: string;
showRetry: boolean;
autoClose?: number;
} | null>(null);
const handleTetrateSetup = async () => {
trackOnboardingProviderSelected('tetrate');
try {
@@ -97,6 +106,34 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
}
};
const handleChatGptCodexSetup = async () => {
trackOnboardingProviderSelected('chatgpt_codex');
try {
const result = await startChatGptCodexSetup();
if (result.success) {
setSwitchModelProvider('chatgpt_codex');
setShowSwitchModelModal(true);
} else {
trackOnboardingSetupFailed('chatgpt_codex', result.message);
setChatgptCodexSetupState({
show: true,
title: 'Setup Failed',
message: result.message,
showRetry: true,
});
}
} catch (error) {
console.error('ChatGPT Codex setup error:', error);
trackOnboardingSetupFailed('chatgpt_codex', 'unexpected_error');
setChatgptCodexSetupState({
show: true,
title: 'Setup Error',
message: 'An unexpected error occurred during setup.',
showRetry: true,
});
}
};
const handleApiKeySuccess = async (provider: string, _model: string, apiKey: string) => {
trackOnboardingProviderSelected('api_key');
const keyName = `${provider.toUpperCase()}_API_KEY`;
@@ -163,21 +200,26 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
setShowOllamaSetup(false);
};
const handleRetrySetup = (setupType: 'openrouter' | 'tetrate') => {
const handleRetrySetup = (setupType: 'openrouter' | 'tetrate' | 'chatgpt_codex') => {
if (setupType === 'openrouter') {
setOpenRouterSetupState(null);
handleOpenRouterSetup();
} else {
} else if (setupType === 'tetrate') {
setTetrateSetupState(null);
handleTetrateSetup();
} else {
setChatgptCodexSetupState(null);
handleChatGptCodexSetup();
}
};
const closeSetupModal = (setupType: 'openrouter' | 'tetrate') => {
const closeSetupModal = (setupType: 'openrouter' | 'tetrate' | 'chatgpt_codex') => {
if (setupType === 'openrouter') {
setOpenRouterSetupState(null);
} else {
} else if (setupType === 'tetrate') {
setTetrateSetupState(null);
} else {
setChatgptCodexSetupState(null);
}
};
@@ -274,9 +316,49 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
}}
/>
{/* ChatGPT Subscription Card - Full Width */}
<div className="relative w-full mb-4">
<div className="absolute -top-2 -right-2 sm:-top-3 sm:-right-3 z-20">
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded-full">
Recommended if you have ChatGPT Plus/Pro
</span>
</div>
<div
onClick={handleChatGptCodexSetup}
className="w-full p-4 sm:p-6 bg-transparent border border-background-hover rounded-xl hover:border-text-muted transition-all duration-200 cursor-pointer group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<ChatGPT className="w-5 h-5 text-text-standard" />
<span className="font-medium text-text-standard text-sm sm:text-base">
ChatGPT Subscription
</span>
</div>
<div className="text-text-muted group-hover:text-text-standard transition-colors">
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
<p className="text-text-muted text-sm sm:text-base">
Use your ChatGPT Plus/Pro subscription for GPT-5 Codex models.
</p>
</div>
</div>
{/* Tetrate Card - Full Width */}
<div className="relative w-full mb-4">
{/* Recommended pill */}
<div className="absolute -top-2 -right-2 sm:-top-3 sm:-right-3 z-20">
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded-full">
Recommended for new users
@@ -415,6 +497,17 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
/>
)}
{chatgptCodexSetupState?.show && (
<SetupModal
title={chatgptCodexSetupState.title}
message={chatgptCodexSetupState.message}
showRetry={chatgptCodexSetupState.showRetry}
onRetry={() => handleRetrySetup('chatgpt_codex')}
onClose={() => closeSetupModal('chatgpt_codex')}
autoClose={chatgptCodexSetupState.autoClose}
/>
)}
{showSwitchModelModal && (
<SwitchModelModal
sessionId={null}
@@ -0,0 +1,17 @@
export default function ChatGPT({ className = '' }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
fill="currentColor"
/>
</svg>
);
}
@@ -2,6 +2,7 @@ import ArrowDown from './ArrowDown';
import ArrowUp from './ArrowUp';
import Attach from './Attach';
import Back from './Back';
import ChatGPT from './ChatGPT';
import { Bird1 } from './Bird1';
import { Bird2 } from './Bird2';
import { Bird3 } from './Bird3';
@@ -59,6 +60,7 @@ export {
ArrowUp,
Attach,
Back,
ChatGPT,
Bird1,
Bird2,
Bird3,
@@ -187,7 +187,8 @@ export const SwitchModelModal = ({
// Load providers for manual model selection
(async () => {
try {
const providersResponse = await getProviders(false);
// Force refresh if initialProvider is set (OAuth flow needs fresh data)
const providersResponse = await getProviders(!!initialProvider);
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
// Create provider options and add "Use other provider" option
setProviderOptions([
@@ -260,7 +261,7 @@ export const SwitchModelModal = ({
setLoadingModels(false);
}
})();
}, [getProviders, getProviderModels, usePredefinedModels, read]);
}, [getProviders, getProviderModels, usePredefinedModels, read, initialProvider]);
const filteredModelOptions = provider
? modelOptions.filter((group) => group.options[0]?.provider === provider)
@@ -16,10 +16,24 @@ import { SecureStorageNotice } from './subcomponents/SecureStorageNotice';
import { providerConfigSubmitHandler } from './subcomponents/handlers/DefaultSubmitHandler';
import { useConfig } from '../../../ConfigContext';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AlertTriangle } from 'lucide-react';
import { ProviderDetails, removeCustomProvider } from '../../../../api';
import { AlertTriangle, LogIn } from 'lucide-react';
import { ProviderDetails, removeCustomProvider, configureProviderOauth } from '../../../../api';
import { Button } from '../../../../components/ui/button';
const formatErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
};
interface ProviderConfigurationModalProps {
provider: ProviderDetails;
onClose: () => void;
@@ -38,11 +52,15 @@ export default function ProviderConfigurationModal({
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [isActiveProvider, setIsActiveProvider] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOAuthLoading, setIsOAuthLoading] = useState(false);
const requiredParameters = provider.metadata.config_keys.filter(
(param) => param.required === true
);
// Check if this provider uses OAuth for configuration
const isOAuthProvider = provider.metadata.config_keys.some((key) => key.oauth_flow);
const isConfigured = provider.is_configured;
const headerText = showDeleteConfirmation
? `Delete configuration for ${provider.metadata.display_name}`
@@ -52,7 +70,28 @@ export default function ProviderConfigurationModal({
? isActiveProvider
? `You cannot delete this provider while it's currently in use. Please switch to a different model first.`
: 'This will permanently delete the current provider configuration.'
: `Add your API key(s) for this provider to integrate into Goose`;
: isOAuthProvider
? `Sign in with your ${provider.metadata.display_name} account to use this provider`
: `Add your API key(s) for this provider to integrate into goose`;
const handleOAuthLogin = async () => {
setIsOAuthLoading(true);
setError(null);
try {
await configureProviderOauth({
path: { name: provider.name },
});
if (onConfigured) {
onConfigured(provider);
} else {
onClose();
}
} catch (err) {
setError(`OAuth login failed: ${formatErrorMessage(err)}`);
} finally {
setIsOAuthLoading(false);
}
};
const handleSubmitForm = async (e: React.FormEvent) => {
e.preventDefault();
@@ -91,7 +130,7 @@ export default function ProviderConfigurationModal({
onClose();
}
} catch (error) {
setError(`${error}`);
setError(formatErrorMessage(error));
}
};
@@ -179,38 +218,70 @@ export default function ProviderConfigurationModal({
{/* Contains information used to set up each provider */}
{/* Only show the form when NOT in delete confirmation mode */}
{!showDeleteConfirmation ? (
<>
{/* Contains information used to set up each provider */}
<DefaultProviderSetupForm
configValues={configValues}
setConfigValues={setConfigValues}
provider={provider}
validationErrors={validationErrors}
/>
isOAuthProvider ? (
<div className="flex flex-col items-center gap-4 py-6">
<Button
onClick={handleOAuthLogin}
disabled={isOAuthLoading}
className="flex items-center gap-2 px-6 py-3"
size="lg"
>
<LogIn size={20} />
{isOAuthLoading
? 'Signing in...'
: `Sign in with ${provider.metadata.display_name}`}
</Button>
<p className="text-sm text-textSubtle text-center">
A browser window will open for you to complete the login.
</p>
</div>
) : (
<>
{/* Contains information used to set up each provider */}
<DefaultProviderSetupForm
configValues={configValues}
setConfigValues={setConfigValues}
provider={provider}
validationErrors={validationErrors}
/>
{requiredParameters.length > 0 &&
provider.metadata.config_keys &&
provider.metadata.config_keys.length > 0 && <SecureStorageNotice />}
</>
{requiredParameters.length > 0 &&
provider.metadata.config_keys &&
provider.metadata.config_keys.length > 0 && <SecureStorageNotice />}
</>
)
) : null}
</div>
<DialogFooter>
<ProviderSetupActions
requiredParameters={requiredParameters}
onCancel={handleCancel}
onSubmit={handleSubmitForm}
onDelete={handleDelete}
showDeleteConfirmation={showDeleteConfirmation}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={() => {
setIsActiveProvider(false);
setShowDeleteConfirmation(false);
}}
canDelete={isConfigured && !isActiveProvider}
providerName={provider.metadata.display_name}
isActiveProvider={isActiveProvider}
/>
{isOAuthProvider && !showDeleteConfirmation ? (
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
{isConfigured && (
<Button variant="destructive" onClick={handleDelete}>
Remove Configuration
</Button>
)}
</div>
) : (
<ProviderSetupActions
requiredParameters={requiredParameters}
onCancel={handleCancel}
onSubmit={handleSubmitForm}
onDelete={handleDelete}
showDeleteConfirmation={showDeleteConfirmation}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={() => {
setIsActiveProvider(false);
setShowDeleteConfirmation(false);
}}
canDelete={isConfigured && !isActiveProvider}
providerName={provider.metadata.display_name}
isActiveProvider={isActiveProvider}
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+4 -4
View File
@@ -69,7 +69,7 @@ export type AnalyticsEvent =
| { name: 'onboarding_started'; properties: Record<string, never> }
| {
name: 'onboarding_provider_selected';
properties: { method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other' };
properties: { method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other' };
}
| {
name: 'onboarding_completed';
@@ -78,7 +78,7 @@ export type AnalyticsEvent =
| { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } }
| {
name: 'onboarding_setup_failed';
properties: { provider: 'openrouter' | 'tetrate'; error_message?: string };
properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex'; error_message?: string };
}
| {
name: 'error_occurred';
@@ -282,7 +282,7 @@ export function trackOnboardingStarted(): void {
}
export function trackOnboardingProviderSelected(
method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other'
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other'
): void {
trackEvent({
name: 'onboarding_provider_selected',
@@ -315,7 +315,7 @@ export function trackOnboardingAbandoned(step: string): void {
}
export function trackOnboardingSetupFailed(
provider: 'openrouter' | 'tetrate',
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex',
errorMessage?: string
): void {
trackEvent({
+16
View File
@@ -0,0 +1,16 @@
import { configureProviderOauth } from '../api';
export async function startChatGptCodexSetup(): Promise<{ success: boolean; message: string }> {
try {
await configureProviderOauth({
path: { name: 'chatgpt_codex' },
throwOnError: true,
});
return { success: true, message: 'ChatGPT Codex setup completed' };
} catch (e) {
return {
success: false,
message: `Failed to start ChatGPT Codex setup: ${e}`,
};
}
}