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:
Cursor Agent
2026-08-02 11:40:02 +00:00
co-authored by Serhii Dziupin
parent d84e4e0312
commit d40bb9e5a0
23 changed files with 815 additions and 104 deletions
@@ -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;
}