3187 lines
122 KiB
Rust
3187 lines
122 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use ai::api_keys::ApiKeyManager;
|
|
pub use ai::LLMId;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use galaxy_agent_rig::{
|
|
discover_anthropic_models, discover_gemini_models, validate_vertex_ai_credentials,
|
|
vertex_ai_model_catalog, RigModelInfo,
|
|
};
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_core::ui::icons::Icon;
|
|
use galaxy_core::user_preferences::GetUserPreferences;
|
|
use galaxyui::r#async::Timer;
|
|
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
|
|
use parking_lot::FairMutex;
|
|
use serde::{de, Deserialize, Serialize};
|
|
use settings::Setting;
|
|
use warp_multi_agent_api as api;
|
|
|
|
use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
|
|
use super::execution_profiles::profiles::AIExecutionProfilesModel;
|
|
use crate::ai::acp::{acp_launch_fingerprint, acp_provider_selection_identity};
|
|
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
|
use crate::auth::AuthStateProvider;
|
|
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::persistence::model::{AcpConversationData, AgentBackend};
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::settings::{
|
|
AcpConfigValueSettings, AcpProviderConfig, BedrockModelConfig, OpenAIModelConfig,
|
|
OpenAIProviderConfig, OpenAIProviderKind,
|
|
};
|
|
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
|
|
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
|
use crate::{report_error, AISettings};
|
|
|
|
/// Checks if a user's' API key is being used for the given provider.
|
|
/// AWS Bedrock is the only supported provider in Galaxy; there is no
|
|
/// user-pasted BYO key path for other providers.
|
|
pub fn is_using_api_key_for_provider(_provider: &LLMProvider, _app: &AppContext) -> bool {
|
|
false
|
|
}
|
|
|
|
pub fn should_show_bedrock_icon_for_model(llm: &LLMInfo, app: &AppContext) -> bool {
|
|
UserWorkspaces::as_ref(app).is_bedrock_enabled(app)
|
|
&& llm
|
|
.host_configs
|
|
.get(&LLMModelHost::AwsBedrock)
|
|
.is_some_and(|config| config.enabled)
|
|
}
|
|
|
|
/// Key for cached LLM metadata in user preferences.
|
|
///
|
|
/// Note: this key used to store a single [`AvailableLLMs`]
|
|
/// but was migrated to store a full [`ModelsByFeature`].
|
|
pub const MODELS_BY_FEATURE_CACHE_KEY: &str = "AvailableLLMs";
|
|
const CUSTOM_ENDPOINT_USAGE_FALLBACK_LABEL: &str = "Custom endpoint";
|
|
const CHATGPT_CODEX_MODELS_URL: &str = "https://chatgpt.com/backend-api/codex/models";
|
|
const CODEX_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
|
|
const CHATGPT_SUBSCRIPTION_MODELS_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24);
|
|
const DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE: u32 = 200_000;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct LLMUsageMetadata {
|
|
pub request_multiplier: usize,
|
|
pub credit_multiplier: Option<f32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum DisableReason {
|
|
AdminDisabled,
|
|
OutOfRequests,
|
|
ProviderOutage,
|
|
RequiresUpgrade,
|
|
Unavailable,
|
|
}
|
|
|
|
impl DisableReason {
|
|
/// Returns a user-facing tooltip explaining why the model is disabled.
|
|
pub fn tooltip_text(&self) -> &'static str {
|
|
match self {
|
|
DisableReason::AdminDisabled => "This model has been disabled by your team admin.",
|
|
DisableReason::OutOfRequests => "Please upgrade your plan to make more requests.",
|
|
DisableReason::ProviderOutage => {
|
|
"This model is temporarily unavailable due to a provider outage."
|
|
}
|
|
DisableReason::RequiresUpgrade => "Please upgrade your plan to access this model.",
|
|
DisableReason::Unavailable => "This model is unavailable.",
|
|
}
|
|
}
|
|
|
|
/// Returns `true` when this disable reason means the user cannot use the model
|
|
/// and we should clear their stored preference.
|
|
///
|
|
/// `RequiresUpgrade` is BYOK-aware: if the user has a BYO API key for the
|
|
/// model's provider (`has_byok_key = true`), the server will still accept
|
|
/// the request, so we keep the selection.
|
|
///
|
|
/// `OutOfRequests` and `ProviderOutage` are transient and expected to
|
|
/// resolve without user action, so we preserve the selection.
|
|
fn should_clear_preference(&self, has_byok_key: bool) -> bool {
|
|
match self {
|
|
DisableReason::AdminDisabled | DisableReason::Unavailable => true,
|
|
DisableReason::RequiresUpgrade => !has_byok_key,
|
|
DisableReason::OutOfRequests | DisableReason::ProviderOutage => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct LLMSpec {
|
|
pub cost: f32,
|
|
pub quality: f32,
|
|
pub speed: f32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub enum LLMProvider {
|
|
OpenAI,
|
|
Anthropic,
|
|
Google,
|
|
Xai,
|
|
Bedrock,
|
|
/// Models served through an OpenAI-compatible proxy (e.g. LiteLLM).
|
|
LiteLLM,
|
|
/// Models selected and executed by an Agent Client Protocol runtime.
|
|
Acp,
|
|
Unknown,
|
|
}
|
|
|
|
impl LLMProvider {
|
|
/// Maps an LLMProvider to its corresponding icon.
|
|
pub fn icon(&self) -> Option<Icon> {
|
|
match self {
|
|
LLMProvider::OpenAI => Some(Icon::OpenAILogo),
|
|
LLMProvider::Anthropic => Some(Icon::ClaudeLogo),
|
|
LLMProvider::Google => Some(Icon::GeminiLogo),
|
|
LLMProvider::Bedrock => Some(Icon::BedrockLogo),
|
|
LLMProvider::LiteLLM => Some(Icon::OpenAILogo),
|
|
LLMProvider::Acp => Some(Icon::Terminal),
|
|
LLMProvider::Xai => None,
|
|
LLMProvider::Unknown => None,
|
|
}
|
|
}
|
|
|
|
/// Human-readable provider name for user-facing copy.
|
|
pub fn display_name(&self) -> &'static str {
|
|
match self {
|
|
LLMProvider::OpenAI => "OpenAI",
|
|
LLMProvider::Anthropic => "Anthropic",
|
|
LLMProvider::Google => "Google",
|
|
LLMProvider::Xai => "xAI",
|
|
LLMProvider::Bedrock => "AWS Bedrock",
|
|
LLMProvider::LiteLLM => "LiteLLM",
|
|
LLMProvider::Acp => "ACP",
|
|
LLMProvider::Unknown => "this provider",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The host where an LLM can be routed to.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum LLMModelHost {
|
|
DirectApi,
|
|
AwsBedrock,
|
|
CustomEndpoint,
|
|
GeminiEnterprise,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
/// Configuration for routing an LLM to a specific host.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct RoutingHostConfig {
|
|
pub enabled: bool,
|
|
pub model_routing_host: LLMModelHost,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct LLMContextWindow {
|
|
#[serde(default)]
|
|
pub is_configurable: bool,
|
|
#[serde(default)]
|
|
pub min: u32,
|
|
#[serde(default)]
|
|
pub max: u32,
|
|
#[serde(default)]
|
|
pub default_max: u32,
|
|
}
|
|
|
|
/// Metadata about an LLM.
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
pub struct LLMInfo {
|
|
pub display_name: String,
|
|
pub base_model_name: String,
|
|
pub id: LLMId,
|
|
pub reasoning_level: Option<String>,
|
|
pub usage_metadata: LLMUsageMetadata,
|
|
pub description: Option<String>,
|
|
pub disable_reason: Option<DisableReason>,
|
|
pub vision_supported: bool,
|
|
pub spec: Option<LLMSpec>,
|
|
pub provider: LLMProvider,
|
|
pub host_configs: HashMap<LLMModelHost, RoutingHostConfig>,
|
|
pub discount_percentage: Option<f32>,
|
|
pub context_window: LLMContextWindow,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for LLMInfo {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: de::Deserializer<'de>,
|
|
{
|
|
/// Helper type that can deserialize host_configs from either:
|
|
/// - A Vec (wire format from server)
|
|
/// - A HashMap (cached format after commit a8a82421c3)
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum HostConfigsWire {
|
|
Vec(Vec<RoutingHostConfig>),
|
|
Map(HashMap<LLMModelHost, RoutingHostConfig>),
|
|
}
|
|
|
|
impl Default for HostConfigsWire {
|
|
fn default() -> Self {
|
|
HostConfigsWire::Vec(Vec::new())
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct WireLLMInfo {
|
|
display_name: String,
|
|
#[serde(default)]
|
|
base_model_name: Option<String>,
|
|
id: LLMId,
|
|
#[serde(default)]
|
|
reasoning_level: Option<String>,
|
|
usage_metadata: LLMUsageMetadata,
|
|
#[serde(default)]
|
|
description: Option<String>,
|
|
#[serde(default)]
|
|
disable_reason: Option<DisableReason>,
|
|
#[serde(default)]
|
|
vision_supported: bool,
|
|
#[serde(default)]
|
|
spec: Option<LLMSpec>,
|
|
provider: LLMProvider,
|
|
#[serde(default)]
|
|
host_configs: HostConfigsWire,
|
|
#[serde(default)]
|
|
discount_percentage: Option<f32>,
|
|
#[serde(default)]
|
|
context_window: LLMContextWindow,
|
|
}
|
|
|
|
let wire = WireLLMInfo::deserialize(deserializer)?;
|
|
let host_configs = match wire.host_configs {
|
|
HostConfigsWire::Map(map) => map,
|
|
HostConfigsWire::Vec(vec) => {
|
|
let mut map = HashMap::new();
|
|
for config in vec {
|
|
let host = config.model_routing_host.clone();
|
|
if map.insert(host.clone(), config).is_some() {
|
|
log::warn!(
|
|
"Duplicate LLMModelHost entry for {:?}, using latest value",
|
|
host
|
|
);
|
|
}
|
|
}
|
|
map
|
|
}
|
|
};
|
|
Ok(Self {
|
|
base_model_name: wire
|
|
.base_model_name
|
|
.unwrap_or_else(|| wire.display_name.clone()),
|
|
vision_supported: wire.vision_supported,
|
|
provider: wire.provider,
|
|
display_name: wire.display_name,
|
|
id: wire.id,
|
|
reasoning_level: wire.reasoning_level,
|
|
usage_metadata: wire.usage_metadata,
|
|
description: wire.description,
|
|
disable_reason: wire.disable_reason,
|
|
spec: wire.spec,
|
|
host_configs,
|
|
discount_percentage: wire.discount_percentage,
|
|
context_window: wire.context_window,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Deduplicates a list of LLMInfo choices by base_model_name and returns an alphabetically sorted
|
|
/// list of display names.
|
|
pub fn dedupe_model_display_names<'a>(
|
|
choices: impl IntoIterator<Item = &'a LLMInfo>,
|
|
) -> Vec<String> {
|
|
let names: HashSet<String> = choices
|
|
.into_iter()
|
|
.map(|choice| choice.base_model_name.clone())
|
|
.collect();
|
|
let mut sorted: Vec<String> = names.into_iter().collect();
|
|
sorted.sort();
|
|
sorted
|
|
}
|
|
|
|
impl LLMInfo {
|
|
/// Returns the display name for the LLM, to be used in the LLM selector menu.
|
|
pub fn menu_display_name(&self) -> String {
|
|
// Custom model routers carry a routing/source description that belongs in
|
|
// the sidecar detail panel, not inline in the chip label. Appending it
|
|
// here would produce a redundant "(Routes by … · …)" suffix.
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// Returns the given model's base name.
|
|
/// For non-reasoning models, this is the same as the display name.
|
|
/// E.g. gpt-5.1 (low reasoning) -> gpt-5.1
|
|
pub fn base_model_name(&self) -> &str {
|
|
&self.base_model_name
|
|
}
|
|
|
|
/// Returns true if this model has a reasoning level configured.
|
|
pub fn has_reasoning_level(&self) -> bool {
|
|
self.reasoning_level.is_some()
|
|
}
|
|
|
|
/// Returns the reasoning level label formatted for display.
|
|
pub fn reasoning_level(&self) -> Option<String> {
|
|
self.reasoning_level.clone()
|
|
}
|
|
|
|
#[cfg(feature = "integration_tests")]
|
|
fn new_for_test(llm_name: &str) -> Self {
|
|
Self {
|
|
display_name: llm_name.to_string(),
|
|
base_model_name: llm_name.to_string(),
|
|
id: llm_name.into(),
|
|
reasoning_level: None,
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: None,
|
|
disable_reason: None,
|
|
vision_supported: false, // Default to false for tests
|
|
spec: None,
|
|
provider: LLMProvider::Unknown,
|
|
host_configs: HashMap::new(),
|
|
discount_percentage: None,
|
|
context_window: LLMContextWindow::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The set of LLMs available for a feature.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct AvailableLLMs {
|
|
/// The Warp "default" LLM.
|
|
default_id: LLMId,
|
|
choices: Vec<LLMInfo>,
|
|
|
|
#[serde(default)]
|
|
preferred_codex_model_id: Option<LLMId>,
|
|
}
|
|
|
|
impl AvailableLLMs {
|
|
/// Constructs an `AvailableLLMs` instance from the given default ID and choices.
|
|
///
|
|
/// If choices is empty, returns an error.
|
|
///
|
|
/// If default_id is not a valid ID present in `choices`, takes the first choice in `choices
|
|
/// and uses it as the default.
|
|
pub fn new<T: Into<LLMInfo>>(
|
|
mut default_id: LLMId,
|
|
choices: impl IntoIterator<Item = T>,
|
|
preferred_codex_model_id: Option<LLMId>,
|
|
) -> Result<Self, anyhow::Error> {
|
|
let choices: Vec<LLMInfo> = choices.into_iter().map(Into::into).collect();
|
|
if choices.is_empty() {
|
|
return Err(anyhow::anyhow!(
|
|
"Tried to create AvailableLLMs with empty`choices`.",
|
|
));
|
|
} else if !choices.iter().any(|info| info.id == default_id) {
|
|
let fallback_default = choices
|
|
.first()
|
|
.ok_or_else(|| anyhow::anyhow!("Choices should not be empty"))?;
|
|
log::error!(
|
|
"Default LLM ID {} not present in choices, falling back to first choice {}",
|
|
default_id,
|
|
fallback_default.display_name
|
|
);
|
|
default_id = fallback_default.id.clone();
|
|
}
|
|
|
|
Ok(Self {
|
|
default_id,
|
|
choices: choices.into_iter().collect(),
|
|
preferred_codex_model_id,
|
|
})
|
|
}
|
|
|
|
fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
|
|
self.choices.iter().find(|info| info.id == *id)
|
|
}
|
|
|
|
/// Returns the info for the given id only if the model is usable (present
|
|
/// and not effectively disabled for the current user).
|
|
fn usable_info_for_id(&self, id: &LLMId, app: &AppContext) -> Option<&LLMInfo> {
|
|
self.info_for_id(id).filter(|info| {
|
|
let has_byok_key = is_using_api_key_for_provider(&info.provider, app);
|
|
info.disable_reason
|
|
.as_ref()
|
|
.is_none_or(|reason| !reason.should_clear_preference(has_byok_key))
|
|
})
|
|
}
|
|
|
|
fn default_llm_info(&self) -> &LLMInfo {
|
|
static NO_PROVIDER_FALLBACK: std::sync::LazyLock<LLMInfo> =
|
|
std::sync::LazyLock::new(|| LLMInfo {
|
|
display_name: "No models configured".to_owned(),
|
|
base_model_name: "No models configured".to_owned(),
|
|
id: "none".to_owned().into(),
|
|
reasoning_level: None,
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: Some("Enable an AI runtime in Models settings".to_string()),
|
|
disable_reason: Some(DisableReason::Unavailable),
|
|
vision_supported: false,
|
|
spec: None,
|
|
provider: LLMProvider::Unknown,
|
|
host_configs: HashMap::new(),
|
|
discount_percentage: None,
|
|
context_window: LLMContextWindow::default(),
|
|
});
|
|
|
|
self.info_for_id(&self.default_id)
|
|
.or_else(|| self.choices.first())
|
|
.unwrap_or(&NO_PROVIDER_FALLBACK)
|
|
}
|
|
|
|
#[cfg(feature = "integration_tests")]
|
|
pub fn new_for_test(llm_name: &str) -> Self {
|
|
Self {
|
|
default_id: llm_name.into(),
|
|
choices: vec![LLMInfo::new_for_test(llm_name)],
|
|
preferred_codex_model_id: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The set of models available to the client, grouped by the feature they support.
|
|
/// This is fetched from the server and cached.
|
|
///
|
|
/// Currently, if a model is available for multiple features,
|
|
/// it will appear denormalized in each of the feature's
|
|
/// [`AvailableLLMs`]. While this denormalization doesn't add much value today,
|
|
/// it eventually lets us add feature-specific properties to an [`LLMInfo`].
|
|
///
|
|
/// NOTE: This used to include a `planning` field; this was removed after planning via subagent was
|
|
/// deprecated.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ModelsByFeature {
|
|
pub agent_mode: AvailableLLMs,
|
|
pub coding: AvailableLLMs,
|
|
/// The set of LLMs available for CLI agent.
|
|
/// This field is optional during deserialization, as older clients might not have this field.
|
|
#[serde(default)]
|
|
pub cli_agent: Option<AvailableLLMs>,
|
|
/// The set of LLMs available for computer use agent.
|
|
/// This field is optional during deserialization, as older clients might not have this field.
|
|
#[serde(default)]
|
|
pub computer_use: Option<AvailableLLMs>,
|
|
}
|
|
|
|
impl ModelsByFeature {
|
|
/// Returns the info about the LLM identified by `id`, if we have it.
|
|
///
|
|
/// For models that are available across multiple features,
|
|
/// any one of the metadata will be returned.
|
|
fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
|
|
self.agent_mode.info_for_id(id)
|
|
}
|
|
}
|
|
|
|
/// Returns the default AvailableLLMs for computer use.
|
|
/// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`.
|
|
fn default_computer_use_llms() -> AvailableLLMs {
|
|
AvailableLLMs {
|
|
default_id: "computer-use-agent-auto".to_owned().into(),
|
|
choices: vec![LLMInfo {
|
|
display_name: "auto".to_owned(),
|
|
base_model_name: "auto".to_owned(),
|
|
id: "computer-use-agent-auto".to_owned().into(),
|
|
reasoning_level: None,
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: None,
|
|
disable_reason: None,
|
|
vision_supported: false,
|
|
spec: None,
|
|
provider: LLMProvider::Unknown,
|
|
host_configs: HashMap::new(),
|
|
discount_percentage: None,
|
|
context_window: LLMContextWindow::default(),
|
|
}],
|
|
preferred_codex_model_id: None,
|
|
}
|
|
}
|
|
|
|
impl Default for ModelsByFeature {
|
|
/// Returns a minimal placeholder. The real model list is populated exclusively
|
|
/// by `inject_bedrock_models` and `inject_openai_models` based on local settings.
|
|
/// The placeholder entry uses `LLMProvider::Unknown` so it gets stripped by
|
|
/// `inject_bedrock_models` once real models are loaded.
|
|
fn default() -> Self {
|
|
let placeholder = || AvailableLLMs {
|
|
default_id: "placeholder".to_owned().into(),
|
|
choices: vec![LLMInfo {
|
|
display_name: "No models configured".to_owned(),
|
|
base_model_name: "No models configured".to_owned(),
|
|
id: "placeholder".to_owned().into(),
|
|
reasoning_level: None,
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: Some("Enable an AI runtime in Models settings".to_string()),
|
|
disable_reason: None,
|
|
vision_supported: false,
|
|
spec: None,
|
|
provider: LLMProvider::Unknown,
|
|
host_configs: HashMap::new(),
|
|
discount_percentage: None,
|
|
context_window: LLMContextWindow::default(),
|
|
}],
|
|
preferred_codex_model_id: None,
|
|
};
|
|
Self {
|
|
agent_mode: placeholder(),
|
|
coding: placeholder(),
|
|
cli_agent: Some(placeholder()),
|
|
computer_use: Some(default_computer_use_llms()),
|
|
}
|
|
}
|
|
}
|
|
|
|
enum UpdatePopupVisibilityState {
|
|
WaitingToBeShown,
|
|
Visible(EntityId),
|
|
Hidden,
|
|
}
|
|
|
|
struct AvailableLLMsUpdate {
|
|
new_choices: Vec<LLMInfo>,
|
|
popup_visibility_state: Arc<FairMutex<UpdatePopupVisibilityState>>,
|
|
}
|
|
|
|
/// Singleton model holding user/workspace LLM preferences, including the set of LLMs available for
|
|
/// use as well as the user's preferred LLM for Agent Mode.
|
|
pub struct LLMPreferences {
|
|
models_by_feature: ModelsByFeature,
|
|
last_update: Option<AvailableLLMsUpdate>,
|
|
base_llm_for_terminal_view: HashMap<EntityId, LLMId>,
|
|
/// Synthetic `LLMInfo` entries built from the user's `ApiKeyManager.custom_endpoints` so
|
|
/// custom models surface in the model picker and resolve through `info_for_id` lookups.
|
|
/// Each entry's `id` is the model's `config_key` (UUID), which is also what flows out to
|
|
/// `Request.Settings.custom_model_providers.providers[*].models[*].config_key`.
|
|
///
|
|
/// Rebuilt from scratch on every `ApiKeyManagerEvent::KeysUpdated`, so adds, edits, and
|
|
/// removals all immediately propagate to the picker.
|
|
custom_llms: Vec<LLMInfo>,
|
|
/// All custom model routers, including both local and cloud-backed.
|
|
custom_model_routers: Vec<CustomModelRouter>,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
openai_provider_routing: HashMap<String, super::openai::client::OpenAIClientConfig>,
|
|
/// Models fetched from the OpenAI-compatible /models endpoint at runtime.
|
|
/// Used as a short-lived fallback while the fetched list is persisted to settings.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fetched_openai_models: Vec<OpenAIModelConfig>,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
chatgpt_subscription_models_refresh_in_flight: bool,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_selections: HashMap<LLMId, AcpModelSelection>,
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct AcpModelSelection {
|
|
pub(crate) provider_id: String,
|
|
pub(crate) agent_id: String,
|
|
pub(crate) launch_fingerprint: String,
|
|
pub(crate) config_values: BTreeMap<String, serde_json::Value>,
|
|
}
|
|
|
|
impl LLMPreferences {
|
|
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
|
let models_by_feature = get_cached_models(ctx).unwrap_or_default();
|
|
|
|
ctx.subscribe_to_model(&NetworkStatus::handle(ctx), |me, _, event, ctx| {
|
|
if let NetworkStatusEvent::NetworkStatusChanged {
|
|
new_status: NetworkStatusKind::Online,
|
|
} = event
|
|
{
|
|
me.refresh_authed_models(ctx);
|
|
me.refresh_chatgpt_subscription_models(ctx);
|
|
}
|
|
});
|
|
|
|
// TODO: Instead of querying this ad-hoc upon a successful log in, we should add the
|
|
// available LLMs query to the general workspace metadata query which is polled
|
|
// and hooked up to workspace changes. For that to work, each user would need to
|
|
// have a personal workspace. This is a stop-gap.
|
|
ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| {
|
|
if let AuthManagerEvent::AuthComplete = event {
|
|
me.refresh_authed_models(ctx);
|
|
me.refresh_chatgpt_subscription_models(ctx);
|
|
}
|
|
});
|
|
|
|
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| {
|
|
if let UserWorkspacesEvent::TeamsChanged = event {
|
|
me.sanitize_disabled_custom_model_preferences(ctx);
|
|
me.refresh_authed_models(ctx);
|
|
}
|
|
});
|
|
|
|
// Re-reconcile disabled model preferences when BYOK keys change, since
|
|
// RequiresUpgrade models may become usable or unusable.
|
|
// Also rebuild `custom_llms` so adds/edits/removals to the user's custom endpoints
|
|
// immediately flow through to the model picker.
|
|
ctx.subscribe_to_model(&ApiKeyManager::handle(ctx), |me, _, _event, ctx| {
|
|
me.reconcile_disabled_model_preferences(ctx);
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
});
|
|
|
|
// Rebuild custom model routers whenever the local `model_configs/` directory
|
|
// changes, and reconcile any now-stale local selection.
|
|
if FeatureFlag::CustomModelRouters.is_enabled() {
|
|
ctx.subscribe_to_model(&WarpConfig::handle(ctx), |me, _, event, ctx| {
|
|
if matches!(event, WarpConfigUpdateEvent::ModelConfigs) {
|
|
me.rebuild_custom_model_routers(ctx);
|
|
me.reconcile_stale_custom_router_selection(ctx);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Re-inject provider models when Bedrock or OpenAI enabled state changes.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
|
|
use crate::settings::AISettingsChangedEvent;
|
|
if matches!(
|
|
event,
|
|
AISettingsChangedEvent::BedrockEnabled { .. }
|
|
| AISettingsChangedEvent::OpenAIEnabled { .. }
|
|
| AISettingsChangedEvent::AcpEnabled { .. }
|
|
| AISettingsChangedEvent::OpenAIBaseUrl { .. }
|
|
| AISettingsChangedEvent::OpenAIApiKey { .. }
|
|
| AISettingsChangedEvent::OpenAIModels { .. }
|
|
| AISettingsChangedEvent::OpenAIProviders { .. }
|
|
| AISettingsChangedEvent::AcpProviders { .. }
|
|
| AISettingsChangedEvent::AcpAgents { .. }
|
|
| AISettingsChangedEvent::AcpAgentId { .. }
|
|
| AISettingsChangedEvent::BedrockModels { .. }
|
|
) {
|
|
me.inject_bedrock_models(ctx);
|
|
me.inject_openai_models(ctx);
|
|
if matches!(
|
|
event,
|
|
AISettingsChangedEvent::OpenAIEnabled { .. }
|
|
| AISettingsChangedEvent::OpenAIBaseUrl { .. }
|
|
| AISettingsChangedEvent::OpenAIApiKey { .. }
|
|
) {
|
|
me.fetch_openai_models_from_endpoint(ctx);
|
|
}
|
|
if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. })
|
|
&& *AISettings::as_ref(ctx).bedrock_enabled.value()
|
|
{
|
|
me.refresh_bedrock_models(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();
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
}
|
|
});
|
|
|
|
let base_llm_for_terminal_view = HashMap::new();
|
|
let custom_llms = Vec::new();
|
|
|
|
let mut me = Self {
|
|
models_by_feature,
|
|
last_update: None,
|
|
base_llm_for_terminal_view,
|
|
custom_llms,
|
|
custom_model_routers: Vec::new(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
openai_provider_routing: HashMap::new(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fetched_openai_models: Vec::new(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
chatgpt_subscription_models_refresh_in_flight: false,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_selections: HashMap::new(),
|
|
};
|
|
|
|
// Seed from any already-loaded local config (the async load emits
|
|
// `ModelConfigs` shortly after startup to populate fully).
|
|
if FeatureFlag::CustomModelRouters.is_enabled() {
|
|
me.rebuild_custom_model_routers(ctx);
|
|
}
|
|
|
|
// In agent mode eval builds, eagerly kick off a fetch of the model list from the server
|
|
// so that it's available by the time test steps like `set_preferred_agent_mode_llm` run.
|
|
// In production, this is handled reactively (on auth complete, network online, etc.)
|
|
// to avoid duplicate requests at startup.
|
|
#[cfg(feature = "agent_mode_evals")]
|
|
me.refresh_available_models(ctx);
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
Self::ensure_default_chatgpt_models_in_settings(ctx);
|
|
me.refresh_bedrock_models(ctx);
|
|
me.inject_openai_models(ctx);
|
|
me.ensure_default_model_present();
|
|
me.fetch_openai_models_from_endpoint(ctx);
|
|
me.refresh_chatgpt_subscription_models(ctx);
|
|
me.schedule_chatgpt_subscription_model_refresh(ctx);
|
|
}
|
|
|
|
me
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn ensure_default_chatgpt_models_in_settings(ctx: &mut ModelContext<Self>) {
|
|
let mut providers = AISettings::as_ref(ctx).openai_providers.value().clone();
|
|
let default_chatgpt_models = crate::settings::ai::default_chatgpt_provider().models;
|
|
let mut providers_changed = false;
|
|
for provider in &mut providers {
|
|
if provider.kind != OpenAIProviderKind::ChatGPTSubscription {
|
|
continue;
|
|
}
|
|
|
|
if provider.models.is_empty() {
|
|
provider.models = default_chatgpt_models.clone();
|
|
providers_changed = true;
|
|
continue;
|
|
}
|
|
|
|
for model in &mut provider.models {
|
|
if !model.reasoning_efforts.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some(default_model) = default_chatgpt_models
|
|
.iter()
|
|
.find(|default_model| default_model.model_id == model.model_id)
|
|
{
|
|
if !default_model.reasoning_efforts.is_empty() {
|
|
model.reasoning_efforts = default_model.reasoning_efforts.clone();
|
|
providers_changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if providers_changed {
|
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
let _ = settings.openai_providers.set_value(providers, ctx);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn refresh_bedrock_models(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let settings = AISettings::as_ref(ctx);
|
|
if !*settings.bedrock_enabled.value() {
|
|
return;
|
|
}
|
|
let config = crate::ai::bedrock::client::BedrockClientConfig {
|
|
auth_method: *settings.bedrock_auth_method.value(),
|
|
profile: settings.bedrock_profile.value().clone(),
|
|
region: settings.bedrock_region.value().clone(),
|
|
access_key_id: settings.bedrock_access_key_id.value().clone(),
|
|
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
|
|
session_token: None,
|
|
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
|
|
use_rig: false,
|
|
};
|
|
|
|
let _ = ctx.spawn(
|
|
async move { crate::ai::bedrock::discovery::discover_available_models(config).await },
|
|
|me, result, ctx| match result {
|
|
Ok(models) => {
|
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
if let Err(error) = settings.bedrock_models.set_value(models, ctx) {
|
|
log::warn!("[bedrock] Failed to persist discovered models: {error}");
|
|
}
|
|
});
|
|
me.inject_bedrock_models(ctx);
|
|
me.ensure_default_model_present();
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
}
|
|
Err(error) => {
|
|
log::debug!("[bedrock] Startup model discovery unavailable: {error}");
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn inject_bedrock_models(&mut self, ctx: &AppContext) {
|
|
// Galaxy's runtime inventory is rebuilt exclusively from enabled local
|
|
// providers. Never retain Warp-hosted or stale cached model entries.
|
|
self.models_by_feature.agent_mode.choices.clear();
|
|
self.models_by_feature.coding.choices.clear();
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
cli.choices.clear();
|
|
}
|
|
|
|
let settings = AISettings::as_ref(ctx);
|
|
if !*settings.bedrock_enabled.value() {
|
|
return;
|
|
}
|
|
|
|
// Bedrock models are populated only by the control-plane discovery
|
|
// flow. Never fall back to a static catalog or external config here.
|
|
let discovered_models: Vec<BedrockModelConfig> = settings.bedrock_models.value().clone();
|
|
let region = settings.bedrock_region.value().clone();
|
|
let cross_region = *settings.bedrock_cross_region_inference.value();
|
|
|
|
// Check if user wants only 1-hour cache models
|
|
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
|
|
let external_config = ExternalBedrockConfig::load();
|
|
let require_1h_cache = external_config.enable_prompt_caching_1h;
|
|
|
|
let mut effective = discovered_models;
|
|
|
|
// Filter out models that don't support 1-hour caching if required
|
|
if require_1h_cache {
|
|
effective.retain(|model| {
|
|
// 1-hour caching is supported by Claude 4.5+ models
|
|
// Opus 4.5+, Sonnet 4.5+, Haiku 4.5+
|
|
let supports_1h = model.model_id.contains("4-5")
|
|
|| model.model_id.contains("4.5")
|
|
|| model.model_id.contains("-4-6") // Opus/Sonnet 4.6+ also support 1h
|
|
|| model.model_id.contains("4.6")
|
|
|| model.model_id.contains("-4-7")
|
|
|| model.model_id.contains("4.7")
|
|
|| model.model_id.contains("-4-8")
|
|
|| model.model_id.contains("4.8");
|
|
|
|
if !supports_1h {
|
|
log::info!(
|
|
"[bedrock] Filtering out model {} - does not support 1-hour cache (ENABLE_PROMPT_CACHING_1H=1)",
|
|
model.model_id
|
|
);
|
|
}
|
|
supports_1h
|
|
});
|
|
|
|
if effective.is_empty() {
|
|
log::warn!("[bedrock] No models left after filtering for 1-hour cache support!");
|
|
}
|
|
}
|
|
|
|
let effective = effective;
|
|
for model in effective {
|
|
let model_id = if cross_region && !region.is_empty() {
|
|
super::bedrock::models::apply_cross_region_prefix(&model.model_id, ®ion)
|
|
} else {
|
|
model.model_id.clone()
|
|
};
|
|
|
|
let llm_info = LLMInfo {
|
|
id: LLMId::from(model_id.as_str()),
|
|
display_name: model.display_name.clone(),
|
|
base_model_name: model.display_name.clone(),
|
|
reasoning_level: None,
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: Some("AWS Bedrock".to_string()),
|
|
disable_reason: None,
|
|
vision_supported: model.vision_supported,
|
|
spec: None,
|
|
provider: LLMProvider::Bedrock,
|
|
host_configs: HashMap::from([(
|
|
LLMModelHost::AwsBedrock,
|
|
RoutingHostConfig {
|
|
enabled: true,
|
|
model_routing_host: LLMModelHost::AwsBedrock,
|
|
},
|
|
)]),
|
|
discount_percentage: None,
|
|
context_window: LLMContextWindow::default(),
|
|
};
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.push(llm_info.clone());
|
|
self.models_by_feature.coding.choices.push(llm_info.clone());
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
cli.choices.push(llm_info);
|
|
}
|
|
}
|
|
|
|
// Default agent mode to ANTHROPIC_MODEL from external config if set,
|
|
// otherwise Claude Opus 4.6, falling back to the first available model.
|
|
// Note: external_config already loaded above for filtering
|
|
let external_config_for_default = ExternalBedrockConfig::load();
|
|
|
|
if let Some(id) = external_config_for_default
|
|
.anthropic_model
|
|
.as_ref()
|
|
.and_then(|model_id| {
|
|
// Match by model_id (with or without cross-region prefix)
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.iter()
|
|
.find(|m| {
|
|
m.id.as_str() == model_id
|
|
|| m.id.as_str().ends_with(model_id)
|
|
|| model_id.ends_with(m.id.as_str())
|
|
})
|
|
.map(|m| {
|
|
log::info!(
|
|
"[bedrock] Setting default agent mode model from ANTHROPIC_MODEL: {} -> {}",
|
|
model_id,
|
|
m.display_name
|
|
);
|
|
m.id.clone()
|
|
})
|
|
})
|
|
.or_else(|| {
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.iter()
|
|
.find(|m| m.display_name.contains("Opus 4.6"))
|
|
.or_else(|| self.models_by_feature.agent_mode.choices.first())
|
|
.map(|m| m.id.clone())
|
|
})
|
|
{
|
|
self.models_by_feature.agent_mode.default_id = id;
|
|
}
|
|
|
|
// Default coding to Claude Sonnet 4.6, falling back to the first available model.
|
|
if let Some(id) = self
|
|
.models_by_feature
|
|
.coding
|
|
.choices
|
|
.iter()
|
|
.find(|m| m.display_name.contains("Sonnet 4.6"))
|
|
.or_else(|| self.models_by_feature.coding.choices.first())
|
|
.map(|m| m.id.clone())
|
|
{
|
|
self.models_by_feature.coding.default_id = id;
|
|
}
|
|
|
|
// Default CLI agent to the first available model.
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
if let Some(id) = cli.choices.first().map(|m| m.id.clone()) {
|
|
cli.default_id = id;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Injects models from OpenAI-compatible providers into the available model lists.
|
|
///
|
|
/// Supports two configuration paths:
|
|
/// 1. Legacy single-provider: `ai.openai.{base_url, api_key, models}`
|
|
/// 2. Multi-provider: `ai.providers[]` (each with name, base_url, api_key, models)
|
|
///
|
|
/// Also populates `openai_provider_routing` so that `resolve_provider_config` can
|
|
/// dispatch requests to the correct endpoint per model.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn inject_openai_models(&mut self, ctx: &AppContext) {
|
|
use super::openai::client::OpenAIClientConfig;
|
|
|
|
// Remove any previously injected LiteLLM models
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.retain(|m| m.provider != LLMProvider::LiteLLM);
|
|
self.models_by_feature
|
|
.coding
|
|
.choices
|
|
.retain(|m| m.provider != LLMProvider::LiteLLM);
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM);
|
|
}
|
|
self.openai_provider_routing.clear();
|
|
|
|
let settings = AISettings::as_ref(ctx);
|
|
self.inject_acp_models(ctx);
|
|
if !*settings.openai_enabled.value() {
|
|
return;
|
|
}
|
|
|
|
type OpenAIProviderEntry = (
|
|
String,
|
|
OpenAIProviderKind,
|
|
bool,
|
|
String,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Vec<OpenAIModelConfig>,
|
|
);
|
|
let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new();
|
|
|
|
let configured_models = settings.openai_models.value().clone();
|
|
let single_provider_models = if configured_models.is_empty() {
|
|
self.fetched_openai_models.clone()
|
|
} else {
|
|
configured_models
|
|
};
|
|
|
|
if !single_provider_models.is_empty() {
|
|
let base_url = settings.openai_base_url.value().clone();
|
|
let api_key = {
|
|
let key = settings.openai_api_key.value().clone();
|
|
if key.is_empty() {
|
|
None
|
|
} else {
|
|
Some(key)
|
|
}
|
|
};
|
|
let name = if base_url.contains("localhost") || base_url.contains("127.0.0.1") {
|
|
"LiteLLM (local)".to_string()
|
|
} else {
|
|
"LiteLLM".to_string()
|
|
};
|
|
provider_entries.push((
|
|
name,
|
|
OpenAIProviderKind::LiteLLM,
|
|
true,
|
|
base_url,
|
|
api_key,
|
|
None,
|
|
None,
|
|
single_provider_models,
|
|
));
|
|
}
|
|
|
|
provider_entries.extend(
|
|
settings
|
|
.openai_providers
|
|
.value()
|
|
.iter()
|
|
.filter_map(|provider| {
|
|
let missing_credentials = match provider.kind {
|
|
OpenAIProviderKind::OpenAI => {
|
|
provider.base_url.trim().is_empty()
|
|
|| provider
|
|
.api_key
|
|
.as_deref()
|
|
.is_none_or(|key| key.trim().is_empty())
|
|
}
|
|
OpenAIProviderKind::LiteLLM => provider.base_url.trim().is_empty(),
|
|
OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
|
|
.api_key
|
|
.as_deref()
|
|
.is_none_or(|key| key.trim().is_empty()),
|
|
OpenAIProviderKind::VertexAI => provider
|
|
.project_id
|
|
.as_deref()
|
|
.is_none_or(|project| project.trim().is_empty()),
|
|
OpenAIProviderKind::ChatGPTSubscription => false,
|
|
};
|
|
if !provider.enabled || missing_credentials || provider.models.is_empty() {
|
|
return None;
|
|
}
|
|
Some((
|
|
provider.name.clone(),
|
|
provider.kind,
|
|
provider.enabled,
|
|
provider.base_url.clone(),
|
|
provider.api_key.clone(),
|
|
provider.project_id.clone(),
|
|
provider.location.clone(),
|
|
provider.models.clone(),
|
|
))
|
|
}),
|
|
);
|
|
|
|
if provider_entries.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut total_injected = 0;
|
|
let mut seen_model_ids: HashSet<String> = HashSet::new();
|
|
for (
|
|
provider_name,
|
|
provider_kind,
|
|
provider_enabled,
|
|
base_url,
|
|
api_key,
|
|
provider_project_id,
|
|
provider_location,
|
|
models,
|
|
) in provider_entries
|
|
{
|
|
if !provider_enabled {
|
|
continue;
|
|
}
|
|
for model in &models {
|
|
if !model.enabled {
|
|
continue;
|
|
}
|
|
if !seen_model_ids.insert(model.model_id.clone()) {
|
|
continue;
|
|
}
|
|
|
|
let reasoning_efforts: Vec<Option<&String>> =
|
|
if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
|
|
// Keep the base model as the provider-default mode, then expose each
|
|
// explicitly supported effort as a separate selectable variant.
|
|
std::iter::once(None)
|
|
.chain(model.reasoning_efforts.iter().map(Some))
|
|
.collect()
|
|
} else {
|
|
vec![None]
|
|
};
|
|
|
|
for reasoning_effort in reasoning_efforts {
|
|
let reasoning_effort = reasoning_effort.cloned();
|
|
let model_key = reasoning_effort.as_deref().map_or_else(
|
|
|| model.model_id.clone(),
|
|
|effort| openai_model_variant_id(&model.model_id, effort),
|
|
);
|
|
|
|
// Register the routing entry. Reasoning variants keep the provider's
|
|
// actual model ID while using their synthetic key only for selection.
|
|
let client_config = OpenAIClientConfig {
|
|
kind: provider_kind,
|
|
base_url: base_url.clone(),
|
|
api_key: api_key.clone(),
|
|
project_id: provider_project_id.clone(),
|
|
location: provider_location.clone(),
|
|
model: Some(model.model_id.clone()),
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
max_input_tokens: Some(openai_model_context_size(model)),
|
|
max_output_tokens: model.max_output_tokens,
|
|
use_rig: model.use_rig
|
|
|| !matches!(
|
|
provider_kind,
|
|
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM
|
|
),
|
|
supports_system_messages: model.supports_system_messages(),
|
|
};
|
|
self.openai_provider_routing
|
|
.insert(model_key.clone(), client_config);
|
|
|
|
let display_name = reasoning_effort.as_deref().map_or_else(
|
|
|| model.display_name.clone(),
|
|
|effort| format!("{} ({effort})", model.display_name),
|
|
);
|
|
let llm_info = LLMInfo {
|
|
id: LLMId::from(model_key.as_str()),
|
|
display_name,
|
|
base_model_name: model.display_name.clone(),
|
|
reasoning_level: reasoning_effort,
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: Some(provider_name.clone()),
|
|
disable_reason: None,
|
|
vision_supported: model.effective_vision_supported(),
|
|
spec: None,
|
|
provider: LLMProvider::LiteLLM,
|
|
host_configs: HashMap::from([(
|
|
LLMModelHost::DirectApi,
|
|
RoutingHostConfig {
|
|
enabled: true,
|
|
model_routing_host: LLMModelHost::DirectApi,
|
|
},
|
|
)]),
|
|
discount_percentage: None,
|
|
context_window: openai_model_context_window(model),
|
|
};
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.push(llm_info.clone());
|
|
self.models_by_feature.coding.choices.push(llm_info.clone());
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
cli.choices.push(llm_info);
|
|
}
|
|
total_injected += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn inject_acp_models(&mut self, ctx: &AppContext) {
|
|
let previous_acp_model_ids = self.acp_selections.keys().cloned().collect::<HashSet<_>>();
|
|
let remove_previous_acp_models = |choices: &mut Vec<LLMInfo>| {
|
|
choices.retain(|model| !previous_acp_model_ids.contains(&model.id));
|
|
};
|
|
remove_previous_acp_models(&mut self.models_by_feature.agent_mode.choices);
|
|
remove_previous_acp_models(&mut self.models_by_feature.coding.choices);
|
|
if let Some(cli) = &mut self.models_by_feature.cli_agent {
|
|
remove_previous_acp_models(&mut cli.choices);
|
|
}
|
|
self.acp_selections.clear();
|
|
let settings = AISettings::as_ref(ctx);
|
|
let providers = settings.enabled_acp_providers();
|
|
if providers.is_empty() {
|
|
return;
|
|
}
|
|
let bedrock_enabled = *settings.bedrock_enabled.value();
|
|
for provider in providers {
|
|
self.inject_acp_provider_models(&provider, bedrock_enabled);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn inject_acp_provider_models(&mut self, provider: &AcpProviderConfig, bedrock_enabled: bool) {
|
|
let agent_id = provider.agent_id.trim();
|
|
let agent_id = if agent_id.is_empty() {
|
|
"codex"
|
|
} else {
|
|
agent_id
|
|
};
|
|
let agent_name = acp_agent_display_name(agent_id);
|
|
if provider.config_options.is_empty() {
|
|
self.push_acp_model(provider, &agent_name, &agent_name, BTreeMap::new(), None);
|
|
return;
|
|
}
|
|
|
|
let model_option = provider
|
|
.config_options
|
|
.iter()
|
|
.find(|option| option.category.as_deref() == Some("model"));
|
|
let Some(model_option) = model_option else {
|
|
let selection =
|
|
crate::ai::acp::AcpRuntimeModel::current_config_values(&provider.config_options);
|
|
self.push_acp_model(provider, &agent_name, &agent_name, selection, None);
|
|
return;
|
|
};
|
|
let reasoning_option = provider
|
|
.config_options
|
|
.iter()
|
|
.find(|option| option.category.as_deref() == Some("thought_level"));
|
|
for value in model_option
|
|
.options
|
|
.iter()
|
|
.filter(|value| acp_model_is_enabled(&value.value, bedrock_enabled))
|
|
{
|
|
let mut selection =
|
|
crate::ai::acp::AcpRuntimeModel::current_config_values(&provider.config_options);
|
|
selection.insert(model_option.id.clone(), value.value.clone());
|
|
if let Some(reasoning_option) =
|
|
reasoning_option.filter(|option| !option.options.is_empty())
|
|
{
|
|
for reasoning in &reasoning_option.options {
|
|
let mut selection = selection.clone();
|
|
selection.insert(reasoning_option.id.clone(), reasoning.value.clone());
|
|
self.push_acp_model(
|
|
provider,
|
|
&value.name,
|
|
&value.name,
|
|
selection,
|
|
Some(reasoning),
|
|
);
|
|
}
|
|
} else {
|
|
self.push_acp_model(provider, &value.name, &value.name, selection, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn push_acp_model(
|
|
&mut self,
|
|
provider: &AcpProviderConfig,
|
|
display_name: &str,
|
|
base_model_name: &str,
|
|
selection: BTreeMap<String, serde_json::Value>,
|
|
reasoning: Option<&AcpConfigValueSettings>,
|
|
) {
|
|
let display_name = reasoning.map_or_else(
|
|
|| display_name.to_owned(),
|
|
|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"
|
|
} else {
|
|
agent_id
|
|
};
|
|
let id = acp_provider_selection_identity(&provider.id, agent_id, &selection);
|
|
let llm_id = LLMId::from(id.as_str());
|
|
self.acp_selections.insert(
|
|
llm_id.clone(),
|
|
AcpModelSelection {
|
|
provider_id: provider.id.clone(),
|
|
agent_id: agent_id.to_owned(),
|
|
launch_fingerprint: acp_launch_fingerprint(
|
|
agent_id,
|
|
&provider.command,
|
|
&provider.args,
|
|
),
|
|
config_values: selection,
|
|
},
|
|
);
|
|
let info = LLMInfo {
|
|
id: llm_id,
|
|
display_name,
|
|
base_model_name: base_model_name.to_owned(),
|
|
reasoning_level: reasoning.map(|reasoning| reasoning.name.clone()),
|
|
usage_metadata: LLMUsageMetadata {
|
|
request_multiplier: 1,
|
|
credit_multiplier: None,
|
|
},
|
|
description: Some("ACP".to_string()),
|
|
disable_reason: None,
|
|
vision_supported: false,
|
|
spec: None,
|
|
provider: LLMProvider::Acp,
|
|
host_configs: HashMap::new(),
|
|
discount_percentage: None,
|
|
context_window: LLMContextWindow::default(),
|
|
};
|
|
self.models_by_feature.agent_mode.choices.push(info.clone());
|
|
self.models_by_feature.coding.choices.push(info.clone());
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
cli.choices.push(info);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub(crate) fn acp_runtime_selection_for_model(
|
|
&self,
|
|
model_id: &LLMId,
|
|
) -> Option<&AcpModelSelection> {
|
|
self.acp_selections.get(model_id)
|
|
}
|
|
|
|
/// Resolves the runtime that owns the active model for a terminal surface.
|
|
///
|
|
/// ACP is an execution backend, not a global lock on Agent Mode. Selecting
|
|
/// an ACP-advertised model routes the conversation to that ACP agent, while
|
|
/// selecting a Rig/provider model routes it through Galaxy's provider path.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub(crate) fn agent_backend_for_active_model(
|
|
&self,
|
|
terminal_view_id: Option<EntityId>,
|
|
ctx: &AppContext,
|
|
) -> AgentBackend {
|
|
if !cfg!(unix) || !FeatureFlag::AgentClientProtocol.is_enabled() {
|
|
return AgentBackend::Provider;
|
|
}
|
|
|
|
let settings = AISettings::as_ref(ctx);
|
|
if !*settings.acp_enabled.value() {
|
|
return AgentBackend::Provider;
|
|
}
|
|
|
|
let active_model = self.get_active_base_model(ctx, terminal_view_id);
|
|
if let Some(selection) = self.acp_runtime_selection_for_model(&active_model.id) {
|
|
return AgentBackend::Acp(AcpConversationData {
|
|
provider_id: selection.provider_id.clone(),
|
|
agent_id: selection.agent_id.clone(),
|
|
launch_fingerprint: selection.launch_fingerprint.clone(),
|
|
session_id: None,
|
|
config_values: selection.config_values.clone(),
|
|
});
|
|
}
|
|
|
|
if active_model.id.as_str() != "none" {
|
|
return AgentBackend::Provider;
|
|
}
|
|
|
|
// ACP remains a valid runtime even before discovery has returned a
|
|
// model option (and for agents that do not expose model selection at
|
|
// all). A discovered model catalog with no enabled entries must not
|
|
// fall back to its disabled current model, though.
|
|
let providers = settings.enabled_acp_providers();
|
|
let [provider] = providers.as_slice() else {
|
|
return AgentBackend::Provider;
|
|
};
|
|
if provider
|
|
.config_options
|
|
.iter()
|
|
.any(|option| option.category.as_deref() == Some("model"))
|
|
{
|
|
return AgentBackend::Provider;
|
|
}
|
|
let agent_id = provider.agent_id.trim();
|
|
let agent_id = if agent_id.is_empty() {
|
|
"codex"
|
|
} else {
|
|
agent_id
|
|
};
|
|
|
|
AgentBackend::Acp(AcpConversationData {
|
|
provider_id: provider.id.clone(),
|
|
agent_id: agent_id.to_owned(),
|
|
launch_fingerprint: acp_launch_fingerprint(agent_id, &provider.command, &provider.args),
|
|
session_id: None,
|
|
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
|
|
&provider.config_options,
|
|
),
|
|
})
|
|
}
|
|
|
|
/// Ensures the default model ID in each feature's choices still points to
|
|
/// an existing entry. If the default was removed (e.g. provider disabled),
|
|
/// switch to the first remaining choice.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn ensure_default_model_present(&mut self) {
|
|
fn fix_default(feature: &mut AvailableLLMs) {
|
|
if feature.choices.is_empty() {
|
|
return;
|
|
}
|
|
let default_exists = feature.choices.iter().any(|m| m.id == feature.default_id);
|
|
if !default_exists {
|
|
let new_default = feature.choices[0].id.clone();
|
|
log::info!(
|
|
"[llm] Default model {:?} no longer available, switching to {:?}",
|
|
feature.default_id,
|
|
new_default
|
|
);
|
|
feature.default_id = new_default;
|
|
}
|
|
}
|
|
fix_default(&mut self.models_by_feature.agent_mode);
|
|
fix_default(&mut self.models_by_feature.coding);
|
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
|
fix_default(cli);
|
|
}
|
|
}
|
|
|
|
/// Returns the OpenAI client config for a given model ID, if it was injected
|
|
/// from an OpenAI-compatible provider.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub fn openai_client_config_for_model(
|
|
&self,
|
|
model_id: &str,
|
|
) -> Option<&super::openai::client::OpenAIClientConfig> {
|
|
self.openai_provider_routing.get(model_id)
|
|
}
|
|
|
|
/// Fetches available models from the configured OpenAI-compatible endpoint.
|
|
///
|
|
/// Tries LiteLLM's `/model/info` first (which returns rich metadata including
|
|
/// accurate `max_input_tokens`, `max_output_tokens`, `supports_vision`, and
|
|
/// `supports_function_calling`). Falls back to the standard OpenAI `/models`
|
|
/// endpoint if `/model/info` is unavailable.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub fn fetch_openai_models_from_endpoint(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let settings = AISettings::as_ref(ctx);
|
|
if !*settings.openai_enabled.value() {
|
|
return;
|
|
}
|
|
|
|
let base_url = settings.openai_base_url.value().clone();
|
|
if base_url.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let api_key = {
|
|
let key = settings.openai_api_key.value().clone();
|
|
if key.is_empty() {
|
|
None
|
|
} else {
|
|
Some(key)
|
|
}
|
|
};
|
|
|
|
let _ = ctx.spawn(
|
|
async move {
|
|
let base = base_url.trim_end_matches('/');
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.unwrap_or_default();
|
|
|
|
// Try LiteLLM /model/info first for rich metadata
|
|
if let Some(models) =
|
|
fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await
|
|
{
|
|
return models;
|
|
}
|
|
|
|
// Fallback to standard OpenAI /models endpoint
|
|
fetch_from_openai_models(base, api_key.as_deref(), &client).await
|
|
},
|
|
|me, models, ctx| {
|
|
if !models.is_empty() {
|
|
me.fetched_openai_models = models.clone();
|
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
if let Err(err) = settings.openai_models.set_value(models, ctx) {
|
|
report_error!(err.context("Failed to persist fetched OpenAI models"));
|
|
}
|
|
});
|
|
me.inject_openai_models(ctx);
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Explicitly refreshes the models for one entry in the OpenAI-compatible
|
|
/// provider registry. Unlike the legacy endpoint refresh, this is only
|
|
/// called from a user action so configured remote endpoints are never
|
|
/// contacted merely because Galaxy started.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub fn fetch_openai_provider_models(
|
|
&mut self,
|
|
provider_index: usize,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let settings = AISettings::as_ref(ctx);
|
|
if !*settings.openai_enabled.value() {
|
|
return;
|
|
}
|
|
|
|
let Some(provider) = settings
|
|
.openai_providers
|
|
.value()
|
|
.get(provider_index)
|
|
.cloned()
|
|
else {
|
|
return;
|
|
};
|
|
if provider.kind != OpenAIProviderKind::ChatGPTSubscription
|
|
&& provider.base_url.trim().is_empty()
|
|
{
|
|
return;
|
|
}
|
|
|
|
let provider_kind = provider.kind;
|
|
let requested_base_url = provider.base_url;
|
|
let requested_provider_kind = provider_kind;
|
|
let api_key = provider.api_key.filter(|key| !key.is_empty());
|
|
let request_base_url = requested_base_url.clone();
|
|
|
|
let _ = ctx.spawn(
|
|
async move {
|
|
if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
|
|
return match Self::discover_chatgpt_subscription_models().await {
|
|
Ok(models) => models,
|
|
Err(error) => {
|
|
log::warn!(
|
|
"[chatgpt/models] Failed to discover ChatGPT subscription models: {error}"
|
|
);
|
|
Vec::new()
|
|
}
|
|
};
|
|
}
|
|
|
|
let base = request_base_url.trim_end_matches('/');
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.unwrap_or_default();
|
|
|
|
if provider_kind == OpenAIProviderKind::LiteLLM {
|
|
if let Some(models) =
|
|
fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await
|
|
{
|
|
return models;
|
|
}
|
|
}
|
|
|
|
fetch_from_openai_models(base, api_key.as_deref(), &client).await
|
|
},
|
|
move |_, discovered_models, ctx| {
|
|
if discovered_models.is_empty() {
|
|
return;
|
|
}
|
|
|
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
let mut providers = settings.openai_providers.value().clone();
|
|
let Some(provider) = providers.get_mut(provider_index) else {
|
|
return;
|
|
};
|
|
|
|
// Do not apply a response to an entry that was edited or
|
|
// reordered while its discovery request was in flight.
|
|
if provider.kind != requested_provider_kind
|
|
|| provider.base_url != requested_base_url
|
|
{
|
|
return;
|
|
}
|
|
|
|
provider.models = if provider.kind == OpenAIProviderKind::ChatGPTSubscription {
|
|
merge_discovered_chatgpt_subscription_models(
|
|
&provider.models,
|
|
discovered_models,
|
|
)
|
|
} else {
|
|
merge_discovered_provider_models(&provider.models, discovered_models)
|
|
};
|
|
if let Err(err) = settings.openai_providers.set_value(providers, ctx) {
|
|
report_error!(err.context("Failed to persist discovered provider models"));
|
|
}
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Discovers models for a provider draft without persisting or injecting it.
|
|
///
|
|
/// The provider setup view uses this to keep configuration changes atomic
|
|
/// until the user clicks Save.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub(crate) async fn discover_openai_provider_models(
|
|
provider: OpenAIProviderConfig,
|
|
) -> Result<Vec<OpenAIModelConfig>, String> {
|
|
if provider.kind == OpenAIProviderKind::ChatGPTSubscription {
|
|
return Self::discover_chatgpt_subscription_models().await;
|
|
}
|
|
|
|
let native_models = match provider.kind {
|
|
OpenAIProviderKind::Anthropic => {
|
|
let api_key = provider
|
|
.api_key
|
|
.as_deref()
|
|
.filter(|key| !key.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
"Enter an Anthropic API key before testing the connection.".to_string()
|
|
})?;
|
|
Some(discover_anthropic_models(api_key).await?)
|
|
}
|
|
OpenAIProviderKind::Gemini => {
|
|
let api_key = provider
|
|
.api_key
|
|
.as_deref()
|
|
.filter(|key| !key.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
"Enter a Gemini API key before testing the connection.".to_string()
|
|
})?;
|
|
Some(discover_gemini_models(api_key).await?)
|
|
}
|
|
OpenAIProviderKind::VertexAI => {
|
|
if provider
|
|
.project_id
|
|
.as_deref()
|
|
.is_none_or(|project| project.trim().is_empty())
|
|
{
|
|
return Err(
|
|
"Enter a Google Cloud project ID before testing the connection."
|
|
.to_string(),
|
|
);
|
|
}
|
|
validate_vertex_ai_credentials(
|
|
provider.project_id.as_deref().unwrap_or_default(),
|
|
provider.location.as_deref().unwrap_or("global"),
|
|
)?;
|
|
Some(vertex_ai_model_catalog())
|
|
}
|
|
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => None,
|
|
OpenAIProviderKind::ChatGPTSubscription => {
|
|
unreachable!("ChatGPT subscription discovery is handled before native discovery")
|
|
}
|
|
};
|
|
|
|
if let Some(models) = native_models {
|
|
if models.is_empty() {
|
|
return Err("The provider responded, but no models were found.".to_string());
|
|
}
|
|
return Ok(Self::rig_models_to_openai_models(models));
|
|
}
|
|
|
|
if provider.base_url.trim().is_empty() {
|
|
return Err("Enter a provider URL before testing the connection.".to_string());
|
|
}
|
|
|
|
let base_url = provider.base_url.trim_end_matches('/').to_string();
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|error| format!("Could not create the provider client: {error}"))?;
|
|
let api_key = provider.api_key.as_deref().filter(|key| !key.is_empty());
|
|
|
|
let models = if provider.kind == OpenAIProviderKind::LiteLLM {
|
|
if let Some(models) = fetch_from_litellm_model_info(&base_url, api_key, &client).await {
|
|
models
|
|
} else {
|
|
fetch_from_openai_models(&base_url, api_key, &client).await
|
|
}
|
|
} else {
|
|
fetch_from_openai_models(&base_url, api_key, &client).await
|
|
};
|
|
|
|
if models.is_empty() {
|
|
let endpoint_description = if provider.kind == OpenAIProviderKind::LiteLLM {
|
|
"/model/info or /models"
|
|
} else {
|
|
"/models"
|
|
};
|
|
return Err(format!(
|
|
"The provider responded, but no models were found at {endpoint_description}."
|
|
));
|
|
}
|
|
|
|
Ok(models)
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn schedule_chatgpt_subscription_model_refresh(&self, ctx: &mut ModelContext<Self>) {
|
|
let _ = ctx.spawn(
|
|
async move {
|
|
Timer::after(CHATGPT_SUBSCRIPTION_MODELS_REFRESH_INTERVAL).await;
|
|
},
|
|
|me, _, ctx| {
|
|
me.refresh_chatgpt_subscription_models(ctx);
|
|
me.schedule_chatgpt_subscription_model_refresh(ctx);
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn refresh_chatgpt_subscription_models(&mut self, ctx: &mut ModelContext<Self>) {
|
|
if self.chatgpt_subscription_models_refresh_in_flight {
|
|
return;
|
|
}
|
|
|
|
let settings = AISettings::as_ref(ctx);
|
|
if !*settings.openai_enabled.value()
|
|
|| !settings.openai_providers.value().iter().any(|provider| {
|
|
provider.enabled && provider.kind == OpenAIProviderKind::ChatGPTSubscription
|
|
})
|
|
{
|
|
return;
|
|
}
|
|
|
|
self.chatgpt_subscription_models_refresh_in_flight = true;
|
|
let _ = ctx.spawn(
|
|
async { Self::discover_chatgpt_subscription_models().await },
|
|
|me, result, ctx| {
|
|
me.chatgpt_subscription_models_refresh_in_flight = false;
|
|
let discovered_models = match result {
|
|
Ok(models) => models,
|
|
Err(error) => {
|
|
log::warn!(
|
|
"[chatgpt/models] Failed to refresh ChatGPT subscription models: {error}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if discovered_models.is_empty() {
|
|
return;
|
|
}
|
|
|
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
let mut providers = settings.openai_providers.value().clone();
|
|
let mut changed = false;
|
|
for provider in &mut providers {
|
|
if provider.kind != OpenAIProviderKind::ChatGPTSubscription {
|
|
continue;
|
|
}
|
|
provider.models = merge_discovered_chatgpt_subscription_models(
|
|
&provider.models,
|
|
discovered_models.clone(),
|
|
);
|
|
changed = true;
|
|
}
|
|
if changed {
|
|
if let Err(err) = settings.openai_providers.set_value(providers, ctx) {
|
|
report_error!(
|
|
err.context("Failed to persist ChatGPT subscription models")
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
me.inject_openai_models(ctx);
|
|
me.ensure_default_model_present();
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
async fn discover_chatgpt_subscription_models() -> Result<Vec<OpenAIModelConfig>, String> {
|
|
let credentials = crate::ai::chatgpt_auth::load_or_import_auth_credentials()?;
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|error| format!("Could not create the ChatGPT model client: {error}"))?;
|
|
let client_version = fetch_latest_codex_client_version(&client).await?;
|
|
fetch_from_chatgpt_codex_models(&client_version, credentials, &client).await
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
|
|
models
|
|
.into_iter()
|
|
.map(|model| OpenAIModelConfig {
|
|
model_id: model.id,
|
|
display_name: model.display_name,
|
|
vision_supported: true,
|
|
context_size: model.context_size.unwrap_or(128_000),
|
|
max_input_tokens: model.context_size,
|
|
max_output_tokens: None,
|
|
provider: None,
|
|
use_rig: true,
|
|
supports_system_messages: Some(true),
|
|
capability_overrides: std::collections::HashMap::new(),
|
|
reasoning_efforts: Vec::new(),
|
|
enabled: true,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
|
|
pub fn get_active_base_model<'a>(
|
|
&'a self,
|
|
app: &'a AppContext,
|
|
terminal_view_id: Option<EntityId>,
|
|
) -> &'a LLMInfo {
|
|
self.get_preferred_base_model(app, terminal_view_id)
|
|
}
|
|
|
|
/// Returns `LLMInfo` for the currently selected LLM to be used for Agent Mode.
|
|
fn get_preferred_base_model(
|
|
&self,
|
|
app: &AppContext,
|
|
terminal_view_id: Option<EntityId>,
|
|
) -> &LLMInfo {
|
|
if let Some(terminal_view_id) = terminal_view_id {
|
|
let raw_override = self.base_llm_for_terminal_view.get(&terminal_view_id);
|
|
if let Some(llm_id) = raw_override {
|
|
if let Some(llm_info) = Self::server_info_for_id_router_gated(
|
|
&self.models_by_feature.agent_mode,
|
|
llm_id,
|
|
)
|
|
.or_else(|| self.custom_llm_info_for_id_if_enabled(llm_id, app))
|
|
.or_else(|| self.custom_router_llm_info_for_id_if_enabled(llm_id))
|
|
{
|
|
return llm_info;
|
|
}
|
|
}
|
|
}
|
|
|
|
let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app);
|
|
|
|
profile
|
|
.data()
|
|
.base_model
|
|
.clone()
|
|
.and_then(|id| {
|
|
Self::server_info_for_id_router_gated(&self.models_by_feature.agent_mode, &id)
|
|
.or_else(|| self.custom_llm_info_for_id_if_enabled(&id, app))
|
|
.or_else(|| self.custom_router_llm_info_for_id_if_enabled(&id))
|
|
})
|
|
.unwrap_or_else(|| self.models_by_feature.agent_mode.default_llm_info())
|
|
}
|
|
|
|
pub fn get_active_coding_model<'a>(
|
|
&'a self,
|
|
app: &'a AppContext,
|
|
terminal_view_id: Option<EntityId>,
|
|
) -> &'a LLMInfo {
|
|
self.get_preferred_coding_model(app, terminal_view_id)
|
|
}
|
|
|
|
/// Returns `LLMInfo` for user's preferred coding model.
|
|
fn get_preferred_coding_model(
|
|
&self,
|
|
app: &AppContext,
|
|
terminal_view_id: Option<EntityId>,
|
|
) -> &LLMInfo {
|
|
let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app);
|
|
|
|
profile
|
|
.data()
|
|
.coding_model
|
|
.clone()
|
|
.and_then(|id| {
|
|
Self::server_info_for_id_router_gated(&self.models_by_feature.coding, &id)
|
|
.or_else(|| self.custom_llm_info_for_id_if_enabled(&id, app))
|
|
.or_else(|| self.custom_router_llm_info_for_id_if_enabled(&id))
|
|
})
|
|
.unwrap_or_else(|| self.models_by_feature.coding.default_llm_info())
|
|
}
|
|
|
|
/// Resolves `id` against a server-provided model list, but hides cloud/team
|
|
/// custom routers when the custom-router feature flag is off. Mirrors the
|
|
/// gating applied to local routers (see
|
|
/// [`Self::custom_router_llm_info_for_id_if_enabled`]) so the whole
|
|
/// custom-router feature is controlled by a single client flag.
|
|
fn server_info_for_id_router_gated<'a>(
|
|
available: &'a AvailableLLMs,
|
|
id: &LLMId,
|
|
) -> Option<&'a LLMInfo> {
|
|
let info = available.info_for_id(id)?;
|
|
if !FeatureFlag::CustomModelRouters.is_enabled()
|
|
&& custom_model_routers::is_cloud_custom_router_id(info.id.as_str())
|
|
{
|
|
return None;
|
|
}
|
|
Some(info)
|
|
}
|
|
|
|
/// Returns the set of LLMs available for Agent Mode use.
|
|
pub fn get_base_llm_choices_for_agent_mode(
|
|
&self,
|
|
app: &AppContext,
|
|
) -> impl Iterator<Item = &LLMInfo> {
|
|
// Don't show admin-disabled models in the dropdown
|
|
let routers_enabled = FeatureFlag::CustomModelRouters.is_enabled();
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.iter()
|
|
.filter(|llm| !matches!(llm.disable_reason, Some(DisableReason::AdminDisabled)))
|
|
// Gate cloud/team routers behind the same flag as local routers so
|
|
// the entire custom-router feature is controlled by one flag.
|
|
.filter(move |llm| {
|
|
routers_enabled || !custom_model_routers::is_cloud_custom_router_id(llm.id.as_str())
|
|
})
|
|
.chain(self.custom_llm_choices(app))
|
|
.chain(self.custom_router_choices())
|
|
}
|
|
|
|
/// Returns the set of LLMs available for coding.
|
|
pub fn get_coding_llm_choices(&self, app: &AppContext) -> impl Iterator<Item = &LLMInfo> {
|
|
// Don't show admin-disabled models in the dropdown
|
|
let routers_enabled = FeatureFlag::CustomModelRouters.is_enabled();
|
|
self.models_by_feature
|
|
.coding
|
|
.choices
|
|
.iter()
|
|
.filter(|llm| !matches!(llm.disable_reason, Some(DisableReason::AdminDisabled)))
|
|
// Gate cloud/team routers behind the same flag as local routers.
|
|
.filter(move |llm| {
|
|
routers_enabled || !custom_model_routers::is_cloud_custom_router_id(llm.id.as_str())
|
|
})
|
|
.chain(self.custom_llm_choices(app))
|
|
.chain(self.custom_router_choices())
|
|
}
|
|
|
|
/// Returns the set of LLMs available for CLI agent.
|
|
pub fn get_cli_agent_llm_choices(&self, app: &AppContext) -> impl Iterator<Item = &LLMInfo> {
|
|
self.get_cli_agent_available()
|
|
.choices
|
|
.iter()
|
|
.chain(self.custom_llm_choices(app))
|
|
}
|
|
|
|
/// Returns the `LLMInfo` for the CLI agent model.
|
|
pub fn get_active_cli_agent_model<'a>(
|
|
&'a self,
|
|
app: &'a AppContext,
|
|
terminal_view_id: Option<EntityId>,
|
|
) -> &'a LLMInfo {
|
|
let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app);
|
|
|
|
let available = self.get_cli_agent_available();
|
|
profile
|
|
.data()
|
|
.cli_agent_model
|
|
.clone()
|
|
.and_then(|id| {
|
|
available
|
|
.info_for_id(&id)
|
|
.or_else(|| self.custom_llm_info_for_id_if_enabled(&id, app))
|
|
})
|
|
.unwrap_or_else(|| available.default_llm_info())
|
|
}
|
|
|
|
/// Returns the default CLI agent model as a fallback.
|
|
pub fn get_default_cli_agent_model(&self) -> &LLMInfo {
|
|
self.get_cli_agent_available().default_llm_info()
|
|
}
|
|
|
|
/// Helper to get the AvailableLLMs for cli_agent, falling back to agent_mode.
|
|
fn get_cli_agent_available(&self) -> &AvailableLLMs {
|
|
self.models_by_feature
|
|
.cli_agent
|
|
.as_ref()
|
|
.unwrap_or(&self.models_by_feature.agent_mode)
|
|
}
|
|
|
|
/// Returns the set of LLMs available for computer use agent.
|
|
pub fn get_computer_use_llm_choices(&self) -> impl Iterator<Item = &LLMInfo> {
|
|
self.get_computer_use_available().choices.iter()
|
|
}
|
|
|
|
/// Returns the `LLMInfo` for the computer use agent model.
|
|
pub fn get_active_computer_use_model<'a>(
|
|
&'a self,
|
|
app: &'a AppContext,
|
|
terminal_view_id: Option<EntityId>,
|
|
) -> &'a LLMInfo {
|
|
let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app);
|
|
|
|
let available = self.get_computer_use_available();
|
|
profile
|
|
.data()
|
|
.computer_use_model
|
|
.clone()
|
|
.and_then(|id| available.info_for_id(&id))
|
|
.unwrap_or_else(|| available.default_llm_info())
|
|
}
|
|
|
|
/// Returns the default computer use model as a fallback.
|
|
pub fn get_default_computer_use_model(&self) -> &LLMInfo {
|
|
self.get_computer_use_available().default_llm_info()
|
|
}
|
|
|
|
/// Helper to get the AvailableLLMs for computer_use.
|
|
/// Falls back to a computer-use-specific default if None.
|
|
fn get_computer_use_available(&self) -> &AvailableLLMs {
|
|
static DEFAULT: OnceLock<AvailableLLMs> = OnceLock::new();
|
|
self.models_by_feature
|
|
.computer_use
|
|
.as_ref()
|
|
.unwrap_or_else(|| DEFAULT.get_or_init(default_computer_use_llms))
|
|
}
|
|
|
|
/// Returns metadata about an LLM, if the client knows about it.
|
|
/// Falls back to the user's custom-endpoint LLMs when the id isn't a server-known model
|
|
/// id (e.g. when it's a `config_key` UUID).
|
|
pub fn get_llm_info(&self, id: &LLMId) -> Option<&LLMInfo> {
|
|
self.models_by_feature
|
|
.info_for_id(id)
|
|
.or_else(|| self.custom_llm_info_for_id(id))
|
|
.or_else(|| self.custom_router_llm_info_for_id(id))
|
|
}
|
|
|
|
/// Resolves an `LLMId` against the user's custom-endpoint LLMs.
|
|
/// Returns `None` if the id isn't a known custom model `config_key`.
|
|
pub fn custom_llm_info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
|
|
self.custom_llms.iter().find(|info| info.id == *id)
|
|
}
|
|
|
|
/// Footer label for custom endpoint usage keyed by the request config_key.
|
|
/// The synthetic custom LLMInfo already owns alias-or-name display semantics.
|
|
pub fn custom_endpoint_usage_display_label(&self, config_key: &str) -> String {
|
|
let config_key = LLMId::from(config_key);
|
|
self.custom_llm_info_for_id(&config_key)
|
|
.map(|info| info.display_name.as_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| CUSTOM_ENDPOINT_USAGE_FALLBACK_LABEL.to_string())
|
|
}
|
|
|
|
fn custom_llm_info_for_id_if_enabled(&self, id: &LLMId, app: &AppContext) -> Option<&LLMInfo> {
|
|
Self::custom_inference_enabled(app)
|
|
.then(|| self.custom_llm_info_for_id(id))
|
|
.flatten()
|
|
}
|
|
|
|
/// Iterator over the user's custom-endpoint LLMs, gated on the feature flag and entitlement.
|
|
pub fn custom_llm_choices(&self, app: &AppContext) -> std::slice::Iter<'_, LLMInfo> {
|
|
if Self::custom_inference_enabled(app) {
|
|
self.custom_llms.iter()
|
|
} else {
|
|
// Empty slice with a matching element type so the return type stays consistent
|
|
// across both branches.
|
|
(&[] as &[LLMInfo]).iter()
|
|
}
|
|
}
|
|
|
|
fn custom_inference_enabled(app: &AppContext) -> bool {
|
|
let _ = app;
|
|
false
|
|
}
|
|
|
|
/// Resolves a custom model router by its `config_key`/`LLMId`.
|
|
pub fn custom_model_router_for_id(&self, id: &LLMId) -> Option<&CustomModelRouter> {
|
|
self.custom_model_routers.iter().find(|m| m.llm_id() == *id)
|
|
}
|
|
|
|
fn custom_router_llm_info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
|
|
self.custom_model_routers
|
|
.iter()
|
|
.find(|m| m.info.id == *id)
|
|
.map(|m| &m.info)
|
|
}
|
|
|
|
fn custom_router_llm_info_for_id_if_enabled(&self, id: &LLMId) -> Option<&LLMInfo> {
|
|
FeatureFlag::CustomModelRouters
|
|
.is_enabled()
|
|
.then(|| self.custom_router_llm_info_for_id(id))
|
|
.flatten()
|
|
}
|
|
|
|
/// Iterator over the custom router picker entries, gated on the feature flag.
|
|
/// Mirrors [`Self::custom_llm_choices`].
|
|
pub fn custom_router_choices(&self) -> impl Iterator<Item = &LLMInfo> {
|
|
let enabled = FeatureFlag::CustomModelRouters.is_enabled();
|
|
self.custom_model_routers
|
|
.iter()
|
|
.filter(move |_| enabled)
|
|
.map(|m| &m.info)
|
|
}
|
|
|
|
/// Builds the custom_model_routers registry for an outbound request.
|
|
pub fn custom_model_routers_for_request(
|
|
&self,
|
|
base_id: &LLMId,
|
|
coding_id: &LLMId,
|
|
) -> api::request::settings::CustomModelRouters {
|
|
let mut models = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
for id in [base_id, coding_id] {
|
|
if let Some(entry) = self.custom_router_proto_entry(id) {
|
|
if seen.insert(entry.config_key.clone()) {
|
|
models.push(entry);
|
|
}
|
|
}
|
|
}
|
|
api::request::settings::CustomModelRouters { routers: models }
|
|
}
|
|
|
|
/// Returns the proto registry entry for a local custom-router id, or `None`
|
|
/// if `id` is not a known local router.
|
|
fn custom_router_proto_entry(
|
|
&self,
|
|
id: &LLMId,
|
|
) -> Option<api::request::settings::custom_model_routers::CustomModelRouter> {
|
|
self.custom_model_router_for_id(id).map(|m| m.to_proto())
|
|
}
|
|
|
|
/// Rebuilds `custom_model_routers` from the `model_configs/` directory,
|
|
/// then notifies subscribers.
|
|
///
|
|
/// Routers whose targets include an unknown model are excluded and a
|
|
/// warning is logged. The check uses the currently loaded model list
|
|
/// (server-fetched + cached), so it is best-effort at startup before
|
|
/// the server responds.
|
|
fn rebuild_custom_model_routers(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let local = WarpConfig::as_ref(ctx).custom_model_routers().clone();
|
|
|
|
let mut deduped = Vec::with_capacity(local.len());
|
|
let mut seen = HashSet::new();
|
|
for model in local {
|
|
if seen.insert(model.config_key()) {
|
|
deduped.push(model);
|
|
}
|
|
}
|
|
let mut validation_errors: Vec<ModelConfigError> = Vec::new();
|
|
deduped.retain(|router| {
|
|
let unknown: Vec<&str> = router
|
|
.all_targets()
|
|
.into_iter()
|
|
.filter(|id| self.get_llm_info(&LLMId::from(*id)).is_none())
|
|
.collect();
|
|
if unknown.is_empty() {
|
|
return true;
|
|
}
|
|
let error_message = format!("unknown target model(s): {}", unknown.join(", "));
|
|
log::warn!(
|
|
"Custom model router '{}': {} — excluding from picker",
|
|
router.info.display_name,
|
|
error_message,
|
|
);
|
|
validation_errors.push(ModelConfigError {
|
|
file_name: router
|
|
.source_path
|
|
.as_ref()
|
|
.and_then(|p| p.file_name())
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or(router.info.display_name.as_str())
|
|
.to_owned(),
|
|
file_path: router.source_path.clone().unwrap_or_default(),
|
|
error_message,
|
|
});
|
|
false
|
|
});
|
|
if !validation_errors.is_empty() {
|
|
WarpConfig::handle(ctx).update(ctx, |_, ctx| {
|
|
ctx.emit(WarpConfigUpdateEvent::ModelConfigErrors(validation_errors));
|
|
});
|
|
}
|
|
|
|
// vision is supported only when every concrete target model supports it.
|
|
for router in &mut deduped {
|
|
router.info.vision_supported = router.all_targets().iter().all(|id| {
|
|
self.get_llm_info(&LLMId::from(*id))
|
|
.is_some_and(|info| info.vision_supported)
|
|
});
|
|
}
|
|
|
|
self.custom_model_routers = deduped;
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
}
|
|
|
|
/// Resets any persisted *local* custom-router selection that no longer resolves
|
|
/// to a loaded definition, so a deleted/invalid local config falls back to the
|
|
/// default model and the visible selection updates. Scoped to local
|
|
/// ids so a cloud selection isn't reset by a local reload.
|
|
fn reconcile_stale_custom_router_selection(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let valid_local: HashSet<LLMId> = self
|
|
.custom_model_routers
|
|
.iter()
|
|
.map(|m| m.llm_id())
|
|
.collect();
|
|
|
|
let mut updated_agent_mode = false;
|
|
let mut updated_coding = false;
|
|
|
|
self.base_llm_for_terminal_view.retain(|_, id| {
|
|
let stale = custom_model_routers::is_local_custom_router_id(id.as_str())
|
|
&& !valid_local.contains(&*id);
|
|
updated_agent_mode |= stale;
|
|
!stale
|
|
});
|
|
|
|
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
|
|
for profile_id in profiles.get_all_profile_ids() {
|
|
let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) else {
|
|
continue;
|
|
};
|
|
let profile_data = profile.data();
|
|
let base_stale = profile_data.base_model.as_ref().is_some_and(|id| {
|
|
custom_model_routers::is_local_custom_router_id(id.as_str())
|
|
&& !valid_local.contains(id)
|
|
});
|
|
if base_stale {
|
|
profiles.set_base_model(profile_id, None, ctx);
|
|
profiles.set_context_window_limit(profile_id, None, ctx);
|
|
updated_agent_mode = true;
|
|
}
|
|
let coding_stale = profile_data.coding_model.as_ref().is_some_and(|id| {
|
|
custom_model_routers::is_local_custom_router_id(id.as_str())
|
|
&& !valid_local.contains(id)
|
|
});
|
|
if coding_stale {
|
|
profiles.set_coding_model(profile_id, None, ctx);
|
|
updated_coding = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
if updated_agent_mode {
|
|
self.trigger_snapshot_save(ctx);
|
|
ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM);
|
|
}
|
|
if updated_coding {
|
|
ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM);
|
|
}
|
|
}
|
|
|
|
/// Reads the user's current `ApiKeyManager.custom_endpoints` and replaces `custom_llms`
|
|
/// with synthetic `LLMInfo`s. Called on every `ApiKeyManagerEvent::KeysUpdated`, so adds,
|
|
/// edits, and removals all propagate immediately.
|
|
fn sanitize_disabled_custom_model_preferences(&mut self, _ctx: &mut ModelContext<Self>) {}
|
|
|
|
/// Returns the default base model as a fallback.
|
|
/// Returns `true` if at least one real AI provider model is configured and available.
|
|
/// When this returns `false`, agent mode should be disabled to avoid null references
|
|
/// or attempts to call unconfigured providers.
|
|
pub fn has_any_provider_models(&self) -> bool {
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.choices
|
|
.iter()
|
|
.any(|m| m.provider != LLMProvider::Unknown)
|
|
}
|
|
|
|
pub fn get_default_base_model(&self) -> &LLMInfo {
|
|
self.models_by_feature.agent_mode.default_llm_info()
|
|
}
|
|
|
|
/// Returns the default coding model as a fallback.
|
|
pub fn get_default_coding_model(&self) -> &LLMInfo {
|
|
self.models_by_feature.coding.default_llm_info()
|
|
}
|
|
|
|
/// Returns the preferred Codex model, if set by the server.
|
|
pub fn get_preferred_codex_model(&self) -> Option<&LLMInfo> {
|
|
self.models_by_feature
|
|
.agent_mode
|
|
.preferred_codex_model_id
|
|
.as_ref()
|
|
.and_then(|id| self.models_by_feature.agent_mode.info_for_id(id))
|
|
}
|
|
|
|
#[cfg(feature = "integration_tests")]
|
|
pub fn is_available_agent_mode_llm(&self, id: &LLMId) -> bool {
|
|
self.models_by_feature.agent_mode.info_for_id(id).is_some()
|
|
}
|
|
|
|
/// Creates a pane-level override for the Agent Mode LLM.
|
|
pub fn update_preferred_agent_mode_llm(
|
|
&mut self,
|
|
preferred_llm_id: &LLMId,
|
|
terminal_view_id: EntityId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let profile =
|
|
AIExecutionProfilesModel::as_ref(ctx).active_profile(Some(terminal_view_id), ctx);
|
|
|
|
let profile_default_model_id = profile
|
|
.data()
|
|
.base_model
|
|
.as_ref()
|
|
.and_then(|id| self.models_by_feature.agent_mode.info_for_id(id))
|
|
.unwrap_or_else(|| self.models_by_feature.agent_mode.default_llm_info())
|
|
.id
|
|
.clone();
|
|
|
|
// Only remove override if we're setting to the profile's default.
|
|
// Otherwise, always set the override explicitly.
|
|
let changed = if preferred_llm_id == &profile_default_model_id {
|
|
self.base_llm_for_terminal_view
|
|
.remove(&terminal_view_id)
|
|
.is_some()
|
|
} else {
|
|
self.base_llm_for_terminal_view
|
|
.insert(terminal_view_id, preferred_llm_id.clone());
|
|
true
|
|
};
|
|
|
|
if changed {
|
|
self.trigger_snapshot_save(ctx);
|
|
ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM);
|
|
}
|
|
}
|
|
|
|
/// Triggers a snapshot save to persist LLM override changes.
|
|
fn trigger_snapshot_save(&self, ctx: &mut ModelContext<Self>) {
|
|
ctx.dispatch_global_action("workspace:save_app", ());
|
|
}
|
|
|
|
pub fn update_preferred_coding_llm(
|
|
&self,
|
|
preferred_llm_id: &LLMId,
|
|
terminal_view_id: Option<EntityId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let new_value = if preferred_llm_id == &self.models_by_feature.coding.default_id {
|
|
None
|
|
} else {
|
|
Some(preferred_llm_id.clone())
|
|
};
|
|
|
|
let mut changed = false;
|
|
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
|
|
let profile = profiles.active_profile(terminal_view_id, ctx);
|
|
|
|
if profile.data().coding_model != new_value {
|
|
profiles.set_coding_model(*profile.id(), new_value, ctx);
|
|
changed = true;
|
|
}
|
|
});
|
|
|
|
if changed {
|
|
ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM);
|
|
}
|
|
}
|
|
|
|
pub fn new_choices_since_last_update(&self) -> Option<Vec<LLMInfo>> {
|
|
self.last_update.as_ref().map(|update| {
|
|
// We don't want to display new choices if they are warp branded.
|
|
let filter_choices: Vec<LLMInfo> = update
|
|
.new_choices
|
|
.clone()
|
|
.into_iter()
|
|
.filter(|choice| !choice.display_name.starts_with("lite"))
|
|
.collect();
|
|
|
|
filter_choices
|
|
})
|
|
}
|
|
|
|
pub fn should_show_new_choices_popup(&self, view_id: EntityId) -> bool {
|
|
self.last_update.as_ref().is_some_and(|update| {
|
|
let popup_state = &*update.popup_visibility_state.lock();
|
|
matches!(popup_state, UpdatePopupVisibilityState::WaitingToBeShown)
|
|
|| matches!(
|
|
popup_state,
|
|
UpdatePopupVisibilityState::Visible(id) if *id == view_id)
|
|
})
|
|
}
|
|
|
|
pub fn mark_new_choices_popup_as_shown(&self, view_id: EntityId) {
|
|
if let Some(update) = self.last_update.as_ref() {
|
|
if matches!(
|
|
&*update.popup_visibility_state.lock(),
|
|
UpdatePopupVisibilityState::WaitingToBeShown
|
|
) {
|
|
*update.popup_visibility_state.lock() =
|
|
UpdatePopupVisibilityState::Visible(view_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn hide_llm_popup(&self, view_id: EntityId) {
|
|
if !self.should_show_new_choices_popup(view_id) {
|
|
return;
|
|
}
|
|
let Some(last_update) = self.last_update.as_ref() else {
|
|
return;
|
|
};
|
|
*last_update.popup_visibility_state.lock() = UpdatePopupVisibilityState::Hidden;
|
|
}
|
|
|
|
/// Fetches the latest set of models from the server for the currently logged in user, and updates the model.
|
|
///
|
|
/// NOTE: Disabled — Galaxy uses only locally configured providers (Bedrock/LiteLLM).
|
|
/// No models are fetched from Warp's cloud API.
|
|
pub fn refresh_authed_models(&self, _ctx: &mut ModelContext<Self>) {
|
|
log::debug!("[llm] Server model fetch disabled — using local providers only");
|
|
}
|
|
|
|
/// No auth required (i.e. to populate the pre-login onboarding picker).
|
|
///
|
|
/// NOTE: Disabled — Galaxy uses only locally configured providers (Bedrock/LiteLLM).
|
|
/// No models are fetched from Warp's cloud API.
|
|
fn refresh_public_models(&self, _ctx: &mut ModelContext<Self>) {
|
|
log::debug!("[llm] Server model fetch disabled — using local providers only");
|
|
}
|
|
|
|
/// NOTE: Disabled — Galaxy uses only locally configured providers (Bedrock/LiteLLM).
|
|
pub fn refresh_available_models(&self, _ctx: &mut ModelContext<Self>) {
|
|
log::debug!("[llm] Server model fetch disabled — using local providers only");
|
|
}
|
|
|
|
/// Disabled — Galaxy does not accept model updates from Warp's server.
|
|
pub fn update_feature_model_choices(
|
|
&mut self,
|
|
_choices_result: Result<ModelsByFeature, anyhow::Error>,
|
|
_ctx: &mut ModelContext<Self>,
|
|
) {
|
|
log::debug!("[llm] Server model update ignored — using local providers only");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn set_models_by_feature_for_test(
|
|
&mut self,
|
|
models_by_feature: ModelsByFeature,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.models_by_feature = models_by_feature;
|
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
|
}
|
|
|
|
/// Disabled — Galaxy does not accept model updates from Warp's server.
|
|
fn on_server_update(&mut self, _update: ModelsByFeature, _ctx: &mut ModelContext<Self>) {
|
|
log::debug!("[llm] Server model update ignored — using local providers only");
|
|
}
|
|
|
|
/// Clear any model selections where the model is no longer supported
|
|
/// or effectively disabled, and clear orphaned context window limits
|
|
/// for non-configurable or unusable models.
|
|
///
|
|
/// Called both when the model list is refreshed from the server and when
|
|
/// BYOK API keys change (since `RequiresUpgrade` usability is BYOK-aware).
|
|
fn reconcile_disabled_model_preferences(&self, ctx: &mut ModelContext<Self>) {
|
|
let profiles_model = AIExecutionProfilesModel::handle(ctx);
|
|
profiles_model.update(ctx, |profiles, ctx| {
|
|
for profile_id in profiles.get_all_profile_ids() {
|
|
if let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) {
|
|
let profile_data = profile.data();
|
|
let preferred_base_model = profile_data.base_model.clone();
|
|
let effective_base_model_id = preferred_base_model
|
|
.as_ref()
|
|
.unwrap_or(&self.models_by_feature.agent_mode.default_id);
|
|
let effective_base_model_usable = self
|
|
.models_by_feature
|
|
.agent_mode
|
|
.usable_info_for_id(effective_base_model_id, ctx)
|
|
.or_else(|| {
|
|
self.custom_llm_info_for_id_if_enabled(effective_base_model_id, ctx)
|
|
});
|
|
let effective_base_model_unusable = effective_base_model_usable.is_none();
|
|
let effective_base_model_is_configurable = effective_base_model_usable
|
|
.is_some_and(|info| info.context_window.is_configurable);
|
|
let has_context_window_limit = profile_data.context_window_limit.is_some();
|
|
|
|
if preferred_base_model.is_some() && effective_base_model_unusable {
|
|
profiles.set_base_model(profile_id, None, ctx);
|
|
}
|
|
if has_context_window_limit
|
|
&& (effective_base_model_unusable || !effective_base_model_is_configurable)
|
|
{
|
|
profiles.set_context_window_limit(profile_id, None, ctx);
|
|
}
|
|
if let Some(preferred_llm_id) = &profile.data().coding_model {
|
|
if self
|
|
.models_by_feature
|
|
.coding
|
|
.usable_info_for_id(preferred_llm_id, ctx)
|
|
.or_else(|| {
|
|
self.custom_llm_info_for_id_if_enabled(preferred_llm_id, ctx)
|
|
})
|
|
.is_none()
|
|
{
|
|
profiles.set_coding_model(profile_id, None, ctx);
|
|
}
|
|
}
|
|
if let Some(preferred_llm_id) = &profile.data().cli_agent_model {
|
|
if self
|
|
.get_cli_agent_available()
|
|
.usable_info_for_id(preferred_llm_id, ctx)
|
|
.or_else(|| {
|
|
self.custom_llm_info_for_id_if_enabled(preferred_llm_id, ctx)
|
|
})
|
|
.is_none()
|
|
{
|
|
profiles.set_cli_agent_model(profile_id, None, ctx);
|
|
}
|
|
}
|
|
if let Some(preferred_llm_id) = &profile.data().computer_use_model {
|
|
if self
|
|
.get_computer_use_available()
|
|
.usable_info_for_id(preferred_llm_id, ctx)
|
|
.is_none()
|
|
{
|
|
profiles.set_computer_use_model(profile_id, None, ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn vision_supported(&self, app: &AppContext, terminal_view_id: Option<EntityId>) -> bool {
|
|
self.get_active_base_model(app, terminal_view_id)
|
|
.vision_supported
|
|
}
|
|
|
|
pub fn get_base_llm_override(&self, terminal_view_id: EntityId) -> Option<String> {
|
|
if let Some(override_str) = self
|
|
.base_llm_for_terminal_view
|
|
.get(&terminal_view_id)
|
|
.and_then(|llm_id| serde_json::to_string(llm_id).ok())
|
|
{
|
|
return Some(override_str);
|
|
}
|
|
|
|
log::debug!("LLM override not found in memory for terminal view: {terminal_view_id:?}");
|
|
None
|
|
}
|
|
|
|
/// Removes the LLM override for a terminal view.
|
|
/// This ensures that the new profile's default model is used.
|
|
pub fn remove_llm_override(
|
|
&mut self,
|
|
terminal_view_id: EntityId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let old = self.base_llm_for_terminal_view.remove(&terminal_view_id);
|
|
if old.is_some() {
|
|
self.trigger_snapshot_save(ctx);
|
|
ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum LLMPreferencesEvent {
|
|
UpdatedAvailableLLMs,
|
|
UpdatedActiveAgentModeLLM,
|
|
UpdatedActiveCodingLLM,
|
|
}
|
|
|
|
impl Entity for LLMPreferences {
|
|
type Event = LLMPreferencesEvent;
|
|
}
|
|
|
|
impl SingletonEntity for LLMPreferences {}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn acp_agent_display_name(agent_id: &str) -> String {
|
|
match agent_id.to_ascii_lowercase().as_str() {
|
|
"codex" => "Codex".to_owned(),
|
|
"opencode" => "OpenCode".to_owned(),
|
|
_ => agent_id.to_owned(),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn acp_model_is_enabled(value: &serde_json::Value, bedrock_enabled: bool) -> bool {
|
|
bedrock_enabled
|
|
|| !value
|
|
.as_str()
|
|
.is_some_and(|model_id| model_id.starts_with("amazon-bedrock/"))
|
|
}
|
|
|
|
fn get_new_agent_mode_choices(
|
|
old_config: &AvailableLLMs,
|
|
new_config: &AvailableLLMs,
|
|
) -> Vec<LLMInfo> {
|
|
let old_ids: HashSet<_> = old_config.choices.iter().map(|info| &info.id).collect();
|
|
new_config
|
|
.choices
|
|
.iter()
|
|
.filter(|info| !old_ids.contains(&info.id))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 {
|
|
model.max_input_tokens.unwrap_or(model.context_size)
|
|
}
|
|
|
|
/// Merges endpoint metadata into a provider's configured models without
|
|
/// discarding local routing choices or manually configured models.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub(crate) fn merge_discovered_provider_models(
|
|
existing_models: &[OpenAIModelConfig],
|
|
discovered_models: Vec<OpenAIModelConfig>,
|
|
) -> Vec<OpenAIModelConfig> {
|
|
let mut merged = Vec::with_capacity(discovered_models.len() + existing_models.len());
|
|
let mut discovered_ids = HashSet::new();
|
|
|
|
for mut discovered in discovered_models {
|
|
if !discovered_ids.insert(discovered.model_id.clone()) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(existing) = existing_models
|
|
.iter()
|
|
.find(|model| model.model_id == discovered.model_id)
|
|
{
|
|
discovered.display_name = existing.display_name.clone();
|
|
discovered.enabled = existing.enabled;
|
|
discovered.use_rig = existing.use_rig;
|
|
if existing.supports_system_messages.is_some() {
|
|
discovered.supports_system_messages = existing.supports_system_messages;
|
|
}
|
|
if discovered.provider.is_none() {
|
|
discovered.provider = existing.provider.clone();
|
|
}
|
|
} else {
|
|
discovered.use_rig = true;
|
|
}
|
|
if discovered.model_id.starts_with("codex-gpt-") {
|
|
discovered.supports_system_messages = Some(false);
|
|
}
|
|
|
|
merged.push(discovered);
|
|
}
|
|
|
|
merged.extend(
|
|
existing_models
|
|
.iter()
|
|
.filter(|model| !discovered_ids.contains(&model.model_id))
|
|
.cloned(),
|
|
);
|
|
merged
|
|
}
|
|
|
|
/// Merges ChatGPT subscription model metadata as a backend-owned catalog.
|
|
///
|
|
/// Unlike generic OpenAI-compatible providers, ChatGPT subscription models come
|
|
/// from Codex's first-party model catalog. Models omitted from a successful
|
|
/// refresh should stop appearing in Galaxy unless they are rediscovered later.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub(crate) fn merge_discovered_chatgpt_subscription_models(
|
|
existing_models: &[OpenAIModelConfig],
|
|
discovered_models: Vec<OpenAIModelConfig>,
|
|
) -> Vec<OpenAIModelConfig> {
|
|
let mut merged = Vec::with_capacity(discovered_models.len());
|
|
let mut discovered_ids = HashSet::new();
|
|
|
|
for mut discovered in discovered_models {
|
|
if !discovered_ids.insert(discovered.model_id.clone()) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(existing) = existing_models
|
|
.iter()
|
|
.find(|model| model.model_id == discovered.model_id)
|
|
{
|
|
discovered.enabled = existing.enabled;
|
|
discovered.use_rig = existing.use_rig;
|
|
if existing.supports_system_messages.is_some() {
|
|
discovered.supports_system_messages = existing.supports_system_messages;
|
|
}
|
|
for (key, value) in &existing.capability_overrides {
|
|
discovered.capability_overrides.insert(key.clone(), *value);
|
|
}
|
|
}
|
|
|
|
merged.push(discovered);
|
|
}
|
|
|
|
merged
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String {
|
|
format!("{model_id}::reasoning::{reasoning_effort}")
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow {
|
|
let context_size = openai_model_context_size(model);
|
|
LLMContextWindow {
|
|
is_configurable: false,
|
|
min: context_size,
|
|
max: context_size,
|
|
default_max: context_size,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn u32_from_json_any(value: &serde_json::Value, keys: &[&str]) -> Option<u32> {
|
|
keys.iter()
|
|
.find_map(|key| value[*key].as_u64())
|
|
.and_then(|value| u32::try_from(value).ok())
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn normalize_codex_release_version(version: &str) -> Option<String> {
|
|
let version = version.trim();
|
|
let version = version
|
|
.strip_prefix("rust-v")
|
|
.or_else(|| version.strip_prefix('v'))
|
|
.unwrap_or(version);
|
|
if version.is_empty()
|
|
|| !version
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|first| first.is_ascii_digit())
|
|
|| !version.chars().all(|character| {
|
|
character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '+')
|
|
})
|
|
{
|
|
return None;
|
|
}
|
|
Some(version.to_string())
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn codex_client_version_from_release_json(body: &serde_json::Value) -> Option<String> {
|
|
body["tag_name"]
|
|
.as_str()
|
|
.and_then(normalize_codex_release_version)
|
|
.or_else(|| {
|
|
body["name"]
|
|
.as_str()
|
|
.and_then(normalize_codex_release_version)
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
async fn fetch_latest_codex_client_version(client: &reqwest::Client) -> Result<String, String> {
|
|
let response = client
|
|
.get(CODEX_LATEST_RELEASE_URL)
|
|
.header(reqwest::header::USER_AGENT, "Galaxy")
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Could not fetch the latest Codex release: {error}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!(
|
|
"Could not fetch the latest Codex release: HTTP {}",
|
|
response.status()
|
|
));
|
|
}
|
|
|
|
let body: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|error| format!("Could not parse the latest Codex release: {error}"))?;
|
|
codex_client_version_from_release_json(&body)
|
|
.ok_or_else(|| "The latest Codex release did not include a usable version.".to_string())
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
async fn fetch_from_chatgpt_codex_models(
|
|
client_version: &str,
|
|
credentials: crate::ai::chatgpt_auth::ChatGPTAuthCredentials,
|
|
client: &reqwest::Client,
|
|
) -> Result<Vec<OpenAIModelConfig>, String> {
|
|
let mut request = client
|
|
.get(CHATGPT_CODEX_MODELS_URL)
|
|
.query(&[("client_version", client_version)])
|
|
.header(
|
|
reqwest::header::AUTHORIZATION,
|
|
format!("Bearer {}", credentials.access_token),
|
|
)
|
|
.header(reqwest::header::ACCEPT, "application/json")
|
|
.header(reqwest::header::USER_AGENT, "Galaxy");
|
|
if let Some(account_id) = credentials.account_id {
|
|
request = request.header("ChatGPT-Account-ID", account_id);
|
|
}
|
|
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Could not fetch ChatGPT subscription models: {error}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!(
|
|
"ChatGPT model discovery failed: HTTP {status} {}",
|
|
body.chars().take(500).collect::<String>()
|
|
));
|
|
}
|
|
|
|
let body: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|error| format!("Could not parse ChatGPT subscription models: {error}"))?;
|
|
let models = chatgpt_models_from_codex_response(&body);
|
|
if models.is_empty() {
|
|
return Err("ChatGPT model discovery returned no visible models.".to_string());
|
|
}
|
|
log::info!(
|
|
"[chatgpt/models] Fetched {} model(s) from Codex models endpoint using client_version={client_version}",
|
|
models.len()
|
|
);
|
|
Ok(models)
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIModelConfig> {
|
|
let Some(models) = body["models"].as_array() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
models
|
|
.iter()
|
|
.filter_map(|model| {
|
|
if model["visibility"].as_str() != Some("list") {
|
|
return None;
|
|
}
|
|
|
|
let model_id = model["slug"].as_str()?.trim();
|
|
if model_id.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let context_size = u32_from_json_any(model, &["context_window", "max_context_window"])
|
|
.unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE);
|
|
let effective_context_percent = model["effective_context_window_percent"]
|
|
.as_u64()
|
|
.and_then(|value| u32::try_from(value).ok())
|
|
.unwrap_or(100);
|
|
let max_input_tokens = Some(
|
|
context_size
|
|
.checked_mul(effective_context_percent)
|
|
.map(|tokens| tokens / 100)
|
|
.unwrap_or(context_size),
|
|
);
|
|
|
|
let vision_supported = model["input_modalities"]
|
|
.as_array()
|
|
.map(|modalities| {
|
|
modalities
|
|
.iter()
|
|
.any(|modality| modality.as_str() == Some("image"))
|
|
})
|
|
.unwrap_or(true);
|
|
let reasoning_efforts = model["supported_reasoning_levels"]
|
|
.as_array()
|
|
.map(|levels| {
|
|
levels
|
|
.iter()
|
|
.filter_map(|level| level["effort"].as_str())
|
|
.filter(|effort| !effort.trim().is_empty())
|
|
.map(str::to_string)
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Some(OpenAIModelConfig {
|
|
model_id: model_id.to_string(),
|
|
display_name: model["display_name"]
|
|
.as_str()
|
|
.filter(|display_name| !display_name.trim().is_empty())
|
|
.unwrap_or(model_id)
|
|
.to_string(),
|
|
vision_supported,
|
|
context_size,
|
|
max_input_tokens,
|
|
max_output_tokens: None,
|
|
provider: Some("openai".to_string()),
|
|
use_rig: true,
|
|
supports_system_messages: Some(true),
|
|
capability_overrides: std::collections::HashMap::new(),
|
|
reasoning_efforts,
|
|
enabled: true,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Fetches model metadata from LiteLLM's `/model/info` endpoint which returns rich
|
|
/// metadata including accurate context window sizes, output token limits, and
|
|
/// capability flags (vision, function calling).
|
|
///
|
|
/// Returns `None` if the endpoint is unavailable or doesn't return valid data,
|
|
/// allowing the caller to fall back to the standard `/models` endpoint.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
async fn fetch_from_litellm_model_info(
|
|
base_url: &str,
|
|
api_key: Option<&str>,
|
|
client: &reqwest::Client,
|
|
) -> Option<Vec<OpenAIModelConfig>> {
|
|
let url = format!("{base_url}/model/info");
|
|
let mut request = client.get(&url);
|
|
if let Some(key) = api_key {
|
|
request = request.header("Authorization", format!("Bearer {key}"));
|
|
}
|
|
|
|
let response = match request.send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
log::info!("[openai/litellm] /model/info not available ({e}), falling back to /models");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
if !response.status().is_success() {
|
|
log::info!(
|
|
"[openai/litellm] /model/info returned HTTP {}, falling back to /models",
|
|
response.status()
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let body: serde_json::Value = match response.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
log::warn!("[openai/litellm] Failed to parse /model/info response: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let data = body["data"].as_array()?;
|
|
if data.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let models: Vec<OpenAIModelConfig> = data
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
let model_name = entry["model_name"].as_str()?;
|
|
let model_info = &entry["model_info"];
|
|
|
|
let max_input_tokens = model_info["max_input_tokens"]
|
|
.as_u64()
|
|
.and_then(|v| u32::try_from(v).ok());
|
|
let max_output_tokens = model_info["max_output_tokens"]
|
|
.as_u64()
|
|
.and_then(|v| u32::try_from(v).ok());
|
|
let context_size = max_input_tokens.unwrap_or(200_000);
|
|
|
|
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true);
|
|
|
|
let display_name = model_name.replace(['-', '_'], " ");
|
|
let display_name = display_name
|
|
.split_whitespace()
|
|
.map(|word| {
|
|
let mut chars = word.chars();
|
|
match chars.next() {
|
|
None => String::new(),
|
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
|
|
// Detect provider from the underlying model path if available
|
|
let litellm_model = entry["litellm_params"]["model"]
|
|
.as_str()
|
|
.unwrap_or(model_name);
|
|
let provider = if litellm_model.contains("claude")
|
|
|| litellm_model.contains("anthropic")
|
|
|| litellm_model.contains("bedrock")
|
|
{
|
|
Some("anthropic".to_string())
|
|
} else if litellm_model.contains("gpt")
|
|
|| litellm_model.contains("o1")
|
|
|| litellm_model.contains("o3")
|
|
{
|
|
Some("openai".to_string())
|
|
} else if litellm_model.contains("gemini") {
|
|
Some("google".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
log::info!(
|
|
"[openai/litellm] Discovered model '{}': context={}, max_output={}, vision={}",
|
|
model_name,
|
|
context_size,
|
|
max_output_tokens.unwrap_or(0),
|
|
vision_supported,
|
|
);
|
|
|
|
Some(OpenAIModelConfig {
|
|
model_id: model_name.to_string(),
|
|
display_name,
|
|
vision_supported,
|
|
context_size,
|
|
max_input_tokens,
|
|
max_output_tokens,
|
|
provider,
|
|
use_rig: false,
|
|
supports_system_messages: if model_name.starts_with("codex-gpt-") {
|
|
Some(false)
|
|
} else {
|
|
model_info["supports_system_messages"].as_bool()
|
|
},
|
|
capability_overrides: std::collections::HashMap::new(),
|
|
reasoning_efforts: Vec::new(),
|
|
enabled: true,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
if models.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
log::info!(
|
|
"[openai/litellm] Fetched {} model(s) from /model/info endpoint",
|
|
models.len()
|
|
);
|
|
Some(models)
|
|
}
|
|
|
|
/// Fetches models from the standard OpenAI-compatible `/models` endpoint.
|
|
/// Used as a fallback when `/model/info` is unavailable.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
async fn fetch_from_openai_models(
|
|
base_url: &str,
|
|
api_key: Option<&str>,
|
|
client: &reqwest::Client,
|
|
) -> Vec<OpenAIModelConfig> {
|
|
let url = format!("{base_url}/models");
|
|
let mut request = client.get(&url);
|
|
if let Some(key) = api_key {
|
|
request = request.header("Authorization", format!("Bearer {key}"));
|
|
}
|
|
|
|
let response = match request.send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
log::warn!("[openai/litellm] Failed to fetch models from /models endpoint: {e}");
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
if !response.status().is_success() {
|
|
log::warn!(
|
|
"[openai/litellm] /models returned HTTP {}",
|
|
response.status()
|
|
);
|
|
return Vec::new();
|
|
}
|
|
|
|
let body: serde_json::Value = match response.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
log::warn!("[openai/litellm] Failed to parse /models response: {e}");
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
fn u32_from_any(value: &serde_json::Value, keys: &[&str]) -> Option<u32> {
|
|
keys.iter()
|
|
.find_map(|key| value[*key].as_u64())
|
|
.and_then(|value| u32::try_from(value).ok())
|
|
}
|
|
|
|
let models: Vec<OpenAIModelConfig> = body["data"]
|
|
.as_array()
|
|
.map(Vec::as_slice)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|m| {
|
|
let id = m["id"].as_str()?;
|
|
let max_input_tokens = u32_from_any(
|
|
m,
|
|
&["max_input_tokens", "input_token_limit", "max_prompt_tokens"],
|
|
);
|
|
let context_size = u32_from_any(m, &["max_model_len", "context_window", "token_size"])
|
|
.or(max_input_tokens)
|
|
.unwrap_or(200_000);
|
|
let max_output_tokens = u32_from_any(
|
|
m,
|
|
&[
|
|
"max_output_tokens",
|
|
"output_token_limit",
|
|
"max_completion_tokens",
|
|
"max_tokens",
|
|
],
|
|
);
|
|
|
|
let display_name = id
|
|
.split('/')
|
|
.next_back()
|
|
.unwrap_or(id)
|
|
.replace(['-', '_'], " ");
|
|
let display_name = display_name
|
|
.split_whitespace()
|
|
.map(|word| {
|
|
let mut chars = word.chars();
|
|
match chars.next() {
|
|
None => String::new(),
|
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
|
|
let provider = if id.contains("claude") || id.contains("anthropic") {
|
|
Some("anthropic".to_string())
|
|
} else if id.contains("gpt") || id.contains("o1") || id.contains("o3") {
|
|
Some("openai".to_string())
|
|
} else if id.contains("gemini") {
|
|
Some("google".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(OpenAIModelConfig {
|
|
model_id: id.to_string(),
|
|
display_name,
|
|
vision_supported: m["supports_vision"]
|
|
.as_bool()
|
|
.or_else(|| m["vision_support"].as_bool())
|
|
.unwrap_or(true),
|
|
context_size,
|
|
max_input_tokens,
|
|
max_output_tokens,
|
|
provider,
|
|
use_rig: false,
|
|
supports_system_messages: if id.starts_with("codex-gpt-") {
|
|
Some(false)
|
|
} else {
|
|
m["supports_system_messages"].as_bool()
|
|
},
|
|
capability_overrides: std::collections::HashMap::new(),
|
|
reasoning_efforts: Vec::new(),
|
|
enabled: true,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
log::info!(
|
|
"[openai/litellm] Fetched {} model(s) from /models endpoint",
|
|
models.len()
|
|
);
|
|
models
|
|
}
|
|
|
|
/// Gets the last cached LLM metadata.
|
|
/// Disabled — Galaxy uses only locally configured providers. No server-fetched models
|
|
/// are cached or restored. The model list is built exclusively from Bedrock/LiteLLM
|
|
/// settings at startup.
|
|
fn get_cached_models(_app: &mut AppContext) -> Option<ModelsByFeature> {
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "llms_tests.rs"]
|
|
mod tests;
|