Skip to main content

everruns_core/capabilities/
model_scout.rs

1// Model Scout Capability — OpenRouter model benchmark blueprint
2//
3// Contributes the `openrouter_model_scout` AgentBlueprint. The blueprint
4// provides a private tool suite that lets an orchestrating agent:
5//   1. Fetch the OpenRouter model catalog.
6//   2. Run small probe tasks against candidate model/provider routes.
7//   3. Rank candidates on success rate, latency, and cost.
8//   4. Produce a concrete model-router update proposal.
9//
10// Applying the proposal is always an explicit operator step; the blueprint
11// never mutates router configuration automatically.
12//
13// Design note: probe calls go directly to OpenRouter via the
14// `provider_credential_store` (openrouter API key) so each candidate is
15// evaluated in isolation. The ranking logic is deterministic and fully unit-
16// tested without network access.
17//
18// Security: TM-LLM — probe prompts are fixed strings provided by this code
19// or supplied by the operator in the blueprint config; they are sent only to
20// OpenRouter endpoints. API key comes from `provider_credential_store` and is
21// never logged. TM-DOS — probe count is bounded by `max_candidates`
22// (default 10) and spend by `max_spend_usd` (default $0.10).
23
24use 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
41/// Capability that contributes the OpenRouter model scout blueprint.
42pub 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            // Haiku is cheap and fast enough for orchestration; heavy work is done
83            // by probe calls directly against candidate models.
84            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// ============================================================================
170// Shared types
171// ============================================================================
172
173/// A single probe task definition.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ProbeTask {
176    pub id: String,
177    pub prompt: String,
178    /// Check IDs to evaluate on the response: `not_empty`, `max_latency_5s`, `max_latency_10s`.
179    #[serde(default)]
180    pub checks: Vec<String>,
181}
182
183/// Result of probing one model with one task.
184#[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    /// Estimated cost in USD (may be None when usage data is unavailable).
193    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/// Aggregated score for one model across all probe tasks.
200#[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    /// Composite score in [0, 1]: higher is better.
209    pub score: f64,
210}
211
212/// Update proposal for a model router route.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct RouterUpdateProposal {
215    pub route_key: String,
216    /// Ordered list — first model is the primary, remainder are fallbacks.
217    pub proposed_candidates: Vec<String>,
218    pub rationale: String,
219}
220
221// ============================================================================
222// Ranking logic (network-free, fully unit-tested)
223// ============================================================================
224
225const MAX_LATENCY_BASELINE_MS: f64 = 15_000.0;
226
227/// Compute a composite score for a model.
228///
229/// Weights: success_rate 50 %, latency 30 %, cost efficiency 20 %.
230/// `max_cost_usd` is the highest per-probe cost observed across all candidates,
231/// used to normalise within the cohort.
232pub 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
250/// Rank a list of probe results, returning candidates sorted by composite score
251/// (highest first). Only includes models with at least one probe result.
252pub fn rank_results(results: &[ProbeResult]) -> Vec<ModelRanking> {
253    use std::collections::HashMap;
254
255    // Aggregate per model
256    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, // filled below
278            }
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
305// ============================================================================
306// Default probe tasks
307// ============================================================================
308
309fn 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
329// ============================================================================
330// Tool: ListOpenRouterCatalogTool
331// ============================================================================
332
333struct 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        // Sort raw models by name first so filter+truncation is deterministic
448        // regardless of the order OpenRouter returns them.
449        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                    // Models with missing or unparseable pricing are excluded when a
477                    // price ceiling is active to avoid silently letting expensive or
478                    // unknown-cost models through.
479                    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                        // OpenRouter reports price per token; convert to per-million
486                        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
516// ============================================================================
517// Tool: ProbeModelTool
518// ============================================================================
519
520struct 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                // Cap the JSON array *before* cloning/deserializing so an
597                // adversarial multi-million-element `tasks` payload can't force
598                // unbounded allocation/CPU ahead of the limit (TM-DOS).
599                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            // Check the budget before each probe so a zero budget runs zero
634            // (paid) probes rather than always paying for the first one.
635            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
688/// Run one probe task against a model and return the result.
689async 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        // Ask OpenRouter to return generation accounting so `usage.cost` is
702        // populated; without this the spend guardrail never observes any cost
703        // and the per-call budget cap is a no-op.
704        "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    // Evaluate checks
792    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            // Unknown check IDs fail so typos in task configs surface immediately
800            // rather than silently inflating success rates.
801            _ => 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
826// ============================================================================
827// Tool: RankModelsTool
828// ============================================================================
829
830struct 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
882// ============================================================================
883// Tool: ProposeRouterUpdateTool
884// ============================================================================
885
886struct 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        // Sort defensively — callers may provide unsorted input.
950        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
985// ============================================================================
986// Credential helper
987// ============================================================================
988
989async 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// ============================================================================
1007// Tests
1008// ============================================================================
1009
1010#[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            // slow_model: 1/3 success, 12s avg, cheap
1051            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            // fast_model: 3/3 success, 500ms avg, more expensive
1055            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        // fast_model should win despite higher cost, because success rate dominates
1063        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        // success=1.0*0.5 + latency=(1-100/15000)*0.3 + cost=1.0*0.2
1099        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        // 0.0*0.5 + 0.0*0.3 + 0.0*0.2 = 0.0
1115        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        // When max_cost_per_probe is 0, cost_score should be 1.0 (no penalty)
1124        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        // cheap should score higher due to lower cost
1250        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}