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:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user