1use 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
36pub(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
44pub 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: Vec<(String, String)>,
56 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 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 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 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 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
126 self.extra_headers = headers;
127 self
128 }
129
130 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 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 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 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 body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
200 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 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 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 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 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 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() }));
425 }
426 _ => {}
430 }
431 }
432 }
433 Message::ToolResults { results, .. } => {
434 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 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
877fn 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 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 if event.event_type == "response.completed" {
992 if done_emitted {
993 continue;
995 }
996 if let Some(response_obj) = &event.response {
997 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 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 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 if streamed_reasoning_ids.contains(reasoning_id) {
1039 continue;
1040 }
1041
1042 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 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 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 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 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_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 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 let stop_reason = match response_obj.get("status").and_then(|s| s.as_str()) {
1136 Some("completed") => {
1137 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 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 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 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 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 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#[derive(Debug, Deserialize)]
1368struct ResponsesStreamEvent {
1369 #[serde(rename = "type")]
1371 event_type: String,
1372 delta: Option<String>,
1374 call_id: Option<String>,
1376 name: Option<String>,
1378 arguments: Option<String>,
1380 item: Option<Value>,
1382 response: Option<Value>,
1384 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 #[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 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 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 assert_eq!(input[0]["type"], "message");
1910 assert_eq!(input[0]["role"], "system");
1911 assert_eq!(input[0]["content"], "You are helpful");
1912
1913 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 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 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 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 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 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 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 #[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 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 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 #[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 #[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 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"); assert_eq!(format["strict"], false); }
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 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 #[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 #[test]
2852 fn test_response_completed_parses_message_content() {
2853 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 assert_eq!(input.len(), 2);
3163 assert_eq!(input[0]["content"], "Hello");
3164 assert_eq!(input[1]["content"], "Still here");
3165 }
3166
3167 #[test]
3172 fn test_stop_reason_from_response_status() {
3173 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 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 #[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 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 assert_eq!(input.len(), 2);
3275 assert_eq!(input[1]["type"], "message");
3276 }
3277
3278 #[tokio::test]
3283 async fn test_stream_does_not_duplicate_tool_calls_when_completed_replays() {
3284 let payload = [
3285 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 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 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 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 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 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 #[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 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 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 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 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 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 #[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}