use std::collections::{HashMap, HashSet};
use anyhow::{Result, anyhow};
use serde_json::{Value, json};
use crate::models::calculate_cost;
use crate::types::{
AssistantContentBlock, AssistantMessage, AssistantMessageEvent, ContentBlock, Context, Message, Model, StopReason,
TextContent, TextSignatureV1, ThinkingContent, ToolCall, Usage, UserContent,
};
use crate::utils::event_stream::AssistantMessageEventStream;
use crate::utils::hash::short_hash;
use crate::utils::json_parse::parse_streaming_json;
use crate::utils::sanitize_unicode::sanitize_surrogates;
use super::transform_messages::transform_messages;
fn encode_text_signature_v1(id: &str, phase: Option<&str>) -> String {
let mut payload = serde_json::json!({ "v": 1, "id": id });
if let Some(phase) = phase {
payload["phase"] = json!(phase);
}
payload.to_string()
}
fn parse_text_signature(signature: Option<&str>) -> Option<(String, Option<String>)> {
let sig = signature?;
if sig.starts_with('{')
&& let Ok(parsed) = serde_json::from_str::<TextSignatureV1>(sig)
&& parsed.v == 1
{
return Some((parsed.id, parsed.phase));
}
Some((sig.to_string(), None))
}
type ServiceTierResolver = Box<dyn Fn(Option<&str>, Option<&str>) -> Option<String> + Send + Sync>;
type ServiceTierPricingApplier = Box<dyn Fn(&mut Usage, Option<&str>) + Send + Sync>;
pub struct OpenAIResponsesStreamOptions {
pub service_tier: Option<String>,
pub resolve_service_tier: Option<ServiceTierResolver>,
pub apply_service_tier_pricing: Option<ServiceTierPricingApplier>,
}
pub struct ConvertResponsesMessagesOptions {
pub include_system_prompt: bool,
}
pub fn convert_responses_messages(
model: &Model,
context: &Context,
allowed_tool_call_providers: &HashSet<String>,
options: Option<ConvertResponsesMessagesOptions>,
) -> Vec<Value> {
let include_system = options.map(|o| o.include_system_prompt).unwrap_or(true);
let mut messages = Vec::new();
let normalize_id_part = |part: &str| -> String {
let sanitized: String = part
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
let normalized: String = sanitized.chars().take(64).collect();
normalized.trim_end_matches('_').to_string()
};
let normalize_tool_call_id = |id: &str, source: &AssistantMessage| -> String {
if !allowed_tool_call_providers.contains(&model.provider) {
return normalize_id_part(id);
}
if !id.contains('|') {
return normalize_id_part(id);
}
let parts: Vec<&str> = id.splitn(2, '|').collect();
let call_id = normalize_id_part(parts[0]);
let item_id = parts.get(1).copied().unwrap_or("");
let is_foreign = source.provider != model.provider || source.api != model.api;
let mut normalized_item_id = if is_foreign {
format!("fc_{}", short_hash(item_id))
} else {
normalize_id_part(item_id)
};
if normalized_item_id.len() > 64 {
normalized_item_id = normalized_item_id.chars().take(64).collect();
}
if !normalized_item_id.starts_with("fc_") {
normalized_item_id = normalize_id_part(&format!("fc_{normalized_item_id}"));
}
format!("{call_id}|{normalized_item_id}")
};
let transformed = transform_messages(context.messages.clone(), model, |id, _m, src| {
normalize_tool_call_id(id, src)
});
if include_system && let Some(sp) = &context.system_prompt {
let role = if model.reasoning { "developer" } else { "system" };
messages.push(json!({ "role": role, "content": sanitize_surrogates(sp) }));
}
for (msg_index, msg) in transformed.into_iter().enumerate() {
match msg {
Message::User { content, .. } => match content {
UserContent::Text(text) => {
messages.push(json!({
"role": "user",
"content": [{ "type": "input_text", "text": sanitize_surrogates(&text) }]
}));
}
UserContent::Blocks(blocks) => {
let content: Vec<Value> = blocks
.into_iter()
.map(|b| match b {
ContentBlock::Text { text } => {
json!({ "type": "input_text", "text": sanitize_surrogates(&text) })
}
ContentBlock::Image { data, mime_type } => json!({
"type": "input_image",
"detail": "auto",
"image_url": format!("data:{mime_type};base64,{data}")
}),
})
.collect();
if !content.is_empty() {
messages.push(json!({ "role": "user", "content": content }));
}
}
},
Message::Assistant(assistant) => {
let is_different_model =
assistant.model != model.id && assistant.provider == model.provider && assistant.api == model.api;
let mut output = Vec::new();
let mut text_block_index = 0usize;
for block in &assistant.content {
match block {
AssistantContentBlock::Thinking(t) => {
if let Some(sig) = &t.thinking_signature
&& let Ok(item) = serde_json::from_str::<Value>(sig)
{
output.push(item);
}
}
AssistantContentBlock::Text(text) => {
let parsed = parse_text_signature(text.text_signature.as_deref());
let fallback = if text_block_index == 0 {
format!("msg_pi_{msg_index}")
} else {
format!("msg_pi_{msg_index}_{text_block_index}")
};
text_block_index += 1;
let mut msg_id = parsed.as_ref().map(|(id, _)| id.clone()).unwrap_or(fallback);
if msg_id.len() > 64 {
msg_id = format!("msg_{}", short_hash(&msg_id));
}
let mut item = json!({
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": sanitize_surrogates(&text.text), "annotations": [] }],
"status": "completed",
"id": msg_id
});
if let Some((_, Some(phase))) = parsed {
item["phase"] = json!(phase);
}
output.push(item);
}
AssistantContentBlock::ToolCall(tc) => {
let parts: Vec<&str> = tc.id.splitn(2, '|').collect();
let call_id = parts[0];
let mut item_id = parts.get(1).map(|s| s.to_string());
if is_different_model && item_id.as_deref().map(|s| s.starts_with("fc_")) == Some(true) {
item_id = None;
}
output.push(json!({
"type": "function_call",
"id": item_id,
"call_id": call_id,
"name": tc.name,
"arguments": tc.arguments.to_string()
}));
}
}
}
if !output.is_empty() {
messages.extend(output);
}
}
Message::ToolResult {
tool_call_id, content, ..
} => {
let text_result: String = content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let has_images = content.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
let has_text = !text_result.is_empty();
let call_id = tool_call_id.split('|').next().unwrap_or(&tool_call_id);
let output_val = if has_images && model.input.iter().any(|i| i == "image") {
let mut parts = Vec::new();
if has_text {
parts.push(json!({ "type": "input_text", "text": sanitize_surrogates(&text_result) }));
}
for b in &content {
if let ContentBlock::Image { data, mime_type } = b {
parts.push(json!({
"type": "input_image",
"detail": "auto",
"image_url": format!("data:{mime_type};base64,{data}")
}));
}
}
Value::Array(parts)
} else {
json!(sanitize_surrogates(if has_text {
text_result.as_str()
} else if has_images {
"(see attached image)"
} else {
"(no tool output)"
}))
};
messages.push(json!({
"type": "function_call_output",
"call_id": call_id,
"output": output_val
}));
}
}
}
messages
}
pub fn convert_responses_tools(tools: &[crate::types::Tool], strict: Option<bool>) -> Vec<Value> {
let strict = strict.unwrap_or(false);
tools
.iter()
.map(|tool| {
json!({
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
"strict": strict
})
})
.collect()
}
struct StreamingToolCall {
tool_call: ToolCall,
partial_json: String,
}
enum OutputSlot {
Thinking {
block: ThinkingContent,
content_index: usize,
},
Text {
block: TextContent,
content_index: usize,
},
ToolCall {
block: StreamingToolCall,
content_index: usize,
},
}
fn create_output_slot(
output_index: usize,
item: &Value,
output: &mut AssistantMessage,
stream: &AssistantMessageEventStream,
output_slots: &mut HashMap<usize, OutputSlot>,
) {
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
match item_type {
"reasoning" => {
let block = ThinkingContent::new("");
output.content.push(AssistantContentBlock::Thinking(block.clone()));
let content_index = output.content.len() - 1;
stream.push(AssistantMessageEvent::ThinkingStart {
content_index,
partial: output.clone(),
});
output_slots.insert(output_index, OutputSlot::Thinking { block, content_index });
}
"message" => {
let block = TextContent::new("");
output.content.push(AssistantContentBlock::Text(block.clone()));
let content_index = output.content.len() - 1;
stream.push(AssistantMessageEvent::TextStart {
content_index,
partial: output.clone(),
});
output_slots.insert(output_index, OutputSlot::Text { block, content_index });
}
"function_call" => {
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = item.get("arguments").and_then(|v| v.as_str()).unwrap_or("");
let block = StreamingToolCall {
tool_call: ToolCall::new(format!("{call_id}|{id}"), name, parse_streaming_json(Some(args))),
partial_json: args.to_string(),
};
output
.content
.push(AssistantContentBlock::ToolCall(block.tool_call.clone()));
let content_index = output.content.len() - 1;
stream.push(AssistantMessageEvent::ToolcallStart {
content_index,
partial: output.clone(),
});
output_slots.insert(output_index, OutputSlot::ToolCall { block, content_index });
}
_ => {}
}
}
#[derive(Default)]
pub struct ResponsesStreamState {
pub saw_terminal: bool,
output_slots: HashMap<usize, OutputSlot>,
}
pub fn process_responses_stream_event(
event: &Value,
state: &mut ResponsesStreamState,
output: &mut AssistantMessage,
stream: &AssistantMessageEventStream,
model: &Model,
options: Option<&OpenAIResponsesStreamOptions>,
) -> Result<()> {
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
match event_type {
"response.created" => {
if let Some(id) = event.pointer("/response/id").and_then(|v| v.as_str()) {
output.response_id = Some(id.to_string());
}
}
"response.output_item.added" => {
let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if let Some(item) = event.get("item") {
create_output_slot(idx, item, output, stream, &mut state.output_slots);
}
}
"response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let delta = event.get("delta").and_then(|v| v.as_str()).unwrap_or("");
if let Some(OutputSlot::Thinking { block, content_index }) = state.output_slots.get_mut(&idx) {
block.thinking.push_str(delta);
if let Some(AssistantContentBlock::Thinking(t)) = output.content.get_mut(*content_index) {
t.thinking = block.thinking.clone();
}
stream.push(AssistantMessageEvent::ThinkingDelta {
content_index: *content_index,
delta: delta.to_string(),
partial: output.clone(),
});
}
}
"response.output_text.delta" | "response.refusal.delta" => {
let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let delta = event.get("delta").and_then(|v| v.as_str()).unwrap_or("");
if let Some(OutputSlot::Text { block, content_index }) = state.output_slots.get_mut(&idx) {
block.text.push_str(delta);
if let Some(AssistantContentBlock::Text(t)) = output.content.get_mut(*content_index) {
t.text = block.text.clone();
}
stream.push(AssistantMessageEvent::TextDelta {
content_index: *content_index,
delta: delta.to_string(),
partial: output.clone(),
});
}
}
"response.function_call_arguments.delta" => {
let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let delta = event.get("delta").and_then(|v| v.as_str()).unwrap_or("");
if let Some(OutputSlot::ToolCall { block, content_index }) = state.output_slots.get_mut(&idx) {
block.partial_json.push_str(delta);
block.tool_call.arguments = parse_streaming_json(Some(&block.partial_json));
if let Some(AssistantContentBlock::ToolCall(tc)) = output.content.get_mut(*content_index) {
tc.arguments = block.tool_call.arguments.clone();
}
stream.push(AssistantMessageEvent::ToolcallDelta {
content_index: *content_index,
delta: delta.to_string(),
partial: output.clone(),
});
}
}
"response.output_item.done" => {
let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if let Some(item) = event.get("item") {
match state.output_slots.get_mut(&idx) {
Some(OutputSlot::Thinking { block, content_index }) => {
if let Some(summary) = item.get("summary").and_then(|v| v.as_array()) {
let text: String = summary
.iter()
.filter_map(|s| s.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n\n");
if !text.is_empty() {
block.thinking = text;
}
}
block.thinking_signature = Some(item.to_string());
if let Some(AssistantContentBlock::Thinking(t)) = output.content.get_mut(*content_index) {
t.thinking = block.thinking.clone();
t.thinking_signature = block.thinking_signature.clone();
}
stream.push(AssistantMessageEvent::ThinkingEnd {
content_index: *content_index,
content: block.thinking.clone(),
partial: output.clone(),
});
state.output_slots.remove(&idx);
}
Some(OutputSlot::Text { block, content_index }) => {
if let Some(content) = item.get("content").and_then(|v| v.as_array()) {
block.text = content
.iter()
.filter_map(|c| {
if c.get("type")?.as_str()? == "output_text" {
c.get("text")?.as_str()
} else {
c.get("refusal")?.as_str()
}
})
.collect::<String>();
}
if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
let phase = item.get("phase").and_then(|v| v.as_str());
block.text_signature = Some(encode_text_signature_v1(id, phase));
}
if let Some(AssistantContentBlock::Text(t)) = output.content.get_mut(*content_index) {
t.text = block.text.clone();
t.text_signature = block.text_signature.clone();
}
stream.push(AssistantMessageEvent::TextEnd {
content_index: *content_index,
content: block.text.clone(),
partial: output.clone(),
});
state.output_slots.remove(&idx);
}
Some(OutputSlot::ToolCall { block, content_index }) => {
if let Some(args) = item.get("arguments").and_then(|v| v.as_str()) {
block.partial_json = args.to_string();
}
block.tool_call.arguments = parse_streaming_json(Some(&block.partial_json));
if let Some(AssistantContentBlock::ToolCall(tc)) = output.content.get_mut(*content_index) {
tc.arguments = block.tool_call.arguments.clone();
}
stream.push(AssistantMessageEvent::ToolcallEnd {
content_index: *content_index,
tool_call: block.tool_call.clone(),
partial: output.clone(),
});
state.output_slots.remove(&idx);
}
None => {
create_output_slot(idx, item, output, stream, &mut state.output_slots);
}
}
}
}
"response.completed" | "response.incomplete" => {
state.saw_terminal = true;
if let Some(response) = event.get("response") {
if let Some(id) = response.get("id").and_then(|v| v.as_str()) {
output.response_id = Some(id.to_string());
}
if let Some(usage) = response.get("usage") {
let cached = usage
.pointer("/input_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let input = usage.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
output.usage.input = input.saturating_sub(cached);
output.usage.output = output_tokens;
output.usage.cache_read = cached;
output.usage.reasoning = usage
.pointer("/output_tokens_details/reasoning_tokens")
.and_then(|v| v.as_u64());
output.usage.total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
calculate_cost(model, &mut output.usage);
if let Some(opts) = &options
&& let Some(apply) = &opts.apply_service_tier_pricing
{
let tier = response.get("service_tier").and_then(|v| v.as_str());
apply(&mut output.usage, tier);
}
}
output.stop_reason = map_stop_reason(response.get("status").and_then(|v| v.as_str()));
if output.content.iter().any(|b| b.is_tool_call()) && output.stop_reason == StopReason::Stop {
output.stop_reason = StopReason::ToolUse;
}
}
}
"error" => {
let code = event.get("code").and_then(|v| v.as_str()).unwrap_or("unknown");
let message = event.get("message").and_then(|v| v.as_str()).unwrap_or("unknown");
return Err(anyhow!("Error Code {code}: {message}"));
}
"response.failed" => {
let err = event.pointer("/response/error");
let code = err
.and_then(|e| e.get("code"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let message = err
.and_then(|e| e.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow!("{code}: {message}"));
}
_ => {}
}
Ok(())
}
pub async fn process_responses_stream(
events: Vec<Value>,
output: &mut AssistantMessage,
stream: &AssistantMessageEventStream,
model: &Model,
options: Option<OpenAIResponsesStreamOptions>,
) -> Result<()> {
let mut state = ResponsesStreamState::default();
for event in events {
process_responses_stream_event(&event, &mut state, output, stream, model, options.as_ref())?;
}
if !state.saw_terminal {
return Err(anyhow!(
"OpenAI Responses stream ended before a terminal response event"
));
}
Ok(())
}
fn map_stop_reason(status: Option<&str>) -> StopReason {
match status {
Some("completed") => StopReason::Stop,
Some("incomplete") => StopReason::Length,
Some("failed") | Some("cancelled") => StopReason::Error,
_ => StopReason::Stop,
}
}