Skip to main content

meerkat_openai/
client.rs

1//! OpenAI API client - Responses API
2//!
3//! Implements the LlmClient trait for OpenAI's Responses API.
4//! This client uses the /v1/responses endpoint which supports reasoning items.
5
6use async_trait::async_trait;
7use futures::StreamExt;
8use meerkat_core::lifecycle::run_primitive::{
9    OpenAiProviderTag, ProviderTag, ReasoningEffort as TypedReasoningEffort,
10};
11use meerkat_core::schema::{CompiledSchema, SchemaError};
12use meerkat_core::{
13    AssistantBlock, ContentBlock, ImageData, ImageGenerationIntent, ImageGenerationWarning,
14    ImageOperationTerminalClass, Message, OpenAiImageMetadata, OutputSchema, ProviderImageMetadata,
15    ProviderMeta, RevisedPromptDisposition, RevisedPromptSource, StopReason, Usage,
16};
17use meerkat_llm_core::BlockAssembler;
18use meerkat_llm_core::LlmError;
19use meerkat_llm_core::{
20    ImageGenerationExecutor, LlmClient, LlmDoneOutcome, LlmEvent, LlmRequest, LlmStream,
21    ProviderGeneratedImage, ProviderImageGenerationOutput, ProviderImageGenerationRequest,
22    dimensions_from_size_preference, media_type_from_format_preference,
23    normalize_base64_image_data,
24};
25use meerkat_llm_core::{http, streaming};
26use serde::Deserialize;
27use serde_json::Value;
28use serde_json::value::RawValue;
29use std::collections::HashSet;
30
31use crate::image_generation::{
32    OpenAiImageOutputOptions, OpenAiImageProviderParams, OpenAiImagesApiEndpoint,
33    OpenAiImagesApiPlan, OpenAiImagesApiRequestShape, OpenAiResponsesImagePlan,
34};
35
36/// Extract the typed OpenAI provider tag from a request.
37pub(crate) fn openai_tag(request: &LlmRequest) -> Option<&OpenAiProviderTag> {
38    match request.provider_params.as_ref()? {
39        ProviderTag::OpenAi(t) => Some(t),
40        _ => None,
41    }
42}
43
44/// Client for OpenAI Responses API
45pub struct OpenAiClient {
46    api_key: Option<String>,
47    base_url: String,
48    responses_path: String,
49    chatgpt_backend_wire: bool,
50    http: reqwest::Client,
51    /// Extra headers emitted on every request (e.g. `ChatGPT-Account-ID`,
52    /// `X-OpenAI-Fedramp`). Populated by provider runtimes when the
53    /// backend is the ChatGPT backend and the OAuth token's JWT carries
54    /// identity claims per Codex `bearer_auth_provider.rs:23-38`.
55    extra_headers: Vec<(String, String)>,
56    /// Dynamic authorizer — when set, replaces the `Authorization:
57    /// Bearer <api_key>` header path with `HttpAuthorizer::authorize`
58    /// invocation. Used for ExternalAuthorizer flows that produce a
59    /// DynamicAuthorizer envelope (host-managed OAuth refresh, etc.).
60    authorizer: Option<std::sync::Arc<dyn meerkat_core::HttpAuthorizer>>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum SystemMessageMode {
65    IncludeInInput,
66    ExtractToInstructions,
67}
68
69impl OpenAiClient {
70    fn model_supports_temperature(model: &str) -> bool {
71        meerkat_core::model_profile::openai::supports_temperature(model)
72    }
73
74    fn model_supports_reasoning_payload(model: &str) -> bool {
75        meerkat_core::model_profile::openai::supports_reasoning(model)
76    }
77
78    fn request_supports_temperature(request: &LlmRequest) -> bool {
79        openai_tag(request)
80            .and_then(|t| t.supports_temperature_override)
81            .unwrap_or_else(|| Self::model_supports_temperature(&request.model))
82    }
83
84    fn request_supports_reasoning_payload(request: &LlmRequest) -> bool {
85        openai_tag(request)
86            .and_then(|t| t.supports_reasoning_override)
87            .unwrap_or_else(|| Self::model_supports_reasoning_payload(&request.model))
88    }
89
90    /// Create a new OpenAI client with the given API key
91    pub fn new(api_key: String) -> Self {
92        Self::new_with_optional_api_key_and_base_url(
93            Some(api_key),
94            "https://api.openai.com".to_string(),
95        )
96    }
97
98    /// Create a new OpenAI client with an explicit base URL
99    pub fn new_with_base_url(api_key: String, base_url: String) -> Self {
100        Self::new_with_optional_api_key_and_base_url(Some(api_key), base_url)
101    }
102
103    /// Create a new OpenAI client with an optional API key and explicit base URL.
104    pub fn new_with_optional_api_key_and_base_url(
105        api_key: Option<String>,
106        base_url: String,
107    ) -> Self {
108        let http = http::build_http_client_for_base_url(reqwest::Client::builder(), &base_url)
109            .unwrap_or_else(|_| reqwest::Client::new());
110        Self {
111            api_key,
112            base_url,
113            responses_path: "v1/responses".to_string(),
114            chatgpt_backend_wire: false,
115            http,
116            extra_headers: Vec::new(),
117            authorizer: None,
118        }
119    }
120
121    /// Install a set of (name, value) headers to include on every request.
122    ///
123    /// ChatGPT-backend callers pass `ChatGPT-Account-ID` and optionally
124    /// `X-OpenAI-Fedramp` here.
125    pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
126        self.extra_headers = headers;
127        self
128    }
129
130    /// Set the Responses endpoint path relative to `base_url`.
131    ///
132    /// Public OpenAI uses `/v1/responses`; the ChatGPT Codex backend
133    /// uses `/responses` under `https://chatgpt.com/backend-api/codex`.
134    pub fn with_responses_path(mut self, path: impl Into<String>) -> Self {
135        self.responses_path = path.into().trim_start_matches('/').to_string();
136        self
137    }
138
139    pub fn with_chatgpt_backend_wire(self) -> Self {
140        let mut client = self.with_responses_path("responses");
141        client.chatgpt_backend_wire = true;
142        client
143    }
144
145    /// Attach a dynamic authorizer. When set, overrides the
146    /// `Authorization: Bearer <api_key>` path on every request with
147    /// `HttpAuthorizer::authorize`. Extra headers (ChatGPT-Account-ID
148    /// etc.) still flow through unchanged.
149    pub fn with_authorizer(
150        mut self,
151        authorizer: std::sync::Arc<dyn meerkat_core::HttpAuthorizer>,
152    ) -> Self {
153        self.authorizer = Some(authorizer);
154        self
155    }
156
157    pub fn extra_headers(&self) -> &[(String, String)] {
158        &self.extra_headers
159    }
160
161    fn responses_endpoint(&self) -> String {
162        format!(
163            "{}/{}",
164            self.base_url.trim_end_matches('/'),
165            self.responses_path.trim_start_matches('/'),
166        )
167    }
168
169    /// Set custom base URL
170    pub fn with_base_url(mut self, url: String) -> Self {
171        if let Ok(http) = http::build_http_client_for_base_url(reqwest::Client::builder(), &url) {
172            self.http = http;
173        }
174        self.base_url = url;
175        self
176    }
177
178    /// Build request body for OpenAI Responses API
179    fn build_request_body(&self, request: &LlmRequest) -> Result<Value, LlmError> {
180        let (input, instructions) = if self.chatgpt_backend_wire {
181            Self::convert_to_responses_input_with_system_mode(
182                &request.messages,
183                SystemMessageMode::ExtractToInstructions,
184            )?
185        } else {
186            (Self::convert_to_responses_input(&request.messages)?, None)
187        };
188        let reasoning_enabled = Self::request_supports_reasoning_payload(request);
189
190        let mut body = serde_json::json!({
191            "model": request.model,
192            "input": input,
193            "max_output_tokens": request.max_tokens,
194            "stream": true,
195        });
196
197        if reasoning_enabled {
198            // Request encrypted_content for stateless replay.
199            body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
200            // Enable reasoning with default effort.
201            body["reasoning"] = serde_json::json!({
202                "effort": "medium",
203                "summary": "auto"
204            });
205        }
206
207        if self.chatgpt_backend_wire {
208            body["instructions"] = Value::String(
209                instructions.unwrap_or_else(|| "You are a helpful assistant.".to_string()),
210            );
211            body["store"] = Value::Bool(false);
212            body["tools"] = Value::Array(Vec::new());
213            body["tool_choice"] = Value::String("auto".to_string());
214            body["parallel_tool_calls"] = Value::Bool(false);
215            if let Some(obj) = body.as_object_mut() {
216                obj.remove("max_output_tokens");
217            }
218        }
219
220        if Self::request_supports_temperature(request)
221            && let Some(temp) = request.temperature
222            && let Some(num) = serde_json::Number::from_f64(temp as f64)
223        {
224            body["temperature"] = Value::Number(num);
225        }
226
227        if !request.tools.is_empty() {
228            // Responses API tool format: {"type": "function", "name": "...", "parameters": {...}}
229            let tools: Vec<Value> = request
230                .tools
231                .iter()
232                .map(|t| {
233                    serde_json::json!({
234                        "type": "function",
235                        "name": t.name,
236                        "description": t.description,
237                        "parameters": t.input_schema
238                    })
239                })
240                .collect();
241            body["tools"] = Value::Array(tools);
242        }
243
244        // Inject provider-native web search tool from typed tag.
245        if let Some(web_search) = openai_tag(request).and_then(|t| t.web_search.as_ref()) {
246            let ws_value = web_search.as_value();
247            if ws_value.is_object() {
248                match body.get_mut("tools").and_then(|v| v.as_array_mut()) {
249                    Some(arr) => arr.push(ws_value),
250                    None => body["tools"] = Value::Array(vec![ws_value]),
251                }
252            }
253        }
254
255        if let Some(tag) = openai_tag(request) {
256            if reasoning_enabled && let Some(effort) = tag.reasoning_effort {
257                let s = match effort {
258                    TypedReasoningEffort::Low => "low",
259                    TypedReasoningEffort::Medium => "medium",
260                    TypedReasoningEffort::High => "high",
261                };
262                body["reasoning"]["effort"] = Value::String(s.to_string());
263            }
264
265            if let Some(seed) = tag.seed {
266                body["seed"] = Value::Number(seed.into());
267            }
268
269            if let Some(fp) = tag.frequency_penalty
270                && let Some(n) = serde_json::Number::from_f64(fp as f64)
271            {
272                body["frequency_penalty"] = Value::Number(n);
273            }
274
275            if let Some(pp) = tag.presence_penalty
276                && let Some(n) = serde_json::Number::from_f64(pp as f64)
277            {
278                body["presence_penalty"] = Value::Number(n);
279            }
280
281            if let Some(output_schema) = tag.structured_output.as_ref() {
282                let compiled =
283                    self.compile_schema(output_schema)
284                        .map_err(|e| LlmError::InvalidRequest {
285                            message: e.to_string(),
286                        })?;
287                let name = output_schema.name.as_deref().unwrap_or("output");
288                let strict = output_schema.strict;
289
290                body["text"] = serde_json::json!({
291                    "format": {
292                        "type": "json_schema",
293                        "name": name,
294                        "schema": compiled.schema,
295                        "strict": strict
296                    }
297                });
298            }
299        }
300
301        Ok(body)
302    }
303
304    /// Convert messages to Responses API input format.
305    ///
306    /// Note: we intentionally do not replay prior `reasoning` items.
307    /// OpenAI enforces strict adjacency invariants for reasoning replay, and
308    /// violating them causes hard request failures. Replaying only user/assistant
309    /// messages and tool items is robust across retries and tool-call turns.
310    fn convert_to_responses_input(messages: &[Message]) -> Result<Vec<Value>, LlmError> {
311        Self::convert_to_responses_input_with_system_mode(
312            messages,
313            SystemMessageMode::IncludeInInput,
314        )
315        .map(|(input, _)| input)
316    }
317
318    fn convert_to_responses_input_with_system_mode(
319        messages: &[Message],
320        system_mode: SystemMessageMode,
321    ) -> Result<(Vec<Value>, Option<String>), LlmError> {
322        let mut items = Vec::new();
323        let mut instructions = Vec::new();
324
325        for msg in messages {
326            match msg {
327                Message::System(s) => match system_mode {
328                    SystemMessageMode::IncludeInInput => {
329                        items.push(serde_json::json!({
330                            "type": "message",
331                            "role": "system",
332                            "content": s.content
333                        }));
334                    }
335                    SystemMessageMode::ExtractToInstructions => {
336                        if !s.content.trim().is_empty() {
337                            instructions.push(s.content.clone());
338                        }
339                    }
340                },
341                Message::SystemNotice(notice) => {
342                    items.push(serde_json::json!({
343                        "type": "message",
344                        "role": "user",
345                        "content": notice.rendered_text()
346                    }));
347                }
348                Message::User(u) => {
349                    if meerkat_core::has_non_text_content(&u.content) {
350                        let content_array: Vec<Value> = u
351                            .content
352                            .iter()
353                            .map(|block| match block {
354                                ContentBlock::Text { text } => serde_json::json!({
355                                    "type": "input_text",
356                                    "text": text
357                                }),
358                                ContentBlock::Image { media_type, data } => match data {
359                                    ImageData::Inline { data } => serde_json::json!({
360                                        "type": "input_image",
361                                        "image_url": format!("data:{media_type};base64,{data}")
362                                    }),
363                                    ImageData::Blob { .. } => serde_json::json!({
364                                        "type": "input_text",
365                                        "text": block.text_projection()
366                                    }),
367                                },
368                                _ => serde_json::json!({
369                                    "type": "input_text",
370                                    "text": block.text_projection()
371                                }),
372                            })
373                            .collect();
374                        items.push(serde_json::json!({
375                            "type": "message",
376                            "role": "user",
377                            "content": content_array
378                        }));
379                    } else {
380                        items.push(serde_json::json!({
381                            "type": "message",
382                            "role": "user",
383                            "content": u.text_content()
384                        }));
385                    }
386                }
387                Message::Assistant(a) => {
388                    // Legacy AssistantMessage format - convert to items
389                    if !a.content.is_empty() {
390                        items.push(serde_json::json!({
391                            "type": "message",
392                            "role": "assistant",
393                            "content": a.content
394                        }));
395                    }
396                    for tc in &a.tool_calls {
397                        items.push(serde_json::json!({
398                            "type": "function_call",
399                            "call_id": tc.id,
400                            "name": tc.name,
401                            "arguments": tc.args.to_string()
402                        }));
403                    }
404                }
405                Message::BlockAssistant(a) => {
406                    // New BlockAssistantMessage format - render blocks as items
407                    for block in &a.blocks {
408                        match block {
409                            AssistantBlock::Text { text, .. } => {
410                                if !text.is_empty() {
411                                    items.push(serde_json::json!({
412                                        "type": "message",
413                                        "role": "assistant",
414                                        "content": text
415                                    }));
416                                }
417                            }
418                            AssistantBlock::ToolUse { id, name, args, .. } => {
419                                items.push(serde_json::json!({
420                                    "type": "function_call",
421                                    "call_id": id,
422                                    "name": name,
423                                    "arguments": args.get()  // Already JSON string
424                                }));
425                            }
426                            // Reasoning replay can violate Responses API adjacency
427                            // constraints and hard-fail requests; skip it and any
428                            // unknown future variants.
429                            _ => {}
430                        }
431                    }
432                }
433                Message::ToolResults { results, .. } => {
434                    // OpenAI function_call_output only accepts strings; images
435                    // degrade to text projection via text_content().
436                    for r in results {
437                        if r.has_video() {
438                            return Err(LlmError::InvalidRequest {
439                                message: "video blocks are not supported in OpenAI tool results"
440                                    .to_string(),
441                            });
442                        }
443                        items.push(serde_json::json!({
444                            "type": "function_call_output",
445                            "call_id": r.tool_use_id,
446                            "output": r.text_content()
447                        }));
448                    }
449                }
450            }
451        }
452
453        let instructions = if instructions.is_empty() {
454            None
455        } else {
456            Some(instructions.join("\n\n"))
457        };
458        Ok((items, instructions))
459    }
460
461    /// Parse an SSE event from the Responses API stream
462    fn parse_responses_sse_line(line: &str) -> Option<ResponsesStreamEvent> {
463        if let Some(data) = line
464            .strip_prefix("data: ")
465            .or_else(|| line.strip_prefix("data:"))
466        {
467            if data == "[DONE]" {
468                return None;
469            }
470            serde_json::from_str(data).ok()
471        } else {
472            None
473        }
474    }
475
476    fn image_prompt(request: &ProviderImageGenerationRequest) -> String {
477        match &request.generate_request.intent {
478            ImageGenerationIntent::Generate { prompt, .. } => prompt.content.clone(),
479            ImageGenerationIntent::Edit { instruction, .. } => instruction.content.clone(),
480        }
481    }
482
483    fn image_count_warning(
484        request: &ProviderImageGenerationRequest,
485        returned: usize,
486    ) -> Vec<ImageGenerationWarning> {
487        let requested = request.generate_request.count;
488        let Some(returned_count) = std::num::NonZeroU32::new(returned as u32) else {
489            return Vec::new();
490        };
491        if returned_count < requested {
492            vec![ImageGenerationWarning::ProviderReturnedFewerImages {
493                requested,
494                returned: returned_count,
495            }]
496        } else {
497            Vec::new()
498        }
499    }
500
501    fn openai_error_terminal(status_code: u16, text: &str) -> ImageOperationTerminalClass {
502        if status_code == 408 || status_code == 504 {
503            ImageOperationTerminalClass::Timeout
504        } else if status_code == 499 {
505            ImageOperationTerminalClass::Cancelled
506        } else if let Ok(value) = serde_json::from_str::<Value>(text) {
507            Self::openai_structured_error_terminal(&value)
508                .unwrap_or(ImageOperationTerminalClass::Failed)
509        } else {
510            ImageOperationTerminalClass::Failed
511        }
512    }
513
514    fn openai_structured_error_terminal(value: &Value) -> Option<ImageOperationTerminalClass> {
515        let error = value.get("error").unwrap_or(value);
516        ["code", "type"].into_iter().find_map(|field| {
517            error
518                .get(field)
519                .and_then(Value::as_str)
520                .and_then(Self::openai_structured_error_code_terminal)
521        })
522    }
523
524    fn openai_structured_error_code_terminal(code: &str) -> Option<ImageOperationTerminalClass> {
525        match code {
526            "content_filter"
527            | "content_filtered"
528            | "content_policy_violation"
529            | "moderation_blocked"
530            | "safety_violation" => Some(ImageOperationTerminalClass::SafetyFiltered),
531            "model_refusal" | "refusal" | "refused_by_model" => {
532                Some(ImageOperationTerminalClass::RefusedByProvider)
533            }
534            _ => None,
535        }
536    }
537
538    async fn post_json_to_openai(
539        &self,
540        endpoint: &str,
541        body: &Value,
542    ) -> Result<reqwest::Response, LlmError> {
543        let mut request_builder = self
544            .http
545            .post(endpoint)
546            .header("Content-Type", "application/json");
547        if let Some(authorizer) = &self.authorizer {
548            let mut extra: Vec<(String, String)> = Vec::new();
549            let mut auth_req = meerkat_core::HttpAuthorizationRequest {
550                method: "POST",
551                url: endpoint,
552                headers: &mut extra,
553            };
554            authorizer.authorize(&mut auth_req).await.map_err(|e| {
555                LlmError::AuthenticationFailed {
556                    message: format!("openai authorizer failed: {e}"),
557                }
558            })?;
559            for (name, value) in extra {
560                request_builder = request_builder.header(name, value);
561            }
562        } else if let Some(api_key) = &self.api_key {
563            request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
564        }
565        for (name, value) in &self.extra_headers {
566            request_builder = request_builder.header(name, value);
567        }
568        request_builder.json(body).send().await.map_err(|e| {
569            if e.is_timeout() {
570                LlmError::NetworkTimeout { duration_ms: 30000 }
571            } else {
572                #[cfg(not(target_arch = "wasm32"))]
573                if e.is_connect() {
574                    return LlmError::ConnectionReset;
575                }
576                LlmError::Unknown {
577                    message: e.to_string(),
578                }
579            }
580        })
581    }
582
583    async fn execute_hosted_responses_image(
584        &self,
585        request: ProviderImageGenerationRequest,
586        plan: OpenAiResponsesImagePlan,
587    ) -> Result<ProviderImageGenerationOutput, LlmError> {
588        let input = if request.projected_messages.is_empty() {
589            vec![serde_json::json!({
590                "type": "message",
591                "role": "user",
592                "content": Self::image_prompt(&request)
593            })]
594        } else {
595            Self::convert_to_responses_input(&request.projected_messages)?
596        };
597        let mut tool = serde_json::Map::new();
598        tool.insert(
599            "type".to_string(),
600            serde_json::Value::String(plan.tool_name),
601        );
602        tool.insert(
603            "model".to_string(),
604            serde_json::Value::String(plan.model.to_string()),
605        );
606        Self::apply_image_output_options(&mut tool, &plan.output);
607        Self::apply_openai_image_provider_params(&mut tool, &plan.provider_params, true);
608        let body = serde_json::json!({
609            "model": request.model,
610            "input": input,
611            "instructions": "You are an image-generation agent. The user's input is always a request to produce one or more images. Call the image_generation tool to produce each image. Never reply in text instead of calling the tool. If the request cannot be fulfilled, refuse briefly and explicitly so the caller can see the reason.",
612            "tools": [serde_json::Value::Object(tool)],
613            "tool_choice": "required",
614            "stream": false,
615        });
616        let endpoint = self.responses_endpoint();
617        let response = self.post_json_to_openai(&endpoint, &body).await?;
618        self.normalize_openai_image_response(request, response, true)
619            .await
620    }
621
622    async fn execute_images_api(
623        &self,
624        request: ProviderImageGenerationRequest,
625        model: String,
626        plan: OpenAiImagesApiPlan,
627    ) -> Result<ProviderImageGenerationOutput, LlmError> {
628        let endpoint_path = match plan.endpoint {
629            OpenAiImagesApiEndpoint::Generations => "/v1/images/generations",
630            OpenAiImagesApiEndpoint::Edits => "/v1/images/edits",
631        };
632        let mut body = serde_json::json!({
633            "model": model,
634            "prompt": Self::image_prompt(&request),
635            "n": request.generate_request.count.get(),
636        });
637        if let Some(obj) = body.as_object_mut() {
638            match plan.request_shape {
639                OpenAiImagesApiRequestShape::GptImage => {
640                    Self::apply_image_output_options(obj, &plan.output);
641                    Self::apply_openai_image_provider_params(obj, &plan.provider_params, false);
642                }
643                OpenAiImagesApiRequestShape::DallE => {
644                    obj.insert(
645                        "response_format".to_string(),
646                        serde_json::Value::String("b64_json".to_string()),
647                    );
648                }
649            }
650        }
651        let endpoint = format!("{}{}", self.base_url, endpoint_path);
652        let response = self.post_json_to_openai(&endpoint, &body).await?;
653        self.normalize_openai_image_response(request, response, false)
654            .await
655    }
656
657    fn apply_image_output_options(
658        obj: &mut serde_json::Map<String, serde_json::Value>,
659        output: &OpenAiImageOutputOptions,
660    ) {
661        obj.insert(
662            "size".to_string(),
663            serde_json::Value::String(output.size.as_wire_value()),
664        );
665        obj.insert(
666            "quality".to_string(),
667            serde_json::Value::String(output.quality.as_wire_value().to_string()),
668        );
669        obj.insert(
670            "output_format".to_string(),
671            serde_json::Value::String(output.output_format.as_wire_value().to_string()),
672        );
673    }
674
675    fn apply_openai_image_provider_params(
676        obj: &mut serde_json::Map<String, serde_json::Value>,
677        params: &OpenAiImageProviderParams,
678        allow_action: bool,
679    ) {
680        if let Some(background) = params.background {
681            obj.insert(
682                "background".to_string(),
683                serde_json::Value::String(background.as_wire_value().to_string()),
684            );
685        }
686        if let Some(output_compression) = params.output_compression {
687            obj.insert(
688                "output_compression".to_string(),
689                serde_json::Value::Number(output_compression.into()),
690            );
691        }
692        if let Some(moderation) = params.moderation {
693            obj.insert(
694                "moderation".to_string(),
695                serde_json::Value::String(moderation.as_wire_value().to_string()),
696            );
697        }
698        if allow_action && let Some(action) = params.action {
699            obj.insert(
700                "action".to_string(),
701                serde_json::Value::String(action.as_wire_value().to_string()),
702            );
703        }
704    }
705
706    async fn normalize_openai_image_response(
707        &self,
708        request: ProviderImageGenerationRequest,
709        response: reqwest::Response,
710        hosted_responses: bool,
711    ) -> Result<ProviderImageGenerationOutput, LlmError> {
712        let status_code = response.status().as_u16();
713        let text = response.text().await.unwrap_or_default();
714        if !(200..=299).contains(&status_code) {
715            return Ok(ProviderImageGenerationOutput {
716                operation_id: request.operation_id,
717                terminal: Self::openai_error_terminal(status_code, &text),
718                images: Vec::new(),
719                provider_text: None,
720                revised_prompt: RevisedPromptDisposition::NotRequested,
721                native_metadata: ProviderImageMetadata::OpenAi(OpenAiImageMetadata {
722                    target_model: request.model,
723                    response_id: None,
724                    image_generation_call_id: None,
725                }),
726                warnings: Vec::new(),
727            });
728        }
729
730        let value: Value = serde_json::from_str(&text).map_err(|e| LlmError::StreamParseError {
731            message: format!("invalid OpenAI image response JSON: {e}"),
732        })?;
733        let (width, height) = dimensions_from_size_preference(&request.generate_request.size);
734        let media_type = media_type_from_format_preference(request.generate_request.format);
735        let mut images = Vec::new();
736        let mut revised_prompt = RevisedPromptDisposition::NotRequested;
737        let mut provider_text = Vec::new();
738        let mut image_generation_call_id = None;
739
740        if hosted_responses {
741            if let Some(output) = value.get("output").and_then(Value::as_array) {
742                for item in output {
743                    match item.get("type").and_then(|v| v.as_str()) {
744                        Some("image_generation_call") => {
745                            if image_generation_call_id.is_none() {
746                                image_generation_call_id =
747                                    item.get("id").and_then(|v| v.as_str()).map(str::to_string);
748                            }
749                            if let Some(data) = item
750                                .get("result")
751                                .or_else(|| item.get("b64_json"))
752                                .or_else(|| item.get("image_data"))
753                                .and_then(|v| v.as_str())
754                            {
755                                images.push(ProviderGeneratedImage {
756                                    media_type: media_type.clone(),
757                                    base64_data: normalize_base64_image_data(data),
758                                    width,
759                                    height,
760                                });
761                            }
762                            if let Some(text) = item.get("revised_prompt").and_then(|v| v.as_str())
763                                && let Ok(prompt) = meerkat_core::PromptText::new(text.to_string())
764                            {
765                                revised_prompt = RevisedPromptDisposition::Revised {
766                                    text: prompt,
767                                    source: RevisedPromptSource::Provider,
768                                };
769                            }
770                        }
771                        Some("message") => {
772                            if let Some(parts) = item.get("content").and_then(|v| v.as_array()) {
773                                for part in parts {
774                                    if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
775                                        provider_text.push(text.to_string());
776                                    }
777                                }
778                            }
779                        }
780                        _ => {}
781                    }
782                }
783            }
784        } else if let Some(data) = value.get("data").and_then(|v| v.as_array()) {
785            for item in data {
786                if let Some(data) = item.get("b64_json").and_then(|v| v.as_str()) {
787                    images.push(ProviderGeneratedImage {
788                        media_type: media_type.clone(),
789                        base64_data: normalize_base64_image_data(data),
790                        width,
791                        height,
792                    });
793                }
794                if let Some(text) = item.get("revised_prompt").and_then(|v| v.as_str())
795                    && let Ok(prompt) = meerkat_core::PromptText::new(text.to_string())
796                {
797                    revised_prompt = RevisedPromptDisposition::Revised {
798                        text: prompt,
799                        source: RevisedPromptSource::Provider,
800                    };
801                }
802            }
803        }
804
805        let terminal = if images.is_empty() {
806            ImageOperationTerminalClass::EmptyResult {
807                provider_text: if provider_text.is_empty() {
808                    meerkat_core::ProviderTextDisposition::NotEmitted
809                } else {
810                    meerkat_core::ProviderTextDisposition::EmittedButNotStored
811                },
812            }
813        } else {
814            ImageOperationTerminalClass::Generated
815        };
816        let warnings = Self::image_count_warning(&request, images.len());
817        Ok(ProviderImageGenerationOutput {
818            operation_id: request.operation_id,
819            terminal,
820            images,
821            provider_text: if provider_text.is_empty() {
822                None
823            } else {
824                Some(provider_text.join("\n"))
825            },
826            revised_prompt,
827            native_metadata: ProviderImageMetadata::OpenAi(OpenAiImageMetadata {
828                target_model: request.model,
829                response_id: value.get("id").and_then(|v| v.as_str()).map(str::to_string),
830                image_generation_call_id,
831            }),
832            warnings,
833        })
834    }
835}
836
837#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
838#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
839impl ImageGenerationExecutor for OpenAiClient {
840    async fn execute_image_generation(
841        &self,
842        request: ProviderImageGenerationRequest,
843    ) -> Result<ProviderImageGenerationOutput, LlmError> {
844        match request.execution_plan.clone() {
845            plan if plan.provider.0 == "openai" => match plan.backend {
846                meerkat_core::ImageGenerationBackendKind::HostedTool => {
847                    let provider_plan: OpenAiResponsesImagePlan =
848                        serde_json::from_value(plan.provider_plan).map_err(|err| {
849                            LlmError::InvalidRequest {
850                                message: format!("invalid OpenAI hosted image plan: {err}"),
851                            }
852                        })?;
853                    self.execute_hosted_responses_image(request, provider_plan)
854                        .await
855                }
856                meerkat_core::ImageGenerationBackendKind::ProviderApi => {
857                    let provider_plan: OpenAiImagesApiPlan =
858                        serde_json::from_value(plan.provider_plan).map_err(|err| {
859                            LlmError::InvalidRequest {
860                                message: format!("invalid OpenAI Images API plan: {err}"),
861                            }
862                        })?;
863                    let model = request.model.clone();
864                    self.execute_images_api(request, model, provider_plan).await
865                }
866                other => Err(LlmError::InvalidRequest {
867                    message: format!("OpenAI image executor cannot run backend {other:?}"),
868                }),
869            },
870            other => Err(LlmError::InvalidRequest {
871                message: format!("OpenAI image executor cannot run plan {other:?}"),
872            }),
873        }
874    }
875}
876
877/// OpenAI strict JSON schema mode requires `additionalProperties: false` on
878/// object schemas. We preserve explicit caller values and only inject when
879/// missing.
880fn ensure_additional_properties_false(value: &mut Value) {
881    match value {
882        Value::Object(obj) => {
883            let is_object_type = match obj.get("type") {
884                Some(Value::String(t)) => t == "object",
885                Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("object")),
886                _ => obj.contains_key("properties") || obj.contains_key("required"),
887            };
888
889            if is_object_type && !obj.contains_key("additionalProperties") {
890                obj.insert("additionalProperties".to_string(), Value::Bool(false));
891            }
892
893            for child in obj.values_mut() {
894                ensure_additional_properties_false(child);
895            }
896        }
897        Value::Array(items) => {
898            for item in items.iter_mut() {
899                ensure_additional_properties_false(item);
900            }
901        }
902        _ => {}
903    }
904}
905
906#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
907#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
908impl LlmClient for OpenAiClient {
909    fn stream<'a>(&'a self, request: &'a LlmRequest) -> LlmStream<'a> {
910        let inner: LlmStream<'a> = Box::pin(async_stream::try_stream! {
911            let body = self.build_request_body(request)?;
912
913            let endpoint = self.responses_endpoint();
914            let mut request_builder = self
915                .http
916                .post(&endpoint)
917                .header("Content-Type", "application/json");
918            // Auth path: authorizer overrides Bearer<api_key>.
919            if let Some(authorizer) = &self.authorizer {
920                let mut extra: Vec<(String, String)> = Vec::new();
921                let mut auth_req = meerkat_core::HttpAuthorizationRequest {
922                    method: "POST",
923                    url: &endpoint,
924                    headers: &mut extra,
925                };
926                authorizer.authorize(&mut auth_req).await.map_err(|e| {
927                    LlmError::AuthenticationFailed {
928                        message: format!("openai authorizer failed: {e}"),
929                    }
930                })?;
931                for (name, value) in extra {
932                    request_builder = request_builder.header(name, value);
933                }
934            } else if let Some(api_key) = &self.api_key {
935                request_builder =
936                    request_builder.header("Authorization", format!("Bearer {api_key}"));
937            }
938            for (name, value) in &self.extra_headers {
939                request_builder = request_builder.header(name, value);
940            }
941            let response = request_builder
942                .json(&body)
943                .send()
944                .await
945                .map_err(|e| {
946                    if e.is_timeout() {
947                        LlmError::NetworkTimeout { duration_ms: 30000 }
948                    } else {
949                        #[cfg(not(target_arch = "wasm32"))]
950                        if e.is_connect() {
951                            return LlmError::ConnectionReset;
952                        }
953                        LlmError::Unknown { message: e.to_string() }
954                    }
955                })?;
956
957            let status_code = response.status().as_u16();
958            let stream_result = if (200..=299).contains(&status_code) {
959                Ok(response.bytes_stream())
960            } else {
961                let headers = response.headers().clone();
962                let text = response.text().await.unwrap_or_default();
963                Err(LlmError::from_http_response(status_code, text, &headers))
964            };
965            let mut stream = stream_result?;
966            let mut buffer = String::with_capacity(512);
967            let mut assembler = BlockAssembler::new();
968            let mut usage = Usage::default();
969            let mut saw_stream_text_delta = false;
970            let mut streamed_tool_ids: HashSet<String> = HashSet::with_capacity(4);
971            let mut streamed_reasoning_ids: HashSet<String> = HashSet::with_capacity(2);
972            let mut done_emitted = false;
973
974            while let Some(chunk) = stream.next().await {
975                let chunk = chunk.map_err(|_| LlmError::ConnectionReset)?;
976                buffer.push_str(&String::from_utf8_lossy(&chunk));
977
978                while let Some(newline_pos) = buffer.find('\n') {
979                    let line = buffer[..newline_pos].trim();
980                    let should_process = !line.is_empty() && !line.starts_with(':');
981                    let parsed_event = if should_process {
982                        Self::parse_responses_sse_line(line)
983                    } else {
984                        None
985                    };
986
987                    buffer.drain(..=newline_pos);
988
989                    if let Some(event) = parsed_event {
990                        // Handle response.completed event (non-streaming final response)
991                        if event.event_type == "response.completed" {
992                            if done_emitted {
993                                // Already processed a terminal event, skip
994                                continue;
995                            }
996                            if let Some(response_obj) = &event.response {
997                                // Process output items
998                                if let Some(output) = response_obj.get("output").and_then(|o| o.as_array()) {
999                                    for item in output {
1000                                        if let Some(item_type) = item.get("type").and_then(|t| t.as_str()) {
1001                                            match item_type {
1002                                                "message" => {
1003                                                    // content is an array: [{"type": "output_text", "text": "..."}, ...]
1004                                                    if let Some(content_parts) = item.get("content").and_then(|c| c.as_array()) {
1005                                                        for part in content_parts {
1006                                                            if let Some(part_type) = part.get("type").and_then(|t| t.as_str()) {
1007                                                                match part_type {
1008                                                                    "output_text" => {
1009                                                                        if let Some(text) = part.get("text").and_then(|t| t.as_str())
1010                                                                            && !saw_stream_text_delta
1011                                                                        {
1012                                                                            assembler.on_text_delta(text, None);
1013                                                                            yield LlmEvent::TextDelta { delta: text.to_string(), meta: None };
1014                                                                        }
1015                                                                    }
1016                                                                    "refusal" => {
1017                                                                        if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str())
1018                                                                            && !saw_stream_text_delta
1019                                                                        {
1020                                                                            assembler.on_text_delta(refusal, None);
1021                                                                            yield LlmEvent::TextDelta { delta: refusal.to_string(), meta: None };
1022                                                                        }
1023                                                                    }
1024                                                                    _ => {}
1025                                                                }
1026                                                            }
1027                                                        }
1028                                                    }
1029                                                }
1030                                                "reasoning" => {
1031                                                    // Required: reasoning item ID for replay
1032                                                    let Some(reasoning_id) = item.get("id").and_then(|i| i.as_str()) else {
1033                                                        tracing::warn!("reasoning item missing id, skipping");
1034                                                        continue;
1035                                                    };
1036
1037                                                    // Skip if already emitted via streaming
1038                                                    if streamed_reasoning_ids.contains(reasoning_id) {
1039                                                        continue;
1040                                                    }
1041
1042                                                    // Extract summary text
1043                                                    let mut summary_text = String::new();
1044                                                    if let Some(summaries) = item.get("summary").and_then(|s| s.as_array()) {
1045                                                        for summary in summaries {
1046                                                            if let Some(text) = summary.get("text").and_then(|t| t.as_str()) {
1047                                                                if !summary_text.is_empty() {
1048                                                                    summary_text.push('\n');
1049                                                                }
1050                                                                summary_text.push_str(text);
1051                                                            }
1052                                                        }
1053                                                    }
1054
1055                                                    // encrypted_content is optional
1056                                                    let encrypted = item.get("encrypted_content")
1057                                                        .and_then(|v| v.as_str())
1058                                                        .map(std::string::ToString::to_string);
1059
1060                                                    let meta = Some(Box::new(ProviderMeta::OpenAi {
1061                                                        id: reasoning_id.to_string(),
1062                                                        encrypted_content: encrypted,
1063                                                    }));
1064
1065                                                    assembler.on_reasoning_start();
1066                                                    if !summary_text.is_empty() {
1067                                                        let _ = assembler.on_reasoning_delta(&summary_text);
1068                                                    }
1069                                                    assembler.on_reasoning_complete(meta.clone());
1070
1071                                                    yield LlmEvent::ReasoningComplete {
1072                                                        text: summary_text,
1073                                                        meta,
1074                                                    };
1075                                                }
1076                                                "function_call" => {
1077                                                    // Extract required fields
1078                                                    let Some(call_id) = item.get("call_id").and_then(|c| c.as_str()) else {
1079                                                        tracing::warn!("function_call missing call_id");
1080                                                        continue;
1081                                                    };
1082
1083                                                    // Skip if already emitted via streaming
1084                                                    if streamed_tool_ids.contains(call_id) {
1085                                                        continue;
1086                                                    }
1087                                                    let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
1088                                                        tracing::warn!(call_id, "function_call missing name");
1089                                                        continue;
1090                                                    };
1091                                                    // arguments is a JSON string. Missing arguments
1092                                                    // represent a no-arg tool; malformed or non-object
1093                                                    // JSON fails closed before projection.
1094                                                    let (args, args_value) = match item.get("arguments").and_then(|a| a.as_str()) {
1095                                                        Some(args_str) => parse_tool_call_arguments(args_str, call_id)?,
1096                                                        None => {
1097                                                            // Empty args - treat as empty object
1098                                                            (empty_tool_args_raw_value(), serde_json::json!({}))
1099                                                        }
1100                                                    };
1101
1102                                                    let _ = assembler.on_tool_call_start(call_id.to_string());
1103                                                    let _ = assembler.on_tool_call_complete(
1104                                                        call_id.to_string(),
1105                                                        name.to_string(),
1106                                                        args.clone(),
1107                                                        None,
1108                                                    );
1109
1110                                                    yield LlmEvent::ToolCallComplete {
1111                                                        id: call_id.to_string(),
1112                                                        name: name.into(),
1113                                                        args: args_value,
1114                                                        meta: None,
1115                                                    };
1116                                                }
1117                                                _ => {}
1118                                            }
1119                                        }
1120                                    }
1121                                }
1122
1123                                // Extract usage
1124                                if let Some(usage_obj) = response_obj.get("usage") {
1125                                    usage.input_tokens = usage_obj.get("input_tokens")
1126                                        .and_then(serde_json::Value::as_u64)
1127                                        .unwrap_or(0);
1128                                    usage.output_tokens = usage_obj.get("output_tokens")
1129                                        .and_then(serde_json::Value::as_u64)
1130                                        .unwrap_or(0);
1131                                    yield LlmEvent::UsageUpdate { usage: usage.clone() };
1132                                }
1133
1134                                // Determine stop reason
1135                                let stop_reason = match response_obj.get("status").and_then(|s| s.as_str()) {
1136                                    Some("completed") => {
1137                                        // Check if there were tool calls
1138                                        let has_tool_calls = response_obj.get("output")
1139                                            .and_then(|o| o.as_array())
1140                                            .is_some_and(|arr| arr.iter().any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call")));
1141                                        if has_tool_calls {
1142                                            StopReason::ToolUse
1143                                        } else {
1144                                            StopReason::EndTurn
1145                                        }
1146                                    }
1147                                    Some("incomplete") => {
1148                                        match response_obj.get("incomplete_details").and_then(|d| d.get("reason")).and_then(|r| r.as_str()) {
1149                                            Some("max_output_tokens") => StopReason::MaxTokens,
1150                                            Some("content_filter") => StopReason::ContentFilter,
1151                                            _ => StopReason::EndTurn,
1152                                        }
1153                                    }
1154                                    Some("cancelled") => StopReason::Cancelled,
1155                                    _ => StopReason::EndTurn,
1156                                };
1157
1158                                done_emitted = true;
1159                                yield LlmEvent::Done {
1160                                    outcome: LlmDoneOutcome::Success { stop_reason },
1161                                };
1162                            }
1163                        }
1164                        // Handle streaming delta events
1165                        else if event.event_type == "response.output_text.delta" {
1166                            if let Some(delta) = &event.delta {
1167                                saw_stream_text_delta = true;
1168                                assembler.on_text_delta(delta, None);
1169                                yield LlmEvent::TextDelta { delta: delta.clone(), meta: None };
1170                            }
1171                        }
1172                        else if event.event_type == "response.reasoning_summary_text.delta" {
1173                            if let Some(delta) = &event.delta {
1174                                yield LlmEvent::ReasoningDelta { delta: delta.clone() };
1175                            }
1176                        }
1177                        else if event.event_type == "response.function_call_arguments.delta" {
1178                            if let (Some(call_id), Some(delta)) = (&event.call_id, &event.delta) {
1179                                let name = event.name.clone();
1180                                yield LlmEvent::ToolCallDelta {
1181                                    id: call_id.clone(),
1182                                    name,
1183                                    args_delta: delta.clone(),
1184                                };
1185                            }
1186                        }
1187                        else if event.event_type == "response.function_call_arguments.done" {
1188                            if let (Some(call_id), Some(arguments)) = (&event.call_id, &event.arguments) {
1189                                let name = event.name.clone().unwrap_or_default();
1190                                let (args, args_value) =
1191                                    parse_tool_call_arguments(arguments, call_id)?;
1192
1193                                let _ = assembler.on_tool_call_start(call_id.clone());
1194                                let _ = assembler.on_tool_call_complete(
1195                                    call_id.clone(),
1196                                    name.clone(),
1197                                    args.clone(),
1198                                    None,
1199                                );
1200
1201                                streamed_tool_ids.insert(call_id.clone());
1202                                yield LlmEvent::ToolCallComplete {
1203                                    id: call_id.clone(),
1204                                    name,
1205                                    args: args_value,
1206                                    meta: None,
1207                                };
1208                            }
1209                        }
1210                        else if event.event_type == "response.reasoning_summary.done" || event.event_type == "response.reasoning.done" {
1211                            // Extract reasoning item details from the item field
1212                            if let Some(item) = &event.item {
1213                                let Some(reasoning_id) = item.get("id")
1214                                    .and_then(|i| i.as_str()) else {
1215                                    tracing::warn!("reasoning item missing id, skipping");
1216                                    continue;
1217                                };
1218
1219                                let mut summary_text = String::new();
1220                                if let Some(summaries) = item.get("summary").and_then(|s| s.as_array()) {
1221                                    for summary in summaries {
1222                                        if let Some(text) = summary.get("text").and_then(|t| t.as_str()) {
1223                                            if !summary_text.is_empty() {
1224                                                summary_text.push('\n');
1225                                            }
1226                                            summary_text.push_str(text);
1227                                        }
1228                                    }
1229                                }
1230
1231                                let encrypted = item.get("encrypted_content")
1232                                    .and_then(|v| v.as_str())
1233                                    .map(std::string::ToString::to_string);
1234
1235                                let meta = Some(Box::new(ProviderMeta::OpenAi {
1236                                    id: reasoning_id.to_string(),
1237                                    encrypted_content: encrypted,
1238                                }));
1239
1240                                assembler.on_reasoning_start();
1241                                if !summary_text.is_empty() {
1242                                    let _ = assembler.on_reasoning_delta(&summary_text);
1243                                }
1244                                assembler.on_reasoning_complete(meta.clone());
1245
1246                                streamed_reasoning_ids.insert(reasoning_id.to_string());
1247                                yield LlmEvent::ReasoningComplete {
1248                                    text: summary_text,
1249                                    meta,
1250                                };
1251                            }
1252                        }
1253                        else if event.event_type == "response.done" {
1254                            // Final done event — always update usage
1255                            if let Some(response_obj) = &event.response {
1256                                if let Some(usage_obj) = response_obj.get("usage") {
1257                                    usage.input_tokens = usage_obj.get("input_tokens")
1258                                        .and_then(serde_json::Value::as_u64)
1259                                        .unwrap_or(0);
1260                                    usage.output_tokens = usage_obj.get("output_tokens")
1261                                        .and_then(serde_json::Value::as_u64)
1262                                        .unwrap_or(0);
1263                                    yield LlmEvent::UsageUpdate { usage: usage.clone() };
1264                                }
1265
1266                                if !done_emitted {
1267                                    let stop_reason = match response_obj.get("status").and_then(|s| s.as_str()) {
1268                                        Some("completed") => {
1269                                            let has_tool_calls = response_obj.get("output")
1270                                                .and_then(|o| o.as_array())
1271                                                .is_some_and(|arr| arr.iter().any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call")));
1272                                            if has_tool_calls {
1273                                                StopReason::ToolUse
1274                                            } else {
1275                                                StopReason::EndTurn
1276                                            }
1277                                        }
1278                                        Some("incomplete") => {
1279                                            match response_obj.get("incomplete_details").and_then(|d| d.get("reason")).and_then(|r| r.as_str()) {
1280                                                Some("max_output_tokens") => StopReason::MaxTokens,
1281                                                Some("content_filter") => StopReason::ContentFilter,
1282                                                _ => StopReason::EndTurn,
1283                                            }
1284                                        }
1285                                        Some("cancelled") => StopReason::Cancelled,
1286                                        _ => StopReason::EndTurn,
1287                                    };
1288
1289                                    done_emitted = true;
1290                                    yield LlmEvent::Done {
1291                                        outcome: LlmDoneOutcome::Success { stop_reason },
1292                                    };
1293                                }
1294                            }
1295                        }
1296                        else if event.event_type == "error" {
1297                            // Streaming error event from OpenAI
1298                            let error_msg = event.error
1299                                .as_ref()
1300                                .and_then(|e| e.get("message"))
1301                                .and_then(|m| m.as_str())
1302                                .unwrap_or("unknown streaming error");
1303                            let error_code = event.error
1304                                .as_ref()
1305                                .and_then(|e| e.get("code"))
1306                                .and_then(|c| c.as_str())
1307                                .unwrap_or("unknown");
1308
1309                            tracing::error!(
1310                                code = error_code,
1311                                message = error_msg,
1312                                "OpenAI streaming error"
1313                            );
1314
1315                            let error = match error_code {
1316                                "rate_limit_exceeded" => LlmError::RateLimited { retry_after_ms: None },
1317                                "server_error" => LlmError::ServerError {
1318                                    status: 500,
1319                                    message: error_msg.to_string(),
1320                                },
1321                                "invalid_request_error" => LlmError::InvalidRequest {
1322                                    message: error_msg.to_string(),
1323                                },
1324                                _ => LlmError::Unknown {
1325                                    message: format!("{error_code}: {error_msg}"),
1326                                },
1327                            };
1328
1329                            done_emitted = true;
1330                            yield LlmEvent::Done {
1331                                outcome: LlmDoneOutcome::Error { error },
1332                            };
1333                        }
1334                    }
1335                }
1336            }
1337        });
1338
1339        streaming::ensure_terminal_done(inner)
1340    }
1341
1342    fn provider(&self) -> &'static str {
1343        "openai"
1344    }
1345
1346    async fn health_check(&self) -> Result<(), LlmError> {
1347        Ok(())
1348    }
1349
1350    fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
1351        let mut schema = output_schema.schema.as_value().clone();
1352        // OpenAI `strict` controls constrained decoding behavior for structured
1353        // output. `compat` is only used for provider-lowering policies where
1354        // warnings/errors may be emitted (e.g. Gemini keyword compatibility).
1355        if output_schema.strict {
1356            ensure_additional_properties_false(&mut schema);
1357        }
1358
1359        Ok(CompiledSchema {
1360            schema,
1361            warnings: Vec::new(),
1362        })
1363    }
1364}
1365
1366/// SSE event from OpenAI Responses API streaming
1367#[derive(Debug, Deserialize)]
1368struct ResponsesStreamEvent {
1369    /// Event type (e.g., "response.output_text.delta", "response.done")
1370    #[serde(rename = "type")]
1371    event_type: String,
1372    /// Text delta for streaming events
1373    delta: Option<String>,
1374    /// Call ID for function call events
1375    call_id: Option<String>,
1376    /// Function name for function call events
1377    name: Option<String>,
1378    /// Complete arguments for function_call_arguments.done
1379    arguments: Option<String>,
1380    /// Item object for reasoning/function done events
1381    item: Option<Value>,
1382    /// Full response object for response.done and response.completed
1383    response: Option<Value>,
1384    /// Error object for streaming error events
1385    error: Option<Value>,
1386}
1387
1388#[allow(clippy::unwrap_used, clippy::expect_used)]
1389fn empty_tool_args_raw_value() -> Box<RawValue> {
1390    RawValue::from_string("{}".to_string()).expect("static JSON is valid")
1391}
1392
1393fn parse_tool_call_arguments(
1394    arguments: &str,
1395    call_id: &str,
1396) -> Result<(Box<RawValue>, Value), LlmError> {
1397    let raw = RawValue::from_string(arguments.to_string()).map_err(|error| {
1398        LlmError::StreamParseError {
1399            message: format!("invalid OpenAI tool call arguments JSON for {call_id}: {error}"),
1400        }
1401    })?;
1402    let value: Value =
1403        serde_json::from_str(raw.get()).map_err(|error| LlmError::StreamParseError {
1404            message: format!("invalid OpenAI tool call arguments JSON for {call_id}: {error}"),
1405        })?;
1406    if value.is_object() {
1407        Ok((raw, value))
1408    } else {
1409        Err(LlmError::StreamParseError {
1410            message: format!("OpenAI tool call arguments for {call_id} must be a JSON object"),
1411        })
1412    }
1413}
1414
1415#[cfg(test)]
1416#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1417mod tests {
1418    use super::*;
1419    use axum::{Json, Router, extract::State, response::IntoResponse, routing::post};
1420    use meerkat_core::UserMessage;
1421    use meerkat_llm_core::ImageGenerationExecutor;
1422    use std::sync::{Arc, Mutex};
1423    use tokio::net::TcpListener;
1424
1425    async fn responses_sse(State(payload): State<String>) -> impl IntoResponse {
1426        ([("content-type", "text/event-stream")], payload)
1427    }
1428
1429    #[derive(Clone)]
1430    struct StreamStubState {
1431        payload: String,
1432        seen: Arc<Mutex<Vec<Value>>>,
1433    }
1434
1435    async fn responses_sse_with_body(
1436        State(state): State<StreamStubState>,
1437        Json(body): Json<Value>,
1438    ) -> impl IntoResponse {
1439        state.seen.lock().expect("seen mutex").push(body);
1440        ([("content-type", "text/event-stream")], state.payload)
1441    }
1442
1443    #[derive(Clone)]
1444    struct ImageStubState {
1445        response: Value,
1446        seen: Arc<Mutex<Vec<Value>>>,
1447    }
1448
1449    async fn openai_image_stub(
1450        State(state): State<ImageStubState>,
1451        Json(body): Json<Value>,
1452    ) -> impl IntoResponse {
1453        state.seen.lock().expect("seen mutex").push(body);
1454        Json(state.response)
1455    }
1456
1457    async fn spawn_openai_stub_server(payload: String) -> (String, tokio::task::JoinHandle<()>) {
1458        let app = Router::new()
1459            .route("/v1/responses", post(responses_sse))
1460            .with_state(payload);
1461        let listener = TcpListener::bind("127.0.0.1:0")
1462            .await
1463            .expect("bind test server");
1464        let addr = listener.local_addr().expect("local addr");
1465        let handle = tokio::spawn(async move {
1466            axum::serve(listener, app).await.expect("serve test server");
1467        });
1468        (format!("http://{addr}"), handle)
1469    }
1470
1471    async fn spawn_chatgpt_stub_server(
1472        payload: String,
1473        seen: Arc<Mutex<Vec<Value>>>,
1474    ) -> (String, tokio::task::JoinHandle<()>) {
1475        let app = Router::new()
1476            .route("/responses", post(responses_sse_with_body))
1477            .with_state(StreamStubState { payload, seen });
1478        let listener = TcpListener::bind("127.0.0.1:0")
1479            .await
1480            .expect("bind test server");
1481        let addr = listener.local_addr().expect("local addr");
1482        let handle = tokio::spawn(async move {
1483            axum::serve(listener, app).await.expect("serve test server");
1484        });
1485        (format!("http://{addr}"), handle)
1486    }
1487
1488    async fn spawn_openai_image_stub(
1489        response: Value,
1490        seen: Arc<Mutex<Vec<Value>>>,
1491    ) -> (String, tokio::task::JoinHandle<()>) {
1492        let app = Router::new()
1493            .route("/v1/responses", post(openai_image_stub))
1494            .route("/v1/images/generations", post(openai_image_stub))
1495            .with_state(ImageStubState { response, seen });
1496        let listener = TcpListener::bind("127.0.0.1:0")
1497            .await
1498            .expect("bind test server");
1499        let addr = listener.local_addr().expect("local addr");
1500        let handle = tokio::spawn(async move {
1501            axum::serve(listener, app).await.expect("serve test server");
1502        });
1503        (format!("http://{addr}"), handle)
1504    }
1505
1506    fn image_executor_request_json(plan: Value) -> ProviderImageGenerationRequest {
1507        serde_json::from_value(serde_json::json!({
1508            "operation_id": "00000000-0000-0000-0000-000000000101",
1509            "model": "gpt-4.1-mini",
1510            "generate_request": {
1511                "intent": {
1512                    "intent": "generate",
1513                    "prompt": {"content": "draw a small red kite"},
1514                    "prompt_source": {
1515                        "source": "user_provided",
1516                        "message_id": "00000000-0000-0000-0000-000000000102"
1517                    },
1518                    "reference_images": []
1519                },
1520                "target": {"target": "auto"},
1521                "size": {"size": "landscape1536x1024"},
1522                "quality": "low",
1523                "format": "png",
1524                "count": 2
1525            },
1526            "execution_plan": plan,
1527            "projected_messages": []
1528        }))
1529        .expect("image executor request")
1530    }
1531
1532    fn hosted_openai_plan_json() -> Value {
1533        serde_json::json!({
1534            "provider": "openai",
1535            "backend": "hosted_tool",
1536            "max_count": 4,
1537            "capabilities": {
1538                "hosted_image_generation_tool": true,
1539                "native_image_output": false,
1540                "custom_tools": true,
1541                "image_search_grounding": false,
1542                "image_continuity_tokens": "unsupported"
1543            },
1544            "requires_scoped_override": false,
1545            "provider_plan": {
1546                "tool_name": "image_generation",
1547                "model": "gpt-image-2",
1548                "output": {
1549                    "size": "landscape1536x1024",
1550                    "quality": "low",
1551                    "output_format": "png"
1552                }
1553            }
1554        })
1555    }
1556
1557    fn images_api_openai_plan_json() -> Value {
1558        serde_json::json!({
1559            "provider": "openai",
1560            "backend": "provider_api",
1561            "max_count": 4,
1562            "capabilities": {
1563                "hosted_image_generation_tool": false,
1564                "native_image_output": true,
1565                "custom_tools": false,
1566                "image_search_grounding": false,
1567                "image_continuity_tokens": "unsupported"
1568            },
1569            "requires_scoped_override": false,
1570            "provider_plan": {
1571                "endpoint": "generations",
1572                "request_shape": "gpt_image",
1573                "output": {
1574                    "size": "landscape1536x1024",
1575                    "quality": "low",
1576                    "output_format": "png"
1577                }
1578            }
1579        })
1580    }
1581
1582    // =========================================================================
1583    // Responses API Request Format Tests
1584    // =========================================================================
1585
1586    #[test]
1587    fn openai_image_error_terminal_uses_structured_error_codes()
1588    -> Result<(), Box<dyn std::error::Error>> {
1589        let safety = serde_json::json!({
1590            "error": {
1591                "type": "invalid_request_error",
1592                "code": "content_policy_violation",
1593                "message": "Request rejected by policy."
1594            }
1595        });
1596        assert_eq!(
1597            OpenAiClient::openai_error_terminal(400, &safety.to_string()),
1598            ImageOperationTerminalClass::SafetyFiltered
1599        );
1600
1601        let refusal = serde_json::json!({
1602            "error": {
1603                "type": "invalid_request_error",
1604                "code": "model_refusal",
1605                "message": "The model declined the request."
1606            }
1607        });
1608        assert_eq!(
1609            OpenAiClient::openai_error_terminal(400, &refusal.to_string()),
1610            ImageOperationTerminalClass::RefusedByProvider
1611        );
1612        Ok(())
1613    }
1614
1615    #[test]
1616    fn openai_image_error_terminal_does_not_parse_message_text() {
1617        let message_only = serde_json::json!({
1618            "error": {
1619                "type": "invalid_request_error",
1620                "message": "diagnostic text mentions safety, content_filter, refusal, and refused"
1621            }
1622        });
1623
1624        assert_eq!(
1625            OpenAiClient::openai_error_terminal(400, &message_only.to_string()),
1626            ImageOperationTerminalClass::Failed
1627        );
1628        assert_eq!(
1629            OpenAiClient::openai_error_terminal(
1630                503,
1631                "provider overloaded while checking safety filters"
1632            ),
1633            ImageOperationTerminalClass::Failed
1634        );
1635    }
1636
1637    #[test]
1638    fn openai_image_error_terminal_uses_transport_status_for_timeout_and_cancelled() {
1639        assert_eq!(
1640            OpenAiClient::openai_error_terminal(408, "request timed out"),
1641            ImageOperationTerminalClass::Timeout
1642        );
1643        assert_eq!(
1644            OpenAiClient::openai_error_terminal(504, "gateway timeout"),
1645            ImageOperationTerminalClass::Timeout
1646        );
1647        assert_eq!(
1648            OpenAiClient::openai_error_terminal(499, "client closed request"),
1649            ImageOperationTerminalClass::Cancelled
1650        );
1651    }
1652
1653    #[tokio::test]
1654    async fn openai_hosted_image_executor_normalizes_fake_response()
1655    -> Result<(), Box<dyn std::error::Error>> {
1656        let seen = Arc::new(Mutex::new(Vec::new()));
1657        let response = serde_json::json!({
1658            "id": "resp_img_1",
1659            "output": [
1660                {
1661                    "type": "message",
1662                    "content": [{"type": "output_text", "text": "Provider caption"}]
1663                },
1664                {
1665                    "type": "image_generation_call",
1666                    "id": "ig_1",
1667                    "result": "data:image/png;base64,aGVsbG8=",
1668                    "revised_prompt": "draw a small red kite in watercolor"
1669                }
1670            ]
1671        });
1672        let (base_url, handle) = spawn_openai_image_stub(response, seen.clone()).await;
1673        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
1674
1675        let output = client
1676            .execute_image_generation(image_executor_request_json(hosted_openai_plan_json()))
1677            .await?;
1678
1679        assert!(matches!(
1680            output.terminal,
1681            ImageOperationTerminalClass::Generated
1682        ));
1683        assert_eq!(output.images.len(), 1);
1684        assert_eq!(output.images[0].base64_data, "aGVsbG8=");
1685        assert_eq!(output.images[0].media_type.as_str(), "image/png");
1686        assert_eq!(
1687            (output.images[0].width, output.images[0].height),
1688            (1536, 1024)
1689        );
1690        assert_eq!(output.provider_text.as_deref(), Some("Provider caption"));
1691        assert!(matches!(
1692            output.revised_prompt,
1693            RevisedPromptDisposition::Revised { .. }
1694        ));
1695        assert!(matches!(
1696            output.native_metadata,
1697            ProviderImageMetadata::OpenAi(OpenAiImageMetadata {
1698                response_id: Some(_),
1699                image_generation_call_id: Some(_),
1700                ..
1701            })
1702        ));
1703        assert!(matches!(
1704            output.warnings.as_slice(),
1705            [ImageGenerationWarning::ProviderReturnedFewerImages { .. }]
1706        ));
1707
1708        let bodies = seen.lock().expect("seen mutex");
1709        let body = bodies.first().expect("captured OpenAI image request");
1710        assert_eq!(body["model"], "gpt-4.1-mini");
1711        assert_eq!(body["tools"][0]["type"], "image_generation");
1712        assert_eq!(body["tools"][0]["model"], "gpt-image-2");
1713        assert_eq!(body["tools"][0]["size"], "1536x1024");
1714        assert_eq!(body["tools"][0]["quality"], "low");
1715        assert_eq!(body["tools"][0]["output_format"], "png");
1716        assert_eq!(body["tool_choice"], "required");
1717        assert!(
1718            body["instructions"]
1719                .as_str()
1720                .is_some_and(|value| value.contains("Never reply in text"))
1721        );
1722        assert!(body.get("stream").and_then(Value::as_bool) == Some(false));
1723
1724        handle.abort();
1725        Ok(())
1726    }
1727
1728    #[tokio::test]
1729    async fn openai_images_api_executor_sends_output_options()
1730    -> Result<(), Box<dyn std::error::Error>> {
1731        let seen = Arc::new(Mutex::new(Vec::new()));
1732        let response = serde_json::json!({
1733            "created": 1713833628,
1734            "data": [{"b64_json": "data:image/png;base64,aGVsbG8="}]
1735        });
1736        let (base_url, handle) = spawn_openai_image_stub(response, seen.clone()).await;
1737        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
1738        let mut request = image_executor_request_json(images_api_openai_plan_json());
1739        request.model = "gpt-image-1".to_string();
1740
1741        let output = client.execute_image_generation(request).await?;
1742
1743        assert!(matches!(
1744            output.terminal,
1745            ImageOperationTerminalClass::Generated
1746        ));
1747        assert_eq!(
1748            (output.images[0].width, output.images[0].height),
1749            (1536, 1024)
1750        );
1751        let bodies = seen.lock().expect("seen mutex");
1752        let body = bodies.first().expect("captured OpenAI image request");
1753        assert_eq!(body["model"], "gpt-image-1");
1754        assert_eq!(body["size"], "1536x1024");
1755        assert_eq!(body["quality"], "low");
1756        assert_eq!(body["output_format"], "png");
1757
1758        handle.abort();
1759        Ok(())
1760    }
1761
1762    #[tokio::test]
1763    async fn openai_image_executor_sends_provider_params() -> Result<(), Box<dyn std::error::Error>>
1764    {
1765        let seen = Arc::new(Mutex::new(Vec::new()));
1766        let response = serde_json::json!({
1767            "id": "resp_img_1",
1768            "output": [{"type": "image_generation_call", "id": "ig_1", "result": "aGVsbG8="}]
1769        });
1770        let (base_url, handle) = spawn_openai_image_stub(response, seen.clone()).await;
1771        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
1772        let mut plan = hosted_openai_plan_json();
1773        plan["provider_plan"]["provider_params"] = serde_json::json!({
1774            "background": "opaque",
1775            "output_compression": 72,
1776            "moderation": "low",
1777            "action": "generate"
1778        });
1779
1780        client
1781            .execute_image_generation(image_executor_request_json(plan))
1782            .await?;
1783
1784        let bodies = seen.lock().expect("seen mutex");
1785        let body = bodies.first().expect("captured OpenAI image request");
1786        assert_eq!(body["tools"][0]["background"], "opaque");
1787        assert_eq!(body["tools"][0]["output_compression"], 72);
1788        assert_eq!(body["tools"][0]["moderation"], "low");
1789        assert_eq!(body["tools"][0]["action"], "generate");
1790
1791        handle.abort();
1792        Ok(())
1793    }
1794
1795    #[test]
1796    fn test_request_uses_responses_api_endpoint_format() {
1797        let client = OpenAiClient::new("test-key".to_string());
1798        let request = LlmRequest::new(
1799            "gpt-5.4",
1800            vec![Message::User(UserMessage::text("Hello".to_string()))],
1801        );
1802
1803        let body = client.build_request_body(&request).expect("build request");
1804
1805        // Should have "input" not "messages"
1806        assert!(body.get("input").is_some(), "should have 'input' field");
1807        assert!(
1808            body.get("messages").is_none(),
1809            "should NOT have 'messages' field"
1810        );
1811
1812        // Should include reasoning.encrypted_content
1813        let include = body.get("include").expect("should have include");
1814        let include_arr = include.as_array().expect("include should be array");
1815        assert!(
1816            include_arr
1817                .iter()
1818                .any(|v| v.as_str() == Some("reasoning.encrypted_content")),
1819            "should include reasoning.encrypted_content"
1820        );
1821    }
1822
1823    #[tokio::test]
1824    async fn chatgpt_backend_wire_uses_codex_responses_path()
1825    -> Result<(), Box<dyn std::error::Error>> {
1826        let payload = [
1827            r#"data: {"type":"response.output_text.delta","delta":"Hello from ChatGPT"}"#,
1828            r#"data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":4,"output_tokens":3}}}"#,
1829            "data: [DONE]",
1830            "",
1831        ]
1832        .join("\n");
1833        let seen = Arc::new(Mutex::new(Vec::new()));
1834        let (base_url, server) = spawn_chatgpt_stub_server(payload, seen.clone()).await;
1835        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url)
1836            .with_chatgpt_backend_wire();
1837        let request = LlmRequest::new(
1838            "gpt-5.5",
1839            vec![
1840                Message::System(meerkat_core::SystemMessage::new(
1841                    "You are a careful assistant.".to_string(),
1842                )),
1843                Message::User(UserMessage::text("hello".to_string())),
1844            ],
1845        );
1846
1847        let mut stream = client.stream(&request);
1848        let mut deltas = Vec::new();
1849        while let Some(event) = stream.next().await {
1850            match event? {
1851                LlmEvent::TextDelta { delta, .. } => deltas.push(delta),
1852                LlmEvent::Done { .. } => break,
1853                _ => {}
1854            }
1855        }
1856        server.abort();
1857
1858        assert_eq!(deltas, vec!["Hello from ChatGPT"]);
1859        let bodies = seen.lock().expect("seen mutex");
1860        let body = bodies.first().expect("captured ChatGPT backend request");
1861        assert_eq!(body["instructions"], "You are a careful assistant.");
1862        assert_eq!(body["store"], false);
1863        assert_eq!(body["tools"], serde_json::json!([]));
1864        assert_eq!(body["tool_choice"], "auto");
1865        assert_eq!(body["parallel_tool_calls"], false);
1866        assert!(body.get("max_output_tokens").is_none());
1867        let input = body["input"].as_array().expect("input should be array");
1868        assert!(
1869            input
1870                .iter()
1871                .all(|item| item.get("role").and_then(Value::as_str) != Some("system")),
1872            "ChatGPT Codex backend rejects system messages in input; they must be lifted to instructions"
1873        );
1874        Ok(())
1875    }
1876
1877    #[test]
1878    fn chatgpt_backend_wire_supplies_default_instructions_without_system_message() {
1879        let client = OpenAiClient::new("test-key".to_string()).with_chatgpt_backend_wire();
1880        let request = LlmRequest::new(
1881            "gpt-5.4",
1882            vec![Message::User(UserMessage::text("Hello".to_string()))],
1883        );
1884
1885        let body = client.build_request_body(&request).expect("build request");
1886
1887        assert_eq!(body["instructions"], "You are a helpful assistant.");
1888        assert_eq!(body["store"], false);
1889        assert!(body.get("max_output_tokens").is_none());
1890    }
1891
1892    #[test]
1893    fn test_request_input_format_system_message() {
1894        let client = OpenAiClient::new("test-key".to_string());
1895        let request = LlmRequest::new(
1896            "gpt-5.4",
1897            vec![
1898                Message::System(meerkat_core::SystemMessage::new(
1899                    "You are helpful".to_string(),
1900                )),
1901                Message::User(UserMessage::text("Hello".to_string())),
1902            ],
1903        );
1904
1905        let body = client.build_request_body(&request).expect("build request");
1906        let input = body["input"].as_array().expect("input should be array");
1907
1908        // System message should have type: "message"
1909        assert_eq!(input[0]["type"], "message");
1910        assert_eq!(input[0]["role"], "system");
1911        assert_eq!(input[0]["content"], "You are helpful");
1912
1913        // User message
1914        assert_eq!(input[1]["type"], "message");
1915        assert_eq!(input[1]["role"], "user");
1916        assert_eq!(input[1]["content"], "Hello");
1917    }
1918
1919    #[test]
1920    fn test_request_input_format_degrades_video_user_content_to_text() {
1921        let client = OpenAiClient::new("test-key".to_string());
1922        let request = LlmRequest::new(
1923            "gpt-5.4",
1924            vec![Message::User(UserMessage::with_blocks(vec![
1925                ContentBlock::Video {
1926                    media_type: "video/mp4".to_string(),
1927                    duration_ms: 12_000,
1928                    data: meerkat_core::VideoData::Inline {
1929                        data: "AAAA".to_string(),
1930                    },
1931                },
1932            ]))],
1933        );
1934
1935        let body = client.build_request_body(&request).expect("build request");
1936        let input = body["input"].as_array().expect("input should be array");
1937        assert_eq!(input[0]["role"], "user");
1938        let content = input[0]["content"]
1939            .as_array()
1940            .expect("content should be array");
1941        assert_eq!(content[0]["type"], "input_text");
1942        assert_eq!(content[0]["text"], "[video: video/mp4]");
1943    }
1944
1945    #[test]
1946    fn test_request_input_rejects_video_tool_results() {
1947        let err = OpenAiClient::convert_to_responses_input(&[Message::ToolResults {
1948            results: vec![meerkat_core::ToolResult::with_blocks(
1949                "tool_1".to_string(),
1950                vec![ContentBlock::Video {
1951                    media_type: "video/mp4".to_string(),
1952                    duration_ms: 12_000,
1953                    data: meerkat_core::VideoData::Inline {
1954                        data: "AAAA".to_string(),
1955                    },
1956                }],
1957                false,
1958            )],
1959            created_at: meerkat_core::types::message_timestamp_now(),
1960        }])
1961        .expect_err("video tool results should be rejected");
1962
1963        match err {
1964            LlmError::InvalidRequest { message } => {
1965                assert!(message.contains("video blocks are not supported"));
1966            }
1967            other => panic!("unexpected error: {other:?}"),
1968        }
1969    }
1970
1971    #[test]
1972    fn test_request_input_format_tool_call() {
1973        let client = OpenAiClient::new("test-key".to_string());
1974        let tool_args = serde_json::json!({"location": "Tokyo"});
1975        let request = LlmRequest::new(
1976            "gpt-5.4",
1977            vec![
1978                Message::User(UserMessage::text("Weather?".to_string())),
1979                Message::Assistant(meerkat_core::AssistantMessage {
1980                    content: String::new(),
1981                    tool_calls: vec![meerkat_core::ToolCall::new(
1982                        "call_abc123".to_string(),
1983                        "get_weather".to_string(),
1984                        tool_args,
1985                    )],
1986                    stop_reason: StopReason::ToolUse,
1987                    usage: Usage::default(),
1988                    created_at: meerkat_core::types::message_timestamp_now(),
1989                }),
1990            ],
1991        );
1992
1993        let body = client.build_request_body(&request).expect("build request");
1994        let input = body["input"].as_array().expect("input should be array");
1995
1996        // Tool call should be type: "function_call"
1997        assert_eq!(input[1]["type"], "function_call");
1998        assert_eq!(input[1]["call_id"], "call_abc123");
1999        assert_eq!(input[1]["name"], "get_weather");
2000        // arguments should be JSON string
2001        let args_str = input[1]["arguments"]
2002            .as_str()
2003            .expect("arguments should be string");
2004        let parsed_args: Value = serde_json::from_str(args_str).expect("should be valid JSON");
2005        assert_eq!(parsed_args["location"], "Tokyo");
2006    }
2007
2008    #[test]
2009    fn test_request_input_format_tool_result() {
2010        let client = OpenAiClient::new("test-key".to_string());
2011        let request = LlmRequest::new(
2012            "gpt-5.4",
2013            vec![
2014                Message::User(UserMessage::text("Weather?".to_string())),
2015                Message::ToolResults {
2016                    results: vec![meerkat_core::ToolResult::new(
2017                        "call_abc123".to_string(),
2018                        "Sunny, 25C".to_string(),
2019                        false,
2020                    )],
2021                    created_at: meerkat_core::types::message_timestamp_now(),
2022                },
2023            ],
2024        );
2025
2026        let body = client.build_request_body(&request).expect("build request");
2027        let input = body["input"].as_array().expect("input should be array");
2028
2029        // Tool result should be type: "function_call_output"
2030        assert_eq!(input[1]["type"], "function_call_output");
2031        assert_eq!(input[1]["call_id"], "call_abc123");
2032        assert_eq!(input[1]["output"], "Sunny, 25C");
2033    }
2034
2035    #[test]
2036    fn test_tool_definition_format() {
2037        use meerkat_core::ToolDef;
2038        use std::sync::Arc;
2039
2040        let client = OpenAiClient::new("test-key".to_string());
2041        let request = LlmRequest::new(
2042            "gpt-4.1-mini",
2043            vec![Message::User(UserMessage::text("test".to_string()))],
2044        )
2045        .with_tools(vec![Arc::new(ToolDef {
2046            name: "get_weather".into(),
2047            description: "Get weather info".to_string(),
2048            input_schema: serde_json::json!({
2049                "type": "object",
2050                "properties": {
2051                    "location": {"type": "string"}
2052                }
2053            }),
2054            provenance: None,
2055        })]);
2056
2057        let body = client.build_request_body(&request).expect("build request");
2058        let tools = body["tools"].as_array().expect("tools should be array");
2059
2060        // Responses API tool format: name at top level, not nested in "function"
2061        assert_eq!(tools[0]["type"], "function");
2062        assert_eq!(tools[0]["name"], "get_weather");
2063        assert_eq!(tools[0]["description"], "Get weather info");
2064        assert!(tools[0]["parameters"].is_object());
2065        // Should NOT have "function" wrapper
2066        assert!(tools[0].get("function").is_none());
2067    }
2068
2069    #[test]
2070    fn test_request_includes_reasoning_config() {
2071        let client = OpenAiClient::new("test-key".to_string());
2072        let request = LlmRequest::new(
2073            "gpt-5.4",
2074            vec![Message::User(UserMessage::text("test".to_string()))],
2075        );
2076
2077        let body = client.build_request_body(&request).expect("build request");
2078
2079        // Should have reasoning config
2080        let reasoning = body.get("reasoning").expect("should have reasoning");
2081        assert_eq!(reasoning["effort"], "medium");
2082        assert_eq!(reasoning["summary"], "auto");
2083    }
2084
2085    #[test]
2086    fn test_request_reasoning_effort_override() {
2087        let client = OpenAiClient::new("test-key".to_string());
2088        let request = LlmRequest::new(
2089            "gpt-5.4",
2090            vec![Message::User(UserMessage::text("test".to_string()))],
2091        )
2092        .with_openai_tag_merge(|t| {
2093            t.reasoning_effort =
2094                Some(meerkat_core::lifecycle::run_primitive::ReasoningEffort::High);
2095        });
2096
2097        let body = client.build_request_body(&request).expect("build request");
2098
2099        assert_eq!(body["reasoning"]["effort"], "high");
2100    }
2101
2102    #[test]
2103    fn test_request_omits_reasoning_payload_for_non_gpt5_model() {
2104        let client = OpenAiClient::new("test-key".to_string());
2105        let request = LlmRequest::new(
2106            "gpt-4.1-mini",
2107            vec![Message::User(UserMessage::text("test".to_string()))],
2108        );
2109
2110        let body = client.build_request_body(&request).expect("build request");
2111        assert!(body.get("reasoning").is_none());
2112        assert!(body.get("include").is_none());
2113    }
2114
2115    #[test]
2116    fn test_reasoning_effort_ignored_for_non_gpt5_model() {
2117        let client = OpenAiClient::new("test-key".to_string());
2118        let request = LlmRequest::new(
2119            "gpt-4.1-mini",
2120            vec![Message::User(UserMessage::text("test".to_string()))],
2121        )
2122        .with_openai_tag_merge(|t| {
2123            t.reasoning_effort =
2124                Some(meerkat_core::lifecycle::run_primitive::ReasoningEffort::High);
2125        });
2126
2127        let body = client.build_request_body(&request).expect("build request");
2128        assert!(body.get("reasoning").is_none());
2129    }
2130
2131    #[test]
2132    fn test_request_respects_internal_capability_overrides_for_self_hosted_aliases() {
2133        let client = OpenAiClient::new("test-key".to_string());
2134        let request = LlmRequest::new(
2135            "gemma4:e2b",
2136            vec![Message::User(UserMessage::text("test".to_string()))],
2137        )
2138        .with_temperature(0.3)
2139        .with_openai_tag_merge(|t| t.supports_temperature_override = Some(true))
2140        .with_openai_tag_merge(|t| t.supports_reasoning_override = Some(true))
2141        .with_openai_tag_merge(|t| {
2142            t.reasoning_effort =
2143                Some(meerkat_core::lifecycle::run_primitive::ReasoningEffort::High);
2144        });
2145
2146        let body = client.build_request_body(&request).expect("build request");
2147
2148        let temperature = body["temperature"]
2149            .as_f64()
2150            .expect("temperature should be numeric");
2151        assert!((temperature - 0.3).abs() < 1e-6);
2152        assert_eq!(body["reasoning"]["effort"], "high");
2153        assert_eq!(body["reasoning"]["summary"], "auto");
2154    }
2155
2156    // =========================================================================
2157    // BlockAssistant Message Tests
2158    // =========================================================================
2159
2160    #[test]
2161    fn test_request_input_format_block_assistant_text() {
2162        use meerkat_core::BlockAssistantMessage;
2163
2164        let client = OpenAiClient::new("test-key".to_string());
2165        let request = LlmRequest::new(
2166            "gpt-5.4",
2167            vec![
2168                Message::User(UserMessage::text("Hello".to_string())),
2169                Message::BlockAssistant(BlockAssistantMessage {
2170                    blocks: vec![AssistantBlock::Text {
2171                        text: "Hi there!".to_string(),
2172                        meta: None,
2173                    }],
2174                    stop_reason: StopReason::EndTurn,
2175                    created_at: meerkat_core::types::message_timestamp_now(),
2176                }),
2177            ],
2178        );
2179
2180        let body = client.build_request_body(&request).expect("build request");
2181        let input = body["input"].as_array().expect("input should be array");
2182
2183        assert_eq!(input[1]["type"], "message");
2184        assert_eq!(input[1]["role"], "assistant");
2185        assert_eq!(input[1]["content"], "Hi there!");
2186    }
2187
2188    #[test]
2189    fn test_request_input_format_block_assistant_reasoning_with_output_skips_reasoning_replay() {
2190        use meerkat_core::BlockAssistantMessage;
2191
2192        let client = OpenAiClient::new("test-key".to_string());
2193        let request = LlmRequest::new(
2194            "gpt-5.4",
2195            vec![
2196                Message::User(UserMessage::text("Hello".to_string())),
2197                Message::BlockAssistant(BlockAssistantMessage {
2198                    blocks: vec![
2199                        AssistantBlock::Reasoning {
2200                            text: "Let me think about this".to_string(),
2201                            meta: Some(Box::new(ProviderMeta::OpenAi {
2202                                id: "rs_abc123".to_string(),
2203                                encrypted_content: Some("encrypted_data".to_string()),
2204                            })),
2205                        },
2206                        AssistantBlock::Text {
2207                            text: "Here is my answer".to_string(),
2208                            meta: None,
2209                        },
2210                    ],
2211                    stop_reason: StopReason::EndTurn,
2212                    created_at: meerkat_core::types::message_timestamp_now(),
2213                }),
2214            ],
2215        );
2216
2217        let body = client.build_request_body(&request).expect("build request");
2218        let input = body["input"].as_array().expect("input should be array");
2219
2220        // Reasoning is intentionally not replayed; assistant text remains.
2221        assert_eq!(input.len(), 2);
2222        assert_eq!(input[1]["type"], "message");
2223        assert_eq!(input[1]["role"], "assistant");
2224    }
2225
2226    #[test]
2227    fn test_request_input_format_block_assistant_tool_use() {
2228        use meerkat_core::BlockAssistantMessage;
2229
2230        let client = OpenAiClient::new("test-key".to_string());
2231        let args = RawValue::from_string(r#"{"location":"Tokyo"}"#.to_string()).unwrap();
2232        let request = LlmRequest::new(
2233            "gpt-5.4",
2234            vec![
2235                Message::User(UserMessage::text("Weather?".to_string())),
2236                Message::BlockAssistant(BlockAssistantMessage {
2237                    blocks: vec![AssistantBlock::ToolUse {
2238                        id: "call_xyz".to_string(),
2239                        name: "get_weather".into(),
2240                        args,
2241                        meta: None,
2242                    }],
2243                    stop_reason: StopReason::ToolUse,
2244                    created_at: meerkat_core::types::message_timestamp_now(),
2245                }),
2246            ],
2247        );
2248
2249        let body = client.build_request_body(&request).expect("build request");
2250        let input = body["input"].as_array().expect("input should be array");
2251
2252        assert_eq!(input[1]["type"], "function_call");
2253        assert_eq!(input[1]["call_id"], "call_xyz");
2254        assert_eq!(input[1]["name"], "get_weather");
2255        // arguments should be the raw JSON string
2256        let args_str = input[1]["arguments"]
2257            .as_str()
2258            .expect("arguments should be string");
2259        assert_eq!(args_str, r#"{"location":"Tokyo"}"#);
2260    }
2261
2262    // =========================================================================
2263    // Provider Parameters Tests
2264    // =========================================================================
2265
2266    #[test]
2267    fn test_request_includes_seed_from_provider_params() {
2268        let client = OpenAiClient::new("test-key".to_string());
2269        let request = LlmRequest::new(
2270            "gpt-5.4",
2271            vec![Message::User(UserMessage::text("test".to_string()))],
2272        )
2273        .with_openai_tag_merge(|t| t.seed = Some(12345));
2274
2275        let body = client.build_request_body(&request).expect("build request");
2276
2277        assert_eq!(body["seed"], 12345);
2278    }
2279
2280    #[test]
2281    fn test_request_includes_frequency_penalty_from_provider_params() {
2282        let client = OpenAiClient::new("test-key".to_string());
2283        let request = LlmRequest::new(
2284            "gpt-5.4",
2285            vec![Message::User(UserMessage::text("test".to_string()))],
2286        )
2287        .with_openai_tag_merge(|t| t.frequency_penalty = Some(0.5));
2288
2289        let body = client.build_request_body(&request).expect("build request");
2290
2291        let fp = body["frequency_penalty"].as_f64().expect("fp numeric");
2292        assert!((fp - 0.5).abs() < 1e-6, "fp drift: {fp}");
2293    }
2294
2295    #[test]
2296    fn test_request_includes_presence_penalty_from_provider_params() {
2297        let client = OpenAiClient::new("test-key".to_string());
2298        let request = LlmRequest::new(
2299            "gpt-5.4",
2300            vec![Message::User(UserMessage::text("test".to_string()))],
2301        )
2302        .with_openai_tag_merge(|t| t.presence_penalty = Some(0.8));
2303
2304        let body = client.build_request_body(&request).expect("build request");
2305
2306        let pp = body["presence_penalty"].as_f64().expect("pp numeric");
2307        assert!((pp - 0.8).abs() < 1e-6, "pp drift: {pp}");
2308    }
2309
2310    #[test]
2311    fn test_request_omits_temperature_for_gpt5_family() {
2312        let client = OpenAiClient::new("test-key".to_string());
2313        let request = LlmRequest::new(
2314            "gpt-5.2-codex",
2315            vec![Message::User(UserMessage::text("test".to_string()))],
2316        )
2317        .with_temperature(0.2);
2318
2319        let body = client.build_request_body(&request).expect("build request");
2320        assert!(
2321            body.get("temperature").is_none(),
2322            "gpt-5/codex requests should not include temperature"
2323        );
2324    }
2325
2326    #[test]
2327    fn test_request_includes_temperature_for_supported_model() {
2328        let client = OpenAiClient::new("test-key".to_string());
2329        let request = LlmRequest::new(
2330            "gpt-realtime",
2331            vec![Message::User(UserMessage::text("test".to_string()))],
2332        )
2333        .with_temperature(0.3);
2334
2335        let body = client.build_request_body(&request).expect("build request");
2336        let temp = body["temperature"]
2337            .as_f64()
2338            .expect("temperature should be numeric");
2339        assert!((temp - 0.3).abs() < 1e-6);
2340    }
2341
2342    #[test]
2343    fn test_multiple_provider_params_combined() {
2344        let client = OpenAiClient::new("test-key".to_string());
2345        let request = LlmRequest::new(
2346            "gpt-5.4",
2347            vec![Message::User(UserMessage::text("test".to_string()))],
2348        )
2349        .with_openai_tag_merge(|t| {
2350            t.reasoning_effort =
2351                Some(meerkat_core::lifecycle::run_primitive::ReasoningEffort::High);
2352        })
2353        .with_openai_tag_merge(|t| t.seed = Some(999))
2354        .with_openai_tag_merge(|t| t.frequency_penalty = Some(0.3))
2355        .with_openai_tag_merge(|t| t.presence_penalty = Some(0.4));
2356
2357        let body = client.build_request_body(&request).expect("build request");
2358
2359        assert_eq!(body["reasoning"]["effort"], "high");
2360        assert_eq!(body["seed"], 999);
2361        let fp = body["frequency_penalty"].as_f64().expect("fp numeric");
2362        assert!((fp - 0.3).abs() < 1e-6, "fp drift: {fp}");
2363        let pp = body["presence_penalty"].as_f64().expect("pp numeric");
2364        assert!((pp - 0.4).abs() < 1e-6, "pp drift: {pp}");
2365    }
2366
2367    #[test]
2368    fn test_tool_args_serialization_no_double_encoding() -> Result<(), Box<dyn std::error::Error>> {
2369        let client = OpenAiClient::new("test-key".to_string());
2370
2371        let tool_args = serde_json::json!({"city": "Tokyo", "units": "celsius"});
2372        let request = LlmRequest::new(
2373            "gpt-5.4",
2374            vec![
2375                Message::User(UserMessage::text("What's the weather?".to_string())),
2376                Message::Assistant(meerkat_core::AssistantMessage {
2377                    content: String::new(),
2378                    tool_calls: vec![meerkat_core::ToolCall::new(
2379                        "call_123".to_string(),
2380                        "get_weather".to_string(),
2381                        tool_args,
2382                    )],
2383                    stop_reason: StopReason::ToolUse,
2384                    usage: Usage::default(),
2385                    created_at: meerkat_core::types::message_timestamp_now(),
2386                }),
2387            ],
2388        );
2389
2390        let body = client.build_request_body(&request).expect("build request");
2391
2392        let input = body["input"].as_array().ok_or("not array")?;
2393        let tool_call = input
2394            .iter()
2395            .find(|item| item["type"] == "function_call")
2396            .ok_or("no tool call")?;
2397        let arguments = tool_call["arguments"].as_str().ok_or("not string")?;
2398
2399        let parsed: serde_json::Value = serde_json::from_str(arguments)?;
2400
2401        assert_eq!(parsed["city"], "Tokyo");
2402        assert_eq!(parsed["units"], "celsius");
2403
2404        assert!(
2405            !arguments.starts_with(r"{\"),
2406            "arguments should not be double-encoded: {arguments}"
2407        );
2408        Ok(())
2409    }
2410
2411    // =========================================================================
2412    // Structured Output Tests
2413    // =========================================================================
2414
2415    #[test]
2416    fn test_build_request_body_with_structured_output() {
2417        let client = OpenAiClient::new("test-key".to_string());
2418
2419        let schema = serde_json::json!({
2420            "type": "object",
2421            "properties": {
2422                "name": {"type": "string"},
2423                "age": {"type": "integer"}
2424            },
2425            "required": ["name", "age"]
2426        });
2427
2428        let request = LlmRequest::new(
2429            "gpt-5.4",
2430            vec![Message::User(UserMessage::text("test".to_string()))],
2431        )
2432        .with_openai_tag_merge(|t| {
2433            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2434                "schema": schema,
2435                "name": "person",
2436                "strict": true
2437            }))
2438            .ok();
2439        });
2440
2441        let body = client.build_request_body(&request).expect("build request");
2442
2443        // Responses API uses "text.format" for structured output
2444        let text = body.get("text").expect("should have text");
2445        let format = text.get("format").expect("should have format");
2446        assert_eq!(format["type"], "json_schema");
2447        assert_eq!(format["name"], "person");
2448        assert_eq!(format["strict"], true);
2449        assert!(format["schema"].is_object());
2450    }
2451
2452    #[test]
2453    fn test_build_request_body_with_structured_output_defaults() {
2454        let client = OpenAiClient::new("test-key".to_string());
2455
2456        let schema = serde_json::json!({"type": "object"});
2457
2458        let request = LlmRequest::new(
2459            "gpt-5.4",
2460            vec![Message::User(UserMessage::text("test".to_string()))],
2461        )
2462        .with_openai_tag_merge(|t| {
2463            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2464                "schema": schema
2465            }))
2466            .ok();
2467        });
2468
2469        let body = client.build_request_body(&request).expect("build request");
2470
2471        let format = &body["text"]["format"];
2472        assert_eq!(format["name"], "output"); // default name
2473        assert_eq!(format["strict"], false); // default strict
2474    }
2475
2476    #[test]
2477    fn test_build_request_body_without_structured_output() {
2478        let client = OpenAiClient::new("test-key".to_string());
2479
2480        let request = LlmRequest::new(
2481            "gpt-5.4",
2482            vec![Message::User(UserMessage::text("test".to_string()))],
2483        );
2484
2485        let body = client.build_request_body(&request).expect("build request");
2486
2487        // text field should not be present
2488        assert!(
2489            body.get("text").is_none(),
2490            "text should not be present without structured_output"
2491        );
2492    }
2493
2494    #[test]
2495    fn test_strict_structured_output_injects_additional_properties_recursively() {
2496        let client = OpenAiClient::new("test-key".to_string());
2497
2498        let schema = serde_json::json!({
2499            "type": "object",
2500            "properties": {
2501                "name": {"type": "string"},
2502                "profile": {
2503                    "type": "object",
2504                    "properties": {
2505                        "city": {"type": "string"}
2506                    }
2507                },
2508                "addresses": {
2509                    "type": "array",
2510                    "items": {
2511                        "type": "object",
2512                        "properties": {
2513                            "street": {"type": "string"}
2514                        }
2515                    }
2516                },
2517                "choice": {
2518                    "anyOf": [
2519                        {
2520                            "type": "object",
2521                            "properties": {
2522                                "kind": {"type": "string"}
2523                            }
2524                        },
2525                        {"type": "string"}
2526                    ]
2527                }
2528            },
2529            "required": ["name", "profile", "addresses"]
2530        });
2531
2532        let request = LlmRequest::new(
2533            "gpt-5.4",
2534            vec![Message::User(UserMessage::text("test".to_string()))],
2535        )
2536        .with_openai_tag_merge(|t| {
2537            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2538                "schema": schema,
2539                "name": "person",
2540                "strict": true
2541            }))
2542            .ok();
2543        });
2544
2545        let body = client.build_request_body(&request).expect("build request");
2546        let compiled = &body["text"]["format"]["schema"];
2547
2548        assert_eq!(compiled["additionalProperties"], false);
2549        assert_eq!(
2550            compiled["properties"]["profile"]["additionalProperties"],
2551            false
2552        );
2553        assert_eq!(
2554            compiled["properties"]["addresses"]["items"]["additionalProperties"],
2555            false
2556        );
2557        assert_eq!(
2558            compiled["properties"]["choice"]["anyOf"][0]["additionalProperties"],
2559            false
2560        );
2561    }
2562
2563    #[test]
2564    fn test_strict_structured_output_preserves_explicit_additional_properties() {
2565        let client = OpenAiClient::new("test-key".to_string());
2566
2567        let schema = serde_json::json!({
2568            "type": "object",
2569            "additionalProperties": true,
2570            "properties": {
2571                "nested": {
2572                    "type": "object",
2573                    "additionalProperties": {"type": "string"},
2574                    "properties": {
2575                        "x": {"type": "string"}
2576                    }
2577                },
2578                "auto": {
2579                    "type": "object",
2580                    "properties": {
2581                        "y": {"type": "integer"}
2582                    }
2583                }
2584            }
2585        });
2586
2587        let request = LlmRequest::new(
2588            "gpt-5.4",
2589            vec![Message::User(UserMessage::text("test".to_string()))],
2590        )
2591        .with_openai_tag_merge(|t| {
2592            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2593                "schema": schema,
2594                "strict": true
2595            }))
2596            .ok();
2597        });
2598
2599        let body = client.build_request_body(&request).expect("build request");
2600        let compiled = &body["text"]["format"]["schema"];
2601
2602        assert_eq!(compiled["additionalProperties"], true);
2603        assert_eq!(
2604            compiled["properties"]["nested"]["additionalProperties"],
2605            serde_json::json!({"type": "string"})
2606        );
2607        assert_eq!(
2608            compiled["properties"]["auto"]["additionalProperties"],
2609            false
2610        );
2611    }
2612
2613    #[test]
2614    fn test_non_strict_structured_output_does_not_inject_additional_properties() {
2615        let client = OpenAiClient::new("test-key".to_string());
2616
2617        let schema = serde_json::json!({
2618            "type": "object",
2619            "properties": {
2620                "nested": {
2621                    "type": "object",
2622                    "properties": {
2623                        "x": {"type": "string"}
2624                    }
2625                }
2626            }
2627        });
2628
2629        let request = LlmRequest::new(
2630            "gpt-5.4",
2631            vec![Message::User(UserMessage::text("test".to_string()))],
2632        )
2633        .with_openai_tag_merge(|t| {
2634            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2635                "schema": schema,
2636                "strict": false
2637            }))
2638            .ok();
2639        });
2640
2641        let body = client.build_request_body(&request).expect("build request");
2642        let compiled = &body["text"]["format"]["schema"];
2643
2644        assert!(
2645            compiled.get("additionalProperties").is_none(),
2646            "root should not be modified in non-strict mode"
2647        );
2648        assert!(
2649            compiled["properties"]["nested"]
2650                .get("additionalProperties")
2651                .is_none(),
2652            "nested object should not be modified in non-strict mode"
2653        );
2654    }
2655
2656    #[test]
2657    fn test_compile_schema_strict_handles_object_union_and_defs() {
2658        let client = OpenAiClient::new("test-key".to_string());
2659        let schema = serde_json::json!({
2660            "type": ["object", "null"],
2661            "$defs": {
2662                "Meta": {
2663                    "type": "object",
2664                    "properties": {
2665                        "id": {"type": "string"}
2666                    }
2667                }
2668            },
2669            "properties": {
2670                "meta": {"$ref": "#/$defs/Meta"},
2671                "items": {
2672                    "type": "array",
2673                    "items": {
2674                        "type": ["object", "null"],
2675                        "properties": {
2676                            "value": {"type": "number"}
2677                        }
2678                    }
2679                }
2680            }
2681        });
2682        let output_schema = OutputSchema::new(schema).expect("valid schema").strict();
2683        let compiled = client
2684            .compile_schema(&output_schema)
2685            .expect("compile should succeed");
2686
2687        assert!(compiled.warnings.is_empty());
2688        assert_eq!(compiled.schema["additionalProperties"], false);
2689        assert_eq!(
2690            compiled.schema["$defs"]["Meta"]["additionalProperties"],
2691            false
2692        );
2693        assert_eq!(
2694            compiled.schema["properties"]["items"]["items"]["additionalProperties"],
2695            false
2696        );
2697    }
2698
2699    #[test]
2700    fn test_compile_schema_strict_keeps_explicit_additional_properties_forms() {
2701        let client = OpenAiClient::new("test-key".to_string());
2702        let schema = serde_json::json!({
2703            "type": "object",
2704            "additionalProperties": {"type": "integer"},
2705            "properties": {
2706                "a": {"type": "string"},
2707                "b": {
2708                    "type": "object",
2709                    "additionalProperties": true,
2710                    "properties": {
2711                        "x": {"type": "string"}
2712                    }
2713                }
2714            }
2715        });
2716        let output_schema = OutputSchema::new(schema).expect("valid schema").strict();
2717        let compiled = client
2718            .compile_schema(&output_schema)
2719            .expect("compile should succeed");
2720
2721        assert!(compiled.warnings.is_empty());
2722        assert_eq!(
2723            compiled.schema["additionalProperties"],
2724            serde_json::json!({"type": "integer"})
2725        );
2726        assert_eq!(
2727            compiled.schema["properties"]["b"]["additionalProperties"],
2728            true
2729        );
2730    }
2731
2732    // =========================================================================
2733    // SSE Parsing Tests
2734    // =========================================================================
2735
2736    #[test]
2737    fn test_parse_responses_sse_line_text_delta() {
2738        let line = r#"data: {"type":"response.output_text.delta","delta":"Hello"}"#;
2739        let event = OpenAiClient::parse_responses_sse_line(line);
2740        assert!(event.is_some());
2741        let event = event.unwrap();
2742        assert_eq!(event.event_type, "response.output_text.delta");
2743        assert_eq!(event.delta, Some("Hello".to_string()));
2744    }
2745
2746    #[test]
2747    fn test_parse_responses_sse_line_reasoning_delta() {
2748        let line =
2749            r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking..."}"#;
2750        let event = OpenAiClient::parse_responses_sse_line(line);
2751        assert!(event.is_some());
2752        let event = event.unwrap();
2753        assert_eq!(event.event_type, "response.reasoning_summary_text.delta");
2754        assert_eq!(event.delta, Some("thinking...".to_string()));
2755    }
2756
2757    #[test]
2758    fn test_parse_responses_sse_line_function_call_done() {
2759        let line = r#"data: {"type":"response.function_call_arguments.done","call_id":"call_123","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}"#;
2760        let event = OpenAiClient::parse_responses_sse_line(line);
2761        assert!(event.is_some());
2762        let event = event.unwrap();
2763        assert_eq!(event.event_type, "response.function_call_arguments.done");
2764        assert_eq!(event.call_id, Some("call_123".to_string()));
2765        assert_eq!(event.name, Some("get_weather".to_string()));
2766        assert_eq!(event.arguments, Some(r#"{"location":"Tokyo"}"#.to_string()));
2767    }
2768
2769    #[test]
2770    fn test_parse_responses_sse_line_response_done() {
2771        let line = r#"data: {"type":"response.done","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Hi"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#;
2772        let event = OpenAiClient::parse_responses_sse_line(line);
2773        assert!(event.is_some());
2774        let event = event.unwrap();
2775        assert_eq!(event.event_type, "response.done");
2776        assert!(event.response.is_some());
2777        let response = event.response.unwrap();
2778        assert_eq!(response["status"], "completed");
2779    }
2780
2781    #[test]
2782    fn test_parse_responses_sse_line_done_marker() {
2783        let line = "data: [DONE]";
2784        let event = OpenAiClient::parse_responses_sse_line(line);
2785        assert!(event.is_none());
2786    }
2787
2788    #[test]
2789    fn test_parse_responses_sse_line_without_trailing_space() {
2790        let line = r#"data:{"type":"response.output_text.delta","delta":"hello"}"#;
2791        let event = OpenAiClient::parse_responses_sse_line(line);
2792        assert!(event.is_some());
2793    }
2794
2795    #[test]
2796    fn test_parse_responses_sse_line_non_data_line() {
2797        let line = "event: message";
2798        let event = OpenAiClient::parse_responses_sse_line(line);
2799        assert!(event.is_none());
2800    }
2801
2802    #[test]
2803    fn test_parse_responses_sse_line_reasoning_item_with_encrypted() {
2804        let line = r#"data: {"type":"response.reasoning.done","item":{"id":"rs_abc123","summary":[{"type":"summary_text","text":"I need to think"}],"encrypted_content":"enc_xyz"}}"#;
2805        let event = OpenAiClient::parse_responses_sse_line(line);
2806        assert!(event.is_some());
2807        let event = event.unwrap();
2808        assert_eq!(event.event_type, "response.reasoning.done");
2809        let item = event.item.expect("should have item");
2810        assert_eq!(item["id"], "rs_abc123");
2811        assert_eq!(item["encrypted_content"], "enc_xyz");
2812        let summary = item["summary"].as_array().expect("summary array");
2813        assert_eq!(summary[0]["text"], "I need to think");
2814    }
2815
2816    #[tokio::test]
2817    async fn test_stream_does_not_duplicate_text_when_completed_replays_output() {
2818        let payload = [
2819            r#"data: {"type":"response.output_text.delta","delta":"Hello"}"#,
2820            r#"data: {"type":"response.completed","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
2821            r#"data: {"type":"response.done","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
2822            "data: [DONE]",
2823            "",
2824        ]
2825        .join("\n");
2826        let (base_url, server) = spawn_openai_stub_server(payload).await;
2827        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
2828        let request = LlmRequest::new(
2829            "gpt-5-mini",
2830            vec![Message::User(UserMessage::text("hello".to_string()))],
2831        );
2832
2833        let mut stream = client.stream(&request);
2834        let mut deltas = Vec::new();
2835        while let Some(event) = stream.next().await {
2836            match event.expect("stream event") {
2837                LlmEvent::TextDelta { delta, .. } => deltas.push(delta),
2838                LlmEvent::Done { .. } => break,
2839                _ => {}
2840            }
2841        }
2842        server.abort();
2843
2844        assert_eq!(deltas, vec!["Hello"]);
2845    }
2846
2847    // =========================================================================
2848    // Response Parsing Tests
2849    // =========================================================================
2850
2851    #[test]
2852    fn test_response_completed_parses_message_content() {
2853        // Test that response.completed event correctly parses message content array
2854        let response_json = serde_json::json!({
2855            "status": "completed",
2856            "output": [
2857                {
2858                    "type": "message",
2859                    "content": [
2860                        {"type": "output_text", "text": "Hello"},
2861                        {"type": "output_text", "text": " World"}
2862                    ]
2863                }
2864            ],
2865            "usage": {"input_tokens": 10, "output_tokens": 5}
2866        });
2867
2868        // Verify structure matches what we expect to parse
2869        let output = response_json["output"].as_array().expect("output array");
2870        assert_eq!(output[0]["type"], "message");
2871        let content = output[0]["content"].as_array().expect("content array");
2872        assert_eq!(content[0]["type"], "output_text");
2873        assert_eq!(content[0]["text"], "Hello");
2874    }
2875
2876    #[test]
2877    fn test_response_completed_parses_reasoning_item() {
2878        let response_json = serde_json::json!({
2879            "status": "completed",
2880            "output": [
2881                {
2882                    "type": "reasoning",
2883                    "id": "rs_abc123",
2884                    "summary": [
2885                        {"type": "summary_text", "text": "Let me think about this"}
2886                    ],
2887                    "encrypted_content": "encrypted_stuff_here"
2888                }
2889            ],
2890            "usage": {"input_tokens": 10, "output_tokens": 5}
2891        });
2892
2893        let output = response_json["output"].as_array().expect("output array");
2894        let reasoning = &output[0];
2895        assert_eq!(reasoning["type"], "reasoning");
2896        assert_eq!(reasoning["id"], "rs_abc123");
2897        assert_eq!(reasoning["encrypted_content"], "encrypted_stuff_here");
2898        let summary = reasoning["summary"].as_array().expect("summary array");
2899        assert_eq!(summary[0]["text"], "Let me think about this");
2900    }
2901
2902    #[test]
2903    fn test_response_completed_parses_function_call() {
2904        let response_json = serde_json::json!({
2905            "status": "completed",
2906            "output": [
2907                {
2908                    "type": "function_call",
2909                    "call_id": "call_xyz789",
2910                    "name": "get_weather",
2911                    "arguments": "{\"location\":\"Tokyo\"}"
2912                }
2913            ],
2914            "usage": {"input_tokens": 10, "output_tokens": 5}
2915        });
2916
2917        let output = response_json["output"].as_array().expect("output array");
2918        let func_call = &output[0];
2919        assert_eq!(func_call["type"], "function_call");
2920        assert_eq!(func_call["call_id"], "call_xyz789");
2921        assert_eq!(func_call["name"], "get_weather");
2922        // arguments is a JSON STRING
2923        let args_str = func_call["arguments"].as_str().expect("string");
2924        let args: Value = serde_json::from_str(args_str).expect("valid json");
2925        assert_eq!(args["location"], "Tokyo");
2926    }
2927
2928    // =========================================================================
2929    // Orphaned Reasoning Stripping Tests
2930    // =========================================================================
2931
2932    #[test]
2933    fn test_orphaned_reasoning_at_end_is_stripped() {
2934        use meerkat_core::BlockAssistantMessage;
2935
2936        let client = OpenAiClient::new("test-key".to_string());
2937        let request = LlmRequest::new(
2938            "gpt-5.4",
2939            vec![
2940                Message::User(UserMessage::text("Hello".to_string())),
2941                // Reasoning-only response (e.g., stream interrupted after reasoning)
2942                Message::BlockAssistant(BlockAssistantMessage {
2943                    blocks: vec![AssistantBlock::Reasoning {
2944                        text: "Let me think".to_string(),
2945                        meta: Some(Box::new(ProviderMeta::OpenAi {
2946                            id: "rs_orphan".to_string(),
2947                            encrypted_content: None,
2948                        })),
2949                    }],
2950                    stop_reason: StopReason::EndTurn,
2951                    created_at: meerkat_core::types::message_timestamp_now(),
2952                }),
2953            ],
2954        );
2955
2956        let body = client.build_request_body(&request).expect("build request");
2957        let input = body["input"].as_array().expect("input should be array");
2958
2959        // Orphaned reasoning should be stripped, leaving only the user message
2960        assert_eq!(input.len(), 1);
2961        assert_eq!(input[0]["type"], "message");
2962        assert_eq!(input[0]["role"], "user");
2963    }
2964
2965    #[test]
2966    fn test_orphaned_reasoning_before_user_message_is_stripped() {
2967        use meerkat_core::BlockAssistantMessage;
2968
2969        let client = OpenAiClient::new("test-key".to_string());
2970        let request = LlmRequest::new(
2971            "gpt-5.4",
2972            vec![
2973                Message::User(UserMessage::text("First question".to_string())),
2974                // Reasoning without output, followed by next user turn
2975                Message::BlockAssistant(BlockAssistantMessage {
2976                    blocks: vec![AssistantBlock::Reasoning {
2977                        text: "Thinking...".to_string(),
2978                        meta: Some(Box::new(ProviderMeta::OpenAi {
2979                            id: "rs_mid".to_string(),
2980                            encrypted_content: None,
2981                        })),
2982                    }],
2983                    stop_reason: StopReason::EndTurn,
2984                    created_at: meerkat_core::types::message_timestamp_now(),
2985                }),
2986                Message::User(UserMessage::text("Second question".to_string())),
2987            ],
2988        );
2989
2990        let body = client.build_request_body(&request).expect("build request");
2991        let input = body["input"].as_array().expect("input should be array");
2992
2993        // Reasoning followed by user message (not assistant output) should be stripped
2994        assert_eq!(input.len(), 2);
2995        assert_eq!(input[0]["role"], "user");
2996        assert_eq!(input[0]["content"], "First question");
2997        assert_eq!(input[1]["role"], "user");
2998        assert_eq!(input[1]["content"], "Second question");
2999    }
3000
3001    #[test]
3002    fn test_orphaned_reasoning_before_tool_result_is_stripped() {
3003        use meerkat_core::BlockAssistantMessage;
3004
3005        let client = OpenAiClient::new("test-key".to_string());
3006        let request = LlmRequest::new(
3007            "gpt-5.4",
3008            vec![
3009                Message::User(UserMessage::text("Hello".to_string())),
3010                // Reasoning-only at end of one assistant message
3011                Message::BlockAssistant(BlockAssistantMessage {
3012                    blocks: vec![AssistantBlock::Reasoning {
3013                        text: "Thinking...".to_string(),
3014                        meta: Some(Box::new(ProviderMeta::OpenAi {
3015                            id: "rs_before_tool".to_string(),
3016                            encrypted_content: None,
3017                        })),
3018                    }],
3019                    stop_reason: StopReason::EndTurn,
3020                    created_at: meerkat_core::types::message_timestamp_now(),
3021                }),
3022                // Next message is tool results — not a valid follower for reasoning
3023                Message::ToolResults {
3024                    results: vec![meerkat_core::ToolResult::new(
3025                        "call_123".to_string(),
3026                        "result".to_string(),
3027                        false,
3028                    )],
3029                    created_at: meerkat_core::types::message_timestamp_now(),
3030                },
3031            ],
3032        );
3033
3034        let body = client.build_request_body(&request).expect("build request");
3035        let input = body["input"].as_array().expect("input should be array");
3036
3037        // Reasoning should be stripped; user message + tool result remain
3038        assert_eq!(input.len(), 2);
3039        assert_eq!(input[0]["type"], "message");
3040        assert_eq!(input[1]["type"], "function_call_output");
3041    }
3042
3043    #[test]
3044    fn test_reasoning_followed_by_function_call_skips_reasoning_replay() {
3045        use meerkat_core::BlockAssistantMessage;
3046
3047        let client = OpenAiClient::new("test-key".to_string());
3048        let args = RawValue::from_string(r#"{"q":"test"}"#.to_string()).unwrap();
3049        let request = LlmRequest::new(
3050            "gpt-5.4",
3051            vec![
3052                Message::User(UserMessage::text("Hello".to_string())),
3053                Message::BlockAssistant(BlockAssistantMessage {
3054                    blocks: vec![
3055                        AssistantBlock::Reasoning {
3056                            text: "I should search".to_string(),
3057                            meta: Some(Box::new(ProviderMeta::OpenAi {
3058                                id: "rs_valid".to_string(),
3059                                encrypted_content: Some("enc_valid".to_string()),
3060                            })),
3061                        },
3062                        AssistantBlock::ToolUse {
3063                            id: "call_1".to_string(),
3064                            name: "search".into(),
3065                            args,
3066                            meta: None,
3067                        },
3068                    ],
3069                    stop_reason: StopReason::ToolUse,
3070                    created_at: meerkat_core::types::message_timestamp_now(),
3071                }),
3072            ],
3073        );
3074
3075        let body = client.build_request_body(&request).expect("build request");
3076        let input = body["input"].as_array().expect("input should be array");
3077
3078        // Reasoning is intentionally not replayed; function_call remains.
3079        assert_eq!(input.len(), 2);
3080        assert_eq!(input[1]["type"], "function_call");
3081    }
3082
3083    #[test]
3084    fn test_non_openai_reasoning_blocks_are_skipped() {
3085        use meerkat_core::BlockAssistantMessage;
3086
3087        let client = OpenAiClient::new("test-key".to_string());
3088        let request = LlmRequest::new(
3089            "gpt-5.4",
3090            vec![
3091                Message::User(UserMessage::text("Hello".to_string())),
3092                Message::BlockAssistant(BlockAssistantMessage {
3093                    blocks: vec![
3094                        AssistantBlock::Reasoning {
3095                            text: "Anthropic thinking".to_string(),
3096                            meta: Some(Box::new(ProviderMeta::Anthropic {
3097                                signature: "sig_abc".to_string(),
3098                            })),
3099                        },
3100                        AssistantBlock::Text {
3101                            text: "Answer".to_string(),
3102                            meta: None,
3103                        },
3104                    ],
3105                    stop_reason: StopReason::EndTurn,
3106                    created_at: meerkat_core::types::message_timestamp_now(),
3107                }),
3108            ],
3109        );
3110
3111        let body = client.build_request_body(&request).expect("build request");
3112        let input = body["input"].as_array().expect("input should be array");
3113
3114        // Non-OpenAI reasoning is not serialized at all, only text remains
3115        assert_eq!(input.len(), 2);
3116        assert_eq!(input[0]["type"], "message");
3117        assert_eq!(input[0]["role"], "user");
3118        assert_eq!(input[1]["type"], "message");
3119        assert_eq!(input[1]["role"], "assistant");
3120    }
3121
3122    #[test]
3123    fn test_consecutive_orphaned_reasoning_items_all_stripped() {
3124        use meerkat_core::BlockAssistantMessage;
3125
3126        let client = OpenAiClient::new("test-key".to_string());
3127        let request = LlmRequest::new(
3128            "gpt-5.4",
3129            vec![
3130                Message::User(UserMessage::text("Hello".to_string())),
3131                // Two consecutive reasoning-only messages (e.g., repeated interruptions)
3132                Message::BlockAssistant(BlockAssistantMessage {
3133                    blocks: vec![AssistantBlock::Reasoning {
3134                        text: "First thought".to_string(),
3135                        meta: Some(Box::new(ProviderMeta::OpenAi {
3136                            id: "rs_first".to_string(),
3137                            encrypted_content: Some("enc_1".to_string()),
3138                        })),
3139                    }],
3140                    stop_reason: StopReason::EndTurn,
3141                    created_at: meerkat_core::types::message_timestamp_now(),
3142                }),
3143                Message::BlockAssistant(BlockAssistantMessage {
3144                    blocks: vec![AssistantBlock::Reasoning {
3145                        text: "Second thought".to_string(),
3146                        meta: Some(Box::new(ProviderMeta::OpenAi {
3147                            id: "rs_second".to_string(),
3148                            encrypted_content: None,
3149                        })),
3150                    }],
3151                    stop_reason: StopReason::EndTurn,
3152                    created_at: meerkat_core::types::message_timestamp_now(),
3153                }),
3154                Message::User(UserMessage::text("Still here".to_string())),
3155            ],
3156        );
3157
3158        let body = client.build_request_body(&request).expect("build request");
3159        let input = body["input"].as_array().expect("input should be array");
3160
3161        // Both orphaned reasoning items stripped, only user messages remain
3162        assert_eq!(input.len(), 2);
3163        assert_eq!(input[0]["content"], "Hello");
3164        assert_eq!(input[1]["content"], "Still here");
3165    }
3166
3167    // =========================================================================
3168    // Response Status Tests
3169    // =========================================================================
3170
3171    #[test]
3172    fn test_stop_reason_from_response_status() {
3173        // completed with tool calls -> ToolUse
3174        let response_tool = serde_json::json!({
3175            "status": "completed",
3176            "output": [{"type": "function_call", "call_id": "1", "name": "x", "arguments": "{}"}]
3177        });
3178        let has_tools = response_tool["output"].as_array().is_some_and(|arr| {
3179            arr.iter()
3180                .any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call"))
3181        });
3182        assert!(has_tools);
3183
3184        // completed without tool calls -> EndTurn
3185        let response_text = serde_json::json!({
3186            "status": "completed",
3187            "output": [{"type": "message", "content": [{"type": "output_text", "text": "Hi"}]}]
3188        });
3189        let has_tools = response_text["output"].as_array().is_some_and(|arr| {
3190            arr.iter()
3191                .any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call"))
3192        });
3193        assert!(!has_tools);
3194    }
3195
3196    // =========================================================================
3197    // Reasoning encrypted_content stripping tests
3198    // =========================================================================
3199
3200    #[test]
3201    fn test_reasoning_without_encrypted_content_is_stripped() {
3202        use meerkat_core::BlockAssistantMessage;
3203
3204        let client = OpenAiClient::new("test-key".to_string());
3205        let args = RawValue::from_string(r#"{"q":"test"}"#.to_string()).unwrap();
3206        let request = LlmRequest::new(
3207            "gpt-5.4",
3208            vec![
3209                Message::User(UserMessage::text("Hello".to_string())),
3210                Message::BlockAssistant(BlockAssistantMessage {
3211                    blocks: vec![
3212                        AssistantBlock::Reasoning {
3213                            text: "I should search".to_string(),
3214                            meta: Some(Box::new(ProviderMeta::OpenAi {
3215                                id: "rs_no_enc".to_string(),
3216                                encrypted_content: None,
3217                            })),
3218                        },
3219                        AssistantBlock::ToolUse {
3220                            id: "call_1".to_string(),
3221                            name: "search".into(),
3222                            args,
3223                            meta: None,
3224                        },
3225                    ],
3226                    stop_reason: StopReason::ToolUse,
3227                    created_at: meerkat_core::types::message_timestamp_now(),
3228                }),
3229            ],
3230        );
3231
3232        let body = client.build_request_body(&request).expect("build request");
3233        let input = body["input"].as_array().expect("input should be array");
3234
3235        // Reasoning without encrypted_content is stripped even with valid follower
3236        assert_eq!(input.len(), 2);
3237        assert_eq!(input[0]["type"], "message");
3238        assert_eq!(input[1]["type"], "function_call");
3239    }
3240
3241    #[test]
3242    fn test_reasoning_with_encrypted_content_is_not_replayed() {
3243        use meerkat_core::BlockAssistantMessage;
3244
3245        let client = OpenAiClient::new("test-key".to_string());
3246        let request = LlmRequest::new(
3247            "gpt-5.4",
3248            vec![
3249                Message::User(UserMessage::text("Hello".to_string())),
3250                Message::BlockAssistant(BlockAssistantMessage {
3251                    blocks: vec![
3252                        AssistantBlock::Reasoning {
3253                            text: "Let me think".to_string(),
3254                            meta: Some(Box::new(ProviderMeta::OpenAi {
3255                                id: "rs_enc".to_string(),
3256                                encrypted_content: Some("enc_data_here".to_string()),
3257                            })),
3258                        },
3259                        AssistantBlock::Text {
3260                            text: "Here is my answer".to_string(),
3261                            meta: None,
3262                        },
3263                    ],
3264                    stop_reason: StopReason::EndTurn,
3265                    created_at: meerkat_core::types::message_timestamp_now(),
3266                }),
3267            ],
3268        );
3269
3270        let body = client.build_request_body(&request).expect("build request");
3271        let input = body["input"].as_array().expect("input should be array");
3272
3273        // Reasoning is intentionally not replayed; assistant text remains.
3274        assert_eq!(input.len(), 2);
3275        assert_eq!(input[1]["type"], "message");
3276    }
3277
3278    // =========================================================================
3279    // Stream deduplication tests
3280    // =========================================================================
3281
3282    #[tokio::test]
3283    async fn test_stream_does_not_duplicate_tool_calls_when_completed_replays() {
3284        let payload = [
3285            // Streaming tool call events
3286            r#"data: {"type":"response.function_call_arguments.delta","call_id":"call_1","name":"get_weather","delta":"{\"loc"}"#,
3287            r#"data: {"type":"response.function_call_arguments.delta","call_id":"call_1","delta":"ation\":\"Tokyo\"}"}"#,
3288            r#"data: {"type":"response.function_call_arguments.done","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}"#,
3289            // response.completed replays the same tool call
3290            r#"data: {"type":"response.completed","response":{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
3291            r#"data: {"type":"response.done","response":{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
3292            "data: [DONE]",
3293            "",
3294        ]
3295        .join("\n");
3296        let (base_url, server) = spawn_openai_stub_server(payload).await;
3297        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
3298        let request = LlmRequest::new(
3299            "gpt-5-mini",
3300            vec![Message::User(UserMessage::text("weather".to_string()))],
3301        );
3302
3303        let mut stream = client.stream(&request);
3304        let mut tool_completes = Vec::new();
3305        while let Some(event) = stream.next().await {
3306            match event.expect("stream event") {
3307                LlmEvent::ToolCallComplete { id, .. } => tool_completes.push(id),
3308                LlmEvent::Done { .. } => break,
3309                _ => {}
3310            }
3311        }
3312        server.abort();
3313
3314        // Should only get one ToolCallComplete, not duplicated from response.completed
3315        assert_eq!(tool_completes.len(), 1);
3316        assert_eq!(tool_completes[0], "call_1");
3317    }
3318
3319    #[tokio::test]
3320    async fn test_stream_malformed_function_call_arguments_done_fails_closed() {
3321        let payload = [
3322            r#"data: {"type":"response.function_call_arguments.done","call_id":"call_bad","name":"get_weather","arguments":"{\"location\":"}"#,
3323            "data: [DONE]",
3324            "",
3325        ]
3326        .join("\n");
3327        let (base_url, server) = spawn_openai_stub_server(payload).await;
3328        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
3329        let request = LlmRequest::new(
3330            "gpt-5-mini",
3331            vec![Message::User(UserMessage::text("weather".to_string()))],
3332        );
3333
3334        let mut stream = client.stream(&request);
3335        let mut tool_completes = 0;
3336        let mut error_done = None;
3337        while let Some(event) = stream.next().await {
3338            match event.expect("stream wrapper should convert errors to Done") {
3339                LlmEvent::ToolCallComplete { .. } => tool_completes += 1,
3340                LlmEvent::Done {
3341                    outcome: LlmDoneOutcome::Error { error },
3342                } => {
3343                    error_done = Some(error);
3344                    break;
3345                }
3346                LlmEvent::Done {
3347                    outcome: LlmDoneOutcome::Success { .. },
3348                } => panic!("malformed tool args must not complete successfully"),
3349                _ => {}
3350            }
3351        }
3352        server.abort();
3353
3354        assert_eq!(tool_completes, 0);
3355        let error = error_done.expect("expected terminal error for malformed args");
3356        assert!(
3357            matches!(error, LlmError::StreamParseError { .. }),
3358            "expected StreamParseError, got: {error:?}"
3359        );
3360        assert!(
3361            error
3362                .to_string()
3363                .contains("invalid OpenAI tool call arguments JSON"),
3364            "error should name invalid OpenAI tool args: {error}"
3365        );
3366    }
3367
3368    #[tokio::test]
3369    async fn test_stream_does_not_duplicate_reasoning_when_completed_replays() {
3370        let payload = [
3371            // Streaming reasoning events
3372            r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking..."}"#,
3373            r#"data: {"type":"response.reasoning.done","item":{"id":"rs_1","summary":[{"type":"summary_text","text":"thinking..."}],"encrypted_content":"enc_xyz"}}"#,
3374            // response.completed replays the same reasoning
3375            r#"data: {"type":"response.completed","response":{"status":"completed","output":[{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"thinking..."}],"encrypted_content":"enc_xyz"},{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
3376            r#"data: {"type":"response.done","response":{"status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
3377            "data: [DONE]",
3378            "",
3379        ]
3380        .join("\n");
3381        let (base_url, server) = spawn_openai_stub_server(payload).await;
3382        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
3383        let request = LlmRequest::new(
3384            "gpt-5-mini",
3385            vec![Message::User(UserMessage::text("hello".to_string()))],
3386        );
3387
3388        let mut stream = client.stream(&request);
3389        let mut reasoning_completes = 0;
3390        while let Some(event) = stream.next().await {
3391            match event.expect("stream event") {
3392                LlmEvent::ReasoningComplete { .. } => reasoning_completes += 1,
3393                LlmEvent::Done { .. } => break,
3394                _ => {}
3395            }
3396        }
3397        server.abort();
3398
3399        // Should only get one ReasoningComplete, not duplicated from response.completed
3400        assert_eq!(reasoning_completes, 1);
3401    }
3402
3403    #[tokio::test]
3404    async fn test_stream_error_event_yields_done_with_error() {
3405        let payload = [
3406            r#"data: {"type":"error","error":{"code":"server_error","message":"Internal server error"}}"#,
3407            "data: [DONE]",
3408            "",
3409        ]
3410        .join("\n");
3411        let (base_url, server) = spawn_openai_stub_server(payload).await;
3412        let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
3413        let request = LlmRequest::new(
3414            "gpt-5-mini",
3415            vec![Message::User(UserMessage::text("hello".to_string()))],
3416        );
3417
3418        let mut stream = client.stream(&request);
3419        let mut saw_error_done = false;
3420        while let Some(event) = stream.next().await {
3421            if let LlmEvent::Done {
3422                outcome: LlmDoneOutcome::Error { error },
3423            } = event.expect("stream event")
3424            {
3425                assert!(
3426                    matches!(error, LlmError::ServerError { status: 500, .. }),
3427                    "expected ServerError, got: {error:?}"
3428                );
3429                let msg = error.to_string();
3430                assert!(
3431                    msg.contains("Internal server error"),
3432                    "error should contain message: {msg}"
3433                );
3434                saw_error_done = true;
3435                break;
3436            }
3437        }
3438        server.abort();
3439
3440        assert!(saw_error_done, "Expected Done with error outcome");
3441    }
3442
3443    // =========================================================================
3444    // Multimodal content (ContentBlock::Image) serialization tests
3445    // =========================================================================
3446
3447    #[test]
3448    fn openai_user_message_with_image() {
3449        use meerkat_core::ContentBlock;
3450
3451        let client = OpenAiClient::new("test-key".to_string());
3452        let request = LlmRequest::new(
3453            "gpt-5.4",
3454            vec![Message::User(UserMessage::with_blocks(vec![
3455                ContentBlock::Text {
3456                    text: "describe this".to_string(),
3457                },
3458                ContentBlock::Image {
3459                    media_type: "image/png".to_string(),
3460                    data: "iVBOR...".into(),
3461                },
3462            ]))],
3463        );
3464
3465        let body = client.build_request_body(&request).expect("build request");
3466        let input = body["input"].as_array().expect("input array");
3467        let user_item = &input[0];
3468
3469        assert_eq!(user_item["type"], "message");
3470        assert_eq!(user_item["role"], "user");
3471
3472        // Content should be an array with typed content parts
3473        let content = user_item["content"].as_array().expect("content array");
3474        assert_eq!(content.len(), 2);
3475
3476        assert_eq!(content[0]["type"], "input_text");
3477        assert_eq!(content[0]["text"], "describe this");
3478
3479        assert_eq!(content[1]["type"], "input_image");
3480        assert_eq!(
3481            content[1]["image_url"], "data:image/png;base64,iVBOR...",
3482            "should be a data URI"
3483        );
3484
3485        // source_path must NOT leak
3486        let body_str = serde_json::to_string(&body).unwrap();
3487        assert!(
3488            !body_str.contains("source_path"),
3489            "source_path must never appear in provider payload"
3490        );
3491        assert!(
3492            !body_str.contains("/tmp/img.png"),
3493            "source_path value must never appear in provider payload"
3494        );
3495    }
3496
3497    #[test]
3498    fn openai_text_only_user_message_stays_string() {
3499        let client = OpenAiClient::new("test-key".to_string());
3500        let request = LlmRequest::new(
3501            "gpt-5.4",
3502            vec![Message::User(UserMessage::text("just text"))],
3503        );
3504
3505        let body = client.build_request_body(&request).expect("build request");
3506        let input = body["input"].as_array().expect("input array");
3507
3508        // Text-only user message content should remain a plain string
3509        assert!(
3510            input[0]["content"].is_string(),
3511            "text-only user message content should be a string"
3512        );
3513        assert_eq!(input[0]["content"], "just text");
3514    }
3515
3516    #[test]
3517    fn openai_tool_result_with_image_degrades_to_text() {
3518        use meerkat_core::ContentBlock;
3519
3520        let client = OpenAiClient::new("test-key".to_string());
3521        let request = LlmRequest::new(
3522            "gpt-5.4",
3523            vec![
3524                Message::User(UserMessage::text("Take a screenshot")),
3525                Message::ToolResults {
3526                    results: vec![meerkat_core::ToolResult::with_blocks(
3527                        "call_1".to_string(),
3528                        vec![
3529                            ContentBlock::Text {
3530                                text: "screenshot taken".to_string(),
3531                            },
3532                            ContentBlock::Image {
3533                                media_type: "image/png".to_string(),
3534                                data: "iVBOR...".into(),
3535                            },
3536                        ],
3537                        false,
3538                    )],
3539                    created_at: meerkat_core::types::message_timestamp_now(),
3540                },
3541            ],
3542        );
3543
3544        let body = client.build_request_body(&request).expect("build request");
3545        let input = body["input"].as_array().expect("input array");
3546
3547        // Tool result item
3548        let tool_output = &input[1];
3549        assert_eq!(tool_output["type"], "function_call_output");
3550        assert_eq!(tool_output["call_id"], "call_1");
3551
3552        // OpenAI tool results only accept strings -- images degrade to text projection
3553        let output = tool_output["output"]
3554            .as_str()
3555            .expect("output should be string");
3556        assert!(
3557            output.contains("screenshot taken"),
3558            "text content should be preserved"
3559        );
3560        assert!(
3561            output.contains("[image: image/png]"),
3562            "image should degrade to text projection: got {output}"
3563        );
3564    }
3565
3566    // =========================================================================
3567    // Web search tool injection tests
3568    // =========================================================================
3569
3570    #[test]
3571    fn test_web_search_tool_appended_openai() {
3572        use meerkat_core::ToolDef;
3573        use std::sync::Arc;
3574
3575        let client = OpenAiClient::new("test-key".to_string());
3576        let request = LlmRequest::new(
3577            "gpt-4.1-mini",
3578            vec![Message::User(UserMessage::text("test".to_string()))],
3579        )
3580        .with_tools(vec![Arc::new(ToolDef::new(
3581            "my_tool",
3582            "A test tool",
3583            serde_json::json!({"type": "object"}),
3584        ))])
3585        .with_openai_tag_merge(|t| {
3586            t.web_search = Some(
3587                meerkat_core::lifecycle::run_primitive::OpaqueProviderBody::from_value(
3588                    &serde_json::json!({"type": "web_search"}),
3589                ),
3590            );
3591        });
3592        let body = client.build_request_body(&request).expect("build request");
3593        let tools = body["tools"].as_array().expect("tools should be array");
3594        assert_eq!(tools.len(), 2, "should have regular tool + web_search");
3595        assert_eq!(tools[0]["type"], "function");
3596        assert_eq!(tools[1]["type"], "web_search");
3597    }
3598
3599    #[test]
3600    fn test_web_search_only_openai() {
3601        let client = OpenAiClient::new("test-key".to_string());
3602        let request = LlmRequest::new(
3603            "gpt-4.1-mini",
3604            vec![Message::User(UserMessage::text("test".to_string()))],
3605        )
3606        .with_openai_tag_merge(|t| {
3607            t.web_search = Some(
3608                meerkat_core::lifecycle::run_primitive::OpaqueProviderBody::from_value(
3609                    &serde_json::json!({"type": "web_search"}),
3610                ),
3611            );
3612        });
3613        let body = client.build_request_body(&request).expect("build request");
3614        let tools = body["tools"].as_array().expect("tools should be array");
3615        assert_eq!(tools.len(), 1);
3616        assert_eq!(tools[0]["type"], "web_search");
3617    }
3618
3619    #[test]
3620    fn test_no_web_search_when_absent_openai() {
3621        use meerkat_core::ToolDef;
3622        use std::sync::Arc;
3623
3624        let client = OpenAiClient::new("test-key".to_string());
3625        let request = LlmRequest::new(
3626            "gpt-4.1-mini",
3627            vec![Message::User(UserMessage::text("test".to_string()))],
3628        )
3629        .with_tools(vec![Arc::new(ToolDef::new(
3630            "my_tool",
3631            "A test tool",
3632            serde_json::json!({"type": "object"}),
3633        ))]);
3634        let body = client.build_request_body(&request).expect("build request");
3635        let tools = body["tools"].as_array().expect("tools should be array");
3636        assert_eq!(tools.len(), 1, "should only have the regular tool");
3637        assert_eq!(tools[0]["type"], "function");
3638    }
3639}