Skip to content
Merged
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"crates/rho": "1.35.0",
"crates/rho-sdk": "3.0.0",
"crates/rho-providers": "0.20.0",
"crates/rho-providers": "0.21.0",
"crates/rho-tools": "0.16.0"
}
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/rho-providers/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rho-providers"
version = "0.20.0"
version = "0.21.0"
edition = "2021"
rust-version = "1.92"
description = "Model provider runtimes, credentials, and model catalog for Rho"
Expand Down
112 changes: 112 additions & 0 deletions crates/rho-providers/src/model/display_name.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! 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.
///
/// 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.
///
/// A cached answer is dropped when a catalog write lands for that provider, so
/// a name that arrives during a session reaches the next text that names the
/// model. Text already produced is never revisited: an entry only changes when
/// the catalog underneath it does.
#[derive(Default)]
struct NameCache {
names: HashMap<(String, String), Option<String>>,
/// Bumped by every catalog write. A lookup reads it before touching sqlite
/// and caches its answer only if it still holds, so a write that lands
/// while a lookup is in flight cannot be overwritten by the older answer.
generation: u64,
}

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

/// 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());
let generation = {
let cache = cache().read().expect("model name cache");
if let Some(name) = cache.names.get(&key) {
return name.clone();
}
cache.generation
};
let name = read_model_display_name(provider, model);
let mut cache = cache().write().expect("model name cache");
if cache.generation == generation {
cache.names.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 this provider's resolved names so the next lookup reads the catalog.
///
/// Every catalog write calls this. Without it a lookup that missed keeps its
/// `None` for the rest of the process, which defeats the startup prefetch on the
/// launch that needs it: the system prompt asks for every name it wants before
/// a download can finish, so the names would first appear on the next launch.
pub(crate) fn forget_provider_display_names(provider: &str) {
let mut cache = cache().write().expect("model name cache");
cache
.names
.retain(|(cached_provider, _), _| cached_provider != provider);
cache.generation += 1;
}

/// 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() {
let mut cache = cache().write().expect("model name cache");
cache.names.clear();
cache.generation += 1;
}

/// `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;
151 changes: 151 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,151 @@
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,
}
}

/// Covers: a name that lands mid-session must reach the next text that names
/// the model. Pinning the first miss for the process would strand the startup
/// prefetch, which cannot beat the system prompt to the first lookup.
/// Owner: pure unit
#[test]
fn a_catalog_write_reaches_a_lookup_that_already_missed() {
let writers: [(&str, fn()); 2] = [
("models.dev row", || {
write_cached_model_metadata_for_tests(
"anthropic",
"claude-fable-5",
&named_metadata("Claude Fable 5"),
);
}),
("provider model list", || {
replace_cached_provider_models_for_tests(
"anthropic",
&[provider_model("claude-fable-5", "Claude Fable 5")],
)
.unwrap();
}),
];

for (writer, write) in writers {
with_empty_caches(|| {
assert_eq!(
model_display_name("anthropic", "claude-fable-5"),
None,
"{writer}: no name before the write"
);

write();

assert_eq!(
model_display_name("anthropic", "claude-fable-5").as_deref(),
Some("Claude Fable 5"),
"{writer}: name after the write"
);
});
}
}

#[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
Loading
Loading