open_agent/
types.rs

1//! Core type definitions for the Open Agent SDK.
2//!
3//! This module contains the fundamental data structures used throughout the SDK for
4//! configuring and interacting with AI agents. The type system is organized into three
5//! main categories:
6//!
7//! # Agent Configuration
8//!
9//! - [`AgentOptions`]: Main configuration struct for agent behavior, model settings,
10//!   and tool management
11//! - [`AgentOptionsBuilder`]: Builder pattern implementation for constructing
12//!   [`AgentOptions`] with validation
13//!
14//! # Message System
15//!
16//! The SDK uses a flexible message system that supports multi-modal content:
17//!
18//! - [`Message`]: Container for conversation messages with role and content
19//! - [`MessageRole`]: Enum defining who sent the message (System, User, Assistant, Tool)
20//! - [`ContentBlock`]: Enum for different content types (text, tool use, tool results)
21//! - [`TextBlock`]: Simple text content
22//! - [`ToolUseBlock`]: Represents an AI request to execute a tool
23//! - [`ToolResultBlock`]: Contains the result of a tool execution
24//!
25//! # OpenAI API Compatibility
26//!
27//! The SDK communicates with LLM providers using the OpenAI-compatible API format.
28//! These types handle serialization/deserialization for streaming responses:
29//!
30//! - [`OpenAIRequest`]: Request payload sent to the API
31//! - [`OpenAIMessage`]: Message format for OpenAI API
32//! - [`OpenAIChunk`]: Streaming response chunk from the API
33//! - [`OpenAIToolCall`], [`OpenAIFunction`]: Tool calling format
34//! - [`OpenAIDelta`], [`OpenAIToolCallDelta`]: Incremental updates in streaming
35//!
36//! # Architecture Overview
37//!
38//! The type system is designed to:
39//!
40//! 1. **Separate concerns**: Internal SDK types (Message, ContentBlock) are distinct
41//!    from API wire format (OpenAI types), allowing flexibility in provider support
42//! 2. **Enable streaming**: OpenAI types support incremental delta parsing for
43//!    real-time responses
44//! 3. **Support tool use**: First-class support for function calling with proper
45//!    request/response tracking
46//! 4. **Provide ergonomics**: Builder pattern and convenience constructors make
47//!    common operations simple
48//!
49//! # Example
50//!
51//! ```no_run
52//! use open_agent::{AgentOptions, Message};
53//!
54//! // Build agent configuration
55//! let options = AgentOptions::builder()
56//!     .model("qwen2.5-32b-instruct")
57//!     .base_url("http://localhost:1234/v1")
58//!     .system_prompt("You are a helpful assistant")
59//!     .max_turns(10)
60//!     .auto_execute_tools(true)
61//!     .build()
62//!     .expect("Valid configuration");
63//!
64//! // Create a user message
65//! let msg = Message::user("Hello, how are you?");
66//! ```
67
68use crate::Error;
69use crate::hooks::Hooks;
70use crate::tools::Tool;
71use serde::{Deserialize, Serialize};
72use std::sync::Arc;
73
74// ============================================================================
75// NEWTYPE WRAPPERS FOR COMPILE-TIME TYPE SAFETY
76// ============================================================================
77
78/// Validated model name with compile-time type safety.
79///
80/// This newtype wrapper ensures that model names are validated at construction time
81/// rather than at runtime, catching invalid configurations earlier in development.
82///
83/// # Validation Rules
84///
85/// - Must not be empty
86/// - Must not be only whitespace
87///
88/// # Example
89///
90/// ```
91/// use open_agent::ModelName;
92///
93/// // Valid model name
94/// let model = ModelName::new("qwen2.5-32b-instruct").unwrap();
95/// assert_eq!(model.as_str(), "qwen2.5-32b-instruct");
96///
97/// // Invalid: empty string
98/// assert!(ModelName::new("").is_err());
99///
100/// // Invalid: whitespace only
101/// assert!(ModelName::new("   ").is_err());
102/// ```
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104pub struct ModelName(String);
105
106impl ModelName {
107    /// Creates a new `ModelName` after validation.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the model name is empty or contains only whitespace.
112    pub fn new(name: impl Into<String>) -> crate::Result<Self> {
113        let name = name.into();
114        let trimmed = name.trim();
115
116        if trimmed.is_empty() {
117            return Err(Error::invalid_input(
118                "Model name cannot be empty or whitespace",
119            ));
120        }
121
122        Ok(ModelName(name))
123    }
124
125    /// Returns the model name as a string slice.
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129
130    /// Consumes the `ModelName` and returns the inner `String`.
131    pub fn into_inner(self) -> String {
132        self.0
133    }
134}
135
136impl std::fmt::Display for ModelName {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        write!(f, "{}", self.0)
139    }
140}
141
142/// Validated base URL with compile-time type safety.
143///
144/// This newtype wrapper ensures that base URLs are validated at construction time
145/// rather than at runtime, catching invalid configurations earlier in development.
146///
147/// # Validation Rules
148///
149/// - Must not be empty
150/// - Must start with `http://` or `https://`
151///
152/// # Example
153///
154/// ```
155/// use open_agent::BaseUrl;
156///
157/// // Valid base URLs
158/// let url = BaseUrl::new("http://localhost:1234/v1").unwrap();
159/// assert_eq!(url.as_str(), "http://localhost:1234/v1");
160///
161/// let url = BaseUrl::new("https://api.openai.com/v1").unwrap();
162/// assert_eq!(url.as_str(), "https://api.openai.com/v1");
163///
164/// // Invalid: no http/https prefix
165/// assert!(BaseUrl::new("localhost:1234").is_err());
166///
167/// // Invalid: empty string
168/// assert!(BaseUrl::new("").is_err());
169/// ```
170#[derive(Debug, Clone, PartialEq, Eq, Hash)]
171pub struct BaseUrl(String);
172
173impl BaseUrl {
174    /// Creates a new `BaseUrl` after validation.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the URL is empty or doesn't start with http:// or https://.
179    pub fn new(url: impl Into<String>) -> crate::Result<Self> {
180        let url = url.into();
181        let trimmed = url.trim();
182
183        if trimmed.is_empty() {
184            return Err(Error::invalid_input("base_url cannot be empty"));
185        }
186
187        if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
188            return Err(Error::invalid_input(
189                "base_url must start with http:// or https://",
190            ));
191        }
192
193        Ok(BaseUrl(url))
194    }
195
196    /// Returns the base URL as a string slice.
197    pub fn as_str(&self) -> &str {
198        &self.0
199    }
200
201    /// Consumes the `BaseUrl` and returns the inner `String`.
202    pub fn into_inner(self) -> String {
203        self.0
204    }
205}
206
207impl std::fmt::Display for BaseUrl {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        write!(f, "{}", self.0)
210    }
211}
212
213/// Validated temperature value with compile-time type safety.
214///
215/// This newtype wrapper ensures that temperature values are validated at construction time
216/// rather than at runtime, catching invalid configurations earlier in development.
217///
218/// # Validation Rules
219///
220/// - Must be between 0.0 and 2.0 (inclusive)
221///
222/// # Example
223///
224/// ```
225/// use open_agent::Temperature;
226///
227/// // Valid temperatures
228/// let temp = Temperature::new(0.7).unwrap();
229/// assert_eq!(temp.value(), 0.7);
230///
231/// let temp = Temperature::new(0.0).unwrap();
232/// assert_eq!(temp.value(), 0.0);
233///
234/// let temp = Temperature::new(2.0).unwrap();
235/// assert_eq!(temp.value(), 2.0);
236///
237/// // Invalid: below range
238/// assert!(Temperature::new(-0.1).is_err());
239///
240/// // Invalid: above range
241/// assert!(Temperature::new(2.1).is_err());
242/// ```
243#[derive(Debug, Clone, Copy, PartialEq)]
244pub struct Temperature(f32);
245
246impl Temperature {
247    /// Creates a new `Temperature` after validation.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the temperature is not between 0.0 and 2.0 (inclusive).
252    pub fn new(temp: f32) -> crate::Result<Self> {
253        if !(0.0..=2.0).contains(&temp) {
254            return Err(Error::invalid_input(
255                "temperature must be between 0.0 and 2.0",
256            ));
257        }
258
259        Ok(Temperature(temp))
260    }
261
262    /// Returns the temperature value.
263    pub fn value(&self) -> f32 {
264        self.0
265    }
266}
267
268impl std::fmt::Display for Temperature {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(f, "{}", self.0)
271    }
272}
273
274// ============================================================================
275// AGENT CONFIGURATION
276// ============================================================================
277
278/// Configuration options for an AI agent instance.
279///
280/// `AgentOptions` controls all aspects of agent behavior including model selection,
281/// conversation management, tool usage, and lifecycle hooks. This struct should be
282/// constructed using [`AgentOptions::builder()`] rather than direct instantiation
283/// to ensure required fields are validated.
284///
285/// # Architecture
286///
287/// The options are organized into several functional areas:
288///
289/// - **Model Configuration**: `model`, `base_url`, `api_key`, `temperature`, `max_tokens`
290/// - **Conversation Control**: `system_prompt`, `max_turns`, `timeout`
291/// - **Tool Management**: `tools`, `auto_execute_tools`, `max_tool_iterations`
292/// - **Lifecycle Hooks**: `hooks` for monitoring and interception
293///
294/// # Thread Safety
295///
296/// Tools are wrapped in `Arc<Tool>` to allow efficient cloning and sharing across
297/// threads, as agents may need to be cloned for parallel processing.
298///
299/// # Examples
300///
301/// ```no_run
302/// use open_agent::AgentOptions;
303///
304/// let options = AgentOptions::builder()
305///     .model("qwen2.5-32b-instruct")
306///     .base_url("http://localhost:1234/v1")
307///     .system_prompt("You are a helpful coding assistant")
308///     .max_turns(5)
309///     .temperature(0.7)
310///     .build()
311///     .expect("Valid configuration");
312/// ```
313#[derive(Clone)]
314pub struct AgentOptions {
315    /// System prompt that defines the agent's behavior and personality.
316    ///
317    /// This is sent as the first message in the conversation to establish
318    /// context and instructions. Can be empty if no system-level guidance
319    /// is needed.
320    system_prompt: String,
321
322    /// Model identifier for the LLM to use (e.g., "qwen2.5-32b-instruct", "gpt-4").
323    ///
324    /// This must match a model available at the configured `base_url`.
325    /// Different models have varying capabilities for tool use, context
326    /// length, and response quality.
327    model: String,
328
329    /// OpenAI-compatible API endpoint URL (e.g., "http://localhost:1234/v1").
330    ///
331    /// The SDK communicates using the OpenAI chat completions API format,
332    /// which is widely supported by local inference servers (LM Studio,
333    /// llama.cpp, vLLM) and cloud providers.
334    base_url: String,
335
336    /// API authentication key for the provider.
337    ///
338    /// Many local servers don't require authentication, so the default
339    /// "not-needed" is often sufficient. For cloud providers like OpenAI,
340    /// set this to your actual API key.
341    api_key: String,
342
343    /// Maximum number of conversation turns (user message + assistant response = 1 turn).
344    ///
345    /// This limits how long a conversation can continue. In auto-execution mode
346    /// with tools, this prevents infinite loops. Set to 1 for single-shot
347    /// interactions or higher for multi-turn conversations.
348    max_turns: u32,
349
350    /// Maximum tokens the model should generate in a single response.
351    ///
352    /// `None` uses the provider's default. Lower values constrain response
353    /// length, which can be useful for cost control or ensuring concise answers.
354    /// Note this is separate from the model's context window size.
355    max_tokens: Option<u32>,
356
357    /// Sampling temperature for response generation (typically 0.0 to 2.0).
358    ///
359    /// - 0.0: Deterministic, always picks most likely tokens
360    /// - 0.7: Balanced creativity and consistency (default)
361    /// - 1.0+: More random and creative responses
362    ///
363    /// Lower temperatures are better for factual tasks, higher for creative ones.
364    temperature: f32,
365
366    /// HTTP request timeout in seconds.
367    ///
368    /// Maximum time to wait for the API to respond. Applies per API call,
369    /// not to the entire conversation. Increase for slower models or when
370    /// expecting long responses.
371    timeout: u64,
372
373    /// Tools available for the agent to use during conversations.
374    ///
375    /// Tools are wrapped in `Arc` for efficient cloning. When the agent
376    /// receives a tool use request, it looks up the tool by name in this
377    /// vector. Empty by default.
378    tools: Vec<Arc<Tool>>,
379
380    /// Whether to automatically execute tools and continue the conversation.
381    ///
382    /// - `true`: SDK automatically executes tool calls and sends results back
383    ///   to the model, continuing until no more tools are requested
384    /// - `false`: Tool calls are returned to the caller, who must manually
385    ///   execute them and provide results
386    ///
387    /// Auto-execution is convenient but gives less control. Manual execution
388    /// allows for approval workflows and selective tool access.
389    auto_execute_tools: bool,
390
391    /// Maximum iterations of tool execution in automatic mode.
392    ///
393    /// Prevents infinite loops where the agent continuously requests tools.
394    /// Each tool execution attempt counts as one iteration. Only relevant
395    /// when `auto_execute_tools` is true.
396    max_tool_iterations: u32,
397
398    /// Lifecycle hooks for observing and intercepting agent operations.
399    ///
400    /// Hooks allow you to inject custom logic at various points:
401    /// - Before/after API requests
402    /// - Tool execution interception
403    /// - Response streaming callbacks
404    ///
405    /// Useful for logging, metrics, debugging, and implementing custom
406    /// authorization logic.
407    hooks: Hooks,
408}
409
410/// Custom Debug implementation to prevent sensitive data leakage.
411///
412/// We override the default Debug implementation because:
413/// 1. The `api_key` field may contain sensitive credentials that shouldn't
414///    appear in logs or error messages
415/// 2. The `tools` vector contains Arc-wrapped closures that don't debug nicely,
416///    so we show a count instead
417///
418/// This ensures that debug output is safe for logging while remaining useful
419/// for troubleshooting.
420impl std::fmt::Debug for AgentOptions {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        f.debug_struct("AgentOptions")
423            .field("system_prompt", &self.system_prompt)
424            .field("model", &self.model)
425            .field("base_url", &self.base_url)
426            // Mask API key to prevent credential leakage in logs
427            .field("api_key", &"***")
428            .field("max_turns", &self.max_turns)
429            .field("max_tokens", &self.max_tokens)
430            .field("temperature", &self.temperature)
431            .field("timeout", &self.timeout)
432            // Show tool count instead of trying to debug Arc<Tool> contents
433            .field("tools", &format!("{} tools", self.tools.len()))
434            .field("auto_execute_tools", &self.auto_execute_tools)
435            .field("max_tool_iterations", &self.max_tool_iterations)
436            .field("hooks", &self.hooks)
437            .finish()
438    }
439}
440
441/// Default values optimized for common single-turn use cases.
442///
443/// These defaults are chosen to:
444/// - Require explicit configuration of critical fields (model, base_url)
445/// - Provide safe, sensible defaults for optional fields
446/// - Work with local inference servers that don't need authentication
447impl Default for AgentOptions {
448    fn default() -> Self {
449        Self {
450            // Empty string forces users to explicitly set context
451            system_prompt: String::new(),
452            // Empty string forces users to explicitly choose a model
453            model: String::new(),
454            // Empty string forces users to explicitly configure the endpoint
455            base_url: String::new(),
456            // Most local servers (LM Studio, llama.cpp) don't require auth
457            api_key: "not-needed".to_string(),
458            // Default to single-shot interaction; users opt into conversations
459            max_turns: 1,
460            // 4096 is a reasonable default that works with most models
461            // while preventing runaway generation costs
462            max_tokens: Some(4096),
463            // 0.7 balances creativity with consistency for general use
464            temperature: 0.7,
465            // 60 seconds handles most requests without timing out prematurely
466            timeout: 60,
467            // No tools by default; users explicitly add capabilities
468            tools: Vec::new(),
469            // Manual tool execution by default for safety and control
470            auto_execute_tools: false,
471            // 5 iterations prevent infinite loops while allowing multi-step workflows
472            max_tool_iterations: 5,
473            // Empty hooks for no-op behavior
474            hooks: Hooks::new(),
475        }
476    }
477}
478
479impl AgentOptions {
480    /// Creates a new builder for constructing [`AgentOptions`].
481    ///
482    /// The builder pattern is used because:
483    /// 1. Some fields are required (model, base_url) and need validation
484    /// 2. Many fields have sensible defaults that can be overridden
485    /// 3. The API is more discoverable and readable than struct initialization
486    ///
487    /// # Example
488    ///
489    /// ```no_run
490    /// use open_agent::AgentOptions;
491    ///
492    /// let options = AgentOptions::builder()
493    ///     .model("qwen2.5-32b-instruct")
494    ///     .base_url("http://localhost:1234/v1")
495    ///     .build()
496    ///     .expect("Valid configuration");
497    /// ```
498    pub fn builder() -> AgentOptionsBuilder {
499        AgentOptionsBuilder::default()
500    }
501
502    /// Returns the system prompt.
503    pub fn system_prompt(&self) -> &str {
504        &self.system_prompt
505    }
506
507    /// Returns the model identifier.
508    pub fn model(&self) -> &str {
509        &self.model
510    }
511
512    /// Returns the base URL.
513    pub fn base_url(&self) -> &str {
514        &self.base_url
515    }
516
517    /// Returns the API key.
518    pub fn api_key(&self) -> &str {
519        &self.api_key
520    }
521
522    /// Returns the maximum number of conversation turns.
523    pub fn max_turns(&self) -> u32 {
524        self.max_turns
525    }
526
527    /// Returns the maximum tokens setting.
528    pub fn max_tokens(&self) -> Option<u32> {
529        self.max_tokens
530    }
531
532    /// Returns the sampling temperature.
533    pub fn temperature(&self) -> f32 {
534        self.temperature
535    }
536
537    /// Returns the HTTP timeout in seconds.
538    pub fn timeout(&self) -> u64 {
539        self.timeout
540    }
541
542    /// Returns a reference to the tools vector.
543    pub fn tools(&self) -> &[Arc<Tool>] {
544        &self.tools
545    }
546
547    /// Returns whether automatic tool execution is enabled.
548    pub fn auto_execute_tools(&self) -> bool {
549        self.auto_execute_tools
550    }
551
552    /// Returns the maximum tool execution iterations.
553    pub fn max_tool_iterations(&self) -> u32 {
554        self.max_tool_iterations
555    }
556
557    /// Returns a reference to the hooks configuration.
558    pub fn hooks(&self) -> &Hooks {
559        &self.hooks
560    }
561}
562
563/// Builder for constructing [`AgentOptions`] with validation.
564///
565/// This builder implements the typestate pattern using `Option<T>` to track
566/// which required fields have been set. The [`build()`](AgentOptionsBuilder::build)
567/// method validates that all required fields are present before creating
568/// the final [`AgentOptions`].
569///
570/// # Required Fields
571///
572/// - `model`: The LLM model identifier
573/// - `base_url`: The API endpoint URL
574///
575/// All other fields have sensible defaults.
576///
577/// # Usage Pattern
578///
579/// 1. Call [`AgentOptions::builder()`]
580/// 2. Chain method calls to set configuration
581/// 3. Call [`build()`](AgentOptionsBuilder::build) to validate and create the final options
582///
583/// Methods return `self` for chaining, following the fluent interface pattern.
584///
585/// # Examples
586///
587/// ```no_run
588/// use open_agent::AgentOptions;
589/// use open_agent::Tool;
590///
591/// let calculator = Tool::new(
592///     "calculate",
593///     "Perform arithmetic",
594///     serde_json::json!({
595///         "type": "object",
596///         "properties": {
597///             "expression": {"type": "string"}
598///         }
599///     }),
600///     |input| Box::pin(async move {
601///         Ok(serde_json::json!({"result": 42}))
602///     }),
603/// );
604///
605/// let options = AgentOptions::builder()
606///     .model("qwen2.5-32b-instruct")
607///     .base_url("http://localhost:1234/v1")
608///     .system_prompt("You are a helpful assistant")
609///     .max_turns(10)
610///     .temperature(0.8)
611///     .tool(calculator)
612///     .auto_execute_tools(true)
613///     .build()
614///     .expect("Valid configuration");
615/// ```
616#[derive(Default)]
617pub struct AgentOptionsBuilder {
618    /// Optional system prompt; defaults to empty if not set
619    system_prompt: Option<String>,
620    /// Required: model identifier
621    model: Option<String>,
622    /// Required: API endpoint URL
623    base_url: Option<String>,
624    /// Optional API key; defaults to "not-needed"
625    api_key: Option<String>,
626    /// Optional max turns; defaults to 1
627    max_turns: Option<u32>,
628    /// Optional max tokens; defaults to Some(4096)
629    max_tokens: Option<u32>,
630    /// Optional temperature; defaults to 0.7
631    temperature: Option<f32>,
632    /// Optional timeout; defaults to 60 seconds
633    timeout: Option<u64>,
634    /// Tools to provide; starts empty
635    tools: Vec<Arc<Tool>>,
636    /// Optional auto-execute flag; defaults to false
637    auto_execute_tools: Option<bool>,
638    /// Optional max iterations; defaults to 5
639    max_tool_iterations: Option<u32>,
640    /// Lifecycle hooks; defaults to empty
641    hooks: Hooks,
642}
643
644/// Custom Debug implementation for builder to show minimal useful information.
645///
646/// Similar to [`AgentOptions`], we provide a simplified debug output that:
647/// - Omits sensitive fields like API keys (not shown at all in builder)
648/// - Shows tool count rather than tool details
649/// - Focuses on the most important configuration fields
650impl std::fmt::Debug for AgentOptionsBuilder {
651    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652        f.debug_struct("AgentOptionsBuilder")
653            .field("system_prompt", &self.system_prompt)
654            .field("model", &self.model)
655            .field("base_url", &self.base_url)
656            .field("tools", &format!("{} tools", self.tools.len()))
657            .finish()
658    }
659}
660
661/// Builder methods for configuring agent options.
662///
663/// All methods follow the builder pattern: they consume `self`, update a field,
664/// and return `self` for method chaining. The generic `impl Into<String>` parameters
665/// allow passing `&str`, `String`, or any other type that converts to `String`.
666impl AgentOptionsBuilder {
667    /// Sets the system prompt that defines agent behavior.
668    ///
669    /// The system prompt is sent at the beginning of every conversation to
670    /// establish context, personality, and instructions for the agent.
671    ///
672    /// # Example
673    ///
674    /// ```no_run
675    /// # use open_agent::AgentOptions;
676    /// let options = AgentOptions::builder()
677    ///     .model("qwen2.5-32b-instruct")
678    ///     .base_url("http://localhost:1234/v1")
679    ///     .system_prompt("You are a helpful coding assistant. Be concise.")
680    ///     .build()
681    ///     .unwrap();
682    /// ```
683    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
684        self.system_prompt = Some(prompt.into());
685        self
686    }
687
688    /// Sets the model identifier (required).
689    ///
690    /// This must match a model available at your configured endpoint.
691    /// Common examples: "qwen2.5-32b-instruct", "gpt-4", "claude-3-sonnet".
692    ///
693    /// # Example
694    ///
695    /// ```no_run
696    /// # use open_agent::AgentOptions;
697    /// let options = AgentOptions::builder()
698    ///     .model("gpt-4")
699    ///     .base_url("https://api.openai.com/v1")
700    ///     .build()
701    ///     .unwrap();
702    /// ```
703    pub fn model(mut self, model: impl Into<String>) -> Self {
704        self.model = Some(model.into());
705        self
706    }
707
708    /// Sets the API endpoint URL (required).
709    ///
710    /// Must be an OpenAI-compatible endpoint. Common values:
711    /// - Local: "http://localhost:1234/v1" (LM Studio default)
712    /// - OpenAI: <https://api.openai.com/v1>
713    /// - Custom: Your inference server URL
714    ///
715    /// # Example
716    ///
717    /// ```no_run
718    /// # use open_agent::AgentOptions;
719    /// let options = AgentOptions::builder()
720    ///     .model("qwen2.5-32b-instruct")
721    ///     .base_url("http://localhost:1234/v1")
722    ///     .build()
723    ///     .unwrap();
724    /// ```
725    pub fn base_url(mut self, url: impl Into<String>) -> Self {
726        self.base_url = Some(url.into());
727        self
728    }
729
730    /// Sets the API key for authentication.
731    ///
732    /// Required for cloud providers like OpenAI. Most local servers don't
733    /// need this - the default "not-needed" works fine.
734    ///
735    /// # Example
736    ///
737    /// ```no_run
738    /// # use open_agent::AgentOptions;
739    /// let options = AgentOptions::builder()
740    ///     .model("gpt-4")
741    ///     .base_url("https://api.openai.com/v1")
742    ///     .api_key("sk-...")
743    ///     .build()
744    ///     .unwrap();
745    /// ```
746    pub fn api_key(mut self, key: impl Into<String>) -> Self {
747        self.api_key = Some(key.into());
748        self
749    }
750
751    /// Sets the maximum number of conversation turns.
752    ///
753    /// One turn = user message + assistant response. Higher values enable
754    /// longer conversations but may increase costs and latency.
755    ///
756    /// # Example
757    ///
758    /// ```no_run
759    /// # use open_agent::AgentOptions;
760    /// let options = AgentOptions::builder()
761    ///     .model("qwen2.5-32b-instruct")
762    ///     .base_url("http://localhost:1234/v1")
763    ///     .max_turns(10)  // Allow multi-turn conversation
764    ///     .build()
765    ///     .unwrap();
766    /// ```
767    pub fn max_turns(mut self, turns: u32) -> Self {
768        self.max_turns = Some(turns);
769        self
770    }
771
772    /// Sets the maximum tokens to generate per response.
773    ///
774    /// Constrains response length. Lower values reduce costs but may truncate
775    /// responses. Higher values allow longer, more complete answers.
776    ///
777    /// # Example
778    ///
779    /// ```no_run
780    /// # use open_agent::AgentOptions;
781    /// let options = AgentOptions::builder()
782    ///     .model("qwen2.5-32b-instruct")
783    ///     .base_url("http://localhost:1234/v1")
784    ///     .max_tokens(1000)  // Limit to shorter responses
785    ///     .build()
786    ///     .unwrap();
787    /// ```
788    pub fn max_tokens(mut self, tokens: u32) -> Self {
789        self.max_tokens = Some(tokens);
790        self
791    }
792
793    /// Sets the sampling temperature for response generation.
794    ///
795    /// Controls randomness:
796    /// - 0.0: Deterministic, always picks most likely tokens
797    /// - 0.7: Balanced (default)
798    /// - 1.0+: More creative/random
799    ///
800    /// # Example
801    ///
802    /// ```no_run
803    /// # use open_agent::AgentOptions;
804    /// let options = AgentOptions::builder()
805    ///     .model("qwen2.5-32b-instruct")
806    ///     .base_url("http://localhost:1234/v1")
807    ///     .temperature(0.0)  // Deterministic for coding tasks
808    ///     .build()
809    ///     .unwrap();
810    /// ```
811    pub fn temperature(mut self, temp: f32) -> Self {
812        self.temperature = Some(temp);
813        self
814    }
815
816    /// Sets the HTTP request timeout in seconds.
817    ///
818    /// How long to wait for the API to respond. Increase for slower models
819    /// or when expecting long responses.
820    ///
821    /// # Example
822    ///
823    /// ```no_run
824    /// # use open_agent::AgentOptions;
825    /// let options = AgentOptions::builder()
826    ///     .model("qwen2.5-32b-instruct")
827    ///     .base_url("http://localhost:1234/v1")
828    ///     .timeout(120)  // 2 minutes for complex tasks
829    ///     .build()
830    ///     .unwrap();
831    /// ```
832    pub fn timeout(mut self, timeout: u64) -> Self {
833        self.timeout = Some(timeout);
834        self
835    }
836
837    /// Enables or disables automatic tool execution.
838    ///
839    /// When true, the SDK automatically executes tool calls and continues
840    /// the conversation. When false, tool calls are returned for manual
841    /// handling, allowing approval workflows.
842    ///
843    /// # Example
844    ///
845    /// ```no_run
846    /// # use open_agent::AgentOptions;
847    /// let options = AgentOptions::builder()
848    ///     .model("qwen2.5-32b-instruct")
849    ///     .base_url("http://localhost:1234/v1")
850    ///     .auto_execute_tools(true)  // Automatic execution
851    ///     .build()
852    ///     .unwrap();
853    /// ```
854    pub fn auto_execute_tools(mut self, auto: bool) -> Self {
855        self.auto_execute_tools = Some(auto);
856        self
857    }
858
859    /// Sets the maximum tool execution iterations in automatic mode.
860    ///
861    /// Prevents infinite loops where the agent continuously calls tools.
862    /// Only relevant when `auto_execute_tools` is true.
863    ///
864    /// # Example
865    ///
866    /// ```no_run
867    /// # use open_agent::AgentOptions;
868    /// let options = AgentOptions::builder()
869    ///     .model("qwen2.5-32b-instruct")
870    ///     .base_url("http://localhost:1234/v1")
871    ///     .auto_execute_tools(true)
872    ///     .max_tool_iterations(10)  // Allow up to 10 tool calls
873    ///     .build()
874    ///     .unwrap();
875    /// ```
876    pub fn max_tool_iterations(mut self, iterations: u32) -> Self {
877        self.max_tool_iterations = Some(iterations);
878        self
879    }
880
881    /// Adds a single tool to the agent's available tools.
882    ///
883    /// The tool is wrapped in `Arc` for efficient sharing. Can be called
884    /// multiple times to add multiple tools.
885    ///
886    /// # Example
887    ///
888    /// ```no_run
889    /// # use open_agent::AgentOptions;
890    /// # use open_agent::Tool;
891    /// let calculator = Tool::new(
892    ///     "calculate",
893    ///     "Evaluate a math expression",
894    ///     serde_json::json!({"type": "object"}),
895    ///     |input| Box::pin(async move { Ok(serde_json::json!({"result": 42})) }),
896    /// );
897    ///
898    /// let options = AgentOptions::builder()
899    ///     .model("qwen2.5-32b-instruct")
900    ///     .base_url("http://localhost:1234/v1")
901    ///     .tool(calculator)
902    ///     .build()
903    ///     .unwrap();
904    /// ```
905    pub fn tool(mut self, tool: Tool) -> Self {
906        self.tools.push(Arc::new(tool));
907        self
908    }
909
910    /// Adds multiple tools at once to the agent's available tools.
911    ///
912    /// Convenience method for bulk tool addition. All tools are wrapped
913    /// in `Arc` automatically.
914    ///
915    /// # Example
916    ///
917    /// ```no_run
918    /// # use open_agent::AgentOptions;
919    /// # use open_agent::Tool;
920    /// let tools = vec![
921    ///     Tool::new("add", "Add numbers", serde_json::json!({}),
922    ///         |input| Box::pin(async move { Ok(serde_json::json!({})) })),
923    ///     Tool::new("multiply", "Multiply numbers", serde_json::json!({}),
924    ///         |input| Box::pin(async move { Ok(serde_json::json!({})) })),
925    /// ];
926    ///
927    /// let options = AgentOptions::builder()
928    ///     .model("qwen2.5-32b-instruct")
929    ///     .base_url("http://localhost:1234/v1")
930    ///     .tools(tools)
931    ///     .build()
932    ///     .unwrap();
933    /// ```
934    pub fn tools(mut self, tools: Vec<Tool>) -> Self {
935        self.tools.extend(tools.into_iter().map(Arc::new));
936        self
937    }
938
939    /// Sets lifecycle hooks for monitoring and intercepting agent operations.
940    ///
941    /// Hooks allow custom logic at various points: before/after API calls,
942    /// tool execution, response streaming, etc. Useful for logging, metrics,
943    /// debugging, and custom authorization.
944    ///
945    /// # Example
946    ///
947    /// ```no_run
948    /// # use open_agent::{AgentOptions, Hooks, HookDecision};
949    /// let hooks = Hooks::new()
950    ///     .add_user_prompt_submit(|event| async move {
951    ///         println!("User prompt: {}", event.prompt);
952    ///         Some(HookDecision::continue_())
953    ///     });
954    ///
955    /// let options = AgentOptions::builder()
956    ///     .model("qwen2.5-32b-instruct")
957    ///     .base_url("http://localhost:1234/v1")
958    ///     .hooks(hooks)
959    ///     .build()
960    ///     .unwrap();
961    /// ```
962    pub fn hooks(mut self, hooks: Hooks) -> Self {
963        self.hooks = hooks;
964        self
965    }
966
967    /// Validates configuration and builds the final [`AgentOptions`].
968    ///
969    /// This method performs validation to ensure required fields are set and
970    /// applies default values for optional fields. Returns an error if
971    /// validation fails.
972    ///
973    /// # Required Fields
974    ///
975    /// - `model`: Must be set or build() returns an error
976    /// - `base_url`: Must be set or build() returns an error
977    ///
978    /// # Errors
979    ///
980    /// Returns a configuration error if any required field is missing.
981    ///
982    /// # Example
983    ///
984    /// ```no_run
985    /// # use open_agent::AgentOptions;
986    /// // Success - all required fields set
987    /// let options = AgentOptions::builder()
988    ///     .model("qwen2.5-32b-instruct")
989    ///     .base_url("http://localhost:1234/v1")
990    ///     .build()
991    ///     .expect("Valid configuration");
992    ///
993    /// // Error - missing model
994    /// let result = AgentOptions::builder()
995    ///     .base_url("http://localhost:1234/v1")
996    ///     .build();
997    /// assert!(result.is_err());
998    /// ```
999    pub fn build(self) -> crate::Result<AgentOptions> {
1000        // Validate required fields - these must be explicitly set by the user
1001        // because they're fundamental to connecting to an LLM provider
1002        let model = self
1003            .model
1004            .ok_or_else(|| crate::Error::config("model is required"))?;
1005
1006        let base_url = self
1007            .base_url
1008            .ok_or_else(|| crate::Error::config("base_url is required"))?;
1009
1010        // Validate model is not empty or whitespace
1011        if model.trim().is_empty() {
1012            return Err(crate::Error::invalid_input(
1013                "model cannot be empty or whitespace",
1014            ));
1015        }
1016
1017        // Validate base_url is not empty and has valid URL format
1018        if base_url.trim().is_empty() {
1019            return Err(crate::Error::invalid_input("base_url cannot be empty"));
1020        }
1021        // Check if URL has a valid scheme (http:// or https://)
1022        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
1023            return Err(crate::Error::invalid_input(
1024                "base_url must start with http:// or https://",
1025            ));
1026        }
1027
1028        // Validate temperature is in valid range (0.0 to 2.0)
1029        let temperature = self.temperature.unwrap_or(0.7);
1030        if !(0.0..=2.0).contains(&temperature) {
1031            return Err(crate::Error::invalid_input(
1032                "temperature must be between 0.0 and 2.0",
1033            ));
1034        }
1035
1036        // Validate max_tokens if set
1037        let max_tokens = self.max_tokens.or(Some(4096));
1038        if let Some(tokens) = max_tokens {
1039            if tokens == 0 {
1040                return Err(crate::Error::invalid_input(
1041                    "max_tokens must be greater than 0",
1042                ));
1043            }
1044        }
1045
1046        // Construct the final options, applying defaults where values weren't set
1047        Ok(AgentOptions {
1048            // Empty system prompt is valid - not all use cases need one
1049            system_prompt: self.system_prompt.unwrap_or_default(),
1050            model,
1051            base_url,
1052            // Default API key works for most local servers
1053            api_key: self.api_key.unwrap_or_else(|| "not-needed".to_string()),
1054            // Default to single-turn for simplicity
1055            max_turns: self.max_turns.unwrap_or(1),
1056            max_tokens,
1057            temperature,
1058            // Conservative timeout that works for most requests
1059            timeout: self.timeout.unwrap_or(60),
1060            // Tools vector was built up during configuration, use as-is
1061            tools: self.tools,
1062            // Manual execution by default for safety and control
1063            auto_execute_tools: self.auto_execute_tools.unwrap_or(false),
1064            // Reasonable limit to prevent runaway tool loops
1065            max_tool_iterations: self.max_tool_iterations.unwrap_or(5),
1066            // Hooks were built up during configuration, use as-is
1067            hooks: self.hooks,
1068        })
1069    }
1070}
1071
1072/// Identifies the sender/role of a message in the conversation.
1073///
1074/// This enum follows the standard chat completion role system used by most
1075/// LLM APIs. The role determines how the message is interpreted and processed.
1076///
1077/// # Serialization
1078///
1079/// Serializes to lowercase strings via serde (`"system"`, `"user"`, etc.)
1080/// to match OpenAI API format.
1081///
1082/// # Role Semantics
1083///
1084/// - [`System`](MessageRole::System): Establishes context, instructions, and behavior
1085/// - [`User`](MessageRole::User): Input from the human or calling application
1086/// - [`Assistant`](MessageRole::Assistant): Response from the AI model
1087/// - [`Tool`](MessageRole::Tool): Results from tool/function execution
1088#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1089#[serde(rename_all = "lowercase")]
1090pub enum MessageRole {
1091    /// System message that establishes agent behavior and context.
1092    ///
1093    /// Typically the first message in a conversation. Used for instructions,
1094    /// personality definition, and constraints that apply throughout the
1095    /// conversation.
1096    System,
1097
1098    /// User message representing human or application input.
1099    ///
1100    /// The prompt or query that the agent should respond to. In multi-turn
1101    /// conversations, user messages alternate with assistant messages.
1102    User,
1103
1104    /// Assistant message containing the AI model's response.
1105    ///
1106    /// Can include text, tool use requests, or both. When the model wants to
1107    /// call a tool, it includes ToolUseBlock content.
1108    Assistant,
1109
1110    /// Tool result message containing function execution results.
1111    ///
1112    /// Sent back to the model after executing a requested tool. Contains the
1113    /// tool's output that the model can use in its next response.
1114    Tool,
1115}
1116
1117/// Multi-modal content blocks that can appear in messages.
1118///
1119/// Messages are composed of one or more content blocks, allowing rich,
1120/// structured communication between the user, assistant, and tools.
1121///
1122/// # Serialization
1123///
1124/// Uses serde's "externally tagged" enum format with a `"type"` field:
1125/// ```json
1126/// {"type": "text", "text": "Hello"}
1127/// {"type": "tool_use", "id": "call_123", "name": "search", "input": {...}}
1128/// {"type": "tool_result", "tool_use_id": "call_123", "content": {...}}
1129/// ```
1130///
1131/// # Block Types
1132///
1133/// - [`Text`](ContentBlock::Text): Simple text content
1134/// - [`Image`](ContentBlock::Image): Image content (URL or base64)
1135/// - [`ToolUse`](ContentBlock::ToolUse): Request from model to execute a tool
1136/// - [`ToolResult`](ContentBlock::ToolResult): Result of tool execution
1137///
1138/// # Usage
1139///
1140/// Messages can contain multiple blocks. For example, a user message might
1141/// include text and an image, or an assistant message might include text
1142/// followed by a tool use request.
1143#[derive(Debug, Clone, Serialize, Deserialize)]
1144#[serde(tag = "type", rename_all = "snake_case")]
1145pub enum ContentBlock {
1146    /// Text content block containing a string message.
1147    Text(TextBlock),
1148
1149    /// Image content block for vision-capable models.
1150    Image(ImageBlock),
1151
1152    /// Tool use request from the model to execute a function.
1153    ToolUse(ToolUseBlock),
1154
1155    /// Tool execution result sent back to the model.
1156    ToolResult(ToolResultBlock),
1157}
1158
1159/// Simple text content in a message.
1160///
1161/// The most common content type, representing plain text communication.
1162/// Both users and assistants primarily use text blocks for their messages.
1163///
1164/// # Example
1165///
1166/// ```
1167/// use open_agent::{TextBlock, ContentBlock};
1168///
1169/// let block = TextBlock::new("Hello, world!");
1170/// let content = ContentBlock::Text(block);
1171/// ```
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1173pub struct TextBlock {
1174    /// The text content.
1175    pub text: String,
1176}
1177
1178impl TextBlock {
1179    /// Creates a new text block from any string-like type.
1180    ///
1181    /// # Example
1182    ///
1183    /// ```
1184    /// use open_agent::TextBlock;
1185    ///
1186    /// let block = TextBlock::new("Hello");
1187    /// assert_eq!(block.text, "Hello");
1188    /// ```
1189    pub fn new(text: impl Into<String>) -> Self {
1190        Self { text: text.into() }
1191    }
1192}
1193
1194/// Tool use request from the AI model.
1195///
1196/// When the model determines it needs to call a tool/function, it returns
1197/// a ToolUseBlock specifying which tool to call and with what parameters.
1198/// The application must then execute the tool and return results via
1199/// [`ToolResultBlock`].
1200///
1201/// # Fields
1202///
1203/// - `id`: Unique identifier for this tool call, used to correlate results
1204/// - `name`: Name of the tool to execute (must match a registered tool)
1205/// - `input`: JSON parameters to pass to the tool
1206///
1207/// # Example
1208///
1209/// ```
1210/// use open_agent::{ToolUseBlock, ContentBlock};
1211/// use serde_json::json;
1212///
1213/// let block = ToolUseBlock::new(
1214///     "call_123",
1215///     "calculate",
1216///     json!({"expression": "2 + 2"})
1217/// );
1218/// assert_eq!(block.id(), "call_123");
1219/// assert_eq!(block.name(), "calculate");
1220/// ```
1221#[derive(Debug, Clone, Serialize, Deserialize)]
1222pub struct ToolUseBlock {
1223    /// Unique identifier for this tool call.
1224    ///
1225    /// Generated by the model. Used to correlate the tool result back to
1226    /// this specific request, especially when multiple tools are called.
1227    id: String,
1228
1229    /// Name of the tool to execute.
1230    ///
1231    /// Must match the name of a tool that was provided in the agent's
1232    /// configuration, otherwise execution will fail.
1233    name: String,
1234
1235    /// JSON parameters to pass to the tool.
1236    ///
1237    /// The structure should match the tool's input schema. The tool's
1238    /// execution function receives this value as input.
1239    input: serde_json::Value,
1240}
1241
1242impl ToolUseBlock {
1243    /// Creates a new tool use block.
1244    ///
1245    /// # Parameters
1246    ///
1247    /// - `id`: Unique identifier for this tool call
1248    /// - `name`: Name of the tool to execute
1249    /// - `input`: JSON parameters for the tool
1250    ///
1251    /// # Example
1252    ///
1253    /// ```
1254    /// use open_agent::ToolUseBlock;
1255    /// use serde_json::json;
1256    ///
1257    /// let block = ToolUseBlock::new(
1258    ///     "call_abc",
1259    ///     "search",
1260    ///     json!({"query": "Rust async programming"})
1261    /// );
1262    /// ```
1263    pub fn new(id: impl Into<String>, name: impl Into<String>, input: serde_json::Value) -> Self {
1264        Self {
1265            id: id.into(),
1266            name: name.into(),
1267            input,
1268        }
1269    }
1270
1271    /// Returns the unique identifier for this tool call.
1272    pub fn id(&self) -> &str {
1273        &self.id
1274    }
1275
1276    /// Returns the name of the tool to execute.
1277    pub fn name(&self) -> &str {
1278        &self.name
1279    }
1280
1281    /// Returns the JSON parameters for the tool.
1282    pub fn input(&self) -> &serde_json::Value {
1283        &self.input
1284    }
1285}
1286
1287/// Tool execution result sent back to the model.
1288///
1289/// After executing a tool requested via [`ToolUseBlock`], the application
1290/// creates a ToolResultBlock containing the tool's output and sends it back
1291/// to the model. The model then uses this information in its next response.
1292///
1293/// # Fields
1294///
1295/// - `tool_use_id`: Must match the `id` from the corresponding ToolUseBlock
1296/// - `content`: JSON result from the tool execution
1297///
1298/// # Example
1299///
1300/// ```
1301/// use open_agent::{ToolResultBlock, ContentBlock};
1302/// use serde_json::json;
1303///
1304/// let result = ToolResultBlock::new(
1305///     "call_123",
1306///     json!({"result": 4})
1307/// );
1308/// assert_eq!(result.tool_use_id(), "call_123");
1309/// ```
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct ToolResultBlock {
1312    /// ID of the tool use request this result corresponds to.
1313    ///
1314    /// Must match the `id` field from the ToolUseBlock that requested
1315    /// this tool execution. This correlation is essential for the model
1316    /// to understand which tool call produced which result.
1317    tool_use_id: String,
1318
1319    /// JSON result from executing the tool.
1320    ///
1321    /// Contains the tool's output data. Can be any valid JSON structure -
1322    /// the model will interpret it based on the tool's description and
1323    /// output schema.
1324    content: serde_json::Value,
1325}
1326
1327impl ToolResultBlock {
1328    /// Creates a new tool result block.
1329    ///
1330    /// # Parameters
1331    ///
1332    /// - `tool_use_id`: ID from the corresponding ToolUseBlock
1333    /// - `content`: JSON result from tool execution
1334    ///
1335    /// # Example
1336    ///
1337    /// ```
1338    /// use open_agent::ToolResultBlock;
1339    /// use serde_json::json;
1340    ///
1341    /// let result = ToolResultBlock::new(
1342    ///     "call_xyz",
1343    ///     json!({
1344    ///         "status": "success",
1345    ///         "data": {"temperature": 72}
1346    ///     })
1347    /// );
1348    /// ```
1349    pub fn new(tool_use_id: impl Into<String>, content: serde_json::Value) -> Self {
1350        Self {
1351            tool_use_id: tool_use_id.into(),
1352            content,
1353        }
1354    }
1355
1356    /// Returns the ID of the tool use request this result corresponds to.
1357    pub fn tool_use_id(&self) -> &str {
1358        &self.tool_use_id
1359    }
1360
1361    /// Returns the JSON result from executing the tool.
1362    pub fn content(&self) -> &serde_json::Value {
1363        &self.content
1364    }
1365}
1366
1367/// Image detail level for vision API calls.
1368///
1369/// Controls the resolution and token cost of image processing.
1370///
1371/// # Token Costs Vary by Model ⚠️
1372///
1373/// **OpenAI Vision API** (reference values):
1374/// - `Low`: ~85 tokens (512x512 max resolution)
1375/// - `High`: Variable tokens based on image dimensions
1376/// - `Auto`: Model decides (balanced default)
1377///
1378/// **Local models** (llama.cpp, Ollama, vLLM):
1379/// - May have **completely different** token calculations
1380/// - Some models don't charge tokens for images at all
1381/// - The `ImageDetail` setting may be ignored entirely
1382///
1383/// **Recommendation:** Always benchmark your specific model to understand
1384/// actual token consumption. Do not rely on OpenAI's values for capacity planning
1385/// with local models.
1386///
1387/// # Examples
1388///
1389/// ```
1390/// use open_agent::ImageDetail;
1391///
1392/// let detail = ImageDetail::High;
1393/// assert_eq!(detail.to_string(), "high");
1394/// ```
1395#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1396#[serde(rename_all = "lowercase")]
1397#[derive(Default)]
1398pub enum ImageDetail {
1399    /// Low resolution (512x512), fixed 85 tokens
1400    Low,
1401    /// High resolution, variable tokens based on dimensions
1402    High,
1403    /// Automatic selection (default)
1404    #[default]
1405    Auto,
1406}
1407
1408impl std::fmt::Display for ImageDetail {
1409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1410        match self {
1411            ImageDetail::Low => write!(f, "low"),
1412            ImageDetail::High => write!(f, "high"),
1413            ImageDetail::Auto => write!(f, "auto"),
1414        }
1415    }
1416}
1417
1418/// Image content block for vision-capable models.
1419///
1420/// Supports both URL-based images and base64-encoded images.
1421///
1422/// # Examples
1423///
1424/// ```
1425/// use open_agent::{ImageBlock, ImageDetail};
1426///
1427/// // From URL
1428/// let image = ImageBlock::from_url("https://example.com/image.jpg")?;
1429///
1430/// // From base64 (use properly formatted base64)
1431/// let image = ImageBlock::from_base64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "image/png")?;
1432///
1433/// // With detail level
1434/// let image = ImageBlock::from_url("https://example.com/image.jpg")?
1435///     .with_detail(ImageDetail::High);
1436/// # Ok::<(), open_agent::Error>(())
1437/// ```
1438#[derive(Debug, Clone, Serialize, Deserialize)]
1439pub struct ImageBlock {
1440    url: String,
1441    #[serde(default)]
1442    detail: ImageDetail,
1443}
1444
1445impl ImageBlock {
1446    /// Creates a new image block from a URL.
1447    ///
1448    /// # Arguments
1449    ///
1450    /// * `url` - The image URL (must be HTTP, HTTPS, or data URI)
1451    ///
1452    /// # Errors
1453    ///
1454    /// Returns `Error::InvalidInput` if:
1455    /// - URL is empty
1456    /// - URL contains control characters (newline, tab, null, etc.)
1457    /// - URL scheme is not `http://`, `https://`, or `data:`
1458    /// - Data URI is malformed (missing MIME type or base64 encoding)
1459    /// - Data URI base64 portion has invalid characters, length, or padding
1460    ///
1461    /// # Warnings
1462    ///
1463    /// - Logs a warning to stderr if URL exceeds 2000 characters
1464    ///
1465    /// # Example
1466    ///
1467    /// ```
1468    /// use open_agent::ImageBlock;
1469    ///
1470    /// let image = ImageBlock::from_url("https://example.com/cat.jpg")?;
1471    /// assert_eq!(image.url(), "https://example.com/cat.jpg");
1472    /// # Ok::<(), open_agent::Error>(())
1473    /// ```
1474    pub fn from_url(url: impl Into<String>) -> crate::Result<Self> {
1475        let url = url.into();
1476
1477        // Validate URL is not empty
1478        if url.is_empty() {
1479            return Err(crate::Error::invalid_input("Image URL cannot be empty"));
1480        }
1481
1482        // Check for control characters in URL
1483        if url.contains(char::is_control) {
1484            return Err(crate::Error::invalid_input(
1485                "Image URL contains invalid control characters",
1486            ));
1487        }
1488
1489        // Warn about very long URLs (>2000 chars)
1490        if url.len() > 2000 {
1491            eprintln!(
1492                "WARNING: Very long image URL ({} chars). \
1493                 Some APIs may have URL length limits.",
1494                url.len()
1495            );
1496        }
1497
1498        // Validate URL scheme
1499        if url.starts_with("http://") || url.starts_with("https://") {
1500            // Valid HTTP/HTTPS URL
1501            Ok(Self {
1502                url,
1503                detail: ImageDetail::default(),
1504            })
1505        } else if let Some(mime_part) = url.strip_prefix("data:") {
1506            // Validate data URI format: data:MIME;base64,DATA
1507            if !url.contains(";base64,") {
1508                return Err(crate::Error::invalid_input(
1509                    "Data URI must be in format: data:image/TYPE;base64,DATA",
1510                ));
1511            }
1512
1513            // Extract MIME type from data:MIME;base64,DATA
1514            let mime_type = if let Some(semicolon_pos) = mime_part.find(';') {
1515                &mime_part[..semicolon_pos]
1516            } else {
1517                return Err(crate::Error::invalid_input(
1518                    "Malformed data URI: missing MIME type",
1519                ));
1520            };
1521
1522            if mime_type.is_empty() || !mime_type.starts_with("image/") {
1523                return Err(crate::Error::invalid_input(
1524                    "Data URI MIME type must start with 'image/'",
1525                ));
1526            }
1527
1528            // Extract and validate base64 data portion
1529            if let Some(base64_start_pos) = url.find(";base64,") {
1530                let base64_data = &url[base64_start_pos + 8..]; // Skip ";base64,"
1531
1532                // Validate base64 data using same rules as from_base64()
1533                // Check data is not empty
1534                if base64_data.is_empty() {
1535                    return Err(crate::Error::invalid_input(
1536                        "Data URI base64 data cannot be empty",
1537                    ));
1538                }
1539
1540                // Check character set
1541                if !base64_data
1542                    .chars()
1543                    .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
1544                {
1545                    return Err(crate::Error::invalid_input(
1546                        "Data URI base64 data contains invalid characters. Valid characters: A-Z, a-z, 0-9, +, /, =",
1547                    ));
1548                }
1549
1550                // Check length (must be multiple of 4)
1551                if base64_data.len() % 4 != 0 {
1552                    return Err(crate::Error::invalid_input(
1553                        "Data URI base64 data has invalid length (must be multiple of 4)",
1554                    ));
1555                }
1556
1557                // Validate padding
1558                let equals_count = base64_data.chars().filter(|c| *c == '=').count();
1559                if equals_count > 2 {
1560                    return Err(crate::Error::invalid_input(
1561                        "Data URI base64 data has invalid padding (max 2 '=' characters allowed)",
1562                    ));
1563                }
1564                // Padding must be at the end
1565                if equals_count > 0 {
1566                    let trimmed = base64_data.trim_end_matches('=');
1567                    if trimmed.len() + equals_count != base64_data.len() {
1568                        return Err(crate::Error::invalid_input(
1569                            "Data URI base64 padding characters must be at the end",
1570                        ));
1571                    }
1572                }
1573            }
1574
1575            Ok(Self {
1576                url,
1577                detail: ImageDetail::default(),
1578            })
1579        } else {
1580            Err(crate::Error::invalid_input(
1581                "Image URL must start with http://, https://, or data:",
1582            ))
1583        }
1584    }
1585
1586    /// Creates a new image block from base64-encoded data.
1587    ///
1588    /// # Arguments
1589    ///
1590    /// * `base64_data` - The base64-encoded image data
1591    /// * `mime_type` - The MIME type (e.g., "image/jpeg", "image/png")
1592    ///
1593    /// # Errors
1594    ///
1595    /// Returns `Error::InvalidInput` if:
1596    /// - Base64 data is empty
1597    /// - Base64 contains invalid characters (only A-Z, a-z, 0-9, +, /, = allowed)
1598    /// - Base64 length is not a multiple of 4
1599    /// - Base64 has invalid padding (more than 2 '=' characters or not at end)
1600    /// - MIME type is empty
1601    /// - MIME type does not start with "image/"
1602    /// - MIME type contains injection characters (;, \\n, \\r, ,)
1603    ///
1604    /// # Warnings
1605    ///
1606    /// - Logs a warning to stderr if base64 data exceeds 10MB (~7.5MB decoded)
1607    ///
1608    /// # Example
1609    ///
1610    /// ```
1611    /// use open_agent::ImageBlock;
1612    ///
1613    /// let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
1614    /// let image = ImageBlock::from_base64(base64, "image/png")?;
1615    /// assert!(image.url().starts_with("data:image/png;base64,"));
1616    /// # Ok::<(), open_agent::Error>(())
1617    /// ```
1618    pub fn from_base64(
1619        base64_data: impl AsRef<str>,
1620        mime_type: impl AsRef<str>,
1621    ) -> crate::Result<Self> {
1622        let data = base64_data.as_ref();
1623        let mime = mime_type.as_ref();
1624
1625        // Validate base64 data is not empty
1626        if data.is_empty() {
1627            return Err(crate::Error::invalid_input(
1628                "Base64 image data cannot be empty",
1629            ));
1630        }
1631
1632        // Validate base64 character set (alphanumeric + +/=)
1633        // This catches common errors like spaces, special characters, etc.
1634        if !data
1635            .chars()
1636            .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
1637        {
1638            return Err(crate::Error::invalid_input(
1639                "Base64 data contains invalid characters. Valid characters: A-Z, a-z, 0-9, +, /, =",
1640            ));
1641        }
1642
1643        // Validate base64 padding (length must be multiple of 4)
1644        if data.len() % 4 != 0 {
1645            return Err(crate::Error::invalid_input(
1646                "Base64 data has invalid length (must be multiple of 4)",
1647            ));
1648        }
1649
1650        // Validate padding characters only appear at the end (max 2)
1651        let equals_count = data.chars().filter(|c| *c == '=').count();
1652        if equals_count > 2 {
1653            return Err(crate::Error::invalid_input(
1654                "Base64 data has invalid padding (max 2 '=' characters allowed)",
1655            ));
1656        }
1657        if equals_count > 0 {
1658            // Padding must be at the end
1659            let trimmed = data.trim_end_matches('=');
1660            if trimmed.len() + equals_count != data.len() {
1661                return Err(crate::Error::invalid_input(
1662                    "Base64 padding characters must be at the end",
1663                ));
1664            }
1665        }
1666
1667        // Validate MIME type is not empty
1668        if mime.is_empty() {
1669            return Err(crate::Error::invalid_input("MIME type cannot be empty"));
1670        }
1671
1672        // Validate MIME type starts with "image/"
1673        if !mime.starts_with("image/") {
1674            return Err(crate::Error::invalid_input(
1675                "MIME type must start with 'image/' (e.g., 'image/png', 'image/jpeg')",
1676            ));
1677        }
1678
1679        // Check for MIME type injection characters
1680        if mime.contains([';', ',', '\n', '\r']) {
1681            return Err(crate::Error::invalid_input(
1682                "MIME type contains invalid characters (;, \\n, \\r not allowed)",
1683            ));
1684        }
1685
1686        // Warn about extremely large base64 data (>10MB)
1687        if data.len() > 10_000_000 {
1688            eprintln!(
1689                "WARNING: Very large base64 image data ({} chars, ~{:.1}MB). \
1690                 This may exceed API limits or cause performance issues.",
1691                data.len(),
1692                (data.len() as f64 * 0.75) / 1_000_000.0
1693            );
1694        }
1695
1696        let url = format!("data:{};base64,{}", mime, data);
1697        Ok(Self {
1698            url,
1699            detail: ImageDetail::default(),
1700        })
1701    }
1702
1703    /// Creates a new image block from a local file path.
1704    ///
1705    /// This is a convenience method that reads the file from disk, encodes it as
1706    /// base64, and creates an ImageBlock with a data URI. The MIME type is inferred
1707    /// from the file extension.
1708    ///
1709    /// # Arguments
1710    ///
1711    /// * `path` - Path to the image file on the local filesystem
1712    ///
1713    /// # Errors
1714    ///
1715    /// Returns `Error::InvalidInput` if:
1716    /// - File cannot be read
1717    /// - File extension is missing or unsupported
1718    /// - File is too large (>10MB warning)
1719    ///
1720    /// # Supported Formats
1721    ///
1722    /// - `.jpg`, `.jpeg` → `image/jpeg`
1723    /// - `.png` → `image/png`
1724    /// - `.gif` → `image/gif`
1725    /// - `.webp` → `image/webp`
1726    /// - `.bmp` → `image/bmp`
1727    /// - `.svg` → `image/svg+xml`
1728    ///
1729    /// # Example
1730    ///
1731    /// ```no_run
1732    /// use open_agent::ImageBlock;
1733    ///
1734    /// let image = ImageBlock::from_file_path("/path/to/photo.jpg")?;
1735    /// # Ok::<(), open_agent::Error>(())
1736    /// ```
1737    ///
1738    /// # Security Note
1739    ///
1740    /// This method reads files from the local filesystem. Ensure the path comes from
1741    /// a trusted source to prevent unauthorized file access.
1742    pub fn from_file_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
1743        use base64::{Engine as _, engine::general_purpose};
1744
1745        let path = path.as_ref();
1746
1747        // Read file bytes
1748        let bytes = std::fs::read(path).map_err(|e| {
1749            crate::Error::invalid_input(format!(
1750                "Failed to read image file '{}': {}",
1751                path.display(),
1752                e
1753            ))
1754        })?;
1755
1756        // Determine MIME type from file extension
1757        let mime_type = match path.extension().and_then(|e| e.to_str()) {
1758            Some("jpg") | Some("jpeg") => "image/jpeg",
1759            Some("png") => "image/png",
1760            Some("gif") => "image/gif",
1761            Some("webp") => "image/webp",
1762            Some("bmp") => "image/bmp",
1763            Some("svg") => "image/svg+xml",
1764            Some(ext) => {
1765                return Err(crate::Error::invalid_input(format!(
1766                    "Unsupported image file extension: .{}. Supported: jpg, jpeg, png, gif, webp, bmp, svg",
1767                    ext
1768                )));
1769            }
1770            None => {
1771                return Err(crate::Error::invalid_input(
1772                    "Image file path must have a file extension (e.g., .jpg, .png)",
1773                ));
1774            }
1775        };
1776
1777        // Encode to base64
1778        let base64_data = general_purpose::STANDARD.encode(&bytes);
1779
1780        // Use existing from_base64 method for validation
1781        Self::from_base64(&base64_data, mime_type)
1782    }
1783
1784    /// Sets the image detail level.
1785    ///
1786    /// # Example
1787    ///
1788    /// ```
1789    /// use open_agent::{ImageBlock, ImageDetail};
1790    ///
1791    /// let image = ImageBlock::from_url("https://example.com/image.jpg")?
1792    ///     .with_detail(ImageDetail::High);
1793    /// # Ok::<(), open_agent::Error>(())
1794    /// ```
1795    pub fn with_detail(mut self, detail: ImageDetail) -> Self {
1796        self.detail = detail;
1797        self
1798    }
1799
1800    /// Returns the image URL (or data URI for base64 images).
1801    pub fn url(&self) -> &str {
1802        &self.url
1803    }
1804
1805    /// Returns the image detail level.
1806    pub fn detail(&self) -> ImageDetail {
1807        self.detail
1808    }
1809}
1810
1811/// A complete message in a conversation.
1812///
1813/// Messages are the primary unit of communication in the agent system. Each
1814/// message has a role (who sent it) and content (what it contains). Content
1815/// is structured as a vector of blocks to support multi-modal communication.
1816///
1817/// # Structure
1818///
1819/// - `role`: Who sent the message ([`MessageRole`])
1820/// - `content`: What the message contains (one or more [`ContentBlock`]s)
1821///
1822/// # Message Patterns
1823///
1824/// ## Simple Text Message
1825/// ```
1826/// use open_agent::Message;
1827///
1828/// let msg = Message::user("What's the weather?");
1829/// ```
1830///
1831/// ## Assistant Response with Tool Call
1832/// ```
1833/// use open_agent::{Message, ContentBlock, TextBlock, ToolUseBlock};
1834/// use serde_json::json;
1835///
1836/// let msg = Message::assistant(vec![
1837///     ContentBlock::Text(TextBlock::new("Let me check that for you.")),
1838///     ContentBlock::ToolUse(ToolUseBlock::new(
1839///         "call_123",
1840///         "get_weather",
1841///         json!({"location": "San Francisco"})
1842///     ))
1843/// ]);
1844/// ```
1845///
1846/// ## Tool Result
1847/// ```
1848/// use open_agent::{Message, ContentBlock, ToolResultBlock};
1849/// use serde_json::json;
1850///
1851/// let msg = Message::user_with_blocks(vec![
1852///     ContentBlock::ToolResult(ToolResultBlock::new(
1853///         "call_123",
1854///         json!({"temp": 72, "conditions": "sunny"})
1855///     ))
1856/// ]);
1857/// ```
1858#[derive(Debug, Clone, Serialize, Deserialize)]
1859pub struct Message {
1860    /// The role/sender of this message.
1861    pub role: MessageRole,
1862
1863    /// The content blocks that make up this message.
1864    ///
1865    /// A message can contain multiple blocks of different types. For example,
1866    /// an assistant message might have both text and tool use blocks.
1867    pub content: Vec<ContentBlock>,
1868}
1869
1870impl Message {
1871    /// Creates a new message with the specified role and content.
1872    ///
1873    /// This is the most general constructor. For convenience, use the
1874    /// role-specific constructors like [`user()`](Message::user),
1875    /// [`assistant()`](Message::assistant), etc.
1876    ///
1877    /// # Example
1878    ///
1879    /// ```
1880    /// use open_agent::{Message, MessageRole, ContentBlock, TextBlock};
1881    ///
1882    /// let msg = Message::new(
1883    ///     MessageRole::User,
1884    ///     vec![ContentBlock::Text(TextBlock::new("Hello"))]
1885    /// );
1886    /// ```
1887    pub fn new(role: MessageRole, content: Vec<ContentBlock>) -> Self {
1888        Self { role, content }
1889    }
1890
1891    /// Creates a user message with simple text content.
1892    ///
1893    /// This is the most common way to create user messages. For more complex
1894    /// content with multiple blocks, use [`user_with_blocks()`](Message::user_with_blocks).
1895    ///
1896    /// # Example
1897    ///
1898    /// ```
1899    /// use open_agent::Message;
1900    ///
1901    /// let msg = Message::user("What is 2+2?");
1902    /// ```
1903    pub fn user(text: impl Into<String>) -> Self {
1904        Self {
1905            role: MessageRole::User,
1906            content: vec![ContentBlock::Text(TextBlock::new(text))],
1907        }
1908    }
1909
1910    /// Creates an assistant message with the specified content blocks.
1911    ///
1912    /// Assistant messages often contain multiple content blocks (text + tool use).
1913    /// This method takes a vector of blocks for maximum flexibility.
1914    ///
1915    /// # Example
1916    ///
1917    /// ```
1918    /// use open_agent::{Message, ContentBlock, TextBlock};
1919    ///
1920    /// let msg = Message::assistant(vec![
1921    ///     ContentBlock::Text(TextBlock::new("The answer is 4"))
1922    /// ]);
1923    /// ```
1924    pub fn assistant(content: Vec<ContentBlock>) -> Self {
1925        Self {
1926            role: MessageRole::Assistant,
1927            content,
1928        }
1929    }
1930
1931    /// Creates a system message with simple text content.
1932    ///
1933    /// System messages establish the agent's behavior and context. They're
1934    /// typically sent at the start of a conversation.
1935    ///
1936    /// # Example
1937    ///
1938    /// ```
1939    /// use open_agent::Message;
1940    ///
1941    /// let msg = Message::system("You are a helpful assistant. Be concise.");
1942    /// ```
1943    pub fn system(text: impl Into<String>) -> Self {
1944        Self {
1945            role: MessageRole::System,
1946            content: vec![ContentBlock::Text(TextBlock::new(text))],
1947        }
1948    }
1949
1950    /// Creates a user message with custom content blocks.
1951    ///
1952    /// Use this when you need to send structured content beyond simple text,
1953    /// such as tool results. For simple text messages, prefer
1954    /// [`user()`](Message::user).
1955    ///
1956    /// # Example
1957    ///
1958    /// ```
1959    /// use open_agent::{Message, ContentBlock, ToolResultBlock};
1960    /// use serde_json::json;
1961    ///
1962    /// let msg = Message::user_with_blocks(vec![
1963    ///     ContentBlock::ToolResult(ToolResultBlock::new(
1964    ///         "call_123",
1965    ///         json!({"result": "success"})
1966    ///     ))
1967    /// ]);
1968    /// ```
1969    pub fn user_with_blocks(content: Vec<ContentBlock>) -> Self {
1970        Self {
1971            role: MessageRole::User,
1972            content,
1973        }
1974    }
1975
1976    /// Creates a user message with text and an image from a URL.
1977    ///
1978    /// This is a convenience method for the common pattern of sending text with
1979    /// an image. The image uses `ImageDetail::Auto` by default. For more control
1980    /// over detail level, use [`user_with_image_detail()`](Message::user_with_image_detail).
1981    ///
1982    /// # Arguments
1983    ///
1984    /// * `text` - The text prompt
1985    /// * `image_url` - URL of the image (http/https or data URI)
1986    ///
1987    /// # Errors
1988    ///
1989    /// Returns `Error::InvalidInput` if the image URL is invalid (empty, wrong scheme, etc.)
1990    ///
1991    /// # Example
1992    ///
1993    /// ```
1994    /// use open_agent::Message;
1995    ///
1996    /// let msg = Message::user_with_image(
1997    ///     "What's in this image?",
1998    ///     "https://example.com/photo.jpg"
1999    /// )?;
2000    /// # Ok::<(), open_agent::Error>(())
2001    /// ```
2002    pub fn user_with_image(
2003        text: impl Into<String>,
2004        image_url: impl Into<String>,
2005    ) -> crate::Result<Self> {
2006        Ok(Self {
2007            role: MessageRole::User,
2008            content: vec![
2009                ContentBlock::Text(TextBlock::new(text)),
2010                ContentBlock::Image(ImageBlock::from_url(image_url)?),
2011            ],
2012        })
2013    }
2014
2015    /// Creates a user message with text and an image with specified detail level.
2016    ///
2017    /// Use this when you need control over the image detail level for token cost
2018    /// management. On OpenAI's Vision API: `ImageDetail::Low` uses ~85 tokens,
2019    /// `ImageDetail::High` uses more tokens based on image dimensions, and
2020    /// `ImageDetail::Auto` lets the model decide. Local models may have very different token costs.
2021    ///
2022    /// # Arguments
2023    ///
2024    /// * `text` - The text prompt
2025    /// * `image_url` - URL of the image (http/https or data URI)
2026    /// * `detail` - Detail level (Low, High, or Auto)
2027    ///
2028    /// # Errors
2029    ///
2030    /// Returns `Error::InvalidInput` if the image URL is invalid (empty, wrong scheme, etc.)
2031    ///
2032    /// # Example
2033    ///
2034    /// ```
2035    /// use open_agent::{Message, ImageDetail};
2036    ///
2037    /// let msg = Message::user_with_image_detail(
2038    ///     "Analyze this diagram in detail",
2039    ///     "https://example.com/diagram.png",
2040    ///     ImageDetail::High
2041    /// )?;
2042    /// # Ok::<(), open_agent::Error>(())
2043    /// ```
2044    pub fn user_with_image_detail(
2045        text: impl Into<String>,
2046        image_url: impl Into<String>,
2047        detail: ImageDetail,
2048    ) -> crate::Result<Self> {
2049        Ok(Self {
2050            role: MessageRole::User,
2051            content: vec![
2052                ContentBlock::Text(TextBlock::new(text)),
2053                ContentBlock::Image(ImageBlock::from_url(image_url)?.with_detail(detail)),
2054            ],
2055        })
2056    }
2057
2058    /// Creates a user message with text and a base64-encoded image.
2059    ///
2060    /// This is useful when you have image data in memory and want to send it
2061    /// without uploading to a URL first. The image will be encoded as a data URI.
2062    ///
2063    /// # Arguments
2064    ///
2065    /// * `text` - The text prompt
2066    /// * `base64_data` - Base64-encoded image data
2067    /// * `mime_type` - MIME type (e.g., "image/png", "image/jpeg")
2068    ///
2069    /// # Errors
2070    ///
2071    /// Returns `Error::InvalidInput` if the base64 data or MIME type is invalid
2072    ///
2073    /// # Example
2074    ///
2075    /// ```
2076    /// use open_agent::Message;
2077    ///
2078    /// // Use properly formatted base64 (length divisible by 4, valid chars)
2079    /// let base64_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
2080    /// let msg = Message::user_with_base64_image(
2081    ///     "What's this image?",
2082    ///     base64_data,
2083    ///     "image/png"
2084    /// )?;
2085    /// # Ok::<(), open_agent::Error>(())
2086    /// ```
2087    pub fn user_with_base64_image(
2088        text: impl Into<String>,
2089        base64_data: impl AsRef<str>,
2090        mime_type: impl AsRef<str>,
2091    ) -> crate::Result<Self> {
2092        Ok(Self {
2093            role: MessageRole::User,
2094            content: vec![
2095                ContentBlock::Text(TextBlock::new(text)),
2096                ContentBlock::Image(ImageBlock::from_base64(base64_data, mime_type)?),
2097            ],
2098        })
2099    }
2100}
2101
2102/// OpenAI API message format for serialization.
2103///
2104/// This struct represents the wire format for messages when communicating
2105/// with OpenAI-compatible APIs. It differs from the internal [`Message`]
2106/// type to accommodate the specific serialization requirements of the
2107/// OpenAI API.
2108///
2109/// # Key Differences from Internal Message Type
2110///
2111/// - Content is a flat string rather than structured blocks
2112/// - Tool calls are represented in OpenAI's specific format
2113/// - Supports both sending tool calls (via `tool_calls`) and tool results
2114///   (via `tool_call_id`)
2115///
2116/// # Serialization
2117///
2118/// Optional fields are skipped when `None` to keep payloads minimal.
2119///
2120/// # Usage
2121///
2122/// This type is typically created by the SDK internally when converting
2123/// from [`Message`] to API format. Users rarely need to construct these
2124/// directly.
2125///
2126/// # OpenAI Content Format
2127///
2128/// OpenAI content format supporting both string and array.
2129///
2130/// For backward compatibility, text-only messages use string format.
2131/// Messages with images use array format with multiple content parts.
2132#[derive(Debug, Clone, Serialize, Deserialize)]
2133#[serde(untagged)]
2134pub enum OpenAIContent {
2135    /// Simple text string (backward compatible)
2136    Text(String),
2137    /// Array of content parts (text and/or images)
2138    Parts(Vec<OpenAIContentPart>),
2139}
2140
2141/// A single content part in an OpenAI message.
2142///
2143/// Can be either text or an image URL. This is a tagged enum that prevents
2144/// invalid states (e.g., having both text and image_url, or neither).
2145#[derive(Debug, Clone, Serialize, Deserialize)]
2146#[serde(tag = "type", rename_all = "snake_case")]
2147pub enum OpenAIContentPart {
2148    /// Text content part
2149    Text {
2150        /// The text content
2151        text: String,
2152    },
2153    /// Image URL content part
2154    #[serde(rename = "image_url")]
2155    ImageUrl {
2156        /// The image URL details
2157        image_url: OpenAIImageUrl,
2158    },
2159}
2160
2161impl OpenAIContentPart {
2162    /// Creates a text content part.
2163    ///
2164    /// # Example
2165    ///
2166    /// ```
2167    /// use open_agent::OpenAIContentPart;
2168    ///
2169    /// let part = OpenAIContentPart::text("Hello world");
2170    /// ```
2171    pub fn text(text: impl Into<String>) -> Self {
2172        Self::Text { text: text.into() }
2173    }
2174
2175    /// Creates an image content part from a validated ImageBlock.
2176    ///
2177    /// This is the preferred way to create image content parts as it ensures
2178    /// the image URL has been validated against security issues (XSS, file disclosure, etc.)
2179    ///
2180    /// # Example
2181    ///
2182    /// ```
2183    /// use open_agent::{OpenAIContentPart, ImageBlock, ImageDetail};
2184    ///
2185    /// let image = ImageBlock::from_url("https://example.com/img.jpg")
2186    ///     .expect("Valid URL");
2187    /// let part = OpenAIContentPart::from_image(&image);
2188    /// ```
2189    pub fn from_image(image: &ImageBlock) -> Self {
2190        Self::ImageUrl {
2191            image_url: OpenAIImageUrl {
2192                url: image.url().to_string(),
2193                detail: Some(image.detail().to_string()),
2194            },
2195        }
2196    }
2197
2198    /// Creates an image URL content part directly (DEPRECATED).
2199    ///
2200    /// # Security Warning
2201    ///
2202    /// This method bypasses validation checks performed by `ImageBlock::from_url()`
2203    /// and `ImageBlock::from_base64()`. Prefer using `from_image()` instead.
2204    ///
2205    /// # Deprecation
2206    ///
2207    /// This method is deprecated and will be removed in v1.0. Use `from_image()` instead.
2208    ///
2209    /// # Example
2210    ///
2211    /// ```
2212    /// use open_agent::{OpenAIContentPart, ImageDetail};
2213    ///
2214    /// // Deprecated approach:
2215    /// let part = OpenAIContentPart::image_url("https://example.com/img.jpg", ImageDetail::High);
2216    ///
2217    /// // Preferred approach:
2218    /// use open_agent::ImageBlock;
2219    /// let image = ImageBlock::from_url("https://example.com/img.jpg").expect("Valid URL");
2220    /// let part = OpenAIContentPart::from_image(&image);
2221    /// ```
2222    #[deprecated(
2223        since = "0.6.0",
2224        note = "Use `from_image()` instead to ensure proper validation"
2225    )]
2226    pub fn image_url(url: impl Into<String>, detail: ImageDetail) -> Self {
2227        Self::ImageUrl {
2228            image_url: OpenAIImageUrl {
2229                url: url.into(),
2230                detail: Some(detail.to_string()),
2231            },
2232        }
2233    }
2234}
2235
2236/// OpenAI image URL structure.
2237#[derive(Debug, Clone, Serialize, Deserialize)]
2238pub struct OpenAIImageUrl {
2239    /// Image URL or data URI
2240    pub url: String,
2241    /// Detail level: "low", "high", or "auto"
2242    #[serde(skip_serializing_if = "Option::is_none")]
2243    pub detail: Option<String>,
2244}
2245
2246#[derive(Debug, Clone, Serialize, Deserialize)]
2247pub struct OpenAIMessage {
2248    /// Message role as a string ("system", "user", "assistant", "tool").
2249    pub role: String,
2250
2251    /// Message content (string for text-only, array for text+images).
2252    #[serde(skip_serializing_if = "Option::is_none")]
2253    pub content: Option<OpenAIContent>,
2254
2255    /// Tool calls requested by the assistant (assistant messages only).
2256    ///
2257    /// When the model wants to call tools, this field contains the list
2258    /// of tool invocations with their parameters. Only present in assistant
2259    /// messages.
2260    #[serde(skip_serializing_if = "Option::is_none")]
2261    pub tool_calls: Option<Vec<OpenAIToolCall>>,
2262
2263    /// ID of the tool call this message is responding to (tool messages only).
2264    ///
2265    /// When sending tool results back to the model, this field links the
2266    /// result to the original tool call request. Only present in tool
2267    /// messages.
2268    #[serde(skip_serializing_if = "Option::is_none")]
2269    pub tool_call_id: Option<String>,
2270}
2271
2272/// OpenAI tool call representation in API messages.
2273///
2274/// Represents a request from the model to execute a specific function/tool.
2275/// This is the wire format used in the OpenAI API, distinct from the internal
2276/// [`ToolUseBlock`] representation.
2277///
2278/// # Structure
2279///
2280/// Each tool call has:
2281/// - A unique ID for correlation with results
2282/// - A type (always "function" in current OpenAI API)
2283/// - Function details (name and arguments)
2284///
2285/// # Example JSON
2286///
2287/// ```json
2288/// {
2289///   "id": "call_abc123",
2290///   "type": "function",
2291///   "function": {
2292///     "name": "get_weather",
2293///     "arguments": "{\"location\":\"San Francisco\"}"
2294///   }
2295/// }
2296/// ```
2297#[derive(Debug, Clone, Serialize, Deserialize)]
2298pub struct OpenAIToolCall {
2299    /// Unique identifier for this tool call.
2300    ///
2301    /// Generated by the model. Used to correlate tool results back to
2302    /// this specific call.
2303    pub id: String,
2304
2305    /// Type of the call (always "function" in current API).
2306    ///
2307    /// The `rename` attribute ensures this serializes as `"type"` in JSON
2308    /// since `type` is a Rust keyword.
2309    #[serde(rename = "type")]
2310    pub call_type: String,
2311
2312    /// Function/tool details (name and arguments).
2313    pub function: OpenAIFunction,
2314}
2315
2316/// OpenAI function call details.
2317///
2318/// Contains the function name and its arguments in the OpenAI API format.
2319/// Note that arguments are serialized as a JSON string, not a JSON object,
2320/// which is an OpenAI API quirk.
2321///
2322/// # Arguments Format
2323///
2324/// The `arguments` field is a **JSON string**, not a parsed JSON object.
2325/// For example: `"{\"x\": 1, \"y\": 2}"` not `{"x": 1, "y": 2}`.
2326/// This must be parsed before use.
2327#[derive(Debug, Clone, Serialize, Deserialize)]
2328pub struct OpenAIFunction {
2329    /// Name of the function/tool to call.
2330    pub name: String,
2331
2332    /// Function arguments as a **JSON string** (OpenAI API quirk).
2333    ///
2334    /// Must be parsed as JSON before use. For example, this might contain
2335    /// the string `"{\"location\":\"NYC\",\"units\":\"fahrenheit\"}"` which
2336    /// needs to be parsed into an actual JSON value.
2337    pub arguments: String,
2338}
2339
2340/// Complete request payload for OpenAI chat completions API.
2341///
2342/// This struct is serialized and sent as the request body when making
2343/// API calls to OpenAI-compatible endpoints. It includes the model,
2344/// conversation history, and configuration parameters.
2345///
2346/// # Streaming
2347///
2348/// The SDK always uses streaming mode (`stream: true`) to enable real-time
2349/// response processing and better user experience.
2350///
2351/// # Optional Fields
2352///
2353/// Fields marked with `skip_serializing_if` are omitted from the JSON payload
2354/// when `None`, allowing the API provider to use its defaults.
2355///
2356/// # Example
2357///
2358/// ```ignore
2359/// use open_agent_sdk::types::{OpenAIRequest, OpenAIMessage};
2360///
2361/// let request = OpenAIRequest {
2362///     model: "gpt-4".to_string(),
2363///     messages: vec![
2364///         OpenAIMessage {
2365///             role: "user".to_string(),
2366///             content: "Hello!".to_string(),
2367///             tool_calls: None,
2368///             tool_call_id: None,
2369///         }
2370///     ],
2371///     stream: true,
2372///     max_tokens: Some(1000),
2373///     temperature: Some(0.7),
2374///     tools: None,
2375/// };
2376/// ```
2377#[derive(Debug, Clone, Serialize)]
2378pub struct OpenAIRequest {
2379    /// Model identifier (e.g., "gpt-4", "qwen2.5-32b-instruct").
2380    pub model: String,
2381
2382    /// Conversation history as a sequence of messages.
2383    ///
2384    /// Includes system prompt, user messages, assistant responses, and
2385    /// tool results. Order matters - messages are processed sequentially.
2386    pub messages: Vec<OpenAIMessage>,
2387
2388    /// Whether to stream the response.
2389    ///
2390    /// The SDK always sets this to `true` for better user experience.
2391    /// Streaming allows incremental processing of responses rather than
2392    /// waiting for the entire completion.
2393    pub stream: bool,
2394
2395    /// Maximum tokens to generate (optional).
2396    ///
2397    /// `None` uses the provider's default. Some providers require this
2398    /// to be set explicitly.
2399    #[serde(skip_serializing_if = "Option::is_none")]
2400    pub max_tokens: Option<u32>,
2401
2402    /// Sampling temperature (optional).
2403    ///
2404    /// `None` uses the provider's default. Controls randomness in
2405    /// generation.
2406    #[serde(skip_serializing_if = "Option::is_none")]
2407    pub temperature: Option<f32>,
2408
2409    /// Tools/functions available to the model (optional).
2410    ///
2411    /// When present, enables function calling. Each tool is described
2412    /// with a JSON schema defining its parameters. `None` means no
2413    /// tools are available.
2414    #[serde(skip_serializing_if = "Option::is_none")]
2415    pub tools: Option<Vec<serde_json::Value>>,
2416}
2417
2418/// A single chunk from OpenAI's streaming response.
2419///
2420/// When the SDK requests streaming responses (`stream: true`), the API
2421/// returns the response incrementally as a series of chunks. Each chunk
2422/// represents a small piece of the complete response, allowing the SDK
2423/// to process and display content as it's generated.
2424///
2425/// # Streaming Architecture
2426///
2427/// Instead of waiting for the entire response, streaming sends many small
2428/// chunks in rapid succession. Each chunk contains:
2429/// - Metadata (id, model, timestamp)
2430/// - One or more choices (usually just one for single completions)
2431/// - Incremental deltas with new content
2432///
2433/// # Server-Sent Events Format
2434///
2435/// Chunks are transmitted as Server-Sent Events (SSE) over HTTP:
2436/// ```text
2437/// data: {"id":"chunk_1","object":"chat.completion.chunk",...}
2438/// data: {"id":"chunk_2","object":"chat.completion.chunk",...}
2439/// data: [DONE]
2440/// ```
2441///
2442/// # Example Chunk JSON
2443///
2444/// ```json
2445/// {
2446///   "id": "chatcmpl-123",
2447///   "object": "chat.completion.chunk",
2448///   "created": 1677652288,
2449///   "model": "gpt-4",
2450///   "choices": [{
2451///     "index": 0,
2452///     "delta": {"content": "Hello"},
2453///     "finish_reason": null
2454///   }]
2455/// }
2456/// ```
2457#[derive(Debug, Clone, Deserialize)]
2458pub struct OpenAIChunk {
2459    /// Unique identifier for this completion.
2460    ///
2461    /// All chunks in a single streaming response share the same ID.
2462    /// Not actively used by the SDK but preserved for debugging.
2463    #[allow(dead_code)]
2464    pub id: String,
2465
2466    /// Object type (always "chat.completion.chunk" for streaming).
2467    ///
2468    /// Not actively used by the SDK but preserved for debugging.
2469    #[allow(dead_code)]
2470    pub object: String,
2471
2472    /// Unix timestamp of when this chunk was created.
2473    ///
2474    /// Not actively used by the SDK but preserved for debugging.
2475    #[allow(dead_code)]
2476    pub created: i64,
2477
2478    /// Model that generated this chunk.
2479    ///
2480    /// Not actively used by the SDK but preserved for debugging.
2481    #[allow(dead_code)]
2482    pub model: String,
2483
2484    /// Array of completion choices (usually contains one element).
2485    ///
2486    /// Each choice represents a possible completion. In normal usage,
2487    /// there's only one choice per chunk. This is the critical field
2488    /// that the SDK processes to extract content and tool calls.
2489    pub choices: Vec<OpenAIChoice>,
2490}
2491
2492/// A single choice/completion option in a streaming chunk.
2493///
2494/// In streaming responses, each chunk can theoretically contain multiple
2495/// choices (parallel completions), but in practice there's usually just one.
2496/// Each choice contains a delta with incremental updates and optionally a
2497/// finish reason when the generation is complete.
2498///
2499/// # Delta vs Complete Content
2500///
2501/// Unlike non-streaming responses that send complete messages, streaming
2502/// sends deltas - just the new content added in this chunk. The SDK
2503/// accumulates these deltas to build the complete response.
2504///
2505/// # Finish Reason
2506///
2507/// - `None`: More content is coming
2508/// - `Some("stop")`: Normal completion
2509/// - `Some("length")`: Hit max token limit
2510/// - `Some("tool_calls")`: Model wants to call tools
2511/// - `Some("content_filter")`: Blocked by content policy
2512#[derive(Debug, Clone, Deserialize)]
2513pub struct OpenAIChoice {
2514    /// Index of this choice in the choices array.
2515    ///
2516    /// Usually 0 since most requests generate a single completion.
2517    /// Not actively used by the SDK but preserved for debugging.
2518    #[allow(dead_code)]
2519    pub index: u32,
2520
2521    /// Incremental update/delta for this chunk.
2522    ///
2523    /// Contains the new content, tool calls, or other updates added in
2524    /// this specific chunk. The SDK processes this to update its internal
2525    /// state and accumulate the full response.
2526    pub delta: OpenAIDelta,
2527
2528    /// Reason why generation finished (None if still generating).
2529    ///
2530    /// Only present in the final chunk of a stream:
2531    /// - `None`: Generation is still in progress
2532    /// - `Some("stop")`: Completed normally
2533    /// - `Some("length")`: Hit token limit
2534    /// - `Some("tool_calls")`: Model requested tools
2535    /// - `Some("content_filter")`: Content was filtered
2536    ///
2537    /// The SDK uses this to detect completion and determine next actions.
2538    pub finish_reason: Option<String>,
2539}
2540
2541/// Incremental update in a streaming chunk.
2542///
2543/// Represents the new content/changes added in this specific chunk.
2544/// Unlike complete messages, deltas only contain what's new, not the
2545/// entire accumulated content. The SDK accumulates these deltas to
2546/// build the complete response.
2547///
2548/// # Incremental Nature
2549///
2550/// If the complete response is "Hello, world!", the deltas might be:
2551/// 1. `content: Some("Hello")`
2552/// 2. `content: Some(", ")`
2553/// 3. `content: Some("world")`
2554/// 4. `content: Some("!")`
2555///
2556/// The SDK concatenates these to build the full text.
2557///
2558/// # Tool Call Deltas
2559///
2560/// Tool calls are also streamed incrementally. The first delta might
2561/// include the tool ID and name, while subsequent deltas stream the
2562/// arguments JSON string piece by piece.
2563#[derive(Debug, Clone, Deserialize)]
2564pub struct OpenAIDelta {
2565    /// Role of the message (only in first chunk).
2566    ///
2567    /// Typically "assistant". Only appears in the first delta of a response
2568    /// to establish who's speaking. Subsequent deltas omit this field.
2569    /// Not actively used by the SDK but preserved for completeness.
2570    #[allow(dead_code)]
2571    #[serde(skip_serializing_if = "Option::is_none")]
2572    pub role: Option<String>,
2573
2574    /// Incremental text content added in this chunk.
2575    ///
2576    /// Contains the new text tokens generated. `None` if this chunk doesn't
2577    /// add text (e.g., it might only have tool call updates). The SDK
2578    /// concatenates these across chunks to build the complete response.
2579    #[serde(skip_serializing_if = "Option::is_none")]
2580    pub content: Option<String>,
2581
2582    /// Incremental tool call updates added in this chunk.
2583    ///
2584    /// When the model wants to call tools, tool call information is streamed
2585    /// incrementally. Each delta might add to different parts of the tool
2586    /// call (ID, name, arguments). The SDK accumulates these to reconstruct
2587    /// complete tool calls.
2588    #[serde(skip_serializing_if = "Option::is_none")]
2589    pub tool_calls: Option<Vec<OpenAIToolCallDelta>>,
2590}
2591
2592/// Incremental update for a tool call in streaming.
2593///
2594/// Tool calls are streamed piece-by-piece, with different chunks potentially
2595/// updating different parts. The SDK must accumulate these deltas to
2596/// reconstruct complete tool calls.
2597///
2598/// # Streaming Pattern
2599///
2600/// A complete tool call is typically streamed as:
2601/// 1. First chunk: `index: 0, id: Some("call_123"), type: Some("function")`
2602/// 2. Second chunk: `index: 0, function: Some(FunctionDelta { name: Some("search"), ... })`
2603/// 3. Multiple chunks: `index: 0, function: Some(FunctionDelta { arguments: Some("part") })`
2604///
2605/// The SDK uses the `index` to know which tool call to update, as multiple
2606/// tool calls can be streamed simultaneously.
2607///
2608/// # Index-Based Accumulation
2609///
2610/// The `index` field is crucial for tracking which tool call is being updated.
2611/// When the model calls multiple tools, each has a different index, and deltas
2612/// specify which one they're updating.
2613#[derive(Debug, Clone, Deserialize)]
2614pub struct OpenAIToolCallDelta {
2615    /// Index identifying which tool call this delta updates.
2616    ///
2617    /// When multiple tools are called, each has an index (0, 1, 2, ...).
2618    /// The SDK uses this to route delta updates to the correct tool call
2619    /// in its accumulation buffer.
2620    pub index: u32,
2621
2622    /// Tool call ID (only in first delta for this tool call).
2623    ///
2624    /// Generated by the model. Present in the first chunk for each tool
2625    /// call, then omitted in subsequent chunks. The SDK stores this to
2626    /// correlate results later.
2627    #[serde(skip_serializing_if = "Option::is_none")]
2628    pub id: Option<String>,
2629
2630    /// Type of call (always "function" when present).
2631    ///
2632    /// Only appears in the first delta for each tool call. Subsequent
2633    /// deltas omit this field. Not actively used by the SDK but preserved
2634    /// for completeness.
2635    #[allow(dead_code)]
2636    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
2637    pub call_type: Option<String>,
2638
2639    /// Incremental function details (name and/or arguments).
2640    ///
2641    /// Contains partial updates to the function name and arguments.
2642    /// The SDK accumulates these across chunks to build the complete
2643    /// function call specification.
2644    #[serde(skip_serializing_if = "Option::is_none")]
2645    pub function: Option<OpenAIFunctionDelta>,
2646}
2647
2648/// Incremental update for function details in streaming tool calls.
2649///
2650/// As the model streams a tool call, the function name and arguments are
2651/// sent incrementally. The name usually comes first in one chunk, then
2652/// arguments are streamed piece-by-piece as a JSON string.
2653///
2654/// # Arguments Streaming
2655///
2656/// The arguments field is particularly important to understand. It contains
2657/// **fragments of a JSON string** that must be accumulated and then parsed:
2658///
2659/// 1. Chunk 1: `arguments: Some("{")`
2660/// 2. Chunk 2: `arguments: Some("\"query\":")`
2661/// 3. Chunk 3: `arguments: Some("\"hello\"")`
2662/// 4. Chunk 4: `arguments: Some("}")`
2663///
2664/// The SDK concatenates these into `"{\"query\":\"hello\"}"` and then
2665/// parses it as JSON.
2666#[derive(Debug, Clone, Deserialize)]
2667pub struct OpenAIFunctionDelta {
2668    /// Function/tool name (only in first delta for this function).
2669    ///
2670    /// Present when the model first starts calling this function, then
2671    /// omitted in subsequent chunks. The SDK stores this to know which
2672    /// tool to execute.
2673    #[serde(skip_serializing_if = "Option::is_none")]
2674    pub name: Option<String>,
2675
2676    /// Incremental fragment of the arguments JSON string.
2677    ///
2678    /// Contains a piece of the complete JSON arguments string. The SDK
2679    /// must concatenate all argument fragments across chunks, then parse
2680    /// the complete string as JSON to get the actual parameters.
2681    ///
2682    /// For example, if the complete arguments should be:
2683    /// `{"x": 1, "y": 2}`
2684    ///
2685    /// This might be streamed as:
2686    /// - `Some("{\"x\": ")`
2687    /// - `Some("1, \"y\": ")`
2688    /// - `Some("2}")`
2689    #[serde(skip_serializing_if = "Option::is_none")]
2690    pub arguments: Option<String>,
2691}
2692
2693#[cfg(test)]
2694mod tests {
2695    use super::*;
2696
2697    #[test]
2698    fn test_agent_options_builder() {
2699        let options = AgentOptions::builder()
2700            .system_prompt("Test prompt")
2701            .model("test-model")
2702            .base_url("http://localhost:1234/v1")
2703            .api_key("test-key")
2704            .max_turns(5)
2705            .max_tokens(1000)
2706            .temperature(0.5)
2707            .timeout(30)
2708            .auto_execute_tools(true)
2709            .max_tool_iterations(10)
2710            .build()
2711            .unwrap();
2712
2713        assert_eq!(options.system_prompt, "Test prompt");
2714        assert_eq!(options.model, "test-model");
2715        assert_eq!(options.base_url, "http://localhost:1234/v1");
2716        assert_eq!(options.api_key, "test-key");
2717        assert_eq!(options.max_turns, 5);
2718        assert_eq!(options.max_tokens, Some(1000));
2719        assert_eq!(options.temperature, 0.5);
2720        assert_eq!(options.timeout, 30);
2721        assert!(options.auto_execute_tools);
2722        assert_eq!(options.max_tool_iterations, 10);
2723    }
2724
2725    #[test]
2726    fn test_agent_options_builder_defaults() {
2727        let options = AgentOptions::builder()
2728            .model("test-model")
2729            .base_url("http://localhost:1234/v1")
2730            .build()
2731            .unwrap();
2732
2733        assert_eq!(options.system_prompt, "");
2734        assert_eq!(options.api_key, "not-needed");
2735        assert_eq!(options.max_turns, 1);
2736        assert_eq!(options.max_tokens, Some(4096));
2737        assert_eq!(options.temperature, 0.7);
2738        assert_eq!(options.timeout, 60);
2739        assert!(!options.auto_execute_tools);
2740        assert_eq!(options.max_tool_iterations, 5);
2741    }
2742
2743    #[test]
2744    fn test_agent_options_builder_missing_required() {
2745        // Missing model
2746        let result = AgentOptions::builder()
2747            .base_url("http://localhost:1234/v1")
2748            .build();
2749        assert!(result.is_err());
2750
2751        // Missing base_url
2752        let result = AgentOptions::builder().model("test-model").build();
2753        assert!(result.is_err());
2754    }
2755
2756    #[test]
2757    fn test_message_user() {
2758        let msg = Message::user("Hello");
2759        assert!(matches!(msg.role, MessageRole::User));
2760        assert_eq!(msg.content.len(), 1);
2761        match &msg.content[0] {
2762            ContentBlock::Text(text) => assert_eq!(text.text, "Hello"),
2763            _ => panic!("Expected TextBlock"),
2764        }
2765    }
2766
2767    #[test]
2768    fn test_message_system() {
2769        let msg = Message::system("System prompt");
2770        assert!(matches!(msg.role, MessageRole::System));
2771        assert_eq!(msg.content.len(), 1);
2772        match &msg.content[0] {
2773            ContentBlock::Text(text) => assert_eq!(text.text, "System prompt"),
2774            _ => panic!("Expected TextBlock"),
2775        }
2776    }
2777
2778    #[test]
2779    fn test_message_assistant() {
2780        let content = vec![ContentBlock::Text(TextBlock::new("Response"))];
2781        let msg = Message::assistant(content);
2782        assert!(matches!(msg.role, MessageRole::Assistant));
2783        assert_eq!(msg.content.len(), 1);
2784    }
2785
2786    #[test]
2787    fn test_message_user_with_image() {
2788        let msg =
2789            Message::user_with_image("What's in this image?", "https://example.com/image.jpg")
2790                .unwrap();
2791        assert!(matches!(msg.role, MessageRole::User));
2792        assert_eq!(msg.content.len(), 2);
2793
2794        // Should have text first, then image
2795        match &msg.content[0] {
2796            ContentBlock::Text(text) => assert_eq!(text.text, "What's in this image?"),
2797            _ => panic!("Expected TextBlock at position 0"),
2798        }
2799        match &msg.content[1] {
2800            ContentBlock::Image(image) => {
2801                assert_eq!(image.url(), "https://example.com/image.jpg");
2802                assert_eq!(image.detail(), ImageDetail::Auto);
2803            }
2804            _ => panic!("Expected ImageBlock at position 1"),
2805        }
2806    }
2807
2808    #[test]
2809    fn test_message_user_with_image_and_detail() {
2810        let msg = Message::user_with_image_detail(
2811            "Analyze this in detail",
2812            "https://example.com/diagram.png",
2813            ImageDetail::High,
2814        )
2815        .unwrap();
2816        assert!(matches!(msg.role, MessageRole::User));
2817        assert_eq!(msg.content.len(), 2);
2818
2819        match &msg.content[1] {
2820            ContentBlock::Image(image) => {
2821                assert_eq!(image.detail(), ImageDetail::High);
2822            }
2823            _ => panic!("Expected ImageBlock"),
2824        }
2825    }
2826
2827    #[test]
2828    fn test_message_user_with_base64_image() {
2829        let base64_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ";
2830        let msg =
2831            Message::user_with_base64_image("What's this?", base64_data, "image/png").unwrap();
2832        assert!(matches!(msg.role, MessageRole::User));
2833        assert_eq!(msg.content.len(), 2);
2834
2835        match &msg.content[1] {
2836            ContentBlock::Image(image) => {
2837                assert!(image.url().starts_with("data:image/png;base64,"));
2838                assert!(image.url().contains(base64_data));
2839            }
2840            _ => panic!("Expected ImageBlock"),
2841        }
2842    }
2843
2844    #[test]
2845    fn test_text_block() {
2846        let block = TextBlock::new("Hello");
2847        assert_eq!(block.text, "Hello");
2848    }
2849
2850    #[test]
2851    fn test_tool_use_block() {
2852        let input = serde_json::json!({"arg": "value"});
2853        let block = ToolUseBlock::new("call_123", "tool_name", input.clone());
2854        assert_eq!(block.id(), "call_123");
2855        assert_eq!(block.name(), "tool_name");
2856        assert_eq!(block.input(), &input);
2857    }
2858
2859    #[test]
2860    fn test_tool_result_block() {
2861        let content = serde_json::json!({"result": "success"});
2862        let block = ToolResultBlock::new("call_123", content.clone());
2863        assert_eq!(block.tool_use_id(), "call_123");
2864        assert_eq!(block.content(), &content);
2865    }
2866
2867    // ========================================================================
2868    // Private Field Getters Tests (Issue #3 - RED Phase)
2869    // ========================================================================
2870
2871    #[test]
2872    fn test_tool_use_block_getters() {
2873        // RED: Test getter methods for ToolUseBlock (don't exist yet)
2874        let input = serde_json::json!({"x": 5});
2875        let block = ToolUseBlock::new("call_123", "calculator", input.clone());
2876
2877        // These should compile with getters
2878        assert_eq!(block.id(), "call_123");
2879        assert_eq!(block.name(), "calculator");
2880        assert_eq!(block.input(), &input);
2881    }
2882
2883    #[test]
2884    fn test_tool_result_block_getters() {
2885        // RED: Test getter methods for ToolResultBlock (don't exist yet)
2886        let content = serde_json::json!({"answer": 42});
2887        let result = ToolResultBlock::new("call_123", content.clone());
2888
2889        assert_eq!(result.tool_use_id(), "call_123");
2890        assert_eq!(result.content(), &content);
2891    }
2892
2893    #[test]
2894    fn test_message_role_serialization() {
2895        assert_eq!(
2896            serde_json::to_string(&MessageRole::User).unwrap(),
2897            "\"user\""
2898        );
2899        assert_eq!(
2900            serde_json::to_string(&MessageRole::System).unwrap(),
2901            "\"system\""
2902        );
2903        assert_eq!(
2904            serde_json::to_string(&MessageRole::Assistant).unwrap(),
2905            "\"assistant\""
2906        );
2907        assert_eq!(
2908            serde_json::to_string(&MessageRole::Tool).unwrap(),
2909            "\"tool\""
2910        );
2911    }
2912
2913    #[test]
2914    fn test_openai_request_serialization() {
2915        let request = OpenAIRequest {
2916            model: "gpt-3.5".to_string(),
2917            messages: vec![OpenAIMessage {
2918                role: "user".to_string(),
2919                content: Some(OpenAIContent::Text("Hello".to_string())),
2920                tool_calls: None,
2921                tool_call_id: None,
2922            }],
2923            stream: true,
2924            max_tokens: Some(100),
2925            temperature: Some(0.7),
2926            tools: None,
2927        };
2928
2929        let json = serde_json::to_string(&request).unwrap();
2930        assert!(json.contains("gpt-3.5"));
2931        assert!(json.contains("Hello"));
2932        assert!(json.contains("\"stream\":true"));
2933    }
2934
2935    #[test]
2936    fn test_openai_chunk_deserialization() {
2937        let json = r#"{
2938            "id": "chunk_1",
2939            "object": "chat.completion.chunk",
2940            "created": 1234567890,
2941            "model": "gpt-3.5",
2942            "choices": [{
2943                "index": 0,
2944                "delta": {
2945                    "content": "Hello"
2946                },
2947                "finish_reason": null
2948            }]
2949        }"#;
2950
2951        let chunk: OpenAIChunk = serde_json::from_str(json).unwrap();
2952        assert_eq!(chunk.id, "chunk_1");
2953        assert_eq!(chunk.choices.len(), 1);
2954        assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
2955    }
2956
2957    #[test]
2958    fn test_content_block_serialization() {
2959        let text_block = ContentBlock::Text(TextBlock::new("Hello"));
2960        let json = serde_json::to_string(&text_block).unwrap();
2961        assert!(json.contains("\"type\":\"text\""));
2962        assert!(json.contains("Hello"));
2963    }
2964
2965    #[test]
2966    fn test_agent_options_clone() {
2967        let options1 = AgentOptions::builder()
2968            .model("test-model")
2969            .base_url("http://localhost:1234/v1")
2970            .build()
2971            .unwrap();
2972
2973        let options2 = options1.clone();
2974        assert_eq!(options1.model, options2.model);
2975        assert_eq!(options1.base_url, options2.base_url);
2976    }
2977
2978    #[test]
2979    fn test_temperature_validation() {
2980        // Temperature too low (< 0.0)
2981        let result = AgentOptions::builder()
2982            .model("test-model")
2983            .base_url("http://localhost:1234/v1")
2984            .temperature(-0.1)
2985            .build();
2986        assert!(result.is_err());
2987        assert!(result.unwrap_err().to_string().contains("temperature"));
2988
2989        // Temperature too high (> 2.0)
2990        let result = AgentOptions::builder()
2991            .model("test-model")
2992            .base_url("http://localhost:1234/v1")
2993            .temperature(2.1)
2994            .build();
2995        assert!(result.is_err());
2996        assert!(result.unwrap_err().to_string().contains("temperature"));
2997
2998        // Valid temperatures should work
2999        let result = AgentOptions::builder()
3000            .model("test-model")
3001            .base_url("http://localhost:1234/v1")
3002            .temperature(0.0)
3003            .build();
3004        assert!(result.is_ok());
3005
3006        let result = AgentOptions::builder()
3007            .model("test-model")
3008            .base_url("http://localhost:1234/v1")
3009            .temperature(2.0)
3010            .build();
3011        assert!(result.is_ok());
3012    }
3013
3014    #[test]
3015    fn test_url_validation() {
3016        // Empty URL should fail
3017        let result = AgentOptions::builder()
3018            .model("test-model")
3019            .base_url("")
3020            .build();
3021        assert!(result.is_err());
3022        assert!(result.unwrap_err().to_string().contains("base_url"));
3023
3024        // Invalid URL format should fail
3025        let result = AgentOptions::builder()
3026            .model("test-model")
3027            .base_url("not-a-url")
3028            .build();
3029        assert!(result.is_err());
3030        assert!(result.unwrap_err().to_string().contains("base_url"));
3031
3032        // Valid URLs should work
3033        let result = AgentOptions::builder()
3034            .model("test-model")
3035            .base_url("http://localhost:1234/v1")
3036            .build();
3037        assert!(result.is_ok());
3038
3039        let result = AgentOptions::builder()
3040            .model("test-model")
3041            .base_url("https://api.openai.com/v1")
3042            .build();
3043        assert!(result.is_ok());
3044    }
3045
3046    #[test]
3047    fn test_model_validation() {
3048        // Empty model should fail
3049        let result = AgentOptions::builder()
3050            .model("")
3051            .base_url("http://localhost:1234/v1")
3052            .build();
3053        assert!(result.is_err());
3054        assert!(result.unwrap_err().to_string().contains("model"));
3055
3056        // Whitespace-only model should fail
3057        let result = AgentOptions::builder()
3058            .model("   ")
3059            .base_url("http://localhost:1234/v1")
3060            .build();
3061        assert!(result.is_err());
3062        assert!(result.unwrap_err().to_string().contains("model"));
3063    }
3064
3065    #[test]
3066    fn test_max_tokens_validation() {
3067        // max_tokens = 0 should fail
3068        let result = AgentOptions::builder()
3069            .model("test-model")
3070            .base_url("http://localhost:1234/v1")
3071            .max_tokens(0)
3072            .build();
3073        assert!(result.is_err());
3074        assert!(result.unwrap_err().to_string().contains("max_tokens"));
3075
3076        // Valid max_tokens should work
3077        let result = AgentOptions::builder()
3078            .model("test-model")
3079            .base_url("http://localhost:1234/v1")
3080            .max_tokens(1)
3081            .build();
3082        assert!(result.is_ok());
3083    }
3084
3085    #[test]
3086    fn test_agent_options_getters() {
3087        // Test that AgentOptions provides getter methods for field access
3088        let options = AgentOptions::builder()
3089            .model("test-model")
3090            .base_url("http://localhost:1234/v1")
3091            .system_prompt("Test prompt")
3092            .api_key("test-key")
3093            .max_turns(5)
3094            .max_tokens(1000)
3095            .temperature(0.5)
3096            .timeout(30)
3097            .auto_execute_tools(true)
3098            .max_tool_iterations(10)
3099            .build()
3100            .unwrap();
3101
3102        // All fields should be accessible via getter methods, not direct field access
3103        assert_eq!(options.system_prompt(), "Test prompt");
3104        assert_eq!(options.model(), "test-model");
3105        assert_eq!(options.base_url(), "http://localhost:1234/v1");
3106        assert_eq!(options.api_key(), "test-key");
3107        assert_eq!(options.max_turns(), 5);
3108        assert_eq!(options.max_tokens(), Some(1000));
3109        assert_eq!(options.temperature(), 0.5);
3110        assert_eq!(options.timeout(), 30);
3111        assert!(options.auto_execute_tools());
3112        assert_eq!(options.max_tool_iterations(), 10);
3113        assert_eq!(options.tools().len(), 0);
3114    }
3115
3116    // ========================================================================
3117    // Image Support Tests (Phase 1 - TDD RED)
3118    // ========================================================================
3119
3120    #[test]
3121    fn test_image_block_from_url() {
3122        // Should create ImageBlock from URL
3123        let block = ImageBlock::from_url("https://example.com/image.jpg").unwrap();
3124        assert_eq!(block.url(), "https://example.com/image.jpg");
3125        assert!(matches!(block.detail(), ImageDetail::Auto));
3126    }
3127
3128    #[test]
3129    fn test_image_block_from_base64() {
3130        // Should create ImageBlock from base64
3131        let block = ImageBlock::from_base64("iVBORw0KGgoAAAA=", "image/jpeg").unwrap();
3132        assert!(block.url().starts_with("data:image/jpeg;base64,"));
3133        assert!(matches!(block.detail(), ImageDetail::Auto));
3134    }
3135
3136    #[test]
3137    fn test_image_block_from_file_path() {
3138        use base64::{Engine as _, engine::general_purpose};
3139        use std::io::Write;
3140
3141        // Create a temporary test file
3142        let temp_dir = std::env::temp_dir();
3143        let test_file = temp_dir.join("test_image.png");
3144
3145        // Write a minimal valid 1x1 PNG (red pixel)
3146        let png_bytes = general_purpose::STANDARD
3147            .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==")
3148            .unwrap();
3149        std::fs::File::create(&test_file)
3150            .unwrap()
3151            .write_all(&png_bytes)
3152            .unwrap();
3153
3154        // Test: Should successfully load the file
3155        let block = ImageBlock::from_file_path(&test_file).unwrap();
3156        assert!(block.url().starts_with("data:image/png;base64,"));
3157        assert!(matches!(block.detail(), ImageDetail::Auto));
3158
3159        // Test: Missing extension should fail
3160        let no_ext_file = temp_dir.join("test_image_no_ext");
3161        std::fs::File::create(&no_ext_file)
3162            .unwrap()
3163            .write_all(&png_bytes)
3164            .unwrap();
3165        let result = ImageBlock::from_file_path(&no_ext_file);
3166        assert!(result.is_err());
3167        assert!(result.unwrap_err().to_string().contains("extension"));
3168
3169        // Test: Unsupported extension should fail
3170        let bad_ext_file = temp_dir.join("test_image.txt");
3171        std::fs::File::create(&bad_ext_file)
3172            .unwrap()
3173            .write_all(&png_bytes)
3174            .unwrap();
3175        let result = ImageBlock::from_file_path(&bad_ext_file);
3176        assert!(result.is_err());
3177        assert!(result.unwrap_err().to_string().contains("Unsupported"));
3178
3179        // Cleanup
3180        let _ = std::fs::remove_file(&test_file);
3181        let _ = std::fs::remove_file(&no_ext_file);
3182        let _ = std::fs::remove_file(&bad_ext_file);
3183    }
3184
3185    #[test]
3186    fn test_image_block_with_detail() {
3187        // Should set detail level
3188        let block = ImageBlock::from_url("https://example.com/image.jpg")
3189            .unwrap()
3190            .with_detail(ImageDetail::High);
3191        assert!(matches!(block.detail(), ImageDetail::High));
3192    }
3193
3194    #[test]
3195    fn test_image_detail_serialization() {
3196        // Should serialize ImageDetail to correct strings
3197        let json = serde_json::to_string(&ImageDetail::Low).unwrap();
3198        assert_eq!(json, "\"low\"");
3199
3200        let json = serde_json::to_string(&ImageDetail::High).unwrap();
3201        assert_eq!(json, "\"high\"");
3202
3203        let json = serde_json::to_string(&ImageDetail::Auto).unwrap();
3204        assert_eq!(json, "\"auto\"");
3205    }
3206
3207    #[test]
3208    fn test_content_block_image_variant() {
3209        // Should add Image variant to ContentBlock
3210        let image = ImageBlock::from_url("https://example.com/image.jpg").unwrap();
3211        let block = ContentBlock::Image(image);
3212
3213        match block {
3214            ContentBlock::Image(img) => {
3215                assert_eq!(img.url(), "https://example.com/image.jpg");
3216            }
3217            _ => panic!("Expected Image variant"),
3218        }
3219    }
3220
3221    #[test]
3222    fn test_openai_content_text_format() {
3223        // Should serialize text-only as string (backward compat)
3224        let content = OpenAIContent::Text("Hello".to_string());
3225        let json = serde_json::to_value(&content).unwrap();
3226        assert_eq!(json, serde_json::json!("Hello"));
3227    }
3228
3229    #[test]
3230    #[allow(deprecated)]
3231    fn test_openai_content_parts_format() {
3232        // Should serialize mixed content as array
3233        let parts = vec![
3234            OpenAIContentPart::text("What's in this image?"),
3235            OpenAIContentPart::image_url("https://example.com/img.jpg", ImageDetail::High),
3236        ];
3237        let content = OpenAIContent::Parts(parts);
3238        let json = serde_json::to_value(&content).unwrap();
3239
3240        assert!(json.is_array());
3241        assert_eq!(json[0]["type"], "text");
3242        assert_eq!(json[0]["text"], "What's in this image?");
3243        assert_eq!(json[1]["type"], "image_url");
3244        assert_eq!(json[1]["image_url"]["url"], "https://example.com/img.jpg");
3245        assert_eq!(json[1]["image_url"]["detail"], "high");
3246    }
3247
3248    // ========================================================================
3249    // OpenAIContentPart Enum Tests (Phase 4 - PR #3 Fixes)
3250    // ========================================================================
3251
3252    #[test]
3253    fn test_openai_content_part_text_serialization() {
3254        // RED: Test that text variant serializes correctly with enum
3255        let part = OpenAIContentPart::text("Hello world");
3256        let json = serde_json::to_value(&part).unwrap();
3257
3258        // Should have type field with value "text"
3259        assert_eq!(json["type"], "text");
3260        assert_eq!(json["text"], "Hello world");
3261        // Should not have image_url field
3262        assert!(json.get("image_url").is_none());
3263    }
3264
3265    #[test]
3266    #[allow(deprecated)]
3267    fn test_openai_content_part_image_serialization() {
3268        // RED: Test that image_url variant serializes correctly with enum
3269        let part = OpenAIContentPart::image_url("https://example.com/img.jpg", ImageDetail::Low);
3270        let json = serde_json::to_value(&part).unwrap();
3271
3272        // Should have type field with value "image_url"
3273        assert_eq!(json["type"], "image_url");
3274        assert_eq!(json["image_url"]["url"], "https://example.com/img.jpg");
3275        assert_eq!(json["image_url"]["detail"], "low");
3276        // Should not have text field
3277        assert!(json.get("text").is_none());
3278    }
3279
3280    #[test]
3281    #[allow(deprecated)]
3282    fn test_openai_content_part_enum_exhaustiveness() {
3283        // RED: Test that enum prevents invalid states
3284        // With tagged enum, it should be impossible to create a part with both text and image_url
3285        // or a part with neither. This test documents expected enum behavior.
3286
3287        let text_part = OpenAIContentPart::text("test");
3288        let image_part = OpenAIContentPart::image_url("url", ImageDetail::Auto);
3289
3290        // Pattern matching should be exhaustive
3291        match text_part {
3292            OpenAIContentPart::Text { .. } => {
3293                // Expected for text part
3294            }
3295            OpenAIContentPart::ImageUrl { .. } => {
3296                panic!("Text part should not match ImageUrl variant");
3297            }
3298        }
3299
3300        match image_part {
3301            OpenAIContentPart::Text { .. } => {
3302                panic!("Image part should not match Text variant");
3303            }
3304            OpenAIContentPart::ImageUrl { .. } => {
3305                // Expected for image part
3306            }
3307        }
3308    }
3309
3310    #[test]
3311    fn test_image_detail_display() {
3312        // Should convert ImageDetail to string
3313        assert_eq!(ImageDetail::Low.to_string(), "low");
3314        assert_eq!(ImageDetail::High.to_string(), "high");
3315        assert_eq!(ImageDetail::Auto.to_string(), "auto");
3316    }
3317
3318    // ========================================================================
3319    // ImageBlock Validation Tests (Phase 1 - PR #3 Fixes)
3320    // ========================================================================
3321
3322    #[test]
3323    fn test_image_block_from_url_rejects_empty() {
3324        // Should reject empty URL strings
3325        let result = ImageBlock::from_url("");
3326        assert!(result.is_err());
3327        let err = result.unwrap_err();
3328        assert!(err.to_string().contains("empty"));
3329    }
3330
3331    #[test]
3332    fn test_image_block_from_url_rejects_invalid_scheme() {
3333        // Should reject non-HTTP/HTTPS/data schemes
3334        let result = ImageBlock::from_url("ftp://example.com/image.jpg");
3335        assert!(result.is_err());
3336        let err = result.unwrap_err();
3337        assert!(err.to_string().contains("scheme") || err.to_string().contains("http"));
3338    }
3339
3340    #[test]
3341    fn test_image_block_from_url_rejects_relative_path() {
3342        // Should reject relative paths
3343        let result = ImageBlock::from_url("/images/photo.jpg");
3344        assert!(result.is_err());
3345        // Error message should mention URL requirements
3346        assert!(matches!(result.unwrap_err(), crate::Error::InvalidInput(_)));
3347    }
3348
3349    #[test]
3350    fn test_image_block_from_url_accepts_http() {
3351        // Should accept HTTP URLs
3352        let result = ImageBlock::from_url("http://example.com/image.jpg");
3353        assert!(result.is_ok());
3354        assert_eq!(result.unwrap().url(), "http://example.com/image.jpg");
3355    }
3356
3357    #[test]
3358    fn test_image_block_from_url_accepts_https() {
3359        // Should accept HTTPS URLs
3360        let result = ImageBlock::from_url("https://example.com/image.jpg");
3361        assert!(result.is_ok());
3362        assert_eq!(result.unwrap().url(), "https://example.com/image.jpg");
3363    }
3364
3365    #[test]
3366    fn test_image_block_from_url_accepts_data_uri() {
3367        // Should accept data URIs
3368        let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
3369        let result = ImageBlock::from_url(data_uri);
3370        assert!(result.is_ok());
3371        assert_eq!(result.unwrap().url(), data_uri);
3372    }
3373
3374    #[test]
3375    fn test_image_block_from_url_rejects_malformed_data_uri() {
3376        // Should reject malformed data URIs
3377        let result = ImageBlock::from_url("data:notanimage");
3378        assert!(result.is_err());
3379        // Should return InvalidInput error for malformed data URI
3380        assert!(matches!(result.unwrap_err(), crate::Error::InvalidInput(_)));
3381    }
3382
3383    // Phase 2: Enhanced URL validation tests (RED)
3384
3385    #[test]
3386    fn test_from_url_rejects_control_characters() {
3387        // Should reject URLs with control characters
3388        let invalid_urls = [
3389            "https://example.com\n/image.jpg", // newline
3390            "https://example.com\t/image.jpg", // tab
3391            "https://example.com\0/image.jpg", // null
3392            "https://example.com\r/image.jpg", // carriage return
3393        ];
3394
3395        for url in &invalid_urls {
3396            let result = ImageBlock::from_url(*url);
3397            assert!(
3398                result.is_err(),
3399                "Should reject URL with control characters: {:?}",
3400                url
3401            );
3402            let err = result.unwrap_err();
3403            assert!(
3404                err.to_string().contains("control") || err.to_string().contains("character"),
3405                "Error should mention control characters, got: {}",
3406                err
3407            );
3408        }
3409    }
3410
3411    #[test]
3412    fn test_from_url_warns_very_long_url() {
3413        // Should warn (but accept) very long URLs (>2000 chars)
3414        // 3000-char URL
3415        let long_url = format!("https://example.com/{}", "a".repeat(2980));
3416
3417        // Should succeed but log a warning
3418        let result = ImageBlock::from_url(&long_url);
3419        assert!(result.is_ok(), "Should accept long URL (with warning)");
3420
3421        // Verify the URL was stored
3422        let block = result.unwrap();
3423        assert_eq!(block.url().len(), 3000);
3424    }
3425
3426    #[test]
3427    fn test_from_url_validates_data_uri_base64() {
3428        // Should validate base64 portion of data URIs
3429        let invalid_data_uris = [
3430            "data:image/png;base64,",            // empty base64
3431            "data:image/png;base64,hello world", // spaces in base64
3432            "data:image/png;base64,@@@",         // invalid chars
3433            "data:image/png;base64,ABC",         // invalid length (not divisible by 4)
3434            "data:image/png;base64,==abc",       // padding in middle (not at end)
3435            "data:image/png;base64,ab==cd",      // padding in middle
3436        ];
3437
3438        for uri in &invalid_data_uris {
3439            let result = ImageBlock::from_url(*uri);
3440            assert!(
3441                result.is_err(),
3442                "Should reject data URI with invalid base64: {}",
3443                uri
3444            );
3445        }
3446    }
3447
3448    #[test]
3449    fn test_from_url_rejects_javascript_scheme() {
3450        // Should explicitly reject javascript: scheme (XSS risk)
3451        let result = ImageBlock::from_url("javascript:alert(1)");
3452        assert!(result.is_err());
3453        let err = result.unwrap_err();
3454        assert!(
3455            err.to_string().contains("http") || err.to_string().contains("scheme"),
3456            "Error should mention scheme requirements, got: {}",
3457            err
3458        );
3459    }
3460
3461    #[test]
3462    fn test_from_url_rejects_file_scheme() {
3463        // Should reject file: scheme (security risk)
3464        let result = ImageBlock::from_url("file:///etc/passwd");
3465        assert!(result.is_err());
3466        let err = result.unwrap_err();
3467        assert!(
3468            err.to_string().contains("http") || err.to_string().contains("scheme"),
3469            "Error should mention scheme requirements, got: {}",
3470            err
3471        );
3472    }
3473
3474    #[test]
3475    fn test_image_block_from_base64_rejects_empty() {
3476        // Should reject empty base64 data
3477        let result = ImageBlock::from_base64("", "image/png");
3478        assert!(result.is_err());
3479        let err = result.unwrap_err();
3480        assert!(err.to_string().contains("empty"));
3481    }
3482
3483    #[test]
3484    fn test_image_block_from_base64_rejects_invalid_mime() {
3485        // Should reject non-image MIME types
3486        let result = ImageBlock::from_base64("somedata", "text/plain");
3487        assert!(result.is_err());
3488        let err = result.unwrap_err();
3489        assert!(err.to_string().contains("MIME") || err.to_string().contains("image"));
3490    }
3491
3492    #[test]
3493    fn test_image_block_from_base64_accepts_valid_input() {
3494        // Should accept valid base64 data with image MIME type
3495        let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
3496        let result = ImageBlock::from_base64(base64, "image/png");
3497        assert!(result.is_ok());
3498        let block = result.unwrap();
3499        assert!(block.url().starts_with("data:image/png;base64,"));
3500    }
3501
3502    #[test]
3503    fn test_image_block_from_base64_accepts_all_image_types() {
3504        // Should accept all common image MIME types
3505        let base64 = "iVBORw0KGgo="; // Valid base64 (length multiple of 4)
3506        let mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"];
3507
3508        for mime in &mime_types {
3509            let result = ImageBlock::from_base64(base64, *mime);
3510            assert!(result.is_ok(), "Should accept {}", mime);
3511            let block = result.unwrap();
3512            assert!(block.url().starts_with(&format!("data:{};base64,", mime)));
3513        }
3514    }
3515
3516    // Phase 1: Enhanced base64 validation tests (RED)
3517
3518    #[test]
3519    fn test_from_base64_rejects_invalid_characters() {
3520        // Should reject base64 with invalid characters
3521        let invalid_inputs = [
3522            "hello world", // spaces
3523            "test@data",   // @
3524            "test#data",   // #
3525            "test$data",   // $
3526            "test%data",   // %
3527            "abc\ndef",    // newline
3528        ];
3529
3530        for invalid in &invalid_inputs {
3531            let result = ImageBlock::from_base64(invalid, "image/png");
3532            assert!(
3533                result.is_err(),
3534                "Should reject base64 with invalid characters: {}",
3535                invalid
3536            );
3537            let err = result.unwrap_err();
3538            assert!(
3539                err.to_string().contains("base64") || err.to_string().contains("character"),
3540                "Error should mention base64 or character issue, got: {}",
3541                err
3542            );
3543        }
3544    }
3545
3546    #[test]
3547    fn test_from_base64_rejects_malformed_padding() {
3548        // Should reject base64 with incorrect padding
3549        let invalid_padding = [
3550            "A",       // Length 1 (not divisible by 4)
3551            "AB",      // Length 2 (not divisible by 4)
3552            "ABC",     // Length 3 (not divisible by 4)
3553            "ABCD===", // Too many padding characters
3554        ];
3555
3556        for invalid in &invalid_padding {
3557            let result = ImageBlock::from_base64(invalid, "image/png");
3558            assert!(
3559                result.is_err(),
3560                "Should reject malformed padding: {}",
3561                invalid
3562            );
3563        }
3564    }
3565
3566    #[test]
3567    fn test_from_base64_rejects_mime_with_semicolon() {
3568        // Should reject MIME types with injection characters (semicolon)
3569        // Use valid base64 (length divisible by 4)
3570        let result = ImageBlock::from_base64("AAAA", "image/png;charset=utf-8");
3571        assert!(result.is_err());
3572        let err = result.unwrap_err();
3573        assert!(
3574            err.to_string().contains("MIME") || err.to_string().contains("character"),
3575            "Error should mention MIME or character issue, got: {}",
3576            err
3577        );
3578    }
3579
3580    #[test]
3581    fn test_from_base64_rejects_mime_with_newline() {
3582        // Should reject MIME types with control characters (newline)
3583        let invalid_mimes = [
3584            "image/png\n",
3585            "image/png\r",
3586            "image/png\r\n",
3587            "image/png,extra",
3588        ];
3589
3590        for mime in &invalid_mimes {
3591            // Use valid base64 (length divisible by 4)
3592            let result = ImageBlock::from_base64("AAAA", mime);
3593            assert!(
3594                result.is_err(),
3595                "Should reject MIME with control/injection chars: {:?}",
3596                mime
3597            );
3598        }
3599    }
3600
3601    #[test]
3602    fn test_from_base64_warns_large_data() {
3603        // Should warn (but accept) very large base64 strings (>10MB)
3604        // 15MB base64 = 15,000,000 chars
3605        let large_base64 = "A".repeat(15_000_000);
3606
3607        // This should succeed but log a warning
3608        let result = ImageBlock::from_base64(&large_base64, "image/png");
3609        assert!(result.is_ok(), "Should accept large base64 (with warning)");
3610
3611        // Verify the data URI was created
3612        let block = result.unwrap();
3613        assert!(block.url().len() > 15_000_000);
3614    }
3615
3616    #[test]
3617    fn test_from_base64_accepts_all_image_mime_types() {
3618        // Should accept all common image MIME types
3619        let valid_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
3620        let mime_types = [
3621            "image/jpeg",
3622            "image/png",
3623            "image/gif",
3624            "image/webp",
3625            "image/avif",
3626            "image/bmp",
3627            "image/tiff",
3628        ];
3629
3630        for mime in &mime_types {
3631            let result = ImageBlock::from_base64(valid_data, *mime);
3632            assert!(result.is_ok(), "Should accept valid MIME type: {}", mime);
3633        }
3634    }
3635
3636    #[test]
3637    fn test_image_block_from_base64_rejects_empty_mime() {
3638        // Should reject empty MIME type
3639        let result = ImageBlock::from_base64("somedata", "");
3640        assert!(result.is_err());
3641        let err = result.unwrap_err();
3642        assert!(err.to_string().contains("MIME") || err.to_string().contains("empty"));
3643    }
3644}