fix custom provider credentials, edit path, and failure UX
Require an API key or {env:VAR} on client and server, add edit/prefill for
existing custom providers, save auth before config, and surface incomplete
auth plus disconnect after partial failures. Add VS Code parity tests and
drop the unused allProvidersConnected locale key.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
d84e4e0312
commit
d40bb9e5a0
@@ -29,28 +29,48 @@ type CustomProviderFormProps = {
|
||||
existingProviderIDs: ReadonlySet<string>;
|
||||
disabledProviders?: readonly string[];
|
||||
busy?: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
initialValues?: CustomProviderFormState;
|
||||
allowExistingAuth?: boolean;
|
||||
authFailureHint?: string | null;
|
||||
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onDisconnect?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
existingProviderIDs,
|
||||
disabledProviders = [],
|
||||
busy = false,
|
||||
mode = 'create',
|
||||
initialValues,
|
||||
allowExistingAuth = false,
|
||||
authFailureHint = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = React.useState<CustomProviderFormState>(() => createEmptyCustomProviderForm());
|
||||
const isEdit = mode === 'edit';
|
||||
const [form, setForm] = React.useState<CustomProviderFormState>(
|
||||
() => initialValues ?? createEmptyCustomProviderForm(),
|
||||
);
|
||||
const [err, setErr] = React.useState<FieldErrors>({});
|
||||
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
|
||||
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialValues) {
|
||||
setForm(initialValues);
|
||||
setErr({});
|
||||
setModelErrors([]);
|
||||
setHeaderErrors([]);
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
if (key !== 'apiKey') {
|
||||
setErr((prev) => ({ ...prev, [key]: undefined }));
|
||||
}
|
||||
setErr((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const setModel = (index: number, key: 'id' | 'name', value: string) => {
|
||||
@@ -88,6 +108,8 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator,
|
||||
existingProviderIDs,
|
||||
disabledProviders,
|
||||
editingProviderID: isEdit ? form.providerID : undefined,
|
||||
allowExistingAuth: isEdit && allowExistingAuth,
|
||||
});
|
||||
setErr(output.err);
|
||||
setModelErrors(output.models);
|
||||
@@ -101,13 +123,19 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-0">
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.custom.title')}
|
||||
title={isEdit ? t('settings.providers.page.custom.editTitle') : t('settings.providers.page.custom.title')}
|
||||
divider={false}
|
||||
settingsItem="providers.custom"
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.description')}</p>
|
||||
|
||||
{authFailureHint ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]" role="status">
|
||||
{authFailureHint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.providerID.label')}
|
||||
info={t('settings.providers.page.custom.field.providerID.info')}
|
||||
@@ -117,7 +145,8 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
onChange={(event) => setField('providerID', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.providerID.placeholder')}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
autoFocus
|
||||
autoFocus={!isEdit}
|
||||
disabled={isEdit || busy}
|
||||
aria-invalid={Boolean(err.providerID)}
|
||||
aria-label={t('settings.providers.page.custom.field.providerID.label')}
|
||||
/>
|
||||
@@ -156,16 +185,26 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
info={t('settings.providers.page.custom.field.apiKey.info')}
|
||||
info={
|
||||
isEdit && allowExistingAuth
|
||||
? t('settings.providers.page.custom.field.apiKey.editInfo')
|
||||
: t('settings.providers.page.custom.field.apiKey.info')
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(event) => setField('apiKey', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.apiKey.placeholder')}
|
||||
placeholder={
|
||||
isEdit && allowExistingAuth
|
||||
? t('settings.providers.page.custom.field.apiKey.editPlaceholder')
|
||||
: t('settings.providers.page.custom.field.apiKey.placeholder')
|
||||
}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-invalid={Boolean(err.apiKey)}
|
||||
aria-label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
/>
|
||||
{err.apiKey ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.apiKey}</p> : null}
|
||||
</SettingsStackedField>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -324,10 +363,24 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
{t('settings.providers.page.custom.actions.back')}
|
||||
</Button>
|
||||
) : null}
|
||||
{onDisconnect ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void onDisconnect()}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.providers.page.actions.disconnect')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={busy}>
|
||||
{busy
|
||||
? t('settings.providers.page.actions.saving')
|
||||
: t('settings.providers.page.custom.actions.save')}
|
||||
: isEdit
|
||||
? t('settings.providers.page.custom.actions.update')
|
||||
: t('settings.providers.page.custom.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
CUSTOM_PROVIDER_ID,
|
||||
isCustomOpenAICompatibleProvider,
|
||||
providerToCustomFormState,
|
||||
type CustomProviderPersistPlan,
|
||||
} from './custom-provider-form';
|
||||
|
||||
@@ -179,8 +181,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
|
||||
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
|
||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
const isCustomMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
|
||||
const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
|
||||
const isCustomEditMode = Boolean(
|
||||
editingCustomProviderId
|
||||
&& selectedProviderId
|
||||
&& editingCustomProviderId === selectedProviderId
|
||||
&& !isAddMode,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId && providers.length > 0) {
|
||||
@@ -291,11 +302,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
if (selectedProviderId === ADD_PROVIDER_ID) {
|
||||
setShowAuthPanel(true);
|
||||
setEditingCustomProviderId(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAuthPanel(false);
|
||||
}, [selectedProviderId, t]);
|
||||
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
|
||||
setEditingCustomProviderId(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
}
|
||||
}, [selectedProviderId, editingCustomProviderId, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||
@@ -376,8 +393,20 @@ export const ProvidersPage: React.FC = () => {
|
||||
const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => {
|
||||
const busyKey = `custom:${plan.providerID}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
setLastCustomPersistId(plan.providerID);
|
||||
setCustomAuthFailureHint(null);
|
||||
|
||||
try {
|
||||
// Auth first so a failed key write cannot leave an orphan config that
|
||||
// blocks create validation, and so PUT can pass hasStoredAuth for literal keys.
|
||||
const authRequest = buildAuthSetRequest(plan);
|
||||
if (authRequest) {
|
||||
const authResult = await opencodeClient.getSdkClient().auth.set(authRequest);
|
||||
if (authResult.error) {
|
||||
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
const upsertBody = buildProviderUpsertRequest(plan);
|
||||
const response = await runtimeFetch('/api/provider', {
|
||||
method: 'PUT',
|
||||
@@ -389,19 +418,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
|
||||
}
|
||||
|
||||
const authRequest = buildAuthSetRequest(plan);
|
||||
if (authRequest) {
|
||||
const authResult = await opencodeClient.getSdkClient().auth.set(authRequest);
|
||||
if (authResult.error) {
|
||||
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
if (authRequest) {
|
||||
setCustomAuthFailureHint(t('settings.providers.page.custom.authFailure.configAfterAuth'));
|
||||
}
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
|
||||
setCandidateProviderId('');
|
||||
setEditingCustomProviderId(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
|
||||
setSelectedProvider(plan.providerID);
|
||||
} catch (error) {
|
||||
@@ -554,6 +581,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectCustomProvider = async (providerId: string) => {
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
await handleDisconnectProvider(providerId);
|
||||
setEditingCustomProviderId(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
setCandidateProviderId('');
|
||||
};
|
||||
|
||||
if (!isAddMode && providers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -692,11 +730,22 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isCustomMode ? (
|
||||
{isCustomCreateMode ? (
|
||||
<CustomProviderForm
|
||||
mode="create"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
onCancel={() => setCandidateProviderId('')}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setCandidateProviderId('');
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
onDisconnect={
|
||||
customAuthFailureHint && lastCustomPersistId
|
||||
? () => void handleDisconnectCustomProvider(lastCustomPersistId)
|
||||
: undefined
|
||||
}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
) : candidateProviderId ? (
|
||||
@@ -853,6 +902,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
|
||||
const isCustomProvider = isCustomOpenAICompatibleProvider(selectedProvider);
|
||||
const providerEnv = Array.isArray(selectedProvider.env)
|
||||
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const sourcesLoaded = Boolean(selectedSources);
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = isCustomProvider && sourcesLoaded && !hasCredentials;
|
||||
|
||||
const filteredModels = providerModels.filter((model) => {
|
||||
const name = typeof model?.name === 'string' ? model.name : '';
|
||||
@@ -862,6 +920,34 @@ export const ProvidersPage: React.FC = () => {
|
||||
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
if (isCustomEditMode && isCustomProvider) {
|
||||
const initialValues = providerToCustomFormState(selectedProvider);
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
|
||||
description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<CustomProviderForm
|
||||
mode="edit"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
initialValues={initialValues}
|
||||
allowExistingAuth={hasCredentials}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setEditingCustomProviderId(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
@@ -873,23 +959,46 @@ export const ProvidersPage: React.FC = () => {
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
divider={false}
|
||||
headerAction={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{isCustomProvider ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setCustomAuthFailureHint(null);
|
||||
setEditingCustomProviderId(selectedProvider.id);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.actions.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
settingsItem="providers.auth"
|
||||
>
|
||||
{!showAuthPanel ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
authStatusIncomplete ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
)
|
||||
) : authLoading ? (
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
) : (
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
mergeProviderConfig,
|
||||
isCustomOpenAICompatibleProvider,
|
||||
providerToCustomFormState,
|
||||
validateCustomProvider,
|
||||
type CustomProviderConfig,
|
||||
type CustomProviderFormState,
|
||||
} from './custom-provider-form';
|
||||
|
||||
@@ -19,6 +21,28 @@ const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProvi
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Mirrors server upsert semantics for request-construction tests. */
|
||||
function mergeProviderConfig(
|
||||
existing: Record<string, unknown>,
|
||||
providerID: string,
|
||||
config: CustomProviderConfig,
|
||||
): Record<string, unknown> {
|
||||
const providerSection = (
|
||||
typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider)
|
||||
? { ...(existing.provider as Record<string, unknown>) }
|
||||
: {}
|
||||
);
|
||||
providerSection[providerID] = config;
|
||||
const next: Record<string, unknown> = {
|
||||
...existing,
|
||||
provider: providerSection,
|
||||
};
|
||||
if (Array.isArray(existing.disabled_providers)) {
|
||||
next.disabled_providers = existing.disabled_providers.filter((entry) => entry !== providerID);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
describe('validateCustomProvider', () => {
|
||||
test('builds trimmed config and auth payloads', () => {
|
||||
const result = validateCustomProvider({
|
||||
@@ -70,6 +94,31 @@ describe('validateCustomProvider', () => {
|
||||
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
|
||||
});
|
||||
|
||||
test('rejects missing credentials', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: ' ' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.apiKey).toBe('settings.providers.page.custom.error.apiKey.required');
|
||||
});
|
||||
|
||||
test('allows empty api key when editing with existing auth', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: '' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
editingProviderID: 'custom-provider',
|
||||
allowExistingAuth: true,
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.apiKey).toEqual(undefined);
|
||||
expect(result.result?.apiKey).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rejects invalid provider id, base URL, and duplicate rows', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
@@ -113,7 +162,7 @@ describe('validateCustomProvider', () => {
|
||||
expect(result.err.providerID).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rejects an already-connected provider id', () => {
|
||||
test('rejects an already-connected provider id on create', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
@@ -123,6 +172,18 @@ describe('validateCustomProvider', () => {
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.exists');
|
||||
});
|
||||
|
||||
test('allows updating the same provider id while editing', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: 'sk-updated' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
editingProviderID: 'custom-provider',
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.providerID).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request construction', () => {
|
||||
@@ -200,3 +261,31 @@ describe('mergeProviderConfig persistence shape', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider edit helpers', () => {
|
||||
test('detects openai-compatible custom providers and prefills form state', () => {
|
||||
expect(isCustomOpenAICompatibleProvider({
|
||||
id: 'campus-llm',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: [],
|
||||
})).toBe(true);
|
||||
|
||||
const state = providerToCustomFormState({
|
||||
id: 'campus-llm',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: [{ id: 'fast', name: 'Fast' }],
|
||||
});
|
||||
|
||||
expect(state.providerID).toBe('campus-llm');
|
||||
expect(state.name).toBe('Campus LLM');
|
||||
expect(state.baseURL).toBe('https://llm.example.edu/v1');
|
||||
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
|
||||
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
|
||||
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export type FieldErrors = {
|
||||
providerID?: string;
|
||||
name?: string;
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export type ModelFieldErrors = {
|
||||
@@ -76,6 +77,13 @@ export type ValidateCustomProviderInput = {
|
||||
t: CustomProviderTranslator;
|
||||
existingProviderIDs: ReadonlySet<string>;
|
||||
disabledProviders?: readonly string[];
|
||||
/** When editing this provider id, treat it as an allowed update target. */
|
||||
editingProviderID?: string;
|
||||
/**
|
||||
* When true, empty apiKey is allowed because auth.json already has a credential
|
||||
* (edit path). Still requires env or key when false.
|
||||
*/
|
||||
allowExistingAuth?: boolean;
|
||||
};
|
||||
|
||||
export type ValidateCustomProviderResult = {
|
||||
@@ -85,6 +93,14 @@ export type ValidateCustomProviderResult = {
|
||||
result?: CustomProviderPersistPlan;
|
||||
};
|
||||
|
||||
export type ProviderLikeForCustomForm = {
|
||||
id: string;
|
||||
name?: string;
|
||||
env?: string[];
|
||||
options?: Record<string, unknown> | null;
|
||||
models?: Array<{ id?: string; name?: string; api?: { npm?: string } }> | Record<string, unknown>;
|
||||
};
|
||||
|
||||
let rowCounter = 0;
|
||||
|
||||
const nextRow = (): string => `row-${rowCounter++}`;
|
||||
@@ -123,6 +139,73 @@ export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
|
||||
return { key: trimmed };
|
||||
}
|
||||
|
||||
export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustomForm): boolean {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : null;
|
||||
const baseURL = typeof options?.baseURL === 'string' ? options.baseURL.trim() : '';
|
||||
if (baseURL && BASE_URL_PATTERN.test(baseURL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const models = Array.isArray(provider.models)
|
||||
? provider.models
|
||||
: (provider.models && typeof provider.models === 'object'
|
||||
? Object.values(provider.models)
|
||||
: []);
|
||||
|
||||
return models.some((model) => {
|
||||
if (!model || typeof model !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const api = 'api' in model && model.api && typeof model.api === 'object'
|
||||
? model.api as { npm?: unknown }
|
||||
: null;
|
||||
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
|
||||
});
|
||||
}
|
||||
|
||||
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
|
||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
|
||||
const headersRaw = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)
|
||||
? options.headers as Record<string, unknown>
|
||||
: {};
|
||||
const headerRows = Object.entries(headersRaw)
|
||||
.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
|
||||
.map(([key, value]) => ({ row: nextRow(), key, value }));
|
||||
|
||||
const modelEntries = Array.isArray(provider.models)
|
||||
? provider.models
|
||||
: (provider.models && typeof provider.models === 'object'
|
||||
? Object.entries(provider.models).map(([id, value]) => ({
|
||||
id,
|
||||
name: value && typeof value === 'object' && 'name' in value && typeof (value as { name?: unknown }).name === 'string'
|
||||
? (value as { name: string }).name
|
||||
: id,
|
||||
}))
|
||||
: []);
|
||||
|
||||
const models = modelEntries.length > 0
|
||||
? modelEntries.map((model) => ({
|
||||
row: nextRow(),
|
||||
id: typeof model?.id === 'string' ? model.id : '',
|
||||
name: typeof model?.name === 'string' ? model.name : (typeof model?.id === 'string' ? model.id : ''),
|
||||
}))
|
||||
: [createModelRow()];
|
||||
|
||||
const envName = Array.isArray(provider.env)
|
||||
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
providerID: provider.id,
|
||||
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
|
||||
baseURL,
|
||||
apiKey: envName ? `{env:${envName}}` : '',
|
||||
models,
|
||||
headers: headerRows.length > 0 ? headerRows : [createHeaderRow()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates form input and builds the auth + OpenCode provider config payloads.
|
||||
*/
|
||||
@@ -132,6 +215,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
|
||||
const baseURL = input.form.baseURL.trim();
|
||||
const { env, key } = parseEnvApiKey(input.form.apiKey);
|
||||
const disabledProviders = input.disabledProviders ?? [];
|
||||
const editingProviderID = input.editingProviderID?.trim();
|
||||
|
||||
const idError = !providerID
|
||||
? input.t('settings.providers.page.custom.error.providerID.required')
|
||||
@@ -149,8 +233,14 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
|
||||
? input.t('settings.providers.page.custom.error.baseURL.format')
|
||||
: undefined;
|
||||
|
||||
const credentialsSatisfied = Boolean(env || key || (editingProviderID && input.allowExistingAuth && editingProviderID === providerID));
|
||||
const apiKeyError = credentialsSatisfied
|
||||
? undefined
|
||||
: input.t('settings.providers.page.custom.error.apiKey.required');
|
||||
|
||||
const disabled = disabledProviders.includes(providerID);
|
||||
const existsError = idError
|
||||
const isSelfEdit = Boolean(editingProviderID && editingProviderID === providerID);
|
||||
const existsError = idError || isSelfEdit
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t('settings.providers.page.custom.error.providerID.exists')
|
||||
@@ -211,9 +301,10 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
apiKey: apiKeyError,
|
||||
};
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid;
|
||||
const ok = !idError && !existsError && !nameError && !urlError && !apiKeyError && modelsValid && headersValid;
|
||||
if (!ok) {
|
||||
return { err, models: modelErrors, headers: headerErrors };
|
||||
}
|
||||
@@ -268,34 +359,3 @@ export function buildProviderUpsertRequest(plan: CustomProviderPersistPlan): {
|
||||
config: plan.config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a custom provider block into an existing OpenCode config object.
|
||||
* Used by persistence tests and mirrors server upsert semantics.
|
||||
*/
|
||||
export function mergeProviderConfig(
|
||||
existing: Record<string, unknown>,
|
||||
providerID: string,
|
||||
config: CustomProviderConfig,
|
||||
options?: { removeFromDisabled?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const providerSection = (
|
||||
typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider)
|
||||
? { ...(existing.provider as Record<string, unknown>) }
|
||||
: {}
|
||||
);
|
||||
providerSection[providerID] = config;
|
||||
|
||||
const next: Record<string, unknown> = {
|
||||
...existing,
|
||||
provider: providerSection,
|
||||
};
|
||||
|
||||
if (options?.removeFromDisabled !== false && Array.isArray(existing.disabled_providers)) {
|
||||
next.disabled_providers = existing.disabled_providers.filter(
|
||||
(entry) => entry !== providerID,
|
||||
);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user