import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BARE_MODEL_ID_DEPRECATION_MSG } from '@/lib/ai/providers'; // config-validation reads env directly and imports provider-config, whose // module-level getConfig() cache must be reset between cases (mirrors the // model-routes / provider-config conventions). fs is mocked so a host-machine // server-providers.yml can never leak into tests. let yamlOverride: string | null = null; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); 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[]) => // eslint-disable-next-line @typescript-eslint/no-explicit-any isYaml(p) ? (yamlOverride ?? '') : (actual.readFileSync as any)(p, ...args), }, existsSync: (p: string) => (isYaml(p) ? yamlOverride !== null : actual.existsSync(p)), readFileSync: (p: string, ...args: unknown[]) => // eslint-disable-next-line @typescript-eslint/no-explicit-any isYaml(p) ? (yamlOverride ?? '') : (actual.readFileSync as any)(p, ...args), }; }); /** LLM env prefixes that provider-config reads for the `providers` section. */ 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; 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'); // Unrouted sites honor client-supplied keys, so the message must not claim requests will fail. expect(String(warnSpy.mock.calls[0][0])).toContain('client supplies its own key'); }); it('warns when _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 _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 _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 _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(); // Ollama ships a default base URL, so a bare _MODELS pin is functional — no warning. 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', // unknown stage 'scene-content': 'anhtropic:claude-sonnet-4', // unknown provider 'pbl-chat': 'claude-sonnet-4', // bare id }), ); 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 () => { // This is the no-DB default deployment: flag unset, no database. It must // boot silently — the warning is for the MISCONFIGURED state only. 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(); } }); });