feat(cli): add ChatGPT Subscription (OpenAI Codex) provider support

- Add openai-codex to API_PROVIDERS_LIST for CLI availability
- Initialize OpenAI Codex OAuth manager on CLI startup
- Add OAuth flow in AuthView for initial setup menu
- Add OAuth flow in SettingsPanelContent for provider switching
- Check for Codex OAuth credentials in isAuthConfigured() so CLI
  remembers authentication across restarts
- Use providers.json as single source of truth for provider ordering
  (removes separate POPULAR_PROVIDERS list)
- Rename provider label to "ChatGPT Subscription" and move to
  second position in provider list
This commit is contained in:
Saoud Rizwan
2026-01-26 16:34:07 -08:00
parent ff2bdde179
commit c7c9813056
6 changed files with 214 additions and 20 deletions
+79 -9
View File
@@ -7,9 +7,11 @@ import { Box, Text, useApp, useInput } from "ink"
import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { AuthService } from "@/services/auth/AuthService"
import { API_PROVIDERS_LIST, openRouterDefaultModelId } from "@/shared/api"
import { API_PROVIDERS_LIST, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
import { ProviderToApiKeyMap } from "@/shared/storage"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { getAllFeaturedModels } from "../constants/featured-models"
import { useStdinContext } from "../context/StdinContext"
@@ -20,7 +22,7 @@ import { ApiKeyInput } from "./ApiKeyInput"
import { StaticRobotFrame } from "./AsciiMotionCli"
import { ImportView } from "./ImportView"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { getProviderLabel, POPULAR_PROVIDERS } from "./ProviderPicker"
import { getProviderLabel, getProviderOrder } from "./ProviderPicker"
type AuthStep =
| "menu"
@@ -33,6 +35,7 @@ type AuthStep =
| "error"
| "cline_auth"
| "cline_model"
| "openai_codex_auth"
| "import"
// Featured models loaded from shared constants
@@ -162,11 +165,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
const [importSource, setImportSource] = useState<ImportSource | null>(null)
// Sort providers with popular ones first, then alphabetically
// Use providers.json order, filtered to only available providers
const sortedProviders = useMemo(() => {
const popular = POPULAR_PROVIDERS.filter((p) => API_PROVIDERS_LIST.includes(p))
const others = API_PROVIDERS_LIST.filter((p) => !POPULAR_PROVIDERS.includes(p)).sort()
return [...popular, ...others]
const availableProviders = new Set(API_PROVIDERS_LIST)
return getProviderOrder().filter((p) => availableProviders.has(p))
}, [])
// Get configured providers (those with API keys set)
@@ -202,6 +204,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const mainMenuItems: SelectItem[] = useMemo(() => {
const items: SelectItem[] = [{ label: "Sign in with Cline account", value: "cline_auth" }]
// Add OpenAI Codex option for ChatGPT subscribers
items.push({ label: "Sign in with ChatGPT Subscription", value: "openai_codex_auth" })
// Add import options if detected
if (importSources.codex) {
items.push({ label: "Import from Codex CLI", value: "import_codex" })
@@ -384,6 +389,42 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
}
}
// Start OpenAI Codex OAuth flow
const startOpenAiCodexAuth = useCallback(async () => {
try {
// Get the authorization URL and start the callback server
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
// Open browser to authorization URL (uses cross-platform 'open' package)
await openExternal(authUrl)
// Wait for the callback
await openAiCodexOAuthManager.waitForCallback()
// Success - save configuration
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
const config: Record<string, string> = {
actModeApiProvider: "openai-codex",
planModeApiProvider: "openai-codex",
[providerKey]: "openai-codex",
[modelIdKey]: openAiCodexDefaultModelId,
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
setSelectedProvider("openai-codex")
setModelId(openAiCodexDefaultModelId)
setStep("success")
} catch (error) {
openAiCodexOAuthManager.cancelAuthorizationFlow()
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
}, [])
const handleMainMenuSelect = useCallback(
(value: string) => {
if (value === "exit") {
@@ -393,6 +434,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setStep("cline_auth")
setAuthStatus("Starting authentication...")
AuthService.getInstance(controller).createAuthRequest()
} else if (value === "openai_codex_auth") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
} else if (value === "configure_byo") {
setStep("provider")
} else if (value === "import_codex") {
@@ -403,7 +447,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setStep("import")
}
},
[exit, onComplete, controller],
[exit, onComplete, controller, startOpenAiCodexAuth],
)
const handleProviderSelect = useCallback(
@@ -413,11 +457,14 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setStep("cline_auth")
setAuthStatus("Starting authentication...")
AuthService.getInstance(controller).createAuthRequest()
} else if (value === "openai-codex") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
} else {
setStep("apikey")
}
},
[controller],
[controller, startOpenAiCodexAuth],
)
const handleApiKeySubmit = useCallback(
@@ -578,6 +625,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
case "cline_auth":
setStep("menu")
break
case "openai_codex_auth":
openAiCodexOAuthManager.cancelAuthorizationFlow()
setStep("menu")
break
case "cline_model":
setClineModelIndex(0)
setStep("menu")
@@ -738,6 +789,25 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
</Box>
)
case "openai_codex_auth":
return (
<Box flexDirection="column">
<Box>
<Text color="blueBright">
<Spinner type="dots" />
</Text>
<Text color="white"> Waiting for ChatGPT sign-in...</Text>
</Box>
<Text> </Text>
<Text color="gray">Sign in with your ChatGPT account in the browser.</Text>
<Text color="gray">Requires ChatGPT Plus, Pro, or Team subscription.</Text>
<Text> </Text>
<Text color="gray" dimColor>
Esc to cancel
</Text>
</Box>
)
case "cline_model": {
const allModels = featuredModels
return (
@@ -818,7 +888,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [menuIndex, setMenuIndex] = useState(0)
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
const canGoBack = ["provider", "modelid", "baseurl", "cline_auth", "cline_model", "error"].includes(step)
const canGoBack = ["provider", "modelid", "baseurl", "cline_auth", "cline_model", "openai_codex_auth", "error"].includes(step)
useInput(
(input, key) => {
+9 -6
View File
@@ -12,13 +12,17 @@ const providerLabels: Record<string, string> = Object.fromEntries(
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
)
// Popular providers to show at the top of the list
export const POPULAR_PROVIDERS = ["anthropic", "openai-native", "openai", "gemini", "bedrock", "openrouter"]
// Get provider order from providers.json (same order as webview)
const providerOrder: string[] = providersData.list.map((p: { value: string }) => p.value)
export function getProviderLabel(providerId: string): string {
return providerLabels[providerId] || providerId
}
export function getProviderOrder(): string[] {
return providerOrder
}
interface ProviderPickerProps {
onSelect: (providerId: string) => void
isActive?: boolean
@@ -26,11 +30,10 @@ interface ProviderPickerProps {
}
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true, configuredProviders = new Set() }) => {
// Sort providers with popular ones first, then alphabetically
// Use providers.json order, filtered to only available providers
const items: SearchableListItem[] = useMemo(() => {
const popular = POPULAR_PROVIDERS.filter((p) => API_PROVIDERS_LIST.includes(p))
const others = API_PROVIDERS_LIST.filter((p) => !POPULAR_PROVIDERS.includes(p)).sort()
const sorted = [...popular, ...others]
const availableProviders = new Set(API_PROVIDERS_LIST)
const sorted = providerOrder.filter((p) => availableProviders.has(p))
return sorted.map((providerId) => ({
id: providerId,
+110 -1
View File
@@ -8,10 +8,14 @@ import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { ProviderToApiKeyMap } from "@shared/storage"
import type { TelemetrySetting } from "@shared/TelemetrySetting"
import { Box, Text, useInput } from "ink"
import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { openAiCodexDefaultModelId } from "@/shared/api"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
@@ -110,6 +114,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
const [isPickingProvider, setIsPickingProvider] = useState(false)
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
const [apiKeyValue, setApiKeyValue] = useState("")
const [editValue, setEditValue] = useState("")
@@ -513,9 +519,57 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
[stateManager],
)
// Handle OpenAI Codex OAuth flow
const startCodexAuth = useCallback(async () => {
try {
setIsWaitingForCodexAuth(true)
setCodexAuthError(null)
// Get the authorization URL and start the callback server
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
// Open browser to authorization URL
await openExternal(authUrl)
// Wait for the callback
await openAiCodexOAuthManager.waitForCallback()
// Success - save configuration
const config: Record<string, string> = {
actModeApiProvider: "openai-codex",
planModeApiProvider: "openai-codex",
actModeApiModelId: openAiCodexDefaultModelId,
planModeApiModelId: openAiCodexDefaultModelId,
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
// Rebuild API handler on active task if one exists
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
setProvider("openai-codex")
setIsWaitingForCodexAuth(false)
} catch (error) {
openAiCodexOAuthManager.cancelAuthorizationFlow()
setCodexAuthError(error instanceof Error ? error.message : String(error))
setIsWaitingForCodexAuth(false)
}
}, [stateManager, controller])
// Handle provider selection from picker
const handleProviderSelect = useCallback(
(providerId: string) => {
// Special handling for OpenAI Codex - uses OAuth instead of API key
if (providerId === "openai-codex") {
setIsPickingProvider(false)
startCodexAuth()
return
}
// Check if this provider needs an API key
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
if (keyField) {
@@ -537,7 +591,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
setIsPickingProvider(false)
}
},
[stateManager],
[stateManager, startCodexAuth],
)
// Handle API key submission after provider selection
@@ -683,6 +737,21 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
return
}
// Codex OAuth waiting mode - escape to cancel
if (isWaitingForCodexAuth) {
if (key.escape) {
openAiCodexOAuthManager.cancelAuthorizationFlow()
setIsWaitingForCodexAuth(false)
}
return
}
// Codex OAuth error mode - any key to dismiss
if (codexAuthError) {
setCodexAuthError(null)
return
}
if (isEditing) {
if (key.escape) {
setIsEditing(false)
@@ -767,6 +836,46 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
)
}
if (isWaitingForCodexAuth) {
return (
<Box flexDirection="column">
<Box>
<Text color="blueBright">
<Spinner type="dots" />
</Text>
<Text color="white"> Waiting for ChatGPT sign-in...</Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Sign in with your ChatGPT account in the browser.</Text>
</Box>
<Text color="gray">Requires ChatGPT Plus, Pro, or Team subscription.</Text>
<Box marginTop={1}>
<Text color="gray" dimColor>
Esc to cancel
</Text>
</Box>
</Box>
)
}
if (codexAuthError) {
return (
<Box flexDirection="column">
<Text bold color="red">
ChatGPT sign-in failed
</Text>
<Box marginTop={1}>
<Text color="yellow">{codexAuthError}</Text>
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>
Press any key to continue
</Text>
</Box>
</Box>
)
}
if (isPickingModel && pickingModelKey) {
const label = pickingModelKey === "actModelId" ? "Model ID (Act)" : "Model ID (Plan)"
return (
+11
View File
@@ -13,6 +13,7 @@ import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
import { BannerService } from "@/services/banner/BannerService"
import { ErrorService } from "@/services/error/ErrorService"
@@ -136,6 +137,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
await ErrorService.initialize()
await StateManager.initialize(extensionContext as any)
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(extensionContext)
// Configure the shared Logging class to use HostProvider's output channel
Logger.setOutput((msg: string) => HostProvider.get().logToChannel(msg))
@@ -509,6 +513,7 @@ program
* Check if the user has authentication configured.
* Returns true if they have either:
* - Cline provider with stored auth data
* - OpenAI Codex provider with OAuth credentials
* - BYO provider with an API key configured
*/
async function isAuthConfigured(): Promise<boolean> {
@@ -523,6 +528,12 @@ async function isAuthConfigured(): Promise<boolean> {
return !!authData
}
if (currentProvider === "openai-codex") {
// For OpenAI Codex, check if OAuth credentials are stored
const isAuthenticated = await openAiCodexOAuthManager.isAuthenticated()
return isAuthenticated
}
// For BYO providers, check if the API key is configured
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
if (!keyField) {
+1
View File
@@ -12,6 +12,7 @@ const API_PROVIDERS_LIST_BASE = [
"lmstudio",
"gemini",
"openai-native",
"openai-codex",
"requesty",
"together",
"deepseek",
+4 -4
View File
@@ -5,8 +5,8 @@
"label": "Cline"
},
{
"value": "openrouter",
"label": "OpenRouter"
"value": "openai-codex",
"label": "ChatGPT Subscription"
},
{
"value": "gemini",
@@ -37,8 +37,8 @@
"label": "OpenAI"
},
{
"value": "openai-codex",
"label": "OpenAI Codex (ChatGPT Plus/Pro)"
"value": "openrouter",
"label": "OpenRouter"
},
{
"value": "ollama",