Desktop: - No Custom Headers field for custom OpenAI-compatible providers (#6681)

Signed-off-by: Lymah123 <fimihanodunola625@gmail.com>
Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
Lymah
2026-02-12 01:20:56 +01:00
committed by GitHub
parent 153872a47c
commit f96d38e382
7 changed files with 246 additions and 25 deletions
@@ -207,7 +207,11 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
api_key_env, api_key_env,
base_url: params.api_url, base_url: params.api_url,
models: model_infos, 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, timeout_seconds: existing_config.timeout_seconds,
supports_streaming: params.supports_streaming, supports_streaming: params.supports_streaming,
requires_auth: params.requires_auth, requires_auth: params.requires_auth,
+15 -19
View File
@@ -126,6 +126,7 @@ export default function ChatInput({
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [pastedImages, setPastedImages] = useState<PastedImage[]>([]); const [pastedImages, setPastedImages] = useState<PastedImage[]>([]);
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
// Derived state - chatState != Idle means we're in some form of loading state // Derived state - chatState != Idle means we're in some form of loading state
const isLoading = chatState !== ChatState.Idle; const isLoading = chatState !== ChatState.Idle;
@@ -148,7 +149,6 @@ export default function ChatInput({
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false); const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false);
const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); const [showEditRecipeModal, setShowEditRecipeModal] = useState(false);
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
const [sessionWorkingDir, setSessionWorkingDir] = useState<string | null>(null); const [sessionWorkingDir, setSessionWorkingDir] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -1190,13 +1190,11 @@ export default function ChatInput({
return ( return (
<div <div
className={`flex flex-col relative h-auto p-4 transition-colors ${ className={`flex flex-col relative h-auto p-4 transition-colors ${disableAnimation ? '' : 'page-transition'
disableAnimation ? '' : 'page-transition' } ${isFocused
} ${
isFocused
? 'border-border-strong hover:border-border-strong' ? 'border-border-strong hover:border-border-strong'
: 'border-border-default hover:border-border-default' : 'border-border-default hover:border-border-default'
} bg-background-default z-10 rounded-t-2xl`} } bg-background-default z-10 rounded-t-2xl`}
data-drop-zone="true" data-drop-zone="true"
onDrop={handleLocalDrop} onDrop={handleLocalDrop}
onDragOver={handleLocalDragOver} onDragOver={handleLocalDragOver}
@@ -1265,7 +1263,7 @@ export default function ChatInput({
size="sm" size="sm"
shape="round" shape="round"
variant="outline" variant="outline"
onClick={() => {}} onClick={() => { }}
disabled={true} disabled={true}
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2" 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} disabled={isTranscribing}
className={`rounded-full px-6 py-2 ${ className={`rounded-full px-6 py-2 ${isRecording
isRecording ? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
? 'bg-red-500 text-white hover:bg-red-600 border-red-500' : isTranscribing
: isTranscribing ? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
? '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'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600' }`}
}`}
> >
<Microphone /> <Microphone />
</Button> </Button>
@@ -1356,11 +1353,10 @@ export default function ChatInput({
shape="round" shape="round"
variant="outline" variant="outline"
disabled={isSubmitButtonDisabled} disabled={isSubmitButtonDisabled}
className={`rounded-full px-10 py-2 flex items-center gap-2 ${ className={`rounded-full px-10 py-2 flex items-center gap-2 ${isSubmitButtonDisabled
isSubmitButtonDisabled ? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
? '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'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer' }`}
}`}
> >
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
<span className="text-sm">Send</span> <span className="text-sm">Send</span>
@@ -156,6 +156,19 @@ export default function ExtensionModal({
}; };
const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => { 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]; const newHeaders = [...formData.headers];
newHeaders[index][field] = value; newHeaders[index][field] = value;
@@ -44,6 +44,10 @@ export default function HeadersSection({
const keyEmpty = !newKey.trim(); const keyEmpty = !newKey.trim();
const valueEmpty = !newValue.trim(); const valueEmpty = !newValue.trim();
const keyHasSpaces = newKey.includes(' '); const keyHasSpaces = newKey.includes(' ');
const normalizedNewKey = newKey.trim().toLowerCase();
const isDuplicate = headers.some(
h => h.key.trim().toLowerCase() === normalizedNewKey
);
if (keyEmpty || valueEmpty) { if (keyEmpty || valueEmpty) {
setInvalidFields({ setInvalidFields({
@@ -63,6 +67,15 @@ export default function HeadersSection({
return; return;
} }
if (isDuplicate) {
setInvalidFields({
key: true,
value: false,
});
setValidationError('A header with this name already exists');
return;
}
setValidationError(null); setValidationError(null);
setInvalidFields({ key: false, value: false }); setInvalidFields({ key: false, value: false });
onAdd(newKey, newValue); onAdd(newKey, newValue);
@@ -100,8 +100,8 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
description: extension.description || '', description: extension.description || '',
type: type:
extension.type === 'frontend' || extension.type === 'frontend' ||
extension.type === 'inline_python' || extension.type === 'inline_python' ||
extension.type === 'platform' extension.type === 'platform'
? 'stdio' ? 'stdio'
: extension.type, : extension.type,
cmd: extension.type === 'stdio' ? quoteShell([extension.cmd, ...extension.args]) : undefined, cmd: extension.type === 'stdio' ? quoteShell([extension.cmd, ...extension.args]) : undefined,
@@ -155,7 +155,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
timeout: formData.timeout, timeout: formData.timeout,
uri: formData.endpoint || '', uri: formData.endpoint || '',
...(env_keys.length > 0 ? { env_keys } : {}), ...(env_keys.length > 0 ? { env_keys } : {}),
...(Object.keys(headers).length > 0 ? { headers } : {}), headers,
}; };
} else { } else {
// For other types // For other types
@@ -238,6 +238,7 @@ function ProviderCards({
models: editingProvider.config.models.map((m) => m.name), models: editingProvider.config.models.map((m) => m.name),
supports_streaming: editingProvider.config.supports_streaming ?? true, supports_streaming: editingProvider.config.supports_streaming ?? true,
requires_auth: editingProvider.config.requires_auth ?? true, requires_auth: editingProvider.config.requires_auth ?? true,
headers: editingProvider.config.headers ?? undefined,
}; };
const editable = editingProvider ? editingProvider.isEditable : true; const editable = editingProvider ? editingProvider.isEditable : true;
@@ -246,7 +247,7 @@ function ProviderCards({
<> <>
{providerCards} {providerCards}
<Dialog open={showCustomProviderModal} onOpenChange={handleCloseModal}> <Dialog open={showCustomProviderModal} onOpenChange={handleCloseModal}>
<DialogContent className="sm:max-w-[600px]"> <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -4,7 +4,8 @@ import { Select } from '../../../../../ui/Select';
import { Button } from '../../../../../ui/button'; import { Button } from '../../../../../ui/button';
import { SecureStorageNotice } from '../SecureStorageNotice'; import { SecureStorageNotice } from '../SecureStorageNotice';
import { UpdateCustomProviderRequest } from '../../../../../../api'; import { UpdateCustomProviderRequest } from '../../../../../../api';
import { Trash2, AlertTriangle } from 'lucide-react'; import { Plus, X, Trash2, AlertTriangle } from 'lucide-react';
import { cn } from '../../../../../../utils';
interface CustomProviderFormProps { interface CustomProviderFormProps {
onSubmit: (data: UpdateCustomProviderRequest) => void; onSubmit: (data: UpdateCustomProviderRequest) => void;
@@ -30,6 +31,14 @@ export default function CustomProviderForm({
const [models, setModels] = useState(''); const [models, setModels] = useState('');
const [requiresApiKey, setRequiresApiKey] = useState(false); const [requiresApiKey, setRequiresApiKey] = useState(false);
const [supportsStreaming, setSupportsStreaming] = useState(true); 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<string | null>(null);
const [invalidHeaderFields, setInvalidHeaderFields] = useState<{ key: boolean; value: boolean }>({
key: false,
value: false,
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
@@ -46,6 +55,14 @@ export default function CustomProviderForm({
setModels(initialData.models.join(', ')); setModels(initialData.models.join(', '));
setSupportsStreaming(initialData.supports_streaming ?? true); setSupportsStreaming(initialData.supports_streaming ?? true);
setRequiresApiKey(initialData.requires_auth ?? true); setRequiresApiKey(initialData.requires_auth ?? true);
if (initialData.headers) {
const headerList = Object.entries(initialData.headers).map(([key, value]) => ({
key,
value,
}));
setHeaders(headerList);
}
} }
}, [initialData]); }, [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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -76,6 +172,30 @@ export default function CustomProviderForm({
.map((m) => m.trim()) .map((m) => m.trim())
.filter((m) => m); .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<string, string>
);
onSubmit({ onSubmit({
engine, engine,
display_name: displayName, display_name: displayName,
@@ -84,6 +204,7 @@ export default function CustomProviderForm({
models: modelList, models: modelList,
supports_streaming: supportsStreaming, supports_streaming: supportsStreaming,
requires_auth: requiresApiKey, requires_auth: requiresApiKey,
headers: headersObject,
}); });
}; };
@@ -260,6 +381,79 @@ export default function CustomProviderForm({
Provider supports streaming responses Provider supports streaming responses
</label> </label>
</div> </div>
<div>
<label className="text-sm font-medium text-textStandard mb-2 block">
Custom Headers
</label>
<p className="text-xs text-textSubtle mb-4">
Add custom HTTP headers to include in requests to the provider. Click the "+" button to add after filling both fields.
</p>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
{headers.map((header, index) => (
<React.Fragment key={index}>
<Input
value={header.key}
onChange={(e) => handleHeaderChange(index, 'key', e.target.value)}
placeholder="Header name"
className="w-full text-textStandard border-borderSubtle hover:border-borderStandard"
/>
<Input
value={header.value}
onChange={(e) => handleHeaderChange(index, 'value', e.target.value)}
placeholder="Value"
className="w-full text-textStandard border-borderSubtle hover:border-borderStandard"
/>
<Button
onClick={() => handleRemoveHeader(index)}
variant="ghost"
type="button"
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
>
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
</Button>
</React.Fragment>
))}
<Input
value={newHeaderKey}
onChange={(e) => {
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'
)}
/>
<Input
value={newHeaderValue}
onChange={(e) => {
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'
)}
/>
<Button
onClick={handleAddHeader}
variant="ghost"
type="button"
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-background-default border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
>
<Plus /> Add
</Button>
</div>
{headerValidationError && (
<div className="mt-2 text-red-500 text-sm">{headerValidationError}</div>
)}
</div>
</> </>
)} )}
<SecureStorageNotice /> <SecureStorageNotice />