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}