diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index e443df33b4ddca..0e9ffa926cf0ba 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -8,7 +8,7 @@ use gpui::{ }; use language_model::LanguageModelRegistry; use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities}; -use settings::{OpenAiCompatibleSettingsContent, update_settings_file}; +use settings::{CustomHeader, OpenAiCompatibleSettingsContent, update_settings_file}; use ui::{ Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, @@ -63,6 +63,8 @@ struct AddLlmProviderInput { api_url: Entity, api_key: Entity, models: Vec, + custom_header_names: Vec>, + custom_header_values: Vec>, } impl AddLlmProviderInput { @@ -79,11 +81,24 @@ impl AddLlmProviderInput { cx, ); + let header_name = cx.new(|cx| { + InputField::new(window, cx, "Header Name") + .tab_index(4) + .tab_stop(true) + }); + let header_value = cx.new(|cx| { + InputField::new(window, cx, "Header Value") + .tab_index(5) + .tab_stop(true) + }); + Self { provider_name, api_url, api_key, models: vec![ModelInput::new(0, window, cx)], + custom_header_names: vec![header_name], + custom_header_values: vec![header_value], } } @@ -254,7 +269,24 @@ fn save_provider_to_settings( } } + // Collect custom headers from input fields + let mut headers: Vec = Vec::new(); + for i in 0..input.custom_header_names.len() { + let name = input.custom_header_names[i].read(cx).text(cx).to_string(); + let value = input.custom_header_values[i].read(cx).text(cx).to_string(); + if !name.trim().is_empty() { + headers.push(CustomHeader { name, value }); + } + } + + let custom_headers = if headers.is_empty() { + None + } else { + Some(headers) + }; + let fs = ::global(cx); + let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes()); cx.spawn(async move |cx| { task.await @@ -271,6 +303,7 @@ fn save_provider_to_settings( OpenAiCompatibleSettingsContent { api_url, available_models: models, + custom_headers: custom_headers, }, ); }); @@ -329,6 +362,78 @@ impl AddLlmProviderModal { cx.emit(DismissEvent); } + fn render_custom_headers_section(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .mt_1() + .gap_2() + .child( + h_flex() + .justify_between() + .child(Label::new("Custom HTTP headers").size(LabelSize::Small)) + .child( + Button::new("add-header", "Add Header") + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _e, window, cx| { + let tab_index = + (4 + this.input.custom_header_names.len() * 2) as isize; + this.input.custom_header_names.push(cx.new(|cx| { + InputField::new(window, cx, "Header Name") + .tab_index(tab_index) + .tab_stop(true) + })); + this.input.custom_header_values.push(cx.new(|cx| { + InputField::new(window, cx, "Header Value") + .tab_index(tab_index + 1) + .tab_stop(true) + })); + cx.notify(); + })), + ), + ) + .children( + (0..self.input.custom_header_names.len()) + .map(|ix| self.render_custom_header(ix, cx)), + ) + } + + fn render_custom_header(&self, ix: usize, cx: &mut Context) -> impl IntoElement + use<> { + v_flex() + .p_2() + .gap_2() + .rounded_sm() + .border_1() + .border_dashed() + .border_color(cx.theme().colors().border.opacity(0.6)) + .bg(cx.theme().colors().element_active.opacity(0.15)) + .child( + h_flex() + .gap_2() + .child(self.input.custom_header_names[ix].clone()) + .child(self.input.custom_header_values[ix].clone()), + ) + .child( + Button::new(("remove-header", ix), "Remove Header") + .icon(IconName::Trash) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .full_width() + .on_click(cx.listener(move |this, _e, _w, cx| { + if ix < this.input.custom_header_names.len() { + this.input.custom_header_names.remove(ix); + this.input.custom_header_values.remove(ix); + cx.notify(); + } + })), + ) + } + fn render_model_section(&self, cx: &mut Context) -> impl IntoElement { v_flex() .mt_1() @@ -531,6 +636,7 @@ impl Render for AddLlmProviderModal { .child(self.input.provider_name.clone()) .child(self.input.api_url.clone()) .child(self.input.api_key.clone()) + .child(self.render_custom_headers_section(cx)) .child(self.render_model_section(cx)), ), ) diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 56a970404419ec..f2912ea7341513 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -186,7 +186,7 @@ pub enum LanguageModelCompletionError { provider: LanguageModelProviderName, message: String, }, - #[error("language model provider API endpoint not found")] + #[error("language model provider API endpoint not found: {provider}")] ApiEndpointNotFound { provider: LanguageModelProviderName }, #[error("I/O error reading response from {provider}'s API")] ApiReadResponseError { diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 905d2b37862eeb..b62d6c353878cd 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -224,6 +224,7 @@ impl OpenAiLanguageModel { &api_url, &api_key, request, + None, ); let response = request.await?; Ok(response) diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index f95f567739d766..c6b93182cfa53b 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use convert_case::{Case, Casing}; +use fs::Fs; use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -11,13 +12,14 @@ use language_model::{ }; use menu; use open_ai::{ResponseStreamEvent, stream_completion}; -use settings::{Settings, SettingsStore}; +use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::Arc; use ui::{ElevationIndex, Tooltip, prelude::*}; use ui_input::InputField; use util::ResultExt; use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai}; +pub use settings::CustomHeader; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; @@ -25,6 +27,7 @@ pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; pub struct OpenAiCompatibleSettings { pub api_url: String, pub available_models: Vec, + pub custom_headers: Option>, } pub struct OpenAiCompatibleLanguageModelProvider { @@ -208,11 +211,12 @@ impl OpenAiCompatibleLanguageModel { > { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = self.state.read_with(cx, |state, _cx| { + let Ok((api_key, api_url, custom_headers)) = self.state.read_with(cx, |state, _cx| { let api_url = &state.settings.api_url; ( state.api_key_state.key(api_url), state.settings.api_url.clone(), + state.settings.custom_headers.clone(), ) }) else { return future::ready(Err(anyhow!("App state dropped").into())).boxed(); @@ -229,6 +233,7 @@ impl OpenAiCompatibleLanguageModel { &api_url, &api_key, request, + custom_headers.as_ref(), ); let response = request.await?; Ok(response) @@ -348,6 +353,8 @@ struct ConfigurationView { api_key_editor: Entity, state: Entity, load_credentials_task: Option>, + header_name_inputs: Vec>, + header_value_inputs: Vec>, } impl ConfigurationView { @@ -383,10 +390,27 @@ impl ConfigurationView { } })); + // Initialize header input fields from current settings + let existing_headers = state + .read(cx) + .settings + .custom_headers + .clone() + .unwrap_or_default(); + + let mut header_name_inputs = Vec::with_capacity(existing_headers.len()); + let mut header_value_inputs = Vec::with_capacity(existing_headers.len()); + for h in existing_headers.iter() { + header_name_inputs.push(cx.new(|cx| InputField::new(window, cx, &h.name))); + header_value_inputs.push(cx.new(|cx| InputField::new(window, cx, &h.value))); + } + Self { api_key_editor, state, load_credentials_task, + header_name_inputs, + header_value_inputs, } } @@ -425,6 +449,59 @@ impl ConfigurationView { fn should_render_editor(&self, cx: &Context) -> bool { !self.state.read(cx).is_authenticated() } + + fn add_header(&mut self, window: &mut Window, cx: &mut Context) { + self.header_name_inputs + .push(cx.new(|cx| InputField::new(window, cx, ""))); + self.header_value_inputs + .push(cx.new(|cx| InputField::new(window, cx, ""))); + } + + fn remove_header(&mut self, index: usize) { + if index < self.header_name_inputs.len() && index < self.header_value_inputs.len() { + self.header_name_inputs.remove(index); + self.header_value_inputs.remove(index); + } + } + + fn save_headers(&mut self, cx: &mut Context) { + // Collect header entries from inputs + let mut headers: Vec = Vec::new(); + for i in 0..self.header_name_inputs.len() { + let name = self.header_name_inputs[i] + .read(cx) + .text(cx) + .trim() + .to_string(); + let value = self.header_value_inputs[i].read(cx).text(cx).to_string(); + if !name.is_empty() { + headers.push(CustomHeader { name, value }); + } + } + + // Optional: basic dedup by case-insensitive name; keep first occurrence + let mut seen = std::collections::HashSet::::new(); + headers.retain(|h| seen.insert(h.name.to_lowercase())); + + // Persist to settings + let fs = ::global(cx); + let id = self.state.read(cx).id.clone(); + update_settings_file(fs, cx, move |settings, _| { + if let Some(ref mut map) = settings + .language_models + .as_mut() + .and_then(|lm| lm.openai_compatible.as_mut()) + { + if let Some(entry) = map.get_mut(&id) { + entry.custom_headers = if headers.is_empty() { + None + } else { + Some(headers.clone()) + }; + } + } + }); + } } impl Render for ConfigurationView { @@ -500,7 +577,84 @@ impl Render for ConfigurationView { if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials…")).into_any() } else { - v_flex().size_full().child(api_key_section).into_any() + // Build Custom Headers section + let mut headers_list = v_flex().gap_1(); + for i in 0..self.header_name_inputs.len() { + headers_list = headers_list.child( + h_flex() + .gap_1() + .items_center() + .child( + v_flex() + .flex_1() + .min_w_0() + .child(self.header_name_inputs[i].clone()), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child(self.header_value_inputs[i].clone()), + ) + .child( + Button::new(("remove-header", i), "") + .style(ButtonStyle::Transparent) + .icon(IconName::Trash) + .icon_size(IconSize::Small) + .on_click(cx.listener({ + let index = i; + move |this, _evt, _window, _cx| { + this.remove_header(index); + } + })), + ), + ); + } + + let headers_section = v_flex() + .mt_3() + .gap_1() + .child(Headline::new("Custom HTTP headers")) + .child( + Label::new( + "These headers will be sent with each request to this provider’s API URL.", + ) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(headers_list) + .child( + h_flex() + .gap_1() + .justify_between() + .child( + Button::new("add-header", "Add header") + .style(ButtonStyle::Outlined) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .on_click(cx.listener(|this, _evt, window, cx| { + this.add_header(window, cx) + })), + ) + .child( + Button::new("save-headers", "Save headers") + .style(ButtonStyle::Filled) + .icon(IconName::Check) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .on_click( + cx.listener(|this, _evt, _window, cx| this.save_headers(cx)), + ), + ), + ); + + v_flex() + .size_full() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_section) + .child(headers_section) + .into_any() } } } diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index e2e692eafff94c..292a2a0c9360e8 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -216,6 +216,7 @@ impl VercelLanguageModel { &api_url, &api_key, request, + None, ); let response = request.await?; Ok(response) diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index f0aa0e71a83ae1..cee203b8aff55c 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -225,6 +225,7 @@ impl XAiLanguageModel { &api_url, &api_key, request, + None, ); let response = request.await?; Ok(response) diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 62f0025c755e10..d0681ff781ed80 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -97,6 +97,7 @@ impl settings::Settings for AllLanguageModelSettings { OpenAiCompatibleSettings { api_url: value.api_url, available_models: value.available_models, + custom_headers: value.custom_headers, }, ) }) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index d8b472254383b6..f1aacebae99c39 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -6,6 +6,7 @@ use http_client::{ }; use serde::{Deserialize, Serialize}; use serde_json::Value; +pub use settings::CustomHeader; pub use settings::OpenAiReasoningEffort as ReasoningEffort; use std::{convert::TryFrom, future::Future}; use strum::EnumIter; @@ -498,14 +499,21 @@ pub async fn stream_completion( api_url: &str, api_key: &str, request: Request, + custom_headers: Option<&Vec>, ) -> Result>, RequestError> { let uri = format!("{api_url}/chat/completions"); - let request_builder = HttpRequest::builder() + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {}", api_key.trim())); + if let Some(headers) = custom_headers { + for custom_header in headers { + request_builder = request_builder.header(&custom_header.name, &custom_header.value); + } + } + let request = request_builder .body(AsyncBody::from( serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?, diff --git a/crates/settings/src/settings_content/language_model.rs b/crates/settings/src/settings_content/language_model.rs index e523286e5f56af..3f13a486e30c89 100644 --- a/crates/settings/src/settings_content/language_model.rs +++ b/crates/settings/src/settings_content/language_model.rs @@ -219,11 +219,18 @@ pub enum OpenAiReasoningEffort { High, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct CustomHeader { + pub name: String, + pub value: String, +} + #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct OpenAiCompatibleSettingsContent { pub api_url: String, pub available_models: Vec, + pub custom_headers: Option>, } #[with_fallible_options]