Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/harness/examples/claude_models_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! Read-only model discovery using the same resolver and protocol as the picker.
use zeron_harness::{ClaudeHarness, Harness};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let harness = ClaudeHarness::new();
let context = harness.model_context()?.expect("Claude context");
println!("binary: {}", context.binary_path.display());
println!(
"version: {}",
context.binary_version.as_deref().unwrap_or("unknown")
);
let catalog = harness.model_catalog(true).await?;
println!("source: {}", catalog.source);
for model in &catalog.models {
println!("{} => {}", model.id, model.label);
}
println!("models: {}", catalog.models.len());
Ok(())
}
61 changes: 52 additions & 9 deletions crates/harness/src/claude/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,38 @@ fn settings_models_keep_manifest_and_full_ladder() {
assert_eq!(models_with_settings(&path), static_models());
}

/// Claude's named rows put the versioned display name before the capability
/// text in their descriptions. Use that supplied name when the displayName is
/// a short alias; never reconstruct a model name from a catalog or model ID.
fn discovered_model_label(entry: &serde_json::Value, id: &str) -> String {
let Some(name) = model_text(entry, "displayName") else {
return id.to_owned();
};
let short_name = name
.strip_suffix(')')
.and_then(|name| name.rsplit_once(" (").map(|(base, _)| base))
.unwrap_or(name);
if let Some(description) = model_text(entry, "description") {
let heading = description.split('·').next().unwrap_or(description).trim();
if let Some(version) = heading
.strip_prefix(short_name)
.and_then(|s| s.strip_prefix(' '))
&& version.chars().next().is_some_and(|c| c.is_ascii_digit())
{
return heading.to_owned();
}
}
name.to_owned()
}

fn model_text<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> {
entry
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
}

/// Overlay only new concrete model IDs; curated metadata remains authoritative.
pub(super) fn with_discovered_models(
mut models: Vec<Model>,
Expand All @@ -236,13 +268,7 @@ pub(super) fn with_discovered_models(
let mut default = None;
let mut valid = false;
for entry in entries {
let text = |key: &str| {
entry
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
};
let text = |key: &str| model_text(entry, key);
let Some(id) = text("resolvedModel").or_else(|| text("value")) else {
continue;
};
Expand Down Expand Up @@ -283,10 +309,27 @@ pub(super) fn with_discovered_models(
if ladder.contains(&ReasoningLevel::XHigh) {
ladder.extend([ReasoningLevel::Ultracode, ReasoningLevel::Ultrathink]);
}
// The default row is a selection policy, not a model name. Match its
// resolved ID to the CLI's named row before deduplication, regardless
// of row order. Unnamed defaults retain the concrete ID as their label.
let named_entry = if text("value") == Some("default") {
entries.iter().find(|named| {
model_text(named, "value") != Some("default")
&& model_text(named, "resolvedModel").or_else(|| model_text(named, "value"))
== Some(id)
})
} else {
Some(entry)
};
models.push(Model {
id: id.into(),
label: text("displayName").unwrap_or(id).into(),
description: text("description").map(str::to_owned),
label: named_entry
.map(|named| discovered_model_label(named, id))
.unwrap_or_else(|| id.to_owned()),
description: named_entry
.and_then(|named| model_text(named, "description"))
.or_else(|| text("description"))
.map(str::to_owned),
reasoning_levels: ladder,
options: vec![],
});
Expand Down
96 changes: 96 additions & 0 deletions crates/harness/tests/claude_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,99 @@ async fn failed_or_logged_out_initialize_falls_back_to_curated_catalog() {
assert!(harness.model_catalog(false).await.is_err());
}
}

#[tokio::test]
async fn default_uses_the_named_rows_live_model_name_in_either_order() {
for aliases in [["default", "sonnet"], ["sonnet", "default"]] {
let (_dir, harness) = fixture(json!({"subtype":"success", "response":{
"models": aliases.map(|alias| json!({
"value":alias, "resolvedModel":"claude-sonnet-5-5",
"displayName":if alias == "default" { "Default (recommended)" } else { "Sonnet" },
"description":if alias == "default" {
"Use the default model (currently Sonnet 5.5) · $2/$10 per Mtok"
} else { "Sonnet 5.5 · Efficient for routine tasks · $2/$10 per Mtok" },
"supportedEffortLevels":["low","medium","high","xhigh","max"]
}))
}}));
let catalog = harness.model_catalog(true).await.unwrap();
let sonnet = &catalog.models[0];
assert_eq!(sonnet.id, "claude-sonnet-5-5");
assert_eq!(sonnet.label, "Sonnet 5.5");
assert_eq!(
sonnet.description.as_deref(),
Some("Sonnet 5.5 · Efficient for routine tasks · $2/$10 per Mtok")
);
assert_eq!(
catalog.models.iter().filter(|m| m.id == sonnet.id).count(),
1
);
assert!(sonnet.reasoning_levels.contains(&ReasoningLevel::XHigh));
// Discovery supplies this name; the fallback must not know the new model.
assert!(
!zeron_harness::claude::catalog::static_models()
.iter()
.any(|m| m.id == sonnet.id)
);
}
}

#[tokio::test]
async fn live_names_do_not_depend_on_known_families_versions_or_id_formats() {
for (id, name, description, expected) in [
(
"provider/opaque-deployment",
"Nebula",
"Nebula 17.3 · Provider description",
"Nebula 17.3",
),
(
"gateway/custom",
"Custom gateway model",
"Gateway description",
"Custom gateway model",
),
(
"opaque-model-without-prose",
"Nebula",
"Nebula 18.1",
"Nebula 18.1",
),
(
"next-generation",
"New Model Preview",
"Provider description",
"New Model Preview",
),
(
"claude-sonnet-6-2[1m]",
"Sonnet (1M context)",
"Sonnet 6.2 · Provider description",
"Sonnet 6.2",
),
] {
let (_dir, harness) = fixture(json!({"subtype":"success", "response":{
"models":[
{"value":"default", "resolvedModel":id, "displayName":"Default (recommended)"},
{"value":"new-alias", "resolvedModel":id, "displayName":name, "description":description}
]
}}));
let catalog = harness.model_catalog(true).await.unwrap();
assert_eq!(catalog.models[0].id, id);
assert_eq!(catalog.models[0].label, expected);
assert_eq!(catalog.models[0].description.as_deref(), Some(description));
}
}

#[tokio::test]
async fn unnamed_default_uses_its_concrete_id_without_guessing_the_model_name() {
let (_dir, harness) = fixture(json!({"subtype":"success", "response":{
"models":[
{"value":"default", "resolvedModel":"gateway/custom", "displayName":"Default (recommended)"},
{"value":"gateway/other", "displayName":"Custom gateway model"}
]
}}));
let catalog = harness.model_catalog(true).await.unwrap();
assert_eq!(catalog.models[0].id, "gateway/custom");
assert_eq!(catalog.models[0].label, "gateway/custom");
assert_eq!(catalog.models.last().unwrap().label, "Custom gateway model");
}
Loading