diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 7e79855f7e..376173c12d 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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 diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 6d628ddfdf..f3a9e2ac9e 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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, @@ -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", diff --git a/packages/types/src/providers/codex-cli.ts b/packages/types/src/providers/codex-cli.ts new file mode 100644 index 0000000000..5abc5f1f38 --- /dev/null +++ b/packages/types/src/providers/codex-cli.ts @@ -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 = { + "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", + }, +} diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 21e43aaa99..3ffc7bdd37 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -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" diff --git a/src/api/index.ts b/src/api/index.ts index ac00967676..ab71450910 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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": diff --git a/src/api/providers/__tests__/codex-cli.spec.ts b/src/api/providers/__tests__/codex-cli.spec.ts new file mode 100644 index 0000000000..5880dfba86 --- /dev/null +++ b/src/api/providers/__tests__/codex-cli.spec.ts @@ -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) + }) + }) +}) diff --git a/src/api/providers/codex-cli.ts b/src/api/providers/codex-cli.ts new file mode 100644 index 0000000000..1e78ab1155 --- /dev/null +++ b/src/api/providers/codex-cli.ts @@ -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 { + 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, + }) + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 85d877b6bc..902c9a14b3 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -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" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index abdfae29fa..9884ffbd59 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb..0c6bb2b755 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..3bbcec2b87 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -182,6 +182,9 @@ export interface WebviewMessage { | "rooCloudSignIn" | "rooCloudSignOut" | "rooCloudManualUrl" + | "codexCliSignIn" + | "codexCliSignOut" + | "codexCliDetect" | "condenseTaskContextRequest" | "requestIndexingStatus" | "startIndexing" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 7f2ac4ed7a..c2f83418d5 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -68,6 +68,7 @@ import { Cerebras, Chutes, ClaudeCode, + CodexCli, DeepSeek, Doubao, Gemini, @@ -680,6 +681,10 @@ const ApiOptions = ({ )} + {selectedProvider === "codex-cli" && ( + + )} + {selectedProviderModels.length > 0 && ( <>
diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index ae336730ff..31357e90b2 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -5,6 +5,7 @@ import { bedrockModels, cerebrasModels, claudeCodeModels, + codexCliModels, deepSeekModels, moonshotModels, geminiModels, @@ -26,6 +27,7 @@ import { export const MODELS_BY_PROVIDER: Partial>> = { 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" }, diff --git a/webview-ui/src/components/settings/providers/CodexCli.tsx b/webview-ui/src/components/settings/providers/CodexCli.tsx new file mode 100644 index 0000000000..dfce584e22 --- /dev/null +++ b/webview-ui/src/components/settings/providers/CodexCli.tsx @@ -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 = ({ apiConfiguration, setApiConfigurationField }) => { + const { t } = useAppTranslation() + const [isSignedIn, setIsSignedIn] = useState(false) + const [isSigningIn, setIsSigningIn] = useState(false) + const [cliDetected, setCliDetected] = useState(null) + const [cliVersion, setCliVersion] = useState(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) => { + 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) => { + const element = e.target as HTMLInputElement + setApiConfigurationField("codexCliBaseUrl", element.value) + } + + return ( +
+ {/* Sign In/Sign Out Section */} +
+ {isSignedIn ? ( + <> +
+ {t("settings:providers.codexCli.authenticatedMessage", { + defaultValue: "You are signed in to Codex CLI", + })} +
+ + {t("settings:providers.codexCli.signOutButton", { defaultValue: "Sign Out" })} + + + ) : ( + <> +
+ {t("settings:providers.codexCli.signInMessage", { + defaultValue: "Sign in to use Codex CLI without API keys", + })} +
+ + {isSigningIn + ? t("settings:providers.codexCli.signingInButton", { defaultValue: "Signing In..." }) + : t("settings:providers.codexCli.signInButton", { defaultValue: "Sign In" })} + + + )} +
+ + {/* CLI Detection Status */} + {cliDetected !== null && ( +
+ {cliDetected ? ( +
+ ✓{" "} + {t("settings:providers.codexCli.cliDetected", { + defaultValue: "Codex CLI detected", + version: cliVersion || "", + })} + {cliVersion && ` (${cliVersion})`} +
+ ) : ( +
+ ✗{" "} + {t("settings:providers.codexCli.cliNotDetected", { + defaultValue: "Codex CLI not found in PATH", + })} +
+ )} +
+ )} + + {/* CLI Path Configuration */} +
+ + {t("settings:providers.codexCli.pathLabel", { + defaultValue: "CLI Path (optional)", + })} + +

+ {t("settings:providers.codexCli.pathDescription", { + defaultValue: + "Leave empty to use 'codex' from PATH, or specify the full path to the CLI executable", + })} +

+
+ + {/* Base URL Configuration (Advanced) */} +
+ + {t("settings:providers.codexCli.baseUrlLabel", { + defaultValue: "Base URL (optional)", + })} + +

+ {t("settings:providers.codexCli.baseUrlDescription", { + defaultValue: + "Override the base URL if the CLI exposes a custom gateway (defaults to http://localhost:3000/v1)", + })} +

+
+
+ ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index fe0e6cecf9..ee38c0946f 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -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" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f8a005e86a..4b9633db7e 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -36,6 +36,8 @@ import { litellmDefaultModelId, claudeCodeDefaultModelId, claudeCodeModels, + codexCliDefaultModelId, + codexCliModels, sambaNovaModels, sambaNovaDefaultModelId, doubaoModels, @@ -343,6 +345,12 @@ function getSelectedModel({ const info = qwenCodeModels[id as keyof typeof qwenCodeModels] return { id, info } } + case "codex-cli": { + // Codex CLI models are OpenAI-compatible + const id = apiConfiguration.apiModelId ?? codexCliDefaultModelId + const info = codexCliModels[id as keyof typeof codexCliModels] + return { id, info: { ...openAiModelInfoSaneDefaults, ...info } } + } case "vercel-ai-gateway": { const id = apiConfiguration.vercelAiGatewayModelId ?? vercelAiGatewayDefaultModelId const info = routerModels["vercel-ai-gateway"]?.[id] @@ -352,7 +360,7 @@ function getSelectedModel({ // case "human-relay": // case "fake-ai": default: { - provider satisfies "anthropic" | "gemini-cli" | "qwen-code" | "human-relay" | "fake-ai" + provider satisfies "anthropic" | "gemini-cli" | "human-relay" | "fake-ai" const id = apiConfiguration.apiModelId ?? anthropicDefaultModelId const baseInfo = anthropicModels[id as keyof typeof anthropicModels]