Simplified custom model flow with canonical models (#6934)

This commit is contained in:
David Katz
2026-02-23 20:12:10 -05:00
committed by GitHub
parent edede637d7
commit 716085ce09
26 changed files with 73986 additions and 531 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+94
View File
@@ -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;
+16 -5
View File
@@ -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}
@@ -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>
);
}
@@ -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' }}>
+12 -2
View File
@@ -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';