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:
Cursor Agent
2026-08-02 09:12:28 +00:00
co-authored by Serhii Dziupin
parent 5d24d6cb2a
commit be87e25c7d
25 changed files with 1980 additions and 33 deletions
@@ -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;
}