use reqwest::{Client, header::HeaderMap};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
pub use crate::compact::{
CompactContent, CompactContentPart, CompactInputItem, CompactOutputItem, CompactRequest,
CompactResponse, CompactUsage, messages_to_compact_input,
};
use crate::driver_registry::{
LlmCallConfig, LlmContentPart, LlmMessage, LlmMessageContent, LlmMessageRole,
fold_system_messages,
};
use crate::error::{AgentLoopError, LlmErrorKind, Result};
use crate::llm_retry::{
LlmRetryConfig, RateLimitInfo, RetryDecision, RetryMetadata, SendOutcome, is_rate_limit_status,
retry_request, send_error_message,
};
use crate::openai_protocol::{is_openai_model_not_found, is_openai_request_too_large};
use crate::openresponses_types::{self as types};
use crate::tool_types::ToolDefinition;
use crate::user_facing_error::is_provider_quota_message;
mod chat_driver;
mod input;
mod streaming;
mod wire;
pub(crate) use input::*;
pub(crate) use streaming::*;
pub(crate) use wire::*;
#[cfg(test)]
mod tests_replay;
#[cfg(test)]
mod tests_request;
#[cfg(test)]
mod tests_support;
#[cfg(test)]
mod tests_tools;
const OPENAI_PROMPT_CACHE_KEY_MAX_LEN: usize = 64;
const PROMPT_CACHE_KEY_PREFIX: &str = "everruns:";
pub trait OpenResponsesRequestExtension: Send + Sync {
fn allow_stateless_recovery(&self) -> bool {
true
}
fn decorate(&self, body: &mut Value, config: &LlmCallConfig) -> Result<()>;
fn decorate_headers(&self, _headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
Ok(())
}
fn update_rate_limit_info(
&self,
_info: &mut RateLimitInfo,
_headers: &HeaderMap,
_error_body: &str,
) {
}
fn classify_error(
&self,
_status: u16,
_headers: &HeaderMap,
_error_body: &str,
) -> Option<LlmErrorKind> {
None
}
}
#[derive(Clone)]
pub struct OpenResponsesProtocolChatDriver {
retry_config: LlmRetryConfig,
request_extension: Option<Arc<dyn OpenResponsesRequestExtension>>,
stateful_responses: Option<bool>,
native_phases: bool,
hosted_tool_search: bool,
native_prompt_cache_options: bool,
}
impl OpenResponsesProtocolChatDriver {
pub fn new() -> Self {
crate::install_default_crypto_provider();
Self {
retry_config: LlmRetryConfig::default(),
request_extension: None,
stateful_responses: None,
native_phases: false,
hosted_tool_search: false,
native_prompt_cache_options: false,
}
}
pub fn with_native_features(mut self, phases: bool, hosted_tool_search: bool) -> Self {
self.native_phases = phases;
self.hosted_tool_search = hosted_tool_search;
self
}
pub fn with_prompt_cache_options(mut self, enabled: bool) -> Self {
self.native_prompt_cache_options = enabled;
self
}
pub fn with_request_extension(
mut self,
extension: Arc<dyn OpenResponsesRequestExtension>,
) -> Self {
self.request_extension = Some(extension);
self
}
pub fn with_stateful_responses(mut self, supported: bool) -> Self {
self.stateful_responses = Some(supported);
self
}
pub fn with_retry_config(mut self, config: LlmRetryConfig) -> Self {
self.retry_config = config;
self
}
async fn send_responses_request(
&self,
endpoint: &crate::runtime_provider::ProviderEndpoint,
api_url: &str,
request_body: &Value,
extension_headers: &HeaderMap,
config: &LlmCallConfig,
retries_consumed: u32,
) -> Result<(reqwest::Response, RetryMetadata)> {
let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let mut retry_config = self.retry_config.clone();
retry_config.max_retries = retry_config.max_retries.saturating_sub(retries_consumed);
let body = serde_json::to_vec(request_body)
.map_err(|e| AgentLoopError::llm(format!("failed to serialize request: {e}")))?;
retry_request(
&retry_config,
"OpenResponsesProtocolDriver",
|| async {
let mut headers = extension_headers.clone();
let service_headers = headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.to_string(), value.to_string()))
})
.collect::<Vec<_>>();
let resolved = endpoint
.resolve("POST", api_url, &body)
.await
.map_err(SendOutcome::Fatal)?;
for (name, value) in service_headers.into_iter().chain(resolved.headers) {
let name =
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
SendOutcome::Fatal(AgentLoopError::llm(format!(
"invalid header name: {e}"
)))
})?;
let mut value =
reqwest::header::HeaderValue::from_str(&value).map_err(|e| {
SendOutcome::Fatal(AgentLoopError::llm(format!(
"invalid header value: {e}"
)))
})?;
value.set_sensitive(true);
headers.insert(name, value);
}
for (name, value) in
crate::driver_helpers::merge_request_headers(Vec::new(), &config.extra_headers)
{
let name =
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
SendOutcome::Fatal(AgentLoopError::llm(format!(
"invalid header name: {e}"
)))
})?;
let value = reqwest::header::HeaderValue::from_str(&value).map_err(|e| {
SendOutcome::Fatal(AgentLoopError::llm(format!(
"invalid header value: {e}"
)))
})?;
headers.insert(name, value);
}
self.client()
.post(&resolved.url)
.headers(headers)
.header("Content-Type", "application/json")
.body(body.clone())
.send()
.await
.map_err(SendOutcome::Send)
},
|response, attempts, can_retry| {
let last_error = Arc::clone(&last_error);
let model = config.model.clone();
async move {
let status = response.status();
if can_retry {
let response_headers = response.headers().clone();
let mut rate_limit_info = if is_rate_limit_status(status) {
Some(RateLimitInfo::from_openai_headers(&response_headers))
} else {
None
};
let error_text = response.text().await.unwrap_or_default();
if let (Some(extension), Some(info)) =
(self.request_extension.as_ref(), rate_limit_info.as_mut())
{
extension.update_rate_limit_info(info, &response_headers, &error_text);
}
if is_provider_quota_message(&error_text) {
return RetryDecision::Terminal(AgentLoopError::llm_kind(
LlmErrorKind::QuotaExhausted,
format!("OpenAI Responses API error ({}): {}", status, error_text),
));
}
let wait = rate_limit_info
.as_ref()
.map(|info| info.recommended_wait(&self.retry_config, attempts))
.unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
*last_error.lock().unwrap() = Some(error_text);
return RetryDecision::Retry {
wait,
rate_limit_info,
};
}
let response_headers = response.headers().clone();
let error_text = response.text().await.unwrap_or_default();
if is_openai_model_not_found(status, &error_text) {
return RetryDecision::Terminal(AgentLoopError::model_not_available(model));
}
if is_openai_request_too_large(status, &error_text) {
return RetryDecision::Terminal(AgentLoopError::request_too_large(
format!("OpenAI Responses API ({}): {}", status, error_text),
));
}
let error_msg =
format!("OpenAI Responses API error ({}): {}", status, error_text);
let kind = self
.request_extension
.as_ref()
.and_then(|extension| {
extension.classify_error(
status.as_u16(),
&response_headers,
&error_text,
)
})
.unwrap_or_else(|| {
LlmErrorKind::from_provider_status(status.as_u16(), &error_text)
});
if attempts > 0 {
return RetryDecision::Terminal(AgentLoopError::llm_kind(
kind,
format!(
"{} (after {} retries, last error: {})",
error_msg,
attempts,
last_error.lock().unwrap().take().unwrap_or_default()
),
));
}
RetryDecision::Terminal(AgentLoopError::llm_kind(kind, error_msg))
}
},
|e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
)
.await
}
pub fn client(&self) -> Client {
crate::driver_helpers::shared_streaming_http_client()
}
fn convert_role(role: &LlmMessageRole) -> &'static str {
match role {
LlmMessageRole::System => "developer", LlmMessageRole::User => "user",
LlmMessageRole::Assistant => "assistant",
LlmMessageRole::Tool => "tool",
}
}
fn convert_message(msg: &LlmMessage, supports_phases: bool) -> ResponsesInputItem {
if msg.role == LlmMessageRole::Tool
&& let Some(tool_call_id) = &msg.tool_call_id
{
let mut has_images = false;
let output = match &msg.content {
LlmMessageContent::Text(text) => text.clone(),
LlmMessageContent::Parts(parts) => {
has_images = parts.iter().any(|p| {
matches!(
p,
LlmContentPart::Image { .. } | LlmContentPart::File { .. }
)
});
parts
.iter()
.filter_map(|p| match p {
LlmContentPart::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
};
if has_images {
tracing::warn!(
tool_call_id = %tool_call_id,
"OpenResponses API does not support images/files in tool results; attachments dropped"
);
}
return ResponsesInputItem::FunctionCallOutput {
r#type: "function_call_output".to_string(),
call_id: tool_call_id.clone(),
output,
};
}
let content = match &msg.content {
LlmMessageContent::Text(text) => ResponsesContent::Text(text.clone()),
LlmMessageContent::Parts(parts) => {
let responses_parts: Vec<ResponsesContentPart> = parts
.iter()
.map(|part| match part {
LlmContentPart::Text { text } => ResponsesContentPart::InputText {
r#type: "input_text".to_string(),
text: text.clone(),
},
LlmContentPart::Image { url } => ResponsesContentPart::InputImage {
r#type: "input_image".to_string(),
image_url: url.clone(),
},
LlmContentPart::Audio { url } => ResponsesContentPart::InputAudio {
r#type: "input_audio".to_string(),
input_audio: ResponsesInputAudio {
data: url.clone(),
format: "wav".to_string(),
},
},
LlmContentPart::File { url, filename } => ResponsesContentPart::InputFile {
r#type: "input_file".to_string(),
input_file: ResponsesInputFile {
file_data: Some(url.clone()),
file_url: None,
filename: filename.clone(),
},
},
})
.collect();
ResponsesContent::Parts(responses_parts)
}
};
let phase = if supports_phases && msg.role == LlmMessageRole::Assistant {
msg.phase.map(|p| p.as_provider_str().to_string())
} else {
None
};
ResponsesInputItem::Message {
r#type: "message".to_string(),
role: Self::convert_role(&msg.role).to_string(),
content,
phase,
}
}
fn sanitize_parameters(params: &Value) -> Value {
let mut p = crate::tool_schema_compat::sanitize_openai_tool_schema(params);
if let Some(obj) = p.as_object_mut()
&& obj.get("type").and_then(|v| v.as_str()) == Some("object")
&& !obj.contains_key("properties")
{
obj.insert(
"properties".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
p
}
fn convert_tools(tools: &[ToolDefinition]) -> Vec<ResponsesTool> {
tools
.iter()
.map(|tool| Self::function_tool(tool, None))
.collect()
}
fn function_tool(tool: &ToolDefinition, defer_loading: Option<bool>) -> ResponsesTool {
let strict_parameters =
crate::tool_schema_compat::strict_openai_tool_schema(tool.parameters());
ResponsesTool::Function {
r#type: "function".to_string(),
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: strict_parameters
.clone()
.unwrap_or_else(|| Self::sanitize_parameters(tool.parameters())),
strict: strict_parameters.as_ref().map(|_| true),
defer_loading,
}
}
fn convert_tools_with_search(tools: &[ToolDefinition], threshold: usize) -> Vec<ResponsesTool> {
use crate::tool_types::DeferrablePolicy;
use std::collections::BTreeMap;
if tools.len() < threshold {
return Self::convert_tools(tools);
}
let mut namespaces: BTreeMap<String, Vec<ResponsesTool>> = BTreeMap::new();
let mut ungrouped = vec![];
let mut never_defer = vec![];
for tool in tools {
let should_defer = match tool.deferrable() {
DeferrablePolicy::Never => false,
DeferrablePolicy::Automatic | DeferrablePolicy::Always => true,
};
let func = Self::function_tool(tool, if should_defer { Some(true) } else { None });
if !should_defer {
never_defer.push(func);
} else {
match tool.category() {
Some(cat) => {
namespaces.entry(cat.to_string()).or_default().push(func);
}
None => ungrouped.push(func),
}
}
}
let mut result: Vec<ResponsesTool> = Vec::new();
result.extend(never_defer);
for (name, tools) in namespaces {
let description = format!("Tools for {name}");
result.push(ResponsesTool::Namespace {
r#type: "namespace".to_string(),
name,
description,
tools,
});
}
result.extend(ungrouped);
result.push(ResponsesTool::ToolSearch {
r#type: "tool_search".to_string(),
});
result
}
fn build_prompt_cache_key(
config: &LlmCallConfig,
_input_items: &[ResponsesInputItem],
instructions: &Option<String>,
tools: &Option<Vec<ResponsesTool>>,
) -> Option<String> {
let prompt_cache = config.prompt_cache.as_ref().filter(|cfg| cfg.enabled)?;
let cache_family = config
.metadata
.get("session_id")
.or_else(|| config.metadata.get("agent_id"))
.or_else(|| config.metadata.get("harness_id"))
.or_else(|| config.metadata.get("org_id"));
let fingerprint = json!({
"strategy": prompt_cache.strategy,
"model": config.model,
"cache_family": cache_family,
"instructions": instructions,
"tools": tools,
});
let payload = serde_json::to_vec(&fingerprint).ok()?;
let digest = hex::encode(Sha256::digest(payload));
let digest_len = OPENAI_PROMPT_CACHE_KEY_MAX_LEN - PROMPT_CACHE_KEY_PREFIX.len();
Some(format!(
"{PROMPT_CACHE_KEY_PREFIX}{}",
&digest[..digest_len]
))
}
pub async fn compact(
&self,
endpoint: &crate::runtime_provider::ProviderEndpoint,
request: CompactRequest,
) -> Result<CompactResponse> {
let responses_url = endpoint.url("responses").ok_or_else(|| {
AgentLoopError::Configuration("Open Responses provider has no base URL".to_string())
})?;
let explicit = request.reasoning_state.is_some();
let mut compact_url = url::Url::parse(&responses_url)
.map_err(|e| AgentLoopError::config(format!("Invalid compact endpoint URL: {e}")))?;
if !explicit {
compact_url.set_path(&format!(
"{}/compact",
compact_url.path().trim_end_matches('/')
));
}
let compact_url = compact_url.to_string();
let mut body = serde_json::to_value(&request).map_err(|e| {
AgentLoopError::llm(format!("failed to serialize compact request: {e}"))
})?;
if let Some(state) = &request.reasoning_state {
if !self.native_phases
|| !crate::reasoning_updates::supports_configuration_updates(&request.model)
|| !state.is_supported()
{
return Err(AgentLoopError::Configuration(
"configuration updates require native Astra Responses".into(),
));
}
let input = body["input"].as_array_mut().ok_or_else(|| {
AgentLoopError::Configuration("explicit compaction needs full input".into())
})?;
input.push(serde_json::json!({"type": "compaction_trigger"}));
body["stream"] = serde_json::json!(false);
body["store"] = serde_json::json!(false);
body["max_output_tokens"] = serde_json::json!(20_000);
body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
if let Some(effort) = state.baseline {
body["reasoning"] = serde_json::json!({"effort": effort});
}
}
let body = serde_json::to_vec(&body).map_err(|e| {
AgentLoopError::llm(format!("failed to serialize compact request: {e}"))
})?;
let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let (response, _retry_metadata) = retry_request(
&self.retry_config,
"OpenResponsesProtocolDriver(compact)",
|| async {
let resolved = endpoint
.resolve("POST", &compact_url, &body)
.await
.map_err(SendOutcome::Fatal)?;
let mut builder = self.client().post(&resolved.url);
for (name, value) in resolved.headers {
builder = builder.header(name, value);
}
builder
.header("Content-Type", "application/json")
.body(body.clone())
.send()
.await
.map_err(SendOutcome::Send)
},
|response, attempts, can_retry| {
let last_error = Arc::clone(&last_error);
let request_model = request.model.clone();
async move {
let status = response.status();
if can_retry {
let response_headers = response.headers().clone();
let mut rate_limit_info = if is_rate_limit_status(status) {
Some(RateLimitInfo::from_openai_headers(&response_headers))
} else {
None
};
let error_text = response.text().await.unwrap_or_default();
if let (Some(extension), Some(info)) =
(self.request_extension.as_ref(), rate_limit_info.as_mut())
{
extension.update_rate_limit_info(info, &response_headers, &error_text);
}
let wait = rate_limit_info
.as_ref()
.map(|info| info.recommended_wait(&self.retry_config, attempts))
.unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
*last_error.lock().unwrap() = Some(error_text);
return RetryDecision::Retry {
wait,
rate_limit_info,
};
}
let error_text = response.text().await.unwrap_or_default();
if is_openai_model_not_found(status, &error_text) {
return RetryDecision::Terminal(AgentLoopError::model_not_available(
request_model,
));
}
if is_openai_request_too_large(status, &error_text) {
return RetryDecision::Terminal(AgentLoopError::request_too_large(
format!("OpenAI Responses compact API ({}): {}", status, error_text),
));
}
let error_msg = format!(
"OpenAI Responses compact API error ({}): {}",
status, error_text
);
if attempts > 0 {
return RetryDecision::Terminal(AgentLoopError::llm(format!(
"{} (after {} retries, last error: {})",
error_msg,
attempts,
last_error.lock().unwrap().take().unwrap_or_default()
)));
}
RetryDecision::Terminal(AgentLoopError::llm(error_msg))
}
},
|e, attempts| {
let suffix = if attempts > 0 {
format!(" (after {attempts} retries)")
} else {
String::new()
};
AgentLoopError::llm(format!("Failed to send compact request: {e}{suffix}"))
},
)
.await?;
let value: Value = response
.json()
.await
.map_err(|e| AgentLoopError::llm(format!("Failed to parse compact response: {}", e)))?;
if explicit && value["status"] != "completed" {
return Err(AgentLoopError::llm("explicit compaction did not complete"));
}
let explicit_output = explicit.then(|| value["output"].clone());
let mut compact_response: CompactResponse = serde_json::from_value(value)
.map_err(|e| AgentLoopError::llm(format!("Failed to parse compact response: {e}")))?;
if explicit {
compact_response.output = explicit_output
.unwrap()
.as_array()
.ok_or_else(|| AgentLoopError::llm("explicit compaction returned invalid output"))?
.iter()
.map(|item| {
if item["type"] == "compaction" {
serde_json::from_value(item.clone()).map_err(|e| {
AgentLoopError::llm(format!("invalid compaction item: {e}"))
})
} else {
Ok(CompactOutputItem::ProviderItem(item.clone()))
}
})
.collect::<Result<Vec<_>>>()?;
let boundary = compact_response
.output
.iter()
.rposition(|item| matches!(item, CompactOutputItem::Compaction { .. }))
.ok_or_else(|| {
AgentLoopError::llm("explicit compaction returned no compaction item")
})?;
compact_response.output.drain(..boundary);
if let Some(usage) = compact_response.usage.as_mut() {
usage.output_tokens = None;
}
}
Ok(compact_response)
}
pub fn supports_compact(&self) -> bool {
true
}
fn build_input(
messages: &[LlmMessage],
supports_phases: bool,
) -> (Option<String>, Vec<ResponsesInputItem>) {
let instructions: Option<String> = fold_system_messages(messages);
let mut input_items = Vec::new();
for msg in messages {
if supports_phases && let Some(effort) = msg.configuration_update {
input_items.push(configuration_update_item(effort));
}
if msg.role == LlmMessageRole::System {
} else if msg.role == LlmMessageRole::Assistant {
for item in &msg.reasoning {
if item.provider != "openai" {
continue;
}
let (Some(id), Some(encrypted_content)) = (&item.item_id, &item.encrypted)
else {
tracing::debug!(
provider = %item.provider,
has_id = item.item_id.is_some(),
has_encrypted = item.encrypted.is_some(),
"OpenResponses: skipping reasoning item without a replayable id/payload"
);
continue;
};
let summary = match &item.text {
Some(crate::reasoning::ReasoningText::Summary { parts }) => parts
.iter()
.map(|text| types::ContentPart::SummaryText { text: text.clone() })
.collect(),
_ => Vec::new(),
};
input_items.push(ResponsesInputItem::Reasoning {
r#type: "reasoning".to_string(),
id: id.clone(),
encrypted_content: encrypted_content.clone(),
summary,
});
tracing::debug!(
item_id = %id,
encrypted_len = encrypted_content.len(),
"OpenResponses: including reasoning item in request"
);
}
if msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()) {
let has_content = match &msg.content {
LlmMessageContent::Text(text) => !text.is_empty(),
LlmMessageContent::Parts(parts) => !parts.is_empty(),
};
if has_content {
input_items.push(Self::convert_message(msg, supports_phases));
}
if let Some(tool_calls) = &msg.tool_calls {
for tc in tool_calls {
input_items.push(ResponsesInputItem::FunctionCall {
r#type: "function_call".to_string(),
call_id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.to_string(),
});
}
}
} else {
input_items.push(Self::convert_message(msg, supports_phases));
}
} else {
input_items.push(Self::convert_message(msg, supports_phases));
}
}
(instructions, input_items)
}
}
impl Default for OpenResponsesProtocolChatDriver {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for OpenResponsesProtocolChatDriver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenResponsesProtocolChatDriver")
.field("stateful_responses", &self.stateful_responses)
.field("native_phases", &self.native_phases)
.field("hosted_tool_search", &self.hosted_tool_search)
.finish_non_exhaustive()
}
}