import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BARE_MODEL_ID_DEPRECATION_MSG } from '@/lib/ai/providers';
let yamlOverride: string | null = null;
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const isYaml = (p: unknown) => typeof p === 'string' && p.endsWith('server-providers.yml');
return {
...actual,
default: {
...actual,
existsSync: (p: string) => (isYaml(p) ? yamlOverride !== null : actual.existsSync(p)),
readFileSync: (p: string, ...args: unknown[]) =>
isYaml(p) ? (yamlOverride ?? '') : (actual.readFileSync as any)(p, ...args),
},
existsSync: (p: string) => (isYaml(p) ? yamlOverride !== null : actual.existsSync(p)),
readFileSync: (p: string, ...args: unknown[]) =>
isYaml(p) ? (yamlOverride ?? '') : (actual.readFileSync as any)(p, ...args),
};
});
const LLM_ENV_PREFIXES = [
'OPENAI',
'AZURE_OPENAI',
'ATLASCLOUD',
'ANTHROPIC',
'GOOGLE',
'DEEPSEEK',
'QWEN',
'KIMI',
'MINIMAX',
'GLM',
'SILICONFLOW',
'DOUBAO',
'OPENROUTER',
'GROK',
'TENCENT',
'TENCENT_HUNYUAN',
'XIAOMI',
'MIMO',
'OLLAMA',
'LEMONADE',
'BEDROCK',
];
function clearConfigEnv() {
delete process.env.MODEL_ROUTES;
delete process.env.DEFAULT_MODEL;
delete process.env.OPENMAIC_AGENT_RUNTIME_ENABLED;
delete process.env.DATABASE_URL;
for (const prefix of LLM_ENV_PREFIXES) {
delete process.env[`${prefix}_API_KEY`];
delete process.env[`${prefix}_BASE_URL`];
delete process.env[`${prefix}_MODELS`];
}
delete process.env.BEDROCK_REGION;
}
describe('validateServerConfig — warning matrix', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
clearConfigEnv();
yamlOverride = null;
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
clearConfigEnv();
});
it('emits no warnings when nothing is configured', async () => {
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('emits no warnings on a fully valid config', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-test');
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-test');
vi.stubEnv('DEFAULT_MODEL', 'openai:gpt-5.4-mini');
vi.stubEnv(
'MODEL_ROUTES',
JSON.stringify({
'scene-content': 'openai:gpt-5.4',
'pbl-chat': { model: 'anthropic:claude-sonnet-4', thinking: { enabled: false } },
}),
);
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns when MODEL_ROUTES is not valid JSON', async () => {
vi.stubEnv('MODEL_ROUTES', '{not valid json');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('MODEL_ROUTES');
expect(String(warnSpy.mock.calls[0][0])).toContain('JSON');
});
it('warns naming an unknown stage key (typo detection)', async () => {
vi.stubEnv('MODEL_ROUTES', JSON.stringify({ 'scene-contnet': 'openai:gpt-5.4' }));
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('scene-contnet');
});
it('warns for an unknown provider prefix in a route', async () => {
vi.stubEnv('MODEL_ROUTES', JSON.stringify({ 'scene-content': 'anhtropic:claude-sonnet-4' }));
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('anhtropic');
});
it('warns when a routed provider has no API key configured', async () => {
vi.stubEnv('MODEL_ROUTES', JSON.stringify({ 'scene-content': 'deepseek:deepseek-v4-pro' }));
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('deepseek');
expect(String(warnSpy.mock.calls[0][0])).toContain('API key');
});
it('does not warn about a keyless provider (ollama)', async () => {
vi.stubEnv('MODEL_ROUTES', JSON.stringify({ 'scene-content': 'ollama:llama3.3' }));
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('emits the deprecation warning for a bare model id in a route', async () => {
vi.stubEnv('MODEL_ROUTES', JSON.stringify({ 'scene-content': 'gpt-5.4' }));
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain(BARE_MODEL_ID_DEPRECATION_MSG);
});
it('emits the deprecation warning for a bare DEFAULT_MODEL', async () => {
vi.stubEnv('DEFAULT_MODEL', 'gpt-5.4');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain(BARE_MODEL_ID_DEPRECATION_MSG);
});
it('warns softly when DEFAULT_MODEL points at a provider with no server key (client keys still work)', async () => {
vi.stubEnv('DEFAULT_MODEL', 'deepseek:deepseek-v4-pro');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('deepseek');
expect(String(warnSpy.mock.calls[0][0])).toContain('client supplies its own key');
});
it('warns when <PREFIX>_MODELS is set without the provider key env', async () => {
vi.stubEnv('DEEPSEEK_MODELS', 'deepseek-v4-pro');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('DEEPSEEK_MODELS');
expect(String(warnSpy.mock.calls[0][0])).toContain('DEEPSEEK_API_KEY');
});
it('does not warn when <PREFIX>_MODELS is set with the key present', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'sk-test');
vi.stubEnv('DEEPSEEK_MODELS', 'deepseek-v4-pro');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('does not warn when <PREFIX>_MODELS is set for a keyless provider with a base URL', async () => {
vi.stubEnv('OLLAMA_BASE_URL', 'http://localhost:11434/v1');
vi.stubEnv('OLLAMA_MODELS', 'llama3.3');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns when <PREFIX>_MODELS is set for a keyless provider without a base URL', async () => {
vi.stubEnv('OLLAMA_MODELS', 'llama3.3');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('does not throw on garbage config (warn-only)', async () => {
vi.stubEnv('MODEL_ROUTES', '{{{');
vi.stubEnv('DEFAULT_MODEL', ':::::');
const { validateServerConfig } = await import('@/lib/server/config-validation');
expect(() => validateServerConfig()).not.toThrow();
});
it('does not warn when a provider is configured via server-providers.yml', async () => {
yamlOverride = 'providers:\n deepseek:\n apiKey: sk-yaml\n';
vi.stubEnv('MODEL_ROUTES', JSON.stringify({ 'scene-content': 'deepseek:deepseek-v4-pro' }));
vi.stubEnv('DEEPSEEK_MODELS', 'deepseek-v4-pro');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns once per distinct problem when a config has several', async () => {
vi.stubEnv(
'MODEL_ROUTES',
JSON.stringify({
'scene-contnet': 'openai:gpt-5.4',
'scene-content': 'anhtropic:claude-sonnet-4',
'pbl-chat': 'claude-sonnet-4',
}),
);
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(3);
});
describe('agent runtime configuration', () => {
it('warns when the runtime flag is set without DATABASE_URL', async () => {
vi.stubEnv('OPENMAIC_AGENT_RUNTIME_ENABLED', 'true');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
const message = String(warnSpy.mock.calls[0][0]);
expect(message).toContain('OPENMAIC_AGENT_RUNTIME_ENABLED');
expect(message).toContain('DATABASE_URL');
});
it('warns when the runtime flag is set and DATABASE_URL is blank', async () => {
vi.stubEnv('OPENMAIC_AGENT_RUNTIME_ENABLED', 'true');
vi.stubEnv('DATABASE_URL', ' ');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('DATABASE_URL');
});
it('does not warn when the runtime flag is set with DATABASE_URL present', async () => {
vi.stubEnv('OPENMAIC_AGENT_RUNTIME_ENABLED', 'true');
vi.stubEnv('DATABASE_URL', 'postgres://runtime');
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
it('does not warn when the runtime flag is off even without DATABASE_URL', async () => {
const { validateServerConfig } = await import('@/lib/server/config-validation');
validateServerConfig();
expect(warnSpy).not.toHaveBeenCalled();
});
});
});
describe('parseModelString — request-derived strings never warn', () => {
it('resolves bare and prefixed ids without logging (client input must not drive log volume)', async () => {
vi.resetModules();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { parseModelString } = await import('@/lib/ai/providers');
expect(parseModelString('gpt-5.5')).toEqual({ providerId: 'openai', modelId: 'gpt-5.5' });
expect(parseModelString('anthropic:claude-sonnet-4')).toEqual({
providerId: 'anthropic',
modelId: 'claude-sonnet-4',
});
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
it('warnBareModelIdDeprecation dedupes per unique config-site id', async () => {
vi.resetModules();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { warnBareModelIdDeprecation } = await import('@/lib/ai/providers');
expect(warnBareModelIdDeprecation('gpt-5.5', 'DEFAULT_MODEL')).toBe(true);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain(BARE_MODEL_ID_DEPRECATION_MSG);
expect(warnBareModelIdDeprecation('gpt-5.5', 'DEFAULT_MODEL')).toBe(false);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnBareModelIdDeprecation('gpt-4.1', 'MODEL_ROUTES stage "pbl-chat"')).toBe(true);
expect(warnSpy).toHaveBeenCalledTimes(2);
} finally {
warnSpy.mockRestore();
}
});
});