1use crate::auth::AuthService;
2use crate::auth::openai_oauth::extract_account_id_from_jwt;
3use crate::multimodal;
4use crate::providers::ProviderRuntimeOptions;
5use crate::providers::traits::{
6 ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities,
7};
8use async_trait::async_trait;
9use futures_util::StreamExt;
10use reqwest::Client;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::path::PathBuf;
14
15const DEFAULT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
16const CODEX_RESPONSES_URL_ENV: &str = "CONSTRUCT_CODEX_RESPONSES_URL";
17const CODEX_BASE_URL_ENV: &str = "CONSTRUCT_CODEX_BASE_URL";
18const DEFAULT_CODEX_INSTRUCTIONS: &str =
19 "You are Construct, a concise and helpful coding assistant.";
20
21pub struct OpenAiCodexProvider {
22 auth: AuthService,
23 auth_profile_override: Option<String>,
24 responses_url: String,
25 custom_endpoint: bool,
26 gateway_api_key: Option<String>,
27 reasoning_effort: Option<String>,
28 client: Client,
29}
30
31#[derive(Debug, Serialize)]
32struct ResponsesRequest {
33 model: String,
34 input: Vec<serde_json::Value>,
35 instructions: String,
36 store: bool,
37 stream: bool,
38 text: ResponsesTextOptions,
39 reasoning: ResponsesReasoningOptions,
40 include: Vec<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 tool_choice: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 parallel_tool_calls: Option<bool>,
45 #[serde(skip_serializing_if = "Vec::is_empty")]
46 tools: Vec<ResponsesTool>,
47}
48
49#[derive(Debug, Serialize)]
50struct ResponsesTool {
51 #[serde(rename = "type")]
52 kind: String,
53 name: String,
54 description: String,
55 parameters: serde_json::Value,
56 strict: bool,
57}
58
59#[derive(Debug, Serialize)]
60struct ResponsesTextOptions {
61 verbosity: String,
62}
63
64#[derive(Debug, Serialize)]
65struct ResponsesReasoningOptions {
66 effort: String,
67 summary: String,
68}
69
70#[derive(Debug, Deserialize)]
71struct ResponsesResponse {
72 #[serde(default)]
73 output: Vec<ResponsesOutput>,
74 #[serde(default)]
75 output_text: Option<String>,
76 #[serde(default)]
77 usage: Option<ResponsesUsage>,
78}
79
80#[derive(Debug, Deserialize, Default)]
81struct ResponsesUsage {
82 #[serde(default)]
83 input_tokens: Option<u64>,
84 #[serde(default)]
85 output_tokens: Option<u64>,
86 #[serde(default)]
87 input_tokens_details: Option<ResponsesUsageInputDetails>,
88}
89
90#[derive(Debug, Deserialize, Default)]
91struct ResponsesUsageInputDetails {
92 #[serde(default)]
93 cached_tokens: Option<u64>,
94}
95
96#[derive(Debug, Deserialize)]
97struct ResponsesOutput {
98 #[serde(rename = "type", default)]
99 kind: Option<String>,
100 #[serde(default)]
101 content: Vec<ResponsesContent>,
102 #[serde(default)]
104 name: Option<String>,
105 #[serde(default)]
106 arguments: Option<String>,
107 #[serde(default)]
108 call_id: Option<String>,
109}
110
111#[derive(Debug, Deserialize)]
112struct ResponsesContent {
113 #[serde(rename = "type")]
114 kind: Option<String>,
115 text: Option<String>,
116}
117
118impl OpenAiCodexProvider {
119 pub fn new(
120 options: &ProviderRuntimeOptions,
121 gateway_api_key: Option<&str>,
122 ) -> anyhow::Result<Self> {
123 let state_dir = options
124 .construct_dir
125 .clone()
126 .unwrap_or_else(default_construct_dir);
127 let auth = AuthService::new(&state_dir, options.secrets_encrypt);
128 let responses_url = resolve_responses_url(options)?;
129
130 Ok(Self {
131 auth,
132 auth_profile_override: options.auth_profile_override.clone(),
133 custom_endpoint: !is_default_responses_url(&responses_url),
134 responses_url,
135 gateway_api_key: gateway_api_key.map(ToString::to_string),
136 reasoning_effort: options.reasoning_effort.clone(),
137 client: Client::builder()
138 .connect_timeout(std::time::Duration::from_secs(10))
139 .read_timeout(std::time::Duration::from_secs(300))
140 .build()
141 .unwrap_or_else(|_| Client::new()),
142 })
143 }
144}
145
146fn default_construct_dir() -> PathBuf {
147 directories::UserDirs::new().map_or_else(
148 || PathBuf::from(".construct"),
149 |dirs| dirs.home_dir().join(".construct"),
150 )
151}
152
153fn build_responses_url(base_or_endpoint: &str) -> anyhow::Result<String> {
154 let candidate = base_or_endpoint.trim();
155 if candidate.is_empty() {
156 anyhow::bail!("OpenAI Codex endpoint override cannot be empty");
157 }
158
159 let mut parsed = reqwest::Url::parse(candidate)
160 .map_err(|_| anyhow::anyhow!("OpenAI Codex endpoint override must be a valid URL"))?;
161
162 match parsed.scheme() {
163 "http" | "https" => {}
164 _ => anyhow::bail!("OpenAI Codex endpoint override must use http:// or https://"),
165 }
166
167 let path = parsed.path().trim_end_matches('/');
168 if !path.ends_with("/responses") {
169 let with_suffix = if path.is_empty() || path == "/" {
170 "/responses".to_string()
171 } else {
172 format!("{path}/responses")
173 };
174 parsed.set_path(&with_suffix);
175 }
176
177 parsed.set_query(None);
178 parsed.set_fragment(None);
179
180 Ok(parsed.to_string())
181}
182
183fn resolve_responses_url(options: &ProviderRuntimeOptions) -> anyhow::Result<String> {
184 if let Some(endpoint) = std::env::var(CODEX_RESPONSES_URL_ENV)
185 .ok()
186 .and_then(|value| first_nonempty(Some(&value)))
187 {
188 return build_responses_url(&endpoint);
189 }
190
191 if let Some(base_url) = std::env::var(CODEX_BASE_URL_ENV)
192 .ok()
193 .and_then(|value| first_nonempty(Some(&value)))
194 {
195 return build_responses_url(&base_url);
196 }
197
198 if let Some(api_url) = options
199 .provider_api_url
200 .as_deref()
201 .and_then(|value| first_nonempty(Some(value)))
202 {
203 return build_responses_url(&api_url);
204 }
205
206 Ok(DEFAULT_CODEX_RESPONSES_URL.to_string())
207}
208
209fn canonical_endpoint(url: &str) -> Option<(String, String, u16, String)> {
210 let parsed = reqwest::Url::parse(url).ok()?;
211 let host = parsed.host_str()?.to_ascii_lowercase();
212 let port = parsed.port_or_known_default()?;
213 let path = parsed.path().trim_end_matches('/').to_string();
214 Some((parsed.scheme().to_ascii_lowercase(), host, port, path))
215}
216
217fn is_default_responses_url(url: &str) -> bool {
218 canonical_endpoint(url) == canonical_endpoint(DEFAULT_CODEX_RESPONSES_URL)
219}
220
221fn first_nonempty(text: Option<&str>) -> Option<String> {
222 text.and_then(|value| {
223 let trimmed = value.trim();
224 if trimmed.is_empty() {
225 None
226 } else {
227 Some(trimmed.to_string())
228 }
229 })
230}
231
232fn resolve_instructions(system_prompt: Option<&str>) -> String {
233 first_nonempty(system_prompt).unwrap_or_else(|| DEFAULT_CODEX_INSTRUCTIONS.to_string())
234}
235
236fn normalize_model_id(model: &str) -> &str {
237 model.rsplit('/').next().unwrap_or(model)
238}
239
240fn build_responses_input(messages: &[ChatMessage]) -> (String, Vec<serde_json::Value>) {
241 let mut system_parts: Vec<&str> = Vec::new();
242 let mut input: Vec<serde_json::Value> = Vec::new();
243
244 for msg in messages {
245 match msg.role.as_str() {
246 "system" => system_parts.push(&msg.content),
247 "user" => {
248 let (cleaned_text, image_refs) = multimodal::parse_image_markers(&msg.content);
249
250 let mut content_items = Vec::new();
251
252 if !cleaned_text.trim().is_empty() {
253 content_items.push(serde_json::json!({
254 "type": "input_text",
255 "text": cleaned_text,
256 }));
257 }
258
259 for image_ref in image_refs {
260 content_items.push(serde_json::json!({
261 "type": "input_image",
262 "image_url": image_ref,
263 }));
264 }
265
266 if content_items.is_empty() {
267 content_items.push(serde_json::json!({
268 "type": "input_text",
269 "text": "",
270 }));
271 }
272
273 input.push(serde_json::json!({
274 "role": "user",
275 "content": content_items,
276 }));
277 }
278 "assistant" => {
279 input.push(serde_json::json!({
280 "role": "assistant",
281 "content": [{
282 "type": "output_text",
283 "text": msg.content,
284 }],
285 }));
286 }
287 _ => {}
288 }
289 }
290
291 let instructions = if system_parts.is_empty() {
292 DEFAULT_CODEX_INSTRUCTIONS.to_string()
293 } else {
294 system_parts.join("\n\n")
295 };
296
297 (instructions, input)
298}
299
300fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
301 let id = normalize_model_id(model);
302 if id == "gpt-5-codex" {
304 return match effort {
305 "low" | "medium" | "high" => effort.to_string(),
306 "minimal" => "low".to_string(),
307 _ => "high".to_string(),
308 };
309 }
310 if (id.starts_with("gpt-5.2") || id.starts_with("gpt-5.3")) && effort == "minimal" {
311 return "low".to_string();
312 }
313 if id.starts_with("gpt-5-codex") && effort == "xhigh" {
314 return "high".to_string();
315 }
316 if id == "gpt-5.1" && effort == "xhigh" {
317 return "high".to_string();
318 }
319 if id == "gpt-5.1-codex-mini" {
320 return if effort == "high" || effort == "xhigh" {
321 "high".to_string()
322 } else {
323 "medium".to_string()
324 };
325 }
326 effort.to_string()
327}
328
329fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String {
330 let raw = configured
331 .map(ToString::to_string)
332 .or_else(|| std::env::var("CONSTRUCT_CODEX_REASONING_EFFORT").ok())
333 .and_then(|value| first_nonempty(Some(&value)))
334 .unwrap_or_else(|| "xhigh".to_string())
335 .to_ascii_lowercase();
336 clamp_reasoning_effort(model_id, &raw)
337}
338
339fn nonempty_preserve(text: Option<&str>) -> Option<String> {
340 text.and_then(|value| {
341 if value.is_empty() {
342 None
343 } else {
344 Some(value.to_string())
345 }
346 })
347}
348
349fn extract_responses_text_and_tools(
351 response: &ResponsesResponse,
352) -> (Option<String>, Vec<crate::providers::ToolCall>) {
353 let text = extract_responses_text(response);
354 let mut tool_calls = Vec::new();
355
356 for item in &response.output {
357 if item.kind.as_deref() == Some("function_call") {
358 if let (Some(name), Some(arguments)) = (&item.name, &item.arguments) {
359 tool_calls.push(crate::providers::ToolCall {
360 id: item
361 .call_id
362 .clone()
363 .unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4())),
364 name: name.clone(),
365 arguments: arguments.clone(),
366 });
367 }
368 }
369 }
370
371 (text, tool_calls)
372}
373
374fn extract_responses_text(response: &ResponsesResponse) -> Option<String> {
375 if let Some(text) = first_nonempty(response.output_text.as_deref()) {
376 return Some(text);
377 }
378
379 for item in &response.output {
380 for content in &item.content {
381 if content.kind.as_deref() == Some("output_text") {
382 if let Some(text) = first_nonempty(content.text.as_deref()) {
383 return Some(text);
384 }
385 }
386 }
387 }
388
389 for item in &response.output {
390 for content in &item.content {
391 if let Some(text) = first_nonempty(content.text.as_deref()) {
392 return Some(text);
393 }
394 }
395 }
396
397 None
398}
399
400fn extract_stream_event_text(event: &Value, saw_delta: bool) -> Option<String> {
401 let event_type = event.get("type").and_then(Value::as_str);
402 match event_type {
403 Some("response.output_text.delta") => {
404 nonempty_preserve(event.get("delta").and_then(Value::as_str))
405 }
406 Some("response.output_text.done") if !saw_delta => {
407 nonempty_preserve(event.get("text").and_then(Value::as_str))
408 }
409 Some("response.completed" | "response.done") => event
410 .get("response")
411 .and_then(|value| serde_json::from_value::<ResponsesResponse>(value.clone()).ok())
412 .and_then(|response| extract_responses_text(&response)),
413 _ => None,
414 }
415}
416
417fn parse_sse_text(body: &str) -> anyhow::Result<Option<String>> {
418 let mut saw_delta = false;
419 let mut delta_accumulator = String::new();
420 let mut fallback_text = None;
421 let mut buffer = body.to_string();
422
423 let mut process_event = |event: Value| -> anyhow::Result<()> {
424 if let Some(message) = extract_stream_error_message(&event) {
425 return Err(anyhow::anyhow!("OpenAI Codex stream error: {message}"));
426 }
427 if let Some(text) = extract_stream_event_text(&event, saw_delta) {
428 let event_type = event.get("type").and_then(Value::as_str);
429 if event_type == Some("response.output_text.delta") {
430 saw_delta = true;
431 delta_accumulator.push_str(&text);
432 } else if fallback_text.is_none() {
433 fallback_text = Some(text);
434 }
435 }
436 Ok(())
437 };
438
439 let mut process_chunk = |chunk: &str| -> anyhow::Result<()> {
440 let data_lines: Vec<String> = chunk
441 .lines()
442 .filter_map(|line| line.strip_prefix("data:"))
443 .map(|line| line.trim().to_string())
444 .collect();
445 if data_lines.is_empty() {
446 return Ok(());
447 }
448
449 let joined = data_lines.join("\n");
450 let trimmed = joined.trim();
451 if trimmed.is_empty() || trimmed == "[DONE]" {
452 return Ok(());
453 }
454
455 if let Ok(event) = serde_json::from_str::<Value>(trimmed) {
456 return process_event(event);
457 }
458
459 for line in data_lines {
460 let line = line.trim();
461 if line.is_empty() || line == "[DONE]" {
462 continue;
463 }
464 if let Ok(event) = serde_json::from_str::<Value>(line) {
465 process_event(event)?;
466 }
467 }
468
469 Ok(())
470 };
471
472 loop {
473 let Some(idx) = buffer.find("\n\n") else {
474 break;
475 };
476
477 let chunk = buffer[..idx].to_string();
478 buffer = buffer[idx + 2..].to_string();
479 process_chunk(&chunk)?;
480 }
481
482 if !buffer.trim().is_empty() {
483 process_chunk(&buffer)?;
484 }
485
486 if saw_delta {
487 return Ok(nonempty_preserve(Some(&delta_accumulator)));
488 }
489
490 Ok(fallback_text)
491}
492
493fn extract_stream_error_message(event: &Value) -> Option<String> {
494 let event_type = event.get("type").and_then(Value::as_str);
495
496 if event_type == Some("error") {
497 return first_nonempty(
498 event
499 .get("message")
500 .and_then(Value::as_str)
501 .or_else(|| event.get("code").and_then(Value::as_str))
502 .or_else(|| {
503 event
504 .get("error")
505 .and_then(|error| error.get("message"))
506 .and_then(Value::as_str)
507 }),
508 );
509 }
510
511 if event_type == Some("response.failed") {
512 return first_nonempty(
513 event
514 .get("response")
515 .and_then(|response| response.get("error"))
516 .and_then(|error| error.get("message"))
517 .and_then(Value::as_str),
518 );
519 }
520
521 None
522}
523
524fn append_utf8_stream_chunk(
525 body: &mut String,
526 pending: &mut Vec<u8>,
527 chunk: &[u8],
528) -> anyhow::Result<()> {
529 if pending.is_empty() {
530 if let Ok(text) = std::str::from_utf8(chunk) {
531 body.push_str(text);
532 return Ok(());
533 }
534 }
535
536 if !chunk.is_empty() {
537 pending.extend_from_slice(chunk);
538 }
539 if pending.is_empty() {
540 return Ok(());
541 }
542
543 match std::str::from_utf8(pending) {
544 Ok(text) => {
545 body.push_str(text);
546 pending.clear();
547 Ok(())
548 }
549 Err(err) => {
550 let valid_up_to = err.valid_up_to();
551 if valid_up_to > 0 {
552 let prefix = std::str::from_utf8(&pending[..valid_up_to])
554 .expect("valid UTF-8 prefix from Utf8Error::valid_up_to");
555 body.push_str(prefix);
556 pending.drain(..valid_up_to);
557 }
558
559 if err.error_len().is_some() {
560 return Err(anyhow::anyhow!(
561 "OpenAI Codex response contained invalid UTF-8: {err}"
562 ));
563 }
564
565 Ok(())
568 }
569 }
570}
571
572fn decode_utf8_stream_chunks<'a, I>(chunks: I) -> anyhow::Result<String>
573where
574 I: IntoIterator<Item = &'a [u8]>,
575{
576 let mut body = String::new();
577 let mut pending = Vec::new();
578
579 for chunk in chunks {
580 append_utf8_stream_chunk(&mut body, &mut pending, chunk)?;
581 }
582
583 if !pending.is_empty() {
584 let err = std::str::from_utf8(&pending).expect_err("pending bytes should be invalid UTF-8");
585 return Err(anyhow::anyhow!(
586 "OpenAI Codex response ended with incomplete UTF-8: {err}"
587 ));
588 }
589
590 Ok(body)
591}
592
593async fn decode_responses_body(response: reqwest::Response) -> anyhow::Result<String> {
600 let mut body = String::new();
601 let mut pending_utf8 = Vec::new();
602 let mut stream = response.bytes_stream();
603
604 while let Some(chunk) = stream.next().await {
605 let bytes = chunk
606 .map_err(|err| anyhow::anyhow!("error reading OpenAI Codex response stream: {err}"))?;
607 append_utf8_stream_chunk(&mut body, &mut pending_utf8, &bytes)?;
608 }
609
610 if !pending_utf8.is_empty() {
611 let err = std::str::from_utf8(&pending_utf8)
612 .expect_err("pending bytes should be invalid UTF-8 at end of stream");
613 return Err(anyhow::anyhow!(
614 "OpenAI Codex response ended with incomplete UTF-8: {err}"
615 ));
616 }
617
618 if let Some(text) = parse_sse_text(&body)? {
619 return Ok(text);
620 }
621
622 let body_trimmed = body.trim_start();
623 let looks_like_sse = body_trimmed.starts_with("event:") || body_trimmed.starts_with("data:");
624 if looks_like_sse {
625 return Err(anyhow::anyhow!(
626 "No response from OpenAI Codex stream payload: {}",
627 super::sanitize_api_error(&body)
628 ));
629 }
630
631 let parsed: ResponsesResponse = serde_json::from_str(&body).map_err(|err| {
632 anyhow::anyhow!(
633 "OpenAI Codex JSON parse failed: {err}. Payload: {}",
634 super::sanitize_api_error(&body)
635 )
636 })?;
637 extract_responses_text(&parsed).ok_or_else(|| anyhow::anyhow!("No response from OpenAI Codex"))
638}
639
640async fn decode_responses_body_with_tools(
642 response: reqwest::Response,
643) -> anyhow::Result<(
644 String,
645 Vec<crate::providers::ToolCall>,
646 Option<crate::providers::traits::TokenUsage>,
647)> {
648 let mut body = String::new();
649 let mut pending_utf8 = Vec::new();
650 let mut stream = response.bytes_stream();
651
652 while let Some(chunk) = stream.next().await {
653 let bytes = chunk
654 .map_err(|err| anyhow::anyhow!("error reading OpenAI Codex response stream: {err}"))?;
655 append_utf8_stream_chunk(&mut body, &mut pending_utf8, &bytes)?;
656 }
657
658 if !pending_utf8.is_empty() {
659 let err = std::str::from_utf8(&pending_utf8)
660 .expect_err("pending bytes should be invalid UTF-8 at end of stream");
661 return Err(anyhow::anyhow!(
662 "OpenAI Codex response ended with incomplete UTF-8: {err}"
663 ));
664 }
665
666 let mut tool_calls: Vec<crate::providers::ToolCall> = Vec::new();
668 let mut text_result: Option<String> = None;
669 let mut usage_result: Option<crate::providers::traits::TokenUsage> = None;
670
671 let body_trimmed = body.trim_start();
673 let looks_like_sse = body_trimmed.starts_with("event:") || body_trimmed.starts_with("data:");
674
675 if looks_like_sse {
676 let mut saw_delta = false;
685 let mut delta_accumulator = String::new();
686
687 struct PendingFunctionCall {
689 name: String,
690 call_id: String,
691 arguments: String,
692 }
693 let mut pending_calls: std::collections::HashMap<u64, PendingFunctionCall> =
694 std::collections::HashMap::new();
695
696 for chunk in body.split("\n\n") {
697 for line in chunk.lines() {
698 if let Some(data) = line.strip_prefix("data:") {
699 let data = data.trim();
700 if data.is_empty() || data == "[DONE]" {
701 continue;
702 }
703 if let Ok(event) = serde_json::from_str::<Value>(data) {
704 let event_type = event.get("type").and_then(Value::as_str);
705 match event_type {
706 Some("response.output_text.delta") => {
707 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
708 saw_delta = true;
709 delta_accumulator.push_str(delta);
710 }
711 }
712 Some("response.output_item.added") => {
714 if let Some(item) = event.get("item") {
715 if item.get("type").and_then(Value::as_str)
716 == Some("function_call")
717 {
718 let output_index = event
719 .get("output_index")
720 .and_then(Value::as_u64)
721 .unwrap_or(0);
722 let name = item
723 .get("name")
724 .and_then(Value::as_str)
725 .unwrap_or("")
726 .to_string();
727 let call_id = item
728 .get("call_id")
729 .and_then(Value::as_str)
730 .unwrap_or("")
731 .to_string();
732 pending_calls.insert(
733 output_index,
734 PendingFunctionCall {
735 name,
736 call_id,
737 arguments: String::new(),
738 },
739 );
740 }
741 }
742 }
743 Some("response.function_call_arguments.delta") => {
745 let output_index = event
746 .get("output_index")
747 .and_then(Value::as_u64)
748 .unwrap_or(0);
749 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
750 if let Some(pending) = pending_calls.get_mut(&output_index) {
751 pending.arguments.push_str(delta);
752 }
753 }
754 }
755 Some("response.function_call_arguments.done") => {
757 let output_index = event
758 .get("output_index")
759 .and_then(Value::as_u64)
760 .unwrap_or(0);
761 if let Some(args) = event.get("arguments").and_then(Value::as_str) {
763 if let Some(pending) = pending_calls.get_mut(&output_index) {
764 pending.arguments = args.to_string();
765 }
766 }
767 }
768 Some("response.completed" | "response.done") => {
769 if let Some(resp_value) = event.get("response") {
770 if let Ok(resp) = serde_json::from_value::<ResponsesResponse>(
771 resp_value.clone(),
772 ) {
773 let (t, tc) = extract_responses_text_and_tools(&resp);
774 if !tc.is_empty() {
775 tool_calls = tc;
776 }
777 if text_result.is_none() {
778 text_result = t;
779 }
780 if let Some(u) = token_usage_from_responses(&resp) {
781 usage_result = Some(u);
782 }
783 }
784 }
785 }
786 _ => {}
787 }
788 }
789 }
790 }
791 }
792
793 if tool_calls.is_empty() && !pending_calls.is_empty() {
795 let mut sorted: Vec<_> = pending_calls.into_iter().collect();
796 sorted.sort_by_key(|(idx, _)| *idx);
797 for (_, pending) in sorted {
798 if !pending.name.is_empty() {
799 tool_calls.push(crate::providers::ToolCall {
800 id: if pending.call_id.is_empty() {
801 format!("call_{}", uuid::Uuid::new_v4())
802 } else {
803 pending.call_id
804 },
805 name: pending.name,
806 arguments: pending.arguments,
807 });
808 }
809 }
810 }
811
812 if saw_delta {
813 text_result = Some(delta_accumulator);
814 }
815
816 if usage_result.is_none() {
817 tracing::warn!(
818 "OpenAI Codex SSE stream completed without a usage payload; cost tracking will record a zero-token request"
819 );
820 }
821
822 let text = text_result.unwrap_or_default();
823 return Ok((text, tool_calls, usage_result));
824 }
825
826 let parsed: ResponsesResponse = serde_json::from_str(&body).map_err(|err| {
828 anyhow::anyhow!(
829 "OpenAI Codex JSON parse failed: {err}. Payload: {}",
830 super::sanitize_api_error(&body)
831 )
832 })?;
833 let (text, tc) = extract_responses_text_and_tools(&parsed);
834 let usage = token_usage_from_responses(&parsed);
835 Ok((text.unwrap_or_default(), tc, usage))
836}
837
838fn token_usage_from_responses(
839 resp: &ResponsesResponse,
840) -> Option<crate::providers::traits::TokenUsage> {
841 let u = resp.usage.as_ref()?;
842 if u.input_tokens.is_none() && u.output_tokens.is_none() {
843 return None;
844 }
845 Some(crate::providers::traits::TokenUsage {
846 input_tokens: u.input_tokens,
847 output_tokens: u.output_tokens,
848 cached_input_tokens: u
849 .input_tokens_details
850 .as_ref()
851 .and_then(|d| d.cached_tokens),
852 })
853}
854
855impl OpenAiCodexProvider {
856 async fn send_responses_request(
857 &self,
858 input: Vec<serde_json::Value>,
859 instructions: String,
860 model: &str,
861 ) -> anyhow::Result<String> {
862 let (text, _, _) = self
863 .send_responses_request_inner(input, instructions, model, Vec::new())
864 .await?;
865 Ok(text)
866 }
867
868 async fn send_responses_request_inner(
869 &self,
870 input: Vec<serde_json::Value>,
871 instructions: String,
872 model: &str,
873 tools: Vec<ResponsesTool>,
874 ) -> anyhow::Result<(
875 String,
876 Vec<crate::providers::ToolCall>,
877 Option<crate::providers::traits::TokenUsage>,
878 )> {
879 let use_gateway_api_key_auth = self.custom_endpoint && self.gateway_api_key.is_some();
880 let profile = match self
881 .auth
882 .get_profile("openai-codex", self.auth_profile_override.as_deref())
883 .await
884 {
885 Ok(profile) => profile,
886 Err(err) if use_gateway_api_key_auth => {
887 tracing::warn!(
888 error = %err,
889 "failed to load OpenAI Codex profile; continuing with custom endpoint API key mode"
890 );
891 None
892 }
893 Err(err) => return Err(err),
894 };
895 let oauth_access_token = match self
896 .auth
897 .get_valid_openai_access_token(self.auth_profile_override.as_deref())
898 .await
899 {
900 Ok(token) => token,
901 Err(err) if use_gateway_api_key_auth => {
902 tracing::warn!(
903 error = %err,
904 "failed to refresh OpenAI token; continuing with custom endpoint API key mode"
905 );
906 None
907 }
908 Err(err) => return Err(err),
909 };
910
911 let account_id = profile.and_then(|profile| profile.account_id).or_else(|| {
912 oauth_access_token
913 .as_deref()
914 .and_then(extract_account_id_from_jwt)
915 });
916 let access_token = if use_gateway_api_key_auth {
917 oauth_access_token
918 } else {
919 Some(oauth_access_token.ok_or_else(|| {
920 anyhow::anyhow!(
921 "OpenAI Codex auth profile not found. Run `construct auth login --provider openai-codex`."
922 )
923 })?)
924 };
925 let account_id = if use_gateway_api_key_auth {
926 account_id
927 } else {
928 Some(account_id.ok_or_else(|| {
929 anyhow::anyhow!(
930 "OpenAI Codex account id not found in auth profile/token. Run `construct auth login --provider openai-codex` again."
931 )
932 })?)
933 };
934 let normalized_model = normalize_model_id(model);
935
936 let request = ResponsesRequest {
937 model: normalized_model.to_string(),
938 input,
939 instructions,
940 store: false,
941 stream: true,
942 text: ResponsesTextOptions {
943 verbosity: "medium".to_string(),
944 },
945 reasoning: ResponsesReasoningOptions {
946 effort: resolve_reasoning_effort(
947 normalized_model,
948 self.reasoning_effort.as_deref(),
949 ),
950 summary: "auto".to_string(),
951 },
952 include: vec!["reasoning.encrypted_content".to_string()],
953 tool_choice: if tools.is_empty() {
954 None
955 } else {
956 Some("auto".to_string())
957 },
958 parallel_tool_calls: if tools.is_empty() { None } else { Some(true) },
959 tools,
960 };
961
962 let bearer_token = if use_gateway_api_key_auth {
963 self.gateway_api_key.as_deref().unwrap_or_default()
964 } else {
965 access_token.as_deref().unwrap_or_default()
966 };
967
968 let mut request_builder = self
969 .client
970 .post(&self.responses_url)
971 .header("Authorization", format!("Bearer {bearer_token}"))
972 .header("OpenAI-Beta", "responses=experimental")
973 .header("originator", "pi")
974 .header("accept", "text/event-stream")
975 .header("Content-Type", "application/json");
976
977 if let Some(account_id) = account_id.as_deref() {
978 request_builder = request_builder.header("chatgpt-account-id", account_id);
979 }
980
981 if use_gateway_api_key_auth {
982 if let Some(access_token) = access_token.as_deref() {
983 request_builder = request_builder.header("x-openai-access-token", access_token);
984 }
985 if let Some(account_id) = account_id.as_deref() {
986 request_builder = request_builder.header("x-openai-account-id", account_id);
987 }
988 }
989
990 tracing::info!(
991 input_count = request.input.len(),
992 tools_count = request.tools.len(),
993 "Codex Responses API request"
994 );
995
996 let response = request_builder.json(&request).send().await?;
997
998 if !response.status().is_success() {
999 tracing::warn!(
1001 input_count = request.input.len(),
1002 tools_count = request.tools.len(),
1003 input_preview = %serde_json::to_string(&request.input.iter().take(3).collect::<Vec<_>>()).unwrap_or_default(),
1004 "Codex API request failed"
1005 );
1006 return Err(super::api_error("OpenAI Codex", response).await);
1007 }
1008
1009 let result = decode_responses_body_with_tools(response).await;
1010 if let Ok((ref text, ref tool_calls, ref usage)) = result {
1011 tracing::info!(
1012 text_len = text.len(),
1013 tool_calls_count = tool_calls.len(),
1014 tool_names = %tool_calls.iter().map(|tc| tc.name.as_str()).collect::<Vec<_>>().join(", "),
1015 input_tokens = usage.as_ref().and_then(|u| u.input_tokens).unwrap_or(0),
1016 output_tokens = usage.as_ref().and_then(|u| u.output_tokens).unwrap_or(0),
1017 "Codex Responses API response"
1018 );
1019 if text.is_empty() && tool_calls.is_empty() {
1020 tracing::warn!(
1021 "Codex Responses API returned empty text AND no tool calls — model produced no output"
1022 );
1023 }
1024 }
1025 if let Err(ref e) = result {
1026 tracing::error!(error = %e, "Codex Responses API decode failed");
1027 }
1028 result
1029 }
1030}
1031
1032fn tool_spec_to_responses_tool(spec: &crate::tools::ToolSpec) -> Option<ResponsesTool> {
1034 if spec.name.is_empty() {
1035 tracing::warn!("Skipping tool with empty name");
1036 return None;
1037 }
1038 Some(ResponsesTool {
1039 kind: "function".to_string(),
1040 name: spec.name.clone(),
1041 description: spec.description.clone(),
1042 parameters: spec.parameters.clone(),
1043 strict: false,
1044 })
1045}
1046
1047fn build_responses_input_with_tools(messages: &[ChatMessage]) -> (String, Vec<serde_json::Value>) {
1050 let mut system_parts: Vec<&str> = Vec::new();
1051 let mut input: Vec<serde_json::Value> = Vec::new();
1052 let mut emitted_call_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1058
1059 let mut outputs_present: std::collections::HashSet<String> = std::collections::HashSet::new();
1067 for msg in messages {
1068 if msg.role.as_str() == "tool" {
1069 if let Ok(parsed) = serde_json::from_str::<Value>(&msg.content) {
1070 if let Some(cid) = parsed.get("tool_call_id").and_then(Value::as_str) {
1071 if !cid.is_empty() {
1072 outputs_present.insert(cid.to_string());
1073 }
1074 }
1075 }
1076 }
1077 }
1078
1079 for msg in messages {
1080 match msg.role.as_str() {
1081 "system" => system_parts.push(&msg.content),
1082 "user" => {
1083 let (cleaned_text, image_refs) = multimodal::parse_image_markers(&msg.content);
1084
1085 let mut content_items: Vec<serde_json::Value> = Vec::new();
1086 if !cleaned_text.trim().is_empty() {
1087 content_items.push(serde_json::json!({
1088 "type": "input_text",
1089 "text": cleaned_text,
1090 }));
1091 }
1092 for image_ref in image_refs {
1093 content_items.push(serde_json::json!({
1094 "type": "input_image",
1095 "image_url": image_ref,
1096 }));
1097 }
1098 if content_items.is_empty() {
1099 content_items.push(serde_json::json!({
1100 "type": "input_text",
1101 "text": "",
1102 }));
1103 }
1104
1105 input.push(serde_json::json!({
1106 "role": "user",
1107 "content": content_items,
1108 }));
1109 }
1110 "assistant" => {
1111 if let Ok(parsed) = serde_json::from_str::<Value>(&msg.content) {
1114 if let Some(tool_calls) = parsed.get("tool_calls").and_then(Value::as_array) {
1115 if let Some(text) = parsed.get("content").and_then(Value::as_str) {
1117 if !text.is_empty() {
1118 input.push(serde_json::json!({
1119 "role": "assistant",
1120 "content": [{
1121 "type": "output_text",
1122 "text": text,
1123 }],
1124 }));
1125 }
1126 }
1127 for tc in tool_calls {
1133 let call_id = tc
1134 .get("id")
1135 .or_else(|| tc.get("call_id"))
1136 .and_then(Value::as_str)
1137 .unwrap_or("");
1138 let name = tc
1139 .get("function")
1140 .and_then(|f| f.get("name"))
1141 .and_then(Value::as_str)
1142 .or_else(|| tc.get("name").and_then(Value::as_str))
1143 .unwrap_or("");
1144 let arguments = tc
1145 .get("function")
1146 .and_then(|f| f.get("arguments"))
1147 .and_then(Value::as_str)
1148 .or_else(|| tc.get("arguments").and_then(Value::as_str))
1149 .unwrap_or("{}");
1150 if name.is_empty() {
1152 tracing::warn!(
1153 call_id,
1154 "Skipping tool call with empty name in history"
1155 );
1156 continue;
1157 }
1158 if call_id.is_empty() || !outputs_present.contains(call_id) {
1162 tracing::debug!(
1163 call_id,
1164 name,
1165 "Dropping orphaned function_call — no matching function_call_output in history"
1166 );
1167 continue;
1168 }
1169 emitted_call_ids.insert(call_id.to_string());
1170 input.push(serde_json::json!({
1171 "type": "function_call",
1172 "call_id": call_id,
1173 "name": name,
1174 "arguments": arguments,
1175 }));
1176 }
1177 continue;
1178 }
1179 }
1180
1181 input.push(serde_json::json!({
1182 "role": "assistant",
1183 "content": [{
1184 "type": "output_text",
1185 "text": msg.content,
1186 }],
1187 }));
1188 }
1189 "tool" => {
1190 if let Ok(parsed) = serde_json::from_str::<Value>(&msg.content) {
1193 let call_id = parsed
1194 .get("tool_call_id")
1195 .and_then(Value::as_str)
1196 .unwrap_or("");
1197 if call_id.is_empty() {
1199 continue;
1200 }
1201 if !emitted_call_ids.contains(call_id) {
1204 tracing::debug!(
1205 call_id,
1206 "Dropping orphaned function_call_output — no matching function_call in history"
1207 );
1208 continue;
1209 }
1210 let output = parsed
1211 .get("content")
1212 .and_then(Value::as_str)
1213 .unwrap_or(&msg.content);
1214 input.push(serde_json::json!({
1215 "type": "function_call_output",
1216 "call_id": call_id,
1217 "output": output,
1218 }));
1219 }
1220 }
1221 _ => {}
1222 }
1223 }
1224
1225 let instructions = if system_parts.is_empty() {
1226 DEFAULT_CODEX_INSTRUCTIONS.to_string()
1227 } else {
1228 system_parts.join("\n\n")
1229 };
1230
1231 (instructions, input)
1232}
1233
1234#[async_trait]
1235impl Provider for OpenAiCodexProvider {
1236 fn capabilities(&self) -> ProviderCapabilities {
1237 ProviderCapabilities {
1238 native_tool_calling: true,
1239 vision: true,
1240 prompt_caching: false,
1241 }
1242 }
1243
1244 async fn chat(
1245 &self,
1246 request: ChatRequest<'_>,
1247 model: &str,
1248 _temperature: f64,
1249 ) -> anyhow::Result<ChatResponse> {
1250 let config = crate::config::MultimodalConfig::default();
1251 let prepared =
1252 crate::multimodal::prepare_messages_for_provider(request.messages, &config).await?;
1253
1254 let (instructions, input) = build_responses_input_with_tools(&prepared.messages);
1255
1256 let tools: Vec<ResponsesTool> = request
1257 .tools
1258 .map(|specs| {
1259 specs
1260 .iter()
1261 .filter_map(tool_spec_to_responses_tool)
1262 .collect()
1263 })
1264 .unwrap_or_default();
1265
1266 let (text, tool_calls, usage) = self
1267 .send_responses_request_inner(input, instructions, model, tools)
1268 .await?;
1269
1270 Ok(ChatResponse {
1271 text: if text.is_empty() { None } else { Some(text) },
1272 tool_calls,
1273 usage,
1274 reasoning_content: None,
1275 })
1276 }
1277
1278 async fn chat_with_system(
1279 &self,
1280 system_prompt: Option<&str>,
1281 message: &str,
1282 model: &str,
1283 _temperature: f64,
1284 ) -> anyhow::Result<String> {
1285 let mut messages = Vec::new();
1286 if let Some(sys) = system_prompt {
1287 messages.push(ChatMessage::system(sys));
1288 }
1289 messages.push(ChatMessage::user(message));
1290
1291 let config = crate::config::MultimodalConfig::default();
1292 let prepared = crate::multimodal::prepare_messages_for_provider(&messages, &config).await?;
1293
1294 let (instructions, input) = build_responses_input(&prepared.messages);
1295 self.send_responses_request(input, instructions, model)
1296 .await
1297 }
1298
1299 async fn chat_with_history(
1300 &self,
1301 messages: &[ChatMessage],
1302 model: &str,
1303 _temperature: f64,
1304 ) -> anyhow::Result<String> {
1305 let config = crate::config::MultimodalConfig::default();
1306 let prepared = crate::multimodal::prepare_messages_for_provider(messages, &config).await?;
1307
1308 let (instructions, input) = build_responses_input(&prepared.messages);
1309 self.send_responses_request(input, instructions, model)
1310 .await
1311 }
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use std::sync::{Mutex, MutexGuard, OnceLock};
1318
1319 fn env_lock() -> MutexGuard<'static, ()> {
1320 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1321 LOCK.get_or_init(|| Mutex::new(()))
1322 .lock()
1323 .expect("env lock poisoned")
1324 }
1325
1326 struct EnvGuard {
1327 key: &'static str,
1328 original: Option<String>,
1329 }
1330
1331 impl EnvGuard {
1332 fn set(key: &'static str, value: Option<&str>) -> Self {
1333 let original = std::env::var(key).ok();
1334 match value {
1335 Some(next) => unsafe { std::env::set_var(key, next) },
1337 None => unsafe { std::env::remove_var(key) },
1339 }
1340 Self { key, original }
1341 }
1342 }
1343
1344 impl Drop for EnvGuard {
1345 fn drop(&mut self) {
1346 if let Some(original) = self.original.as_deref() {
1347 unsafe { std::env::set_var(self.key, original) };
1349 } else {
1350 unsafe { std::env::remove_var(self.key) };
1352 }
1353 }
1354 }
1355
1356 #[test]
1357 fn extracts_output_text_first() {
1358 let response = ResponsesResponse {
1359 output: vec![],
1360 output_text: Some("hello".into()),
1361 usage: None,
1362 };
1363 assert_eq!(extract_responses_text(&response).as_deref(), Some("hello"));
1364 }
1365
1366 #[test]
1367 fn extracts_nested_output_text() {
1368 let response = ResponsesResponse {
1369 output: vec![ResponsesOutput {
1370 kind: None,
1371 content: vec![ResponsesContent {
1372 kind: Some("output_text".into()),
1373 text: Some("nested".into()),
1374 }],
1375 name: None,
1376 arguments: None,
1377 call_id: None,
1378 }],
1379 output_text: None,
1380 usage: None,
1381 };
1382 assert_eq!(extract_responses_text(&response).as_deref(), Some("nested"));
1383 }
1384
1385 #[test]
1386 fn default_state_dir_is_non_empty() {
1387 let path = default_construct_dir();
1388 assert!(!path.as_os_str().is_empty());
1389 }
1390
1391 #[test]
1392 fn build_responses_url_appends_suffix_for_base_url() {
1393 assert_eq!(
1394 build_responses_url("https://api.tonsof.blue/v1").unwrap(),
1395 "https://api.tonsof.blue/v1/responses"
1396 );
1397 }
1398
1399 #[test]
1400 fn build_responses_url_keeps_existing_responses_endpoint() {
1401 assert_eq!(
1402 build_responses_url("https://api.tonsof.blue/v1/responses").unwrap(),
1403 "https://api.tonsof.blue/v1/responses"
1404 );
1405 }
1406
1407 #[test]
1408 fn resolve_responses_url_prefers_explicit_endpoint_env() {
1409 let _lock = env_lock();
1410 let _endpoint_guard = EnvGuard::set(
1411 CODEX_RESPONSES_URL_ENV,
1412 Some("https://env.example.com/v1/responses"),
1413 );
1414 let _base_guard = EnvGuard::set(CODEX_BASE_URL_ENV, Some("https://base.example.com/v1"));
1415
1416 let options = ProviderRuntimeOptions::default();
1417 assert_eq!(
1418 resolve_responses_url(&options).unwrap(),
1419 "https://env.example.com/v1/responses"
1420 );
1421 }
1422
1423 #[test]
1424 fn resolve_responses_url_uses_provider_api_url_override() {
1425 let _lock = env_lock();
1426 let _endpoint_guard = EnvGuard::set(CODEX_RESPONSES_URL_ENV, None);
1427 let _base_guard = EnvGuard::set(CODEX_BASE_URL_ENV, None);
1428
1429 let options = ProviderRuntimeOptions {
1430 provider_api_url: Some("https://proxy.example.com/v1".to_string()),
1431 ..ProviderRuntimeOptions::default()
1432 };
1433
1434 assert_eq!(
1435 resolve_responses_url(&options).unwrap(),
1436 "https://proxy.example.com/v1/responses"
1437 );
1438 }
1439
1440 #[test]
1441 fn default_responses_url_detector_handles_equivalent_urls() {
1442 assert!(is_default_responses_url(DEFAULT_CODEX_RESPONSES_URL));
1443 assert!(is_default_responses_url(
1444 "https://chatgpt.com/backend-api/codex/responses/"
1445 ));
1446 assert!(!is_default_responses_url(
1447 "https://api.tonsof.blue/v1/responses"
1448 ));
1449 }
1450
1451 #[test]
1452 fn constructor_enables_custom_endpoint_key_mode() {
1453 let options = ProviderRuntimeOptions {
1454 provider_api_url: Some("https://api.tonsof.blue/v1".to_string()),
1455 ..ProviderRuntimeOptions::default()
1456 };
1457
1458 let provider = OpenAiCodexProvider::new(&options, Some("test-key")).unwrap();
1459 assert!(provider.custom_endpoint);
1460 assert_eq!(provider.gateway_api_key.as_deref(), Some("test-key"));
1461 }
1462
1463 #[test]
1464 fn resolve_instructions_uses_default_when_missing() {
1465 assert_eq!(
1466 resolve_instructions(None),
1467 DEFAULT_CODEX_INSTRUCTIONS.to_string()
1468 );
1469 }
1470
1471 #[test]
1472 fn resolve_instructions_uses_default_when_blank() {
1473 assert_eq!(
1474 resolve_instructions(Some(" ")),
1475 DEFAULT_CODEX_INSTRUCTIONS.to_string()
1476 );
1477 }
1478
1479 #[test]
1480 fn resolve_instructions_uses_system_prompt_when_present() {
1481 assert_eq!(
1482 resolve_instructions(Some("Be strict")),
1483 "Be strict".to_string()
1484 );
1485 }
1486
1487 #[test]
1488 fn clamp_reasoning_effort_adjusts_known_models() {
1489 assert_eq!(
1490 clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1491 "high".to_string()
1492 );
1493 assert_eq!(
1494 clamp_reasoning_effort("gpt-5-codex", "minimal"),
1495 "low".to_string()
1496 );
1497 assert_eq!(
1498 clamp_reasoning_effort("gpt-5-codex", "medium"),
1499 "medium".to_string()
1500 );
1501 assert_eq!(
1502 clamp_reasoning_effort("gpt-5.3-codex", "minimal"),
1503 "low".to_string()
1504 );
1505 assert_eq!(
1506 clamp_reasoning_effort("gpt-5.1", "xhigh"),
1507 "high".to_string()
1508 );
1509 assert_eq!(
1510 clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1511 "high".to_string()
1512 );
1513 assert_eq!(
1514 clamp_reasoning_effort("gpt-5.1-codex-mini", "low"),
1515 "medium".to_string()
1516 );
1517 assert_eq!(
1518 clamp_reasoning_effort("gpt-5.1-codex-mini", "xhigh"),
1519 "high".to_string()
1520 );
1521 assert_eq!(
1522 clamp_reasoning_effort("gpt-5.3-codex", "xhigh"),
1523 "xhigh".to_string()
1524 );
1525 }
1526
1527 #[test]
1528 fn resolve_reasoning_effort_prefers_configured_override() {
1529 let _lock = env_lock();
1530 let _guard = EnvGuard::set("CONSTRUCT_CODEX_REASONING_EFFORT", Some("low"));
1531 assert_eq!(
1532 resolve_reasoning_effort("gpt-5-codex", Some("high")),
1533 "high".to_string()
1534 );
1535 }
1536
1537 #[test]
1538 fn resolve_reasoning_effort_uses_legacy_env_when_unconfigured() {
1539 let _lock = env_lock();
1540 let _guard = EnvGuard::set("CONSTRUCT_CODEX_REASONING_EFFORT", Some("minimal"));
1541 assert_eq!(
1542 resolve_reasoning_effort("gpt-5-codex", None),
1543 "low".to_string()
1544 );
1545 }
1546
1547 #[test]
1548 fn parse_sse_text_reads_output_text_delta() {
1549 let payload = r#"data: {"type":"response.created","response":{"id":"resp_123"}}
1550
1551data: {"type":"response.output_text.delta","delta":"Hello"}
1552data: {"type":"response.output_text.delta","delta":" world"}
1553data: {"type":"response.completed","response":{"output_text":"Hello world"}}
1554data: [DONE]
1555"#;
1556
1557 assert_eq!(
1558 parse_sse_text(payload).unwrap().as_deref(),
1559 Some("Hello world")
1560 );
1561 }
1562
1563 #[test]
1564 fn parse_sse_text_falls_back_to_completed_response() {
1565 let payload = r#"data: {"type":"response.completed","response":{"output_text":"Done"}}
1566data: [DONE]
1567"#;
1568
1569 assert_eq!(parse_sse_text(payload).unwrap().as_deref(), Some("Done"));
1570 }
1571
1572 #[test]
1573 fn decode_utf8_stream_chunks_handles_multibyte_split_across_chunks() {
1574 let payload = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello 世\"}\n\ndata: [DONE]\n";
1575 let bytes = payload.as_bytes();
1576 let split_at = payload.find('世').unwrap() + 1;
1577
1578 let decoded = decode_utf8_stream_chunks([&bytes[..split_at], &bytes[split_at..]]).unwrap();
1579 assert_eq!(decoded, payload);
1580 assert_eq!(
1581 parse_sse_text(&decoded).unwrap().as_deref(),
1582 Some("Hello 世")
1583 );
1584 }
1585
1586 #[test]
1587 fn build_responses_input_maps_content_types_by_role() {
1588 let messages = vec![
1589 ChatMessage {
1590 role: "system".into(),
1591 content: "You are helpful.".into(),
1592 },
1593 ChatMessage {
1594 role: "user".into(),
1595 content: "Hi".into(),
1596 },
1597 ChatMessage {
1598 role: "assistant".into(),
1599 content: "Hello!".into(),
1600 },
1601 ChatMessage {
1602 role: "user".into(),
1603 content: "Thanks".into(),
1604 },
1605 ];
1606 let (instructions, input) = build_responses_input(&messages);
1607 assert_eq!(instructions, "You are helpful.");
1608 assert_eq!(input.len(), 3);
1609
1610 assert_eq!(input[0]["role"], "user");
1611 assert_eq!(input[0]["content"][0]["type"], "input_text");
1612 assert_eq!(input[1]["role"], "assistant");
1613 assert_eq!(input[1]["content"][0]["type"], "output_text");
1614 assert_eq!(input[2]["role"], "user");
1615 assert_eq!(input[2]["content"][0]["type"], "input_text");
1616 }
1617
1618 #[test]
1619 fn build_responses_input_uses_default_instructions_without_system() {
1620 let messages = vec![ChatMessage {
1621 role: "user".into(),
1622 content: "Hello".into(),
1623 }];
1624 let (instructions, input) = build_responses_input(&messages);
1625 assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
1626 assert_eq!(input.len(), 1);
1627 }
1628
1629 #[test]
1630 fn build_responses_input_ignores_unknown_roles() {
1631 let messages = vec![
1632 ChatMessage {
1633 role: "tool".into(),
1634 content: "result".into(),
1635 },
1636 ChatMessage {
1637 role: "user".into(),
1638 content: "Go".into(),
1639 },
1640 ];
1641 let (instructions, input) = build_responses_input(&messages);
1642 assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
1643 assert_eq!(input.len(), 1);
1644 assert_eq!(input[0]["role"], "user");
1645 }
1646
1647 #[test]
1648 fn build_responses_input_handles_image_markers() {
1649 let messages = vec![ChatMessage::user(
1650 "Describe this\n\n[IMAGE:data:image/png;base64,abc]",
1651 )];
1652 let (_, input) = build_responses_input(&messages);
1653
1654 assert_eq!(input.len(), 1);
1655 assert_eq!(input[0]["role"], "user");
1656 let content = input[0]["content"].as_array().unwrap();
1657 assert_eq!(content.len(), 2);
1658
1659 assert_eq!(content[0]["type"], "input_text");
1661 assert!(
1662 content[0]["text"]
1663 .as_str()
1664 .unwrap()
1665 .contains("Describe this")
1666 );
1667
1668 assert_eq!(content[1]["type"], "input_image");
1670 assert_eq!(content[1]["image_url"], "data:image/png;base64,abc");
1671 }
1672
1673 #[test]
1674 fn build_responses_input_preserves_text_only_messages() {
1675 let messages = vec![ChatMessage::user("Hello without images")];
1676 let (_, input) = build_responses_input(&messages);
1677
1678 assert_eq!(input.len(), 1);
1679 let content = input[0]["content"].as_array().unwrap();
1680 assert_eq!(content.len(), 1);
1681
1682 assert_eq!(content[0]["type"], "input_text");
1683 assert_eq!(content[0]["text"], "Hello without images");
1684 }
1685
1686 #[test]
1687 fn build_responses_input_handles_multiple_images() {
1688 let messages = vec![ChatMessage::user(
1689 "Compare these: [IMAGE:data:image/png;base64,img1] and [IMAGE:data:image/jpeg;base64,img2]",
1690 )];
1691 let (_, input) = build_responses_input(&messages);
1692
1693 assert_eq!(input.len(), 1);
1694 let content = input[0]["content"].as_array().unwrap();
1695 assert_eq!(content.len(), 3); assert_eq!(content[0]["type"], "input_text");
1698 assert_eq!(content[1]["type"], "input_image");
1699 assert_eq!(content[2]["type"], "input_image");
1700 }
1701
1702 #[test]
1703 fn capabilities_includes_vision() {
1704 let options = ProviderRuntimeOptions {
1705 provider_api_url: None,
1706 construct_dir: None,
1707 secrets_encrypt: false,
1708 auth_profile_override: None,
1709 reasoning_enabled: None,
1710 reasoning_effort: None,
1711 provider_timeout_secs: None,
1712 extra_headers: std::collections::HashMap::new(),
1713 api_path: None,
1714 provider_max_tokens: None,
1715 };
1716 let provider =
1717 OpenAiCodexProvider::new(&options, None).expect("provider should initialize");
1718 let caps = provider.capabilities();
1719
1720 assert!(caps.native_tool_calling);
1721 assert!(caps.vision);
1722 }
1723
1724 #[test]
1725 fn build_responses_input_drops_orphaned_function_call_output() {
1726 let messages = vec![
1729 ChatMessage {
1730 role: "system".into(),
1731 content: "You are helpful.".into(),
1732 },
1733 ChatMessage {
1734 role: "user".into(),
1735 content: "Do something.".into(),
1736 },
1737 ChatMessage {
1739 role: "tool".into(),
1740 content: r#"{"tool_call_id": "call_orphaned", "content": "some result"}"#.into(),
1741 },
1742 ];
1743 let (_, input) = build_responses_input_with_tools(&messages);
1744 assert!(
1746 !input.iter().any(
1747 |item| item.get("type").and_then(Value::as_str) == Some("function_call_output")
1748 ),
1749 "orphaned function_call_output should be filtered out"
1750 );
1751 }
1752
1753 #[test]
1754 fn build_responses_input_keeps_matched_function_call_output() {
1755 let messages = vec![
1756 ChatMessage { role: "system".into(), content: "You are helpful.".into() },
1757 ChatMessage { role: "user".into(), content: "Do something.".into() },
1758 ChatMessage {
1759 role: "assistant".into(),
1760 content: r#"{"content": null, "tool_calls": [{"id": "call_good", "name": "tool_search", "arguments": "{}"}]}"#.into(),
1761 },
1762 ChatMessage {
1763 role: "tool".into(),
1764 content: r#"{"tool_call_id": "call_good", "content": "search results"}"#.into(),
1765 },
1766 ];
1767 let (_, input) = build_responses_input_with_tools(&messages);
1768 let has_call = input.iter().any(|item| {
1769 item.get("type").and_then(Value::as_str) == Some("function_call")
1770 && item.get("call_id").and_then(Value::as_str) == Some("call_good")
1771 });
1772 let has_output = input.iter().any(|item| {
1773 item.get("type").and_then(Value::as_str) == Some("function_call_output")
1774 && item.get("call_id").and_then(Value::as_str) == Some("call_good")
1775 });
1776 assert!(has_call, "function_call should be present");
1777 assert!(has_output, "matched function_call_output should be kept");
1778 }
1779}