feat(providers): support custom API protocols
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user