Skip to main content

vtcode_core/llm/providers/openai/
request_builder.rs

1//! Chat Completions request builder for OpenAI-compatible APIs.
2//!
3//! Keeps JSON shaping for chat payloads out of the main provider.
4
5use 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
455/// Retained custom Responses item/history boundary.
456///
457/// Rig 0.38.2 has typed Responses input items, but does not preserve VTCode's
458/// assistant `phase` replay field, open-ended include strings such as
459/// `output_text.annotations`, synthetic missing tool outputs, or the exact
460/// instruction fallback used for ChatGPT replay parity. Protective tests:
461/// `api_key_and_chatgpt_subscription_share_responses_item_history_builder`,
462/// `openai_request_builder_preserves_custom_include_strings_around_typed_include`,
463/// and the `responses_api` history tests. Remove this boundary once Rig exposes
464/// open custom include values and VTCode-compatible structured history hooks.
465fn 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
496/// Retained custom Responses JSON boundary.
497///
498/// Rig 0.38.2 typed request fields are used here for compatible state such as
499/// `store`, `previous_response_id`, and known `include` enum values. The final
500/// JSON remains custom because Rig lacks typed coverage for VTCode's
501/// `output_types`, nested `sampling_parameters`, prompt-cache overlay fields,
502/// `context_management`, text verbosity, and provider-specific tool payloads.
503/// Protective tests: `openai_request_builder_serialises_rig_typed_state_fields`,
504/// `chatgpt_backend_forces_store_false_and_omits_output_sampling_cache`, and
505/// `responses_payload_includes_prompt_cache_retention_for_native_openai`.
506/// Remove this boundary when Rig exposes those fields with identical streaming
507/// and request JSON parity.
508fn 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        // `output_types` constrains which native item types GPT-5 may emit.
541        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        // Check if any tools are custom types - if so, disable parallel tool calls
642        // as per GPT-5 specification: "custom tool type does NOT support parallel tool calling"
643        let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
644        if has_custom_tool {
645            // Override parallel tool calls to false if custom tools are present
646            openai_request["parallel_tool_calls"] = Value::Bool(false);
647        }
648
649        // Only add tool_choice when tools are present. Native allowed-tools
650        // filtering is advisory and derives its subset from the stable
651        // catalogue above; unsupported backends/models degrade to regular
652        // provider tool_choice values.
653        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        // Only set parallel tool calls if not overridden due to custom tools
663        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        // Only add parallel_tool_config when tools are present
670        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    // Enable reasoning summaries if supported (OpenAI GPT-5 only)
696    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    // Add text formatting options for GPT-5 and compatible models, including verbosity and grammar
708    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    // Add grammar constraint if tools include grammar definitions
719    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            // Use the first grammar definition found
727            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    // Rig 0.38.2 lacks typed `prompt_cache_key` and
752    // `prompt_cache_retention` fields, so VT Code injects them after typed
753    // request construction at the final JSON boundary. `or_insert` preserves
754    // future Rig output; remove this overlay once Rig exposes typed fields and
755    // the prompt-cache JSON-boundary tests are updated to assert Rig-owned
756    // serialisation instead.
757    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}