mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ const API_PROVIDERS_LIST_BASE = [
|
||||
"lmstudio",
|
||||
"gemini",
|
||||
"openai-native",
|
||||
"openai-codex",
|
||||
"requesty",
|
||||
"together",
|
||||
"deepseek",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user