feat: add custom/other OpenAI-compatible LLM providers #2571
This commit is contained in:
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user