1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum CostTier {
19 Free,
21 Economy,
23 Standard,
25 Premium,
27}
28
29impl CostTier {
30 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum TaskComplexity {
72 Simple,
74 Medium,
76 Complex,
78 Critical,
80}
81
82impl TaskComplexity {
83 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#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ModelEntry {
97 pub id: String,
99
100 pub provider: String,
102
103 pub name: String,
105
106 pub display_name: String,
108
109 pub tier: CostTier,
111
112 pub enabled: bool,
114
115 pub available: bool,
117
118 pub context_window: Option<u32>,
120
121 pub supports_vision: bool,
123
124 pub supports_tools: bool,
126
127 pub supports_thinking: bool,
129
130 pub notes: Option<String>,
132}
133
134impl ModelEntry {
135 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 pub fn with_context(mut self, tokens: u32) -> Self {
160 self.context_window = Some(tokens);
161 self
162 }
163
164 pub fn with_vision(mut self) -> Self {
166 self.supports_vision = true;
167 self
168 }
169
170 pub fn with_thinking(mut self) -> Self {
172 self.supports_thinking = true;
173 self
174 }
175
176 pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
178 self.notes = Some(notes.into());
179 self
180 }
181
182 pub fn is_usable(&self) -> bool {
184 self.enabled && self.available
185 }
186
187 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
206pub struct ModelRegistry {
208 models: HashMap<String, ModelEntry>,
210
211 active_model: Option<String>,
213
214 subagent_defaults: HashMap<TaskComplexity, String>,
216}
217
218impl ModelRegistry {
219 pub fn new() -> Self {
221 Self {
222 models: HashMap::new(),
223 active_model: None,
224 subagent_defaults: HashMap::new(),
225 }
226 }
227
228 pub fn with_defaults() -> Self {
230 let mut registry = Self::new();
231 registry.populate_defaults();
232 registry
233 }
234
235 fn populate_defaults(&mut self) {
237 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 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 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 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 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 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 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 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 pub fn get(&self, id: &str) -> Option<&ModelEntry> {
384 self.models.get(id)
385 }
386
387 pub fn get_mut(&mut self, id: &str) -> Option<&mut ModelEntry> {
389 self.models.get_mut(id)
390 }
391
392 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 pub fn enabled(&self) -> Vec<&ModelEntry> {
406 self.all().into_iter().filter(|m| m.enabled).collect()
407 }
408
409 pub fn usable(&self) -> Vec<&ModelEntry> {
411 self.all().into_iter().filter(|m| m.is_usable()).collect()
412 }
413
414 pub fn by_tier(&self, tier: CostTier) -> Vec<&ModelEntry> {
416 self.all().into_iter().filter(|m| m.tier == tier).collect()
417 }
418
419 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 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 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 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 pub fn active(&self) -> Option<&ModelEntry> {
460 self.active_model
461 .as_ref()
462 .and_then(|id| self.models.get(id))
463 }
464
465 pub fn recommend_for_subagent(&self, complexity: TaskComplexity) -> Option<&ModelEntry> {
467 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 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) }
483
484 pub fn set_subagent_default(&mut self, complexity: TaskComplexity, model_id: String) {
486 self.subagent_defaults.insert(complexity, model_id);
487 }
488
489 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
501pub type SharedModelRegistry = Arc<RwLock<ModelRegistry>>;
503
504pub fn create_model_registry() -> SharedModelRegistry {
506 Arc::new(RwLock::new(ModelRegistry::with_defaults()))
507}
508
509fn format_display_name(name: &str) -> String {
513 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
526pub 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 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}