Skip to content

Commit 3f4b427

Browse files
committed
feat: add Codex CLI (native) provider with local authentication
- Add codex-cli-native provider type to types package - Create CodexCliHandler for CLI authentication operations - Add CodexCliNative UI component with sign-in/sign-out functionality - Implement message handlers for authentication flow - Add secret storage for bearer token - Reuse OpenAI Native handler with locally obtained token - Keep UI text generic as requested
1 parent 2263d86 commit 3f4b427

File tree

12 files changed

+408
-4
lines changed

12 files changed

+408
-4
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export const SECRET_STATE_KEYS = [
181181
"geminiApiKey",
182182
"openAiNativeApiKey",
183183
"cerebrasApiKey",
184+
"codexCliOpenAiNativeToken",
184185
"deepSeekApiKey",
185186
"doubaoApiKey",
186187
"moonshotApiKey",

packages/types/src/provider-settings.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
export const providerNames = [
3535
"anthropic",
3636
"claude-code",
37+
"codex-cli-native",
3738
"glama",
3839
"openrouter",
3940
"bedrock",
@@ -343,13 +344,19 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
343344
vercelAiGatewayModelId: z.string().optional(),
344345
})
345346

347+
const codexCliNativeSchema = apiModelIdProviderModelSchema.extend({
348+
codexCliPath: z.string().optional(),
349+
// No API key field - uses token from secrets
350+
})
351+
346352
const defaultSchema = z.object({
347353
apiProvider: z.undefined(),
348354
})
349355

350356
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
351357
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
352358
claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
359+
codexCliNativeSchema.merge(z.object({ apiProvider: z.literal("codex-cli-native") })),
353360
glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })),
354361
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
355362
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
@@ -391,6 +398,7 @@ export const providerSettingsSchema = z.object({
391398
apiProvider: providerNamesSchema.optional(),
392399
...anthropicSchema.shape,
393400
...claudeCodeSchema.shape,
401+
...codexCliNativeSchema.shape,
394402
...glamaSchema.shape,
395403
...openRouterSchema.shape,
396404
...bedrockSchema.shape,
@@ -483,7 +491,10 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
483491
}
484492

485493
export const MODELS_BY_PROVIDER: Record<
486-
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama">,
494+
Exclude<
495+
ProviderName,
496+
"fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama" | "codex-cli-native"
497+
>,
487498
{ id: ProviderName; label: string; models: string[] }
488499
> = {
489500
anthropic: {

src/api/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
9595
return new AnthropicHandler(options)
9696
case "claude-code":
9797
return new ClaudeCodeHandler(options)
98+
case "codex-cli-native":
99+
// Reuse OpenAI Native handler with token from secrets
100+
// The token will be injected from the secret storage
101+
// Note: The token is stored in secrets as codexCliOpenAiNativeToken
102+
// and will be injected into openAiNativeApiKey for the handler
103+
return new OpenAiNativeHandler({
104+
...options,
105+
openAiNativeApiKey: (options as any).codexCliOpenAiNativeToken,
106+
openAiNativeBaseUrl: options.openAiNativeBaseUrl || "https://api.openai.com",
107+
})
98108
case "glama":
99109
return new GlamaHandler(options)
100110
case "openrouter":
@@ -166,7 +176,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
166176
case "vercel-ai-gateway":
167177
return new VercelAiGatewayHandler(options)
168178
default:
169-
apiProvider satisfies "gemini-cli" | undefined
179+
apiProvider satisfies "gemini-cli" | "codex-cli-native" | undefined
170180
return new AnthropicHandler(options)
171181
}
172182
}

src/core/config/ContextProxy.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,37 @@ export class ContextProxy {
267267
const values = this.getValues()
268268

269269
try {
270-
return providerSettingsSchema.parse(values)
270+
const settings = providerSettingsSchema.parse(values)
271+
272+
// For codex-cli-native provider, inject the token from secrets
273+
if (settings.apiProvider === "codex-cli-native") {
274+
const token = this.getSecret("codexCliOpenAiNativeToken" as SecretStateKey)
275+
if (token) {
276+
// Add the token to the settings object so it can be used by the API handler
277+
;(settings as any).codexCliOpenAiNativeToken = token
278+
}
279+
}
280+
281+
return settings
271282
} catch (error) {
272283
if (error instanceof ZodError) {
273284
TelemetryService.instance.captureSchemaValidationError({ schemaName: "ProviderSettings", error })
274285
}
275286

276-
return PROVIDER_SETTINGS_KEYS.reduce((acc, key) => ({ ...acc, [key]: values[key] }), {} as ProviderSettings)
287+
const settings = PROVIDER_SETTINGS_KEYS.reduce(
288+
(acc, key) => ({ ...acc, [key]: values[key] }),
289+
{} as ProviderSettings,
290+
)
291+
292+
// For codex-cli-native provider, inject the token from secrets (fallback case)
293+
if (settings.apiProvider === "codex-cli-native") {
294+
const token = this.getSecret("codexCliOpenAiNativeToken" as SecretStateKey)
295+
if (token) {
296+
;(settings as any).codexCliOpenAiNativeToken = token
297+
}
298+
}
299+
300+
return settings
277301
}
278302
}
279303

src/core/webview/webviewMessageHandler.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2323,6 +2323,102 @@ export const webviewMessageHandler = async (
23232323

23242324
break
23252325
}
2326+
case "codexCliNativeCheckToken": {
2327+
// Check if token exists in secrets
2328+
const token = await provider.context.secrets.get("codexCliOpenAiNativeToken")
2329+
await provider.postMessageToWebview({
2330+
type: "codexCliNativeTokenStatus",
2331+
hasToken: !!token,
2332+
})
2333+
break
2334+
}
2335+
case "codexCliNativeSignIn": {
2336+
try {
2337+
// Import the CLI handler module
2338+
const { CodexCliHandler } = await import("../../services/codex-cli/CodexCliHandler")
2339+
2340+
// Get the CLI path from settings or use default
2341+
const cliPath = message.text || "codex"
2342+
2343+
// Run the sign-in flow
2344+
const handler = new CodexCliHandler(cliPath)
2345+
const token = await handler.signIn()
2346+
2347+
if (token) {
2348+
// Store the token in secrets
2349+
await provider.context.secrets.store("codexCliOpenAiNativeToken", token)
2350+
2351+
// Notify the webview of success
2352+
await provider.postMessageToWebview({
2353+
type: "codexCliNativeSignInResult",
2354+
success: true,
2355+
})
2356+
2357+
// Update the state to reflect the new token
2358+
await provider.postStateToWebview()
2359+
} else {
2360+
throw new Error("Failed to obtain token from CLI")
2361+
}
2362+
} catch (error) {
2363+
provider.log(`CodexCliNative sign-in failed: ${error}`)
2364+
await provider.postMessageToWebview({
2365+
type: "codexCliNativeSignInResult",
2366+
success: false,
2367+
error: error instanceof Error ? error.message : String(error),
2368+
})
2369+
}
2370+
break
2371+
}
2372+
case "codexCliNativeSignOut": {
2373+
try {
2374+
// Clear the token from secrets
2375+
await provider.context.secrets.delete("codexCliOpenAiNativeToken")
2376+
2377+
// Notify the webview of success
2378+
await provider.postMessageToWebview({
2379+
type: "codexCliNativeSignOutResult",
2380+
success: true,
2381+
})
2382+
2383+
// Update the state
2384+
await provider.postStateToWebview()
2385+
} catch (error) {
2386+
provider.log(`CodexCliNative sign-out failed: ${error}`)
2387+
await provider.postMessageToWebview({
2388+
type: "codexCliNativeSignOutResult",
2389+
success: false,
2390+
error: error instanceof Error ? error.message : String(error),
2391+
})
2392+
}
2393+
break
2394+
}
2395+
case "codexCliNativeDetect": {
2396+
try {
2397+
// Import the CLI handler module
2398+
const { CodexCliHandler } = await import("../../services/codex-cli/CodexCliHandler")
2399+
2400+
// Get the CLI path from settings or use default
2401+
const cliPath = message.text || "codex"
2402+
2403+
// Check if CLI is available
2404+
const handler = new CodexCliHandler(cliPath)
2405+
const isAvailable = await handler.detect()
2406+
2407+
await provider.postMessageToWebview({
2408+
type: "codexCliNativeDetectResult",
2409+
available: isAvailable,
2410+
path: isAvailable ? cliPath : undefined,
2411+
})
2412+
} catch (error) {
2413+
provider.log(`CodexCliNative detect failed: ${error}`)
2414+
await provider.postMessageToWebview({
2415+
type: "codexCliNativeDetectResult",
2416+
available: false,
2417+
error: error instanceof Error ? error.message : String(error),
2418+
})
2419+
}
2420+
break
2421+
}
23262422
case "rooCloudManualUrl": {
23272423
try {
23282424
if (!message.text) {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { spawn } from "child_process"
2+
import * as vscode from "vscode"
3+
4+
/**
5+
* Handler for Codex CLI authentication operations
6+
* Based on the ChatMock reference implementation
7+
*/
8+
export class CodexCliHandler {
9+
constructor(private cliPath: string = "codex") {}
10+
11+
/**
12+
* Detect if the CLI is available
13+
*/
14+
async detect(): Promise<boolean> {
15+
return new Promise((resolve) => {
16+
const process = spawn(this.cliPath, ["--version"], {
17+
shell: true,
18+
windowsHide: true,
19+
})
20+
21+
process.on("error", () => {
22+
resolve(false)
23+
})
24+
25+
process.on("exit", (code) => {
26+
resolve(code === 0)
27+
})
28+
29+
// Timeout after 5 seconds
30+
setTimeout(() => {
31+
process.kill()
32+
resolve(false)
33+
}, 5000)
34+
})
35+
}
36+
37+
/**
38+
* Run the sign-in flow to obtain a bearer token
39+
*/
40+
async signIn(): Promise<string | null> {
41+
return new Promise((resolve, reject) => {
42+
// Run the CLI auth command
43+
const process = spawn(this.cliPath, ["auth", "login", "--json"], {
44+
shell: true,
45+
windowsHide: true,
46+
})
47+
48+
let stdout = ""
49+
let stderr = ""
50+
51+
process.stdout?.on("data", (data) => {
52+
stdout += data.toString()
53+
})
54+
55+
process.stderr?.on("data", (data) => {
56+
stderr += data.toString()
57+
})
58+
59+
process.on("error", (error) => {
60+
reject(new Error(`Failed to spawn CLI: ${error.message}`))
61+
})
62+
63+
process.on("exit", (code) => {
64+
if (code === 0) {
65+
try {
66+
// Parse the JSON output to extract the token
67+
const result = JSON.parse(stdout)
68+
if (result.token) {
69+
resolve(result.token)
70+
} else {
71+
reject(new Error("No token in CLI response"))
72+
}
73+
} catch (error) {
74+
// If JSON parsing fails, try to extract token from plain text
75+
const tokenMatch = stdout.match(/token[:\s]+([a-zA-Z0-9\-._~+/]+=*)/i)
76+
if (tokenMatch) {
77+
resolve(tokenMatch[1])
78+
} else {
79+
reject(new Error(`Failed to parse CLI output: ${stdout}`))
80+
}
81+
}
82+
} else {
83+
reject(new Error(`CLI exited with code ${code}: ${stderr || stdout}`))
84+
}
85+
})
86+
87+
// Timeout after 2 minutes (to allow for browser auth flow)
88+
setTimeout(() => {
89+
process.kill()
90+
reject(new Error("Sign-in timed out"))
91+
}, 120000)
92+
})
93+
}
94+
95+
/**
96+
* Check if a token is valid by making a test API call
97+
*/
98+
async validateToken(token: string): Promise<boolean> {
99+
try {
100+
// Make a simple API call to validate the token
101+
const response = await fetch("https://api.openai.com/v1/models", {
102+
headers: {
103+
Authorization: `Bearer ${token}`,
104+
},
105+
})
106+
return response.ok
107+
} catch {
108+
return false
109+
}
110+
}
111+
}

src/shared/ExtensionMessage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export interface ExtensionMessage {
124124
| "commands"
125125
| "insertTextIntoTextarea"
126126
| "dismissedUpsells"
127+
| "codexCliNativeTokenStatus"
128+
| "codexCliNativeSignInResult"
129+
| "codexCliNativeSignOutResult"
130+
| "codexCliNativeDetectResult"
127131
text?: string
128132
payload?: any // Add a generic payload for now, can refine later
129133
action?:
@@ -201,6 +205,9 @@ export interface ExtensionMessage {
201205
commands?: Command[]
202206
queuedMessages?: QueuedMessage[]
203207
list?: string[] // For dismissedUpsells
208+
hasToken?: boolean // For codexCliNativeTokenStatus
209+
available?: boolean // For codexCliNativeDetectResult
210+
path?: string // For codexCliNativeDetectResult
204211
}
205212

206213
export type ExtensionState = Pick<

src/shared/WebviewMessage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ export interface WebviewMessage {
225225
| "editQueuedMessage"
226226
| "dismissUpsell"
227227
| "getDismissedUpsells"
228+
| "codexCliNativeCheckToken"
229+
| "codexCliNativeSignIn"
230+
| "codexCliNativeSignOut"
231+
| "codexCliNativeDetect"
228232
text?: string
229233
editedMessageContent?: string
230234
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
Cerebras,
6969
Chutes,
7070
ClaudeCode,
71+
CodexCliNative,
7172
DeepSeek,
7273
Doubao,
7374
Gemini,
@@ -322,6 +323,7 @@ const ApiOptions = ({
322323
"claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId },
323324
"qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId },
324325
"openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
326+
"codex-cli-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
325327
gemini: { field: "apiModelId", default: geminiDefaultModelId },
326328
deepseek: { field: "apiModelId", default: deepSeekDefaultModelId },
327329
doubao: { field: "apiModelId", default: doubaoDefaultModelId },
@@ -513,6 +515,13 @@ const ApiOptions = ({
513515
<ClaudeCode apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
514516
)}
515517

518+
{selectedProvider === "codex-cli-native" && (
519+
<CodexCliNative
520+
apiConfiguration={apiConfiguration}
521+
setApiConfigurationField={setApiConfigurationField}
522+
/>
523+
)}
524+
516525
{selectedProvider === "openai-native" && (
517526
<OpenAI
518527
apiConfiguration={apiConfiguration}

0 commit comments

Comments
 (0)