1use crate::ModelProviderRuntimeOptions;
2use crate::auth::AuthService;
3use crate::auth::openai_oauth::extract_account_id_from_jwt;
4use crate::multimodal;
5use crate::traits::{
6 ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
7 ModelProvider, ProviderCapabilities, StreamChunk, StreamError, StreamEvent, StreamOptions,
8 StreamResult, ToolCall as ProviderToolCall,
9};
10use async_trait::async_trait;
11use futures_util::StreamExt;
12use futures_util::stream;
13use reqwest::Client;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::collections::{HashMap, HashSet};
17use std::path::PathBuf;
18use zeroclaw_api::tool::ToolSpec;
19
20const DEFAULT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
21const DEFAULT_CODEX_INSTRUCTIONS: &str =
22 "You are ZeroClaw, a concise and helpful coding assistant.";
23const WIRE_API: &str = "responses";
25const RESPONSES_HISTORY_PROVIDER: &str = "openai_codex";
26const RESPONSES_HISTORY_KIND: &str = "responses_output_items";
27
28#[derive(Clone)]
29pub struct OpenAiCodexModelProvider {
30 alias: String,
32 auth: AuthService,
33 auth_profile_override: Option<String>,
34 responses_url: String,
35 custom_endpoint: bool,
36 gateway_api_key: Option<String>,
37 reasoning_effort: Option<String>,
38 client: Client,
39}
40
41#[derive(Debug, Serialize)]
42struct ResponsesRequest {
43 model: String,
44 input: Vec<Value>,
45 instructions: String,
46 store: bool,
47 stream: bool,
48 text: ResponsesTextOptions,
49 reasoning: ResponsesReasoningOptions,
50 include: Vec<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 tools: Option<Vec<ResponsesToolSpec>>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 tool_choice: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 parallel_tool_calls: Option<bool>,
57}
58
59#[derive(Debug, Serialize)]
60struct ResponsesToolSpec {
61 #[serde(rename = "type")]
62 kind: String,
63 name: String,
64 description: String,
65 parameters: Value,
66 strict: bool,
67}
68
69#[derive(Debug, Serialize)]
70struct ResponsesTextOptions {
71 verbosity: String,
72}
73
74#[derive(Debug, Serialize)]
75struct ResponsesReasoningOptions {
76 effort: String,
77 summary: String,
78}
79
80#[derive(Debug, Deserialize)]
81struct ResponsesResponse {
82 #[serde(default)]
83 output: Vec<Value>,
84 #[serde(default)]
85 output_text: Option<String>,
86}
87
88#[derive(Debug, Default)]
89struct ResponsesStreamState {
90 saw_text_delta: bool,
91 text_accumulator: String,
92 fallback_text: Option<String>,
93 tool_calls: HashMap<String, PendingToolCall>,
94 emitted_tool_call_ids: HashSet<String>,
95 collected_tool_calls: Vec<ProviderToolCall>,
96 output_items: Vec<Value>,
97}
98
99#[derive(Debug, Default, Clone)]
100struct PendingToolCall {
101 item_id: Option<String>,
102 call_id: Option<String>,
103 name: Option<String>,
104 arguments: String,
105}
106
107#[derive(Debug, Default)]
108struct ResponsesTurnResult {
109 text: Option<String>,
110 tool_calls: Vec<ProviderToolCall>,
111 reasoning_content: Option<String>,
112}
113
114impl OpenAiCodexModelProvider {
115 pub fn new(
116 alias: &str,
117 options: &ModelProviderRuntimeOptions,
118 gateway_api_key: Option<&str>,
119 ) -> anyhow::Result<Self> {
120 let state_dir = options
121 .zeroclaw_dir
122 .clone()
123 .unwrap_or_else(default_zeroclaw_dir);
124 let auth = AuthService::new(&state_dir, options.secrets_encrypt);
125 let responses_url = resolve_responses_url(options)?;
126
127 Ok(Self {
128 alias: alias.to_string(),
129 auth,
130 auth_profile_override: options.auth_profile_override.clone(),
131 custom_endpoint: !is_default_responses_url(&responses_url),
132 responses_url,
133 gateway_api_key: gateway_api_key.map(ToString::to_string),
134 reasoning_effort: options.reasoning_effort.clone(),
135 client: Client::builder()
136 .connect_timeout(std::time::Duration::from_secs(10))
137 .read_timeout(std::time::Duration::from_secs(300))
138 .build()
139 .unwrap_or_else(|_| Client::new()),
140 })
141 }
142}
143
144fn default_zeroclaw_dir() -> PathBuf {
145 directories::UserDirs::new().map_or_else(
146 || PathBuf::from(".zeroclaw"),
147 |dirs| dirs.home_dir().join(".zeroclaw"),
148 )
149}
150
151fn build_responses_url(base_or_endpoint: &str) -> anyhow::Result<String> {
152 let candidate = base_or_endpoint.trim();
153 if candidate.is_empty() {
154 anyhow::bail!("OpenAI Codex endpoint override cannot be empty");
155 }
156
157 let mut parsed = reqwest::Url::parse(candidate).map_err(|_| {
158 ::zeroclaw_log::record!(
159 WARN,
160 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
161 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
162 .with_attrs(::serde_json::json!({"candidate": candidate})),
163 "openai_codex: endpoint override is not a valid URL"
164 );
165 anyhow::Error::msg("OpenAI Codex endpoint override must be a valid URL")
166 })?;
167
168 match parsed.scheme() {
169 "http" | "https" => {}
170 _ => anyhow::bail!("OpenAI Codex endpoint override must use http:// or https://"),
171 }
172
173 let path = parsed.path().trim_end_matches('/');
174 if !path.ends_with("/responses") {
175 let with_suffix = if path.is_empty() || path == "/" {
176 "/responses".to_string()
177 } else {
178 format!("{path}/responses")
179 };
180 parsed.set_path(&with_suffix);
181 }
182
183 parsed.set_query(None);
184 parsed.set_fragment(None);
185
186 Ok(parsed.to_string())
187}
188
189fn resolve_responses_url(options: &ModelProviderRuntimeOptions) -> anyhow::Result<String> {
190 if let Some(api_url) = options
191 .provider_api_url
192 .as_deref()
193 .and_then(|value| first_nonempty(Some(value)))
194 {
195 return build_responses_url(&api_url);
196 }
197
198 Ok(DEFAULT_CODEX_RESPONSES_URL.to_string())
199}
200
201fn canonical_endpoint(url: &str) -> Option<(String, String, u16, String)> {
202 let parsed = reqwest::Url::parse(url).ok()?;
203 let host = parsed.host_str()?.to_ascii_lowercase();
204 let port = parsed.port_or_known_default()?;
205 let path = parsed.path().trim_end_matches('/').to_string();
206 Some((parsed.scheme().to_ascii_lowercase(), host, port, path))
207}
208
209fn is_default_responses_url(url: &str) -> bool {
210 canonical_endpoint(url) == canonical_endpoint(DEFAULT_CODEX_RESPONSES_URL)
211}
212
213fn first_nonempty(text: Option<&str>) -> Option<String> {
214 text.and_then(|value| {
215 let trimmed = value.trim();
216 if trimmed.is_empty() {
217 None
218 } else {
219 Some(trimmed.to_string())
220 }
221 })
222}
223
224fn normalize_model_id(model: &str) -> &str {
225 model.rsplit('/').next().unwrap_or(model)
226}
227
228fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<ResponsesToolSpec>> {
229 let items = tools?;
230 if items.is_empty() {
231 return None;
232 }
233
234 Some(
235 items
236 .iter()
237 .map(|tool| ResponsesToolSpec {
238 kind: "function".to_string(),
239 name: tool.name.clone(),
240 description: tool.description.clone(),
241 parameters: tool.parameters.clone(),
242 strict: false,
243 })
244 .collect(),
245 )
246}
247
248fn response_message_item(role: &str, content: Vec<Value>) -> Value {
249 serde_json::json!({
250 "type": "message",
251 "role": role,
252 "content": content,
253 })
254}
255
256fn legacy_tool_output_message(content: &str) -> Value {
257 response_message_item(
258 "user",
259 vec![serde_json::json!({
260 "type": "input_text",
261 "text": format!("Legacy tool output without call_id:\n{content}"),
262 })],
263 )
264}
265
266fn response_item_type(item: &Value) -> Option<&str> {
267 item.get("type").and_then(Value::as_str)
268}
269
270fn is_replayable_responses_output_item(item: &Value) -> bool {
271 matches!(
272 response_item_type(item),
273 Some("message" | "reasoning" | "function_call")
274 )
275}
276
277fn encode_responses_history_items(output_items: &[Value], has_tool_calls: bool) -> Option<String> {
278 if !has_tool_calls {
279 return None;
280 }
281
282 let replay_items = output_items
283 .iter()
284 .filter(|item| is_replayable_responses_output_item(item))
285 .cloned()
286 .collect::<Vec<_>>();
287
288 if !replay_items
289 .iter()
290 .any(|item| response_item_type(item) == Some("function_call"))
291 {
292 return None;
293 }
294
295 serde_json::to_string(&serde_json::json!({
296 "provider": RESPONSES_HISTORY_PROVIDER,
297 "kind": RESPONSES_HISTORY_KIND,
298 "items": replay_items,
299 }))
300 .ok()
301}
302
303fn decode_responses_history_items(reasoning_content: &str) -> Option<Vec<Value>> {
304 let value = serde_json::from_str::<Value>(reasoning_content).ok()?;
305 if value.get("provider").and_then(Value::as_str) != Some(RESPONSES_HISTORY_PROVIDER)
306 || value.get("kind").and_then(Value::as_str) != Some(RESPONSES_HISTORY_KIND)
307 {
308 return None;
309 }
310
311 let items = value
312 .get("items")
313 .and_then(Value::as_array)?
314 .iter()
315 .filter(|item| is_replayable_responses_output_item(item))
316 .cloned()
317 .collect::<Vec<_>>();
318
319 (!items.is_empty()).then_some(items)
320}
321
322fn build_responses_input(messages: &[ChatMessage]) -> (String, Vec<Value>) {
323 let mut system_parts: Vec<&str> = Vec::new();
324 let mut input: Vec<Value> = Vec::new();
325
326 for msg in messages {
327 match msg.role.as_str() {
328 "system" => system_parts.push(&msg.content),
329 "user" => {
330 let (cleaned_text, image_refs) = multimodal::parse_image_markers(&msg.content);
331
332 let mut content_items = Vec::new();
333
334 if !cleaned_text.trim().is_empty() {
335 content_items.push(serde_json::json!({
336 "type": "input_text",
337 "text": cleaned_text,
338 }));
339 }
340
341 for image_ref in image_refs {
342 content_items.push(serde_json::json!({
343 "type": "input_image",
344 "image_url": image_ref,
345 }));
346 }
347
348 if content_items.is_empty() {
349 content_items.push(serde_json::json!({
350 "type": "input_text",
351 "text": "",
352 }));
353 }
354
355 input.push(response_message_item("user", content_items));
356 }
357 "assistant" => {
358 if let Ok(value) = serde_json::from_str::<Value>(&msg.content)
359 && let Some(tool_calls_value) = value.get("tool_calls")
360 && let Ok(parsed_calls) =
361 serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
362 {
363 let content = value
364 .get("content")
365 .and_then(Value::as_str)
366 .filter(|content| !content.trim().is_empty());
367 let responses_history_items = value
368 .get("reasoning_content")
369 .and_then(Value::as_str)
370 .and_then(decode_responses_history_items);
371
372 if let Some(items) = responses_history_items {
373 if let Some(content) = content
374 && !items
375 .iter()
376 .any(|item| response_item_type(item) == Some("message"))
377 {
378 input.push(response_message_item(
379 "assistant",
380 vec![serde_json::json!({
381 "type": "output_text",
382 "text": content,
383 })],
384 ));
385 }
386
387 input.extend(items);
388 continue;
389 }
390
391 if let Some(content) = value
392 .get("content")
393 .and_then(Value::as_str)
394 .filter(|content| !content.trim().is_empty())
395 {
396 input.push(response_message_item(
397 "assistant",
398 vec![serde_json::json!({
399 "type": "output_text",
400 "text": content,
401 })],
402 ));
403 }
404
405 for call in parsed_calls {
406 input.push(serde_json::json!({
407 "type": "function_call",
408 "call_id": call.id,
409 "name": call.name,
410 "arguments": call.arguments,
411 }));
412 }
413 } else if !msg.content.trim().is_empty() {
414 input.push(response_message_item(
415 "assistant",
416 vec![serde_json::json!({
417 "type": "output_text",
418 "text": msg.content,
419 })],
420 ));
421 }
422 }
423 "tool" => {
424 if let Ok(value) = serde_json::from_str::<Value>(&msg.content) {
425 if let Some(call_id) = value
426 .get("tool_call_id")
427 .and_then(Value::as_str)
428 .and_then(|id| first_nonempty(Some(id)))
429 {
430 let output = value
431 .get("content")
432 .and_then(Value::as_str)
433 .unwrap_or_default();
434 input.push(serde_json::json!({
435 "type": "function_call_output",
436 "call_id": call_id,
437 "output": output,
438 }));
439 } else if !msg.content.trim().is_empty() {
440 input.push(legacy_tool_output_message(&msg.content));
441 }
442 } else if !msg.content.trim().is_empty() {
443 input.push(legacy_tool_output_message(&msg.content));
444 }
445 }
446 _ => {}
447 }
448 }
449
450 let instructions = if system_parts.is_empty() {
451 DEFAULT_CODEX_INSTRUCTIONS.to_string()
452 } else {
453 system_parts.join("\n\n")
454 };
455
456 (instructions, input)
457}
458
459fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
460 let id = normalize_model_id(model);
461 if id == "gpt-5-codex" {
463 return match effort {
464 "low" | "medium" | "high" => effort.to_string(),
465 "minimal" => "low".to_string(),
466 _ => "high".to_string(),
467 };
468 }
469 if (id.starts_with("gpt-5.2") || id.starts_with("gpt-5.3")) && effort == "minimal" {
470 return "low".to_string();
471 }
472 if id.starts_with("gpt-5-codex") && effort == "xhigh" {
473 return "high".to_string();
474 }
475 if id == "gpt-5.1" && effort == "xhigh" {
476 return "high".to_string();
477 }
478 if id == "gpt-5.1-codex-mini" {
479 return if effort == "high" || effort == "xhigh" {
480 "high".to_string()
481 } else {
482 "medium".to_string()
483 };
484 }
485 effort.to_string()
486}
487
488fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String {
489 let raw = configured
490 .and_then(|value| first_nonempty(Some(value)))
491 .map(|s| s.to_ascii_lowercase())
492 .unwrap_or_else(|| "xhigh".to_string());
493 clamp_reasoning_effort(model_id, &raw)
494}
495
496fn nonempty_preserve(text: Option<&str>) -> Option<String> {
497 text.and_then(|value| {
498 if value.is_empty() {
499 None
500 } else {
501 Some(value.to_string())
502 }
503 })
504}
505
506fn extract_responses_text(response: &ResponsesResponse) -> Option<String> {
507 if let Some(text) = first_nonempty(response.output_text.as_deref()) {
508 return Some(text);
509 }
510
511 for item in &response.output {
512 if response_item_type(item) != Some("message") {
513 continue;
514 }
515
516 if let Some(parts) = item.get("content").and_then(Value::as_array) {
517 for content in parts {
518 if response_item_type(content) == Some("output_text")
519 && let Some(text) = first_nonempty(content.get("text").and_then(Value::as_str))
520 {
521 return Some(text);
522 }
523 }
524 }
525 }
526
527 for item in &response.output {
528 if let Some(parts) = item.get("content").and_then(Value::as_array) {
529 for content in parts {
530 if let Some(text) = first_nonempty(content.get("text").and_then(Value::as_str)) {
531 return Some(text);
532 }
533 }
534 }
535 }
536
537 None
538}
539
540fn extract_responses_tool_calls(response: &ResponsesResponse) -> Vec<ProviderToolCall> {
541 response
542 .output
543 .iter()
544 .filter(|item| response_item_type(item) == Some("function_call"))
545 .filter_map(|item| {
546 let name = item.get("name").and_then(Value::as_str)?.to_string();
547 let arguments = item
548 .get("arguments")
549 .and_then(Value::as_str)
550 .unwrap_or_default()
551 .to_string();
552 Some(ProviderToolCall {
553 id: item
554 .get("call_id")
555 .and_then(Value::as_str)
556 .or_else(|| item.get("id").and_then(Value::as_str))
557 .map(ToString::to_string)
558 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
559 name,
560 arguments,
561 extra_content: None,
562 })
563 })
564 .collect()
565}
566
567fn responses_turn_from_response(response: &ResponsesResponse) -> ResponsesTurnResult {
568 let tool_calls = extract_responses_tool_calls(response);
569 let reasoning_content =
570 encode_responses_history_items(&response.output, !tool_calls.is_empty());
571
572 ResponsesTurnResult {
573 text: extract_responses_text(response),
574 tool_calls,
575 reasoning_content,
576 }
577}
578
579fn record_responses_output_item(state: &mut ResponsesStreamState, item: Value) {
580 if !is_replayable_responses_output_item(&item) {
581 return;
582 }
583
584 let item_id = item
585 .get("id")
586 .and_then(Value::as_str)
587 .or_else(|| item.get("call_id").and_then(Value::as_str));
588 if let Some(item_id) = item_id
589 && state.output_items.iter().any(|existing| {
590 existing
591 .get("id")
592 .and_then(Value::as_str)
593 .or_else(|| existing.get("call_id").and_then(Value::as_str))
594 == Some(item_id)
595 })
596 {
597 return;
598 }
599
600 state.output_items.push(item);
601}
602
603fn replace_responses_output_items(state: &mut ResponsesStreamState, items: &[Value]) {
604 let replay_items = items
605 .iter()
606 .filter(|item| is_replayable_responses_output_item(item))
607 .cloned()
608 .collect::<Vec<_>>();
609
610 if !replay_items.is_empty() {
611 state.output_items = replay_items;
612 }
613}
614
615fn response_output_text_from_event_item(item: &Value) -> Option<String> {
616 if item.get("type").and_then(Value::as_str) != Some("message") {
617 return None;
618 }
619
620 item.get("content")
621 .and_then(Value::as_array)
622 .and_then(|parts| {
623 parts.iter().find_map(|part| {
624 if part.get("type").and_then(Value::as_str) == Some("output_text") {
625 first_nonempty(part.get("text").and_then(Value::as_str))
626 } else {
627 None
628 }
629 })
630 })
631}
632
633fn pending_tool_call_key(item_id: Option<&str>, output_index: Option<u64>) -> Option<String> {
634 item_id
635 .map(ToString::to_string)
636 .or_else(|| output_index.map(|index| format!("output:{index}")))
637}
638
639fn emit_tool_call(
640 state: &mut ResponsesStreamState,
641 tool_call: ProviderToolCall,
642) -> Option<ProviderToolCall> {
643 if state.emitted_tool_call_ids.insert(tool_call.id.clone()) {
644 state.collected_tool_calls.push(tool_call.clone());
645 Some(tool_call)
646 } else {
647 None
648 }
649}
650
651#[derive(Debug)]
652struct ResponsesStreamApiError(String);
653
654impl std::fmt::Display for ResponsesStreamApiError {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 write!(f, "OpenAI Codex stream error: {}", self.0)
657 }
658}
659
660impl std::error::Error for ResponsesStreamApiError {}
661
662fn process_responses_stream_event(
663 event: Value,
664 state: &mut ResponsesStreamState,
665) -> anyhow::Result<Vec<StreamEvent>> {
666 if let Some(message) = extract_stream_error_message(&event) {
667 return Err(ResponsesStreamApiError(message).into());
668 }
669
670 let mut emitted = Vec::new();
671 match event.get("type").and_then(Value::as_str) {
672 Some("response.output_text.delta") => {
673 if let Some(text) = nonempty_preserve(event.get("delta").and_then(Value::as_str)) {
674 state.saw_text_delta = true;
675 state.text_accumulator.push_str(&text);
676 emitted.push(StreamEvent::TextDelta(StreamChunk::delta(text)));
677 }
678 }
679 Some("response.output_text.done") if !state.saw_text_delta => {
680 state.fallback_text = nonempty_preserve(event.get("text").and_then(Value::as_str));
681 }
682 Some("response.output_item.added") => {
683 let item = event.get("item");
684 let item_type = item
685 .and_then(|value| value.get("type"))
686 .and_then(Value::as_str);
687 if item_type == Some("function_call") {
688 let key = pending_tool_call_key(
689 item.and_then(|value| value.get("id"))
690 .and_then(Value::as_str),
691 event.get("output_index").and_then(Value::as_u64),
692 );
693 if let Some(key) = key {
694 let entry = state.tool_calls.entry(key).or_default();
695 entry.item_id = item
696 .and_then(|value| value.get("id"))
697 .and_then(Value::as_str)
698 .map(ToString::to_string);
699 entry.call_id = item
700 .and_then(|value| value.get("call_id"))
701 .and_then(Value::as_str)
702 .map(ToString::to_string);
703 entry.name = item
704 .and_then(|value| value.get("name"))
705 .and_then(Value::as_str)
706 .map(ToString::to_string);
707 if let Some(arguments) = item
708 .and_then(|value| value.get("arguments"))
709 .and_then(Value::as_str)
710 {
711 entry.arguments = arguments.to_string();
712 }
713 }
714 }
715 }
716 Some("response.function_call_arguments.delta") => {
717 if let Some(key) = pending_tool_call_key(
718 event.get("item_id").and_then(Value::as_str),
719 event.get("output_index").and_then(Value::as_u64),
720 ) {
721 let entry = state.tool_calls.entry(key).or_default();
722 entry.item_id = event
723 .get("item_id")
724 .and_then(Value::as_str)
725 .map(ToString::to_string);
726 entry.arguments.push_str(
727 event
728 .get("delta")
729 .and_then(Value::as_str)
730 .unwrap_or_default(),
731 );
732 }
733 }
734 Some("response.function_call_arguments.done") => {
735 let key = pending_tool_call_key(
736 event.get("item_id").and_then(Value::as_str),
737 event.get("output_index").and_then(Value::as_u64),
738 );
739 let mut pending = key
740 .as_ref()
741 .and_then(|key| state.tool_calls.remove(key))
742 .unwrap_or_default();
743 pending.item_id = pending.item_id.or_else(|| {
744 event
745 .get("item_id")
746 .and_then(Value::as_str)
747 .map(ToString::to_string)
748 });
749 pending.call_id = pending.call_id.or_else(|| {
750 event
751 .get("call_id")
752 .and_then(Value::as_str)
753 .map(ToString::to_string)
754 });
755 pending.name = pending.name.or_else(|| {
756 event
757 .get("name")
758 .and_then(Value::as_str)
759 .map(ToString::to_string)
760 });
761 if let Some(arguments) = event.get("arguments").and_then(Value::as_str) {
762 pending.arguments = arguments.to_string();
763 }
764
765 if let Some(name) = pending.name {
766 let tool_call = ProviderToolCall {
767 id: pending
768 .call_id
769 .or(pending.item_id)
770 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
771 name,
772 arguments: pending.arguments,
773 extra_content: None,
774 };
775 if let Some(tool_call) = emit_tool_call(state, tool_call) {
776 emitted.push(StreamEvent::ToolCall(tool_call));
777 }
778 }
779 }
780 Some("response.output_item.done") => {
781 if let Some(item) = event.get("item") {
782 record_responses_output_item(state, item.clone());
783 match item.get("type").and_then(Value::as_str) {
784 Some("message") if !state.saw_text_delta && state.fallback_text.is_none() => {
785 state.fallback_text = response_output_text_from_event_item(item);
786 }
787 Some("function_call") => {
788 if let Some(name) = item.get("name").and_then(Value::as_str) {
789 let tool_call = ProviderToolCall {
790 id: item
791 .get("call_id")
792 .and_then(Value::as_str)
793 .or_else(|| item.get("id").and_then(Value::as_str))
794 .unwrap_or_default()
795 .to_string(),
796 name: name.to_string(),
797 arguments: item
798 .get("arguments")
799 .and_then(Value::as_str)
800 .unwrap_or_default()
801 .to_string(),
802 extra_content: None,
803 };
804 if let Some(tool_call) = emit_tool_call(state, tool_call) {
805 emitted.push(StreamEvent::ToolCall(tool_call));
806 }
807 }
808 }
809 _ => {}
810 }
811 }
812 }
813 Some("response.completed" | "response.done") => {
814 if let Some(response) = event
815 .get("response")
816 .and_then(|value| serde_json::from_value::<ResponsesResponse>(value.clone()).ok())
817 {
818 if !state.saw_text_delta && state.fallback_text.is_none() {
819 state.fallback_text = extract_responses_text(&response);
820 }
821 replace_responses_output_items(state, &response.output);
822 for tool_call in extract_responses_tool_calls(&response) {
823 if let Some(tool_call) = emit_tool_call(state, tool_call) {
824 emitted.push(StreamEvent::ToolCall(tool_call));
825 }
826 }
827 }
828 }
829 _ => {}
830 }
831
832 Ok(emitted)
833}
834
835fn process_sse_chunk(
836 chunk: &str,
837 state: &mut ResponsesStreamState,
838) -> anyhow::Result<Vec<StreamEvent>> {
839 let data_lines: Vec<String> = chunk
840 .lines()
841 .filter_map(|line| line.strip_prefix("data:"))
842 .map(|line| line.trim().to_string())
843 .collect();
844 if data_lines.is_empty() {
845 return Ok(Vec::new());
846 }
847
848 let joined = data_lines.join("\n");
849 let trimmed = joined.trim();
850 if trimmed.is_empty() || trimmed == "[DONE]" {
851 return Ok(Vec::new());
852 }
853
854 if let Ok(event) = serde_json::from_str::<Value>(trimmed) {
855 return process_responses_stream_event(event, state);
856 }
857
858 let mut emitted = Vec::new();
859 for line in data_lines {
860 let line = line.trim();
861 if line.is_empty() || line == "[DONE]" {
862 continue;
863 }
864 let event = serde_json::from_str::<Value>(line).map_err(|err| {
865 let sanitized = super::sanitize_api_error(line);
866 ::zeroclaw_log::record!(
867 ERROR,
868 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
869 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
870 .with_attrs(::serde_json::json!({
871 "phase": "sse_parse",
872 "payload": &sanitized,
873 "error": format!("{}", err),
874 })),
875 "openai_codex: SSE data parse failed"
876 );
877 anyhow::Error::msg(format!(
878 "OpenAI Codex SSE data parse failed: {err}. Payload: {sanitized}"
879 ))
880 })?;
881 emitted.extend(process_responses_stream_event(event, state)?);
882 }
883
884 Ok(emitted)
885}
886
887fn parse_sse_turn(body: &str) -> anyhow::Result<ResponsesTurnResult> {
888 let mut state = ResponsesStreamState::default();
889 let mut buffer = body.to_string();
890
891 while let Some(idx) = buffer.find("\n\n") {
892 let chunk = buffer[..idx].to_string();
893 buffer = buffer[idx + 2..].to_string();
894 process_sse_chunk(&chunk, &mut state)?;
895 }
896
897 if !buffer.trim().is_empty() {
898 process_sse_chunk(&buffer, &mut state)?;
899 }
900
901 Ok(ResponsesTurnResult {
902 text: if state.saw_text_delta {
903 nonempty_preserve(Some(&state.text_accumulator))
904 } else {
905 state.fallback_text
906 },
907 reasoning_content: encode_responses_history_items(
908 &state.output_items,
909 !state.collected_tool_calls.is_empty(),
910 ),
911 tool_calls: state.collected_tool_calls,
912 })
913}
914
915fn ensure_nonempty_responses_turn(
916 result: ResponsesTurnResult,
917 empty_error: impl FnOnce() -> anyhow::Error,
918) -> anyhow::Result<ResponsesTurnResult> {
919 if result.text.as_deref().is_some_and(|text| !text.is_empty()) || !result.tool_calls.is_empty()
920 {
921 Ok(result)
922 } else {
923 Err(empty_error())
924 }
925}
926
927fn extract_stream_error_message(event: &Value) -> Option<String> {
928 let event_type = event.get("type").and_then(Value::as_str);
929
930 if event_type == Some("error") {
931 return first_nonempty(
932 event
933 .get("message")
934 .and_then(Value::as_str)
935 .or_else(|| event.get("code").and_then(Value::as_str))
936 .or_else(|| {
937 event
938 .get("error")
939 .and_then(|error| error.get("message"))
940 .and_then(Value::as_str)
941 }),
942 );
943 }
944
945 if event_type == Some("response.failed") {
946 return first_nonempty(
947 event
948 .get("response")
949 .and_then(|response| response.get("error"))
950 .and_then(|error| error.get("message"))
951 .and_then(Value::as_str),
952 );
953 }
954
955 None
956}
957
958fn append_utf8_stream_chunk(
959 body: &mut String,
960 pending: &mut Vec<u8>,
961 chunk: &[u8],
962) -> anyhow::Result<()> {
963 if pending.is_empty()
964 && let Ok(text) = std::str::from_utf8(chunk)
965 {
966 body.push_str(text);
967 return Ok(());
968 }
969
970 if !chunk.is_empty() {
971 pending.extend_from_slice(chunk);
972 }
973 if pending.is_empty() {
974 return Ok(());
975 }
976
977 match std::str::from_utf8(pending) {
978 Ok(text) => {
979 body.push_str(text);
980 pending.clear();
981 Ok(())
982 }
983 Err(err) => {
984 let valid_up_to = err.valid_up_to();
985 if valid_up_to > 0 {
986 let prefix = std::str::from_utf8(&pending[..valid_up_to])
988 .expect("valid UTF-8 prefix from Utf8Error::valid_up_to");
989 body.push_str(prefix);
990 pending.drain(..valid_up_to);
991 }
992
993 if err.error_len().is_some() {
994 ::zeroclaw_log::record!(
995 ERROR,
996 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
997 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
998 .with_attrs(::serde_json::json!({
999 "phase": "utf8_decode",
1000 "error": format!("{}", err),
1001 })),
1002 "openai_codex: response contained invalid UTF-8"
1003 );
1004 return Err(anyhow::Error::msg(format!(
1005 "OpenAI Codex response contained invalid UTF-8: {err}"
1006 )));
1007 }
1008
1009 Ok(())
1012 }
1013 }
1014}
1015
1016fn parse_responses_body(body: &str) -> anyhow::Result<ResponsesTurnResult> {
1017 let body_trimmed = body.trim_start();
1018 let looks_like_sse = body_trimmed.starts_with("event:") || body_trimmed.starts_with("data:");
1019 if looks_like_sse {
1020 let result = parse_sse_turn(body)?;
1021 return ensure_nonempty_responses_turn(result, || {
1022 let sanitized = super::sanitize_api_error(body);
1023 ::zeroclaw_log::record!(
1024 ERROR,
1025 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1026 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1027 .with_attrs(::serde_json::json!({"payload": &sanitized})),
1028 "openai_codex: empty SSE stream payload"
1029 );
1030 anyhow::Error::msg(format!(
1031 "No response from OpenAI Codex stream payload: {sanitized}"
1032 ))
1033 });
1034 }
1035
1036 let parsed: ResponsesResponse = serde_json::from_str(body).map_err(|err| {
1037 let sanitized = super::sanitize_api_error(body);
1038 ::zeroclaw_log::record!(
1039 ERROR,
1040 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1041 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1042 .with_attrs(::serde_json::json!({
1043 "payload": &sanitized,
1044 "error": format!("{}", err),
1045 })),
1046 "openai_codex: JSON parse failed"
1047 );
1048 anyhow::Error::msg(format!(
1049 "OpenAI Codex JSON parse failed: {err}. Payload: {sanitized}"
1050 ))
1051 })?;
1052 let result = responses_turn_from_response(&parsed);
1053 ensure_nonempty_responses_turn(result, || {
1054 let sanitized = super::sanitize_api_error(body);
1055 ::zeroclaw_log::record!(
1056 ERROR,
1057 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1058 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1059 .with_attrs(::serde_json::json!({"payload": &sanitized})),
1060 "openai_codex: empty response"
1061 );
1062 anyhow::Error::msg(format!("No response from OpenAI Codex: {sanitized}"))
1063 })
1064}
1065
1066async fn decode_responses_body(response: reqwest::Response) -> anyhow::Result<ResponsesTurnResult> {
1073 let mut body = String::new();
1074 let mut pending_utf8 = Vec::new();
1075 let mut stream = response.bytes_stream();
1076
1077 while let Some(chunk) = stream.next().await {
1078 let bytes = chunk.map_err(|err| {
1079 ::zeroclaw_log::record!(
1080 ERROR,
1081 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1082 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1083 .with_attrs(::serde_json::json!({
1084 "phase": "stream_read",
1085 "error": format!("{}", err),
1086 })),
1087 "openai_codex: error reading response stream"
1088 );
1089 anyhow::Error::msg(format!("error reading OpenAI Codex response stream: {err}"))
1090 })?;
1091 append_utf8_stream_chunk(&mut body, &mut pending_utf8, &bytes)?;
1092 }
1093
1094 if !pending_utf8.is_empty() {
1095 let err = match std::str::from_utf8(&pending_utf8) {
1096 Err(e) => e,
1097 Ok(_) => {
1098 ::zeroclaw_log::record!(
1103 ERROR,
1104 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1105 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
1106 "openai_codex: pending bytes were valid UTF-8 (invariant violated)"
1107 );
1108 return Err(anyhow::Error::msg(
1109 "OpenAI Codex response stream ended with valid UTF-8 in pending bytes (unexpected)",
1110 ));
1111 }
1112 };
1113 ::zeroclaw_log::record!(
1114 ERROR,
1115 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1116 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1117 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1118 "openai_codex: response ended with incomplete UTF-8"
1119 );
1120 return Err(anyhow::Error::msg(format!(
1121 "OpenAI Codex response ended with incomplete UTF-8: {err}"
1122 )));
1123 }
1124
1125 parse_responses_body(&body)
1126}
1127
1128impl OpenAiCodexModelProvider {
1129 fn responses_request_builder(
1130 &self,
1131 bearer_token: &str,
1132 account_id: Option<&str>,
1133 access_token: Option<&str>,
1134 use_gateway_api_key_auth: bool,
1135 request: &ResponsesRequest,
1136 ) -> reqwest::RequestBuilder {
1137 let mut request_builder = self
1138 .client
1139 .post(&self.responses_url)
1140 .header("Authorization", format!("Bearer {bearer_token}"))
1141 .header("OpenAI-Beta", "responses=experimental")
1142 .header("originator", "pi")
1143 .header("Content-Type", "application/json");
1144
1145 if request.stream {
1146 request_builder = request_builder.header("accept", "text/event-stream");
1147 }
1148
1149 if let Some(account_id) = account_id {
1150 request_builder = request_builder.header("chatgpt-account-id", account_id);
1151 }
1152
1153 if use_gateway_api_key_auth {
1154 if let Some(access_token) = access_token {
1155 request_builder = request_builder.header("x-openai-access-token", access_token);
1156 }
1157 if let Some(account_id) = account_id {
1158 request_builder = request_builder.header("x-openai-account-id", account_id);
1159 }
1160 }
1161
1162 request_builder
1163 }
1164
1165 async fn send_responses_request(
1166 &self,
1167 input: Vec<Value>,
1168 instructions: String,
1169 tools: Option<Vec<ResponsesToolSpec>>,
1170 model: &str,
1171 ) -> anyhow::Result<ResponsesTurnResult> {
1172 let use_gateway_api_key_auth = self.custom_endpoint && self.gateway_api_key.is_some();
1173 let profile = match self
1174 .auth
1175 .get_profile("openai-codex", self.auth_profile_override.as_deref())
1176 .await
1177 {
1178 Ok(profile) => profile,
1179 Err(err) if use_gateway_api_key_auth => {
1180 ::zeroclaw_log::record!(
1181 WARN,
1182 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1183 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1184 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1185 "failed to load OpenAI Codex profile; continuing with custom endpoint API key mode"
1186 );
1187 None
1188 }
1189 Err(err) => return Err(err),
1190 };
1191 let oauth_access_token = match self
1192 .auth
1193 .get_valid_openai_access_token(self.auth_profile_override.as_deref())
1194 .await
1195 {
1196 Ok(token) => token,
1197 Err(err) if use_gateway_api_key_auth => {
1198 ::zeroclaw_log::record!(
1199 WARN,
1200 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1201 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1202 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1203 "failed to refresh OpenAI token; continuing with custom endpoint API key mode"
1204 );
1205 None
1206 }
1207 Err(err) => return Err(err),
1208 };
1209
1210 let account_id = profile.and_then(|profile| profile.account_id).or_else(|| {
1211 oauth_access_token
1212 .as_deref()
1213 .and_then(extract_account_id_from_jwt)
1214 });
1215 let access_token = if use_gateway_api_key_auth {
1216 oauth_access_token
1217 } else {
1218 Some(oauth_access_token.ok_or_else(|| {
1219 ::zeroclaw_log::record!(
1220 ERROR,
1221 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1222 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1223 .with_attrs(::serde_json::json!({"missing": "oauth_access_token"})),
1224 "openai_codex: auth profile not found"
1225 );
1226 anyhow::Error::msg(
1227 "OpenAI Codex auth profile not found. Run `zeroclaw auth login --provider openai-codex`.",
1228 )
1229 })?)
1230 };
1231 let account_id = if use_gateway_api_key_auth {
1232 account_id
1233 } else {
1234 Some(account_id.ok_or_else(|| {
1235 ::zeroclaw_log::record!(
1236 ERROR,
1237 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1238 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1239 .with_attrs(::serde_json::json!({"missing": "account_id"})),
1240 "openai_codex: account_id not found in profile/token"
1241 );
1242 anyhow::Error::msg(
1243 "OpenAI Codex account id not found in auth profile/token. Run `zeroclaw auth login --provider openai-codex` again.",
1244 )
1245 })?)
1246 };
1247 let normalized_model = normalize_model_id(model);
1248
1249 let has_tools = tools.is_some();
1250 let mut request = ResponsesRequest {
1251 model: normalized_model.to_string(),
1252 input,
1253 instructions,
1254 store: false,
1255 stream: true,
1256 text: ResponsesTextOptions {
1257 verbosity: "medium".to_string(),
1258 },
1259 reasoning: ResponsesReasoningOptions {
1260 effort: resolve_reasoning_effort(
1261 normalized_model,
1262 self.reasoning_effort.as_deref(),
1263 ),
1264 summary: "auto".to_string(),
1265 },
1266 include: vec!["reasoning.encrypted_content".to_string()],
1267 tools,
1268 tool_choice: has_tools.then(|| "auto".to_string()),
1269 parallel_tool_calls: has_tools.then_some(true),
1270 };
1271
1272 let bearer_token = if use_gateway_api_key_auth {
1273 self.gateway_api_key.as_deref().unwrap_or_default()
1274 } else {
1275 access_token.as_deref().unwrap_or_default()
1276 };
1277
1278 let request_builder = self.responses_request_builder(
1279 bearer_token,
1280 account_id.as_deref(),
1281 access_token.as_deref(),
1282 use_gateway_api_key_auth,
1283 &request,
1284 );
1285
1286 let response = request_builder.json(&request).send().await?;
1287
1288 if !response.status().is_success() {
1289 return Err(super::api_error("OpenAI Codex", response).await);
1290 }
1291
1292 match decode_responses_body(response).await {
1293 Ok(result) => Ok(result),
1294 Err(stream_err) => {
1295 if stream_err
1296 .downcast_ref::<ResponsesStreamApiError>()
1297 .is_some()
1298 {
1299 return Err(stream_err);
1300 }
1301
1302 ::zeroclaw_log::record!(
1303 WARN,
1304 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1305 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1306 .with_attrs(::serde_json::json!({"error": format!("{}", stream_err)})),
1307 "OpenAI Codex streaming response decode failed, retrying without streaming"
1308 );
1309
1310 request.stream = false;
1311 let non_streaming_response = self
1312 .responses_request_builder(
1313 bearer_token,
1314 account_id.as_deref(),
1315 access_token.as_deref(),
1316 use_gateway_api_key_auth,
1317 &request,
1318 )
1319 .json(&request)
1320 .send()
1321 .await?;
1322
1323 if !non_streaming_response.status().is_success() {
1324 return Err(super::api_error("OpenAI Codex", non_streaming_response).await);
1325 }
1326
1327 decode_responses_body(non_streaming_response)
1328 .await
1329 .map_err(|fallback_err| {
1330 ::zeroclaw_log::record!(
1331 ERROR,
1332 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1333 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1334 .with_attrs(::serde_json::json!({
1335 "stream_err": format!("{}", stream_err),
1336 "fallback_err": format!("{}", fallback_err),
1337 })),
1338 "openai_codex: stream + non-stream fallback both failed"
1339 );
1340 anyhow::Error::msg(format!(
1341 "OpenAI Codex streaming response decode failed ({stream_err}); non-streaming retry failed ({fallback_err})"
1342 ))
1343 })
1344 }
1345 }
1346 }
1347}
1348
1349#[async_trait]
1350impl ModelProvider for OpenAiCodexModelProvider {
1351 fn default_wire_api(&self) -> &str {
1353 WIRE_API
1354 }
1355
1356 fn default_base_url(&self) -> Option<&str> {
1357 Some(DEFAULT_CODEX_RESPONSES_URL)
1358 }
1359
1360 fn capabilities(&self) -> ProviderCapabilities {
1361 ProviderCapabilities {
1362 native_tool_calling: true,
1363 vision: true,
1364 prompt_caching: false,
1365 extended_thinking: false,
1366 }
1367 }
1368
1369 async fn chat_with_system(
1370 &self,
1371 system_prompt: Option<&str>,
1372 message: &str,
1373 model: &str,
1374 _temperature: Option<f64>,
1375 ) -> anyhow::Result<String> {
1376 let mut messages = Vec::new();
1378 if let Some(sys) = system_prompt {
1379 messages.push(ChatMessage::system(sys));
1380 }
1381 messages.push(ChatMessage::user(message));
1382
1383 let config = zeroclaw_config::schema::MultimodalConfig::default();
1385 let prepared = crate::multimodal::prepare_messages_for_provider(&messages, &config).await?;
1386
1387 let (instructions, input) = build_responses_input(&prepared.messages);
1388 self.send_responses_request(input, instructions, None, model)
1389 .await
1390 .map(|response| response.text.unwrap_or_default())
1391 }
1392
1393 async fn chat_with_history(
1394 &self,
1395 messages: &[ChatMessage],
1396 model: &str,
1397 _temperature: Option<f64>,
1398 ) -> anyhow::Result<String> {
1399 let config = zeroclaw_config::schema::MultimodalConfig::default();
1401 let prepared = crate::multimodal::prepare_messages_for_provider(messages, &config).await?;
1402
1403 let (instructions, input) = build_responses_input(&prepared.messages);
1404 self.send_responses_request(input, instructions, None, model)
1405 .await
1406 .map(|response| response.text.unwrap_or_default())
1407 }
1408
1409 async fn chat(
1410 &self,
1411 request: ProviderChatRequest<'_>,
1412 model: &str,
1413 _temperature: Option<f64>,
1414 ) -> anyhow::Result<ProviderChatResponse> {
1415 let config = zeroclaw_config::schema::MultimodalConfig::default();
1416 let prepared =
1417 crate::multimodal::prepare_messages_for_provider(request.messages, &config).await?;
1418 let (instructions, input) = build_responses_input(&prepared.messages);
1419 let response = self
1420 .send_responses_request(input, instructions, convert_tools(request.tools), model)
1421 .await?;
1422
1423 Ok(ProviderChatResponse {
1424 text: response.text,
1425 tool_calls: response.tool_calls,
1426 usage: None,
1427 reasoning_content: response.reasoning_content,
1428 })
1429 }
1430
1431 fn supports_streaming(&self) -> bool {
1432 false
1433 }
1434
1435 fn supports_streaming_tool_events(&self) -> bool {
1436 false
1437 }
1438
1439 fn stream_chat(
1440 &self,
1441 request: ProviderChatRequest<'_>,
1442 model: &str,
1443 _temperature: Option<f64>,
1444 options: StreamOptions,
1445 ) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
1446 if !options.enabled {
1447 return stream::once(async { Ok(StreamEvent::Final) }).boxed();
1448 }
1449
1450 let provider = self.clone();
1451 let messages = request.messages.to_vec();
1452 let tools = request.tools.map(|items| items.to_vec());
1453 let model = model.to_string();
1454 let count_tokens = options.count_tokens;
1455 let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamEvent>>(16);
1456
1457 tokio::spawn(async move {
1458 let config = zeroclaw_config::schema::MultimodalConfig::default();
1459 let prepared =
1460 match crate::multimodal::prepare_messages_for_provider(&messages, &config).await {
1461 Ok(prepared) => prepared,
1462 Err(err) => {
1463 let _ = tx
1464 .send(Err(StreamError::ModelProvider(err.to_string())))
1465 .await;
1466 return;
1467 }
1468 };
1469
1470 let (instructions, input) = build_responses_input(&prepared.messages);
1471 let result = provider
1472 .send_responses_request(
1473 input,
1474 instructions,
1475 convert_tools(tools.as_deref()),
1476 &model,
1477 )
1478 .await;
1479
1480 match result {
1481 Ok(response) => {
1482 for tool_call in response.tool_calls {
1483 if tx.send(Ok(StreamEvent::ToolCall(tool_call))).await.is_err() {
1484 return;
1485 }
1486 }
1487
1488 if let Some(text) = response.text.filter(|text| !text.is_empty()) {
1489 let chunk = if count_tokens {
1490 StreamChunk::delta(text).with_token_estimate()
1491 } else {
1492 StreamChunk::delta(text)
1493 };
1494 if tx.send(Ok(StreamEvent::TextDelta(chunk))).await.is_err() {
1495 return;
1496 }
1497 }
1498
1499 let _ = tx.send(Ok(StreamEvent::Final)).await;
1500 }
1501 Err(err) => {
1502 let _ = tx
1503 .send(Err(StreamError::ModelProvider(err.to_string())))
1504 .await;
1505 }
1506 }
1507 });
1508
1509 stream::unfold(rx, |mut rx| async move {
1510 rx.recv().await.map(|event| (event, rx))
1511 })
1512 .boxed()
1513 }
1514}
1515
1516impl ::zeroclaw_api::attribution::Attributable for OpenAiCodexModelProvider {
1517 fn role(&self) -> ::zeroclaw_api::attribution::Role {
1518 ::zeroclaw_api::attribution::Role::Provider(
1519 ::zeroclaw_api::attribution::ProviderKind::Model(
1520 ::zeroclaw_api::attribution::ModelProviderKind::OpenAiCodex,
1521 ),
1522 )
1523 }
1524 fn alias(&self) -> &str {
1525 &self.alias
1526 }
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531 use super::*;
1532
1533 enum MockCodexReply {
1534 Sse(&'static str),
1535 Json(serde_json::Value),
1536 Status(axum::http::StatusCode, &'static str),
1537 }
1538
1539 async fn mock_codex_provider(
1540 replies: Vec<MockCodexReply>,
1541 ) -> (
1542 OpenAiCodexModelProvider,
1543 std::sync::Arc<std::sync::Mutex<Vec<serde_json::Value>>>,
1544 tokio::task::JoinHandle<()>,
1545 tempfile::TempDir,
1546 ) {
1547 use axum::http::header;
1548 use axum::response::IntoResponse;
1549 use axum::{Json, Router, routing::post};
1550 use std::collections::VecDeque;
1551 use std::sync::{Arc, Mutex};
1552 use tokio::net::TcpListener;
1553
1554 let captured: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new()));
1555 let captured_clone = Arc::clone(&captured);
1556 let replies = Arc::new(Mutex::new(VecDeque::from(replies)));
1557 let replies_clone = Arc::clone(&replies);
1558
1559 let app = Router::new().route(
1560 "/responses",
1561 post(move |Json(body): Json<serde_json::Value>| {
1562 let captured = Arc::clone(&captured_clone);
1563 let replies = Arc::clone(&replies_clone);
1564 async move {
1565 captured.lock().unwrap().push(body);
1566 match replies
1567 .lock()
1568 .unwrap()
1569 .pop_front()
1570 .unwrap_or(MockCodexReply::Status(
1571 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1572 "",
1573 )) {
1574 MockCodexReply::Sse(body) => (
1575 axum::http::StatusCode::OK,
1576 [(header::CONTENT_TYPE, "text/event-stream")],
1577 body.to_string(),
1578 )
1579 .into_response(),
1580 MockCodexReply::Json(body) => Json(body).into_response(),
1581 MockCodexReply::Status(status, body) => {
1582 (status, body.to_string()).into_response()
1583 }
1584 }
1585 }
1586 }),
1587 );
1588
1589 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1590 let addr = listener.local_addr().unwrap();
1591 let server_handle = tokio::spawn(async move {
1592 axum::serve(listener, app).await.unwrap();
1593 });
1594
1595 let temp_dir = tempfile::tempdir().unwrap();
1596 let options = ModelProviderRuntimeOptions {
1597 provider_api_url: Some(format!("http://{addr}")),
1598 zeroclaw_dir: Some(temp_dir.path().to_path_buf()),
1599 secrets_encrypt: false,
1600 ..ModelProviderRuntimeOptions::default()
1601 };
1602 let provider = OpenAiCodexModelProvider::new("test", &options, Some("test-key")).unwrap();
1603
1604 (provider, captured, server_handle, temp_dir)
1605 }
1606
1607 #[test]
1608 fn extracts_output_text_first() {
1609 let response = ResponsesResponse {
1610 output: vec![],
1611 output_text: Some("hello".into()),
1612 };
1613 assert_eq!(extract_responses_text(&response).as_deref(), Some("hello"));
1614 }
1615
1616 #[test]
1617 fn extracts_nested_output_text() {
1618 let response = ResponsesResponse {
1619 output: vec![serde_json::json!({
1620 "type": "message",
1621 "role": "assistant",
1622 "content": [
1623 {
1624 "type": "output_text",
1625 "text": "nested"
1626 }
1627 ]
1628 })],
1629 output_text: None,
1630 };
1631 assert_eq!(extract_responses_text(&response).as_deref(), Some("nested"));
1632 }
1633
1634 #[test]
1635 fn default_state_dir_is_non_empty() {
1636 let path = default_zeroclaw_dir();
1637 assert!(!path.as_os_str().is_empty());
1638 }
1639
1640 #[test]
1641 fn build_responses_url_appends_suffix_for_base_url() {
1642 assert_eq!(
1643 build_responses_url("https://api.tonsof.blue/v1").unwrap(),
1644 "https://api.tonsof.blue/v1/responses"
1645 );
1646 }
1647
1648 #[test]
1649 fn build_responses_url_keeps_existing_responses_endpoint() {
1650 assert_eq!(
1651 build_responses_url("https://api.tonsof.blue/v1/responses").unwrap(),
1652 "https://api.tonsof.blue/v1/responses"
1653 );
1654 }
1655
1656 #[test]
1657 fn resolve_responses_url_uses_provider_api_url_override() {
1658 let options = ModelProviderRuntimeOptions {
1659 provider_api_url: Some("https://proxy.example.com/v1".to_string()),
1660 ..ModelProviderRuntimeOptions::default()
1661 };
1662
1663 assert_eq!(
1664 resolve_responses_url(&options).unwrap(),
1665 "https://proxy.example.com/v1/responses"
1666 );
1667 }
1668
1669 #[test]
1670 fn default_responses_url_detector_handles_equivalent_urls() {
1671 assert!(is_default_responses_url(DEFAULT_CODEX_RESPONSES_URL));
1672 assert!(is_default_responses_url(
1673 "https://chatgpt.com/backend-api/codex/responses/"
1674 ));
1675 assert!(!is_default_responses_url(
1676 "https://api.tonsof.blue/v1/responses"
1677 ));
1678 }
1679
1680 #[test]
1681 fn constructor_enables_custom_endpoint_key_mode() {
1682 let options = ModelProviderRuntimeOptions {
1683 provider_api_url: Some("https://api.tonsof.blue/v1".to_string()),
1684 ..ModelProviderRuntimeOptions::default()
1685 };
1686
1687 let provider = OpenAiCodexModelProvider::new("test", &options, Some("test-key")).unwrap();
1688 assert!(provider.custom_endpoint);
1689 assert_eq!(provider.gateway_api_key.as_deref(), Some("test-key"));
1690 }
1691
1692 #[tokio::test]
1693 async fn codex_retries_non_streaming_when_stream_decode_fails() {
1694 let (provider, captured, server_handle, _temp_dir) = mock_codex_provider(vec![
1695 MockCodexReply::Sse("data: not-json\n\ndata: [DONE]\n"),
1696 MockCodexReply::Json(serde_json::json!({
1697 "output_text": "fallback ok",
1698 "output": []
1699 })),
1700 ])
1701 .await;
1702
1703 let messages = vec![ChatMessage::user("hello")];
1704 let response = provider
1705 .chat(
1706 ProviderChatRequest {
1707 messages: &messages,
1708 tools: None,
1709 thinking: None,
1710 },
1711 "gpt-5-codex",
1712 None,
1713 )
1714 .await
1715 .expect("provider should retry with stream=false after streaming decode failure");
1716
1717 assert_eq!(response.text.as_deref(), Some("fallback ok"));
1718
1719 let requests = captured.lock().unwrap();
1720 assert_eq!(requests.len(), 2, "expected one retry request");
1721 assert_eq!(requests[0]["stream"], true);
1722 assert_eq!(requests[1]["stream"], false);
1723
1724 server_handle.abort();
1725 }
1726
1727 #[tokio::test]
1728 async fn codex_retries_non_streaming_when_stream_contains_malformed_frame_after_text() {
1729 let (provider, captured, server_handle, _temp_dir) = mock_codex_provider(vec![
1730 MockCodexReply::Sse(
1731 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\ndata: not-json\n\ndata: [DONE]\n",
1732 ),
1733 MockCodexReply::Json(serde_json::json!({
1734 "output_text": "fallback after partial",
1735 "output": []
1736 })),
1737 ])
1738 .await;
1739
1740 let messages = vec![ChatMessage::user("hello")];
1741 let response = provider
1742 .chat(
1743 ProviderChatRequest {
1744 messages: &messages,
1745 tools: None,
1746 thinking: None,
1747 },
1748 "gpt-5-codex",
1749 None,
1750 )
1751 .await
1752 .expect("provider should retry after malformed stream frame");
1753
1754 assert_eq!(response.text.as_deref(), Some("fallback after partial"));
1755
1756 let requests = captured.lock().unwrap();
1757 assert_eq!(requests.len(), 2, "expected one retry request");
1758 assert_eq!(requests[0]["stream"], true);
1759 assert_eq!(requests[1]["stream"], false);
1760
1761 server_handle.abort();
1762 }
1763
1764 #[tokio::test]
1765 async fn codex_does_not_retry_stream_api_error_events() {
1766 let (provider, captured, server_handle, _temp_dir) = mock_codex_provider(vec![
1767 MockCodexReply::Sse(
1768 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"quota exceeded\"}}}\n\ndata: [DONE]\n",
1769 ),
1770 ])
1771 .await;
1772
1773 let messages = vec![ChatMessage::user("hello")];
1774 let err = provider
1775 .chat(
1776 ProviderChatRequest {
1777 messages: &messages,
1778 tools: None,
1779 thinking: None,
1780 },
1781 "gpt-5-codex",
1782 None,
1783 )
1784 .await
1785 .expect_err("stream API errors should not be retried");
1786
1787 assert!(
1788 err.to_string()
1789 .contains("OpenAI Codex stream error: quota exceeded"),
1790 "{err}"
1791 );
1792 assert_eq!(captured.lock().unwrap().len(), 1);
1793
1794 server_handle.abort();
1795 }
1796
1797 #[tokio::test]
1798 async fn codex_does_not_retry_failed_http_status() {
1799 let (provider, captured, server_handle, _temp_dir) =
1800 mock_codex_provider(vec![MockCodexReply::Status(
1801 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1802 "server down",
1803 )])
1804 .await;
1805
1806 let messages = vec![ChatMessage::user("hello")];
1807 provider
1808 .chat(
1809 ProviderChatRequest {
1810 messages: &messages,
1811 tools: None,
1812 thinking: None,
1813 },
1814 "gpt-5-codex",
1815 None,
1816 )
1817 .await
1818 .expect_err("HTTP errors should not be retried");
1819
1820 assert_eq!(captured.lock().unwrap().len(), 1);
1821
1822 server_handle.abort();
1823 }
1824
1825 #[test]
1826 fn clamp_reasoning_effort_adjusts_known_models() {
1827 assert_eq!(
1828 clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1829 "high".to_string()
1830 );
1831 assert_eq!(
1832 clamp_reasoning_effort("gpt-5-codex", "minimal"),
1833 "low".to_string()
1834 );
1835 assert_eq!(
1836 clamp_reasoning_effort("gpt-5-codex", "medium"),
1837 "medium".to_string()
1838 );
1839 assert_eq!(
1840 clamp_reasoning_effort("gpt-5.3-codex", "minimal"),
1841 "low".to_string()
1842 );
1843 assert_eq!(
1844 clamp_reasoning_effort("gpt-5.1", "xhigh"),
1845 "high".to_string()
1846 );
1847 assert_eq!(
1848 clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1849 "high".to_string()
1850 );
1851 assert_eq!(
1852 clamp_reasoning_effort("gpt-5.1-codex-mini", "low"),
1853 "medium".to_string()
1854 );
1855 assert_eq!(
1856 clamp_reasoning_effort("gpt-5.1-codex-mini", "xhigh"),
1857 "high".to_string()
1858 );
1859 assert_eq!(
1860 clamp_reasoning_effort("gpt-5.3-codex", "xhigh"),
1861 "xhigh".to_string()
1862 );
1863 }
1864
1865 #[test]
1866 fn resolve_reasoning_effort_prefers_configured_override() {
1867 assert_eq!(
1869 resolve_reasoning_effort("gpt-5-codex", Some("high")),
1870 "high".to_string()
1871 );
1872 }
1873
1874 #[test]
1875 fn resolve_reasoning_effort_defaults_when_unconfigured() {
1876 assert_eq!(
1877 resolve_reasoning_effort("gpt-5-codex", None),
1878 "high".to_string()
1879 );
1880 }
1881
1882 #[test]
1883 fn parse_sse_turn_reads_output_text_delta() {
1884 let payload = r#"data: {"type":"response.created","response":{"id":"resp_123"}}
1885
1886data: {"type":"response.output_text.delta","delta":"Hello"}
1887data: {"type":"response.output_text.delta","delta":" world"}
1888data: {"type":"response.completed","response":{"output_text":"Hello world"}}
1889data: [DONE]
1890"#;
1891
1892 assert_eq!(
1893 parse_sse_turn(payload).unwrap().text.as_deref(),
1894 Some("Hello world")
1895 );
1896 }
1897
1898 #[test]
1899 fn parse_sse_turn_falls_back_to_completed_response() {
1900 let payload = r#"data: {"type":"response.completed","response":{"output_text":"Done"}}
1901data: [DONE]
1902"#;
1903
1904 assert_eq!(
1905 parse_sse_turn(payload).unwrap().text.as_deref(),
1906 Some("Done")
1907 );
1908 }
1909
1910 #[test]
1911 fn parse_responses_body_rejects_unrecognized_sse_without_payload() {
1912 let payload = r#"data: not-json
1913data: [DONE]
1914"#;
1915
1916 let err = parse_responses_body(payload).expect_err("empty SSE should fail closed");
1917 assert!(
1918 err.to_string()
1919 .contains("OpenAI Codex SSE data parse failed"),
1920 "{err}"
1921 );
1922 }
1923
1924 #[test]
1925 fn parse_responses_body_rejects_json_without_text_or_tool_calls() {
1926 let payload = r#"{"output":[]}"#;
1927
1928 let err = parse_responses_body(payload).expect_err("empty JSON should fail closed");
1929 assert!(
1930 err.to_string().contains("No response from OpenAI Codex"),
1931 "{err}"
1932 );
1933 }
1934
1935 #[test]
1936 fn parse_responses_body_allows_sse_markers_inside_json_text() {
1937 let payload = serde_json::json!({
1938 "output_text": "Example SSE frame:\ndata: {\"type\":\"example\"}\nevent: response.done",
1939 "output": []
1940 })
1941 .to_string();
1942
1943 let result = parse_responses_body(&payload).expect("JSON text should not be parsed as SSE");
1944 assert_eq!(
1945 result.text.as_deref(),
1946 Some("Example SSE frame:\ndata: {\"type\":\"example\"}\nevent: response.done")
1947 );
1948 assert!(result.tool_calls.is_empty());
1949 }
1950
1951 #[test]
1952 fn parse_responses_body_preserves_reasoning_items_for_tool_calls() {
1953 let payload = serde_json::json!({
1954 "output": [
1955 {
1956 "type": "reasoning",
1957 "id": "rs_1",
1958 "summary": [],
1959 "encrypted_content": "enc_reasoning"
1960 },
1961 {
1962 "type": "function_call",
1963 "id": "fc_1",
1964 "call_id": "call_1",
1965 "name": "shell",
1966 "arguments": "{\"command\":\"pwd\"}",
1967 "status": "completed"
1968 }
1969 ]
1970 })
1971 .to_string();
1972
1973 let result = parse_responses_body(&payload).expect("tool call response should parse");
1974 assert_eq!(result.tool_calls.len(), 1);
1975 assert_eq!(result.tool_calls[0].id, "call_1");
1976
1977 let items = decode_responses_history_items(
1978 result
1979 .reasoning_content
1980 .as_deref()
1981 .expect("Responses history items should be captured"),
1982 )
1983 .expect("history envelope should decode");
1984
1985 assert_eq!(items.len(), 2);
1986 assert_eq!(items[0]["type"], "reasoning");
1987 assert_eq!(items[0]["encrypted_content"], "enc_reasoning");
1988 assert_eq!(items[1]["type"], "function_call");
1989 assert_eq!(items[1]["call_id"], "call_1");
1990 }
1991
1992 #[test]
1993 fn build_responses_input_maps_content_types_by_role() {
1994 let messages = vec![
1995 ChatMessage {
1996 role: "system".into(),
1997 content: "You are helpful.".into(),
1998 },
1999 ChatMessage {
2000 role: "user".into(),
2001 content: "Hi".into(),
2002 },
2003 ChatMessage {
2004 role: "assistant".into(),
2005 content: "Hello!".into(),
2006 },
2007 ChatMessage {
2008 role: "user".into(),
2009 content: "Thanks".into(),
2010 },
2011 ];
2012 let (instructions, input) = build_responses_input(&messages);
2013 assert_eq!(instructions, "You are helpful.");
2014 assert_eq!(input.len(), 3);
2015
2016 let json: Vec<Value> = input
2017 .iter()
2018 .map(|item| serde_json::to_value(item).unwrap())
2019 .collect();
2020 assert_eq!(json[0]["role"], "user");
2021 assert_eq!(json[0]["content"][0]["type"], "input_text");
2022 assert_eq!(json[1]["role"], "assistant");
2023 assert_eq!(json[1]["content"][0]["type"], "output_text");
2024 assert_eq!(json[2]["role"], "user");
2025 assert_eq!(json[2]["content"][0]["type"], "input_text");
2026 }
2027
2028 #[test]
2029 fn build_responses_input_uses_default_instructions_without_system() {
2030 let messages = vec![ChatMessage {
2031 role: "user".into(),
2032 content: "Hello".into(),
2033 }];
2034 let (instructions, input) = build_responses_input(&messages);
2035 assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
2036 assert_eq!(input.len(), 1);
2037 }
2038
2039 #[test]
2040 fn build_responses_input_maps_tool_outputs() {
2041 let messages = vec![
2042 ChatMessage {
2043 role: "tool".into(),
2044 content: r#"{"tool_call_id":"call_123","content":"result"}"#.into(),
2045 },
2046 ChatMessage {
2047 role: "user".into(),
2048 content: "Go".into(),
2049 },
2050 ];
2051 let (instructions, input) = build_responses_input(&messages);
2052 assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
2053 assert_eq!(input.len(), 2);
2054 assert_eq!(input[0]["type"], "function_call_output");
2055 assert_eq!(input[0]["call_id"], "call_123");
2056 assert_eq!(input[0]["output"], "result");
2057 assert_eq!(input[1]["role"], "user");
2058 }
2059
2060 #[test]
2061 fn build_responses_input_replays_plain_tool_text_without_synthetic_call_id() {
2062 let messages = vec![ChatMessage {
2063 role: "tool".into(),
2064 content: "legacy plain text result".into(),
2065 }];
2066
2067 let (_, input) = build_responses_input(&messages);
2068
2069 assert_eq!(input.len(), 1);
2070 assert_eq!(input[0]["type"], "message");
2071 assert_eq!(input[0]["role"], "user");
2072 assert_eq!(input[0]["content"][0]["type"], "input_text");
2073 assert_eq!(
2074 input[0]["content"][0]["text"],
2075 "Legacy tool output without call_id:\nlegacy plain text result"
2076 );
2077 assert!(input[0].get("call_id").is_none());
2078 }
2079
2080 #[test]
2081 fn build_responses_input_replays_tool_json_without_call_id_as_text() {
2082 let messages = vec![ChatMessage {
2083 role: "tool".into(),
2084 content: r#"{"content":"legacy result","status":"ok"}"#.into(),
2085 }];
2086
2087 let (_, input) = build_responses_input(&messages);
2088
2089 assert_eq!(input.len(), 1);
2090 assert_eq!(input[0]["type"], "message");
2091 assert_eq!(input[0]["role"], "user");
2092 assert_eq!(input[0]["content"][0]["type"], "input_text");
2093 assert_eq!(
2094 input[0]["content"][0]["text"],
2095 r#"Legacy tool output without call_id:
2096{"content":"legacy result","status":"ok"}"#
2097 );
2098 assert!(input[0].get("call_id").is_none());
2099 }
2100
2101 #[test]
2102 fn build_responses_input_replays_blank_tool_call_id_as_legacy_text() {
2103 for raw_id in ["", " "] {
2104 let messages = vec![ChatMessage {
2105 role: "tool".into(),
2106 content: serde_json::json!({
2107 "tool_call_id": raw_id,
2108 "content": "legacy result"
2109 })
2110 .to_string(),
2111 }];
2112
2113 let (_, input) = build_responses_input(&messages);
2114
2115 assert_eq!(input.len(), 1);
2116 assert_eq!(input[0]["type"], "message");
2117 assert_eq!(input[0]["role"], "user");
2118 assert_eq!(input[0]["content"][0]["type"], "input_text");
2119 assert!(input[0].get("call_id").is_none());
2120 assert!(
2121 input[0]["content"][0]["text"]
2122 .as_str()
2123 .unwrap()
2124 .contains("\"legacy result\"")
2125 );
2126 }
2127 }
2128
2129 #[test]
2130 fn build_responses_input_maps_native_assistant_tool_calls() {
2131 let messages = vec![ChatMessage::assistant(
2132 r#"{"content":"Using shell","tool_calls":[{"id":"call_abc","name":"shell","arguments":"{\"command\":\"pwd\"}"}]}"#,
2133 )];
2134 let (_, input) = build_responses_input(&messages);
2135
2136 assert_eq!(input.len(), 2);
2137 assert_eq!(input[0]["type"], "message");
2138 assert_eq!(input[0]["role"], "assistant");
2139 assert_eq!(input[0]["content"][0]["type"], "output_text");
2140 assert_eq!(input[1]["type"], "function_call");
2141 assert_eq!(input[1]["call_id"], "call_abc");
2142 assert_eq!(input[1]["name"], "shell");
2143 }
2144
2145 #[test]
2146 fn build_responses_input_replays_reasoning_item_before_tool_result() {
2147 let reasoning_item = serde_json::json!({
2148 "type": "reasoning",
2149 "id": "rs_1",
2150 "summary": [],
2151 "encrypted_content": "enc_reasoning"
2152 });
2153 let function_call_item = serde_json::json!({
2154 "type": "function_call",
2155 "id": "fc_1",
2156 "call_id": "call_1",
2157 "name": "shell",
2158 "arguments": "{\"command\":\"pwd\"}",
2159 "status": "completed"
2160 });
2161 let reasoning_content =
2162 encode_responses_history_items(&[reasoning_item, function_call_item], true)
2163 .expect("history envelope should encode");
2164 let messages = vec![
2165 ChatMessage::assistant(
2166 serde_json::json!({
2167 "content": null,
2168 "tool_calls": [
2169 {
2170 "id": "call_1",
2171 "name": "shell",
2172 "arguments": "{\"command\":\"pwd\"}"
2173 }
2174 ],
2175 "reasoning_content": reasoning_content
2176 })
2177 .to_string(),
2178 ),
2179 ChatMessage::tool(
2180 serde_json::json!({
2181 "tool_call_id": "call_1",
2182 "content": "ok"
2183 })
2184 .to_string(),
2185 ),
2186 ];
2187
2188 let (_, input) = build_responses_input(&messages);
2189 assert_eq!(input.len(), 3);
2190 assert_eq!(input[0]["type"], "reasoning");
2191 assert_eq!(input[0]["encrypted_content"], "enc_reasoning");
2192 assert_eq!(input[1]["type"], "function_call");
2193 assert_eq!(input[1]["call_id"], "call_1");
2194 assert_eq!(input[2]["type"], "function_call_output");
2195 assert_eq!(input[2]["call_id"], "call_1");
2196 assert_eq!(input[2]["output"], "ok");
2197 }
2198
2199 #[test]
2200 fn convert_tools_opts_out_of_responses_strict_mode() {
2201 let tools = vec![ToolSpec {
2202 name: "jira".to_string(),
2203 description: "Interact with Jira".to_string(),
2204 parameters: serde_json::json!({
2205 "type": "object",
2206 "properties": {
2207 "action": { "type": "string" },
2208 "issue_key": { "type": "string" }
2209 },
2210 "required": ["action"]
2211 }),
2212 }];
2213
2214 let converted = convert_tools(Some(&tools)).expect("tool should convert");
2215 let value = serde_json::to_value(&converted[0]).expect("tool should serialize");
2216 assert_eq!(value["type"], "function");
2217 assert_eq!(value["name"], "jira");
2218 assert_eq!(value["strict"], false);
2219 assert_eq!(value["parameters"]["required"][0], "action");
2220 }
2221
2222 #[test]
2223 fn parse_sse_turn_collects_function_calls() {
2224 let payload = r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":""}}
2225
2226data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"command\":\"pw"}
2227data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"name":"shell","arguments":"{\"command\":\"pwd\"}"}
2228data: {"type":"response.completed","response":{"output":[]}}
2229data: [DONE]
2230"#;
2231
2232 let result = parse_sse_turn(payload).unwrap();
2233 assert_eq!(result.tool_calls.len(), 1);
2234 assert_eq!(result.tool_calls[0].id, "call_1");
2235 assert_eq!(result.tool_calls[0].name, "shell");
2236 assert_eq!(result.tool_calls[0].arguments, "{\"command\":\"pwd\"}");
2237 }
2238
2239 #[test]
2240 fn build_responses_input_handles_image_markers() {
2241 let messages = vec![ChatMessage::user(
2242 "Describe this\n\n[IMAGE:data:image/png;base64,abc]",
2243 )];
2244 let (_, input) = build_responses_input(&messages);
2245
2246 assert_eq!(input.len(), 1);
2247 assert_eq!(input[0]["role"], "user");
2248 assert_eq!(input[0]["content"].as_array().unwrap().len(), 2);
2249
2250 let json = input[0]["content"].as_array().unwrap();
2251
2252 assert_eq!(json[0]["type"], "input_text");
2254 assert!(json[0]["text"].as_str().unwrap().contains("Describe this"));
2255
2256 assert_eq!(json[1]["type"], "input_image");
2258 assert_eq!(json[1]["image_url"], "data:image/png;base64,abc");
2259 }
2260
2261 #[test]
2262 fn build_responses_input_preserves_text_only_messages() {
2263 let messages = vec![ChatMessage::user("Hello without images")];
2264 let (_, input) = build_responses_input(&messages);
2265
2266 assert_eq!(input.len(), 1);
2267 assert_eq!(input[0]["content"].as_array().unwrap().len(), 1);
2268
2269 let json = &input[0]["content"][0];
2270 assert_eq!(json["type"], "input_text");
2271 assert_eq!(json["text"], "Hello without images");
2272 }
2273
2274 #[test]
2275 fn build_responses_input_handles_multiple_images() {
2276 let messages = vec![ChatMessage::user(
2277 "Compare these: [IMAGE:data:image/png;base64,img1] and [IMAGE:data:image/jpeg;base64,img2]",
2278 )];
2279 let (_, input) = build_responses_input(&messages);
2280
2281 assert_eq!(input.len(), 1);
2282 assert_eq!(input[0]["content"].as_array().unwrap().len(), 3); let json = input[0]["content"].as_array().unwrap();
2285
2286 assert_eq!(json[0]["type"], "input_text");
2287 assert_eq!(json[1]["type"], "input_image");
2288 assert_eq!(json[2]["type"], "input_image");
2289 }
2290
2291 #[test]
2292 fn capabilities_includes_vision() {
2293 let options = ModelProviderRuntimeOptions {
2294 secrets_encrypt: false,
2295 ..ModelProviderRuntimeOptions::default()
2296 };
2297 let provider = OpenAiCodexModelProvider::new("test", &options, None)
2298 .expect("provider should initialize");
2299 let caps = provider.capabilities();
2300
2301 assert!(caps.native_tool_calling);
2302 assert!(caps.vision);
2303 }
2304
2305 #[test]
2306 fn provider_does_not_advertise_streaming_until_live_sse_is_wired() {
2307 let provider =
2308 OpenAiCodexModelProvider::new("test", &ModelProviderRuntimeOptions::default(), None)
2309 .expect("provider should initialize");
2310
2311 assert!(!provider.supports_streaming());
2312 assert!(!provider.supports_streaming_tool_events());
2313 }
2314}