Skip to main content

elph_ai/api/
openai_responses_shared.rs

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}