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