Skip to repository content4709 lines · 171.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:36:36.843Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
completion.rs
1use anyhow::{Result, anyhow};
2use collections::HashMap;
3use futures::{Stream, StreamExt};
4use language_model_core::{
5 CompactedContext, CompactionUpdate, LanguageModelCompletionError, LanguageModelCompletionEvent,
6 LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage,
7 LanguageModelProviderId, LanguageModelRequest, LanguageModelRequestMessage,
8 LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolResultContent,
9 LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role,
10 StopReason, TokenUsage,
11 util::{fix_streamed_json, is_context_window_exceeded_message, parse_tool_arguments},
12};
13use std::pin::Pin;
14use std::sync::Arc;
15
16use crate::responses::{
17 ContextManagement, Request as ResponseRequest, ResponseCustomToolCallItem,
18 ResponseCustomToolCallOutputItem, ResponseError, ResponseFunctionCallItem,
19 ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem, ResponseIncludable,
20 ResponseInputContent, ResponseInputItem, ResponseMessageItem, ResponseOutputItem,
21 ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem,
22 ResponseReasoningSummaryPart, ResponseSummary as ResponsesSummary,
23 ResponseUsage as ResponsesUsage, StreamEvent as ResponsesStreamEvent,
24 provider_compaction_items, provider_compaction_state_from_items,
25};
26use crate::{
27 FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort,
28 ResponseStreamEvent, ServiceTier, ToolCall, ToolCallContent,
29};
30
31const RESPONSE_MESSAGE_PHASE_COMMENTARY: &str = "commentary";
32const RESPONSE_MESSAGE_PHASE_FINAL_ANSWER: &str = "final_answer";
33
34/// Translates the request's `Speed` into the corresponding OpenAI service tier.
35/// Only `Fast` produces a value; `Standard` leaves the field unset so that the
36/// project's default tier applies.
37fn service_tier_for(speed: Option<language_model_core::Speed>) -> Option<ServiceTier> {
38 match speed? {
39 language_model_core::Speed::Fast => Some(ServiceTier::Priority),
40 language_model_core::Speed::Standard => None,
41 }
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum ChatCompletionMaxTokensParameter {
46 MaxCompletionTokens,
47 MaxTokens,
48}
49
50pub fn into_open_ai(
51 request: LanguageModelRequest,
52 model_id: &str,
53 supports_parallel_tool_calls: bool,
54 supports_prompt_cache_key: bool,
55 max_output_tokens: Option<u64>,
56 max_tokens_parameter: ChatCompletionMaxTokensParameter,
57 reasoning_effort: Option<ReasoningEffort>,
58 interleaved_reasoning: bool,
59) -> Result<crate::Request> {
60 if request
61 .tools
62 .iter()
63 .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. }))
64 {
65 return Err(anyhow!(
66 "OpenAI Chat Completions does not support custom tools; use Responses API instead"
67 ));
68 }
69
70 let stream = !model_id.starts_with("o1-");
71 let service_tier = service_tier_for(request.speed);
72
73 let mut messages = Vec::new();
74 let mut current_reasoning: Option<String> = None;
75 for message in request.messages {
76 for content in message.content {
77 match content {
78 MessageContent::Thinking { text, .. } if interleaved_reasoning => {
79 current_reasoning.get_or_insert_default().push_str(&text);
80 }
81 MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
82 let should_add = if message.role == Role::User {
83 // Including whitespace-only user messages can cause error with OpenAI compatible APIs
84 // See https://github.com/zed-industries/zed/issues/40097
85 !text.trim().is_empty()
86 } else {
87 !text.is_empty()
88 };
89 if should_add {
90 add_message_content_part(
91 MessagePart::Text { text },
92 message.role,
93 &mut messages,
94 );
95 if let Some(reasoning) = current_reasoning.take() {
96 if let Some(crate::RequestMessage::Assistant {
97 reasoning_content,
98 ..
99 }) = messages.last_mut()
100 {
101 *reasoning_content = Some(reasoning);
102 }
103 }
104 }
105 }
106 MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {}
107 MessageContent::Image(image) => {
108 add_message_content_part(
109 MessagePart::Image {
110 image_url: ImageUrl {
111 url: image.to_base64_url(),
112 detail: None,
113 },
114 },
115 message.role,
116 &mut messages,
117 );
118 }
119 MessageContent::ToolUse(tool_use) => {
120 let LanguageModelToolUseInput::Json(input) = &tool_use.input else {
121 return Err(anyhow!(
122 "OpenAI Chat Completions cannot replay custom tool call `{}`",
123 tool_use.name
124 ));
125 };
126 let tool_call = ToolCall {
127 id: tool_use.id.to_string(),
128 content: ToolCallContent::Function {
129 function: FunctionContent {
130 name: tool_use.name.to_string(),
131 arguments: serde_json::to_string(input).unwrap_or_default(),
132 },
133 },
134 };
135
136 if let Some(crate::RequestMessage::Assistant { tool_calls, .. }) =
137 messages.last_mut()
138 {
139 tool_calls.push(tool_call);
140 } else {
141 messages.push(crate::RequestMessage::Assistant {
142 content: None,
143 tool_calls: vec![tool_call],
144 reasoning_content: current_reasoning.take(),
145 });
146 }
147 }
148 MessageContent::ToolResult(tool_result) => {
149 let content: Vec<MessagePart> = tool_result
150 .content
151 .iter()
152 .map(|part| match part {
153 LanguageModelToolResultContent::Text(text) => MessagePart::Text {
154 text: text.to_string(),
155 },
156 LanguageModelToolResultContent::Image(image) => MessagePart::Image {
157 image_url: ImageUrl {
158 url: image.to_base64_url(),
159 detail: None,
160 },
161 },
162 })
163 .collect();
164
165 messages.push(crate::RequestMessage::Tool {
166 content: content.into(),
167 tool_call_id: tool_result.tool_use_id.to_string(),
168 });
169 }
170 }
171 }
172 }
173
174 Ok(crate::Request {
175 model: model_id.into(),
176 messages,
177 stream,
178 stream_options: if stream {
179 Some(crate::StreamOptions::default())
180 } else {
181 None
182 },
183 stop: request.stop,
184 temperature: request.temperature.or(Some(1.0)),
185 max_completion_tokens: match max_tokens_parameter {
186 ChatCompletionMaxTokensParameter::MaxCompletionTokens => max_output_tokens,
187 ChatCompletionMaxTokensParameter::MaxTokens => None,
188 },
189 max_tokens: match max_tokens_parameter {
190 ChatCompletionMaxTokensParameter::MaxCompletionTokens => None,
191 ChatCompletionMaxTokensParameter::MaxTokens => max_output_tokens,
192 },
193 parallel_tool_calls: if supports_parallel_tool_calls && !request.tools.is_empty() {
194 Some(supports_parallel_tool_calls)
195 } else {
196 None
197 },
198 prompt_cache_key: if supports_prompt_cache_key {
199 request.thread_id
200 } else {
201 None
202 },
203 tools: request
204 .tools
205 .into_iter()
206 .map(|tool| match tool.input {
207 LanguageModelRequestToolInput::Function { input_schema, .. } => {
208 Ok(crate::ToolDefinition::Function {
209 function: FunctionDefinition {
210 name: tool.name,
211 description: Some(tool.description),
212 parameters: Some(input_schema),
213 },
214 })
215 }
216 LanguageModelRequestToolInput::Custom { .. } => Err(anyhow!(
217 "OpenAI Chat Completions does not support custom tools; use Responses API instead"
218 )),
219 })
220 .collect::<Result<_>>()?,
221 tool_choice: request.tool_choice.map(|choice| match choice {
222 LanguageModelToolChoice::Auto => crate::ToolChoice::Auto,
223 LanguageModelToolChoice::Any => crate::ToolChoice::Required,
224 LanguageModelToolChoice::None => crate::ToolChoice::None,
225 }),
226 reasoning_effort,
227 service_tier,
228 })
229}
230
231/// `compaction_state_owner` identifies the backend this request will be sent
232/// to for the purpose of replaying provider-native compaction state. Multiple
233/// backends share this conversion, but their encrypted compaction items are
234/// not interchangeable, so only state owned by this id is replayed; state
235/// owned by any other backend falls back to replaying the full transcript.
236pub fn into_open_ai_response(
237 request: LanguageModelRequest,
238 model_id: &str,
239 supports_parallel_tool_calls: bool,
240 supports_prompt_cache_key: bool,
241 max_output_tokens: Option<u64>,
242 default_reasoning_effort: Option<ReasoningEffort>,
243 supports_none_reasoning_effort: bool,
244 compaction_state_owner: &LanguageModelProviderId,
245) -> Result<ResponseRequest> {
246 let stream = !model_id.starts_with("o1-");
247
248 let LanguageModelRequest {
249 thread_id,
250 prompt_id: _,
251 intent: _,
252 messages,
253 tools,
254 tool_choice,
255 stop: _,
256 temperature,
257 thinking_allowed,
258 thinking_effort,
259 speed,
260 compact_at_tokens,
261 } = request;
262
263 let service_tier = service_tier_for(speed);
264
265 let mut provider_items = Vec::new();
266 let mut input_items = Vec::new();
267 let mut replayed_reasoning_item_indexes = HashMap::default();
268 let mut tool_use_kinds_by_id = HashMap::default();
269 let mut system_instructions = Vec::new();
270 for (index, message) in messages.into_iter().enumerate() {
271 // System messages go to the top-level `instructions` field rather
272 // than the input item list. `instructions` is applied per request
273 // (the Responses API documents it as a system message inserted into
274 // the model's context), so the current system prompt survives when
275 // replaying provider compaction state replaces the accumulated input
276 // items below. This also matches the Codex backend, which rejects
277 // system-role input items outright.
278 if message.role == Role::System {
279 for content in message.content {
280 if let MessageContent::Text(text) = content
281 && !text.trim().is_empty()
282 {
283 system_instructions.push(text);
284 }
285 }
286 continue;
287 }
288 append_message_to_response_items(
289 message,
290 index,
291 compaction_state_owner,
292 &mut replayed_reasoning_item_indexes,
293 &mut tool_use_kinds_by_id,
294 &mut provider_items,
295 &mut input_items,
296 )?;
297 }
298
299 let tools: Vec<_> = tools
300 .into_iter()
301 .map(|tool| match tool.input {
302 LanguageModelRequestToolInput::Function { input_schema, .. } => {
303 crate::responses::ToolDefinition::Function {
304 name: tool.name,
305 description: Some(tool.description),
306 parameters: Some(input_schema),
307 strict: None,
308 }
309 }
310 LanguageModelRequestToolInput::Custom { format } => {
311 crate::responses::ToolDefinition::Custom {
312 name: tool.name,
313 description: if tool.description.is_empty() {
314 None
315 } else {
316 Some(tool.description)
317 },
318 format: format.map(custom_tool_format_into_open_ai),
319 }
320 }
321 })
322 .collect();
323
324 let default_reasoning_effort =
325 default_reasoning_effort.filter(|effort| *effort != ReasoningEffort::None);
326 let reasoning_effort = if thinking_allowed {
327 thinking_effort
328 .as_deref()
329 .and_then(|effort| effort.parse::<ReasoningEffort>().ok())
330 .filter(|effort| *effort != ReasoningEffort::None)
331 .or(default_reasoning_effort)
332 } else if supports_none_reasoning_effort {
333 Some(ReasoningEffort::None)
334 } else {
335 None
336 };
337
338 let reasoning = reasoning_effort.map(|effort| crate::responses::ReasoningConfig {
339 effort,
340 summary: if effort == ReasoningEffort::None {
341 None
342 } else {
343 Some(crate::responses::ReasoningSummaryMode::Auto)
344 },
345 });
346
347 let include = if reasoning
348 .as_ref()
349 .is_some_and(|reasoning| reasoning.effort != ReasoningEffort::None)
350 || input_items
351 .iter()
352 .any(|item| matches!(item, ResponseInputItem::Reasoning(_)))
353 {
354 vec![ResponseIncludable::ReasoningEncryptedContent]
355 } else {
356 Vec::new()
357 };
358
359 Ok(ResponseRequest {
360 model: model_id.into(),
361 instructions: if system_instructions.is_empty() {
362 None
363 } else {
364 Some(system_instructions.join("\n\n"))
365 },
366 input: crate::responses::ResponseInput::new(provider_items, input_items),
367 store: Some(false),
368 include,
369 stream,
370 temperature,
371 top_p: None,
372 max_output_tokens,
373 parallel_tool_calls: if tools.is_empty() {
374 None
375 } else {
376 Some(supports_parallel_tool_calls)
377 },
378 tool_choice: tool_choice.map(|choice| match choice {
379 LanguageModelToolChoice::Auto => crate::ToolChoice::Auto,
380 LanguageModelToolChoice::Any => crate::ToolChoice::Required,
381 LanguageModelToolChoice::None => crate::ToolChoice::None,
382 }),
383 tools,
384 prompt_cache_key: if supports_prompt_cache_key {
385 thread_id
386 } else {
387 None
388 },
389 reasoning,
390 service_tier,
391 context_management: compact_at_tokens
392 .map(|compact_threshold| vec![ContextManagement::Compaction { compact_threshold }]),
393 })
394}
395
396fn append_message_to_response_items(
397 message: LanguageModelRequestMessage,
398 index: usize,
399 compaction_state_owner: &LanguageModelProviderId,
400 replayed_reasoning_item_indexes: &mut HashMap<String, usize>,
401 tool_use_kinds_by_id: &mut HashMap<LanguageModelToolUseId, ReplayToolKind>,
402 provider_items: &mut Vec<serde_json::Value>,
403 input_items: &mut Vec<ResponseInputItem>,
404) -> Result<()> {
405 let mut content_parts: Vec<ResponseInputContent> = Vec::new();
406
407 let LanguageModelRequestMessage {
408 role,
409 content,
410 reasoning_details,
411 ..
412 } = message;
413 let phase = if role == Role::Assistant {
414 response_message_phase_from_details(reasoning_details.as_deref())
415 } else {
416 None
417 };
418
419 if role == Role::Assistant {
420 append_reasoning_details_to_response_items(
421 reasoning_details.as_deref(),
422 replayed_reasoning_item_indexes,
423 input_items,
424 );
425 }
426
427 for content in content {
428 match content {
429 MessageContent::Text(text) => {
430 push_response_text_part(&role, text, &mut content_parts);
431 }
432 MessageContent::Thinking { .. } | MessageContent::RedactedThinking(_) => {}
433 MessageContent::Compaction(CompactedContext::ProviderState(state)) => {
434 // The canonical replacement window already encodes all
435 // context retained at the compaction point, so everything
436 // accumulated before it -- earlier input items, earlier parts
437 // of this same message, and replay bookkeeping -- is
438 // superseded and must not be resent. When a transcript
439 // contains multiple compactions, each later window supersedes
440 // the previous one, so the last compaction wins. State owned
441 // by another backend yields `None`, in which case we fall
442 // back to replaying the full transcript.
443 if let Some(items) = provider_compaction_items(&state, compaction_state_owner)? {
444 content_parts.clear();
445 input_items.clear();
446 replayed_reasoning_item_indexes.clear();
447 tool_use_kinds_by_id.clear();
448 provider_items.clear();
449 provider_items.extend(items);
450 }
451 }
452 MessageContent::Compaction(CompactedContext::Summary { .. }) => {}
453 MessageContent::Image(image) => {
454 push_response_image_part(&role, image, &mut content_parts);
455 }
456 MessageContent::ToolUse(tool_use) => {
457 flush_response_parts(
458 &role,
459 index,
460 phase.as_deref(),
461 &mut content_parts,
462 input_items,
463 );
464 let call_id = tool_use.id.to_string();
465 match tool_use.input {
466 LanguageModelToolUseInput::Json(_) => {
467 tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Function);
468 input_items.push(ResponseInputItem::FunctionCall(
469 ResponseFunctionCallItem {
470 call_id,
471 name: tool_use.name.to_string(),
472 arguments: tool_use.raw_input,
473 },
474 ));
475 }
476 LanguageModelToolUseInput::Text(_) => {
477 tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Custom);
478 input_items.push(ResponseInputItem::CustomToolCall(
479 ResponseCustomToolCallItem {
480 id: None,
481 call_id,
482 name: tool_use.name.to_string(),
483 input: tool_use.raw_input,
484 },
485 ));
486 }
487 }
488 }
489 MessageContent::ToolResult(tool_result) => {
490 flush_response_parts(
491 &role,
492 index,
493 phase.as_deref(),
494 &mut content_parts,
495 input_items,
496 );
497 let output = match tool_result.content.as_slice() {
498 [LanguageModelToolResultContent::Text(text)] => {
499 ResponseFunctionCallOutputContent::Text(text.to_string())
500 }
501 _ => {
502 let parts = tool_result
503 .content
504 .into_iter()
505 .map(|part| match part {
506 LanguageModelToolResultContent::Text(text) => {
507 ResponseInputContent::Text {
508 text: text.to_string(),
509 }
510 }
511 LanguageModelToolResultContent::Image(image) => {
512 ResponseInputContent::Image {
513 image_url: image.to_base64_url(),
514 }
515 }
516 })
517 .collect();
518 ResponseFunctionCallOutputContent::List(parts)
519 }
520 };
521 match tool_use_kinds_by_id.get(&tool_result.tool_use_id) {
522 Some(ReplayToolKind::Custom) => {
523 input_items.push(ResponseInputItem::CustomToolCallOutput(
524 ResponseCustomToolCallOutputItem {
525 call_id: tool_result.tool_use_id.to_string(),
526 output,
527 },
528 ));
529 }
530 Some(ReplayToolKind::Function) | None => {
531 input_items.push(ResponseInputItem::FunctionCallOutput(
532 ResponseFunctionCallOutputItem {
533 call_id: tool_result.tool_use_id.to_string(),
534 output,
535 },
536 ));
537 }
538 }
539 }
540 }
541 }
542
543 flush_response_parts(
544 &role,
545 index,
546 phase.as_deref(),
547 &mut content_parts,
548 input_items,
549 );
550 Ok(())
551}
552
553#[derive(Clone, Copy)]
554enum ReplayToolKind {
555 Function,
556 Custom,
557}
558
559fn custom_tool_format_into_open_ai(
560 format: LanguageModelCustomToolFormat,
561) -> crate::responses::CustomToolFormat {
562 match format {
563 LanguageModelCustomToolFormat::Text => crate::responses::CustomToolFormat::Text,
564 LanguageModelCustomToolFormat::Grammar { syntax, definition } => {
565 crate::responses::CustomToolFormat::Grammar {
566 syntax: match syntax {
567 LanguageModelCustomToolGrammarSyntax::Lark => {
568 crate::responses::CustomToolGrammarSyntax::Lark
569 }
570 LanguageModelCustomToolGrammarSyntax::Regex => {
571 crate::responses::CustomToolGrammarSyntax::Regex
572 }
573 },
574 definition,
575 }
576 }
577 }
578}
579
580fn append_reasoning_details_to_response_items(
581 reasoning_details: Option<&serde_json::Value>,
582 replayed_reasoning_item_indexes: &mut HashMap<String, usize>,
583 input_items: &mut Vec<ResponseInputItem>,
584) {
585 let Some(reasoning_details) = reasoning_details else {
586 return;
587 };
588
589 let Some(metadata) = response_message_metadata_from_details(reasoning_details) else {
590 return;
591 };
592
593 for reasoning_item in metadata.reasoning_items {
594 push_replayed_reasoning_item(reasoning_item, replayed_reasoning_item_indexes, input_items);
595 }
596}
597
598fn push_replayed_reasoning_item(
599 reasoning_item: ResponseReasoningInputItem,
600 replayed_reasoning_item_indexes: &mut HashMap<String, usize>,
601 input_items: &mut Vec<ResponseInputItem>,
602) {
603 if let Some(id) = reasoning_item.id.as_ref() {
604 if let Some(index) = replayed_reasoning_item_indexes.get(id) {
605 input_items[*index] = ResponseInputItem::Reasoning(reasoning_item);
606 return;
607 }
608
609 replayed_reasoning_item_indexes.insert(id.clone(), input_items.len());
610 }
611
612 input_items.push(ResponseInputItem::Reasoning(reasoning_item));
613}
614
615fn push_response_text_part(
616 role: &Role,
617 text: impl Into<String>,
618 parts: &mut Vec<ResponseInputContent>,
619) {
620 let text = text.into();
621 if text.trim().is_empty() {
622 return;
623 }
624
625 match role {
626 Role::Assistant => parts.push(ResponseInputContent::OutputText {
627 text,
628 annotations: Vec::new(),
629 }),
630 _ => parts.push(ResponseInputContent::Text { text }),
631 }
632}
633
634fn push_response_image_part(
635 role: &Role,
636 image: LanguageModelImage,
637 parts: &mut Vec<ResponseInputContent>,
638) {
639 match role {
640 Role::Assistant => parts.push(ResponseInputContent::OutputText {
641 text: "[image omitted]".to_string(),
642 annotations: Vec::new(),
643 }),
644 _ => parts.push(ResponseInputContent::Image {
645 image_url: image.to_base64_url(),
646 }),
647 }
648}
649
650fn flush_response_parts(
651 role: &Role,
652 _index: usize,
653 phase: Option<&str>,
654 parts: &mut Vec<ResponseInputContent>,
655 input_items: &mut Vec<ResponseInputItem>,
656) {
657 if parts.is_empty() {
658 return;
659 }
660
661 let item = ResponseInputItem::Message(ResponseMessageItem {
662 role: match role {
663 Role::User => crate::Role::User,
664 Role::Assistant => crate::Role::Assistant,
665 Role::System => crate::Role::System,
666 },
667 content: parts.clone(),
668 phase: match role {
669 Role::Assistant => phase.map(str::to_string),
670 Role::User | Role::System => None,
671 },
672 });
673
674 input_items.push(item);
675 parts.clear();
676}
677
678fn add_message_content_part(
679 new_part: MessagePart,
680 role: Role,
681 messages: &mut Vec<crate::RequestMessage>,
682) {
683 match (role, messages.last_mut()) {
684 (Role::User, Some(crate::RequestMessage::User { content }))
685 | (
686 Role::Assistant,
687 Some(crate::RequestMessage::Assistant {
688 content: Some(content),
689 ..
690 }),
691 )
692 | (Role::System, Some(crate::RequestMessage::System { content, .. })) => {
693 content.push_part(new_part);
694 }
695 _ => {
696 messages.push(match role {
697 Role::User => crate::RequestMessage::User {
698 content: crate::MessageContent::from(vec![new_part]),
699 },
700 Role::Assistant => crate::RequestMessage::Assistant {
701 content: Some(crate::MessageContent::from(vec![new_part])),
702 tool_calls: Vec::new(),
703 reasoning_content: None,
704 },
705 Role::System => crate::RequestMessage::System {
706 content: crate::MessageContent::from(vec![new_part]),
707 },
708 });
709 }
710 }
711}
712
713pub struct OpenAiEventMapper {
714 tool_calls_by_index: HashMap<usize, RawToolCall>,
715}
716
717impl OpenAiEventMapper {
718 pub fn new() -> Self {
719 Self {
720 tool_calls_by_index: HashMap::default(),
721 }
722 }
723
724 pub fn map_stream(
725 mut self,
726 events: Pin<Box<dyn Send + Stream<Item = Result<ResponseStreamEvent>>>>,
727 ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
728 {
729 events.flat_map(move |event| {
730 futures::stream::iter(match event {
731 Ok(event) => self.map_event(event),
732 Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))],
733 })
734 })
735 }
736
737 pub fn map_event(
738 &mut self,
739 event: ResponseStreamEvent,
740 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
741 let mut events = Vec::new();
742 if let Some(usage) = event.usage
743 && let Some(prompt_tokens) = usage.prompt_tokens
744 && let Some(completion_tokens) = usage.completion_tokens
745 {
746 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
747 input_tokens: prompt_tokens,
748 output_tokens: completion_tokens,
749 cache_creation_input_tokens: 0,
750 cache_read_input_tokens: 0,
751 })));
752 }
753
754 let Some(choice) = event.choices.first() else {
755 return events;
756 };
757
758 if let Some(delta) = choice.delta.as_ref() {
759 if let Some(reasoning) = delta.reasoning.clone() {
760 push_thinking_event(reasoning, &mut events);
761 }
762 if let Some(reasoning_content) = delta.reasoning_content.clone() {
763 push_thinking_event(reasoning_content, &mut events);
764 }
765 if let Some(content) = delta.content.clone() {
766 if !content.is_empty() {
767 events.push(Ok(LanguageModelCompletionEvent::Text(content)));
768 }
769 }
770
771 if let Some(tool_calls) = delta.tool_calls.as_ref() {
772 for tool_call in tool_calls {
773 let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
774
775 if let Some(tool_id) = tool_call.id.clone()
776 && !tool_id.is_empty()
777 {
778 entry.id = tool_id;
779 }
780
781 if let Some(function) = tool_call.function.as_ref() {
782 if let Some(name) = function.name.clone()
783 && !name.is_empty()
784 {
785 entry.name = name;
786 }
787
788 if let Some(arguments) = function.arguments.clone() {
789 entry.arguments.push_str(&arguments);
790 }
791 }
792
793 if !entry.id.is_empty() && !entry.name.is_empty() {
794 if let Ok(input) = serde_json::from_str::<serde_json::Value>(
795 &fix_streamed_json(&entry.arguments),
796 ) {
797 events.push(Ok(LanguageModelCompletionEvent::ToolUse(
798 LanguageModelToolUse {
799 id: entry.id.clone().into(),
800 name: entry.name.as_str().into(),
801 is_input_complete: false,
802 input: LanguageModelToolUseInput::Json(input),
803 raw_input: entry.arguments.clone(),
804 thought_signature: None,
805 },
806 )));
807 }
808 }
809 }
810 }
811 }
812
813 match choice.finish_reason.as_deref() {
814 Some("stop") => {
815 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
816 }
817 Some("tool_calls") => {
818 events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
819 match parse_tool_arguments(&tool_call.arguments) {
820 Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
821 LanguageModelToolUse {
822 id: tool_call.id.clone().into(),
823 name: tool_call.name.as_str().into(),
824 is_input_complete: true,
825 input: LanguageModelToolUseInput::Json(input),
826 raw_input: tool_call.arguments.clone(),
827 thought_signature: None,
828 },
829 )),
830 Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
831 id: tool_call.id.into(),
832 tool_name: tool_call.name.into(),
833 raw_input: tool_call.arguments.clone().into(),
834 json_parse_error: error.to_string(),
835 }),
836 }
837 }));
838
839 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
840 }
841 Some(stop_reason) => {
842 log::error!("Unexpected OpenAI stop_reason: {stop_reason:?}",);
843 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
844 }
845 None => {}
846 }
847
848 events
849 }
850}
851
852fn push_thinking_event(
853 text: String,
854 events: &mut Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
855) {
856 if !text.is_empty() {
857 events.push(Ok(LanguageModelCompletionEvent::Thinking {
858 text,
859 signature: None,
860 }));
861 }
862}
863
864#[derive(Default)]
865struct RawToolCall {
866 id: String,
867 name: String,
868 arguments: String,
869}
870
871pub struct OpenAiResponseEventMapper {
872 /// The backend whose infrastructure produced this stream; stamped on any
873 /// compaction state it emits so replay is limited to the same backend.
874 compaction_state_owner: LanguageModelProviderId,
875 function_calls_by_item: HashMap<String, PendingResponseFunctionCall>,
876 custom_tool_calls_by_item: HashMap<String, PendingResponseCustomToolCall>,
877 reasoning_items: Vec<ResponseReasoningInputItem>,
878 current_message_phase: Option<String>,
879 pending_stop_reason: Option<StopReason>,
880 pending_compaction_items: usize,
881}
882
883#[derive(Default)]
884struct PendingResponseFunctionCall {
885 call_id: String,
886 name: Arc<str>,
887 arguments: String,
888}
889
890struct PendingResponseCustomToolCall {
891 call_id: String,
892 name: Arc<str>,
893 input: String,
894}
895
896impl OpenAiResponseEventMapper {
897 pub fn new(compaction_state_owner: LanguageModelProviderId) -> Self {
898 Self {
899 compaction_state_owner,
900 function_calls_by_item: HashMap::default(),
901 custom_tool_calls_by_item: HashMap::default(),
902 reasoning_items: Vec::new(),
903 current_message_phase: None,
904 pending_stop_reason: None,
905 pending_compaction_items: 0,
906 }
907 }
908
909 pub fn map_stream(
910 mut self,
911 events: Pin<Box<dyn Send + Stream<Item = Result<ResponsesStreamEvent>>>>,
912 ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
913 {
914 events.flat_map(move |event| {
915 futures::stream::iter(match event {
916 Ok(event) => self.map_event(event),
917 Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))],
918 })
919 })
920 }
921
922 pub fn map_event(
923 &mut self,
924 event: ResponsesStreamEvent,
925 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
926 match event {
927 ResponsesStreamEvent::OutputItemAdded { item, .. } => {
928 let mut events = Vec::new();
929
930 match &item {
931 ResponseOutputItem::Message(message) => {
932 if let Some(id) = &message.id {
933 events.push(Ok(LanguageModelCompletionEvent::StartMessage {
934 message_id: id.clone(),
935 }));
936 }
937 events.extend(self.capture_message_phase(message));
938 }
939 ResponseOutputItem::FunctionCall(function_call) => {
940 if let Some(item_id) = function_call.id.clone() {
941 let call_id = function_call
942 .call_id
943 .clone()
944 .or_else(|| function_call.id.clone())
945 .unwrap_or_else(|| item_id.clone());
946 let entry = PendingResponseFunctionCall {
947 call_id,
948 name: Arc::<str>::from(
949 function_call.name.clone().unwrap_or_default(),
950 ),
951 arguments: function_call.arguments.clone(),
952 };
953 self.function_calls_by_item.insert(item_id, entry);
954 }
955 }
956 ResponseOutputItem::CustomToolCall(custom_tool_call) => {
957 if let Some(item_id) = custom_tool_call.id.clone() {
958 let call_id = custom_tool_call
959 .call_id
960 .clone()
961 .or_else(|| custom_tool_call.id.clone())
962 .unwrap_or_else(|| item_id.clone());
963 let entry = PendingResponseCustomToolCall {
964 call_id,
965 name: Arc::<str>::from(
966 custom_tool_call.name.clone().unwrap_or_default(),
967 ),
968 input: custom_tool_call.input.clone(),
969 };
970 self.custom_tool_calls_by_item.insert(item_id, entry);
971 }
972 }
973 ResponseOutputItem::Compaction(_) => {
974 self.pending_compaction_items += 1;
975 events.push(Ok(LanguageModelCompletionEvent::Compaction(
976 CompactionUpdate::Started,
977 )));
978 }
979 ResponseOutputItem::Reasoning(_) | ResponseOutputItem::Unknown => {}
980 }
981 events
982 }
983 ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. }
984 | ResponsesStreamEvent::ReasoningDelta { delta, .. } => {
985 if delta.is_empty() {
986 Vec::new()
987 } else {
988 vec![Ok(LanguageModelCompletionEvent::Thinking {
989 text: delta,
990 signature: None,
991 })]
992 }
993 }
994 ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
995 if delta.is_empty() {
996 Vec::new()
997 } else {
998 vec![Ok(LanguageModelCompletionEvent::Text(delta))]
999 }
1000 }
1001 ResponsesStreamEvent::RefusalDelta { .. }
1002 | ResponsesStreamEvent::RefusalDone { .. } => {
1003 self.pending_stop_reason = Some(StopReason::Refusal);
1004 Vec::new()
1005 }
1006 ResponsesStreamEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
1007 if let Some(entry) = self.function_calls_by_item.get_mut(&item_id) {
1008 entry.arguments.push_str(&delta);
1009 if let Ok(input) = serde_json::from_str::<serde_json::Value>(
1010 &fix_streamed_json(&entry.arguments),
1011 ) {
1012 return vec![Ok(LanguageModelCompletionEvent::ToolUse(
1013 LanguageModelToolUse {
1014 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1015 name: entry.name.clone(),
1016 is_input_complete: false,
1017 input: LanguageModelToolUseInput::Json(input),
1018 raw_input: entry.arguments.clone(),
1019 thought_signature: None,
1020 },
1021 ))];
1022 }
1023 }
1024 Vec::new()
1025 }
1026 ResponsesStreamEvent::FunctionCallArgumentsDone {
1027 item_id, arguments, ..
1028 } => {
1029 if let Some(mut entry) = self.function_calls_by_item.remove(&item_id) {
1030 if !arguments.is_empty() {
1031 entry.arguments = arguments;
1032 }
1033 let raw_input = entry.arguments.clone();
1034 self.pending_stop_reason = Some(StopReason::ToolUse);
1035 match parse_tool_arguments(&entry.arguments) {
1036 Ok(input) => {
1037 vec![Ok(LanguageModelCompletionEvent::ToolUse(
1038 LanguageModelToolUse {
1039 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1040 name: entry.name.clone(),
1041 is_input_complete: true,
1042 input: LanguageModelToolUseInput::Json(input),
1043 raw_input,
1044 thought_signature: None,
1045 },
1046 ))]
1047 }
1048 Err(error) => {
1049 vec![Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
1050 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1051 tool_name: entry.name.clone(),
1052 raw_input: Arc::<str>::from(raw_input),
1053 json_parse_error: error.to_string(),
1054 })]
1055 }
1056 }
1057 } else {
1058 Vec::new()
1059 }
1060 }
1061 ResponsesStreamEvent::CustomToolCallInputDelta { item_id, delta, .. } => {
1062 if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) {
1063 entry.input.push_str(&delta);
1064 return vec![Ok(LanguageModelCompletionEvent::ToolUse(
1065 LanguageModelToolUse {
1066 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1067 name: entry.name.clone(),
1068 is_input_complete: false,
1069 input: LanguageModelToolUseInput::Text(entry.input.clone()),
1070 raw_input: entry.input.clone(),
1071 thought_signature: None,
1072 },
1073 ))];
1074 }
1075 Vec::new()
1076 }
1077 ResponsesStreamEvent::CustomToolCallInputDone { item_id, input, .. } => {
1078 if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id)
1079 && !input.is_empty()
1080 {
1081 entry.input = input;
1082 }
1083 self.finish_pending_custom_tool_call(&item_id, None)
1084 }
1085 ResponsesStreamEvent::Completed { response } => {
1086 self.handle_completion(response, StopReason::EndTurn)
1087 }
1088 ResponsesStreamEvent::Incomplete { response } => {
1089 let reason = response
1090 .incomplete_details
1091 .as_ref()
1092 .and_then(|details| details.reason.as_deref());
1093 let mut stop_reason = match reason {
1094 Some("max_tokens" | "max_output_tokens") => StopReason::MaxTokens,
1095 Some("content_filter") => {
1096 self.pending_stop_reason = Some(StopReason::Refusal);
1097 StopReason::Refusal
1098 }
1099 _ => self
1100 .pending_stop_reason
1101 .take()
1102 .unwrap_or(StopReason::EndTurn),
1103 };
1104
1105 let mut events = Vec::new();
1106 events.extend(self.capture_reasoning_items_from_output(&response.output));
1107 if response_output_contains_refusal(&response.output)
1108 && !matches!(stop_reason, StopReason::MaxTokens)
1109 {
1110 self.pending_stop_reason = Some(StopReason::Refusal);
1111 stop_reason = StopReason::Refusal;
1112 }
1113 if self.pending_stop_reason.is_none() {
1114 events.extend(self.emit_tool_calls_from_output(&response.output));
1115 }
1116 if let Some(usage) = response.usage.as_ref() {
1117 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(
1118 token_usage_from_response_usage(usage),
1119 )));
1120 }
1121 events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason)));
1122 events
1123 }
1124 ResponsesStreamEvent::Failed { response } => match response.error.as_ref() {
1125 Some(error) => vec![Err(completion_error_from_response_error(error))],
1126 None => vec![Err(LanguageModelCompletionError::Other(anyhow!(
1127 response_failure_message(&response)
1128 )))],
1129 },
1130 ResponsesStreamEvent::Error { error } => {
1131 vec![Err(completion_error_from_response_error(&error))]
1132 }
1133 ResponsesStreamEvent::GenericError { error } => {
1134 let error = error.into_response_error();
1135 vec![Err(completion_error_from_response_error(&error))]
1136 }
1137 ResponsesStreamEvent::ReasoningSummaryPartAdded { summary_index, .. } => {
1138 if summary_index > 0 {
1139 vec![Ok(LanguageModelCompletionEvent::Thinking {
1140 text: "\n\n".to_string(),
1141 signature: None,
1142 })]
1143 } else {
1144 Vec::new()
1145 }
1146 }
1147 ResponsesStreamEvent::OutputItemDone { item, .. } => match item {
1148 ResponseOutputItem::Reasoning(reasoning) => self.capture_reasoning_item(&reasoning),
1149 ResponseOutputItem::Message(message) => self.capture_message_phase(&message),
1150 ResponseOutputItem::CustomToolCall(custom_tool_call) => {
1151 if let Some(item_id) = custom_tool_call.id.as_ref() {
1152 self.finish_pending_custom_tool_call(item_id, Some(&custom_tool_call))
1153 } else {
1154 Vec::new()
1155 }
1156 }
1157 ResponseOutputItem::Compaction(compaction) => {
1158 self.pending_compaction_items = self.pending_compaction_items.saturating_sub(1);
1159 match serde_json::to_value(ResponseInputItem::Compaction(compaction))
1160 .map_err(anyhow::Error::from)
1161 .and_then(|item| {
1162 provider_compaction_state_from_items(
1163 self.compaction_state_owner.clone(),
1164 vec![item],
1165 )
1166 }) {
1167 Ok(state) => vec![Ok(LanguageModelCompletionEvent::Compaction(
1168 CompactionUpdate::Finished(CompactedContext::ProviderState(state)),
1169 ))],
1170 Err(error) => vec![Err(LanguageModelCompletionError::Other(error))],
1171 }
1172 }
1173 ResponseOutputItem::FunctionCall(_) | ResponseOutputItem::Unknown => Vec::new(),
1174 },
1175 ResponsesStreamEvent::OutputTextDone { .. }
1176 | ResponsesStreamEvent::ContentPartAdded { .. }
1177 | ResponsesStreamEvent::ContentPartDone { .. }
1178 | ResponsesStreamEvent::ReasoningSummaryTextDone { .. }
1179 | ResponsesStreamEvent::ReasoningSummaryPartDone { .. }
1180 | ResponsesStreamEvent::ReasoningDone { .. }
1181 | ResponsesStreamEvent::Created { .. }
1182 | ResponsesStreamEvent::InProgress { .. }
1183 | ResponsesStreamEvent::Unknown => Vec::new(),
1184 }
1185 }
1186
1187 fn handle_completion(
1188 &mut self,
1189 response: ResponsesSummary,
1190 default_reason: StopReason,
1191 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1192 // A compaction item that was added but never done means the server
1193 // already pruned its context, but we never received the canonical
1194 // replacement window. Continuing as if the turn succeeded would
1195 // leave the conversation unable to continue coherently.
1196 if self.pending_compaction_items > 0 {
1197 return vec![Err(LanguageModelCompletionError::Other(anyhow!(
1198 "response completed with an unfinished compaction item"
1199 )))];
1200 }
1201
1202 let mut events = Vec::new();
1203
1204 events.extend(self.capture_reasoning_items_from_output(&response.output));
1205
1206 if response_output_contains_refusal(&response.output) {
1207 self.pending_stop_reason = Some(StopReason::Refusal);
1208 }
1209
1210 if self.pending_stop_reason.is_none() {
1211 events.extend(self.emit_tool_calls_from_output(&response.output));
1212 }
1213
1214 if let Some(usage) = response.usage.as_ref() {
1215 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(
1216 token_usage_from_response_usage(usage),
1217 )));
1218 }
1219
1220 let stop_reason = self.pending_stop_reason.take().unwrap_or(default_reason);
1221 events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason)));
1222 events
1223 }
1224
1225 fn emit_tool_calls_from_output(
1226 &mut self,
1227 output: &[ResponseOutputItem],
1228 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1229 let mut events = Vec::new();
1230 for item in output {
1231 match item {
1232 ResponseOutputItem::FunctionCall(function_call) => {
1233 let Some(call_id) = function_call
1234 .call_id
1235 .clone()
1236 .or_else(|| function_call.id.clone())
1237 else {
1238 log::error!(
1239 "Function call item missing both call_id and id: {:?}",
1240 function_call
1241 );
1242 continue;
1243 };
1244 let name: Arc<str> = Arc::from(function_call.name.clone().unwrap_or_default());
1245 let arguments = &function_call.arguments;
1246 self.pending_stop_reason = Some(StopReason::ToolUse);
1247 match parse_tool_arguments(arguments) {
1248 Ok(input) => {
1249 events.push(Ok(LanguageModelCompletionEvent::ToolUse(
1250 LanguageModelToolUse {
1251 id: LanguageModelToolUseId::from(call_id.clone()),
1252 name: name.clone(),
1253 is_input_complete: true,
1254 input: LanguageModelToolUseInput::Json(input),
1255 raw_input: arguments.clone(),
1256 thought_signature: None,
1257 },
1258 )));
1259 }
1260 Err(error) => {
1261 events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
1262 id: LanguageModelToolUseId::from(call_id.clone()),
1263 tool_name: name.clone(),
1264 raw_input: Arc::<str>::from(arguments.clone()),
1265 json_parse_error: error.to_string(),
1266 }));
1267 }
1268 }
1269 }
1270 ResponseOutputItem::CustomToolCall(custom_tool_call) => {
1271 events.extend(self.emit_custom_tool_call(custom_tool_call));
1272 }
1273 _ => {}
1274 }
1275 }
1276 events
1277 }
1278
1279 fn emit_custom_tool_call(
1280 &mut self,
1281 custom_tool_call: &crate::responses::ResponseCustomToolCall,
1282 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1283 let Some(call_id) = custom_tool_call
1284 .call_id
1285 .clone()
1286 .or_else(|| custom_tool_call.id.clone())
1287 else {
1288 log::error!(
1289 "Custom tool call item missing both call_id and id: {:?}",
1290 custom_tool_call
1291 );
1292 return Vec::new();
1293 };
1294 self.pending_stop_reason = Some(StopReason::ToolUse);
1295 let input = custom_tool_call.input.clone();
1296 vec![Ok(LanguageModelCompletionEvent::ToolUse(
1297 LanguageModelToolUse {
1298 id: LanguageModelToolUseId::from(call_id),
1299 name: Arc::from(custom_tool_call.name.clone().unwrap_or_default()),
1300 is_input_complete: true,
1301 input: LanguageModelToolUseInput::Text(input.clone()),
1302 raw_input: input,
1303 thought_signature: None,
1304 },
1305 ))]
1306 }
1307
1308 fn finish_pending_custom_tool_call(
1309 &mut self,
1310 item_id: &str,
1311 fallback: Option<&crate::responses::ResponseCustomToolCall>,
1312 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1313 let Some(mut entry) = self.custom_tool_calls_by_item.remove(item_id) else {
1314 return Vec::new();
1315 };
1316 if let Some(fallback) = fallback
1317 && !fallback.input.is_empty()
1318 {
1319 entry.input = fallback.input.clone();
1320 }
1321 self.pending_stop_reason = Some(StopReason::ToolUse);
1322 vec![Ok(LanguageModelCompletionEvent::ToolUse(
1323 LanguageModelToolUse {
1324 id: LanguageModelToolUseId::from(entry.call_id),
1325 name: entry.name,
1326 is_input_complete: true,
1327 input: LanguageModelToolUseInput::Text(entry.input.clone()),
1328 raw_input: entry.input,
1329 thought_signature: None,
1330 },
1331 ))]
1332 }
1333
1334 fn capture_reasoning_items_from_output(
1335 &mut self,
1336 output: &[ResponseOutputItem],
1337 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1338 let mut events = Vec::new();
1339 for item in output {
1340 if let ResponseOutputItem::Reasoning(reasoning) = item {
1341 events.extend(self.capture_reasoning_item(reasoning));
1342 }
1343 }
1344 events
1345 }
1346
1347 fn capture_message_phase(
1348 &mut self,
1349 message: &ResponseOutputMessage,
1350 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1351 self.current_message_phase = message
1352 .phase
1353 .as_deref()
1354 .and_then(normalize_response_message_phase)
1355 .map(str::to_string);
1356
1357 if self.current_message_phase.is_none() && self.reasoning_items.is_empty() {
1358 return Vec::new();
1359 }
1360
1361 self.emit_response_message_metadata()
1362 }
1363
1364 fn capture_reasoning_item(
1365 &mut self,
1366 reasoning: &ResponseReasoningItem,
1367 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1368 let reasoning_item = response_reasoning_input_item_from_output(reasoning);
1369
1370 if self.reasoning_items.contains(&reasoning_item) {
1371 return Vec::new();
1372 }
1373
1374 if let Some(id) = reasoning_item.id.as_ref()
1375 && let Some(existing_reasoning_item) = self
1376 .reasoning_items
1377 .iter_mut()
1378 .find(|existing_reasoning_item| existing_reasoning_item.id.as_ref() == Some(id))
1379 {
1380 *existing_reasoning_item = reasoning_item;
1381 } else {
1382 self.reasoning_items.push(reasoning_item);
1383 }
1384
1385 self.emit_response_message_metadata()
1386 }
1387
1388 fn emit_response_message_metadata(
1389 &self,
1390 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1391 let details = serde_json::to_value(ResponseMessageMetadata {
1392 phase: self.current_message_phase.clone(),
1393 reasoning_items: self.reasoning_items.clone(),
1394 });
1395
1396 match details {
1397 Ok(details) => vec![Ok(LanguageModelCompletionEvent::ReasoningDetails(details))],
1398 Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
1399 }
1400 }
1401}
1402
1403#[derive(serde::Serialize, serde::Deserialize)]
1404struct ResponseMessageMetadata {
1405 #[serde(default, skip_serializing_if = "Option::is_none")]
1406 phase: Option<String>,
1407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1408 reasoning_items: Vec<ResponseReasoningInputItem>,
1409}
1410
1411fn response_message_metadata_from_details(
1412 details: &serde_json::Value,
1413) -> Option<ResponseMessageMetadata> {
1414 serde_json::from_value::<ResponseMessageMetadata>(details.clone()).ok()
1415}
1416
1417fn response_message_phase_from_details(details: Option<&serde_json::Value>) -> Option<String> {
1418 let metadata = response_message_metadata_from_details(details?)?;
1419 metadata
1420 .phase
1421 .as_deref()
1422 .and_then(normalize_response_message_phase)
1423 .map(str::to_string)
1424}
1425
1426fn normalize_response_message_phase(phase: &str) -> Option<&'static str> {
1427 match phase {
1428 RESPONSE_MESSAGE_PHASE_COMMENTARY => Some(RESPONSE_MESSAGE_PHASE_COMMENTARY),
1429 RESPONSE_MESSAGE_PHASE_FINAL_ANSWER => Some(RESPONSE_MESSAGE_PHASE_FINAL_ANSWER),
1430 _ => None,
1431 }
1432}
1433
1434fn response_failure_message(response: &ResponsesSummary) -> String {
1435 if let Some(error) = response.error.as_ref() {
1436 return response_error_message(error);
1437 }
1438
1439 response
1440 .status
1441 .as_deref()
1442 .map(|status| format!("response.{status}"))
1443 .unwrap_or_else(|| "response.failed".to_string())
1444}
1445
1446fn completion_error_from_response_error(error: &ResponseError) -> LanguageModelCompletionError {
1447 let message = response_error_message(error);
1448 if is_context_window_exceeded_message(&message) {
1449 LanguageModelCompletionError::PromptTooLarge { tokens: None }
1450 } else {
1451 LanguageModelCompletionError::Other(anyhow!(message))
1452 }
1453}
1454
1455fn response_error_message(error: &ResponseError) -> String {
1456 let code = error.code.as_deref().filter(|code| !code.trim().is_empty());
1457 let message = error.message.trim();
1458
1459 match (code, message.is_empty()) {
1460 (Some(code), false) => format!("{code}: {message}"),
1461 (Some(code), true) => code.to_string(),
1462 (None, false) => message.to_string(),
1463 (None, true) => "response error".to_string(),
1464 }
1465}
1466
1467fn response_output_contains_refusal(output: &[ResponseOutputItem]) -> bool {
1468 output.iter().any(|item| {
1469 if let ResponseOutputItem::Message(message) = item {
1470 message.content.iter().any(response_content_is_refusal)
1471 } else {
1472 false
1473 }
1474 })
1475}
1476
1477fn response_content_is_refusal(content: &serde_json::Value) -> bool {
1478 let content_type = content
1479 .get("type")
1480 .and_then(|content_type| content_type.as_str());
1481 let refusal = content
1482 .get("refusal")
1483 .and_then(|refusal| refusal.as_str())
1484 .unwrap_or_default();
1485
1486 content_type == Some("refusal") || !refusal.is_empty()
1487}
1488
1489pub fn token_usage_from_response_usage(usage: &ResponsesUsage) -> TokenUsage {
1490 let cache_read_input_tokens = usage.input_tokens_details.cached_tokens;
1491
1492 TokenUsage {
1493 input_tokens: usage
1494 .input_tokens
1495 .unwrap_or_default()
1496 .saturating_sub(cache_read_input_tokens),
1497 output_tokens: usage.output_tokens.unwrap_or_default(),
1498 cache_creation_input_tokens: 0,
1499 cache_read_input_tokens,
1500 }
1501}
1502
1503fn response_reasoning_input_item_from_output(
1504 reasoning: &ResponseReasoningItem,
1505) -> ResponseReasoningInputItem {
1506 let encrypted_content = reasoning.encrypted_content.clone();
1507
1508 let summary = reasoning
1509 .summary
1510 .iter()
1511 .filter_map(|part| match part {
1512 crate::responses::ReasoningSummaryPart::SummaryText { text } => {
1513 Some(ResponseReasoningSummaryPart::SummaryText { text: text.clone() })
1514 }
1515 crate::responses::ReasoningSummaryPart::Unknown => None,
1516 })
1517 .collect();
1518
1519 ResponseReasoningInputItem {
1520 id: reasoning.id.clone(),
1521 summary,
1522 content: reasoning.content.clone(),
1523 encrypted_content,
1524 status: reasoning.status.clone(),
1525 }
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530 use crate::responses::{
1531 ReasoningSummaryPart, ResponseCustomToolCall, ResponseError, ResponseFunctionToolCall,
1532 ResponseIncompleteDetails, ResponseInputItem, ResponseInputTokensDetails,
1533 ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseSummary,
1534 ResponseUsage, StreamEvent as ResponsesStreamEvent, ToolDefinition,
1535 };
1536 use futures::{StreamExt, executor::block_on};
1537 use language_model_core::{
1538 LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage,
1539 LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelRequestToolInput,
1540 LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse,
1541 LanguageModelToolUseId, LanguageModelToolUseInput, OPEN_AI_PROVIDER_ID, SharedString,
1542 Speed,
1543 };
1544 use pretty_assertions::assert_eq;
1545 use serde_json::json;
1546
1547 use super::*;
1548 use crate::{
1549 ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamEvent, ToolCallChunk,
1550 };
1551
1552 fn map_response_events(events: Vec<ResponsesStreamEvent>) -> Vec<LanguageModelCompletionEvent> {
1553 block_on(async {
1554 OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID)
1555 .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
1556 .collect::<Vec<_>>()
1557 .await
1558 .into_iter()
1559 .map(Result::unwrap)
1560 .collect()
1561 })
1562 }
1563
1564 fn map_completion_events(
1565 events: Vec<ResponseStreamEvent>,
1566 ) -> Vec<LanguageModelCompletionEvent> {
1567 let mut mapper = OpenAiEventMapper::new();
1568 let mut all_events = Vec::new();
1569 for event in events {
1570 all_events.extend(mapper.map_event(event));
1571 }
1572 all_events.into_iter().filter_map(|e| e.ok()).collect()
1573 }
1574
1575 fn response_item_message(id: &str) -> ResponseOutputItem {
1576 ResponseOutputItem::Message(ResponseOutputMessage {
1577 id: Some(id.to_string()),
1578 role: Some("assistant".to_string()),
1579 status: Some("in_progress".to_string()),
1580 content: vec![],
1581 phase: None,
1582 })
1583 }
1584
1585 fn response_item_function_call(id: &str, args: Option<&str>) -> ResponseOutputItem {
1586 ResponseOutputItem::FunctionCall(ResponseFunctionToolCall {
1587 id: Some(id.to_string()),
1588 status: Some("in_progress".to_string()),
1589 name: Some("get_weather".to_string()),
1590 call_id: Some("call_123".to_string()),
1591 arguments: args.map(|s| s.to_string()).unwrap_or_default(),
1592 })
1593 }
1594
1595 fn response_item_custom_tool_call(id: &str, input: &str) -> ResponseOutputItem {
1596 ResponseOutputItem::CustomToolCall(ResponseCustomToolCall {
1597 id: Some(id.to_string()),
1598 status: Some("in_progress".to_string()),
1599 name: Some("apply_patch".to_string()),
1600 call_id: Some("call_abc".to_string()),
1601 input: input.to_string(),
1602 })
1603 }
1604
1605 fn response_reasoning_item(
1606 id: &str,
1607 summary: Vec<ReasoningSummaryPart>,
1608 encrypted_content: Option<&str>,
1609 status: Option<String>,
1610 ) -> ResponseReasoningItem {
1611 ResponseReasoningItem {
1612 id: Some(id.to_string()),
1613 summary,
1614 content: Vec::new(),
1615 encrypted_content: encrypted_content.map(str::to_string),
1616 status,
1617 }
1618 }
1619
1620 #[test]
1621 fn responses_stream_maps_text_and_usage() {
1622 let events = vec![
1623 ResponsesStreamEvent::OutputItemAdded {
1624 output_index: 0,
1625 sequence_number: None,
1626 item: response_item_message("msg_123"),
1627 },
1628 ResponsesStreamEvent::OutputTextDelta {
1629 item_id: "msg_123".into(),
1630 output_index: 0,
1631 content_index: Some(0),
1632 delta: "Hello".into(),
1633 },
1634 ResponsesStreamEvent::Completed {
1635 response: ResponseSummary {
1636 usage: Some(ResponseUsage {
1637 input_tokens: Some(5),
1638 input_tokens_details: ResponseInputTokensDetails { cached_tokens: 2 },
1639 output_tokens: Some(3),
1640 total_tokens: Some(8),
1641 ..Default::default()
1642 }),
1643 ..Default::default()
1644 },
1645 },
1646 ];
1647
1648 let mapped = map_response_events(events);
1649 assert!(matches!(
1650 mapped[0],
1651 LanguageModelCompletionEvent::StartMessage { ref message_id } if message_id == "msg_123"
1652 ));
1653 assert!(matches!(
1654 mapped[1],
1655 LanguageModelCompletionEvent::Text(ref text) if text == "Hello"
1656 ));
1657 assert!(matches!(
1658 mapped[2],
1659 LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
1660 input_tokens: 3,
1661 output_tokens: 3,
1662 cache_read_input_tokens: 2,
1663 ..
1664 })
1665 ));
1666 assert!(matches!(
1667 mapped[3],
1668 LanguageModelCompletionEvent::Stop(StopReason::EndTurn)
1669 ));
1670 }
1671
1672 #[test]
1673 fn responses_stream_maps_mantle_reasoning_delta() {
1674 let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
1675 "type": "response.reasoning.delta",
1676 "delta": "checking the contract terms"
1677 }))
1678 .unwrap();
1679
1680 let mapped = map_response_events(vec![event]);
1681 assert!(matches!(
1682 &mapped[0],
1683 LanguageModelCompletionEvent::Thinking { text, signature: None }
1684 if text == "checking the contract terms"
1685 ));
1686 }
1687
1688 #[test]
1689 fn response_usage_deserializes_cached_tokens() -> Result<()> {
1690 let usage: ResponseUsage = serde_json::from_value(json!({
1691 "input_tokens": 5,
1692 "input_tokens_details": {
1693 "cached_tokens": 2,
1694 },
1695 "output_tokens": 3,
1696 "output_tokens_details": {
1697 "reasoning_tokens": 1,
1698 },
1699 "total_tokens": 8,
1700 }))?;
1701
1702 assert_eq!(usage.output_tokens_details.reasoning_tokens, 1);
1703 assert_eq!(
1704 token_usage_from_response_usage(&usage),
1705 TokenUsage {
1706 input_tokens: 3,
1707 output_tokens: 3,
1708 cache_creation_input_tokens: 0,
1709 cache_read_input_tokens: 2,
1710 }
1711 );
1712
1713 Ok(())
1714 }
1715
1716 #[test]
1717 fn responses_custom_tool_wire_types_round_trip() -> Result<()> {
1718 let tool_json = json!({
1719 "type": "custom",
1720 "name": "apply_patch",
1721 "description": "Apply a patch",
1722 "format": {
1723 "type": "grammar",
1724 "syntax": "lark",
1725 "definition": "start: /.+/"
1726 }
1727 });
1728 let tool: ToolDefinition = serde_json::from_value(tool_json.clone())?;
1729 assert_eq!(serde_json::to_value(tool)?, tool_json);
1730
1731 let text_tool_json = json!({
1732 "type": "custom",
1733 "name": "write_text",
1734 "format": { "type": "text" }
1735 });
1736 let text_tool: ToolDefinition = serde_json::from_value(text_tool_json.clone())?;
1737 assert_eq!(serde_json::to_value(text_tool)?, text_tool_json);
1738
1739 let input_json = json!({
1740 "type": "custom_tool_call",
1741 "id": "ctc_1",
1742 "call_id": "call_abc",
1743 "name": "apply_patch",
1744 "input": "*** Begin Patch\n*** End Patch"
1745 });
1746 let input: ResponseInputItem = serde_json::from_value(input_json.clone())?;
1747 assert_eq!(serde_json::to_value(input)?, input_json);
1748
1749 let output_json = json!({
1750 "type": "custom_tool_call_output",
1751 "call_id": "call_abc",
1752 "output": "ok"
1753 });
1754 let output: ResponseInputItem = serde_json::from_value(output_json.clone())?;
1755 assert_eq!(serde_json::to_value(output)?, output_json);
1756
1757 let output_item_json = json!({
1758 "id": "ctc_1",
1759 "type": "custom_tool_call",
1760 "status": "completed",
1761 "call_id": "call_abc",
1762 "name": "apply_patch",
1763 "input": "*** Begin Patch\n*** End Patch"
1764 });
1765 let output_item: ResponseOutputItem = serde_json::from_value(output_item_json.clone())?;
1766 assert_eq!(serde_json::to_value(output_item)?, output_item_json);
1767
1768 let delta_json = json!({
1769 "type": "response.custom_tool_call_input.delta",
1770 "output_index": 0,
1771 "item_id": "ctc_1",
1772 "sequence_number": 5,
1773 "delta": "chunk"
1774 });
1775 let delta: ResponsesStreamEvent = serde_json::from_value(delta_json)?;
1776 assert!(matches!(
1777 delta,
1778 ResponsesStreamEvent::CustomToolCallInputDelta {
1779 output_index: 0,
1780 sequence_number: Some(5),
1781 ref item_id,
1782 ref delta,
1783 } if item_id == "ctc_1" && delta == "chunk"
1784 ));
1785
1786 let done_json = json!({
1787 "type": "response.custom_tool_call_input.done",
1788 "output_index": 0,
1789 "item_id": "ctc_1",
1790 "sequence_number": 6,
1791 "input": "full text"
1792 });
1793 let done: ResponsesStreamEvent = serde_json::from_value(done_json)?;
1794 assert!(matches!(
1795 done,
1796 ResponsesStreamEvent::CustomToolCallInputDone {
1797 output_index: 0,
1798 sequence_number: Some(6),
1799 ref item_id,
1800 ref input,
1801 } if item_id == "ctc_1" && input == "full text"
1802 ));
1803
1804 Ok(())
1805 }
1806
1807 #[test]
1808 fn into_open_ai_response_builds_complete_payload() {
1809 let tool_call_id = LanguageModelToolUseId::from("call-42");
1810 let tool_input = json!({ "city": "Boston" });
1811 let tool_arguments = serde_json::to_string(&tool_input).unwrap();
1812 let tool_use = LanguageModelToolUse {
1813 id: tool_call_id.clone(),
1814 name: Arc::from("get_weather"),
1815 raw_input: tool_arguments.clone(),
1816 input: LanguageModelToolUseInput::Json(tool_input),
1817 is_input_complete: true,
1818 thought_signature: None,
1819 };
1820 let tool_result = LanguageModelToolResult {
1821 tool_use_id: tool_call_id,
1822 tool_name: Arc::from("get_weather"),
1823 is_error: false,
1824 content: vec![LanguageModelToolResultContent::Text(Arc::from("Sunny"))],
1825 output: Some(json!({ "forecast": "Sunny" })),
1826 };
1827 let user_image = LanguageModelImage {
1828 source: SharedString::from("aGVsbG8="),
1829 };
1830 let expected_image_url = user_image.to_base64_url();
1831
1832 let request = LanguageModelRequest {
1833 thread_id: Some("thread-123".into()),
1834 prompt_id: None,
1835 intent: None,
1836 messages: vec![
1837 LanguageModelRequestMessage {
1838 role: Role::System,
1839 content: vec![MessageContent::Text("System context".into())],
1840 cache: false,
1841 reasoning_details: None,
1842 },
1843 LanguageModelRequestMessage {
1844 role: Role::User,
1845 content: vec![
1846 MessageContent::Text("Please check the weather.".into()),
1847 MessageContent::Image(user_image),
1848 ],
1849 cache: false,
1850 reasoning_details: None,
1851 },
1852 LanguageModelRequestMessage {
1853 role: Role::Assistant,
1854 content: vec![
1855 MessageContent::Text("Looking that up.".into()),
1856 MessageContent::ToolUse(tool_use),
1857 ],
1858 cache: false,
1859 reasoning_details: None,
1860 },
1861 LanguageModelRequestMessage {
1862 role: Role::Assistant,
1863 content: vec![MessageContent::ToolResult(tool_result)],
1864 cache: false,
1865 reasoning_details: None,
1866 },
1867 ],
1868 tools: vec![LanguageModelRequestTool::function(
1869 "get_weather".into(),
1870 "Fetches the weather".into(),
1871 json!({ "type": "object" }),
1872 false,
1873 )],
1874 tool_choice: Some(LanguageModelToolChoice::Any),
1875 stop: vec!["<STOP>".into()],
1876 temperature: None,
1877 thinking_allowed: true,
1878 thinking_effort: Some("high".into()),
1879 speed: None,
1880 compact_at_tokens: None,
1881 };
1882
1883 let response = into_open_ai_response(
1884 request,
1885 "custom-model",
1886 true,
1887 true,
1888 Some(2048),
1889 Some(ReasoningEffort::Low),
1890 false,
1891 &OPEN_AI_PROVIDER_ID,
1892 )
1893 .unwrap();
1894
1895 let serialized = serde_json::to_value(&response).unwrap();
1896 let expected = json!({
1897 "model": "custom-model",
1898 "instructions": "System context",
1899 "input": [
1900 {
1901 "type": "message",
1902 "role": "user",
1903 "content": [
1904 { "type": "input_text", "text": "Please check the weather." },
1905 { "type": "input_image", "image_url": expected_image_url }
1906 ]
1907 },
1908 {
1909 "type": "message",
1910 "role": "assistant",
1911 "content": [
1912 { "type": "output_text", "text": "Looking that up.", "annotations": [] }
1913 ]
1914 },
1915 {
1916 "type": "function_call",
1917 "call_id": "call-42",
1918 "name": "get_weather",
1919 "arguments": tool_arguments
1920 },
1921 {
1922 "type": "function_call_output",
1923 "call_id": "call-42",
1924 "output": "Sunny"
1925 }
1926 ],
1927 "store": false,
1928 "include": ["reasoning.encrypted_content"],
1929 "stream": true,
1930 "max_output_tokens": 2048,
1931 "parallel_tool_calls": true,
1932 "tool_choice": "required",
1933 "tools": [
1934 {
1935 "type": "function",
1936 "name": "get_weather",
1937 "description": "Fetches the weather",
1938 "parameters": { "type": "object" }
1939 }
1940 ],
1941 "prompt_cache_key": "thread-123",
1942 "reasoning": { "effort": "high", "summary": "auto" }
1943 });
1944
1945 assert_eq!(serialized, expected);
1946 }
1947
1948 #[test]
1949 fn responses_stream_maps_custom_tool_input() {
1950 let events = vec![
1951 ResponsesStreamEvent::OutputItemAdded {
1952 output_index: 0,
1953 sequence_number: None,
1954 item: response_item_custom_tool_call("ctc_1", ""),
1955 },
1956 ResponsesStreamEvent::CustomToolCallInputDelta {
1957 item_id: "ctc_1".into(),
1958 output_index: 0,
1959 delta: "*** Begin".into(),
1960 sequence_number: Some(1),
1961 },
1962 ResponsesStreamEvent::CustomToolCallInputDelta {
1963 item_id: "ctc_1".into(),
1964 output_index: 0,
1965 delta: " Patch".into(),
1966 sequence_number: Some(2),
1967 },
1968 ResponsesStreamEvent::CustomToolCallInputDone {
1969 item_id: "ctc_1".into(),
1970 output_index: 0,
1971 input: "*** Begin Patch".into(),
1972 sequence_number: Some(3),
1973 },
1974 ResponsesStreamEvent::OutputItemDone {
1975 output_index: 0,
1976 sequence_number: None,
1977 item: response_item_custom_tool_call("ctc_1", "*** Begin Patch"),
1978 },
1979 ResponsesStreamEvent::Completed {
1980 response: ResponseSummary::default(),
1981 },
1982 ];
1983
1984 let mapped = map_response_events(events);
1985 assert_eq!(
1986 mapped,
1987 vec![
1988 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
1989 id: LanguageModelToolUseId::from("call_abc"),
1990 name: Arc::from("apply_patch"),
1991 raw_input: "*** Begin".into(),
1992 input: LanguageModelToolUseInput::Text("*** Begin".into()),
1993 is_input_complete: false,
1994 thought_signature: None,
1995 }),
1996 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
1997 id: LanguageModelToolUseId::from("call_abc"),
1998 name: Arc::from("apply_patch"),
1999 raw_input: "*** Begin Patch".into(),
2000 input: LanguageModelToolUseInput::Text("*** Begin Patch".into()),
2001 is_input_complete: false,
2002 thought_signature: None,
2003 }),
2004 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
2005 id: LanguageModelToolUseId::from("call_abc"),
2006 name: Arc::from("apply_patch"),
2007 raw_input: "*** Begin Patch".into(),
2008 input: LanguageModelToolUseInput::Text("*** Begin Patch".into()),
2009 is_input_complete: true,
2010 thought_signature: None,
2011 }),
2012 LanguageModelCompletionEvent::Stop(StopReason::ToolUse),
2013 ]
2014 );
2015 }
2016
2017 #[test]
2018 fn into_open_ai_response_replays_custom_tool_calls() {
2019 let tool_call_id = LanguageModelToolUseId::from("call_abc");
2020 let raw_input = "*** Begin Patch\n*** End Patch".to_string();
2021 let tool_use = LanguageModelToolUse {
2022 id: tool_call_id.clone(),
2023 name: Arc::from("apply_patch"),
2024 raw_input: raw_input.clone(),
2025 input: LanguageModelToolUseInput::Text(raw_input.clone()),
2026 is_input_complete: true,
2027 thought_signature: None,
2028 };
2029 let tool_result = LanguageModelToolResult {
2030 tool_use_id: tool_call_id,
2031 tool_name: Arc::from("apply_patch"),
2032 is_error: false,
2033 content: vec![LanguageModelToolResultContent::Text(Arc::from("ok"))],
2034 output: None,
2035 };
2036
2037 let request = LanguageModelRequest {
2038 thread_id: None,
2039 prompt_id: None,
2040 intent: None,
2041 messages: vec![LanguageModelRequestMessage {
2042 role: Role::Assistant,
2043 content: vec![
2044 MessageContent::ToolUse(tool_use),
2045 MessageContent::ToolResult(tool_result),
2046 ],
2047 cache: false,
2048 reasoning_details: None,
2049 }],
2050 tools: vec![LanguageModelRequestTool {
2051 name: "apply_patch".into(),
2052 description: "Apply a patch".into(),
2053 input: LanguageModelRequestToolInput::Custom {
2054 format: Some(LanguageModelCustomToolFormat::Grammar {
2055 syntax: LanguageModelCustomToolGrammarSyntax::Lark,
2056 definition: "start: /.+/".into(),
2057 }),
2058 },
2059 }],
2060 tool_choice: None,
2061 stop: Vec::new(),
2062 temperature: None,
2063 thinking_allowed: false,
2064 thinking_effort: None,
2065 speed: None,
2066 compact_at_tokens: None,
2067 };
2068
2069 let response = into_open_ai_response(
2070 request,
2071 "custom-model",
2072 false,
2073 false,
2074 None,
2075 None,
2076 false,
2077 &OPEN_AI_PROVIDER_ID,
2078 )
2079 .unwrap();
2080 let serialized = serde_json::to_value(response).unwrap();
2081 assert_eq!(
2082 serialized,
2083 json!({
2084 "model": "custom-model",
2085 "input": [
2086 {
2087 "type": "custom_tool_call",
2088 "call_id": "call_abc",
2089 "name": "apply_patch",
2090 "input": raw_input
2091 },
2092 {
2093 "type": "custom_tool_call_output",
2094 "call_id": "call_abc",
2095 "output": "ok"
2096 }
2097 ],
2098 "store": false,
2099 "stream": true,
2100 "parallel_tool_calls": false,
2101 "tools": [
2102 {
2103 "type": "custom",
2104 "name": "apply_patch",
2105 "description": "Apply a patch",
2106 "format": {
2107 "type": "grammar",
2108 "syntax": "lark",
2109 "definition": "start: /.+/"
2110 }
2111 }
2112 ]
2113 })
2114 );
2115 }
2116
2117 #[test]
2118 fn into_open_ai_response_replays_encrypted_reasoning_details() {
2119 let tool_call_id = LanguageModelToolUseId::from("call-42");
2120 let tool_arguments = "{\"city\":\"Boston\"}".to_string();
2121 let tool_use = LanguageModelToolUse {
2122 id: tool_call_id,
2123 name: Arc::from("get_weather"),
2124 raw_input: tool_arguments.clone(),
2125 input: LanguageModelToolUseInput::Json(json!({ "city": "Boston" })),
2126 is_input_complete: true,
2127 thought_signature: None,
2128 };
2129
2130 let request = LanguageModelRequest {
2131 thread_id: None,
2132 prompt_id: None,
2133 intent: None,
2134 messages: vec![LanguageModelRequestMessage {
2135 role: Role::Assistant,
2136 content: vec![MessageContent::ToolUse(tool_use)],
2137 cache: false,
2138 reasoning_details: Some(Arc::new(json!({
2139 "reasoning_items": [
2140 {
2141 "id": "rs_123",
2142 "summary": [
2143 {
2144 "type": "summary_text",
2145 "text": "Checked what information is needed."
2146 }
2147 ],
2148 "content": [
2149 {
2150 "type": "reasoning_text",
2151 "text": "Internal reasoning text."
2152 }
2153 ],
2154 "encrypted_content": "ENC",
2155 "status": "completed",
2156 }
2157 ]
2158 }))),
2159 }],
2160 tools: Vec::new(),
2161 tool_choice: None,
2162 stop: Vec::new(),
2163 temperature: None,
2164 thinking_allowed: false,
2165 thinking_effort: None,
2166 speed: None,
2167 compact_at_tokens: None,
2168 };
2169
2170 let response = into_open_ai_response(
2171 request,
2172 "gpt-5",
2173 true,
2174 true,
2175 None,
2176 Some(ReasoningEffort::Low),
2177 false,
2178 &OPEN_AI_PROVIDER_ID,
2179 )
2180 .unwrap();
2181
2182 let serialized = serde_json::to_value(&response).unwrap();
2183 assert_eq!(
2184 serialized["input"],
2185 json!([
2186 {
2187 "type": "reasoning",
2188 "id": "rs_123",
2189 "summary": [
2190 {
2191 "type": "summary_text",
2192 "text": "Checked what information is needed."
2193 }
2194 ],
2195 "content": [
2196 {
2197 "type": "reasoning_text",
2198 "text": "Internal reasoning text."
2199 }
2200 ],
2201 "encrypted_content": "ENC",
2202 "status": "completed"
2203 },
2204 {
2205 "type": "function_call",
2206 "call_id": "call-42",
2207 "name": "get_weather",
2208 "arguments": tool_arguments
2209 }
2210 ])
2211 );
2212 assert_eq!(
2213 serialized["include"],
2214 json!(["reasoning.encrypted_content"])
2215 );
2216 assert_eq!(serialized.get("reasoning"), None);
2217 }
2218
2219 #[test]
2220 fn into_open_ai_response_replays_reasoning_without_encrypted_content() {
2221 let request = LanguageModelRequest {
2222 thread_id: None,
2223 prompt_id: None,
2224 intent: None,
2225 messages: vec![LanguageModelRequestMessage {
2226 role: Role::Assistant,
2227 content: vec![MessageContent::Text("Done.".into())],
2228 cache: false,
2229 reasoning_details: Some(Arc::new(json!({
2230 "reasoning_items": [
2231 {
2232 "id": "rs_123",
2233 "summary": [],
2234 "status": "completed"
2235 },
2236 {
2237 "id": "rs_456",
2238 "summary": [],
2239 "encrypted_content": "",
2240 "status": "completed"
2241 }
2242 ]
2243 }))),
2244 }],
2245 tools: Vec::new(),
2246 tool_choice: None,
2247 stop: Vec::new(),
2248 temperature: None,
2249 thinking_allowed: false,
2250 thinking_effort: None,
2251 speed: None,
2252 compact_at_tokens: None,
2253 };
2254
2255 let response = into_open_ai_response(
2256 request,
2257 "custom-model",
2258 false,
2259 false,
2260 None,
2261 None,
2262 false,
2263 &OPEN_AI_PROVIDER_ID,
2264 )
2265 .unwrap();
2266 let serialized = serde_json::to_value(&response).unwrap();
2267
2268 assert_eq!(
2269 serialized["input"],
2270 json!([
2271 {
2272 "type": "reasoning",
2273 "id": "rs_123",
2274 "summary": [],
2275 "status": "completed"
2276 },
2277 {
2278 "type": "reasoning",
2279 "id": "rs_456",
2280 "summary": [],
2281 "encrypted_content": "",
2282 "status": "completed"
2283 },
2284 {
2285 "type": "message",
2286 "role": "assistant",
2287 "content": [
2288 {
2289 "type": "output_text",
2290 "text": "Done.",
2291 "annotations": []
2292 }
2293 ]
2294 }
2295 ])
2296 );
2297 }
2298
2299 #[test]
2300 fn into_open_ai_response_omits_reasoning_when_thinking_is_disabled_and_none_is_unsupported() {
2301 let request = LanguageModelRequest {
2302 thread_id: None,
2303 prompt_id: None,
2304 intent: None,
2305 messages: vec![LanguageModelRequestMessage {
2306 role: Role::User,
2307 content: vec![MessageContent::Text("Hello".into())],
2308 cache: false,
2309 reasoning_details: None,
2310 }],
2311 tools: Vec::new(),
2312 tool_choice: None,
2313 stop: Vec::new(),
2314 temperature: None,
2315 thinking_allowed: false,
2316 thinking_effort: Some("high".into()),
2317 speed: None,
2318 compact_at_tokens: None,
2319 };
2320
2321 let response = into_open_ai_response(
2322 request,
2323 "gpt-5",
2324 true,
2325 true,
2326 None,
2327 Some(ReasoningEffort::Medium),
2328 false,
2329 &OPEN_AI_PROVIDER_ID,
2330 )
2331 .unwrap();
2332
2333 let serialized = serde_json::to_value(&response).unwrap();
2334 assert_eq!(serialized.get("reasoning"), None);
2335 }
2336
2337 /// `Speed::Fast` should translate to `service_tier: "priority"` on the
2338 /// outgoing Responses request, while `Standard` / `None` should leave the
2339 /// field unset so the project's default tier wins.
2340 #[test]
2341 fn into_open_ai_response_sets_service_tier_for_fast_speed() -> Result<()> {
2342 for (speed, expected) in [
2343 (None, None),
2344 (Some(Speed::Standard), None),
2345 (Some(Speed::Fast), Some("priority")),
2346 ] {
2347 let request = LanguageModelRequest {
2348 thread_id: None,
2349 prompt_id: None,
2350 intent: None,
2351 messages: vec![LanguageModelRequestMessage {
2352 role: Role::User,
2353 content: vec![MessageContent::Text("Hello".into())],
2354 cache: false,
2355 reasoning_details: None,
2356 }],
2357 tools: Vec::new(),
2358 tool_choice: None,
2359 stop: Vec::new(),
2360 temperature: None,
2361 thinking_allowed: false,
2362 thinking_effort: None,
2363 speed,
2364 compact_at_tokens: None,
2365 };
2366
2367 let response = into_open_ai_response(
2368 request,
2369 "gpt-5.4",
2370 true,
2371 true,
2372 None,
2373 None,
2374 true,
2375 &OPEN_AI_PROVIDER_ID,
2376 )
2377 .unwrap();
2378
2379 let serialized = serde_json::to_value(&response)?;
2380 assert_eq!(
2381 serialized
2382 .get("service_tier")
2383 .and_then(|value| value.as_str()),
2384 expected,
2385 "speed = {speed:?} should produce service_tier = {expected:?}",
2386 );
2387 }
2388 Ok(())
2389 }
2390
2391 /// Same as above but for the Chat Completions code path.
2392 #[test]
2393 fn into_open_ai_sets_service_tier_for_fast_speed() -> Result<()> {
2394 for (speed, expected) in [
2395 (None, None),
2396 (Some(Speed::Standard), None),
2397 (Some(Speed::Fast), Some("priority")),
2398 ] {
2399 let request = LanguageModelRequest {
2400 thread_id: None,
2401 prompt_id: None,
2402 intent: None,
2403 messages: vec![LanguageModelRequestMessage {
2404 role: Role::User,
2405 content: vec![MessageContent::Text("Hello".into())],
2406 cache: false,
2407 reasoning_details: None,
2408 }],
2409 tools: Vec::new(),
2410 tool_choice: None,
2411 stop: Vec::new(),
2412 temperature: None,
2413 thinking_allowed: false,
2414 thinking_effort: None,
2415 speed,
2416 compact_at_tokens: None,
2417 };
2418
2419 let chat = into_open_ai(
2420 request,
2421 "gpt-5.4",
2422 true,
2423 true,
2424 None,
2425 ChatCompletionMaxTokensParameter::MaxCompletionTokens,
2426 None,
2427 false,
2428 )?;
2429
2430 let serialized = serde_json::to_value(&chat)?;
2431 assert_eq!(
2432 serialized
2433 .get("service_tier")
2434 .and_then(|value| value.as_str()),
2435 expected,
2436 "speed = {speed:?} should produce service_tier = {expected:?}",
2437 );
2438 }
2439 Ok(())
2440 }
2441
2442 #[test]
2443 fn into_open_ai_can_send_max_tokens_parameter() -> Result<()> {
2444 let request = LanguageModelRequest {
2445 thread_id: None,
2446 prompt_id: None,
2447 intent: None,
2448 messages: vec![LanguageModelRequestMessage {
2449 role: Role::User,
2450 content: vec![MessageContent::Text("Hello".into())],
2451 cache: false,
2452 reasoning_details: None,
2453 }],
2454 tools: Vec::new(),
2455 tool_choice: None,
2456 stop: Vec::new(),
2457 temperature: None,
2458 thinking_allowed: false,
2459 thinking_effort: None,
2460 speed: None,
2461 compact_at_tokens: None,
2462 };
2463
2464 let chat = into_open_ai(
2465 request,
2466 "compatible-model",
2467 false,
2468 false,
2469 Some(4096),
2470 ChatCompletionMaxTokensParameter::MaxTokens,
2471 None,
2472 false,
2473 )?;
2474
2475 let serialized = serde_json::to_value(&chat)?;
2476 assert_eq!(serialized.get("max_completion_tokens"), None);
2477 assert_eq!(serialized["max_tokens"], json!(4096));
2478 Ok(())
2479 }
2480
2481 #[test]
2482 fn into_open_ai_response_sends_none_reasoning_when_thinking_is_disabled() -> Result<()> {
2483 let request = LanguageModelRequest {
2484 thread_id: None,
2485 prompt_id: None,
2486 intent: None,
2487 messages: vec![LanguageModelRequestMessage {
2488 role: Role::User,
2489 content: vec![MessageContent::Text("Hello".into())],
2490 cache: false,
2491 reasoning_details: None,
2492 }],
2493 tools: Vec::new(),
2494 tool_choice: None,
2495 stop: Vec::new(),
2496 temperature: None,
2497 thinking_allowed: false,
2498 thinking_effort: Some("high".into()),
2499 speed: None,
2500 compact_at_tokens: None,
2501 };
2502
2503 let response = into_open_ai_response(
2504 request,
2505 "gpt-5.1",
2506 true,
2507 true,
2508 None,
2509 Some(ReasoningEffort::Medium),
2510 true,
2511 &OPEN_AI_PROVIDER_ID,
2512 )
2513 .unwrap();
2514
2515 let serialized = serde_json::to_value(&response)?;
2516 assert_eq!(serialized["reasoning"], json!({ "effort": "none" }));
2517 assert_eq!(serialized.get("include"), None);
2518
2519 Ok(())
2520 }
2521
2522 #[test]
2523 fn into_open_ai_response_uses_default_effort_when_selected_effort_is_none() -> Result<()> {
2524 let request = LanguageModelRequest {
2525 thread_id: None,
2526 prompt_id: None,
2527 intent: None,
2528 messages: vec![LanguageModelRequestMessage {
2529 role: Role::User,
2530 content: vec![MessageContent::Text("Hello".into())],
2531 cache: false,
2532 reasoning_details: None,
2533 }],
2534 tools: Vec::new(),
2535 tool_choice: None,
2536 stop: Vec::new(),
2537 temperature: None,
2538 thinking_allowed: true,
2539 thinking_effort: Some("none".into()),
2540 speed: None,
2541 compact_at_tokens: None,
2542 };
2543
2544 let response = into_open_ai_response(
2545 request,
2546 "gpt-5.1",
2547 true,
2548 true,
2549 None,
2550 Some(ReasoningEffort::Medium),
2551 true,
2552 &OPEN_AI_PROVIDER_ID,
2553 )
2554 .unwrap();
2555
2556 let serialized = serde_json::to_value(&response)?;
2557 assert_eq!(
2558 serialized["reasoning"],
2559 json!({ "effort": "medium", "summary": "auto" })
2560 );
2561
2562 Ok(())
2563 }
2564
2565 #[test]
2566 fn into_open_ai_response_replays_assistant_phase() {
2567 let request = LanguageModelRequest {
2568 thread_id: None,
2569 prompt_id: None,
2570 intent: None,
2571 messages: vec![LanguageModelRequestMessage {
2572 role: Role::Assistant,
2573 content: vec![MessageContent::Text("Done.".into())],
2574 cache: false,
2575 reasoning_details: Some(Arc::new(json!({
2576 "phase": "final_answer",
2577 "reasoning_items": [
2578 {
2579 "id": "rs_123",
2580 "summary": [],
2581 "encrypted_content": "ENC",
2582 "status": "completed"
2583 }
2584 ]
2585 }))),
2586 }],
2587 tools: Vec::new(),
2588 tool_choice: None,
2589 stop: Vec::new(),
2590 temperature: None,
2591 thinking_allowed: true,
2592 thinking_effort: None,
2593 speed: None,
2594 compact_at_tokens: None,
2595 };
2596
2597 let response = into_open_ai_response(
2598 request,
2599 "gpt-5.3-codex",
2600 true,
2601 true,
2602 None,
2603 Some(ReasoningEffort::Medium),
2604 false,
2605 &OPEN_AI_PROVIDER_ID,
2606 )
2607 .unwrap();
2608
2609 let serialized = serde_json::to_value(&response).unwrap();
2610 assert_eq!(
2611 serialized["input"],
2612 json!([
2613 {
2614 "type": "reasoning",
2615 "id": "rs_123",
2616 "summary": [],
2617 "encrypted_content": "ENC",
2618 "status": "completed"
2619 },
2620 {
2621 "type": "message",
2622 "role": "assistant",
2623 "content": [
2624 { "type": "output_text", "text": "Done.", "annotations": [] }
2625 ],
2626 "phase": "final_answer"
2627 }
2628 ])
2629 );
2630 }
2631
2632 #[test]
2633 fn into_open_ai_response_deduplicates_replayed_reasoning_items() {
2634 let first_reasoning_details = json!({
2635 "phase": "final_answer",
2636 "reasoning_items": [
2637 {
2638 "id": "rs_123",
2639 "summary": [],
2640 "encrypted_content": "ENC_OLD",
2641 "status": "in_progress"
2642 }
2643 ]
2644 });
2645 let second_reasoning_details = json!({
2646 "phase": "final_answer",
2647 "reasoning_items": [
2648 {
2649 "id": "rs_123",
2650 "summary": [
2651 {
2652 "type": "summary_text",
2653 "text": "Later metadata has the complete summary."
2654 }
2655 ],
2656 "encrypted_content": "ENC_NEW",
2657 "status": "completed"
2658 }
2659 ]
2660 });
2661 let request = LanguageModelRequest {
2662 thread_id: None,
2663 prompt_id: None,
2664 intent: None,
2665 messages: vec![
2666 LanguageModelRequestMessage {
2667 role: Role::Assistant,
2668 content: vec![MessageContent::Text("First.".into())],
2669 cache: false,
2670 reasoning_details: Some(Arc::new(first_reasoning_details)),
2671 },
2672 LanguageModelRequestMessage {
2673 role: Role::Assistant,
2674 content: vec![MessageContent::Text("Second.".into())],
2675 cache: false,
2676 reasoning_details: Some(Arc::new(second_reasoning_details)),
2677 },
2678 ],
2679 tools: Vec::new(),
2680 tool_choice: None,
2681 stop: Vec::new(),
2682 temperature: None,
2683 thinking_allowed: true,
2684 thinking_effort: None,
2685 speed: None,
2686 compact_at_tokens: None,
2687 };
2688
2689 let response = into_open_ai_response(
2690 request,
2691 "gpt-5.3-codex",
2692 true,
2693 true,
2694 None,
2695 Some(ReasoningEffort::Medium),
2696 false,
2697 &OPEN_AI_PROVIDER_ID,
2698 )
2699 .unwrap();
2700
2701 let serialized = serde_json::to_value(&response).unwrap();
2702 assert_eq!(
2703 serialized["input"],
2704 json!([
2705 {
2706 "type": "reasoning",
2707 "id": "rs_123",
2708 "summary": [
2709 {
2710 "type": "summary_text",
2711 "text": "Later metadata has the complete summary."
2712 }
2713 ],
2714 "encrypted_content": "ENC_NEW",
2715 "status": "completed"
2716 },
2717 {
2718 "type": "message",
2719 "role": "assistant",
2720 "content": [
2721 { "type": "output_text", "text": "First.", "annotations": [] }
2722 ],
2723 "phase": "final_answer"
2724 },
2725 {
2726 "type": "message",
2727 "role": "assistant",
2728 "content": [
2729 { "type": "output_text", "text": "Second.", "annotations": [] }
2730 ],
2731 "phase": "final_answer"
2732 }
2733 ])
2734 );
2735 }
2736
2737 #[test]
2738 fn into_open_ai_response_replays_reasoning_details_but_not_thinking_text() {
2739 let request = LanguageModelRequest {
2740 thread_id: None,
2741 prompt_id: None,
2742 intent: None,
2743 messages: vec![LanguageModelRequestMessage {
2744 role: Role::Assistant,
2745 content: vec![
2746 MessageContent::Thinking {
2747 text: "This is a reasoning summary, not assistant output.".into(),
2748 signature: None,
2749 },
2750 MessageContent::Text("This is visible assistant output.".into()),
2751 ],
2752 cache: false,
2753 reasoning_details: Some(Arc::new(json!({
2754 "reasoning_items": [
2755 {
2756 "id": "rs_123",
2757 "summary": [
2758 {
2759 "type": "summary_text",
2760 "text": "This is the reasoning summary to preserve."
2761 }
2762 ],
2763 "encrypted_content": "ENC",
2764 "status": "completed"
2765 }
2766 ]
2767 }))),
2768 }],
2769 tools: Vec::new(),
2770 tool_choice: None,
2771 stop: Vec::new(),
2772 temperature: None,
2773 thinking_allowed: false,
2774 thinking_effort: None,
2775 speed: None,
2776 compact_at_tokens: None,
2777 };
2778
2779 let response = into_open_ai_response(
2780 request,
2781 "custom-model",
2782 false,
2783 false,
2784 None,
2785 None,
2786 false,
2787 &OPEN_AI_PROVIDER_ID,
2788 )
2789 .unwrap();
2790 let serialized = serde_json::to_value(&response).unwrap();
2791
2792 assert_eq!(
2793 serialized["input"],
2794 json!([
2795 {
2796 "type": "reasoning",
2797 "id": "rs_123",
2798 "summary": [
2799 {
2800 "type": "summary_text",
2801 "text": "This is the reasoning summary to preserve."
2802 }
2803 ],
2804 "encrypted_content": "ENC",
2805 "status": "completed"
2806 },
2807 {
2808 "type": "message",
2809 "role": "assistant",
2810 "content": [
2811 {
2812 "type": "output_text",
2813 "text": "This is visible assistant output.",
2814 "annotations": []
2815 }
2816 ]
2817 }
2818 ])
2819 );
2820 assert_eq!(
2821 serialized["include"],
2822 json!(["reasoning.encrypted_content"])
2823 );
2824 }
2825
2826 #[test]
2827 fn responses_stream_maps_tool_calls() {
2828 let events = vec![
2829 ResponsesStreamEvent::OutputItemAdded {
2830 output_index: 0,
2831 sequence_number: None,
2832 item: response_item_function_call("item_fn", Some("{\"city\":\"Bos")),
2833 },
2834 ResponsesStreamEvent::FunctionCallArgumentsDelta {
2835 item_id: "item_fn".into(),
2836 output_index: 0,
2837 delta: "ton\"}".into(),
2838 sequence_number: None,
2839 },
2840 ResponsesStreamEvent::FunctionCallArgumentsDone {
2841 item_id: "item_fn".into(),
2842 output_index: 0,
2843 arguments: "{\"city\":\"Boston\"}".into(),
2844 sequence_number: None,
2845 },
2846 ResponsesStreamEvent::Completed {
2847 response: ResponseSummary::default(),
2848 },
2849 ];
2850
2851 let mapped = map_response_events(events);
2852 assert_eq!(mapped.len(), 3);
2853 assert!(matches!(
2854 mapped[0],
2855 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
2856 is_input_complete: false,
2857 ..
2858 })
2859 ));
2860 assert!(matches!(
2861 mapped[1],
2862 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
2863 ref id,
2864 ref name,
2865 ref raw_input,
2866 is_input_complete: true,
2867 ..
2868 }) if id.to_string() == "call_123"
2869 && name.as_ref() == "get_weather"
2870 && raw_input == "{\"city\":\"Boston\"}"
2871 ));
2872 assert!(matches!(
2873 mapped[2],
2874 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
2875 ));
2876 }
2877
2878 #[test]
2879 fn responses_stream_uses_max_tokens_stop_reason() {
2880 let events = vec![ResponsesStreamEvent::Incomplete {
2881 response: ResponseSummary {
2882 incomplete_details: Some(ResponseIncompleteDetails {
2883 reason: Some("max_tokens".into()),
2884 }),
2885 usage: Some(ResponseUsage {
2886 input_tokens: Some(10),
2887 output_tokens: Some(20),
2888 total_tokens: Some(30),
2889 ..Default::default()
2890 }),
2891 ..Default::default()
2892 },
2893 }];
2894
2895 let mapped = map_response_events(events);
2896 assert!(matches!(
2897 mapped[0],
2898 LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
2899 input_tokens: 10,
2900 output_tokens: 20,
2901 ..
2902 })
2903 ));
2904 assert!(matches!(
2905 mapped[1],
2906 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
2907 ));
2908 }
2909
2910 #[test]
2911 fn responses_stream_failed_uses_response_error_message() {
2912 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
2913 let mapped = mapper.map_event(ResponsesStreamEvent::Failed {
2914 response: ResponseSummary {
2915 status: Some("failed".into()),
2916 error: Some(ResponseError {
2917 code: Some("server_error".into()),
2918 message: "The model failed to generate a response.".into(),
2919 param: None,
2920 }),
2921 ..Default::default()
2922 },
2923 });
2924
2925 assert_eq!(mapped.len(), 1);
2926 let error = mapped.into_iter().next().unwrap().unwrap_err();
2927 assert_eq!(
2928 error.to_string(),
2929 "server_error: The model failed to generate a response."
2930 );
2931 }
2932
2933 #[test]
2934 fn responses_stream_deserializes_documented_error_event() {
2935 let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
2936 "type": "error",
2937 "code": "ERR_SOMETHING",
2938 "message": "Something went wrong",
2939 "param": null,
2940 "sequence_number": 1
2941 }))
2942 .expect("documented error event");
2943
2944 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
2945 let mapped = mapper.map_event(event);
2946
2947 assert_eq!(mapped.len(), 1);
2948 let error = mapped.into_iter().next().unwrap().unwrap_err();
2949 assert_eq!(error.to_string(), "ERR_SOMETHING: Something went wrong");
2950 }
2951
2952 #[test]
2953 fn responses_stream_deserializes_nested_error_event() {
2954 // In practice the Responses API often nests error fields under an
2955 // `error` object even though the public spec documents them at the top
2956 // level. Make sure we don't lose the message and code in that case.
2957 let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
2958 "type": "error",
2959 "error": {
2960 "type": "invalid_request_error",
2961 "code": "invalid_prompt",
2962 "message": "Your prompt was flagged.",
2963 "param": "input"
2964 },
2965 "sequence_number": 2
2966 }))
2967 .expect("nested error event");
2968
2969 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
2970 let mapped = mapper.map_event(event);
2971
2972 assert_eq!(mapped.len(), 1);
2973 let error = mapped.into_iter().next().unwrap().unwrap_err();
2974 assert_eq!(
2975 error.to_string(),
2976 "invalid_prompt: Your prompt was flagged."
2977 );
2978 }
2979
2980 #[test]
2981 fn responses_stream_maps_context_length_exceeded_to_prompt_too_large() {
2982 let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
2983 "type": "error",
2984 "error": {
2985 "type": "invalid_request_error",
2986 "code": "context_length_exceeded",
2987 "message": "Your input exceeds the context window of this model. Please adjust your input and try again.",
2988 "param": "input"
2989 },
2990 "sequence_number": 2
2991 }))
2992 .expect("nested error event");
2993
2994 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
2995 let mapped = mapper.map_event(event);
2996
2997 assert_eq!(mapped.len(), 1);
2998 let error = mapped.into_iter().next().unwrap().unwrap_err();
2999 assert!(matches!(
3000 error,
3001 LanguageModelCompletionError::PromptTooLarge { tokens: None }
3002 ));
3003 }
3004
3005 #[test]
3006 fn responses_stream_maps_failed_context_length_exceeded_to_prompt_too_large() {
3007 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
3008 let mapped = mapper.map_event(ResponsesStreamEvent::Failed {
3009 response: ResponseSummary {
3010 status: Some("failed".into()),
3011 error: Some(ResponseError {
3012 code: Some("context_length_exceeded".into()),
3013 message: "Your input exceeds the context window of this model.".into(),
3014 param: Some("input".into()),
3015 }),
3016 ..Default::default()
3017 },
3018 });
3019
3020 assert_eq!(mapped.len(), 1);
3021 let error = mapped.into_iter().next().unwrap().unwrap_err();
3022 assert!(matches!(
3023 error,
3024 LanguageModelCompletionError::PromptTooLarge { tokens: None }
3025 ));
3026 }
3027
3028 #[test]
3029 fn responses_stream_deserializes_response_error_event() {
3030 let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
3031 "type": "response.error",
3032 "error": {
3033 "code": "invalid_request_error",
3034 "message": "Invalid request."
3035 }
3036 }))
3037 .expect("response error event");
3038
3039 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
3040 let mapped = mapper.map_event(event);
3041
3042 assert_eq!(mapped.len(), 1);
3043 let error = mapped.into_iter().next().unwrap().unwrap_err();
3044 assert_eq!(error.to_string(), "invalid_request_error: Invalid request.");
3045 }
3046
3047 #[test]
3048 fn responses_stream_maps_refusal_events_to_refusal_stop() {
3049 let delta = serde_json::from_value::<ResponsesStreamEvent>(json!({
3050 "type": "response.refusal.delta",
3051 "item_id": "msg_123",
3052 "output_index": 0,
3053 "content_index": 0,
3054 "delta": "I can't help",
3055 "sequence_number": 1
3056 }))
3057 .expect("documented refusal delta event");
3058 let done = serde_json::from_value::<ResponsesStreamEvent>(json!({
3059 "type": "response.refusal.done",
3060 "item_id": "msg_123",
3061 "output_index": 0,
3062 "content_index": 0,
3063 "refusal": "I can't help with that.",
3064 "sequence_number": 2
3065 }))
3066 .expect("documented refusal done event");
3067
3068 let mapped = map_response_events(vec![
3069 delta,
3070 done,
3071 ResponsesStreamEvent::Completed {
3072 response: ResponseSummary::default(),
3073 },
3074 ]);
3075
3076 assert_eq!(mapped.len(), 1);
3077 assert!(matches!(
3078 mapped[0],
3079 LanguageModelCompletionEvent::Stop(StopReason::Refusal)
3080 ));
3081 }
3082
3083 #[test]
3084 fn responses_stream_maps_refusal_output_to_refusal_stop() {
3085 let mapped = map_response_events(vec![ResponsesStreamEvent::Completed {
3086 response: ResponseSummary {
3087 output: vec![ResponseOutputItem::Message(ResponseOutputMessage {
3088 id: Some("msg_123".into()),
3089 role: Some("assistant".into()),
3090 status: Some("completed".into()),
3091 content: vec![json!({
3092 "type": "refusal",
3093 "refusal": "I can't help with that."
3094 })],
3095 phase: None,
3096 })],
3097 ..Default::default()
3098 },
3099 }]);
3100
3101 assert_eq!(mapped.len(), 1);
3102 assert!(matches!(
3103 mapped[0],
3104 LanguageModelCompletionEvent::Stop(StopReason::Refusal)
3105 ));
3106 }
3107
3108 #[test]
3109 fn responses_stream_handles_multiple_tool_calls() {
3110 let events = vec![
3111 ResponsesStreamEvent::OutputItemAdded {
3112 output_index: 0,
3113 sequence_number: None,
3114 item: response_item_function_call("item_fn1", Some("{\"city\":\"NYC\"}")),
3115 },
3116 ResponsesStreamEvent::FunctionCallArgumentsDone {
3117 item_id: "item_fn1".into(),
3118 output_index: 0,
3119 arguments: "{\"city\":\"NYC\"}".into(),
3120 sequence_number: None,
3121 },
3122 ResponsesStreamEvent::OutputItemAdded {
3123 output_index: 1,
3124 sequence_number: None,
3125 item: response_item_function_call("item_fn2", Some("{\"city\":\"LA\"}")),
3126 },
3127 ResponsesStreamEvent::FunctionCallArgumentsDone {
3128 item_id: "item_fn2".into(),
3129 output_index: 1,
3130 arguments: "{\"city\":\"LA\"}".into(),
3131 sequence_number: None,
3132 },
3133 ResponsesStreamEvent::Completed {
3134 response: ResponseSummary::default(),
3135 },
3136 ];
3137
3138 let mapped = map_response_events(events);
3139 assert_eq!(mapped.len(), 3);
3140 assert!(matches!(
3141 mapped[0],
3142 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. })
3143 if raw_input == "{\"city\":\"NYC\"}"
3144 ));
3145 assert!(matches!(
3146 mapped[1],
3147 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. })
3148 if raw_input == "{\"city\":\"LA\"}"
3149 ));
3150 assert!(matches!(
3151 mapped[2],
3152 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
3153 ));
3154 }
3155
3156 #[test]
3157 fn responses_stream_handles_mixed_text_and_tool_calls() {
3158 let events = vec![
3159 ResponsesStreamEvent::OutputItemAdded {
3160 output_index: 0,
3161 sequence_number: None,
3162 item: response_item_message("msg_123"),
3163 },
3164 ResponsesStreamEvent::OutputTextDelta {
3165 item_id: "msg_123".into(),
3166 output_index: 0,
3167 content_index: Some(0),
3168 delta: "Let me check that".into(),
3169 },
3170 ResponsesStreamEvent::OutputItemAdded {
3171 output_index: 1,
3172 sequence_number: None,
3173 item: response_item_function_call("item_fn", Some("{\"query\":\"test\"}")),
3174 },
3175 ResponsesStreamEvent::FunctionCallArgumentsDone {
3176 item_id: "item_fn".into(),
3177 output_index: 1,
3178 arguments: "{\"query\":\"test\"}".into(),
3179 sequence_number: None,
3180 },
3181 ResponsesStreamEvent::Completed {
3182 response: ResponseSummary::default(),
3183 },
3184 ];
3185
3186 let mapped = map_response_events(events);
3187 assert!(matches!(
3188 mapped[0],
3189 LanguageModelCompletionEvent::StartMessage { .. }
3190 ));
3191 assert!(
3192 matches!(mapped[1], LanguageModelCompletionEvent::Text(ref text) if text == "Let me check that")
3193 );
3194 assert!(
3195 matches!(mapped[2], LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. }) if raw_input == "{\"query\":\"test\"}")
3196 );
3197 assert!(matches!(
3198 mapped[3],
3199 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
3200 ));
3201 }
3202
3203 #[test]
3204 fn responses_stream_handles_json_parse_error() {
3205 let events = vec![
3206 ResponsesStreamEvent::OutputItemAdded {
3207 output_index: 0,
3208 sequence_number: None,
3209 item: response_item_function_call("item_fn", Some("{invalid json")),
3210 },
3211 ResponsesStreamEvent::FunctionCallArgumentsDone {
3212 item_id: "item_fn".into(),
3213 output_index: 0,
3214 arguments: "{invalid json".into(),
3215 sequence_number: None,
3216 },
3217 ResponsesStreamEvent::Completed {
3218 response: ResponseSummary::default(),
3219 },
3220 ];
3221
3222 let mapped = map_response_events(events);
3223 assert!(matches!(
3224 mapped[0],
3225 LanguageModelCompletionEvent::ToolUseJsonParseError { ref raw_input, .. }
3226 if raw_input.as_ref() == "{invalid json"
3227 ));
3228 }
3229
3230 #[test]
3231 fn responses_stream_handles_incomplete_function_call() {
3232 let events = vec![
3233 ResponsesStreamEvent::OutputItemAdded {
3234 output_index: 0,
3235 sequence_number: None,
3236 item: response_item_function_call("item_fn", Some("{\"city\":")),
3237 },
3238 ResponsesStreamEvent::FunctionCallArgumentsDelta {
3239 item_id: "item_fn".into(),
3240 output_index: 0,
3241 delta: "\"Boston\"".into(),
3242 sequence_number: None,
3243 },
3244 ResponsesStreamEvent::Incomplete {
3245 response: ResponseSummary {
3246 incomplete_details: Some(ResponseIncompleteDetails {
3247 reason: Some("max_tokens".into()),
3248 }),
3249 output: vec![response_item_function_call(
3250 "item_fn",
3251 Some("{\"city\":\"Boston\"}"),
3252 )],
3253 ..Default::default()
3254 },
3255 },
3256 ];
3257
3258 let mapped = map_response_events(events);
3259 assert_eq!(mapped.len(), 3);
3260 assert!(matches!(
3261 mapped[0],
3262 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
3263 is_input_complete: false,
3264 ..
3265 })
3266 ));
3267 assert!(
3268 matches!(mapped[1], LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, is_input_complete: true, .. }) if raw_input == "{\"city\":\"Boston\"}")
3269 );
3270 assert!(matches!(
3271 mapped[2],
3272 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
3273 ));
3274 }
3275
3276 #[test]
3277 fn responses_stream_incomplete_does_not_duplicate_tool_calls() {
3278 let events = vec![
3279 ResponsesStreamEvent::OutputItemAdded {
3280 output_index: 0,
3281 sequence_number: None,
3282 item: response_item_function_call("item_fn", Some("{\"city\":\"Boston\"}")),
3283 },
3284 ResponsesStreamEvent::FunctionCallArgumentsDone {
3285 item_id: "item_fn".into(),
3286 output_index: 0,
3287 arguments: "{\"city\":\"Boston\"}".into(),
3288 sequence_number: None,
3289 },
3290 ResponsesStreamEvent::Incomplete {
3291 response: ResponseSummary {
3292 incomplete_details: Some(ResponseIncompleteDetails {
3293 reason: Some("max_tokens".into()),
3294 }),
3295 output: vec![response_item_function_call(
3296 "item_fn",
3297 Some("{\"city\":\"Boston\"}"),
3298 )],
3299 ..Default::default()
3300 },
3301 },
3302 ];
3303
3304 let mapped = map_response_events(events);
3305 assert_eq!(mapped.len(), 2);
3306 assert!(
3307 matches!(mapped[0], LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. }) if raw_input == "{\"city\":\"Boston\"}")
3308 );
3309 assert!(matches!(
3310 mapped[1],
3311 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
3312 ));
3313 }
3314
3315 #[test]
3316 fn responses_stream_handles_empty_tool_arguments() {
3317 let events = vec![
3318 ResponsesStreamEvent::OutputItemAdded {
3319 output_index: 0,
3320 sequence_number: None,
3321 item: response_item_function_call("item_fn", Some("")),
3322 },
3323 ResponsesStreamEvent::FunctionCallArgumentsDone {
3324 item_id: "item_fn".into(),
3325 output_index: 0,
3326 arguments: "".into(),
3327 sequence_number: None,
3328 },
3329 ResponsesStreamEvent::Completed {
3330 response: ResponseSummary::default(),
3331 },
3332 ];
3333
3334 let mapped = map_response_events(events);
3335 assert_eq!(mapped.len(), 2);
3336 assert!(matches!(
3337 &mapped[0],
3338 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
3339 id, name, raw_input, input, ..
3340 }) if id.to_string() == "call_123"
3341 && name.as_ref() == "get_weather"
3342 && raw_input == ""
3343 && matches!(input, LanguageModelToolUseInput::Json(value) if value.as_object().is_some_and(|object| object.is_empty()))
3344 ));
3345 assert!(matches!(
3346 mapped[1],
3347 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
3348 ));
3349 }
3350
3351 #[test]
3352 fn responses_stream_emits_partial_tool_use_events() {
3353 let events = vec![
3354 ResponsesStreamEvent::OutputItemAdded {
3355 output_index: 0,
3356 sequence_number: None,
3357 item: ResponseOutputItem::FunctionCall(
3358 crate::responses::ResponseFunctionToolCall {
3359 id: Some("item_fn".to_string()),
3360 status: Some("in_progress".to_string()),
3361 name: Some("get_weather".to_string()),
3362 call_id: Some("call_abc".to_string()),
3363 arguments: String::new(),
3364 },
3365 ),
3366 },
3367 ResponsesStreamEvent::FunctionCallArgumentsDelta {
3368 item_id: "item_fn".into(),
3369 output_index: 0,
3370 delta: "{\"city\":\"Bos".into(),
3371 sequence_number: None,
3372 },
3373 ResponsesStreamEvent::FunctionCallArgumentsDelta {
3374 item_id: "item_fn".into(),
3375 output_index: 0,
3376 delta: "ton\"}".into(),
3377 sequence_number: None,
3378 },
3379 ResponsesStreamEvent::FunctionCallArgumentsDone {
3380 item_id: "item_fn".into(),
3381 output_index: 0,
3382 arguments: "{\"city\":\"Boston\"}".into(),
3383 sequence_number: None,
3384 },
3385 ResponsesStreamEvent::Completed {
3386 response: ResponseSummary::default(),
3387 },
3388 ];
3389
3390 let mapped = map_response_events(events);
3391 assert!(mapped.len() >= 3);
3392
3393 let complete_tool_use = mapped.iter().find(|e| {
3394 matches!(
3395 e,
3396 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
3397 is_input_complete: true,
3398 ..
3399 })
3400 )
3401 });
3402 assert!(
3403 complete_tool_use.is_some(),
3404 "should have a complete tool use event"
3405 );
3406
3407 let tool_uses: Vec<_> = mapped
3408 .iter()
3409 .filter(|e| matches!(e, LanguageModelCompletionEvent::ToolUse(_)))
3410 .collect();
3411 assert!(
3412 tool_uses.len() >= 2,
3413 "should have at least one partial and one complete event"
3414 );
3415 assert!(matches!(
3416 tool_uses.last().unwrap(),
3417 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
3418 is_input_complete: true,
3419 ..
3420 })
3421 ));
3422 }
3423
3424 #[test]
3425 fn responses_stream_maps_reasoning_summary_deltas() {
3426 let events = vec![
3427 ResponsesStreamEvent::OutputItemAdded {
3428 output_index: 0,
3429 sequence_number: None,
3430 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3431 "rs_123",
3432 vec![],
3433 None,
3434 None,
3435 )),
3436 },
3437 ResponsesStreamEvent::ReasoningSummaryPartAdded {
3438 item_id: "rs_123".into(),
3439 output_index: 0,
3440 summary_index: 0,
3441 },
3442 ResponsesStreamEvent::ReasoningSummaryTextDelta {
3443 item_id: "rs_123".into(),
3444 output_index: 0,
3445 delta: "Thinking about".into(),
3446 },
3447 ResponsesStreamEvent::ReasoningSummaryTextDelta {
3448 item_id: "rs_123".into(),
3449 output_index: 0,
3450 delta: " the answer".into(),
3451 },
3452 ResponsesStreamEvent::ReasoningSummaryTextDone {
3453 item_id: "rs_123".into(),
3454 output_index: 0,
3455 text: "Thinking about the answer".into(),
3456 },
3457 ResponsesStreamEvent::ReasoningSummaryPartDone {
3458 item_id: "rs_123".into(),
3459 output_index: 0,
3460 summary_index: 0,
3461 },
3462 ResponsesStreamEvent::ReasoningSummaryPartAdded {
3463 item_id: "rs_123".into(),
3464 output_index: 0,
3465 summary_index: 1,
3466 },
3467 ResponsesStreamEvent::ReasoningSummaryTextDelta {
3468 item_id: "rs_123".into(),
3469 output_index: 0,
3470 delta: "Second part".into(),
3471 },
3472 ResponsesStreamEvent::ReasoningSummaryTextDone {
3473 item_id: "rs_123".into(),
3474 output_index: 0,
3475 text: "Second part".into(),
3476 },
3477 ResponsesStreamEvent::ReasoningSummaryPartDone {
3478 item_id: "rs_123".into(),
3479 output_index: 0,
3480 summary_index: 1,
3481 },
3482 ResponsesStreamEvent::OutputItemDone {
3483 output_index: 0,
3484 sequence_number: None,
3485 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3486 "rs_123",
3487 vec![
3488 ReasoningSummaryPart::SummaryText {
3489 text: "Thinking about the answer".into(),
3490 },
3491 ReasoningSummaryPart::SummaryText {
3492 text: "Second part".into(),
3493 },
3494 ],
3495 None,
3496 None,
3497 )),
3498 },
3499 ResponsesStreamEvent::OutputItemAdded {
3500 output_index: 1,
3501 sequence_number: None,
3502 item: response_item_message("msg_456"),
3503 },
3504 ResponsesStreamEvent::OutputTextDelta {
3505 item_id: "msg_456".into(),
3506 output_index: 1,
3507 content_index: Some(0),
3508 delta: "The answer is 42".into(),
3509 },
3510 ResponsesStreamEvent::Completed {
3511 response: ResponseSummary::default(),
3512 },
3513 ];
3514
3515 let mapped = map_response_events(events);
3516
3517 let thinking_events: Vec<_> = mapped
3518 .iter()
3519 .filter(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. }))
3520 .collect();
3521 assert_eq!(
3522 thinking_events.len(),
3523 4,
3524 "expected 4 thinking events, got {:?}",
3525 thinking_events
3526 );
3527 assert!(
3528 matches!(&thinking_events[0], LanguageModelCompletionEvent::Thinking { text, .. } if text == "Thinking about")
3529 );
3530 assert!(
3531 matches!(&thinking_events[1], LanguageModelCompletionEvent::Thinking { text, .. } if text == " the answer")
3532 );
3533 assert!(
3534 matches!(&thinking_events[2], LanguageModelCompletionEvent::Thinking { text, .. } if text == "\n\n"),
3535 "expected separator between summary parts"
3536 );
3537 assert!(
3538 matches!(&thinking_events[3], LanguageModelCompletionEvent::Thinking { text, .. } if text == "Second part")
3539 );
3540
3541 assert!(mapped.iter().any(
3542 |e| matches!(e, LanguageModelCompletionEvent::Text(t) if t == "The answer is 42")
3543 ));
3544 }
3545
3546 #[test]
3547 fn responses_stream_maps_reasoning_from_done_only() {
3548 let events = vec![
3549 ResponsesStreamEvent::OutputItemAdded {
3550 output_index: 0,
3551 sequence_number: None,
3552 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3553 "rs_789",
3554 vec![],
3555 None,
3556 None,
3557 )),
3558 },
3559 ResponsesStreamEvent::OutputItemDone {
3560 output_index: 0,
3561 sequence_number: None,
3562 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3563 "rs_789",
3564 vec![ReasoningSummaryPart::SummaryText {
3565 text: "Summary without deltas".into(),
3566 }],
3567 None,
3568 None,
3569 )),
3570 },
3571 ResponsesStreamEvent::Completed {
3572 response: ResponseSummary::default(),
3573 },
3574 ];
3575
3576 let mapped = map_response_events(events);
3577 assert!(
3578 !mapped
3579 .iter()
3580 .any(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })),
3581 "OutputItemDone reasoning should not produce Thinking events"
3582 );
3583 }
3584
3585 #[test]
3586 fn responses_stream_preserves_encrypted_reasoning_details() {
3587 let mut reasoning_item = response_reasoning_item(
3588 "rs_123",
3589 vec![ReasoningSummaryPart::SummaryText {
3590 text: "Checked what information is needed.".into(),
3591 }],
3592 Some("ENC"),
3593 Some("completed".into()),
3594 );
3595 reasoning_item.content = vec![json!({
3596 "type": "reasoning_text",
3597 "text": "Internal reasoning text."
3598 })];
3599
3600 let events = vec![
3601 ResponsesStreamEvent::OutputItemDone {
3602 output_index: 0,
3603 sequence_number: None,
3604 item: ResponseOutputItem::Reasoning(reasoning_item),
3605 },
3606 ResponsesStreamEvent::Completed {
3607 response: ResponseSummary::default(),
3608 },
3609 ];
3610
3611 let mapped = map_response_events(events);
3612 let details = mapped
3613 .iter()
3614 .find_map(|event| match event {
3615 LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details),
3616 _ => None,
3617 })
3618 .expect("reasoning details");
3619
3620 assert_eq!(
3621 details,
3622 &json!({
3623 "reasoning_items": [
3624 {
3625 "id": "rs_123",
3626 "summary": [
3627 {
3628 "type": "summary_text",
3629 "text": "Checked what information is needed."
3630 }
3631 ],
3632 "content": [
3633 {
3634 "type": "reasoning_text",
3635 "text": "Internal reasoning text."
3636 }
3637 ],
3638 "encrypted_content": "ENC",
3639 "status": "completed",
3640 }
3641 ]
3642 })
3643 );
3644 }
3645
3646 #[test]
3647 fn responses_stream_replaces_reasoning_details_with_same_id() {
3648 let events = vec![
3649 ResponsesStreamEvent::OutputItemDone {
3650 output_index: 0,
3651 sequence_number: None,
3652 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3653 "rs_123",
3654 Vec::new(),
3655 Some("ENC_OLD"),
3656 Some("in_progress".into()),
3657 )),
3658 },
3659 ResponsesStreamEvent::OutputItemDone {
3660 output_index: 0,
3661 sequence_number: None,
3662 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3663 "rs_123",
3664 vec![ReasoningSummaryPart::SummaryText {
3665 text: "Finished reasoning.".into(),
3666 }],
3667 Some("ENC_NEW"),
3668 Some("completed".into()),
3669 )),
3670 },
3671 ResponsesStreamEvent::Completed {
3672 response: ResponseSummary::default(),
3673 },
3674 ];
3675
3676 let mapped = map_response_events(events);
3677 let details = mapped
3678 .iter()
3679 .filter_map(|event| match event {
3680 LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details),
3681 _ => None,
3682 })
3683 .next_back()
3684 .expect("reasoning details");
3685
3686 assert_eq!(
3687 details,
3688 &json!({
3689 "reasoning_items": [
3690 {
3691 "id": "rs_123",
3692 "summary": [
3693 {
3694 "type": "summary_text",
3695 "text": "Finished reasoning."
3696 }
3697 ],
3698 "encrypted_content": "ENC_NEW",
3699 "status": "completed"
3700 }
3701 ]
3702 })
3703 );
3704 }
3705
3706 #[test]
3707 fn responses_stream_reemits_reasoning_details_after_phase_less_message_start() {
3708 let events = vec![
3709 ResponsesStreamEvent::OutputItemDone {
3710 output_index: 0,
3711 sequence_number: None,
3712 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3713 "rs_123",
3714 Vec::new(),
3715 Some("ENC"),
3716 Some("completed".into()),
3717 )),
3718 },
3719 ResponsesStreamEvent::OutputItemAdded {
3720 output_index: 1,
3721 sequence_number: None,
3722 item: ResponseOutputItem::Message(ResponseOutputMessage {
3723 id: Some("msg_123".into()),
3724 role: Some("assistant".into()),
3725 status: Some("in_progress".into()),
3726 content: vec![],
3727 phase: None,
3728 }),
3729 },
3730 ResponsesStreamEvent::OutputTextDelta {
3731 item_id: "msg_123".into(),
3732 output_index: 1,
3733 content_index: Some(0),
3734 delta: "Hello".into(),
3735 },
3736 ResponsesStreamEvent::Completed {
3737 response: ResponseSummary::default(),
3738 },
3739 ];
3740
3741 let mapped = map_response_events(events);
3742 let start_message_index = mapped
3743 .iter()
3744 .position(|event| matches!(event, LanguageModelCompletionEvent::StartMessage { .. }))
3745 .expect("start message");
3746 let details = mapped
3747 .iter()
3748 .skip(start_message_index + 1)
3749 .find_map(|event| match event {
3750 LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details),
3751 _ => None,
3752 })
3753 .expect("reasoning details after start message");
3754
3755 assert_eq!(
3756 details,
3757 &json!({
3758 "reasoning_items": [
3759 {
3760 "id": "rs_123",
3761 "summary": [],
3762 "encrypted_content": "ENC",
3763 "status": "completed"
3764 }
3765 ]
3766 })
3767 );
3768 }
3769
3770 #[test]
3771 fn responses_stream_preserves_assistant_phase_with_reasoning_details() {
3772 let events = vec![
3773 ResponsesStreamEvent::OutputItemAdded {
3774 output_index: 0,
3775 sequence_number: None,
3776 item: ResponseOutputItem::Message(ResponseOutputMessage {
3777 id: Some("msg_123".into()),
3778 role: Some("assistant".into()),
3779 status: Some("in_progress".into()),
3780 content: vec![],
3781 phase: Some("commentary".into()),
3782 }),
3783 },
3784 ResponsesStreamEvent::OutputTextDelta {
3785 item_id: "msg_123".into(),
3786 output_index: 0,
3787 content_index: Some(0),
3788 delta: "I will inspect the workspace.".into(),
3789 },
3790 ResponsesStreamEvent::OutputItemDone {
3791 output_index: 1,
3792 sequence_number: None,
3793 item: ResponseOutputItem::Reasoning(response_reasoning_item(
3794 "rs_123",
3795 Vec::new(),
3796 Some("ENC"),
3797 Some("completed".into()),
3798 )),
3799 },
3800 ResponsesStreamEvent::Completed {
3801 response: ResponseSummary::default(),
3802 },
3803 ];
3804
3805 let mapped = map_response_events(events);
3806 let details = mapped
3807 .iter()
3808 .filter_map(|event| match event {
3809 LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details),
3810 _ => None,
3811 })
3812 .next_back()
3813 .expect("reasoning details");
3814
3815 assert_eq!(
3816 details,
3817 &json!({
3818 "phase": "commentary",
3819 "reasoning_items": [
3820 {
3821 "id": "rs_123",
3822 "summary": [],
3823 "encrypted_content": "ENC",
3824 "status": "completed"
3825 }
3826 ]
3827 })
3828 );
3829 }
3830
3831 #[test]
3832 fn into_open_ai_interleaved_reasoning() {
3833 let tool_use_id = LanguageModelToolUseId::from("call-1");
3834 let tool_input = json!({"query": "foo"});
3835 let tool_arguments = serde_json::to_string(&tool_input).unwrap();
3836 let tool_use = LanguageModelToolUse {
3837 id: tool_use_id.clone(),
3838 name: Arc::from("search"),
3839 raw_input: tool_arguments.clone(),
3840 input: LanguageModelToolUseInput::Json(tool_input),
3841 is_input_complete: true,
3842 thought_signature: None,
3843 };
3844 let tool_result = LanguageModelToolResult {
3845 tool_use_id: tool_use_id,
3846 tool_name: Arc::from("search"),
3847 is_error: false,
3848 content: vec![LanguageModelToolResultContent::Text(Arc::from("result"))],
3849 output: None,
3850 };
3851 let request = LanguageModelRequest {
3852 thread_id: None,
3853 prompt_id: None,
3854 intent: None,
3855 messages: vec![
3856 LanguageModelRequestMessage {
3857 role: Role::User,
3858 content: vec![MessageContent::Text("search for something".into())],
3859 cache: false,
3860 reasoning_details: None,
3861 },
3862 LanguageModelRequestMessage {
3863 role: Role::Assistant,
3864 content: vec![
3865 MessageContent::Thinking {
3866 text: "I should search".into(),
3867 signature: None,
3868 },
3869 MessageContent::Text("Searching now.".into()),
3870 MessageContent::ToolUse(tool_use),
3871 ],
3872 cache: false,
3873 reasoning_details: None,
3874 },
3875 LanguageModelRequestMessage {
3876 role: Role::Assistant,
3877 content: vec![MessageContent::ToolResult(tool_result)],
3878 cache: false,
3879 reasoning_details: None,
3880 },
3881 ],
3882 tools: vec![],
3883 tool_choice: None,
3884 stop: vec![],
3885 temperature: None,
3886 thinking_allowed: true,
3887 thinking_effort: None,
3888 speed: None,
3889 compact_at_tokens: None,
3890 };
3891
3892 let result = into_open_ai(
3893 request.clone(),
3894 "model",
3895 false,
3896 false,
3897 None,
3898 ChatCompletionMaxTokensParameter::MaxCompletionTokens,
3899 None,
3900 true,
3901 )
3902 .unwrap();
3903 assert_eq!(
3904 serde_json::to_value(&result).unwrap()["messages"],
3905 json!([
3906 {"role": "user", "content": "search for something"},
3907 {
3908 "role": "assistant",
3909 "content": "Searching now.",
3910 "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "search", "arguments": tool_arguments}}],
3911 "reasoning_content": "I should search"
3912 },
3913 {"role": "tool", "content": "result", "tool_call_id": "call-1"}
3914 ])
3915 );
3916
3917 let result = into_open_ai(
3918 request,
3919 "model",
3920 false,
3921 false,
3922 None,
3923 ChatCompletionMaxTokensParameter::MaxCompletionTokens,
3924 None,
3925 false,
3926 )
3927 .unwrap();
3928 assert_eq!(
3929 serde_json::to_value(&result).unwrap()["messages"],
3930 json!([
3931 {"role": "user", "content": "search for something"},
3932 {
3933 "role": "assistant",
3934 "content": [
3935 {"type": "text", "text": "I should search"},
3936 {"type": "text", "text": "Searching now."}
3937 ],
3938 "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "search", "arguments": tool_arguments}}]
3939 },
3940 {"role": "tool", "content": "result", "tool_call_id": "call-1"}
3941 ])
3942 );
3943 }
3944
3945 #[test]
3946 fn stream_maps_reasoning() {
3947 let events = map_completion_events(vec![ResponseStreamEvent {
3948 choices: vec![ChoiceDelta {
3949 index: 0,
3950 delta: Some(ResponseMessageDelta {
3951 role: None,
3952 content: None,
3953 reasoning: Some("thinking".into()),
3954 tool_calls: None,
3955 reasoning_content: None,
3956 }),
3957 finish_reason: None,
3958 }],
3959 usage: None,
3960 }]);
3961
3962 assert_eq!(
3963 events,
3964 vec![LanguageModelCompletionEvent::Thinking {
3965 text: "thinking".into(),
3966 signature: None,
3967 }]
3968 );
3969 }
3970
3971 #[test]
3972 fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() {
3973 // DashScope sends id="" and name="" in subsequent tool_calls delta
3974 // chunks after the first chunk. OpenAiEventMapper must not overwrite
3975 // the accumulated id and name with these empty strings.
3976
3977 let events = vec![
3978 // First chunk: id and name are present
3979 ResponseStreamEvent {
3980 choices: vec![ChoiceDelta {
3981 index: 0,
3982 delta: Some(ResponseMessageDelta {
3983 role: None,
3984 content: None,
3985 reasoning: None,
3986 tool_calls: Some(vec![ToolCallChunk {
3987 index: 0,
3988 id: Some("call_dashscope_test".into()),
3989 function: Some(FunctionChunk {
3990 name: Some("list_directory".into()),
3991 arguments: Some("".into()),
3992 }),
3993 }]),
3994 reasoning_content: None,
3995 }),
3996 finish_reason: None,
3997 }],
3998 usage: None,
3999 },
4000 // Subsequent chunks: DashScope sends id="" and name=""
4001 ResponseStreamEvent {
4002 choices: vec![ChoiceDelta {
4003 index: 0,
4004 delta: Some(ResponseMessageDelta {
4005 role: None,
4006 content: None,
4007 reasoning: None,
4008 tool_calls: Some(vec![ToolCallChunk {
4009 index: 0,
4010 id: Some("".into()),
4011 function: Some(FunctionChunk {
4012 name: Some("".into()),
4013 arguments: Some("{\"path\": \"".into()),
4014 }),
4015 }]),
4016 reasoning_content: None,
4017 }),
4018 finish_reason: None,
4019 }],
4020 usage: None,
4021 },
4022 ResponseStreamEvent {
4023 choices: vec![ChoiceDelta {
4024 index: 0,
4025 delta: Some(ResponseMessageDelta {
4026 role: None,
4027 content: None,
4028 reasoning: None,
4029 tool_calls: Some(vec![ToolCallChunk {
4030 index: 0,
4031 id: Some("".into()),
4032 function: Some(FunctionChunk {
4033 name: Some("".into()),
4034 arguments: Some("blog-scraper\"}".into()),
4035 }),
4036 }]),
4037 reasoning_content: None,
4038 }),
4039 finish_reason: None,
4040 }],
4041 usage: None,
4042 },
4043 // Final chunk: finish_reason = "tool_calls"
4044 ResponseStreamEvent {
4045 choices: vec![ChoiceDelta {
4046 index: 0,
4047 delta: None,
4048 finish_reason: Some("tool_calls".into()),
4049 }],
4050 usage: None,
4051 },
4052 ];
4053
4054 let mapped = map_completion_events(events);
4055
4056 // Events emitted:
4057 // 1. Partial ToolUse from chunk 1 (fix_json("") → "{}", parseable)
4058 // 2. Partial ToolUse from chunk 3 (arguments fully assembled)
4059 // 3. Complete ToolUse from finish_reason="tool_calls" drain
4060 // 4. Stop(ToolUse)
4061 assert_eq!(mapped.len(), 4);
4062
4063 // Verify the complete ToolUse event (from finish_reason drain)
4064 // has the correct id, name, and accumulated arguments.
4065 let complete_tool_use = mapped.iter().find_map(|event| {
4066 if let LanguageModelCompletionEvent::ToolUse(tool_use) = event {
4067 if tool_use.is_input_complete {
4068 return Some(tool_use);
4069 }
4070 }
4071 None
4072 });
4073 assert!(
4074 complete_tool_use.is_some(),
4075 "expected a completed ToolUse event"
4076 );
4077 let tool_use = complete_tool_use.unwrap();
4078 assert_eq!(
4079 tool_use.id.to_string(),
4080 "call_dashscope_test",
4081 "id must survive empty-string overwrites"
4082 );
4083 assert_eq!(
4084 tool_use.name.as_ref(),
4085 "list_directory",
4086 "name must survive empty-string overwrites"
4087 );
4088 assert_eq!(
4089 tool_use.raw_input, "{\"path\": \"blog-scraper\"}",
4090 "arguments should accumulate across chunks"
4091 );
4092
4093 // Verify the Stop event
4094 assert!(mapped.iter().any(|event| {
4095 matches!(
4096 event,
4097 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
4098 )
4099 }));
4100 }
4101
4102 #[test]
4103 fn into_open_ai_response_prepends_provider_input_unchanged() {
4104 let provider_input = json!([
4105 {
4106 "type": "message",
4107 "role": "user",
4108 "content": "Retained user context.",
4109 "provider_extension": {"preserve": true}
4110 },
4111 {
4112 "type": "compaction",
4113 "id": "cmp_manual",
4114 "encrypted_content": "opaque-state"
4115 }
4116 ]);
4117 let request = LanguageModelRequest {
4118 messages: vec![
4119 LanguageModelRequestMessage {
4120 role: Role::Assistant,
4121 content: vec![MessageContent::Compaction(CompactedContext::ProviderState(
4122 provider_compaction_state_from_items(
4123 OPEN_AI_PROVIDER_ID,
4124 provider_input.as_array().unwrap().clone(),
4125 )
4126 .unwrap(),
4127 ))],
4128 cache: false,
4129 reasoning_details: None,
4130 },
4131 LanguageModelRequestMessage {
4132 role: Role::User,
4133 content: vec![MessageContent::Text("Continue.".into())],
4134 cache: false,
4135 reasoning_details: None,
4136 },
4137 ],
4138 ..Default::default()
4139 };
4140
4141 let response = into_open_ai_response(
4142 request,
4143 "gpt-5.4",
4144 true,
4145 true,
4146 None,
4147 None,
4148 false,
4149 &OPEN_AI_PROVIDER_ID,
4150 )
4151 .unwrap();
4152
4153 assert_eq!(
4154 serde_json::to_value(&response).unwrap()["input"],
4155 json!([
4156 {
4157 "type": "message",
4158 "role": "user",
4159 "content": "Retained user context.",
4160 "provider_extension": {"preserve": true}
4161 },
4162 {
4163 "type": "compaction",
4164 "id": "cmp_manual",
4165 "encrypted_content": "opaque-state"
4166 },
4167 {
4168 "type": "message",
4169 "role": "user",
4170 "content": [{"type": "input_text", "text": "Continue."}]
4171 }
4172 ])
4173 );
4174 }
4175
4176 #[test]
4177 fn open_ai_response_converts_to_compact_request_with_supported_controls() {
4178 let request = LanguageModelRequest {
4179 thread_id: Some("thread-123".to_string()),
4180 messages: vec![LanguageModelRequestMessage {
4181 role: Role::User,
4182 content: vec![MessageContent::Text("Retain this context.".into())],
4183 cache: false,
4184 reasoning_details: None,
4185 }],
4186 speed: Some(Speed::Fast),
4187 compact_at_tokens: Some(100_000),
4188 ..Default::default()
4189 };
4190
4191 let mut response_request = into_open_ai_response(
4192 request,
4193 "gpt-5.4",
4194 true,
4195 true,
4196 None,
4197 None,
4198 false,
4199 &OPEN_AI_PROVIDER_ID,
4200 )
4201 .unwrap();
4202 response_request.instructions = Some("Preserve implementation details.".to_string());
4203 let compact_request = response_request.into_compact_request();
4204
4205 assert_eq!(
4206 serde_json::to_value(compact_request).unwrap(),
4207 json!({
4208 "model": "gpt-5.4",
4209 "instructions": "Preserve implementation details.",
4210 "input": [{
4211 "type": "message",
4212 "role": "user",
4213 "content": [{
4214 "type": "input_text",
4215 "text": "Retain this context."
4216 }]
4217 }],
4218 "prompt_cache_key": "thread-123",
4219 "service_tier": "priority"
4220 })
4221 );
4222 }
4223
4224 #[test]
4225 fn into_open_ai_response_maps_compact_at_tokens_to_context_management() {
4226 let request = LanguageModelRequest {
4227 messages: vec![LanguageModelRequestMessage {
4228 role: Role::User,
4229 content: vec![MessageContent::Text("Hello".into())],
4230 cache: false,
4231 reasoning_details: None,
4232 }],
4233 compact_at_tokens: Some(100_000),
4234 ..Default::default()
4235 };
4236
4237 let response = into_open_ai_response(
4238 request,
4239 "gpt-5.1",
4240 true,
4241 true,
4242 None,
4243 None,
4244 false,
4245 &OPEN_AI_PROVIDER_ID,
4246 )
4247 .unwrap();
4248
4249 assert_eq!(
4250 serde_json::to_value(&response).unwrap()["context_management"],
4251 json!([{ "type": "compaction", "compact_threshold": 100_000 }])
4252 );
4253 }
4254
4255 #[test]
4256 fn into_open_ai_response_omits_context_management_without_compact_at_tokens() {
4257 let request = LanguageModelRequest {
4258 messages: vec![LanguageModelRequestMessage {
4259 role: Role::User,
4260 content: vec![MessageContent::Text("Hello".into())],
4261 cache: false,
4262 reasoning_details: None,
4263 }],
4264 ..Default::default()
4265 };
4266
4267 let response = into_open_ai_response(
4268 request,
4269 "gpt-5.1",
4270 true,
4271 true,
4272 None,
4273 None,
4274 false,
4275 &OPEN_AI_PROVIDER_ID,
4276 )
4277 .unwrap();
4278
4279 assert!(
4280 serde_json::to_value(&response)
4281 .unwrap()
4282 .get("context_management")
4283 .is_none()
4284 );
4285 }
4286
4287 #[test]
4288 fn into_open_ai_response_replays_provider_compaction_block() {
4289 let state = provider_compaction_state_from_items(
4290 OPEN_AI_PROVIDER_ID,
4291 vec![json!({
4292 "type": "compaction",
4293 "id": "cmp_1",
4294 "encrypted_content": "encrypted-blob"
4295 })],
4296 )
4297 .unwrap();
4298 let request = LanguageModelRequest {
4299 messages: vec![LanguageModelRequestMessage {
4300 role: Role::Assistant,
4301 content: vec![
4302 MessageContent::Compaction(CompactedContext::ProviderState(state)),
4303 MessageContent::Text("Done.".into()),
4304 ],
4305 cache: false,
4306 reasoning_details: None,
4307 }],
4308 ..Default::default()
4309 };
4310
4311 let response = into_open_ai_response(
4312 request,
4313 "gpt-5.1",
4314 true,
4315 true,
4316 None,
4317 None,
4318 false,
4319 &OPEN_AI_PROVIDER_ID,
4320 )
4321 .unwrap();
4322
4323 assert_eq!(
4324 serde_json::to_value(&response).unwrap()["input"],
4325 json!([
4326 {
4327 "type": "compaction",
4328 "id": "cmp_1",
4329 "encrypted_content": "encrypted-blob"
4330 },
4331 {
4332 "type": "message",
4333 "role": "assistant",
4334 "content": [
4335 { "type": "output_text", "text": "Done.", "annotations": [] }
4336 ]
4337 }
4338 ])
4339 );
4340 }
4341
4342 #[test]
4343 fn into_open_ai_response_hoists_system_messages_into_instructions() {
4344 let request = LanguageModelRequest {
4345 messages: vec![
4346 LanguageModelRequestMessage {
4347 role: Role::System,
4348 content: vec![MessageContent::Text("You are a coding assistant.".into())],
4349 cache: false,
4350 reasoning_details: None,
4351 },
4352 LanguageModelRequestMessage {
4353 role: Role::User,
4354 content: vec![MessageContent::Text("Hello.".into())],
4355 cache: false,
4356 reasoning_details: None,
4357 },
4358 LanguageModelRequestMessage {
4359 role: Role::System,
4360 content: vec![MessageContent::Text("Prefer terse answers.".into())],
4361 cache: false,
4362 reasoning_details: None,
4363 },
4364 ],
4365 ..Default::default()
4366 };
4367
4368 let response = into_open_ai_response(
4369 request,
4370 "gpt-5.1",
4371 true,
4372 true,
4373 None,
4374 None,
4375 false,
4376 &OPEN_AI_PROVIDER_ID,
4377 )
4378 .unwrap();
4379
4380 let serialized = serde_json::to_value(&response).unwrap();
4381 assert_eq!(
4382 serialized["instructions"],
4383 json!("You are a coding assistant.\n\nPrefer terse answers.")
4384 );
4385 assert_eq!(
4386 serialized["input"],
4387 json!([
4388 {
4389 "type": "message",
4390 "role": "user",
4391 "content": [
4392 { "type": "input_text", "text": "Hello." }
4393 ]
4394 }
4395 ])
4396 );
4397 }
4398
4399 /// Replaying provider compaction state discards all input items
4400 /// accumulated before it, but the system prompt must not be lost with
4401 /// them: it lives in the per-request `instructions` field, outside the
4402 /// compacted window, so the model keeps running on the current prompt
4403 /// rather than whatever was frozen into the window at compaction time.
4404 #[test]
4405 fn into_open_ai_response_preserves_system_prompt_across_compaction_replay() {
4406 let state = provider_compaction_state_from_items(
4407 OPEN_AI_PROVIDER_ID,
4408 vec![json!({
4409 "type": "compaction",
4410 "id": "cmp_1",
4411 "encrypted_content": "encrypted-blob"
4412 })],
4413 )
4414 .unwrap();
4415 let request = LanguageModelRequest {
4416 messages: vec![
4417 LanguageModelRequestMessage {
4418 role: Role::System,
4419 content: vec![MessageContent::Text("Current system prompt.".into())],
4420 cache: false,
4421 reasoning_details: None,
4422 },
4423 LanguageModelRequestMessage {
4424 role: Role::User,
4425 content: vec![MessageContent::Text("Old context.".into())],
4426 cache: false,
4427 reasoning_details: None,
4428 },
4429 LanguageModelRequestMessage {
4430 role: Role::Assistant,
4431 content: vec![MessageContent::Compaction(CompactedContext::ProviderState(
4432 state,
4433 ))],
4434 cache: false,
4435 reasoning_details: None,
4436 },
4437 LanguageModelRequestMessage {
4438 role: Role::User,
4439 content: vec![MessageContent::Text("Continue.".into())],
4440 cache: false,
4441 reasoning_details: None,
4442 },
4443 ],
4444 ..Default::default()
4445 };
4446
4447 let response = into_open_ai_response(
4448 request,
4449 "gpt-5.1",
4450 true,
4451 true,
4452 None,
4453 None,
4454 false,
4455 &OPEN_AI_PROVIDER_ID,
4456 )
4457 .unwrap();
4458
4459 let serialized = serde_json::to_value(&response).unwrap();
4460 assert_eq!(serialized["instructions"], json!("Current system prompt."));
4461 assert_eq!(
4462 serialized["input"],
4463 json!([
4464 {
4465 "type": "compaction",
4466 "id": "cmp_1",
4467 "encrypted_content": "encrypted-blob"
4468 },
4469 {
4470 "type": "message",
4471 "role": "user",
4472 "content": [
4473 { "type": "input_text", "text": "Continue." }
4474 ]
4475 }
4476 ])
4477 );
4478 }
4479
4480 #[test]
4481 fn into_open_ai_response_rejects_malformed_provider_compaction_state() {
4482 let request = LanguageModelRequest {
4483 messages: vec![LanguageModelRequestMessage {
4484 role: Role::Assistant,
4485 content: vec![MessageContent::Compaction(CompactedContext::ProviderState(
4486 language_model_core::ProviderCompactionState::new(
4487 language_model_core::OPEN_AI_PROVIDER_ID,
4488 crate::responses::COMPACTION_STATE_FORMAT,
4489 "not valid JSON",
4490 ),
4491 ))],
4492 cache: false,
4493 reasoning_details: None,
4494 }],
4495 ..Default::default()
4496 };
4497
4498 let error = into_open_ai_response(
4499 request,
4500 "gpt-5.1",
4501 true,
4502 true,
4503 None,
4504 None,
4505 false,
4506 &OPEN_AI_PROVIDER_ID,
4507 )
4508 .unwrap_err();
4509
4510 assert!(
4511 error
4512 .to_string()
4513 .contains("malformed OpenAI compaction state payload")
4514 );
4515 }
4516
4517 #[test]
4518 fn into_open_ai_response_ignores_compaction_state_owned_by_another_backend() {
4519 // Several backends share this request conversion (OpenAI itself,
4520 // OpenAI-compatible endpoints, Codex, Mantle), but an encrypted
4521 // compaction item is only decryptable by the backend that produced
4522 // it. Replaying OpenAI-owned state through a different backend would
4523 // discard the entire transcript in exchange for an opaque blob that
4524 // backend cannot read, so the state must be ignored and the full
4525 // transcript replayed instead.
4526 let state = provider_compaction_state_from_items(
4527 OPEN_AI_PROVIDER_ID,
4528 vec![json!({
4529 "type": "compaction",
4530 "id": "cmp_1",
4531 "encrypted_content": "encrypted-blob"
4532 })],
4533 )
4534 .unwrap();
4535 let request = LanguageModelRequest {
4536 messages: vec![
4537 LanguageModelRequestMessage {
4538 role: Role::User,
4539 content: vec![MessageContent::Text("Set up the project.".into())],
4540 cache: false,
4541 reasoning_details: None,
4542 },
4543 LanguageModelRequestMessage {
4544 role: Role::Assistant,
4545 content: vec![
4546 MessageContent::Compaction(CompactedContext::ProviderState(state)),
4547 MessageContent::Text("Done.".into()),
4548 ],
4549 cache: false,
4550 reasoning_details: None,
4551 },
4552 LanguageModelRequestMessage {
4553 role: Role::User,
4554 content: vec![MessageContent::Text("Continue.".into())],
4555 cache: false,
4556 reasoning_details: None,
4557 },
4558 ],
4559 ..Default::default()
4560 };
4561
4562 let response = into_open_ai_response(
4563 request,
4564 "compatible-model",
4565 true,
4566 true,
4567 None,
4568 None,
4569 false,
4570 &LanguageModelProviderId::new("my-compatible-endpoint"),
4571 )
4572 .unwrap();
4573
4574 assert_eq!(
4575 serde_json::to_value(&response).unwrap()["input"],
4576 json!([
4577 {
4578 "type": "message",
4579 "role": "user",
4580 "content": [{ "type": "input_text", "text": "Set up the project." }]
4581 },
4582 {
4583 "type": "message",
4584 "role": "assistant",
4585 "content": [
4586 { "type": "output_text", "text": "Done.", "annotations": [] }
4587 ]
4588 },
4589 {
4590 "type": "message",
4591 "role": "user",
4592 "content": [{ "type": "input_text", "text": "Continue." }]
4593 }
4594 ])
4595 );
4596 }
4597
4598 #[test]
4599 fn responses_stream_stamps_compaction_state_with_owning_backend() {
4600 let owner = LanguageModelProviderId::new("my-compatible-endpoint");
4601 let mut mapper = OpenAiResponseEventMapper::new(owner.clone());
4602 let item: ResponseOutputItem = serde_json::from_value(json!({
4603 "type": "compaction",
4604 "id": "cmp_1",
4605 "encrypted_content": "encrypted-blob"
4606 }))
4607 .unwrap();
4608
4609 let mut events = mapper.map_event(ResponsesStreamEvent::OutputItemDone {
4610 output_index: 0,
4611 sequence_number: None,
4612 item,
4613 });
4614
4615 let Some(Ok(LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished(
4616 CompactedContext::ProviderState(state),
4617 )))) = events.pop()
4618 else {
4619 panic!("expected finished provider compaction state");
4620 };
4621 assert_eq!(state.provider_id(), &owner);
4622 assert!(
4623 crate::responses::provider_compaction_items(&state, &owner)
4624 .unwrap()
4625 .is_some()
4626 );
4627 // OpenAI proper must not attempt to replay a window produced by a
4628 // different backend's infrastructure.
4629 assert_eq!(
4630 crate::responses::provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(),
4631 None
4632 );
4633 }
4634
4635 #[test]
4636 fn responses_stream_rejects_completion_with_unfinished_compaction() {
4637 let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
4638 let item: ResponseOutputItem = serde_json::from_value(json!({
4639 "type": "compaction",
4640 "id": "cmp_1",
4641 "encrypted_content": "encrypted-blob"
4642 }))
4643 .unwrap();
4644
4645 let started = mapper.map_event(ResponsesStreamEvent::OutputItemAdded {
4646 output_index: 0,
4647 sequence_number: None,
4648 item,
4649 });
4650 assert!(matches!(
4651 started.as_slice(),
4652 [Ok(LanguageModelCompletionEvent::Compaction(
4653 CompactionUpdate::Started
4654 ))]
4655 ));
4656
4657 let mut completed = mapper.map_event(ResponsesStreamEvent::Completed {
4658 response: ResponseSummary::default(),
4659 });
4660 let error = completed.pop().unwrap().unwrap_err();
4661
4662 assert!(error.to_string().contains("unfinished compaction"));
4663 }
4664
4665 #[test]
4666 fn responses_stream_maps_compaction_output_item() {
4667 let item: ResponseOutputItem = serde_json::from_value(json!({
4668 "type": "compaction",
4669 "id": "cmp_1",
4670 "encrypted_content": "encrypted-blob"
4671 }))
4672 .unwrap();
4673 let events = vec![
4674 ResponsesStreamEvent::OutputItemAdded {
4675 output_index: 0,
4676 sequence_number: None,
4677 item: item.clone(),
4678 },
4679 ResponsesStreamEvent::OutputItemDone {
4680 output_index: 0,
4681 sequence_number: None,
4682 item,
4683 },
4684 ];
4685
4686 let mapped = map_response_events(events);
4687
4688 assert_eq!(
4689 mapped,
4690 vec![
4691 LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started),
4692 LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished(
4693 CompactedContext::ProviderState(
4694 provider_compaction_state_from_items(
4695 OPEN_AI_PROVIDER_ID,
4696 vec![json!({
4697 "type": "compaction",
4698 "id": "cmp_1",
4699 "encrypted_content": "encrypted-blob"
4700 })],
4701 )
4702 .unwrap()
4703 )
4704 )),
4705 ]
4706 );
4707 }
4708}
4709