From 0af7822b9aed8d36265d8e6f2f2e569b28cbf730 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 18 May 2026 11:03:31 -0400 Subject: [PATCH 1/2] add Codex provider support --- desktop/src/main/codexAuth.ts | 432 ++++++++++++++++ desktop/src/main/index.ts | 3 + desktop/src/preload/index.ts | 45 ++ desktop/src/renderer/src/App.tsx | 103 ++++ .../src/components/CodexAuthPanel.tsx | 63 +++ .../src/renderer/src/components/LlmForm.tsx | 20 +- .../renderer/src/components/WizardScreen.tsx | 20 + .../renderer/src/components/steps/LlmStep.tsx | 32 +- .../src/components/steps/WorkerLlmStep.tsx | 35 +- desktop/src/renderer/src/lib/i18n.ts | 11 + desktop/src/renderer/src/lib/install.ts | 25 +- desktop/src/renderer/src/lib/logos.ts | 1 + desktop/src/renderer/src/lib/wizard.ts | 20 +- desktop/src/renderer/src/styles.css | 68 +++ desktop/src/renderer/src/vite-env.d.ts | 33 ++ src/octopal/cli/configure.py | 2 +- .../infrastructure/providers/catalog.py | 10 + .../providers/codex_provider.py | 489 ++++++++++++++++++ .../infrastructure/providers/factory.py | 22 + src/octopal/runtime/app.py | 4 +- src/octopal/runtime/workers/agent_worker.py | 7 +- tests/test_codex_provider_catalog.py | 32 ++ 22 files changed, 1445 insertions(+), 32 deletions(-) create mode 100644 desktop/src/main/codexAuth.ts create mode 100644 desktop/src/renderer/src/components/CodexAuthPanel.tsx create mode 100644 src/octopal/infrastructure/providers/codex_provider.py create mode 100644 src/octopal/infrastructure/providers/factory.py create mode 100644 tests/test_codex_provider_catalog.py diff --git a/desktop/src/main/codexAuth.ts b/desktop/src/main/codexAuth.ts new file mode 100644 index 00000000..f4852b37 --- /dev/null +++ b/desktop/src/main/codexAuth.ts @@ -0,0 +1,432 @@ +import { BrowserWindow, app, ipcMain, shell } from "electron"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import readline from "node:readline"; + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +type RpcResponse = { + id?: number | string; + result?: JsonValue; + error?: { + code?: number; + message?: string; + data?: JsonValue; + }; +}; + +type RpcNotification = { + method: string; + params?: unknown; +}; + +type PendingRequest = { + method: string; + resolve: (value: JsonValue) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +}; + +export type CodexAuthStatus = { + available: boolean; + connected: boolean; + accountLabel?: string; + accountType?: string; + requiresOpenAIAuth?: boolean; + error?: string; +}; + +export type CodexModelInfo = { + id: string; + model: string; + displayName: string; + hidden?: boolean; +}; + +const CODEX_REQUEST_TIMEOUT_MS = 30_000; +const CODEX_LOGIN_TIMEOUT_MS = 10 * 60_000; + +class CodexAppServerClient extends EventEmitter { + private child: ChildProcessWithoutNullStreams | null = null; + private lines: readline.Interface | null = null; + private initialized = false; + private closed = false; + private nextId = 1; + private stderrTail = ""; + private readonly pending = new Map(); + + constructor( + private readonly command: string, + private readonly args: string[], + private readonly env: NodeJS.ProcessEnv, + ) { + super(); + } + + async start(): Promise { + if (this.initialized) { + return; + } + + this.child = spawn(this.command, this.args, { + env: this.env, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + this.child.once("error", (error) => this.closeWithError(error)); + this.child.once("exit", (code, signal) => { + this.closeWithError( + new Error( + `codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}${ + this.stderrTail ? `: ${this.stderrTail}` : "" + }`, + ), + ); + }); + this.child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + this.stderrTail = `${this.stderrTail}${text}`.slice(-4000); + const trimmed = text.trim(); + if (trimmed) { + console.log("[CodexAppServer]", trimmed); + } + }); + + this.lines = readline.createInterface({ input: this.child.stdout }); + this.lines.on("line", (line) => this.handleLine(line)); + + await this.request("initialize", { + clientInfo: { + name: "octopal_desktop", + title: "Octopal Desktop", + version: app.getVersion(), + }, + capabilities: { + experimentalApi: true, + }, + }); + this.notify("initialized", {}); + this.initialized = true; + } + + async request(method: string, params?: JsonValue, timeoutMs = CODEX_REQUEST_TIMEOUT_MS): Promise { + if (this.closed) { + throw new Error("codex app-server client is closed"); + } + if (!this.child) { + throw new Error("codex app-server is not running"); + } + + const id = this.nextId++; + const message = { method, id, ...(params === undefined ? {} : { params }) }; + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out`)); + }, timeoutMs); + + this.pending.set(id, { method, resolve, reject, timer }); + this.child?.stdin.write(`${JSON.stringify(message)}\n`, (error) => { + if (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(error); + } + }); + }); + } + + notify(method: string, params?: JsonValue): void { + if (!this.child || this.closed) { + return; + } + const message = { method, ...(params === undefined ? {} : { params }) }; + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + close(): void { + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("codex app-server client closed")); + } + this.pending.clear(); + this.lines?.close(); + this.lines = null; + this.child?.kill(); + this.child = null; + } + + private handleLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + let message: RpcResponse | RpcNotification; + try { + message = JSON.parse(trimmed) as RpcResponse | RpcNotification; + } catch { + console.warn("[CodexAppServer] Failed to parse JSON-RPC line:", trimmed); + return; + } + + if ("id" in message && message.id !== undefined) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(message.id); + if ("error" in message && message.error) { + pending.reject(new Error(message.error.message || `${pending.method} failed`)); + } else { + pending.resolve(message.result ?? null); + } + return; + } + + if ("method" in message && typeof message.method === "string") { + this.emit("notification", message); + } + } + + private closeWithError(error: Error): void { + if (this.closed) { + return; + } + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + this.emit("error", error); + } +} + +class CodexAuthManager { + private client: CodexAppServerClient | null = null; + private starting: Promise | null = null; + + async getStatus(): Promise { + try { + const client = await this.getClient(); + const result = (await client.request("account/read", { refreshToken: false })) as Record; + return normalizeAccountStatus(result); + } catch (error) { + return { + available: false, + connected: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async startLogin(): Promise<{ success: boolean; authUrl?: string; loginId?: string; error?: string }> { + try { + const client = await this.getClient(); + const response = (await client.request("account/login/start", { + type: "chatgpt", + codexStreamlinedLogin: true, + })) as Record; + + const authUrl = typeof response.authUrl === "string" ? response.authUrl : ""; + if (response.type !== "chatgpt" || !authUrl) { + return { success: false, error: "Codex did not return a ChatGPT authorization URL." }; + } + + void shell.openExternal(authUrl); + this.waitForLoginCompletion(client, typeof response.loginId === "string" ? response.loginId : undefined); + return { success: true, authUrl, loginId: typeof response.loginId === "string" ? response.loginId : undefined }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + async logout(): Promise<{ success: boolean; error?: string }> { + try { + const client = await this.getClient(); + await client.request("account/logout"); + this.broadcast("codex-auth-status-changed", { available: true, connected: false }); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + async listModels(): Promise<{ success: boolean; models?: CodexModelInfo[]; error?: string }> { + try { + const client = await this.getClient(); + const response = (await client.request("model/list", { + cursor: null, + limit: 100, + includeHidden: false, + })) as Record; + const data = Array.isArray(response.data) ? response.data : []; + return { success: true, models: data.map((item) => normalizeModelInfo(item)).filter(Boolean) as CodexModelInfo[] }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + stop(): void { + this.client?.close(); + this.client = null; + this.starting = null; + } + + private async getClient(): Promise { + if (this.client) { + return this.client; + } + if (!this.starting) { + this.starting = this.createClient(); + } + this.client = await this.starting; + this.starting = null; + return this.client; + } + + private async createClient(): Promise { + const command = process.env.OCTOPAL_CODEX_COMMAND || "codex"; + const args = (process.env.OCTOPAL_CODEX_ARGS || "app-server") + .split(/\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + const client = new CodexAppServerClient(command, args, { ...process.env }); + client.on("notification", (notification) => this.handleNotification(notification as RpcNotification)); + client.on("error", (error) => { + console.error("[CodexAppServer] Client error:", error); + if (this.client === client) { + this.client = null; + } + }); + await client.start(); + return client; + } + + private handleNotification(notification: RpcNotification): void { + if (notification.method === "account/login/completed") { + this.broadcast("codex-auth-login-completed", notification.params); + return; + } + if (notification.method === "account/updated") { + this.broadcast("codex-auth-updated", notification.params); + } + } + + private waitForLoginCompletion(client: CodexAppServerClient, loginId: string | undefined): void { + const timer = setTimeout(() => { + client.off("notification", onNotification); + }, CODEX_LOGIN_TIMEOUT_MS); + + const onNotification = (notification: RpcNotification) => { + if (notification.method !== "account/login/completed") { + return; + } + const params = notification.params && typeof notification.params === "object" ? (notification.params as Record) : {}; + if (loginId && typeof params.loginId === "string" && params.loginId !== loginId) { + return; + } + clearTimeout(timer); + client.off("notification", onNotification); + void this.getStatus().then((status) => { + this.broadcast("codex-auth-status-changed", status); + }); + }; + + client.on("notification", onNotification); + } + + private broadcast(channel: string, payload: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send(channel, payload); + } + } + } +} + +function normalizeAccountStatus(response: Record): CodexAuthStatus { + const account = response.account && typeof response.account === "object" ? (response.account as Record) : null; + if (!account) { + return { + available: true, + connected: false, + requiresOpenAIAuth: Boolean(response.requiresOpenAIAuth || response.requiresOpenaiAuth), + }; + } + + const accountType = typeof account.type === "string" ? account.type : "connected"; + if (accountType === "chatgpt") { + const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : "ChatGPT"; + const plan = typeof account.planType === "string" && account.planType.trim() ? ` (${account.planType.trim()})` : ""; + return { + available: true, + connected: true, + accountType, + accountLabel: `${email}${plan}`, + requiresOpenAIAuth: false, + }; + } + + if (accountType === "apiKey") { + return { + available: true, + connected: true, + accountType, + accountLabel: "OpenAI API key", + requiresOpenAIAuth: false, + }; + } + + return { + available: true, + connected: true, + accountType, + accountLabel: accountType, + requiresOpenAIAuth: false, + }; +} + +function normalizeModelInfo(model: unknown): CodexModelInfo | null { + if (!model || typeof model !== "object") { + return null; + } + const record = model as Record; + const id = typeof record.id === "string" ? record.id : typeof record.model === "string" ? record.model : ""; + if (!id) { + return null; + } + return { + id, + model: typeof record.model === "string" ? record.model : id, + displayName: typeof record.displayName === "string" ? record.displayName : id, + hidden: Boolean(record.hidden), + }; +} + +export const codexAuthManager = new CodexAuthManager(); + +let codexAuthIPCHandlersRegistered = false; + +export function registerCodexAuthIPCHandlers(): void { + if (codexAuthIPCHandlersRegistered) { + return; + } + codexAuthIPCHandlersRegistered = true; + + ipcMain.handle("codex-auth:status", async () => codexAuthManager.getStatus()); + ipcMain.handle("codex-auth:start-login", async () => codexAuthManager.startLogin()); + ipcMain.handle("codex-auth:disconnect", async () => codexAuthManager.logout()); + ipcMain.handle("codex-models:list", async () => codexAuthManager.listModels()); +} + +export function stopCodexAuthServer(): void { + codexAuthManager.stop(); +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index e1164703..1a52003c 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -19,6 +19,7 @@ import { type ConnectorAuthPayload, type ConnectorName, } from "./connectors"; +import { registerCodexAuthIPCHandlers, stopCodexAuthServer } from "./codexAuth"; import { checkOctopalUpdateSafely, ensureWorkspaceBootstrap, @@ -984,6 +985,7 @@ ipcMain.handle( ipcMain.handle("desktop:start-whatsapp-link", async (_event, installDir: string) => startWhatsAppLink(installDir)); ipcMain.handle("desktop:get-whatsapp-link-status", async (_event, installDir: string) => getWhatsAppLinkStatus(installDir)); ipcMain.handle("desktop:stop-whatsapp-link", async (_event, installDir: string) => stopWhatsAppLink(installDir)); +registerCodexAuthIPCHandlers(); void app.whenReady().then(async () => { nativeTheme.themeSource = (await readSettings()).theme; @@ -998,6 +1000,7 @@ void app.whenReady().then(async () => { }); app.on("window-all-closed", () => { + stopCodexAuthServer(); if (process.platform !== "darwin") { app.quit(); } diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index d51ec195..ddfc3798 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -146,6 +146,33 @@ type DesktopConnectorActionResult = { detail: string; }; +type DesktopCodexAuthStatus = { + available: boolean; + connected: boolean; + accountLabel?: string; + accountType?: string; + requiresOpenAIAuth?: boolean; + error?: string; +}; + +type DesktopCodexAuthStartResult = { + success: boolean; + authUrl?: string; + loginId?: string; + error?: string; +}; + +type DesktopCodexModelListResult = { + success: boolean; + models?: Array<{ + id: string; + model: string; + displayName: string; + hidden?: boolean; + }>; + error?: string; +}; + type DesktopDashboardSnapshot = { ok: boolean; detail: string; @@ -264,6 +291,10 @@ contextBridge.exposeInMainWorld("octopalDesktop", { ipcRenderer.invoke("desktop:authorize-connector", installDir, payload) as Promise, disconnectConnector: (installDir: string, name: DesktopConnectorName, forgetCredentials = false) => ipcRenderer.invoke("desktop:disconnect-connector", installDir, name, forgetCredentials) as Promise, + getCodexAuthStatus: () => ipcRenderer.invoke("codex-auth:status") as Promise, + startCodexAuth: () => ipcRenderer.invoke("codex-auth:start-login") as Promise, + disconnectCodexAuth: () => ipcRenderer.invoke("codex-auth:disconnect") as Promise<{ success: boolean; error?: string }>, + listCodexModels: () => ipcRenderer.invoke("codex-models:list") as Promise, startWhatsAppLink: (installDir: string) => ipcRenderer.invoke("desktop:start-whatsapp-link", installDir) as Promise, getWhatsAppLinkStatus: (installDir: string) => @@ -280,4 +311,18 @@ contextBridge.exposeInMainWorld("octopalDesktop", { ipcRenderer.on("desktop:app-update-status", handler); return () => ipcRenderer.removeListener("desktop:app-update-status", handler); }, + onCodexAuthStatus: (callback: (status: DesktopCodexAuthStatus) => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: DesktopCodexAuthStatus) => callback(status); + ipcRenderer.on("codex-auth-status-changed", handler); + return () => ipcRenderer.removeListener("codex-auth-status-changed", handler); + }, + onCodexAuthUpdated: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on("codex-auth-login-completed", handler); + ipcRenderer.on("codex-auth-updated", handler); + return () => { + ipcRenderer.removeListener("codex-auth-login-completed", handler); + ipcRenderer.removeListener("codex-auth-updated", handler); + }; + }, }); diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 365f2914..385a1de3 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -59,6 +59,8 @@ export function App() { const [connectorMessage, setConnectorMessage] = useState(""); const [connectorMessageTone, setConnectorMessageTone] = useState<"success" | "error" | "info">("info"); const [selectedConnector, setSelectedConnector] = useState("google"); + const [codexAuthStatus, setCodexAuthStatus] = useState(null); + const [codexAuthBusy, setCodexAuthBusy] = useState(false); const [configurationMode, setConfigurationMode] = useState<"install" | "edit">("install"); const [loadedConfigChannel, setLoadedConfigChannel] = useState(null); const [installState, setInstallState] = useState({ @@ -276,6 +278,17 @@ export function App() { setConnectorStatus(result); }, [installState.installed, runtimeInstallDir]); + const refreshCodexAuthStatus = useCallback(async () => { + if (!window.octopalDesktop) { + setCodexAuthStatus(null); + return null; + } + + const result = await window.octopalDesktop.getCodexAuthStatus(); + setCodexAuthStatus(result); + return result; + }, []); + useEffect(() => { void loadSettings().then(async (settings) => { setLanguage(settings.language); @@ -379,6 +392,32 @@ export function App() { void refreshConnectorStatus(); }, [refreshConnectorStatus, screen, settingsLoaded, step]); + useEffect(() => { + if (!settingsLoaded || screen !== "wizard" || (values.providerId !== "codex" && values.workerProviderId !== "codex")) { + return; + } + + void refreshCodexAuthStatus(); + }, [refreshCodexAuthStatus, screen, settingsLoaded, values.providerId, values.workerProviderId]); + + useEffect(() => { + if (!settingsLoaded || !window.octopalDesktop) { + return; + } + + const unsubscribeStatus = window.octopalDesktop.onCodexAuthStatus((status) => { + setCodexAuthStatus(status); + setCodexAuthBusy(false); + }); + const unsubscribeUpdated = window.octopalDesktop.onCodexAuthUpdated(() => { + void refreshCodexAuthStatus().finally(() => setCodexAuthBusy(false)); + }); + return () => { + unsubscribeStatus(); + unsubscribeUpdated(); + }; + }, [refreshCodexAuthStatus, settingsLoaded]); + useEffect(() => { if (!settingsLoaded || screen !== "wizard" || step !== "review") { return; @@ -595,6 +634,65 @@ export function App() { } } + async function authorizeCodex() { + if (!window.octopalDesktop) { + return; + } + + setCodexAuthBusy(true); + try { + const result = await window.octopalDesktop.startCodexAuth(); + if (!result.success) { + setCodexAuthStatus({ + available: true, + connected: false, + error: result.error || copy("codexAuthorizeFailed"), + }); + setCodexAuthBusy(false); + return; + } + window.setTimeout(() => { + void refreshCodexAuthStatus().finally(() => setCodexAuthBusy(false)); + }, 1500); + } catch (error) { + setCodexAuthStatus({ + available: false, + connected: false, + error: error instanceof Error ? error.message : copy("codexAuthorizeFailed"), + }); + setCodexAuthBusy(false); + } + } + + async function disconnectCodex() { + if (!window.octopalDesktop) { + return; + } + + setCodexAuthBusy(true); + try { + const result = await window.octopalDesktop.disconnectCodexAuth(); + if (!result.success) { + setCodexAuthStatus({ + available: true, + connected: codexAuthStatus?.connected === true, + accountLabel: codexAuthStatus?.accountLabel, + error: result.error || copy("codexDisconnectFailed"), + }); + return; + } + await refreshCodexAuthStatus(); + } catch (error) { + setCodexAuthStatus({ + available: false, + connected: false, + error: error instanceof Error ? error.message : copy("codexDisconnectFailed"), + }); + } finally { + setCodexAuthBusy(false); + } + } + async function chooseInstallDir() { try { const selected = window.octopalDesktop ? await window.octopalDesktop.chooseInstallDir() : "C:\\Octopal"; @@ -993,6 +1091,11 @@ export function App() { connectorMessageTone={connectorMessageTone} selectedConnector={selectedConnector} canAuthorizeConnectors={installState.installed && configurationMode === "edit"} + codexAuthStatus={codexAuthStatus} + codexAuthBusy={codexAuthBusy} + onCodexAuthorize={() => void authorizeCodex()} + onCodexRefresh={() => void refreshCodexAuthStatus()} + onCodexDisconnect={() => void disconnectCodex()} /> ) : null} diff --git a/desktop/src/renderer/src/components/CodexAuthPanel.tsx b/desktop/src/renderer/src/components/CodexAuthPanel.tsx new file mode 100644 index 00000000..6d8f210d --- /dev/null +++ b/desktop/src/renderer/src/components/CodexAuthPanel.tsx @@ -0,0 +1,63 @@ +import { AlertCircle, CheckCircle2, LogIn, LogOut, RefreshCw } from "lucide-react"; + +import type { CopyFn } from "../lib/appTypes"; +import { Button } from "./Button"; + +export function CodexAuthPanel({ + copy, + status, + busy, + onAuthorize, + onRefresh, + onDisconnect, +}: { + copy: CopyFn; + status: DesktopCodexAuthStatus | null; + busy: boolean; + onAuthorize: () => void; + onRefresh: () => void; + onDisconnect: () => void; +}) { + const connected = status?.connected === true; + const unavailable = status?.available === false; + const title = unavailable + ? copy("codexUnavailable") + : connected + ? status?.accountLabel || copy("codexConnected") + : copy("codexNotConnected"); + const detail = status?.error || (unavailable ? copy("codexUnavailableBody") : copy("codexAuthBody")); + + const toneClass = connected + ? "codex-auth-panel codex-auth-ready" + : unavailable || status?.error + ? "codex-auth-panel codex-auth-error" + : "codex-auth-panel"; + + return ( +
+
+ {connected ? : } +
+ {title} + {detail} +
+
+
+ + + {connected ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/renderer/src/components/LlmForm.tsx b/desktop/src/renderer/src/components/LlmForm.tsx index 9ba30e0d..ceeef775 100644 --- a/desktop/src/renderer/src/components/LlmForm.tsx +++ b/desktop/src/renderer/src/components/LlmForm.tsx @@ -9,6 +9,8 @@ export function LlmForm({ apiBaseLabel, apiKeyHint, apiBaseHint, + showApiKey = true, + showApiBase = true, modelInvalid, apiKeyInvalid, apiBaseInvalid, @@ -21,6 +23,8 @@ export function LlmForm({ apiBaseLabel: string; apiKeyHint: string; apiBaseHint: string; + showApiKey?: boolean; + showApiBase?: boolean; modelInvalid?: boolean; apiKeyInvalid?: boolean; apiBaseInvalid?: boolean; @@ -33,12 +37,16 @@ export function LlmForm({ - - - - - - + {showApiKey ? ( + + + + ) : null} + {showApiBase ? ( + + + + ) : null} ); } diff --git a/desktop/src/renderer/src/components/WizardScreen.tsx b/desktop/src/renderer/src/components/WizardScreen.tsx index af95e10f..019937ba 100644 --- a/desktop/src/renderer/src/components/WizardScreen.tsx +++ b/desktop/src/renderer/src/components/WizardScreen.tsx @@ -51,6 +51,11 @@ export function WizardScreen({ connectorMessageTone, selectedConnector, canAuthorizeConnectors, + codexAuthStatus, + codexAuthBusy, + onCodexAuthorize, + onCodexRefresh, + onCodexDisconnect, }: { copy: CopyFn; language: Language; @@ -85,6 +90,11 @@ export function WizardScreen({ connectorMessageTone: "success" | "error" | "info"; selectedConnector: DesktopConnectorName; canAuthorizeConnectors: boolean; + codexAuthStatus: DesktopCodexAuthStatus | null; + codexAuthBusy: boolean; + onCodexAuthorize: () => void; + onCodexRefresh: () => void; + onCodexDisconnect: () => void; }) { return ( onProviderChange(providerId, "octo")} + codexAuthStatus={codexAuthStatus} + codexAuthBusy={codexAuthBusy} + onCodexAuthorize={onCodexAuthorize} + onCodexRefresh={onCodexRefresh} + onCodexDisconnect={onCodexDisconnect} /> ) : null} {step === "worker-llm" ? ( @@ -125,6 +140,11 @@ export function WizardScreen({ form={form} errors={errors} onProviderChange={(providerId) => onProviderChange(providerId, "worker")} + codexAuthStatus={codexAuthStatus} + codexAuthBusy={codexAuthBusy} + onCodexAuthorize={onCodexAuthorize} + onCodexRefresh={onCodexRefresh} + onCodexDisconnect={onCodexDisconnect} /> ) : null} {step === "search" ? ( diff --git a/desktop/src/renderer/src/components/steps/LlmStep.tsx b/desktop/src/renderer/src/components/steps/LlmStep.tsx index 87fc1f8a..2159cd55 100644 --- a/desktop/src/renderer/src/components/steps/LlmStep.tsx +++ b/desktop/src/renderer/src/components/steps/LlmStep.tsx @@ -1,10 +1,11 @@ import type { FieldErrors, UseFormReturn } from "react-hook-form"; +import { CodexAuthPanel } from "../CodexAuthPanel"; import { LlmForm } from "../LlmForm"; import { ProviderPicker } from "../ProviderPicker"; import { StepSection } from "../StepSection"; import type { CopyFn } from "../../lib/appTypes"; -import { isExistingSecret, type InstallForm } from "../../lib/install"; +import { isExistingSecret, providerRequiresApiBase, providerRequiresApiKey, type InstallForm } from "../../lib/install"; export function LlmStep({ copy, @@ -12,13 +13,26 @@ export function LlmStep({ form, errors, onProviderChange, + codexAuthStatus, + codexAuthBusy, + onCodexAuthorize, + onCodexRefresh, + onCodexDisconnect, }: { copy: CopyFn; values: InstallForm; form: UseFormReturn; errors: FieldErrors; onProviderChange: (providerId: string) => void; + codexAuthStatus: DesktopCodexAuthStatus | null; + codexAuthBusy: boolean; + onCodexAuthorize: () => void; + onCodexRefresh: () => void; + onCodexDisconnect: () => void; }) { + const showApiKey = providerRequiresApiKey(values.providerId); + const showApiBase = values.providerId !== "codex"; + return ( @@ -26,12 +40,24 @@ export function LlmStep({ {copy("sameWorker")} + {values.providerId === "codex" ? ( + + ) : null} ; errors: FieldErrors; onProviderChange: (providerId: string) => void; + codexAuthStatus: DesktopCodexAuthStatus | null; + codexAuthBusy: boolean; + onCodexAuthorize: () => void; + onCodexRefresh: () => void; + onCodexDisconnect: () => void; }) { + const workerProviderId = values.workerProviderId || values.providerId; + const showApiKey = providerRequiresApiKey(workerProviderId); + const showApiBase = workerProviderId !== "codex"; + return ( - + + {workerProviderId === "codex" ? ( + + ) : null} = { anthropic: anthropicLogo, + codex: openaiLogo, custom: customLogo, google: geminiLogo, groq: groqLogo, diff --git a/desktop/src/renderer/src/lib/wizard.ts b/desktop/src/renderer/src/lib/wizard.ts index 5971db62..660b2566 100644 --- a/desktop/src/renderer/src/lib/wizard.ts +++ b/desktop/src/renderer/src/lib/wizard.ts @@ -1,4 +1,4 @@ -import type { InstallForm } from "./install"; +import { providerRequiresApiBase, providerRequiresApiKey, type InstallForm } from "./install"; import { messages } from "./i18n"; import type { StepId } from "./appTypes"; @@ -40,13 +40,23 @@ export function getValidationFields(step: StepId, values: InstallForm): Array; + error?: string; +}; + type DesktopDashboardWorkerRun = { id?: string; template_name?: string; @@ -262,11 +289,17 @@ type OctopalDesktopApi = { name: DesktopConnectorName, forgetCredentials?: boolean, ) => Promise; + getCodexAuthStatus: () => Promise; + startCodexAuth: () => Promise; + disconnectCodexAuth: () => Promise<{ success: boolean; error?: string }>; + listCodexModels: () => Promise; startWhatsAppLink: (installDir: string) => Promise; getWhatsAppLinkStatus: (installDir: string) => Promise; stopWhatsAppLink: (installDir: string) => Promise; onInstallEvent: (callback: (event: DesktopInstallEvent) => void) => () => void; onAppUpdateStatus: (callback: (status: DesktopAppUpdateStatus) => void) => () => void; + onCodexAuthStatus: (callback: (status: DesktopCodexAuthStatus) => void) => () => void; + onCodexAuthUpdated: (callback: () => void) => () => void; }; interface Window { diff --git a/src/octopal/cli/configure.py b/src/octopal/cli/configure.py index 8086499e..f3511e6a 100644 --- a/src/octopal/cli/configure.py +++ b/src/octopal/cli/configure.py @@ -36,7 +36,7 @@ _PROVIDER_GROUPS: dict[str, tuple[str, ...]] = { "Routers and Gateways": ("openrouter", "minimax", "custom"), - "Hosted APIs": ("zai", "openai", "anthropic", "google", "mistral", "together", "groq"), + "Hosted APIs": ("zai", "openai", "codex", "anthropic", "google", "mistral", "together", "groq"), "Local": ("ollama",), } diff --git a/src/octopal/infrastructure/providers/catalog.py b/src/octopal/infrastructure/providers/catalog.py index b13cac47..706e3f75 100644 --- a/src/octopal/infrastructure/providers/catalog.py +++ b/src/octopal/infrastructure/providers/catalog.py @@ -56,6 +56,16 @@ class ProviderCatalogEntry: model_label="OpenAI model", base_url_label="OpenAI base URL", ), + ProviderCatalogEntry( + id="codex", + label="ChatGPT Codex", + description="ChatGPT subscription auth through the local Codex CLI app-server.", + default_model="gpt-5.4", + requires_api_key=False, + supports_custom_base_url=False, + api_key_label="ChatGPT login", + model_label="Codex model", + ), ProviderCatalogEntry( id="anthropic", label="Anthropic", diff --git a/src/octopal/infrastructure/providers/codex_provider.py b/src/octopal/infrastructure/providers/codex_provider.py new file mode 100644 index 00000000..9a70e3ae --- /dev/null +++ b/src/octopal/infrastructure/providers/codex_provider.py @@ -0,0 +1,489 @@ +"""Codex CLI app-server backed inference provider.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import subprocess +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +from octopal.infrastructure.config.models import LLMConfig +from octopal.infrastructure.config.settings import Settings +from octopal.infrastructure.providers.base import Message +from octopal.infrastructure.providers.profile_resolver import resolve_litellm_profile + +CODEX_REQUEST_TIMEOUT_SECONDS = 30.0 +CODEX_TURN_TIMEOUT_SECONDS = 180.0 + + +class CodexAppServerError(RuntimeError): + pass + + +class _CodexAppServerClient: + def __init__(self, command: str, args: list[str], env: dict[str, str]) -> None: + self._command = command + self._args = args + self._env = env + self._process: asyncio.subprocess.Process | None = None + self._next_id = 1 + self._pending: dict[int, asyncio.Future[Any]] = {} + self._notifications: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._requests: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._reader_task: asyncio.Task[None] | None = None + self._stderr_task: asyncio.Task[None] | None = None + self._stderr_tail = "" + + async def start(self) -> None: + if self._process is not None: + return + + if os.name == "nt" and self._command.lower().endswith((".cmd", ".bat")): + self._process = await asyncio.create_subprocess_shell( + subprocess.list2cmdline([self._command, *self._args]), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._env, + ) + else: + self._process = await asyncio.create_subprocess_exec( + self._command, + *self._args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._env, + ) + self._reader_task = asyncio.create_task(self._read_stdout()) + self._stderr_task = asyncio.create_task(self._read_stderr()) + await self.request( + "initialize", + { + "clientInfo": { + "name": "octopal", + "title": "Octopal", + "version": "runtime", + }, + "capabilities": {"experimentalApi": True}, + }, + ) + self.notify("initialized", {}) + + async def request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + timeout: float = CODEX_REQUEST_TIMEOUT_SECONDS, + ) -> Any: + process = self._require_process() + if process.stdin is None: + raise CodexAppServerError("codex app-server stdin is unavailable") + + request_id = self._next_id + self._next_id += 1 + message: dict[str, Any] = {"method": method, "id": request_id} + if params is not None: + message["params"] = params + + future = asyncio.get_running_loop().create_future() + self._pending[request_id] = future + process.stdin.write((json.dumps(message) + "\n").encode("utf-8")) + await process.stdin.drain() + try: + return await asyncio.wait_for(future, timeout=timeout) + finally: + self._pending.pop(request_id, None) + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + process = self._require_process() + if process.stdin is None: + return + message: dict[str, Any] = {"method": method} + if params is not None: + message["params"] = params + process.stdin.write((json.dumps(message) + "\n").encode("utf-8")) + + async def respond(self, request_id: int | str, result: dict[str, Any]) -> None: + process = self._require_process() + if process.stdin is None: + return + process.stdin.write((json.dumps({"id": request_id, "result": result}) + "\n").encode("utf-8")) + await process.stdin.drain() + + async def respond_error(self, request_id: int | str, message: str) -> None: + process = self._require_process() + if process.stdin is None: + return + payload = {"id": request_id, "error": {"code": -32000, "message": message}} + process.stdin.write((json.dumps(payload) + "\n").encode("utf-8")) + await process.stdin.drain() + + async def next_event(self, timeout: float) -> tuple[str, dict[str, Any]]: + notification_task = asyncio.create_task(self._notifications.get()) + request_task = asyncio.create_task(self._requests.get()) + done, pending = await asyncio.wait( + {notification_task, request_task}, + timeout=timeout, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + if not done: + raise TimeoutError("codex app-server turn timed out") + task = done.pop() + return ("request" if task is request_task else "notification", task.result()) + + async def close(self) -> None: + process = self._process + if process and process.stdin: + try: + process.stdin.close() + await process.stdin.wait_closed() + except Exception: + pass + if process: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=2) + except TimeoutError: + process.kill() + await process.wait() + if self._reader_task: + self._reader_task.cancel() + if self._stderr_task: + self._stderr_task.cancel() + await asyncio.gather( + *(task for task in (self._reader_task, self._stderr_task) if task is not None), + return_exceptions=True, + ) + for future in self._pending.values(): + if not future.done(): + future.set_exception(CodexAppServerError("codex app-server closed")) + self._pending.clear() + self._process = None + + def _require_process(self) -> asyncio.subprocess.Process: + if self._process is None: + raise CodexAppServerError("codex app-server is not running") + return self._process + + async def _read_stdout(self) -> None: + process = self._require_process() + assert process.stdout is not None + async for raw_line in process.stdout: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if "id" in message and "method" in message: + await self._requests.put(message) + continue + if "id" in message: + future = self._pending.get(message["id"]) + if future and not future.done(): + if message.get("error"): + error = message["error"] + future.set_exception(CodexAppServerError(error.get("message") or "Codex request failed")) + else: + future.set_result(message.get("result")) + continue + if "method" in message: + await self._notifications.put(message) + + async def _read_stderr(self) -> None: + process = self._require_process() + assert process.stderr is not None + async for raw_chunk in process.stderr: + text = raw_chunk.decode("utf-8", errors="replace") + self._stderr_tail = f"{self._stderr_tail}{text}"[-4000:] + + +class CodexProvider: + """Inference provider backed by a locally authenticated Codex CLI.""" + + def __init__( + self, + settings: Settings, + model: str | None = None, + config: LLMConfig | None = None, + trace_sink: object | None = None, + ) -> None: + self._settings = settings + self._profile = resolve_litellm_profile(settings, model_override=model, config_override=config) + self._model = self._profile.raw_model or self._profile.model + + @property + def provider_id(self) -> str: + return "codex" + + async def complete(self, messages: list[Message | dict], **kwargs: object) -> str: + result = await self._run_turn(messages, tools=None, on_partial=None) + return result["content"] + + async def complete_stream( + self, + messages: list[Message | dict], + *, + on_partial: Callable[[str], Awaitable[None]], + **kwargs: object, + ) -> str: + result = await self._run_turn(messages, tools=None, on_partial=on_partial) + return result["content"] + + async def complete_with_tools( + self, + messages: list[Message | dict], + *, + tools: list[dict], + tool_choice: str = "auto", + **kwargs: object, + ) -> dict: + result = await self._run_turn(messages, tools=tools, on_partial=None) + return { + "content": result["content"], + "tool_calls": result["tool_calls"], + "usage": {}, + } + + async def _run_turn( + self, + messages: list[Message | dict], + *, + tools: list[dict] | None, + on_partial: Callable[[str], Awaitable[None]] | None, + ) -> dict[str, Any]: + client = _CodexAppServerClient(_codex_command(), _codex_args(), _codex_env()) + await client.start() + try: + instructions, input_items = _messages_to_codex_input(messages) + dynamic_tools = _tools_to_dynamic_tools(tools or []) + cwd = str(Path.cwd()) + thread = await client.request( + "thread/start", + _compact( + { + "model": self._model, + "cwd": cwd, + "approvalPolicy": "never", + "sandbox": "read-only", + "developerInstructions": instructions or None, + "personality": "none", + "serviceName": "octopal", + "ephemeral": True, + "environments": [], + "dynamicTools": dynamic_tools or None, + } + ), + timeout=CODEX_TURN_TIMEOUT_SECONDS, + ) + thread_id = ((thread or {}).get("thread") or {}).get("id") + if not thread_id: + raise CodexAppServerError("Codex did not return a thread id") + + turn = await client.request( + "turn/start", + _compact( + { + "threadId": thread_id, + "input": input_items, + "cwd": cwd, + "model": self._model, + "approvalPolicy": "never", + "sandboxPolicy": {"type": "readOnly", "networkAccess": False}, + "effort": _normalize_effort(getattr(self._settings, "codex_reasoning_effort", None)), + "environments": [], + } + ), + timeout=CODEX_TURN_TIMEOUT_SECONDS, + ) + turn_id = ((turn or {}).get("turn") or {}).get("id") + return await _collect_turn(client, thread_id=thread_id, turn_id=turn_id, on_partial=on_partial) + finally: + await client.close() + + +async def _collect_turn( + client: _CodexAppServerClient, + *, + thread_id: str, + turn_id: str | None, + on_partial: Callable[[str], Awaitable[None]] | None, +) -> dict[str, Any]: + output = "" + tool_calls: list[dict[str, Any]] = [] + current_turn_id = turn_id + + while True: + kind, event = await client.next_event(CODEX_TURN_TIMEOUT_SECONDS) + payload = event.get("params") or {} + if kind == "request": + method = str(event.get("method") or "") + if method == "item/tool/call": + call = _tool_call_from_codex_request(payload) + if call: + tool_calls.append(call) + if current_turn_id: + try: + await client.request( + "turn/interrupt", + {"threadId": thread_id, "turnId": current_turn_id}, + timeout=CODEX_REQUEST_TIMEOUT_SECONDS, + ) + except Exception: + pass + await client.respond( + event["id"], + { + "success": False, + "contentItems": [ + { + "type": "inputText", + "text": "Tool execution is handled by Octopal after the provider returns the tool call.", + } + ], + }, + ) + return {"content": output, "tool_calls": tool_calls} + await _respond_to_auxiliary_request(client, event) + continue + + method = str(event.get("method") or "") + if payload.get("threadId") and payload.get("threadId") != thread_id: + continue + if payload.get("turnId"): + current_turn_id = str(payload.get("turnId")) + if method == "item/agentMessage/delta": + delta = str(payload.get("delta") or "") + output += delta + if on_partial: + await on_partial(output) + continue + if method == "turn/completed": + return {"content": output, "tool_calls": tool_calls} + if method == "error": + raise CodexAppServerError(json.dumps(payload, ensure_ascii=False)) + + +async def _respond_to_auxiliary_request(client: _CodexAppServerClient, event: dict[str, Any]) -> None: + method = str(event.get("method") or "") + if method == "item/tool/requestUserInput": + await client.respond(event["id"], {"answers": {}}) + return + if method == "item/permissions/requestApproval": + await client.respond(event["id"], {"permissions": {}, "scope": "turn"}) + return + if method.endswith("/requestApproval"): + await client.respond(event["id"], {"decision": "decline"}) + return + await client.respond_error(event["id"], f"Unsupported Codex app-server request: {method}") + + +def _messages_to_codex_input(messages: list[Message | dict]) -> tuple[str, list[dict[str, str]]]: + instructions: list[str] = [] + chunks: list[str] = [] + for message in messages: + data = message.to_dict() if isinstance(message, Message) else dict(message) + role = str(data.get("role") or "message").lower() + content = _content_to_text(data.get("content")) + if not content: + continue + if role == "system": + instructions.append(content) + else: + chunks.append(f"{role.upper()}:\n{content}") + text = "\n\n".join(chunks).strip() or "Continue." + return "\n\n".join(instructions).strip(), [{"type": "text", "text": text}] + + +def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(str(item.get("text") or "").strip()) + elif item.get("type") == "image_url": + parts.append("[image omitted]") + elif item is not None: + parts.append(str(item).strip()) + return "\n".join(part for part in parts if part).strip() + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + +def _tools_to_dynamic_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + dynamic_tools: list[dict[str, Any]] = [] + for tool in tools: + if tool.get("type") != "function": + continue + function = tool.get("function") if isinstance(tool.get("function"), dict) else {} + name = str(function.get("name") or "").strip() + if not name: + continue + parameters = function.get("parameters") + dynamic_tools.append( + { + "name": name, + "description": str(function.get("description") or name), + "inputSchema": parameters if isinstance(parameters, dict) else {"type": "object", "properties": {}}, + } + ) + return dynamic_tools + + +def _tool_call_from_codex_request(payload: Any) -> dict[str, Any] | None: + if not isinstance(payload, dict): + return None + tool = str(payload.get("tool") or "").strip() + if not tool: + return None + arguments = payload.get("arguments") + return { + "id": str(payload.get("callId") or f"codex-call-{tool}"), + "type": "function", + "function": { + "name": tool, + "arguments": json.dumps(arguments if arguments is not None else {}, ensure_ascii=False), + }, + } + + +def _compact(value: dict[str, Any]) -> dict[str, Any]: + return {key: item for key, item in value.items() if item is not None} + + +def _normalize_effort(value: Any) -> str | None: + if value == "minimal": + return "low" + return value if isinstance(value, str) and value else None + + +def _codex_command() -> str: + configured = os.getenv("OCTOPAL_CODEX_COMMAND") + if configured: + return configured + return shutil.which("codex") or "codex" + + +def _codex_args() -> list[str]: + raw = os.getenv("OCTOPAL_CODEX_ARGS", "app-server") + return [part for part in raw.split() if part] + + +def _codex_env() -> dict[str, str]: + env = {key: value for key, value in os.environ.items() if value is not None} + env.pop("OPENAI_API_KEY", None) + env.pop("CODEX_API_KEY", None) + return env diff --git a/src/octopal/infrastructure/providers/factory.py b/src/octopal/infrastructure/providers/factory.py new file mode 100644 index 00000000..7fed06e2 --- /dev/null +++ b/src/octopal/infrastructure/providers/factory.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from octopal.infrastructure.config.models import LLMConfig +from octopal.infrastructure.config.settings import Settings +from octopal.infrastructure.observability.base import TraceSink +from octopal.infrastructure.providers.base import InferenceProvider +from octopal.infrastructure.providers.codex_provider import CodexProvider +from octopal.infrastructure.providers.litellm_provider import LiteLLMProvider +from octopal.infrastructure.providers.profile_resolver import resolve_litellm_profile + + +def build_inference_provider( + settings: Settings, + *, + model: str | None = None, + config: LLMConfig | None = None, + trace_sink: TraceSink | None = None, +) -> InferenceProvider: + profile = resolve_litellm_profile(settings, model_override=model, config_override=config) + if profile.provider_id == "codex": + return CodexProvider(settings, model=model, config=config, trace_sink=trace_sink) + return LiteLLMProvider(settings, model=model, config=config, trace_sink=trace_sink) diff --git a/src/octopal/runtime/app.py b/src/octopal/runtime/app.py index de91a417..4f56e7eb 100644 --- a/src/octopal/runtime/app.py +++ b/src/octopal/runtime/app.py @@ -6,7 +6,7 @@ from octopal.infrastructure.config.settings import Settings from octopal.infrastructure.mcp.manager import MCPManager from octopal.infrastructure.observability import build_trace_sink -from octopal.infrastructure.providers.litellm_provider import LiteLLMProvider +from octopal.infrastructure.providers.factory import build_inference_provider from octopal.infrastructure.providers.openai_embeddings import OpenAIEmbeddingsProvider from octopal.infrastructure.store.sqlite import SQLiteStore from octopal.runtime.memory.canon import CanonService @@ -27,7 +27,7 @@ def build_octo(settings: Settings) -> Octo: ensure_skills_layout(settings.workspace_dir) trace_sink = build_trace_sink(settings) - provider = LiteLLMProvider(settings, trace_sink=trace_sink) + provider = build_inference_provider(settings, trace_sink=trace_sink) store = SQLiteStore(settings) from octopal.runtime.workers.templates import initialize_templates diff --git a/src/octopal/runtime/workers/agent_worker.py b/src/octopal/runtime/workers/agent_worker.py index e5f50603..2834a78c 100644 --- a/src/octopal/runtime/workers/agent_worker.py +++ b/src/octopal/runtime/workers/agent_worker.py @@ -23,7 +23,8 @@ import structlog from octopal.infrastructure.config.settings import load_settings -from octopal.infrastructure.providers.litellm_provider import LiteLLMProvider +from octopal.infrastructure.providers.base import InferenceProvider +from octopal.infrastructure.providers.factory import build_inference_provider from octopal.runtime.temporal_context import format_temporal_context_prompt from octopal.runtime.tool_errors import ToolBridgeError from octopal.runtime.tool_loop import ( @@ -616,7 +617,7 @@ async def execute_agent_task( # Initialize LLM provider from settings settings = load_settings() - provider = LiteLLMProvider(settings, model=spec.model, config=spec.llm_config) + provider = build_inference_provider(settings, model=spec.model, config=spec.llm_config) # Build system prompt with tool descriptions available_tools = get_tools() @@ -1165,7 +1166,7 @@ def _extract_result_block(content: str) -> dict[str, Any] | None: async def _call_llm( - provider: LiteLLMProvider, + provider: InferenceProvider, messages: list[dict], tools: list, ) -> dict: diff --git a/tests/test_codex_provider_catalog.py b/tests/test_codex_provider_catalog.py new file mode 100644 index 00000000..36053db9 --- /dev/null +++ b/tests/test_codex_provider_catalog.py @@ -0,0 +1,32 @@ +from octopal.infrastructure.config.models import LLMConfig, OctopalConfig +from octopal.infrastructure.config.settings import Settings +from octopal.infrastructure.providers.catalog import get_provider_catalog_entry +from octopal.infrastructure.providers.codex_provider import CodexProvider +from octopal.infrastructure.providers.factory import build_inference_provider + + +def test_codex_catalog_does_not_require_api_key() -> None: + entry = get_provider_catalog_entry("codex") + + assert entry.label == "ChatGPT Codex" + assert entry.requires_api_key is False + assert entry.supports_custom_base_url is False + + +def test_provider_factory_uses_codex_provider_for_codex_profile() -> None: + settings = Settings.model_construct( + config_obj=OctopalConfig(llm=LLMConfig(provider_id="codex", model="gpt-5.4")), + litellm_provider_id=None, + litellm_model=None, + litellm_api_key=None, + litellm_api_base=None, + litellm_model_prefix=None, + llm_provider="litellm", + openrouter_api_key=None, + zai_api_key=None, + ) + + provider = build_inference_provider(settings) + + assert isinstance(provider, CodexProvider) + assert provider.provider_id == "codex" From 8bef354fd88b56ebdaea2bef54409dbd41f2154d Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 18 May 2026 11:11:10 -0400 Subject: [PATCH 2/2] fix worker provider test patching --- tests/test_agent_loop_improvements.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_agent_loop_improvements.py b/tests/test_agent_loop_improvements.py index 08ae114c..f8d0bec2 100644 --- a/tests/test_agent_loop_improvements.py +++ b/tests/test_agent_loop_improvements.py @@ -638,7 +638,7 @@ async def _noop_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _noop_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) @@ -707,7 +707,7 @@ async def _noop_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _noop_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) monkeypatch.setattr("octopal.runtime.workers.agent_worker.get_tools", lambda: []) @@ -738,7 +738,7 @@ async def _noop_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _noop_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) @@ -821,7 +821,7 @@ async def _noop_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _noop_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) @@ -926,7 +926,7 @@ async def _noop_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _noop_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) @@ -1038,7 +1038,7 @@ async def _fake_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _fake_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) @@ -1171,7 +1171,7 @@ async def _noop_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _noop_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), ) @@ -1364,7 +1364,7 @@ async def _fake_log(level: str, message: str) -> None: monkeypatch.setattr(worker, "log", _fake_log) monkeypatch.setattr("octopal.runtime.workers.agent_worker.load_settings", lambda: object()) monkeypatch.setattr( - "octopal.runtime.workers.agent_worker.LiteLLMProvider", + "octopal.runtime.workers.agent_worker.build_inference_provider", lambda settings, model=None, config=None: object(), )