Skip to main content

rustyclaw_core/models/
registry.rs

1//! Model registry — manages available models with cost tiers and enable/disable.
2//!
3//! This module provides:
4//! - A registry of all configured models
5//! - Cost tier classification (premium, standard, economy, free)
6//! - Enable/disable per model (independent of active selection)
7//! - Model selection recommendations for sub-agents
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14
15/// Cost tier for a model — used to guide sub-agent model selection.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum CostTier {
19    /// Free models (local Ollama, free API tiers)
20    Free,
21    /// Economy models (fast, cheap — good for simple tasks)
22    Economy,
23    /// Standard models (balanced cost/capability)
24    Standard,
25    /// Premium models (highest capability, highest cost)
26    Premium,
27}
28
29impl CostTier {
30    /// Parse from string.
31    pub fn from_str(s: &str) -> Option<Self> {
32        match s.to_lowercase().as_str() {
33            "free" => Some(Self::Free),
34            "economy" | "eco" | "cheap" => Some(Self::Economy),
35            "standard" | "std" | "balanced" => Some(Self::Standard),
36            "premium" | "pro" | "expensive" => Some(Self::Premium),
37            _ => None,
38        }
39    }
40
41    /// Display name.
42    pub fn display(&self) -> &'static str {
43        match self {
44            Self::Free => "Free",
45            Self::Economy => "Economy",
46            Self::Standard => "Standard",
47            Self::Premium => "Premium",
48        }
49    }
50
51    /// Emoji indicator.
52    pub fn emoji(&self) -> &'static str {
53        match self {
54            Self::Free => "🆓",
55            Self::Economy => "💰",
56            Self::Standard => "⚖️",
57            Self::Premium => "💎",
58        }
59    }
60}
61
62impl Default for CostTier {
63    fn default() -> Self {
64        Self::Standard
65    }
66}
67
68/// Task complexity hint for model selection.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum TaskComplexity {
72    /// Simple tasks: grep, list, format, summarize
73    Simple,
74    /// Medium tasks: code edits, analysis, research
75    Medium,
76    /// Complex tasks: architecture, debugging, multi-step reasoning
77    Complex,
78    /// Critical tasks: security, production changes, important decisions
79    Critical,
80}
81
82impl TaskComplexity {
83    /// Recommended minimum cost tier for this complexity.
84    pub fn recommended_tier(&self) -> CostTier {
85        match self {
86            Self::Simple => CostTier::Free,
87            Self::Medium => CostTier::Economy,
88            Self::Complex => CostTier::Standard,
89            Self::Critical => CostTier::Premium,
90        }
91    }
92}
93
94/// A registered model with metadata.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ModelEntry {
97    /// Full model ID (e.g., "anthropic/claude-sonnet-4")
98    pub id: String,
99
100    /// Provider ID (e.g., "anthropic", "openai", "ollama")
101    pub provider: String,
102
103    /// Short model name (e.g., "claude-sonnet-4")
104    pub name: String,
105
106    /// Display name for UI
107    pub display_name: String,
108
109    /// Cost tier
110    pub tier: CostTier,
111
112    /// Whether the model is enabled for use
113    pub enabled: bool,
114
115    /// Whether credentials are available for this model
116    pub available: bool,
117
118    /// Context window size (tokens)
119    pub context_window: Option<u32>,
120
121    /// Supports vision/images
122    pub supports_vision: bool,
123
124    /// Supports tool use
125    pub supports_tools: bool,
126
127    /// Supports extended thinking
128    pub supports_thinking: bool,
129
130    /// Optional notes
131    pub notes: Option<String>,
132}
133
134impl ModelEntry {
135    /// Create a new model entry.
136    pub fn new(id: impl Into<String>, provider: impl Into<String>, tier: CostTier) -> Self {
137        let id = id.into();
138        let provider = provider.into();
139        let name = id.split('/').last().unwrap_or(&id).to_string();
140        let display_name = format_display_name(&name);
141
142        Self {
143            id,
144            provider,
145            name,
146            display_name,
147            tier,
148            enabled: true,
149            available: false,
150            context_window: None,
151            supports_vision: false,
152            supports_tools: true,
153            supports_thinking: false,
154            notes: None,
155        }
156    }
157
158    /// Builder: set context window.
159    pub fn with_context(mut self, tokens: u32) -> Self {
160        self.context_window = Some(tokens);
161        self
162    }
163
164    /// Builder: set vision support.
165    pub fn with_vision(mut self) -> Self {
166        self.supports_vision = true;
167        self
168    }
169
170    /// Builder: set thinking support.
171    pub fn with_thinking(mut self) -> Self {
172        self.supports_thinking = true;
173        self
174    }
175
176    /// Builder: set notes.
177    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
178        self.notes = Some(notes.into());
179        self
180    }
181
182    /// Check if model can be used (enabled + available).
183    pub fn is_usable(&self) -> bool {
184        self.enabled && self.available
185    }
186
187    /// Format for display with tier indicator.
188    pub fn format_display(&self) -> String {
189        let status = if !self.available {
190            "⚪"
191        } else if !self.enabled {
192            "🔴"
193        } else {
194            "🟢"
195        };
196        format!(
197            "{} {} {} ({})",
198            status,
199            self.tier.emoji(),
200            self.display_name,
201            self.provider
202        )
203    }
204}
205
206/// Model registry — manages all available models.
207pub struct ModelRegistry {
208    /// All registered models by ID
209    models: HashMap<String, ModelEntry>,
210
211    /// Currently active model ID
212    active_model: Option<String>,
213
214    /// Default model for sub-agents by complexity
215    subagent_defaults: HashMap<TaskComplexity, String>,
216}
217
218impl ModelRegistry {
219    /// Create a new empty registry.
220    pub fn new() -> Self {
221        Self {
222            models: HashMap::new(),
223            active_model: None,
224            subagent_defaults: HashMap::new(),
225        }
226    }
227
228    /// Create with default model entries from providers.
229    pub fn with_defaults() -> Self {
230        let mut registry = Self::new();
231        registry.populate_defaults();
232        registry
233    }
234
235    /// Populate with default models from known providers.
236    fn populate_defaults(&mut self) {
237        // Anthropic
238        self.register(
239            ModelEntry::new("anthropic/claude-opus-4", "anthropic", CostTier::Premium)
240                .with_context(200_000)
241                .with_vision()
242                .with_thinking(),
243        );
244        self.register(
245            ModelEntry::new("anthropic/claude-sonnet-4", "anthropic", CostTier::Standard)
246                .with_context(200_000)
247                .with_vision()
248                .with_thinking(),
249        );
250        self.register(
251            ModelEntry::new("anthropic/claude-haiku-4", "anthropic", CostTier::Economy)
252                .with_context(200_000)
253                .with_vision(),
254        );
255
256        // OpenAI
257        self.register(
258            ModelEntry::new("openai/gpt-4.1", "openai", CostTier::Standard)
259                .with_context(128_000)
260                .with_vision(),
261        );
262        self.register(
263            ModelEntry::new("openai/gpt-4.1-mini", "openai", CostTier::Economy)
264                .with_context(128_000)
265                .with_vision(),
266        );
267        self.register(
268            ModelEntry::new("openai/gpt-4.1-nano", "openai", CostTier::Economy)
269                .with_context(128_000),
270        );
271        self.register(
272            ModelEntry::new("openai/o3", "openai", CostTier::Premium)
273                .with_context(200_000)
274                .with_thinking(),
275        );
276        self.register(
277            ModelEntry::new("openai/o4-mini", "openai", CostTier::Standard)
278                .with_context(200_000)
279                .with_thinking(),
280        );
281
282        // Google
283        self.register(
284            ModelEntry::new("google/gemini-2.5-pro", "google", CostTier::Standard)
285                .with_context(1_000_000)
286                .with_vision()
287                .with_thinking(),
288        );
289        self.register(
290            ModelEntry::new("google/gemini-2.5-flash", "google", CostTier::Economy)
291                .with_context(1_000_000)
292                .with_vision(),
293        );
294        self.register(
295            ModelEntry::new("google/gemini-2.0-flash", "google", CostTier::Economy)
296                .with_context(1_000_000)
297                .with_vision(),
298        );
299
300        // xAI
301        self.register(
302            ModelEntry::new("xai/grok-3", "xai", CostTier::Standard)
303                .with_context(131_072)
304                .with_vision(),
305        );
306        self.register(
307            ModelEntry::new("xai/grok-3-mini", "xai", CostTier::Economy)
308                .with_context(131_072)
309                .with_thinking(),
310        );
311
312        // GitHub Copilot (via proxy)
313        self.register(
314            ModelEntry::new(
315                "github-copilot/claude-opus-4",
316                "github-copilot",
317                CostTier::Free,
318            )
319            .with_context(200_000)
320            .with_vision()
321            .with_thinking()
322            .with_notes("Via Copilot subscription"),
323        );
324        self.register(
325            ModelEntry::new(
326                "github-copilot/claude-sonnet-4",
327                "github-copilot",
328                CostTier::Free,
329            )
330            .with_context(200_000)
331            .with_vision()
332            .with_thinking()
333            .with_notes("Via Copilot subscription"),
334        );
335        self.register(
336            ModelEntry::new("github-copilot/gpt-4.1", "github-copilot", CostTier::Free)
337                .with_context(128_000)
338                .with_vision()
339                .with_notes("Via Copilot subscription"),
340        );
341
342        // Local (Ollama)
343        self.register(
344            ModelEntry::new("ollama/llama3.1", "ollama", CostTier::Free)
345                .with_context(128_000)
346                .with_notes("Local inference"),
347        );
348        self.register(
349            ModelEntry::new("ollama/llama3.2:3b", "ollama", CostTier::Free)
350                .with_context(128_000)
351                .with_notes("Lightweight local"),
352        );
353        self.register(
354            ModelEntry::new("ollama/qwen2.5-coder", "ollama", CostTier::Free)
355                .with_context(32_000)
356                .with_notes("Code-focused local"),
357        );
358
359        // Set default subagent models
360        self.subagent_defaults
361            .insert(TaskComplexity::Simple, "ollama/llama3.2:3b".to_string());
362        self.subagent_defaults.insert(
363            TaskComplexity::Medium,
364            "anthropic/claude-haiku-4".to_string(),
365        );
366        self.subagent_defaults.insert(
367            TaskComplexity::Complex,
368            "anthropic/claude-sonnet-4".to_string(),
369        );
370        self.subagent_defaults.insert(
371            TaskComplexity::Critical,
372            "anthropic/claude-opus-4".to_string(),
373        );
374    }
375
376    /// Register a model.
377    pub fn register(&mut self, model: ModelEntry) {
378        debug!(model_id = %model.id, tier = ?model.tier, "Registering model");
379        self.models.insert(model.id.clone(), model);
380    }
381
382    /// Get a model by ID.
383    pub fn get(&self, id: &str) -> Option<&ModelEntry> {
384        self.models.get(id)
385    }
386
387    /// Get a mutable model by ID.
388    pub fn get_mut(&mut self, id: &str) -> Option<&mut ModelEntry> {
389        self.models.get_mut(id)
390    }
391
392    /// List all models.
393    pub fn all(&self) -> Vec<&ModelEntry> {
394        let mut models: Vec<_> = self.models.values().collect();
395        models.sort_by(|a, b| {
396            a.tier
397                .cmp(&b.tier)
398                .then_with(|| a.provider.cmp(&b.provider))
399                .then_with(|| a.name.cmp(&b.name))
400        });
401        models
402    }
403
404    /// List enabled models.
405    pub fn enabled(&self) -> Vec<&ModelEntry> {
406        self.all().into_iter().filter(|m| m.enabled).collect()
407    }
408
409    /// List usable models (enabled + available).
410    pub fn usable(&self) -> Vec<&ModelEntry> {
411        self.all().into_iter().filter(|m| m.is_usable()).collect()
412    }
413
414    /// List models by tier.
415    pub fn by_tier(&self, tier: CostTier) -> Vec<&ModelEntry> {
416        self.all().into_iter().filter(|m| m.tier == tier).collect()
417    }
418
419    /// Enable a model.
420    pub fn enable(&mut self, id: &str) -> Result<(), String> {
421        let model = self
422            .models
423            .get_mut(id)
424            .ok_or_else(|| format!("Model not found: {}", id))?;
425        model.enabled = true;
426        info!(model_id = %id, "Model enabled");
427        Ok(())
428    }
429
430    /// Disable a model.
431    pub fn disable(&mut self, id: &str) -> Result<(), String> {
432        let model = self
433            .models
434            .get_mut(id)
435            .ok_or_else(|| format!("Model not found: {}", id))?;
436        model.enabled = false;
437        info!(model_id = %id, "Model disabled");
438        Ok(())
439    }
440
441    /// Set model availability (based on credentials).
442    pub fn set_available(&mut self, id: &str, available: bool) {
443        if let Some(model) = self.models.get_mut(id) {
444            model.available = available;
445        }
446    }
447
448    /// Set the active model.
449    pub fn set_active(&mut self, id: &str) -> Result<(), String> {
450        if !self.models.contains_key(id) {
451            return Err(format!("Model not found: {}", id));
452        }
453        self.active_model = Some(id.to_string());
454        info!(model_id = %id, "Active model set");
455        Ok(())
456    }
457
458    /// Get the active model.
459    pub fn active(&self) -> Option<&ModelEntry> {
460        self.active_model
461            .as_ref()
462            .and_then(|id| self.models.get(id))
463    }
464
465    /// Get recommended model for a sub-agent task.
466    pub fn recommend_for_subagent(&self, complexity: TaskComplexity) -> Option<&ModelEntry> {
467        // Try the configured default for this complexity
468        if let Some(default_id) = self.subagent_defaults.get(&complexity) {
469            if let Some(model) = self.models.get(default_id) {
470                if model.is_usable() {
471                    return Some(model);
472                }
473            }
474        }
475
476        // Fall back: find any usable model at or below the recommended tier
477        let recommended_tier = complexity.recommended_tier();
478        self.usable()
479            .into_iter()
480            .filter(|m| m.tier <= recommended_tier)
481            .max_by_key(|m| m.tier) // Prefer highest tier within budget
482    }
483
484    /// Set the default model for a complexity level.
485    pub fn set_subagent_default(&mut self, complexity: TaskComplexity, model_id: String) {
486        self.subagent_defaults.insert(complexity, model_id);
487    }
488
489    /// Get subagent defaults.
490    pub fn subagent_defaults(&self) -> &HashMap<TaskComplexity, String> {
491        &self.subagent_defaults
492    }
493}
494
495impl Default for ModelRegistry {
496    fn default() -> Self {
497        Self::with_defaults()
498    }
499}
500
501/// Shared model registry.
502pub type SharedModelRegistry = Arc<RwLock<ModelRegistry>>;
503
504/// Create a shared model registry.
505pub fn create_model_registry() -> SharedModelRegistry {
506    Arc::new(RwLock::new(ModelRegistry::with_defaults()))
507}
508
509// ── Helpers ─────────────────────────────────────────────────────────────────
510
511/// Format a model name for display.
512fn format_display_name(name: &str) -> String {
513    // Convert snake_case or kebab-case to Title Case
514    name.split(&['-', '_'][..])
515        .map(|word| {
516            let mut chars = word.chars();
517            match chars.next() {
518                Some(c) => c.to_uppercase().chain(chars).collect(),
519                None => String::new(),
520            }
521        })
522        .collect::<Vec<_>>()
523        .join(" ")
524}
525
526/// Generate system prompt section for sub-agent model selection guidance.
527pub fn generate_subagent_guidance(registry: &ModelRegistry) -> String {
528    let mut guidance = String::from(
529        "## Sub-Agent Model Selection\n\n\
530        When spawning sub-agents, choose models based on task complexity:\n\n",
531    );
532
533    // List defaults by complexity
534    for (complexity, default_id) in registry.subagent_defaults() {
535        let tier = complexity.recommended_tier();
536        let model_name = registry
537            .get(default_id)
538            .map(|m| m.display_name.as_str())
539            .unwrap_or(default_id);
540        guidance.push_str(&format!(
541            "- **{:?}** tasks → {} {} (default: {})\n",
542            complexity,
543            tier.emoji(),
544            tier.display(),
545            model_name
546        ));
547    }
548
549    guidance.push_str("\n\
550        **Spawn sub-agents freely!** The async architecture handles multiple concurrent agents efficiently.\n\
551        Use cheaper models for:\n\
552        - Simple file operations, grep, formatting\n\
553        - Routine code edits with clear instructions\n\
554        - Data transformation, summarization\n\
555        - Background monitoring tasks\n\n\
556        Reserve premium models for:\n\
557        - Complex debugging and architecture decisions\n\
558        - Security-sensitive operations\n\
559        - Tasks requiring deep reasoning\n\n\
560        Sub-agents run asynchronously — you can spawn several and continue working.\n"
561    );
562
563    guidance
564}