373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
buildAuthSetRequest,
|
|
buildProviderUpsertRequest,
|
|
isConfigDefinedCustomProvider,
|
|
isCustomOpenAICompatibleProvider,
|
|
providerToCustomFormState,
|
|
resolveProviderConfigScope,
|
|
validateCustomProvider,
|
|
type CustomProviderConfig,
|
|
type CustomProviderFormState,
|
|
} from './custom-provider-form';
|
|
|
|
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' }],
|
|
headers: [{ row: 'h0', key: '', value: '' }],
|
|
...overrides,
|
|
});
|
|
|
|
/** Mirrors server upsert semantics for request-construction tests. */
|
|
function mergeProviderConfig(
|
|
existing: Record<string, unknown>,
|
|
providerID: string,
|
|
config: CustomProviderConfig,
|
|
): 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 (Array.isArray(existing.disabled_providers)) {
|
|
next.disabled_providers = existing.disabled_providers.filter((entry) => entry !== providerID);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
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('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: ' ' }),
|
|
t,
|
|
existingProviderIDs: new Set(),
|
|
});
|
|
|
|
expect(result.result).toEqual(undefined);
|
|
expect(result.err.apiKey).toBe('settings.providers.page.custom.error.apiKey.required');
|
|
});
|
|
|
|
test('allows empty api key when editing with existing auth', () => {
|
|
const result = validateCustomProvider({
|
|
form: baseForm({ apiKey: '' }),
|
|
t,
|
|
existingProviderIDs: new Set(['custom-provider']),
|
|
editingProviderID: 'custom-provider',
|
|
allowExistingAuth: true,
|
|
});
|
|
|
|
expect(result.result?.providerID).toBe('custom-provider');
|
|
expect(result.err.apiKey).toEqual(undefined);
|
|
expect(result.result?.apiKey).toEqual(undefined);
|
|
});
|
|
|
|
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 on create', () => {
|
|
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');
|
|
});
|
|
|
|
test('allows updating the same provider id while editing', () => {
|
|
const result = validateCustomProvider({
|
|
form: baseForm({ apiKey: 'sk-updated' }),
|
|
t,
|
|
existingProviderIDs: new Set(['custom-provider']),
|
|
editingProviderID: 'custom-provider',
|
|
});
|
|
|
|
expect(result.result?.providerID).toBe('custom-provider');
|
|
expect(result.err.providerID).toEqual(undefined);
|
|
});
|
|
});
|
|
|
|
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,
|
|
scope: 'user',
|
|
});
|
|
});
|
|
|
|
test('includes explicit project/custom scope on upsert requests', () => {
|
|
const validated = validateCustomProvider({
|
|
form: baseForm(),
|
|
t,
|
|
existingProviderIDs: new Set(),
|
|
});
|
|
const plan = validated.result!;
|
|
|
|
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
|
|
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('provider edit helpers', () => {
|
|
test('detects openai-compatible custom providers and prefills form state', () => {
|
|
expect(isCustomOpenAICompatibleProvider({
|
|
id: 'campus-llm',
|
|
options: { baseURL: 'https://llm.example.edu/v1' },
|
|
models: [],
|
|
})).toBe(true);
|
|
|
|
const state = providerToCustomFormState({
|
|
id: 'campus-llm',
|
|
name: 'Campus LLM',
|
|
env: ['CAMPUS_KEY'],
|
|
options: {
|
|
baseURL: 'https://llm.example.edu/v1',
|
|
headers: { 'X-Campus': '1' },
|
|
},
|
|
models: [{ id: 'fast', name: 'Fast' }],
|
|
});
|
|
|
|
expect(state.providerID).toBe('campus-llm');
|
|
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',
|
|
options: { baseURL: 'https://api.openai.com/v1' },
|
|
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
|
|
};
|
|
|
|
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
|
|
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
|
|
expect(isConfigDefinedCustomProvider(catalogLike, {
|
|
user: { exists: false },
|
|
project: { exists: false },
|
|
custom: { exists: false },
|
|
})).toBe(false);
|
|
expect(isConfigDefinedCustomProvider(catalogLike, {
|
|
user: { exists: true },
|
|
project: { exists: false },
|
|
})).toBe(true);
|
|
});
|
|
|
|
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
|
|
expect(resolveProviderConfigScope(undefined)).toBe('user');
|
|
expect(resolveProviderConfigScope({
|
|
user: { exists: true },
|
|
project: { exists: false },
|
|
custom: { exists: false },
|
|
})).toBe('user');
|
|
expect(resolveProviderConfigScope({
|
|
user: { exists: true },
|
|
project: { exists: true },
|
|
custom: { exists: false },
|
|
})).toBe('project');
|
|
expect(resolveProviderConfigScope({
|
|
user: { exists: true },
|
|
project: { exists: true },
|
|
custom: { exists: true },
|
|
})).toBe('custom');
|
|
expect(resolveProviderConfigScope({
|
|
user: { exists: false },
|
|
project: { exists: false },
|
|
custom: { exists: true },
|
|
})).toBe('custom');
|
|
});
|
|
});
|