diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 6898eb5e4950..fb0f83d18596 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -207,7 +207,11 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> api_key_env, base_url: params.api_url, models: model_infos, - headers: params.headers.or(existing_config.headers), + headers: match params.headers { + Some(h) if h.is_empty() => None, + Some(h) => Some(h), + None => existing_config.headers, + }, timeout_seconds: existing_config.timeout_seconds, supports_streaming: params.supports_streaming, requires_auth: params.requires_auth, diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 760a7862d6da..1e4cd8e6661e 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -126,6 +126,7 @@ export default function ChatInput({ const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); const [pastedImages, setPastedImages] = useState([]); + const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); // Derived state - chatState != Idle means we're in some form of loading state const isLoading = chatState !== ChatState.Idle; @@ -148,7 +149,6 @@ export default function ChatInput({ const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false); const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); - const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); const [sessionWorkingDir, setSessionWorkingDir] = useState(null); useEffect(() => { @@ -1190,13 +1190,11 @@ export default function ChatInput({ return (
{}} + onClick={() => { }} disabled={true} className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2" > @@ -1312,13 +1310,12 @@ export default function ChatInput({ } }} disabled={isTranscribing} - className={`rounded-full px-6 py-2 ${ - isRecording - ? 'bg-red-500 text-white hover:bg-red-600 border-red-500' - : isTranscribing - ? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600' - : 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600' - }`} + className={`rounded-full px-6 py-2 ${isRecording + ? 'bg-red-500 text-white hover:bg-red-600 border-red-500' + : isTranscribing + ? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600' + : 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600' + }`} > @@ -1356,11 +1353,10 @@ export default function ChatInput({ shape="round" variant="outline" disabled={isSubmitButtonDisabled} - className={`rounded-full px-10 py-2 flex items-center gap-2 ${ - isSubmitButtonDisabled - ? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600' - : 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer' - }`} + className={`rounded-full px-10 py-2 flex items-center gap-2 ${isSubmitButtonDisabled + ? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600' + : 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer' + }`} > Send diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx index 35fe6c2a4141..4968e8d566fd 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx @@ -156,6 +156,19 @@ export default function ExtensionModal({ }; const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => { + if (field === 'key') { + if (value.includes(' ')) { + return; + } + const trimmedNewKey = value.trim(); + const normalizedNewKey = trimmedNewKey.toLowerCase(); + const isDuplicate = formData.headers.some( + (h, i) => i !== index && h.key.trim().toLowerCase() === normalizedNewKey, + ); + if (isDuplicate && trimmedNewKey !== '') { + return; + } + } const newHeaders = [...formData.headers]; newHeaders[index][field] = value; diff --git a/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx b/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx index 3868f0d4fed3..5b265529f424 100644 --- a/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx @@ -44,6 +44,10 @@ export default function HeadersSection({ const keyEmpty = !newKey.trim(); const valueEmpty = !newValue.trim(); const keyHasSpaces = newKey.includes(' '); + const normalizedNewKey = newKey.trim().toLowerCase(); + const isDuplicate = headers.some( + h => h.key.trim().toLowerCase() === normalizedNewKey + ); if (keyEmpty || valueEmpty) { setInvalidFields({ @@ -63,6 +67,15 @@ export default function HeadersSection({ return; } + if (isDuplicate) { + setInvalidFields({ + key: true, + value: false, + }); + setValidationError('A header with this name already exists'); + return; + } + setValidationError(null); setInvalidFields({ key: false, value: false }); onAdd(newKey, newValue); diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index b4221ef03275..3ea8bb04cff0 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -100,8 +100,8 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo description: extension.description || '', type: extension.type === 'frontend' || - extension.type === 'inline_python' || - extension.type === 'platform' + extension.type === 'inline_python' || + extension.type === 'platform' ? 'stdio' : extension.type, cmd: extension.type === 'stdio' ? quoteShell([extension.cmd, ...extension.args]) : undefined, @@ -155,7 +155,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon timeout: formData.timeout, uri: formData.endpoint || '', ...(env_keys.length > 0 ? { env_keys } : {}), - ...(Object.keys(headers).length > 0 ? { headers } : {}), + headers, }; } else { // For other types diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index ee1e9e4aaac2..a67f69872e88 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -238,6 +238,7 @@ function ProviderCards({ models: editingProvider.config.models.map((m) => m.name), supports_streaming: editingProvider.config.supports_streaming ?? true, requires_auth: editingProvider.config.requires_auth ?? true, + headers: editingProvider.config.headers ?? undefined, }; const editable = editingProvider ? editingProvider.isEditable : true; @@ -246,7 +247,7 @@ function ProviderCards({ <> {providerCards} - + {title} diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx index be252a1735d8..78a3ddb433e9 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx @@ -4,7 +4,8 @@ import { Select } from '../../../../../ui/Select'; import { Button } from '../../../../../ui/button'; import { SecureStorageNotice } from '../SecureStorageNotice'; import { UpdateCustomProviderRequest } from '../../../../../../api'; -import { Trash2, AlertTriangle } from 'lucide-react'; +import { Plus, X, Trash2, AlertTriangle } from 'lucide-react'; +import { cn } from '../../../../../../utils'; interface CustomProviderFormProps { onSubmit: (data: UpdateCustomProviderRequest) => void; @@ -30,6 +31,14 @@ export default function CustomProviderForm({ const [models, setModels] = useState(''); const [requiresApiKey, setRequiresApiKey] = useState(false); const [supportsStreaming, setSupportsStreaming] = useState(true); + const [headers, setHeaders] = useState<{ key: string; value: string }[]>([]); + const [newHeaderKey, setNewHeaderKey] = useState(''); + const [newHeaderValue, setNewHeaderValue] = useState(''); + const [headerValidationError, setHeaderValidationError] = useState(null); + const [invalidHeaderFields, setInvalidHeaderFields] = useState<{ key: boolean; value: boolean }>({ + key: false, + value: false, + }); const [validationErrors, setValidationErrors] = useState>({}); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); @@ -46,6 +55,14 @@ export default function CustomProviderForm({ setModels(initialData.models.join(', ')); setSupportsStreaming(initialData.supports_streaming ?? true); setRequiresApiKey(initialData.requires_auth ?? true); + + if (initialData.headers) { + const headerList = Object.entries(initialData.headers).map(([key, value]) => ({ + key, + value, + })); + setHeaders(headerList); + } } }, [initialData]); @@ -56,6 +73,85 @@ export default function CustomProviderForm({ } }; + const handleAddHeader = () => { + const keyEmpty = !newHeaderKey.trim(); + const valueEmpty = !newHeaderValue.trim(); + const keyHasSpaces = newHeaderKey.includes(' '); + const normalizedNewKey = newHeaderKey.trim().toLowerCase(); + const isDuplicate = headers.some(h => h.key.trim().toLowerCase() === normalizedNewKey); + + if (keyEmpty || valueEmpty) { + setInvalidHeaderFields({ + key: keyEmpty, + value: valueEmpty, + }); + setHeaderValidationError('Both header name and value must be entered'); + return; + } + + if (keyHasSpaces) { + setInvalidHeaderFields({ + key: true, + value: false, + }); + setHeaderValidationError('Header name cannot contain spaces'); + return; + } + + if (isDuplicate) { + setInvalidHeaderFields({ + key: true, + value: false, + }); + setHeaderValidationError('A header with this name already exists'); + return; + } + + setHeaderValidationError(null); + setInvalidHeaderFields({ key: false, value: false }); + setHeaders([...headers, { key: newHeaderKey, value: newHeaderValue }]); + setNewHeaderKey(''); + setNewHeaderValue(''); + }; + + const handleRemoveHeader = (index: number) => { + setHeaders(headers.filter((_, i) => i !== index)); + }; + + const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => { + if (field === 'key') { + if (value.includes(' ')) { + return; + } + const normalizedValue = value.trim().toLowerCase(); + const isDuplicate = headers.some( + (h, i) => i !== index && h.key.trim().toLowerCase() === normalizedValue, + ); + if (isDuplicate && normalizedValue !== '') { + return; + } + const updatedHeaders = [...headers]; + updatedHeaders[index].key = value; + setHeaders(updatedHeaders); + return; + } + const updatedHeaders = [...headers]; + updatedHeaders[index][field] = value; + setHeaders(updatedHeaders); + }; + + const clearHeaderValidation = () => { + setHeaderValidationError(null); + setInvalidHeaderFields({ key: false, value: false }); + }; + + const handleHeaderKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddHeader(); + } + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -76,6 +172,30 @@ export default function CustomProviderForm({ .map((m) => m.trim()) .filter((m) => m); + let allHeaders = [...headers]; + + if (newHeaderKey.trim() && newHeaderValue.trim()) { + const keyHasSpaces = newHeaderKey.includes(' '); + const normalizedPendingKey = newHeaderKey.trim().toLowerCase(); + const isDuplicate = headers.some( + (h) => h.key.trim().toLowerCase() === normalizedPendingKey, + ); + + if (!keyHasSpaces && !isDuplicate) { + allHeaders.push({ key: newHeaderKey, value: newHeaderValue }); + } + } + + const headersObject = allHeaders.reduce( + (acc, header) => { + if (header.key.trim() && header.value.trim()) { + acc[header.key.trim()] = header.value.trim(); + } + return acc; + }, + {} as Record + ); + onSubmit({ engine, display_name: displayName, @@ -84,6 +204,7 @@ export default function CustomProviderForm({ models: modelList, supports_streaming: supportsStreaming, requires_auth: requiresApiKey, + headers: headersObject, }); }; @@ -260,6 +381,79 @@ export default function CustomProviderForm({ Provider supports streaming responses
+ +
+ +

+ Add custom HTTP headers to include in requests to the provider. Click the "+" button to add after filling both fields. +

+
+ {headers.map((header, index) => ( + + handleHeaderChange(index, 'key', e.target.value)} + placeholder="Header name" + className="w-full text-textStandard border-borderSubtle hover:border-borderStandard" + /> + handleHeaderChange(index, 'value', e.target.value)} + placeholder="Value" + className="w-full text-textStandard border-borderSubtle hover:border-borderStandard" + /> + + + ))} + + { + setNewHeaderKey(e.target.value); + clearHeaderValidation(); + }} + onKeyDown={handleHeaderKeyDown} + placeholder="Header name" + className={cn( + 'w-full text-textStandard border-borderSubtle hover:border-borderStandard', + invalidHeaderFields.key && 'border-red-500 focus:border-red-500' + )} + /> + { + setNewHeaderValue(e.target.value); + clearHeaderValidation(); + }} + onKeyDown={handleHeaderKeyDown} + placeholder="Value" + className={cn( + 'w-full text-textStandard border-borderSubtle hover:border-borderStandard', + invalidHeaderFields.value && 'border-red-500 focus:border-red-500' + )} + /> + +
+ {headerValidationError && ( +
{headerValidationError}
+ )} +
)}