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', 'AZURE_OPENAI', 'ATLASCLOUD', 'ANTHROPIC', 'GOOGLE', 'DEEPSEEK', 'QWEN', 'KIMI', 'MINIMAX', 'GLM', 'SILICONFLOW', 'DOUBAO', 'OPENROUTER', 'GROK', 'TENCENT', 'TENCENT_HUNYUAN', 'XIAOMI', 'MIMO', 'HY3', 'OLLAMA', 'BEDROCK', 'TTS_OPENAI', 'TTS_AZURE', 'TTS_GLM', 'TTS_QWEN', 'TTS_DOUBAO', 'TTS_ELEVENLABS', 'TTS_MINIMAX', 'TTS_VOXCPM', 'ASR_OPENAI', 'ASR_QWEN', 'ASR_FUNASR', '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', 'WEB_SEARCH_CLAUDE', 'WEB_SEARCH_DOUBAO', ]; 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[`${prefix}_ENABLED`]; } delete process.env.TAVILY_API_KEY; delete process.env.BOCHA_API_KEY; delete process.env.BOCHA_BASE_URL; delete process.env.ALIDOCMIND_ACCESS_KEY_ID; delete process.env.ALIDOCMIND_ACCESS_KEY_SECRET; delete process.env.ALIDOCMIND_BASE_URL; delete process.env.BEDROCK_REGION; delete process.env.AWS_BEARER_TOKEN_BEDROCK; delete process.env.TTS_QWEN_VOICE_CLONE_MODEL; } 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('resolves Azure OpenAI via its dedicated env prefix', async () => { vi.stubEnv('AZURE_OPENAI_API_KEY', 'azure-key'); const { resolveApiKey } = await import('@/lib/server/provider-config'); expect(resolveApiKey('azure')).toBe('azure-key'); }); 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 Azure deployment names to the built-in provider', async () => { vi.stubEnv('AZURE_OPENAI_API_KEY', 'azure-key'); vi.stubEnv('AZURE_OPENAI_BASE_URL', 'https://test-resource.openai.azure.com/openai'); vi.stubEnv('AZURE_OPENAI_MODELS', 'course-gpt-4o,course-gpt-5'); const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.azure.models).toEqual(['course-gpt-4o', 'course-gpt-5']); }); it('maps Atlas Cloud env vars to the built-in OpenAI-compatible provider', async () => { vi.stubEnv('ATLASCLOUD_API_KEY', 'sk-atlas'); vi.stubEnv('ATLASCLOUD_BASE_URL', 'https://api.atlascloud.ai/v1'); vi.stubEnv('ATLASCLOUD_MODELS', 'qwen/qwen3.5-flash,deepseek-ai/deepseek-v4-pro'); const { getServerProviders, resolveBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.atlascloud.models).toEqual([ 'qwen/qwen3.5-flash', 'deepseek-ai/deepseek-v4-pro', ]); expect(resolveBaseUrl('atlascloud')).toBe('https://api.atlascloud.ai/v1'); expect((providers.atlascloud as Record).apiKey).toBeUndefined(); expect((providers.atlascloud as Record).baseUrl).toBeUndefined(); }); 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(); }); it('includes Bedrock from env without an API key', async () => { vi.stubEnv('BEDROCK_REGION', 'us-east-1'); vi.stubEnv('BEDROCK_MODELS', ' us.anthropic.claude-sonnet-5 , us.anthropic.claude-opus-4-8 '); const { getServerProviders, resolveApiKey, resolveBaseUrl } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.bedrock).toEqual({ models: ['us.anthropic.claude-sonnet-5', 'us.anthropic.claude-opus-4-8'], }); expect(resolveApiKey('bedrock')).toBe(''); expect(resolveBaseUrl('bedrock')).toBeUndefined(); }); it('does not enable Bedrock for whitespace-only region and models', async () => { vi.stubEnv('BEDROCK_REGION', ' '); vi.stubEnv('BEDROCK_MODELS', ' , '); const { getServerProviders } = await import('@/lib/server/provider-config'); expect(getServerProviders().bedrock).toBeUndefined(); }); it('includes Bedrock from YAML with only models configured', async () => { yamlOverride = ` providers: bedrock: models: - us.anthropic.claude-sonnet-5 - us.anthropic.claude-opus-4-8 `; const { getServerProviders } = await import('@/lib/server/provider-config'); const providers = getServerProviders(); expect(providers.bedrock.models).toEqual([ 'us.anthropic.claude-sonnet-5', 'us.anthropic.claude-opus-4-8', ]); }); }); 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({}); }); it('resolves Claude web search key, base URL, and pinned model from dedicated env vars', async () => { vi.stubEnv('WEB_SEARCH_CLAUDE_API_KEY', 'claude-env-key'); vi.stubEnv('WEB_SEARCH_CLAUDE_BASE_URL', 'https://proxy.example.com/anthropic'); vi.stubEnv('WEB_SEARCH_CLAUDE_MODELS', 'claude-sonnet-5,claude-opus-5'); const { getServerWebSearchProviders, resolveWebSearchApiKey, resolveWebSearchBaseUrl, resolveWebSearchModel, } = await import('@/lib/server/provider-config'); expect(resolveWebSearchApiKey('claude', undefined)).toBe('claude-env-key'); expect(resolveWebSearchBaseUrl('claude')).toBe('https://proxy.example.com/anthropic'); // Server-pinned model (first entry) is authoritative over the client model. expect(resolveWebSearchModel('claude', 'claude-haiku-4-5')).toBe('claude-sonnet-5'); expect(getServerWebSearchProviders().claude).toEqual({}); }); it('lets the client model win when no Claude model is pinned server-side', async () => { const { resolveWebSearchModel } = await import('@/lib/server/provider-config'); expect(resolveWebSearchModel('claude', 'claude-opus-5')).toBe('claude-opus-5'); expect(resolveWebSearchModel('claude')).toBeUndefined(); }); }); 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'); }); it('activates keyless image providers (lemonade) from a base URL alone', async () => { vi.stubEnv('IMAGE_LEMONADE_BASE_URL', 'http://localhost:13305/v1'); const { getServerImageProviders, resolveImageApiKey, isServerConfiguredProvider } = await import('@/lib/server/provider-config'); expect(isServerConfiguredProvider('image', 'lemonade')).toBe(true); expect(getServerImageProviders().lemonade).toBeDefined(); expect(resolveImageApiKey('lemonade')).toBe(''); }); }); describe('media model resolution', () => { it('allowlists the client image model against IMAGE__MODELS', async () => { vi.stubEnv('IMAGE_SEEDREAM_API_KEY', 'sk-seedream'); vi.stubEnv('IMAGE_SEEDREAM_MODELS', 'model-a,model-b'); const { resolveImageModel } = await import('@/lib/server/provider-config'); // Allowlisted client choice wins over the managed default. expect(resolveImageModel('seedream', 'model-b')).toBe('model-b'); // Non-allowlisted client choice falls back to the managed default. expect(resolveImageModel('seedream', 'client-model')).toBe('model-a'); expect(resolveImageModel('seedream')).toBe('model-a'); }); it('lets the client image model win when nothing is pinned server-side', async () => { const { resolveImageModel } = await import('@/lib/server/provider-config'); expect(resolveImageModel('seedream', 'client-model')).toBe('client-model'); }); it('returns undefined for the image model when neither client nor server provides one', async () => { const { resolveImageModel } = await import('@/lib/server/provider-config'); expect(resolveImageModel('seedream')).toBeUndefined(); }); it('resolves the default image provider as the first server-configured one', async () => { vi.stubEnv('IMAGE_SEEDREAM_API_KEY', 'sk-seedream'); vi.stubEnv('IMAGE_GROK_API_KEY', 'sk-grok'); const { resolveServerImageProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerImageProviderId()).toBe('seedream'); }); it('returns undefined for the default image provider when none is configured', async () => { const { resolveServerImageProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerImageProviderId()).toBeUndefined(); }); it('pins the video model from server config and allowlists the client choice', async () => { vi.stubEnv('VIDEO_SEEDANCE_API_KEY', 'sk-seedance'); vi.stubEnv('VIDEO_SEEDANCE_MODELS', 'v1,v2'); const { resolveVideoModel } = await import('@/lib/server/provider-config'); // Allowlisted client choice wins over the managed default. expect(resolveVideoModel('seedance', 'v2')).toBe('v2'); // Non-allowlisted client choice falls back to the managed default. expect(resolveVideoModel('seedance', 'not-allowed')).toBe('v1'); expect(resolveVideoModel('seedance')).toBe('v1'); }); it('lets the client video model win when nothing is pinned server-side', async () => { const { resolveVideoModel } = await import('@/lib/server/provider-config'); expect(resolveVideoModel('seedance', 'client-model')).toBe('client-model'); }); it('returns undefined for the video model when neither client nor server provides one', async () => { const { resolveVideoModel } = await import('@/lib/server/provider-config'); expect(resolveVideoModel('seedance')).toBeUndefined(); }); it('normalizes YAML model lists like env (trim + drop empties, never a garbage pin)', async () => { yamlOverride = ` video: seedance: apiKey: sk-yaml-seedance models: - " doubao-seedance-2-0-260128 " - "" - " " kling: apiKey: sk-yaml-kling models: - "" `; const { resolveVideoModel } = await import('@/lib/server/provider-config'); // Whitespace-trimmed real entries survive; empty entries are dropped. expect(resolveVideoModel('seedance')).toBe('doubao-seedance-2-0-260128'); // The stored list is trimmed, so an exact-match client choice is allowlisted. expect(resolveVideoModel('seedance', 'doubao-seedance-2-0-260128')).toBe( 'doubao-seedance-2-0-260128', ); // A garbage-only `models: [""]` list normalizes to no pin at all — it must // never become a truthy pin of "" (the YAML path used to copy it verbatim). expect(resolveVideoModel('kling')).toBeUndefined(); }); it('resolves the default video provider as the first server-configured one', async () => { vi.stubEnv('VIDEO_SEEDANCE_API_KEY', 'sk-seedance'); vi.stubEnv('VIDEO_VEO_API_KEY', 'sk-veo'); const { resolveServerVideoProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerVideoProviderId()).toBe('seedance'); }); it('returns undefined for the default video provider when none is configured', async () => { const { resolveServerVideoProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerVideoProviderId()).toBeUndefined(); }); it('allowlists the client ASR model against ASR__MODELS', async () => { vi.stubEnv('ASR_OPENAI_API_KEY', 'sk-asr'); vi.stubEnv('ASR_OPENAI_MODELS', 'whisper-x,whisper-y'); const { resolveASRModel } = await import('@/lib/server/provider-config'); // Allowlisted client choice wins over the managed default. expect(resolveASRModel('openai-whisper', 'whisper-y')).toBe('whisper-y'); // Non-allowlisted client choice falls back to the managed default. expect(resolveASRModel('openai-whisper', 'client-model')).toBe('whisper-x'); expect(resolveASRModel('openai-whisper')).toBe('whisper-x'); }); it('lets the client ASR model win when nothing is pinned server-side', async () => { const { resolveASRModel } = await import('@/lib/server/provider-config'); expect(resolveASRModel('openai-whisper', 'client-model')).toBe('client-model'); }); it('returns undefined for the ASR model when neither client nor server provides one', async () => { const { resolveASRModel } = await import('@/lib/server/provider-config'); expect(resolveASRModel('openai-whisper')).toBeUndefined(); }); }); 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); }); }); describe('per-capability force-disable (#665)', () => { it('image: marks an env-configured provider as managed (no disabled flag)', async () => { vi.stubEnv('IMAGE_OPENAI_API_KEY', 'sk-img'); const { getServerImageProviders } = await import('@/lib/server/provider-config'); expect(getServerImageProviders()['openai-image']).toEqual({}); }); it('image: force-disables via IMAGE_

_ENABLED=false even when it has a key', async () => { vi.stubEnv('IMAGE_OPENAI_API_KEY', 'sk-img'); vi.stubEnv('IMAGE_OPENAI_ENABLED', 'false'); const { getServerImageProviders } = await import('@/lib/server/provider-config'); expect(getServerImageProviders()['openai-image']).toEqual({ disabled: true }); }); it('image: force-disables the keyless client-only ComfyUI provider via env', async () => { vi.stubEnv('IMAGE_COMFYUI_ENABLED', 'false'); const { getServerImageProviders } = await import('@/lib/server/provider-config'); expect(getServerImageProviders()['comfyui-image']).toEqual({ disabled: true }); }); it('image: an _ENABLED=true value does NOT force-enable an unconfigured keyless provider', async () => { vi.stubEnv('IMAGE_COMFYUI_ENABLED', 'true'); const { getServerImageProviders, resolveServerImageProviderId } = await import('@/lib/server/provider-config'); // ComfyUI has no credential env, so a truthy _ENABLED must not conjure a // configured/enabled entry out of thin air — it can only disable (#665). expect(getServerImageProviders()['comfyui-image']).toBeUndefined(); expect(resolveServerImageProviderId()).toBeUndefined(); }); it('image: force-disables via YAML image..enabled: false', async () => { yamlOverride = 'image:\n seedream:\n enabled: false\n'; const { getServerImageProviders } = await import('@/lib/server/provider-config'); expect(getServerImageProviders()['seedream']).toEqual({ disabled: true }); }); it('image: env ENABLED=true overrides a YAML disable', async () => { yamlOverride = 'image:\n openai-image:\n enabled: false\n apiKey: sk-yaml\n'; vi.stubEnv('IMAGE_OPENAI_ENABLED', 'true'); const { getServerImageProviders } = await import('@/lib/server/provider-config'); expect(getServerImageProviders()['openai-image']).toEqual({}); }); it('image: an empty IMAGE_

_ENABLED does NOT override a YAML disable', async () => { yamlOverride = 'image:\n openai-image:\n enabled: false\n apiKey: sk-yaml\n'; vi.stubEnv('IMAGE_OPENAI_ENABLED', ''); const { getServerImageProviders } = await import('@/lib/server/provider-config'); expect(getServerImageProviders()['openai-image']).toEqual({ disabled: true }); }); it('asr: force-disables a keyed provider via ASR_

_ENABLED=false even when it has a key', async () => { vi.stubEnv('ASR_OPENAI_API_KEY', 'sk-asr'); vi.stubEnv('ASR_OPENAI_ENABLED', 'false'); const { getServerASRProviders } = await import('@/lib/server/provider-config'); expect(getServerASRProviders()['openai-whisper']).toEqual({ disabled: true }); }); it('asr: force-disables the client-only browser-native provider via env', async () => { vi.stubEnv('ASR_BROWSER_NATIVE_ENABLED', 'false'); const { getServerASRProviders } = await import('@/lib/server/provider-config'); expect(getServerASRProviders()['browser-native']).toEqual({ disabled: true }); }); it('video: force-disables via VIDEO_

_ENABLED=false even when it has a key', async () => { vi.stubEnv('VIDEO_GROK_API_KEY', 'xai-video'); vi.stubEnv('VIDEO_GROK_ENABLED', 'false'); const { getServerVideoProviders } = await import('@/lib/server/provider-config'); expect(getServerVideoProviders()['grok-video']).toEqual({ disabled: true }); }); it('video: force-disables via YAML video..enabled: false', async () => { yamlOverride = 'video:\n kling:\n enabled: false\n apiKey: sk-yaml\n'; const { getServerVideoProviders } = await import('@/lib/server/provider-config'); expect(getServerVideoProviders()['kling']).toEqual({ disabled: true }); }); it('web-search: force-disables a keyed provider via

_ENABLED=false even when it has a key', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly-key'); vi.stubEnv('TAVILY_ENABLED', 'false'); const { getServerWebSearchProviders } = await import('@/lib/server/provider-config'); expect(getServerWebSearchProviders()['tavily']).toEqual({ disabled: true }); }); it('web-search: force-disables the keyless SearXNG provider via env', async () => { vi.stubEnv('SEARXNG_BASE_URL', 'http://searxng.internal'); vi.stubEnv('SEARXNG_ENABLED', 'false'); const { getServerWebSearchProviders } = await import('@/lib/server/provider-config'); expect(getServerWebSearchProviders()['searxng']).toEqual({ disabled: true }); }); it('web-search: force-disables the built-in Doubao provider via its dedicated env', async () => { vi.stubEnv('WEB_SEARCH_DOUBAO_ENABLED', 'false'); const { getServerWebSearchProviders } = await import('@/lib/server/provider-config'); expect(getServerWebSearchProviders().doubao).toEqual({ disabled: true }); }); it('isServerProviderDisabled reflects the per-section force-disable set', async () => { vi.stubEnv('IMAGE_OPENAI_API_KEY', 'sk-img'); vi.stubEnv('IMAGE_OPENAI_ENABLED', 'false'); vi.stubEnv('VIDEO_GROK_ENABLED', 'false'); vi.stubEnv('ASR_OPENAI_ENABLED', 'false'); const { isServerProviderDisabled } = await import('@/lib/server/provider-config'); expect(isServerProviderDisabled('image', 'openai-image')).toBe(true); expect(isServerProviderDisabled('image', 'seedream')).toBe(false); expect(isServerProviderDisabled('video', 'grok-video')).toBe(true); expect(isServerProviderDisabled('asr', 'openai-whisper')).toBe(true); expect(isServerProviderDisabled('tts', 'openai-tts')).toBe(false); }); }); describe('server defaults skip force-disabled providers (#665)', () => { it('resolveServerImageProviderId skips a disabled provider', async () => { vi.stubEnv('IMAGE_OPENAI_API_KEY', 'sk-1'); vi.stubEnv('IMAGE_GROK_API_KEY', 'sk-2'); vi.stubEnv('IMAGE_OPENAI_ENABLED', 'false'); const { resolveServerImageProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerImageProviderId()).toBe('grok-image'); }); it('resolveServerImageProviderId returns undefined when every configured provider is disabled', async () => { vi.stubEnv('IMAGE_OPENAI_API_KEY', 'sk-1'); vi.stubEnv('IMAGE_OPENAI_ENABLED', 'false'); const { resolveServerImageProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerImageProviderId()).toBeUndefined(); }); it('resolveServerVideoProviderId skips a disabled provider', async () => { vi.stubEnv('VIDEO_GROK_API_KEY', 'sk-1'); vi.stubEnv('VIDEO_KLING_API_KEY', 'sk-2'); vi.stubEnv('VIDEO_GROK_ENABLED', 'false'); const { resolveServerVideoProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerVideoProviderId()).toBe('kling'); }); it('resolveServerASRProviderId skips a disabled provider', async () => { vi.stubEnv('ASR_OPENAI_API_KEY', 'sk-1'); vi.stubEnv('ASR_QWEN_API_KEY', 'sk-2'); vi.stubEnv('ASR_OPENAI_ENABLED', 'false'); const { resolveServerASRProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerASRProviderId()).toBe('qwen-asr'); }); it('web-search preference chain skips disabled providers', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly'); vi.stubEnv('BOCHA_API_KEY', 'bocha'); vi.stubEnv('TAVILY_ENABLED', 'false'); const { resolveServerWebSearchProviderId } = await import('@/lib/server/provider-config'); // Tavily is the preferred default but disabled ⇒ bocha is chosen. expect(resolveServerWebSearchProviderId()).toBe('bocha'); }); it('web-search preference chain honors an enabled preferred provider', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly'); vi.stubEnv('BOCHA_API_KEY', 'bocha'); const { resolveServerWebSearchProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerWebSearchProviderId()).toBe('tavily'); }); it('web-search preference chain skips a disabled client-preferred provider', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly'); vi.stubEnv('BOCHA_API_KEY', 'bocha'); vi.stubEnv('TAVILY_ENABLED', 'false'); const { resolveServerWebSearchProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerWebSearchProviderId('tavily')).toBe('bocha'); }); it('web-search preference chain returns undefined when every configured provider is disabled', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly'); vi.stubEnv('TAVILY_ENABLED', 'false'); const { resolveServerWebSearchProviderId } = await import('@/lib/server/provider-config'); expect(resolveServerWebSearchProviderId()).toBeUndefined(); }); }); describe('enabledProviderIds resolver (#665)', () => { it('returns only non-disabled entries of a capability listing', async () => { const { enabledProviderIds } = await import('@/lib/server/provider-config'); expect( enabledProviderIds({ 'openai-image': { models: ['gpt-image-1'] }, seedream: { disabled: true }, 'grok-image': {}, }), ).toEqual(['openai-image', 'grok-image']); }); it('keeps object-key order and drops nothing when nothing is disabled', async () => { const { enabledProviderIds } = await import('@/lib/server/provider-config'); expect(enabledProviderIds({ a: {}, b: { models: [] }, c: { disabled: false } })).toEqual([ 'a', 'b', 'c', ]); }); it('returns an empty list when every entry is force-disabled', async () => { const { enabledProviderIds } = await import('@/lib/server/provider-config'); expect(enabledProviderIds({ a: { disabled: true }, b: { disabled: true } })).toEqual([]); }); }); describe('Qwen TTS resolution', () => { it('uses the provider default base URL when none is configured or supplied', async () => { const { resolveTTSBaseUrl } = await import('@/lib/server/provider-config'); expect(resolveTTSBaseUrl('qwen-tts')).toBe('https://dashscope.aliyuncs.com/api/v1'); }); it('maps VC sentinels to the resolved model and rejects pin bypasses', async () => { vi.stubEnv('TTS_QWEN_API_KEY', 'key'); vi.stubEnv('TTS_QWEN_MODELS', 'qwen3-tts-flash'); vi.stubEnv('TTS_QWEN_VOICE_CLONE_MODEL', 'operator-vc-model'); const { resolveTTSModel } = await import('@/lib/server/provider-config'); expect(resolveTTSModel('qwen-tts', 'qwen3-tts-vc-custom', 'clone-1')).toBe( 'operator-vc-model', ); expect(() => resolveTTSModel('qwen-tts', 'qwen3-tts-flash-other', 'Cherry')).toThrow( 'not allowed', ); expect(resolveTTSModel('qwen-tts', 'operator-vc-model', 'Cherry')).toBe('qwen3-tts-flash'); }); it('reads the VC override only from server-side resolution', async () => { vi.stubEnv('TTS_QWEN_VOICE_CLONE_MODEL', 'operator-vc-model'); const { resolveQwenVoiceCloneModel } = await import('@/lib/server/provider-config'); expect(resolveQwenVoiceCloneModel()).toBe('operator-vc-model'); }); it('rejects catalog synthesis when the operator pins only the clone model', async () => { vi.stubEnv('TTS_QWEN_API_KEY', 'key'); vi.stubEnv('TTS_QWEN_MODELS', 'operator-vc-model'); vi.stubEnv('TTS_QWEN_VOICE_CLONE_MODEL', 'operator-vc-model'); const { resolveTTSModel } = await import('@/lib/server/provider-config'); expect(() => resolveTTSModel('qwen-tts', undefined, 'Cherry')).toThrow('not allowed'); }); }); describe('FunASR server configuration', () => { it('activates the keyless provider from an env base URL', async () => { vi.stubEnv('ASR_FUNASR_BASE_URL', 'http://localhost:8000/v1'); const { getServerASRProviders, resolveASRApiKey, resolveASRBaseUrl } = await import('@/lib/server/provider-config'); expect(getServerASRProviders()['funasr-asr']).toEqual({}); expect(resolveASRApiKey('funasr-asr')).toBe(''); expect(resolveASRBaseUrl('funasr-asr')).toBe('http://localhost:8000/v1'); }); it('activates the keyless provider from YAML and keeps server config authoritative', async () => { yamlOverride = 'asr:\n funasr-asr:\n baseUrl: http://funasr.internal:8000/v1\n'; const { getServerASRProviders, resolveASRBaseUrl } = await import('@/lib/server/provider-config'); expect(getServerASRProviders()['funasr-asr']).toEqual({}); expect(resolveASRBaseUrl('funasr-asr', 'https://client.example.com/v1')).toBe( 'http://funasr.internal:8000/v1', ); }); }); describe('resolveManagedAliDocMindCredentials (AK/SK)', () => { it('resolves YAML-managed AK/SK with NO ALIDOCMIND_* env vars', async () => { // Regression: verification resolved YAML creds but extraction only had an // env fallback, so a YAML-only deployment verified then failed to extract. yamlOverride = 'pdf:\n alidocmind:\n accessKeyId: yaml-ak\n accessKeySecret: yaml-sk\n'; const { resolveManagedAliDocMindCredentials, isServerConfiguredProvider } = await import('@/lib/server/provider-config'); expect(isServerConfiguredProvider('pdf', 'alidocmind')).toBe(true); expect(resolveManagedAliDocMindCredentials()).toEqual({ accessKeyId: 'yaml-ak', accessKeySecret: 'yaml-sk', baseUrl: undefined, }); }); it('resolves YAML AK/SK even when the entry also has baseUrl', async () => { // Regression: a YAML entry WITH baseUrl makes the generic loader create a // pdf.alidocmind entry (copying only apiKey/baseUrl/models/proxy, never // AK/SK). The fallback must merge AK/SK into that entry, not skip it. yamlOverride = 'pdf:\n alidocmind:\n' + ' accessKeyId: review-ak\n' + ' accessKeySecret: review-sk\n' + ' baseUrl: https://docmind-api.cn-hangzhou.aliyuncs.com\n'; const { resolveManagedAliDocMindCredentials, isServerConfiguredProvider } = await import('@/lib/server/provider-config'); expect(isServerConfiguredProvider('pdf', 'alidocmind')).toBe(true); expect(resolveManagedAliDocMindCredentials()).toEqual({ accessKeyId: 'review-ak', accessKeySecret: 'review-sk', baseUrl: 'https://docmind-api.cn-hangzhou.aliyuncs.com', }); }); it('resolves AK/SK from env vars', async () => { vi.stubEnv('ALIDOCMIND_ACCESS_KEY_ID', 'env-ak'); vi.stubEnv('ALIDOCMIND_ACCESS_KEY_SECRET', 'env-sk'); const { resolveManagedAliDocMindCredentials } = await import('@/lib/server/provider-config'); expect(resolveManagedAliDocMindCredentials()).toMatchObject({ accessKeyId: 'env-ak', accessKeySecret: 'env-sk', }); }); it('returns undefined when neither env nor YAML configures AliDocMind', async () => { const { resolveManagedAliDocMindCredentials, isServerConfiguredProvider } = await import('@/lib/server/provider-config'); expect(resolveManagedAliDocMindCredentials()).toBeUndefined(); expect(isServerConfiguredProvider('pdf', 'alidocmind')).toBe(false); }); it('stays UNMANAGED when YAML sets baseUrl but no AK/SK (no lockout)', async () => { // Regression: a baseUrl-only YAML entry made the generic loader create a // pdf.alidocmind entry → isServerConfigured=true (managed) → but with no // AK/SK the provider was locked out AND client-entered creds were dropped. // With no usable server creds it must stay unmanaged so clients can supply // their own. yamlOverride = 'pdf:\n alidocmind:\n baseUrl: https://docmind-api.cn-beijing.aliyuncs.com\n'; const { resolveManagedAliDocMindCredentials, isServerConfiguredProvider } = await import('@/lib/server/provider-config'); expect(isServerConfiguredProvider('pdf', 'alidocmind')).toBe(false); expect(resolveManagedAliDocMindCredentials()).toBeUndefined(); }); }); });