fix(provider): show active OpenAI-compatible profile name in header (#329)

The header and info widget hard-coded 'OpenRouter' for any model routed
through the OpenRouter slot, even when the user switched to a direct
OpenAI-compatible profile such as NVIDIA NIM at runtime. The display name
was resolved from process env vars that only reflect the startup profile,
so a runtime '/model' switch never updated the label.

Add a runtime-aware Provider::display_name() (default = name()) overridden
by OpenRouterProvider (maps profile_id -> 'NVIDIA NIM', etc.) and
MultiProvider (delegates to the active execution runtime). name() stays
the stable machine id ('openrouter') that billing/routing keys off.
format_model_name() in the header now uses the active provider's display
name instead of a fixed 'OpenRouter:' prefix.

Adds regression tests.
This commit is contained in:
jeremy
2026-06-05 14:04:22 -07:00
parent c449f76e75
commit 6c855cc978
8 changed files with 188 additions and 10 deletions
+3 -1
View File
@@ -134,7 +134,9 @@ impl Agent {
}
pub fn provider_name(&self) -> String {
crate::provider_catalog::runtime_provider_display_name(self.provider.name())
// `display_name()` resolves the active runtime profile (e.g. NVIDIA NIM)
// for the OpenRouter slot; for all other providers it equals `name()`.
self.provider.display_name()
}
pub fn provider_model(&self) -> String {
+13
View File
@@ -1040,6 +1040,19 @@ impl Provider for MultiProvider {
}
}
fn display_name(&self) -> String {
// The OpenRouter slot multiplexes the public aggregator and every
// direct OpenAI-compatible profile (NVIDIA NIM, DeepSeek, ...). Ask the
// active execution runtime for its own label so the UI reflects the
// profile selected at runtime rather than the fixed "OpenRouter" name.
if matches!(self.active_provider(), ActiveProvider::OpenRouter)
&& let Some(execution) = self.active_openrouter_execution_provider()
{
return execution.runtime_display_name();
}
self.name().to_string()
}
fn model(&self) -> String {
match self.active_provider() {
ActiveProvider::Claude => {
@@ -1046,6 +1046,45 @@ impl OpenRouterProvider {
self.supports_provider_features
}
/// Human-facing label for the runtime backing this provider instance.
///
/// Unlike the env-var based [`crate::provider_catalog::runtime_provider_display_name`],
/// this reads the instance's own `profile_id`/`api_base`, so it stays correct
/// after a runtime `/model` switch to a different OpenAI-compatible profile
/// (e.g. NVIDIA NIM) even though `name()` is fixed at `"openrouter"`.
pub(crate) fn runtime_display_name(&self) -> String {
// Direct OpenAI-compatible profile (NVIDIA NIM, DeepSeek, Z.AI, ...).
if let Some(profile_id) = self.profile_id.as_deref() {
if let Some(profile) = openai_compatible_profile_by_id(profile_id) {
return profile.display_name.to_string();
}
return profile_id.to_string();
}
// Non-aggregator endpoint without a known profile id: classify by base
// URL so custom OpenAI-compatible endpoints don't masquerade as the
// public OpenRouter aggregator.
if !self.supports_provider_features {
if let Some(profile_id) =
crate::provider_catalog::openai_compatible_profile_id_for_api_base(&self.api_base)
&& let Some(profile) = openai_compatible_profile_by_id(profile_id)
{
return profile.display_name.to_string();
}
if std::env::var("JCODE_RUNTIME_PROVIDER")
.ok()
.is_some_and(|value| value.trim().eq_ignore_ascii_case("azure-openai"))
{
return "Azure OpenAI".to_string();
}
if !self.api_base.contains("openrouter.ai") {
return "OpenAI-compatible".to_string();
}
}
"OpenRouter".to_string()
}
pub(crate) fn direct_openai_compatible_route_parts(&self) -> Option<(String, String, String)> {
if self.supports_provider_features {
return None;
@@ -743,6 +743,10 @@ impl Provider for OpenRouterProvider {
"openrouter"
}
fn display_name(&self) -> String {
self.runtime_display_name()
}
fn model(&self) -> String {
self.model
.try_read()
@@ -2193,3 +2193,71 @@ fn strict_openai_schema_endpoint_allows_other_providers() {
"https://api.openai.com/v1"
));
}
#[test]
fn runtime_display_name_tracks_active_openai_compatible_profile() {
// Regression for issue #329: switching to a direct OpenAI-compatible
// profile (NVIDIA NIM) at runtime must surface that profile's display
// name, not the fixed "OpenRouter" aggregator label. The machine-facing
// `name()` stays "openrouter" because billing/routing logic keys off it.
let _lock = ENV_LOCK.lock();
let temp = TempDir::new().expect("create temp home");
let jcode_home = temp.path().join("jcode-home");
let _jcode_home = EnvVarGuard::set("JCODE_HOME", &jcode_home);
let _home = EnvVarGuard::set("HOME", temp.path());
let _appdata = EnvVarGuard::set("APPDATA", temp.path().join("AppData").join("Roaming"));
let _env = isolate_openrouter_autodetect_env();
// Configure both the OpenRouter aggregator and NVIDIA NIM credentials so
// the slot can host either runtime. Set after the isolate guard, which
// clears every profile api-key env var.
let _or_key = EnvVarGuard::set("OPENROUTER_API_KEY", "or-test-key");
let _nim_key = EnvVarGuard::set("NVIDIA_API_KEY", "nim-test-key");
crate::config::invalidate_config_cache();
let provider =
crate::provider::MultiProvider::new_with_auth_status(crate::auth::AuthStatus::default());
// Switch to a NVIDIA NIM model via the profile-prefixed model request.
provider
.set_model("nvidia-nim:nvidia/llama-3.1-nemotron-ultra-253b-v1")
.expect("switch to nvidia-nim profile");
assert_eq!(
Provider::name(&provider),
"OpenRouter",
"machine-facing name must stay stable for billing/routing"
);
assert_eq!(
Provider::display_name(&provider),
"NVIDIA NIM",
"header/UI display name must reflect the active runtime profile"
);
// Switching back to the plain OpenRouter aggregator restores the label.
provider
.set_model("anthropic/claude-sonnet-4")
.expect("switch back to openrouter aggregator");
assert_eq!(Provider::display_name(&provider), "OpenRouter");
}
#[test]
fn runtime_display_name_for_profile_runtime_instance() {
// Direct unit coverage of the per-instance resolver used by
// `Provider::display_name`.
let _lock = ENV_LOCK.lock();
let temp = TempDir::new().expect("create temp home");
let jcode_home = temp.path().join("jcode-home");
let _jcode_home = EnvVarGuard::set("JCODE_HOME", &jcode_home);
let _home = EnvVarGuard::set("HOME", temp.path());
let _appdata = EnvVarGuard::set("APPDATA", temp.path().join("AppData").join("Roaming"));
let _env = isolate_openrouter_autodetect_env();
let _key = EnvVarGuard::set("NVIDIA_API_KEY", "nim-test-key");
let nim = OpenRouterProvider::new_openai_compatible_profile_runtime(
crate::provider_catalog::NVIDIA_NIM_PROFILE,
)
.expect("build nvidia-nim runtime");
assert_eq!(nim.runtime_display_name(), "NVIDIA NIM");
assert_eq!(Provider::name(&nim), "openrouter");
}
+18 -1
View File
@@ -74,8 +74,25 @@ pub trait Provider: Send + Sync {
}
/// Get the provider name.
///
/// This is the stable, machine-facing identifier (e.g. `"openrouter"`,
/// `"claude"`). Several surfaces key billing and routing decisions off this
/// value, so it must stay constant for a given provider class even when the
/// underlying runtime is a specific OpenAI-compatible profile. Use
/// [`Provider::display_name`] for anything shown to the user.
fn name(&self) -> &str;
/// Human-facing provider label for the *current runtime selection*.
///
/// Defaults to [`Provider::name`]. Provider orchestrators that multiplex
/// several backends behind one `name()` (notably the OpenRouter slot, which
/// also serves direct OpenAI-compatible profiles such as NVIDIA NIM or
/// DeepSeek) override this so the UI reflects the profile the user actually
/// selected at runtime instead of a fixed aggregator label.
fn display_name(&self) -> String {
self.name().to_string()
}
/// Get the model identifier being used.
fn model(&self) -> String {
"unknown".to_string()
@@ -823,7 +840,7 @@ impl ModelCatalogSnapshot {
pub fn from_provider(provider: &dyn Provider) -> Self {
Self::new(
Some(provider.name().to_string()),
Some(provider.display_name()),
Some(provider.model()),
provider.available_models_display(),
provider.model_routes(),
+5 -5
View File
@@ -459,9 +459,9 @@ impl crate::tui::TuiState for App {
if self.is_remote {
self.remote_header_provider_name().unwrap_or_default()
} else {
self.remote_provider_name.clone().unwrap_or_else(|| {
crate::provider_catalog::runtime_provider_display_name(self.provider.name())
})
self.remote_provider_name
.clone()
.unwrap_or_else(|| self.provider.display_name())
}
}
@@ -1285,9 +1285,9 @@ impl crate::tui::TuiState for App {
provider_name: if uses_remote_widget_metadata {
self.remote_provider_name
.clone()
.or_else(|| Some(self.provider.name().to_string()))
.or_else(|| Some(self.provider.display_name()))
} else {
Some(self.provider.name().to_string())
Some(self.provider.display_name())
},
auth_method,
upstream_provider: self.upstream_provider.clone(),
+38 -3
View File
@@ -45,9 +45,22 @@ pub(crate) fn capitalize(s: &str) -> String {
}
}
fn format_model_name(short: &str) -> String {
fn format_model_name(short: &str, provider_name: &str) -> String {
if short.contains('/') {
return format!("OpenRouter: {}", short);
// Slashed model ids (e.g. `nvidia/nemotron-...`) are served by the
// OpenRouter slot, which also fronts direct OpenAI-compatible profiles
// such as NVIDIA NIM or DeepSeek. Label the line with the active
// provider's display name instead of hard-coding "OpenRouter" so the
// header matches the profile the user actually selected.
let label = {
let trimmed = provider_name.trim();
if trimmed.is_empty() {
"OpenRouter".to_string()
} else {
trimmed.to_string()
}
};
return format!("{}: {}", label, short);
}
if short.contains("opus") {
if short.contains("4.5") {
@@ -389,7 +402,7 @@ pub(super) fn build_persistent_header(app: &dyn TuiState, width: u16) -> Vec<Lin
let short_model = shorten_model_name(&model);
let icon = connection_type_icon(app.connection_type().as_deref())
.unwrap_or_else(|| crate::id::session_icon(&session_name));
let nice_model = format_model_name(&short_model);
let nice_model = format_model_name(&short_model, &app.provider_name());
let build_info = binary_age().unwrap_or_else(|| "unknown".to_string());
let align = Alignment::Center;
let mut lines: Vec<Line> = Vec::new();
@@ -1028,4 +1041,26 @@ mod tests {
let line = build_auth_status_line(&AuthStatus::default(), 120);
assert!(line.spans.is_empty(), "line should be empty: {line:?}");
}
#[test]
fn format_model_name_labels_slashed_models_with_active_provider() {
// Regression for issue #329: a NVIDIA NIM model must be labeled with the
// active provider's display name, not the fixed "OpenRouter" aggregator.
assert_eq!(
format_model_name("nvidia/nemotron-3-super-120b-a12b", "NVIDIA NIM"),
"NVIDIA NIM: nvidia/nemotron-3-super-120b-a12b"
);
// The public aggregator still reads "OpenRouter".
assert_eq!(
format_model_name("anthropic/claude-sonnet-4", "OpenRouter"),
"OpenRouter: anthropic/claude-sonnet-4"
);
// Missing provider name falls back to "OpenRouter" rather than an empty label.
assert_eq!(
format_model_name("deepseek/deepseek-chat", ""),
"OpenRouter: deepseek/deepseek-chat"
);
// Non-slashed models are unaffected by the provider label.
assert_eq!(format_model_name("claude-opus-4-6", "OpenRouter"), "Claude Opus");
}
}