Skip to main content

behest_core/
message.rs

1//! Provider-neutral chat request and response data structures.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::cache::CacheControl;
7use crate::id::{ModelName, ProviderId};
8use crate::tool_types::{ToolCall, ToolChoice, ToolSpec};
9
10/// Request for a complete or streamed chat response.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ChatRequest {
13    /// Provider-specific model name.
14    pub model: ModelName,
15    /// Ordered conversation messages.
16    pub messages: Vec<Message>,
17    /// Tool definitions available to the model.
18    pub tools: Vec<ToolSpec>,
19    /// Tool selection policy.
20    pub tool_choice: ToolChoice,
21    /// Optional output format constraint.
22    pub response_format: Option<ResponseFormat>,
23    /// Sampling temperature.
24    pub temperature: Option<f32>,
25    /// Nucleus sampling probability.
26    pub top_p: Option<f32>,
27    /// Maximum output tokens.
28    pub max_output_tokens: Option<u32>,
29    /// Stop sequences.
30    pub stop: Vec<String>,
31    /// Application metadata forwarded to provider adapters.
32    pub metadata: Value,
33}
34
35impl ChatRequest {
36    /// Creates a chat request for the given model with no messages.
37    #[must_use]
38    pub fn new(model: ModelName) -> Self {
39        Self {
40            model,
41            messages: Vec::new(),
42            tools: Vec::new(),
43            tool_choice: ToolChoice::default(),
44            response_format: None,
45            temperature: None,
46            top_p: None,
47            max_output_tokens: None,
48            stop: Vec::new(),
49            metadata: Value::Null,
50        }
51    }
52
53    /// Appends one message to the request.
54    #[must_use]
55    pub fn with_message(mut self, message: Message) -> Self {
56        self.messages.push(message);
57        self
58    }
59
60    /// Appends a user text message to the request.
61    #[must_use]
62    pub fn with_user_text(self, text: impl Into<String>) -> Self {
63        self.with_message(Message::user_text(text))
64    }
65
66    /// Adds a tool definition to the request.
67    #[must_use]
68    pub fn with_tool(mut self, tool: ToolSpec) -> Self {
69        self.tools.push(tool);
70        self
71    }
72}
73
74/// Chat message role and content.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case", tag = "role")]
77#[non_exhaustive]
78pub enum Message {
79    /// System instruction message.
80    System {
81        /// Message content parts.
82        content: Vec<ContentPart>,
83    },
84    /// User message.
85    User {
86        /// Message content parts.
87        content: Vec<ContentPart>,
88    },
89    /// Assistant message, optionally including tool calls.
90    Assistant {
91        /// Message content parts.
92        content: Vec<ContentPart>,
93        /// Tool calls requested by the assistant.
94        tool_calls: Vec<ToolCall>,
95    },
96    /// Tool result message.
97    Tool {
98        /// Tool call identifier being answered.
99        tool_call_id: String,
100        /// Tool name that produced the result.
101        name: String,
102        /// Tool result content parts.
103        content: Vec<ContentPart>,
104    },
105}
106
107impl Message {
108    /// Creates a system text message.
109    #[must_use]
110    pub fn system_text(text: impl Into<String>) -> Self {
111        Self::System {
112            content: vec![ContentPart::text(text)],
113        }
114    }
115
116    /// Creates a user text message.
117    #[must_use]
118    pub fn user_text(text: impl Into<String>) -> Self {
119        Self::User {
120            content: vec![ContentPart::text(text)],
121        }
122    }
123
124    /// Creates an assistant text message without tool calls.
125    #[must_use]
126    pub fn assistant_text(text: impl Into<String>) -> Self {
127        Self::Assistant {
128            content: vec![ContentPart::text(text)],
129            tool_calls: Vec::new(),
130        }
131    }
132
133    /// Creates a tool result message.
134    #[must_use]
135    pub fn tool_text(
136        tool_call_id: impl Into<String>,
137        name: impl Into<String>,
138        text: impl Into<String>,
139    ) -> Self {
140        Self::Tool {
141            tool_call_id: tool_call_id.into(),
142            name: name.into(),
143            content: vec![ContentPart::text(text)],
144        }
145    }
146
147    /// Returns the tool calls from an Assistant message, or empty slice.
148    #[must_use]
149    pub fn tool_calls(&self) -> &[ToolCall] {
150        match self {
151            Self::Assistant { tool_calls, .. } => tool_calls.as_slice(),
152            _ => &[],
153        }
154    }
155
156    /// Marks the last content part of this message as a cache breakpoint.
157    ///
158    /// If the message has no content parts, the marker is a no-op. If the
159    /// last content part already has a cache control, it is replaced.
160    ///
161    /// This is a convenience for callers that want to place a cache
162    /// breakpoint at the end of a message (the most common case).
163    #[must_use]
164    pub fn mark_cache_breakpoint(mut self) -> Self {
165        let ctrl = CacheControl::ephemeral();
166        match &mut self {
167            Self::System { content } | Self::User { content } | Self::Tool { content, .. } => {
168                if let Some(last) = content.last_mut() {
169                    last.set_cache_control(ctrl);
170                }
171            }
172            Self::Assistant { content, .. } => {
173                if let Some(last) = content.last_mut() {
174                    last.set_cache_control(ctrl);
175                }
176            }
177        }
178        self
179    }
180}
181
182/// A single content part inside a chat message.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case", tag = "type")]
185#[non_exhaustive]
186pub enum ContentPart {
187    /// Plain text content.
188    Text {
189        /// Text payload.
190        text: String,
191        /// Optional cache marker placed at the end of this content block.
192        #[serde(default, skip_serializing_if = "Option::is_none")]
193        cache_control: Option<CacheControl>,
194    },
195    /// JSON value content.
196    Json {
197        /// JSON payload.
198        value: Value,
199        /// Optional cache marker placed at the end of this content block.
200        #[serde(default, skip_serializing_if = "Option::is_none")]
201        cache_control: Option<CacheControl>,
202    },
203    /// Image referenced by URL.
204    ImageUrl {
205        /// Public or provider-accessible image URL.
206        url: String,
207        /// Optional MIME type hint.
208        mime_type: Option<String>,
209        /// Optional cache marker placed at the end of this content block.
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        cache_control: Option<CacheControl>,
212    },
213}
214
215impl ContentPart {
216    /// Creates a text content part.
217    #[must_use]
218    pub fn text(text: impl Into<String>) -> Self {
219        Self::Text {
220            text: text.into(),
221            cache_control: None,
222        }
223    }
224
225    /// Creates a JSON content part.
226    #[must_use]
227    pub fn json(value: Value) -> Self {
228        Self::Json {
229            value,
230            cache_control: None,
231        }
232    }
233
234    /// Creates an image URL content part.
235    #[must_use]
236    pub fn image_url(url: impl Into<String>, mime_type: Option<String>) -> Self {
237        Self::ImageUrl {
238            url: url.into(),
239            mime_type,
240            cache_control: None,
241        }
242    }
243
244    /// Returns a copy of this content part with the given cache control marker.
245    #[must_use]
246    pub fn with_cache_control(mut self, ctrl: CacheControl) -> Self {
247        self.set_cache_control(ctrl);
248        self
249    }
250
251    /// Sets the cache control marker on this content part in place.
252    pub fn set_cache_control(&mut self, ctrl: CacheControl) {
253        match self {
254            Self::Text { cache_control, .. }
255            | Self::Json { cache_control, .. }
256            | Self::ImageUrl { cache_control, .. } => *cache_control = Some(ctrl),
257        }
258    }
259
260    /// Returns the cache control marker on this content part, if any.
261    #[must_use]
262    pub fn cache_control(&self) -> Option<CacheControl> {
263        match self {
264            Self::Text { cache_control, .. }
265            | Self::Json { cache_control, .. }
266            | Self::ImageUrl { cache_control, .. } => *cache_control,
267        }
268    }
269}
270
271/// Output format requested from a chat provider.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case", tag = "type")]
274#[non_exhaustive]
275pub enum ResponseFormat {
276    /// Unconstrained text output.
277    Text,
278    /// Provider-native JSON object mode.
279    JsonObject,
280    /// Provider-native JSON schema mode.
281    JsonSchema {
282        /// Schema name sent to the provider.
283        name: String,
284        /// JSON schema document.
285        schema: Value,
286        /// Whether provider should strictly enforce the schema.
287        strict: bool,
288    },
289}
290
291/// Complete chat response from a provider.
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293pub struct ChatResponse {
294    /// Provider that produced the response.
295    pub provider: ProviderId,
296    /// Model that produced the response.
297    pub model: ModelName,
298    /// Assistant message returned by the provider.
299    pub message: Message,
300    /// Reason the provider stopped generating.
301    pub finish_reason: FinishReason,
302    /// Token accounting, when supplied by the provider.
303    pub usage: Option<TokenUsage>,
304    /// Raw provider response for adapters that retain it.
305    pub raw: Option<Value>,
306}
307
308/// Reason generation ended.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311#[non_exhaustive]
312pub enum FinishReason {
313    /// Natural stop condition.
314    Stop,
315    /// Provider stopped because tool calls were produced.
316    ToolCalls,
317    /// Maximum token limit was reached.
318    Length,
319    /// Provider content filter interrupted generation.
320    ContentFilter,
321    /// Provider reported an error after partial generation.
322    Error,
323    /// Provider-specific finish reason.
324    Unknown(String),
325}
326
327/// Token usage reported by a provider.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329pub struct TokenUsage {
330    /// Number of input tokens.
331    pub input_tokens: u64,
332    /// Number of output tokens.
333    pub output_tokens: u64,
334    /// Total token count.
335    pub total_tokens: u64,
336    /// Number of input tokens written to cache by this call.
337    ///
338    /// Populated by Anthropic when the request created a new cache entry.
339    /// `None` for providers that do not report cache writes or when no
340    /// caching occurred.
341    pub cache_creation_input_tokens: Option<u64>,
342    /// Number of input tokens served from cache by this call.
343    ///
344    /// Populated by Anthropic (`cache_read_input_tokens`) and DeepSeek
345    /// (`prompt_cache_hit_tokens`). `None` when the provider does not
346    /// report cache reads or no cache was hit.
347    pub cache_read_input_tokens: Option<u64>,
348    /// Number of input tokens served from cache by this call.
349    ///
350    /// Populated by OpenAI's `prompt_tokens_details.cached_tokens`.
351    /// `None` for providers that do not report this field.
352    pub cached_input_tokens: Option<u64>,
353}
354
355impl TokenUsage {
356    /// Creates token usage and computes the total.
357    ///
358    /// Cache fields default to `None`; use [`TokenUsage::with_cache_stats`] to
359    /// populate them or [`TokenUsage::merge`] to combine two usages.
360    #[must_use]
361    pub const fn new(input_tokens: u64, output_tokens: u64) -> Self {
362        Self {
363            input_tokens,
364            output_tokens,
365            total_tokens: input_tokens + output_tokens,
366            cache_creation_input_tokens: None,
367            cache_read_input_tokens: None,
368            cached_input_tokens: None,
369        }
370    }
371
372    /// Returns a copy of this usage with the cache fields populated.
373    #[must_use]
374    pub const fn with_cache_stats(
375        mut self,
376        cache_creation_input_tokens: Option<u64>,
377        cache_read_input_tokens: Option<u64>,
378        cached_input_tokens: Option<u64>,
379    ) -> Self {
380        self.cache_creation_input_tokens = cache_creation_input_tokens;
381        self.cache_read_input_tokens = cache_read_input_tokens;
382        self.cached_input_tokens = cached_input_tokens;
383        self
384    }
385
386    /// Returns the total number of cache-related input tokens.
387    ///
388    /// Sums `cache_creation_input_tokens`, `cache_read_input_tokens`, and
389    /// `cached_input_tokens` when present. Returns `0` when no provider
390    /// reported any cache stats.
391    #[must_use]
392    pub fn cache_tokens(&self) -> u64 {
393        self.cache_creation_input_tokens.unwrap_or(0)
394            + self.cache_read_input_tokens.unwrap_or(0)
395            + self.cached_input_tokens.unwrap_or(0)
396    }
397
398    /// Merges two usages by summing all numeric fields.
399    ///
400    /// Numeric fields (`input_tokens`, `output_tokens`, `total_tokens`)
401    /// are summed. Cache fields use the rule: `None + None = None`,
402    /// `None + Some(x) = Some(x)`, `Some(x) + None = Some(x)`,
403    /// `Some(x) + Some(y) = Some(x + y)`.
404    #[must_use]
405    pub fn merge(self, other: Self) -> Self {
406        Self {
407            input_tokens: self.input_tokens + other.input_tokens,
408            output_tokens: self.output_tokens + other.output_tokens,
409            total_tokens: self.input_tokens
410                + other.input_tokens
411                + self.output_tokens
412                + other.output_tokens,
413            cache_creation_input_tokens: add_opt(
414                self.cache_creation_input_tokens,
415                other.cache_creation_input_tokens,
416            ),
417            cache_read_input_tokens: add_opt(
418                self.cache_read_input_tokens,
419                other.cache_read_input_tokens,
420            ),
421            cached_input_tokens: add_opt(self.cached_input_tokens, other.cached_input_tokens),
422        }
423    }
424}
425
426const fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
427    match (a, b) {
428        (None, None) => None,
429        (Some(x), None) | (None, Some(x)) => Some(x),
430        (Some(x), Some(y)) => Some(x + y),
431    }
432}
433
434#[cfg(test)]
435#[allow(clippy::unwrap_used)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn token_usage_new_has_no_cache_stats() {
441        let usage = TokenUsage::new(10, 5);
442        assert_eq!(usage.input_tokens, 10);
443        assert_eq!(usage.output_tokens, 5);
444        assert_eq!(usage.total_tokens, 15);
445        assert_eq!(usage.cache_creation_input_tokens, None);
446        assert_eq!(usage.cache_read_input_tokens, None);
447        assert_eq!(usage.cached_input_tokens, None);
448    }
449
450    #[test]
451    fn token_usage_with_cache_stats_populates_fields() {
452        let usage = TokenUsage::new(100, 50).with_cache_stats(Some(80), Some(20), None);
453        assert_eq!(usage.cache_creation_input_tokens, Some(80));
454        assert_eq!(usage.cache_read_input_tokens, Some(20));
455        assert_eq!(usage.cached_input_tokens, None);
456    }
457
458    #[test]
459    fn token_usage_cache_tokens_sums_all_three_fields() {
460        let usage = TokenUsage::new(100, 50).with_cache_stats(Some(10), Some(20), Some(30));
461        assert_eq!(usage.cache_tokens(), 60);
462    }
463
464    #[test]
465    fn token_usage_cache_tokens_zero_when_all_none() {
466        let usage = TokenUsage::new(100, 50);
467        assert_eq!(usage.cache_tokens(), 0);
468    }
469
470    #[test]
471    fn token_usage_merge_sums_numeric_fields() {
472        let a = TokenUsage::new(100, 50);
473        let b = TokenUsage::new(200, 30);
474        let merged = a.merge(b);
475        assert_eq!(merged.input_tokens, 300);
476        assert_eq!(merged.output_tokens, 80);
477        assert_eq!(merged.total_tokens, 380);
478    }
479
480    #[test]
481    fn token_usage_merge_both_none_yields_none() {
482        let a = TokenUsage::new(100, 50);
483        let b = TokenUsage::new(200, 30);
484        let merged = a.merge(b);
485        assert_eq!(merged.cache_creation_input_tokens, None);
486        assert_eq!(merged.cache_read_input_tokens, None);
487        assert_eq!(merged.cached_input_tokens, None);
488    }
489
490    #[test]
491    fn token_usage_merge_one_some_passes_through() {
492        let a = TokenUsage::new(100, 50).with_cache_stats(Some(40), Some(20), Some(10));
493        let b = TokenUsage::new(200, 30);
494        let merged = a.merge(b);
495        assert_eq!(merged.cache_creation_input_tokens, Some(40));
496        assert_eq!(merged.cache_read_input_tokens, Some(20));
497        assert_eq!(merged.cached_input_tokens, Some(10));
498    }
499
500    #[test]
501    fn token_usage_merge_both_some_sums() {
502        let a = TokenUsage::new(100, 50).with_cache_stats(Some(40), Some(20), Some(10));
503        let b = TokenUsage::new(200, 30).with_cache_stats(Some(60), Some(80), Some(90));
504        let merged = a.merge(b);
505        assert_eq!(merged.cache_creation_input_tokens, Some(100));
506        assert_eq!(merged.cache_read_input_tokens, Some(100));
507        assert_eq!(merged.cached_input_tokens, Some(100));
508    }
509}