mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls (#693)
* feat: add Codex auth.json token reuse for LLM authentication When LLM_USE_CODEX_AUTH=true, IronClaw reads the Codex CLI's auth.json (default ~/.codex/auth.json) and extracts the API key or OAuth access token. This lets IronClaw piggyback on a Codex login without implementing its own OAuth flow. New env vars: - LLM_USE_CODEX_AUTH: enable Codex auth fallback (default: false) - CODEX_AUTH_PATH: override path to auth.json * fix: handle ChatGPT auth mode correctly Switch base_url to chatgpt.com/backend-api/codex when auth.json contains ChatGPT OAuth tokens. The access_token is a JWT that only works against the private ChatGPT backend, not the public OpenAI API. Refactored codex_auth.rs to return CodexCredentials (token + is_chatgpt_mode) instead of just a string key. * fix: Codex auth takes highest priority over secrets store When LLM_USE_CODEX_AUTH=true, Codex credentials are now loaded before checking env vars or the secrets store overlay. Previously the secrets store key (injected during onboarding) would shadow the Codex token. * feat: Responses API provider for ChatGPT backend - New CodexChatGptProvider speaks the Responses API protocol - Auto-detects model from /models endpoint (gpt-4o -> gpt-5.2-codex) - Adds store=false (required by ChatGPT backend) - Error handling with timeout for HTTP 400 responses - Message format translation: Chat Completions -> Responses API - SSE response parsing for text, tool calls, and usage stats - 7 unit tests for message conversion and SSE parsing * fix: SSE parser uses item_id instead of call_id for tool call deltas The Responses API sends function_call_arguments.delta events with item_id (e.g. fc_...) not call_id (e.g. call_...). The parser now keys pending tool calls by item_id from output_item.added and tracks call_id separately for result matching. * fix: strip empty string values from tool call arguments gpt-5.2-codex fills optional tool parameters with empty strings (e.g. timestamp: ""), which IronClaw's tool validation rejects. Strip them before passing to tool execution. * fix: prevent apiKey mode fallback to ChatGPT token When auth_mode is explicitly 'apiKey' but the key is missing/empty, do not fall through to check for a ChatGPT access_token. This prevents returning credentials with is_chatgpt_mode: true and routing to the wrong LLM provider. * refactor: reuse single reqwest::Client across model discovery and LLM calls Create Client once in with_auto_model, pass &Client to fetch_default_model, and move it into the provider struct. Eliminates the redundant Client::new() that wasted a connection pool. * fix: bump client_version to 1.0.0 to unlock gpt-5.3-codex and gpt-5.4 The /models endpoint gates newer models behind client_version. Version 0.1.0 only returns up to gpt-5.2-codex, while 1.0.0+ also returns gpt-5.3-codex and gpt-5.4. * feat: user-configured LLM_MODEL takes priority over auto-detection Fetch the full model list from /models endpoint. If LLM_MODEL is set, validate it against the supported list and warn with available models if not found. If LLM_MODEL is not set, auto-detect the highest-priority model. Also bumps client_version to 1.0.0 to unlock gpt-5.3/5.4. * fix: add 10s timeout to model discovery HTTP request Prevents startup from blocking indefinitely if chatgpt.com is slow or unreachable. Uses reqwest per-request timeout. * docs: add private API warning for ChatGPT backend endpoint The chatgpt.com/backend-api/codex endpoint is private and undocumented. Add warning in module docs and a runtime log on first use to inform users of potential ToS implications. * feat: implement OAuth 401 token refresh for Codex ChatGPT provider On HTTP 401, if a refresh_token is available, the provider now automatically refreshes the access token via auth.openai.com/oauth/token (same protocol as Codex CLI) and retries the request once. Refreshed tokens are persisted back to auth.json. Changes: - codex_auth: read refresh_token, add refresh_access_token() and persist_refreshed_tokens() - codex_chatgpt: RwLock for api_key, 401 detection + retry in send_request, send_http_request helper - config/llm: thread refresh_token/auth_path through RegistryProviderConfig - llm/mod: pass refresh params to with_auto_model * refactor: lazy model detection via OnceCell, remove block_in_place Model is no longer resolved during provider construction. Instead, resolve_model() uses tokio::sync::OnceCell to lazily fetch from /models on the first LLM call. This eliminates the block_in_place + block_on workaround in create_codex_chatgpt_from_registry. - with_auto_model (async) -> with_lazy_model (sync constructor) - resolve_model() added with OnceCell-based lazy init - build_request_body takes model as parameter - model_name() returns resolved or configured_model as fallback * feat: support multimodal content (images) in Codex ChatGPT provider message_to_input_items now checks content_parts for user messages. ContentPart::Text maps to input_text and ContentPart::ImageUrl maps to input_image, matching the Responses API format used by Codex CLI. Falls back to plain text when content_parts is empty. Also updates client_version to 0.111.0 for /models endpoint. Adds test: test_message_conversion_user_with_image * refactor: move codex_auth module from src/ to src/llm/ codex_auth is only used by the LLM layer (codex_chatgpt provider and config/llm). Moving it under src/llm/ reflects its actual scope. - Remove pub mod codex_auth from lib.rs - Add pub mod codex_auth to llm/mod.rs - Update imports: super::codex_auth, crate::llm::codex_auth * Fix codex provider style issues * Use SecretString throughout codex auth refresh flow * Use SecretString for codex access tokens * Reuse provider client for codex token refresh * Stream Codex SSE responses incrementally * Fix Windows clippy and SQLite test linkage * Trigger checks after regression skip label * Tighten codex auth module handling
This commit is contained in:
@@ -18,6 +18,11 @@ DATABASE_POOL_SIZE=10
|
|||||||
|
|
||||||
# === OpenAI Direct ===
|
# === OpenAI Direct ===
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# Reuse Codex CLI auth.json instead of setting OPENAI_API_KEY manually.
|
||||||
|
# Works with both OpenAI API-key mode and Codex ChatGPT OAuth mode.
|
||||||
|
# In ChatGPT mode this uses the private `chatgpt.com/backend-api/codex` endpoint.
|
||||||
|
# LLM_USE_CODEX_AUTH=true
|
||||||
|
# CODEX_AUTH_PATH=~/.codex/auth.json
|
||||||
|
|
||||||
# === NEAR AI (Chat Completions API) ===
|
# === NEAR AI (Chat Completions API) ===
|
||||||
# Two auth modes:
|
# Two auth modes:
|
||||||
|
|||||||
Generated
+1
@@ -3461,6 +3461,7 @@ dependencies = [
|
|||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
"eventsource-stream",
|
||||||
"flate2",
|
"flate2",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ eula = false
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
eventsource-stream = "0.2"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
|
||||||
|
|||||||
+49
-19
@@ -9,7 +9,6 @@ use crate::llm::config::*;
|
|||||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||||
use crate::llm::session::SessionConfig;
|
use crate::llm::session::SessionConfig;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
impl LlmConfig {
|
impl LlmConfig {
|
||||||
/// Create a test-friendly config without reading env vars.
|
/// Create a test-friendly config without reading env vars.
|
||||||
#[cfg(feature = "libsql")]
|
#[cfg(feature = "libsql")]
|
||||||
@@ -241,8 +240,30 @@ impl LlmConfig {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve API key from env
|
// Codex auth.json override: when LLM_USE_CODEX_AUTH=true,
|
||||||
let api_key = if let Some(env_var) = api_key_env {
|
// credentials from the Codex CLI's auth.json take highest priority
|
||||||
|
// (over env vars AND secrets store). In ChatGPT mode, the base URL
|
||||||
|
// is also overridden to the private ChatGPT backend endpoint.
|
||||||
|
let mut codex_base_url_override: Option<String> = None;
|
||||||
|
let codex_creds = if parse_optional_env("LLM_USE_CODEX_AUTH", false)? {
|
||||||
|
let path = optional_env("CODEX_AUTH_PATH")?
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(crate::llm::codex_auth::default_codex_auth_path);
|
||||||
|
crate::llm::codex_auth::load_codex_credentials(&path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let codex_refresh_token = codex_creds.as_ref().and_then(|c| c.refresh_token.clone());
|
||||||
|
let codex_auth_path = codex_creds.as_ref().and_then(|c| c.auth_path.clone());
|
||||||
|
|
||||||
|
let api_key = if let Some(creds) = codex_creds {
|
||||||
|
if creds.is_chatgpt_mode {
|
||||||
|
codex_base_url_override = Some(creds.base_url().to_string());
|
||||||
|
}
|
||||||
|
Some(creds.token)
|
||||||
|
} else if let Some(env_var) = api_key_env {
|
||||||
|
// Resolve API key from env (including secrets store overlay)
|
||||||
optional_env(env_var)?.map(SecretString::from)
|
optional_env(env_var)?.map(SecretString::from)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -259,22 +280,28 @@ impl LlmConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve base URL: env var > settings (backward compat) > registry default
|
// Resolve base URL: codex override > env var > settings (backward compat) > registry default
|
||||||
let base_url = if let Some(env_var) = base_url_env {
|
let is_codex_chatgpt = codex_base_url_override.is_some();
|
||||||
optional_env(env_var)?
|
let base_url = codex_base_url_override
|
||||||
} else {
|
.or_else(|| {
|
||||||
None
|
if let Some(env_var) = base_url_env {
|
||||||
}
|
optional_env(env_var).ok().flatten()
|
||||||
.or_else(|| {
|
} else {
|
||||||
// Backward compat: check legacy settings fields
|
None
|
||||||
match backend {
|
}
|
||||||
"ollama" => settings.ollama_base_url.clone(),
|
})
|
||||||
"openai_compatible" | "openrouter" => settings.openai_compatible_base_url.clone(),
|
.or_else(|| {
|
||||||
_ => None,
|
// Backward compat: check legacy settings fields
|
||||||
}
|
match backend {
|
||||||
})
|
"ollama" => settings.ollama_base_url.clone(),
|
||||||
.or_else(|| default_base_url.map(String::from))
|
"openai_compatible" | "openrouter" => {
|
||||||
.unwrap_or_default();
|
settings.openai_compatible_base_url.clone()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|| default_base_url.map(String::from))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
if base_url_required
|
if base_url_required
|
||||||
&& base_url.is_empty()
|
&& base_url.is_empty()
|
||||||
@@ -340,6 +367,9 @@ impl LlmConfig {
|
|||||||
model,
|
model,
|
||||||
extra_headers,
|
extra_headers,
|
||||||
oauth_token,
|
oauth_token,
|
||||||
|
is_codex_chatgpt,
|
||||||
|
refresh_token: codex_refresh_token,
|
||||||
|
auth_path: codex_auth_path,
|
||||||
cache_retention,
|
cache_retention,
|
||||||
unsupported_params,
|
unsupported_params,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
|
|||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `mod.rs` | Provider factory (`create_llm_provider`, `build_provider_chain`); `LlmBackend` enum |
|
| `mod.rs` | Provider factory (`create_llm_provider`, `build_provider_chain`); `LlmBackend` enum |
|
||||||
|
| `config.rs` | LLM config types (`LlmConfig`, `RegistryProviderConfig`, `NearAiConfig`, `BedrockConfig`) |
|
||||||
|
| `error.rs` | `LlmError` enum used by all providers |
|
||||||
| `provider.rs` | `LlmProvider` trait, `ChatMessage`, `ToolCall`, `CompletionRequest`, `sanitize_tool_messages` |
|
| `provider.rs` | `LlmProvider` trait, `ChatMessage`, `ToolCall`, `CompletionRequest`, `sanitize_tool_messages` |
|
||||||
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
|
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
|
||||||
|
| `codex_auth.rs` | Reads Codex CLI `auth.json`, extracts tokens, refreshes ChatGPT OAuth access tokens |
|
||||||
|
| `codex_chatgpt.rs` | Custom Responses API provider for Codex ChatGPT backend (`/backend-api/codex`) |
|
||||||
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
|
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
|
||||||
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
|
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
|
||||||
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
|
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
|
||||||
@@ -35,6 +39,12 @@ Set via `LLM_BACKEND` env var:
|
|||||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||||
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
|
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
|
||||||
|
|
||||||
|
Codex auth reuse:
|
||||||
|
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
|
||||||
|
- If Codex is logged in with API-key mode, IronClaw uses the standard OpenAI endpoint.
|
||||||
|
- If Codex is logged in with ChatGPT OAuth mode, IronClaw routes to the private `chatgpt.com/backend-api/codex` Responses API via `codex_chatgpt.rs`.
|
||||||
|
- ChatGPT mode supports one automatic 401 refresh using the refresh token persisted in `auth.json`.
|
||||||
|
|
||||||
## AWS Bedrock Provider
|
## AWS Bedrock Provider
|
||||||
|
|
||||||
Uses the native Converse API via `aws-sdk-bedrockruntime` (`bedrock.rs`). Requires `--features bedrock` at build time — not in default features due to heavy AWS SDK dependencies.
|
Uses the native Converse API via `aws-sdk-bedrockruntime` (`bedrock.rs`). Requires `--features bedrock` at build time — not in default features due to heavy AWS SDK dependencies.
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
//! Read Codex CLI credentials for LLM authentication.
|
||||||
|
//!
|
||||||
|
//! When `LLM_USE_CODEX_AUTH=true`, IronClaw reads the Codex CLI's
|
||||||
|
//! `auth.json` file (default: `~/.codex/auth.json`) and extracts
|
||||||
|
//! credentials. This lets IronClaw piggyback on a Codex login without
|
||||||
|
//! implementing its own OAuth flow.
|
||||||
|
//!
|
||||||
|
//! Codex supports two auth modes:
|
||||||
|
//! - **API key** (`auth_mode: "apiKey"`) → uses `OPENAI_API_KEY` field
|
||||||
|
//! against `api.openai.com/v1`.
|
||||||
|
//! - **ChatGPT** (`auth_mode: "chatgpt"`) → uses `tokens.access_token`
|
||||||
|
//! (OAuth JWT) against `chatgpt.com/backend-api/codex`.
|
||||||
|
//!
|
||||||
|
//! When in ChatGPT mode, the provider supports automatic token refresh
|
||||||
|
//! on 401 responses using the `refresh_token` from `auth.json`.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// ChatGPT backend API endpoint used by Codex in ChatGPT auth mode.
|
||||||
|
const CHATGPT_BACKEND_URL: &str = "https://chatgpt.com/backend-api/codex";
|
||||||
|
|
||||||
|
/// Standard OpenAI API endpoint used by Codex in API key mode.
|
||||||
|
const OPENAI_API_URL: &str = "https://api.openai.com/v1";
|
||||||
|
|
||||||
|
/// OAuth token refresh endpoint (same as Codex CLI).
|
||||||
|
const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
|
||||||
|
|
||||||
|
/// OAuth client ID used for token refresh (same as Codex CLI).
|
||||||
|
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||||
|
|
||||||
|
/// Credentials extracted from Codex's `auth.json`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CodexCredentials {
|
||||||
|
/// The bearer token (API key or ChatGPT access_token).
|
||||||
|
pub token: SecretString,
|
||||||
|
/// Whether this is a ChatGPT OAuth token (vs. an OpenAI API key).
|
||||||
|
pub is_chatgpt_mode: bool,
|
||||||
|
/// OAuth refresh token (only present in ChatGPT mode).
|
||||||
|
pub refresh_token: Option<SecretString>,
|
||||||
|
/// Path to the auth.json file (for persisting refreshed tokens).
|
||||||
|
pub auth_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodexCredentials {
|
||||||
|
/// Returns the correct base URL for the auth mode.
|
||||||
|
///
|
||||||
|
/// - ChatGPT mode → `https://chatgpt.com/backend-api/codex`
|
||||||
|
/// - API key mode → `https://api.openai.com/v1`
|
||||||
|
pub fn base_url(&self) -> &'static str {
|
||||||
|
if self.is_chatgpt_mode {
|
||||||
|
CHATGPT_BACKEND_URL
|
||||||
|
} else {
|
||||||
|
OPENAI_API_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partial representation of Codex's `$CODEX_HOME/auth.json`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CodexAuthJson {
|
||||||
|
auth_mode: Option<String>,
|
||||||
|
#[serde(rename = "OPENAI_API_KEY")]
|
||||||
|
openai_api_key: Option<String>,
|
||||||
|
tokens: Option<CodexTokens>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CodexTokens {
|
||||||
|
access_token: SecretString,
|
||||||
|
refresh_token: Option<SecretString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for OAuth token refresh.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RefreshRequest<'a> {
|
||||||
|
client_id: &'a str,
|
||||||
|
grant_type: &'a str,
|
||||||
|
refresh_token: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the OAuth token refresh endpoint.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RefreshResponse {
|
||||||
|
access_token: SecretString,
|
||||||
|
refresh_token: Option<SecretString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default path used by Codex CLI: `~/.codex/auth.json`.
|
||||||
|
pub fn default_codex_auth_path() -> PathBuf {
|
||||||
|
let home_dir = dirs::home_dir().unwrap_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not determine home directory; falling back to current working directory for Codex auth.json path"
|
||||||
|
);
|
||||||
|
PathBuf::from(".")
|
||||||
|
});
|
||||||
|
|
||||||
|
home_dir.join(".codex").join("auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load credentials from a Codex `auth.json` file.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the file is missing, unreadable, or contains
|
||||||
|
/// no usable credentials.
|
||||||
|
pub fn load_codex_credentials(path: &Path) -> Option<CodexCredentials> {
|
||||||
|
let content = match std::fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Could not read Codex auth file {}: {}", path.display(), e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth: CodexAuthJson = match serde_json::from_str(&content) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse Codex auth file {}: {}", path.display(), e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_chatgpt = auth
|
||||||
|
.auth_mode
|
||||||
|
.as_deref()
|
||||||
|
.map(|m| m == "chatgpt" || m == "chatgptAuthTokens")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// API key mode: use OPENAI_API_KEY field.
|
||||||
|
if !is_chatgpt {
|
||||||
|
if let Some(key) = auth.openai_api_key.filter(|k| !k.is_empty()) {
|
||||||
|
tracing::info!("Loaded API key from Codex auth.json (API key mode)");
|
||||||
|
return Some(CodexCredentials {
|
||||||
|
token: SecretString::from(key),
|
||||||
|
is_chatgpt_mode: false,
|
||||||
|
refresh_token: None,
|
||||||
|
auth_path: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If auth_mode was explicitly `apiKey`, do not fall back to checking for a token.
|
||||||
|
if auth.auth_mode.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatGPT mode: use access_token as bearer token.
|
||||||
|
if let Some(tokens) = auth.tokens
|
||||||
|
&& !tokens.access_token.expose_secret().is_empty()
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
"Loaded access token from Codex auth.json (ChatGPT mode, base_url={})",
|
||||||
|
CHATGPT_BACKEND_URL
|
||||||
|
);
|
||||||
|
return Some(CodexCredentials {
|
||||||
|
token: tokens.access_token,
|
||||||
|
is_chatgpt_mode: true,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
auth_path: Some(path.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Codex auth.json at {} contains no usable credentials",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to refresh an expired access token using the refresh token.
|
||||||
|
///
|
||||||
|
/// On success, returns the new `access_token` and persists the refreshed
|
||||||
|
/// tokens back to `auth.json`. This follows the same OAuth protocol as
|
||||||
|
/// Codex CLI (`POST https://auth.openai.com/oauth/token`).
|
||||||
|
///
|
||||||
|
/// Returns `None` if the refresh token is missing, the request fails,
|
||||||
|
/// or the response is malformed.
|
||||||
|
pub async fn refresh_access_token(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
refresh_token: &SecretString,
|
||||||
|
auth_path: Option<&Path>,
|
||||||
|
) -> Option<SecretString> {
|
||||||
|
let req = RefreshRequest {
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refresh_token.expose_secret(),
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Attempting to refresh Codex OAuth access token");
|
||||||
|
|
||||||
|
let resp = match client
|
||||||
|
.post(REFRESH_TOKEN_URL)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&req)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Token refresh request failed: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!("Token refresh failed: HTTP {status}: {body}");
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
tracing::warn!(
|
||||||
|
"Refresh token may be expired or revoked. \
|
||||||
|
Please re-authenticate with: codex --login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh_resp: RefreshResponse = match resp.json().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse token refresh response: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_access_token = refresh_resp.access_token.clone();
|
||||||
|
|
||||||
|
// Persist refreshed tokens back to auth.json
|
||||||
|
if let Some(path) = auth_path {
|
||||||
|
if let Err(e) = persist_refreshed_tokens(
|
||||||
|
path,
|
||||||
|
refresh_resp.access_token.expose_secret(),
|
||||||
|
refresh_resp
|
||||||
|
.refresh_token
|
||||||
|
.as_ref()
|
||||||
|
.map(ExposeSecret::expose_secret),
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to persist refreshed tokens to {}: {e}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Refreshed tokens persisted to {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(new_access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update `auth.json` with refreshed tokens, preserving other fields.
|
||||||
|
fn persist_refreshed_tokens(
|
||||||
|
path: &Path,
|
||||||
|
new_access_token: &str,
|
||||||
|
new_refresh_token: Option<&str>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let mut json: serde_json::Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
if let Some(tokens) = json.get_mut("tokens") {
|
||||||
|
tokens["access_token"] = serde_json::Value::String(new_access_token.to_string());
|
||||||
|
if let Some(rt) = new_refresh_token {
|
||||||
|
tokens["refresh_token"] = serde_json::Value::String(rt.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = serde_json::to_string_pretty(&json)?;
|
||||||
|
let tmp_path = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp_path, updated)?;
|
||||||
|
if let Err(e) = std::fs::rename(&tmp_path, path) {
|
||||||
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
|
return Err(Box::new(e));
|
||||||
|
}
|
||||||
|
set_auth_file_permissions(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_auth_file_permissions(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_auth_file_permissions(_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_api_key_mode() {
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
r#"{{"auth_mode":"apiKey","OPENAI_API_KEY":"sk-test-123"}}"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let creds = load_codex_credentials(f.path()).expect("should load");
|
||||||
|
assert_eq!(creds.token.expose_secret(), "sk-test-123");
|
||||||
|
assert!(!creds.is_chatgpt_mode);
|
||||||
|
assert_eq!(creds.base_url(), OPENAI_API_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_chatgpt_mode() {
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
r#"{{"auth_mode":"chatgpt","tokens":{{"id_token":{{}},"access_token":"eyJ-test","refresh_token":"rt-x"}}}}"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let creds = load_codex_credentials(f.path()).expect("should load");
|
||||||
|
assert_eq!(creds.token.expose_secret(), "eyJ-test");
|
||||||
|
assert!(creds.is_chatgpt_mode);
|
||||||
|
assert_eq!(
|
||||||
|
creds
|
||||||
|
.refresh_token
|
||||||
|
.as_ref()
|
||||||
|
.expect("refresh token should be present")
|
||||||
|
.expose_secret(),
|
||||||
|
"rt-x"
|
||||||
|
);
|
||||||
|
assert_eq!(creds.base_url(), CHATGPT_BACKEND_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_key_mode_ignores_tokens() {
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
r#"{{"auth_mode":"apiKey","OPENAI_API_KEY":"sk-priority","tokens":{{"id_token":{{}},"access_token":"eyJ-fallback","refresh_token":"rt-x"}}}}"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let creds = load_codex_credentials(f.path()).expect("should load");
|
||||||
|
assert_eq!(creds.token.expose_secret(), "sk-priority");
|
||||||
|
assert!(!creds.is_chatgpt_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_missing_file() {
|
||||||
|
assert!(load_codex_credentials(Path::new("/tmp/nonexistent_codex_auth.json")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_empty_json() {
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(f, "{{}}").unwrap();
|
||||||
|
assert!(load_codex_credentials(f.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_empty_key() {
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(f, r#"{{"auth_mode":"apiKey","OPENAI_API_KEY":""}}"#).unwrap();
|
||||||
|
assert!(load_codex_credentials(f.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_key_mode_missing_key_does_not_fallback_to_chatgpt() {
|
||||||
|
// Bug: if auth_mode is "apiKey" but key is missing, the old code would
|
||||||
|
// fall through to check for a ChatGPT token, returning is_chatgpt_mode: true.
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
r#"{{"auth_mode":"apiKey","OPENAI_API_KEY":"","tokens":{{"id_token":{{}},"access_token":"eyJ-bad","refresh_token":"rt-x"}}}}"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(load_codex_credentials(f.path()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,932 @@
|
|||||||
|
//! Codex ChatGPT Responses API provider.
|
||||||
|
//!
|
||||||
|
//! Implements `LlmProvider` by speaking the OpenAI Responses API protocol
|
||||||
|
//! (`POST /responses`) used by the ChatGPT backend at
|
||||||
|
//! `chatgpt.com/backend-api/codex`. This bypasses `rig-core`'s Chat
|
||||||
|
//! Completions path, which is incompatible with this endpoint.
|
||||||
|
//!
|
||||||
|
//! # Warning
|
||||||
|
//!
|
||||||
|
//! The ChatGPT backend endpoint (`chatgpt.com/backend-api/codex`) is a
|
||||||
|
//! **private, undocumented API**. Using subscriber OAuth tokens from a
|
||||||
|
//! third-party application may violate the token's intended scope or
|
||||||
|
//! OpenAI's Terms of Service. This feature is provided as-is for
|
||||||
|
//! convenience and may break without notice.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use eventsource_stream::Eventsource;
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
|
use reqwest::Client;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
use super::codex_auth;
|
||||||
|
use crate::error::LlmError;
|
||||||
|
|
||||||
|
use super::provider::{
|
||||||
|
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, LlmProvider,
|
||||||
|
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Provider that speaks the Responses API protocol against the ChatGPT backend.
|
||||||
|
pub struct CodexChatGptProvider {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
api_key: RwLock<SecretString>,
|
||||||
|
/// User-configured model name (or empty/"default" for auto-detect).
|
||||||
|
configured_model: String,
|
||||||
|
/// Lazily resolved model name (populated on first LLM call).
|
||||||
|
resolved_model: tokio::sync::OnceCell<String>,
|
||||||
|
/// OAuth refresh token for automatic 401 retry.
|
||||||
|
refresh_token: Option<SecretString>,
|
||||||
|
/// Path to auth.json for persisting refreshed tokens.
|
||||||
|
auth_path: Option<PathBuf>,
|
||||||
|
/// Timeout for actual `/responses` requests.
|
||||||
|
request_timeout: Duration,
|
||||||
|
/// Prevent concurrent 401 handlers from racing the same refresh token.
|
||||||
|
refresh_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodexChatGptProvider {
|
||||||
|
#[cfg(test)]
|
||||||
|
fn new(base_url: &str, api_key: &str, model: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
api_key: RwLock::new(SecretString::from(api_key.to_string())),
|
||||||
|
configured_model: model.to_string(),
|
||||||
|
resolved_model: tokio::sync::OnceCell::const_new(),
|
||||||
|
refresh_token: None,
|
||||||
|
auth_path: None,
|
||||||
|
request_timeout: Duration::from_secs(120),
|
||||||
|
refresh_lock: Mutex::new(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a provider with lazy model detection.
|
||||||
|
///
|
||||||
|
/// The model is **not** resolved during construction. Instead, it is
|
||||||
|
/// resolved on the first LLM call via [`resolve_model`], avoiding the
|
||||||
|
/// need for `block_in_place` / `block_on` during provider setup.
|
||||||
|
///
|
||||||
|
/// **Model selection priority** (applied at resolution time):
|
||||||
|
/// 1. If `configured_model` is non-empty, validate it against the
|
||||||
|
/// `/models` endpoint. If it isn't in the supported list, log a
|
||||||
|
/// warning with available models and fall back to the top model.
|
||||||
|
/// 2. If `configured_model` is empty (or a generic placeholder like
|
||||||
|
/// "default"), auto-detect the highest-priority model from the API.
|
||||||
|
pub fn with_lazy_model(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: SecretString,
|
||||||
|
configured_model: &str,
|
||||||
|
refresh_token: Option<SecretString>,
|
||||||
|
auth_path: Option<PathBuf>,
|
||||||
|
request_timeout_secs: u64,
|
||||||
|
) -> Self {
|
||||||
|
tracing::warn!(
|
||||||
|
"Codex ChatGPT provider uses a private, undocumented API \
|
||||||
|
(chatgpt.com/backend-api/codex). This may violate OpenAI's \
|
||||||
|
Terms of Service and could break without notice."
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
api_key: RwLock::new(api_key),
|
||||||
|
configured_model: configured_model.to_string(),
|
||||||
|
resolved_model: tokio::sync::OnceCell::const_new(),
|
||||||
|
refresh_token,
|
||||||
|
auth_path,
|
||||||
|
request_timeout: Duration::from_secs(request_timeout_secs),
|
||||||
|
refresh_lock: Mutex::new(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the model to use, lazily on first call.
|
||||||
|
///
|
||||||
|
/// Uses `OnceCell` so the `/models` fetch happens at most once.
|
||||||
|
async fn resolve_model(&self) -> &str {
|
||||||
|
self.resolved_model
|
||||||
|
.get_or_init(|| async {
|
||||||
|
let api_key = self.api_key.read().await.clone();
|
||||||
|
let available = Self::fetch_available_models(&self.client, &self.base_url, &api_key)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let configured = &self.configured_model;
|
||||||
|
if !configured.is_empty() && configured != "default" {
|
||||||
|
// User explicitly configured a model — validate it
|
||||||
|
if available.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not fetch model list; using configured model '{configured}'"
|
||||||
|
);
|
||||||
|
return configured.clone();
|
||||||
|
}
|
||||||
|
if available.iter().any(|m| m == configured) {
|
||||||
|
tracing::info!(model = %configured, "Codex ChatGPT: using configured model");
|
||||||
|
return configured.clone();
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
configured = %configured,
|
||||||
|
available = ?available,
|
||||||
|
"Configured model not found in supported list, falling back to top model"
|
||||||
|
);
|
||||||
|
available
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| configured.clone())
|
||||||
|
} else {
|
||||||
|
// No user preference — auto-detect
|
||||||
|
if let Some(top) = available.into_iter().next() {
|
||||||
|
tracing::info!(model = %top, "Codex ChatGPT: auto-detected model");
|
||||||
|
top
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not auto-detect model, using fallback '{configured}'"
|
||||||
|
);
|
||||||
|
configured.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query `/models?client_version=0.111.0` and return the list of available
|
||||||
|
/// model slugs, ordered by priority (highest first).
|
||||||
|
async fn fetch_available_models(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &SecretString,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let url = format!("{base_url}/models?client_version=0.111.0");
|
||||||
|
let resp = match client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(api_key.expose_secret())
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to fetch Codex models: {e}");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
tracing::warn!(status = %resp.status(), "Failed to fetch Codex models");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let body: Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
// The response has { "models": [ { "slug": "...", ... }, ... ] }
|
||||||
|
body.get("models")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
.map(|models| {
|
||||||
|
models
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| {
|
||||||
|
m.get("slug")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert IronClaw messages to Responses API request JSON.
|
||||||
|
fn build_request_body(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: &[ToolDefinition],
|
||||||
|
tool_choice: Option<&str>,
|
||||||
|
) -> Value {
|
||||||
|
// Extract system instructions
|
||||||
|
let instructions: String = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == Role::System)
|
||||||
|
.map(|m| m.content.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
// Convert non-system messages to Responses API input items
|
||||||
|
let input: Vec<Value> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role != Role::System)
|
||||||
|
.flat_map(Self::message_to_input_items)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Convert tool definitions
|
||||||
|
let api_tools: Vec<Value> = tools
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
json!({
|
||||||
|
"type": "function",
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"parameters": t.parameters,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut body = json!({
|
||||||
|
"model": model,
|
||||||
|
"instructions": instructions,
|
||||||
|
"input": input,
|
||||||
|
"stream": true,
|
||||||
|
"store": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if !api_tools.is_empty() {
|
||||||
|
body["tools"] = json!(api_tools);
|
||||||
|
body["tool_choice"] = json!(tool_choice.unwrap_or("auto"));
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a single ChatMessage to one or more Responses API input items.
|
||||||
|
fn message_to_input_items(msg: &ChatMessage) -> Vec<Value> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
match msg.role {
|
||||||
|
Role::User => {
|
||||||
|
// Build content array: if content_parts is populated, use it
|
||||||
|
// to include multimodal content (images). Otherwise fall back
|
||||||
|
// to the plain text content field.
|
||||||
|
let content = if !msg.content_parts.is_empty() {
|
||||||
|
msg.content_parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| match part {
|
||||||
|
ContentPart::Text { text } => json!({
|
||||||
|
"type": "input_text",
|
||||||
|
"text": text,
|
||||||
|
}),
|
||||||
|
ContentPart::ImageUrl { image_url } => json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": image_url.url,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
vec![json!({
|
||||||
|
"type": "input_text",
|
||||||
|
"text": msg.content,
|
||||||
|
})]
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Role::Assistant => {
|
||||||
|
// If the assistant message has tool calls, emit function_call items
|
||||||
|
if let Some(ref tool_calls) = msg.tool_calls {
|
||||||
|
// Emit the assistant text as a message if non-empty
|
||||||
|
if !msg.content.is_empty() {
|
||||||
|
items.push(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": msg.content,
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for tc in tool_calls {
|
||||||
|
let args = if tc.arguments.is_string() {
|
||||||
|
tc.arguments.as_str().unwrap_or("{}").to_string()
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&tc.arguments).unwrap_or_default()
|
||||||
|
};
|
||||||
|
items.push(json!({
|
||||||
|
"type": "function_call",
|
||||||
|
"name": tc.name,
|
||||||
|
"arguments": args,
|
||||||
|
"call_id": tc.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.push(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": msg.content,
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Role::Tool => {
|
||||||
|
items.push(json!({
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": msg.tool_call_id.as_deref().unwrap_or(""),
|
||||||
|
"output": msg.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Role::System => {
|
||||||
|
// System messages are handled via `instructions` field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request and parse the SSE response.
|
||||||
|
///
|
||||||
|
/// On HTTP 401, if a refresh token is available, attempts to refresh
|
||||||
|
/// the access token and retry the request once.
|
||||||
|
async fn send_request(&self, body: Value) -> Result<ResponsesResult, LlmError> {
|
||||||
|
let url = format!("{}/responses", self.base_url);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
url = %url,
|
||||||
|
model = %body.get("model").and_then(|m| m.as_str()).unwrap_or("?"),
|
||||||
|
"Codex ChatGPT: sending request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let api_key = self.api_key.read().await.clone();
|
||||||
|
let resp =
|
||||||
|
Self::send_http_request(&self.client, &url, &api_key, &body, self.request_timeout)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
// Attempt token refresh if we have a refresh token
|
||||||
|
if let Some(ref rt) = self.refresh_token {
|
||||||
|
let _refresh_guard = self.refresh_lock.lock().await;
|
||||||
|
let current_token = self.api_key.read().await.clone();
|
||||||
|
|
||||||
|
if current_token.expose_secret() != api_key.expose_secret() {
|
||||||
|
tracing::info!("Received 401, but another request already refreshed the token");
|
||||||
|
let retry_resp = Self::send_http_request(
|
||||||
|
&self.client,
|
||||||
|
&url,
|
||||||
|
¤t_token,
|
||||||
|
&body,
|
||||||
|
self.request_timeout,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let retry_status = retry_resp.status();
|
||||||
|
if !retry_status.is_success() {
|
||||||
|
let body_text =
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), retry_resp.text())
|
||||||
|
.await
|
||||||
|
.unwrap_or(Ok(String::new()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
reason: format!(
|
||||||
|
"HTTP {retry_status} from {url} (after concurrent token refresh): {body_text}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Self::parse_sse_response_stream(retry_resp, self.request_timeout).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Received 401, attempting token refresh");
|
||||||
|
if let Some(new_token) =
|
||||||
|
codex_auth::refresh_access_token(&self.client, rt, self.auth_path.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
// Update stored api_key
|
||||||
|
*self.api_key.write().await = new_token.clone();
|
||||||
|
tracing::info!("Token refreshed, retrying request");
|
||||||
|
|
||||||
|
// Retry the request with the new token
|
||||||
|
let retry_resp = Self::send_http_request(
|
||||||
|
&self.client,
|
||||||
|
&url,
|
||||||
|
&new_token,
|
||||||
|
&body,
|
||||||
|
self.request_timeout,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let retry_status = retry_resp.status();
|
||||||
|
if !retry_status.is_success() {
|
||||||
|
let body_text =
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), retry_resp.text())
|
||||||
|
.await
|
||||||
|
.unwrap_or(Ok(String::new()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
reason: format!(
|
||||||
|
"HTTP {retry_status} from {url} (after token refresh): {body_text}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Self::parse_sse_response_stream(retry_resp, self.request_timeout).await;
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"Token refresh failed. Please re-authenticate with: codex --login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No refresh token or refresh failed — return the 401 error
|
||||||
|
// Drain the response body to release the connection
|
||||||
|
let _ = resp.text().await;
|
||||||
|
return Err(LlmError::AuthFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
// Read the error body with a timeout to avoid hanging
|
||||||
|
let body_text = tokio::time::timeout(Duration::from_secs(5), resp.text())
|
||||||
|
.await
|
||||||
|
.unwrap_or(Ok(String::new()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
reason: format!("HTTP {status} from {url}: {body_text}",),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::parse_sse_response_stream(resp, self.request_timeout).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Low-level HTTP POST to the /responses endpoint.
|
||||||
|
async fn send_http_request(
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
api_key: &SecretString,
|
||||||
|
body: &Value,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<reqwest::Response, LlmError> {
|
||||||
|
client
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(api_key.expose_secret())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.json(body)
|
||||||
|
.timeout(timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
reason: format!("HTTP request failed: {e}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_sse_response_stream(
|
||||||
|
resp: reqwest::Response,
|
||||||
|
idle_timeout: Duration,
|
||||||
|
) -> Result<ResponsesResult, LlmError> {
|
||||||
|
let stream = resp
|
||||||
|
.bytes_stream()
|
||||||
|
.map(|chunk| chunk.map_err(|e| e.to_string()));
|
||||||
|
Self::parse_sse_stream(stream, idle_timeout).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_sse_stream<S>(
|
||||||
|
stream: S,
|
||||||
|
idle_timeout: Duration,
|
||||||
|
) -> Result<ResponsesResult, LlmError>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<bytes::Bytes, String>> + Unpin,
|
||||||
|
{
|
||||||
|
let mut result = ResponsesResult::default();
|
||||||
|
let mut stream = stream.eventsource();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout(idle_timeout, stream.next()).await {
|
||||||
|
Ok(Some(Ok(event))) => {
|
||||||
|
let data = event.data.trim();
|
||||||
|
if data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: Value = match serde_json::from_str(data) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if Self::handle_sse_event(&mut result, event.event.as_str(), &parsed) {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(Err(e))) => {
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
reason: format!("Failed to read SSE stream: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(None) => return Ok(result),
|
||||||
|
Err(_) => {
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
reason: format!(
|
||||||
|
"Timed out waiting for SSE event after {}s",
|
||||||
|
idle_timeout.as_secs()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse SSE events from the response text.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn parse_sse_response(sse_text: &str) -> Result<ResponsesResult, LlmError> {
|
||||||
|
let mut result = ResponsesResult::default();
|
||||||
|
let mut current_event_type = String::new();
|
||||||
|
|
||||||
|
for line in sse_text.lines() {
|
||||||
|
if let Some(event) = line.strip_prefix("event: ") {
|
||||||
|
current_event_type = event.trim().to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = line.strip_prefix("data: ") {
|
||||||
|
let data = data.trim();
|
||||||
|
if data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: Value = match serde_json::from_str(data) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if Self::handle_sse_event(&mut result, current_event_type.as_str(), &parsed) {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_sse_event(result: &mut ResponsesResult, event_type: &str, parsed: &Value) -> bool {
|
||||||
|
match event_type {
|
||||||
|
"response.output_text.delta" => {
|
||||||
|
if let Some(delta) = parsed.get("delta").and_then(|d| d.as_str()) {
|
||||||
|
result.text.push_str(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"response.output_item.added" => {
|
||||||
|
// Capture function call metadata when the item is first added.
|
||||||
|
// The item has: id (item_id), call_id, name, type.
|
||||||
|
let item = parsed.get("item").unwrap_or(parsed);
|
||||||
|
if item.get("type").and_then(|t| t.as_str()) == Some("function_call") {
|
||||||
|
let item_id = item
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let call_id = item
|
||||||
|
.get("call_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();
|
||||||
|
|
||||||
|
result
|
||||||
|
.pending_tool_calls
|
||||||
|
.entry(item_id)
|
||||||
|
.or_insert_with(|| PendingToolCall {
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
arguments: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"response.function_call_arguments.delta" => {
|
||||||
|
// Delta events use `item_id` (not `call_id`)
|
||||||
|
if let Some(item_id) = parsed.get("item_id").and_then(|v| v.as_str())
|
||||||
|
&& let Some(entry) = result.pending_tool_calls.get_mut(item_id)
|
||||||
|
&& let Some(delta) = parsed.get("delta").and_then(|d| d.as_str())
|
||||||
|
{
|
||||||
|
entry.arguments.push_str(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"response.completed" => {
|
||||||
|
if let Some(response) = parsed.get("response")
|
||||||
|
&& let Some(usage) = response.get("usage")
|
||||||
|
{
|
||||||
|
result.input_tokens = usage
|
||||||
|
.get("input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
result.output_tokens = usage
|
||||||
|
.get("output_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove keys with empty-string values from a JSON object.
|
||||||
|
///
|
||||||
|
/// gpt-5.2-codex fills optional tool parameters with `""` (e.g.
|
||||||
|
/// `"timestamp": ""`). IronClaw's tool validation treats these as
|
||||||
|
/// invalid "non-empty input expected". Stripping them makes the
|
||||||
|
/// tool see only the actually-provided values.
|
||||||
|
fn strip_empty_string_values(value: Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
let cleaned: serde_json::Map<String, Value> = map
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, v)| !matches!(v, Value::String(s) if s.is_empty()))
|
||||||
|
.map(|(k, v)| (k, Self::strip_empty_string_values(v)))
|
||||||
|
.collect();
|
||||||
|
Value::Object(cleaned)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ResponsesResult {
|
||||||
|
text: String,
|
||||||
|
/// Keyed by item_id (the SSE item identifier, e.g. "fc_...").
|
||||||
|
pending_tool_calls: std::collections::HashMap<String, PendingToolCall>,
|
||||||
|
input_tokens: u32,
|
||||||
|
output_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PendingToolCall {
|
||||||
|
/// The call_id from the API (e.g. "call_..."), used to match results.
|
||||||
|
call_id: String,
|
||||||
|
name: String,
|
||||||
|
arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for CodexChatGptProvider {
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
// Return resolved model if available, otherwise the configured name.
|
||||||
|
self.resolved_model
|
||||||
|
.get()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or(&self.configured_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||||
|
// ChatGPT backend doesn't expose per-token pricing
|
||||||
|
(Decimal::ZERO, Decimal::ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
|
let model = self.resolve_model().await;
|
||||||
|
let body = self.build_request_body(model, &request.messages, &[], None);
|
||||||
|
let result = self.send_request(body).await?;
|
||||||
|
|
||||||
|
Ok(CompletionResponse {
|
||||||
|
content: result.text,
|
||||||
|
input_tokens: result.input_tokens,
|
||||||
|
output_tokens: result.output_tokens,
|
||||||
|
finish_reason: FinishReason::Stop,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_with_tools(
|
||||||
|
&self,
|
||||||
|
request: ToolCompletionRequest,
|
||||||
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
|
let model = self.resolve_model().await;
|
||||||
|
let body = self.build_request_body(
|
||||||
|
model,
|
||||||
|
&request.messages,
|
||||||
|
&request.tools,
|
||||||
|
request.tool_choice.as_deref(),
|
||||||
|
);
|
||||||
|
let result = self.send_request(body).await?;
|
||||||
|
|
||||||
|
let tool_calls: Vec<ToolCall> = result
|
||||||
|
.pending_tool_calls
|
||||||
|
.into_values()
|
||||||
|
.map(|tc| {
|
||||||
|
let args: Value =
|
||||||
|
serde_json::from_str(&tc.arguments).unwrap_or_else(|_| json!(tc.arguments));
|
||||||
|
// gpt-5.2-codex fills optional parameters with empty strings (e.g.
|
||||||
|
// `"timestamp": ""`), which IronClaw's tool validation rejects.
|
||||||
|
// Strip them so only actually-provided values reach the tool.
|
||||||
|
let args = Self::strip_empty_string_values(args);
|
||||||
|
ToolCall {
|
||||||
|
id: tc.call_id,
|
||||||
|
name: tc.name,
|
||||||
|
arguments: args,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let finish_reason = if tool_calls.is_empty() {
|
||||||
|
FinishReason::Stop
|
||||||
|
} else {
|
||||||
|
FinishReason::ToolUse
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ToolCompletionResponse {
|
||||||
|
content: if result.text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(result.text)
|
||||||
|
},
|
||||||
|
tool_calls,
|
||||||
|
input_tokens: result.input_tokens,
|
||||||
|
output_tokens: result.output_tokens,
|
||||||
|
finish_reason,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_conversion_user() {
|
||||||
|
let items = CodexChatGptProvider::message_to_input_items(&ChatMessage::user("hello"));
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0]["type"], "message");
|
||||||
|
assert_eq!(items[0]["role"], "user");
|
||||||
|
assert_eq!(items[0]["content"][0]["type"], "input_text");
|
||||||
|
assert_eq!(items[0]["content"][0]["text"], "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_conversion_user_with_image() {
|
||||||
|
use super::super::provider::ImageUrl;
|
||||||
|
let parts = vec![
|
||||||
|
ContentPart::Text {
|
||||||
|
text: "What's in this image?".to_string(),
|
||||||
|
},
|
||||||
|
ContentPart::ImageUrl {
|
||||||
|
image_url: ImageUrl {
|
||||||
|
url: "data:image/png;base64,iVBOR...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let msg = ChatMessage::user_with_parts("", parts);
|
||||||
|
let items = CodexChatGptProvider::message_to_input_items(&msg);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0]["type"], "message");
|
||||||
|
assert_eq!(items[0]["role"], "user");
|
||||||
|
let content = items[0]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(content.len(), 2);
|
||||||
|
assert_eq!(content[0]["type"], "input_text");
|
||||||
|
assert_eq!(content[0]["text"], "What's in this image?");
|
||||||
|
assert_eq!(content[1]["type"], "input_image");
|
||||||
|
assert_eq!(content[1]["image_url"], "data:image/png;base64,iVBOR...");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn test_message_conversion_assistant() {
|
||||||
|
let items = CodexChatGptProvider::message_to_input_items(&ChatMessage::assistant("hi"));
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0]["type"], "message");
|
||||||
|
assert_eq!(items[0]["role"], "assistant");
|
||||||
|
assert_eq!(items[0]["content"][0]["type"], "output_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_conversion_tool_result() {
|
||||||
|
let msg = ChatMessage::tool_result("call_1", "search", "result text");
|
||||||
|
let items = CodexChatGptProvider::message_to_input_items(&msg);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0]["type"], "function_call_output");
|
||||||
|
assert_eq!(items[0]["call_id"], "call_1");
|
||||||
|
assert_eq!(items[0]["output"], "result text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_conversion_assistant_with_tool_calls() {
|
||||||
|
let tc = ToolCall {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "search".to_string(),
|
||||||
|
arguments: json!({"query": "rust"}),
|
||||||
|
};
|
||||||
|
let msg = ChatMessage::assistant_with_tool_calls(Some("thinking...".into()), vec![tc]);
|
||||||
|
let items = CodexChatGptProvider::message_to_input_items(&msg);
|
||||||
|
// Should produce: 1 text message + 1 function_call
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(items[0]["type"], "message");
|
||||||
|
assert_eq!(items[1]["type"], "function_call");
|
||||||
|
assert_eq!(items[1]["name"], "search");
|
||||||
|
assert_eq!(items[1]["call_id"], "call_1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_request_extracts_system_as_instructions() {
|
||||||
|
let provider = CodexChatGptProvider::new("https://example.com", "key", "gpt-4o");
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::system("You are helpful."),
|
||||||
|
ChatMessage::user("hello"),
|
||||||
|
];
|
||||||
|
let body = provider.build_request_body("gpt-4o", &messages, &[], None);
|
||||||
|
assert_eq!(body["instructions"], "You are helpful.");
|
||||||
|
// input should only contain the user message, not the system message
|
||||||
|
assert_eq!(body["input"].as_array().unwrap().len(), 1);
|
||||||
|
// store must be false for ChatGPT backend
|
||||||
|
assert_eq!(body["store"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_sse_text_response() {
|
||||||
|
let sse = r#"event: response.output_text.delta
|
||||||
|
data: {"delta":"Hello"}
|
||||||
|
|
||||||
|
event: response.output_text.delta
|
||||||
|
data: {"delta":" world!"}
|
||||||
|
|
||||||
|
event: response.completed
|
||||||
|
data: {"response":{"usage":{"input_tokens":10,"output_tokens":5}}}
|
||||||
|
|
||||||
|
"#;
|
||||||
|
let result = CodexChatGptProvider::parse_sse_response(sse).unwrap();
|
||||||
|
assert_eq!(result.text, "Hello world!");
|
||||||
|
assert_eq!(result.input_tokens, 10);
|
||||||
|
assert_eq!(result.output_tokens, 5);
|
||||||
|
assert!(result.pending_tool_calls.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_sse_tool_call() {
|
||||||
|
// Real API format: output_item.added has item.id (item_id) + item.call_id,
|
||||||
|
// delta events use item_id (not call_id)
|
||||||
|
let sse = r#"event: response.output_item.added
|
||||||
|
data: {"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"search"}}
|
||||||
|
|
||||||
|
event: response.function_call_arguments.delta
|
||||||
|
data: {"item_id":"fc_1","delta":"{\"query\":"}
|
||||||
|
|
||||||
|
event: response.function_call_arguments.delta
|
||||||
|
data: {"item_id":"fc_1","delta":"\"rust\"}"}
|
||||||
|
|
||||||
|
event: response.completed
|
||||||
|
data: {"response":{"usage":{"input_tokens":20,"output_tokens":15}}}
|
||||||
|
|
||||||
|
"#;
|
||||||
|
let result = CodexChatGptProvider::parse_sse_response(sse).unwrap();
|
||||||
|
assert!(result.text.is_empty());
|
||||||
|
assert_eq!(result.pending_tool_calls.len(), 1);
|
||||||
|
let tc = result.pending_tool_calls.get("fc_1").unwrap();
|
||||||
|
assert_eq!(tc.call_id, "call_1");
|
||||||
|
assert_eq!(tc.name, "search");
|
||||||
|
assert_eq!(tc.arguments, "{\"query\":\"rust\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_sse_stream_response() {
|
||||||
|
let stream = stream::iter(vec![
|
||||||
|
Ok(Bytes::from_static(
|
||||||
|
b"event: response.output_text.delta\ndata: {\"delta\":\"Hello\"}\n\n",
|
||||||
|
)),
|
||||||
|
Ok(Bytes::from_static(
|
||||||
|
b"event: response.output_text.delta\ndata: {\"delta\":\" world\"}\n\n",
|
||||||
|
)),
|
||||||
|
Ok(Bytes::from_static(
|
||||||
|
b"event: response.completed\ndata: {\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = CodexChatGptProvider::parse_sse_stream(stream, Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.text, "Hello world");
|
||||||
|
assert_eq!(result.input_tokens, 3);
|
||||||
|
assert_eq!(result.output_tokens, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_empty_string_values() {
|
||||||
|
let input = json!({
|
||||||
|
"format": "%Y-%m-%d",
|
||||||
|
"operation": "now",
|
||||||
|
"timestamp": "",
|
||||||
|
"timestamp2": "",
|
||||||
|
});
|
||||||
|
let cleaned = CodexChatGptProvider::strip_empty_string_values(input);
|
||||||
|
assert_eq!(cleaned, json!({"format": "%Y-%m-%d", "operation": "now"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
//! extracted into a standalone crate. Resolution logic (reading env vars,
|
//! extracted into a standalone crate. Resolution logic (reading env vars,
|
||||||
//! settings) lives in `crate::config::llm`.
|
//! settings) lives in `crate::config::llm`.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
|
||||||
use crate::llm::registry::ProviderProtocol;
|
use crate::llm::registry::ProviderProtocol;
|
||||||
@@ -85,6 +87,13 @@ pub struct RegistryProviderConfig {
|
|||||||
/// OAuth token for providers that support Bearer auth (e.g. Anthropic via `claude login`).
|
/// OAuth token for providers that support Bearer auth (e.g. Anthropic via `claude login`).
|
||||||
/// When set, the provider factory routes to the OAuth-specific provider implementation.
|
/// When set, the provider factory routes to the OAuth-specific provider implementation.
|
||||||
pub oauth_token: Option<SecretString>,
|
pub oauth_token: Option<SecretString>,
|
||||||
|
/// When true, route OpenAI-compatible traffic to the Codex ChatGPT
|
||||||
|
/// Responses API provider instead of rig-core's Chat Completions path.
|
||||||
|
pub is_codex_chatgpt: bool,
|
||||||
|
/// OAuth refresh token for Codex ChatGPT token refresh.
|
||||||
|
pub refresh_token: Option<SecretString>,
|
||||||
|
/// Path to Codex auth.json for persisting refreshed tokens.
|
||||||
|
pub auth_path: Option<PathBuf>,
|
||||||
/// Prompt cache retention (Anthropic-specific).
|
/// Prompt cache retention (Anthropic-specific).
|
||||||
pub cache_retention: CacheRetention,
|
pub cache_retention: CacheRetention,
|
||||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
||||||
|
|||||||
+40
-1
@@ -12,6 +12,8 @@ mod anthropic_oauth;
|
|||||||
#[cfg(feature = "bedrock")]
|
#[cfg(feature = "bedrock")]
|
||||||
mod bedrock;
|
mod bedrock;
|
||||||
pub mod circuit_breaker;
|
pub mod circuit_breaker;
|
||||||
|
pub(crate) mod codex_auth;
|
||||||
|
mod codex_chatgpt;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod costs;
|
pub mod costs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -102,7 +104,7 @@ pub async fn create_llm_provider(
|
|||||||
provider: config.backend.clone(),
|
provider: config.backend.clone(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
create_registry_provider(reg_config)
|
create_registry_provider(reg_config, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an LLM provider from a `NearAiConfig` directly.
|
/// Create an LLM provider from a `NearAiConfig` directly.
|
||||||
@@ -140,7 +142,13 @@ pub fn create_llm_provider_with_config(
|
|||||||
/// `create_*_provider` functions.
|
/// `create_*_provider` functions.
|
||||||
fn create_registry_provider(
|
fn create_registry_provider(
|
||||||
config: &RegistryProviderConfig,
|
config: &RegistryProviderConfig,
|
||||||
|
request_timeout_secs: u64,
|
||||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
// Codex ChatGPT mode: use the Responses API provider
|
||||||
|
if config.is_codex_chatgpt {
|
||||||
|
return create_codex_chatgpt_from_registry(config, request_timeout_secs);
|
||||||
|
}
|
||||||
|
|
||||||
match config.protocol {
|
match config.protocol {
|
||||||
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
||||||
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
||||||
@@ -148,6 +156,36 @@ fn create_registry_provider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_codex_chatgpt_from_registry(
|
||||||
|
config: &RegistryProviderConfig,
|
||||||
|
request_timeout_secs: u64,
|
||||||
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
let api_key = config
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "codex_chatgpt".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
configured_model = %config.model,
|
||||||
|
base_url = %config.base_url,
|
||||||
|
"Using Codex ChatGPT provider (Responses API) — model detection deferred to first call"
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider = codex_chatgpt::CodexChatGptProvider::with_lazy_model(
|
||||||
|
&config.base_url,
|
||||||
|
api_key,
|
||||||
|
&config.model,
|
||||||
|
config.refresh_token.clone(),
|
||||||
|
config.auth_path.clone(),
|
||||||
|
request_timeout_secs,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Arc::new(provider))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "bedrock")]
|
#[cfg(feature = "bedrock")]
|
||||||
async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
let br = config
|
let br = config
|
||||||
@@ -163,6 +201,7 @@ async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvid
|
|||||||
br.region,
|
br.region,
|
||||||
provider.active_model_name(),
|
provider.active_model_name(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Arc::new(provider))
|
Ok(Arc::new(provider))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -27,6 +27,8 @@ use ironclaw::{
|
|||||||
webhooks::{self, ToolWebhookState},
|
webhooks::{self, ToolWebhookState},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use ironclaw::channels::ChannelSecretUpdater;
|
||||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||||
use ironclaw::setup::{SetupConfig, SetupWizard};
|
use ironclaw::setup::{SetupConfig, SetupWizard};
|
||||||
|
|
||||||
@@ -740,7 +742,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use ironclaw::channels::ChannelSecretUpdater;
|
|
||||||
// Collect all channels that support secret updates
|
// Collect all channels that support secret updates
|
||||||
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
|
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
|
||||||
if let Some(ref state) = http_channel_state {
|
if let Some(ref state) = http_channel_state {
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ impl GatewayWorkflowHarness {
|
|||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
extra_headers: Vec::new(),
|
extra_headers: Vec::new(),
|
||||||
oauth_token: None,
|
oauth_token: None,
|
||||||
|
is_codex_chatgpt: false,
|
||||||
|
refresh_token: None,
|
||||||
|
auth_path: None,
|
||||||
cache_retention: Default::default(),
|
cache_retention: Default::default(),
|
||||||
unsupported_params: Vec::new(),
|
unsupported_params: Vec::new(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user