mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
2263d86a20
commit
26d766bd7b
15 changed files with 819 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
91
packages/types/src/providers/codex-cli.ts
Normal file
91
packages/types/src/providers/codex-cli.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
308
src/api/providers/__tests__/codex-cli.spec.ts
Normal file
308
src/api/providers/__tests__/codex-cli.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
45
src/api/providers/codex-cli.ts
Normal file
45
src/api/providers/codex-cli.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ export interface WebviewMessage {
|
|||
| "rooCloudSignIn"
|
||||
| "rooCloudSignOut"
|
||||
| "rooCloudManualUrl"
|
||||
| "codexCliSignIn"
|
||||
| "codexCliSignOut"
|
||||
| "codexCliDetect"
|
||||
| "condenseTaskContextRequest"
|
||||
| "requestIndexingStatus"
|
||||
| "startIndexing"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
188
webview-ui/src/components/settings/providers/CodexCli.tsx
Normal file
188
webview-ui/src/components/settings/providers/CodexCli.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue