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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user