use super::model::{ApiProtocol, OpenAiCompat};
use super::responses_stream::{Flow, ResponsesStreamState, CALL_IDS_KEY, MESSAGE_ID_KEY};
use super::traits::*;
use crate::types::*;
use futures::StreamExt;
use serde_json::{json, Value};
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, warn};
pub(crate) const INCLUDE_ENCRYPTED_REASONING: &str = "reasoning.encrypted_content";
pub(crate) fn build_request_body(config: &StreamConfig, protocol: ApiProtocol) -> Value {
let default_compat = OpenAiCompat::default();
let compat = config
.model_config
.as_ref()
.and_then(|m| m.compat.as_ref())
.unwrap_or(&default_compat);
let effort = compat.openai_reasoning_effort(&config.model, config.thinking_level);
let reasoning_model = compat.encrypted_reasoning
&& (config.model_config.as_ref().is_some_and(|m| m.reasoning) || effort.is_some());
let replay = reasoning_model.then_some(protocol);
let mut input: Vec<Value> = Vec::new();
for msg in &config.messages {
match msg {
Message::User { content, .. } => {
let user_content = input_parts(content);
if user_content.len() == 1 && user_content[0]["type"] == "input_text" {
input.push(json!({
"role": "user",
"content": user_content[0]["text"].as_str().unwrap_or(""),
}));
} else {
input.push(json!({
"role": "user",
"content": user_content,
}));
}
}
Message::Assistant { content, .. } => assistant_items(content, replay, &mut input),
Message::ToolResult {
tool_call_id,
content,
..
} => {
let output_val = if content.iter().any(|c| matches!(c, Content::Image { .. })) {
json!(input_parts(content))
} else {
let text = content
.iter()
.find_map(|c| match c {
Content::Text { text } => Some(text.clone()),
_ => None,
})
.unwrap_or_default();
json!(text)
};
input.push(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": output_val,
}));
}
}
}
let mut body = json!({
"model": config.model,
"stream": true,
"input": input,
});
if !config.system_prompt.is_empty() {
body["instructions"] = json!(config.system_prompt);
}
if let Some(max) = config.max_tokens {
body["max_output_tokens"] = json!(max);
}
if !config.tools.is_empty() {
let tools: Vec<Value> = config
.tools
.iter()
.map(|t| {
json!({
"type": "function",
"name": t.name,
"description": t.description,
"parameters": t.parameters,
})
})
.collect();
body["tools"] = json!(tools);
}
if let Some(effort) = effort {
body["reasoning"] = json!({"effort": effort});
}
if reasoning_model {
body["include"] = json!([INCLUDE_ENCRYPTED_REASONING]);
}
if let Some(key) = config.cache_session_key() {
body["prompt_cache_key"] = json!(key);
}
if let Some(temp) = config.temperature {
body["temperature"] = json!(temp);
}
body
}
fn input_parts(content: &[Content]) -> Vec<Value> {
content
.iter()
.filter(|c| !matches!(c, Content::Text { text } if text.is_empty()))
.filter_map(|c| match c {
Content::Text { text } => Some(json!({
"type": "input_text",
"text": text,
})),
Content::Image { data, mime_type } => Some(json!({
"type": "input_image",
"image_url": format!("data:{};base64,{}", mime_type, data),
})),
_ => None,
})
.collect()
}
fn assistant_items(content: &[Content], replay: Option<ApiProtocol>, input: &mut Vec<Value>) {
let mut pending_reasoning: Vec<Value> = Vec::new();
let mut call_ids: HashMap<String, String> = HashMap::new();
let mut message_id: Option<String> = None;
for c in content {
match c {
Content::Thinking {
redacted: Some(_), ..
} => {
let Some(target) = replay else {
debug!("Responses: target model takes no reasoning items; not replayed");
continue;
};
match c.redacted_for(target).and_then(reasoning_item) {
Some(replayed) => {
pending_reasoning.push(replayed.item);
call_ids.extend(replayed.call_ids);
message_id = replayed.message_id;
}
None => debug!(
"Responses ({target}): skipping encrypted reasoning from another \
provider (or unreadable)"
),
}
}
Content::Text { text } if text.is_empty() => {}
Content::Text { text } => {
let paired = message_id.is_some();
release_reasoning(&mut pending_reasoning, paired, input);
let mut item = json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": text}],
});
if let Some(id) = message_id.take() {
item["id"] = json!(id);
}
input.push(item);
}
Content::ToolCall {
id,
name,
arguments,
..
} => {
let paired = call_ids.contains_key(id);
release_reasoning(&mut pending_reasoning, paired, input);
let mut item = json!({
"type": "function_call",
"call_id": id,
"name": name,
"arguments": arguments.to_string(),
});
if let Some(item_id) = call_ids.get(id) {
item["id"] = json!(item_id);
}
input.push(item);
}
_ => {}
}
}
if !pending_reasoning.is_empty() {
debug!(
"Responses: {} trailing reasoning item(s) with no output after them; not replayed",
pending_reasoning.len()
);
}
}
fn release_reasoning(pending: &mut Vec<Value>, paired: bool, input: &mut Vec<Value>) {
if pending.is_empty() {
return;
}
if paired {
input.append(pending);
} else {
warn!(
"Responses: {} reasoning item(s) whose following output item has no recorded id; \
not replayed",
pending.len()
);
pending.clear();
}
}
struct ReplayedReasoning {
item: Value,
call_ids: HashMap<String, String>,
message_id: Option<String>,
}
fn reasoning_item(data: &str) -> Option<ReplayedReasoning> {
let stored: Value = serde_json::from_str(data).ok()?;
let id = stored.get("id")?.as_str()?;
let encrypted = stored.get("encrypted_content")?.as_str()?;
let summary = match stored.get("summary") {
Some(Value::Array(parts)) => Value::Array(parts.clone()),
_ => json!([]),
};
let call_ids = stored
.get(CALL_IDS_KEY)
.and_then(Value::as_object)
.map(|ids| {
ids.iter()
.filter_map(|(call_id, id)| {
let id = id.as_str().filter(|id| !id.is_empty())?;
Some((call_id.clone(), id.to_string()))
})
.collect()
})
.unwrap_or_default();
let message_id = stored
.get(MESSAGE_ID_KEY)
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
Some(ReplayedReasoning {
item: json!({
"type": "reasoning",
"id": id,
"summary": summary,
"encrypted_content": encrypted,
}),
call_ids,
message_id,
})
}
pub(crate) async fn stream_response(
request: reqwest::RequestBuilder,
label: &'static str,
protocol: ApiProtocol,
config: &StreamConfig,
provider: &str,
tx: mpsc::UnboundedSender<StreamEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<Message, ProviderError> {
let mut es = super::sse::open_event_source(request)?;
let mut state = ResponsesStreamState::new(label, protocol);
let _ = tx.send(StreamEvent::Start);
loop {
tokio::select! {
_ = cancel.cancelled() => {
es.close();
return Err(ProviderError::Cancelled);
}
event = es.next() => {
match event {
None => break,
Some(Ok(reqwest_eventsource::Event::Open)) => {}
Some(Ok(reqwest_eventsource::Event::Message(msg))) => {
if state.handle(&msg.event, &msg.data, &tx)? == Flow::Done {
break;
}
}
Some(Err(e)) => {
let provider_err = classify_eventsource_error(e).await;
warn!("{label} SSE error: {provider_err}");
return Err(provider_err);
}
}
}
}
}
let error_message = state.error_message();
let (content, usage, stop_reason) = state.finish(&tx);
let message = Message::Assistant {
content,
stop_reason,
model: config.model.clone(),
provider: provider.to_string(),
usage,
timestamp: now_ms(),
error_message,
};
let _ = tx.send(StreamEvent::Done {
message: message.clone(),
});
Ok(message)
}