diff --git a/Cargo.lock b/Cargo.lock index ec03eb4dd1c..bc2ec8b4d36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15936,7 +15936,7 @@ dependencies = [ [[package]] name = "warp_multi_agent_api" version = "0.0.0" -source = "git+https://github.com/warpdotdev/warp-proto-apis.git?rev=8320a94875bce9e730d9bfc4abda1544279e5db3#8320a94875bce9e730d9bfc4abda1544279e5db3" +source = "git+https://github.com/warpdotdev/warp-proto-apis.git?rev=1766ae05d1a6#1766ae05d1a6377041ed11798de18ce805460a78" dependencies = [ "prost", "prost-reflect", diff --git a/Cargo.toml b/Cargo.toml index 1c1bb1de030..2be27145b95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -348,7 +348,7 @@ version-compare = "0.1" vte = { git = "https://github.com/warpdotdev/vte.git", rev = "4b399c87b63ba88f45709edaa6383fc519f6c900", default-features = false } walkdir = "2" warp-workflows = { git = "https://github.com/warpdotdev/workflows", rev = "793a98ddda6ef19682aed66364faebd2829f0e01" } -warp_multi_agent_api = { git = "https://github.com/warpdotdev/warp-proto-apis.git", rev = "8320a94875bce9e730d9bfc4abda1544279e5db3" } +warp_multi_agent_api = { git = "https://github.com/warpdotdev/warp-proto-apis.git", rev = "1766ae05d1a6" } wasm-bindgen = "0.2.89" wasm-bindgen-futures = "0.4.42" web-sys = { version = "0.3.69", features = [ diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index e08d716e761..520fe657dce 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; -use ai::api_keys::{ApiKeyManager, CustomEndpointParams, CustomEndpointSchema}; +use ai::api_keys::{ + ApiKeyManager, CustomEndpointModel, CustomEndpointParams, CustomEndpointSchema, +}; use warp_core::features::FeatureFlag; use warp_multi_agent_api as api; use warpui::{App, SingletonEntity}; @@ -771,11 +773,12 @@ fn update_cost_and_usage_resolves_custom_endpoint_alias_for_footer_usage() { name: "Endpoint".to_string(), url: "https://custom.example".to_string(), api_key: "key".to_string(), - models: vec![( - "raw-model".to_string(), - Some("Friendly alias".to_string()), - Some("config-key".to_string()), - )], + models: vec![CustomEndpointModel { + name: "raw-model".to_string(), + alias: Some("Friendly alias".to_string()), + config_key: "config-key".to_string(), + ..Default::default() + }], schema: CustomEndpointSchema::default(), }, ctx, @@ -1031,11 +1034,12 @@ fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_label name: "Endpoint".to_string(), url: "https://custom.example".to_string(), api_key: "key".to_string(), - models: vec![( - "raw-model".to_string(), - Some("Resolved custom".to_string()), - Some("config-key".to_string()), - )], + models: vec![CustomEndpointModel { + name: "raw-model".to_string(), + alias: Some("Resolved custom".to_string()), + config_key: "config-key".to_string(), + ..Default::default() + }], schema: CustomEndpointSchema::default(), }, ctx, diff --git a/app/src/ai/blocklist/controller_tests.rs b/app/src/ai/blocklist/controller_tests.rs index dfe6d97f6f5..cbe2f0af8a9 100644 --- a/app/src/ai/blocklist/controller_tests.rs +++ b/app/src/ai/blocklist/controller_tests.rs @@ -3,8 +3,8 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; use ai::api_keys::{ - ApiKeyManager, AwsCredentials, AwsCredentialsState, CustomEndpointParams, GeapCredentials, - GeapCredentialsState, + ApiKeyManager, AwsCredentials, AwsCredentialsState, CustomEndpointModel, CustomEndpointParams, + GeapCredentials, GeapCredentialsState, }; use chrono::Local; use uuid::Uuid; @@ -973,7 +973,10 @@ fn passive_suggestions_request_params_scope_member_byo_credentials_by_the_window name: "member-endpoint".to_string(), url: "https://example.com/v1".to_string(), api_key: "endpoint-key".to_string(), - models: vec![("member-model".to_string(), None, None)], + models: vec![CustomEndpointModel { + name: "member-model".to_string(), + ..Default::default() + }], schema: Default::default(), }, ctx, diff --git a/app/src/ai/custom_endpoints.rs b/app/src/ai/custom_endpoints.rs index 42bd61efa1f..c6cba38a5a1 100644 --- a/app/src/ai/custom_endpoints.rs +++ b/app/src/ai/custom_endpoints.rs @@ -1,6 +1,6 @@ use ai::api_keys::{ ApiKeyManager, CustomEndpointDefinition, CustomEndpointDefinitions, CustomEndpointId, - CustomEndpointModel, CustomEndpointParams, + CustomEndpointParams, }; use settings::Setting; use uuid::Uuid; @@ -151,7 +151,8 @@ pub(crate) fn save( .id_at(index) .cloned() .ok_or_else(|| anyhow::anyhow!("custom endpoint index is out of bounds"))?; - definitions.insert(id.clone(), definition_from_params(params.clone()))?; + let definition = definition_from_params(params.clone()); + definitions.insert(id.clone(), definition)?; let old_key = ApiKeyManager::as_ref(ctx) .custom_endpoint_key(&id) .map(ToOwned::to_owned); @@ -191,12 +192,11 @@ fn definition_from_params(params: CustomEndpointParams) -> CustomEndpointDefinit models: params .models .into_iter() - .map(|(name, alias, config_key)| CustomEndpointModel { - name, - alias, - config_key: config_key - .filter(|key| !key.trim().is_empty()) - .unwrap_or_else(|| Uuid::new_v4().to_string()), + .map(|mut model| { + if model.config_key.trim().is_empty() { + model.config_key = Uuid::new_v4().to_string(); + } + model }) .collect(), } diff --git a/app/src/ai/custom_endpoints_tests.rs b/app/src/ai/custom_endpoints_tests.rs index ebb1593d9f8..94938ac58c8 100644 --- a/app/src/ai/custom_endpoints_tests.rs +++ b/app/src/ai/custom_endpoints_tests.rs @@ -1,7 +1,7 @@ use std::cell::Cell; use std::rc::Rc; -use ai::api_keys::CustomEndpointSchema; +use ai::api_keys::{ComputerUseToolset, CustomEndpointModel, CustomEndpointSchema}; use settings::Setting as _; use warpui::{App, SingletonEntity as _}; use warpui_extras::secure_storage; @@ -9,6 +9,39 @@ use warpui_extras::secure_storage; use super::*; use crate::settings::init_and_register_user_preferences; +#[test] +fn endpoint_params_supply_toolsets_for_existing_and_new_models() { + let edited = definition_from_params(CustomEndpointParams { + name: "Renamed gateway".into(), + url: "https://gateway.example/v1".into(), + api_key: "rotated-key".into(), + schema: CustomEndpointSchema::AnthropicMessages, + models: vec![ + CustomEndpointModel { + name: "model".into(), + alias: Some("Alias".into()), + config_key: "pinned-key".into(), + computer_use_toolset: ComputerUseToolset::None, + }, + CustomEndpointModel { + name: "new".into(), + computer_use_toolset: ComputerUseToolset::AnthropicComputer20251124, + ..Default::default() + }, + ], + }); + assert_eq!(edited.models[0].config_key, "pinned-key"); + assert!(!edited.models[1].config_key.is_empty()); + assert_eq!( + edited.models[0].computer_use_toolset, + ComputerUseToolset::None + ); + assert_eq!( + edited.models[1].computer_use_toolset, + ComputerUseToolset::AnthropicComputer20251124 + ); +} + struct FailingSecureStorage { write_attempts: Rc>, } @@ -57,6 +90,7 @@ fn removal_succeeds_when_credential_cleanup_fails() { name: "model".to_owned(), alias: None, config_key: "config-key".to_owned(), + computer_use_toolset: Default::default(), }], }, ) diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 872ecf09b95..f1eef73a89c 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -1242,19 +1242,29 @@ impl LLMPreferences { let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); let available = self.get_computer_use_available(scope.team_uid(), app); - let selected_model = terminal_view_id - .and_then(|id| self.computer_use_llm_for_terminal_view.get(&id)) - .map(Option::as_ref) - .unwrap_or_else(|| profile.data().computer_use_model.as_ref()); - selected_model - .and_then(|id| available.info_for_id(id)) + if let Some(selection) = + terminal_view_id.and_then(|id| self.computer_use_llm_for_terminal_view.get(&id)) + { + match selection { + Some(id) => { + if let Some(info) = self.usable_model_info_for_id(available, id, app) { + return info; + } + } + None => return self.get_default_computer_use_model(scope, app), + } + } + profile + .data() + .computer_use_model + .as_ref() + .and_then(|id| self.usable_model_info_for_id(available, id, app)) .unwrap_or_else(|| self.get_default_computer_use_model(scope, app)) } /// Returns the effective default computer use model as a fallback: the /// server default when usable, else the first usable choice, else the - /// (possibly disabled) server default. No custom-endpoint fallback here: - /// custom models aren't offered for computer use. + /// (possibly disabled) server default. Custom endpoints are only used when explicitly selected. pub fn get_default_computer_use_model<'a>( &'a self, scope: &(impl TeamScope + ?Sized), diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 8ae181f7c16..079f65a69b6 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -324,6 +324,7 @@ fn model(name: &str, alias: Option<&str>, config_key: &str) -> CustomEndpointMod name: name.into(), alias: alias.map(|s| s.into()), config_key: config_key.into(), + computer_use_toolset: Default::default(), } } @@ -634,11 +635,11 @@ fn active_models_fall_back_to_usable_choice_or_custom_endpoint_when_default_disa name: "local".to_string(), url: "https://example.com/v1".to_string(), api_key: "test-key".to_string(), - models: vec![( - "custom-model".to_string(), - None, - Some(custom_model_id.to_string()), - )], + models: vec![CustomEndpointModel { + name: "custom-model".to_string(), + config_key: custom_model_id.to_string(), + ..Default::default() + }], schema: ai::api_keys::CustomEndpointSchema::default(), }, ctx, @@ -819,11 +820,12 @@ fn reconcile_preserves_custom_models_saved_on_execution_profile() { name: "local".to_string(), url: "https://example.com/v1".to_string(), api_key: "test-key".to_string(), - models: vec![( - "custom-model".to_string(), - Some("Custom Model".to_string()), - Some(custom_model_id.to_string()), - )], + models: vec![CustomEndpointModel { + name: "custom-model".to_string(), + alias: Some("Custom Model".to_string()), + config_key: custom_model_id.to_string(), + ..Default::default() + }], schema: ai::api_keys::CustomEndpointSchema::default(), }, ctx, @@ -1398,6 +1400,30 @@ fn resetting_profiles_clears_session_computer_use() { }); } +#[cfg(not(target_family = "wasm"))] +#[test] +fn computer_use_model_falls_back_when_the_profile_is_unavailable() { + with_computer_use_test_context(|app| { + AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| { + profiles.set_computer_use_model( + &profiles.default_profile_id(), + Some("unknown-model".into()), + ctx, + ); + }); + + LLMPreferences::handle(app).read(app, |preferences, ctx| { + assert_eq!( + preferences + .get_active_computer_use_model(&TeamlessScopeForTest, ctx, None) + .id + .as_str(), + "computer-use-agent-auto" + ); + }); + }); +} + #[cfg(not(target_family = "wasm"))] #[test] fn computer_use_model_override_takes_precedence_over_the_profile() { @@ -1541,10 +1567,17 @@ fn clearing_computer_use_model_override_restores_the_profile_model() { #[cfg(not(target_family = "wasm"))] #[test] -fn computer_use_model_override_falls_back_to_default_for_an_unknown_model() { +fn computer_use_model_falls_back_when_the_override_is_unavailable() { with_computer_use_test_context(|app| { let preferences = LLMPreferences::handle(app); let terminal_view_id = EntityId::new(); + AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| { + profiles.set_computer_use_model( + &profiles.default_profile_id(), + Some("profile-model".into()), + ctx, + ); + }); preferences.update(app, |preferences, _| { preferences @@ -1561,7 +1594,7 @@ fn computer_use_model_override_falls_back_to_default_for_an_unknown_model() { ) .id .as_str(), - "computer-use-agent-auto" + "profile-model" ); }); }); diff --git a/app/src/settings_view/custom_inference_modal.rs b/app/src/settings_view/custom_inference_modal.rs index cd7f09d5a27..6ed07601515 100644 --- a/app/src/settings_view/custom_inference_modal.rs +++ b/app/src/settings_view/custom_inference_modal.rs @@ -1,4 +1,7 @@ -use ::ai::api_keys::{CustomEndpoint, CustomEndpointSchema, validate_custom_endpoint_url}; +use ::ai::api_keys::{ + ComputerUseToolset, CustomEndpoint, CustomEndpointModel, CustomEndpointSchema, + validate_custom_endpoint_url, +}; use warp_editor::editor::NavigationKey; use warpui::elements::{ Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, @@ -54,7 +57,7 @@ pub enum CustomEndpointModalEvent { url: String, api_key: String, schema: CustomEndpointSchema, - models: Vec<(String, Option, Option)>, + models: Vec, }, SaveEndpoint { index: usize, @@ -62,7 +65,7 @@ pub enum CustomEndpointModalEvent { url: String, api_key: String, schema: CustomEndpointSchema, - models: Vec<(String, Option, Option)>, + models: Vec, }, RemoveEndpoint { index: usize, @@ -84,6 +87,7 @@ struct ModelRow { alias_editor: ViewHandle, remove_mouse_state: MouseStateHandle, config_key: Option, + computer_use_toolset: ComputerUseToolset, } pub struct CustomEndpointModal { @@ -216,9 +220,7 @@ impl CustomEndpointModal { if let Some(ep) = endpoint { for model in &ep.models { model_rows.push(Self::create_model_row( - Some(&model.name), - model.alias.as_deref(), - Some(model.config_key.clone()), + Some(model), font_family, &text_colors, ctx, @@ -226,14 +228,7 @@ impl CustomEndpointModal { } } if model_rows.is_empty() { - model_rows.push(Self::create_model_row( - None, - None, - None, - font_family, - &text_colors, - ctx, - )); + model_rows.push(Self::create_model_row(None, font_family, &text_colors, ctx)); } ctx.subscribe_to_view(&endpoint_name_editor, |me, _, event, ctx| { @@ -284,9 +279,7 @@ impl CustomEndpointModal { } fn create_model_row( - name: Option<&str>, - alias: Option<&str>, - config_key: Option, + model: Option<&CustomEndpointModel>, font_family: FamilyId, text_colors: &crate::editor::TextColors, ctx: &mut ViewContext, @@ -305,8 +298,8 @@ impl CustomEndpointModal { }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("e.g., GLM-5-FP8", ctx); - if let Some(n) = name { - editor.set_buffer_text(n, ctx); + if let Some(model) = model { + editor.set_buffer_text(&model.name, ctx); } editor }); @@ -325,7 +318,7 @@ impl CustomEndpointModal { }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("e.g., GLM-5", ctx); - if let Some(a) = alias { + if let Some(a) = model.and_then(|model| model.alias.as_deref()) { editor.set_buffer_text(a, ctx); } editor @@ -335,7 +328,10 @@ impl CustomEndpointModal { name_editor, alias_editor, remove_mouse_state: Default::default(), - config_key, + config_key: model.map(|model| model.config_key.clone()), + computer_use_toolset: model + .map(|model| model.computer_use_toolset) + .unwrap_or_default(), } } @@ -376,9 +372,7 @@ impl CustomEndpointModal { if let Some(ep) = endpoint { for model in &ep.models { self.model_rows.push(Self::create_model_row( - Some(&model.name), - model.alias.as_deref(), - Some(model.config_key.clone()), + Some(model), font_family, &text_colors, ctx, @@ -386,14 +380,8 @@ impl CustomEndpointModal { } } if self.model_rows.is_empty() { - self.model_rows.push(Self::create_model_row( - None, - None, - None, - font_family, - &text_colors, - ctx, - )); + self.model_rows + .push(Self::create_model_row(None, font_family, &text_colors, ctx)); } for row in &self.model_rows { let name_editor = row.name_editor.clone(); @@ -463,7 +451,7 @@ impl CustomEndpointModal { let url = self.endpoint_url_editor.as_ref(ctx).buffer_text(ctx); let api_key = self.api_key_editor.as_ref(ctx).buffer_text(ctx); let schema = self.selected_schema(ctx); - let models: Vec<(String, Option, Option)> = self + let models = self .model_rows .iter() .map(|row| { @@ -474,9 +462,14 @@ impl CustomEndpointModal { } else { Some(alias) }; - (name, alias_opt, row.config_key.clone()) + CustomEndpointModel { + name, + alias: alias_opt, + config_key: row.config_key.clone().unwrap_or_default(), + computer_use_toolset: row.computer_use_toolset, + } }) - .filter(|(name, _, _)| !name.trim().is_empty()) + .filter(|model| !model.name.trim().is_empty()) .collect(); if let Some(index) = self.editing_index { ctx.emit(CustomEndpointModalEvent::SaveEndpoint { @@ -519,7 +512,7 @@ impl CustomEndpointModal { fn add_model(&mut self, ctx: &mut ViewContext) { let font_family = Appearance::as_ref(ctx).ui_font_family(); let text_colors = crate::settings_view::editor_text_colors(Appearance::as_ref(ctx)); - let row = Self::create_model_row(None, None, None, font_family, &text_colors, ctx); + let row = Self::create_model_row(None, font_family, &text_colors, ctx); // Subscribe to the new editors let name_editor = row.name_editor.clone(); ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| { diff --git a/app/src/settings_view/custom_inference_modal_tests.rs b/app/src/settings_view/custom_inference_modal_tests.rs index 8251fe77db4..47a4d31961d 100644 --- a/app/src/settings_view/custom_inference_modal_tests.rs +++ b/app/src/settings_view/custom_inference_modal_tests.rs @@ -19,6 +19,7 @@ fn endpoint_with_models(model_count: usize) -> CustomEndpoint { name: format!("model-{index}"), alias: None, config_key: format!("config-{index}"), + computer_use_toolset: Default::default(), }) .collect(), } @@ -27,6 +28,43 @@ fn endpoint_with_models(model_count: usize) -> CustomEndpoint { fn init_modal_test_models(app: &mut App) { initialize_app_for_terminal_view(app); } + +#[test] +fn endpoint_edit_emits_declared_toolset_metadata() { + App::test((), |mut app| async move { + init_modal_test_models(&mut app); + let mut endpoint = endpoint_with_models(1); + endpoint.schema = CustomEndpointSchema::AnthropicMessages; + endpoint.models[0].computer_use_toolset = ComputerUseToolset::AnthropicComputer20251124; + let (_window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { + let mut modal = CustomEndpointModal::new(None, None, ctx); + modal.prefill(Some(&endpoint), Some(0), ctx); + modal + }); + let saved_models = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let captured_models = saved_models.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&modal, move |_, event, _| { + if let CustomEndpointModalEvent::SaveEndpoint { models, .. } = event { + *captured_models.borrow_mut() = models.clone(); + } + }); + }); + + modal.update(&mut app, |modal, ctx| { + modal.model_rows[0].alias_editor.update(ctx, |editor, ctx| { + editor.set_buffer_text("Renamed", ctx); + }); + modal.save(ctx); + }); + + assert_eq!(saved_models.borrow()[0].config_key, "config-0"); + assert_eq!( + saved_models.borrow()[0].computer_use_toolset, + ComputerUseToolset::AnthropicComputer20251124 + ); + }); +} fn custom_endpoint_modal_height(scene: &Scene) -> f32 { let rects = scene .layers() diff --git a/crates/ai/src/api_keys.rs b/crates/ai/src/api_keys.rs index 44b22c0ee8e..903029d3e5a 100644 --- a/crates/ai/src/api_keys.rs +++ b/crates/ai/src/api_keys.rs @@ -418,6 +418,37 @@ pub struct CustomEndpointModel { /// as the `CustomModelProviders.providers[*].models[*].config_key` on the request wire. /// Generated as a UUIDv4 at model creation. pub config_key: String, + pub computer_use_toolset: ComputerUseToolset, +} + +/// Native computer-use contract declared by a custom endpoint model. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ComputerUseToolset { + #[default] + None, + #[serde(rename = "anthropic_computer_20250124")] + AnthropicComputer20250124, + #[serde(rename = "anthropic_computer_20251124")] + AnthropicComputer20251124, + #[serde(rename = "anthropic_computer_toolset_20260801")] + AnthropicComputerToolset20260801, + OpenaiResponsesComputerTool, +} + +impl ComputerUseToolset { + fn to_proto(self) -> api::request::settings::custom_model_providers::ComputerUseToolset { + use api::request::settings::custom_model_providers::ComputerUseToolset as Proto; + match self { + Self::None => Proto::None, + Self::AnthropicComputer20250124 => Proto::AnthropicComputer20250124, + Self::AnthropicComputer20251124 => Proto::AnthropicComputer20251124, + Self::AnthropicComputerToolset20260801 => Proto::AnthropicComputerToolset20260801, + Self::OpenaiResponsesComputerTool => Proto::OpenaiResponsesComputerTool, + } + } } impl CustomEndpointModel { @@ -612,7 +643,7 @@ pub struct CustomEndpointParams { pub name: String, pub url: String, pub api_key: String, - pub models: Vec<(String, Option, Option)>, + pub models: Vec, pub schema: CustomEndpointSchema, } fn provider_credential_action(is_present: bool) -> ProviderCredentialTelemetryAction { @@ -1006,12 +1037,11 @@ impl ApiKeyManager { schema, models: models .into_iter() - .map(|(name, alias, config_key)| CustomEndpointModel { - name, - alias, - config_key: config_key - .filter(|k| !k.is_empty()) - .unwrap_or_else(|| Uuid::new_v4().to_string()), + .map(|mut model| { + if model.config_key.trim().is_empty() { + model.config_key = Uuid::new_v4().to_string(); + } + model }) .collect(), }); @@ -1045,12 +1075,11 @@ impl ApiKeyManager { schema, models: models .into_iter() - .map(|(name, alias, config_key)| CustomEndpointModel { - name, - alias, - config_key: config_key - .filter(|k| !k.is_empty()) - .unwrap_or_else(|| Uuid::new_v4().to_string()), + .map(|mut model| { + if model.config_key.trim().is_empty() { + model.config_key = Uuid::new_v4().to_string(); + } + model }) .collect(), }; @@ -1149,6 +1178,7 @@ impl ApiKeyManager { slug: m.name.clone(), config_key: m.config_key.clone(), reasoning_effort: String::new(), + computer_use_toolset: m.computer_use_toolset.to_proto() as i32, }, ) .collect(), diff --git a/crates/ai/src/api_keys_tests.rs b/crates/ai/src/api_keys_tests.rs index c480076a9b0..c5c1153532e 100644 --- a/crates/ai/src/api_keys_tests.rs +++ b/crates/ai/src/api_keys_tests.rs @@ -8,6 +8,51 @@ use super::*; fn make_manager(keys: ApiKeys) -> ApiKeyManager { make_manager_with_grok(keys, None) } + +#[test] +fn endpoint_params_update_computer_use_toolsets() { + warpui_core::App::test((), |mut app| async move { + app.update(|ctx| warpui_extras::secure_storage::register_noop("test", ctx)); + let manager = app.add_singleton_model(ApiKeyManager::new); + let params = CustomEndpointParams { + name: "Gateway".into(), + url: "https://gateway.example/v1".into(), + api_key: "key".into(), + schema: CustomEndpointSchema::AnthropicMessages, + models: vec![CustomEndpointModel { + name: "model".into(), + config_key: "stable-key".into(), + computer_use_toolset: ComputerUseToolset::AnthropicComputer20250124, + ..Default::default() + }], + }; + manager.update(&mut app, |manager, ctx| { + manager.add_custom_endpoint(params.clone(), ctx); + }); + manager.read(&app, |manager, _| { + assert_eq!( + manager + .custom_model_providers_for_request(true) + .unwrap() + .providers[0] + .models[0] + .computer_use_toolset, + ComputerUseToolset::AnthropicComputer20250124 as i32 + ); + }); + + manager.update(&mut app, |manager, ctx| { + let mut params = params; + params.models[0].computer_use_toolset = ComputerUseToolset::None; + manager.save_custom_endpoint(0, params, ctx); + }); + manager.read(&app, |manager, _| { + let providers = manager.custom_model_providers_for_request(true).unwrap(); + assert_eq!(providers.providers[0].models[0].config_key, "stable-key"); + assert_eq!(providers.providers[0].models[0].computer_use_toolset, 0); + }); + }); +} #[test] fn aws_credentials_are_cleared_only_when_refresh_strategy_changes() { warpui_core::App::test((), |mut app| async move { @@ -168,6 +213,50 @@ fn custom_model_providers_preserves_configured_schema() { ); } +#[test] +fn custom_computer_use_toolsets_survive_storage_and_request_serialization() { + use prost::Message; + for (toolset, expected) in [ + (ComputerUseToolset::None, 0), + (ComputerUseToolset::AnthropicComputer20250124, 1), + (ComputerUseToolset::AnthropicComputer20251124, 2), + (ComputerUseToolset::AnthropicComputerToolset20260801, 3), + (ComputerUseToolset::OpenaiResponsesComputerTool, 4), + ] { + let mut endpoint = endpoint_with_keys( + "Gateway", + "https://gateway.example/v1", + "key", + &[("model", None, "custom-key")], + ); + endpoint.models[0].computer_use_toolset = toolset; + let encoded = serde_json::to_string(&endpoint).unwrap(); + let decoded = serde_json::from_str(&encoded).unwrap(); + let manager = make_manager(ApiKeys { + custom_endpoints: vec![decoded], + ..Default::default() + }); + let providers = manager.custom_model_providers_for_request(true).unwrap(); + let decoded = api::request::settings::CustomModelProviders::decode( + providers.encode_to_vec().as_slice(), + ) + .unwrap(); + assert_eq!( + decoded.providers[0].models[0].computer_use_toolset, + expected + ); + assert_eq!(decoded.providers[0].models[0].config_key, "custom-key"); + } + let legacy: CustomEndpointModel = + serde_json::from_str(r#"{"name":"model","config_key":"legacy"}"#).unwrap(); + assert_eq!(legacy.computer_use_toolset, ComputerUseToolset::None); + assert!( + serde_json::from_str::( + r#"{"name":"model","config_key":"custom","computer_use_toolset":"future_toolset"}"# + ) + .is_err() + ); +} fn make_manager_with_grok(keys: ApiKeys, grok_tokens: Option) -> ApiKeyManager { let custom_endpoints = keys.custom_endpoints.clone(); ApiKeyManager { @@ -283,6 +372,7 @@ fn endpoint_with_keys( name: (*n).into(), alias: a.map(|s| s.into()), config_key: (*cfg).into(), + computer_use_toolset: Default::default(), }) .collect(), } @@ -700,6 +790,7 @@ fn display_label_uses_alias_when_present() { name: "raw-name".into(), alias: Some("My Alias".into()), config_key: "k".into(), + computer_use_toolset: Default::default(), }; assert_eq!(m.display_label(), "My Alias"); } @@ -710,6 +801,7 @@ fn display_label_falls_back_to_name_when_alias_missing() { name: "raw-name".into(), alias: None, config_key: "k".into(), + computer_use_toolset: Default::default(), }; assert_eq!(m.display_label(), "raw-name"); } @@ -720,6 +812,7 @@ fn display_label_falls_back_to_name_when_alias_is_whitespace() { name: "raw-name".into(), alias: Some(" ".into()), config_key: "k".into(), + computer_use_toolset: Default::default(), }; assert_eq!(m.display_label(), "raw-name"); } diff --git a/crates/warp_tui/src/api_keys_menu_tests.rs b/crates/warp_tui/src/api_keys_menu_tests.rs index 251f3bb50bb..d2faf72ba49 100644 --- a/crates/warp_tui/src/api_keys_menu_tests.rs +++ b/crates/warp_tui/src/api_keys_menu_tests.rs @@ -27,6 +27,7 @@ fn endpoint_definitions(names: &[&str]) -> CustomEndpointDefinitions { name: format!("model-{index}"), alias: None, config_key: format!("config-{index}"), + computer_use_toolset: Default::default(), }], }) .collect::>(); diff --git a/crates/warp_tui/src/zero_state_tests.rs b/crates/warp_tui/src/zero_state_tests.rs index a49508ca8d5..5047132b73e 100644 --- a/crates/warp_tui/src/zero_state_tests.rs +++ b/crates/warp_tui/src/zero_state_tests.rs @@ -81,6 +81,7 @@ fn endpoint_definitions() -> CustomEndpointDefinitions { name: format!("model-{index}"), alias: None, config_key: format!("config-{index}"), + computer_use_toolset: Default::default(), }], }) .collect::>();