use foundation_compact::SystemTime;
use std::collections::HashMap;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use foundation_auth::{AuthCredential, ConfidentialText};
use foundation_core::url::Uri;
use foundation_core::valtron::{Stream, StreamSpread};
use foundation_netio::event_source::{Event, ParseResult};
use foundation_netio::shared::client::{
request::Extensions,
body_reader::collect_strings_from_send_safe,
http_client::{BoxedSseIterator, HttpClient},
request::PreparedRequest,
};
use foundation_netio::shared::http::{
SendSafeBody, SimpleHeader, SimpleHeaders, SimpleMethod,
};
use serde::{Deserialize, Serialize};
use crate::backends::backend_utils::{
empty_usage_report, flatten_tools, json_value_to_arg_type, model_id_to_string,
};
use crate::errors::{GenerationError, GenerationResult, ModelProviderErrors, ModelProviderResult};
use crate::types::base_types::{
AuthProvider, CostStatus, GenerationMetadata, Messages, Model, ModelId, ModelInteraction,
ModelOutput, ModelParams, ModelProvider, ModelProviderDescriptor, ModelProviders, ModelSpec,
ModelState, ModelStreamBox, StopReason, TextBasedFormatter, TextContent, ToolFormatter,
UsageCosting, UsageReport,
};
#[derive(Debug)]
pub struct ResponsesConfig {
pub base_url: String,
pub api_version: String,
pub timeout_secs: u64,
pub max_retries: u32,
pub proxy_url: Option<String>,
pub streaming: bool,
pub auth: Option<AuthCredential>,
}
impl Default for ResponsesConfig {
fn default() -> Self {
Self {
base_url: String::from("https://api.openai.com"),
api_version: String::from("v1"),
timeout_secs: 120,
max_retries: 3,
proxy_url: None,
streaming: true,
auth: None,
}
}
}
impl ResponsesConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
#[must_use]
pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = version.into();
self
}
#[must_use]
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
#[must_use]
pub fn with_max_retries(mut self, retries: u32) -> Self {
self.max_retries = retries;
self
}
#[must_use]
pub fn with_streaming(mut self, enabled: bool) -> Self {
self.streaming = enabled;
self
}
#[must_use]
pub fn with_auth(mut self, auth: AuthCredential) -> Self {
self.auth = Some(auth);
self
}
#[must_use]
pub fn build_url(&self, endpoint: &str) -> String {
format!(
"{}/{}/{}",
self.base_url.trim_end_matches('/'),
self.api_version,
endpoint.trim_start_matches('/')
)
}
}
impl Clone for ResponsesConfig {
fn clone(&self) -> Self {
Self {
base_url: self.base_url.clone(),
api_version: self.api_version.clone(),
timeout_secs: self.timeout_secs,
max_retries: self.max_retries,
proxy_url: self.proxy_url.clone(),
streaming: self.streaming,
auth: None,
}
}
}
impl AuthProvider for ResponsesConfig {
fn auth(&self) -> Option<&AuthCredential> {
self.auth.as_ref()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ResponseRequest {
pub model: String,
pub input: ResponseInput,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ResponseTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ResponseToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncate: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseTool {
#[serde(rename = "type")]
pub tool_type: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponseToolChoice {
Simple(String),
Function {
#[serde(rename = "type")]
r#type: String,
function: ResponseToolChoiceFunction,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseToolChoiceFunction {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponseInput {
Text(String),
Items(Vec<ResponseInputItem>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseInputItem {
Message {
role: String,
content: ResponseInputContent,
},
FunctionCallOutput {
call_id: String,
output: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponseInputContent {
Text(String),
Parts(Vec<ResponseInputContentPart>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseInputContentPart {
InputText { text: String },
InputImage { image_url: String },
}
#[derive(Debug, Clone, Deserialize)]
pub struct Response {
pub id: String,
pub object: String,
pub created_at: u64,
pub model: String,
pub output: Vec<ResponseOutputItem>,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ResponseError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ResponseUsage>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseOutputItem {
Message {
id: String,
status: String,
role: String,
content: Vec<ResponseOutputContent>,
},
FunctionCall {
id: String,
call_id: String,
name: String,
arguments: String,
status: String,
},
Reasoning {
id: String,
content: String,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseOutputContent {
OutputText { text: String },
}
#[derive(Debug, Clone, Deserialize)]
pub struct ResponseUsage {
pub input_tokens: usize,
pub output_tokens: usize,
pub total_tokens: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ResponseError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum ResponseEvent {
#[serde(rename = "response.created")]
ResponseCreated { response: Response },
#[serde(rename = "response.in_progress")]
ResponseInProgress { response: Response },
#[serde(rename = "response.output_item.added")]
ResponseOutputItemAdded {
response_id: String,
item: ResponseOutputItem,
},
#[serde(rename = "response.output_item.done")]
ResponseOutputItemDone {
response_id: String,
item: ResponseOutputItem,
},
#[serde(rename = "response.output_text.delta")]
ResponseOutputTextDelta { item_id: String, delta: String },
#[serde(rename = "response.output_text.done")]
ResponseOutputTextDone { item_id: String, text: String },
#[serde(rename = "response.completed")]
ResponseCompleted { response: Response },
#[serde(rename = "response.failed")]
ResponseFailed { response: Response },
}
pub struct ResponsesProvider {
config: ResponsesConfig,
api_key: Option<ConfidentialText>,
http_client: Option<Arc<dyn HttpClient>>,
models_cache:
Arc<std::sync::Mutex<HashMap<String, crate::backends::openai_provider::OpenAIModelInfo>>>,
}
impl Default for ResponsesProvider {
fn default() -> Self {
Self::new()
}
}
impl ResponsesProvider {
#[must_use]
pub fn new() -> Self {
Self {
config: ResponsesConfig::default(),
api_key: None,
http_client: None,
models_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
#[must_use]
pub fn with_config(config: ResponsesConfig) -> Self {
Self {
config,
api_key: None,
http_client: None,
models_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
#[must_use]
pub fn with_http_client(client: Arc<dyn HttpClient>) -> Self {
Self {
config: ResponsesConfig::default(),
api_key: None,
http_client: Some(client),
models_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
#[must_use]
pub fn with_http_client_and_config(
client: Arc<dyn HttpClient>,
config: ResponsesConfig,
) -> Self {
Self {
config,
api_key: None,
http_client: Some(client),
models_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
fn auth_headers(&self) -> SimpleHeaders {
let mut headers = SimpleHeaders::new();
if let Some(key) = &self.api_key {
headers.insert(
SimpleHeader::AUTHORIZATION,
vec![format!("Bearer {}", key.get())],
);
}
headers.insert(
SimpleHeader::CONTENT_TYPE,
vec![String::from("application/json")],
);
headers
}
fn build_prepared_request(&self, url: &str, body: &str) -> GenerationResult<PreparedRequest> {
let uri =
Uri::parse(url).map_err(|e| GenerationError::Backend(format!("Invalid URL: {e}")))?;
let mut headers = self.auth_headers();
headers.insert(SimpleHeader::ACCEPT, vec![String::from("application/json")]);
Ok(PreparedRequest {
method: SimpleMethod::POST,
url: uri,
headers,
body: SendSafeBody::Text(body.to_string()),
extensions: Extensions::default(),
})
}
fn build_url(&self, endpoint: &str) -> String {
self.config.build_url(endpoint)
}
fn execute_request<T: for<'de> Deserialize<'de> + Send>(
&self,
url: &str,
body: &str,
) -> GenerationResult<T> {
let mut attempt = 0;
let max_retries = self.config.max_retries;
loop {
let result = self.do_request::<T>(url, body)?;
match result {
Ok(value) => return Ok(value),
Err((status, retry_after, msg)) => {
if attempt >= max_retries || !is_retryable_status(status) {
return Err(GenerationError::Backend(msg));
}
let delay = retry_after.unwrap_or_else(|| exponential_backoff(attempt));
attempt += 1;
thread::sleep(Duration::from_secs(delay));
}
}
}
}
#[allow(clippy::type_complexity, clippy::cast_possible_truncation)]
fn do_request<T: for<'de> Deserialize<'de> + Send>(
&self,
url: &str,
body: &str,
) -> GenerationResult<Result<T, (u16, Option<u64>, String)>> {
let client = self
.http_client
.as_ref()
.ok_or_else(|| GenerationError::Generic("HTTP client not initialized".into()))?;
let req = self.build_prepared_request(url, body)?;
let response = client
.send(req)
.map_err(|e| GenerationError::Backend(format!("Request failed: {e}")))?;
let (status, headers, body) = response.into_parts();
let status_code: usize = status.into();
let body_text = collect_strings_from_send_safe(body)
.map_err(|e| GenerationError::Generic(format!("Parse error: {e}")))?;
if !(200..=299).contains(&status_code) {
let retry_after = extract_retry_after(&headers);
let msg = format!("HTTP {status_code}: {body_text}");
return Ok(Err((status_code as u16, retry_after, msg)));
}
serde_json::from_str(&body_text)
.map(|v| Ok(v))
.map_err(|e| GenerationError::Generic(format!("Parse error: {e}")))
}
}
impl ModelProvider for ResponsesProvider {
type Config = ResponsesConfig;
type Model = ResponsesModel;
fn create(mut self, config: Option<Self::Config>) -> ModelProviderResult<Self> {
if let Some(cfg) = config {
if let Some(cred) = cfg.auth() {
match &cred {
AuthCredential::SecretOnly(key) => {
self.api_key = Some(key.clone());
}
AuthCredential::ClientSecret { client_secret, .. } => {
self.api_key = Some(client_secret.clone());
}
AuthCredential::OAuth(cred) => {
self.api_key = Some(cred.access_token.clone());
}
AuthCredential::EmailAuth { .. }
| AuthCredential::UsernameAndPassword { .. } => {
return Err(ModelProviderErrors::NotFound(
"Responses provider requires SecretOnly, ClientSecret, or OAuth credentials"
.into(),
));
}
}
}
self.config = cfg;
}
#[cfg(not(target_family = "wasm"))]
if self.http_client.is_none() {
self.http_client = Some(foundation_netio::http::default_http_client());
}
Ok(self)
}
fn describe(&self) -> ModelProviderResult<ModelProviderDescriptor> {
Ok(ModelProviderDescriptor {
id: "openai-responses",
name: "OpenAI Responses",
reasoning: true,
api: crate::types::base_types::ModelAPI::OpenAIResponses,
provider: ModelProviders::OPENAIRESPONSES,
base_url: None,
inputs: crate::types::base_types::MessageType::TextAndImages,
cost: crate::types::base_types::ModelUsageCosting {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 0,
max_tokens: 0,
})
}
fn get_model(&self, model_id: ModelId) -> ModelProviderResult<Self::Model> {
let model_name = model_id_to_string(&model_id);
let cache = self.models_cache.lock().expect("model cache poisoned");
if let Some(info) = cache.get(&model_name) {
return Ok(ResponsesModel {
config: self.config.clone(),
model_id: model_id.clone(),
model_name: model_name.clone(),
api_key: self.api_key.clone(),
http_client: self.http_client.clone(),
info: info.clone(),
pricing: self.describe().ok().map(|d| d.cost).unwrap_or_default(),
cumulative_cost: Arc::new(std::sync::Mutex::new(
crate::costing::CostAccumulator::new(),
)),
});
}
drop(cache);
let url = self.build_url(&format!("models/{model_name}"));
let result: Result<crate::backends::openai_provider::OpenAIModelResponse, _> =
self.execute_request(&url, "");
let info = match result {
Ok(resp) => crate::backends::openai_provider::OpenAIModelInfo {
id: resp.id,
object: resp.object,
owned_by: resp.owned_by.unwrap_or_default(),
created: resp.created.unwrap_or(0),
},
Err(_) => crate::backends::openai_provider::OpenAIModelInfo {
id: model_name.clone(),
object: String::from("model"),
owned_by: String::new(),
created: 0,
},
};
let mut cache = self.models_cache.lock().expect("model cache poisoned");
cache.insert(model_name.clone(), info.clone());
Ok(ResponsesModel {
config: self.config.clone(),
model_id,
model_name,
api_key: self.api_key.clone(),
http_client: self.http_client.clone(),
info,
pricing: self.describe().ok().map(|d| d.cost).unwrap_or_default(),
cumulative_cost: Arc::new(std::sync::Mutex::new(
crate::costing::CostAccumulator::new(),
)),
})
}
fn get_model_by_spec(&self, spec: ModelSpec) -> ModelProviderResult<Self::Model> {
self.get_model(spec.id)
}
fn get_one(&self, model_id: ModelId) -> ModelProviderResult<ModelSpec> {
self.get_all(model_id.clone())?
.into_iter()
.next()
.ok_or_else(|| ModelProviderErrors::NotFound(format!("No model matching {model_id:?}")))
}
fn get_all(&self, model_id: ModelId) -> ModelProviderResult<Vec<ModelSpec>> {
use crate::backends::openai_provider::OpenAIListResponse;
let url = self.build_url("models");
let response: OpenAIListResponse = self
.execute_request(&url, "")
.map_err(|e| ModelProviderErrors::NotFound(e.to_string()))?;
let filter_pattern = match &model_id {
ModelId::Name(name, _) => name.to_lowercase(),
ModelId::Alias(alias, _) => alias.to_lowercase(),
ModelId::Group(group, _) => group.to_lowercase(),
ModelId::Architecture(arch, _) => arch.to_lowercase(),
};
let specs: Vec<ModelSpec> = response
.data
.into_iter()
.filter(|m| m.id.to_lowercase().contains(&filter_pattern))
.map(|m| ModelSpec {
name: m.id.clone(),
id: ModelId::Name(m.id.clone(), None),
devices: None,
model_location: None,
lora_location: None,
})
.collect();
Ok(specs)
}
}
pub struct ResponsesModel {
config: ResponsesConfig,
model_id: ModelId,
model_name: String,
api_key: Option<ConfidentialText>,
http_client: Option<Arc<dyn HttpClient>>,
#[allow(dead_code)]
info: crate::backends::openai_provider::OpenAIModelInfo,
pricing: crate::types::base_types::ModelUsageCosting,
cumulative_cost: Arc<std::sync::Mutex<crate::costing::CostAccumulator>>,
}
impl ResponsesModel {
fn build_url(&self, endpoint: &str) -> String {
self.config.build_url(endpoint)
}
fn auth_headers(&self) -> SimpleHeaders {
let mut headers = SimpleHeaders::new();
if let Some(key) = &self.api_key {
headers.insert(
SimpleHeader::AUTHORIZATION,
vec![format!("Bearer {}", key.get())],
);
}
headers.insert(
SimpleHeader::CONTENT_TYPE,
vec![String::from("application/json")],
);
headers
}
fn build_prepared_request(&self, url: &str, body: &str) -> GenerationResult<PreparedRequest> {
let uri =
Uri::parse(url).map_err(|e| GenerationError::Backend(format!("Invalid URL: {e}")))?;
let mut headers = self.auth_headers();
headers.insert(SimpleHeader::ACCEPT, vec![String::from("application/json")]);
Ok(PreparedRequest {
method: SimpleMethod::POST,
url: uri,
headers,
body: SendSafeBody::Text(body.to_string()),
extensions: Extensions::default(),
})
}
fn build_sse_request(&self, url: &str, body: &str) -> GenerationResult<PreparedRequest> {
let uri =
Uri::parse(url).map_err(|e| GenerationError::Backend(format!("Invalid URL: {e}")))?;
let mut headers = self.auth_headers();
headers.insert(
SimpleHeader::ACCEPT,
vec![String::from("text/event-stream")],
);
Ok(PreparedRequest {
method: SimpleMethod::POST,
url: uri,
headers,
body: SendSafeBody::Text(body.to_string()),
extensions: Extensions::default(),
})
}
fn build_request(
&self,
interaction: &ModelInteraction,
params: &ModelParams,
streaming: bool,
) -> ResponseRequest {
let input = build_response_input(interaction);
let instructions = match (&interaction.system_prompt, &interaction.soul) {
(Some(sys), Some(soul)) => Some(format!("{sys}\n\n{soul}")),
(Some(sys), None) => Some(sys.clone()),
(None, Some(soul)) => Some(soul.clone()),
(None, None) => None,
};
let tools = {
let all = flatten_tools(&interaction.tools_shed);
if all.is_empty() {
None
} else {
Some(
all.iter()
.map(|tool| {
let spec = tool.function_spec();
ResponseTool {
tool_type: String::from("function"),
name: spec.name,
description: Some(spec.description),
parameters: Some(spec.parameters),
strict: spec.returns.as_ref().map(|_| true),
}
})
.collect::<Vec<_>>(),
)
}
};
let tool_choice = interaction.tool_choice.as_ref().map(convert_tool_choice);
ResponseRequest {
model: self.model_name.clone(),
input,
instructions,
tools,
tool_choice,
max_output_tokens: if params.max_tokens > 0 {
Some(params.max_tokens)
} else {
None
},
temperature: if params.temperature > 0.0 {
Some(params.temperature)
} else {
None
},
top_p: if params.top_p > 0.0 && params.top_p < 1.0 {
Some(params.top_p)
} else {
None
},
stream: Some(streaming),
truncate: None,
previous_response_id: None,
}
}
fn execute_request<T: for<'de> Deserialize<'de> + Send>(
&self,
url: &str,
body: &str,
) -> GenerationResult<T> {
let mut attempt = 0;
let max_retries = self.config.max_retries;
loop {
let result = self.do_request::<T>(url, body)?;
match result {
Ok(value) => return Ok(value),
Err((status, retry_after, msg)) => {
if attempt >= max_retries || !is_retryable_status(status) {
return Err(GenerationError::Backend(msg));
}
let delay = retry_after.unwrap_or_else(|| exponential_backoff(attempt));
attempt += 1;
thread::sleep(Duration::from_secs(delay));
}
}
}
}
#[allow(clippy::type_complexity, clippy::cast_possible_truncation)]
fn do_request<T: for<'de> Deserialize<'de> + Send>(
&self,
url: &str,
body: &str,
) -> GenerationResult<Result<T, (u16, Option<u64>, String)>> {
let client = self
.http_client
.as_ref()
.ok_or_else(|| GenerationError::Generic("HTTP client not initialized".into()))?;
let req = self.build_prepared_request(url, body)?;
let response = client
.send(req)
.map_err(|e| GenerationError::Backend(format!("Request failed: {e}")))?;
let (status, _headers, body) = response.into_parts();
let status_code: usize = status.into();
let body_text = collect_strings_from_send_safe(body)
.map_err(|e| GenerationError::Generic(format!("Parse error: {e}")))?;
if !(200..=299).contains(&status_code) {
let msg = format!("HTTP {status_code}: {body_text}");
return Ok(Err((status_code as u16, None, msg)));
}
serde_json::from_str(&body_text)
.map(|v| Ok(v))
.map_err(|e| GenerationError::Generic(format!("Parse error: {e}")))
}
}
impl Model for ResponsesModel {
fn spec(&self) -> ModelSpec {
ModelSpec {
name: self.model_name.clone(),
id: self.model_id.clone(),
devices: None,
model_location: None,
lora_location: None,
}
}
fn tool_formatter(&self) -> Box<dyn ToolFormatter> {
Box::new(TextBasedFormatter)
}
fn descriptor(&self) -> Option<ModelProviderDescriptor> {
Some(ModelProviderDescriptor {
id: "openai-responses",
name: "OpenAI Responses",
reasoning: true,
api: crate::types::base_types::ModelAPI::OpenAIResponses,
provider: ModelProviders::OPENAIRESPONSES,
base_url: None,
inputs: crate::types::base_types::MessageType::TextAndImages,
cost: self.pricing,
context_window: 0,
max_tokens: 0,
})
}
fn costing(&self) -> GenerationResult<UsageReport> {
let cost = self.cumulative_cost.lock().unwrap().result();
Ok(UsageReport {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
total_tokens: cost.total_tokens,
cost,
})
}
fn generate(
&self,
interaction: ModelInteraction,
specs: Option<ModelParams>,
) -> GenerationResult<Vec<Messages>> {
let params = specs.unwrap_or_default();
let request = self.build_request(&interaction, ¶ms, false);
let body = serde_json::to_string(&request)
.map_err(|e| GenerationError::Generic(format!("Failed to serialize request: {e}")))?;
let url = self.build_url("responses");
let response: Response = self.execute_request(&url, &body)?;
let message = parse_response(&response, &self.model_id);
if let Messages::Assistant { ref usage, .. } = message {
self.cumulative_cost.lock().unwrap().add(&usage.cost);
}
Ok(vec![message])
}
fn stream(
&self,
interaction: ModelInteraction,
specs: Option<ModelParams>,
) -> GenerationResult<ModelStreamBox> {
let params = specs.unwrap_or_default();
let request = self.build_request(&interaction, ¶ms, true);
let body = serde_json::to_string(&request)
.map_err(|e| GenerationError::Generic(format!("Failed to serialize request: {e}")))?;
let url = self.build_url("responses");
let client = self
.http_client
.as_ref()
.ok_or_else(|| GenerationError::Generic("HTTP client not initialized".into()))?;
let req = self.build_sse_request(&url, &body)?;
let sse_iter = client
.send_sse(req)
.map_err(|e| GenerationError::Backend(format!("SSE request failed: {e}")))?;
Ok(Box::new(ResponsesStream {
inner: sse_iter,
model_id: self.model_id.clone(),
accumulated_text: String::new(),
response: None,
done: false,
}))
}
}
struct ResponsesStream {
inner: BoxedSseIterator,
model_id: ModelId,
accumulated_text: String,
response: Option<Response>,
done: bool,
}
impl Iterator for ResponsesStream {
type Item = Stream<Messages, ModelState>;
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
let item = self.inner.next()?;
match item {
Stream::Next(ref parse_result) => Some(self.process_parse_result(parse_result)),
Stream::Pending(_) => Some(Stream::Pending(ModelState::GeneratingTokens(None))),
Stream::Delayed(d) => Some(Stream::Delayed(d)),
Stream::Init => Some(Stream::Init),
Stream::Ignore => Some(Stream::Ignore),
Stream::Wait => Some(Stream::Wait),
Stream::Spread(items) => {
let mut mapped: Vec<StreamSpread<Messages, ModelState>> = Vec::new();
for item in items {
match item {
StreamSpread::Done(ref inner) => match self.process_parse_result(inner) {
Stream::Next(msg) => mapped.push(StreamSpread::Done(msg)),
Stream::Pending(p) => mapped.push(StreamSpread::Pending(p)),
Stream::Delayed(_)
| Stream::Spread(_)
| Stream::Init
| Stream::Wait
| Stream::Ignore => {}
},
StreamSpread::Pending(_) => {
mapped.push(StreamSpread::Pending(ModelState::GeneratingTokens(None)));
}
}
}
if mapped.is_empty() {
Some(Stream::Ignore)
} else {
Some(Stream::Spread(mapped))
}
}
}
}
}
impl ResponsesStream {
fn process_parse_result(&mut self, parse_result: &ParseResult) -> Stream<Messages, ModelState> {
let Event::Message { data, .. } = &parse_result.event else {
return Stream::Ignore;
};
let Ok(event) = serde_json::from_str::<ResponseEvent>(data) else {
tracing::warn!(data = %data, "Failed to parse SSE chunk JSON in Responses API");
return Stream::Next(Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: self.model_id.clone(),
timestamp: SystemTime::now(),
usage: empty_usage_report(),
content: ModelOutput::Text(TextContent {
content: self.accumulated_text.clone(),
signature: None,
}),
stop_reason: StopReason::Error,
provider: ModelProviders::OPENAIRESPONSES,
error_detail: Some(format!("Failed to parse SSE chunk: {data}")),
signature: None,
metadata: None,
});
};
match event {
ResponseEvent::ResponseOutputTextDelta { delta, .. } => {
self.accumulated_text.push_str(&delta);
Stream::Next(Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: self.model_id.clone(),
timestamp: SystemTime::now(),
usage: empty_usage_report(),
content: ModelOutput::Text(TextContent {
content: self.accumulated_text.clone(),
signature: None,
}),
stop_reason: StopReason::Stop,
provider: ModelProviders::OPENAIRESPONSES,
error_detail: None,
signature: None,
metadata: None,
})
}
ResponseEvent::ResponseCompleted { response }
| ResponseEvent::ResponseFailed { response } => {
self.response = Some(response);
self.done = true;
Stream::Next(self.build_final_message())
}
_ => Stream::Ignore,
}
}
fn build_final_message(&self) -> Messages {
let Some(response) = &self.response else {
return Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: self.model_id.clone(),
timestamp: SystemTime::now(),
usage: empty_usage_report(),
content: ModelOutput::Text(TextContent {
content: self.accumulated_text.clone(),
signature: None,
}),
stop_reason: StopReason::Error,
provider: ModelProviders::OPENAIRESPONSES,
error_detail: Some("No response received".into()),
signature: None,
metadata: None,
};
};
#[allow(clippy::cast_precision_loss)]
let usage = response
.usage
.as_ref()
.map_or_else(empty_usage_report, |u| UsageReport {
input: u.input_tokens as f64,
output: u.output_tokens as f64,
cache_read: u.reasoning_tokens.map_or(0.0, |r| r as f64),
cache_write: 0.0,
total_tokens: u.total_tokens as f64,
cost: UsageCosting {
currency: String::from("USD"),
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
total_tokens: u.total_tokens as f64,
status: CostStatus::Actual,
},
});
let stop_reason = match response.status.as_str() {
"completed" | "in_progress" => StopReason::Stop,
"failed" => StopReason::Error,
other => StopReason::Message(other.to_string()),
};
let content = extract_output(&response.output);
let metadata = build_response_metadata(&response.output);
Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: self.model_id.clone(),
timestamp: SystemTime::now(),
usage,
content,
stop_reason,
provider: ModelProviders::OPENAIRESPONSES,
error_detail: response.error.as_ref().map(|e| e.message.clone()),
signature: None,
metadata,
}
}
}
#[must_use]
pub fn build_response_input(interaction: &ModelInteraction) -> ResponseInput {
let items: Vec<ResponseInputItem> = interaction
.messages
.iter()
.filter_map(|msg| match msg {
Messages::User { content, .. } => match content {
crate::types::base_types::UserModelContent::Text(tc) => {
Some(ResponseInputItem::Message {
role: String::from("user"),
content: ResponseInputContent::Text(tc.content.clone()),
})
}
crate::types::base_types::UserModelContent::Image(img) => {
let mime_str = match img.mime_type {
#[allow(clippy::match_same_arms)]
crate::types::base_types::MimeType::ImagePng => "image/png",
crate::types::base_types::MimeType::ImageJpeg => "image/jpeg",
crate::types::base_types::MimeType::ImageGif => "image/gif",
crate::types::base_types::MimeType::ImageWebp => "image/webp",
_ => "image/png",
};
let data_url = format!("data:{};base64,{}", mime_str, img.b64);
Some(ResponseInputItem::Message {
role: String::from("user"),
content: ResponseInputContent::Parts(vec![
ResponseInputContentPart::InputImage {
image_url: data_url,
},
]),
})
}
},
Messages::Assistant { content, .. } => match content {
ModelOutput::Text(tc) => Some(ResponseInputItem::Message {
role: String::from("assistant"),
content: ResponseInputContent::Text(tc.content.clone()),
}),
ModelOutput::ThinkingContent { thinking, .. } => Some(ResponseInputItem::Message {
role: String::from("assistant"),
content: ResponseInputContent::Text(thinking.clone()),
}),
ModelOutput::ToolCall {
name, arguments, ..
} => Some(ResponseInputItem::FunctionCallOutput {
call_id: name.clone(),
output: arguments
.as_ref()
.map(|a| serde_json::to_string(a).unwrap_or_default())
.unwrap_or_default(),
}),
_ => None,
},
Messages::ToolResult {
tool_call_id,
name,
content,
..
} => {
let text = match content {
crate::types::base_types::UserModelContent::Text(tc) => tc.content.clone(),
crate::types::base_types::UserModelContent::Image(_) => String::from("[Image]"),
};
Some(ResponseInputItem::FunctionCallOutput {
call_id: tool_call_id.clone(),
output: format!("[{name}] {text}"),
})
}
})
.collect();
if items.is_empty() {
ResponseInput::Text(String::new())
} else {
ResponseInput::Items(items)
}
}
fn extract_output(output: &[ResponseOutputItem]) -> ModelOutput {
for item in output {
match item {
ResponseOutputItem::Message { content, .. } => {
let text: String = content
.iter()
.map(|c| {
let ResponseOutputContent::OutputText { text } = c;
text.clone()
})
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
return ModelOutput::Text(TextContent {
content: text,
signature: None,
});
}
}
ResponseOutputItem::FunctionCall {
id,
name,
arguments,
..
} => {
let args: Option<HashMap<String, crate::types::base_types::ArgType>> =
serde_json::from_str(arguments)
.ok()
.map(|v: serde_json::Value| {
v.as_object()
.map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), json_value_to_arg_type(v)))
.collect()
})
.unwrap_or_default()
});
return ModelOutput::ToolCall {
id: id.clone(),
name: name.clone(),
arguments: args,
signature: None,
depends_on: Vec::new(),
execution_hint: crate::types::base_types::ExecutionHint::default(),
};
}
ResponseOutputItem::Reasoning { content, .. } => {
return ModelOutput::ThinkingContent {
thinking: content.clone(),
signature: None,
};
}
}
}
ModelOutput::Text(TextContent {
content: String::new(),
signature: None,
})
}
fn build_response_metadata(output: &[ResponseOutputItem]) -> Option<Vec<GenerationMetadata>> {
let has_reasoning = output
.iter()
.any(|item| matches!(item, ResponseOutputItem::Reasoning { .. }));
if has_reasoning {
Some(vec![GenerationMetadata::Timing {
total_ms: 0,
time_to_first_ms: None,
tokens_per_sec: None,
}])
} else {
None
}
}
fn parse_response(response: &Response, model_id: &ModelId) -> Messages {
#[allow(clippy::cast_precision_loss)]
let usage = response
.usage
.as_ref()
.map_or_else(empty_usage_report, |u| UsageReport {
input: u.input_tokens as f64,
output: u.output_tokens as f64,
cache_read: u.reasoning_tokens.map_or(0.0, |r| r as f64),
cache_write: 0.0,
total_tokens: u.total_tokens as f64,
cost: UsageCosting {
currency: String::from("USD"),
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
total_tokens: u.total_tokens as f64,
status: CostStatus::Actual,
},
});
let stop_reason = match response.status.as_str() {
"completed" | "in_progress" => StopReason::Stop,
"failed" => StopReason::Error,
other => StopReason::Message(other.to_string()),
};
let content = extract_output(&response.output);
let metadata = build_response_metadata(&response.output);
Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: model_id.clone(),
timestamp: SystemTime::now(),
usage,
content,
stop_reason,
provider: ModelProviders::OPENAIRESPONSES,
error_detail: response.error.as_ref().map(|e| e.message.clone()),
signature: None,
metadata,
}
}
#[must_use]
pub fn is_retryable_status(status: u16) -> bool {
status == 429 || (500..=503).contains(&status)
}
#[must_use]
pub fn exponential_backoff(attempt: u32) -> u64 {
let base_secs: u64 = 1 << attempt.min(5);
base_secs.min(30)
}
fn extract_retry_after(headers: &SimpleHeaders) -> Option<u64> {
let header = SimpleHeader::from("Retry-After".to_string());
headers
.get(&header)
.and_then(|values| values.first())
.and_then(|v| v.parse::<u64>().ok())
}
fn convert_tool_choice(choice: &crate::types::base_types::ToolChoice) -> ResponseToolChoice {
match choice {
crate::types::base_types::ToolChoice::Auto => {
ResponseToolChoice::Simple(String::from("auto"))
}
crate::types::base_types::ToolChoice::None => {
ResponseToolChoice::Simple(String::from("none"))
}
crate::types::base_types::ToolChoice::Required => {
ResponseToolChoice::Simple(String::from("required"))
}
crate::types::base_types::ToolChoice::Function(f) => ResponseToolChoice::Function {
r#type: String::from("function"),
function: ResponseToolChoiceFunction {
name: f.function.name.clone(),
},
},
}
}