Simplified custom model flow with canonical models (#6934)
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -169,6 +169,7 @@ export type CspMetadata = {
|
||||
export type DeclarativeProviderConfig = {
|
||||
api_key_env?: string;
|
||||
base_url: string;
|
||||
catalog_provider_id?: string | null;
|
||||
description?: string | null;
|
||||
display_name: string;
|
||||
engine: ProviderEngine;
|
||||
@@ -671,6 +672,13 @@ export type MessageMetadata = {
|
||||
userVisible: boolean;
|
||||
};
|
||||
|
||||
export type ModelCapabilities = {
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
temperature: boolean;
|
||||
tool_call: boolean;
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
context_limit?: number | null;
|
||||
max_tokens?: number | null;
|
||||
@@ -767,6 +775,14 @@ export type ModelSettings = {
|
||||
use_mlock?: boolean;
|
||||
};
|
||||
|
||||
export type ModelTemplate = {
|
||||
capabilities: ModelCapabilities;
|
||||
context_limit: number;
|
||||
deprecated: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ParseRecipeRequest = {
|
||||
content: string;
|
||||
};
|
||||
@@ -819,6 +835,16 @@ export type PromptsListResponse = {
|
||||
prompts: Array<Template>;
|
||||
};
|
||||
|
||||
export type ProviderCatalogEntry = {
|
||||
api_url: string;
|
||||
doc_url: string;
|
||||
env_var: string;
|
||||
format: string;
|
||||
id: string;
|
||||
model_count: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProviderDetails = {
|
||||
is_configured: boolean;
|
||||
metadata: ProviderMetadata;
|
||||
@@ -862,6 +888,17 @@ export type ProviderMetadata = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProviderTemplate = {
|
||||
api_url: string;
|
||||
doc_url: string;
|
||||
env_var: string;
|
||||
format: string;
|
||||
id: string;
|
||||
models: Array<ModelTemplate>;
|
||||
name: string;
|
||||
supports_streaming: boolean;
|
||||
};
|
||||
|
||||
export type ProviderType = 'Preferred' | 'Builtin' | 'Declarative' | 'Custom';
|
||||
|
||||
export type ProvidersResponse = {
|
||||
@@ -1435,6 +1472,7 @@ export type UiMetadata = {
|
||||
export type UpdateCustomProviderRequest = {
|
||||
api_key: string;
|
||||
api_url: string;
|
||||
catalog_provider_id?: string | null;
|
||||
display_name: string;
|
||||
engine: string;
|
||||
headers?: {
|
||||
@@ -2470,6 +2508,62 @@ export type SavePromptResponses = {
|
||||
|
||||
export type SavePromptResponse = SavePromptResponses[keyof SavePromptResponses];
|
||||
|
||||
export type GetProviderCatalogData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Filter by provider format (openai, anthropic, ollama)
|
||||
*/
|
||||
format?: string | null;
|
||||
};
|
||||
url: '/config/provider-catalog';
|
||||
};
|
||||
|
||||
export type GetProviderCatalogErrors = {
|
||||
/**
|
||||
* Invalid format parameter
|
||||
*/
|
||||
400: unknown;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogResponses = {
|
||||
/**
|
||||
* Provider catalog retrieved successfully
|
||||
*/
|
||||
200: Array<ProviderCatalogEntry>;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogResponse = GetProviderCatalogResponses[keyof GetProviderCatalogResponses];
|
||||
|
||||
export type GetProviderCatalogTemplateData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Provider ID from models.dev
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/config/provider-catalog/{id}';
|
||||
};
|
||||
|
||||
export type GetProviderCatalogTemplateErrors = {
|
||||
/**
|
||||
* Provider not found in catalog
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogTemplateResponses = {
|
||||
/**
|
||||
* Provider template retrieved successfully
|
||||
*/
|
||||
200: ProviderTemplate;
|
||||
};
|
||||
|
||||
export type GetProviderCatalogTemplateResponse = GetProviderCatalogTemplateResponses[keyof GetProviderCatalogTemplateResponses];
|
||||
|
||||
export type ProvidersData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -183,7 +183,8 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl font-light">Run Locally</h1>
|
||||
<p className="text-text-muted text-base sm:text-lg">
|
||||
Download a model to run Goose entirely on your machine — no API keys, no accounts, completely free and private.
|
||||
Download a model to run Goose entirely on your machine — no API keys, no accounts,
|
||||
completely free and private.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +270,12 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -294,8 +300,12 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-text-default text-sm">{model.id}</span>
|
||||
<span className="text-xs text-text-muted">{formatSize(model.size_bytes)}</span>
|
||||
<span className="font-medium text-text-default text-sm">
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatSize(model.size_bytes)}
|
||||
</span>
|
||||
{model.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
Ready
|
||||
@@ -368,7 +378,8 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
|
||||
)}
|
||||
{downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && (
|
||||
<span>
|
||||
~{downloadProgress.eta_seconds < 60
|
||||
~
|
||||
{downloadProgress.eta_seconds < 60
|
||||
? `${Math.round(downloadProgress.eta_seconds)}s`
|
||||
: `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '}
|
||||
remaining
|
||||
|
||||
@@ -214,9 +214,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !searching && (
|
||||
<p className="text-xs text-text-muted">{error}</p>
|
||||
)}
|
||||
{error && !searching && <p className="text-xs text-text-muted">{error}</p>}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||
@@ -289,9 +287,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
{variant.description && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{variant.description}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{variant.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -323,7 +319,8 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-default mb-2">Direct Download</h4>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
Specify a model directly: <code className="bg-background-subtle px-1 rounded">user/repo:quantization</code>
|
||||
Specify a model directly:{' '}
|
||||
<code className="bg-background-subtle px-1 rounded">user/repo:quantization</code>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
@@ -14,12 +14,7 @@ import {
|
||||
} from '../../../api';
|
||||
import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
|
||||
import { ModelSettingsPanel } from './ModelSettingsPanel';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
@@ -273,9 +268,7 @@ export const LocalInferenceSettings = () => {
|
||||
onChange={() => selectModel(model.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-default">
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-default">{model.id}</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(model.size_bytes)}
|
||||
</span>
|
||||
@@ -324,9 +317,7 @@ export const LocalInferenceSettings = () => {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium text-text-default">
|
||||
{model.id}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-text-default">{model.id}</h4>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(model.size_bytes)}
|
||||
</span>
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
const DEFAULT_SETTINGS: ModelSettings = {
|
||||
context_size: null,
|
||||
max_output_tokens: null,
|
||||
sampling: { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null },
|
||||
sampling: {
|
||||
type: 'Temperature',
|
||||
temperature: 0.8,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
seed: null,
|
||||
},
|
||||
repeat_penalty: 1.0,
|
||||
repeat_last_n: 64,
|
||||
frequency_penalty: 0.0,
|
||||
@@ -177,7 +184,14 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
} else if (type === 'MirostatV2') {
|
||||
sampling = { type: 'MirostatV2', tau: 5.0, eta: 0.1, seed: null };
|
||||
} else {
|
||||
sampling = { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null };
|
||||
sampling = {
|
||||
type: 'Temperature',
|
||||
temperature: 0.8,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
seed: null,
|
||||
};
|
||||
}
|
||||
save({ ...settings, sampling });
|
||||
};
|
||||
@@ -391,7 +405,13 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<SelectField
|
||||
label="Flash attention"
|
||||
description="Enable flash attention optimization"
|
||||
value={settings.flash_attention === null || settings.flash_attention === undefined ? 'auto' : settings.flash_attention ? 'on' : 'off'}
|
||||
value={
|
||||
settings.flash_attention === null || settings.flash_attention === undefined
|
||||
? 'auto'
|
||||
: settings.flash_attention
|
||||
? 'on'
|
||||
: 'off'
|
||||
}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'on', label: 'On' },
|
||||
|
||||
@@ -38,8 +38,8 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
|
||||
<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 className="font-medium">Add Provider</div>
|
||||
<div className="text-xs text-gray-500 mt-1">From template or manual setup</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -239,6 +239,7 @@ function ProviderCards({
|
||||
supports_streaming: editingProvider.config.supports_streaming ?? true,
|
||||
requires_auth: editingProvider.config.requires_auth ?? true,
|
||||
headers: editingProvider.config.headers ?? undefined,
|
||||
catalog_provider_id: editingProvider.config.catalog_provider_id ?? undefined,
|
||||
};
|
||||
|
||||
const editable = editingProvider ? editingProvider.isEditable : true;
|
||||
@@ -262,7 +263,7 @@ function ProviderCards({
|
||||
isActiveProvider={isActiveProvider}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>{' '}
|
||||
</Dialog>
|
||||
{configuringProvider && (
|
||||
<ProviderConfigurationModal
|
||||
provider={configuringProvider}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '../../../../ui/button';
|
||||
import { Search, ExternalLink, Check } from 'lucide-react';
|
||||
import { Input } from '../../../../ui/input';
|
||||
import { Select } from '../../../../ui/Select';
|
||||
import {
|
||||
getProviderCatalog,
|
||||
getProviderCatalogTemplate,
|
||||
type ProviderCatalogEntry,
|
||||
type ProviderTemplate,
|
||||
} from '../../../../../api';
|
||||
|
||||
interface ProviderCatalogPickerProps {
|
||||
onSelect: (template: ProviderTemplate) => void;
|
||||
onCancel: () => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export default function ProviderCatalogPicker({
|
||||
onSelect,
|
||||
onCancel,
|
||||
embedded,
|
||||
}: ProviderCatalogPickerProps) {
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('openai');
|
||||
const [providers, setProviders] = useState<ProviderCatalogEntry[]>([]);
|
||||
const [filteredProviders, setFilteredProviders] = useState<ProviderCatalogEntry[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: 'openai', label: 'OpenAI Compatible' },
|
||||
{ value: 'anthropic', label: 'Anthropic Compatible' },
|
||||
];
|
||||
|
||||
// Fetch providers when format changes
|
||||
useEffect(() => {
|
||||
fetchProviders(selectedFormat);
|
||||
}, [selectedFormat]);
|
||||
|
||||
// Filter providers based on search query
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === '') {
|
||||
setFilteredProviders(providers);
|
||||
} else {
|
||||
const query = searchQuery.toLowerCase();
|
||||
setFilteredProviders(
|
||||
providers.filter(
|
||||
(p) => p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [searchQuery, providers]);
|
||||
|
||||
const fetchProviders = async (format: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await getProviderCatalog({
|
||||
query: { format },
|
||||
throwOnError: true,
|
||||
});
|
||||
setProviders(data || []);
|
||||
setFilteredProviders(data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSelect = async (providerId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data: template } = await getProviderCatalogTemplate({
|
||||
path: { id: providerId },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (template) {
|
||||
onSelect(template);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-textStandard mb-2">Choose Provider</h3>
|
||||
<p className="text-sm text-textSubtle">
|
||||
Select an API format and provider. We'll auto-fill the configuration for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">API Format</label>
|
||||
<Select
|
||||
options={formatOptions}
|
||||
value={formatOptions.find((opt) => opt.value === selectedFormat)}
|
||||
onChange={(option: unknown) => {
|
||||
const selectedOption = option as { value: string; label: string } | null;
|
||||
if (selectedOption && selectedOption.value) {
|
||||
setSelectedFormat(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-textSubtle w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search providers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading/Error */}
|
||||
{loading && <div className="text-center py-8 text-textSubtle">Loading providers...</div>}
|
||||
{error && <div className="text-center py-8 text-red-500">Error: {error}</div>}
|
||||
|
||||
{/* Provider List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="text-center py-8 text-textSubtle">
|
||||
{searchQuery ? `No providers found for "${searchQuery}"` : 'No providers available'}
|
||||
</div>
|
||||
) : (
|
||||
filteredProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => handleProviderSelect(provider.id)}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-textStandard">{provider.name}</div>
|
||||
{provider.doc_url && (
|
||||
<a
|
||||
href={provider.doc_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-textSubtle hover:text-textStandard transition-colors flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-textSubtle mt-1 break-all">{provider.api_url}</div>
|
||||
<div className="text-xs text-textSubtle mt-2">
|
||||
{provider.model_count} models available
|
||||
{provider.env_var && ` • Requires ${provider.env_var}`}
|
||||
</div>
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!embedded && (
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+412
-226
@@ -3,9 +3,12 @@ import { Input } from '../../../../../ui/input';
|
||||
import { Select } from '../../../../../ui/Select';
|
||||
import { Button } from '../../../../../ui/button';
|
||||
import { SecureStorageNotice } from '../SecureStorageNotice';
|
||||
import { UpdateCustomProviderRequest } from '../../../../../../api';
|
||||
import { Plus, X, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { UpdateCustomProviderRequest, type ProviderTemplate } from '../../../../../../api';
|
||||
import { Plus, X, Trash2, AlertTriangle, ExternalLink, Search, Settings } from 'lucide-react';
|
||||
import { cn } from '../../../../../../utils';
|
||||
import ProviderCatalogPicker from '../ProviderCatalogPicker';
|
||||
|
||||
type Step = 'choice' | 'catalog' | 'form';
|
||||
|
||||
interface CustomProviderFormProps {
|
||||
onSubmit: (data: UpdateCustomProviderRequest) => void;
|
||||
@@ -29,7 +32,7 @@ export default function CustomProviderForm({
|
||||
const [apiUrl, setApiUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [models, setModels] = useState('');
|
||||
const [requiresApiKey, setRequiresApiKey] = useState(false);
|
||||
const [requiresAuth, setRequiresAuth] = useState(false);
|
||||
const [supportsStreaming, setSupportsStreaming] = useState(true);
|
||||
const [headers, setHeaders] = useState<{ key: string; value: string }[]>([]);
|
||||
const [newHeaderKey, setNewHeaderKey] = useState('');
|
||||
@@ -42,6 +45,10 @@ export default function CustomProviderForm({
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
|
||||
// Template + step state
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplate | null>(null);
|
||||
const [step, setStep] = useState<Step>(initialData ? 'form' : 'choice');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const engineMap: Record<string, string> = {
|
||||
@@ -54,7 +61,7 @@ export default function CustomProviderForm({
|
||||
setApiUrl(initialData.api_url);
|
||||
setModels(initialData.models.join(', '));
|
||||
setSupportsStreaming(initialData.supports_streaming ?? true);
|
||||
setRequiresApiKey(initialData.requires_auth ?? true);
|
||||
setRequiresAuth(initialData.requires_auth ?? true);
|
||||
|
||||
if (initialData.headers) {
|
||||
const headerList = Object.entries(initialData.headers).map(([key, value]) => ({
|
||||
@@ -63,11 +70,46 @@ export default function CustomProviderForm({
|
||||
}));
|
||||
setHeaders(headerList);
|
||||
}
|
||||
|
||||
setStep('form');
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleRequiresApiKeyChange = (checked: boolean) => {
|
||||
setRequiresApiKey(checked);
|
||||
const handleTemplateSelect = (template: ProviderTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
|
||||
// Prefill fields from template
|
||||
setDisplayName(template.name);
|
||||
setApiUrl(template.api_url);
|
||||
setSupportsStreaming(template.supports_streaming);
|
||||
setRequiresAuth(true);
|
||||
|
||||
const formatToEngine: Record<string, string> = {
|
||||
openai: 'openai_compatible',
|
||||
anthropic: 'anthropic_compatible',
|
||||
ollama: 'ollama_compatible',
|
||||
};
|
||||
setEngine(formatToEngine[template.format] || 'openai_compatible');
|
||||
|
||||
const templateModels = template.models.filter((m) => !m.deprecated).map((m) => m.id);
|
||||
setModels(templateModels.join(', '));
|
||||
|
||||
setStep('form');
|
||||
};
|
||||
|
||||
const handleClearTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setDisplayName('');
|
||||
setApiUrl('');
|
||||
setModels('');
|
||||
setEngine('openai_compatible');
|
||||
setSupportsStreaming(true);
|
||||
setRequiresAuth(false);
|
||||
setStep('choice');
|
||||
};
|
||||
|
||||
const handleRequiresAuthChange = (checked: boolean) => {
|
||||
setRequiresAuth(checked);
|
||||
if (!checked) {
|
||||
setApiKey('');
|
||||
}
|
||||
@@ -81,28 +123,19 @@ export default function CustomProviderForm({
|
||||
const isDuplicate = headers.some((h) => h.key.trim().toLowerCase() === normalizedNewKey);
|
||||
|
||||
if (keyEmpty || valueEmpty) {
|
||||
setInvalidHeaderFields({
|
||||
key: keyEmpty,
|
||||
value: valueEmpty,
|
||||
});
|
||||
setInvalidHeaderFields({ key: keyEmpty, value: valueEmpty });
|
||||
setHeaderValidationError('Both header name and value must be entered');
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyHasSpaces) {
|
||||
setInvalidHeaderFields({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setInvalidHeaderFields({ key: true, value: false });
|
||||
setHeaderValidationError('Header name cannot contain spaces');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
setInvalidHeaderFields({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setInvalidHeaderFields({ key: true, value: false });
|
||||
setHeaderValidationError('A header with this name already exists');
|
||||
return;
|
||||
}
|
||||
@@ -120,16 +153,12 @@ export default function CustomProviderForm({
|
||||
|
||||
const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
if (field === 'key') {
|
||||
if (value.includes(' ')) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (isDuplicate && normalizedValue !== '') return;
|
||||
const updatedHeaders = [...headers];
|
||||
updatedHeaders[index].key = value;
|
||||
setHeaders(updatedHeaders);
|
||||
@@ -159,7 +188,7 @@ export default function CustomProviderForm({
|
||||
if (!displayName) errors.displayName = 'Display name is required';
|
||||
if (!apiUrl) errors.apiUrl = 'API URL is required';
|
||||
const existingHadAuth = initialData && (initialData.requires_auth ?? true);
|
||||
if (requiresApiKey && !apiKey && !existingHadAuth) errors.apiKey = 'API key is required';
|
||||
if (requiresAuth && !apiKey && !existingHadAuth) errors.apiKey = 'API key is required';
|
||||
if (!models) errors.models = 'At least one model is required';
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
@@ -201,102 +230,228 @@ export default function CustomProviderForm({
|
||||
api_key: apiKey,
|
||||
models: modelList,
|
||||
supports_streaming: supportsStreaming,
|
||||
requires_auth: requiresApiKey,
|
||||
requires_auth: requiresAuth,
|
||||
headers: headersObject,
|
||||
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Aggregate capability badges for template models
|
||||
const templateModelCapabilities = selectedTemplate?.models
|
||||
.filter((m) => !m.deprecated)
|
||||
.reduce(
|
||||
(acc, m) => {
|
||||
if (m.capabilities.tool_call) acc.tool_call = true;
|
||||
if (m.capabilities.reasoning) acc.reasoning = true;
|
||||
if (m.capabilities.attachment) acc.attachment = true;
|
||||
return acc;
|
||||
},
|
||||
{ tool_call: false, reasoning: false, attachment: false }
|
||||
);
|
||||
|
||||
// -- Step: Choice --
|
||||
if (step === 'choice') {
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-textSubtle">Choose how you'd like to set up your provider.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('catalog')}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-textStandard">Start from a provider template</div>
|
||||
<div className="text-sm text-textSubtle mt-0.5">
|
||||
Pick a known provider and we'll auto-fill the configuration
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('form')}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-textSubtle flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-textStandard">Configure manually</div>
|
||||
<div className="text-sm text-textSubtle mt-0.5">
|
||||
Enter all provider details yourself
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Step: Catalog picker --
|
||||
if (step === 'catalog') {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<ProviderCatalogPicker onSelect={handleTemplateSelect} onCancel={onCancel} embedded />
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setStep('choice')}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Step: Form --
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{isEditable && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-select"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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>
|
||||
)}
|
||||
{/* Template info banner */}
|
||||
{selectedTemplate && (
|
||||
<div className="p-3 bg-surfaceHover border border-border rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-textStandard">
|
||||
Using template: {selectedTemplate.name}
|
||||
</div>
|
||||
<div className="text-textSubtle mt-1">{selectedTemplate.api_url}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTemplate.doc_url && (
|
||||
<a
|
||||
href={selectedTemplate.doc_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
Docs <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearTemplate}
|
||||
className="text-textSubtle hover:text-textStandard"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="display-name"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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-text-primary 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"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Back to choice (create without template only) */}
|
||||
{!initialData && !selectedTemplate && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setStep('choice')}>
|
||||
← Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Provider type dropdown */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-select"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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>
|
||||
)}
|
||||
|
||||
{/* Display name */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="display-name"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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>
|
||||
)}
|
||||
|
||||
{/* API URL */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="api-url"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Authentication</label>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
@@ -305,23 +460,28 @@ export default function CustomProviderForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requires-api-key"
|
||||
checked={requiresApiKey}
|
||||
onChange={(e) => handleRequiresApiKeyChange(e.target.checked)}
|
||||
id="requires-auth"
|
||||
checked={requiresAuth}
|
||||
onChange={(e) => handleRequiresAuthChange(e.target.checked)}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<label htmlFor="requires-api-key" className="text-sm text-text-secondary">
|
||||
<label htmlFor="requires-auth" className="text-sm text-text-secondary">
|
||||
This provider requires an API key
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{requiresApiKey && (
|
||||
{requiresAuth && (
|
||||
<div className="mt-3">
|
||||
<label
|
||||
htmlFor="api-key"
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
API Key
|
||||
{selectedTemplate?.env_var && (
|
||||
<span className="text-textSubtle ml-1 font-normal">
|
||||
({selectedTemplate.env_var})
|
||||
</span>
|
||||
)}
|
||||
{!initialData && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
@@ -342,119 +502,145 @@ export default function CustomProviderForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{isEditable && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="available-models"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-streaming"
|
||||
checked={supportsStreaming}
|
||||
onChange={(e) => setSupportsStreaming(e.target.checked)}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<label htmlFor="supports-streaming" className="text-sm text-text-secondary">
|
||||
Provider supports streaming responses
|
||||
</label>
|
||||
</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.
|
||||
<div>
|
||||
<label
|
||||
htmlFor="available-models"
|
||||
className="flex items-center text-sm font-medium text-text-primary 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 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-primary border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
</Button>
|
||||
)}
|
||||
{/* Capability badges when template is active */}
|
||||
{selectedTemplate && templateModelCapabilities && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{templateModelCapabilities.tool_call && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
Tool calling
|
||||
</span>
|
||||
)}
|
||||
{templateModelCapabilities.reasoning && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Reasoning
|
||||
</span>
|
||||
)}
|
||||
{templateModelCapabilities.attachment && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">
|
||||
Attachments
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{headerValidationError && (
|
||||
<div className="mt-2 text-red-500 text-sm">{headerValidationError}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming */}
|
||||
{isEditable && (
|
||||
<div className="flex items-center space-x-2 mb-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-streaming"
|
||||
checked={supportsStreaming}
|
||||
onChange={(e) => setSupportsStreaming(e.target.checked)}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<label htmlFor="supports-streaming" className="text-sm text-text-secondary">
|
||||
Provider supports streaming responses
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom headers */}
|
||||
{isEditable && (
|
||||
<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-primary 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 />
|
||||
|
||||
{showDeleteConfirmation ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ interface CardContainerProps {
|
||||
grayedOut: boolean;
|
||||
testId?: string;
|
||||
borderStyle?: 'solid' | 'dashed';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function GlowingRing() {
|
||||
@@ -35,6 +36,7 @@ export default function CardContainer({
|
||||
grayedOut = false,
|
||||
testId,
|
||||
borderStyle = 'solid',
|
||||
className = '',
|
||||
}: CardContainerProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +61,8 @@ export default function CardContainer({
|
||||
grayedOut
|
||||
? 'border-border-primary'
|
||||
: 'border-border-primary hover:border-border-primary'
|
||||
}`}
|
||||
}
|
||||
${className}`}
|
||||
>
|
||||
{header && (
|
||||
<div style={{ opacity: grayedOut ? '0.5' : '1' }}>
|
||||
|
||||
@@ -70,7 +70,14 @@ export type AnalyticsEvent =
|
||||
| {
|
||||
name: 'onboarding_provider_selected';
|
||||
properties: {
|
||||
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other';
|
||||
method:
|
||||
| 'api_key'
|
||||
| 'openrouter'
|
||||
| 'tetrate'
|
||||
| 'chatgpt_codex'
|
||||
| 'ollama'
|
||||
| 'local'
|
||||
| 'other';
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -80,7 +87,10 @@ export type AnalyticsEvent =
|
||||
| { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } }
|
||||
| {
|
||||
name: 'onboarding_setup_failed';
|
||||
properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local'; error_message?: string };
|
||||
properties: {
|
||||
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local';
|
||||
error_message?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'error_occurred';
|
||||
|
||||
Reference in New Issue
Block a user