From 96b6ed4149f297b7b2069c65761ceda7e55fe368 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Mon, 5 Oct 2026 10:36:23 -0500 Subject: [PATCH] Fix Claude model limits and provider labels in model pickers --- .../ai/execution_profiles/model_menu_items.rs | 6 +- app/src/ai/llms.rs | 53 +++++++------ app/src/ai/llms_tests.rs | 24 +++++- app/src/terminal/profile_model_selector.rs | 31 +++----- .../view/ambient_agent/model_selector.rs | 3 +- .../data/anthropic_limits.json | 70 +++++++++++++++++ crates/galaxy_agent_rig/src/native.rs | 75 +++++++++++++++---- crates/galaxy_agent_rig/src/native_tests.rs | 21 ++++++ 8 files changed, 218 insertions(+), 65 deletions(-) create mode 100644 crates/galaxy_agent_rig/data/anthropic_limits.json diff --git a/app/src/ai/execution_profiles/model_menu_items.rs b/app/src/ai/execution_profiles/model_menu_items.rs index 7ce85e56..6af90d25 100644 --- a/app/src/ai/execution_profiles/model_menu_items.rs +++ b/app/src/ai/execution_profiles/model_menu_items.rs @@ -28,7 +28,7 @@ pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool { } all_models .iter() - .filter(|other| other.base_model_name() == llm.base_model_name() && other.id != llm.id) + .filter(|other| other.reasoning_group_key() == llm.reasoning_group_key() && other.id != llm.id) .any(|other| other.has_reasoning_level()) } @@ -77,9 +77,9 @@ fn make_item_fields( app: &AppContext, ) -> MenuItem { let label = if collapse_auto && is_auto(llm) { - "auto".to_string() + llm.label_with_provider("auto") } else if collapse_reasoning_variants && llm.has_reasoning_level() { - llm.base_model_name().to_string() + llm.label_with_provider(llm.base_model_name()) } else { llm.menu_display_name() }; diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 07e249cd..436a97b9 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -320,14 +320,24 @@ impl LLMInfo { if custom_model_routers::is_custom_router_id(self.id.as_str()) { return self.display_name.clone(); } - // Base label includes optional description in parentheses - match &self.description { - // This is a temporary implementation that won't scale well for longer - // descriptions. We should implement a better approach for displaying - // model descriptions, maybe through subtext. - Some(desc) => format!("{} ({})", self.display_name, desc), - None => self.display_name.clone(), + self.label_with_provider(&self.display_name) + } + + pub fn label_with_provider(&self, label: &str) -> String { + if custom_model_routers::is_custom_router_id(self.id.as_str()) { + return label.to_string(); } + match self.description.as_deref().filter(|name| !name.trim().is_empty()) { + Some(name) => format!("{label} ({name})"), + None => label.to_string(), + } + } + + /// Reasoning menus must never merge models belonging to different connections. + pub fn reasoning_group_key(&self) -> String { + let connection = self.id.as_str().rsplit_once("::connection::") + .map(|(_, connection)| connection).unwrap_or_default(); + format!("{}\u{1f}{}\u{1f}{:?}\u{1f}{connection}", self.base_model_name, self.description.as_deref().unwrap_or_default(), self.provider) } /// Returns the given model's base name. @@ -600,7 +610,7 @@ pub struct LLMPreferences { #[cfg(not(target_family = "wasm"))] fetched_openai_models: Vec, #[cfg(not(target_family = "wasm"))] - litellm_connections: Vec, + catalog_connections: Vec, #[cfg(not(target_family = "wasm"))] catalog_refresh_generations: HashMap, u64>, #[cfg(not(target_family = "wasm"))] @@ -740,7 +750,7 @@ impl LLMPreferences { ) { me.fetch_openai_models_from_endpoint(ctx); } - me.refresh_changed_litellm_connections(ctx); + me.refresh_changed_catalog_connections(ctx); // Safety: ensure the default model is still present in choices. // If all provider models were removed, the default_id would dangle. me.ensure_default_model_present(); @@ -780,7 +790,7 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] fetched_openai_models: Vec::new(), #[cfg(not(target_family = "wasm"))] - litellm_connections: Vec::new(), + catalog_connections: Vec::new(), #[cfg(not(target_family = "wasm"))] catalog_refresh_generations: HashMap::new(), #[cfg(not(target_family = "wasm"))] @@ -816,7 +826,7 @@ impl LLMPreferences { me.inject_openai_models(ctx); me.ensure_default_model_present(); me.fetch_openai_models_from_endpoint(ctx); - me.refresh_changed_litellm_connections(ctx); + me.refresh_changed_catalog_connections(ctx); me.refresh_chatgpt_subscription_models(ctx); me.schedule_chatgpt_subscription_model_refresh(ctx); } @@ -1440,11 +1450,6 @@ impl LLMPreferences { |reasoning| format!("{display_name} ({})", reasoning.name), ); let provider_name = provider.display_name(); - let display_name = if display_name.eq_ignore_ascii_case(&provider_name) { - display_name - } else { - format!("{display_name} · {provider_name}") - }; let agent_id = provider.agent_id.trim(); let agent_id = if agent_id.is_empty() { "codex" @@ -1475,7 +1480,7 @@ impl LLMPreferences { request_multiplier: 1, credit_multiplier: None, }, - description: Some("ACP".to_string()), + description: Some(provider_name), disable_reason: None, vision_supported: false, spec: None, @@ -1693,10 +1698,10 @@ impl LLMPreferences { } #[cfg(not(target_family = "wasm"))] - fn refresh_changed_litellm_connections(&mut self, ctx: &mut ModelContext) { + fn refresh_changed_catalog_connections(&mut self, ctx: &mut ModelContext) { let settings = AISettings::as_ref(ctx); if !settings.is_openai_provider_enabled() { - self.litellm_connections.clear(); + self.catalog_connections.clear(); return; } let connections: Vec<_> = settings @@ -1714,19 +1719,19 @@ impl LLMPreferences { .enumerate() .filter_map(|(index, provider)| { (provider.enabled - && provider.kind == OpenAIProviderKind::LiteLLM - && self.litellm_connections.get(index) != Some(provider)) + && matches!(provider.kind, OpenAIProviderKind::LiteLLM | OpenAIProviderKind::Anthropic) + && self.catalog_connections.get(index) != Some(provider)) .then_some(index) }) .collect(); - self.litellm_connections = connections; + self.catalog_connections = connections; for index in changed { self.fetch_openai_provider_models(index, ctx); } } /// Explicitly refreshes the models for one entry in the OpenAI-compatible - /// provider registry. LiteLLM also refreshes on startup and connector changes. + /// provider registry. LiteLLM and Anthropic also refresh on startup and connector changes. #[cfg(not(target_family = "wasm"))] pub fn fetch_openai_provider_models( &mut self, @@ -1989,7 +1994,7 @@ impl LLMPreferences { context_size: model.context_size.unwrap_or(128_000), max_input_tokens: model.context_size, default_context_size: None, - max_output_tokens: None, + max_output_tokens: model.max_output_tokens, provider: None, supports_system_messages: Some(true), capability_overrides: std::collections::HashMap::new(), diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 4d8c5c5c..e8546ff8 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -231,7 +231,7 @@ fn empty_preferences() -> LLMPreferences { custom_model_routers: Vec::new(), openai_provider_routing: HashMap::new(), fetched_openai_models: Vec::new(), - litellm_connections: Vec::new(), + catalog_connections: Vec::new(), catalog_refresh_generations: HashMap::new(), chatgpt_subscription_models_refresh_in_flight: false, bedrock_models_refresh_in_flight: false, @@ -447,7 +447,7 @@ fn acp_models_are_injected_only_while_acp_is_enabled() { custom_model_routers: Vec::new(), openai_provider_routing: HashMap::new(), fetched_openai_models: Vec::new(), - litellm_connections: Vec::new(), + catalog_connections: Vec::new(), catalog_refresh_generations: HashMap::new(), chatgpt_subscription_models_refresh_in_flight: false, bedrock_models_refresh_in_flight: false, @@ -1256,3 +1256,23 @@ fn bedrock_verification_survives_settings_reload_but_not_connection_changes() { app.read(|ctx| assert!(!preferences.bedrock_model_is_verified(&model.model_id, ctx))); }); } + +#[test] +fn model_selector_labels_and_reasoning_groups_include_configured_provider() { + let first: LLMInfo = serde_json::from_value(serde_json::json!({ + "id": "model-a", "display_name": "Claude (high)", "base_model_name": "Claude", + "description": "My Claude Subscription", "provider": "LiteLLM", + "usage_metadata": { "request_multiplier": 1 } + })).unwrap(); + assert_eq!(first.menu_display_name(), "Claude (high) (My Claude Subscription)"); + assert_eq!(first.label_with_provider(first.base_model_name()), "Claude (My Claude Subscription)"); + let mut other = first.clone(); + other.description = Some("My LiteLLM".to_string()); + assert_ne!(first.reasoning_group_key(), other.reasoning_group_key()); + other.description = first.description.clone(); + other.display_name = "Claude (low)".to_string(); + other.id = LLMId::from("model-b"); + assert_eq!(first.reasoning_group_key(), other.reasoning_group_key()); + other.id = LLMId::from("model-b::connection::second-connection"); + assert_ne!(first.reasoning_group_key(), other.reasoning_group_key()); +} diff --git a/app/src/terminal/profile_model_selector.rs b/app/src/terminal/profile_model_selector.rs index 75972e95..deb0ba0f 100644 --- a/app/src/terminal/profile_model_selector.rs +++ b/app/src/terminal/profile_model_selector.rs @@ -34,7 +34,6 @@ use crate::ai::blocklist::{ BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIInputEvent, BlocklistAIInputModel, }; use crate::ai::cloud_agent_settings::CloudAgentSettings; -use crate::ai::custom_model_routers::is_custom_router_id; use crate::ai::execution_profiles::model_menu_items::{ available_model_menu_items, has_reasoning_variants, is_auto, }; @@ -715,17 +714,7 @@ impl ProfileModelSelector { llm_preferences.get_active_base_model(ctx, Some(self.terminal_view_id)) }; - // Don't append description for custom model routers — it would add a - // redundant "(Custom auto · Local)" suffix to the button label. - if !is_custom_router_id(active_llm.id.as_str()) { - if let Some(description) = &active_llm.description { - format!("{} ({})", active_llm.display_name, description) - } else { - active_llm.display_name.clone() - } - } else { - active_llm.display_name.clone() - } + active_llm.menu_display_name() }; // Non-Oz runs lock silently: the harness owns model selection, and the @@ -1021,7 +1010,7 @@ impl ProfileModelSelector { .filter(|llm| custom_ids.contains(&llm.id)) .collect(); - // Group models by base_model_name to collapse reasoning variants. + // Group reasoning variants by model and configured provider. // Use "auto" as the key for all auto models so they collapse together. // Only group models that have reasoning levels - others stay separate. let mut groups: IndexMap> = IndexMap::new(); @@ -1029,7 +1018,7 @@ impl ProfileModelSelector { let key = if is_auto(llm) { "auto".to_string() } else if llm.has_reasoning_level() { - llm.base_model_name().to_string() + llm.reasoning_group_key() } else { llm.id.to_string() }; @@ -1057,7 +1046,7 @@ impl ProfileModelSelector { ProfileModelSelectorAction::SelectAutoModel } else if has_reasoning_variants(llm, &all_refs) { ProfileModelSelectorAction::SelectReasoningModel( - llm.base_model_name().to_string(), + llm.reasoning_group_key(), ) } else { ProfileModelSelectorAction::SelectModel(llm.id.clone()) @@ -1108,7 +1097,7 @@ impl ProfileModelSelector { ProfileModelSelectorAction::SelectAutoModel } else if has_reasoning_variants(llm, &all_refs) { ProfileModelSelectorAction::SelectReasoningModel( - llm.base_model_name().to_string(), + llm.reasoning_group_key(), ) } else { ProfileModelSelectorAction::SelectModel(llm.id.clone()) @@ -1197,7 +1186,7 @@ impl ProfileModelSelector { let items: Vec> = self .all_model_choices .iter() - .filter(|llm| llm.base_model_name() == base_name && llm.has_reasoning_level()) + .filter(|llm| llm.reasoning_group_key() == base_name && llm.has_reasoning_level()) .map(|llm| { let is_selected = llm.id == active_llm_id; let label = llm.reasoning_level().unwrap_or_default(); @@ -1287,10 +1276,10 @@ impl ProfileModelSelector { Some(ProfileModelSelectorAction::SelectAutoModel) ) } else if active_llm.has_reasoning_level() { - // For models with reasoning levels, match by base_model_name + // Match the selected model and its configured provider. matches!( item.item_on_select_action(), - Some(ProfileModelSelectorAction::SelectReasoningModel(name)) if *name == active_llm.base_model_name() + Some(ProfileModelSelectorAction::SelectReasoningModel(name)) if *name == active_llm.reasoning_group_key() ) } else { let item_model_id = item @@ -1349,7 +1338,7 @@ impl ProfileModelSelector { self.all_model_choices .iter() .find(|llm| { - llm.base_model_name() == base_name && llm.has_reasoning_level() + llm.reasoning_group_key() == *base_name && llm.has_reasoning_level() }) .cloned() } @@ -1405,7 +1394,7 @@ impl ProfileModelSelector { self.set_sidecar_hovered_info(auto_index, ctx); } else if self.has_multiple_reasoning_variants(info) { // If hovering a model with multiple reasoning variants, refresh reasoning menu - self.refresh_model_spec_sidecar_for_model(info.base_model_name(), ctx); + self.refresh_model_spec_sidecar_for_model(&info.reasoning_group_key(), ctx); } } } diff --git a/app/src/terminal/view/ambient_agent/model_selector.rs b/app/src/terminal/view/ambient_agent/model_selector.rs index d6863dc9..69787be5 100644 --- a/app/src/terminal/view/ambient_agent/model_selector.rs +++ b/app/src/terminal/view/ambient_agent/model_selector.rs @@ -402,8 +402,7 @@ impl ModelSelector { .unwrap_or_else(|| "default".to_string()), _ => LLMPreferences::as_ref(ctx) .get_active_base_model(ctx, Some(self.terminal_view_id)) - .display_name - .clone(), + .menu_display_name(), }; self.button.update(ctx, |button, ctx| { button.set_label(active_label, ctx); diff --git a/crates/galaxy_agent_rig/data/anthropic_limits.json b/crates/galaxy_agent_rig/data/anthropic_limits.json new file mode 100644 index 00000000..866f7f77 --- /dev/null +++ b/crates/galaxy_agent_rig/data/anthropic_limits.json @@ -0,0 +1,70 @@ +{ + "source": "https://models.dev/api.json", + "retrieved": "2026-10-04", + "models": { + "claude-fable-5": { + "context": 1000000, + "output": 128000 + }, + "claude-fable-5-1": { + "context": 1000000, + "output": 128000 + }, + "claude-haiku-4-5": { + "context": 200000, + "output": 64000 + }, + "claude-haiku-4-5-20251001": { + "context": 200000, + "output": 64000 + }, + "claude-opus-4-5": { + "context": 200000, + "output": 64000 + }, + "claude-opus-4-5-20251101": { + "context": 200000, + "output": 64000 + }, + "claude-opus-4-6": { + "context": 1000000, + "output": 128000 + }, + "claude-opus-4-7": { + "context": 1000000, + "output": 128000 + }, + "claude-opus-4-8": { + "context": 1000000, + "output": 128000 + }, + "claude-opus-5": { + "context": 1000000, + "output": 128000 + }, + "claude-opus-5-5": { + "context": 1000000, + "output": 128000 + }, + "claude-sonnet-4-5": { + "context": 1000000, + "output": 64000 + }, + "claude-sonnet-4-5-20250929": { + "context": 1000000, + "output": 64000 + }, + "claude-sonnet-4-6": { + "context": 1000000, + "output": 128000 + }, + "claude-sonnet-5": { + "context": 1000000, + "output": 128000 + }, + "claude-sonnet-5-5": { + "context": 1000000, + "output": 128000 + } + } +} diff --git a/crates/galaxy_agent_rig/src/native.rs b/crates/galaxy_agent_rig/src/native.rs index 8450fa17..767448ee 100644 --- a/crates/galaxy_agent_rig/src/native.rs +++ b/crates/galaxy_agent_rig/src/native.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::collections::HashSet; +use std::sync::{Arc, OnceLock}; use async_trait::async_trait; use futures::StreamExt; @@ -9,6 +10,7 @@ use galaxy_agent_core::{ }; use rig_core::client::{CompletionClient, ModelListingClient}; use rig_core::providers::gemini; +use rig_core::http_client::{self, HttpClientExt}; use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names}; use crate::request::build_completion_request; @@ -19,22 +21,67 @@ pub struct RigModelInfo { pub id: String, pub display_name: String, pub context_size: Option, + pub max_output_tokens: Option, } pub async fn discover_anthropic_models(api_key: &str) -> Result, String> { let client = anthropic_client(api_key)?; - let models = client - .list_models() - .await - .map_err(|error| error.to_string())?; - Ok(models - .into_iter() - .map(|model| RigModelInfo { - display_name: model.display_name().to_string(), - id: model.id, - context_size: model.context_length, - }) - .collect()) + let mut models = Vec::new(); + let mut cursors = HashSet::new(); + let mut path = "/v1/models?limit=1000".to_string(); + loop { + let request = client.get(&path).and_then(|request| Ok(request.body(http_client::NoBody)?)) + .map_err(|_| "Could not build the Anthropic models request.".to_string())?; + let response = client.send::<_, Vec>(request).await + .map_err(|_| "Could not retrieve Anthropic models.".to_string())?; + if !response.status().is_success() { + return Err(format!("Anthropic model discovery returned HTTP {}.", response.status())); + } + let body = response.into_body().await + .map_err(|_| "Could not read the Anthropic models response.".to_string())?; + let page: serde_json::Value = serde_json::from_slice(&body) + .map_err(|_| "Invalid Anthropic models response.".to_string())?; + let entries = page["data"].as_array() + .ok_or_else(|| "Anthropic models response is missing its model list.".to_string())?; + for entry in entries { + models.push(anthropic_model_info(entry)?); + } + match page["has_more"].as_bool() { + Some(false) => return Ok(models), + Some(true) => {}, + None => return Err("Anthropic models response is missing pagination state.".to_string()), + } + let cursor = page["last_id"].as_str().filter(|cursor| { + !cursor.is_empty() && cursor.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }).ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?; + if !cursors.insert(cursor.to_string()) { + return Err("Anthropic model pagination did not advance.".to_string()); + } + path = format!("/v1/models?limit=1000&after_id={cursor}"); + } +} + +fn positive_token_limit(value: &serde_json::Value) -> Option { + value.as_u64().and_then(|tokens| u32::try_from(tokens).ok()).filter(|tokens| *tokens > 0) +} + +fn anthropic_model_info(entry: &serde_json::Value) -> Result { + let id = entry["id"].as_str().filter(|id| !id.is_empty()) + .ok_or_else(|| "Anthropic returned a model without an ID.".to_string())?; + // Read live limits first. Rig's generic model lister drops these fields. + // The models.dev snapshot is metadata only, never an availability allowlist. + static LIMITS: OnceLock = OnceLock::new(); + let limits = LIMITS.get_or_init(|| serde_json::from_str(include_str!("../data/anthropic_limits.json")) + .expect("bundled Anthropic limits must be valid JSON")); + let fallback = &limits["models"][id]; + Ok(RigModelInfo { + id: id.to_string(), + display_name: entry["display_name"].as_str().unwrap_or(id).to_string(), + context_size: positive_token_limit(&entry["max_input_tokens"]) + .or_else(|| positive_token_limit(&fallback["context"])), + max_output_tokens: positive_token_limit(&entry["max_tokens"]) + .or_else(|| positive_token_limit(&fallback["output"])), + }) } pub async fn discover_gemini_models(api_key: &str) -> Result, String> { @@ -50,6 +97,7 @@ pub async fn discover_gemini_models(api_key: &str) -> Result, display_name: model.display_name().to_string(), id: model.id, context_size: model.context_length, + max_output_tokens: None, }) .collect()) } @@ -76,6 +124,7 @@ pub fn vertex_ai_model_catalog() -> Vec { id: id.to_string(), display_name: display_name.to_string(), context_size: None, + max_output_tokens: None, }) .collect() } diff --git a/crates/galaxy_agent_rig/src/native_tests.rs b/crates/galaxy_agent_rig/src/native_tests.rs index 9e497680..742f1fde 100644 --- a/crates/galaxy_agent_rig/src/native_tests.rs +++ b/crates/galaxy_agent_rig/src/native_tests.rs @@ -7,3 +7,24 @@ fn native_descriptors_are_provider_specific() { assert_eq!(descriptor.id, "rig-anthropic:claude-sonnet"); assert_eq!(descriptor.display_name, "Rig / anthropic / claude-sonnet"); } + +#[test] +fn anthropic_discovery_prefers_live_limits_over_catalog_defaults() { + let model = super::anthropic_model_info(&serde_json::json!({ + "id": "claude-opus-4-6", "display_name": "Opus", "max_input_tokens": 500000, "max_tokens": 32000 + })).unwrap(); + assert_eq!(model.context_size, Some(500000)); + assert_eq!(model.max_output_tokens, Some(32000)); +} + +#[test] +fn anthropic_discovery_uses_exact_catalog_limits_when_api_omits_them() { + let model = super::anthropic_model_info(&serde_json::json!({ + "id": "claude-opus-4-6", "max_input_tokens": null, "max_tokens": null + })).unwrap(); + assert_eq!(model.context_size, Some(1000000)); + assert_eq!(model.max_output_tokens, Some(128000)); + let unknown = super::anthropic_model_info(&serde_json::json!({"id": "claude-unknown"})).unwrap(); + assert_eq!(unknown.context_size, None); + assert_eq!(unknown.max_output_tokens, None); +}