1use crate::config::constants::models::openai as openai_models;
6use crate::config::core::OpenAIHostedShellConfig;
7use crate::config::models::Provider as ModelProvider;
8use crate::config::types::{ReasoningEffortLevel, VerbosityLevel};
9use crate::llm::error_display;
10use crate::llm::provider;
11use crate::llm::providers::common::serialize_message_content_openai_for_model;
12use crate::llm::rig_adapter::RigProviderCapabilities;
13use crate::prompts::system::{default_system_prompt, openai_gpt55_contract_addendum};
14use hashbrown::HashSet;
15use rig::providers::openai::responses_api::{
16 AdditionalParameters as RigResponsesAdditionalParameters, Include as RigResponsesInclude,
17};
18use serde_json::{Value, json};
19
20use super::responses_api::build_standard_responses_payload;
21use super::tool_serialization;
22use super::types::{MAX_COMPLETION_TOKENS_FIELD, OpenAIResponsesPayload};
23
24const NONE_REASONING_EFFORT_MODELS: &[&str] = &[
25 openai_models::GPT,
26 openai_models::GPT_5_2,
27 openai_models::GPT_5_4,
28];
29const MEDIUM_REASONING_EFFORT_MODELS: &[&str] = &[openai_models::GPT_5, openai_models::GPT_5_4_PRO];
30const TEXT_VERBOSITY_MODELS: &[&str] = &[
31 openai_models::GPT,
32 openai_models::GPT_5_2,
33 openai_models::GPT_5_4,
34 openai_models::GPT_5_4_PRO,
35 openai_models::GPT_5_3_CODEX,
36];
37const LOW_VERBOSITY_MODELS: &[&str] = &[
38 openai_models::GPT,
39 openai_models::GPT_5_2,
40 openai_models::GPT_5_4,
41 openai_models::GPT_5_4_PRO,
42];
43const PHASE_REPLAY_MODELS: &[&str] = &[
44 openai_models::GPT,
45 openai_models::GPT_5_4,
46 openai_models::GPT_5_4_PRO,
47 openai_models::GPT_5_3_CODEX,
48];
49const GATED_SAMPLING_MODELS: &[&str] = &[
50 openai_models::GPT,
51 openai_models::GPT_5_2,
52 openai_models::GPT_5_4,
53 openai_models::GPT_5_5,
54 openai_models::GPT_5_5_DATED,
55];
56const SAMPLING_DISABLED_MODELS: &[&str] = &[
57 openai_models::GPT_5,
58 openai_models::GPT_5_4_PRO,
59 openai_models::GPT_5_MINI,
60 openai_models::GPT_5_NANO,
61];
62
63pub(crate) struct ChatRequestContext<'a> {
64 pub model: &'a str,
65 pub is_native_openai: bool,
66 pub supports_tools: bool,
67 pub supports_parallel_tool_config: bool,
68 pub supports_temperature: bool,
69 pub prompt_cache_key: Option<&'a str>,
70 pub default_service_tier: Option<&'a str>,
71}
72
73pub(crate) struct ResponsesRequestContext<'a> {
74 pub supports_tools: bool,
75 pub supports_allowed_tools: bool,
76 pub supports_parallel_tool_config: bool,
77 pub supports_temperature: bool,
78 pub supports_reasoning_effort: bool,
79 pub supports_reasoning: bool,
80 pub is_responses_api_model: bool,
81 pub include_max_output_tokens: bool,
82 pub include_previous_response_id: bool,
83 pub include_output_types: bool,
84 pub include_sampling_parameters: bool,
85 pub force_response_store_false: bool,
86 pub include_assistant_phase: bool,
87 pub prompt_cache_key: Option<&'a str>,
88 pub include_prompt_cache_retention: bool,
89 pub prompt_cache_retention: Option<&'a str>,
90 pub default_service_tier: Option<&'a str>,
91 pub default_response_store: Option<bool>,
92 pub default_responses_include: Option<&'a [String]>,
93 pub include_encrypted_reasoning: bool,
94 pub hosted_shell: Option<&'a OpenAIHostedShellConfig>,
95 pub include_structured_history_in_input: bool,
96 pub preserve_structured_history_on_replay: bool,
97 pub preserve_assistant_phase_on_replay: bool,
98}
99
100fn strip_non_native_assistant_phase(input: &mut [Value]) {
101 for item in input {
102 if let Some(map) = item.as_object_mut() {
103 map.remove("phase");
104 }
105 }
106}
107
108fn is_gpt5_codex_model(model: &str) -> bool {
109 model == openai_models::GPT_5_CODEX
110 || (model.starts_with(openai_models::GPT_5) && model.contains("codex"))
111}
112
113fn is_gpt55_model(model: &str) -> bool {
114 model == openai_models::GPT_5_5 || model == openai_models::GPT_5_5_DATED
115}
116
117fn is_openai_gpt_responses_model(model: &str) -> bool {
118 model == openai_models::GPT || model.starts_with(openai_models::GPT_5)
119}
120
121fn supports_assistant_phase_replay(model: &str) -> bool {
122 PHASE_REPLAY_MODELS.contains(&model)
123}
124
125fn default_replay_instructions(model: &str) -> Option<String> {
126 if is_gpt5_codex_model(model) {
127 Some(format!(
128 "You are Codex, based on GPT-5. {}",
129 default_system_prompt()
130 ))
131 } else if is_gpt55_model(model) {
132 Some(default_system_prompt().to_string())
133 } else {
134 None
135 }
136}
137
138fn default_reasoning_effort_for_model(model: &str) -> Option<ReasoningEffortLevel> {
139 if NONE_REASONING_EFFORT_MODELS.contains(&model) {
140 Some(ReasoningEffortLevel::None)
141 } else if is_gpt5_codex_model(model) {
142 Some(ReasoningEffortLevel::High)
143 } else if MEDIUM_REASONING_EFFORT_MODELS.contains(&model) {
144 Some(ReasoningEffortLevel::Medium)
145 } else {
146 None
147 }
148}
149
150fn supports_text_verbosity(model: &str) -> bool {
151 TEXT_VERBOSITY_MODELS.contains(&model)
152}
153
154fn push_unique_include(include_values: &mut Vec<String>, field: &str) {
155 let field = field.trim();
156 if field.is_empty() || include_values.iter().any(|value| value == field) {
157 return;
158 }
159
160 include_values.push(field.to_string());
161}
162
163fn rig_include_for_field(field: &str) -> Option<RigResponsesInclude> {
164 match field {
165 "file_search_call.results" => Some(RigResponsesInclude::FileSearchCallResults),
166 "message.input_image.image_url" => Some(RigResponsesInclude::MessageInputImageImageUrl),
167 "computer_call.output.image_url" => {
168 Some(RigResponsesInclude::ComputerCallOutputOutputImageUrl)
169 }
170 "reasoning.encrypted_content" => Some(RigResponsesInclude::ReasoningEncryptedContent),
171 "code_interpreter_call.outputs" => Some(RigResponsesInclude::CodeInterpreterCallOutputs),
172 _ => None,
173 }
174}
175
176fn responses_include_value(field: &str) -> Value {
177 rig_include_for_field(field)
178 .and_then(|include| serde_json::to_value(include).ok())
179 .unwrap_or_else(|| json!(field))
180}
181
182fn merge_typed_responses_parameters(
183 openai_request: &mut Value,
184 params: RigResponsesAdditionalParameters,
185) {
186 let Ok(Value::Object(fields)) = serde_json::to_value(params) else {
187 return;
188 };
189 let Some(request) = openai_request.as_object_mut() else {
190 return;
191 };
192
193 request.extend(fields);
194}
195
196fn apply_prompt_cache_overlay(openai_request: &mut Value, ctx: &ResponsesRequestContext<'_>) {
197 let Some(request) = openai_request.as_object_mut() else {
198 return;
199 };
200
201 if let Some(prompt_cache_key) = trimmed_non_empty(ctx.prompt_cache_key) {
202 request
203 .entry("prompt_cache_key".to_string())
204 .or_insert_with(|| json!(prompt_cache_key));
205 }
206
207 if ctx.include_prompt_cache_retention
208 && ctx.is_responses_api_model
209 && let Some(retention) = trimmed_non_empty(ctx.prompt_cache_retention)
210 {
211 request
212 .entry("prompt_cache_retention".to_string())
213 .or_insert_with(|| json!(retention));
214 }
215}
216
217fn openai_responses_allowed_tools_choice(
218 tool_choice: &provider::ToolChoice,
219 stable_tools: &[provider::ToolDefinition],
220) -> Option<Value> {
221 let provider::ToolChoice::AllowedTools(choice) = tool_choice else {
222 return None;
223 };
224 if choice.tools.is_empty() {
225 return None;
226 }
227
228 let active_names: HashSet<&str> = choice.tools.iter().map(String::as_str).collect();
229 let tools = stable_tools
230 .iter()
231 .filter_map(|tool| {
232 let name = tool.function_name();
233 active_names.contains(name).then(|| json!(name))
234 })
235 .collect::<Vec<_>>();
236 if tools.is_empty() {
237 return None;
238 }
239
240 Some(json!({
241 "type": "allowed_tools",
242 "mode": choice.mode.as_str(),
243 "tools": tools,
244 }))
245}
246
247fn default_text_verbosity_for_model(model: &str) -> Option<VerbosityLevel> {
248 if LOW_VERBOSITY_MODELS.contains(&model) {
249 Some(VerbosityLevel::Low)
250 } else {
251 None
252 }
253}
254
255fn trimmed_non_empty(value: Option<&str>) -> Option<&str> {
256 value.map(str::trim).filter(|value| !value.is_empty())
257}
258
259fn augment_openai_instructions(model: &str, instructions: String) -> String {
260 if !is_gpt55_model(model) {
261 return instructions;
262 }
263
264 let addendum = openai_gpt55_contract_addendum();
265 if instructions.contains(addendum.trim()) {
266 instructions
267 } else if instructions.trim().is_empty() {
268 addendum
269 } else {
270 format!("{instructions}\n\n{addendum}")
271 }
272}
273
274fn allows_sampling_parameters(model: &str, reasoning_effort: Option<ReasoningEffortLevel>) -> bool {
275 if GATED_SAMPLING_MODELS.contains(&model) {
276 matches!(
277 reasoning_effort.unwrap_or(ReasoningEffortLevel::None),
278 ReasoningEffortLevel::None
279 )
280 } else {
281 !SAMPLING_DISABLED_MODELS.contains(&model)
282 }
283}
284
285pub(crate) fn build_chat_request(
286 request: &provider::LLMRequest,
287 ctx: &ChatRequestContext<'_>,
288) -> Result<Value, provider::LLMError> {
289 for message in &request.messages {
290 if let provider::MessageContent::Parts(parts) = &message.content {
291 for part in parts {
292 if let provider::ContentPart::File {
293 file_url: Some(_), ..
294 } = part
295 {
296 let formatted_error = error_display::format_llm_error(
297 "OpenAI",
298 "Chat Completions does not support file_url inputs; use Responses API or file_id/file_data",
299 );
300 return Err(provider::LLMError::InvalidRequest {
301 message: formatted_error,
302 metadata: None,
303 });
304 }
305 }
306 }
307 }
308
309 let mut messages = Vec::with_capacity(request.messages.len() + 1);
310 let mut active_tool_call_ids: HashSet<String> = HashSet::with_capacity(16);
311
312 if let Some(system_prompt) = &request.system_prompt {
313 let system_prompt = augment_openai_instructions(&request.model, system_prompt.to_string());
314 messages.push(json!({
315 "role": crate::config::constants::message_roles::SYSTEM,
316 "content": system_prompt
317 }));
318 }
319
320 for msg in &request.messages {
321 let role = msg.role.as_openai_str();
322 let mut message = json!({
323 "role": role,
324 "content": serialize_message_content_openai_for_model(msg, &request.model)
325 });
326 let mut skip_message = false;
327
328 if msg.role == provider::MessageRole::Assistant
329 && let Some(tool_calls) = &msg.tool_calls
330 && !tool_calls.is_empty()
331 {
332 let tool_calls_json: Vec<Value> = tool_calls
333 .iter()
334 .filter_map(|tc| {
335 tc.function.as_ref().map(|func| {
336 active_tool_call_ids.insert(tc.id.clone());
337 json!({
338 "id": tc.id,
339 "type": "function",
340 "function": {
341 "name": func.name,
342 "arguments": func.arguments
343 }
344 })
345 })
346 })
347 .collect();
348
349 message["tool_calls"] = Value::Array(tool_calls_json);
350 }
351
352 if msg.role == provider::MessageRole::Tool {
353 match &msg.tool_call_id {
354 Some(tool_call_id) if active_tool_call_ids.contains(tool_call_id) => {
355 message["tool_call_id"] = Value::String(tool_call_id.clone());
356 active_tool_call_ids.remove(tool_call_id);
357 }
358 Some(_) | None => {
359 skip_message = true;
360 }
361 }
362 }
363
364 if !skip_message {
365 messages.push(message);
366 }
367 }
368
369 if messages.is_empty() {
370 let formatted_error = error_display::format_llm_error("OpenAI", "No messages provided");
371 return Err(provider::LLMError::InvalidRequest {
372 message: formatted_error,
373 metadata: None,
374 });
375 }
376
377 let mut openai_request = json!({
378 "model": request.model,
379 "messages": messages,
380 "stream": request.stream
381 });
382 let effective_reasoning_effort = request
383 .reasoning_effort
384 .or_else(|| default_reasoning_effort_for_model(&request.model));
385
386 let max_tokens_field = if !ctx.is_native_openai {
387 "max_tokens"
388 } else {
389 MAX_COMPLETION_TOKENS_FIELD
390 };
391
392 if let Some(max_tokens) = request.max_tokens {
393 openai_request[max_tokens_field] = json!(max_tokens);
394 }
395
396 if let Some(temperature) = request.temperature
397 && ctx.supports_temperature
398 && allows_sampling_parameters(&request.model, effective_reasoning_effort)
399 {
400 openai_request["temperature"] = json!(temperature);
401 }
402
403 if ModelProvider::OpenAI.supports_service_tier(&request.model)
404 && let Some(service_tier) =
405 trimmed_non_empty(request.service_tier.as_deref().or(ctx.default_service_tier))
406 {
407 openai_request["service_tier"] = json!(service_tier);
408 }
409
410 if let Some(prompt_cache_key) = trimmed_non_empty(ctx.prompt_cache_key) {
411 openai_request["prompt_cache_key"] = json!(prompt_cache_key);
412 }
413
414 if ctx.supports_tools
415 && let Some(tools) = &request.tools
416 && let Some(serialized) = tool_serialization::serialize_tools(tools, ctx.model)
417 {
418 openai_request["tools"] = serialized;
419
420 let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
421 if has_custom_tool {
422 openai_request["parallel_tool_calls"] = Value::Bool(false);
423 }
424
425 if let Some(tool_choice) = &request.tool_choice {
426 openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
427 }
428
429 if request.parallel_tool_calls.is_some()
430 && openai_request.get("parallel_tool_calls").is_none()
431 && let Some(parallel) = request.parallel_tool_calls
432 {
433 openai_request["parallel_tool_calls"] = Value::Bool(parallel);
434 }
435
436 if ctx.supports_parallel_tool_config
437 && let Some(config) = &request.parallel_tool_config
438 && let Ok(config_value) = serde_json::to_value(config)
439 {
440 openai_request["parallel_tool_config"] = config_value;
441 }
442 }
443
444 Ok(openai_request)
445}
446
447pub(crate) fn build_responses_request(
448 request: &provider::LLMRequest,
449 ctx: &ResponsesRequestContext<'_>,
450) -> Result<Value, provider::LLMError> {
451 let responses_payload = build_responses_item_history(request, ctx)?;
452 build_responses_request_from_history(request, ctx, responses_payload)
453}
454
455fn build_responses_item_history(
466 request: &provider::LLMRequest,
467 ctx: &ResponsesRequestContext<'_>,
468) -> Result<OpenAIResponsesPayload, provider::LLMError> {
469 let preserve_structured_history = ctx.include_structured_history_in_input
470 || (ctx.preserve_structured_history_on_replay
471 && is_openai_gpt_responses_model(&request.model));
472 let mut responses_payload =
473 build_standard_responses_payload(request, preserve_structured_history)?;
474 if responses_payload.instructions.is_none()
475 && preserve_structured_history
476 && let Some(instructions) = default_replay_instructions(&request.model)
477 {
478 responses_payload.instructions = Some(instructions);
479 }
480
481 responses_payload.instructions = responses_payload
482 .instructions
483 .take()
484 .map(|instructions| augment_openai_instructions(&request.model, instructions));
485
486 if !(ctx.include_assistant_phase
487 || ctx.preserve_assistant_phase_on_replay
488 && supports_assistant_phase_replay(&request.model))
489 {
490 strip_non_native_assistant_phase(&mut responses_payload.input);
491 }
492
493 Ok(responses_payload)
494}
495
496fn build_responses_request_from_history(
509 request: &provider::LLMRequest,
510 ctx: &ResponsesRequestContext<'_>,
511 responses_payload: OpenAIResponsesPayload,
512) -> Result<Value, provider::LLMError> {
513 let input = responses_payload.input;
514 let instructions = responses_payload.instructions;
515 if input.is_empty() {
516 let formatted_error =
517 error_display::format_llm_error("OpenAI", "No messages provided for Responses API");
518 return Err(provider::LLMError::InvalidRequest {
519 message: formatted_error,
520 metadata: None,
521 });
522 }
523
524 let mut openai_request = json!({
525 "model": request.model,
526 "input": input,
527 "stream": request.stream,
528 });
529 let effective_reasoning_effort = request
530 .reasoning_effort
531 .or_else(|| default_reasoning_effort_for_model(&request.model));
532
533 if ctx.include_max_output_tokens
534 && let Some(max_tokens) = request.max_tokens
535 {
536 openai_request["max_output_tokens"] = json!(max_tokens);
537 }
538
539 if ctx.include_output_types {
540 let mut output_types = vec!["message", "tool_call"];
542 if ctx.hosted_shell.is_some() {
543 output_types.push("shell_call");
544 }
545 openai_request["output_types"] = json!(output_types);
546 }
547
548 if let Some(instructions) = instructions
549 && !instructions.trim().is_empty()
550 {
551 openai_request["instructions"] = json!(instructions);
552 }
553
554 let mut typed_parameters = RigResponsesAdditionalParameters::default();
555 if ctx.include_previous_response_id
556 && let Some(previous_response_id) =
557 trimmed_non_empty(request.previous_response_id.as_deref())
558 {
559 typed_parameters.previous_response_id = Some(previous_response_id.to_string());
560 }
561
562 if ModelProvider::OpenAI.supports_service_tier(&request.model)
563 && let Some(service_tier) =
564 trimmed_non_empty(request.service_tier.as_deref().or(ctx.default_service_tier))
565 {
566 openai_request["service_tier"] = json!(service_tier);
567 }
568
569 if ctx.force_response_store_false {
570 typed_parameters.store = Some(false);
571 } else if let Some(store) = request.response_store.or(ctx.default_response_store) {
572 typed_parameters.store = Some(store);
573 }
574 merge_typed_responses_parameters(&mut openai_request, typed_parameters);
575
576 let mut include_values = Vec::new();
577 if let Some(include_fields) = request
578 .responses_include
579 .as_deref()
580 .or(ctx.default_responses_include)
581 {
582 for field in include_fields {
583 push_unique_include(&mut include_values, field);
584 }
585 }
586 if ctx.include_encrypted_reasoning {
587 push_unique_include(&mut include_values, "reasoning.encrypted_content");
588 }
589 if !include_values.is_empty() {
590 openai_request["include"] = Value::Array(
591 include_values
592 .iter()
593 .map(|field| responses_include_value(field))
594 .collect(),
595 );
596 }
597
598 if let Some(context_management) = &request.context_management {
599 openai_request["context_management"] = context_management.clone();
600 }
601
602 let mut sampling_parameters = json!({});
603 let mut has_sampling = false;
604
605 if let Some(temperature) = request.temperature
606 && ctx.supports_temperature
607 && allows_sampling_parameters(&request.model, effective_reasoning_effort)
608 {
609 sampling_parameters["temperature"] = json!(temperature);
610 has_sampling = true;
611 }
612
613 if let Some(top_p) = request.top_p
614 && allows_sampling_parameters(&request.model, effective_reasoning_effort)
615 {
616 sampling_parameters["top_p"] = json!(top_p);
617 has_sampling = true;
618 }
619
620 if let Some(presence_penalty) = request.presence_penalty {
621 sampling_parameters["presence_penalty"] = json!(presence_penalty);
622 has_sampling = true;
623 }
624
625 if let Some(frequency_penalty) = request.frequency_penalty {
626 sampling_parameters["frequency_penalty"] = json!(frequency_penalty);
627 has_sampling = true;
628 }
629
630 if ctx.include_sampling_parameters && has_sampling {
631 openai_request["sampling_parameters"] = sampling_parameters;
632 }
633
634 if ctx.supports_tools
635 && let Some(tools) = &request.tools
636 && let Some(serialized) =
637 tool_serialization::serialize_tools_for_responses(tools, ctx.hosted_shell)
638 {
639 openai_request["tools"] = serialized;
640
641 let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
644 if has_custom_tool {
645 openai_request["parallel_tool_calls"] = Value::Bool(false);
647 }
648
649 if let Some(tool_choice) = &request.tool_choice {
654 openai_request["tool_choice"] = if ctx.supports_allowed_tools {
655 openai_responses_allowed_tools_choice(tool_choice, tools)
656 .unwrap_or_else(|| tool_choice.to_provider_format("openai"))
657 } else {
658 tool_choice.to_provider_format("openai")
659 };
660 }
661
662 if let Some(parallel) = request.parallel_tool_calls
664 && openai_request.get("parallel_tool_calls").is_none()
665 {
666 openai_request["parallel_tool_calls"] = Value::Bool(parallel);
667 }
668
669 if ctx.supports_parallel_tool_config
671 && let Some(config) = &request.parallel_tool_config
672 && let Ok(config_value) = serde_json::to_value(config)
673 {
674 openai_request["parallel_tool_config"] = config_value;
675 }
676 }
677
678 if ctx.supports_reasoning_effort {
679 if let Some(effort) = request.reasoning_effort {
680 if let Some(payload) =
681 RigProviderCapabilities::new(ModelProvider::OpenAI, &request.model)
682 .reasoning_parameters(effort)
683 {
684 openai_request["reasoning"] = payload;
685 } else {
686 openai_request["reasoning"] = json!({ "effort": effort.as_str() });
687 }
688 } else if openai_request.get("reasoning").is_none()
689 && let Some(default_effort) = default_reasoning_effort_for_model(&request.model)
690 {
691 openai_request["reasoning"] = json!({ "effort": default_effort.as_str() });
692 }
693 }
694
695 if ctx.supports_reasoning
697 && let Some(map) = openai_request.as_object_mut()
698 {
699 let reasoning_value = map.entry("reasoning").or_insert(json!({}));
700 if let Some(reasoning_obj) = reasoning_value.as_object_mut() {
701 reasoning_obj
702 .entry("summary".to_string())
703 .or_insert_with(|| json!("auto"));
704 }
705 }
706
707 let mut text_format = json!({});
709 let mut has_format_options = false;
710
711 if supports_text_verbosity(&request.model)
712 && let Some(verbosity) = request.verbosity
713 {
714 text_format["verbosity"] = json!(verbosity.as_str());
715 has_format_options = true;
716 }
717
718 if let Some(ref tools) = request.tools {
720 let grammar_tools: Vec<&provider::ToolDefinition> = tools
721 .iter()
722 .filter(|tool| tool.tool_type == "grammar")
723 .collect();
724
725 if !grammar_tools.is_empty() {
726 if let Some(grammar_tool) = grammar_tools.first()
728 && let Some(ref grammar) = grammar_tool.grammar
729 {
730 text_format["format"] = json!({
731 "type": "grammar",
732 "syntax": grammar.syntax,
733 "definition": grammar.definition
734 });
735 has_format_options = true;
736 }
737 }
738 }
739
740 if !has_format_options
741 && let Some(default_verbosity) = default_text_verbosity_for_model(&request.model)
742 {
743 text_format["verbosity"] = json!(default_verbosity.as_str());
744 has_format_options = true;
745 }
746
747 if has_format_options {
748 openai_request["text"] = text_format;
749 }
750
751 apply_prompt_cache_overlay(&mut openai_request, ctx);
758
759 Ok(openai_request)
760}
761
762#[cfg(test)]
763mod tests {
764 use super::{ResponsesRequestContext, apply_prompt_cache_overlay, build_responses_request};
765 use crate::config::constants::models;
766 use crate::llm::provider;
767 use serde_json::{Value, json};
768
769 fn base_context<'a>(
770 default_responses_include: Option<&'a [String]>,
771 ) -> ResponsesRequestContext<'a> {
772 ResponsesRequestContext {
773 supports_tools: false,
774 supports_allowed_tools: false,
775 supports_parallel_tool_config: false,
776 supports_temperature: true,
777 supports_reasoning_effort: true,
778 supports_reasoning: true,
779 is_responses_api_model: true,
780 include_max_output_tokens: true,
781 include_previous_response_id: true,
782 include_output_types: true,
783 include_sampling_parameters: true,
784 force_response_store_false: false,
785 include_assistant_phase: true,
786 prompt_cache_key: None,
787 include_prompt_cache_retention: false,
788 prompt_cache_retention: None,
789 default_service_tier: None,
790 default_response_store: None,
791 default_responses_include,
792 include_encrypted_reasoning: false,
793 hosted_shell: None,
794 include_structured_history_in_input: true,
795 preserve_structured_history_on_replay: false,
796 preserve_assistant_phase_on_replay: false,
797 }
798 }
799
800 fn request() -> provider::LLMRequest {
801 provider::LLMRequest {
802 messages: vec![provider::Message::user("Hello".to_owned())],
803 model: models::openai::GPT_5.to_string(),
804 stream: true,
805 ..Default::default()
806 }
807 }
808
809 #[test]
810 fn openai_request_builder_serialises_rig_typed_state_fields() {
811 let mut request = request();
812 request.previous_response_id = Some("resp_previous".to_owned());
813 request.response_store = Some(false);
814
815 let payload = build_responses_request(&request, &base_context(None))
816 .expect("responses request should build");
817
818 assert_eq!(
819 payload.get("previous_response_id").and_then(Value::as_str),
820 Some("resp_previous")
821 );
822 assert_eq!(payload.get("store").and_then(Value::as_bool), Some(false));
823 }
824
825 #[test]
826 fn openai_request_builder_preserves_custom_include_strings_around_typed_include() {
827 let default_include = vec!["output_text.annotations".to_owned()];
828 let mut ctx = base_context(Some(default_include.as_slice()));
829 ctx.include_encrypted_reasoning = true;
830
831 let payload =
832 build_responses_request(&request(), &ctx).expect("responses request should build");
833
834 assert_eq!(
835 payload.get("include").and_then(Value::as_array),
836 Some(&vec![
837 json!("output_text.annotations"),
838 json!("reasoning.encrypted_content"),
839 ])
840 );
841 }
842
843 #[test]
844 fn prompt_cache_overlay_inserts_fields_at_final_json_boundary() {
845 let mut ctx = base_context(None);
846 ctx.prompt_cache_key = Some("vtcode:openai:session-123");
847 ctx.include_prompt_cache_retention = true;
848 ctx.prompt_cache_retention = Some("24h");
849
850 let payload =
851 build_responses_request(&request(), &ctx).expect("responses request should build");
852
853 assert_eq!(
854 payload.get("prompt_cache_key").and_then(Value::as_str),
855 Some("vtcode:openai:session-123")
856 );
857 assert_eq!(
858 payload
859 .get("prompt_cache_retention")
860 .and_then(Value::as_str),
861 Some("24h")
862 );
863 }
864
865 #[test]
866 fn prompt_cache_overlay_does_not_overwrite_existing_fields() {
867 let mut ctx = base_context(None);
868 ctx.prompt_cache_key = Some("vtcode:openai:session-123");
869 ctx.include_prompt_cache_retention = true;
870 ctx.prompt_cache_retention = Some("24h");
871 let mut payload = json!({
872 "prompt_cache_key": "typed-key",
873 "prompt_cache_retention": "in_memory"
874 });
875
876 apply_prompt_cache_overlay(&mut payload, &ctx);
877
878 assert_eq!(
879 payload.get("prompt_cache_key").and_then(Value::as_str),
880 Some("typed-key")
881 );
882 assert_eq!(
883 payload
884 .get("prompt_cache_retention")
885 .and_then(Value::as_str),
886 Some("in_memory")
887 );
888 }
889}