feat(acp+): models/providers in desktop on ACP+ (#9987)
This commit is contained in:
@@ -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();
|
||||
|
||||
+34
-28
@@ -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" />
|
||||
|
||||
+48
-33
@@ -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>
|
||||
|
||||
+19
-8
@@ -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();
|
||||
|
||||
+14
-97
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user