feat(providers): finish OpenAI Codex (ChatGPT OAuth) provider and cut v0.8.55

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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-06-08 16:16:49 -07:00
parent c13bc24805
commit b46f607d91
29 changed files with 1225 additions and 65 deletions
+23
View File
@@ -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
Generated
+16 -16
View File
@@ -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",
+1 -1
View File
@@ -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
+12
View File
@@ -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
# ─────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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
+11
View File
@@ -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(),
+9 -9
View File
@@ -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
+8 -8
View File
@@ -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
+4 -1
View File
@@ -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"],
}
}
+2 -2
View File
@@ -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
+49 -2
View File
@@ -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<String>,
together_base_url: Option<String>,
together_model: Option<String>,
openai_codex_base_url: Option<String>,
openai_codex_model: Option<String>,
}
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);
}
}
+41 -2
View File
@@ -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,
}
}
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+12
View File
@@ -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
+5 -5
View File
@@ -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"
+11 -2
View File
@@ -1130,6 +1130,9 @@ impl LlmClient for DeepSeekClient {
&self,
request: MessageRequest,
) -> Result<crate::llm_client::StreamEventBox> {
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};
+523
View File
@@ -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<Value> =
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<StreamEventBox> {
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<String> = None;
let mut current_block_index: Option<u32> = None;
let mut saw_tool_call = false;
let mut _output_text = String::new();
let mut _thinking_text = String::new();
let mut usage_data: Option<Usage> = 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<Value> {
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,
}
}
+68 -1
View File
@@ -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<PathBuf>
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<PathBuf>
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"),
}
}
+1
View File
@@ -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"),
}
}
+1
View File
@@ -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!(
+6
View File
@@ -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",
}
+399
View File
@@ -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<String>,
refresh_token: Option<String>,
id_token: Option<String>,
account_id: Option<String>,
}
/// Top-level structure of Codex CLI's `auth.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
struct CodexAuthFile {
tokens: Option<AuthTokens>,
last_refresh: Option<String>,
}
/// Resolved OAuth credentials ready for API use.
#[derive(Debug, Clone)]
pub struct CodexCredentials {
pub access_token: String,
pub refresh_token: Option<String>,
pub account_id: Option<String>,
}
/// JWT claims subset for expiry extraction.
#[derive(Debug, Deserialize)]
struct JwtClaims {
exp: Option<u64>,
}
/// 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<u64> {
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<Option<CodexCredentials>> {
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<CodexCredentials> {
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(&params)
.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<String> {
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<CodexCredentials> {
// 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<String> {
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<String> {
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<String> {
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"));
}
}
+4 -2
View File
@@ -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);
+3
View File
@@ -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);
}
+2
View File
@@ -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
+2 -2
View File
@@ -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",