use std::collections::HashMap;
use std::path::PathBuf;
use newt_core::TokenEstimation;
pub use newt_core::parse_context_window_error;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolConformance {
Native,
TextMode,
NoTools,
}
impl ToolConformance {
pub fn symbol(&self) -> &'static str {
match self {
Self::Native => "✓ native",
Self::TextMode => "~ text ",
Self::NoTools => "✗ none ",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TuneConfidence {
#[default]
None,
Low,
Medium,
High,
}
impl TuneConfidence {
pub fn promote(&self) -> Self {
match self {
Self::None => Self::Low,
Self::Low => Self::Medium,
Self::Medium | Self::High => Self::High,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapabilityEntry {
pub conformance: ToolConformance,
pub tested_date: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hard_context_window: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub safe_context: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overflow_at: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_ok_input: Option<u32>,
#[serde(default)]
pub consecutive_ok: u32,
#[serde(default)]
pub tune_confidence: TuneConfidence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tune_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimate_ratio: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emits_thinking: Option<bool>,
#[serde(default)]
pub accounting_version: u32,
}
pub const ACCOUNTING_VERSION: u32 = 1;
impl Default for CapabilityEntry {
fn default() -> Self {
Self {
conformance: ToolConformance::NoTools,
tested_date: String::new(),
context_window: None,
hard_context_window: None,
safe_context: None,
overflow_at: None,
max_ok_input: None,
consecutive_ok: 0,
tune_confidence: TuneConfidence::None,
tune_date: None,
estimate_ratio: None,
emits_thinking: None,
accounting_version: ACCOUNTING_VERSION,
}
}
}
impl CapabilityEntry {
pub fn record_success(&mut self, input_tokens: u32, today: &str) -> bool {
let mut changed = false;
if self.max_ok_input.map(|m| input_tokens > m).unwrap_or(true) {
self.max_ok_input = Some(input_tokens);
changed = true;
}
self.consecutive_ok = self.consecutive_ok.saturating_add(1);
if self.consecutive_ok >= 5 && self.tune_confidence != TuneConfidence::High {
self.tune_confidence = self.tune_confidence.promote();
self.tune_date = Some(today.to_string());
self.consecutive_ok = 0;
changed = true;
}
changed
}
pub fn record_context_window_400(&mut self, hard_limit: u32, today: &str) -> bool {
self.record_context_window_400_with_pct(hard_limit, 80, today)
}
pub fn record_context_window_400_with_pct(
&mut self,
hard_limit: u32,
input_ceiling_pct: u32,
today: &str,
) -> bool {
let hard_limit = self
.hard_context_window
.map_or(hard_limit, |known| known.min(hard_limit));
self.hard_context_window = Some(hard_limit);
self.context_window = Some(hard_limit);
let new_cap = newt_core::config::input_percentage_ceiling(hard_limit, input_ceiling_pct);
self.max_ok_input = Some(new_cap);
self.consecutive_ok = 0;
self.tune_confidence = TuneConfidence::Low;
self.tune_date = Some(today.to_string());
if self.safe_context.map(|s| new_cap < s).unwrap_or(true) {
self.safe_context = Some(new_cap);
}
true }
pub fn record_overflow(&mut self, input_tokens: u32, today: &str) -> bool {
let new_safe = input_tokens * 75 / 100;
self.overflow_at = Some(input_tokens);
self.consecutive_ok = 0;
self.tune_confidence = TuneConfidence::Low;
self.tune_date = Some(today.to_string());
let changed = self.safe_context.map(|s| new_safe < s).unwrap_or(true);
if changed {
self.safe_context = Some(new_safe);
}
if self.max_ok_input.map(|m| new_safe < m).unwrap_or(false) {
self.max_ok_input = Some(new_safe);
}
true }
pub fn record_accepted_prompt(&mut self, prompt_tokens: u32, today: &str) -> bool {
if self.max_ok_input.map(|m| prompt_tokens > m).unwrap_or(true) {
self.max_ok_input = Some(prompt_tokens);
self.tune_date = Some(today.to_string());
return true;
}
false
}
pub fn record_estimate_sample(&mut self, observed: u32, estimated: usize) -> bool {
if estimated == 0 {
return false;
}
let raw = observed as f32 / estimated as f32;
if raw < 1.0 {
return false;
}
let sample = raw.clamp(1.0, 3.0);
let new = match self.estimate_ratio {
None => sample,
Some(old) => (0.75 * old + 0.25 * sample).clamp(0.5, 3.0),
};
let dirty = match self.estimate_ratio {
None => true,
Some(old) => (new - old).abs() > 0.01,
};
self.estimate_ratio = Some(new);
dirty
}
pub fn record_thinking_only(&mut self) -> bool {
if self.emits_thinking == Some(true) {
return false;
}
self.emits_thinking = Some(true);
true
}
}
pub fn apply_observation(
entry: &mut CapabilityEntry,
obs: &newt_core::RoundObservation,
today: &str,
) -> bool {
apply_observation_with_input_ceiling_pct(entry, obs, today, 80)
}
pub fn apply_observation_with_input_ceiling_pct(
entry: &mut CapabilityEntry,
obs: &newt_core::RoundObservation,
today: &str,
input_ceiling_pct: u32,
) -> bool {
match *obs {
newt_core::RoundObservation::Accepted {
prompt_tokens,
estimated_tokens,
} => {
entry.record_accepted_prompt(prompt_tokens, today)
| entry.record_estimate_sample(prompt_tokens, estimated_tokens)
}
newt_core::RoundObservation::SuspectedOverflow { prompt_tokens } => {
entry.record_overflow(prompt_tokens, today)
}
newt_core::RoundObservation::ContextWindow400 { context_window } => {
entry.record_context_window_400_with_pct(context_window, input_ceiling_pct, today)
}
newt_core::RoundObservation::ThinkingOnly => entry.record_thinking_only(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CapKey(String);
impl CapKey {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CapKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub type CapabilityCache = HashMap<CapKey, CapabilityEntry>;
#[must_use]
pub fn cap_key(serving: newt_core::Serving, backend_name: &str, model: &str) -> CapKey {
CapKey(match serving {
newt_core::Serving::Multiplexer => model.to_string(),
newt_core::Serving::Instance => format!("backend:{backend_name}"),
})
}
#[derive(Debug, Clone)]
pub struct ModelInfo {
pub name: String,
pub param_size: String,
}
#[cfg(test)]
thread_local! {
static CACHE_DIR_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_cache_dir_override(dir: Option<PathBuf>) {
CACHE_DIR_OVERRIDE.with(|c| *c.borrow_mut() = dir);
}
#[cfg(test)]
#[path = "probe_tests/cap_key_tests.rs"]
mod cap_key_tests;
fn cache_path() -> Option<PathBuf> {
#[cfg(test)]
if let Some(dir) = CACHE_DIR_OVERRIDE.with(|c| c.borrow().clone()) {
return Some(dir.join("model-capabilities.json"));
}
newt_core::Config::user_config_path().map(|p| p.with_file_name("model-capabilities.json"))
}
pub fn load_cache() -> CapabilityCache {
let Some(path) = cache_path() else {
return Default::default();
};
let Ok(data) = std::fs::read_to_string(&path) else {
return Default::default();
};
let mut cache: CapabilityCache = serde_json::from_str(&data).unwrap_or_default();
if migrate_accounting(&mut cache) | invalidate_suspect_pins(&mut cache) {
save_cache(&cache);
}
cache
}
pub fn migrate_accounting(cache: &mut CapabilityCache) -> bool {
let mut dirty = false;
for (key, entry) in cache.iter_mut() {
if entry.accounting_version >= ACCOUNTING_VERSION {
continue;
}
if entry.max_ok_input.is_some() {
tracing::info!(
cap_key = %key,
max_ok_input = entry.max_ok_input,
"invalidating max_ok_input recorded under the double-counting \
regime (Step 18.1); the ratchet will re-learn"
);
entry.max_ok_input = None;
entry.consecutive_ok = 0;
entry.tune_confidence = TuneConfidence::None;
}
entry.accounting_version = ACCOUNTING_VERSION;
dirty = true;
}
dirty
}
pub fn invalidate_suspect_pins(cache: &mut CapabilityCache) -> bool {
let mut dirty = false;
for (key, entry) in cache.iter_mut() {
if entry.hard_context_window.is_some() {
continue;
}
let Some(max_ok) = entry.max_ok_input else {
continue;
};
if !newt_core::agentic::is_truncation_suspect(max_ok, entry.safe_context) {
continue;
}
tracing::warn!(
cap_key = %key,
max_ok_input = max_ok,
safe_context = ?entry.safe_context,
"invalidating a max_ok_input pin at >= 95% of its own safe_context — \
window evidence of nothing (#1967/#1968); the ratchet will re-learn"
);
entry.max_ok_input = None;
entry.consecutive_ok = 0;
entry.tune_confidence = TuneConfidence::None;
dirty = true;
}
dirty
}
pub fn save_cache(cache: &CapabilityCache) {
let Some(path) = cache_path() else { return };
if let Ok(data) = serde_json::to_string_pretty(cache) {
let _ = std::fs::write(path, data);
}
}
pub fn resolve_memory_budget(
explicit: Option<u32>,
declared_window: Option<u32>,
entry: Option<&CapabilityEntry>,
) -> u32 {
explicit
.or(declared_window)
.or_else(|| {
entry.and_then(|e| match (e.max_ok_input, e.safe_context) {
(Some(m), Some(s)) => Some(m.max(s)),
(m, s) => m.or(s),
})
})
.unwrap_or(newt_core::DEFAULT_CONTEXT_TOKENS)
}
pub fn fetch_ollama_models(endpoint: &str) -> anyhow::Result<Vec<ModelInfo>> {
let url = format!("{}/api/tags", endpoint.trim_end_matches('/'));
let json: serde_json::Value = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?
.get(&url)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
}
resp.json::<serde_json::Value>()
.await
.map_err(anyhow::Error::from)
})
})?;
Ok(json["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| {
let name = m["name"].as_str()?.to_string();
let param_size = m["details"]["parameter_size"]
.as_str()
.unwrap_or("")
.to_string();
Some(ModelInfo { name, param_size })
})
.collect()
})
.unwrap_or_default())
}
pub fn fetch_context_window(
endpoint: &str,
model: &str,
kind: newt_core::BackendKind,
api_key: Option<&str>,
) -> Option<u32> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok()?;
newt_core::backend_probe::api_for(kind)
.context_window(&client, endpoint, model, api_key)
.await
})
})
}
pub fn ensure_context_window(
entry: &mut CapabilityEntry,
endpoint: &str,
model: &str,
trust_declared: bool,
kind: newt_core::BackendKind,
api_key: Option<&str>,
) -> bool {
if !trust_declared && entry.context_window.is_some() {
return false;
}
let mut changed = false;
if entry.context_window.is_none() {
let Some(window) = fetch_context_window(endpoint, model, kind, api_key) else {
return false;
};
entry.context_window = Some(window);
changed = true;
}
let Some(window) = entry.context_window else {
return changed;
};
let declared_safe = window * 80 / 100;
if trust_declared {
if entry.safe_context != Some(declared_safe) {
entry.safe_context = Some(declared_safe);
changed = true;
}
} else if entry.safe_context.is_none() {
entry.safe_context = Some(declared_safe);
changed = true;
}
changed
}
pub fn today_local_date() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string()
}
pub fn refresh_context_window(
entry: &mut CapabilityEntry,
endpoint: &str,
model: &str,
trust_declared: bool,
kind: newt_core::BackendKind,
api_key: Option<&str>,
) -> bool {
let Some(window) = fetch_context_window(endpoint, model, kind, api_key) else {
return false;
};
let mut changed = entry.context_window != Some(window);
entry.context_window = Some(window);
let declared_safe = window * 80 / 100;
if trust_declared {
if entry.safe_context != Some(declared_safe) {
entry.safe_context = Some(declared_safe);
changed = true;
}
} else if entry.safe_context.is_none() {
entry.safe_context = Some(declared_safe);
}
changed
}
pub fn message_thinking_fields(message: &serde_json::Value) -> bool {
let content_empty = message["content"]
.as_str()
.map(|c| c.trim().is_empty())
.unwrap_or(true);
if !content_empty {
return false;
}
["thinking", "reasoning", "reasoning_content"]
.iter()
.any(|field| {
message[*field]
.as_str()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProbeThinking {
pub emits_thinking: bool,
pub calibration: Option<(u32, usize)>,
}
pub fn probe_thinking(
endpoint: &str,
model: &str,
est: TokenEstimation,
) -> anyhow::Result<ProbeThinking> {
let url = format!("{}/api/chat", endpoint.trim_end_matches('/'));
let prompt = "Reply with the single word: ok";
let body = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": false,
});
let estimated = serde_json::to_string(&body)
.map(|s| est.tokens_for_chars(s.chars().count()))
.unwrap_or(est.tokens_for_chars(prompt.chars().count()));
let json: serde_json::Value = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
if !resp.status().is_success() {
anyhow::bail!("Ollama returned {}", resp.status());
}
resp.json::<serde_json::Value>()
.await
.map_err(anyhow::Error::from)
})
})?;
let emits_thinking = message_thinking_fields(&json["message"]);
let calibration = json["prompt_eval_count"]
.as_u64()
.map(|observed| (observed as u32, estimated));
Ok(ProbeThinking {
emits_thinking,
calibration,
})
}
fn sanitize_ratio(ratio: f32) -> f32 {
if ratio.is_finite() && (0.5..=3.0).contains(&ratio) {
ratio
} else {
1.0
}
}
pub fn build_padded_prompt(target_real_tokens: u32, ratio: f32) -> String {
let ratio = sanitize_ratio(ratio);
let target_estimate = (target_real_tokens as f32 / ratio).round().max(1.0);
let target_chars = (target_estimate * 4.0).round() as usize;
const CLAUSE: &str = "lorem ipsum dolor sit amet ";
let mut s = String::with_capacity(target_chars + CLAUSE.len());
while s.chars().count() < target_chars {
s.push_str(CLAUSE);
}
s
}
#[derive(Debug, Clone, PartialEq)]
pub enum BoundaryClass {
Accepted { prompt_tokens: u32 },
Truncated,
CtxWindow400 { limit: u32 },
Inconclusive,
}
pub fn classify_boundary_probe(
http: Result<&serde_json::Value, &anyhow::Error>,
sent_real_estimate: u32,
) -> BoundaryClass {
match http {
Ok(json) => {
let message = &json["message"];
let content_nonempty = message["content"]
.as_str()
.map(|c| !c.trim().is_empty())
.unwrap_or(false);
let has_tool_call = message["tool_calls"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false);
let eval_count = json["eval_count"].as_u64().unwrap_or(0);
let usable = content_nonempty || has_tool_call || eval_count > 0;
let prompt_eval = json["prompt_eval_count"].as_u64().unwrap_or(0) as u32;
let threshold = (sent_real_estimate as u64 * 90 / 100) as u32;
if usable && prompt_eval >= threshold {
BoundaryClass::Accepted {
prompt_tokens: prompt_eval,
}
} else {
BoundaryClass::Truncated
}
}
Err(e) => match parse_context_window_error(&e.to_string()) {
Some((_, limit)) => BoundaryClass::CtxWindow400 {
limit: limit as u32,
},
None => BoundaryClass::Inconclusive,
},
}
}
fn boundary_probe_request(
endpoint: &str,
model: &str,
prompt: &str,
num_ctx: u32,
est: TokenEstimation,
) -> (anyhow::Result<serde_json::Value>, usize) {
let url = format!("{}/api/chat", endpoint.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": false,
"options": {"num_ctx": num_ctx, "num_predict": 8},
});
let sent_chars4 = serde_json::to_string(&body)
.map(|s| est.tokens_for_chars(s.chars().count()))
.unwrap_or(est.tokens_for_chars(prompt.chars().count()));
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("inference endpoint {status}: {text}");
}
serde_json::from_str::<serde_json::Value>(&text)
.map_err(|e| anyhow::anyhow!("bad JSON from /api/chat: {e}"))
})
});
(result, sent_chars4)
}
#[derive(Debug, Clone, PartialEq)]
pub struct BoundarySearchOutcome {
pub highest_accepted: Option<u32>,
pub steps: u32,
pub final_bounds: (u32, u32),
pub error: Option<String>,
}
pub fn probe_input_boundary(
endpoint: &str,
model: &str,
entry: &mut CapabilityEntry,
mut progress: impl FnMut(&str),
today: &str,
est: TokenEstimation,
) -> anyhow::Result<BoundarySearchOutcome> {
const STEP_CAP: u32 = 12;
const REPLY_MARGIN: u32 = 256;
const LOW_FLOOR: u32 = 2_048;
const DOUBLE_CAP: u32 = 1_000_000;
let ratio = entry.estimate_ratio.map(sanitize_ratio).unwrap_or(1.0);
let mut low = entry.safe_context.unwrap_or(LOW_FLOOR).max(LOW_FLOOR);
let declared = entry.context_window;
let mut highest_accepted: Option<u32> = None;
let mut steps = 0u32;
let mut error: Option<String> = None;
let run_probe =
|n: u32, entry: &mut CapabilityEntry, progress: &mut dyn FnMut(&str)| -> BoundaryClass {
let num_ctx = match declared {
Some(w) => (n + REPLY_MARGIN).min(w),
None => n + REPLY_MARGIN,
};
let prompt = build_padded_prompt(n, ratio);
let (http, sent_chars4) =
boundary_probe_request(endpoint, model, &prompt, num_ctx, est);
let class = classify_boundary_probe(http.as_ref(), n);
match &class {
BoundaryClass::Accepted { prompt_tokens } => {
entry.record_accepted_prompt(*prompt_tokens, today);
entry.record_estimate_sample(*prompt_tokens, sent_chars4);
progress(&format!(
" num_ctx={num_ctx}: accepted (prompt_eval={prompt_tokens})"
));
}
BoundaryClass::Truncated => {
progress(&format!(" num_ctx={num_ctx}: truncated/rejected"));
}
BoundaryClass::CtxWindow400 { limit } => {
progress(&format!(
" num_ctx={num_ctx}: context-window 400 (limit {limit})"
));
}
BoundaryClass::Inconclusive => {
progress(&format!(
" num_ctx={num_ctx}: inconclusive — {}",
http.err()
.map(|e| e.to_string())
.unwrap_or_else(|| "transport error".into())
));
}
}
class
};
let mut high = match declared {
Some(w) => w.max(low + 1),
None => {
let mut candidate = low.saturating_mul(2).max(low + 1024).min(DOUBLE_CAP);
let mut bracket_high = DOUBLE_CAP;
loop {
if steps >= STEP_CAP {
break;
}
steps += 1;
match run_probe(candidate, entry, &mut progress) {
BoundaryClass::Accepted { prompt_tokens } => {
highest_accepted =
Some(highest_accepted.map_or(prompt_tokens, |h| h.max(prompt_tokens)));
low = low.max(candidate);
if candidate >= DOUBLE_CAP {
bracket_high = DOUBLE_CAP;
break;
}
candidate = candidate.saturating_mul(2).min(DOUBLE_CAP);
}
BoundaryClass::CtxWindow400 { limit } => {
entry.record_context_window_400(limit, today);
bracket_high = limit;
break;
}
BoundaryClass::Truncated => {
bracket_high = candidate;
break;
}
BoundaryClass::Inconclusive => {
error = Some("boundary search stopped: inconclusive probe".into());
bracket_high = candidate;
break;
}
}
}
bracket_high
}
};
while error.is_none() && steps < STEP_CAP {
let tolerance = 1_024u32.max((high as u64 * 5 / 100) as u32);
if high.saturating_sub(low) <= tolerance {
break;
}
let mid = low + (high - low) / 2;
steps += 1;
match run_probe(mid, entry, &mut progress) {
BoundaryClass::Accepted { prompt_tokens } => {
highest_accepted =
Some(highest_accepted.map_or(prompt_tokens, |h| h.max(prompt_tokens)));
low = mid;
}
BoundaryClass::Truncated => {
high = mid;
}
BoundaryClass::CtxWindow400 { limit } => {
entry.record_context_window_400(limit, today);
high = limit.min(high);
if limit < low {
low = limit;
}
}
BoundaryClass::Inconclusive => {
error = Some("boundary search stopped: inconclusive probe".into());
break;
}
}
}
if highest_accepted.is_some() {
entry.tune_confidence = TuneConfidence::High;
entry.tune_date = Some(today.to_string());
}
Ok(BoundarySearchOutcome {
highest_accepted,
steps,
final_bounds: (low, high),
error,
})
}
pub fn is_tuning_stale(tune_date: Option<&str>, today: &str, max_age_days: i64) -> bool {
let Some(stamp) = tune_date else {
return true;
};
let (Ok(then), Ok(now)) = (
chrono::NaiveDate::parse_from_str(stamp, "%Y-%m-%d"),
chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d"),
) else {
return true;
};
(now - then).num_days() > max_age_days
}
pub fn tuning_age_days(tune_date: Option<&str>, today: &str) -> Option<i64> {
let then = chrono::NaiveDate::parse_from_str(tune_date?, "%Y-%m-%d").ok()?;
let now = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d").ok()?;
Some((now - then).num_days())
}
#[derive(Debug, Clone, PartialEq)]
pub struct FullProbeReport {
pub conformance: ToolConformance,
pub context_window: Option<u32>,
pub emits_thinking: bool,
pub estimate_ratio: Option<f32>,
pub boundary: Option<BoundarySearchOutcome>,
pub notes: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
pub fn full_probe(
endpoint: &str,
model: &str,
entry: &mut CapabilityEntry,
do_window: bool,
today: &str,
mut progress: impl FnMut(&str),
est: TokenEstimation,
kind: newt_core::BackendKind,
api_key: Option<&str>,
) -> FullProbeReport {
let mut notes = Vec::new();
let conformance = match tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(probe_tool_conformance_calibrated(endpoint, model, est))
}) {
Ok(pc) => {
if let Some((observed, estimated)) = pc.calibration {
entry.record_estimate_sample(observed, estimated);
}
pc.conformance
}
Err(e) => {
notes.push(format!("conformance probe failed: {e}"));
entry.conformance.clone()
}
};
entry.conformance = conformance.clone();
entry.tested_date = today.to_string();
refresh_context_window(entry, endpoint, model, false, kind, api_key);
let mut emits_thinking = entry.emits_thinking.unwrap_or(false);
match probe_thinking(endpoint, model, est) {
Ok(pt) => {
if pt.emits_thinking {
entry.record_thinking_only();
emits_thinking = true;
}
if let Some((observed, estimated)) = pt.calibration {
entry.record_estimate_sample(observed, estimated);
}
}
Err(e) => notes.push(format!("thinking probe failed: {e}")),
}
let boundary = if do_window {
match probe_input_boundary(endpoint, model, entry, &mut progress, today, est) {
Ok(outcome) => Some(outcome),
Err(e) => {
notes.push(format!("boundary search failed: {e}"));
None
}
}
} else {
None
};
FullProbeReport {
conformance,
context_window: entry.context_window,
emits_thinking,
estimate_ratio: entry.estimate_ratio,
boundary,
notes,
}
}
fn probe_tool_schema() -> serde_json::Value {
serde_json::json!([{
"type": "function",
"function": {
"name": "list_dir",
"description": "List files in a directory",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path (use '.' for current directory)"
}
},
"required": ["path"]
}
}
}])
}
pub fn looks_like_tool_call_json(content: &str) -> bool {
let trimmed = content.trim();
if !trimmed.contains("\"name\"") || !trimmed.contains("\"arguments\"") {
return false;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
let is_call =
|v: &serde_json::Value| v.get("name").is_some() && v.get("arguments").is_some();
if is_call(&val) {
return true;
}
if val
.as_array()
.map(|a| a.iter().any(is_call))
.unwrap_or(false)
{
return true;
}
}
false
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProbeConformance {
pub conformance: ToolConformance,
pub calibration: Option<(u32, usize)>,
}
fn classify_conformance(message: &serde_json::Value) -> ToolConformance {
if let Some(tcs) = message["tool_calls"].as_array() {
if !tcs.is_empty() {
return ToolConformance::Native;
}
}
let content = message["content"].as_str().unwrap_or("");
if looks_like_tool_call_json(content) {
return ToolConformance::TextMode;
}
ToolConformance::NoTools
}
pub async fn probe_tool_conformance_calibrated(
endpoint: &str,
model: &str,
est: TokenEstimation,
) -> anyhow::Result<ProbeConformance> {
let url = format!("{}/api/chat", endpoint.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"messages": [{
"role": "user",
"content": "Call the list_dir tool on path '.'. \
Do not explain — just call the tool."
}],
"tools": probe_tool_schema(),
"stream": false,
});
let estimated = serde_json::to_string(&body)
.map(|s| est.tokens_for_chars(s.chars().count()))
.unwrap_or(0);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
if !resp.status().is_success() {
anyhow::bail!("Ollama returned {}", resp.status());
}
let json: serde_json::Value = resp.json().await?;
let conformance = classify_conformance(&json["message"]);
let calibration = json["prompt_eval_count"]
.as_u64()
.map(|observed| (observed as u32, estimated));
Ok(ProbeConformance {
conformance,
calibration,
})
}
pub async fn probe_tool_conformance(
endpoint: &str,
model: &str,
est: TokenEstimation,
) -> anyhow::Result<ToolConformance> {
probe_tool_conformance_calibrated(endpoint, model, est)
.await
.map(|p| p.conformance)
}
const EMPTY: &str = "\u{2014}";
pub(crate) struct CapabilitiesTable {
pub(crate) head: Vec<String>,
pub(crate) rows: Vec<String>,
pub(crate) active: Option<usize>,
}
pub(crate) fn capabilities_table(
models: &[ModelInfo],
cache: &CapabilityCache,
active: &str,
) -> CapabilitiesTable {
use newt_core::markup::table::{render_table, Align, Column};
let mut active_row: Option<usize> = None;
let mut cells: Vec<Vec<String>> = Vec::with_capacity(models.len());
for m in models {
let is_active = m.name == active;
if is_active {
active_row = Some(cells.len());
}
let name = if is_active {
format!("{} \u{25c0}", m.name)
} else {
m.name.clone()
};
let size = if m.param_size.is_empty() {
EMPTY.to_string()
} else {
m.param_size.clone()
};
let (conformance, think, ctx_win, safe_ctx, conf, tested) =
match cache.get(&cap_key(newt_core::Serving::Multiplexer, "", &m.name)) {
Some(e) => {
let ctx = e.context_window.map_or_else(|| EMPTY.to_string(), fmt_k);
let safe = e.safe_context.map_or_else(|| EMPTY.to_string(), fmt_k);
let conf = match e.tune_confidence {
TuneConfidence::None => EMPTY,
TuneConfidence::Low => "Low",
TuneConfidence::Medium => "Med",
TuneConfidence::High => "High",
};
let think = if e.emits_thinking == Some(true) {
"\u{2713}"
} else {
EMPTY
};
(
e.conformance.symbol().to_string(),
think.to_string(),
ctx,
safe,
conf.to_string(),
e.tested_date.clone(),
)
}
None => (
EMPTY.to_string(),
EMPTY.to_string(),
EMPTY.to_string(),
EMPTY.to_string(),
EMPTY.to_string(),
"(untested)".to_string(),
),
};
cells.push(vec![
name,
size,
conformance,
think,
ctx_win,
safe_ctx,
conf,
tested,
]);
}
let columns = [
Column::new("Model"),
Column::new("Size").align(Align::Right),
Column::new("Tool Use"),
Column::new("Think"),
Column::new("Ctx Win").align(Align::Right),
Column::new("Safe Ctx").align(Align::Right),
Column::new("Conf").align(Align::Right),
Column::new("Tested"),
];
let rendered = render_table(&columns, &cells);
let mut lines = rendered.lines().map(str::to_string);
let head: Vec<String> = lines.by_ref().take(2).collect();
CapabilitiesTable {
head,
rows: lines.collect(),
active: active_row,
}
}
pub fn print_capabilities_table(
models: &[ModelInfo],
cache: &CapabilityCache,
active: &str,
endpoint: &str,
color: bool,
) {
let tested = models
.iter()
.filter(|m| cache.contains_key(&cap_key(newt_core::Serving::Multiplexer, "", &m.name)))
.count();
println!(
"Models on {} ({} total, {} tested)\n",
endpoint,
models.len(),
tested,
);
let table = capabilities_table(models, cache, active);
for line in &table.head {
println!("{line}");
}
for (i, row) in table.rows.iter().enumerate() {
if color && table.active == Some(i) {
use crossterm::style::Color as CtColor;
use crossterm::{
execute,
style::{Print, ResetColor, SetForegroundColor},
};
execute!(
std::io::stdout(),
SetForegroundColor(CtColor::Rgb {
r: 220,
g: 60,
b: 20
}),
Print(format!("{row}\n")),
ResetColor,
)
.ok();
} else {
println!("{row}");
}
}
println!();
println!("Legend:");
println!(" ✓ native tool_calls field — works with this harness");
println!(" ~ text JSON embedded in content — NOT dispatched by newt");
println!(" ✗ none ignores tools, answers directly");
println!(" — untested → /probe <model> to classify");
println!();
println!(" Think ✓ reasoning model — emits chain-of-thought tokens (auto-detected;");
println!(" newt streams it dimmed and keeps it out of the saved answer)");
println!(" Ctx Win declared context window from Ollama /api/show");
println!(" Safe Ctx num_ctx sent to Ollama (auto-tuned; human-overridable in config)");
println!(" Conf tuning confidence: None | Low | Med | High");
println!();
println!("Run /probe <model> to test a model (warm-up included).");
println!("Run /probe all to test every untested model in sequence.");
println!("Run /probe window <model> for an empirical input-boundary search (High confidence).");
}
fn fmt_k(n: u32) -> String {
if n >= 1024 {
format!("{}k", n / 1024)
} else {
n.to_string()
}
}
#[cfg(test)]
#[path = "probe_tests/core.rs"]
mod tests;