Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

520 lines
21 KiB
TypeScript

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<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[]) =>
// 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<string, unknown>).apiKey).toBeUndefined();
expect((providers.openai as Record<string, unknown>).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_<P>_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.<id>.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_<P>_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);
});
});
});