Declarative providers (#5084)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2025-10-15 09:48:14 -04:00
committed by GitHub
parent 925a042cb1
commit 9251da4314
28 changed files with 1219 additions and 802 deletions
@@ -18,6 +18,7 @@ import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata } from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import { ProviderType } from '../../../../api';
type SwitchModelModalProps = {
sessionId: string | null;
@@ -165,8 +166,9 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
const results = await Promise.all(modelPromises);
// Process results and build grouped options
const groupedOptions: { options: { value: string; label: string; provider: string }[] }[] =
[];
const groupedOptions: {
options: { value: string; label: string; provider: string; providerType: ProviderType }[];
}[] = [];
const errors: string[] = [];
results.forEach(({ provider: p, models, error }) => {
@@ -178,13 +180,19 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
options: p.metadata.known_models.map(({ name }) => ({
value: name,
label: name,
providerType: p.provider_type,
provider: p.name,
})),
});
}
} else if (models && models.length > 0) {
groupedOptions.push({
options: models.map((m) => ({ value: m, label: m, provider: p.name })),
options: models.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
})),
});
}
});
@@ -196,12 +204,14 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
// Add the "Custom model" option to each provider group
groupedOptions.forEach((group) => {
const providerName = group.options[0]?.provider;
if (providerName && !providerName.startsWith('custom_')) {
const option = group.options[0];
const providerName = option?.provider;
if (providerName && option?.providerType !== 'Custom') {
group.options.push({
value: 'custom',
label: 'Use custom model',
provider: providerName,
providerType: option?.providerType,
});
}
});
@@ -3,7 +3,11 @@ import { ProviderCard } from './subcomponents/ProviderCard';
import CardContainer from './subcomponents/CardContainer';
import { ProviderModalProvider, useProviderModal } from './modal/ProviderModalProvider';
import ProviderConfigurationModal from './modal/ProviderConfiguationModal';
import { ProviderDetails, CreateCustomProviderRequest } from '../../../api';
import {
DeclarativeProviderConfig,
ProviderDetails,
UpdateCustomProviderRequest,
} from '../../../api';
import { Plus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
@@ -43,7 +47,6 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
);
});
// Memoize the ProviderCards component
const ProviderCards = memo(function ProviderCards({
providers,
isOnboarding,
@@ -57,28 +60,69 @@ const ProviderCards = memo(function ProviderCards({
}) {
const { openModal } = useProviderModal();
const [showCustomProviderModal, setShowCustomProviderModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<{
id: string;
config: DeclarativeProviderConfig;
isEditable: boolean;
} | null>(null);
// Memoize these functions so they don't get recreated on every render
const configureProviderViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
onSubmit: () => {
// Only refresh if the function is provided
if (refreshProviders) {
refreshProviders();
}
},
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
},
formProps: {},
});
async (provider: ProviderDetails) => {
if (provider.provider_type === 'Custom' || provider.provider_type === 'Declarative') {
const { getCustomProvider } = await import('../../../api');
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
if (result.data) {
setEditingProvider({
id: provider.name,
config: result.data.config,
isEditable: result.data.is_editable,
});
setShowCustomProviderModal(true);
}
} else {
openModal(provider, {
onSubmit: () => {
if (refreshProviders) {
refreshProviders();
}
},
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
},
formProps: {},
});
}
},
[openModal, refreshProviders]
);
const handleUpdateCustomProvider = useCallback(
async (data: UpdateCustomProviderRequest) => {
if (!editingProvider) return;
const { updateCustomProvider } = await import('../../../api');
await updateCustomProvider({
path: { id: editingProvider.id },
body: data,
throwOnError: true,
});
setShowCustomProviderModal(false);
setEditingProvider(null);
if (refreshProviders) {
refreshProviders();
}
},
[editingProvider, refreshProviders]
);
const handleCloseModal = useCallback(() => {
setShowCustomProviderModal(false);
setEditingProvider(null);
}, []);
const deleteProviderConfigViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
@@ -95,22 +139,17 @@ const ProviderCards = memo(function ProviderCards({
);
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);
async (data: UpdateCustomProviderRequest) => {
const { createCustomProvider } = await import('../../../api');
await createCustomProvider({ body: data, throwOnError: true });
setShowCustomProviderModal(false);
if (refreshProviders) {
refreshProviders();
}
},
[refreshProviders]
);
// Use useMemo to memoize the cards array
const providerCards = useMemo(() => {
// providers needs to be an array
const providersArray = Array.isArray(providers) ? providers : [];
@@ -138,21 +177,33 @@ const ProviderCards = memo(function ProviderCards({
onProviderLaunch,
]);
const initialData = editingProvider && {
engine: editingProvider.config.engine.toLowerCase() + '_compatible',
display_name: editingProvider.config.display_name,
api_url: editingProvider.config.base_url,
api_key: '',
models: editingProvider.config.models.map((m) => m.name),
supports_streaming: editingProvider.config.supports_streaming ?? true,
};
const editable = editingProvider ? editingProvider.isEditable : true;
const title = (editingProvider ? (editable ? 'Edit' : 'Configure') : 'Add') + ' Provider';
return (
<>
{providerCards}
<Dialog open={showCustomProviderModal} onOpenChange={setShowCustomProviderModal}>
<Dialog open={showCustomProviderModal} onOpenChange={handleCloseModal}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Custom Provider</DialogTitle>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<CustomProviderForm
onSubmit={handleCreateCustomProvider}
onCancel={() => setShowCustomProviderModal(false)}
initialData={initialData}
isEditable={editable}
onSubmit={editingProvider ? handleUpdateCustomProvider : handleCreateCustomProvider}
onCancel={handleCloseModal}
/>
</DialogContent>
</Dialog>
</Dialog>{' '}
</>
);
});
@@ -162,7 +162,7 @@ export default function ProviderConfigurationModal() {
}
try {
const isCustomProvider = currentProvider.name.startsWith('custom_');
const isCustomProvider = currentProvider.provider_type === 'Custom';
if (isCustomProvider) {
await removeCustomProvider({
@@ -1,24 +1,25 @@
import React, { useState } from 'react';
import React, { useState, useEffect } 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';
import { UpdateCustomProviderRequest } from '../../../../../../api';
interface CustomProviderFormProps {
onSubmit: (data: {
provider_type: string;
display_name: string;
api_url: string;
api_key: string;
models: string[];
supports_streaming: boolean;
}) => void;
onSubmit: (data: UpdateCustomProviderRequest) => void;
onCancel: () => void;
initialData: UpdateCustomProviderRequest | null;
isEditable?: boolean;
}
export default function CustomProviderForm({ onSubmit, onCancel }: CustomProviderFormProps) {
const [providerType, setProviderType] = useState('openai_compatible');
export default function CustomProviderForm({
onSubmit,
onCancel,
initialData,
isEditable,
}: CustomProviderFormProps) {
const [engine, setEngine] = useState('openai_compatible');
const [displayName, setDisplayName] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
@@ -27,6 +28,22 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
const [supportsStreaming, setSupportsStreaming] = useState(true);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (initialData) {
const engineMap: Record<string, string> = {
openai: 'openai_compatible',
anthropic: 'anthropic_compatible',
ollama: 'ollama_compatible',
};
setEngine(engineMap[initialData.engine.toLowerCase()] || 'openai_compatible');
setDisplayName(initialData.display_name);
setApiUrl(initialData.api_url);
setModels(initialData.models.join(', '));
setSupportsStreaming(initialData.supports_streaming ?? true);
}
}, [initialData]);
const handleLocalModels = (checked: boolean) => {
setIsLocalModel(checked);
if (checked) {
@@ -42,7 +59,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
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 (!isLocalModel && !apiKey && !initialData) errors.apiKey = 'API key is required';
if (!models) errors.models = 'At least one model is required';
if (Object.keys(errors).length > 0) {
@@ -56,7 +73,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
.filter((m) => m);
onSubmit({
provider_type: providerType,
engine,
display_name: displayName,
api_url: apiUrl,
api_key: apiKey,
@@ -67,92 +84,94 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
return (
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<div>
<label
htmlFor="provider-select"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
id="provider-select"
aria-invalid={!!validationErrors.providerType}
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
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}
/>
{validationErrors.providerType && (
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
{validationErrors.providerType}
</p>
)}
</div>
<div>
<label
htmlFor="display-name"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
aria-invalid={!!validationErrors.displayName}
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p id="display-name-error" className="text-red-500 text-sm mt-1">
{validationErrors.displayName}
</p>
)}
</div>
<div>
<label
htmlFor="api-url"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="api-url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
aria-invalid={!!validationErrors.apiUrl}
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p id="api-url-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiUrl}
</p>
)}
</div>
{isEditable && (
<>
<div>
<label
htmlFor="provider-select"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
id="provider-select"
aria-invalid={!!validationErrors.providerType}
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
]}
value={{
value: engine,
label:
engine === 'openai_compatible'
? 'OpenAI Compatible'
: engine === 'anthropic_compatible'
? 'Anthropic Compatible'
: 'Ollama Compatible',
}}
onChange={(option: unknown) => {
const selectedOption = option as { value: string; label: string } | null;
if (selectedOption) setEngine(selectedOption.value);
}}
isSearchable={false}
/>
{validationErrors.providerType && (
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
{validationErrors.providerType}
</p>
)}
</div>
<div>
<label
htmlFor="display-name"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
aria-invalid={!!validationErrors.displayName}
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p id="display-name-error" className="text-red-500 text-sm mt-1">
{validationErrors.displayName}
</p>
)}
</div>
<div>
<label
htmlFor="api-url"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="api-url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
aria-invalid={!!validationErrors.apiUrl}
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p id="api-url-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiUrl}
</p>
)}
</div>
</>
)}
<div>
<label
@@ -160,14 +179,14 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API Key
{!isLocalModel && <span className="text-red-500 ml-1">*</span>}
{!isLocalModel && !initialData && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your API key"
placeholder={initialData ? 'Leave blank to keep existing key' : 'Your API key'}
aria-invalid={!!validationErrors.apiKey}
aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined}
className={validationErrors.apiKey ? 'border-red-500' : ''}
@@ -179,62 +198,64 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
</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-textSubtle"
>
This is a local model (no auth required)
</label>
</div>
</div>
<div>
<label
htmlFor="available-models"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="available-models"
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
aria-invalid={!!validationErrors.models}
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p id="available-models-error" className="text-red-500 text-sm mt-1">
{validationErrors.models}
</p>
{!initialData && (
<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-textSubtle"
>
This is a local model (no auth required)
</label>
</div>
)}
</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-textSubtle"
>
Provider supports streaming responses
</label>
</div>
{isEditable && (
<>
<div>
<label
htmlFor="available-models"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="available-models"
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
aria-invalid={!!validationErrors.models}
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p id="available-models-error" 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-textSubtle"
>
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>
<Button type="submit">{initialData ? 'Update Provider' : 'Create Provider'}</Button>
</div>
</form>
);