From b46f607d9107200bfff96c36cd31cf5afb895377 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 8 Jun 2026 16:16:49 -0700 Subject: [PATCH] feat(providers): finish OpenAI Codex (ChatGPT OAuth) provider and cut v0.8.55 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the in-progress OpenAI Codex provider and bumps the workspace to 0.8.55. Builds on the committed Together AI provider + model catalog work. OpenAI Codex (ChatGPT) provider — experimental: - Wire the previously-dead OAuth module into credential resolution. The TUI config now resolves the access token via the Codex CLI login in ~/.codex/auth.json (env overrides OPENAI_CODEX_ACCESS_TOKEN/CODEX_ACCESS_TOKEN), refreshing expired tokens synchronously via the OpenAI token endpoint — mirroring the existing Kimi OAuth flow rather than introducing a new pattern. - Send the ChatGPT backend's required headers from the Responses client (chatgpt-account-id, OpenAI-Beta: responses=experimental, originator) and stop duplicating the Authorization header already installed on the client. - Fix the cli crate's non-exhaustive ProviderKind matches (compile blocker). Consistency / de-slop pass (so the provider fits the whole app, not one path): - has_api_key_for / active_provider_has_config_api_key now detect the Codex OAuth login on disk, the same way they detect Kimi OAuth — a `codex login` user is no longer reported as unauthenticated. - Replace the bogus OPENAI_CODEX_API_KEY hint (which exists nowhere else) with the real OPENAI_CODEX_ACCESS_TOKEN/CODEX_ACCESS_TOKEN in the auth-error and picker surfaces. - Drop dead state in the Responses stream parser (unused ToolCallState fields / imports); tool-call data is streamed live. - Update docs/PROVIDERS.md, config.example.toml, and the provider-metadata wire test for the Responses wire format. Release: - Bump workspace + crates + npm package to 0.8.55; update CHANGELOG.md and crates/tui/CHANGELOG.md. Note: the live Responses round-trip has not been exercised against the production ChatGPT backend in this environment; the provider ships as preview. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 23 ++ Cargo.lock | 32 +- Cargo.toml | 2 +- config.example.toml | 12 + crates/agent/Cargo.toml | 2 +- crates/agent/src/lib.rs | 11 + crates/app-server/Cargo.toml | 18 +- crates/cli/Cargo.toml | 16 +- crates/cli/src/lib.rs | 5 +- crates/config/Cargo.toml | 4 +- crates/config/src/lib.rs | 51 ++- crates/config/src/provider.rs | 43 ++- crates/core/Cargo.toml | 16 +- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 12 + crates/tui/Cargo.toml | 10 +- crates/tui/src/client.rs | 13 +- crates/tui/src/client/responses.rs | 523 ++++++++++++++++++++++++++ crates/tui/src/config.rs | 69 +++- crates/tui/src/config_persistence.rs | 1 + crates/tui/src/core/engine.rs | 1 + crates/tui/src/main.rs | 6 + crates/tui/src/oauth.rs | 399 ++++++++++++++++++++ crates/tui/src/tui/provider_picker.rs | 6 +- crates/tui/src/tui/ui.rs | 3 + docs/PROVIDERS.md | 2 + npm/codewhale/package.json | 4 +- 29 files changed, 1225 insertions(+), 65 deletions(-) create mode 100644 crates/tui/src/client/responses.rs create mode 100644 crates/tui/src/oauth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4d3845..d9dfbc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.55] - 2026-06-08 + +### Added + +- **Together AI provider.** Added Together AI as a first-class provider + (`[providers.together]`, `TOGETHER_API_KEY`/`TOGETHER_BASE_URL`/`TOGETHER_MODEL`) + with default models `deepseek-ai/DeepSeek-V4-Pro` and + `deepseek-ai/DeepSeek-V4-Flash`, TUI provider-picker/auth/capability support, + and CLI `auth list`/`auth status` coverage. +- **Model catalog updates.** Added Qwen 3.7 Max (`qwen/qwen3.7-max`), MiniMax 2.7 + (`minimax/minimax-2.7`), and NVIDIA Nemotron 3 Ultra (`nvidia/nemotron-3-ultra`) + on OpenRouter. +- **OpenAI Codex (ChatGPT) provider — experimental.** Added an `openai-codex` + provider that reuses an existing ChatGPT/Codex CLI OAuth login. The access + token is read and refreshed from `~/.codex/auth.json` (no API key is stored), + and requests use the OpenAI Responses API at `/codex/responses` with the + `chatgpt-account-id` header and `responses=experimental` beta opt-in. Env + overrides: `OPENAI_CODEX_ACCESS_TOKEN`/`CODEX_ACCESS_TOKEN`, + `OPENAI_CODEX_BASE_URL`/`CODEX_BASE_URL`, `OPENAI_CODEX_MODEL`/`CODEX_MODEL`, + `OPENAI_CODEX_ACCOUNT_ID`/`CODEX_ACCOUNT_ID`, `OPENAI_CODEX_AUTH_FILE`, + `CODEX_HOME`. Default model `gpt-5.5`. The live Responses round-trip has not + been exercised against the production backend in CI; treat as preview. + ## [0.8.54] - 2026-06-08 ### Added diff --git a/Cargo.lock b/Cargo.lock index b2e9407e..003d89fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,7 +771,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.54" +version = "0.8.55" dependencies = [ "codewhale-config", "serde", @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "axum", @@ -806,7 +806,7 @@ dependencies = [ [[package]] name = "codewhale-cli" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "chrono", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "codewhale-execpolicy", @@ -848,7 +848,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "chrono", @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "codewhale-protocol", @@ -876,7 +876,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "async-trait", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "serde", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.54" +version = "0.8.55" dependencies = [ "serde", "serde_json", @@ -907,7 +907,7 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "reqwest", @@ -919,7 +919,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.54" +version = "0.8.55" dependencies = [ "dirs", "keyring", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "chrono", @@ -944,7 +944,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "async-trait", @@ -958,7 +958,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "arboard", @@ -1030,11 +1030,11 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.54" +version = "0.8.55" [[package]] name = "codewhale-whaleflow" -version = "0.8.54" +version = "0.8.55" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index 81ea37ba..c63d7417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.54" +version = "0.8.55" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/config.example.toml b/config.example.toml index fee1aecf..0a66c369 100644 --- a/config.example.toml +++ b/config.example.toml @@ -428,6 +428,18 @@ max_subagents = 10 # optional (1-20) # base_url = "https://api.together.xyz/v1" # model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash +# ───────────────────────────────────────────────────────────────────────────────── +# OpenAI Codex (ChatGPT) Provider — EXPERIMENTAL +# Reuses your existing ChatGPT/Codex CLI OAuth login. Run `codex login` first; +# CodeWhale reads and refreshes the access token from ~/.codex/auth.json. No API +# key is stored here. Talks to the OpenAI Responses API at /codex/responses. +# Env var aliases: OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN (token override), +# OPENAI_CODEX_BASE_URL / CODEX_BASE_URL, OPENAI_CODEX_MODEL / CODEX_MODEL, +# OPENAI_CODEX_ACCOUNT_ID / CODEX_ACCOUNT_ID, OPENAI_CODEX_AUTH_FILE, CODEX_HOME +[providers.openai_codex] +# base_url = "https://chatgpt.com/backend-api" +# model = "gpt-5.5" + # ───────────────────────────────────────────────────────────────────────────────── # Web Search Provider # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index ba9c851a..a59c2a9f 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.54" } +codewhale-config = { path = "../config", version = "0.8.55" } serde.workspace = true diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 7adea12d..71631047 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -599,6 +599,17 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + // OpenAI Codex (ChatGPT OAuth) models + ModelInfo { + id: "gpt-5.5".to_string(), + provider: ProviderKind::OpenaiCodex, + aliases: vec![ + "codex-gpt-5.5".to_string(), + "chatgpt-gpt-5.5".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, // MiniMax 2.7 (OpenRouter) ModelInfo { id: "minimax/minimax-2.7".to_string(), diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 873447b0..6b3ac2c7 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.54" } -codewhale-config = { path = "../config", version = "0.8.54" } -codewhale-core = { path = "../core", version = "0.8.54" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.54" } -codewhale-hooks = { path = "../hooks", version = "0.8.54" } -codewhale-mcp = { path = "../mcp", version = "0.8.54" } -codewhale-protocol = { path = "../protocol", version = "0.8.54" } -codewhale-state = { path = "../state", version = "0.8.54" } -codewhale-tools = { path = "../tools", version = "0.8.54" } +codewhale-agent = { path = "../agent", version = "0.8.55" } +codewhale-config = { path = "../config", version = "0.8.55" } +codewhale-core = { path = "../core", version = "0.8.55" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.55" } +codewhale-hooks = { path = "../hooks", version = "0.8.55" } +codewhale-mcp = { path = "../mcp", version = "0.8.55" } +codewhale-protocol = { path = "../protocol", version = "0.8.55" } +codewhale-state = { path = "../state", version = "0.8.55" } +codewhale-tools = { path = "../tools", version = "0.8.55" } serde.workspace = true serde_json.workspace = true rustls.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 31e2eefa..d8e3637d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,14 +19,14 @@ path = "src/bin/codew_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.54" } -codewhale-app-server = { path = "../app-server", version = "0.8.54" } -codewhale-config = { path = "../config", version = "0.8.54" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.54" } -codewhale-mcp = { path = "../mcp", version = "0.8.54" } -codewhale-release = { path = "../release", version = "0.8.54" } -codewhale-secrets = { path = "../secrets", version = "0.8.54" } -codewhale-state = { path = "../state", version = "0.8.54" } +codewhale-agent = { path = "../agent", version = "0.8.55" } +codewhale-app-server = { path = "../app-server", version = "0.8.55" } +codewhale-config = { path = "../config", version = "0.8.55" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.55" } +codewhale-mcp = { path = "../mcp", version = "0.8.55" } +codewhale-release = { path = "../release", version = "0.8.55" } +codewhale-secrets = { path = "../secrets", version = "0.8.55" } +codewhale-state = { path = "../state", version = "0.8.55" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 8e6bd21e..2e2712ce 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -766,11 +766,12 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Ollama => "ollama", ProviderKind::Huggingface => "huggingface", ProviderKind::Together => "together", + ProviderKind::OpenaiCodex => "openai-codex", } } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 19] = [ +const PROVIDER_LIST: [ProviderKind; 20] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -790,6 +791,7 @@ const PROVIDER_LIST: [ProviderKind; 19] = [ ProviderKind::Ollama, ProviderKind::Huggingface, ProviderKind::Together, + ProviderKind::OpenaiCodex, ]; #[cfg(test)] @@ -862,6 +864,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { "WANJIE_MAAS_API_KEY", ], ProviderKind::Together => &["TOGETHER_API_KEY"], + ProviderKind::OpenaiCodex => &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"], } } diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 8f6dc5f8..43ef660b 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,8 +8,8 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.54" } -codewhale-secrets = { path = "../secrets", version = "0.8.54" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.55" } +codewhale-secrets = { path = "../secrets", version = "0.8.55" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 269caf6d..88c387c0 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -26,6 +26,8 @@ const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; +const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5"; +const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; @@ -141,10 +143,19 @@ pub enum ProviderKind { Huggingface, #[serde(alias = "together-ai", alias = "together_ai")] Together, + #[serde( + alias = "openai-codex", + alias = "openai_codex", + alias = "codex", + alias = "chatgpt", + alias = "chatgpt-codex", + alias = "chatgpt_codex" + )] + OpenaiCodex, } impl ProviderKind { - pub const ALL: [Self; 19] = [ + pub const ALL: [Self; 20] = [ Self::Deepseek, Self::NvidiaNim, Self::Openai, @@ -164,6 +175,7 @@ impl ProviderKind { Self::Ollama, Self::Huggingface, Self::Together, + Self::OpenaiCodex, ]; #[must_use] @@ -188,6 +200,7 @@ impl ProviderKind { Self::Ollama => "ollama", Self::Huggingface => "huggingface", Self::Together => "together", + Self::OpenaiCodex => "openai-codex", } } @@ -218,6 +231,8 @@ impl ProviderKind { "ollama" | "ollama-local" => Some(Self::Ollama), "huggingface" | "hugging-face" | "hugging_face" | "hf" => Some(Self::Huggingface), "together" | "together-ai" | "together_ai" => Some(Self::Together), + "openai-codex" | "openai_codex" | "openaicodex" | "codex" | "chatgpt" + | "chatgpt-codex" | "chatgpt_codex" | "chatgptcodex" => Some(Self::OpenaiCodex), _ => None, } } @@ -288,6 +303,15 @@ pub struct ProvidersToml { pub huggingface: ProviderConfigToml, #[serde(default)] pub together: ProviderConfigToml, + #[serde( + default, + alias = "openai-codex", + alias = "openai_codex", + alias = "codex", + alias = "chatgpt", + alias = "chatgpt-codex" + )] + pub openai_codex: ProviderConfigToml, } /// Sibling `permissions.toml` schema. @@ -336,6 +360,7 @@ impl ProvidersToml { ProviderKind::Ollama => &self.ollama, ProviderKind::Huggingface => &self.huggingface, ProviderKind::Together => &self.together, + ProviderKind::OpenaiCodex => &self.openai_codex, } } @@ -359,6 +384,7 @@ impl ProvidersToml { ProviderKind::Ollama => &mut self.ollama, ProviderKind::Huggingface => &mut self.huggingface, ProviderKind::Together => &mut self.together, + ProviderKind::OpenaiCodex => &mut self.openai_codex, } } } @@ -1995,6 +2021,7 @@ impl ConfigToml { ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(), ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(), + ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(), }) }; // CLI flag wins outright. Otherwise: config-file → injected secrets/env. @@ -2426,6 +2453,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL, ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL, ProviderKind::Together => DEFAULT_TOGETHER_MODEL, + ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL, } } @@ -2450,6 +2478,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL, ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL, ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL, + ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL, } } @@ -3200,6 +3229,8 @@ struct EnvRuntimeOverrides { huggingface_model: Option, together_base_url: Option, together_model: Option, + openai_codex_base_url: Option, + openai_codex_model: Option, } impl EnvRuntimeOverrides { @@ -3355,6 +3386,14 @@ impl EnvRuntimeOverrides { together_model: std::env::var("TOGETHER_MODEL") .ok() .filter(|v| !v.trim().is_empty()), + openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL") + .or_else(|_| std::env::var("CODEX_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), + openai_codex_model: std::env::var("OPENAI_CODEX_MODEL") + .or_else(|_| std::env::var("CODEX_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), } } @@ -3382,6 +3421,7 @@ impl EnvRuntimeOverrides { ProviderKind::Ollama => self.ollama_base_url.clone(), ProviderKind::Huggingface => self.huggingface_base_url.clone(), ProviderKind::Together => self.together_base_url.clone(), + ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(), } } @@ -3400,6 +3440,7 @@ impl EnvRuntimeOverrides { ProviderKind::Fireworks => self.fireworks_model.clone(), ProviderKind::Huggingface => self.huggingface_model.clone(), ProviderKind::Together => self.together_model.clone(), + ProviderKind::OpenaiCodex => self.openai_codex_model.clone(), _ => None, }?; @@ -5091,7 +5132,13 @@ unix_socket_path = "/tmp/cw-hooks.sock" ); assert!(!provider.display_name().trim().is_empty()); assert!(!provider.env_vars().is_empty()); - assert_eq!(provider.wire(), provider::WireFormat::ChatCompletions); + // OpenAI Codex (ChatGPT) speaks the Responses API; every other + // built-in provider is OpenAI-compatible Chat Completions. + let expected_wire = match kind { + ProviderKind::OpenaiCodex => provider::WireFormat::Responses, + _ => provider::WireFormat::ChatCompletions, + }; + assert_eq!(provider.wire(), expected_wire); } } diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index bef2f10b..61e751f3 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -11,7 +11,8 @@ use super::{ DEFAULT_HUGGINGFACE_MODEL, DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL, DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, - DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL, DEFAULT_OPENAI_CODEX_MODEL, + DEFAULT_OPENAI_MODEL, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL, DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, @@ -25,6 +26,8 @@ use super::{ pub enum WireFormat { /// OpenAI-compatible `/v1/chat/completions` style payloads. ChatCompletions, + /// OpenAI Responses API (`/responses`). + Responses, } /// Static metadata for a built-in model provider. @@ -285,6 +288,39 @@ provider!( "together" ); +/// OpenAI Codex / ChatGPT OAuth provider using the Responses API. +pub struct OpenaiCodex; + +impl Provider for OpenaiCodex { + fn kind(&self) -> ProviderKind { + ProviderKind::OpenaiCodex + } + + fn display_name(&self) -> &'static str { + "OpenAI Codex (ChatGPT)" + } + + fn default_base_url(&self) -> &'static str { + DEFAULT_OPENAI_CODEX_BASE_URL + } + + fn default_model(&self) -> &'static str { + DEFAULT_OPENAI_CODEX_MODEL + } + + fn env_vars(&self) -> &'static [&'static str] { + &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"] + } + + fn provider_config_key(&self) -> &'static str { + "openai_codex" + } + + fn wire(&self) -> WireFormat { + WireFormat::Responses + } +} + static DEEPSEEK: Deepseek = Deepseek; static NVIDIA_NIM: NvidiaNim = NvidiaNim; static OPENAI: Openai = Openai; @@ -304,8 +340,9 @@ static VLLM: Vllm = Vllm; static OLLAMA: Ollama = Ollama; static HUGGINGFACE: Huggingface = Huggingface; static TOGETHER: Together = Together; +static OPENAI_CODEX: OpenaiCodex = OpenaiCodex; -static PROVIDER_REGISTRY: [&dyn Provider; 19] = [ +static PROVIDER_REGISTRY: [&dyn Provider; 20] = [ &DEEPSEEK, &NVIDIA_NIM, &OPENAI, @@ -325,6 +362,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 19] = [ &OLLAMA, &HUGGINGFACE, &TOGETHER, + &OPENAI_CODEX, ]; /// Return all built-in provider metadata entries in `ProviderKind::ALL` order. @@ -372,5 +410,6 @@ pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider { ProviderKind::Ollama => &OLLAMA, ProviderKind::Huggingface => &HUGGINGFACE, ProviderKind::Together => &TOGETHER, + ProviderKind::OpenaiCodex => &OPENAI_CODEX, } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 3b85f9f9..39bd57e0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.54" } -codewhale-config = { path = "../config", version = "0.8.54" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.54" } -codewhale-hooks = { path = "../hooks", version = "0.8.54" } -codewhale-mcp = { path = "../mcp", version = "0.8.54" } -codewhale-protocol = { path = "../protocol", version = "0.8.54" } -codewhale-state = { path = "../state", version = "0.8.54" } -codewhale-tools = { path = "../tools", version = "0.8.54" } +codewhale-agent = { path = "../agent", version = "0.8.55" } +codewhale-config = { path = "../config", version = "0.8.55" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.55" } +codewhale-hooks = { path = "../hooks", version = "0.8.55" } +codewhale-mcp = { path = "../mcp", version = "0.8.55" } +codewhale-protocol = { path = "../protocol", version = "0.8.55" } +codewhale-state = { path = "../state", version = "0.8.55" } +codewhale-tools = { path = "../tools", version = "0.8.55" } serde_json.workspace = true tracing.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 670a7d75..7b72d045 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.54" } +codewhale-protocol = { path = "../protocol", version = "0.8.55" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 3f37af18..1fbe8ba6 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.54" } +codewhale-protocol = { path = "../protocol", version = "0.8.55" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 91c5c449..5a8ce7be 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.54" } +codewhale-protocol = { path = "../protocol", version = "0.8.55" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index fb4d3845..8f0b062c 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.55] - 2026-06-08 + +### Added + +- Together AI provider (`[providers.together]`) with picker, auth, and + capability support, plus Qwen 3.7 Max, MiniMax 2.7, and NVIDIA Nemotron 3 + Ultra catalog entries. +- Experimental OpenAI Codex (ChatGPT) provider (`openai-codex`) that reuses an + existing Codex CLI OAuth login from `~/.codex/auth.json` and talks to the + OpenAI Responses API. See `docs/PROVIDERS.md`. The live round-trip is not + exercised in CI; treat as preview. + ## [0.8.54] - 2026-06-08 ### Added diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index a520be97..f1702b08 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -20,11 +20,11 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" -codewhale-config = { path = "../config", version = "0.8.54" } -codewhale-protocol = { path = "../protocol", version = "0.8.54" } -codewhale-release = { path = "../release", version = "0.8.54" } -codewhale-secrets = { path = "../secrets", version = "0.8.54" } -codewhale-tools = { path = "../tools", version = "0.8.54" } +codewhale-config = { path = "../config", version = "0.8.55" } +codewhale-protocol = { path = "../protocol", version = "0.8.55" } +codewhale-release = { path = "../release", version = "0.8.55" } +codewhale-secrets = { path = "../secrets", version = "0.8.55" } +codewhale-tools = { path = "../tools", version = "0.8.55" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index a9bdf149..c4e39d7b 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1130,6 +1130,9 @@ impl LlmClient for DeepSeekClient { &self, request: MessageRequest, ) -> Result { + if self.api_provider == ApiProvider::OpenaiCodex { + return self.handle_responses_stream(request).await; + } self.handle_chat_completion_stream(request).await } } @@ -1208,6 +1211,9 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Together => { body["thinking"] = json!({ "type": "disabled" }); } + ApiProvider::OpenaiCodex => { + // OpenAI Codex uses Responses API — thinking handled differently + } ApiProvider::Fireworks => {} // vLLM is an OpenAI-protocol server, not an Anthropic-protocol one. // For Qwen3 / DeepSeek-R1 / other reasoning models hosted via vLLM, @@ -1291,7 +1297,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Atlascloud | ApiProvider::WanjieArk | ApiProvider::Moonshot - | ApiProvider::Ollama => {} + | ApiProvider::Ollama + | ApiProvider::OpenaiCodex => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": true, @@ -1334,7 +1341,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Atlascloud | ApiProvider::WanjieArk | ApiProvider::Moonshot - | ApiProvider::Ollama => {} + | ApiProvider::Ollama + | ApiProvider::OpenaiCodex => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": true, @@ -1461,6 +1469,7 @@ impl DeepSeekClient { } mod chat; +mod responses; pub(crate) use chat::{CacheWarmupKey, PromptInspection}; diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs new file mode 100644 index 00000000..fdbc11b8 --- /dev/null +++ b/crates/tui/src/client/responses.rs @@ -0,0 +1,523 @@ +//! OpenAI Responses API bridge for the OpenAI Codex / ChatGPT provider. +//! +//! Implements a dedicated Responses API client that maps CodeWhale's internal +//! message/tool types to the Responses wire format and parses streaming SSE +//! events back into CodeWhale's `StreamEvent` / `MessageResponse` types. +//! +//! This is intentionally separate from the Chat Completions path +//! (`client/chat.rs`) to avoid protocol hacks. + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; + +use crate::llm_client::StreamEventBox; +use crate::logging; +use crate::models::{ + ContentBlock, ContentBlockStart, Delta, MessageDelta, MessageRequest, MessageResponse, + StreamEvent, Tool, Usage, +}; + +use super::{DeepSeekClient, ERROR_BODY_MAX_BYTES, bounded_error_text, system_to_instructions}; + +/// Base URL path for the Codex Responses endpoint. +const CODEX_RESPONSES_PATH: &str = "/codex/responses"; + +impl DeepSeekClient { + /// Build the Responses API request body from a `MessageRequest`. + fn build_responses_body(&self, request: &MessageRequest) -> Value { + let model = &request.model; + let mut body = json!({ + "model": model, + "stream": true, + "store": false, + }); + + // Instructions (system prompt). + if let Some(instructions) = system_to_instructions(request.system.clone()) { + body["instructions"] = json!(instructions); + } + + // Convert messages to Responses input items. + let input = convert_messages_to_responses_input(request); + body["input"] = json!(input); + + // Convert tools to Responses function tools. + if let Some(tools) = request.tools.as_ref() { + let responses_tools: Vec = + tools.iter().map(tool_to_responses_function).collect(); + if !responses_tools.is_empty() { + body["tools"] = json!(responses_tools); + body["tool_choice"] = json!("auto"); + body["parallel_tool_calls"] = json!(true); + } + } + + // Reasoning configuration. + if let Some(effort) = request.reasoning_effort.as_deref() { + let summary = match effort { + "off" | "disabled" | "none" | "false" => "off", + _ => "auto", + }; + if summary != "off" { + body["reasoning"] = json!({ + "effort": effort, + "summary": summary, + }); + } + } + + // Include reasoning summaries in the stream. + body["include"] = json!(["reasoning.encrypted_content"]); + + body + } + + /// Handle a streaming Responses API request for the OpenAI Codex provider. + pub(super) async fn handle_responses_stream( + &self, + request: MessageRequest, + ) -> Result { + let body = self.build_responses_body(&request); + let url = format!("{}{}", self.base_url, CODEX_RESPONSES_PATH); + + // The bearer Authorization header is already installed as a default + // header on `http_client` (resolved from the Codex OAuth access token), + // so it must not be set again here or it would be duplicated. The + // ChatGPT backend additionally requires the account id and the + // experimental Responses beta opt-in. + let mut builder = self + .http_client + .post(&url) + .header("Content-Type", "application/json") + .header("OpenAI-Beta", "responses=experimental") + .header("originator", "codex_cli_rs"); + if let Some(account_id) = crate::oauth::codex_account_id() { + builder = builder.header("chatgpt-account-id", account_id); + } + + let response = builder + .json(&body) + .send() + .await + .context("Responses API request failed")?; + + let status = response.status(); + if !status.is_success() { + let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + anyhow::bail!("Responses API error (HTTP {status}): {raw}"); + } + + let stream_idle_timeout = self.stream_idle_timeout; + let byte_stream = response.bytes_stream(); + + let stream = async_stream::stream! { + use futures_util::StreamExt; + + // Emit synthetic MessageStart. + yield Ok(StreamEvent::MessageStart { + message: MessageResponse { + id: String::new(), + r#type: "message".to_string(), + role: "assistant".to_string(), + content: vec![], + model: request.model.clone(), + stop_reason: None, + stop_sequence: None, + container: None, + usage: Usage::default(), + }, + }); + + let mut _response_id = String::new(); + let mut _current_item_type: Option = None; + let mut current_block_index: Option = None; + let mut saw_tool_call = false; + let mut _output_text = String::new(); + let mut _thinking_text = String::new(); + let mut usage_data: Option = None; + let mut buffer = String::new(); + let mut done = false; + let mut content_block_counter: u32 = 0; + + tokio::pin!(byte_stream); + + while !done { + let chunk = match tokio::time::timeout(stream_idle_timeout, byte_stream.next()).await { + Ok(Some(Ok(chunk))) => chunk, + Ok(Some(Err(e))) => { + yield Err(anyhow::anyhow!("Stream read error: {e}")); + return; + } + Ok(None) => break, + Err(_) => { + yield Err(anyhow::anyhow!("Stream idle timeout")); + return; + } + }; + + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete SSE lines. + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim().to_string(); + buffer = buffer[line_end + 1..].to_string(); + + if line.is_empty() || line.starts_with(':') { + continue; + } + + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + done = true; + break; + } + + let event: Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(e) => { + logging::warn(format!( + "Failed to parse Responses SSE event: {e}" + )); + continue; + } + }; + + let event_type = + event.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match event_type { + "response.created" => { + if let Some(resp) = event.get("response") { + _response_id = resp + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + } + } + "response.output_item.added" => { + if let Some(item) = event.get("item") { + let item_type = item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + _current_item_type = Some(item_type.to_string()); + + match item_type { + "message" => { + content_block_counter += 1; + yield Ok(StreamEvent::ContentBlockStart { + index: content_block_counter - 1, + content_block: ContentBlockStart::Text { + text: String::new(), + }, + }); + current_block_index = + Some(content_block_counter - 1); + } + "function_call" => { + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let item_id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + saw_tool_call = true; + // call_id and item_id are folded + // into a composite tool-use id so + // the function_call_output can be + // routed back to the right call. + let composite_id = + format!("{call_id}|{item_id}"); + content_block_counter += 1; + yield Ok(StreamEvent::ContentBlockStart { + index: content_block_counter - 1, + content_block: + ContentBlockStart::ToolUse { + id: composite_id, + name, + input: json!({}), + caller: None, + }, + }); + current_block_index = + Some(content_block_counter - 1); + } + "reasoning" => { + content_block_counter += 1; + yield Ok(StreamEvent::ContentBlockStart { + index: content_block_counter - 1, + content_block: + ContentBlockStart::Thinking { + thinking: String::new(), + }, + }); + current_block_index = + Some(content_block_counter - 1); + } + _ => {} + } + } + } + "response.output_text.delta" => { + if let Some(delta_text) = + event.get("delta").and_then(|d| d.as_str()) + { + _output_text.push_str(delta_text); + if let Some(idx) = current_block_index { + yield Ok(StreamEvent::ContentBlockDelta { + index: idx, + delta: Delta::TextDelta { + text: delta_text.to_string(), + }, + }); + } + } + } + "response.function_call_arguments.delta" => { + if let Some(delta_text) = + event.get("delta").and_then(|d| d.as_str()) + { + if let Some(idx) = current_block_index { + yield Ok(StreamEvent::ContentBlockDelta { + index: idx, + delta: Delta::InputJsonDelta { + partial_json: delta_text.to_string(), + }, + }); + } + } + } + "response.reasoning_summary_text.delta" + | "response.reasoning_text.delta" => { + if let Some(delta_text) = + event.get("delta").and_then(|d| d.as_str()) + { + _thinking_text.push_str(delta_text); + if let Some(idx) = current_block_index { + yield Ok(StreamEvent::ContentBlockDelta { + index: idx, + delta: Delta::ThinkingDelta { + thinking: delta_text.to_string(), + }, + }); + } + } + } + "response.output_item.done" => { + if let Some(idx) = current_block_index { + yield Ok(StreamEvent::ContentBlockStop { index: idx }); + current_block_index = None; + _current_item_type = None; + } + } + "response.completed" => { + if let Some(resp) = event.get("response") { + if let Some(usage_val) = resp.get("usage") { + usage_data = + Some(parse_responses_usage(usage_val)); + } + let status = resp + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("completed"); + let stop_reason = match status { + "completed" => { + if saw_tool_call { + "tool_use" + } else { + "end_turn" + } + } + "incomplete" => "max_tokens", + _ => "end_turn", + }; + yield Ok(StreamEvent::MessageDelta { + delta: MessageDelta { + stop_reason: Some(stop_reason.to_string()), + stop_sequence: None, + }, + usage: usage_data.take(), + }); + } + } + "error" => { + let msg = event + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + let code = event + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or("unknown"); + yield Err(anyhow::anyhow!( + "Responses API error [{code}]: {msg}" + )); + return; + } + _ => { + // Ignore unknown event types. + } + } + } + } + } + + // Emit MessageStop. + yield Ok(StreamEvent::MessageStop); + }; + + Ok(Box::pin(stream)) + } +} + +/// Convert CodeWhale messages to Responses API input items. +fn convert_messages_to_responses_input(request: &MessageRequest) -> Vec { + let mut items = Vec::new(); + + for msg in &request.messages { + match msg.role.as_str() { + "user" => { + let mut content_items = Vec::new(); + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } => { + content_items.push(json!({ + "type": "input_text", + "text": text, + })); + } + ContentBlock::ImageUrl { image_url } => { + content_items.push(json!({ + "type": "input_image", + "image_url": image_url.url, + })); + } + _ => {} + } + } + if !content_items.is_empty() { + items.push(json!({ + "type": "message", + "role": "user", + "content": content_items, + })); + } + } + "assistant" => { + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } => { + items.push(json!({ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": text, + }], + })); + } + ContentBlock::ToolUse { id, name, input, .. } => { + let (call_id, _item_id) = parse_tool_use_id(id); + items.push(json!({ + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": serde_json::to_string(input).unwrap_or_default(), + })); + } + ContentBlock::Thinking { thinking } => { + items.push(json!({ + "type": "reasoning", + "summary": [{ + "type": "summary_text", + "text": thinking, + }], + })); + } + _ => {} + } + } + } + "tool" => { + for block in &msg.content { + if let ContentBlock::ToolResult { + tool_use_id, + content, + .. + } = block + { + let (call_id, _item_id) = parse_tool_use_id(tool_use_id); + items.push(json!({ + "type": "function_call_output", + "call_id": call_id, + "output": content, + })); + } + } + } + _ => {} + } + } + + items +} + +/// Convert a CodeWhale tool definition to a Responses API function tool. +fn tool_to_responses_function(tool: &Tool) -> Value { + json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + "strict": false, + }) +} + +/// Parse a composite tool_use_id back to (call_id, item_id). +/// Composite format: "call_id|item_id" +fn parse_tool_use_id(id: &str) -> (String, String) { + if let Some(pipe_pos) = id.find('|') { + ( + id[..pipe_pos].to_string(), + id[pipe_pos + 1..].to_string(), + ) + } else { + (id.to_string(), String::new()) + } +} + +/// Parse usage from a Responses API usage object. +fn parse_responses_usage(val: &Value) -> Usage { + let input = val + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let output = val + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let cached = val + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + Usage { + input_tokens: input, + output_tokens: output, + prompt_cache_hit_tokens: if cached > 0 { Some(cached) } else { None }, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, + reasoning_replay_tokens: None, + server_tool_use: None, + } +} diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 56ba7164..ad494f61 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -144,6 +144,8 @@ pub const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash pub const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1"; pub const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; pub const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1"; +pub const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5"; +pub const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api"; /// Legacy `deepseek-cn` provider alias. /// /// DeepSeek's official API host is the same worldwide. Keep this alias for @@ -185,6 +187,7 @@ pub enum ApiProvider { Ollama, Huggingface, Together, + OpenaiCodex, } impl ApiProvider { @@ -234,6 +237,8 @@ impl ApiProvider { "ollama" | "ollama-local" => Some(Self::Ollama), "huggingface" | "hugging-face" | "hugging_face" | "hf" => Some(Self::Huggingface), "together" | "together-ai" | "together_ai" => Some(Self::Together), + "openai-codex" | "openai_codex" | "openaicodex" | "codex" | "chatgpt" + | "chatgpt-codex" | "chatgpt_codex" | "chatgptcodex" => Some(Self::OpenaiCodex), _ => None, } } @@ -261,6 +266,7 @@ impl ApiProvider { Self::Ollama => "ollama", Self::Huggingface => "huggingface", Self::Together => "together", + Self::OpenaiCodex => "openai-codex", } } @@ -288,6 +294,7 @@ impl ApiProvider { Self::Ollama => "Ollama", Self::Huggingface => "Hugging Face", Self::Together => "Together AI", + Self::OpenaiCodex => "OpenAI Codex (ChatGPT)", } } @@ -314,6 +321,7 @@ impl ApiProvider { Self::Ollama, Self::Huggingface, Self::Together, + Self::OpenaiCodex, ] } } @@ -817,6 +825,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Ollama => Vec::new(), ApiProvider::Openai | ApiProvider::Atlascloud => OFFICIAL_DEEPSEEK_MODELS.to_vec(), ApiProvider::Together => vec![DEFAULT_TOGETHER_MODEL], + ApiProvider::OpenaiCodex => vec![DEFAULT_OPENAI_CODEX_MODEL], } } @@ -1958,6 +1967,8 @@ pub struct ProvidersConfig { pub huggingface: ProviderConfig, #[serde(default, alias = "together-ai")] pub together: ProviderConfig, + #[serde(default, alias = "openai-codex", alias = "codex", alias = "chatgpt")] + pub openai_codex: ProviderConfig, } #[derive(Debug, Clone, Deserialize, Default)] @@ -2122,6 +2133,7 @@ impl Config { ApiProvider::Huggingface => "providers.huggingface", ApiProvider::NvidiaNim => "providers.nvidia_nim", ApiProvider::Together => "providers.together", + ApiProvider::OpenaiCodex => "providers.openai_codex", ApiProvider::Deepseek | ApiProvider::DeepseekCN => return, }; tracing::warn!( @@ -2270,6 +2282,7 @@ impl Config { ApiProvider::Volcengine => &providers.volcengine, ApiProvider::Huggingface => &providers.huggingface, ApiProvider::Together => &providers.together, + ApiProvider::OpenaiCodex => &providers.openai_codex, }) } @@ -2295,6 +2308,7 @@ impl Config { ApiProvider::Volcengine => &mut providers.volcengine, ApiProvider::Huggingface => &mut providers.huggingface, ApiProvider::Together => &mut providers.together, + ApiProvider::OpenaiCodex => &mut providers.openai_codex, } } @@ -2407,6 +2421,7 @@ impl Config { ApiProvider::Volcengine => DEFAULT_VOLCENGINE_MODEL, ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_MODEL, ApiProvider::Together => DEFAULT_TOGETHER_MODEL, + ApiProvider::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL, } .to_string() } @@ -2445,7 +2460,8 @@ impl Config { | ApiProvider::Ollama | ApiProvider::Volcengine | ApiProvider::Huggingface - | ApiProvider::Together => None, + | ApiProvider::Together + | ApiProvider::OpenaiCodex => None, }; let configured_base_url = provider_base.or(root_base); let base = if provider == ApiProvider::XiaomiMimo { @@ -2491,6 +2507,7 @@ impl Config { ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL, ApiProvider::Together => DEFAULT_TOGETHER_BASE_URL, + ApiProvider::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL, } .to_string() }) @@ -2539,6 +2556,7 @@ impl Config { ApiProvider::Volcengine => "volcengine", ApiProvider::Huggingface => "huggingface", ApiProvider::Together => "together", + ApiProvider::OpenaiCodex => "openai_codex", }; // 0. DeepSeek compatibility slot. The legacy top-level `api_key` @@ -2560,6 +2578,15 @@ impl Config { return kimi_cli_oauth_access_token(); } + // OpenAI Codex (ChatGPT) reuses the existing Codex CLI OAuth login. + // The access token lives in ~/.codex/auth.json (refreshed on demand) + // rather than a stored API key, so resolve it before the config-file + // and env slots. Explicit env overrides are handled inside + // `codex_access_token`. + if provider == ApiProvider::OpenaiCodex { + return crate::oauth::codex_access_token(); + } + // 1. Config file (provider-scoped slot). This intentionally wins // over ambient env so `codewhale auth set` fixes stale shell exports. if let Some(configured) = self @@ -2682,6 +2709,16 @@ impl Config { "Together AI API key not found. Run 'codewhale auth set --provider together', \ set TOGETHER_API_KEY, or add [providers.together] api_key in ~/.codewhale/config.toml." ), + ApiProvider::OpenaiCodex => anyhow::bail!( + "OpenAI Codex OAuth credentials not found.\n\ + \n\ + CodeWhale uses your existing ChatGPT/Codex login.\n\ + 1. Run: codex login (or use the Codex CLI to authenticate)\n\ + 2. CodeWhale will read credentials from ~/.codex/auth.json\n\ + \n\ + Env overrides:\n\ + OPENAI_CODEX_ACCESS_TOKEN or CODEX_ACCESS_TOKEN" + ), // Self-hosted deployments commonly run without auth on localhost. // Return an empty key and let the client omit the Authorization header. ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()), @@ -3488,6 +3525,13 @@ fn apply_env_overrides(config: &mut Config) { .together .base_url = Some(value); } + ApiProvider::OpenaiCodex => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openai_codex + .base_url = Some(value); + } } } if matches!(config.api_provider(), ApiProvider::NvidiaNim) @@ -3695,6 +3739,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Volcengine => &mut providers.volcengine, ApiProvider::Huggingface => &mut providers.huggingface, ApiProvider::Together => &mut providers.together, + ApiProvider::OpenaiCodex => &mut providers.openai_codex, }; let mut provider_headers = entry.http_headers.clone().unwrap_or_default(); provider_headers.extend(headers); @@ -3890,6 +3935,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Volcengine => &mut providers.volcengine, ApiProvider::Huggingface => &mut providers.huggingface, ApiProvider::Together => &mut providers.together, + ApiProvider::OpenaiCodex => &mut providers.openai_codex, }; entry.model = Some(value); } @@ -4213,6 +4259,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL, ApiProvider::Together => DEFAULT_TOGETHER_BASE_URL, + ApiProvider::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL, } } @@ -4635,6 +4682,7 @@ fn merge_providers( volcengine: merge_provider_config(base.volcengine, override_cfg.volcengine), huggingface: merge_provider_config(base.huggingface, override_cfg.huggingface), together: merge_provider_config(base.together, override_cfg.together), + openai_codex: merge_provider_config(base.openai_codex, override_cfg.openai_codex), }), } } @@ -5049,6 +5097,12 @@ pub fn active_provider_has_config_api_key(config: &Config) -> bool { { return kimi_cli_credentials_present(); } + if provider == ApiProvider::OpenaiCodex { + // The persistent Codex login is the OAuth credential file, analogous to + // a stored config key. Token env overrides are scored separately by + // active_provider_has_env_api_key. + return crate::oauth::auth_file_path().exists(); + } if matches!(provider, ApiProvider::Huggingface) && std::env::var("HF_TOKEN").is_ok_and(|k| !k.trim().is_empty()) { @@ -5124,6 +5178,10 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { ApiProvider::Together => { std::env::var("TOGETHER_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } + ApiProvider::OpenaiCodex => { + std::env::var("OPENAI_CODEX_ACCESS_TOKEN").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("CODEX_ACCESS_TOKEN").is_ok_and(|k| !k.trim().is_empty()) + } } } @@ -5151,6 +5209,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Arcee => "ARCEE_API_KEY", ApiProvider::Huggingface => "HUGGINGFACE_API_KEY", ApiProvider::Together => "TOGETHER_API_KEY", + ApiProvider::OpenaiCodex => "OPENAI_CODEX_ACCESS_TOKEN", ApiProvider::Moonshot => "MOONSHOT_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", @@ -5196,6 +5255,11 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { { return kimi_cli_credentials_present(); } + if provider == ApiProvider::OpenaiCodex { + // Any usable Codex credential: either token env override or the Codex + // CLI OAuth login on disk. + return crate::oauth::credentials_present(); + } if matches!(provider, ApiProvider::Huggingface) && std::env::var("HF_TOKEN").is_ok_and(|k| !k.trim().is_empty()) { @@ -5273,6 +5337,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Ollama => "providers.ollama", ApiProvider::Volcengine => "providers.volcengine", ApiProvider::Together => "providers.together", + ApiProvider::OpenaiCodex => "providers.openai_codex", }; // Parse existing TOML (or start fresh) so we can edit the right table @@ -5317,6 +5382,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Ollama => "ollama", ApiProvider::Volcengine => "volcengine", ApiProvider::Together => "together", + ApiProvider::OpenaiCodex => "openai_codex", }; let entry = providers .entry(key_inside.to_string()) @@ -5413,6 +5479,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { ApiProvider::Vllm => Ok("vllm"), ApiProvider::Ollama => Ok("ollama"), ApiProvider::Together => Ok("together"), + ApiProvider::OpenaiCodex => Ok("openai_codex"), } } diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index 8c97cb82..eb1cf012 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -219,6 +219,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static ApiProvider::Vllm => Ok("vllm"), ApiProvider::Ollama => Ok("ollama"), ApiProvider::Together => Ok("together"), + ApiProvider::OpenaiCodex => Ok("openai_codex"), } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2a4b6f9e..d642100d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -615,6 +615,7 @@ impl Engine { ApiProvider::Ollama => "OLLAMA_API_KEY", ApiProvider::Huggingface => "HUGGINGFACE_API_KEY/HF_TOKEN", ApiProvider::Together => "TOGETHER_API_KEY", + ApiProvider::OpenaiCodex => "OPENAI_CODEX_ACCESS_TOKEN/CODEX_ACCESS_TOKEN", }; Some(format!( diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index db897bc2..998c53a9 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -50,6 +50,7 @@ mod memory; mod model_routing; mod models; mod network_policy; +mod oauth; mod palette; mod prefix_cache; mod pricing; @@ -2048,6 +2049,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "TOGETHER_API_KEY", "codewhale auth set --provider together --api-key \"...\"", ), + crate::config::ApiProvider::OpenaiCodex => ( + "OPENAI_CODEX_ACCESS_TOKEN/CODEX_ACCESS_TOKEN", + "see docs/PROVIDERS.md for ChatGPT/Codex OAuth setup", + ), crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => { ("DEEPSEEK_API_KEY", "codewhale auth set --provider deepseek") } @@ -2074,6 +2079,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Ollama => "ollama", crate::config::ApiProvider::Huggingface => "huggingface", crate::config::ApiProvider::Together => "together", + crate::config::ApiProvider::OpenaiCodex => "openai_codex", crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => "deepseek", } diff --git a/crates/tui/src/oauth.rs b/crates/tui/src/oauth.rs new file mode 100644 index 00000000..45ac352c --- /dev/null +++ b/crates/tui/src/oauth.rs @@ -0,0 +1,399 @@ +//! OpenAI Codex / ChatGPT OAuth credential loading and token refresh. +//! +//! Reads existing Codex CLI credentials from `~/.codex/auth.json` (or +//! `$CODEX_HOME/auth.json`) and transparently refreshes expired access tokens +//! using the OpenAI auth endpoint. +//! +//! # Security +//! +//! Token values are never logged or printed. All debug representations +//! redact sensitive fields. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Serialize}; + +/// OAuth token payload stored in `auth.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +struct AuthTokens { + access_token: Option, + refresh_token: Option, + id_token: Option, + account_id: Option, +} + +/// Top-level structure of Codex CLI's `auth.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexAuthFile { + tokens: Option, + last_refresh: Option, +} + +/// Resolved OAuth credentials ready for API use. +#[derive(Debug, Clone)] +pub struct CodexCredentials { + pub access_token: String, + pub refresh_token: Option, + pub account_id: Option, +} + +/// JWT claims subset for expiry extraction. +#[derive(Debug, Deserialize)] +struct JwtClaims { + exp: Option, +} + +/// Resolve the path to the Codex auth file. +/// +/// Priority: +/// 1. `OPENAI_CODEX_AUTH_FILE` env var +/// 2. `$CODEX_HOME/auth.json` +/// 3. `~/.codex/auth.json` +pub fn auth_file_path() -> PathBuf { + if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") { + let p = PathBuf::from(&path); + if !p.as_os_str().is_empty() { + return p; + } + } + let codex_home = std::env::var("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".codex") + }); + codex_home.join("auth.json") +} + +/// Whether any usable Codex credential is present without performing a refresh +/// or network call. +/// +/// Mirrors `kimi_cli_credentials_present`: returns true if an access-token env +/// override is set or the Codex auth file exists on disk. Used by the provider +/// picker / auth surfaces so a `codex login` user is not treated as +/// unauthenticated. +#[must_use] +pub fn credentials_present() -> bool { + for var in ["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"] { + if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) { + return true; + } + } + auth_file_path().exists() +} + +/// Try to extract `exp` (epoch seconds) from a JWT without verifying +/// the signature. Returns `None` on any parse failure. +fn jwt_expiry_seconds(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = parts[1]; + let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?; + let claims: JwtClaims = serde_json::from_slice(&decoded).ok()?; + claims.exp +} + +/// Check whether an access token is expired, with a 60-second safety margin. +fn token_is_expired(access_token: &str) -> bool { + match jwt_expiry_seconds(access_token) { + Some(exp) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs(); + // 60-second safety margin + now + 60 >= exp + } + // If we can't parse expiry, assume it might be expired — try refresh. + None => true, + } +} + +/// Load Codex credentials from the auth file. +/// +/// Returns `Ok(None)` if the file doesn't exist or has no usable tokens. +/// Returns `Err` only on parse/IO errors that aren't "file not found". +pub fn load_credentials() -> Result> { + let path = auth_file_path(); + if !path.exists() { + return Ok(None); + } + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("reading Codex auth file: {}", path.display()))?; + let auth: CodexAuthFile = serde_json::from_str(&contents) + .with_context(|| format!("parsing Codex auth file: {}", path.display()))?; + let tokens = match auth.tokens { + Some(t) => t, + None => return Ok(None), + }; + let access_token = match tokens.access_token { + Some(t) if !t.trim().is_empty() => t, + _ => return Ok(None), + }; + Ok(Some(CodexCredentials { + access_token, + refresh_token: tokens.refresh_token, + account_id: tokens.account_id, + })) +} + +/// Refresh an expired access token using the refresh token. +/// +/// Calls the OpenAI token endpoint and returns new credentials. +/// On success, updates the auth file on disk. Synchronous (blocking) so it can +/// run inside the prompt-free, sync config credential-resolution path, matching +/// the Kimi OAuth refresh flow. +fn refresh_access_token(refresh_token: &str) -> Result { + let client = crate::tls::reqwest_blocking_client_builder() + .timeout(Duration::from_secs(30)) + .build() + .context("building token refresh client")?; + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CODEX_CLIENT_ID), + ]; + let response = client + .post(TOKEN_URL) + .form(¶ms) + .send() + .context("sending token refresh request")?; + let status = response.status(); + if !status.is_success() { + let body = response.text().unwrap_or_default(); + bail!("Token refresh failed (HTTP {status}): {body}"); + } + let body: serde_json::Value = response + .json() + .context("parsing token refresh response")?; + let new_access = body["access_token"] + .as_str() + .context("missing access_token in refresh response")? + .to_string(); + let new_refresh = body["refresh_token"] + .as_str() + .map(ToOwned::to_owned); + let new_id = body["id_token"].as_str().map(ToOwned::to_owned); + + // Extract account_id from id_token if available. + let account_id = new_id + .as_deref() + .and_then(extract_account_id_from_id_token); + + let creds = CodexCredentials { + access_token: new_access, + refresh_token: new_refresh.or_else(|| Some(refresh_token.to_string())), + account_id, + }; + + // Persist refreshed credentials. + if let Err(e) = save_credentials(&creds, new_id.as_deref()) { + tracing::warn!("Failed to persist refreshed Codex credentials: {e}"); + } + + Ok(creds) +} + +/// Extract `chatgpt_account_id` from the `https://api.openai.com/auth` +/// JWT claim namespace. +fn extract_account_id_from_id_token(id_token: &str) -> Option { + let parts: Vec<&str> = id_token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let decoded = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + value + .get("https://api.openai.com/auth")? + .get("chatgpt_account_id")? + .as_str() + .map(ToOwned::to_owned) +} + +/// Save credentials back to the auth file, preserving file permissions. +fn save_credentials(creds: &CodexCredentials, id_token: Option<&str>) -> Result<()> { + let path = auth_file_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating Codex auth dir: {}", parent.display()))?; + } + let auth = CodexAuthFile { + tokens: Some(AuthTokens { + access_token: Some(creds.access_token.clone()), + refresh_token: creds.refresh_token.clone(), + id_token: id_token.map(ToOwned::to_owned), + account_id: creds.account_id.clone(), + }), + last_refresh: Some( + chrono_humanize_if_available() + ), + }; + let json = serde_json::to_string_pretty(&auth).context("serializing credentials")?; + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true).mode(0o600); + let mut file = opts + .open(&path) + .with_context(|| format!("writing Codex auth file: {}", path.display()))?; + std::io::Write::write_all(&mut file, json.as_bytes())?; + } + #[cfg(not(unix))] + { + std::fs::write(&path, &json) + .with_context(|| format!("writing Codex auth file: {}", path.display()))?; + } + Ok(()) +} + +fn chrono_humanize_if_available() -> String { + // Simple ISO-ish timestamp without adding a chrono dependency. + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| format!("{} seconds since epoch", d.as_secs())) + .unwrap_or_else(|_| "unknown".to_string()) +} + +/// Load or refresh Codex credentials. +/// +/// 1. Try env overrides first (`OPENAI_CODEX_ACCESS_TOKEN` / `CODEX_ACCESS_TOKEN`). +/// 2. Load from auth file. +/// 3. If access token is expired and refresh token is available, refresh. +/// +/// Synchronous so it can be called from the prompt-free config credential +/// resolution path (mirrors the Kimi OAuth flow). +pub fn get_credentials() -> Result { + // Env override takes priority. + if let Ok(token) = std::env::var("OPENAI_CODEX_ACCESS_TOKEN") { + if !token.trim().is_empty() { + return Ok(CodexCredentials { + access_token: token, + refresh_token: None, + account_id: codex_account_id_env(), + }); + } + } + if let Ok(token) = std::env::var("CODEX_ACCESS_TOKEN") { + if !token.trim().is_empty() { + return Ok(CodexCredentials { + access_token: token, + refresh_token: None, + account_id: codex_account_id_env(), + }); + } + } + + let creds = load_credentials()?.context( + "No Codex credentials found.\n\ + \n\ + Run `codex login` to authenticate, or set OPENAI_CODEX_ACCESS_TOKEN.", + )?; + + // Check if the access token is still valid. + if !token_is_expired(&creds.access_token) { + return Ok(creds); + } + + // Try refreshing. + match creds.refresh_token { + Some(ref rt) if !rt.trim().is_empty() => { + tracing::info!("Codex access token expired, refreshing..."); + refresh_access_token(rt) + } + _ => bail!( + "Codex access token expired and no refresh token available.\n\ + Run `codex login` to re-authenticate." + ), + } +} + +/// Resolve a Codex access token for use as a bearer credential. +/// +/// Thin wrapper over [`get_credentials`] that returns just the token string, +/// matching the shape the config credential-resolution path expects. +pub fn codex_access_token() -> Result { + Ok(get_credentials()?.access_token) +} + +/// Best-effort ChatGPT account id for the `chatgpt-account-id` request header. +/// +/// Resolves from env overrides first, then the on-disk auth file. Never +/// refreshes and never errors — a missing account id just means the header is +/// omitted. +pub fn codex_account_id() -> Option { + if let Some(id) = codex_account_id_env() { + return Some(id); + } + load_credentials().ok().flatten().and_then(|c| c.account_id) +} + +/// Read a ChatGPT account id from env overrides only. +fn codex_account_id_env() -> Option { + for var in ["OPENAI_CODEX_ACCOUNT_ID", "CODEX_ACCOUNT_ID"] { + if let Ok(value) = std::env::var(var) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +/// OpenAI OAuth constants (from Codex CLI reference implementation). +const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jwt_expiry_parses_valid_token() { + // A minimal JWT with {"exp": 9999999999} as payload. + let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}"); + let token = format!("header.{}.signature", payload); + assert_eq!(jwt_expiry_seconds(&token), Some(9999999999)); + } + + #[test] + fn jwt_expiry_returns_none_for_malformed() { + assert_eq!(jwt_expiry_seconds("not.a.jwt"), None); + assert_eq!(jwt_expiry_seconds(""), None); + assert_eq!(jwt_expiry_seconds("x"), None); + } + + #[test] + fn token_is_expired_detects_future() { + // Far future — should not be expired. + let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}"); + let token = format!("header.{}.sig", payload); + assert!(!token_is_expired(&token)); + } + + #[test] + fn token_is_expired_detects_past() { + // Way in the past. + let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}"); + let token = format!("header.{}.sig", payload); + assert!(token_is_expired(&token)); + } + + #[test] + fn auth_file_path_respects_env() { + // Just verify it returns a path without panicking. + let path = auth_file_path(); + assert!(path.to_string_lossy().contains("auth.json")); + } +} diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index accc52ab..8b51455a 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -119,6 +119,7 @@ impl ProviderPickerView { ApiProvider::Ollama => "OLLAMA_API_KEY", ApiProvider::Huggingface => "HUGGINGFACE_API_KEY / HF_TOKEN", ApiProvider::Together => "TOGETHER_API_KEY", + ApiProvider::OpenaiCodex => "OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN", } } @@ -510,7 +511,8 @@ mod tests { "vLLM", "Ollama", "Hugging Face", - "Together AI" + "Together AI", + "OpenAI Codex (ChatGPT)" ] ); } @@ -545,7 +547,7 @@ mod tests { let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); picker.handle_key(key(KeyCode::Up)); - assert_eq!(picker.selected_provider(), ApiProvider::Together); + assert_eq!(picker.selected_provider(), ApiProvider::OpenaiCodex); picker.handle_key(key(KeyCode::Down)); assert_eq!(picker.selected_provider(), ApiProvider::Deepseek); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 97c44341..a897c1c6 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -7174,6 +7174,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Ollama => Some("Ollama"), crate::config::ApiProvider::Huggingface => Some("HF"), crate::config::ApiProvider::Together => Some("Together"), + crate::config::ApiProvider::OpenaiCodex => Some("Codex"), }; let status_indicator_started_at = if app.low_motion { None @@ -8215,6 +8216,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Ollama => &mut providers.ollama, ApiProvider::Huggingface => &mut providers.huggingface, ApiProvider::Together => &mut providers.together, + ApiProvider::OpenaiCodex => &mut providers.openai_codex, }; entry.api_key = Some(api_key); } @@ -8273,6 +8275,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, ApiProvider::Ollama => &mut providers.ollama, ApiProvider::Huggingface => &mut providers.huggingface, ApiProvider::Together => &mut providers.together, + ApiProvider::OpenaiCodex => &mut providers.openai_codex, }; entry.auth_mode = Some(auth_mode); } diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 83b0e4b1..89556be3 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -136,6 +136,7 @@ endpoint. | `ollama` | `[providers.ollama]` | Optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | `deepseek-coder:1.3b`; provider-hinted custom tags pass through | Self-hosted Ollama OpenAI-compatible route. Localhost deployments commonly omit auth. `OLLAMA_MODEL` is accepted. | | `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible route. Org-prefixed model IDs pass through. | | `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs. | +| `openai-codex` | `[providers.openai_codex]` | OAuth via `codex login` (`~/.codex/auth.json`); env override `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN` | `OPENAI_CODEX_BASE_URL`/`CODEX_BASE_URL`; default `https://chatgpt.com/backend-api` | `gpt-5.5` | **Experimental.** Reuses your existing ChatGPT/Codex CLI OAuth login and talks to the OpenAI Responses API at `/codex/responses`. The access token is read and refreshed from `~/.codex/auth.json`; no API key is stored. `OPENAI_CODEX_MODEL`/`CODEX_MODEL` and `OPENAI_CODEX_ACCOUNT_ID`/`CODEX_ACCOUNT_ID` are accepted. | ### Hugging Face Provider vs MCP vs Hub @@ -217,6 +218,7 @@ endpoint when the endpoint supports model listing. | `ollama` | `deepseek-coder:1.3b`; custom tags pass through when provider hint is `ollama` | yes | no | | `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no | | `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | +| `openai-codex` | `gpt-5.5` | yes | yes | AtlasCloud keeps the same default model as the config layer and adds provider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index e8b0af37..b618b0ca 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,7 +1,7 @@ { "name": "codewhale", - "version": "0.8.54", - "codewhaleBinaryVersion": "0.8.54", + "version": "0.8.55", + "codewhaleBinaryVersion": "0.8.55", "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",