1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Result, anyhow};
4use serde_json::{Value, json};
5
6use crate::models::calculate_cost;
7use crate::types::{
8 AssistantContentBlock, AssistantMessage, AssistantMessageEvent, ContentBlock, Context, Message, Model, StopReason,
9 TextContent, TextSignatureV1, ThinkingContent, ToolCall, Usage, UserContent,
10};
11use crate::utils::event_stream::AssistantMessageEventStream;
12use crate::utils::hash::short_hash;
13use crate::utils::json_parse::parse_streaming_json;
14use crate::utils::sanitize_unicode::sanitize_surrogates;
15
16use super::transform_messages::transform_messages;
17
18fn encode_text_signature_v1(id: &str, phase: Option<&str>) -> String {
19 let mut payload = serde_json::json!({ "v": 1, "id": id });
20 if let Some(phase) = phase {
21 payload["phase"] = json!(phase);
22 }
23 payload.to_string()
24}
25
26fn parse_text_signature(signature: Option<&str>) -> Option<(String, Option<String>)> {
27 let sig = signature?;
28 if sig.starts_with('{')
29 && let Ok(parsed) = serde_json::from_str::<TextSignatureV1>(sig)
30 && parsed.v == 1
31 {
32 return Some((parsed.id, parsed.phase));
33 }
34 Some((sig.to_string(), None))
35}
36
37type ServiceTierResolver = Box<dyn Fn(Option<&str>, Option<&str>) -> Option<String> + Send + Sync>;
38type ServiceTierPricingApplier = Box<dyn Fn(&mut Usage, Option<&str>) + Send + Sync>;
39
40pub struct OpenAIResponsesStreamOptions {
41 pub service_tier: Option<String>,
42 pub resolve_service_tier: Option<ServiceTierResolver>,
43 pub apply_service_tier_pricing: Option<ServiceTierPricingApplier>,
44}
45
46pub struct ConvertResponsesMessagesOptions {
47 pub include_system_prompt: bool,
48}
49
50pub fn convert_responses_messages(
51 model: &Model,
52 context: &Context,
53 allowed_tool_call_providers: &HashSet<String>,
54 options: Option<ConvertResponsesMessagesOptions>,
55) -> Vec<Value> {
56 let include_system = options.map(|o| o.include_system_prompt).unwrap_or(true);
57 let mut messages = Vec::new();
58
59 let normalize_id_part = |part: &str| -> String {
60 let sanitized: String = part
61 .chars()
62 .map(|c| {
63 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
64 c
65 } else {
66 '_'
67 }
68 })
69 .collect();
70 let normalized: String = sanitized.chars().take(64).collect();
71 normalized.trim_end_matches('_').to_string()
72 };
73
74 let normalize_tool_call_id = |id: &str, source: &AssistantMessage| -> String {
75 if !allowed_tool_call_providers.contains(&model.provider) {
76 return normalize_id_part(id);
77 }
78 if !id.contains('|') {
79 return normalize_id_part(id);
80 }
81 let parts: Vec<&str> = id.splitn(2, '|').collect();
82 let call_id = normalize_id_part(parts[0]);
83 let item_id = parts.get(1).copied().unwrap_or("");
84 let is_foreign = source.provider != model.provider || source.api != model.api;
85 let mut normalized_item_id = if is_foreign {
86 format!("fc_{}", short_hash(item_id))
87 } else {
88 normalize_id_part(item_id)
89 };
90 if normalized_item_id.len() > 64 {
91 normalized_item_id = normalized_item_id.chars().take(64).collect();
92 }
93 if !normalized_item_id.starts_with("fc_") {
94 normalized_item_id = normalize_id_part(&format!("fc_{normalized_item_id}"));
95 }
96 format!("{call_id}|{normalized_item_id}")
97 };
98
99 let transformed = transform_messages(context.messages.clone(), model, |id, _m, src| {
100 normalize_tool_call_id(id, src)
101 });
102
103 if include_system && let Some(sp) = &context.system_prompt {
104 let role = if model.reasoning { "developer" } else { "system" };
105 messages.push(json!({ "role": role, "content": sanitize_surrogates(sp) }));
106 }
107
108 for (msg_index, msg) in transformed.into_iter().enumerate() {
109 match msg {
110 Message::User { content, .. } => match content {
111 UserContent::Text(text) => {
112 messages.push(json!({
113 "role": "user",
114 "content": [{ "type": "input_text", "text": sanitize_surrogates(&text) }]
115 }));
116 }
117 UserContent::Blocks(blocks) => {
118 let content: Vec<Value> = blocks
119 .into_iter()
120 .map(|b| match b {
121 ContentBlock::Text { text } => {
122 json!({ "type": "input_text", "text": sanitize_surrogates(&text) })
123 }
124 ContentBlock::Image { data, mime_type } => json!({
125 "type": "input_image",
126 "detail": "auto",
127 "image_url": format!("data:{mime_type};base64,{data}")
128 }),
129 })
130 .collect();
131 if !content.is_empty() {
132 messages.push(json!({ "role": "user", "content": content }));
133 }
134 }
135 },
136 Message::Assistant(assistant) => {
137 let is_different_model =
138 assistant.model != model.id && assistant.provider == model.provider && assistant.api == model.api;
139 let mut output = Vec::new();
140 let mut text_block_index = 0usize;
141 for block in &assistant.content {
142 match block {
143 AssistantContentBlock::Thinking(t) => {
144 if let Some(sig) = &t.thinking_signature
145 && let Ok(item) = serde_json::from_str::<Value>(sig)
146 {
147 output.push(item);
148 }
149 }
150 AssistantContentBlock::Text(text) => {
151 let parsed = parse_text_signature(text.text_signature.as_deref());
152 let fallback = if text_block_index == 0 {
153 format!("msg_pi_{msg_index}")
154 } else {
155 format!("msg_pi_{msg_index}_{text_block_index}")
156 };
157 text_block_index += 1;
158 let mut msg_id = parsed.as_ref().map(|(id, _)| id.clone()).unwrap_or(fallback);
159 if msg_id.len() > 64 {
160 msg_id = format!("msg_{}", short_hash(&msg_id));
161 }
162 let mut item = json!({
163 "type": "message",
164 "role": "assistant",
165 "content": [{ "type": "output_text", "text": sanitize_surrogates(&text.text), "annotations": [] }],
166 "status": "completed",
167 "id": msg_id
168 });
169 if let Some((_, Some(phase))) = parsed {
170 item["phase"] = json!(phase);
171 }
172 output.push(item);
173 }
174 AssistantContentBlock::ToolCall(tc) => {
175 let parts: Vec<&str> = tc.id.splitn(2, '|').collect();
176 let call_id = parts[0];
177 let mut item_id = parts.get(1).map(|s| s.to_string());
178 if is_different_model && item_id.as_deref().map(|s| s.starts_with("fc_")) == Some(true) {
179 item_id = None;
180 }
181 output.push(json!({
182 "type": "function_call",
183 "id": item_id,
184 "call_id": call_id,
185 "name": tc.name,
186 "arguments": tc.arguments.to_string()
187 }));
188 }
189 }
190 }
191 if !output.is_empty() {
192 messages.extend(output);
193 }
194 }
195 Message::ToolResult {
196 tool_call_id, content, ..
197 } => {
198 let text_result: String = content
199 .iter()
200 .filter_map(|b| match b {
201 ContentBlock::Text { text } => Some(text.as_str()),
202 _ => None,
203 })
204 .collect::<Vec<_>>()
205 .join("\n");
206 let has_images = content.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
207 let has_text = !text_result.is_empty();
208 let call_id = tool_call_id.split('|').next().unwrap_or(&tool_call_id);
209 let output_val = if has_images && model.input.iter().any(|i| i == "image") {
210 let mut parts = Vec::new();
211 if has_text {
212 parts.push(json!({ "type": "input_text", "text": sanitize_surrogates(&text_result) }));
213 }
214 for b in &content {
215 if let ContentBlock::Image { data, mime_type } = b {
216 parts.push(json!({
217 "type": "input_image",
218 "detail": "auto",
219 "image_url": format!("data:{mime_type};base64,{data}")
220 }));
221 }
222 }
223 Value::Array(parts)
224 } else {
225 json!(sanitize_surrogates(if has_text {
226 text_result.as_str()
227 } else if has_images {
228 "(see attached image)"
229 } else {
230 "(no tool output)"
231 }))
232 };
233 messages.push(json!({
234 "type": "function_call_output",
235 "call_id": call_id,
236 "output": output_val
237 }));
238 }
239 }
240 }
241 messages
242}
243
244pub fn convert_responses_tools(tools: &[crate::types::Tool], strict: Option<bool>) -> Vec<Value> {
245 let strict = strict.unwrap_or(false);
246 tools
247 .iter()
248 .map(|tool| {
249 json!({
250 "type": "function",
251 "name": tool.name,
252 "description": tool.description,
253 "parameters": tool.parameters,
254 "strict": strict
255 })
256 })
257 .collect()
258}
259
260struct StreamingToolCall {
261 tool_call: ToolCall,
262 partial_json: String,
263}
264
265enum OutputSlot {
266 Thinking {
267 block: ThinkingContent,
268 content_index: usize,
269 },
270 Text {
271 block: TextContent,
272 content_index: usize,
273 },
274 ToolCall {
275 block: StreamingToolCall,
276 content_index: usize,
277 },
278}
279
280fn create_output_slot(
281 output_index: usize,
282 item: &Value,
283 output: &mut AssistantMessage,
284 stream: &AssistantMessageEventStream,
285 output_slots: &mut HashMap<usize, OutputSlot>,
286) {
287 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
288 match item_type {
289 "reasoning" => {
290 let block = ThinkingContent::new("");
291 output.content.push(AssistantContentBlock::Thinking(block.clone()));
292 let content_index = output.content.len() - 1;
293 stream.push(AssistantMessageEvent::ThinkingStart {
294 content_index,
295 partial: output.clone(),
296 });
297 output_slots.insert(output_index, OutputSlot::Thinking { block, content_index });
298 }
299 "message" => {
300 let block = TextContent::new("");
301 output.content.push(AssistantContentBlock::Text(block.clone()));
302 let content_index = output.content.len() - 1;
303 stream.push(AssistantMessageEvent::TextStart {
304 content_index,
305 partial: output.clone(),
306 });
307 output_slots.insert(output_index, OutputSlot::Text { block, content_index });
308 }
309 "function_call" => {
310 let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
311 let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
312 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
313 let args = item.get("arguments").and_then(|v| v.as_str()).unwrap_or("");
314 let block = StreamingToolCall {
315 tool_call: ToolCall::new(format!("{call_id}|{id}"), name, parse_streaming_json(Some(args))),
316 partial_json: args.to_string(),
317 };
318 output
319 .content
320 .push(AssistantContentBlock::ToolCall(block.tool_call.clone()));
321 let content_index = output.content.len() - 1;
322 stream.push(AssistantMessageEvent::ToolcallStart {
323 content_index,
324 partial: output.clone(),
325 });
326 output_slots.insert(output_index, OutputSlot::ToolCall { block, content_index });
327 }
328 _ => {}
329 }
330}
331
332#[derive(Default)]
333pub struct ResponsesStreamState {
334 pub saw_terminal: bool,
335 output_slots: HashMap<usize, OutputSlot>,
336}
337
338pub fn process_responses_stream_event(
339 event: &Value,
340 state: &mut ResponsesStreamState,
341 output: &mut AssistantMessage,
342 stream: &AssistantMessageEventStream,
343 model: &Model,
344 options: Option<&OpenAIResponsesStreamOptions>,
345) -> Result<()> {
346 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
347 match event_type {
348 "response.created" => {
349 if let Some(id) = event.pointer("/response/id").and_then(|v| v.as_str()) {
350 output.response_id = Some(id.to_string());
351 }
352 }
353 "response.output_item.added" => {
354 let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
355 if let Some(item) = event.get("item") {
356 create_output_slot(idx, item, output, stream, &mut state.output_slots);
357 }
358 }
359 "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
360 let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
361 let delta = event.get("delta").and_then(|v| v.as_str()).unwrap_or("");
362 if let Some(OutputSlot::Thinking { block, content_index }) = state.output_slots.get_mut(&idx) {
363 block.thinking.push_str(delta);
364 if let Some(AssistantContentBlock::Thinking(t)) = output.content.get_mut(*content_index) {
365 t.thinking = block.thinking.clone();
366 }
367 stream.push(AssistantMessageEvent::ThinkingDelta {
368 content_index: *content_index,
369 delta: delta.to_string(),
370 partial: output.clone(),
371 });
372 }
373 }
374 "response.output_text.delta" | "response.refusal.delta" => {
375 let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
376 let delta = event.get("delta").and_then(|v| v.as_str()).unwrap_or("");
377 if let Some(OutputSlot::Text { block, content_index }) = state.output_slots.get_mut(&idx) {
378 block.text.push_str(delta);
379 if let Some(AssistantContentBlock::Text(t)) = output.content.get_mut(*content_index) {
380 t.text = block.text.clone();
381 }
382 stream.push(AssistantMessageEvent::TextDelta {
383 content_index: *content_index,
384 delta: delta.to_string(),
385 partial: output.clone(),
386 });
387 }
388 }
389 "response.function_call_arguments.delta" => {
390 let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
391 let delta = event.get("delta").and_then(|v| v.as_str()).unwrap_or("");
392 if let Some(OutputSlot::ToolCall { block, content_index }) = state.output_slots.get_mut(&idx) {
393 block.partial_json.push_str(delta);
394 block.tool_call.arguments = parse_streaming_json(Some(&block.partial_json));
395 if let Some(AssistantContentBlock::ToolCall(tc)) = output.content.get_mut(*content_index) {
396 tc.arguments = block.tool_call.arguments.clone();
397 }
398 stream.push(AssistantMessageEvent::ToolcallDelta {
399 content_index: *content_index,
400 delta: delta.to_string(),
401 partial: output.clone(),
402 });
403 }
404 }
405 "response.output_item.done" => {
406 let idx = event.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
407 if let Some(item) = event.get("item") {
408 match state.output_slots.get_mut(&idx) {
409 Some(OutputSlot::Thinking { block, content_index }) => {
410 if let Some(summary) = item.get("summary").and_then(|v| v.as_array()) {
411 let text: String = summary
412 .iter()
413 .filter_map(|s| s.get("text").and_then(|t| t.as_str()))
414 .collect::<Vec<_>>()
415 .join("\n\n");
416 if !text.is_empty() {
417 block.thinking = text;
418 }
419 }
420 block.thinking_signature = Some(item.to_string());
421 if let Some(AssistantContentBlock::Thinking(t)) = output.content.get_mut(*content_index) {
422 t.thinking = block.thinking.clone();
423 t.thinking_signature = block.thinking_signature.clone();
424 }
425 stream.push(AssistantMessageEvent::ThinkingEnd {
426 content_index: *content_index,
427 content: block.thinking.clone(),
428 partial: output.clone(),
429 });
430 state.output_slots.remove(&idx);
431 }
432 Some(OutputSlot::Text { block, content_index }) => {
433 if let Some(content) = item.get("content").and_then(|v| v.as_array()) {
434 block.text = content
435 .iter()
436 .filter_map(|c| {
437 if c.get("type")?.as_str()? == "output_text" {
438 c.get("text")?.as_str()
439 } else {
440 c.get("refusal")?.as_str()
441 }
442 })
443 .collect::<String>();
444 }
445 if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
446 let phase = item.get("phase").and_then(|v| v.as_str());
447 block.text_signature = Some(encode_text_signature_v1(id, phase));
448 }
449 if let Some(AssistantContentBlock::Text(t)) = output.content.get_mut(*content_index) {
450 t.text = block.text.clone();
451 t.text_signature = block.text_signature.clone();
452 }
453 stream.push(AssistantMessageEvent::TextEnd {
454 content_index: *content_index,
455 content: block.text.clone(),
456 partial: output.clone(),
457 });
458 state.output_slots.remove(&idx);
459 }
460 Some(OutputSlot::ToolCall { block, content_index }) => {
461 if let Some(args) = item.get("arguments").and_then(|v| v.as_str()) {
462 block.partial_json = args.to_string();
463 }
464 block.tool_call.arguments = parse_streaming_json(Some(&block.partial_json));
465 if let Some(AssistantContentBlock::ToolCall(tc)) = output.content.get_mut(*content_index) {
466 tc.arguments = block.tool_call.arguments.clone();
467 }
468 stream.push(AssistantMessageEvent::ToolcallEnd {
469 content_index: *content_index,
470 tool_call: block.tool_call.clone(),
471 partial: output.clone(),
472 });
473 state.output_slots.remove(&idx);
474 }
475 None => {
476 create_output_slot(idx, item, output, stream, &mut state.output_slots);
477 }
478 }
479 }
480 }
481 "response.completed" | "response.incomplete" => {
482 state.saw_terminal = true;
483 if let Some(response) = event.get("response") {
484 if let Some(id) = response.get("id").and_then(|v| v.as_str()) {
485 output.response_id = Some(id.to_string());
486 }
487 if let Some(usage) = response.get("usage") {
488 let cached = usage
489 .pointer("/input_tokens_details/cached_tokens")
490 .and_then(|v| v.as_u64())
491 .unwrap_or(0);
492 let input = usage.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
493 let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
494 output.usage.input = input.saturating_sub(cached);
495 output.usage.output = output_tokens;
496 output.usage.cache_read = cached;
497 output.usage.reasoning = usage
498 .pointer("/output_tokens_details/reasoning_tokens")
499 .and_then(|v| v.as_u64());
500 output.usage.total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
501 calculate_cost(model, &mut output.usage);
502 if let Some(opts) = &options
503 && let Some(apply) = &opts.apply_service_tier_pricing
504 {
505 let tier = response.get("service_tier").and_then(|v| v.as_str());
506 apply(&mut output.usage, tier);
507 }
508 }
509 output.stop_reason = map_stop_reason(response.get("status").and_then(|v| v.as_str()));
510 if output.content.iter().any(|b| b.is_tool_call()) && output.stop_reason == StopReason::Stop {
511 output.stop_reason = StopReason::ToolUse;
512 }
513 }
514 }
515 "error" => {
516 let code = event.get("code").and_then(|v| v.as_str()).unwrap_or("unknown");
517 let message = event.get("message").and_then(|v| v.as_str()).unwrap_or("unknown");
518 return Err(anyhow!("Error Code {code}: {message}"));
519 }
520 "response.failed" => {
521 let err = event.pointer("/response/error");
522 let code = err
523 .and_then(|e| e.get("code"))
524 .and_then(|v| v.as_str())
525 .unwrap_or("unknown");
526 let message = err
527 .and_then(|e| e.get("message"))
528 .and_then(|v| v.as_str())
529 .unwrap_or("Unknown error");
530 return Err(anyhow!("{code}: {message}"));
531 }
532 _ => {}
533 }
534 Ok(())
535}
536
537pub async fn process_responses_stream(
538 events: Vec<Value>,
539 output: &mut AssistantMessage,
540 stream: &AssistantMessageEventStream,
541 model: &Model,
542 options: Option<OpenAIResponsesStreamOptions>,
543) -> Result<()> {
544 let mut state = ResponsesStreamState::default();
545 for event in events {
546 process_responses_stream_event(&event, &mut state, output, stream, model, options.as_ref())?;
547 }
548 if !state.saw_terminal {
549 return Err(anyhow!(
550 "OpenAI Responses stream ended before a terminal response event"
551 ));
552 }
553 Ok(())
554}
555
556fn map_stop_reason(status: Option<&str>) -> StopReason {
557 match status {
558 Some("completed") => StopReason::Stop,
559 Some("incomplete") => StopReason::Length,
560 Some("failed") | Some("cancelled") => StopReason::Error,
561 _ => StopReason::Stop,
562 }
563}