From 88475e4c0c503be770bcbe76c604a37cfa60cea8 Mon Sep 17 00:00:00 2001 From: Oz Date: Sat, 18 Jul 2026 06:04:47 +0000 Subject: [PATCH] feat: support configurable BYOE endpoint schemas Co-Authored-By: Oz --- Cargo.lock | 2 +- Cargo.toml | 2 +- app/src/ai/agent/conversation_tests.rs | 5 +- app/src/ai/llms_tests.rs | 3 + app/src/settings_view/ai_page.rs | 6 +- .../settings_view/custom_inference_modal.rs | 61 ++++++++++++++++++- .../custom_inference_modal_tests.rs | 3 +- crates/ai/src/api_keys.rs | 51 ++++++++++++++++ crates/ai/src/api_keys_tests.rs | 34 +++++++++++ 9 files changed, 161 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb508c5dc83..b9ea7069bb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15735,7 +15735,7 @@ dependencies = [ [[package]] name = "warp_multi_agent_api" version = "0.0.0" -source = "git+https://github.com/warpdotdev/warp-proto-apis.git?rev=3d26f166b74469d5c12080ef6a3423fadee4d0e3#3d26f166b74469d5c12080ef6a3423fadee4d0e3" +source = "git+https://github.com/warpdotdev/warp-proto-apis.git?rev=d0e6727#d0e67275" dependencies = [ "prost", "prost-reflect", diff --git a/Cargo.toml b/Cargo.toml index 429f85e9b86..f6813f4b3af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -342,7 +342,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 = "3d26f166b74469d5c12080ef6a3423fadee4d0e3" } +warp_multi_agent_api = { git = "https://github.com/warpdotdev/warp-proto-apis.git", rev = "d0e6727" } 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 b8624d6381a..06f3b144320 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use ai::api_keys::ApiKeyManager; +use ai::api_keys::{ApiKeyManager, CustomEndpointSchema}; use warp_core::features::FeatureFlag; use warp_multi_agent_api as api; use warpui::{App, SingletonEntity}; @@ -118,6 +118,7 @@ fn tool_call_result_message( fn start_recording_tool_call() -> api::message::tool_call::Tool { api::message::tool_call::Tool::StartRecording(api::message::tool_call::StartRecording { + description: String::new(), frame_rate: 15, limits: None, summary: String::new(), @@ -604,6 +605,7 @@ fn update_cost_and_usage_resolves_custom_endpoint_alias_for_footer_usage() { Some("Friendly alias".to_string()), Some("config-key".to_string()), )], + CustomEndpointSchema::default(), ctx, ); }); @@ -766,6 +768,7 @@ fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_label Some("Resolved custom".to_string()), Some("config-key".to_string()), )], + CustomEndpointSchema::default(), ctx, ); }); diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 82ec23fdd4c..ad3bd505413 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -192,6 +192,7 @@ fn endpoint( name: name.into(), url: url.into(), api_key: api_key.into(), + schema: Default::default(), models, } } @@ -532,6 +533,7 @@ fn active_models_fall_back_to_usable_choice_or_custom_endpoint_when_default_disa None, Some(custom_model_id.to_string()), )], + ai::api_keys::CustomEndpointSchema::default(), ctx, ); }); @@ -700,6 +702,7 @@ fn reconcile_preserves_custom_models_saved_on_execution_profile() { Some("Custom Model".to_string()), Some(custom_model_id.to_string()), )], + ai::api_keys::CustomEndpointSchema::default(), ctx, ); }); diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 59ff9726a1c..92965b6d97e 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -2366,6 +2366,7 @@ impl AISettingsPageView { name, url, api_key, + schema, models, } => { if !Self::can_use_custom_inference_controls(ctx) { @@ -2378,6 +2379,7 @@ impl AISettingsPageView { url.clone(), api_key.clone(), models.clone(), + *schema, ctx, ); }); @@ -2405,6 +2407,7 @@ impl AISettingsPageView { name, url, api_key, + schema, models, } => { if !Self::can_use_custom_inference_controls(ctx) { @@ -2418,6 +2421,7 @@ impl AISettingsPageView { url.clone(), api_key.clone(), models.clone(), + *schema, ctx, ); }); @@ -8531,7 +8535,7 @@ impl ApiKeysWidget { if show_custom_endpoints { add_paragraph(vec![FormattedTextFragment::plain_text( - "Add custom endpoints to use third-party models. Custom endpoints must support the OpenAI-compatible Chat Completions API.", + "Add custom endpoints to use third-party models. Custom endpoints must support OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages.", )]); } diff --git a/app/src/settings_view/custom_inference_modal.rs b/app/src/settings_view/custom_inference_modal.rs index bf9b21e6378..160c5d84e1e 100644 --- a/app/src/settings_view/custom_inference_modal.rs +++ b/app/src/settings_view/custom_inference_modal.rs @@ -1,6 +1,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use ::ai::api_keys::CustomEndpoint; +use ::ai::api_keys::{CustomEndpoint, CustomEndpointSchema}; use url::Url; use warp_editor::editor::NavigationKey; use warpui::elements::{ @@ -25,6 +25,7 @@ use crate::editor::{ use crate::modal::{Modal, ModalViewState}; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ActionButton, DangerSecondaryTheme}; +use crate::view_components::{Dropdown, DropdownItem}; const LABEL_FONT_SIZE: f32 = 12.; const INPUT_WIDTH: f32 = 480.; @@ -54,6 +55,7 @@ pub enum CustomEndpointModalEvent { name: String, url: String, api_key: String, + schema: CustomEndpointSchema, models: Vec<(String, Option, Option)>, }, SaveEndpoint { @@ -61,6 +63,7 @@ pub enum CustomEndpointModalEvent { name: String, url: String, api_key: String, + schema: CustomEndpointSchema, models: Vec<(String, Option, Option)>, }, RemoveEndpoint { @@ -75,6 +78,7 @@ pub enum CustomEndpointModalAction { AddModel, RemoveModel(usize), RemoveEndpoint, + SetSchema(CustomEndpointSchema), } struct ModelRow { @@ -88,6 +92,8 @@ pub struct CustomEndpointModal { endpoint_name_editor: ViewHandle, endpoint_url_editor: ViewHandle, api_key_editor: ViewHandle, + schema_dropdown: ViewHandle>, + schema: CustomEndpointSchema, model_rows: Vec, cancel_button_mouse_state: MouseStateHandle, save_button_mouse_state: MouseStateHandle, @@ -175,6 +181,30 @@ impl CustomEndpointModal { editor }); + let schema = endpoint.map(|endpoint| endpoint.schema).unwrap_or_default(); + let schema_dropdown = ctx.add_typed_action_view(|ctx| { + let mut dropdown = Dropdown::new(ctx); + dropdown.set_items( + [ + CustomEndpointSchema::OpenaiChatCompletions, + CustomEndpointSchema::OpenaiResponses, + CustomEndpointSchema::AnthropicMessages, + ] + .into_iter() + .map(|schema| { + DropdownItem::new( + schema.display_name(), + CustomEndpointModalAction::SetSchema(schema), + ) + }) + .collect(), + ctx, + ); + dropdown + }); + schema_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_selected_by_name(schema.display_name(), ctx); + }); let mut model_rows = Vec::new(); if let Some(ep) = endpoint { for model in &ep.models { @@ -233,6 +263,8 @@ impl CustomEndpointModal { endpoint_name_editor, endpoint_url_editor, api_key_editor, + schema_dropdown, + schema, model_rows, cancel_button_mouse_state: Default::default(), save_button_mouse_state: Default::default(), @@ -319,6 +351,16 @@ impl CustomEndpointModal { self.api_key_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(endpoint.map(|e| e.api_key.as_str()).unwrap_or(""), ctx); }); + self.schema_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_selected_by_name( + endpoint + .map(|endpoint| endpoint.schema) + .unwrap_or_default() + .display_name(), + ctx, + ); + }); + self.schema = endpoint.map(|endpoint| endpoint.schema).unwrap_or_default(); // Rebuild model rows // Old model row editors will be dropped with the modal body self.model_rows.clear(); @@ -434,6 +476,7 @@ impl CustomEndpointModal { name, url, api_key, + schema: self.schema, models, }); } else { @@ -441,6 +484,7 @@ impl CustomEndpointModal { name, url, api_key, + schema: self.schema, models, }); } @@ -704,6 +748,17 @@ impl View for CustomEndpointModal { .with_margin_bottom(16.) .finish(), ); + // Request/response protocol + column.add_child( + Container::new(label("API schema")) + .with_margin_bottom(4.) + .finish(), + ); + column.add_child( + Container::new(ChildView::new(&self.schema_dropdown).finish()) + .with_margin_bottom(16.) + .finish(), + ); // Endpoint name column.add_child( @@ -1067,6 +1122,10 @@ impl TypedActionView for CustomEndpointModal { ctx.emit(CustomEndpointModalEvent::RemoveEndpoint { index }); } } + CustomEndpointModalAction::SetSchema(schema) => { + self.schema = *schema; + ctx.notify(); + } } } } diff --git a/app/src/settings_view/custom_inference_modal_tests.rs b/app/src/settings_view/custom_inference_modal_tests.rs index 4c1caddebd5..93e71b1b8bb 100644 --- a/app/src/settings_view/custom_inference_modal_tests.rs +++ b/app/src/settings_view/custom_inference_modal_tests.rs @@ -1,4 +1,4 @@ -use ai::api_keys::CustomEndpointModel; +use ai::api_keys::{CustomEndpointModel, CustomEndpointSchema}; use pathfinder_geometry::vector::vec2f; use warpui::platform::WindowStyle; use warpui::scene::Scene; @@ -13,6 +13,7 @@ fn endpoint_with_models(model_count: usize) -> CustomEndpoint { name: "Test endpoint".to_string(), url: "https://api.example.com/v1".to_string(), api_key: "key".to_string(), + schema: CustomEndpointSchema::default(), models: (0..model_count) .map(|index| CustomEndpointModel { name: format!("model-{index}"), diff --git a/crates/ai/src/api_keys.rs b/crates/ai/src/api_keys.rs index dc97dd52790..1b3c71f7f53 100644 --- a/crates/ai/src/api_keys.rs +++ b/crates/ai/src/api_keys.rs @@ -49,6 +49,52 @@ pub struct CustomEndpoint { pub url: String, pub api_key: String, pub models: Vec, + pub schema: CustomEndpointSchema, +} + +/// The request/response protocol used by a custom inference endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CustomEndpointSchema { + /// OpenAI Chat Completions, retained as the legacy/default protocol. + #[default] + OpenaiChatCompletions, + /// OpenAI Responses. + OpenaiResponses, + /// Anthropic Messages. + AnthropicMessages, +} + +impl CustomEndpointSchema { + pub fn display_name(self) -> &'static str { + match self { + Self::OpenaiChatCompletions => "OpenAI Chat Completions", + Self::OpenaiResponses => "OpenAI Responses", + Self::AnthropicMessages => "Anthropic Messages", + } + } + + pub fn from_display_name(name: &str) -> Option { + match name { + "OpenAI Chat Completions" => Some(Self::OpenaiChatCompletions), + "OpenAI Responses" => Some(Self::OpenaiResponses), + "Anthropic Messages" => Some(Self::AnthropicMessages), + _ => None, + } + } + fn to_proto(self) -> api::request::settings::custom_model_providers::CustomEndpointSchema { + match self { + Self::OpenaiChatCompletions => { + api::request::settings::custom_model_providers::CustomEndpointSchema::OpenaiChatCompletions + } + Self::OpenaiResponses => { + api::request::settings::custom_model_providers::CustomEndpointSchema::OpenaiResponses + } + Self::AnthropicMessages => { + api::request::settings::custom_model_providers::CustomEndpointSchema::AnthropicMessages + } + } + } } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -295,12 +341,14 @@ impl ApiKeyManager { url: String, api_key: String, models: Vec<(String, Option, Option)>, + schema: CustomEndpointSchema, ctx: &mut ModelContext, ) { self.keys.custom_endpoints.push(CustomEndpoint { name, url, api_key, + schema, models: models .into_iter() .map(|(name, alias, config_key)| CustomEndpointModel { @@ -323,6 +371,7 @@ impl ApiKeyManager { url: String, api_key: String, models: Vec<(String, Option, Option)>, + schema: CustomEndpointSchema, ctx: &mut ModelContext, ) { if index >= self.keys.custom_endpoints.len() { @@ -332,6 +381,7 @@ impl ApiKeyManager { name, url, api_key, + schema, models: models .into_iter() .map(|(name, alias, config_key)| CustomEndpointModel { @@ -431,6 +481,7 @@ impl ApiKeyManager { |endpoint| api::request::settings::custom_model_providers::CustomModelProvider { base_url: endpoint.url.clone(), api_key: endpoint.api_key.clone(), + schema: endpoint.schema.to_proto() as i32, models: endpoint .models .iter() diff --git a/crates/ai/src/api_keys_tests.rs b/crates/ai/src/api_keys_tests.rs index be9f3fc23a3..06165948044 100644 --- a/crates/ai/src/api_keys_tests.rs +++ b/crates/ai/src/api_keys_tests.rs @@ -6,6 +6,30 @@ fn make_manager(keys: ApiKeys) -> ApiKeyManager { make_manager_with_grok(keys, None) } +#[test] +fn custom_model_providers_preserves_configured_schema() { + let mut endpoint = endpoint_with_keys( + "Anthropic", + "https://custom.io", + "ep-key", + &[("claude", None, "uuid-1")], + ); + endpoint.schema = CustomEndpointSchema::AnthropicMessages; + let mgr = make_manager(ApiKeys { + custom_endpoints: vec![endpoint], + ..Default::default() + }); + + let provider = &mgr + .custom_model_providers_for_request(true) + .expect("configured endpoint should be sent") + .providers[0]; + assert_eq!( + provider.schema, + CustomEndpointSchema::AnthropicMessages as i32 + ); +} + fn make_manager_with_grok(keys: ApiKeys, grok_tokens: Option) -> ApiKeyManager { ApiKeyManager { keys, @@ -101,6 +125,7 @@ fn endpoint_with_keys( name: name.into(), url: url.into(), api_key: api_key.into(), + schema: CustomEndpointSchema::default(), models: models .iter() .map(|(n, a, cfg)| CustomEndpointModel { @@ -165,6 +190,14 @@ fn serde_ignores_unknown_fields() { assert_eq!(keys.openai, Some("sk-x".into())); assert!(keys.custom_endpoints.is_empty()); } +#[test] +fn serde_legacy_endpoint_defaults_to_chat_completions() { + let endpoint: CustomEndpoint = serde_json::from_str( + r#"{"name":"legacy","url":"https://example.com","api_key":"key","models":[]}"#, + ) + .unwrap(); + assert_eq!(endpoint.schema, CustomEndpointSchema::OpenaiChatCompletions); +} // ── has_any_key ───────────────────────────────────────────────── @@ -269,6 +302,7 @@ fn custom_model_providers_populates_single_endpoint() { assert_eq!(p.models.len(), 1); assert_eq!(p.models[0].slug, "big-model"); assert_eq!(p.models[0].config_key, "uuid-1"); + assert_eq!(p.schema, CustomEndpointSchema::OpenaiChatCompletions as i32); } #[test]