vtcode_core/llm/providers/openai/
request_builder.rs1use 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 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 let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
501 if has_custom_tool {
502 openai_request["parallel_tool_calls"] = Value::Bool(false);
504 }
505
506 if let Some(tool_choice) = &request.tool_choice {
508 openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
509 }
510
511 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 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 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 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 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 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 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}