import { describe, expect, it, vi, beforeEach } from 'vitest'; // Mock fs — only intercept server-providers.yml; delegate everything else to real fs. // This prevents YAML config from leaking host-machine state into tests while keeping // the mock scoped to what provider-config actually reads. let yamlOverride: string | null = null; const ENV_PREFIXES_TO_CLEAR = [ 'OPENAI', 'ANTHROPIC', 'GOOGLE', 'DEEPSEEK', 'QWEN', 'KIMI', 'MINIMAX', 'GLM', 'SILICONFLOW', 'DOUBAO', 'OPENROUTER', 'GROK', 'TENCENT', 'TENCENT_HUNYUAN', 'XIAOMI', 'MIMO', 'HY3', 'OLLAMA', 'TTS_OPENAI', 'TTS_AZURE', 'TTS_GLM', 'TTS_QWEN', 'TTS_DOUBAO', 'TTS_ELEVENLABS', 'TTS_MINIMAX', 'ASR_OPENAI', 'ASR_QWEN', 'PDF_UNPDF', 'PDF_MINERU', 'PDF_MINERU_CLOUD', 'IMAGE_OPENAI', 'IMAGE_SEEDREAM', 'IMAGE_QWEN_IMAGE', 'IMAGE_NANO_BANANA', 'IMAGE_MINIMAX', 'IMAGE_GROK', 'VIDEO_SEEDANCE', 'VIDEO_KLING', 'VIDEO_VEO', 'VIDEO_SORA', 'VIDEO_MINIMAX', 'VIDEO_GROK', 'BOCHA', 'WEB_SEARCH_MINIMAX', ]; function clearProviderEnv() { for (const prefix of ENV_PREFIXES_TO_CLEAR) { delete process.env[`${prefix}_API_KEY`]; delete process.env[`${prefix}_BASE_URL`]; delete process.env[`${prefix}_MODELS`]; } delete process.env.TAVILY_API_KEY; delete process.env.BOCHA_API_KEY; delete process.env.BOCHA_BASE_URL; } 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), }; }); describe('provider-config', () => { beforeEach(() => { vi.resetModules(); vi.unstubAllEnvs(); clearProviderEnv(); yamlOverride = null; }); describe('resolveApiKey', () => { it('returns client key when provided', async () => { const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('openai', 'sk-client')).toBe('sk-client'); }); it('returns server key from env when no client key', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-server'); const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('openai')).toBe('sk-server'); }); it('returns empty string when neither client nor server key exists', async () => { const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('openai')).toBe(''); }); it('ignores client key for a server-managed provider (server is authoritative)', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-server'); const { resolveApiKey } = await import('@/lib/server/provider-config'); // openai is server-configured ⇒ managed ⇒ client override is ignored. expect(resolveApiKey('openai', 'sk-client')).toBe('sk-server'); }); it('uses the client key for an unmanaged provider', async () => { // No env key for openai ⇒ not managed ⇒ client key flows through. const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('openai', 'sk-client')).toBe('sk-client'); }); it('resolves non-OpenAI providers via their env prefix', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-anthropic'); const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('anthropic')).toBe('sk-anthropic'); }); it('returns empty string for unknown provider with no env var', async () => { const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('nonexistent-provider')).toBe(''); }); }); describe('getParallelSceneConcurrency', () => { beforeEach(() => { delete process.env.PARALLEL_SCENE_CONCURRENCY; }); it('defaults to 0 (serial) when unset', async () => { const { getParallelSceneConcurrency } = await import('@/lib/server/provider-config'); expect(getParallelSceneConcurrency()).toBe(0); }); it('reads a positive integer from the env var', async () => { vi.stubEnv('PARALLEL_SCENE_CONCURRENCY', '3'); const { getParallelSceneConcurrency } = await import('@/lib/server/provider-config'); expect(getParallelSceneConcurrency()).toBe(3); }); it('clamps to a maximum of 10', async () => { vi.stubEnv('PARALLEL_SCENE_CONCURRENCY', '50'); const { getParallelSceneConcurrency } = await import('@/lib/server/provider-config'); expect(getParallelSceneConcurrency()).toBe(10); }); it('treats zero, negative, and non-numeric values as off', async () => { for (const value of ['0', '-2', 'abc']) { vi.resetModules(); vi.stubEnv('PARALLEL_SCENE_CONCURRENCY', value); const { getParallelSceneConcurrency } = await import('@/lib/server/provider-config'); expect(getParallelSceneConcurrency(), `value=${value}`).toBe(0); } }); }); describe('resolveBaseUrl', () => { it('returns client URL for an unmanaged provider', async () => { const { resolveBaseUrl } = await import('@/lib/server/provider-config'); expect(resolveBaseUrl('openai', 'https://custom.api.com')).toBe('https://custom.api.com'); }); it('ignores client URL for a server-managed provider', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-server'); vi.stubEnv('OPENAI_BASE_URL', 'https://proxy.example.com/v1'); const { resolveBaseUrl } = await import('@/lib/server/provider-config'); // Managed ⇒ server URL wins, client override is dropped. expect(resolveBaseUrl('openai', 'https://client.example.com')).toBe( 'https://proxy.example.com/v1', ); }); it('returns server URL from env when no client URL', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-test'); vi.stubEnv('OPENAI_BASE_URL', 'https://proxy.example.com/v1'); const { resolveBaseUrl } = await import('@/lib/server/provider-config'); expect(resolveBaseUrl('openai')).toBe('https://proxy.example.com/v1'); }); it('returns undefined when neither client nor server URL exists', async () => { const { resolveBaseUrl } = await import('@/lib/server/provider-config'); expect(resolveBaseUrl('openai')).toBeUndefined(); }); }); describe('resolveProxy', () => { it('returns undefined when no proxy configured', async () => { const { resolveProxy } = await import('@/lib/server/provider-config'); expect(resolveProxy('openai')).toBeUndefined(); }); it('returns proxy URL from YAML config', async () => { yamlOverride = ` providers: openai: apiKey: sk-yaml proxy: http://proxy.internal:8080 `; const { resolveProxy } = await import('@/lib/server/provider-config'); expect(resolveProxy('openai')).toBe('http://proxy.internal:8080'); }); }); describe('getServerProviders', () => { it('returns empty object when no providers configured', async () => { const { getServerProviders } = await import('@/lib/server/provider-config'); expect(getServerProviders()).toEqual({}); }); it('returns allowed models but never the API key or base URL', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-secret'); vi.stubEnv('OPENAI_BASE_URL', 'https://proxy.com/v1'); vi.stubEnv('OPENAI_MODELS', 'gpt-4o,gpt-4o-mini'); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.openai).toBeDefined(); expect(providers.openai.models).toEqual(['gpt-4o', 'gpt-4o-mini']); // Neither the API key nor the base URL may leak to the client. expect((providers.openai as Record).apiKey).toBeUndefined(); expect((providers.openai as Record).baseUrl).toBeUndefined(); }); it('lists multiple providers', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-openai'); vi.stubEnv('ANTHROPIC_API_KEY', 'sk-anthropic'); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(Object.keys(providers)).toContain('openai'); expect(Object.keys(providers)).toContain('anthropic'); }); it('maps OpenRouter env prefix to provider ID', async () => { vi.stubEnv('OPENROUTER_API_KEY', 'sk-openrouter'); vi.stubEnv('OPENROUTER_MODELS', 'deepseek/deepseek-v4-pro,deepseek/deepseek-v4-flash'); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.openrouter.models).toEqual([ 'deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-flash', ]); }); it('maps Tencent Hunyuan and Xiaomi MiMo env prefixes to provider IDs', async () => { vi.stubEnv('TENCENT_HUNYUAN_API_KEY', 'sk-tencent'); vi.stubEnv('TENCENT_HUNYUAN_MODELS', 'hy3-preview,hunyuan-2.0-instruct-20251111'); vi.stubEnv('MIMO_API_KEY', 'sk-mimo'); vi.stubEnv('MIMO_MODELS', 'mimo-v2.5-pro'); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers['tencent-hunyuan'].models).toEqual([ 'hy3-preview', 'hunyuan-2.0-instruct-20251111', ]); expect(providers.xiaomi.models).toEqual(['mimo-v2.5-pro']); }); it('does not treat HY3 as an env prefix', async () => { vi.stubEnv('HY3_API_KEY', 'sk-hy3'); vi.stubEnv('HY3_MODELS', 'hy3-preview'); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers['tencent-hunyuan']).toBeUndefined(); }); it('omits providers without API key', async () => { vi.stubEnv('OPENAI_BASE_URL', 'https://proxy.com/v1'); // No OPENAI_API_KEY set const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.openai).toBeUndefined(); }); }); describe('env var model parsing', () => { it('splits comma-separated models and trims whitespace', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-test'); vi.stubEnv('OPENAI_MODELS', ' gpt-4o , gpt-4o-mini , '); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.openai.models).toEqual(['gpt-4o', 'gpt-4o-mini']); }); }); describe('resolveWebSearchApiKey', () => { it('returns client key first', async () => { const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); expect(resolveWebSearchApiKey('client-key')).toBe('client-key'); }); it('falls back to TAVILY_API_KEY env var', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly-bare-env'); const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); expect(resolveWebSearchApiKey()).toBe('tvly-bare-env'); }); it('resolves Bocha API key and base URL from env vars (managed flag only, no URL exposed)', async () => { vi.stubEnv('BOCHA_API_KEY', 'bocha-env-key'); vi.stubEnv('BOCHA_BASE_URL', 'https://proxy.example.com/bocha'); const { getServerWebSearchProviders, resolveWebSearchApiKey, resolveWebSearchBaseUrl } = await import('@/lib/server/provider-config'); expect(resolveWebSearchApiKey('bocha', undefined)).toBe('bocha-env-key'); expect(resolveWebSearchBaseUrl('bocha')).toBe('https://proxy.example.com/bocha'); // The map exposes only the managed flag (presence) — not the base URL. expect(getServerWebSearchProviders().bocha).toEqual({}); }); it('ignores client key and base URL for a server-managed Bocha provider', async () => { vi.stubEnv('BOCHA_API_KEY', 'bocha-env-key'); vi.stubEnv('BOCHA_BASE_URL', 'https://proxy.example.com/bocha'); const { resolveWebSearchApiKey, resolveWebSearchBaseUrl } = await import('@/lib/server/provider-config'); // Managed ⇒ server config is authoritative, client overrides dropped. expect(resolveWebSearchApiKey('bocha', 'bocha-client-key')).toBe('bocha-env-key'); expect(resolveWebSearchBaseUrl('bocha', 'https://client.example.com')).toBe( 'https://proxy.example.com/bocha', ); }); it('resolves MiniMax web search API key and base URL from dedicated env vars', async () => { vi.stubEnv('WEB_SEARCH_MINIMAX_API_KEY', 'minimax-env-key'); vi.stubEnv('WEB_SEARCH_MINIMAX_BASE_URL', 'https://proxy.example.com/minimax'); const { getServerWebSearchProviders, resolveWebSearchApiKey, resolveWebSearchBaseUrl } = await import('@/lib/server/provider-config'); expect(resolveWebSearchApiKey('minimax', undefined)).toBe('minimax-env-key'); expect(resolveWebSearchBaseUrl('minimax')).toBe('https://proxy.example.com/minimax'); expect(getServerWebSearchProviders().minimax).toEqual({}); }); }); describe('baseUrl-only providers (e.g. mineru)', () => { it('includes PDF provider from YAML when only baseUrl is configured (no apiKey)', async () => { yamlOverride = ` pdf: mineru: baseUrl: http://localhost:8888 `; const { getServerPDFProviders, resolvePDFBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerPDFProviders(); expect(providers.mineru).toBeDefined(); expect(resolvePDFBaseUrl('mineru')).toBe('http://localhost:8888'); }); it('includes provider from env when only BASE_URL is set (no API_KEY)', async () => { vi.stubEnv('PDF_MINERU_BASE_URL', 'http://localhost:8888'); const { getServerPDFProviders, resolvePDFBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerPDFProviders(); expect(providers.mineru).toBeDefined(); expect(resolvePDFBaseUrl('mineru')).toBe('http://localhost:8888'); }); it('excludes PDF provider when only apiKey is configured (no baseUrl)', async () => { yamlOverride = ` pdf: mineru: apiKey: sk-fake `; const { getServerPDFProviders } = await import('@/lib/server/provider-config'); const providers = getServerPDFProviders(); expect(providers.mineru).toBeUndefined(); }); it('includes MinerU Cloud from env when only API key is configured', async () => { vi.stubEnv('PDF_MINERU_CLOUD_API_KEY', 'mineru-cloud-key'); const { getServerPDFProviders, resolvePDFApiKey, resolvePDFBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerPDFProviders(); expect(providers['mineru-cloud']).toBeDefined(); expect(resolvePDFApiKey('mineru-cloud')).toBe('mineru-cloud-key'); expect(resolvePDFBaseUrl('mineru-cloud')).toBeUndefined(); }); it('includes MinerU Cloud from YAML when only API key is configured', async () => { yamlOverride = ` pdf: mineru-cloud: apiKey: mineru-cloud-yaml-key `; const { getServerPDFProviders, resolvePDFApiKey, resolvePDFBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerPDFProviders(); expect(providers['mineru-cloud']).toBeDefined(); expect(resolvePDFApiKey('mineru-cloud')).toBe('mineru-cloud-yaml-key'); expect(resolvePDFBaseUrl('mineru-cloud')).toBeUndefined(); }); }); describe('image and video provider metadata', () => { it('uses standard OpenAI env vars for OpenAI image generation fallback', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-openai'); vi.stubEnv('OPENAI_BASE_URL', 'https://proxy.example.com/v1'); const { getServerImageProviders, resolveImageApiKey, resolveImageBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerImageProviders(); // No base URL exposed; resolution still works server-side. expect(providers['openai-image']).toEqual({}); expect(resolveImageApiKey('openai-image')).toBe('sk-openai'); expect(resolveImageBaseUrl('openai-image')).toBe('https://proxy.example.com/v1'); }); it('maps IMAGE_OPENAI and exposes image baseUrl', async () => { vi.stubEnv('IMAGE_OPENAI_API_KEY', 'sk-openai-image'); vi.stubEnv('IMAGE_OPENAI_BASE_URL', 'https://proxy.example.com/v1'); const { getServerImageProviders, resolveImageBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerImageProviders(); expect(providers['openai-image']).toEqual({}); expect(resolveImageBaseUrl('openai-image')).toBe('https://proxy.example.com/v1'); }); it('exposes video provider baseUrl', async () => { vi.stubEnv('VIDEO_GROK_API_KEY', 'xai-secret'); vi.stubEnv('VIDEO_GROK_BASE_URL', 'https://proxy.example.com/video'); const { getServerVideoProviders, resolveVideoBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerVideoProviders(); expect(providers['grok-video']).toEqual({}); expect(resolveVideoBaseUrl('grok-video')).toBe('https://proxy.example.com/video'); }); }); describe('isServerConfiguredProvider', () => { it('is true only for operator-configured providers, per section', async () => { vi.stubEnv('OPENAI_API_KEY', 'sk-openai'); vi.stubEnv('VIDEO_GROK_API_KEY', 'xai-secret'); const { isServerConfiguredProvider } = await import('@/lib/server/provider-config'); expect(isServerConfiguredProvider('providers', 'openai')).toBe(true); expect(isServerConfiguredProvider('providers', 'anthropic')).toBe(false); expect(isServerConfiguredProvider('video', 'grok-video')).toBe(true); // section-scoped: an LLM provider id is not a video provider expect(isServerConfiguredProvider('video', 'openai')).toBe(false); }); }); describe('getServerTTSProviders force-disable (#665)', () => { it('reports nothing when no TTS provider is configured or disabled', async () => { const { getServerTTSProviders } = await import('@/lib/server/provider-config'); expect(getServerTTSProviders()).toEqual({}); }); it('marks an env-configured TTS provider as managed (no disabled flag)', async () => { vi.stubEnv('TTS_OPENAI_API_KEY', 'sk-tts'); const { getServerTTSProviders } = await import('@/lib/server/provider-config'); expect(getServerTTSProviders()['openai-tts']).toEqual({}); }); it('force-disables a provider via TTS_

_ENABLED=false even when it has a key', async () => { vi.stubEnv('TTS_OPENAI_API_KEY', 'sk-tts'); vi.stubEnv('TTS_OPENAI_ENABLED', 'false'); const { getServerTTSProviders } = await import('@/lib/server/provider-config'); expect(getServerTTSProviders()['openai-tts']).toEqual({ disabled: true }); }); it('force-disables browser-native via env (it is client-only, has no key)', async () => { vi.stubEnv('TTS_BROWSER_NATIVE_ENABLED', 'false'); const { getServerTTSProviders } = await import('@/lib/server/provider-config'); expect(getServerTTSProviders()['browser-native-tts']).toEqual({ disabled: true }); }); it('force-disables a provider via YAML tts..enabled: false', async () => { yamlOverride = 'tts:\n voxcpm-tts:\n enabled: false\n'; const { getServerTTSProviders } = await import('@/lib/server/provider-config'); expect(getServerTTSProviders()['voxcpm-tts']).toEqual({ disabled: true }); }); it('env ENABLED=true overrides a YAML disable', async () => { yamlOverride = 'tts:\n openai-tts:\n enabled: false\n apiKey: sk-yaml\n'; vi.stubEnv('TTS_OPENAI_ENABLED', 'true'); const { getServerTTSProviders } = await import('@/lib/server/provider-config'); // Re-enabled by env, and configured via YAML key ⇒ managed, not disabled. expect(getServerTTSProviders()['openai-tts']).toEqual({}); }); it('an empty TTS_

_ENABLED does NOT override a YAML disable', async () => { yamlOverride = 'tts:\n openai-tts:\n enabled: false\n apiKey: sk-yaml\n'; vi.stubEnv('TTS_OPENAI_ENABLED', ''); const { getServerTTSProviders } = await import('@/lib/server/provider-config'); expect(getServerTTSProviders()['openai-tts']).toEqual({ disabled: true }); }); it('isServerTTSProviderDisabled reflects the force-disable set', async () => { vi.stubEnv('TTS_OPENAI_API_KEY', 'sk-tts'); vi.stubEnv('TTS_OPENAI_ENABLED', 'false'); const { isServerTTSProviderDisabled } = await import('@/lib/server/provider-config'); expect(isServerTTSProviderDisabled('openai-tts')).toBe(true); expect(isServerTTSProviderDisabled('qwen-tts')).toBe(false); }); }); });