feat: add custom/other OpenAI-compatible LLM providers #2571

This commit is contained in:
Bohdan Triapitsyn
2026-08-03 13:21:45 +03:00
committed by GitHub
30 changed files with 3125 additions and 67 deletions
@@ -0,0 +1,396 @@
import React from 'react';
import {
SettingsSection,
SettingsStackedField,
SETTINGS_FIELDS_STACK_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_HELPER_CLASS,
SETTINGS_ICON_BUTTON_CLASS,
SETTINGS_CONTROL_CLUSTER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
validateCustomProvider,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type CustomProviderTranslator,
type FieldErrors,
type HeaderFieldErrors,
type ModelFieldErrors,
} from './custom-provider-form';
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 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[]>([]);
const seededEditProviderIdRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!initialValues) {
return;
}
// Edit mode: seed once per provider id so parent re-renders (new object
// identity for the same snapshot) do not wipe in-progress edits.
if (isEdit && seededEditProviderIdRef.current === initialValues.providerID) {
return;
}
seededEditProviderIdRef.current = isEdit ? initialValues.providerID : null;
setForm(initialValues);
setErr({});
setModelErrors([]);
setHeaderErrors([]);
}, [initialValues, isEdit]);
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErr((prev) => ({ ...prev, [key]: undefined }));
};
const setModel = (index: number, key: 'id' | 'name', value: string) => {
setForm((prev) => ({
...prev,
models: prev.models.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
}));
setModelErrors((prev) => {
const next = [...prev];
next[index] = { ...(next[index] ?? {}), [key]: undefined };
return next;
});
};
const setHeader = (index: number, key: 'key' | 'value', value: string) => {
setForm((prev) => ({
...prev,
headers: prev.headers.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
}));
setHeaderErrors((prev) => {
const next = [...prev];
next[index] = { ...(next[index] ?? {}), [key]: undefined };
return next;
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (busy) {
return;
}
const output = validateCustomProvider({
form,
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);
setHeaderErrors(output.headers);
if (!output.result) {
return;
}
await onSubmit(output.result);
};
return (
<form onSubmit={handleSubmit} className="space-y-0">
<SettingsSection
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')}
>
<Input
value={form.providerID}
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={!isEdit}
disabled={isEdit || busy}
aria-invalid={Boolean(err.providerID)}
aria-label={t('settings.providers.page.custom.field.providerID.label')}
/>
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
>
<Input
value={form.name}
onChange={(event) => setField('name', event.target.value)}
placeholder={t('settings.providers.page.custom.field.name.placeholder')}
className="h-8 rounded-md px-3"
aria-invalid={Boolean(err.name)}
aria-label={t('settings.providers.page.custom.field.name.label')}
/>
{err.name ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.name}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.baseURL.label')}
info={t('settings.providers.page.custom.field.baseURL.info')}
>
<Input
value={form.baseURL}
onChange={(event) => setField('baseURL', event.target.value)}
placeholder={t('settings.providers.page.custom.field.baseURL.placeholder')}
className="h-8 rounded-md px-3 font-mono text-xs"
aria-invalid={Boolean(err.baseURL)}
aria-label={t('settings.providers.page.custom.field.baseURL.label')}
/>
{err.baseURL ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.baseURL}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.apiKey.label')}
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={
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>
<SettingsSection
title={t('settings.providers.page.custom.models.title')}
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
{form.models.map((model, index) => (
<div key={model.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.models.idLabel')}
</label>
<Input
value={model.id}
onChange={(event) => setModel(index, 'id', event.target.value)}
placeholder={t('settings.providers.page.custom.models.idPlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.models.idLabel')}
/>
{modelErrors[index]?.id ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.id}</p>
) : null}
</div>
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.models.nameLabel')}
</label>
<Input
value={model.name}
onChange={(event) => setModel(index, 'name', event.target.value)}
placeholder={t('settings.providers.page.custom.models.namePlaceholder')}
className="mt-1 h-8 rounded-md px-3"
aria-label={t('settings.providers.page.custom.models.nameLabel')}
/>
{modelErrors[index]?.name ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.name}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={SETTINGS_ICON_BUTTON_CLASS}
disabled={form.models.length <= 1}
onClick={() => {
if (form.models.length <= 1) return;
setForm((prev) => ({
...prev,
models: prev.models.filter((_, rowIndex) => rowIndex !== index),
}));
setModelErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
}}
aria-label={t('settings.providers.page.custom.models.remove')}
>
<Icon name="delete-bin" className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setForm((prev) => ({ ...prev, models: [...prev.models, createModelRow()] }));
setModelErrors((prev) => [...prev, {}]);
}}
>
{t('settings.providers.page.custom.models.add')}
</Button>
</SettingsSection>
<SettingsSection
title={t('settings.providers.page.custom.headers.title')}
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.headers.description')}</p>
{form.headers.map((header, index) => (
<div key={header.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.headers.keyLabel')}
</label>
<Input
value={header.key}
onChange={(event) => setHeader(index, 'key', event.target.value)}
placeholder={t('settings.providers.page.custom.headers.keyPlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.headers.keyLabel')}
/>
{headerErrors[index]?.key ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.key}</p>
) : null}
</div>
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.headers.valueLabel')}
</label>
<Input
value={header.value}
onChange={(event) => setHeader(index, 'value', event.target.value)}
placeholder={t('settings.providers.page.custom.headers.valuePlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.headers.valueLabel')}
/>
{headerErrors[index]?.value ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.value}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={SETTINGS_ICON_BUTTON_CLASS}
disabled={form.headers.length <= 1}
onClick={() => {
if (form.headers.length <= 1) return;
setForm((prev) => ({
...prev,
headers: prev.headers.filter((_, rowIndex) => rowIndex !== index),
}));
setHeaderErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
}}
aria-label={t('settings.providers.page.custom.headers.remove')}
>
<Icon name="delete-bin" className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setForm((prev) => ({ ...prev, headers: [...prev.headers, createHeaderRow()] }));
setHeaderErrors((prev) => [...prev, {}]);
}}
>
{t('settings.providers.page.custom.headers.add')}
</Button>
</SettingsSection>
<div className="flex flex-wrap items-center gap-2 py-4">
{onCancel ? (
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={onCancel} disabled={busy}>
{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')
: isEdit
? t('settings.providers.page.custom.actions.update')
: t('settings.providers.page.custom.actions.save')}
</Button>
</div>
</form>
);
};
@@ -26,6 +26,18 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { CustomProviderForm } from './CustomProviderForm';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
CUSTOM_PROVIDER_ID,
isConfigDefinedCustomProvider,
providerToCustomFormState,
resolveProviderConfigScope,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type ProviderConfigScope,
} from './custom-provider-form';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
notation: 'compact',
@@ -172,7 +184,19 @@ 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 [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
const [editingCustomScope, setEditingCustomScope] = React.useState<ProviderConfigScope | 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 isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
const isCustomEditMode = Boolean(
editingCustomProviderId
&& selectedProviderId
&& editingCustomProviderId === selectedProviderId
&& !isAddMode,
);
React.useEffect(() => {
if (!selectedProviderId && providers.length > 0) {
@@ -271,7 +295,11 @@ export const ProvidersPage: React.FC = () => {
return;
}
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
if (
candidateProviderId
&& candidateProviderId !== CUSTOM_PROVIDER_ID
&& !unconnectedProviders.some((provider) => provider.id === candidateProviderId)
) {
setCandidateProviderId('');
}
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
@@ -279,11 +307,21 @@ export const ProvidersPage: React.FC = () => {
React.useEffect(() => {
if (selectedProviderId === ADD_PROVIDER_ID) {
setShowAuthPanel(true);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
return;
}
setShowAuthPanel(false);
}, [selectedProviderId, t]);
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
}
}, [selectedProviderId, editingCustomProviderId]);
React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
@@ -361,6 +399,68 @@ 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, {
// Create defaults to user. Edit must rewrite the winning config layer
// (custom > project > user) so project/custom providers are not copied
// into a global user override.
scope: editingCustomProviderId
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(upsertBody),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
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);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
setSelectedProvider(plan.providerID);
} catch (error) {
console.error('Failed to save custom provider:', error);
toast.error(
error instanceof Error && error.message
? error.message
: t('settings.providers.page.toast.customProviderSaveFailed'),
);
} finally {
setAuthBusyKey(null);
}
};
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
const busyKey = `oauth:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
@@ -499,6 +599,19 @@ export const ProvidersPage: React.FC = () => {
}
};
const handleDisconnectCustomProvider = async (providerId: string) => {
if (!providerId) {
return;
}
await handleDisconnectProvider(providerId);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
setCandidateProviderId('');
};
if (!isAddMode && providers.length === 0) {
return (
<div className="flex h-full items-center justify-center">
@@ -528,8 +641,6 @@ export const ProvidersPage: React.FC = () => {
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
) : availableError ? (
<p className="typography-meta text-muted-foreground">{availableError}</p>
) : unconnectedProviders.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
) : (
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
setProviderDropdownOpen(open);
@@ -541,11 +652,15 @@ export const ProvidersPage: React.FC = () => {
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
>
<span className="flex items-center gap-2 min-w-0">
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
{candidateProviderId && candidateProviderId !== CUSTOM_PROVIDER_ID ? (
<ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" />
) : null}
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
{candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: t('settings.providers.page.connect.selectProviderPlaceholder')}
{candidateProviderId === CUSTOM_PROVIDER_ID
? t('settings.providers.page.custom.optionLabel')
: candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: t('settings.providers.page.connect.selectProviderPlaceholder')}
</span>
</span>
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
@@ -573,32 +688,60 @@ export const ProvidersPage: React.FC = () => {
</div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => {
const query = providerSearchQuery.toLowerCase();
const customLabel = t('settings.providers.page.custom.optionLabel');
const customMatches = !query
|| customLabel.toLowerCase().includes(query)
|| 'other'.includes(query)
|| 'custom'.includes(query);
const filtered = unconnectedProviders.filter(p => {
const query = providerSearchQuery.toLowerCase();
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
});
if (filtered.length === 0) {
if (filtered.length === 0 && !customMatches) {
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
}
return filtered.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => {
setCandidateProviderId(provider.id);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && (
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
));
return (
<>
{filtered.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => {
setCandidateProviderId(provider.id);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && (
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
))}
{customMatches ? (
<DropdownMenuItem
key={CUSTOM_PROVIDER_ID}
onSelect={() => {
setCandidateProviderId(CUSTOM_PROVIDER_ID);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<Icon name="add" className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{customLabel}</span>
</span>
{candidateProviderId === CUSTOM_PROVIDER_ID && (
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
) : null}
</>
);
})()}
</ScrollableOverlay>
</DropdownMenuContent>
@@ -607,7 +750,25 @@ export const ProvidersPage: React.FC = () => {
</div>
</SettingsSection>
{candidateProviderId && (
{isCustomCreateMode ? (
<CustomProviderForm
mode="create"
existingProviderIDs={connectedProviderIds}
busy={authBusyKey?.startsWith('custom:') ?? false}
authFailureHint={customAuthFailureHint}
onCancel={() => {
setCandidateProviderId('');
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
onDisconnect={
customAuthFailureHint && lastCustomPersistId
? () => void handleDisconnectCustomProvider(lastCustomPersistId)
: undefined
}
onSubmit={handleSaveCustomProvider}
/>
) : candidateProviderId ? (
<SettingsSection
title={t('settings.providers.page.auth.title')}
settingsItem="providers.auth"
@@ -741,7 +902,7 @@ export const ProvidersPage: React.FC = () => {
</>
)}
</SettingsSection>
)}
) : null}
</SettingsPageLayout>
);
}
@@ -761,6 +922,16 @@ 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 sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
const providerEnv = Array.isArray(selectedProvider.env)
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials;
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : '';
@@ -770,6 +941,35 @@ export const ProvidersPage: React.FC = () => {
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
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={editingCustomFormInitial}
allowExistingAuth={hasCredentials || !sourcesLoaded}
busy={authBusyKey?.startsWith('custom:') ?? false}
authFailureHint={customAuthFailureHint}
onCancel={() => {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)}
onSubmit={handleSaveCustomProvider}
/>
</SettingsPageLayout>
);
}
return (
<SettingsPageLayout
title={selectedProvider.name || selectedProvider.id}
@@ -781,23 +981,48 @@ 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">
{isEditableCustomProvider ? (
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setCustomAuthFailureHint(null);
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
setEditingCustomScope(resolveProviderConfigScope(selectedSources));
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>
) : (
@@ -0,0 +1,350 @@
import { describe, expect, test } from 'bun:test';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
isConfigDefinedCustomProvider,
isCustomOpenAICompatibleProvider,
providerToCustomFormState,
resolveProviderConfigScope,
validateCustomProvider,
type CustomProviderConfig,
type CustomProviderFormState,
} from './custom-provider-form';
const t = (key: string) => key;
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
providerID: 'custom-provider',
name: 'Custom Provider',
baseURL: 'https://api.example.com/v1',
apiKey: 'sk-test',
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
headers: [{ row: 'h0', key: '', value: '' }],
...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({
form: baseForm({
providerID: ' custom-provider ',
name: ' Custom Provider ',
baseURL: ' https://api.example.com/v1 ',
apiKey: ' sk-secret ',
models: [{ row: 'm0', id: ' model-a ', name: ' Model A ' }],
headers: [
{ row: 'h0', key: ' X-Test ', value: ' enabled ' },
{ row: 'h1', key: '', value: '' },
],
}),
t,
existingProviderIDs: new Set(),
});
expect(result.result).toEqual({
providerID: 'custom-provider',
name: 'Custom Provider',
apiKey: 'sk-secret',
config: {
npm: '@ai-sdk/openai-compatible',
name: 'Custom Provider',
options: {
baseURL: 'https://api.example.com/v1',
headers: {
'X-Test': 'enabled',
},
},
models: {
'model-a': { name: 'Model A' },
},
},
});
});
test('supports {env:VAR} credentials without writing an auth key', () => {
const result = validateCustomProvider({
form: baseForm({
apiKey: '{env: CUSTOM_PROVIDER_KEY}',
}),
t,
existingProviderIDs: new Set(),
});
expect(result.result?.apiKey).toEqual(undefined);
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({
providerID: 'Bad ID',
baseURL: 'ftp://example.com',
models: [
{ row: 'm0', id: 'model-a', name: 'Model A' },
{ row: 'm1', id: 'model-a', name: 'Model A 2' },
],
headers: [
{ row: 'h0', key: 'Authorization', value: 'one' },
{ row: 'h1', key: 'authorization', value: 'two' },
],
}),
t,
existingProviderIDs: new Set(),
});
expect(result.result).toEqual(undefined);
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.format');
expect(result.err.baseURL).toBe('settings.providers.page.custom.error.baseURL.format');
expect(result.models[1]).toEqual({
id: 'settings.providers.page.custom.error.duplicate',
name: undefined,
});
expect(result.headers[1]).toEqual({
key: 'settings.providers.page.custom.error.duplicate',
value: undefined,
});
});
test('allows reconnecting a disabled provider id', () => {
const result = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(['custom-provider']),
disabledProviders: ['custom-provider'],
});
expect(result.result?.providerID).toBe('custom-provider');
expect(result.err.providerID).toEqual(undefined);
});
test('rejects an already-connected provider id on create', () => {
const result = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(['custom-provider']),
});
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', () => {
test('builds auth.set and provider upsert requests', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
expect(buildAuthSetRequest(plan)).toEqual({
providerID: 'custom-provider',
auth: { type: 'api', key: 'sk-test' },
});
expect(buildProviderUpsertRequest(plan)).toEqual({
providerID: 'custom-provider',
config: plan.config,
scope: 'user',
});
});
test('includes explicit project/custom scope on upsert requests', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
});
test('omits auth.set when using env credentials', () => {
const validated = validateCustomProvider({
form: baseForm({ apiKey: '{env:MY_KEY}' }),
t,
existingProviderIDs: new Set(),
});
expect(buildAuthSetRequest(validated.result!)).toBeNull();
});
});
describe('mergeProviderConfig persistence shape', () => {
test('merges provider block and clears disabled_providers entry', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
const next = mergeProviderConfig(
{
model: 'openai/gpt-4o',
provider: {
openai: { name: 'OpenAI' },
},
disabled_providers: ['custom-provider', 'other'],
},
plan.providerID,
plan.config,
);
expect(next).toEqual({
model: 'openai/gpt-4o',
provider: {
openai: { name: 'OpenAI' },
'custom-provider': plan.config,
},
disabled_providers: ['other'],
});
});
test('creates provider section when missing', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
const next = mergeProviderConfig({}, plan.providerID, plan.config);
expect(next.provider).toEqual({
'custom-provider': plan.config,
});
});
});
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' });
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
options: { baseURL: 'https://api.openai.com/v1' },
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
};
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
expect(isConfigDefinedCustomProvider(catalogLike, {
user: { exists: false },
project: { exists: false },
custom: { exists: false },
})).toBe(false);
expect(isConfigDefinedCustomProvider(catalogLike, {
user: { exists: true },
project: { exists: false },
})).toBe(true);
});
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
expect(resolveProviderConfigScope(undefined)).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: false },
custom: { exists: false },
})).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: false },
})).toBe('project');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: true },
})).toBe('custom');
expect(resolveProviderConfigScope({
user: { exists: false },
project: { exists: false },
custom: { exists: true },
})).toBe('custom');
});
});
@@ -0,0 +1,410 @@
/**
* Custom / Other OpenAI-compatible provider form helpers.
* Mirrors OpenCode web UI validation and request construction so a provider
* can be defined from Settings without code changes.
*/
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
export const BASE_URL_PATTERN = /^https?:\/\//;
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
export type CustomProviderTranslator = (
key: string,
vars?: Record<string, string | number | boolean>,
) => string;
export type ModelRow = {
row: string;
id: string;
name: string;
};
export type HeaderRow = {
row: string;
key: string;
value: string;
};
export type CustomProviderFormState = {
providerID: string;
name: string;
baseURL: string;
apiKey: string;
models: ModelRow[];
headers: HeaderRow[];
};
export type FieldErrors = {
providerID?: string;
name?: string;
baseURL?: string;
apiKey?: string;
};
export type ModelFieldErrors = {
id?: string;
name?: string;
};
export type HeaderFieldErrors = {
key?: string;
value?: string;
};
export type CustomProviderConfig = {
npm: typeof CUSTOM_PROVIDER_NPM;
name: string;
env?: string[];
options: {
baseURL: string;
headers?: Record<string, string>;
};
models: Record<string, { name: string }>;
};
export type CustomProviderPersistPlan = {
providerID: string;
name: string;
/** Literal API key to send via auth.set; omitted when using {env:VAR} or empty. */
apiKey?: string;
config: CustomProviderConfig;
};
export type ValidateCustomProviderInput = {
form: CustomProviderFormState;
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 = {
err: FieldErrors;
models: ModelFieldErrors[];
headers: HeaderFieldErrors[];
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++}`;
export const createModelRow = (): ModelRow => ({
row: nextRow(),
id: '',
name: '',
});
export const createHeaderRow = (): HeaderRow => ({
row: nextRow(),
key: '',
value: '',
});
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
providerID: '',
name: '',
baseURL: '',
apiKey: '',
models: [createModelRow()],
headers: [createHeaderRow()],
});
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
return {};
}
const envMatch = trimmed.match(ENV_KEY_PATTERN);
const env = envMatch?.[1]?.trim();
if (env) {
return { env };
}
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 type ProviderConfigSourcesLike = {
user?: { exists?: boolean };
project?: { exists?: boolean };
custom?: { exists?: boolean };
};
export type ProviderConfigScope = 'user' | 'project' | 'custom';
/**
* True when a provider both looks OpenAI-compatible-custom and is defined in a
* user/project/custom OpenCode config layer. Catalog-only providers often share
* the same npm/baseURL signals and must not get Edit / config overrides.
*/
export function isConfigDefinedCustomProvider(
provider: ProviderLikeForCustomForm,
sources: ProviderConfigSourcesLike | null | undefined,
): boolean {
if (!sources) {
return false;
}
const inConfigLayer = Boolean(
sources.user?.exists || sources.project?.exists || sources.custom?.exists,
);
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
}
/**
* Effective writable config layer for a provider, matching OpenCode merge
* precedence: custom > project > user.
*/
export function resolveProviderConfigScope(
sources: ProviderConfigSourcesLike | null | undefined,
): ProviderConfigScope {
if (sources?.custom?.exists) {
return 'custom';
}
if (sources?.project?.exists) {
return 'project';
}
return 'user';
}
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.
*/
export function validateCustomProvider(input: ValidateCustomProviderInput): ValidateCustomProviderResult {
const providerID = input.form.providerID.trim();
const name = input.form.name.trim();
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')
: !PROVIDER_ID_PATTERN.test(providerID)
? input.t('settings.providers.page.custom.error.providerID.format')
: undefined;
const nameError = !name
? input.t('settings.providers.page.custom.error.name.required')
: undefined;
const urlError = !baseURL
? input.t('settings.providers.page.custom.error.baseURL.required')
: !BASE_URL_PATTERN.test(baseURL)
? 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 isSelfEdit = Boolean(editingProviderID && editingProviderID === providerID);
const existsError = idError || isSelfEdit
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t('settings.providers.page.custom.error.providerID.exists')
: undefined;
const seenModels = new Set<string>();
const modelErrors = input.form.models.map((model) => {
const id = model.id.trim();
const modelIdError = !id
? input.t('settings.providers.page.custom.error.required')
: seenModels.has(id)
? input.t('settings.providers.page.custom.error.duplicate')
: (() => {
seenModels.add(id);
return undefined;
})();
const modelNameError = !model.name.trim()
? input.t('settings.providers.page.custom.error.required')
: undefined;
return { id: modelIdError, name: modelNameError };
});
const modelsValid = modelErrors.every((entry) => !entry.id && !entry.name);
const modelConfig = Object.fromEntries(
input.form.models.map((model) => [model.id.trim(), { name: model.name.trim() }]),
);
const seenHeaders = new Set<string>();
const headerErrors = input.form.headers.map((header) => {
const headerKey = header.key.trim();
const headerValue = header.value.trim();
if (!headerKey && !headerValue) {
return {};
}
const keyError = !headerKey
? input.t('settings.providers.page.custom.error.required')
: seenHeaders.has(headerKey.toLowerCase())
? input.t('settings.providers.page.custom.error.duplicate')
: (() => {
seenHeaders.add(headerKey.toLowerCase());
return undefined;
})();
const valueError = !headerValue
? input.t('settings.providers.page.custom.error.required')
: undefined;
return { key: keyError, value: valueError };
});
const headersValid = headerErrors.every((entry) => !entry.key && !entry.value);
const headerConfig = Object.fromEntries(
input.form.headers
.map((header) => ({ key: header.key.trim(), value: header.value.trim() }))
.filter((header) => header.key && header.value)
.map((header) => [header.key, header.value]),
);
const err: FieldErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
apiKey: apiKeyError,
};
const ok = !idError && !existsError && !nameError && !urlError && !apiKeyError && modelsValid && headersValid;
if (!ok) {
return { err, models: modelErrors, headers: headerErrors };
}
return {
err,
models: modelErrors,
headers: headerErrors,
result: {
providerID,
name,
apiKey: key,
config: {
npm: CUSTOM_PROVIDER_NPM,
name,
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(Object.keys(headerConfig).length > 0 ? { headers: headerConfig } : {}),
},
models: modelConfig,
},
},
};
}
/**
* Builds the OpenCode auth.set request body when a literal API key is present.
*/
export function buildAuthSetRequest(plan: CustomProviderPersistPlan): {
providerID: string;
auth: { type: 'api'; key: string };
} | null {
if (!plan.apiKey) {
return null;
}
return {
providerID: plan.providerID,
auth: { type: 'api', key: plan.apiKey },
};
}
/**
* Builds the OpenChamber provider upsert request body (config persistence).
* `scope` selects the OpenCode config layer (user/project/custom). Create
* defaults to user; edit must pass the provider's effective existing layer.
*/
export function buildProviderUpsertRequest(
plan: CustomProviderPersistPlan,
options?: { scope?: ProviderConfigScope },
): {
providerID: string;
config: CustomProviderConfig;
scope: ProviderConfigScope;
} {
return {
providerID: plan.providerID,
config: plan.config,
scope: options?.scope ?? 'user',
};
}