//! OpenAI Codex Responses API client. //! //! Implements `LlmProvider` using the Responses API at //! `chatgpt.com/backend-api/codex/responses` -- the endpoint that works //! with ChatGPT subscription OAuth tokens. //! //! This mirrors OpenClaw's Responses API flow translated to Rust. use async_trait::async_trait; use reqwest::Client; use rust_decimal::Decimal; use serde::Deserialize; use tokio::sync::RwLock; use crate::error::LlmError; use crate::llm::provider::{ ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, }; /// OpenAI Codex Responses API provider. /// /// Sends requests to `{api_base_url}/responses` using SSE streaming, /// with JWT-based auth headers matching OpenClaw's approach. /// Token + account ID pair, updated atomically. struct AuthState { token: String, account_id: String, } pub struct OpenAiCodexProvider { client: Client, model: String, api_base_url: String, auth: RwLock, } impl OpenAiCodexProvider { /// Create a new provider. /// /// Extracts the `chatgpt_account_id` from the JWT token. /// `request_timeout_secs` controls the HTTP client timeout (falls back to 300s). pub fn new( model: &str, api_base_url: &str, token: &str, request_timeout_secs: u64, ) -> Result { let account_id = extract_account_id(token)?; Ok(Self { client: Client::builder() .timeout(std::time::Duration::from_secs(request_timeout_secs)) .build() .map_err(|e| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Failed to create HTTP client: {e}"), })?, model: model.to_string(), api_base_url: api_base_url.trim_end_matches('/').to_string(), auth: RwLock::new(AuthState { token: token.to_string(), account_id, }), }) } /// Update the access token after a refresh. pub async fn update_token(&self, token: &str) -> Result<(), LlmError> { let account_id = extract_account_id(token)?; *self.auth.write().await = AuthState { token: token.to_string(), account_id, }; tracing::debug!("Updated Codex provider token"); Ok(()) } /// Build request headers matching OpenClaw's `buildHeaders`. async fn build_headers(&self) -> Result { use reqwest::header::{ ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT, }; let auth = self.auth.read().await; let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", auth.token)).map_err(|e| { LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Invalid token for header: {e}"), } })?, ); headers.insert( HeaderName::from_static("chatgpt-account-id"), HeaderValue::from_str(&auth.account_id).map_err(|e| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Invalid account ID for header: {e}"), })?, ); headers.insert( HeaderName::from_static("openai-beta"), HeaderValue::from_static("responses=experimental"), ); headers.insert( HeaderName::from_static("originator"), HeaderValue::from_static("ironclaw"), ); headers.insert( USER_AGENT, HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))), ); headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); Ok(headers) } /// Build the request body for the Responses API. fn build_request_body( &self, messages: &[ChatMessage], tools: Option<&[ToolDefinition]>, ) -> serde_json::Value { // Separate system messages into `instructions` let instructions: String = messages .iter() .filter(|m| m.role == Role::System) .map(|m| m.content.as_str()) .collect::>() .join("\n\n"); // Convert non-system messages to Responses API format let input: Vec = messages .iter() .filter(|m| m.role != Role::System) .enumerate() .flat_map(|(i, m)| convert_message(m, i)) .collect(); let mut body = serde_json::json!({ "model": self.model, "store": false, "stream": true, "input": input, "text": { "verbosity": "medium" }, // Safe for non-reasoning models โ€” API ignores unrecognized include values "include": ["reasoning.encrypted_content"], }); if !instructions.is_empty() { body["instructions"] = serde_json::Value::String(instructions); } if let Some(tools) = tools && !tools.is_empty() { let tools_json: Vec = tools.iter().map(convert_tool_definition).collect(); body["tools"] = serde_json::Value::Array(tools_json); body["tool_choice"] = serde_json::Value::String("auto".to_string()); body["parallel_tool_calls"] = serde_json::Value::Bool(true); } body } /// Send a request and parse the SSE response stream. async fn send_request(&self, body: serde_json::Value) -> Result { let url = format!("{}/responses", self.api_base_url); let headers = self.build_headers().await?; tracing::debug!( url = %url, model = %self.model, "Sending Responses API request" ); let response = self .client .post(&url) .headers(headers) .json(&body) .send() .await .map_err(|e| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("HTTP request failed: {e}"), })?; let status = response.status(); if !status.is_success() { // Extract Retry-After header before consuming the response body. // Supports both delay-seconds (RFC 7231 ยง7.1.3) and HTTP-date formats. let retry_after = response .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|v| { if let Ok(secs) = v.trim().parse::() { return Some(std::time::Duration::from_secs(secs)); } if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) { let now = chrono::Utc::now(); let delta = dt.signed_duration_since(now); return Some(std::time::Duration::from_secs( delta.num_seconds().max(0) as u64 )); } None }); let body_text = response.text().await.unwrap_or_default(); if status == reqwest::StatusCode::UNAUTHORIZED { return Err(LlmError::AuthFailed { provider: "openai_codex".to_string(), }); } if status == reqwest::StatusCode::TOO_MANY_REQUESTS { return Err(LlmError::RateLimited { provider: "openai_codex".to_string(), retry_after, }); } return Err(LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("HTTP {status}: {body_text}"), }); } // Read the full body and parse SSE events let body_bytes = response .bytes() .await .map_err(|e| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Failed to read response body: {e}"), })?; let body_text = String::from_utf8_lossy(&body_bytes); parse_sse_response(&body_text) } } #[async_trait] impl LlmProvider for OpenAiCodexProvider { fn model_name(&self) -> &str { &self.model } fn cost_per_token(&self) -> (Decimal, Decimal) { (Decimal::ZERO, Decimal::ZERO) } fn calculate_cost(&self, _input_tokens: u32, _output_tokens: u32) -> Decimal { Decimal::ZERO } async fn complete(&self, request: CompletionRequest) -> Result { let body = self.build_request_body(&request.messages, None); let parsed = self.send_request(body).await?; Ok(CompletionResponse { content: parsed.text_content, input_tokens: parsed.input_tokens, output_tokens: parsed.output_tokens, finish_reason: parsed.finish_reason, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } async fn complete_with_tools( &self, request: ToolCompletionRequest, ) -> Result { let body = self.build_request_body(&request.messages, Some(&request.tools)); let parsed = self.send_request(body).await?; let finish_reason = if !parsed.tool_calls.is_empty() { FinishReason::ToolUse } else { parsed.finish_reason }; Ok(ToolCompletionResponse { content: if parsed.text_content.is_empty() { None } else { Some(parsed.text_content) }, tool_calls: parsed.tool_calls, input_tokens: parsed.input_tokens, output_tokens: parsed.output_tokens, finish_reason, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } /// Returns empty โ€” Codex uses subscription-based access with a fixed model, /// no model enumeration API is available. async fn list_models(&self) -> Result, LlmError> { Ok(vec![]) } async fn model_metadata(&self) -> Result { Ok(ModelMetadata { id: self.model.clone(), context_length: None, }) } fn set_model(&self, _model: &str) -> Result<(), LlmError> { Err(LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: "Cannot change model on Codex provider at runtime".to_string(), }) } fn effective_model_name(&self, _requested_model: Option<&str>) -> String { self.model.clone() } } // --------------------------------------------------------------------------- // JWT account ID extraction // --------------------------------------------------------------------------- /// Extract `chatgpt_account_id` from a JWT token's payload. /// /// Matches OpenClaw's `extractAccountId` which reads: /// `payload["https://api.openai.com/auth"]["chatgpt_account_id"]` fn extract_account_id(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() < 2 { return Err(LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: "JWT token has fewer than 2 parts".to_string(), }); } use base64::Engine; let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; // JWT base64url may need padding let payload_b64 = parts[1]; let decoded = engine .decode(payload_b64) .map_err(|e| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Failed to decode JWT payload: {e}"), })?; let payload: serde_json::Value = serde_json::from_slice(&decoded).map_err(|e| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Failed to parse JWT payload as JSON: {e}"), })?; let account_id = payload .get("https://api.openai.com/auth") .and_then(|auth| auth.get("chatgpt_account_id")) .and_then(|v| v.as_str()) .ok_or_else(|| LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: "JWT payload missing chatgpt_account_id claim".to_string(), })?; Ok(account_id.to_string()) } // --------------------------------------------------------------------------- // Message conversion (matching OpenClaw's convertResponsesMessages) // --------------------------------------------------------------------------- /// Convert a single `ChatMessage` to Responses API `input` items. /// /// Returns a Vec because assistant messages with tool_calls produce /// one `function_call` item per tool call. fn convert_message(msg: &ChatMessage, index: usize) -> Vec { match msg.role { Role::System => { // System messages are handled separately as `instructions` vec![] } Role::User => { let image_count = msg .content_parts .iter() .filter(|p| matches!(p, ContentPart::ImageUrl { .. })) .count(); if image_count > 0 { tracing::warn!( "OpenAI Codex: {} image attachment(s) dropped โ€” Responses API image support not yet implemented", image_count ); } vec![serde_json::json!({ "role": "user", "content": [{ "type": "input_text", "text": msg.content, }], })] } Role::Assistant => { // Check if this message has tool calls if let Some(ref tool_calls) = msg.tool_calls { // Emit one function_call item per tool call tool_calls .iter() .map(|tc| { let args_str = if tc.arguments.is_string() { tc.arguments.as_str().unwrap_or("{}").to_string() } else { tc.arguments.to_string() }; serde_json::json!({ "type": "function_call", "call_id": tc.id, "name": tc.name, "arguments": args_str, }) }) .collect() } else { // Plain text assistant message vec![serde_json::json!({ "type": "message", "role": "assistant", "id": format!("msg_{index}"), "status": "completed", "content": [{ "type": "output_text", "text": msg.content, "annotations": [], }], })] } } Role::Tool => { let call_id = msg.tool_call_id.as_deref().unwrap_or("unknown"); vec![serde_json::json!({ "type": "function_call_output", "call_id": call_id, "output": msg.content, })] } } } /// Convert a `ToolDefinition` to Responses API tool format. /// /// Applies strict-mode schema normalization (same as OpenAI Chat Completions): /// `additionalProperties: false`, all properties required, optional fields nullable. fn convert_tool_definition(tool: &ToolDefinition) -> serde_json::Value { use crate::llm::rig_adapter::normalize_schema_strict; serde_json::json!({ "type": "function", "name": tool.name, "description": tool.description, "parameters": normalize_schema_strict(&tool.parameters), }) } // --------------------------------------------------------------------------- // SSE response parsing (matching OpenClaw's processResponsesStream) // --------------------------------------------------------------------------- /// Parsed result from the SSE stream. #[derive(Debug)] struct ParsedResponse { text_content: String, tool_calls: Vec, input_tokens: u32, output_tokens: u32, finish_reason: FinishReason, } /// SSE event data from the Responses API. #[derive(Debug, Deserialize)] struct SseEvent { #[serde(rename = "type")] event_type: String, #[serde(flatten)] data: serde_json::Value, } /// Tracking state for an in-progress function call. #[derive(Debug, Default)] struct FunctionCallState { call_id: String, name: String, arguments: String, } /// Parse the full SSE response body into a `ParsedResponse`. fn parse_sse_response(body: &str) -> Result { let mut text_content = String::new(); let mut tool_calls: Vec = Vec::new(); let mut input_tokens: u32 = 0; let mut output_tokens: u32 = 0; let mut finish_reason = FinishReason::Stop; let mut active_function_calls: std::collections::HashMap = std::collections::HashMap::new(); let mut response_status: Option = None; for line in body.lines() { let line = line.trim(); // Skip empty lines and comments if line.is_empty() || line.starts_with(':') { continue; } // Parse SSE data lines let data_str = if let Some(stripped) = line.strip_prefix("data: ") { stripped.trim() } else if let Some(stripped) = line.strip_prefix("data:") { stripped.trim() } else { continue; }; // Skip [DONE] marker if data_str == "[DONE]" { break; } // Parse JSON let event: SseEvent = match serde_json::from_str(data_str) { Ok(e) => e, Err(e) => { tracing::trace!(data = data_str, error = %e, "Skipping unparseable SSE event"); continue; } }; match event.event_type.as_str() { // Text output "response.output_text.delta" => { if let Some(delta) = event.data.get("delta").and_then(|d| d.as_str()) { text_content.push_str(delta); } } // Output item added (could be message or function_call) "response.output_item.added" => { if let Some(item) = event.data.get("item") { let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); if item_type == "function_call" { let item_id = item .get("id") .or_else(|| 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(); let call_id = item .get("call_id") .and_then(|v| v.as_str()) .unwrap_or(&item_id) .to_string(); active_function_calls.insert( item_id.clone(), FunctionCallState { call_id, name, arguments: String::new(), }, ); } } } // Function call arguments streaming "response.function_call_arguments.delta" => { if let Some(delta) = event.data.get("delta").and_then(|d| d.as_str()) { let item_id = event .data .get("item_id") .and_then(|v| v.as_str()) .unwrap_or(""); if let Some(state) = active_function_calls.get_mut(item_id) { state.arguments.push_str(delta); } } } // Function call arguments done "response.function_call_arguments.done" => { // Arguments are finalized, item_id used to match if let Some(args_str) = event.data.get("arguments").and_then(|a| a.as_str()) { let item_id = event .data .get("item_id") .and_then(|v| v.as_str()) .unwrap_or(""); if let Some(state) = active_function_calls.get_mut(item_id) { state.arguments = args_str.to_string(); } } } // Output item done (finalize function call) "response.output_item.done" => { if let Some(item) = event.data.get("item") { let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); if item_type == "function_call" { let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or(""); if let Some(state) = active_function_calls.remove(item_id) { let arguments: serde_json::Value = serde_json::from_str(&state.arguments).unwrap_or_else(|_| { serde_json::Value::String(state.arguments.clone()) }); tool_calls.push(ToolCall { id: state.call_id, name: state.name, arguments, }); } else { // Fallback: extract directly from the item let call_id = item .get("call_id") .and_then(|v| v.as_str()) .unwrap_or(item_id) .to_string(); let name = item .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let args_str = item .get("arguments") .and_then(|v| v.as_str()) .unwrap_or("{}"); let arguments: serde_json::Value = serde_json::from_str(args_str) .unwrap_or_else(|_| { serde_json::Value::String(args_str.to_string()) }); tool_calls.push(ToolCall { id: call_id, name, arguments, }); } } } } // Response completed "response.completed" => { if let Some(response) = event.data.get("response") { // Extract usage if let Some(usage) = response.get("usage") { input_tokens = usage .get("input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; output_tokens = usage .get("output_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; } // Extract status if let Some(status) = response.get("status").and_then(|s| s.as_str()) { response_status = Some(status.to_string()); } } } // Response failed "response.failed" => { let reason = event .data .get("response") .and_then(|r| r.get("status_details")) .and_then(|d| d.get("error")) .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) .unwrap_or("Unknown error"); return Err(LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Response failed: {reason}"), }); } // Error event "error" => { let code = event .data .get("code") .and_then(|c| c.as_str()) .unwrap_or("unknown"); let message = event .data .get("message") .and_then(|m| m.as_str()) .unwrap_or("Unknown error"); return Err(LlmError::RequestFailed { provider: "openai_codex".to_string(), reason: format!("Error {code}: {message}"), }); } _ => { // Ignore unhandled event types (e.g. response.created, // response.output_item.added for messages, etc.) } } } // Finalize any remaining active function calls for (_, state) in active_function_calls { if !state.name.is_empty() { let arguments: serde_json::Value = serde_json::from_str(&state.arguments) .unwrap_or(serde_json::Value::String(state.arguments)); tool_calls.push(ToolCall { id: state.call_id, name: state.name, arguments, }); } } // Map status to finish reason (matching OpenClaw's mapStopReason) if !tool_calls.is_empty() { finish_reason = FinishReason::ToolUse; } else if let Some(ref status) = response_status { finish_reason = match status.as_str() { "completed" => FinishReason::Stop, "incomplete" => FinishReason::Length, _ => FinishReason::Stop, }; } Ok(ParsedResponse { text_content, tool_calls, input_tokens, output_tokens, finish_reason, }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::llm::codex_test_helpers::make_test_jwt; #[test] fn test_extract_account_id_success() { let jwt = make_test_jwt("acct_abc123"); let result = extract_account_id(&jwt); assert!(result.is_ok()); assert_eq!(result.unwrap(), "acct_abc123"); } #[test] fn test_extract_account_id_missing_claim() { use base64::Engine; let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = engine.encode(b"{\"alg\":\"RS256\"}"); let payload = engine.encode(b"{\"sub\":\"user123\"}"); let sig = engine.encode(b"sig"); let jwt = format!("{header}.{payload}.{sig}"); let result = extract_account_id(&jwt); assert!(result.is_err()); } #[test] fn test_extract_account_id_invalid_jwt() { let result = extract_account_id("not-a-jwt"); assert!(result.is_err()); } #[test] fn test_convert_user_message() { let msg = ChatMessage::user("Hello world"); let items = convert_message(&msg, 0); assert_eq!(items.len(), 1); assert_eq!(items[0]["role"], "user"); assert_eq!(items[0]["content"][0]["type"], "input_text"); assert_eq!(items[0]["content"][0]["text"], "Hello world"); } #[test] fn test_convert_system_message_excluded() { let msg = ChatMessage::system("You are helpful"); let items = convert_message(&msg, 0); assert!(items.is_empty()); } #[test] fn test_convert_assistant_text_message() { let msg = ChatMessage::assistant("Sure, I can help"); let items = convert_message(&msg, 3); assert_eq!(items.len(), 1); assert_eq!(items[0]["type"], "message"); assert_eq!(items[0]["role"], "assistant"); assert_eq!(items[0]["id"], "msg_3"); assert_eq!(items[0]["content"][0]["type"], "output_text"); } #[test] fn test_convert_assistant_with_tool_calls() { let tool_calls = vec![ ToolCall { id: "call_1".to_string(), name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), }, ToolCall { id: "call_2".to_string(), name: "read".to_string(), arguments: serde_json::json!({"path": "/tmp"}), }, ]; let msg = ChatMessage::assistant_with_tool_calls(Some("Let me check".to_string()), tool_calls); let items = convert_message(&msg, 0); assert_eq!(items.len(), 2); assert_eq!(items[0]["type"], "function_call"); assert_eq!(items[0]["call_id"], "call_1"); assert_eq!(items[0]["name"], "search"); assert_eq!(items[1]["type"], "function_call"); assert_eq!(items[1]["call_id"], "call_2"); } #[test] fn test_convert_tool_result_message() { let msg = ChatMessage::tool_result("call_1", "search", "found 3 results"); let items = convert_message(&msg, 0); 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"], "found 3 results"); } #[test] fn test_convert_tool_definition() { let tool = ToolDefinition { name: "my_tool".to_string(), description: "Does things".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { "x": { "type": "string" } } }), }; let json = convert_tool_definition(&tool); assert_eq!(json["type"], "function"); assert_eq!(json["name"], "my_tool"); assert_eq!(json["description"], "Does things"); } #[test] fn test_parse_sse_text_response() { let sse_body = r#"data: {"type":"response.output_item.added","item":{"type":"message","role":"assistant","id":"msg_1"}} data: {"type":"response.output_text.delta","delta":"Hello "} data: {"type":"response.output_text.delta","delta":"world!"} data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":10,"output_tokens":5}}} "#; let result = parse_sse_response(sse_body); assert!(result.is_ok()); let parsed = result.unwrap(); assert_eq!(parsed.text_content, "Hello world!"); assert_eq!(parsed.input_tokens, 10); assert_eq!(parsed.output_tokens, 5); assert_eq!(parsed.finish_reason, FinishReason::Stop); assert!(parsed.tool_calls.is_empty()); } #[test] fn test_parse_sse_tool_call_response() { let sse_body = r#"data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"search"}} data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"query\":"} data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"test\"}"} data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"search","arguments":"{\"query\":\"test\"}"}} data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":15,"output_tokens":8}}} "#; let result = parse_sse_response(sse_body); assert!(result.is_ok()); let parsed = result.unwrap(); assert!(parsed.text_content.is_empty()); assert_eq!(parsed.tool_calls.len(), 1); assert_eq!(parsed.tool_calls[0].id, "call_abc"); assert_eq!(parsed.tool_calls[0].name, "search"); assert_eq!( parsed.tool_calls[0].arguments, serde_json::json!({"query": "test"}) ); assert_eq!(parsed.finish_reason, FinishReason::ToolUse); } #[test] fn test_parse_sse_error_response() { let sse_body = r#"data: {"type":"error","code":"rate_limit_exceeded","message":"Too many requests"} "#; let result = parse_sse_response(sse_body); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("rate_limit_exceeded")); } #[test] fn test_parse_sse_failed_response() { let sse_body = r#"data: {"type":"response.failed","response":{"status":"failed","status_details":{"error":{"message":"Model overloaded"}}}} "#; let result = parse_sse_response(sse_body); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("Model overloaded")); } #[test] fn test_parse_sse_incomplete_status() { let sse_body = r#"data: {"type":"response.output_text.delta","delta":"partial"} data: {"type":"response.completed","response":{"status":"incomplete","usage":{"input_tokens":5,"output_tokens":2}}} "#; let result = parse_sse_response(sse_body); assert!(result.is_ok()); let parsed = result.unwrap(); assert_eq!(parsed.text_content, "partial"); assert_eq!(parsed.finish_reason, FinishReason::Length); } #[test] fn test_parse_sse_done_marker() { let sse_body = r#"data: {"type":"response.output_text.delta","delta":"hello"} data: [DONE] data: {"type":"response.output_text.delta","delta":" ignored"} "#; let result = parse_sse_response(sse_body); assert!(result.is_ok()); let parsed = result.unwrap(); assert_eq!(parsed.text_content, "hello"); } #[tokio::test] async fn test_provider_new() { let jwt = make_test_jwt("acct_test"); let provider = OpenAiCodexProvider::new( "gpt-5.3-codex", "https://chatgpt.com/backend-api/codex", &jwt, 300, ); assert!(provider.is_ok()); let provider = provider.unwrap(); assert_eq!(provider.model_name(), "gpt-5.3-codex"); assert_eq!(provider.cost_per_token(), (Decimal::ZERO, Decimal::ZERO)); assert_eq!(provider.calculate_cost(1000, 500), Decimal::ZERO); } #[tokio::test] async fn test_update_token() { let jwt1 = make_test_jwt("acct_old"); let provider = OpenAiCodexProvider::new( "gpt-5.3-codex", "https://chatgpt.com/backend-api/codex", &jwt1, 300, ) .unwrap(); let jwt2 = make_test_jwt("acct_new"); let result = provider.update_token(&jwt2).await; assert!(result.is_ok()); // Verify account_id was updated let auth = provider.auth.read().await; assert_eq!(auth.account_id, "acct_new"); } #[test] fn test_build_request_body_structure() { let jwt = make_test_jwt("acct_test"); let provider = OpenAiCodexProvider::new( "gpt-5.3-codex", "https://chatgpt.com/backend-api/codex", &jwt, 300, ) .unwrap(); let messages = vec![ ChatMessage::system("You are helpful"), ChatMessage::user("Hello"), ]; let body = provider.build_request_body(&messages, None); assert_eq!(body["model"], "gpt-5.3-codex"); assert_eq!(body["store"], false); assert_eq!(body["stream"], true); assert_eq!(body["instructions"], "You are helpful"); // input should only contain the user message, not system let input = body["input"].as_array().unwrap(); assert_eq!(input.len(), 1); assert_eq!(input[0]["role"], "user"); // No tools assert!(body.get("tools").is_none()); } #[test] fn test_build_request_body_with_tools() { let jwt = make_test_jwt("acct_test"); let provider = OpenAiCodexProvider::new( "gpt-5.3-codex", "https://chatgpt.com/backend-api/codex", &jwt, 300, ) .unwrap(); let messages = vec![ChatMessage::user("Search for X")]; let tools = vec![ToolDefinition { name: "search".to_string(), description: "Search for things".to_string(), parameters: serde_json::json!({"type": "object"}), }]; let body = provider.build_request_body(&messages, Some(&tools)); assert!(body.get("tools").is_some()); let tools_arr = body["tools"].as_array().unwrap(); assert_eq!(tools_arr.len(), 1); assert_eq!(tools_arr[0]["type"], "function"); assert_eq!(body["tool_choice"], "auto"); assert_eq!(body["parallel_tool_calls"], true); } #[test] fn test_parse_sse_multiple_tool_calls() { let sse_body = r#"data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"read_file"}} data: {"type":"response.function_call_arguments.done","item_id":"fc_1","arguments":"{\"path\":\"/tmp/a\"}"} data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"read_file","arguments":"{\"path\":\"/tmp/a\"}"}} data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_2","call_id":"call_2","name":"read_file"}} data: {"type":"response.function_call_arguments.done","item_id":"fc_2","arguments":"{\"path\":\"/tmp/b\"}"} data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_2","call_id":"call_2","name":"read_file","arguments":"{\"path\":\"/tmp/b\"}"}} data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":20,"output_tokens":12}}} "#; let result = parse_sse_response(sse_body); assert!(result.is_ok()); let parsed = result.unwrap(); assert_eq!(parsed.tool_calls.len(), 2); assert_eq!(parsed.tool_calls[0].id, "call_1"); assert_eq!(parsed.tool_calls[0].name, "read_file"); assert_eq!(parsed.tool_calls[1].id, "call_2"); assert_eq!(parsed.tool_calls[1].name, "read_file"); assert_eq!(parsed.finish_reason, FinishReason::ToolUse); } }