feat: add Codex CLI provider with local login support

- Add new provider 'codex-cli' extending BaseOpenAiCompatibleProvider
- Implement local sign-in flow without API keys using session tokens
- Store session tokens securely in VS Code Secret Storage
- Add UI component with Sign In/Sign Out buttons and CLI path configuration
- Add message handlers for codexCliSignIn, codexCliSignOut, and codexCliDetect
- Include comprehensive test coverage for the new provider
- Follow existing patterns from Roo Cloud and Claude Code providers

This implementation allows users to use OpenAI-compatible models through
Codex CLI without managing API keys directly, using a local authentication
flow instead.
This commit is contained in:
Roo Code 2025-09-17 01:26:34 +00:00
parent 2263d86a20
commit 26d766bd7b
15 changed files with 819 additions and 1 deletions

View file

@ -47,6 +47,10 @@ export const globalSettingsSchema = z.object({
openRouterImageApiKey: z.string().optional(),
openRouterImageGenerationSelectedModel: z.string().optional(),
// Codex CLI settings
codexCliSessionToken: z.string().optional(),
codexCliBaseUrl: z.string().optional(),
condensingApiConfigId: z.string().optional(),
customCondensingPrompt: z.string().optional(),
@ -210,6 +214,7 @@ export const SECRET_STATE_KEYS = [
// Global secrets that are part of GlobalSettings (not ProviderSettings)
export const GLOBAL_SECRET_KEYS = [
"openRouterImageApiKey", // For image generation
"codexCliSessionToken", // For Codex CLI authentication
] as const
// Type for the actual secret storage keys

View file

@ -8,6 +8,7 @@ import {
cerebrasModels,
chutesModels,
claudeCodeModels,
codexCliModels,
deepSeekModels,
doubaoModels,
featherlessModels,
@ -34,6 +35,7 @@ import {
export const providerNames = [
"anthropic",
"claude-code",
"codex-cli",
"glama",
"openrouter",
"bedrock",
@ -343,6 +345,11 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
vercelAiGatewayModelId: z.string().optional(),
})
const codexCliSchema = apiModelIdProviderModelSchema.extend({
codexCliPath: z.string().optional(),
codexCliBaseUrl: z.string().optional(),
})
const defaultSchema = z.object({
apiProvider: z.undefined(),
})
@ -350,6 +357,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") })),
codexCliSchema.merge(z.object({ apiProvider: z.literal("codex-cli") })),
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 +399,7 @@ export const providerSettingsSchema = z.object({
apiProvider: providerNamesSchema.optional(),
...anthropicSchema.shape,
...claudeCodeSchema.shape,
...codexCliSchema.shape,
...glamaSchema.shape,
...openRouterSchema.shape,
...bedrockSchema.shape,
@ -483,7 +492,7 @@ 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">,
{ id: ProviderName; label: string; models: string[] }
> = {
anthropic: {
@ -507,6 +516,7 @@ export const MODELS_BY_PROVIDER: Record<
models: Object.keys(chutesModels),
},
"claude-code": { id: "claude-code", label: "Claude Code", models: Object.keys(claudeCodeModels) },
"codex-cli": { id: "codex-cli", label: "Codex CLI", models: Object.keys(codexCliModels) },
deepseek: {
id: "deepseek",
label: "DeepSeek",

View file

@ -0,0 +1,91 @@
import type { ModelInfo } from "../model.js"
// Codex CLI models - mirrors OpenAI models since it's OpenAI-compatible
export type CodexCliModelId =
| "gpt-4o"
| "gpt-4o-mini"
| "gpt-4-turbo"
| "gpt-4"
| "gpt-3.5-turbo"
| "o1-preview"
| "o1-mini"
| "o1"
| "o3-mini"
export const codexCliDefaultModelId: CodexCliModelId = "gpt-4o-mini"
export const codexCliModels: Record<CodexCliModelId, ModelInfo> = {
"gpt-4o": {
maxTokens: 16384,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 2.5,
outputPrice: 10,
},
"gpt-4o-mini": {
maxTokens: 16384,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.15,
outputPrice: 0.6,
},
"gpt-4-turbo": {
maxTokens: 4096,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 10,
outputPrice: 30,
},
"gpt-4": {
maxTokens: 8192,
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 30,
outputPrice: 60,
},
"gpt-3.5-turbo": {
maxTokens: 4096,
contextWindow: 16385,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.5,
outputPrice: 1.5,
},
"o1-preview": {
maxTokens: 32768,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 15,
outputPrice: 60,
},
"o1-mini": {
maxTokens: 65536,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 12,
},
o1: {
maxTokens: 100000,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 15,
outputPrice: 60,
},
"o3-mini": {
maxTokens: 65536,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.1,
outputPrice: 4.4,
reasoningEffort: "medium",
},
}

View file

@ -3,6 +3,7 @@ export * from "./bedrock.js"
export * from "./cerebras.js"
export * from "./chutes.js"
export * from "./claude-code.js"
export * from "./codex-cli.js"
export * from "./deepseek.js"
export * from "./doubao.js"
export * from "./featherless.js"

View file

@ -30,6 +30,7 @@ import {
ChutesHandler,
LiteLLMHandler,
ClaudeCodeHandler,
CodexCliHandler,
QwenCodeHandler,
SambaNovaHandler,
IOIntelligenceHandler,
@ -95,6 +96,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new AnthropicHandler(options)
case "claude-code":
return new ClaudeCodeHandler(options)
case "codex-cli":
return new CodexCliHandler(options)
case "glama":
return new GlamaHandler(options)
case "openrouter":

View file

@ -0,0 +1,308 @@
// npx vitest run api/providers/__tests__/codex-cli.spec.ts
import { CodexCliHandler } from "../codex-cli"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { codexCliDefaultModelId } from "@roo-code/types"
const mockCreate = vitest.fn()
vitest.mock("openai", () => {
const mockConstructor = vitest.fn()
return {
__esModule: true,
default: mockConstructor.mockImplementation(() => ({
chat: {
completions: {
create: mockCreate.mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [
{
message: { role: "assistant", content: "Test response", refusal: null },
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
}
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}
}),
},
},
})),
}
})
describe("CodexCliHandler", () => {
let handler: CodexCliHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
// Reset mocks
mockCreate.mockClear()
mockOptions = {
codexCliPath: "/usr/local/bin/codex",
codexCliBaseUrl: "http://localhost:3000/v1",
apiModelId: codexCliDefaultModelId,
}
handler = new CodexCliHandler(mockOptions)
})
describe("constructor", () => {
it("should initialize with default values when no options provided", () => {
const minimalHandler = new CodexCliHandler({})
expect(minimalHandler).toBeInstanceOf(CodexCliHandler)
expect(minimalHandler.getModel().id).toBe(codexCliDefaultModelId)
})
it("should use provided base URL", () => {
expect(handler).toBeInstanceOf(CodexCliHandler)
// The handler should be created successfully with the provided base URL
})
it("should use custom base URL if provided", () => {
const customBaseUrl = "http://custom.local:8080/v1"
const handlerWithCustomUrl = new CodexCliHandler({
...mockOptions,
codexCliBaseUrl: customBaseUrl,
})
expect(handlerWithCustomUrl).toBeInstanceOf(CodexCliHandler)
})
it("should handle missing session token gracefully", () => {
const handlerWithoutToken = new CodexCliHandler({})
expect(handlerWithoutToken).toBeInstanceOf(CodexCliHandler)
// Should still create handler even without token
expect(handlerWithoutToken.getModel().id).toBe(codexCliDefaultModelId)
})
})
describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello!",
},
],
},
]
it("should handle streaming responses", async () => {
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
})
it("should handle non-streaming mode", async () => {
const nonStreamingHandler = new CodexCliHandler({
...mockOptions,
openAiStreamingEnabled: false,
})
const stream = nonStreamingHandler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunk = chunks.find((chunk) => chunk.type === "text")
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
expect(textChunk).toBeDefined()
expect(textChunk?.text).toBe("Test response")
expect(usageChunk).toBeDefined()
expect(usageChunk?.inputTokens).toBe(10)
expect(usageChunk?.outputTokens).toBe(5)
})
it("should use bearer token authentication", async () => {
const stream = handler.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Verify the OpenAI client was created with bearer token
expect(vi.mocked(OpenAI)).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: expect.any(String), // Session token or "unauthenticated"
baseURL: "http://localhost:3000/v1",
}),
)
})
})
describe("error handling", () => {
const testMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello",
},
],
},
]
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
const stream = handler.createMessage("system prompt", testMessages)
await expect(async () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("API Error")
})
it("should handle authentication errors", async () => {
const authError = new Error("Unauthorized")
authError.name = "Error"
;(authError as any).status = 401
mockCreate.mockRejectedValueOnce(authError)
const stream = handler.createMessage("system prompt", testMessages)
await expect(async () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("Unauthorized")
})
})
describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: codexCliDefaultModelId,
messages: [{ role: "user", content: "Test prompt" }],
}),
)
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Codex CLI completion error: API Error")
})
it("should handle empty response", async () => {
mockCreate.mockImplementationOnce(() => ({
choices: [{ message: { content: "" } }],
}))
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
})
describe("getModel", () => {
it("should return model info for default model", () => {
const model = handler.getModel()
expect(model.id).toBe(codexCliDefaultModelId)
expect(model.info).toBeDefined()
expect(model.info?.contextWindow).toBeGreaterThan(0)
})
it("should handle custom model ID", () => {
const customHandler = new CodexCliHandler({
...mockOptions,
apiModelId: "gpt-4o",
})
const model = customHandler.getModel()
expect(model.id).toBe("gpt-4o")
})
it("should return model info with correct capabilities", () => {
const model = handler.getModel()
expect(model.info).toBeDefined()
expect(model.info?.supportsImages).toBeDefined()
expect(model.info?.supportsPromptCache).toBeDefined()
})
})
describe("integration with base provider", () => {
it("should properly extend BaseOpenAiCompatibleProvider", () => {
expect(handler).toHaveProperty("createMessage")
expect(handler).toHaveProperty("completePrompt")
expect(handler).toHaveProperty("getModel")
})
it("should handle model switching", () => {
const handlerWithDifferentModel = new CodexCliHandler({
...mockOptions,
apiModelId: "gpt-4o",
})
const model = handlerWithDifferentModel.getModel()
expect(model.id).toBe("gpt-4o")
})
it("should respect temperature settings", async () => {
const handlerWithTemp = new CodexCliHandler({
...mockOptions,
modelTemperature: 0.7,
})
const stream = handlerWithTemp.createMessage("system", [{ role: "user", content: "test" }])
// Consume stream to trigger API call
for await (const _chunk of stream) {
}
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.temperature).toBe(0.7)
})
})
})

View file

@ -0,0 +1,45 @@
import { type CodexCliModelId, codexCliDefaultModelId, codexCliModels } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { ContextProxy } from "../../core/config/ContextProxy"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
export class CodexCliHandler extends BaseOpenAiCompatibleProvider<CodexCliModelId> {
constructor(options: ApiHandlerOptions) {
// Get the session token from VS Code Secret Storage if available
let sessionToken = ""
let baseURL = "http://localhost:3000/v1" // Default local URL for Codex CLI
// Try to get the stored session token and base URL
try {
if (ContextProxy.instance) {
// Use getSecret for the session token (it's a secret)
sessionToken = ContextProxy.instance.getSecret("codexCliSessionToken") || ""
// Use getValue for the base URL (it's not a secret)
const storedBaseUrl = ContextProxy.instance.getValue("codexCliBaseUrl")
if (storedBaseUrl) {
baseURL = storedBaseUrl
}
}
} catch (error) {
// If ContextProxy is not initialized, continue with defaults
console.debug("ContextProxy not available, using default values for Codex CLI")
}
// Check if a custom CLI path is configured
const cliPath = options.codexCliPath
// Always construct the handler, even without a valid token.
// The backend will return 401 if authentication fails.
super({
...options,
providerName: "Codex CLI",
baseURL: options.codexCliBaseUrl || baseURL,
apiKey: sessionToken || "unauthenticated", // Use a placeholder if no token
defaultProviderModelId: codexCliDefaultModelId,
providerModels: codexCliModels,
defaultTemperature: 0.7,
})
}
}

View file

@ -4,6 +4,7 @@ export { AwsBedrockHandler } from "./bedrock"
export { CerebrasHandler } from "./cerebras"
export { ChutesHandler } from "./chutes"
export { ClaudeCodeHandler } from "./claude-code"
export { CodexCliHandler } from "./codex-cli"
export { DeepSeekHandler } from "./deepseek"
export { DoubaoHandler } from "./doubao"
export { MoonshotHandler } from "./moonshot"

View file

@ -2323,6 +2323,155 @@ export const webviewMessageHandler = async (
break
}
case "codexCliSignIn": {
try {
// Import child_process for spawning the CLI
const { spawn } = await import("child_process")
const { promisify } = await import("util")
const exec = promisify((await import("child_process")).exec)
// Get the CLI path from configuration or use default
const { apiConfiguration } = await provider.getState()
const cliPath = apiConfiguration.codexCliPath || "codex"
// First, check if the CLI is available
try {
await exec(`${cliPath} --version`)
} catch (error) {
vscode.window.showErrorMessage(
"Codex CLI not found. Please install it or specify the correct path.",
)
await provider.postMessageToWebview({
type: "codexCliAuthResult",
success: false,
error: "CLI not found",
})
return
}
// Start the local login flow
const loginProcess = spawn(cliPath, ["auth", "login", "--local"])
let output = ""
loginProcess.stdout.on("data", (data) => {
output += data.toString()
// Parse the output for the device code URL if present
const urlMatch = output.match(/https?:\/\/[^\s]+/)
if (urlMatch) {
// Open the URL in the browser for the user to authenticate
vscode.env.openExternal(vscode.Uri.parse(urlMatch[0]))
}
})
loginProcess.stderr.on("data", (data) => {
provider.log(`Codex CLI login error: ${data}`)
})
loginProcess.on("close", async (code) => {
if (code === 0) {
// Login successful, extract the token from the output
const tokenMatch = output.match(/token:\s*([^\s]+)/)
if (tokenMatch && tokenMatch[1]) {
const token = tokenMatch[1]
// Store the token in VS Code Secret Storage
await provider.contextProxy.storeSecret("codexCliSessionToken", token)
// Get the base URL if provided in the output
const baseUrlMatch = output.match(/base_url:\s*([^\s]+)/)
if (baseUrlMatch && baseUrlMatch[1]) {
await updateGlobalState("codexCliBaseUrl", baseUrlMatch[1])
}
await provider.postStateToWebview()
await provider.postMessageToWebview({
type: "codexCliAuthResult",
success: true,
})
vscode.window.showInformationMessage("Successfully signed in to Codex CLI")
} else {
vscode.window.showErrorMessage("Codex CLI login failed: Could not extract session token")
await provider.postMessageToWebview({
type: "codexCliAuthResult",
success: false,
error: "Failed to extract token",
})
}
} else {
vscode.window.showErrorMessage("Codex CLI login failed")
await provider.postMessageToWebview({
type: "codexCliAuthResult",
success: false,
error: `Process exited with code ${code}`,
})
}
})
} catch (error) {
provider.log(`Codex CLI sign in failed: ${error}`)
vscode.window.showErrorMessage("Codex CLI login failed")
await provider.postMessageToWebview({
type: "codexCliAuthResult",
success: false,
error: error instanceof Error ? error.message : String(error),
})
}
break
}
case "codexCliSignOut": {
try {
// Clear the stored session token
await provider.contextProxy.storeSecret("codexCliSessionToken", undefined)
await updateGlobalState("codexCliBaseUrl", undefined)
await provider.postStateToWebview()
vscode.window.showInformationMessage("Successfully signed out of Codex CLI")
} catch (error) {
provider.log(`Codex CLI sign out failed: ${error}`)
vscode.window.showErrorMessage("Failed to sign out of Codex CLI")
}
break
}
case "codexCliDetect": {
try {
const { promisify } = await import("util")
const exec = promisify((await import("child_process")).exec)
// Try to detect the CLI in PATH
try {
const { stdout } = await exec("which codex")
const cliPath = stdout.trim()
if (cliPath) {
// CLI found, check version
const { stdout: versionOutput } = await exec(`${cliPath} --version`)
await provider.postMessageToWebview({
type: "codexCliDetectResult",
found: true,
path: cliPath,
version: versionOutput.trim(),
})
} else {
await provider.postMessageToWebview({
type: "codexCliDetectResult",
found: false,
})
}
} catch (error) {
// CLI not found in PATH
await provider.postMessageToWebview({
type: "codexCliDetectResult",
found: false,
})
}
} catch (error) {
provider.log(`Codex CLI detection failed: ${error}`)
await provider.postMessageToWebview({
type: "codexCliDetectResult",
found: false,
error: error instanceof Error ? error.message : String(error),
})
}
break
}
case "rooCloudManualUrl": {
try {
if (!message.text) {

View file

@ -108,6 +108,8 @@ export interface ExtensionMessage {
| "mcpExecutionStatus"
| "vsCodeSetting"
| "authenticatedUser"
| "codexCliAuthResult"
| "codexCliDetectResult"
| "condenseTaskContextResponse"
| "singleRouterModelFetchResponse"
| "indexingStatusUpdate"
@ -201,6 +203,9 @@ export interface ExtensionMessage {
commands?: Command[]
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
found?: boolean // For codexCliDetectResult
path?: string // For codexCliDetectResult
version?: string // For codexCliDetectResult
}
export type ExtensionState = Pick<

View file

@ -182,6 +182,9 @@ export interface WebviewMessage {
| "rooCloudSignIn"
| "rooCloudSignOut"
| "rooCloudManualUrl"
| "codexCliSignIn"
| "codexCliSignOut"
| "codexCliDetect"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
| "startIndexing"

View file

@ -68,6 +68,7 @@ import {
Cerebras,
Chutes,
ClaudeCode,
CodexCli,
DeepSeek,
Doubao,
Gemini,
@ -680,6 +681,10 @@ const ApiOptions = ({
<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProvider === "codex-cli" && (
<CodexCli apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProviderModels.length > 0 && (
<>
<div>

View file

@ -5,6 +5,7 @@ import {
bedrockModels,
cerebrasModels,
claudeCodeModels,
codexCliModels,
deepSeekModels,
moonshotModels,
geminiModels,
@ -26,6 +27,7 @@ import {
export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, ModelInfo>>> = {
anthropic: anthropicModels,
"claude-code": claudeCodeModels,
"codex-cli": codexCliModels,
bedrock: bedrockModels,
cerebras: cerebrasModels,
deepseek: deepSeekModels,
@ -51,6 +53,7 @@ export const PROVIDERS = [
{ value: "deepinfra", label: "DeepInfra" },
{ value: "anthropic", label: "Anthropic" },
{ value: "claude-code", label: "Claude Code" },
{ value: "codex-cli", label: "Codex CLI" },
{ value: "cerebras", label: "Cerebras" },
{ value: "gemini", label: "Google Gemini" },
{ value: "doubao", label: "Doubao" },

View file

@ -0,0 +1,188 @@
import React, { useState, useEffect } from "react"
import { VSCodeTextField, VSCodeButton } 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"
interface CodexCliProps {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
}
export const CodexCli: React.FC<CodexCliProps> = ({ apiConfiguration, setApiConfigurationField }) => {
const { t } = useAppTranslation()
const [isSignedIn, setIsSignedIn] = useState(false)
const [isSigningIn, setIsSigningIn] = useState(false)
const [cliDetected, setCliDetected] = useState<boolean | null>(null)
const [cliVersion, setCliVersion] = useState<string | null>(null)
// Check if we have a session token stored
useEffect(() => {
// We can't directly access the secret storage from the webview,
// but we can infer the signed-in state from whether the provider is working
// For now, we'll rely on the backend to manage the session state
const checkSignInStatus = async () => {
// Request CLI detection to check if it's installed
vscode.postMessage({ type: "codexCliDetect" })
}
checkSignInStatus()
}, [])
// Listen for messages from the extension
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
switch (message.type) {
case "codexCliAuthResult":
setIsSigningIn(false)
if (message.success) {
setIsSignedIn(true)
}
break
case "codexCliDetectResult":
setCliDetected(message.found)
if (message.version) {
setCliVersion(message.version)
}
break
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
const handlePathChange = (e: Event | React.FormEvent<HTMLElement>) => {
const element = e.target as HTMLInputElement
setApiConfigurationField("codexCliPath", element.value)
}
const handleSignIn = () => {
setIsSigningIn(true)
vscode.postMessage({ type: "codexCliSignIn" })
}
const handleSignOut = () => {
setIsSignedIn(false)
vscode.postMessage({ type: "codexCliSignOut" })
}
const handleBaseUrlChange = (e: Event | React.FormEvent<HTMLElement>) => {
const element = e.target as HTMLInputElement
setApiConfigurationField("codexCliBaseUrl", element.value)
}
return (
<div className="flex flex-col gap-4">
{/* Sign In/Sign Out Section */}
<div className="flex flex-col gap-2">
{isSignedIn ? (
<>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.codexCli.authenticatedMessage", {
defaultValue: "You are signed in to Codex CLI",
})}
</div>
<VSCodeButton appearance="secondary" onClick={handleSignOut} className="w-fit">
{t("settings:providers.codexCli.signOutButton", { defaultValue: "Sign Out" })}
</VSCodeButton>
</>
) : (
<>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.codexCli.signInMessage", {
defaultValue: "Sign in to use Codex CLI without API keys",
})}
</div>
<VSCodeButton
appearance="primary"
onClick={handleSignIn}
disabled={isSigningIn}
className="w-fit">
{isSigningIn
? t("settings:providers.codexCli.signingInButton", { defaultValue: "Signing In..." })
: t("settings:providers.codexCli.signInButton", { defaultValue: "Sign In" })}
</VSCodeButton>
</>
)}
</div>
{/* CLI Detection Status */}
{cliDetected !== null && (
<div className="text-sm">
{cliDetected ? (
<div className="text-vscode-testing-iconPassed">
{" "}
{t("settings:providers.codexCli.cliDetected", {
defaultValue: "Codex CLI detected",
version: cliVersion || "",
})}
{cliVersion && ` (${cliVersion})`}
</div>
) : (
<div className="text-vscode-testing-iconFailed">
{" "}
{t("settings:providers.codexCli.cliNotDetected", {
defaultValue: "Codex CLI not found in PATH",
})}
</div>
)}
</div>
)}
{/* CLI Path Configuration */}
<div>
<VSCodeTextField
value={apiConfiguration?.codexCliPath || ""}
style={{ width: "100%", marginTop: 3 }}
type="text"
onInput={handlePathChange}
placeholder={t("settings:providers.codexCli.pathPlaceholder", {
defaultValue: "codex (or full path to CLI)",
})}>
{t("settings:providers.codexCli.pathLabel", {
defaultValue: "CLI Path (optional)",
})}
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
{t("settings:providers.codexCli.pathDescription", {
defaultValue:
"Leave empty to use 'codex' from PATH, or specify the full path to the CLI executable",
})}
</p>
</div>
{/* Base URL Configuration (Advanced) */}
<div>
<VSCodeTextField
value={apiConfiguration?.codexCliBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="text"
onInput={handleBaseUrlChange}
placeholder={t("settings:providers.codexCli.baseUrlPlaceholder", {
defaultValue: "http://localhost:3000/v1",
})}>
{t("settings:providers.codexCli.baseUrlLabel", {
defaultValue: "Base URL (optional)",
})}
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
{t("settings:providers.codexCli.baseUrlDescription", {
defaultValue:
"Override the base URL if the CLI exposes a custom gateway (defaults to http://localhost:3000/v1)",
})}
</p>
</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 { CodexCli } from "./CodexCli"
export { DeepSeek } from "./DeepSeek"
export { Doubao } from "./Doubao"
export { Gemini } from "./Gemini"