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 serde_json::{Value, json};
16
17use super::responses_api::build_standard_responses_payload;
18use super::tool_serialization;
19use super::types::MAX_COMPLETION_TOKENS_FIELD;
20
21const NONE_REASONING_EFFORT_MODELS: &[&str] = &[
22    openai_models::GPT,
23    openai_models::GPT_5_2,
24    openai_models::GPT_5_4,
25];
26const MEDIUM_REASONING_EFFORT_MODELS: &[&str] = &[openai_models::GPT_5, openai_models::GPT_5_4_PRO];
27const TEXT_VERBOSITY_MODELS: &[&str] = &[
28    openai_models::GPT,
29    openai_models::GPT_5_2,
30    openai_models::GPT_5_4,
31    openai_models::GPT_5_4_PRO,
32    openai_models::GPT_5_3_CODEX,
33];
34const LOW_VERBOSITY_MODELS: &[&str] = &[
35    openai_models::GPT,
36    openai_models::GPT_5_2,
37    openai_models::GPT_5_4,
38    openai_models::GPT_5_4_PRO,
39];
40const PHASE_REPLAY_MODELS: &[&str] = &[
41    openai_models::GPT,
42    openai_models::GPT_5_4,
43    openai_models::GPT_5_4_PRO,
44    openai_models::GPT_5_3_CODEX,
45];
46const GATED_SAMPLING_MODELS: &[&str] = &[
47    openai_models::GPT,
48    openai_models::GPT_5_2,
49    openai_models::GPT_5_4,
50    openai_models::GPT_5_5,
51    openai_models::GPT_5_5_DATED,
52];
53const SAMPLING_DISABLED_MODELS: &[&str] = &[
54    openai_models::GPT_5,
55    openai_models::GPT_5_4_PRO,
56    openai_models::GPT_5_MINI,
57    openai_models::GPT_5_NANO,
58];
59
60pub(crate) struct ChatRequestContext<'a> {
61    pub model: &'a str,
62    pub base_url: &'a str,
63    pub supports_tools: bool,
64    pub supports_parallel_tool_config: bool,
65    pub supports_temperature: bool,
66    pub prompt_cache_key: Option<&'a str>,
67    pub default_service_tier: Option<&'a str>,
68}
69
70pub(crate) struct ResponsesRequestContext<'a> {
71    pub supports_tools: bool,
72    pub supports_parallel_tool_config: bool,
73    pub supports_temperature: bool,
74    pub supports_reasoning_effort: bool,
75    pub supports_reasoning: bool,
76    pub is_responses_api_model: bool,
77    pub include_max_output_tokens: bool,
78    pub include_previous_response_id: bool,
79    pub include_output_types: bool,
80    pub include_sampling_parameters: bool,
81    pub force_response_store_false: bool,
82    pub include_assistant_phase: bool,
83    pub prompt_cache_key: Option<&'a str>,
84    pub include_prompt_cache_retention: bool,
85    pub prompt_cache_retention: Option<&'a str>,
86    pub default_service_tier: Option<&'a str>,
87    pub default_response_store: Option<bool>,
88    pub default_responses_include: Option<&'a [String]>,
89    pub include_encrypted_reasoning: bool,
90    pub hosted_shell: Option<&'a OpenAIHostedShellConfig>,
91    pub include_structured_history_in_input: bool,
92    pub preserve_structured_history_on_replay: bool,
93    pub preserve_assistant_phase_on_replay: bool,
94}
95
96fn strip_non_native_assistant_phase(input: &mut [Value]) {
97    for item in input {
98        if let Some(map) = item.as_object_mut() {
99            map.remove("phase");
100        }
101    }
102}
103
104fn is_gpt5_codex_model(model: &str) -> bool {
105    model == openai_models::GPT_5_CODEX
106        || (model.starts_with(openai_models::GPT_5) && model.contains("codex"))
107}
108
109fn is_gpt55_model(model: &str) -> bool {
110    model == openai_models::GPT_5_5 || model == openai_models::GPT_5_5_DATED
111}
112
113fn is_openai_gpt_responses_model(model: &str) -> bool {
114    model == openai_models::GPT || model.starts_with(openai_models::GPT_5)
115}
116
117fn supports_assistant_phase_replay(model: &str) -> bool {
118    PHASE_REPLAY_MODELS.contains(&model)
119}
120
121fn default_replay_instructions(model: &str) -> Option<String> {
122    if is_gpt5_codex_model(model) {
123        Some(format!(
124            "You are Codex, based on GPT-5. {}",
125            default_system_prompt()
126        ))
127    } else if is_gpt55_model(model) {
128        Some(default_system_prompt().to_string())
129    } else {
130        None
131    }
132}
133
134fn default_reasoning_effort_for_model(model: &str) -> Option<ReasoningEffortLevel> {
135    if NONE_REASONING_EFFORT_MODELS.contains(&model) {
136        Some(ReasoningEffortLevel::None)
137    } else if is_gpt5_codex_model(model) {
138        Some(ReasoningEffortLevel::High)
139    } else if MEDIUM_REASONING_EFFORT_MODELS.contains(&model) {
140        Some(ReasoningEffortLevel::Medium)
141    } else {
142        None
143    }
144}
145
146fn supports_text_verbosity(model: &str) -> bool {
147    TEXT_VERBOSITY_MODELS.contains(&model)
148}
149
150fn push_unique_include(include_values: &mut Vec<String>, field: &str) {
151    let field = field.trim();
152    if field.is_empty() || include_values.iter().any(|value| value == field) {
153        return;
154    }
155
156    include_values.push(field.to_string());
157}
158
159fn default_text_verbosity_for_model(model: &str) -> Option<VerbosityLevel> {
160    if LOW_VERBOSITY_MODELS.contains(&model) {
161        Some(VerbosityLevel::Low)
162    } else {
163        None
164    }
165}
166
167fn trimmed_non_empty(value: Option<&str>) -> Option<&str> {
168    value.map(str::trim).filter(|value| !value.is_empty())
169}
170
171fn augment_openai_instructions(model: &str, instructions: String) -> String {
172    if !is_gpt55_model(model) {
173        return instructions;
174    }
175
176    let addendum = openai_gpt55_contract_addendum();
177    if instructions.contains(addendum.trim()) {
178        instructions
179    } else if instructions.trim().is_empty() {
180        addendum
181    } else {
182        format!("{instructions}\n\n{addendum}")
183    }
184}
185
186fn allows_sampling_parameters(model: &str, reasoning_effort: Option<ReasoningEffortLevel>) -> bool {
187    if GATED_SAMPLING_MODELS.contains(&model) {
188        matches!(
189            reasoning_effort.unwrap_or(ReasoningEffortLevel::None),
190            ReasoningEffortLevel::None
191        )
192    } else {
193        !SAMPLING_DISABLED_MODELS.contains(&model)
194    }
195}
196
197pub(crate) fn build_chat_request(
198    request: &provider::LLMRequest,
199    ctx: &ChatRequestContext<'_>,
200) -> Result<Value, provider::LLMError> {
201    for message in &request.messages {
202        if let provider::MessageContent::Parts(parts) = &message.content {
203            for part in parts {
204                if let provider::ContentPart::File {
205                    file_url: Some(_), ..
206                } = part
207                {
208                    let formatted_error = error_display::format_llm_error(
209                        "OpenAI",
210                        "Chat Completions does not support file_url inputs; use Responses API or file_id/file_data",
211                    );
212                    return Err(provider::LLMError::InvalidRequest {
213                        message: formatted_error,
214                        metadata: None,
215                    });
216                }
217            }
218        }
219    }
220
221    let mut messages = Vec::with_capacity(request.messages.len() + 1);
222    let mut active_tool_call_ids: HashSet<String> = HashSet::with_capacity(16);
223
224    if let Some(system_prompt) = &request.system_prompt {
225        let system_prompt = augment_openai_instructions(&request.model, system_prompt.to_string());
226        messages.push(json!({
227            "role": crate::config::constants::message_roles::SYSTEM,
228            "content": system_prompt
229        }));
230    }
231
232    for msg in &request.messages {
233        let role = msg.role.as_openai_str();
234        let mut message = json!({
235            "role": role,
236            "content": serialize_message_content_openai_for_model(msg, &request.model)
237        });
238        let mut skip_message = false;
239
240        if msg.role == provider::MessageRole::Assistant
241            && let Some(tool_calls) = &msg.tool_calls
242            && !tool_calls.is_empty()
243        {
244            let tool_calls_json: Vec<Value> = tool_calls
245                .iter()
246                .filter_map(|tc| {
247                    tc.function.as_ref().map(|func| {
248                        active_tool_call_ids.insert(tc.id.clone());
249                        json!({
250                            "id": tc.id,
251                            "type": "function",
252                            "function": {
253                                "name": func.name,
254                                "arguments": func.arguments
255                            }
256                        })
257                    })
258                })
259                .collect();
260
261            message["tool_calls"] = Value::Array(tool_calls_json);
262        }
263
264        if msg.role == provider::MessageRole::Tool {
265            match &msg.tool_call_id {
266                Some(tool_call_id) if active_tool_call_ids.contains(tool_call_id) => {
267                    message["tool_call_id"] = Value::String(tool_call_id.clone());
268                    active_tool_call_ids.remove(tool_call_id);
269                }
270                Some(_) | None => {
271                    skip_message = true;
272                }
273            }
274        }
275
276        if !skip_message {
277            messages.push(message);
278        }
279    }
280
281    if messages.is_empty() {
282        let formatted_error = error_display::format_llm_error("OpenAI", "No messages provided");
283        return Err(provider::LLMError::InvalidRequest {
284            message: formatted_error,
285            metadata: None,
286        });
287    }
288
289    let mut openai_request = json!({
290        "model": request.model,
291        "messages": messages,
292        "stream": request.stream
293    });
294    let effective_reasoning_effort = request
295        .reasoning_effort
296        .or_else(|| default_reasoning_effort_for_model(&request.model));
297
298    let is_native_openai = ctx.base_url.contains("api.openai.com");
299    let max_tokens_field = if !is_native_openai {
300        "max_tokens"
301    } else {
302        MAX_COMPLETION_TOKENS_FIELD
303    };
304
305    if let Some(max_tokens) = request.max_tokens {
306        openai_request[max_tokens_field] = json!(max_tokens);
307    }
308
309    if let Some(temperature) = request.temperature
310        && ctx.supports_temperature
311        && allows_sampling_parameters(&request.model, effective_reasoning_effort)
312    {
313        openai_request["temperature"] = json!(temperature);
314    }
315
316    if ModelProvider::OpenAI.supports_service_tier(&request.model)
317        && let Some(service_tier) =
318            trimmed_non_empty(request.service_tier.as_deref().or(ctx.default_service_tier))
319    {
320        openai_request["service_tier"] = json!(service_tier);
321    }
322
323    if let Some(prompt_cache_key) = trimmed_non_empty(ctx.prompt_cache_key) {
324        openai_request["prompt_cache_key"] = json!(prompt_cache_key);
325    }
326
327    if ctx.supports_tools
328        && let Some(tools) = &request.tools
329        && let Some(serialized) = tool_serialization::serialize_tools(tools, ctx.model)
330    {
331        openai_request["tools"] = serialized;
332
333        let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
334        if has_custom_tool {
335            openai_request["parallel_tool_calls"] = Value::Bool(false);
336        }
337
338        if let Some(tool_choice) = &request.tool_choice {
339            openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
340        }
341
342        if request.parallel_tool_calls.is_some()
343            && openai_request.get("parallel_tool_calls").is_none()
344            && let Some(parallel) = request.parallel_tool_calls
345        {
346            openai_request["parallel_tool_calls"] = Value::Bool(parallel);
347        }
348
349        if ctx.supports_parallel_tool_config
350            && let Some(config) = &request.parallel_tool_config
351            && let Ok(config_value) = serde_json::to_value(config)
352        {
353            openai_request["parallel_tool_config"] = config_value;
354        }
355    }
356
357    Ok(openai_request)
358}
359
360pub(crate) fn build_responses_request(
361    request: &provider::LLMRequest,
362    ctx: &ResponsesRequestContext<'_>,
363) -> Result<Value, provider::LLMError> {
364    let preserve_structured_history = ctx.include_structured_history_in_input
365        || (ctx.preserve_structured_history_on_replay
366            && is_openai_gpt_responses_model(&request.model));
367    let mut responses_payload =
368        build_standard_responses_payload(request, preserve_structured_history)?;
369    if responses_payload.instructions.is_none()
370        && preserve_structured_history
371        && let Some(instructions) = default_replay_instructions(&request.model)
372    {
373        responses_payload.instructions = Some(instructions);
374    }
375
376    responses_payload.instructions = responses_payload
377        .instructions
378        .take()
379        .map(|instructions| augment_openai_instructions(&request.model, instructions));
380
381    let mut input = responses_payload.input;
382    let instructions = responses_payload.instructions;
383    if !(ctx.include_assistant_phase
384        || ctx.preserve_assistant_phase_on_replay
385            && supports_assistant_phase_replay(&request.model))
386    {
387        strip_non_native_assistant_phase(&mut input);
388    }
389
390    if input.is_empty() {
391        let formatted_error =
392            error_display::format_llm_error("OpenAI", "No messages provided for Responses API");
393        return Err(provider::LLMError::InvalidRequest {
394            message: formatted_error,
395            metadata: None,
396        });
397    }
398
399    let mut openai_request = json!({
400        "model": request.model,
401        "input": input,
402        "stream": request.stream,
403    });
404    let effective_reasoning_effort = request
405        .reasoning_effort
406        .or_else(|| default_reasoning_effort_for_model(&request.model));
407
408    if ctx.include_max_output_tokens
409        && let Some(max_tokens) = request.max_tokens
410    {
411        openai_request["max_output_tokens"] = json!(max_tokens);
412    }
413
414    if ctx.include_output_types {
415        // `output_types` constrains which native item types GPT-5 may emit.
416        let mut output_types = vec!["message", "tool_call"];
417        if ctx.hosted_shell.is_some() {
418            output_types.push("shell_call");
419        }
420        openai_request["output_types"] = json!(output_types);
421    }
422
423    if let Some(instructions) = instructions
424        && !instructions.trim().is_empty()
425    {
426        openai_request["instructions"] = json!(instructions);
427    }
428
429    if ctx.include_previous_response_id
430        && let Some(previous_response_id) =
431            trimmed_non_empty(request.previous_response_id.as_deref())
432    {
433        openai_request["previous_response_id"] = json!(previous_response_id);
434    }
435
436    if ModelProvider::OpenAI.supports_service_tier(&request.model)
437        && let Some(service_tier) =
438            trimmed_non_empty(request.service_tier.as_deref().or(ctx.default_service_tier))
439    {
440        openai_request["service_tier"] = json!(service_tier);
441    }
442
443    if ctx.force_response_store_false {
444        openai_request["store"] = json!(false);
445    } else if let Some(store) = request.response_store.or(ctx.default_response_store) {
446        openai_request["store"] = json!(store);
447    }
448
449    let mut include_values = Vec::new();
450    if let Some(include_fields) = request
451        .responses_include
452        .as_deref()
453        .or(ctx.default_responses_include)
454    {
455        for field in include_fields {
456            push_unique_include(&mut include_values, field);
457        }
458    }
459    if ctx.include_encrypted_reasoning {
460        push_unique_include(&mut include_values, "reasoning.encrypted_content");
461    }
462    if !include_values.is_empty() {
463        openai_request["include"] = json!(include_values);
464    }
465
466    if let Some(context_management) = &request.context_management {
467        openai_request["context_management"] = context_management.clone();
468    }
469
470    let mut sampling_parameters = json!({});
471    let mut has_sampling = false;
472
473    if let Some(temperature) = request.temperature
474        && ctx.supports_temperature
475        && allows_sampling_parameters(&request.model, effective_reasoning_effort)
476    {
477        sampling_parameters["temperature"] = json!(temperature);
478        has_sampling = true;
479    }
480
481    if let Some(top_p) = request.top_p
482        && allows_sampling_parameters(&request.model, effective_reasoning_effort)
483    {
484        sampling_parameters["top_p"] = json!(top_p);
485        has_sampling = true;
486    }
487
488    if let Some(presence_penalty) = request.presence_penalty {
489        sampling_parameters["presence_penalty"] = json!(presence_penalty);
490        has_sampling = true;
491    }
492
493    if let Some(frequency_penalty) = request.frequency_penalty {
494        sampling_parameters["frequency_penalty"] = json!(frequency_penalty);
495        has_sampling = true;
496    }
497
498    if ctx.include_sampling_parameters && has_sampling {
499        openai_request["sampling_parameters"] = sampling_parameters;
500    }
501
502    if ctx.supports_tools
503        && let Some(tools) = &request.tools
504        && let Some(serialized) =
505            tool_serialization::serialize_tools_for_responses(tools, ctx.hosted_shell)
506    {
507        openai_request["tools"] = serialized;
508
509        // Check if any tools are custom types - if so, disable parallel tool calls
510        // as per GPT-5 specification: "custom tool type does NOT support parallel tool calling"
511        let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
512        if has_custom_tool {
513            // Override parallel tool calls to false if custom tools are present
514            openai_request["parallel_tool_calls"] = Value::Bool(false);
515        }
516
517        // Only add tool_choice when tools are present
518        if let Some(tool_choice) = &request.tool_choice {
519            openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
520        }
521
522        // Only set parallel tool calls if not overridden due to custom tools
523        if let Some(parallel) = request.parallel_tool_calls
524            && openai_request.get("parallel_tool_calls").is_none()
525        {
526            openai_request["parallel_tool_calls"] = Value::Bool(parallel);
527        }
528
529        // Only add parallel_tool_config when tools are present
530        if ctx.supports_parallel_tool_config
531            && let Some(config) = &request.parallel_tool_config
532            && let Ok(config_value) = serde_json::to_value(config)
533        {
534            openai_request["parallel_tool_config"] = config_value;
535        }
536    }
537
538    if ctx.supports_reasoning_effort {
539        if let Some(effort) = request.reasoning_effort {
540            if let Some(payload) =
541                RigProviderCapabilities::new(ModelProvider::OpenAI, &request.model)
542                    .reasoning_parameters(effort)
543            {
544                openai_request["reasoning"] = payload;
545            } else {
546                openai_request["reasoning"] = json!({ "effort": effort.as_str() });
547            }
548        } else if openai_request.get("reasoning").is_none()
549            && let Some(default_effort) = default_reasoning_effort_for_model(&request.model)
550        {
551            openai_request["reasoning"] = json!({ "effort": default_effort.as_str() });
552        }
553    }
554
555    // Enable reasoning summaries if supported (OpenAI GPT-5 only)
556    if ctx.supports_reasoning
557        && let Some(map) = openai_request.as_object_mut()
558    {
559        let reasoning_value = map.entry("reasoning").or_insert(json!({}));
560        if let Some(reasoning_obj) = reasoning_value.as_object_mut() {
561            reasoning_obj
562                .entry("summary".to_string())
563                .or_insert_with(|| json!("auto"));
564        }
565    }
566
567    // Add text formatting options for GPT-5 and compatible models, including verbosity and grammar
568    let mut text_format = json!({});
569    let mut has_format_options = false;
570
571    if supports_text_verbosity(&request.model)
572        && let Some(verbosity) = request.verbosity
573    {
574        text_format["verbosity"] = json!(verbosity.as_str());
575        has_format_options = true;
576    }
577
578    // Add grammar constraint if tools include grammar definitions
579    if let Some(ref tools) = request.tools {
580        let grammar_tools: Vec<&provider::ToolDefinition> = tools
581            .iter()
582            .filter(|tool| tool.tool_type == "grammar")
583            .collect();
584
585        if !grammar_tools.is_empty() {
586            // Use the first grammar definition found
587            if let Some(grammar_tool) = grammar_tools.first()
588                && let Some(ref grammar) = grammar_tool.grammar
589            {
590                text_format["format"] = json!({
591                    "type": "grammar",
592                    "syntax": grammar.syntax,
593                    "definition": grammar.definition
594                });
595                has_format_options = true;
596            }
597        }
598    }
599
600    if !has_format_options
601        && let Some(default_verbosity) = default_text_verbosity_for_model(&request.model)
602    {
603        text_format["verbosity"] = json!(default_verbosity.as_str());
604        has_format_options = true;
605    }
606
607    if has_format_options {
608        openai_request["text"] = text_format;
609    }
610
611    if let Some(prompt_cache_key) = trimmed_non_empty(ctx.prompt_cache_key) {
612        openai_request["prompt_cache_key"] = json!(prompt_cache_key);
613    }
614
615    // If configured, include the `prompt_cache_retention` value in the Responses API
616    // request. This allows the user to extend the server-side prompt cache window
617    // (e.g., "24h") to increase cache reuse and reduce cost/latency on GPT-5.
618    // Only include prompt_cache_retention when both configured and when the selected
619    // model uses the OpenAI Responses API.
620    if ctx.include_prompt_cache_retention
621        && ctx.is_responses_api_model
622        && let Some(retention) = ctx.prompt_cache_retention
623        && !retention.trim().is_empty()
624    {
625        openai_request["prompt_cache_retention"] = json!(retention);
626    }
627
628    Ok(openai_request)
629}