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