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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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(¶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<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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user