Custom providers update (#4099)

Co-authored-by: developerayo <shodipovi@gmail.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
Douwe Osinga
2025-08-19 18:03:10 -04:00
committed by GitHub
parent 1d93a59f31
commit 942ef5b0a3
31 changed files with 1704 additions and 567 deletions
+12 -4
View File
@@ -170,9 +170,15 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const getProviders = useCallback(
async (forceRefresh = false): Promise<ProviderDetails[]> => {
if (forceRefresh || providersList.length === 0) {
const response = await providers();
setProvidersList(response.data || []);
return response.data || [];
try {
const response = await providers();
const providersData = response.data || [];
setProvidersList(providersData);
return providersData;
} catch (error) {
console.error('Failed to fetch providers:', error);
return [];
}
}
return providersList;
},
@@ -189,9 +195,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
// Load providers
try {
const providersResponse = await providers();
setProvidersList(providersResponse.data || []);
const providersData = providersResponse.data || [];
setProvidersList(providersData);
} catch (error) {
console.error('Failed to load providers:', error);
setProvidersList([]);
}
// Load extensions
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/dom';
import MarkdownContent from './MarkdownContent';
// Mock the icons to avoid import issues
@@ -159,11 +159,14 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
// Add the "Custom model" option to each provider group
formattedModelOptions.forEach((group) => {
group.options.push({
value: 'custom',
label: 'Use custom model',
provider: group.options[0]?.provider,
});
const providerName = group.options[0]?.provider;
if (providerName && !providerName.startsWith('custom_')) {
group.options.push({
value: 'custom',
label: 'Use custom model',
provider: providerName,
});
}
});
setModelOptions(formattedModelOptions);
@@ -45,7 +45,7 @@ export default function PermissionModal({ extensionName, onClose }: PermissionMo
console.error('Failed to get tools');
} else {
const filteredTools = (response.data || []).filter(
(tool) =>
(tool: ToolInfo) =>
tool.name !== 'platform__read_resource' && tool.name !== 'platform__list_resources'
);
setTools(filteredTools);
@@ -1,8 +1,12 @@
import React, { memo, useMemo, useCallback } from 'react';
import React, { memo, useMemo, useCallback, useState } from 'react';
import { ProviderCard } from './subcomponents/ProviderCard';
import CardContainer from './subcomponents/CardContainer';
import { ProviderModalProvider, useProviderModal } from './modal/ProviderModalProvider';
import ProviderConfigurationModal from './modal/ProviderConfiguationModal';
import { ProviderDetails } from '../../../api';
import { ProviderDetails, CreateCustomProviderRequest } from '../../../api';
import { Plus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
const GridLayout = memo(function GridLayout({ children }: { children: React.ReactNode }) {
return (
@@ -18,6 +22,27 @@ const GridLayout = memo(function GridLayout({ children }: { children: React.Reac
);
});
const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onClick: () => void }) {
return (
<CardContainer
testId="add-custom-provider-card"
onClick={onClick}
header={null}
body={
<div className="flex flex-col items-center justify-center min-h-[200px]">
<Plus className="w-8 h-8 text-gray-400 mb-2" />
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
<div>Add</div>
<div>Custom Provider</div>
</div>
</div>
}
grayedOut={false}
borderStyle="dashed"
/>
);
});
// Memoize the ProviderCards component
const ProviderCards = memo(function ProviderCards({
providers,
@@ -31,6 +56,7 @@ const ProviderCards = memo(function ProviderCards({
onProviderLaunch: (provider: ProviderDetails) => void;
}) {
const { openModal } = useProviderModal();
const [showCustomProviderModal, setShowCustomProviderModal] = useState(false);
// Memoize these functions so they don't get recreated on every render
const configureProviderViaModal = useCallback(
@@ -42,7 +68,7 @@ const ProviderCards = memo(function ProviderCards({
refreshProviders();
}
},
onDelete: () => {
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
@@ -56,7 +82,7 @@ const ProviderCards = memo(function ProviderCards({
const deleteProviderConfigViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
onDelete: () => {
onDelete: (_values: unknown) => {
// Only refresh if the function is provided
if (refreshProviders) {
refreshProviders();
@@ -68,12 +94,27 @@ const ProviderCards = memo(function ProviderCards({
[openModal, refreshProviders]
);
// We don't need an intermediate function here
// Just pass the onProviderLaunch directly
const handleCreateCustomProvider = useCallback(
async (data: CreateCustomProviderRequest) => {
try {
const { createCustomProvider } = await import('../../../api');
await createCustomProvider({ body: data });
setShowCustomProviderModal(false);
if (refreshProviders) {
refreshProviders();
}
} catch (error) {
console.error('Failed to create custom provider:', error);
}
},
[refreshProviders]
);
// Use useMemo to memoize the cards array
const providerCards = useMemo(() => {
return providers.map((provider) => (
// providers needs to be an array
const providersArray = Array.isArray(providers) ? providers : [];
const cards = providersArray.map((provider) => (
<ProviderCard
key={provider.name}
provider={provider}
@@ -83,6 +124,12 @@ const ProviderCards = memo(function ProviderCards({
isOnboarding={isOnboarding}
/>
));
cards.push(
<CustomProviderCard key="add-custom" onClick={() => setShowCustomProviderModal(true)} />
);
return cards;
}, [
providers,
isOnboarding,
@@ -91,7 +138,23 @@ const ProviderCards = memo(function ProviderCards({
onProviderLaunch,
]);
return <>{providerCards}</>;
return (
<>
{providerCards}
<Dialog open={showCustomProviderModal} onOpenChange={setShowCustomProviderModal}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Custom Provider</DialogTitle>
</DialogHeader>
<CustomProviderForm
onSubmit={handleCreateCustomProvider}
onCancel={() => setShowCustomProviderModal(false)}
/>
</DialogContent>
</Dialog>
</>
);
});
export default memo(function ProviderGrid({
@@ -18,7 +18,7 @@ import OllamaForm from './subcomponents/forms/OllamaForm';
import { useConfig } from '../../../ConfigContext';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AlertTriangle } from 'lucide-react';
import { ConfigKey } from '../../../../api';
import { ConfigKey, removeCustomProvider } from '../../../../api';
interface FormValues {
[key: string]: string | number | boolean | null;
@@ -162,13 +162,21 @@ export default function ProviderConfigurationModal() {
}
try {
// Remove the provider configuration
// get the keys
const params = currentProvider.metadata.config_keys;
const isCustomProvider = currentProvider.name.startsWith('custom_');
// go through the keys are remove them
for (const param of params) {
await remove(param.name, param.secret);
if (isCustomProvider) {
await removeCustomProvider({
path: { id: currentProvider.name },
});
} else {
// Remove the provider configuration
// get the keys
const params = currentProvider.metadata.config_keys;
// go through the keys are remove them
for (const param of params) {
await remove(param.name, param.secret);
}
}
// Call onDelete callback if provided
@@ -247,9 +255,9 @@ export default function ProviderConfigurationModal() {
setShowDeleteConfirmation(false);
setIsActiveProvider(false);
}}
canDelete={isConfigured && !isActiveProvider} // Disable delete button for active provider
canDelete={isConfigured && !isActiveProvider}
providerName={currentProvider.metadata.display_name}
isActiveProvider={isActiveProvider} // Pass this to actions for button state
isActiveProvider={isActiveProvider}
/>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,198 @@
import React, { useState } from 'react';
import { Input } from '../../../../../ui/input';
import { Select } from '../../../../../ui/Select';
import { Button } from '../../../../../ui/button';
import { SecureStorageNotice } from '../SecureStorageNotice';
import { Checkbox } from '@radix-ui/themes';
interface CustomProviderFormProps {
onSubmit: (data: {
provider_type: string;
display_name: string;
api_url: string;
api_key: string;
models: string[];
supports_streaming: boolean;
}) => void;
onCancel: () => void;
}
export default function CustomProviderForm({ onSubmit, onCancel }: CustomProviderFormProps) {
const [providerType, setProviderType] = useState('openai_compatible');
const [displayName, setDisplayName] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [models, setModels] = useState('');
const [isLocalModel, setIsLocalModel] = useState(false);
const [supportsStreaming, setSupportsStreaming] = useState(true);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const handleLocalModels = (checked: boolean) => {
setIsLocalModel(checked);
if (checked) {
setApiKey('notrequired');
} else {
setApiKey('');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const errors: Record<string, string> = {};
if (!displayName) errors.displayName = 'Display name is required';
if (!apiUrl) errors.apiUrl = 'API URL is required';
if (!isLocalModel && !apiKey) errors.apiKey = 'API key is required';
if (!models) errors.models = 'At least one model is required';
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
const modelList = models
.split(',')
.map((m) => m.trim())
.filter((m) => m);
onSubmit({
provider_type: providerType,
display_name: displayName,
api_url: apiUrl,
api_key: apiKey,
models: modelList,
supports_streaming: supportsStreaming,
});
};
return (
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<div>
<label className="flex items-center text-sm font-medium text-white mb-1">
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
options={[
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
]}
value={{
value: providerType,
label:
providerType === 'openai_compatible'
? 'OpenAI Compatible'
: providerType === 'anthropic_compatible'
? 'Anthropic Compatible'
: 'Ollama Compatible',
}}
onChange={(option: unknown) => {
const selectedOption = option as { value: string; label: string } | null;
if (selectedOption) setProviderType(selectedOption.value);
}}
isSearchable={false}
/>
</div>
<div>
<label className="flex items-center text-sm font-medium text-white mb-1">
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p className="text-red-500 text-sm mt-1">{validationErrors.displayName}</p>
)}
</div>
<div>
<label className="flex items-center text-sm font-medium text-white mb-1">
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p className="text-red-500 text-sm mt-1">{validationErrors.apiUrl}</p>
)}
</div>
<div>
<label className="flex items-center text-sm font-medium text-white mb-1">
API Key
{!isLocalModel && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your API key"
className={validationErrors.apiKey ? 'border-red-500' : ''}
disabled={isLocalModel}
/>
{validationErrors.apiKey && (
<p className="text-red-500 text-sm mt-1">{validationErrors.apiKey}</p>
)}
<div className="flex items-center space-x-2 mt-2">
<Checkbox id="local-model" checked={isLocalModel} onCheckedChange={handleLocalModels} />
<label
htmlFor="local-model"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-400"
>
This is a local model (no auth required)
</label>
</div>
</div>
<div>
<label className="flex items-center text-sm font-medium text-white mb-1">
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p className="text-red-500 text-sm mt-1">{validationErrors.models}</p>
)}
</div>
<div className="flex items-center space-x-2 mb-10">
<Checkbox
id="supports-streaming"
checked={supportsStreaming}
onCheckedChange={(checked) => setSupportsStreaming(checked as boolean)}
/>
<label
htmlFor="supports-streaming"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-400"
>
Provider supports streaming responses
</label>
</div>
<SecureStorageNotice />
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Create Provider</Button>
</div>
</form>
);
}
@@ -3,9 +3,7 @@ import { Input } from '../../../../../ui/input';
import { useConfig } from '../../../../../ConfigContext'; // Adjust this import path as needed
import { ProviderDetails, ConfigKey } from '../../../../../../api';
interface ValidationErrors {
[key: string]: string;
}
type ValidationErrors = Record<string, string>;
interface DefaultProviderSetupFormProps {
configValues: Record<string, string>;
@@ -36,33 +34,30 @@ export default function DefaultProviderSetupForm({
// Try to load actual values from config for each parameter that is not secret
for (const parameter of parameters) {
if (parameter.required) {
try {
// Check if there's a stored value in the config system
const configKey = `${parameter.name}`;
const configResponse = await read(configKey, parameter.secret || false);
try {
// Check if there's a stored value in the config system
const configKey = `${parameter.name}`;
const configResponse = await read(configKey, parameter.secret || false);
if (configResponse) {
// Use the value from the config provider
newValues[parameter.name] = String(configResponse);
} else if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
// Fall back to default value if no config value exists
newValues[parameter.name] = String(parameter.default);
}
} catch (error) {
console.error(`Failed to load config for ${parameter.name}:`, error);
// Fall back to default if read operation fails
if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
newValues[parameter.name] = String(parameter.default);
}
if (configResponse) {
newValues[parameter.name] = parameter.secret ? 'true' : String(configResponse);
} else if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
// Fall back to default value if no config value exists
newValues[parameter.name] = String(parameter.default);
}
} catch (error) {
console.error(`Failed to load config for ${parameter.name}:`, error);
// Fall back to default if read operation fails
if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
newValues[parameter.name] = String(parameter.default);
}
}
}
@@ -85,6 +80,11 @@ export default function DefaultProviderSetupForm({
return parameters.filter((param) => param.required === true);
}, [parameters]);
// TODO: show all params, not just required ones
// const allParameters = useMemo(() => {
// return parameters;
// }, [parameters]);
// Helper function to generate appropriate placeholder text
const getPlaceholder = (parameter: ConfigKey): string => {
// If default is defined and not null, show it
@@ -92,8 +92,30 @@ export default function DefaultProviderSetupForm({
return `Default: ${parameter.default}`;
}
// Otherwise, use the parameter name as a hint
return parameter.name.toUpperCase();
const name = parameter.name.toLowerCase();
if (name.includes('api_key')) return 'Your API key';
if (name.includes('api_url') || name.includes('host')) return 'https://api.example.com';
if (name.includes('models')) return 'model-a, model-b';
return parameter.name
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
};
// helper for custom labels
const getFieldLabel = (parameter: ConfigKey): string => {
const name = parameter.name.toLowerCase();
if (name.includes('api_key')) return 'API Key';
if (name.includes('api_url') || name.includes('host')) return 'API Host';
if (name.includes('models')) return 'Models';
return parameter.name
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
};
if (isLoading) {
@@ -111,25 +133,30 @@ export default function DefaultProviderSetupForm({
requiredParameters.map((parameter) => (
<div key={parameter.name}>
<label className="block text-sm font-medium text-textStandard mb-1">
{parameter.name}
{getFieldLabel(parameter)}
{parameter.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
type={parameter.secret ? 'password' : 'text'}
value={configValues[parameter.name] || ''}
onChange={(e) =>
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
console.log(`Setting ${parameter.name} to:`, e.target.value);
setConfigValues((prev) => ({
...prev,
[parameter.name]: e.target.value,
}))
}
}));
}}
placeholder={getPlaceholder(parameter)}
className={`w-full h-14 px-4 font-regular rounded-lg shadow-none ${
validationErrors[parameter.name]
? 'border-2 border-red-500'
: 'border border-borderSubtle hover:border-borderStandard'
} bg-background-default text-lg placeholder:text-textSubtle font-regular text-textStandard`}
required={true}
required={parameter.required}
/>
{validationErrors[parameter.name] && (
<p className="text-red-500 text-sm mt-1">{validationErrors[parameter.name]}</p>
)}
</div>
))
)}
@@ -6,6 +6,7 @@ interface CardContainerProps {
onClick: () => void;
grayedOut: boolean;
testId?: string;
borderStyle?: 'solid' | 'dashed';
}
function GlowingRing() {
@@ -33,6 +34,7 @@ export default function CardContainer({
onClick,
grayedOut = false,
testId,
borderStyle = 'solid',
}: CardContainerProps) {
return (
<div
@@ -50,19 +52,21 @@ export default function CardContainer({
>
{!grayedOut && <GlowingRing />}
<div
className={`relative bg-background-default rounded-lg p-3 transition-all duration-200 h-[160px] flex flex-col justify-between
className={`relative bg-background-default rounded-lg p-3 transition-all duration-200 h-[160px] flex flex-col
${header ? 'justify-between' : 'justify-center'}
${borderStyle === 'dashed' ? 'border-2 border-dashed' : 'border'}
${
grayedOut
? 'border border-borderSubtle'
: 'border border-borderSubtle hover:border-borderStandard'
? 'border-borderSubtle'
: 'border-borderSubtle hover:border-borderStandard'
}`}
>
{/* Apply opacity only to the header when grayed out */}
<div style={{ opacity: grayedOut ? '0.5' : '1' }}>
<HeaderContainer>{header}</HeaderContainer>
</div>
{header && (
<div style={{ opacity: grayedOut ? '0.5' : '1' }}>
<HeaderContainer>{header}</HeaderContainer>
</div>
)}
{/* Body always at full opacity */}
<div>{body}</div>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {