1use super::{
25 AgentBlueprint, BlueprintModel, Capability, CapabilityLocalization, CapabilityStatus, RiskLevel,
26};
27use crate::tools::{Tool, ToolExecutionResult};
28use crate::traits::ToolContext;
29use async_trait::async_trait;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32
33pub const MODEL_SCOUT_CAPABILITY_ID: &str = "model_scout";
34const DEFAULT_PROBE_TIMEOUT_MS: u64 = 10_000;
35const MIN_PROBE_TIMEOUT_MS: u64 = 1_000;
36const MAX_PROBE_TIMEOUT_MS: u64 = 60_000;
37const DEFAULT_MAX_SPEND_USD: f64 = 0.10;
38const MAX_PROBE_SPEND_USD: f64 = 10.0;
39const MAX_PROBE_TASKS: usize = 50;
40
41pub struct ModelScoutCapability;
43
44impl Capability for ModelScoutCapability {
45 fn id(&self) -> &str {
46 MODEL_SCOUT_CAPABILITY_ID
47 }
48
49 fn name(&self) -> &str {
50 "Model Scout"
51 }
52
53 fn description(&self) -> &str {
54 "OpenRouter model benchmarking blueprint — evaluates model/provider routes with probe tasks and recommends model-router updates."
55 }
56
57 fn localizations(&self) -> Vec<CapabilityLocalization> {
58 vec![]
59 }
60
61 fn status(&self) -> CapabilityStatus {
62 CapabilityStatus::Available
63 }
64
65 fn icon(&self) -> Option<&str> {
66 Some("telescope")
67 }
68
69 fn category(&self) -> Option<&str> {
70 Some("AI")
71 }
72
73 fn risk_level(&self) -> RiskLevel {
74 RiskLevel::High
75 }
76
77 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
78 vec![AgentBlueprint {
79 id: "openrouter_model_scout",
80 name: "OpenRouter Model Scout",
81 description: "Benchmarks OpenRouter models/providers using small probe tasks and recommends model-router updates. Use when you need to evaluate which OpenRouter models best suit a task profile for cost, latency, or quality.",
82 model: BlueprintModel::Default("claude-haiku-4-5-20251001".to_string()),
85 system_prompt: SCOUT_SYSTEM_PROMPT,
86 tools: vec![
87 Box::new(ListOpenRouterCatalogTool),
88 Box::new(ProbeModelTool),
89 Box::new(RankModelsTool),
90 Box::new(ProposeRouterUpdateTool),
91 ],
92 max_turns: Some(30),
93 config_schema: Some(json!({
94 "type": "object",
95 "properties": {
96 "max_candidates": {
97 "type": "integer",
98 "minimum": 1,
99 "maximum": 50,
100 "default": 10,
101 "description": "Maximum number of models to probe."
102 },
103 "max_spend_usd": {
104 "type": "number",
105 "minimum": 0.0,
106 "maximum": 10.0,
107 "default": 0.10,
108 "description": "Maximum total spend in USD across all probes."
109 },
110 "probe_timeout_ms": {
111 "type": "integer",
112 "minimum": 1000,
113 "maximum": 60000,
114 "default": 10000,
115 "description": "Per-probe HTTP timeout in milliseconds."
116 },
117 "probe_tasks": {
118 "type": "array",
119 "items": { "$ref": "#/$defs/ProbeTask" },
120 "description": "Custom probe tasks. If empty, built-in probes are used."
121 },
122 "target_route_key": {
123 "type": "string",
124 "default": "base",
125 "description": "Router route key to update in the proposal."
126 }
127 },
128 "$defs": {
129 "ProbeTask": {
130 "type": "object",
131 "required": ["id", "prompt"],
132 "properties": {
133 "id": { "type": "string" },
134 "prompt": { "type": "string" },
135 "checks": {
136 "type": "array",
137 "items": { "type": "string" },
138 "description": "Check IDs: not_empty, max_latency_5s, max_latency_10s"
139 }
140 }
141 }
142 }
143 })),
144 }]
145 }
146}
147
148const SCOUT_SYSTEM_PROMPT: &str = "\
149You are the OpenRouter Model Scout. Your job is to benchmark OpenRouter \
150models/providers against a set of probe tasks and recommend model-router updates.
151
152Workflow:
1531. Call list_openrouter_catalog to get available models with metadata. Apply any \
154 filters from your config (capability requirements, cost ceilings).
1552. Select up to max_candidates models to probe (respect the cost budget).
1563. For each candidate, call probe_model with each probe task. Track cumulative \
157 estimated cost; stop probing if you approach max_spend_usd.
1584. Call rank_models with all collected probe results.
1595. Call propose_router_update with the rankings to generate a model-router proposal.
1606. Present a clear summary: top-3 ranked models, key trade-offs, and the router \
161 update proposal. Explain why the top model was chosen.
162
163Guard rails:
164- Never probe more than max_candidates models.
165- Stop probing if cumulative estimated cost reaches max_spend_usd.
166- If a probe fails or times out, record the error and continue with remaining candidates.
167- The proposal is advisory — do NOT apply it automatically.";
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ProbeTask {
176 pub id: String,
177 pub prompt: String,
178 #[serde(default)]
180 pub checks: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ProbeResult {
186 pub model_id: String,
187 pub task_id: String,
188 pub success: bool,
189 pub latency_ms: u64,
190 pub input_tokens: Option<u64>,
191 pub output_tokens: Option<u64>,
192 pub cost_usd: Option<f64>,
194 pub error: Option<String>,
195 pub passed_checks: Vec<String>,
196 pub failed_checks: Vec<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ModelRanking {
202 pub model_id: String,
203 pub display_name: Option<String>,
204 pub success_rate: f64,
205 pub avg_latency_ms: f64,
206 pub total_cost_usd: f64,
207 pub probe_count: usize,
208 pub score: f64,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct RouterUpdateProposal {
215 pub route_key: String,
216 pub proposed_candidates: Vec<String>,
218 pub rationale: String,
219}
220
221const MAX_LATENCY_BASELINE_MS: f64 = 15_000.0;
226
227pub fn compute_score(ranking: &ModelRanking, max_cost_per_probe_usd: f64) -> f64 {
233 let success = ranking.success_rate;
234 let latency_norm = (ranking.avg_latency_ms / MAX_LATENCY_BASELINE_MS).min(1.0);
235 let latency_score = 1.0 - latency_norm;
236 let cost_per_probe = if ranking.probe_count > 0 {
237 ranking.total_cost_usd / ranking.probe_count as f64
238 } else {
239 0.0
240 };
241 let cost_norm = if max_cost_per_probe_usd > 0.0 {
242 (cost_per_probe / max_cost_per_probe_usd).min(1.0)
243 } else {
244 0.0
245 };
246 let cost_score = 1.0 - cost_norm;
247 success * 0.50 + latency_score * 0.30 + cost_score * 0.20
248}
249
250pub fn rank_results(results: &[ProbeResult]) -> Vec<ModelRanking> {
253 use std::collections::HashMap;
254
255 let mut by_model: HashMap<&str, Vec<&ProbeResult>> = HashMap::new();
257 for r in results {
258 by_model.entry(&r.model_id).or_default().push(r);
259 }
260
261 let mut rankings: Vec<ModelRanking> = by_model
262 .into_iter()
263 .map(|(model_id, probes)| {
264 let probe_count = probes.len();
265 let success_count = probes.iter().filter(|p| p.success).count();
266 let success_rate = success_count as f64 / probe_count as f64;
267 let avg_latency_ms =
268 probes.iter().map(|p| p.latency_ms as f64).sum::<f64>() / probe_count as f64;
269 let total_cost_usd = probes.iter().filter_map(|p| p.cost_usd).sum::<f64>();
270 ModelRanking {
271 model_id: model_id.to_string(),
272 display_name: None,
273 success_rate,
274 avg_latency_ms,
275 total_cost_usd,
276 probe_count,
277 score: 0.0, }
279 })
280 .collect();
281
282 let max_cost = rankings
283 .iter()
284 .map(|r| {
285 if r.probe_count > 0 {
286 r.total_cost_usd / r.probe_count as f64
287 } else {
288 0.0
289 }
290 })
291 .fold(0.0f64, f64::max);
292
293 for r in &mut rankings {
294 r.score = compute_score(r, max_cost);
295 }
296
297 rankings.sort_by(|a, b| {
298 b.score
299 .partial_cmp(&a.score)
300 .unwrap_or(std::cmp::Ordering::Equal)
301 });
302 rankings
303}
304
305fn default_probe_tasks() -> Vec<ProbeTask> {
310 vec![
311 ProbeTask {
312 id: "basic_response".to_string(),
313 prompt: "Reply with exactly: OK".to_string(),
314 checks: vec!["not_empty".to_string()],
315 },
316 ProbeTask {
317 id: "tool_hint".to_string(),
318 prompt: "You have a tool called `get_weather(city: string)`. If someone asks for London weather, what tool call would you make? Reply only with the JSON object: {\"name\": \"...\", \"arguments\": {...}}".to_string(),
319 checks: vec!["not_empty".to_string(), "max_latency_10s".to_string()],
320 },
321 ProbeTask {
322 id: "json_output".to_string(),
323 prompt: "Return a JSON object with keys `status` (string \"ok\") and `value` (integer 42). Reply only with the JSON.".to_string(),
324 checks: vec!["not_empty".to_string(), "max_latency_10s".to_string()],
325 },
326 ]
327}
328
329struct ListOpenRouterCatalogTool;
334
335#[async_trait]
336impl Tool for ListOpenRouterCatalogTool {
337 fn name(&self) -> &str {
338 "list_openrouter_catalog"
339 }
340
341 fn description(&self) -> &str {
342 "Fetch the OpenRouter model catalog. Returns model IDs, display names, pricing, and supported capabilities. Use this to select candidates for probing."
343 }
344
345 fn parameters_schema(&self) -> Value {
346 json!({
347 "type": "object",
348 "properties": {
349 "max_results": {
350 "type": "integer",
351 "minimum": 1,
352 "maximum": 200,
353 "default": 50,
354 "description": "Maximum number of models to return."
355 },
356 "require_tools": {
357 "type": "boolean",
358 "default": false,
359 "description": "If true, only return models that advertise tool/function-calling support."
360 },
361 "require_json": {
362 "type": "boolean",
363 "default": false,
364 "description": "If true, only return models that advertise JSON/response_format support."
365 },
366 "max_prompt_price_per_million": {
367 "type": "number",
368 "description": "Filter: exclude models with prompt price above this USD/M-token threshold."
369 }
370 }
371 })
372 }
373
374 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
375 ToolExecutionResult::tool_error(
376 "list_openrouter_catalog requires provider credentials — use execute_with_context",
377 )
378 }
379
380 async fn execute_with_context(
381 &self,
382 arguments: Value,
383 context: &ToolContext,
384 ) -> ToolExecutionResult {
385 let max_results = arguments
386 .get("max_results")
387 .and_then(|v| v.as_u64())
388 .unwrap_or(50) as usize;
389 let require_tools = arguments
390 .get("require_tools")
391 .and_then(|v| v.as_bool())
392 .unwrap_or(false);
393 let require_json = arguments
394 .get("require_json")
395 .and_then(|v| v.as_bool())
396 .unwrap_or(false);
397 let max_price = arguments
398 .get("max_prompt_price_per_million")
399 .and_then(|v| v.as_f64());
400
401 let api_key = match resolve_openrouter_key(context).await {
402 Ok(k) => k,
403 Err(e) => return ToolExecutionResult::tool_error(e),
404 };
405
406 let client = reqwest::Client::new();
407 let response = match client
408 .get("https://openrouter.ai/api/v1/models")
409 .bearer_auth(&api_key)
410 .send()
411 .await
412 {
413 Ok(r) => r,
414 Err(e) => {
415 return ToolExecutionResult::tool_error(format!(
416 "Failed to fetch OpenRouter catalog: {e}"
417 ));
418 }
419 };
420
421 if !response.status().is_success() {
422 let status = response.status();
423 let body = response.text().await.unwrap_or_default();
424 return ToolExecutionResult::tool_error(format!(
425 "OpenRouter /models returned HTTP {status}: {body}"
426 ));
427 }
428
429 let body: Value = match response.json().await {
430 Ok(v) => v,
431 Err(e) => {
432 return ToolExecutionResult::tool_error(format!(
433 "Failed to parse OpenRouter catalog response: {e}"
434 ));
435 }
436 };
437
438 let models = match body.get("data").and_then(|d| d.as_array()) {
439 Some(arr) => arr,
440 None => {
441 return ToolExecutionResult::tool_error(
442 "OpenRouter catalog response missing 'data' array".to_string(),
443 );
444 }
445 };
446
447 let mut sorted_models: Vec<&Value> = models.iter().collect();
450 sorted_models.sort_by(|a, b| {
451 let na = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
452 let nb = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
453 na.cmp(nb)
454 });
455
456 let entries: Vec<Value> = sorted_models
457 .iter()
458 .filter(|m| {
459 let params: Vec<String> = m
460 .get("supported_parameters")
461 .and_then(|p| p.as_array())
462 .map(|arr| {
463 arr.iter()
464 .filter_map(|v| v.as_str().map(str::to_string))
465 .collect()
466 })
467 .unwrap_or_default();
468
469 if require_tools && !params.iter().any(|p| p == "tools") {
470 return false;
471 }
472 if require_json && !params.iter().any(|p| p == "response_format") {
473 return false;
474 }
475 if let Some(max_p) = max_price {
476 let prompt_price: Option<f64> = m
480 .get("pricing")
481 .and_then(|pr| pr.get("prompt"))
482 .and_then(|v| v.as_str())
483 .and_then(|s| s.parse().ok());
484 match prompt_price {
485 Some(p) if p * 1_000_000.0 <= max_p => {}
487 _ => return false,
488 }
489 }
490 true
491 })
492 .take(max_results)
493 .map(|m| {
494 json!({
495 "id": m.get("id").cloned().unwrap_or(Value::Null),
496 "name": m.get("name").cloned().unwrap_or(Value::Null),
497 "context_length": m.get("context_length").cloned().unwrap_or(Value::Null),
498 "prompt_price_per_token": m.get("pricing").and_then(|p| p.get("prompt")).cloned().unwrap_or(Value::Null),
499 "completion_price_per_token": m.get("pricing").and_then(|p| p.get("completion")).cloned().unwrap_or(Value::Null),
500 "supported_parameters": m.get("supported_parameters").cloned().unwrap_or(json!([])),
501 })
502 })
503 .collect();
504
505 ToolExecutionResult::success(json!({
506 "total_returned": entries.len(),
507 "models": entries,
508 }))
509 }
510
511 fn requires_context(&self) -> bool {
512 true
513 }
514}
515
516struct ProbeModelTool;
521
522#[async_trait]
523impl Tool for ProbeModelTool {
524 fn name(&self) -> &str {
525 "probe_model"
526 }
527
528 fn description(&self) -> &str {
529 "Run one or more probe tasks against an OpenRouter model and return latency, success, token usage, and cost signals. Use built-in probes or supply custom tasks."
530 }
531
532 fn parameters_schema(&self) -> Value {
533 json!({
534 "type": "object",
535 "required": ["model_id"],
536 "properties": {
537 "model_id": {
538 "type": "string",
539 "description": "OpenRouter model ID (e.g. 'openai/gpt-4o-mini')."
540 },
541 "tasks": {
542 "type": "array",
543 "items": {
544 "type": "object",
545 "required": ["id", "prompt"],
546 "properties": {
547 "id": { "type": "string" },
548 "prompt": { "type": "string" },
549 "checks": {
550 "type": "array",
551 "items": { "type": "string" }
552 }
553 }
554 },
555 "description": "Probe tasks to run. If omitted, built-in probes are used."
556 },
557 "timeout_ms": {
558 "type": "integer",
559 "minimum": 1000,
560 "maximum": 60000,
561 "default": 10000,
562 "description": "HTTP timeout per probe in milliseconds."
563 },
564 "max_spend_usd": {
565 "type": "number",
566 "minimum": 0.0,
567 "maximum": 10.0,
568 "default": 0.10,
569 "description": "Maximum observed spend in USD before stopping further probes."
570 }
571 }
572 })
573 }
574
575 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
576 ToolExecutionResult::tool_error(
577 "probe_model requires provider credentials — use execute_with_context",
578 )
579 }
580
581 async fn execute_with_context(
582 &self,
583 arguments: Value,
584 context: &ToolContext,
585 ) -> ToolExecutionResult {
586 let model_id = match arguments.get("model_id").and_then(|v| v.as_str()) {
587 Some(s) => s.to_string(),
588 None => return ToolExecutionResult::tool_error("model_id is required"),
589 };
590
591 let timeout_ms = bounded_probe_timeout_ms(&arguments);
592 let max_spend_usd = bounded_probe_max_spend_usd(&arguments);
593
594 let tasks: Vec<ProbeTask> = match arguments.get("tasks") {
595 Some(Value::Array(arr)) if !arr.is_empty() => {
596 let capped: Vec<Value> = arr.iter().take(MAX_PROBE_TASKS).cloned().collect();
600 match serde_json::from_value(Value::Array(capped)) {
601 Ok(t) => limit_probe_tasks(t),
602 Err(e) => {
603 return ToolExecutionResult::tool_error(format!(
604 "Invalid probe tasks: {e}"
605 ));
606 }
607 }
608 }
609 _ => default_probe_tasks(),
610 };
611
612 let api_key = match resolve_openrouter_key(context).await {
613 Ok(k) => k,
614 Err(e) => return ToolExecutionResult::tool_error(e),
615 };
616
617 let client = match reqwest::Client::builder()
618 .timeout(std::time::Duration::from_millis(timeout_ms))
619 .build()
620 {
621 Ok(c) => c,
622 Err(e) => {
623 return ToolExecutionResult::tool_error(format!(
624 "Failed to build HTTP client: {e}"
625 ));
626 }
627 };
628
629 let mut results: Vec<ProbeResult> = Vec::new();
630 let mut observed_spend_usd = 0.0;
631
632 for task in &tasks {
633 if observed_spend_usd >= max_spend_usd {
636 break;
637 }
638 let result = run_probe(&client, &api_key, &model_id, task).await;
639 if let Some(cost_usd) = result.cost_usd {
640 observed_spend_usd += cost_usd;
641 }
642 results.push(result);
643 }
644
645 let result_values: Vec<Value> = results
646 .iter()
647 .map(|r| serde_json::to_value(r).unwrap_or(Value::Null))
648 .collect();
649
650 ToolExecutionResult::success(json!({
651 "model_id": model_id,
652 "results": result_values,
653 "observed_spend_usd": observed_spend_usd,
654 "max_spend_usd": max_spend_usd,
655 }))
656 }
657
658 fn requires_context(&self) -> bool {
659 true
660 }
661}
662
663fn bounded_probe_timeout_ms(arguments: &Value) -> u64 {
664 arguments
665 .get("timeout_ms")
666 .and_then(|v| v.as_u64())
667 .unwrap_or(DEFAULT_PROBE_TIMEOUT_MS)
668 .clamp(MIN_PROBE_TIMEOUT_MS, MAX_PROBE_TIMEOUT_MS)
669}
670
671fn bounded_probe_max_spend_usd(arguments: &Value) -> f64 {
672 let spend = arguments
673 .get("max_spend_usd")
674 .and_then(|v| v.as_f64())
675 .unwrap_or(DEFAULT_MAX_SPEND_USD);
676
677 if spend.is_finite() {
678 spend.clamp(0.0, MAX_PROBE_SPEND_USD)
679 } else {
680 DEFAULT_MAX_SPEND_USD
681 }
682}
683
684fn limit_probe_tasks(tasks: Vec<ProbeTask>) -> Vec<ProbeTask> {
685 tasks.into_iter().take(MAX_PROBE_TASKS).collect()
686}
687
688async fn run_probe(
690 client: &reqwest::Client,
691 api_key: &str,
692 model_id: &str,
693 task: &ProbeTask,
694) -> ProbeResult {
695 let start = std::time::Instant::now();
696
697 let payload = json!({
698 "model": model_id,
699 "messages": [{"role": "user", "content": task.prompt}],
700 "max_tokens": 256,
701 "usage": { "include": true },
705 });
706
707 let response = match client
708 .post("https://openrouter.ai/api/v1/chat/completions")
709 .bearer_auth(api_key)
710 .json(&payload)
711 .send()
712 .await
713 {
714 Ok(r) => r,
715 Err(e) => {
716 return ProbeResult {
717 model_id: model_id.to_string(),
718 task_id: task.id.clone(),
719 success: false,
720 latency_ms: start.elapsed().as_millis() as u64,
721 input_tokens: None,
722 output_tokens: None,
723 cost_usd: None,
724 error: Some(format!("Request failed: {e}")),
725 passed_checks: vec![],
726 failed_checks: task.checks.clone(),
727 };
728 }
729 };
730
731 let latency_ms = start.elapsed().as_millis() as u64;
732
733 if !response.status().is_success() {
734 let status = response.status();
735 let body = response.text().await.unwrap_or_default();
736 return ProbeResult {
737 model_id: model_id.to_string(),
738 task_id: task.id.clone(),
739 success: false,
740 latency_ms,
741 input_tokens: None,
742 output_tokens: None,
743 cost_usd: None,
744 error: Some(format!("HTTP {status}: {body}")),
745 passed_checks: vec![],
746 failed_checks: task.checks.clone(),
747 };
748 }
749
750 let body: Value = match response.json().await {
751 Ok(v) => v,
752 Err(e) => {
753 return ProbeResult {
754 model_id: model_id.to_string(),
755 task_id: task.id.clone(),
756 success: false,
757 latency_ms,
758 input_tokens: None,
759 output_tokens: None,
760 cost_usd: None,
761 error: Some(format!("Failed to parse response: {e}")),
762 passed_checks: vec![],
763 failed_checks: task.checks.clone(),
764 };
765 }
766 };
767
768 let content = body
769 .get("choices")
770 .and_then(|c| c.as_array())
771 .and_then(|arr| arr.first())
772 .and_then(|ch| ch.get("message"))
773 .and_then(|m| m.get("content"))
774 .and_then(|c| c.as_str())
775 .unwrap_or("")
776 .to_string();
777
778 let input_tokens = body
779 .get("usage")
780 .and_then(|u| u.get("prompt_tokens"))
781 .and_then(|v| v.as_u64());
782 let output_tokens = body
783 .get("usage")
784 .and_then(|u| u.get("completion_tokens"))
785 .and_then(|v| v.as_u64());
786 let cost_usd = body
787 .get("usage")
788 .and_then(|u| u.get("cost"))
789 .and_then(|v| v.as_f64());
790
791 let mut passed = vec![];
793 let mut failed = vec![];
794 for check in &task.checks {
795 let ok = match check.as_str() {
796 "not_empty" => !content.trim().is_empty(),
797 "max_latency_5s" => latency_ms <= 5_000,
798 "max_latency_10s" => latency_ms <= 10_000,
799 _ => false,
802 };
803 if ok {
804 passed.push(check.clone());
805 } else {
806 failed.push(check.clone());
807 }
808 }
809
810 let success = failed.is_empty() && !content.trim().is_empty();
811
812 ProbeResult {
813 model_id: model_id.to_string(),
814 task_id: task.id.clone(),
815 success,
816 latency_ms,
817 input_tokens,
818 output_tokens,
819 cost_usd,
820 error: None,
821 passed_checks: passed,
822 failed_checks: failed,
823 }
824}
825
826struct RankModelsTool;
831
832#[async_trait]
833impl Tool for RankModelsTool {
834 fn name(&self) -> &str {
835 "rank_models"
836 }
837
838 fn description(&self) -> &str {
839 "Aggregate probe results and rank models by composite score (success rate, latency, cost). Returns candidates sorted highest-score first."
840 }
841
842 fn parameters_schema(&self) -> Value {
843 json!({
844 "type": "object",
845 "required": ["results"],
846 "properties": {
847 "results": {
848 "type": "array",
849 "items": { "type": "object" },
850 "description": "ProbeResult objects returned by probe_model."
851 }
852 }
853 })
854 }
855
856 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
857 let raw_results = match arguments.get("results").and_then(|v| v.as_array()) {
858 Some(arr) => arr.clone(),
859 None => return ToolExecutionResult::tool_error("results array is required"),
860 };
861
862 let probe_results: Vec<ProbeResult> =
863 match serde_json::from_value(Value::Array(raw_results)) {
864 Ok(r) => r,
865 Err(e) => return ToolExecutionResult::tool_error(format!("Invalid results: {e}")),
866 };
867
868 if probe_results.is_empty() {
869 return ToolExecutionResult::success(json!({ "rankings": [] }));
870 }
871
872 let rankings = rank_results(&probe_results);
873 let out: Vec<Value> = rankings
874 .iter()
875 .map(|r| serde_json::to_value(r).unwrap_or(Value::Null))
876 .collect();
877
878 ToolExecutionResult::success(json!({ "rankings": out }))
879 }
880}
881
882struct ProposeRouterUpdateTool;
887
888#[async_trait]
889impl Tool for ProposeRouterUpdateTool {
890 fn name(&self) -> &str {
891 "propose_router_update"
892 }
893
894 fn description(&self) -> &str {
895 "Generate a model-router update proposal from ranked results. The proposal lists an ordered candidate set (primary + fallbacks) for the target route key. Applying it is always an explicit operator step."
896 }
897
898 fn parameters_schema(&self) -> Value {
899 json!({
900 "type": "object",
901 "required": ["rankings"],
902 "properties": {
903 "rankings": {
904 "type": "array",
905 "items": { "type": "object" },
906 "description": "ModelRanking objects from rank_models."
907 },
908 "route_key": {
909 "type": "string",
910 "default": "base",
911 "description": "Router route key to update."
912 },
913 "top_n": {
914 "type": "integer",
915 "minimum": 1,
916 "maximum": 10,
917 "default": 3,
918 "description": "Number of candidates to include (primary + N-1 fallbacks)."
919 }
920 }
921 })
922 }
923
924 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
925 let raw_rankings = match arguments.get("rankings").and_then(|v| v.as_array()) {
926 Some(arr) => arr.clone(),
927 None => return ToolExecutionResult::tool_error("rankings array is required"),
928 };
929
930 let rankings: Vec<ModelRanking> = match serde_json::from_value(Value::Array(raw_rankings)) {
931 Ok(r) => r,
932 Err(e) => return ToolExecutionResult::tool_error(format!("Invalid rankings: {e}")),
933 };
934
935 if rankings.is_empty() {
936 return ToolExecutionResult::tool_error(
937 "No ranked models provided — run rank_models first",
938 );
939 }
940
941 let route_key = arguments
942 .get("route_key")
943 .and_then(|v| v.as_str())
944 .unwrap_or("base")
945 .to_string();
946
947 let top_n = arguments.get("top_n").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
948
949 let mut sorted = rankings;
951 sorted.sort_by(|a, b| {
952 b.score
953 .partial_cmp(&a.score)
954 .unwrap_or(std::cmp::Ordering::Equal)
955 });
956
957 let top_models: Vec<String> = sorted
958 .iter()
959 .take(top_n)
960 .map(|r| r.model_id.clone())
961 .collect();
962
963 let best = &sorted[0];
964 let rationale = format!(
965 "Top model '{}' scored {:.3} (success_rate={:.0}%, avg_latency={:.0}ms, total_cost_usd={:.4}). \
966 {} candidates probed total.",
967 best.model_id,
968 best.score,
969 best.success_rate * 100.0,
970 best.avg_latency_ms,
971 best.total_cost_usd,
972 sorted.len(),
973 );
974
975 let proposal = RouterUpdateProposal {
976 route_key,
977 proposed_candidates: top_models,
978 rationale,
979 };
980
981 ToolExecutionResult::success(serde_json::to_value(&proposal).unwrap_or(Value::Null))
982 }
983}
984
985async fn resolve_openrouter_key(context: &ToolContext) -> Result<String, String> {
990 let store = context
991 .provider_credential_store
992 .as_ref()
993 .ok_or_else(|| "No provider credential store available".to_string())?;
994
995 let creds = store
996 .get_default_provider_credentials("openrouter")
997 .await
998 .map_err(|e| format!("Failed to resolve OpenRouter credentials: {e}"))?
999 .ok_or_else(|| {
1000 "No OpenRouter provider configured — add an OpenRouter provider to your org".to_string()
1001 })?;
1002
1003 Ok(creds.api_key)
1004}
1005
1006#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 fn make_probe(
1015 model_id: &str,
1016 task_id: &str,
1017 success: bool,
1018 latency_ms: u64,
1019 cost: Option<f64>,
1020 ) -> ProbeResult {
1021 ProbeResult {
1022 model_id: model_id.to_string(),
1023 task_id: task_id.to_string(),
1024 success,
1025 latency_ms,
1026 input_tokens: Some(10),
1027 output_tokens: Some(20),
1028 cost_usd: cost,
1029 error: if success {
1030 None
1031 } else {
1032 Some("error".to_string())
1033 },
1034 passed_checks: if success {
1035 vec!["not_empty".to_string()]
1036 } else {
1037 vec![]
1038 },
1039 failed_checks: if success {
1040 vec![]
1041 } else {
1042 vec!["not_empty".to_string()]
1043 },
1044 }
1045 }
1046
1047 #[test]
1048 fn rank_results_orders_by_score() {
1049 let results = vec![
1050 make_probe("slow_model", "t1", false, 12_000, Some(0.0001)),
1052 make_probe("slow_model", "t2", false, 11_000, Some(0.0001)),
1053 make_probe("slow_model", "t3", true, 13_000, Some(0.0001)),
1054 make_probe("fast_model", "t1", true, 500, Some(0.001)),
1056 make_probe("fast_model", "t2", true, 400, Some(0.001)),
1057 make_probe("fast_model", "t3", true, 600, Some(0.001)),
1058 ];
1059
1060 let rankings = rank_results(&results);
1061 assert_eq!(rankings.len(), 2);
1062 assert_eq!(rankings[0].model_id, "fast_model");
1064 assert_eq!(rankings[1].model_id, "slow_model");
1065 assert!(rankings[0].score > rankings[1].score);
1066 }
1067
1068 #[test]
1069 fn rank_results_empty_input_returns_empty() {
1070 assert!(rank_results(&[]).is_empty());
1071 }
1072
1073 #[test]
1074 fn rank_results_single_model() {
1075 let results = vec![
1076 make_probe("only_model", "t1", true, 1_000, Some(0.001)),
1077 make_probe("only_model", "t2", true, 2_000, Some(0.001)),
1078 ];
1079 let rankings = rank_results(&results);
1080 assert_eq!(rankings.len(), 1);
1081 assert_eq!(rankings[0].model_id, "only_model");
1082 assert_eq!(rankings[0].success_rate, 1.0);
1083 assert_eq!(rankings[0].probe_count, 2);
1084 }
1085
1086 #[test]
1087 fn compute_score_perfect_is_high() {
1088 let r = ModelRanking {
1089 model_id: "m".to_string(),
1090 display_name: None,
1091 success_rate: 1.0,
1092 avg_latency_ms: 100.0,
1093 total_cost_usd: 0.0001,
1094 probe_count: 3,
1095 score: 0.0,
1096 };
1097 let s = compute_score(&r, 0.01);
1098 assert!(s > 0.9, "perfect model should score above 0.9, got {s}");
1100 }
1101
1102 #[test]
1103 fn compute_score_zero_success_is_low() {
1104 let r = ModelRanking {
1105 model_id: "m".to_string(),
1106 display_name: None,
1107 success_rate: 0.0,
1108 avg_latency_ms: 15_000.0,
1109 total_cost_usd: 1.0,
1110 probe_count: 5,
1111 score: 0.0,
1112 };
1113 let s = compute_score(&r, 0.2);
1114 assert_eq!(
1116 s, 0.0,
1117 "zero success + max latency + max cost should score 0"
1118 );
1119 }
1120
1121 #[test]
1122 fn compute_score_cost_zero_max_cost_zero() {
1123 let r = ModelRanking {
1125 model_id: "m".to_string(),
1126 display_name: None,
1127 success_rate: 1.0,
1128 avg_latency_ms: 0.0,
1129 total_cost_usd: 0.0,
1130 probe_count: 1,
1131 score: 0.0,
1132 };
1133 let s = compute_score(&r, 0.0);
1134 assert!((s - 1.0).abs() < 1e-9, "score should be 1.0, got {s}");
1135 }
1136
1137 #[test]
1138 fn rank_results_prefers_lower_latency_among_equal_success() {
1139 let results = vec![
1140 make_probe("model_a", "t1", true, 5_000, Some(0.001)),
1141 make_probe("model_b", "t1", true, 1_000, Some(0.001)),
1142 ];
1143 let rankings = rank_results(&results);
1144 assert_eq!(
1145 rankings[0].model_id, "model_b",
1146 "lower latency should rank higher when success rates are equal"
1147 );
1148 }
1149
1150 #[test]
1151 fn default_probe_tasks_non_empty() {
1152 let tasks = default_probe_tasks();
1153 assert!(!tasks.is_empty());
1154 for t in &tasks {
1155 assert!(!t.id.is_empty());
1156 assert!(!t.prompt.is_empty());
1157 }
1158 }
1159
1160 #[test]
1161 fn model_scout_capability_is_high_risk() {
1162 assert_eq!(ModelScoutCapability.risk_level(), RiskLevel::High);
1163 }
1164
1165 #[test]
1166 fn probe_timeout_is_clamped_to_schema_bounds() {
1167 assert_eq!(
1168 bounded_probe_timeout_ms(&json!({})),
1169 DEFAULT_PROBE_TIMEOUT_MS
1170 );
1171 assert_eq!(
1172 bounded_probe_timeout_ms(&json!({ "timeout_ms": 1 })),
1173 MIN_PROBE_TIMEOUT_MS
1174 );
1175 assert_eq!(
1176 bounded_probe_timeout_ms(&json!({ "timeout_ms": 3_600_000 })),
1177 MAX_PROBE_TIMEOUT_MS
1178 );
1179 }
1180
1181 #[test]
1182 fn probe_spend_is_clamped_to_schema_bounds() {
1183 assert_eq!(
1184 bounded_probe_max_spend_usd(&json!({})),
1185 DEFAULT_MAX_SPEND_USD
1186 );
1187 assert_eq!(
1188 bounded_probe_max_spend_usd(&json!({ "max_spend_usd": -1.0 })),
1189 0.0
1190 );
1191 assert_eq!(
1192 bounded_probe_max_spend_usd(&json!({ "max_spend_usd": 1_000.0 })),
1193 MAX_PROBE_SPEND_USD
1194 );
1195 }
1196
1197 #[test]
1198 fn custom_probe_tasks_are_capped() {
1199 let tasks: Vec<ProbeTask> = (0..75)
1200 .map(|i| ProbeTask {
1201 id: format!("task_{i}"),
1202 prompt: "Reply OK".to_string(),
1203 checks: vec![],
1204 })
1205 .collect();
1206
1207 let limited = limit_probe_tasks(tasks);
1208 assert_eq!(limited.len(), MAX_PROBE_TASKS);
1209 assert_eq!(limited[0].id, "task_0");
1210 assert_eq!(limited[MAX_PROBE_TASKS - 1].id, "task_49");
1211 }
1212
1213 #[tokio::test]
1214 async fn rank_models_tool_returns_sorted_rankings() {
1215 let tool = RankModelsTool;
1216 let results = json!([
1217 {
1218 "model_id": "cheap",
1219 "task_id": "t1",
1220 "success": true,
1221 "latency_ms": 800,
1222 "input_tokens": 10,
1223 "output_tokens": 10,
1224 "cost_usd": 0.0001,
1225 "error": null,
1226 "passed_checks": ["not_empty"],
1227 "failed_checks": []
1228 },
1229 {
1230 "model_id": "expensive",
1231 "task_id": "t1",
1232 "success": true,
1233 "latency_ms": 800,
1234 "input_tokens": 10,
1235 "output_tokens": 10,
1236 "cost_usd": 0.1,
1237 "error": null,
1238 "passed_checks": ["not_empty"],
1239 "failed_checks": []
1240 }
1241 ]);
1242
1243 let out = tool.execute(json!({ "results": results })).await;
1244 assert!(out.is_success());
1245 let tool_result = out.into_tool_result("id", "rank_models");
1246 let content = tool_result.result.unwrap();
1247 let rankings = content["rankings"].as_array().unwrap();
1248 assert_eq!(rankings.len(), 2);
1249 assert_eq!(rankings[0]["model_id"].as_str().unwrap(), "cheap");
1251 }
1252
1253 #[tokio::test]
1254 async fn propose_router_update_tool_produces_proposal() {
1255 let tool = ProposeRouterUpdateTool;
1256 let rankings = json!([
1257 {
1258 "model_id": "best_model",
1259 "display_name": null,
1260 "success_rate": 1.0,
1261 "avg_latency_ms": 500.0,
1262 "total_cost_usd": 0.001,
1263 "probe_count": 3,
1264 "score": 0.95
1265 },
1266 {
1267 "model_id": "fallback_model",
1268 "display_name": null,
1269 "success_rate": 0.66,
1270 "avg_latency_ms": 2000.0,
1271 "total_cost_usd": 0.0005,
1272 "probe_count": 3,
1273 "score": 0.60
1274 }
1275 ]);
1276
1277 let out = tool
1278 .execute(json!({
1279 "rankings": rankings,
1280 "route_key": "base",
1281 "top_n": 2
1282 }))
1283 .await;
1284
1285 assert!(out.is_success());
1286 let tool_result = out.into_tool_result("id", "propose_router_update");
1287 let content = tool_result.result.unwrap();
1288 assert_eq!(content["route_key"].as_str().unwrap(), "base");
1289 let candidates = content["proposed_candidates"].as_array().unwrap();
1290 assert_eq!(candidates.len(), 2);
1291 assert_eq!(candidates[0].as_str().unwrap(), "best_model");
1292 assert_eq!(candidates[1].as_str().unwrap(), "fallback_model");
1293 }
1294
1295 #[tokio::test]
1296 async fn propose_router_update_empty_rankings_returns_error() {
1297 let tool = ProposeRouterUpdateTool;
1298 let out = tool.execute(json!({ "rankings": [] })).await;
1299 assert!(out.is_error(), "empty rankings should return error");
1300 }
1301}