Skip to main content

rai_sdk/
message.rs

1//! Prompts, messages, and responses.
2//!
3//! A request is described by a [`Prompt`], which is just an ordered list of
4//! [`Message`]s. Messages are text-only by default and become multimodal when
5//! they carry [`ContentBlock`]s instead. Providers answer with a [`Response`]
6//! (or a stream of [`StreamChunk`]/[`StreamEvent`] values), and structured
7//! requests answer with a [`StructuredOutput<T>`](StructuredOutput) that pairs
8//! the parsed value with the raw response.
9//!
10//! These types are provider-agnostic: each provider translates them to and
11//! from its own wire format.
12//!
13//! # Examples
14//!
15//! ```no_run
16//! use rai_sdk::{ContentBlock, Message, Prompt};
17//!
18//! // Anything that converts into a `Prompt` can be passed to a request.
19//! let simple: Prompt = "Summarize this file.".into();
20//!
21//! // Multi-turn conversations are built explicitly.
22//! let conversation = Prompt::new(vec![
23//!     Message::system("You are terse."),
24//!     Message::user("Who wrote Dune?"),
25//!     Message::assistant("Frank Herbert."),
26//!     Message::user("When?"),
27//! ]);
28//! assert_eq!(conversation.system_message(), Some("You are terse."));
29//!
30//! // Multimodal messages mix text with images.
31//! let vision = Prompt::single(Message::user_multimodal(vec![
32//!     ContentBlock::text("What is in this picture?"),
33//!     ContentBlock::image_url("https://example.com/cat.png"),
34//! ]));
35//! assert!(vision.is_multimodal());
36//! # let _ = simple;
37//! ```
38
39use serde::{Deserialize, Serialize};
40
41use crate::error::ProviderKind;
42
43/// Role of a message participant in the conversation.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Role {
47    /// Instructions that steer the whole conversation.
48    System,
49    /// Input from the end user.
50    User,
51    /// Output produced by the model.
52    Assistant,
53    /// The result of executing a tool the model asked for.
54    Tool,
55}
56
57impl Role {
58    /// Return the canonical lowercase name.
59    pub fn as_str(&self) -> &'static str {
60        match self {
61            Role::System => "system",
62            Role::User => "user",
63            Role::Assistant => "assistant",
64            Role::Tool => "tool",
65        }
66    }
67}
68
69/// A tool invocation emitted by a model.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct ToolCall {
72    /// Provider-assigned call identifier.
73    pub id: String,
74    /// Name of the tool to invoke.
75    pub name: String,
76    /// Arguments as a JSON value.
77    pub arguments: serde_json::Value,
78}
79
80/// Content block for multimodal messages.
81///
82/// Only [`ContentBlock::Text`] and [`ContentBlock::Image`] are currently
83/// translated by the bundled providers; audio, video, and file blocks are
84/// modelled here but not yet sent on the wire.
85///
86/// # Examples
87///
88/// ```no_run
89/// use rai_sdk::ContentBlock;
90///
91/// let caption = ContentBlock::text("Describe this diagram.");
92/// let remote = ContentBlock::image_url("https://example.com/diagram.png");
93/// let inline = ContentBlock::image_base64("image/png", "iVBORw0KGgo=");
94/// # let _ = (caption, remote, inline);
95/// ```
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "type")]
98pub enum ContentBlock {
99    /// Plain text.
100    #[serde(rename = "text")]
101    Text {
102        /// The text content.
103        text: String,
104    },
105
106    /// An image, by URL or inline base64 data.
107    #[serde(rename = "image")]
108    Image {
109        /// Where the image data comes from.
110        source: ImageSource,
111    },
112
113    /// An audio clip. Not yet supported by the bundled providers.
114    #[serde(rename = "audio")]
115    Audio {
116        /// Where the audio data comes from.
117        source: FileSource,
118    },
119
120    /// A video clip. Not yet supported by the bundled providers.
121    #[serde(rename = "video")]
122    Video {
123        /// Where the video data comes from.
124        source: FileSource,
125    },
126
127    /// An arbitrary file. Not yet supported by the bundled providers.
128    #[serde(rename = "file")]
129    File {
130        /// Where the file data comes from.
131        source: FileSource,
132    },
133}
134
135impl ContentBlock {
136    /// Create a text content block.
137    pub fn text(text: impl Into<String>) -> Self {
138        Self::Text { text: text.into() }
139    }
140
141    /// Create an image content block from a URL.
142    pub fn image_url(url: impl Into<String>) -> Self {
143        Self::Image {
144            source: ImageSource::Url { url: url.into() },
145        }
146    }
147
148    /// Create an image content block from base64 data.
149    pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
150        Self::Image {
151            source: ImageSource::Base64 {
152                media_type: media_type.into(),
153                data: data.into(),
154            },
155        }
156    }
157
158    /// Create an audio content block from a URL.
159    pub fn audio_url(url: impl Into<String>) -> Self {
160        Self::Audio {
161            source: FileSource::Url { url: url.into() },
162        }
163    }
164
165    /// Create an audio content block from base64 data.
166    pub fn audio_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
167        Self::Audio {
168            source: FileSource::Base64 {
169                media_type: media_type.into(),
170                data: data.into(),
171            },
172        }
173    }
174
175    /// Create a video content block from a URL.
176    pub fn video_url(url: impl Into<String>) -> Self {
177        Self::Video {
178            source: FileSource::Url { url: url.into() },
179        }
180    }
181
182    /// Create a video content block from base64 data.
183    pub fn video_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
184        Self::Video {
185            source: FileSource::Base64 {
186                media_type: media_type.into(),
187                data: data.into(),
188            },
189        }
190    }
191
192    /// Create a file content block from a URL.
193    pub fn file_url(url: impl Into<String>) -> Self {
194        Self::File {
195            source: FileSource::Url { url: url.into() },
196        }
197    }
198
199    /// Create a file content block from base64 data.
200    pub fn file_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
201        Self::File {
202            source: FileSource::Base64 {
203                media_type: media_type.into(),
204                data: data.into(),
205            },
206        }
207    }
208}
209
210/// Image source for multimodal content.
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212#[serde(tag = "type")]
213pub enum ImageSource {
214    /// A publicly reachable image URL the provider will fetch.
215    #[serde(rename = "url")]
216    Url {
217        /// URL of the image.
218        url: String,
219    },
220
221    /// Image bytes embedded in the request.
222    #[serde(rename = "base64")]
223    Base64 {
224        /// MIME type of the data, e.g. `image/png`.
225        media_type: String,
226        /// Base64-encoded image bytes, without a data-URL prefix.
227        data: String,
228    },
229}
230
231/// File source for multimodal content.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(tag = "type")]
234pub enum FileSource {
235    /// A publicly reachable URL the provider will fetch.
236    #[serde(rename = "url")]
237    Url {
238        /// URL of the file.
239        url: String,
240    },
241
242    /// File bytes embedded in the request.
243    #[serde(rename = "base64")]
244    Base64 {
245        /// MIME type of the data, e.g. `audio/mpeg`.
246        media_type: String,
247        /// Base64-encoded file bytes, without a data-URL prefix.
248        data: String,
249    },
250}
251
252/// A single message in a conversation.
253///
254/// Supports text-only and multimodal (text + images) content.
255///
256/// Use the constructors ([`Message::system`], [`Message::user`],
257/// [`Message::assistant`], [`Message::tool`], [`Message::user_multimodal`], …)
258/// rather than building the struct by hand; they keep the role and the
259/// tool-related fields consistent.
260///
261/// # Examples
262///
263/// ```no_run
264/// use rai_sdk::Message;
265///
266/// let system = Message::system("Answer in one sentence.");
267/// let user = Message::user("Why is the sky blue?");
268/// assert_eq!(user.text_content(), "Why is the sky blue?");
269/// assert!(!user.is_multimodal());
270///
271/// // Tool results reference the call they answer.
272/// let result = Message::tool(r#"{"temp_c":21}"#, "call_abc123");
273/// assert_eq!(result.tool_call_id.as_deref(), Some("call_abc123"));
274/// # let _ = system;
275/// ```
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277pub struct Message {
278    /// Who produced this message.
279    pub role: Role,
280
281    /// Text content (for simple text-only messages).
282    #[serde(default, skip_serializing_if = "String::is_empty")]
283    pub content: String,
284
285    /// Multimodal content blocks.  When non-empty, `content` is ignored.
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub content_blocks: Vec<ContentBlock>,
288
289    /// Tool calls emitted by an assistant message.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub tool_calls: Vec<ToolCall>,
292
293    /// The tool call ID this tool result corresponds to.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub tool_call_id: Option<String>,
296
297    /// Whether a tool result represents an error.
298    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
299    pub tool_error: bool,
300}
301
302impl Message {
303    /// Create a system message.
304    pub fn system(content: impl Into<String>) -> Self {
305        Self {
306            role: Role::System,
307            content: content.into(),
308            content_blocks: Vec::new(),
309            tool_calls: Vec::new(),
310            tool_call_id: None,
311            tool_error: false,
312        }
313    }
314
315    /// Create a user message.
316    pub fn user(content: impl Into<String>) -> Self {
317        Self {
318            role: Role::User,
319            content: content.into(),
320            content_blocks: Vec::new(),
321            tool_calls: Vec::new(),
322            tool_call_id: None,
323            tool_error: false,
324        }
325    }
326
327    /// Create an assistant message.
328    pub fn assistant(content: impl Into<String>) -> Self {
329        Self {
330            role: Role::Assistant,
331            content: content.into(),
332            content_blocks: Vec::new(),
333            tool_calls: Vec::new(),
334            tool_call_id: None,
335            tool_error: false,
336        }
337    }
338
339    /// Create an assistant message that includes tool calls.
340    pub fn assistant_with_tool_calls(
341        content: impl Into<String>,
342        tool_calls: Vec<ToolCall>,
343    ) -> Self {
344        Self {
345            role: Role::Assistant,
346            content: content.into(),
347            content_blocks: Vec::new(),
348            tool_calls,
349            tool_call_id: None,
350            tool_error: false,
351        }
352    }
353
354    /// Create a successful tool result message.
355    pub fn tool(content: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
356        Self {
357            role: Role::Tool,
358            content: content.into(),
359            content_blocks: Vec::new(),
360            tool_calls: Vec::new(),
361            tool_call_id: Some(tool_call_id.into()),
362            tool_error: false,
363        }
364    }
365
366    /// Create a tool result message that represents an error.
367    pub fn tool_error(content: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
368        Self {
369            role: Role::Tool,
370            content: content.into(),
371            content_blocks: Vec::new(),
372            tool_calls: Vec::new(),
373            tool_call_id: Some(tool_call_id.into()),
374            tool_error: true,
375        }
376    }
377
378    /// Create a user message with multimodal content blocks.
379    pub fn user_multimodal(content_blocks: Vec<ContentBlock>) -> Self {
380        Self {
381            role: Role::User,
382            content: String::new(),
383            content_blocks,
384            tool_calls: Vec::new(),
385            tool_call_id: None,
386            tool_error: false,
387        }
388    }
389
390    /// Check if this message has multimodal content.
391    pub fn is_multimodal(&self) -> bool {
392        !self.content_blocks.is_empty()
393    }
394
395    /// Check whether the assistant message contains tool calls.
396    pub fn has_tool_calls(&self) -> bool {
397        !self.tool_calls.is_empty()
398    }
399
400    /// Get the text content of this message.
401    ///
402    /// For multimodal messages, this concatenates all text blocks.
403    pub fn text_content(&self) -> String {
404        if self.is_multimodal() {
405            self.content_blocks
406                .iter()
407                .filter_map(|block| match block {
408                    ContentBlock::Text { text } => Some(text.as_str()),
409                    _ => None,
410                })
411                .collect::<Vec<_>>()
412                .join("\n")
413        } else {
414            self.content.clone()
415        }
416    }
417}
418
419/// A single conversation turn involving user, assistant, and potentially tools.
420///
421/// Turns are a convenient way to keep history around: replaying them with
422/// [`Prompt::with_history`] re-expands them into the flat message list a
423/// provider expects.
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
425pub struct ConversationTurn {
426    /// The user message that opened the turn.
427    pub user_message: Message,
428    /// The assistant reply, including any tool calls it requested.
429    pub assistant_message: Message,
430    /// Tool result messages produced for this turn, in execution order.
431    pub tool_results: Vec<Message>,
432}
433
434/// A stream event from an AI generation request.
435///
436/// Emitted by
437/// [`RequestBuilder::generate_stream_events`](crate::RequestBuilder::generate_stream_events),
438/// which assembles low-level provider events into this higher-level shape.
439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
440#[serde(tag = "type", rename_all = "snake_case")]
441pub enum StreamEvent {
442    /// An incremental piece of assistant text.
443    TextDelta {
444        /// Text to append to what has been received so far.
445        text: String,
446    },
447    /// A tool call that has finished streaming its arguments.
448    ToolCall {
449        /// Provider-assigned call identifier.
450        id: String,
451        /// Name of the tool the model wants to run.
452        name: String,
453        /// Raw JSON argument string as streamed by the provider.
454        arguments: String,
455    },
456    /// The result of executing a tool call.
457    ToolResult {
458        /// Identifier of the call this result answers.
459        id: String,
460        /// Serialized tool output.
461        result: String,
462    },
463    /// The turn is finished; carries the assembled conversation turn.
464    TurnComplete {
465        /// The complete turn, ready to be stored as history.
466        turn: ConversationTurn,
467    },
468}
469
470/// A collection of messages forming a prompt.
471///
472/// `Prompt` implements `From<&str>`, `From<String>`, `From<Message>`, and
473/// `From<Vec<Message>>`, so most call sites can pass their input directly to
474/// [`RequestBuilder::prompt`](crate::RequestBuilder::prompt).
475///
476/// # Examples
477///
478/// ```no_run
479/// use rai_sdk::{Message, Prompt};
480///
481/// let prompt = Prompt::single(Message::system("Be brief."))
482///     .with_message(Message::user("Define entropy."));
483///
484/// assert_eq!(prompt.system_message(), Some("Be brief."));
485/// assert_eq!(prompt.conversation_messages().len(), 1);
486/// ```
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct Prompt {
489    /// Messages in provider order; system messages usually come first.
490    pub messages: Vec<Message>,
491}
492
493impl Prompt {
494    /// Build a prompt from a full message list.
495    pub fn new(messages: Vec<Message>) -> Self {
496        Self { messages }
497    }
498
499    /// Build a prompt from a single message.
500    pub fn single(message: Message) -> Self {
501        Self {
502            messages: vec![message],
503        }
504    }
505
506    /// Append a message to the prompt.
507    pub fn push_message(&mut self, message: Message) {
508        self.messages.push(message);
509    }
510
511    /// Return a new prompt with an additional message appended.
512    pub fn with_message(mut self, message: Message) -> Self {
513        self.push_message(message);
514        self
515    }
516
517    /// Add a history of conversation turns to the prompt.
518    pub fn with_history(mut self, history: Vec<ConversationTurn>) -> Self {
519        for turn in history {
520            self.push_turn(turn);
521        }
522        self
523    }
524
525    /// Append a single conversation turn to the prompt.
526    pub fn push_turn(&mut self, turn: ConversationTurn) {
527        self.messages.push(turn.user_message);
528        self.messages.push(turn.assistant_message);
529        self.messages.extend(turn.tool_results);
530    }
531
532    /// Extract the system message if present (first system message).
533    pub fn system_message(&self) -> Option<&str> {
534        self.messages
535            .iter()
536            .find(|m| m.role == Role::System)
537            .map(|m| m.content.as_str())
538    }
539
540    /// Get non-system messages.
541    pub fn conversation_messages(&self) -> Vec<&Message> {
542        self.messages
543            .iter()
544            .filter(|m| m.role != Role::System)
545            .collect()
546    }
547
548    /// Check if this prompt contains any multimodal content.
549    pub fn is_multimodal(&self) -> bool {
550        self.messages.iter().any(|m| m.is_multimodal())
551    }
552}
553
554impl From<&Prompt> for Prompt {
555    fn from(prompt: &Prompt) -> Self {
556        prompt.clone()
557    }
558}
559
560impl From<Vec<Message>> for Prompt {
561    fn from(messages: Vec<Message>) -> Self {
562        Self::new(messages)
563    }
564}
565
566impl From<Message> for Prompt {
567    fn from(message: Message) -> Self {
568        Self::single(message)
569    }
570}
571
572impl From<&str> for Prompt {
573    fn from(text: &str) -> Self {
574        Prompt {
575            messages: vec![Message::user(text.to_string())],
576        }
577    }
578}
579
580impl From<String> for Prompt {
581    fn from(text: String) -> Self {
582        Prompt {
583            messages: vec![Message::user(text)],
584        }
585    }
586}
587
588/// Token usage metadata from the AI response.
589///
590/// Fields are optional because providers do not all report the same counters,
591/// and streaming responses only include usage on the final event.
592#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
593pub struct Usage {
594    /// Tokens consumed by the prompt (input tokens).
595    pub prompt_tokens: Option<i32>,
596    /// Tokens produced by the model (output tokens).
597    pub completion_tokens: Option<i32>,
598    /// Prompt plus completion tokens.
599    pub total_tokens: Option<i32>,
600}
601
602/// The response from an AI generation request.
603///
604/// # Examples
605///
606/// ```no_run
607/// # async fn run() -> rai_sdk::Result<()> {
608/// use rai_sdk::{ClientBuilder, Model};
609///
610/// let client = ClientBuilder::new()
611///     .from_env()
612///     .model(Model::gpt4o_mini())
613///     .build()?;
614///
615/// let response = client.request().prompt("Say hi.").generate().await?;
616///
617/// println!("{}", response.text());
618/// println!("served by {} using {}", response.provider, response.model);
619/// if let Some(usage) = &response.usage {
620///     println!("{:?} total tokens", usage.total_tokens);
621/// }
622/// # Ok(())
623/// # }
624/// ```
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
626pub struct Response {
627    /// The generated message(s).
628    pub messages: Vec<Message>,
629
630    /// Token usage information.
631    pub usage: Option<Usage>,
632
633    /// The model that was used.
634    pub model: String,
635
636    /// The provider that was used.
637    pub provider: ProviderKind,
638
639    /// Finish reason (e.g., "stop", "length", "tool_use").
640    pub finish_reason: Option<String>,
641}
642
643impl Response {
644    /// Helper to extract the text content from the first message in the response.
645    ///
646    /// Returns an empty string if the response contains no messages.
647    pub fn text(&self) -> String {
648        self.messages
649            .first()
650            .map(|m| m.text_content())
651            .unwrap_or_default()
652    }
653}
654
655/// A chunk of streamed response.
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657pub struct StreamChunk {
658    /// The text content in this chunk.
659    pub content: String,
660
661    /// Whether this is the final chunk.
662    pub done: bool,
663
664    /// Finish reason (only present on final chunk).
665    pub finish_reason: Option<String>,
666
667    /// Usage metadata (only present on final chunk for some providers).
668    pub usage: Option<Usage>,
669}
670
671/// Parsed structured output together with the underlying AI response.
672///
673/// Returned by
674/// [`RequestBuilder::generate_structured`](crate::RequestBuilder::generate_structured);
675/// the raw [`Response`] is kept so callers still have access to usage, model,
676/// and finish reason.
677#[derive(Debug, Clone)]
678pub struct StructuredOutput<T> {
679    /// The response content deserialized into `T` and schema-validated.
680    pub output: T,
681    /// The underlying provider response the value was parsed from.
682    pub response: Response,
683}
684
685/// A provider-agnostic tool definition sent to the model.
686///
687/// Produced from a [`Tool`](crate::Tool) when a request is built; providers
688/// translate it into their own function/tool schema.
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690pub struct ToolDefinition {
691    /// Tool name the model uses to call it.
692    pub name: String,
693    /// Optional description telling the model when to use the tool.
694    #[serde(skip_serializing_if = "Option::is_none")]
695    pub description: Option<String>,
696    /// JSON Schema describing the accepted arguments.
697    pub input_schema: serde_json::Value,
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn system_message_extraction() {
706        let prompt = Prompt::new(vec![Message::system("Be concise."), Message::user("Hello")]);
707        assert_eq!(prompt.system_message(), Some("Be concise."));
708        assert_eq!(prompt.conversation_messages().len(), 1);
709    }
710
711    #[test]
712    fn multimodal_text_content_concatenation() {
713        let msg = Message::user_multimodal(vec![
714            ContentBlock::text("First"),
715            ContentBlock::image_url("https://example.com/img.png"),
716            ContentBlock::text("Second"),
717        ]);
718        assert!(msg.is_multimodal());
719        assert_eq!(msg.text_content(), "First\nSecond");
720    }
721
722    #[test]
723    fn prompt_from_conversions() {
724        let p1: Prompt = Message::user("hi").into();
725        assert_eq!(p1.messages.len(), 1);
726
727        let p2: Prompt = vec![Message::user("a"), Message::user("b")].into();
728        assert_eq!(p2.messages.len(), 2);
729    }
730}