Coverage for agentos/agent/model_router.py: 52%

110 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-10 01:20 +0800

1"""Model Router — intelligent model selection based on task complexity and budget. 

2 

3Automatically selects the best LLM model for each task based on: 

4 - Task complexity (TRIVIAL → EXPERT) 

5 - Priority (LOW → CRITICAL) 

6 - Daily budget constraints 

7 - Model cost/performance tiers 

8 - Fallback chains when budget is tight 

9""" 

10 

11from __future__ import annotations 

12 

13import time 

14from dataclasses import dataclass, field 

15from enum import Enum 

16 

17__all__ = [ 

18 "TaskComplexity", 

19 "TaskPriority", 

20 "ModelSpec", 

21 "RequestSpec", 

22 "RouteResult", 

23 "ModelRouter", 

24] 

25 

26 

27# ── Enums ────────────────────────────────────────────────────────── 

28 

29 

30class TaskComplexity(Enum): 

31 TRIVIAL = 0 # weather, time, simple calc 

32 SIMPLE = 1 # basic Q&A, short translation 

33 MODERATE = 2 # typical assistant tasks 

34 COMPLEX = 3 # code review, analysis, research 

35 EXPERT = 4 # deep research, architecture design 

36 

37 

38class TaskPriority(Enum): 

39 LOW = 0 

40 NORMAL = 1 

41 HIGH = 2 

42 CRITICAL = 3 

43 

44 

45# ── Dataclasses ──────────────────────────────────────────────────── 

46 

47 

48@dataclass 

49class ModelSpec: 

50 name: str 

51 provider: str 

52 cost_per_1k_input: float # USD per 1k input tokens 

53 cost_per_1k_output: float # USD per 1k output tokens 

54 max_tokens: int = 4096 

55 context_window: int = 128_000 

56 min_complexity: TaskComplexity = TaskComplexity.TRIVIAL 

57 tags: list[str] = field(default_factory=list) 

58 

59 

60@dataclass 

61class RequestSpec: 

62 estimated_input_tokens: int 

63 estimated_output_tokens: int 

64 complexity: TaskComplexity 

65 priority: TaskPriority 

66 task_id: str = "" 

67 session_id: str = "" 

68 

69 

70@dataclass 

71class RouteResult: 

72 success: bool 

73 model: ModelSpec 

74 reason: str = "" 

75 estimated_cost: float = 0.0 

76 fallback_chain: list[str] = field(default_factory=list) 

77 

78 

79# ── Model Registry ──────────────────────────────────────────────── 

80 

81DEFAULT_MODELS: list[ModelSpec] = [ 

82 # GPT family 

83 ModelSpec( 

84 "gpt-4o", 

85 "openai", 

86 2.50, 

87 10.00, 

88 16384, 

89 128000, 

90 TaskComplexity.COMPLEX, 

91 ["gpt", "vision", "best"], 

92 ), 

93 ModelSpec( 

94 "gpt-4o-mini", 

95 "openai", 

96 0.15, 

97 0.60, 

98 16384, 

99 128000, 

100 TaskComplexity.SIMPLE, 

101 ["gpt", "cheap", "fast"], 

102 ), 

103 # Claude family 

104 ModelSpec( 

105 "claude-3.5-sonnet", 

106 "anthropic", 

107 3.00, 

108 15.00, 

109 8192, 

110 200000, 

111 TaskComplexity.COMPLEX, 

112 ["claude", "code", "best"], 

113 ), 

114 ModelSpec( 

115 "claude-3-haiku", 

116 "anthropic", 

117 0.25, 

118 1.25, 

119 4096, 

120 200000, 

121 TaskComplexity.TRIVIAL, 

122 ["claude", "cheap", "fast"], 

123 ), 

124 # DeepSeek 

125 ModelSpec( 

126 "deepseek-v3", 

127 "deepseek", 

128 0.27, 

129 1.10, 

130 8192, 

131 64000, 

132 TaskComplexity.MODERATE, 

133 ["deepseek", "value"], 

134 ), 

135 ModelSpec( 

136 "deepseek-r1", 

137 "deepseek", 

138 0.55, 

139 2.19, 

140 32768, 

141 128000, 

142 TaskComplexity.EXPERT, 

143 ["deepseek", "reasoning", "best"], 

144 ), 

145 # Gemini 

146 ModelSpec( 

147 "gemini-2.5-pro", 

148 "google", 

149 1.25, 

150 10.00, 

151 8192, 

152 1048576, 

153 TaskComplexity.EXPERT, 

154 ["gemini", "best", "context"], 

155 ), 

156 ModelSpec( 

157 "gemini-2.5-flash", 

158 "google", 

159 0.15, 

160 0.60, 

161 8192, 

162 1048576, 

163 TaskComplexity.SIMPLE, 

164 ["gemini", "cheap", "fast"], 

165 ), 

166 # Ollama / local 

167 ModelSpec( 

168 "llama3.2-3b", "ollama", 0.0, 0.0, 4096, 128000, TaskComplexity.TRIVIAL, ["local", "free"] 

169 ), 

170 ModelSpec( 

171 "qwen2.5-7b", "ollama", 0.0, 0.0, 8192, 128000, TaskComplexity.SIMPLE, ["local", "free"] 

172 ), 

173] 

174 

175 

176# ── Model Router ────────────────────────────────────────────────── 

177 

178 

179class ModelRouter: 

180 """Intelligent model router with budget-aware selection. 

181 

182 Selects the best model for each task based on complexity, priority, 

183 and daily budget. Supports fallback chains when the preferred model 

184 would exceed budget limits. 

185 

186 Usage: 

187 router = ModelRouter.with_defaults(daily_budget_usd=50.0) 

188 spec = RequestSpec( 

189 estimated_input_tokens=2000, 

190 estimated_output_tokens=500, 

191 complexity=TaskComplexity.COMPLEX, 

192 priority=TaskPriority.NORMAL, 

193 ) 

194 result = router.route(spec) 

195 if result.success: 

196 print(f"Routed to {result.model.name}, cost ~${result.estimated_cost:.4f}") 

197 """ 

198 

199 def __init__( 

200 self, 

201 models: list[ModelSpec], 

202 daily_budget_usd: float = 50.0, 

203 complexity_cost_map: dict[TaskComplexity, float] | None = None, 

204 ): 

205 self.models = models 

206 self._daily_budget = daily_budget_usd 

207 self._daily_spent = 0.0 

208 self._day_start = time.time() 

209 self._request_count = 0 

210 self._route_cache: dict[str, str] = {} # session_id → model_name 

211 

212 # Complexity → max estimated cost multiplier 

213 self._complexity_budget: dict[TaskComplexity, float] = complexity_cost_map or { 

214 TaskComplexity.TRIVIAL: 0.002, 

215 TaskComplexity.SIMPLE: 0.01, 

216 TaskComplexity.MODERATE: 0.05, 

217 TaskComplexity.COMPLEX: 0.20, 

218 TaskComplexity.EXPERT: 0.50, 

219 } 

220 

221 # Sort models by cost (cheapest first for fallback) 

222 self._by_cost = sorted(models, key=lambda m: m.cost_per_1k_input) 

223 

224 # ── Factory ──────────────────────────────────────────────────── 

225 

226 @classmethod 

227 def with_defaults(cls, daily_budget_usd: float = 50.0) -> ModelRouter: 

228 """Create a ModelRouter with default model registry.""" 

229 return cls(models=list(DEFAULT_MODELS), daily_budget_usd=daily_budget_usd) 

230 

231 # ── Routing ──────────────────────────────────────────────────── 

232 

233 def route(self, spec: RequestSpec) -> RouteResult: 

234 """Select the best model for the given request spec.""" 

235 self._request_count += 1 

236 self._maybe_reset_daily() 

237 

238 # Check session affinity: if we've routed this session before, use same model 

239 if spec.session_id and spec.session_id in self._route_cache: 

240 model_name = self._route_cache[spec.session_id] 

241 model = next((m for m in self.models if m.name == model_name), None) 

242 if model: 

243 est_cost = self._estimate_cost(model, spec) 

244 if est_cost + self._daily_spent <= self._daily_budget: 

245 return RouteResult( 

246 success=True, 

247 model=model, 

248 reason=f"Session affinity → {model.name}", 

249 estimated_cost=est_cost, 

250 ) 

251 

252 # 1. Find candidate models that can handle this complexity 

253 candidates = [m for m in self.models if m.min_complexity.value <= spec.complexity.value] 

254 if not candidates: 

255 # Use cheapest as fallback 

256 candidates = [self._by_cost[0]] 

257 

258 # 2. For high/critical priority, prefer top-tier 

259 if spec.priority in (TaskPriority.HIGH, TaskPriority.CRITICAL): 

260 # Filter to best-in-class models 

261 tier = [m for m in candidates if "best" in m.tags] 

262 if tier: 

263 candidates = tier 

264 

265 # 3. Budget-aware selection 

266 max_budget = self._complexity_budget.get(spec.complexity, 0.05) 

267 

268 # Try each candidate, falling back to cheaper ones if over budget 

269 fallback_chain: list[str] = [] 

270 for model in sorted(candidates, key=lambda m: m.cost_per_1k_input): 

271 est_cost = self._estimate_cost(model, spec) 

272 if est_cost <= max_budget: 

273 if self._daily_spent + est_cost <= self._daily_budget: 

274 # Cache session → model 

275 if spec.session_id: 

276 self._route_cache[spec.session_id] = model.name 

277 return RouteResult( 

278 success=True, 

279 model=model, 

280 reason=f"Best match within budget ({spec.complexity.name})", 

281 estimated_cost=est_cost, 

282 fallback_chain=fallback_chain, 

283 ) 

284 fallback_chain.append(model.name) 

285 

286 # 4. No model fits budget — use cheapest as emergency fallback 

287 cheapest = self._by_cost[0] 

288 est_cost = self._estimate_cost(cheapest, spec) 

289 return RouteResult( 

290 success=True, 

291 model=cheapest, 

292 reason=f"Budget constrained — fallen back to {cheapest.name}", 

293 estimated_cost=est_cost, 

294 fallback_chain=fallback_chain, 

295 ) 

296 

297 def record_request( 

298 self, 

299 model_name: str, 

300 success: bool = True, 

301 tokens_used: int = 0, 

302 cost_usd: float = 0.0, 

303 latency_ms: float = 0.0, 

304 ): 

305 """Record actual usage for budget tracking. 

306 

307 Args: 

308 model_name: The model that was actually used. 

309 success: Whether the request succeeded. 

310 tokens_used: Total tokens consumed (input + output). 

311 cost_usd: Actual cost in USD. 

312 latency_ms: Request latency in milliseconds. 

313 """ 

314 self._daily_spent += cost_usd 

315 self._request_count += 1 

316 

317 # ── Budget ───────────────────────────────────────────────────── 

318 

319 @property 

320 def daily_budget_remaining(self) -> float: 

321 self._maybe_reset_daily() 

322 return max(0.0, self._daily_budget - self._daily_spent) 

323 

324 @property 

325 def daily_budget_total(self) -> float: 

326 return self._daily_budget 

327 

328 @property 

329 def daily_spent(self) -> float: 

330 self._maybe_reset_daily() 

331 return self._daily_spent 

332 

333 # ── Summary ──────────────────────────────────────────────────── 

334 

335 def summary(self) -> dict: 

336 """Return router state summary.""" 

337 return { 

338 "models_available": len(self.models), 

339 "daily_budget_usd": self._daily_budget, 

340 "daily_spent": round(self._daily_spent, 6), 

341 "daily_remaining": round(self.daily_budget_remaining, 4), 

342 "request_count": self._request_count, 

343 "cached_sessions": len(self._route_cache), 

344 } 

345 

346 # ── Internal ─────────────────────────────────────────────────── 

347 

348 def _estimate_cost(self, model: ModelSpec, spec: RequestSpec) -> float: 

349 """Estimate USD cost for a request.""" 

350 input_cost = (spec.estimated_input_tokens / 1000) * model.cost_per_1k_input 

351 output_cost = (spec.estimated_output_tokens / 1000) * model.cost_per_1k_output 

352 return input_cost + output_cost 

353 

354 def _maybe_reset_daily(self): 

355 """Reset daily budget if a day has passed.""" 

356 if time.time() - self._day_start > 86400: 

357 self._daily_spent = 0.0 

358 self._day_start = time.time() 

359 self._route_cache.clear()