291 lines
9.5 KiB
TypeScript
291 lines
9.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
isProviderUsable,
|
|
validateProvider,
|
|
validateModel,
|
|
resolveSelectedModel,
|
|
hasUsableLLMProvider,
|
|
isLLMProviderConfigured,
|
|
type ProviderCfgLike,
|
|
} from '@/lib/store/settings-validation';
|
|
|
|
describe('isProviderUsable', () => {
|
|
it('returns true when provider has client API key', () => {
|
|
expect(isProviderUsable({ apiKey: 'sk-xxx' })).toBe(true);
|
|
});
|
|
|
|
it('returns true when provider is server-configured', () => {
|
|
expect(isProviderUsable({ isServerConfigured: true })).toBe(true);
|
|
});
|
|
|
|
it('returns true when provider has both client key and server config', () => {
|
|
expect(isProviderUsable({ apiKey: 'sk-xxx', isServerConfigured: true })).toBe(true);
|
|
});
|
|
|
|
it('returns false when has neither client key nor server config', () => {
|
|
expect(isProviderUsable({ apiKey: '', isServerConfigured: false })).toBe(false);
|
|
});
|
|
|
|
it('returns false when apiKey is empty and not server-configured', () => {
|
|
expect(isProviderUsable({ apiKey: '' })).toBe(false);
|
|
});
|
|
|
|
it('returns false for undefined config', () => {
|
|
expect(isProviderUsable(undefined)).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty object', () => {
|
|
expect(isProviderUsable({})).toBe(false);
|
|
});
|
|
|
|
it('returns true for keyless provider with explicit baseUrl', () => {
|
|
expect(isProviderUsable({ requiresApiKey: false, baseUrl: 'http://localhost:11434/v1' })).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('returns false for keyless provider without baseUrl', () => {
|
|
expect(isProviderUsable({ requiresApiKey: false })).toBe(false);
|
|
});
|
|
|
|
it('returns false for keyless provider with empty baseUrl', () => {
|
|
expect(isProviderUsable({ requiresApiKey: false, baseUrl: '' })).toBe(false);
|
|
});
|
|
|
|
it('returns true for keyless provider when server-configured', () => {
|
|
expect(isProviderUsable({ requiresApiKey: false, isServerConfigured: true })).toBe(true);
|
|
});
|
|
|
|
it('returns false for keyless provider with apiKey but no baseUrl', () => {
|
|
expect(isProviderUsable({ requiresApiKey: false, apiKey: 'some-key' })).toBe(false);
|
|
});
|
|
|
|
it('returns false for a server-disabled provider even with a client API key (#665)', () => {
|
|
expect(isProviderUsable({ apiKey: 'sk-xxx', serverDisabled: true })).toBe(false);
|
|
expect(isProviderUsable({ isServerConfigured: true, serverDisabled: true })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateProvider', () => {
|
|
const cfg = (overrides: Partial<ProviderCfgLike> = {}): ProviderCfgLike => ({
|
|
apiKey: '',
|
|
isServerConfigured: false,
|
|
...overrides,
|
|
});
|
|
|
|
it('keeps current provider when it is server-configured', () => {
|
|
const configMap = {
|
|
'provider-a': cfg({ isServerConfigured: true }),
|
|
'provider-b': cfg(),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-a');
|
|
});
|
|
|
|
it('keeps current provider when it has client API key', () => {
|
|
const configMap = {
|
|
'provider-a': cfg({ apiKey: 'sk-xxx' }),
|
|
'provider-b': cfg(),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-a');
|
|
});
|
|
|
|
it('falls back to first usable provider when current is unusable', () => {
|
|
const configMap = {
|
|
'provider-a': cfg(),
|
|
'provider-b': cfg({ isServerConfigured: true }),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-b');
|
|
});
|
|
|
|
it('re-points away from a server-disabled current provider that has a client key (#665)', () => {
|
|
const configMap = {
|
|
'provider-a': cfg({ apiKey: 'sk-xxx', serverDisabled: true }),
|
|
'provider-b': cfg({ isServerConfigured: true }),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-b');
|
|
});
|
|
|
|
it('returns empty string when no fallback is usable and no default', () => {
|
|
const configMap = {
|
|
'provider-a': cfg(),
|
|
'provider-b': cfg(),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('');
|
|
});
|
|
|
|
it('falls back to defaultId when no fallback is usable', () => {
|
|
const configMap = {
|
|
'provider-a': cfg(),
|
|
'provider-b': cfg(),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'], 'browser-native')).toBe(
|
|
'browser-native',
|
|
);
|
|
});
|
|
|
|
it('prefers usable fallback over defaultId', () => {
|
|
const configMap = {
|
|
'provider-a': cfg(),
|
|
'provider-b': cfg({ isServerConfigured: true }),
|
|
};
|
|
expect(validateProvider('provider-a', configMap, ['provider-b'], 'browser-native')).toBe(
|
|
'provider-b',
|
|
);
|
|
});
|
|
|
|
it('returns current id unchanged when it is empty', () => {
|
|
const configMap = { 'provider-a': cfg({ isServerConfigured: true }) };
|
|
expect(validateProvider('', configMap, ['provider-a'])).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('validateModel', () => {
|
|
it('keeps model when still in available list', () => {
|
|
expect(validateModel('gpt-4o', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe('gpt-4o');
|
|
});
|
|
|
|
it('falls back to first model when current is not in list', () => {
|
|
expect(validateModel('gpt-4-turbo', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe('gpt-4o');
|
|
});
|
|
|
|
it('returns empty string when list is empty', () => {
|
|
expect(validateModel('gpt-4o', [])).toBe('');
|
|
});
|
|
|
|
it('returns current id unchanged when it is empty', () => {
|
|
expect(validateModel('', [{ id: 'gpt-4o' }])).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('resolveSelectedModel', () => {
|
|
it('keeps model when still in available list', () => {
|
|
expect(resolveSelectedModel('gpt-4o', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe(
|
|
'gpt-4o',
|
|
);
|
|
});
|
|
|
|
it('falls back to first model when current is not in list', () => {
|
|
expect(resolveSelectedModel('gpt-4-turbo', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe(
|
|
'gpt-4o',
|
|
);
|
|
});
|
|
|
|
it('falls back to first model when current is empty (the invariant: usable provider ⇒ concrete model)', () => {
|
|
expect(resolveSelectedModel('', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe('gpt-4o');
|
|
});
|
|
|
|
it('returns empty string only when the model list is empty', () => {
|
|
expect(resolveSelectedModel('gpt-4o', [])).toBe('');
|
|
expect(resolveSelectedModel('', [])).toBe('');
|
|
});
|
|
|
|
it('never yields empty when the provider has at least one model', () => {
|
|
for (const current of ['', 'unknown', 'glm-4']) {
|
|
expect(resolveSelectedModel(current, [{ id: 'glm-4' }])).not.toBe('');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('hasUsableLLMProvider', () => {
|
|
const ok = { apiKey: 'sk-x', models: [{ id: 'm1' }], defaultBaseUrl: 'https://x' };
|
|
|
|
it('returns true when a provider has credentials, ≥1 model, and an endpoint', () => {
|
|
expect(hasUsableLLMProvider({ openai: ok })).toBe(true);
|
|
});
|
|
|
|
it('returns true for a server-configured provider without a client key', () => {
|
|
expect(
|
|
hasUsableLLMProvider({
|
|
openai: { isServerConfigured: true, models: [{ id: 'm1' }] },
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('returns false when the only provider has no models', () => {
|
|
expect(hasUsableLLMProvider({ openai: { ...ok, models: [] } })).toBe(false);
|
|
});
|
|
|
|
it('returns false when the only provider lacks credentials and requires a key', () => {
|
|
expect(
|
|
hasUsableLLMProvider({
|
|
openai: { requiresApiKey: true, apiKey: '', models: [{ id: 'm1' }], defaultBaseUrl: 'x' },
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false for an empty or nullish config', () => {
|
|
expect(hasUsableLLMProvider({})).toBe(false);
|
|
expect(hasUsableLLMProvider(undefined)).toBe(false);
|
|
expect(hasUsableLLMProvider(null)).toBe(false);
|
|
});
|
|
|
|
it('treats a keyless provider with only a registry defaultBaseUrl as NOT configured', () => {
|
|
// ollama/lemonade ship requiresApiKey:false + a defaultBaseUrl but an
|
|
// empty user baseUrl by default — must NOT count as usable, otherwise
|
|
// the page gate is true while reconcile never selects it (#580 keyless).
|
|
expect(
|
|
hasUsableLLMProvider({
|
|
ollama: {
|
|
requiresApiKey: false,
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
models: [{ id: 'llama3.3' }],
|
|
defaultBaseUrl: 'http://localhost:11434/v1',
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('treats a keyless provider as configured once the user sets an explicit baseUrl', () => {
|
|
expect(
|
|
hasUsableLLMProvider({
|
|
ollama: {
|
|
requiresApiKey: false,
|
|
apiKey: '',
|
|
baseUrl: 'http://my-ollama:11434/v1',
|
|
models: [{ id: 'llama3.3' }],
|
|
defaultBaseUrl: 'http://localhost:11434/v1',
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('treats a keyless provider as configured when server-configured', () => {
|
|
expect(
|
|
hasUsableLLMProvider({
|
|
ollama: {
|
|
requiresApiKey: false,
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
isServerConfigured: true,
|
|
models: [{ id: 'llama3.3' }],
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isLLMProviderConfigured', () => {
|
|
// Regression: a freshly-applied token-plan provider may have no `models`
|
|
// field yet (probe populates it later). The validator must not throw.
|
|
it('returns false (not throw) when models is undefined', () => {
|
|
expect(() =>
|
|
isLLMProviderConfigured({ apiKey: 'sk-x', baseUrl: 'https://b/v1' } as never),
|
|
).not.toThrow();
|
|
expect(isLLMProviderConfigured({ apiKey: 'sk-x', baseUrl: 'https://b/v1' } as never)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('returns true when key + baseUrl + ≥1 model', () => {
|
|
expect(
|
|
isLLMProviderConfigured({
|
|
apiKey: 'sk-x',
|
|
baseUrl: 'https://b/v1',
|
|
requiresApiKey: true,
|
|
models: [{ id: 'm1' }],
|
|
} as never),
|
|
).toBe(true);
|
|
});
|
|
});
|