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
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}`,
};
}
}