feat(providers): support custom API protocols

This commit is contained in:
Bohdan Triapitsyn
2026-08-20 18:39:13 +03:00
parent d80a011cbd
commit 647a949369
16 changed files with 180 additions and 20 deletions
@@ -10,9 +10,11 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
CUSTOM_PROVIDER_PROTOCOLS,
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
@@ -161,6 +163,31 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
{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.protocol.label')}
info={t('settings.providers.page.custom.field.protocol.info')}
>
<Select
value={form.protocol}
onValueChange={(protocol) => {
if (!(protocol in CUSTOM_PROVIDER_PROTOCOLS)) {
return;
}
setForm((prev) => ({ ...prev, protocol }));
}}
disabled={busy}
>
<SelectTrigger aria-label={t('settings.providers.page.custom.field.protocol.label')} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai-chat">{t('settings.providers.page.custom.field.protocol.openaiChat')}</SelectItem>
<SelectItem value="openai-responses">{t('settings.providers.page.custom.field.protocol.openaiResponses')}</SelectItem>
<SelectItem value="anthropic-messages">{t('settings.providers.page.custom.field.protocol.anthropicMessages')}</SelectItem>
</SelectContent>
</Select>
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
@@ -16,6 +16,7 @@ const t = (key: string) => key;
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
providerID: 'custom-provider',
name: 'Custom Provider',
protocol: 'openai-chat',
baseURL: 'https://api.example.com/v1',
apiKey: 'sk-test',
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
@@ -96,6 +97,16 @@ describe('validateCustomProvider', () => {
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
});
test('uses the selected OpenCode provider adapter', () => {
const result = validateCustomProvider({
form: baseForm({ protocol: 'openai-responses' }),
t,
existingProviderIDs: new Set(),
});
expect(result.result?.config.npm).toBe('@ai-sdk/openai');
});
test('rejects missing credentials', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: ' ' }),
@@ -300,10 +311,21 @@ describe('provider edit helpers', () => {
expect(state.name).toBe('Campus LLM');
expect(state.baseURL).toBe('https://llm.example.edu/v1');
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
expect(state.protocol).toBe('openai-chat');
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('prefills the protocol from a custom provider model', () => {
const state = providerToCustomFormState({
id: 'responses-api',
options: { baseURL: 'https://api.example.com/v1' },
models: [{ id: 'gpt', name: 'GPT', api: { npm: '@ai-sdk/openai' } }],
});
expect(state.protocol).toBe('openai-responses');
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
@@ -1,10 +1,16 @@
/**
* Custom / Other OpenAI-compatible provider form helpers.
* Custom 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_PROTOCOLS = {
'openai-chat': '@ai-sdk/openai-compatible',
'openai-responses': '@ai-sdk/openai',
'anthropic-messages': '@ai-sdk/anthropic',
} as const;
export type CustomProviderProtocol = keyof typeof CUSTOM_PROVIDER_PROTOCOLS;
export type CustomProviderNpm = (typeof CUSTOM_PROVIDER_PROTOCOLS)[CustomProviderProtocol];
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
@@ -30,6 +36,7 @@ export type HeaderRow = {
export type CustomProviderFormState = {
providerID: string;
name: string;
protocol: CustomProviderProtocol;
baseURL: string;
apiKey: string;
models: ModelRow[];
@@ -54,7 +61,7 @@ export type HeaderFieldErrors = {
};
export type CustomProviderConfig = {
npm: typeof CUSTOM_PROVIDER_NPM;
npm: CustomProviderNpm;
name: string;
env?: string[];
options: {
@@ -120,12 +127,24 @@ export const createHeaderRow = (): HeaderRow => ({
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
providerID: '',
name: '',
protocol: 'openai-chat',
baseURL: '',
apiKey: '',
models: [createModelRow()],
headers: [createHeaderRow()],
});
function protocolFromNpm(npm: string | undefined): CustomProviderProtocol {
switch (npm) {
case '@ai-sdk/openai':
return 'openai-responses';
case '@ai-sdk/anthropic':
return 'anthropic-messages';
default:
return 'openai-chat';
}
}
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
@@ -159,7 +178,7 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom
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;
return typeof api?.npm === 'string' && new Set<string>(Object.values(CUSTOM_PROVIDER_PROTOCOLS)).has(api.npm);
});
}
@@ -238,9 +257,14 @@ export function providerToCustomFormState(provider: ProviderLikeForCustomForm):
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
: undefined;
const modelWithApi = modelEntries.find(
(model): model is { id?: string; name?: string; api?: { npm?: string } } => 'api' in model,
);
return {
providerID: provider.id,
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
protocol: protocolFromNpm(modelWithApi?.api?.npm),
baseURL,
apiKey: envName ? `{env:${envName}}` : '',
models,
@@ -360,7 +384,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
name,
apiKey: key,
config: {
npm: CUSTOM_PROVIDER_NPM,
npm: CUSTOM_PROVIDER_PROTOCOLS[input.form.protocol],
name,
...(env ? { env: [env] } : {}),
options: {