Skip to content
86 changes: 86 additions & 0 deletions crates/rho-providers/src/model/display_name.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Catalog names for models, for text that people and models read.
//!
//! A model id such as `gpt-5.6-sol` is the fact Rho acts on: it selects the
//! provider route, and it is what a user types back into `/model`. The name is
//! the fact a person recognizes. Both come from caches Rho already fills, so
//! nothing here reaches the network, and nothing here invents a name from an id:
//! an unknown model shows its id alone rather than a guess.

use std::{
collections::HashMap,
sync::{OnceLock, RwLock},
};

use crate::model::{models_dev, provider_models};

/// Names resolved this process, including the models that have none.
///
/// Two reasons, and both matter. Every lookup otherwise opens a fresh sqlite
/// connection and runs its schema statements, and these lookups sit on hot
/// paths: every delegated run listing formats one. And a name that changed
/// mid-session would rewrite text a caller was already given, which the prompt
/// surfaces must never do. Resolving once per process fixes both.
///
/// The cost is that a name arriving later - the startup prefetch landing, a
/// model refresh - is not picked up until the next launch. That is the intended
/// trade: a stable id beats a name that appears halfway through a session.
type NameCache = HashMap<(String, String), Option<String>>;

fn cache() -> &'static RwLock<NameCache> {
static CACHE: OnceLock<RwLock<NameCache>> = OnceLock::new();
CACHE.get_or_init(|| RwLock::new(NameCache::new()))
}

/// Catalog name for `provider/model`, or `None` when no catalog carries one.
///
/// The models.dev catalog wins because its names are curated across providers
/// (`GPT-5.6 Sol`, `Claude Fable 5`). A provider's own model list is the
/// fallback, and only when it named the model: discovery stores the id as the
/// display name for providers that publish no name, and echoing the id back as
/// a name would be noise.
///
/// Resolved once per process; see [`NameCache`].
pub fn model_display_name(provider: &str, model: &str) -> Option<String> {
let key = (provider.to_string(), model.to_string());
if let Some(name) = cache().read().expect("model name cache").get(&key) {
return name.clone();
}
let name = read_model_display_name(provider, model);
cache()
.write()
.expect("model name cache")
.insert(key, name.clone());
name
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

fn read_model_display_name(provider: &str, model: &str) -> Option<String> {
if let Some(name) = models_dev::cached_model_metadata(provider, model)
.and_then(|metadata| metadata.display_name)
{
return Some(name);
}
provider_models::cached_provider_model(provider, model)
.map(|entry| entry.display_name)
.filter(|name| name != model)
}

/// Drops resolved names so a test can write a catalog row and read it back.
#[doc(hidden)]
pub fn clear_model_display_name_cache_for_tests() {
cache().write().expect("model name cache").clear();
}

/// `provider/model (Catalog Name)`, or `provider/model` when no name is known.
///
/// The id stays first and always present: it is the part a caller can act on.
pub fn model_reference_with_display_name(provider: &str, model: &str) -> String {
let reference = crate::provider::model_reference(provider, model);
match model_display_name(provider, model) {
Some(name) => format!("{reference} ({name})"),
None => reference,
}
}

#[cfg(test)]
#[path = "display_name_tests.rs"]
mod tests;
109 changes: 109 additions & 0 deletions crates/rho-providers/src/model/display_name_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use pretty_assertions::assert_eq;

use super::*;
use crate::model::{
models_dev::{
with_models_dev_cache_dir_for_tests, write_cached_model_metadata_for_tests, ModelMetadata,
},
provider_models::{
replace_cached_provider_models_for_tests, with_provider_models_cache_dir_for_tests,
ProviderModel,
},
ReasoningCapabilities,
};

/// Runs `f` against empty models.dev and provider-model caches.
fn with_empty_caches<T>(f: impl FnOnce() -> T) -> T {
let catalog = tempfile::tempdir().unwrap();
let provider = tempfile::tempdir().unwrap();
with_models_dev_cache_dir_for_tests(catalog.path().to_path_buf(), || {
with_provider_models_cache_dir_for_tests(provider.path().to_path_buf(), || {
// Names resolve once per process; each case needs a fresh read.
clear_model_display_name_cache_for_tests();
f()
})
})
}

fn named_metadata(display_name: &str) -> ModelMetadata {
ModelMetadata {
display_name: Some(display_name.to_string()),
reasoning_metadata_complete: true,
..ModelMetadata::default()
}
}

fn provider_model(model: &str, display_name: &str) -> ProviderModel {
ProviderModel {
provider: "anthropic".into(),
model: model.into(),
display_name: display_name.into(),
context_window: None,
max_output_tokens: None,
reasoning_capabilities: ReasoningCapabilities::Unknown,
}
}

#[test]
fn prefers_the_catalog_name_then_the_provider_name_then_nothing() {
struct Case {
name: &'static str,
catalog: Option<ModelMetadata>,
provider_models: Vec<ProviderModel>,
expected_name: Option<&'static str>,
expected_reference: &'static str,
}

let cases = [
Case {
name: "catalog name wins over the provider name",
catalog: Some(named_metadata("Claude Fable 5")),
provider_models: vec![provider_model("claude-fable-5", "Claude Fable 5 (latest)")],
expected_name: Some("Claude Fable 5"),
expected_reference: "anthropic/claude-fable-5 (Claude Fable 5)",
},
Case {
name: "provider name fills in when the catalog has none",
catalog: None,
provider_models: vec![provider_model("claude-fable-5", "Claude Fable 5")],
expected_name: Some("Claude Fable 5"),
expected_reference: "anthropic/claude-fable-5 (Claude Fable 5)",
},
Case {
name: "a provider name equal to the id is not a name",
catalog: None,
provider_models: vec![provider_model("claude-fable-5", "claude-fable-5")],
expected_name: None,
expected_reference: "anthropic/claude-fable-5",
},
Case {
name: "an unknown model shows its id alone",
catalog: None,
provider_models: Vec::new(),
expected_name: None,
expected_reference: "anthropic/claude-fable-5",
},
];

for case in cases {
with_empty_caches(|| {
if let Some(metadata) = &case.catalog {
write_cached_model_metadata_for_tests("anthropic", "claude-fable-5", metadata);
}
replace_cached_provider_models_for_tests("anthropic", &case.provider_models).unwrap();

assert_eq!(
model_display_name("anthropic", "claude-fable-5").as_deref(),
case.expected_name,
"{}",
case.name
);
assert_eq!(
model_reference_with_display_name("anthropic", "claude-fable-5"),
case.expected_reference,
"{}",
case.name
);
});
}
}
2 changes: 2 additions & 0 deletions crates/rho-providers/src/model/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod catalog;
pub mod context;
mod contract;
pub mod display_name;
pub mod favorites;
pub mod handoff;
pub mod image;
Expand All @@ -17,6 +18,7 @@ pub use contract::{
ModelRequest, ModelResponse, ModelUsage, PartialToolCall, ProviderContextBlock,
ProviderReportedErrorKind, ToolCall, ToolResult, ToolSpec,
};
pub use display_name::{model_display_name, model_reference_with_display_name};
pub use image::image_summary;
pub use models_dev::ModelMetadata;
pub use reasoning_capabilities::{
Expand Down
59 changes: 57 additions & 2 deletions crates/rho-providers/src/model/models_dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ use crate::{

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct ModelMetadata {
/// Catalog name for people, such as `GPT-5.6 Sol`. Absent when the catalog
/// has no name for the model; callers then show the model id alone rather
/// than inventing a name from it.
#[serde(default)]
pub display_name: Option<String>,
pub advertised_context_window: Option<u64>,
pub effective_context_window: Option<u64>,
pub usable_context_window: Option<u64>,
Expand Down Expand Up @@ -172,6 +177,39 @@ pub async fn fetch_model_metadata(provider: &str, model: &str) -> Option<ModelMe
override_metadata(provider, model)
}

/// Fills catalog rows for several models with one models.dev download.
///
/// [`fetch_model_metadata`] downloads the whole catalog per call, so asking it
/// for a list would download the same document once per model. This exists for
/// callers that need rows for models the session only *names* - the models
/// behind subagents and internal agents - which no selection would ever fetch.
///
/// Returns the number of rows written. Nothing reaches the network when every
/// target is already current, so a warm cache costs one sqlite read per target.
pub async fn prefetch_model_metadata(targets: impl IntoIterator<Item = (String, String)>) -> usize {
let stale = targets
.into_iter()
.collect::<HashSet<_>>()
.into_iter()
.filter(|(provider, model)| model_metadata_needs_refresh(provider, model))
.collect::<Vec<_>>();
if stale.is_empty() {
return 0;
}
let Some(response) = fetch_models_dev_api().await else {
return 0;
};
stale
.into_iter()
.filter(|(provider, model)| {
upstream_metadata_from_api(&response, provider, model)
.filter(|metadata| metadata.reasoning_metadata_complete)
.inspect(|metadata| write_cached_upstream_model_metadata(provider, model, metadata))
.is_some()
})
.count()
}

fn upstream_metadata_from_api(api: &Value, provider: &str, model: &str) -> Option<ModelMetadata> {
let descriptor = crate::provider::provider_descriptor(provider)?;
model_metadata_from_api_with_policy(
Expand Down Expand Up @@ -230,7 +268,8 @@ fn override_metadata(provider: &str, model: &str) -> Option<ModelMetadata> {
}

fn metadata_has_values(metadata: &ModelMetadata) -> bool {
metadata.advertised_context_window.is_some()
metadata.display_name.is_some()
|| metadata.advertised_context_window.is_some()
|| metadata.effective_context_window.is_some()
|| metadata.usable_context_window.is_some()
|| metadata.long_context_threshold.is_some()
Expand Down Expand Up @@ -283,7 +322,10 @@ async fn fetch_models_dev_api() -> Option<Value> {
/// v7: Qwen Token Plan switched from Unknown to ExactAdvertised. Rows written
/// under Unknown stored `reasoning_metadata_complete = true` with no levels, so
/// fetch short-circuited forever. Bump forces rehydrate from models.dev.
const MODEL_METADATA_CACHE_VERSION: i64 = 7;
///
/// v8: `display_name` added. Older rows are complete without it, so only a bump
/// makes them refetch and pick up the catalog name.
const MODEL_METADATA_CACHE_VERSION: i64 = 8;

fn cached_upstream_model_metadata(provider: &str, model: &str) -> Option<ModelMetadata> {
cached_upstream_model_metadata_with_freshness(provider, model, CacheFreshness::AllowStale)
Expand Down Expand Up @@ -457,6 +499,12 @@ fn model_metadata_from_api_with_policy(
let cost = model.get("cost");
let (long_context_threshold, cost_long_context) = long_context_cost_from_api(cost);
Some(ModelMetadata {
display_name: model
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string),
advertised_context_window: limit
.and_then(|limit| limit.get("context"))
.and_then(|value| value.as_u64()),
Expand Down Expand Up @@ -743,6 +791,13 @@ fn merge_toml_override(
mut metadata: ModelMetadata,
table: &toml::map::Map<String, toml::Value>,
) -> ModelMetadata {
metadata.display_name = table
.get("display_name")
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.or(metadata.display_name);
metadata.advertised_context_window =
toml_u64(table, "advertised_context_window").or(metadata.advertised_context_window);
metadata.effective_context_window =
Expand Down
Loading
Loading