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 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 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 let has_custom_tool = tools.iter().any(|tool| tool.tool_type == "custom");
512 if has_custom_tool {
513 openai_request["parallel_tool_calls"] = Value::Bool(false);
515 }
516
517 if let Some(tool_choice) = &request.tool_choice {
519 openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
520 }
521
522 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 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 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 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 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 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 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}