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}