Skip to main content

mixtape_core/
model.rs

1//! Model traits and types
2//!
3//! This module defines the core model abstraction:
4//! - `Model` trait for model metadata (name, token limits)
5//! - Provider-specific traits (`BedrockModel`, `AnthropicModel`) for API IDs
6//!
7//! Models are simple structs that implement these traits. All API interaction
8//! goes through the provider (e.g., `BedrockProvider`).
9
10mod runtime;
11pub use runtime::RuntimeBedrockModel;
12
13use crate::events::TokenUsage;
14use crate::types::{ContentBlock, Message, StopReason, ToolDefinition};
15
16/// Request parameters for model completion
17#[derive(Debug, Clone)]
18pub struct ModelRequest {
19    pub messages: Vec<Message>,
20    pub system_prompt: Option<String>,
21    pub max_tokens: i32,
22    pub temperature: Option<f32>,
23    pub top_p: Option<f32>,
24    pub tools: Vec<ToolDefinition>,
25}
26
27/// Response from a model completion
28#[derive(Debug, Clone)]
29pub struct ModelResponse {
30    /// The assistant's response message
31    pub message: Message,
32    /// Why the model stopped generating
33    pub stop_reason: StopReason,
34    /// Token usage statistics (if provided by the model)
35    pub usage: Option<TokenUsage>,
36}
37
38/// Core model metadata trait
39///
40/// All models implement this to provide their capabilities.
41/// This is provider-agnostic - the same model has the same
42/// context window whether accessed via Bedrock or Anthropic.
43pub trait Model: Send + Sync {
44    /// Human-readable model name (e.g., "Claude Sonnet 4.5")
45    fn name(&self) -> &str;
46
47    /// Maximum input context tokens
48    fn max_context_tokens(&self) -> usize;
49
50    /// Maximum output tokens the model can generate
51    fn max_output_tokens(&self) -> usize;
52
53    /// Estimate token count for text
54    ///
55    /// Models should implement this to provide accurate token estimation.
56    /// A simple heuristic (~4 characters per token) works reasonably well
57    /// for most models but can be overridden with actual tokenization.
58    fn estimate_token_count(&self, text: &str) -> usize;
59
60    /// Estimate tokens for a conversation
61    ///
62    /// Default implementation sums token estimates for all content blocks
63    /// plus overhead for message structure.
64    fn estimate_message_tokens(&self, messages: &[Message]) -> usize {
65        let mut total = 0;
66        for message in messages {
67            // Role overhead (~4 tokens for role marker and structure)
68            total += 4;
69            // Content blocks
70            for block in &message.content {
71                total += self.estimate_content_block_tokens(block);
72            }
73        }
74        total
75    }
76
77    /// Estimate tokens for a single content block
78    fn estimate_content_block_tokens(&self, block: &ContentBlock) -> usize {
79        match block {
80            ContentBlock::Text(text) => self.estimate_token_count(text),
81            ContentBlock::ToolUse(tool_use) => {
82                // Tool name + ID + JSON input
83                self.estimate_token_count(&tool_use.name)
84                    + self.estimate_token_count(&tool_use.id)
85                    + self.estimate_token_count(&tool_use.input.to_string())
86                    + 10 // Structure overhead
87            }
88            ContentBlock::ToolResult(result) => {
89                // Tool use ID + content
90                self.estimate_token_count(&result.tool_use_id)
91                    + match &result.content {
92                        crate::tool::ToolResult::Text(t) => self.estimate_token_count(t.as_str()),
93                        crate::tool::ToolResult::Json(v) => {
94                            self.estimate_token_count(&v.to_string())
95                        }
96                        crate::tool::ToolResult::Image { data, .. } => {
97                            // Images are typically ~1 token per 750 bytes
98                            data.len() / 750 + 85 // Base overhead for image
99                        }
100                        crate::tool::ToolResult::Document { data, .. } => {
101                            // Documents vary; rough estimate
102                            data.len() / 500 + 50 // Base overhead for document
103                        }
104                    }
105                    + 10 // Structure overhead
106            }
107            ContentBlock::ResponsesReplay(replay) => {
108                replay
109                    .items
110                    .iter()
111                    .map(|item| self.estimate_token_count(&item.to_string()))
112                    .sum::<usize>()
113                    + 10
114            }
115            ContentBlock::RedactedThinking { data } => self.estimate_token_count(data) + 10,
116            ContentBlock::Thinking {
117                thinking,
118                signature,
119            } => {
120                // Estimate tokens for thinking content
121                self.estimate_token_count(thinking) + self.estimate_token_count(signature) + 10
122            }
123        }
124    }
125}
126
127/// Cross-region inference profile configuration for Bedrock
128///
129/// Inference profiles enable cross-region load balancing for higher throughput
130/// and improved reliability. When enabled, Bedrock automatically routes requests
131/// to the optimal region within the specified geographic scope.
132///
133/// Some newer models (Claude 4/4.5, Nova 2 Lite) require inference profiles
134/// and don't support direct single-region invocation.
135///
136/// See: <https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html>
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub enum InferenceProfile {
139    /// No inference profile - single-region invocation (default)
140    ///
141    /// Requests go directly to the region configured in your AWS SDK.
142    /// Use this for predictable routing and when data locality is important.
143    #[default]
144    None,
145
146    /// US regions only (us-east-1, us-east-2, us-west-2, etc.)
147    US,
148
149    /// European regions only (eu-central-1, eu-west-1, eu-west-2, etc.)
150    EU,
151
152    /// Asia-Pacific regions (ap-northeast-1, ap-southeast-1, etc.)
153    APAC,
154
155    /// Global cross-region inference (all commercial AWS regions)
156    ///
157    /// Provides maximum throughput but may route to any region worldwide.
158    Global,
159}
160
161impl InferenceProfile {
162    /// Apply this inference profile to a base model ID
163    ///
164    /// Returns the full model ID to use with Bedrock API. An already-qualified
165    /// profile ID or ARN is an exact target and is never prefixed or rerouted.
166    pub fn apply_to(&self, base_model_id: &str) -> String {
167        if base_model_id.starts_with("arn:")
168            || ["us.", "eu.", "apac.", "au.", "in.", "jp.", "global."]
169                .iter()
170                .any(|prefix| base_model_id.starts_with(prefix))
171        {
172            return base_model_id.to_owned();
173        }
174        match self.prefix() {
175            Some(prefix) => format!("{}.{}", prefix, base_model_id),
176            None => base_model_id.to_string(),
177        }
178    }
179
180    /// Get the prefix for this inference profile, if any
181    fn prefix(&self) -> Option<&'static str> {
182        match self {
183            InferenceProfile::None => None,
184            InferenceProfile::US => Some("us"),
185            InferenceProfile::EU => Some("eu"),
186            InferenceProfile::APAC => Some("apac"),
187            InferenceProfile::Global => Some("global"),
188        }
189    }
190}
191
192/// Trait for models available on AWS Bedrock
193///
194/// Models implement this to be usable with `BedrockProvider`.
195pub trait BedrockModel: Model {
196    /// The Bedrock model ID
197    ///
198    /// This is the full model identifier used in Bedrock API calls,
199    /// e.g., "anthropic.claude-sonnet-4-5-20250929-v1:0"
200    fn bedrock_id(&self) -> &str;
201
202    /// The default inference profile for this model
203    ///
204    /// Models that require cross-region inference (Claude 4/4.5, Nova 2 Lite)
205    /// should return `InferenceProfile::Global`. Other models default to
206    /// `InferenceProfile::None` for single-region invocation.
207    fn default_inference_profile(&self) -> InferenceProfile {
208        InferenceProfile::None
209    }
210}
211
212/// Trait for models available via Anthropic's direct API
213///
214/// Models implement this to be usable with a future `AnthropicProvider`.
215pub trait AnthropicModel: Model {
216    /// The Anthropic API model ID
217    ///
218    /// e.g., "claude-sonnet-4-5-20250929"
219    fn anthropic_id(&self) -> &'static str;
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::tool::{DocumentFormat, ImageFormat, ToolResult};
226    use crate::types::{
227        ContentBlock, Message, Role, ToolResultBlock, ToolResultStatus, ToolUseBlock,
228    };
229
230    /// Simple test model with predictable token estimation
231    struct TestModel;
232
233    impl Model for TestModel {
234        fn name(&self) -> &'static str {
235            "TestModel"
236        }
237
238        fn max_context_tokens(&self) -> usize {
239            100_000
240        }
241
242        fn max_output_tokens(&self) -> usize {
243            4096
244        }
245
246        fn estimate_token_count(&self, text: &str) -> usize {
247            // Simple: ~4 chars per token, rounding up
248            text.len().div_ceil(4)
249        }
250    }
251
252    // ===== Token Estimation Tests =====
253
254    #[test]
255    fn test_estimate_message_tokens_empty() {
256        let model = TestModel;
257        let messages: Vec<Message> = vec![];
258        assert_eq!(model.estimate_message_tokens(&messages), 0);
259    }
260
261    #[test]
262    fn test_estimate_message_tokens_simple_text() {
263        let model = TestModel;
264        let messages = vec![Message::user("Hello world")]; // 11 chars = 3 tokens + 4 overhead = 7
265
266        let tokens = model.estimate_message_tokens(&messages);
267        assert_eq!(tokens, 7);
268    }
269
270    #[test]
271    fn test_estimate_message_tokens_multiple_messages() {
272        let model = TestModel;
273        let messages = vec![
274            Message::user("Hello"),         // 5 chars = 2 tokens + 4 overhead = 6
275            Message::assistant("Hi there"), // 8 chars = 2 tokens + 4 overhead = 6
276        ];
277
278        let tokens = model.estimate_message_tokens(&messages);
279        assert_eq!(tokens, 12);
280    }
281
282    #[test]
283    fn test_estimate_content_block_tokens_text() {
284        let model = TestModel;
285        let block = ContentBlock::Text("test".to_string()); // 4 chars = 1 token
286        assert_eq!(model.estimate_content_block_tokens(&block), 1);
287    }
288
289    #[test]
290    fn test_estimate_content_block_tokens_text_empty() {
291        let model = TestModel;
292        let block = ContentBlock::Text(String::new());
293        assert_eq!(model.estimate_content_block_tokens(&block), 0);
294    }
295
296    #[test]
297    fn test_estimate_content_block_tokens_tool_use() {
298        let model = TestModel;
299        let block = ContentBlock::ToolUse(ToolUseBlock {
300            id: "id12".to_string(),               // 4 chars = 1 token
301            name: "search".to_string(),           // 6 chars = 2 tokens
302            input: serde_json::json!({"q": "x"}), // ~10 chars = 3 tokens
303        });
304
305        // 1 + 2 + 3 + 10 (overhead) = 16
306        let tokens = model.estimate_content_block_tokens(&block);
307        assert!(tokens >= 10, "Should include overhead, got {}", tokens);
308    }
309
310    #[test]
311    fn test_estimate_content_block_tokens_tool_result_text() {
312        let model = TestModel;
313        let block = ContentBlock::ToolResult(ToolResultBlock {
314            tool_use_id: "id12".to_string(), // 4 chars = 1 token
315            content: ToolResult::Text("result text".to_string()), // 11 chars = 3 tokens
316            status: ToolResultStatus::Success,
317        });
318
319        // 1 + 3 + 10 (overhead) = 14
320        let tokens = model.estimate_content_block_tokens(&block);
321        assert!(tokens >= 10, "Should include overhead, got {}", tokens);
322    }
323
324    #[test]
325    fn test_estimate_content_block_tokens_tool_result_json() {
326        let model = TestModel;
327        let block = ContentBlock::ToolResult(ToolResultBlock {
328            tool_use_id: "id".to_string(),
329            content: ToolResult::Json(serde_json::json!({"key": "value"})),
330            status: ToolResultStatus::Success,
331        });
332
333        let tokens = model.estimate_content_block_tokens(&block);
334        assert!(tokens >= 10, "Should include overhead, got {}", tokens);
335    }
336
337    #[test]
338    fn test_estimate_content_block_tokens_image() {
339        let model = TestModel;
340        // 7500 bytes / 750 + 85 = 95 tokens
341        let data = vec![0u8; 7500];
342        let block = ContentBlock::ToolResult(ToolResultBlock {
343            tool_use_id: "img".to_string(),
344            content: ToolResult::Image {
345                format: ImageFormat::Png,
346                data,
347            },
348            status: ToolResultStatus::Success,
349        });
350
351        let tokens = model.estimate_content_block_tokens(&block);
352        // 7500/750 + 85 = 10 + 85 = 95 + tool_use_id tokens + overhead
353        assert!(
354            tokens >= 95,
355            "Expected at least 95 tokens for image, got {}",
356            tokens
357        );
358    }
359
360    #[test]
361    fn test_estimate_content_block_tokens_document() {
362        let model = TestModel;
363        // 5000 bytes / 500 + 50 = 60 tokens
364        let data = vec![0u8; 5000];
365        let block = ContentBlock::ToolResult(ToolResultBlock {
366            tool_use_id: "doc".to_string(),
367            content: ToolResult::Document {
368                format: DocumentFormat::Pdf,
369                data,
370                name: Some("test.pdf".to_string()),
371            },
372            status: ToolResultStatus::Success,
373        });
374
375        let tokens = model.estimate_content_block_tokens(&block);
376        // 5000/500 + 50 = 10 + 50 = 60 + overhead
377        assert!(
378            tokens >= 60,
379            "Expected at least 60 tokens for document, got {}",
380            tokens
381        );
382    }
383
384    #[test]
385    fn test_estimate_content_block_tokens_thinking() {
386        let model = TestModel;
387        let block = ContentBlock::Thinking {
388            thinking: "complex reasoning here".to_string(), // 22 chars = 6 tokens
389            signature: "sig".to_string(),                   // 3 chars = 1 token
390        };
391
392        // 6 + 1 + 10 (overhead) = 17
393        let tokens = model.estimate_content_block_tokens(&block);
394        assert!(tokens >= 10, "Should include overhead, got {}", tokens);
395    }
396
397    #[test]
398    fn test_estimate_message_with_multiple_content_blocks() {
399        let model = TestModel;
400        let messages = vec![Message {
401            role: Role::Assistant,
402            content: vec![
403                ContentBlock::Text("Let me search".to_string()),
404                ContentBlock::ToolUse(ToolUseBlock {
405                    id: "1".to_string(),
406                    name: "search".to_string(),
407                    input: serde_json::json!({"q": "test"}),
408                }),
409            ],
410        }];
411
412        let tokens = model.estimate_message_tokens(&messages);
413        // 4 (overhead) + text tokens + tool use tokens
414        assert!(tokens > 4, "Should have content tokens plus overhead");
415    }
416
417    // ===== InferenceProfile Tests =====
418
419    #[test]
420    fn test_inference_profile_apply_none() {
421        let profile = InferenceProfile::None;
422        assert_eq!(profile.apply_to("anthropic.claude-3"), "anthropic.claude-3");
423    }
424
425    #[test]
426    fn test_inference_profile_apply_us() {
427        let profile = InferenceProfile::US;
428        assert_eq!(
429            profile.apply_to("anthropic.claude-3"),
430            "us.anthropic.claude-3"
431        );
432    }
433
434    #[test]
435    fn test_inference_profile_apply_eu() {
436        let profile = InferenceProfile::EU;
437        assert_eq!(
438            profile.apply_to("anthropic.claude-3"),
439            "eu.anthropic.claude-3"
440        );
441    }
442
443    #[test]
444    fn test_inference_profile_apply_apac() {
445        let profile = InferenceProfile::APAC;
446        assert_eq!(profile.apply_to("model-id"), "apac.model-id");
447    }
448
449    #[test]
450    fn test_inference_profile_apply_global() {
451        let profile = InferenceProfile::Global;
452        assert_eq!(profile.apply_to("model-id"), "global.model-id");
453    }
454
455    #[test]
456    fn test_inference_profile_all_variants() {
457        let cases = [
458            (InferenceProfile::None, "model", "model"),
459            (InferenceProfile::US, "model", "us.model"),
460            (InferenceProfile::EU, "model", "eu.model"),
461            (InferenceProfile::APAC, "model", "apac.model"),
462            (InferenceProfile::Global, "model", "global.model"),
463        ];
464
465        for (profile, base, expected) in cases {
466            assert_eq!(profile.apply_to(base), expected, "Failed for {:?}", profile);
467        }
468    }
469
470    #[test]
471    fn test_inference_profile_default() {
472        let profile = InferenceProfile::default();
473        assert_eq!(profile, InferenceProfile::None);
474    }
475}