feat(acp+): models/providers in desktop on ACP+ (#9987)

This commit is contained in:
Alex Hancock
2026-06-26 13:04:24 -04:00
committed by GitHub
parent d1d2166190
commit 2fda760641
49 changed files with 2734 additions and 1687 deletions
@@ -2,11 +2,14 @@ import React, { memo, useMemo, useCallback, useState } from 'react';
import { ProviderCard } from './subcomponents/ProviderCard';
import CardContainer from './subcomponents/CardContainer';
import ProviderConfigurationModal from './modal/ProviderConfigurationModal';
import { ProviderDetails, UpdateCustomProviderRequest } from '../../../api';
import type { CustomProviderConfigDto } from '@aaif/goose-sdk';
import {
DeclarativeProviderConfig,
ProviderDetails,
UpdateCustomProviderRequest,
} from '../../../api';
acpCreateCustomProviderFromRequest,
acpGetCustomProvider,
acpDeleteCustomProvider,
acpUpdateCustomProviderFromRequest,
} from '../../../acp/providers';
import { Plus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
@@ -102,7 +105,7 @@ function ProviderCards({
const { getCurrentModelAndProvider } = useModelAndProvider();
const [editingProvider, setEditingProvider] = useState<{
id: string;
config: DeclarativeProviderConfig;
config: CustomProviderConfigDto;
isEditable: boolean;
providerType: string;
} | null>(null);
@@ -120,14 +123,13 @@ function ProviderCards({
const configureProviderViaModal = useCallback(
async (provider: ProviderDetails) => {
if (provider.provider_type === 'Custom') {
const { getCustomProvider } = await import('../../../api');
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
const result = await acpGetCustomProvider(provider.name);
if (result.data) {
if (result) {
setEditingProvider({
id: provider.name,
config: result.data.config,
isEditable: result.data.is_editable,
config: result.provider,
isEditable: result.editable,
providerType: provider.provider_type,
});
@@ -152,12 +154,7 @@ function ProviderCards({
async (data: UpdateCustomProviderRequest) => {
if (!editingProvider) return;
const { updateCustomProvider } = await import('../../../api');
await updateCustomProvider({
path: { id: editingProvider.id },
body: data,
throwOnError: true,
});
await acpUpdateCustomProviderFromRequest(editingProvider.id, data);
const providerId = editingProvider.id;
setShowCustomProviderModal(false);
setEditingProvider(null);
@@ -173,11 +170,7 @@ function ProviderCards({
const handleDeleteCustomProvider = useCallback(async () => {
if (!editingProvider) return;
const { removeCustomProvider } = await import('../../../api');
await removeCustomProvider({
path: { id: editingProvider.id },
throwOnError: true,
});
await acpDeleteCustomProvider(editingProvider.id);
setShowCustomProviderModal(false);
setEditingProvider(null);
setIsActiveProvider(false);
@@ -227,9 +220,8 @@ function ProviderCards({
const handleCreateCustomProvider = useCallback(
async (data: UpdateCustomProviderRequest) => {
const { createCustomProvider } = await import('../../../api');
const result = await createCustomProvider({ body: data, throwOnError: true });
const providerId = result.data?.provider_name;
const result = await acpCreateCustomProviderFromRequest(data);
const providerId = result.provider_name;
setShowCustomProviderModal(false);
if (refreshProviders) {
await refreshProviders();
@@ -266,15 +258,15 @@ function ProviderCards({
const initialData = editingProvider && {
engine: editingProvider.config.engine,
display_name: editingProvider.config.display_name,
api_url: editingProvider.config.base_url,
base_path: editingProvider.config.base_path ?? undefined,
display_name: editingProvider.config.displayName,
api_url: editingProvider.config.apiUrl,
base_path: editingProvider.config.basePath ?? undefined,
api_key: '',
models: editingProvider.config.models.map((m) => m.name),
supports_streaming: editingProvider.config.supports_streaming ?? true,
requires_auth: editingProvider.config.requires_auth ?? true,
models: editingProvider.config.models ?? [],
supports_streaming: editingProvider.config.supportsStreaming ?? true,
requires_auth: editingProvider.config.requiresAuth ?? true,
headers: editingProvider.config.headers ?? undefined,
catalog_provider_id: editingProvider.config.catalog_provider_id ?? undefined,
catalog_provider_id: editingProvider.config.catalogProviderId ?? undefined,
};
const editable = editingProvider ? editingProvider.isEditable : true;
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { ScrollArea } from '../../ui/scroll-area';
import BackButton from '../../ui/BackButton';
import ProviderGrid from './ProviderGrid';
import { useConfig } from '../../ConfigContext';
import { acpListProviderDetails } from '../../../acp/providers';
import { ProviderDetails } from '../../../api';
import { createNavigationHandler } from '../../../utils/navigationUtils';
import { defineMessages, useIntl } from '../../../i18n';
@@ -40,7 +40,6 @@ export default function ProviderSettings({
onProviderLaunched,
}: ProviderSettingsProps) {
const intl = useIntl();
const { getProviders } = useConfig();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [providers, setProviders] = useState<ProviderDetails[]>([]);
@@ -52,8 +51,7 @@ export default function ProviderSettings({
const loadProviders = useCallback(async () => {
setLoading(true);
try {
// Only force refresh when explicitly requested, not on initial load
const result = await getProviders(!initialLoadDone.current);
const result = await acpListProviderDetails();
if (result) {
setProviders(result);
initialLoadDone.current = true;
@@ -63,7 +61,7 @@ export default function ProviderSettings({
} finally {
setLoading(false);
}
}, [getProviders]);
}, []);
// Load providers only once when component mounts
useEffect(() => {
@@ -74,10 +72,10 @@ export default function ProviderSettings({
// This function will be passed to ProviderGrid for manual refreshes after config changes
const refreshProviders = useCallback(async () => {
if (initialLoadDone.current) {
const result = await getProviders(true);
const result = await acpListProviderDetails();
if (result) setProviders(result);
}
}, [getProviders]);
}, []);
return (
<div className="h-screen w-full flex flex-col bg-background-primary text-text-primary">
@@ -14,15 +14,15 @@ import ProviderSetupActions from './subcomponents/ProviderSetupActions';
import ProviderLogo from './subcomponents/ProviderLogo';
import { SecureStorageNotice } from './subcomponents/SecureStorageNotice';
import { providerConfigSubmitHandler } from './subcomponents/handlers/DefaultSubmitHandler';
import { useConfig } from '../../../ConfigContext';
import {
acpAuthenticateProvider,
acpDeleteCustomProvider,
acpDeleteProviderConfig,
acpSaveProviderConfig,
} from '../../../../acp/providers';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AlertTriangle, LogIn } from 'lucide-react';
import {
ProviderDetails,
removeCustomProvider,
configureProviderOauth,
cleanupProviderCache,
} from '../../../../api';
import { ProviderDetails } from '../../../../api';
import { Button } from '../../../../components/ui/button';
import { errorMessage } from '../../../../utils/conversionUtils';
import { defineMessages, useIntl } from '../../../../i18n';
@@ -165,7 +165,6 @@ export default function ProviderConfigurationModal({
}: ProviderConfigurationModalProps) {
const intl = useIntl();
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const { upsert, remove } = useConfig();
const { getCurrentModelAndProvider } = useModelAndProvider();
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
@@ -211,25 +210,20 @@ export default function ProviderConfigurationModal({
setError(null);
try {
if (hasConfig) {
const fields: { key: string; value: string }[] = [];
for (const key of configKeys) {
const entry = configValues[key.name];
const value =
entry?.value ?? (typeof entry?.serverValue === 'string' ? entry.serverValue : null);
if (value) {
await upsert(key.name, value, key.secret);
fields.push({ key: key.name, value });
}
}
if (fields.length > 0) {
await acpSaveProviderConfig(provider.name, fields);
}
}
const oauthResult = await configureProviderOauth({
path: { name: provider.name },
});
if (oauthResult.error) {
const err = oauthResult.error as Record<string, unknown>;
const errDetail = typeof oauthResult.error === 'string'
? oauthResult.error
: (err?.message as string) ?? (err?.detail as string) ?? JSON.stringify(oauthResult.error);
throw new Error(errDetail);
}
await acpAuthenticateProvider(provider.name);
if (onConfigured) {
onConfigured(provider);
} else {
@@ -280,7 +274,7 @@ export default function ProviderConfigurationModal({
);
try {
await providerConfigSubmitHandler(upsert, provider, toSubmit);
await providerConfigSubmitHandler(provider, toSubmit);
if (onConfigured) {
onConfigured(provider);
} else {
@@ -316,30 +310,14 @@ export default function ProviderConfigurationModal({
return;
}
// Clean up provider-specific cache files (e.g., OAuth tokens) before removing config
try {
await cleanupProviderCache({ path: { name: provider.name } });
} catch {
// Cleanup is best-effort — proceed with deletion even if it fails
}
const isCustomProvider = provider.provider_type === 'Custom';
if (isCustomProvider) {
await removeCustomProvider({
path: { id: provider.name },
});
await acpDeleteCustomProvider(provider.name);
} else {
const params = provider.metadata.config_keys;
for (const param of params) {
await remove(param.name, param.secret);
}
const hasOAuthKey = params.some((key) => key.oauth_flow);
if (hasOAuthKey) {
const configuredMarker = `${provider.name}_configured`;
await remove(configuredMarker, false);
}
// Deletes all config/secret fields and cleans up provider-specific cache
// (e.g. OAuth tokens) server-side.
await acpDeleteProviderConfig(provider.name);
}
onClose();
@@ -3,12 +3,11 @@ import { Button } from '../../../../ui/button';
import { Search, ExternalLink, Check } from 'lucide-react';
import { Input } from '../../../../ui/input';
import { Select } from '../../../../ui/Select';
import type { ProviderTemplateCatalogEntryDto, ProviderTemplateDto } from '@aaif/goose-sdk';
import {
getProviderCatalog,
getProviderCatalogTemplate,
type ProviderCatalogEntry,
type ProviderTemplate,
} from '../../../../../api';
acpGetProviderTemplate,
acpListProviderCatalogEntries,
} from '../../../../../acp/providers';
import { defineMessages, useIntl } from '../../../../../i18n';
const i18n = defineMessages({
@@ -67,7 +66,7 @@ const i18n = defineMessages({
});
interface ProviderCatalogPickerProps {
onSelect: (template: ProviderTemplate) => void;
onSelect: (template: ProviderTemplateDto) => void;
onCancel: () => void;
embedded?: boolean;
}
@@ -79,8 +78,8 @@ export default function ProviderCatalogPicker({
}: ProviderCatalogPickerProps) {
const intl = useIntl();
const [selectedFormat, setSelectedFormat] = useState<string>('openai');
const [providers, setProviders] = useState<ProviderCatalogEntry[]>([]);
const [filteredProviders, setFilteredProviders] = useState<ProviderCatalogEntry[]>([]);
const [providers, setProviders] = useState<ProviderTemplateCatalogEntryDto[]>([]);
const [filteredProviders, setFilteredProviders] = useState<ProviderTemplateCatalogEntryDto[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -103,7 +102,7 @@ export default function ProviderCatalogPicker({
const query = searchQuery.toLowerCase();
setFilteredProviders(
providers.filter(
(p) => p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query)
(p) => p.name.toLowerCase().includes(query) || p.providerId.toLowerCase().includes(query)
)
);
}
@@ -113,10 +112,7 @@ export default function ProviderCatalogPicker({
setLoading(true);
setError(null);
try {
const { data } = await getProviderCatalog({
query: { format },
throwOnError: true,
});
const data = await acpListProviderCatalogEntries(format);
setProviders(data || []);
setFilteredProviders(data || []);
} catch (err) {
@@ -130,10 +126,7 @@ export default function ProviderCatalogPicker({
setLoading(true);
setError(null);
try {
const { data: template } = await getProviderCatalogTemplate({
path: { id: providerId },
throwOnError: true,
});
const template = await acpGetProviderTemplate(providerId);
if (template) {
onSelect(template);
}
@@ -148,7 +141,9 @@ export default function ProviderCatalogPicker({
<div className="space-y-4">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-textStandard mb-2">{intl.formatMessage(i18n.chooseProvider)}</h3>
<h3 className="text-lg font-semibold text-textStandard mb-2">
{intl.formatMessage(i18n.chooseProvider)}
</h3>
<p className="text-sm text-textSubtle">
{intl.formatMessage(i18n.selectFormatDescription)}
</p>
@@ -156,7 +151,9 @@ export default function ProviderCatalogPicker({
{/* Format Selection */}
<div>
<label className="text-sm font-medium text-textStandard mb-2 block">{intl.formatMessage(i18n.apiFormat)}</label>
<label className="text-sm font-medium text-textStandard mb-2 block">
{intl.formatMessage(i18n.apiFormat)}
</label>
<Select
options={formatOptions}
value={formatOptions.find((opt) => opt.value === selectedFormat)}
@@ -183,8 +180,16 @@ export default function ProviderCatalogPicker({
</div>
{/* Loading/Error */}
{loading && <div className="text-center py-8 text-textSubtle">{intl.formatMessage(i18n.loadingProviders)}</div>}
{error && <div className="text-center py-8 text-red-500">{intl.formatMessage(i18n.errorPrefix, { error })}</div>}
{loading && (
<div className="text-center py-8 text-textSubtle">
{intl.formatMessage(i18n.loadingProviders)}
</div>
)}
{error && (
<div className="text-center py-8 text-red-500">
{intl.formatMessage(i18n.errorPrefix, { error })}
</div>
)}
{/* Provider List */}
{!loading && !error && (
@@ -198,17 +203,17 @@ export default function ProviderCatalogPicker({
) : (
filteredProviders.map((provider) => (
<button
key={provider.id}
onClick={() => handleProviderSelect(provider.id)}
key={provider.providerId}
onClick={() => handleProviderSelect(provider.providerId)}
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 && (
{provider.docUrl && (
<a
href={provider.doc_url}
href={provider.docUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@@ -218,10 +223,11 @@ export default function ProviderCatalogPicker({
</a>
)}
</div>
<div className="text-sm text-textSubtle mt-1 break-all">{provider.api_url}</div>
<div className="text-sm text-textSubtle mt-1 break-all">{provider.apiUrl}</div>
<div className="text-xs text-textSubtle mt-2">
{intl.formatMessage(i18n.modelsAvailable, { count: provider.model_count })}
{provider.env_var && intl.formatMessage(i18n.requiresEnvVar, { envVar: provider.env_var })}
{intl.formatMessage(i18n.modelsAvailable, { count: provider.modelCount })}
{provider.envVar &&
intl.formatMessage(i18n.requiresEnvVar, { envVar: provider.envVar })}
</div>
</div>
<Check className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
@@ -3,7 +3,8 @@ import { Input } from '../../../../../ui/input';
import { Select } from '../../../../../ui/Select';
import { Button } from '../../../../../ui/button';
import { SecureStorageNotice } from '../SecureStorageNotice';
import { UpdateCustomProviderRequest, type ProviderTemplate } from '../../../../../../api';
import { UpdateCustomProviderRequest } from '../../../../../../api';
import type { ProviderTemplateDto } from '@aaif/goose-sdk';
import { Plus, X, Trash2, AlertTriangle, ExternalLink, Search, Settings } from 'lucide-react';
import { cn } from '../../../../../../utils';
import ProviderCatalogPicker from '../ProviderCatalogPicker';
@@ -92,7 +93,8 @@ const i18n = defineMessages({
},
apiBasePathHint: {
id: 'customProviderForm.apiBasePathHint',
defaultMessage: "Override the default API path. Leave blank to use the provider's default path.",
defaultMessage:
"Override the default API path. Leave blank to use the provider's default path.",
},
authentication: {
id: 'customProviderForm.authentication',
@@ -264,7 +266,7 @@ export default function CustomProviderForm({
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
// Template + step state
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplateDto | null>(null);
const [step, setStep] = useState<Step>(initialData ? 'form' : 'choice');
useEffect(() => {
@@ -294,14 +296,14 @@ export default function CustomProviderForm({
}
}, [initialData]);
const handleTemplateSelect = (template: ProviderTemplate) => {
const handleTemplateSelect = (template: ProviderTemplateDto) => {
setSelectedTemplate(template);
// Prefill fields from template
setDisplayName(template.name);
setApiUrl(template.api_url);
setApiUrl(template.apiUrl);
setBasePath('');
setSupportsStreaming(template.supports_streaming);
setSupportsStreaming(template.supportsStreaming);
setRequiresAuth(true);
const formatToEngine: Record<string, string> = {
@@ -411,7 +413,8 @@ export default function CustomProviderForm({
if (!displayName) errors.displayName = intl.formatMessage(i18n.displayNameRequired);
if (!apiUrl) errors.apiUrl = intl.formatMessage(i18n.apiUrlRequired);
const existingHadAuth = initialData && (initialData.requires_auth ?? true);
if (requiresAuth && !apiKey && !existingHadAuth) errors.apiKey = intl.formatMessage(i18n.apiKeyRequired);
if (requiresAuth && !apiKey && !existingHadAuth)
errors.apiKey = intl.formatMessage(i18n.apiKeyRequired);
if (!models) errors.models = intl.formatMessage(i18n.modelsRequired);
if (Object.keys(errors).length > 0) {
@@ -456,7 +459,8 @@ export default function CustomProviderForm({
supports_streaming: supportsStreaming,
requires_auth: requiresAuth,
headers: headersObject,
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
catalog_provider_id:
selectedTemplate?.providerId ?? initialData?.catalog_provider_id ?? undefined,
base_path: basePath || undefined,
});
} catch (error) {
@@ -470,7 +474,7 @@ export default function CustomProviderForm({
.filter((m) => !m.deprecated)
.reduce(
(acc, m) => {
if (m.capabilities.tool_call) acc.tool_call = true;
if (m.capabilities.toolCall) acc.tool_call = true;
if (m.capabilities.reasoning) acc.reasoning = true;
if (m.capabilities.attachment) acc.attachment = true;
return acc;
@@ -491,7 +495,9 @@ export default function CustomProviderForm({
<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">{intl.formatMessage(i18n.startFromTemplate)}</div>
<div className="font-medium text-textStandard">
{intl.formatMessage(i18n.startFromTemplate)}
</div>
<div className="text-sm text-textSubtle mt-0.5">
{intl.formatMessage(i18n.startFromTemplateDesc)}
</div>
@@ -506,7 +512,9 @@ export default function CustomProviderForm({
<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">{intl.formatMessage(i18n.configureManually)}</div>
<div className="font-medium text-textStandard">
{intl.formatMessage(i18n.configureManually)}
</div>
<div className="text-sm text-textSubtle mt-0.5">
{intl.formatMessage(i18n.configureManuallyDesc)}
</div>
@@ -550,12 +558,12 @@ export default function CustomProviderForm({
<div className="font-medium text-textStandard">
{intl.formatMessage(i18n.usingTemplate, { name: selectedTemplate.name })}
</div>
<div className="text-textSubtle mt-1">{selectedTemplate.api_url}</div>
<div className="text-textSubtle mt-1">{selectedTemplate.apiUrl}</div>
</div>
<div className="flex items-center gap-2">
{selectedTemplate.doc_url && (
{selectedTemplate.docUrl && (
<a
href={selectedTemplate.doc_url}
href={selectedTemplate.docUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm flex items-center gap-1"
@@ -600,7 +608,10 @@ export default function CustomProviderForm({
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: intl.formatMessage(i18n.openaiCompatible) },
{ value: 'anthropic_compatible', label: intl.formatMessage(i18n.anthropicCompatible) },
{
value: 'anthropic_compatible',
label: intl.formatMessage(i18n.anthropicCompatible),
},
{ value: 'ollama_compatible', label: intl.formatMessage(i18n.ollamaCompatible) },
]}
value={{
@@ -695,18 +706,16 @@ export default function CustomProviderForm({
onChange={(e) => setBasePath(e.target.value)}
placeholder={intl.formatMessage(i18n.apiBasePathPlaceholder)}
/>
<p className="text-xs text-textSubtle mt-1">
{intl.formatMessage(i18n.apiBasePathHint)}
</p>
<p className="text-xs text-textSubtle mt-1">{intl.formatMessage(i18n.apiBasePathHint)}</p>
</div>
)}
{/* Authentication */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">{intl.formatMessage(i18n.authentication)}</label>
<p className="text-sm text-text-secondary mb-3">
{intl.formatMessage(i18n.authHint)}
</p>
<label className="block text-sm font-medium text-text-primary mb-2">
{intl.formatMessage(i18n.authentication)}
</label>
<p className="text-sm text-text-secondary mb-3">{intl.formatMessage(i18n.authHint)}</p>
<div className="flex items-center space-x-2">
<input
type="checkbox"
@@ -727,9 +736,9 @@ export default function CustomProviderForm({
className="flex items-center text-sm font-medium text-text-primary mb-2"
>
{intl.formatMessage(i18n.apiKey)}
{selectedTemplate?.env_var && (
{selectedTemplate?.envVar && (
<span className="text-textSubtle ml-1 font-normal">
({selectedTemplate.env_var})
({selectedTemplate.envVar})
</span>
)}
{!initialData && <span className="text-red-500 ml-1">*</span>}
@@ -739,7 +748,11 @@ export default function CustomProviderForm({
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={initialData ? intl.formatMessage(i18n.apiKeyPlaceholderExisting) : intl.formatMessage(i18n.apiKeyPlaceholderNew)}
placeholder={
initialData
? intl.formatMessage(i18n.apiKeyPlaceholderExisting)
: intl.formatMessage(i18n.apiKeyPlaceholderNew)
}
aria-invalid={!!validationErrors.apiKey}
aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined}
className={validationErrors.apiKey ? 'border-red-500' : ''}
@@ -819,7 +832,9 @@ export default function CustomProviderForm({
{/* Custom headers */}
{isEditable && (
<div>
<label className="text-sm font-medium text-textStandard mb-2 block">{intl.formatMessage(i18n.customHeaders)}</label>
<label className="text-sm font-medium text-textStandard mb-2 block">
{intl.formatMessage(i18n.customHeaders)}
</label>
<p className="text-xs text-textSubtle mb-4">
{intl.formatMessage(i18n.customHeadersHint)}
</p>
@@ -900,16 +915,12 @@ export default function CustomProviderForm({
<div className="px-4 py-3 bg-yellow-600/20 border border-yellow-500/30 rounded">
<p className="text-yellow-500 text-sm flex items-start">
<AlertTriangle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>
{intl.formatMessage(i18n.cannotDeleteActive)}
</span>
<span>{intl.formatMessage(i18n.cannotDeleteActive)}</span>
</p>
</div>
) : (
<div className="px-4 py-3 bg-red-900/20 border border-red-500/30 rounded">
<p className="text-red-400 text-sm">
{intl.formatMessage(i18n.deleteConfirmation)}
</p>
<p className="text-red-400 text-sm">{intl.formatMessage(i18n.deleteConfirmation)}</p>
</div>
)}
<div className="flex justify-end space-x-2">
@@ -944,7 +955,11 @@ export default function CustomProviderForm({
<Button type="button" variant="outline" onClick={onCancel}>
{intl.formatMessage(i18n.cancel)}
</Button>
<Button type="submit">{initialData ? intl.formatMessage(i18n.updateProvider) : intl.formatMessage(i18n.createProvider)}</Button>
<Button type="submit">
{initialData
? intl.formatMessage(i18n.updateProvider)
: intl.formatMessage(i18n.createProvider)}
</Button>
</div>
)}
</form>
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Input } from '../../../../../ui/input';
import { useConfig } from '../../../../../ConfigContext';
import { acpReadProviderConfig } from '../../../../../../acp/providers';
import { ProviderDetails, ConfigKey } from '../../../../../../api';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../../../ui/collapsible';
import { configLabels, configPlaceholders } from '../../../../../../utils/configUtils';
@@ -95,19 +95,30 @@ export default function DefaultProviderSetupForm({
const intl = useIntl();
const [isLoading, setIsLoading] = useState(true);
const [optionalExpanded, setOptionalExpanded] = useState(false);
const { read } = useConfig();
const loadConfigValues = useCallback(async () => {
setIsLoading(true);
try {
const values: { [k: string]: ConfigInput } = {};
for (const parameter of parameters) {
const configKey = `${parameter.name}`;
const configValue = (await read(configKey, parameter.secret || false)) as ConfigValue;
let fields: Awaited<ReturnType<typeof acpReadProviderConfig>> = [];
try {
fields = await acpReadProviderConfig(provider.name);
} catch {
// Provider may not be in the registry yet; fall back to defaults below.
}
const fieldByKey = new Map(fields.map((field) => [field.key, field]));
if (configValue !== undefined && configValue !== null) {
values[parameter.name] = { serverValue: configValue };
for (const parameter of parameters) {
const field = fieldByKey.get(parameter.name);
if (field?.isSet && field.value != null) {
// Secrets come back masked from the server; preserve the masked shape
// so the form renders a placeholder rather than the raw value.
const serverValue: ConfigValue = parameter.secret
? { maskedValue: field.value }
: field.value;
values[parameter.name] = { serverValue };
} else if (parameter.default !== undefined && parameter.default !== null) {
values[parameter.name] = { value: parameter.default };
}
@@ -120,7 +131,7 @@ export default function DefaultProviderSetupForm({
} finally {
setIsLoading(false);
}
}, [parameters, read, setConfigValues]);
}, [parameters, provider.name, setConfigValues]);
useEffect(() => {
loadConfigValues();
@@ -1,112 +1,29 @@
import { getProviderModels, readConfig } from '../../../../../../api';
import { acpSaveProviderConfig } from '../../../../../../acp/providers';
/**
* Standalone function to submit provider configuration
* Useful for components that don't want to use the hook
* Submit provider configuration through ACP.
*
* The ACP server validates the supplied fields, persists config/secret values,
* and triggers an inventory refresh in a single call, so no client-side
* rollback is required.
*/
export const providerConfigSubmitHandler = async (
upsertFn: (key: string, value: unknown, isSecret: boolean) => Promise<void>,
provider: {
name: string;
metadata: {
config_keys?: Array<{
name: string;
required?: boolean;
default?: unknown;
secret?: boolean;
}>;
config_keys?: Array<{ name: string; default?: unknown }>;
};
},
configValues: Record<string, string>
) => {
const parameters = provider.metadata.config_keys || [];
// Save current NON-SECRET config values for rollback on failure
// We skip secrets because readConfig returns masked values for secrets,
// and upserting those masked values would corrupt the actual secret
const previousConfigValues: Record<string, { value: unknown; isSecret: boolean }> = {};
const nonSecretParams = parameters.filter((param) => !param.secret);
await Promise.all(
nonSecretParams.map(async (param) => {
try {
const currentValue = await readConfig({
body: { key: param.name, is_secret: false },
});
if (currentValue.data) {
previousConfigValues[param.name] = {
value: currentValue.data,
isSecret: false,
};
}
} catch {
// No previous value exists, that's fine
}
})
);
const requiredParams = parameters.filter((param) => param.required);
if (requiredParams.length === 0 && parameters.length > 0) {
const allOptionalWithDefaults = parameters.every(
(param) => !param.required && param.default !== undefined
);
if (allOptionalWithDefaults) {
const promises: Promise<void>[] = [];
for (const param of parameters) {
if (param.default !== undefined) {
const value =
configValues[param.name] !== undefined ? configValues[param.name] : param.default;
promises.push(upsertFn(param.name, value, param.secret === true));
}
}
await Promise.all(promises);
return;
const fields: { key: string; value: string }[] = [];
for (const { name, default: defaultValue } of provider.metadata.config_keys ?? []) {
const value = configValues[name] ?? defaultValue;
if (value === undefined || value === null || value === '') {
continue;
}
fields.push({ key: name, value: String(value) });
}
const upsertPromises = parameters.map(
async (parameter: {
name: string;
required?: boolean;
default?: unknown;
secret?: boolean;
}) => {
if (!configValues[parameter.name] && !parameter.required) {
return;
}
const value =
configValues[parameter.name] !== undefined
? configValues[parameter.name]
: parameter.default;
if (value === undefined || value === null) {
return;
}
const configKey = `${parameter.name}`;
const isSecret = parameter.secret === true;
await upsertFn(configKey, value, isSecret);
}
);
await Promise.all(upsertPromises);
try {
await getProviderModels({
path: { name: provider.name },
throwOnError: true,
});
} catch (error) {
const rollbackPromises: Promise<void>[] = [];
for (const [key, { value, isSecret }] of Object.entries(previousConfigValues)) {
rollbackPromises.push(upsertFn(key, value, isSecret));
}
await Promise.all(rollbackPromises);
throw error;
}
await acpSaveProviderConfig(provider.name, fields);
};