canact 0.1.1

Probe an LLM and return host policy: max tools, edit format, XML fallback, JSON repair
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
//! File-based probe cache.
//!
//! Persists [`CapabilityProfile`] results to disk so that probing is only
//! performed once per model+provider+settings combination (with a 30-day
//! TTL). Cache keys include reasoning effort, probe suite version, the
//! cheap/full plus vision suite knobs, and the advertised context cap.

use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::error::ProbeError;
use crate::types::{CapabilityLevel, CapabilityProfile, TOOL_PROBE_NAMES};

/// How long a cached entry remains valid (30 days in seconds).
pub const CACHE_TTL_SECS: u64 = 30 * 24 * 60 * 60;

/// Bump when probe identity/scoring changes enough to invalidate old entries.
/// v2: #1336 rename, #1337 multi-turn probe, #1339 system adherence redesign.
/// v3: transient stream/tool probe errors are not 30-day Weak/Medium.
/// v4: context-faithfulness timeout synonyms (#3317).
/// v5: generic edit_file on tool_selection is not 30-day Weak/max_tools=10 (#3315).
/// v6: forceful tool-call prompts (Goose #6281) and stricter arg schema.
/// v7: persisted effective_context_tokens ladder.
/// v8: token_efficiency prefers live ProbeResponse usage when present.
/// v9: cheap ladder is not a finished size; vision refusal beats "text";
///     synthesized error Medium does not open can_use_tools.
/// v10: empty instruction-following is Weak; vision color-only refusal
///      is Weak; SSE late tool name still emits ToolCallStart.
/// v11: synthesized error Medium does not open host policy (vision,
///      XML fallback, edit format, meets); o-series chat uses
///      max_completion_tokens and omits temperature.
/// v12: empty token_efficiency is Weak; SSE tool_calls indexes
///      each emit ToolCallStart.
/// v13: chat parse accepts legacy function/function_call; complex
///      two-name imprecise args are Medium; ladder heartbeat
///      accepts 2,840.
/// v14: SSE function_call deltas emit starts; empty max_tokens
///      compliance is Weak; parallel numeric paths are Medium.
/// v15: JSON fence language tags; streaming Strong requires
///      read_file; system-adherence details are UTF-8 safe.
/// v16: one_shot_tool_plan and tool_selection Strong require
///      non-empty string args on preferred tools.
/// v17: whitespace-only args are not Strong; tool_selection scores
///      the best same-name call; one_shot order uses first precise
///      call; tool_calling/xml/streaming reject empty path.
/// v18: remaining tool probes reject empty/whitespace string args;
///      tool_selection doc_set Strong requires a present non-null value.
/// v19: multi_turn_task_sequencing Strong requires nonempty string args;
///      token_efficiency empty text is Weak even when usage is 1-10.
/// v20: tool_selection Strong requires all three preferred tools
///      precise; empty/whitespace `doc_set` value is not precise;
///      json_output Strong requires nonempty word and reversed.
/// v21: streaming_tool_calls Strong requires a string `path` on the
///      `read_file` start that produced the args (no other-tool merge);
///      search_replace / unified_diff Strong require the edit bodies
///      (not whole-response contains) to hold greet/Hello and
///      welcome/Welcome.
/// v22: json_output Strong requires word=hello, length=5,
///      reversed=olleh (prompt example is not Strong);
///      stream ignores leftover function_call when tool_calls is
///      present; vision "no text" is Weak.
/// v23: vision Strong requires a standalone `bl` token (not `black`);
///      tool-arg strings reject ZWSP-only values; unprobed default
///      dimensions do not open host policy.
/// v24: XML format-card echo and tag mention do not open canUseTools;
///      SEARCH/REPLACE Strong requires `fn greet` / `fn welcome`;
///      unified_diff Medium requires hunk or file headers (not a
///      markdown +/- list); vision Medium surface words are tokens
///      (not `whitespace` / `context`).
/// v25: vision "no letters" / "no characters" is Weak; a closed XML
///      block that only mentions `<name>read_file</name>` without
///      `<arguments>` does not open canUseTools.
/// v26: closed XML Medium requires arguments that open `{`;
///      vision "don't see letters" is Weak; SEARCH/REPLACE Strong
///      requires `fn greet(` / `fn welcome(`.
/// v27: vision question echo, unified-diff format-card echo, and
///      XML JSON Schema paste do not open host policy.
/// v28: unified_diff headers and hunks must start a line
///      (prose `---` / `+++` / `@@` is Weak).
/// v29: vision "text-only" / "black box" is Weak; XML
///      `{"path":"value"}` is format-card echo.
/// v30: vision "no visible" is Weak; an unclosed `<tool_call>`
///      does not open tools from a `read_file` name before the tag.
/// v31: json_output does not peel an array wrapper to reach a
///      valid object (array-wrapped JSON must not skip repair).
/// v32: tool_calling empty or whitespace `function.name` is Weak
///      (does not open can_use_tools).
/// v33: SEARCH/REPLACE Strong ignores `//` comment tokens
///      (`fn greet(` / Hello in comments is not a rename).
/// v34: xml_tool_calling empty or whitespace `<name>` is Weak
///      (does not open can_use_tools).
/// v35: unified_diff Strong requires `fn greet` / `fn welcome` in
///      code +/- lines; comment-only +/- is Weak (not UnifiedDiff).
/// v36: SEARCH/REPLACE and unified_diff ignore `/* */` comment
///      tokens the same way as `//` comments.
/// v37: tool names that are only ZWSP/format marks are Weak
///      (same as empty names for can_use_tools).
/// v38: vision "text model" / "processes text" is Weak
///      (does not set supportsVision).
/// v39: vision "text-based" / "work with text" / "isn't any text"
///      is Weak.
/// v40: unified_diff format-card body (`removed line` / `added line`)
///      is Weak even when the path is greet.rs.
/// v41: vision "no visible letters" / "doesn't contain letters" is Weak
///      (does not set supportsVision).
/// v42: unified_diff title-case format-card body (`Removed line` /
///      `Added line`) is Weak.
/// v43: vision "white space" / "white-box" / "text-processing" is Weak
///      (does not set supportsVision).
/// v44: SEARCH/REPLACE and unified_diff ignore `#` comment tokens
///      the same way as `//` (`# fn greet` is not a rename).
///      Rust attributes (`#[derive]`) are not comments.
/// v45: XML `{"param":"value"}` plus extra keys, or `{'param':'value'}`,
///      is format-card echo (does not set canUseTools).
/// v46: vision "no visible characters" / "no discernible text" /
///      "white-space" is Weak (does not set supportsVision).
/// v47: XML array-wrapped `[{"param":"value"}]` and unclosed
///      param/value cards are format-card echo (do not set canUseTools).
/// v48: vision "no discernible characters" / "aren't any characters"
///      is Weak (does not set supportsVision).
/// v49: unified_diff format-card body (`remove line` / `add line`)
///      is Weak.
/// v50: XML multi-element `[{"param":"value"},{"param":"value"}]`
///      is format-card echo (does not set canUseTools).
/// v51: unified_diff hyphenated or double-space format-card body
///      (`removed-line` / `added-line`, `removed  line` / `added  line`)
///      is Weak.
/// v52: XML nested `{"payload":{"param":"value"}}`, JSON-string
///      wrap `"{\"param\":\"value\"}"`, and `{"param":["value"]}`
///      are format-card echo (do not set canUseTools).
/// v53: XML card keys and `value` compare case-insensitively
///      (`Param`/`Value` / `path":"Value"` is echo, not canUseTools).
/// v54: unified_diff underscore format-card body
///      (`removed_line` / `added_line`) is Weak.
/// v55: XML `{"path":["value"]}` and padded `"value "` are format-card
///      echo (do not set canUseTools).
/// v56: unparseable `{'path':'value'}` is format-card echo
///      (does not set canUseTools).
/// v57: XML card `value` ignores ZWSP/format marks
///      (`"value\\u200b"` is echo, not canUseTools).
/// v58: unparseable `{'path':'Value'}` is format-card echo
///      (does not set canUseTools).
/// v59: XML card keys ignore ZWSP/format marks
///      (`"path\\u200b"` is echo, not canUseTools).
/// v60: XML card `value` (fullwidth) is echo
///      (does not set canUseTools).
/// v61: unparseable `{p\\u200bath:'value'}` is format-card echo
///      (does not set canUseTools).
/// v62: unified-diff card `removed\\u{200B}line` is echo
///      (does not set UnifiedDiff).
/// v63: vision `no\\u{200B} text` is a refusal
///      (does not set supportsVision).
/// v64: vision `don\\u{200B}'t` / `can\\u{200B}'t` is a refusal
///      (does not set supportsVision).
/// v65: cheap skip is not a measured Medium (envelope status + completed_level);
///      persist probed_context_floor; CLI envelope cacheable/skipExpensive.
/// v66: adapter strips <think> / thinking parts so CoT is not graded.
/// v67: Length + no tool call is truncated (uncacheable), not 30-day Weak.
/// v68: recommendedContextTokens = min(advertised, measured); advertised in cache key.
/// v69: Length + incomplete tool/XML is truncated, not 30-day Medium.
/// v70: stream Length is truncated (uncacheable), not 30-day Weak/Medium.
/// v71: Length on JSON / edit / vision / instruction / context ladder
///      is truncated, not a 30-day host-policy card.
/// v72: instruction_following Strong requires the asked capital.
/// v73: blank-path parallel reads no longer outrank valid paths;
///      Length on code/faithfulness/memory/system is uncacheable;
///      SSE buffers bytes so split UTF-8 is not corrupted.
/// v74: vision Strong requires no refusal; code_syntax ignores
///      docstring `...`; empty Length on max_tokens is Transient.
/// v75: memory refusals are Weak; code_syntax uses the merge fence;
///      JSON length 5.0; XML/unified-diff pick a real edit over a card;
///      fenced Paris; ladder 2.84s; Length-empty token_efficiency Transient.
/// v76: tool_calling Strong if any call is read_file + path (not only first).
/// v77: json_output scores the best object, not only the first fence.
/// v78: SEARCH/REPLACE parses without -------; unified-diff Strong
///      requires Hello/Welcome in the +/- body.
/// v79: stream string indexes, numeric tool ids, array content,
///      finish_reason on delta, capped error bodies.
/// v80: JSON length "5"; port 9,847; old_text aliases; XML path child.
/// v81: STATUS: ok space-fold; code_syntax ignores comment return
///      and requires def merge_sorted.
/// v82: nested edit path alias; XML file_path JSON alias.
/// v83: vision cannot-read BL; memory cannot-share; numeric chat tool id;
///      incremental success-body cap.
/// v84: vision unable/can-not-read; memory unable-to-recall; docstring
///      return; merge_sorted fence; native file_path; stream JSON
///      Transient; loopback provider URL.
/// v85: stream strips think/reasoning; ZWSP stream names; file_path
///      alias on streaming/parallel/sequencing; search_replace ORs
///      split edits; XML bare path is 0.4; advertised probe isolation;
///      export provider aliases; overlay prefixes slash models;
///      looks_cheap uses host hint; code_syntax strips quotes;
///      nested_arguments pick-best; memory ZWSP refusals.
/// v86: code_syntax unfenced prose; memory can-not/reveal/forgotten;
///      vision no-readable / not-able-to-read; second != milliseconds;
///      0.0.0.0 is ollama; export strips provider prefix; knob aliases.
/// v87: code_syntax fenced name-only; extract prefers def merge_sorted(;
///      colon-in-prose name-drops are not a signature; multi_turn
///      requires read<edit<run turns; MCP stdio NDJSON.
/// v88: code_syntax sentence-with-paren is not a def; English paren
///      contents, same-line prose after colon, and English return
///      phrases (same-line or indented) stay Weak; extract falls
///      back when no fence is a real def; path-once SEARCH/REPLACE;
///      array-then-object JSON pick-best; memory not-able/can-not-
///      repeat/I forgot; vision identify/make-BL-out/can-not; msec
///      unit; file_path on sequencing edit, one-shot, and selection
///      tasks.
/// v89: typed merge_sorted signatures and defaults; extract picks
///      complete fence over stub; return must be in merge_sorted body;
///      lecture-line return is Weak; JSON nested objects and object
///      before fenced array; memory provide/tell/should-not refusals;
///      vision cannot-recognize.
/// v90: wrapped typed merge_sorted, trailing commas, and *args are
///      real defs; ellipsis assignment is not Strong; lecture
///      `fn greet(` is not SearchReplace; TOOL_NAME plus a real path
///      is Medium; vision don't/do not/recognise and curly apostrophes;
///      memory not-able/not-allowed/won't/should-not on provide/tell/repeat
///      (including won't tell); recognize-the-font hedge stays Strong.
/// v91: hash comments in wrapped typed defs; start-of-line lecture
///      signatures; array-wrapped hello after an example object;
///      vision could-not / did-not-see / make-out plus letters Medium;
///      this/that font hedges; memory hyphenless code and more refusals;
///      minutes are not the planted ms fact; XML file_path=value echo.
/// v92: these/those font hedges; past-tense recognize/identify; memory
///      didn't-recall / I'd-forgotten; hyphenless warehouse and proto;
///      hours and 1750 minutes are not the planted ms fact; trailing-
///      comma or commented array wrap stays Weak.
/// v93: leftover lecture verbs after `-> Type`; letters plus cannot-read
///      is Medium; identify-the-font and a/any typeface stay Strong;
///      cannot-quite-read and not-readable are not Strong; memory
///      say/give/wouldn't/permitted/retrieve/no-longer; 1750/2840
///      seconds are not the planted ms fact.
/// v94: JSON string-wrapped hello stays Weak; loopback :11434
///      shares the ollama cache family; MCP isError only on
///      actual tool failures.
/// v95: xai/grok/api.x.ai share the xai cache family; api.x.ai
///      requires a key; 400 incorrect-key is Auth.
/// v96: claude/anthropic/api.anthropic.com share the anthropic
///      family; Anthropic OAuth headers; ANTHROPIC_* keys.
pub const PROBE_SUITE_VERSION: u32 = 96;

/// Default effort label when probes leave `reasoning_effort` unset.
pub const DEFAULT_PROBE_EFFORT: &str = "unset";

/// Default cost knob: paid/full suite (`skip_expensive = false`).
pub const DEFAULT_SKIP_EXPENSIVE: bool = false;

/// Default vision knob: vision probe not requested.
pub const DEFAULT_VISION: bool = false;

/// A cached probe result together with the time it was stored.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheEntry {
    /// The cached capability profile.
    pub profile: CapabilityProfile,
    /// Unix epoch seconds when this entry was written.
    pub cached_at: u64,
    /// Effective reasoning effort used for probe requests (e.g. `unset`, `low`).
    #[serde(default = "default_effort_label")]
    pub reasoning_effort: String,
    /// Probe suite version used when this entry was written.
    #[serde(default = "default_suite_v1")]
    pub probe_suite_version: u32,
}

fn default_effort_label() -> String {
    DEFAULT_PROBE_EFFORT.to_owned()
}

fn default_suite_v1() -> u32 {
    1
}

/// File-based probe cache keyed by model|provider|effort|suite|cost|vision|ctx.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ProbeCache {
    /// All cached entries.
    pub profiles: HashMap<String, CacheEntry>,
}

impl ProbeCache {
    /// Load cache from disk. Returns an empty cache if the file does not exist.
    ///
    /// Applies migrations to fix stale probe scores from older versions.
    pub fn load(path: &Path) -> Result<Self, ProbeError> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let len = std::fs::metadata(path)?.len();
        if len > 8 * 1024 * 1024 {
            return Err(ProbeError::Internal(format!(
                "probe cache is too large ({} bytes): {}",
                len,
                path.display()
            )));
        }
        let mut cache = Self::read_disk(path)?;
        if cache.migrate_stale_tool_scores() {
            // Keep the migrated rows in memory even when rewrite fails.
            // The next process retries migrate against the stale file.
            if let Err(err) = cache.save(path) {
                eprintln!(
                    "warning: failed to persist migrated probe cache ({}): {err}",
                    path.display()
                );
            }
        }
        Ok(cache)
    }

    fn read_disk(path: &Path) -> Result<Self, ProbeError> {
        let contents = std::fs::read_to_string(path)?;
        Ok(serde_json::from_str(&contents)?)
    }

    /// Save cache to disk, creating parent directories if necessary.
    pub fn save(&self, path: &Path) -> Result<(), ProbeError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let mut profiles = self.profiles.clone();
        if path.exists() {
            if let Ok(disk) = Self::read_disk(path) {
                for (key, theirs) in disk.profiles {
                    match profiles.get(&key) {
                        Some(ours) if ours.cached_at >= theirs.cached_at => {}
                        _ => {
                            profiles.insert(key, theirs);
                        }
                    }
                }
            }
        }
        let outgoing = Self { profiles };
        let contents = serde_json::to_string_pretty(&outgoing)?;
        let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
        std::fs::write(&tmp, contents)?;
        // Windows rename cannot replace an existing file. Move the dest
        // aside so a failed rename can restore it.
        #[cfg(windows)]
        {
            let bak = path.with_extension("bak");
            if path.exists() {
                let _ = std::fs::remove_file(&bak);
                std::fs::rename(path, &bak)?;
            }
            if let Err(err) = std::fs::rename(&tmp, path) {
                let _ = std::fs::rename(&bak, path);
                return Err(err.into());
            }
            let _ = std::fs::remove_file(&bak);
        }
        #[cfg(not(windows))]
        std::fs::rename(&tmp, path)?;
        Ok(())
    }

    /// Newest valid current-suite profile for `model_id` + `provider`.
    ///
    /// Tries the default knob key, then the newest matching row. Export and MCP
    /// use this so a cheap-suite cache still produces an overlay.
    pub fn find_profile(&self, model_id: &str, provider: &str) -> Option<&CapabilityProfile> {
        if let Some(profile) = self.get(model_id, provider) {
            return Some(profile);
        }
        self.find_profile_with_cost(model_id, provider)
            .map(|(profile, _)| profile)
    }

    /// Newest matching row and whether that row was stored as cheap.
    pub fn find_profile_with_cost(
        &self,
        model_id: &str,
        provider: &str,
    ) -> Option<(&CapabilityProfile, bool)> {
        if let Some(profile) = self.get(model_id, provider) {
            return Some((profile, DEFAULT_SKIP_EXPENSIVE));
        }
        self.profiles
            .iter()
            .filter(|(_, entry)| {
                Self::is_valid(entry)
                    && entry.probe_suite_version == PROBE_SUITE_VERSION
                    && models_equivalent(
                        &entry.profile.model_id,
                        model_id,
                        provider,
                        &entry.profile.provider,
                    )
                    && providers_equivalent(&entry.profile.provider, provider)
            })
            .max_by_key(|(_, entry)| entry.cached_at)
            .map(|(key, entry)| (&entry.profile, key.split('|').nth(4) == Some("cheap")))
    }

    /// Newest matching row for probe cache hits (cheap/full fallback).
    ///
    /// Keeps advertised isolation. Export uses [`Self::find_profile_with_cost`],
    /// which may still return a loose advertised row.
    pub fn find_profile_with_cost_and_advertised(
        &self,
        model_id: &str,
        provider: &str,
        advertised: Option<u32>,
    ) -> Option<(&CapabilityProfile, bool)> {
        if let Some(profile) = self.get_with_knobs(
            model_id,
            provider,
            DEFAULT_SKIP_EXPENSIVE,
            DEFAULT_VISION,
            advertised,
        ) {
            return Some((profile, DEFAULT_SKIP_EXPENSIVE));
        }
        self.profiles
            .iter()
            .filter(|(key, entry)| {
                Self::is_valid(entry)
                    && entry.probe_suite_version == PROBE_SUITE_VERSION
                    && models_equivalent(
                        &entry.profile.model_id,
                        model_id,
                        provider,
                        &entry.profile.provider,
                    )
                    && providers_equivalent(&entry.profile.provider, provider)
                    && key_advertised(key) == advertised
            })
            .max_by_key(|(_, entry)| entry.cached_at)
            .map(|(key, entry)| (&entry.profile, key.split('|').nth(4) == Some("cheap")))
    }

    /// Get a cached profile for the current suite, default effort, and default knobs.
    pub fn get(&self, model_id: &str, provider: &str) -> Option<&CapabilityProfile> {
        self.get_with_knobs(
            model_id,
            provider,
            DEFAULT_SKIP_EXPENSIVE,
            DEFAULT_VISION,
            None,
        )
    }

    /// Get a cached profile for the current suite and explicit cheap/vision/context knobs.
    pub fn get_with_knobs(
        &self,
        model_id: &str,
        provider: &str,
        skip_expensive: bool,
        vision: bool,
        advertised: Option<u32>,
    ) -> Option<&CapabilityProfile> {
        self.get_with_settings(
            model_id,
            provider,
            DEFAULT_PROBE_EFFORT,
            PROBE_SUITE_VERSION,
            skip_expensive,
            vision,
            advertised,
        )
    }

    /// Get a cached profile for explicit effort/suite/knob settings.
    #[allow(clippy::too_many_arguments)]
    pub fn get_with_settings(
        &self,
        model_id: &str,
        provider: &str,
        reasoning_effort: &str,
        suite_version: u32,
        skip_expensive: bool,
        vision: bool,
        advertised: Option<u32>,
    ) -> Option<&CapabilityProfile> {
        let key = Self::cache_key_with_knobs(
            model_id,
            provider,
            reasoning_effort,
            suite_version,
            skip_expensive,
            vision,
            advertised,
        );
        if let Some(profile) = self.profiles.get(&key).and_then(|entry| {
            if Self::is_valid(entry) {
                Some(&entry.profile)
            } else {
                None
            }
        }) {
            return Some(profile);
        }
        // Alias-aware (openai ≡ api.openai.com) and provider-prefix retry
        // without falling through to a different cheap/vision/ctx row.
        self.profiles
            .iter()
            .filter(|(stored_key, entry)| {
                Self::is_valid(entry)
                    && entry.probe_suite_version == suite_version
                    && entry.reasoning_effort == reasoning_effort
                    && models_equivalent(
                        &entry.profile.model_id,
                        model_id,
                        provider,
                        &entry.profile.provider,
                    )
                    && providers_equivalent(&entry.profile.provider, provider)
                    && key_knobs_match(stored_key, skip_expensive, vision, advertised)
            })
            .max_by_key(|(_, entry)| entry.cached_at)
            .map(|(_, entry)| &entry.profile)
    }

    /// Look up the full cache entry (for doctor/probe display metadata).
    pub fn get_entry(&self, model_id: &str, provider: &str) -> Option<&CacheEntry> {
        self.get_entry_with_knobs(
            model_id,
            provider,
            DEFAULT_SKIP_EXPENSIVE,
            DEFAULT_VISION,
            None,
        )
    }

    /// Look up the full cache entry for explicit cheap/vision/context knobs.
    pub fn get_entry_with_knobs(
        &self,
        model_id: &str,
        provider: &str,
        skip_expensive: bool,
        vision: bool,
        advertised: Option<u32>,
    ) -> Option<&CacheEntry> {
        let key = Self::cache_key_with_knobs(
            model_id,
            provider,
            DEFAULT_PROBE_EFFORT,
            PROBE_SUITE_VERSION,
            skip_expensive,
            vision,
            advertised,
        );
        if let Some(entry) = self.profiles.get(&key).filter(|e| Self::is_valid(e)) {
            return Some(entry);
        }
        self.profiles
            .iter()
            .filter(|(stored_key, entry)| {
                Self::is_valid(entry)
                    && entry.probe_suite_version == PROBE_SUITE_VERSION
                    && entry.reasoning_effort == DEFAULT_PROBE_EFFORT
                    && models_equivalent(
                        &entry.profile.model_id,
                        model_id,
                        provider,
                        &entry.profile.provider,
                    )
                    && providers_equivalent(&entry.profile.provider, provider)
                    && key_knobs_match(stored_key, skip_expensive, vision, advertised)
            })
            .max_by_key(|(_, entry)| entry.cached_at)
            .map(|(_, entry)| entry)
    }

    /// Store a profile under the current suite, default effort, and default knobs.
    pub fn put(&mut self, profile: CapabilityProfile) {
        self.put_with_knobs(profile, DEFAULT_SKIP_EXPENSIVE, DEFAULT_VISION, None);
    }

    /// Store a profile under the current suite with explicit cheap/vision/context knobs.
    pub fn put_with_knobs(
        &mut self,
        profile: CapabilityProfile,
        skip_expensive: bool,
        vision: bool,
        advertised: Option<u32>,
    ) {
        self.put_with_settings(
            profile,
            DEFAULT_PROBE_EFFORT,
            PROBE_SUITE_VERSION,
            skip_expensive,
            vision,
            advertised,
        );
    }

    /// Store a profile with explicit effort/suite/knob metadata.
    pub fn put_with_settings(
        &mut self,
        profile: CapabilityProfile,
        reasoning_effort: &str,
        suite_version: u32,
        skip_expensive: bool,
        vision: bool,
        advertised: Option<u32>,
    ) {
        let key = Self::cache_key_with_knobs(
            &profile.model_id,
            &profile.provider,
            reasoning_effort,
            suite_version,
            skip_expensive,
            vision,
            advertised,
        );
        let entry = CacheEntry {
            cached_at: unix_now(),
            profile,
            reasoning_effort: reasoning_effort.to_owned(),
            probe_suite_version: suite_version,
        };
        self.profiles.insert(key, entry);
    }

    /// Fix stale probe scores from before the "does not support tools"
    /// scoring fix. Tool-related probes that failed because the provider
    /// returned "does not support tools" were incorrectly scored as Medium
    /// (0.5) instead of Weak (0.0) by the old `probe_or_default`.
    ///
    /// Returns true when any row was rewritten so [`Self::load`] can persist.
    fn migrate_stale_tool_scores(&mut self) -> bool {
        let mut changed = false;
        for entry in self.profiles.values_mut() {
            for name in TOOL_PROBE_NAMES {
                let Some(probe) = entry.profile.dimension_result_mut(name) else {
                    continue;
                };
                if probe.details.contains("does not support tools")
                    && probe.level != CapabilityLevel::Weak
                {
                    probe.level = CapabilityLevel::Weak;
                    probe.score = 0.0;
                    changed = true;
                }
            }
        }
        changed
    }

    /// Cache key for model+provider+effort+suite and default cheap/vision knobs.
    pub fn cache_key(
        model_id: &str,
        provider: &str,
        reasoning_effort: &str,
        suite_version: u32,
    ) -> String {
        Self::cache_key_with_knobs(
            model_id,
            provider,
            reasoning_effort,
            suite_version,
            DEFAULT_SKIP_EXPENSIVE,
            DEFAULT_VISION,
            None,
        )
    }

    /// Cache key including cheap/full, vision, and advertised context knobs.
    #[allow(clippy::too_many_arguments)]
    pub fn cache_key_with_knobs(
        model_id: &str,
        provider: &str,
        reasoning_effort: &str,
        suite_version: u32,
        skip_expensive: bool,
        vision: bool,
        advertised: Option<u32>,
    ) -> String {
        let cost = if skip_expensive { "cheap" } else { "full" };
        let vis = if vision { "vision" } else { "novision" };
        let ctx = match advertised {
            Some(n) => format!("ctx{n}"),
            None => "ctxnone".to_owned(),
        };
        format!("{model_id}|{provider}|{reasoning_effort}|v{suite_version}|{cost}|{vis}|{ctx}")
    }

    /// Check whether a cache entry is still valid (less than 30 days old).
    fn is_valid(entry: &CacheEntry) -> bool {
        let now = unix_now();
        now.saturating_sub(entry.cached_at) < CACHE_TTL_SECS
    }
}

fn providers_equivalent(stored: &str, requested: &str) -> bool {
    let a = stored.to_ascii_lowercase();
    let b = requested.to_ascii_lowercase();
    if a == b {
        return true;
    }
    provider_family(&a) == provider_family(&b)
}

fn models_equivalent(
    stored: &str,
    requested: &str,
    requested_provider: &str,
    stored_provider: &str,
) -> bool {
    if stored == requested {
        return true;
    }
    let stored_n = strip_normalized_provider_prefix(stored, requested_provider)
        .or_else(|| strip_normalized_provider_prefix(stored, stored_provider))
        .unwrap_or(stored);
    let requested_n = strip_normalized_provider_prefix(requested, requested_provider)
        .or_else(|| strip_normalized_provider_prefix(requested, stored_provider))
        .unwrap_or(requested);
    stored_n == requested_n
}

fn strip_normalized_provider_prefix<'a>(model_id: &'a str, provider: &str) -> Option<&'a str> {
    let lower_model = model_id.to_ascii_lowercase();
    let provider_l = provider.to_ascii_lowercase();
    let family = provider_family(&provider_l);
    for prefix in [provider_l.as_str(), family] {
        let with_slash = format!("{prefix}/");
        if let Some(rest) = lower_model
            .strip_prefix(&with_slash)
            .and_then(|_| model_id.get(with_slash.len()..))
        {
            if !rest.is_empty() {
                return Some(rest);
            }
        }
    }
    None
}

fn provider_family(provider: &str) -> &str {
    match provider {
        "openai" | "api.openai.com" => "openai",
        "openrouter" | "openrouter.ai" => "openrouter",
        "xai" | "grok" | "api.x.ai" | "x.ai" => "xai",
        "claude" | "anthropic" | "api.anthropic.com" => "anthropic",
        "ollama" | "localhost" | "127.0.0.1" | "::1" | "[::1]" | "0.0.0.0" => "ollama",
        other if loopback_default_ollama_port(other) => "ollama",
        other => other,
    }
}

fn loopback_default_ollama_port(provider: &str) -> bool {
    let Some(host) = provider.strip_suffix(":11434") else {
        return false;
    };
    let bare = host
        .strip_prefix('[')
        .and_then(|h| h.strip_suffix(']'))
        .unwrap_or(host);
    matches!(bare, "localhost" | "127.0.0.1" | "0.0.0.0" | "::1")
}

fn key_knobs_match(key: &str, skip_expensive: bool, vision: bool, advertised: Option<u32>) -> bool {
    let mut parts = key.rsplit('|');
    let ctx = parts.next().unwrap_or("");
    let vis = parts.next().unwrap_or("");
    let cost = parts.next().unwrap_or("");
    let want_cost = if skip_expensive { "cheap" } else { "full" };
    let want_vis = if vision { "vision" } else { "novision" };
    let want_ctx = match advertised {
        Some(n) => format!("ctx{n}"),
        None => "ctxnone".to_owned(),
    };
    cost == want_cost && vis == want_vis && ctx == want_ctx
}

fn key_advertised(key: &str) -> Option<u32> {
    let ctx = key.rsplit('|').next().unwrap_or("");
    ctx.strip_prefix("ctx")
        .filter(|s| *s != "none")
        .and_then(|s| s.parse().ok())
}

/// Current time as Unix epoch seconds.
fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}