feat: add Codex CLI (native) provider with local authentication

- Add codex-cli-native provider type to types package
- Create CodexCliHandler for CLI authentication operations
- Add CodexCliNative UI component with sign-in/sign-out functionality
- Implement message handlers for authentication flow
- Add secret storage for bearer token
- Reuse OpenAI Native handler with locally obtained token
- Keep UI text generic as requested
This commit is contained in:
Roo Code 2025-09-17 02:06:08 +00:00
parent 2263d86a20
commit 3f4b427062
12 changed files with 408 additions and 4 deletions

View file

@ -181,6 +181,7 @@ export const SECRET_STATE_KEYS = [
"geminiApiKey",
"openAiNativeApiKey",
"cerebrasApiKey",
"codexCliOpenAiNativeToken",
"deepSeekApiKey",
"doubaoApiKey",
"moonshotApiKey",

View file

@ -34,6 +34,7 @@ import {
export const providerNames = [
"anthropic",
"claude-code",
"codex-cli-native",
"glama",
"openrouter",
"bedrock",
@ -343,6 +344,11 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
vercelAiGatewayModelId: z.string().optional(),
})
const codexCliNativeSchema = apiModelIdProviderModelSchema.extend({
codexCliPath: z.string().optional(),
// No API key field - uses token from secrets
})
const defaultSchema = z.object({
apiProvider: z.undefined(),
})
@ -350,6 +356,7 @@ const defaultSchema = z.object({
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
codexCliNativeSchema.merge(z.object({ apiProvider: z.literal("codex-cli-native") })),
glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })),
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
@ -391,6 +398,7 @@ export const providerSettingsSchema = z.object({
apiProvider: providerNamesSchema.optional(),
...anthropicSchema.shape,
...claudeCodeSchema.shape,
...codexCliNativeSchema.shape,
...glamaSchema.shape,
...openRouterSchema.shape,
...bedrockSchema.shape,
@ -483,7 +491,10 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
}
export const MODELS_BY_PROVIDER: Record<
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama">,
Exclude<
ProviderName,
"fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama" | "codex-cli-native"
>,
{ id: ProviderName; label: string; models: string[] }
> = {
anthropic: {

View file

@ -95,6 +95,16 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new AnthropicHandler(options)
case "claude-code":
return new ClaudeCodeHandler(options)
case "codex-cli-native":
// Reuse OpenAI Native handler with token from secrets
// The token will be injected from the secret storage
// Note: The token is stored in secrets as codexCliOpenAiNativeToken
// and will be injected into openAiNativeApiKey for the handler
return new OpenAiNativeHandler({
...options,
openAiNativeApiKey: (options as any).codexCliOpenAiNativeToken,
openAiNativeBaseUrl: options.openAiNativeBaseUrl || "https://api.openai.com",
})
case "glama":
return new GlamaHandler(options)
case "openrouter":
@ -166,7 +176,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
case "vercel-ai-gateway":
return new VercelAiGatewayHandler(options)
default:
apiProvider satisfies "gemini-cli" | undefined
apiProvider satisfies "gemini-cli" | "codex-cli-native" | undefined
return new AnthropicHandler(options)
}
}

View file

@ -267,13 +267,37 @@ export class ContextProxy {
const values = this.getValues()
try {
return providerSettingsSchema.parse(values)
const settings = providerSettingsSchema.parse(values)
// For codex-cli-native provider, inject the token from secrets
if (settings.apiProvider === "codex-cli-native") {
const token = this.getSecret("codexCliOpenAiNativeToken" as SecretStateKey)
if (token) {
// Add the token to the settings object so it can be used by the API handler
;(settings as any).codexCliOpenAiNativeToken = token
}
}
return settings
} catch (error) {
if (error instanceof ZodError) {
TelemetryService.instance.captureSchemaValidationError({ schemaName: "ProviderSettings", error })
}
return PROVIDER_SETTINGS_KEYS.reduce((acc, key) => ({ ...acc, [key]: values[key] }), {} as ProviderSettings)
const settings = PROVIDER_SETTINGS_KEYS.reduce(
(acc, key) => ({ ...acc, [key]: values[key] }),
{} as ProviderSettings,
)
// For codex-cli-native provider, inject the token from secrets (fallback case)
if (settings.apiProvider === "codex-cli-native") {
const token = this.getSecret("codexCliOpenAiNativeToken" as SecretStateKey)
if (token) {
;(settings as any).codexCliOpenAiNativeToken = token
}
}
return settings
}
}

View file

@ -2323,6 +2323,102 @@ export const webviewMessageHandler = async (
break
}
case "codexCliNativeCheckToken": {
// Check if token exists in secrets
const token = await provider.context.secrets.get("codexCliOpenAiNativeToken")
await provider.postMessageToWebview({
type: "codexCliNativeTokenStatus",
hasToken: !!token,
})
break
}
case "codexCliNativeSignIn": {
try {
// Import the CLI handler module
const { CodexCliHandler } = await import("../../services/codex-cli/CodexCliHandler")
// Get the CLI path from settings or use default
const cliPath = message.text || "codex"
// Run the sign-in flow
const handler = new CodexCliHandler(cliPath)
const token = await handler.signIn()
if (token) {
// Store the token in secrets
await provider.context.secrets.store("codexCliOpenAiNativeToken", token)
// Notify the webview of success
await provider.postMessageToWebview({
type: "codexCliNativeSignInResult",
success: true,
})
// Update the state to reflect the new token
await provider.postStateToWebview()
} else {
throw new Error("Failed to obtain token from CLI")
}
} catch (error) {
provider.log(`CodexCliNative sign-in failed: ${error}`)
await provider.postMessageToWebview({
type: "codexCliNativeSignInResult",
success: false,
error: error instanceof Error ? error.message : String(error),
})
}
break
}
case "codexCliNativeSignOut": {
try {
// Clear the token from secrets
await provider.context.secrets.delete("codexCliOpenAiNativeToken")
// Notify the webview of success
await provider.postMessageToWebview({
type: "codexCliNativeSignOutResult",
success: true,
})
// Update the state
await provider.postStateToWebview()
} catch (error) {
provider.log(`CodexCliNative sign-out failed: ${error}`)
await provider.postMessageToWebview({
type: "codexCliNativeSignOutResult",
success: false,
error: error instanceof Error ? error.message : String(error),
})
}
break
}
case "codexCliNativeDetect": {
try {
// Import the CLI handler module
const { CodexCliHandler } = await import("../../services/codex-cli/CodexCliHandler")
// Get the CLI path from settings or use default
const cliPath = message.text || "codex"
// Check if CLI is available
const handler = new CodexCliHandler(cliPath)
const isAvailable = await handler.detect()
await provider.postMessageToWebview({
type: "codexCliNativeDetectResult",
available: isAvailable,
path: isAvailable ? cliPath : undefined,
})
} catch (error) {
provider.log(`CodexCliNative detect failed: ${error}`)
await provider.postMessageToWebview({
type: "codexCliNativeDetectResult",
available: false,
error: error instanceof Error ? error.message : String(error),
})
}
break
}
case "rooCloudManualUrl": {
try {
if (!message.text) {

View file

@ -0,0 +1,111 @@
import { spawn } from "child_process"
import * as vscode from "vscode"
/**
* Handler for Codex CLI authentication operations
* Based on the ChatMock reference implementation
*/
export class CodexCliHandler {
constructor(private cliPath: string = "codex") {}
/**
* Detect if the CLI is available
*/
async detect(): Promise<boolean> {
return new Promise((resolve) => {
const process = spawn(this.cliPath, ["--version"], {
shell: true,
windowsHide: true,
})
process.on("error", () => {
resolve(false)
})
process.on("exit", (code) => {
resolve(code === 0)
})
// Timeout after 5 seconds
setTimeout(() => {
process.kill()
resolve(false)
}, 5000)
})
}
/**
* Run the sign-in flow to obtain a bearer token
*/
async signIn(): Promise<string | null> {
return new Promise((resolve, reject) => {
// Run the CLI auth command
const process = spawn(this.cliPath, ["auth", "login", "--json"], {
shell: true,
windowsHide: true,
})
let stdout = ""
let stderr = ""
process.stdout?.on("data", (data) => {
stdout += data.toString()
})
process.stderr?.on("data", (data) => {
stderr += data.toString()
})
process.on("error", (error) => {
reject(new Error(`Failed to spawn CLI: ${error.message}`))
})
process.on("exit", (code) => {
if (code === 0) {
try {
// Parse the JSON output to extract the token
const result = JSON.parse(stdout)
if (result.token) {
resolve(result.token)
} else {
reject(new Error("No token in CLI response"))
}
} catch (error) {
// If JSON parsing fails, try to extract token from plain text
const tokenMatch = stdout.match(/token[:\s]+([a-zA-Z0-9\-._~+/]+=*)/i)
if (tokenMatch) {
resolve(tokenMatch[1])
} else {
reject(new Error(`Failed to parse CLI output: ${stdout}`))
}
}
} else {
reject(new Error(`CLI exited with code ${code}: ${stderr || stdout}`))
}
})
// Timeout after 2 minutes (to allow for browser auth flow)
setTimeout(() => {
process.kill()
reject(new Error("Sign-in timed out"))
}, 120000)
})
}
/**
* Check if a token is valid by making a test API call
*/
async validateToken(token: string): Promise<boolean> {
try {
// Make a simple API call to validate the token
const response = await fetch("https://api.openai.com/v1/models", {
headers: {
Authorization: `Bearer ${token}`,
},
})
return response.ok
} catch {
return false
}
}
}

View file

@ -124,6 +124,10 @@ export interface ExtensionMessage {
| "commands"
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "codexCliNativeTokenStatus"
| "codexCliNativeSignInResult"
| "codexCliNativeSignOutResult"
| "codexCliNativeDetectResult"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -201,6 +205,9 @@ export interface ExtensionMessage {
commands?: Command[]
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
hasToken?: boolean // For codexCliNativeTokenStatus
available?: boolean // For codexCliNativeDetectResult
path?: string // For codexCliNativeDetectResult
}
export type ExtensionState = Pick<

View file

@ -225,6 +225,10 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "codexCliNativeCheckToken"
| "codexCliNativeSignIn"
| "codexCliNativeSignOut"
| "codexCliNativeDetect"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

View file

@ -68,6 +68,7 @@ import {
Cerebras,
Chutes,
ClaudeCode,
CodexCliNative,
DeepSeek,
Doubao,
Gemini,
@ -322,6 +323,7 @@ const ApiOptions = ({
"claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId },
"qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId },
"openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
"codex-cli-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
gemini: { field: "apiModelId", default: geminiDefaultModelId },
deepseek: { field: "apiModelId", default: deepSeekDefaultModelId },
doubao: { field: "apiModelId", default: doubaoDefaultModelId },
@ -513,6 +515,13 @@ const ApiOptions = ({
<ClaudeCode apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProvider === "codex-cli-native" && (
<CodexCliNative
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
/>
)}
{selectedProvider === "openai-native" && (
<OpenAI
apiConfiguration={apiConfiguration}

View file

@ -26,6 +26,7 @@ import {
export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, ModelInfo>>> = {
anthropic: anthropicModels,
"claude-code": claudeCodeModels,
"codex-cli-native": openAiNativeModels, // Reuses OpenAI native models
bedrock: bedrockModels,
cerebras: cerebrasModels,
deepseek: deepSeekModels,
@ -51,6 +52,7 @@ export const PROVIDERS = [
{ value: "deepinfra", label: "DeepInfra" },
{ value: "anthropic", label: "Anthropic" },
{ value: "claude-code", label: "Claude Code" },
{ value: "codex-cli-native", label: "Codex CLI (native)" },
{ value: "cerebras", label: "Cerebras" },
{ value: "gemini", label: "Google Gemini" },
{ value: "doubao", label: "Doubao" },

View file

@ -0,0 +1,128 @@
import { useState, useEffect } from "react"
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import type { ProviderSettings } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { vscode } from "@src/utils/vscode"
import { inputEventTransform } from "../transforms"
type CodexCliNativeProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
}
export const CodexCliNative = ({ apiConfiguration, setApiConfigurationField }: CodexCliNativeProps) => {
const { t } = useAppTranslation()
const [isSignedIn, setIsSignedIn] = useState(false)
const [isSigningIn, setIsSigningIn] = useState(false)
const [showCustomPath, setShowCustomPath] = useState(false)
// Check if user is signed in by checking if token exists
useEffect(() => {
// Request token status from extension
vscode.postMessage({ type: "codexCliNativeCheckToken" })
}, [])
// Listen for token status updates
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "codexCliNativeTokenStatus") {
setIsSignedIn(message.hasToken)
} else if (message.type === "codexCliNativeSignInResult") {
setIsSigningIn(false)
if (message.success) {
setIsSignedIn(true)
}
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
const handleSignIn = () => {
setIsSigningIn(true)
vscode.postMessage({ type: "codexCliNativeSignIn" })
}
const handleSignOut = () => {
vscode.postMessage({ type: "codexCliNativeSignOut" })
setIsSignedIn(false)
}
const handleDetect = () => {
vscode.postMessage({ type: "codexCliNativeDetect" })
}
const handlePathChange = (event: Event | React.FormEvent<HTMLElement>) => {
const value = inputEventTransform(event as Event)
setApiConfigurationField("codexCliPath", value as string)
}
return (
<div className="flex flex-col gap-3">
{isSignedIn ? (
<>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.codexCliNative.signedInMessage")}
</div>
<VSCodeButton appearance="secondary" onClick={handleSignOut} className="w-fit">
{t("settings:providers.codexCliNative.signOutButton")}
</VSCodeButton>
</>
) : (
<>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.codexCliNative.signInMessage")}
</div>
<div className="flex gap-2">
<VSCodeButton
appearance="primary"
onClick={handleSignIn}
disabled={isSigningIn}
className="w-fit">
{isSigningIn
? t("settings:providers.codexCliNative.signingInButton")
: t("settings:providers.codexCliNative.signInButton")}
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={handleDetect} className="w-fit">
{t("settings:providers.codexCliNative.detectButton")}
</VSCodeButton>
</div>
</>
)}
<div className="mt-2">
<button
type="button"
onClick={() => setShowCustomPath(!showCustomPath)}
className="text-sm text-vscode-link hover:underline cursor-pointer">
{showCustomPath
? t("settings:providers.codexCliNative.hideCustomPath")
: t("settings:providers.codexCliNative.showCustomPath")}
</button>
</div>
{showCustomPath && (
<div>
<VSCodeTextField
value={apiConfiguration?.codexCliPath || ""}
type="text"
onInput={handlePathChange}
placeholder={t("settings:providers.codexCliNative.pathPlaceholder")}
className="w-full">
<label className="block font-medium mb-1">
{t("settings:providers.codexCliNative.pathLabel")}
</label>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground mt-1">
{t("settings:providers.codexCliNative.pathDescription")}
</div>
</div>
)}
</div>
)
}

View file

@ -3,6 +3,7 @@ export { Bedrock } from "./Bedrock"
export { Cerebras } from "./Cerebras"
export { Chutes } from "./Chutes"
export { ClaudeCode } from "./ClaudeCode"
export { CodexCliNative } from "./CodexCliNative"
export { DeepSeek } from "./DeepSeek"
export { Doubao } from "./Doubao"
export { Gemini } from "./Gemini"