Files
optimclaw/src/llm/codex_chatgpt.rs
T
ZeroTrustandGitHub 1b59eb6b39 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
2026-03-16 07:43:45 +00:00

933 lines
35 KiB
Rust

//! 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,
&current_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"}));
}
}