feat: add custom/other OpenAI-compatible LLM providers
Allow Settings → Providers to define custom providers (id, name, base URL, API key, models, headers) without code changes. Persist config via OpenCode layers, store keys through auth.set, and keep web/VS Code parity. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
5d24d6cb2a
commit
be87e25c7d
@@ -0,0 +1,335 @@
|
||||
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;
|
||||
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
existingProviderIDs,
|
||||
disabledProviders = [],
|
||||
busy = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = React.useState<CustomProviderFormState>(() => createEmptyCustomProviderForm());
|
||||
const [err, setErr] = React.useState<FieldErrors>({});
|
||||
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
|
||||
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
|
||||
|
||||
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
if (key !== 'apiKey') {
|
||||
setErr((prev) => ({ ...prev, [key]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
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={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>
|
||||
|
||||
<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
|
||||
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={t('settings.providers.page.custom.field.apiKey.info')}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(event) => setField('apiKey', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.apiKey.placeholder')}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
/>
|
||||
</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}
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={busy}>
|
||||
{busy
|
||||
? t('settings.providers.page.actions.saving')
|
||||
: t('settings.providers.page.custom.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,13 @@ 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,
|
||||
type CustomProviderPersistPlan,
|
||||
} from './custom-provider-form';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -173,6 +180,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
const isCustomMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId && providers.length > 0) {
|
||||
@@ -271,7 +279,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]);
|
||||
@@ -361,6 +373,49 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => {
|
||||
const busyKey = `custom:${plan.providerID}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const upsertBody = buildProviderUpsertRequest(plan);
|
||||
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) {
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
|
||||
}
|
||||
|
||||
const authRequest = buildAuthSetRequest(plan);
|
||||
if (authRequest) {
|
||||
const authResult = await opencodeClient.getSdkClient().auth.set(authRequest);
|
||||
if (authResult.error) {
|
||||
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
|
||||
setCandidateProviderId('');
|
||||
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);
|
||||
@@ -528,8 +583,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 +594,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 +630,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 +692,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{candidateProviderId && (
|
||||
{isCustomMode ? (
|
||||
<CustomProviderForm
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
onCancel={() => setCandidateProviderId('')}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
) : candidateProviderId ? (
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
settingsItem="providers.auth"
|
||||
@@ -741,7 +833,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
) : null}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
mergeProviderConfig,
|
||||
validateCustomProvider,
|
||||
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,
|
||||
});
|
||||
|
||||
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 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', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
export type ValidateCustomProviderResult = {
|
||||
err: FieldErrors;
|
||||
models: ModelFieldErrors[];
|
||||
headers: HeaderFieldErrors[];
|
||||
result?: CustomProviderPersistPlan;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 disabled = disabledProviders.includes(providerID);
|
||||
const existsError = idError
|
||||
? 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,
|
||||
};
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && 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).
|
||||
*/
|
||||
export function buildProviderUpsertRequest(plan: CustomProviderPersistPlan): {
|
||||
providerID: string;
|
||||
config: CustomProviderConfig;
|
||||
} {
|
||||
return {
|
||||
providerID: plan.providerID,
|
||||
config: plan.config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a custom provider block into an existing OpenCode config object.
|
||||
* Used by persistence tests and mirrors server upsert semantics.
|
||||
*/
|
||||
export function mergeProviderConfig(
|
||||
existing: Record<string, unknown>,
|
||||
providerID: string,
|
||||
config: CustomProviderConfig,
|
||||
options?: { removeFromDisabled?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const providerSection = (
|
||||
typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider)
|
||||
? { ...(existing.provider as Record<string, unknown>) }
|
||||
: {}
|
||||
);
|
||||
providerSection[providerID] = config;
|
||||
|
||||
const next: Record<string, unknown> = {
|
||||
...existing,
|
||||
provider: providerSection,
|
||||
};
|
||||
|
||||
if (options?.removeFromDisabled !== false && Array.isArray(existing.disabled_providers)) {
|
||||
next.disabled_providers = existing.disabled_providers.filter(
|
||||
(entry) => entry !== providerID,
|
||||
);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
Reference in New Issue
Block a user