Skip to main content

open_agent/types/
openai.rs

1/// OpenAI API message format for serialization.
2///
3/// This struct represents the wire format for messages when communicating
4/// with OpenAI-compatible APIs. It differs from the internal [`Message`]
5/// type to accommodate the specific serialization requirements of the
6/// OpenAI API.
7///
8/// # Key Differences from Internal Message Type
9///
10/// - Content is a flat string rather than structured blocks
11/// - Tool calls are represented in OpenAI's specific format
12/// - Supports both sending tool calls (via `tool_calls`) and tool results
13///   (via `tool_call_id`)
14///
15/// # Serialization
16///
17/// Optional fields are skipped when `None` to keep payloads minimal.
18///
19/// # Usage
20///
21/// This type is typically created by the SDK internally when converting
22/// from [`Message`] to API format. Users rarely need to construct these
23/// directly.
24///
25/// # OpenAI Content Format
26///
27/// OpenAI content format supporting both string and array.
28///
29/// For backward compatibility, text-only messages use string format.
30/// Messages with images use array format with multiple content parts.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(untagged)]
33pub enum OpenAIContent {
34    /// Simple text string (backward compatible)
35    Text(String),
36    /// Array of content parts (text and/or images)
37    Parts(Vec<OpenAIContentPart>),
38}
39
40/// A single content part in an OpenAI message.
41///
42/// Can be either text or an image URL. This is a tagged enum that prevents
43/// invalid states (e.g., having both text and image_url, or neither).
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum OpenAIContentPart {
47    /// Text content part
48    Text {
49        /// The text content
50        text: String,
51    },
52    /// Image URL content part
53    #[serde(rename = "image_url")]
54    ImageUrl {
55        /// The image URL details
56        image_url: OpenAIImageUrl,
57    },
58}
59
60impl OpenAIContentPart {
61    /// Creates a text content part.
62    ///
63    /// # Example
64    ///
65    /// ```
66    /// use open_agent::OpenAIContentPart;
67    ///
68    /// let part = OpenAIContentPart::text("Hello world");
69    /// ```
70    pub fn text(text: impl Into<String>) -> Self {
71        Self::Text { text: text.into() }
72    }
73
74    /// Creates an image content part from a validated ImageBlock.
75    ///
76    /// This is the preferred way to create image content parts as it ensures
77    /// the image URL has been validated against security issues (XSS, file disclosure, etc.)
78    ///
79    /// # Example
80    ///
81    /// ```
82    /// use open_agent::{OpenAIContentPart, ImageBlock, ImageDetail};
83    ///
84    /// let image = ImageBlock::from_url("https://example.com/img.jpg")
85    ///     .expect("Valid URL");
86    /// let part = OpenAIContentPart::from_image(&image);
87    /// ```
88    pub fn from_image(image: &ImageBlock) -> Self {
89        Self::ImageUrl {
90            image_url: OpenAIImageUrl {
91                url: image.url().to_string(),
92                detail: Some(image.detail().to_string()),
93            },
94        }
95    }
96
97    /// Creates an image URL content part directly (DEPRECATED).
98    ///
99    /// # Security Warning
100    ///
101    /// This method bypasses validation checks performed by `ImageBlock::from_url()`
102    /// and `ImageBlock::from_base64()`. Prefer using `from_image()` instead.
103    ///
104    /// # Deprecation
105    ///
106    /// This method is deprecated and will be removed in v1.0. Use `from_image()` instead.
107    ///
108    /// # Example
109    ///
110    /// ```
111    /// use open_agent::{OpenAIContentPart, ImageDetail};
112    ///
113    /// // Deprecated approach:
114    /// let part = OpenAIContentPart::image_url("https://example.com/img.jpg", ImageDetail::High);
115    ///
116    /// // Preferred approach:
117    /// use open_agent::ImageBlock;
118    /// let image = ImageBlock::from_url("https://example.com/img.jpg").expect("Valid URL");
119    /// let part = OpenAIContentPart::from_image(&image);
120    /// ```
121    #[deprecated(
122        since = "0.6.0",
123        note = "Use `from_image()` instead to ensure proper validation"
124    )]
125    pub fn image_url(url: impl Into<String>, detail: ImageDetail) -> Self {
126        Self::ImageUrl {
127            image_url: OpenAIImageUrl {
128                url: url.into(),
129                detail: Some(detail.to_string()),
130            },
131        }
132    }
133}
134
135/// OpenAI image URL structure.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OpenAIImageUrl {
138    /// Image URL or data URI
139    pub url: String,
140    /// Detail level: "low", "high", or "auto"
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub detail: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct OpenAIMessage {
147    /// Message role as a string ("system", "user", "assistant", "tool").
148    pub role: String,
149
150    /// Message content (string for text-only, array for text+images).
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub content: Option<OpenAIContent>,
153
154    /// Tool calls requested by the assistant (assistant messages only).
155    ///
156    /// When the model wants to call tools, this field contains the list
157    /// of tool invocations with their parameters. Only present in assistant
158    /// messages.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub tool_calls: Option<Vec<OpenAIToolCall>>,
161
162    /// ID of the tool call this message is responding to (tool messages only).
163    ///
164    /// When sending tool results back to the model, this field links the
165    /// result to the original tool call request. Only present in tool
166    /// messages.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub tool_call_id: Option<String>,
169}
170
171/// OpenAI tool call representation in API messages.
172///
173/// Represents a request from the model to execute a specific function/tool.
174/// This is the wire format used in the OpenAI API, distinct from the internal
175/// [`ToolUseBlock`] representation.
176///
177/// # Structure
178///
179/// Each tool call has:
180/// - A unique ID for correlation with results
181/// - A type (always "function" in current OpenAI API)
182/// - Function details (name and arguments)
183///
184/// # Example JSON
185///
186/// ```json
187/// {
188///   "id": "call_abc123",
189///   "type": "function",
190///   "function": {
191///     "name": "get_weather",
192///     "arguments": "{\"location\":\"San Francisco\"}"
193///   }
194/// }
195/// ```
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct OpenAIToolCall {
198    /// Unique identifier for this tool call.
199    ///
200    /// Generated by the model. Used to correlate tool results back to
201    /// this specific call.
202    pub id: String,
203
204    /// Type of the call (always "function" in current API).
205    ///
206    /// The `rename` attribute ensures this serializes as `"type"` in JSON
207    /// since `type` is a Rust keyword.
208    #[serde(rename = "type")]
209    pub call_type: String,
210
211    /// Function/tool details (name and arguments).
212    pub function: OpenAIFunction,
213}
214
215/// OpenAI function call details.
216///
217/// Contains the function name and its arguments in the OpenAI API format.
218/// Note that arguments are serialized as a JSON string, not a JSON object,
219/// which is an OpenAI API quirk.
220///
221/// # Arguments Format
222///
223/// The `arguments` field is a **JSON string**, not a parsed JSON object.
224/// For example: `"{\"x\": 1, \"y\": 2}"` not `{"x": 1, "y": 2}`.
225/// This must be parsed before use.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct OpenAIFunction {
228    /// Name of the function/tool to call.
229    pub name: String,
230
231    /// Function arguments as a **JSON string** (OpenAI API quirk).
232    ///
233    /// Must be parsed as JSON before use. For example, this might contain
234    /// the string `"{\"location\":\"NYC\",\"units\":\"fahrenheit\"}"` which
235    /// needs to be parsed into an actual JSON value.
236    pub arguments: String,
237}
238
239/// Complete request payload for OpenAI chat completions API.
240///
241/// This struct is serialized and sent as the request body when making
242/// API calls to OpenAI-compatible endpoints. It includes the model,
243/// conversation history, and configuration parameters.
244///
245/// # Streaming
246///
247/// The SDK always uses streaming mode (`stream: true`) to enable real-time
248/// response processing and better user experience.
249///
250/// # Optional Fields
251///
252/// Fields marked with `skip_serializing_if` are omitted from the JSON payload
253/// when `None`, allowing the API provider to use its defaults.
254///
255/// # Example
256///
257/// ```ignore
258/// use open_agent_sdk::types::{OpenAIRequest, OpenAIMessage};
259///
260/// let request = OpenAIRequest {
261///     model: "gpt-4".to_string(),
262///     messages: vec![
263///         OpenAIMessage {
264///             role: "user".to_string(),
265///             content: "Hello!".to_string(),
266///             tool_calls: None,
267///             tool_call_id: None,
268///         }
269///     ],
270///     stream: true,
271///     max_tokens: Some(1000),
272///     temperature: Some(0.7),
273///     tools: None,
274/// };
275/// ```
276#[derive(Debug, Clone, Serialize)]
277pub struct OpenAIRequest {
278    /// Model identifier (e.g., "gpt-4", "qwen2.5-32b-instruct").
279    pub model: String,
280
281    /// Conversation history as a sequence of messages.
282    ///
283    /// Includes system prompt, user messages, assistant responses, and
284    /// tool results. Order matters - messages are processed sequentially.
285    pub messages: Vec<OpenAIMessage>,
286
287    /// Whether to stream the response.
288    ///
289    /// The SDK always sets this to `true` for better user experience.
290    /// Streaming allows incremental processing of responses rather than
291    /// waiting for the entire completion.
292    pub stream: bool,
293
294    /// Maximum tokens to generate (optional).
295    ///
296    /// `None` uses the provider's default. Some providers require this
297    /// to be set explicitly.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub max_tokens: Option<u32>,
300
301    /// Sampling temperature (optional).
302    ///
303    /// `None` uses the provider's default. Controls randomness in
304    /// generation.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub temperature: Option<f32>,
307
308    /// Tools/functions available to the model (optional).
309    ///
310    /// When present, enables function calling. Each tool is described
311    /// with a JSON schema defining its parameters. `None` means no
312    /// tools are available.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub tools: Option<Vec<serde_json::Value>>,
315}
316
317/// A single chunk from OpenAI's streaming response.
318///
319/// When the SDK requests streaming responses (`stream: true`), the API
320/// returns the response incrementally as a series of chunks. Each chunk
321/// represents a small piece of the complete response, allowing the SDK
322/// to process and display content as it's generated.
323///
324/// # Streaming Architecture
325///
326/// Instead of waiting for the entire response, streaming sends many small
327/// chunks in rapid succession. Each chunk contains:
328/// - Metadata (id, model, timestamp)
329/// - One or more choices (usually just one for single completions)
330/// - Incremental deltas with new content
331///
332/// # Server-Sent Events Format
333///
334/// Chunks are transmitted as Server-Sent Events (SSE) over HTTP:
335/// ```text
336/// data: {"id":"chunk_1","object":"chat.completion.chunk",...}
337/// data: {"id":"chunk_2","object":"chat.completion.chunk",...}
338/// data: [DONE]
339/// ```
340///
341/// # Example Chunk JSON
342///
343/// ```json
344/// {
345///   "id": "chatcmpl-123",
346///   "object": "chat.completion.chunk",
347///   "created": 1677652288,
348///   "model": "gpt-4",
349///   "choices": [{
350///     "index": 0,
351///     "delta": {"content": "Hello"},
352///     "finish_reason": null
353///   }]
354/// }
355/// ```
356#[derive(Debug, Clone, Deserialize)]
357pub struct OpenAIChunk {
358    /// Unique identifier for this completion.
359    ///
360    /// All chunks in a single streaming response share the same ID.
361    /// Not actively used by the SDK but preserved for debugging.
362    #[allow(dead_code)]
363    pub id: String,
364
365    /// Object type (always "chat.completion.chunk" for streaming).
366    ///
367    /// Not actively used by the SDK but preserved for debugging.
368    #[allow(dead_code)]
369    pub object: String,
370
371    /// Unix timestamp of when this chunk was created.
372    ///
373    /// Not actively used by the SDK but preserved for debugging.
374    #[allow(dead_code)]
375    pub created: i64,
376
377    /// Model that generated this chunk.
378    ///
379    /// Not actively used by the SDK but preserved for debugging.
380    #[allow(dead_code)]
381    pub model: String,
382
383    /// Array of completion choices (usually contains one element).
384    ///
385    /// Each choice represents a possible completion. In normal usage,
386    /// there's only one choice per chunk. This is the critical field
387    /// that the SDK processes to extract content and tool calls.
388    pub choices: Vec<OpenAIChoice>,
389}
390
391/// A single choice/completion option in a streaming chunk.
392///
393/// In streaming responses, each chunk can theoretically contain multiple
394/// choices (parallel completions), but in practice there's usually just one.
395/// Each choice contains a delta with incremental updates and optionally a
396/// finish reason when the generation is complete.
397///
398/// # Delta vs Complete Content
399///
400/// Unlike non-streaming responses that send complete messages, streaming
401/// sends deltas - just the new content added in this chunk. The SDK
402/// accumulates these deltas to build the complete response.
403///
404/// # Finish Reason
405///
406/// - `None`: More content is coming
407/// - `Some("stop")`: Normal completion
408/// - `Some("length")`: Hit max token limit
409/// - `Some("tool_calls")`: Model wants to call tools
410/// - `Some("content_filter")`: Blocked by content policy
411#[derive(Debug, Clone, Deserialize)]
412pub struct OpenAIChoice {
413    /// Index of this choice in the choices array.
414    ///
415    /// Usually 0 since most requests generate a single completion.
416    /// Not actively used by the SDK but preserved for debugging.
417    #[allow(dead_code)]
418    pub index: u32,
419
420    /// Incremental update/delta for this chunk.
421    ///
422    /// Contains the new content, tool calls, or other updates added in
423    /// this specific chunk. The SDK processes this to update its internal
424    /// state and accumulate the full response.
425    pub delta: OpenAIDelta,
426
427    /// Reason why generation finished (None if still generating).
428    ///
429    /// Only present in the final chunk of a stream:
430    /// - `None`: Generation is still in progress
431    /// - `Some("stop")`: Completed normally
432    /// - `Some("length")`: Hit token limit
433    /// - `Some("tool_calls")`: Model requested tools
434    /// - `Some("content_filter")`: Content was filtered
435    ///
436    /// The SDK uses this to detect completion and determine next actions.
437    pub finish_reason: Option<String>,
438}
439
440/// Incremental update in a streaming chunk.
441///
442/// Represents the new content/changes added in this specific chunk.
443/// Unlike complete messages, deltas only contain what's new, not the
444/// entire accumulated content. The SDK accumulates these deltas to
445/// build the complete response.
446///
447/// # Incremental Nature
448///
449/// If the complete response is "Hello, world!", the deltas might be:
450/// 1. `content: Some("Hello")`
451/// 2. `content: Some(", ")`
452/// 3. `content: Some("world")`
453/// 4. `content: Some("!")`
454///
455/// The SDK concatenates these to build the full text.
456///
457/// # Tool Call Deltas
458///
459/// Tool calls are also streamed incrementally. The first delta might
460/// include the tool ID and name, while subsequent deltas stream the
461/// arguments JSON string piece by piece.
462#[derive(Debug, Clone, Deserialize)]
463pub struct OpenAIDelta {
464    /// Role of the message (only in first chunk).
465    ///
466    /// Typically "assistant". Only appears in the first delta of a response
467    /// to establish who's speaking. Subsequent deltas omit this field.
468    /// Not actively used by the SDK but preserved for completeness.
469    #[allow(dead_code)]
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub role: Option<String>,
472
473    /// Incremental text content added in this chunk.
474    ///
475    /// Contains the new text tokens generated. `None` if this chunk doesn't
476    /// add text (e.g., it might only have tool call updates). The SDK
477    /// concatenates these across chunks to build the complete response.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub content: Option<String>,
480
481    /// Incremental tool call updates added in this chunk.
482    ///
483    /// When the model wants to call tools, tool call information is streamed
484    /// incrementally. Each delta might add to different parts of the tool
485    /// call (ID, name, arguments). The SDK accumulates these to reconstruct
486    /// complete tool calls.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub tool_calls: Option<Vec<OpenAIToolCallDelta>>,
489}
490
491/// Incremental update for a tool call in streaming.
492///
493/// Tool calls are streamed piece-by-piece, with different chunks potentially
494/// updating different parts. The SDK must accumulate these deltas to
495/// reconstruct complete tool calls.
496///
497/// # Streaming Pattern
498///
499/// A complete tool call is typically streamed as:
500/// 1. First chunk: `index: 0, id: Some("call_123"), type: Some("function")`
501/// 2. Second chunk: `index: 0, function: Some(FunctionDelta { name: Some("search"), ... })`
502/// 3. Multiple chunks: `index: 0, function: Some(FunctionDelta { arguments: Some("part") })`
503///
504/// The SDK uses the `index` to know which tool call to update, as multiple
505/// tool calls can be streamed simultaneously.
506///
507/// # Index-Based Accumulation
508///
509/// The `index` field is crucial for tracking which tool call is being updated.
510/// When the model calls multiple tools, each has a different index, and deltas
511/// specify which one they're updating.
512#[derive(Debug, Clone, Deserialize)]
513pub struct OpenAIToolCallDelta {
514    /// Index identifying which tool call this delta updates.
515    ///
516    /// When multiple tools are called, each has an index (0, 1, 2, ...).
517    /// The SDK uses this to route delta updates to the correct tool call
518    /// in its accumulation buffer.
519    pub index: u32,
520
521    /// Tool call ID (only in first delta for this tool call).
522    ///
523    /// Generated by the model. Present in the first chunk for each tool
524    /// call, then omitted in subsequent chunks. The SDK stores this to
525    /// correlate results later.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub id: Option<String>,
528
529    /// Type of call (always "function" when present).
530    ///
531    /// Only appears in the first delta for each tool call. Subsequent
532    /// deltas omit this field. Not actively used by the SDK but preserved
533    /// for completeness.
534    #[allow(dead_code)]
535    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
536    pub call_type: Option<String>,
537
538    /// Incremental function details (name and/or arguments).
539    ///
540    /// Contains partial updates to the function name and arguments.
541    /// The SDK accumulates these across chunks to build the complete
542    /// function call specification.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub function: Option<OpenAIFunctionDelta>,
545}
546
547/// Incremental update for function details in streaming tool calls.
548///
549/// As the model streams a tool call, the function name and arguments are
550/// sent incrementally. The name usually comes first in one chunk, then
551/// arguments are streamed piece-by-piece as a JSON string.
552///
553/// # Arguments Streaming
554///
555/// The arguments field is particularly important to understand. It contains
556/// **fragments of a JSON string** that must be accumulated and then parsed:
557///
558/// 1. Chunk 1: `arguments: Some("{")`
559/// 2. Chunk 2: `arguments: Some("\"query\":")`
560/// 3. Chunk 3: `arguments: Some("\"hello\"")`
561/// 4. Chunk 4: `arguments: Some("}")`
562///
563/// The SDK concatenates these into `"{\"query\":\"hello\"}"` and then
564/// parses it as JSON.
565#[derive(Debug, Clone, Deserialize)]
566pub struct OpenAIFunctionDelta {
567    /// Function/tool name (only in first delta for this function).
568    ///
569    /// Present when the model first starts calling this function, then
570    /// omitted in subsequent chunks. The SDK stores this to know which
571    /// tool to execute.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub name: Option<String>,
574
575    /// Incremental fragment of the arguments JSON string.
576    ///
577    /// Contains a piece of the complete JSON arguments string. The SDK
578    /// must concatenate all argument fragments across chunks, then parse
579    /// the complete string as JSON to get the actual parameters.
580    ///
581    /// For example, if the complete arguments should be:
582    /// `{"x": 1, "y": 2}`
583    ///
584    /// This might be streamed as:
585    /// - `Some("{\"x\": ")`
586    /// - `Some("1, \"y\": ")`
587    /// - `Some("2}")`
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub arguments: Option<String>,
590}