import { ChannelCapacityQueueError } from './channel-capacity-queue'; import { createChannelRouter, describeChannelFailure, getChannelPoolSummary, isChannelFailure, isChannelRequestModeFailure, isCredentialFailure, parseChannelPoolConfig, resolveEffectiveCredential, toPublicChannelFailure } from './channel-router'; import { RequestValidationError } from './image-request-utils'; import { IMAGE_UPSTREAM_PROFILES } from './image-upstream-profile'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; const DEFAULT_HEADER_SUMMARY = { user_agent_effective: 'visual-journal/2.2.0', has_extra_headers: false, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: [] }; describe('parseChannelPoolConfig', () => { it('parses multiple channels with multiple keys from numbered env vars', () => { const config = parseChannelPoolConfig({ OPENAI_ROUTING_STRATEGY: 'round_robin', OPENAI_CHANNEL_1_ID: 'official', OPENAI_CHANNEL_1_BASE_URL: 'https://api.openai.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one, sk-two', OPENAI_CHANNEL_2_ID: 'backup', OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'sk-three' }); assert.equal(config.strategy, 'round_robin'); assert.deepEqual( config.credentials.map((credential) => ({ id: credential.id, channelId: credential.channelId, apiKey: credential.apiKey, baseUrl: credential.baseUrl })), [ { id: 'official#0', channelId: 'official', apiKey: 'sk-one', baseUrl: 'https://api.openai.com/v1' }, { id: 'official#1', channelId: 'official', apiKey: 'sk-two', baseUrl: 'https://api.openai.com/v1' }, { id: 'backup#0', channelId: 'backup', apiKey: 'sk-three', baseUrl: 'https://backup.example.com/v1' } ] ); }); it('falls back to legacy single key env when numbered channels are not configured', () => { const config = parseChannelPoolConfig({ OPENAI_API_KEY: 'sk-legacy', OPENAI_API_BASE_URL: 'https://legacy.example.com/v1' }); assert.equal(config.strategy, 'sticky'); assert.deepEqual(config.credentials, [ { id: 'default#0', channelId: 'default', apiKey: 'sk-legacy', baseUrl: 'https://legacy.example.com/v1', upstreamProfile: 'openai-compatible' } ]); }); it('applies the global proxy and lets a numbered channel override it', () => { const config = parseChannelPoolConfig({ OPENAI_UPSTREAM_PROXY_URL: 'https://global-proxy.example:8443', OPENAI_CHANNEL_1_ID: 'primary', OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-primary', OPENAI_CHANNEL_2_ID: 'backup', OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'sk-backup', OPENAI_CHANNEL_2_PROXY_URL: 'http://channel-proxy.example:8080' }); assert.deepEqual( config.credentials.map((credential) => ({ id: credential.id, upstreamProxyUrl: credential.upstreamProxyUrl })), [ { id: 'primary#0', upstreamProxyUrl: 'https://global-proxy.example:8443/' }, { id: 'backup#0', upstreamProxyUrl: 'http://channel-proxy.example:8080/' } ] ); const summary = getChannelPoolSummary(config); assert.deepEqual( summary.channels.map((channel) => ({ id: channel.id, upstreamProxy: channel.upstreamProxy })), [ { id: 'primary', upstreamProxy: { configured: true, protocol: 'https' } }, { id: 'backup', upstreamProxy: { configured: true, protocol: 'http' } } ] ); assert.equal(JSON.stringify(summary).includes('global-proxy.example'), false); assert.equal(JSON.stringify(summary).includes('channel-proxy.example'), false); }); it('rejects an invalid global or channel proxy URL explicitly', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_API_KEY: 'sk-legacy', OPENAI_API_BASE_URL: 'https://legacy.example.com/v1', OPENAI_UPSTREAM_PROXY_URL: 'ftp://proxy.example' }), /OPENAI_UPSTREAM_PROXY_URL/ ); assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-primary', OPENAI_CHANNEL_1_PROXY_URL: 'http://proxy.example/path' }), /OPENAI_CHANNEL_1_PROXY_URL/ ); }); it('rejects invalid legacy upstream profile values instead of silently using the default profile', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_API_KEY: 'sk-legacy', OPENAI_API_BASE_URL: 'https://legacy.example.com/v1', OPENAI_UPSTREAM_PROFILE: 'unknown' }), /OPENAI_UPSTREAM_PROFILE/ ); }); it('uses channel index as the default channel id', () => { const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_BASE_URL: 'https://default-id.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one' }); assert.deepEqual(config.credentials, [ { id: 'channel-1#0', channelId: 'channel-1', apiKey: 'sk-one', baseUrl: 'https://default-id.example.com/v1', upstreamProfile: 'openai-compatible' } ]); }); it('rejects remote HTTP-compatible upstream base URLs unless they are allowlisted', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'j3gb', OPENAI_CHANNEL_1_BASE_URL: 'http://api.j3gb.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one' }), /HTTPS/ ); }); it('accepts allowlisted HTTP-compatible upstream base URLs in server channels', () => { const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'j3gb', OPENAI_CHANNEL_1_BASE_URL: 'http://api.j3gb.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS: 'http://api.j3gb.com/v1' }); assert.equal(config.credentials[0]?.baseUrl, 'http://api.j3gb.com/v1'); }); it('rejects a channel with missing API keys instead of silently falling back', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'empty', OPENAI_CHANNEL_1_BASE_URL: 'https://empty.example.com/v1' }), /OPENAI_CHANNEL_1_API_KEYS/ ); }); it('rejects removed JSON config with a migration hint', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNELS_JSON: '{"channels":[]}', OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: 'images-sse,images-non-stream' }), /OPENAI_CHANNEL_N/ ); }); it('parses explicit per-channel request modes', () => { const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'images-only', OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-json, images-sse' }); assert.deepEqual(config.credentials[0]?.requestModes, ['images-non-stream', 'images-sse']); }); it('parses global and per-channel request mode priority without changing the whitelist', () => { const config = parseChannelPoolConfig({ OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: 'images-sse, images-json', OPENAI_CHANNEL_1_ID: 'images', OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-json,images-sse', OPENAI_CHANNEL_2_ID: 'json-first', OPENAI_CHANNEL_2_BASE_URL: 'https://json-first.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'sk-two', OPENAI_CHANNEL_2_REQUEST_MODES: 'images-json,images-sse', OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY: 'images-json,images-sse' }); assert.deepEqual(config.credentials[0]?.requestModes, ['images-non-stream', 'images-sse']); assert.deepEqual(config.requestModePriority, ['images-sse', 'images-non-stream']); assert.deepEqual(config.credentials[0]?.requestModePriority, ['images-sse', 'images-non-stream']); assert.deepEqual(config.credentials[1]?.requestModePriority, ['images-non-stream', 'images-sse']); }); it('rejects invalid per-channel request modes', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'bad', OPENAI_CHANNEL_1_BASE_URL: 'https://bad.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-websocket' }), /请求方式/ ); }); it('rejects per-channel request mode priority outside the channel whitelist', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'images-only', OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-json', OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY: 'responses-sse,images-json' }), /REQUEST_MODE_PRIORITY.*不在白名单/ ); }); }); describe('getChannelPoolSummary', () => { it('returns sanitized capacity metadata without exposing API keys', () => { const config = parseChannelPoolConfig({ OPENAI_ROUTING_STRATEGY: 'round_robin', OPENAI_CHANNEL_1_ID: 'official', OPENAI_CHANNEL_1_BASE_URL: 'https://api.openai.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one, sk-two', OPENAI_CHANNEL_2_ID: 'backup', OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'sk-three' }); assert.deepEqual(getChannelPoolSummary(config), { credentialCount: 3, channelCount: 2, strategy: 'round_robin', channels: [ { id: 'official', baseUrl: 'https://api.openai.com/v1', upstreamProxy: { configured: false }, upstreamProfile: 'openai-compatible', effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'], hasExtraHeaders: false, requestHeaders: DEFAULT_HEADER_SUMMARY, requestModes: ['images-non-stream'], requestModePriority: ['images-non-stream'], credentialCount: 2 }, { id: 'backup', baseUrl: 'https://backup.example.com/v1', upstreamProxy: { configured: false }, upstreamProfile: 'openai-compatible', effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'], hasExtraHeaders: false, requestHeaders: DEFAULT_HEADER_SUMMARY, requestModes: ['images-non-stream'], requestModePriority: ['images-non-stream'], credentialCount: 1 } ] }); }); it('reports channel upstream profile and extra header availability without exposing header values', () => { const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'matsca', OPENAI_CHANNEL_1_BASE_URL: 'https://matsca.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_UPSTREAM_PROFILE: 'matsca', OPENAI_CHANNEL_1_MATSCA_APP_ID: 'app-id', OPENAI_CHANNEL_1_MATSCA_APP_SECRET: 'app-secret' }); assert.deepEqual(getChannelPoolSummary(config), { credentialCount: 1, channelCount: 1, strategy: 'sticky', channels: [ { id: 'matsca', baseUrl: 'https://matsca.example.com/v1', upstreamProxy: { configured: false }, upstreamProfile: 'matsca', effectiveProfile: IMAGE_UPSTREAM_PROFILES.matsca, hasExtraHeaders: true, requestHeaders: { ...DEFAULT_HEADER_SUMMARY, has_extra_headers: true, configured_header_names: ['x-app-id', 'x-app-secret'] }, requestModes: ['images-non-stream'], requestModePriority: ['images-non-stream'], credentialCount: 1 } ] }); }); it('supports safe per-channel User-Agent overrides without exposing header values', () => { const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'jisuanyun', OPENAI_CHANNEL_1_BASE_URL: 'https://xkj.jisuanyun.vip/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_USER_AGENT: 'visual-journal/customer-node' }); assert.equal(config.credentials[0]?.upstreamHeaders?.['User-Agent'], 'visual-journal/customer-node'); assert.deepEqual(getChannelPoolSummary(config).channels[0]?.requestHeaders, { ...DEFAULT_HEADER_SUMMARY, user_agent_effective: 'visual-journal/customer-node', configured_header_names: ['user-agent'] }); assert.equal(JSON.stringify(getChannelPoolSummary(config)).includes('sk-one'), false); }); it('rejects unsafe per-channel JSON headers that would override protocol headers', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'bad', OPENAI_CHANNEL_1_BASE_URL: 'https://bad.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON: '{"Authorization":"Bearer wrong"}' }), /不能配置 Authorization/ ); }); it('rejects half-configured Matsca app headers', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'matsca', OPENAI_CHANNEL_1_BASE_URL: 'https://matsca.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_UPSTREAM_PROFILE: 'matsca', OPENAI_CHANNEL_1_MATSCA_APP_ID: 'app-id' }), /必须成对配置/ ); }); it('parses provider manifest summaries and derived profile constraints without exposing secrets', () => { const manifest = JSON.stringify({ schema_version: 1, id: 'custom_provider', name: 'Custom Provider', base_profile: 'matsca', modes: { generate: { submit: { path: '/images/generations', content_type: 'application/json', response_format: 'custom-json' }, poll: { path: '/jobs/{id}', status_path: 'status', success_values: ['succeeded'], failure_values: ['failed'] } }, edit: { submit: { path: '/images/edits', content_type: 'multipart/form-data' } } }, constraints: { generate_count: { min: 1, max: 2 }, edit_count: { min: 1, max: 1 }, partial_images: { min: 0, max: 2 }, upload: { max_images: 4, max_single_bytes: 10485760, max_total_bytes: 41943040 } } }); const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'custom', OPENAI_CHANNEL_1_BASE_URL: 'https://custom.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_UPSTREAM_PROFILE: 'matsca', OPENAI_CHANNEL_1_PROVIDER_MANIFEST: manifest }); assert.equal(config.credentials[0]?.providerProfile?.generateCount.max, 2); assert.equal(config.credentials[0]?.providerProfile?.editCount.max, 1); assert.equal(config.credentials[0]?.providerProfile?.upload.maxImages, 4); assert.deepEqual(getChannelPoolSummary(config), { credentialCount: 1, channelCount: 1, strategy: 'sticky', channels: [ { id: 'custom', baseUrl: 'https://custom.example.com/v1', upstreamProxy: { configured: false }, upstreamProfile: 'matsca', effectiveProfile: config.credentials[0]?.providerProfile, hasExtraHeaders: false, requestHeaders: DEFAULT_HEADER_SUMMARY, requestModes: ['images-non-stream'], requestModePriority: ['images-non-stream'], providerManifest: { id: 'custom_provider', name: 'Custom Provider', baseProfile: 'matsca', modes: { generate: 'async-poll', edit: 'multipart' }, requestTypes: { generate: 'application/json', edit: 'multipart/form-data' }, responseFormats: { generate: 'custom-json', edit: 'openai-images' }, asyncPolling: { generate: true, edit: false }, executionSupport: { generate: 'declared_only', edit: 'implemented' } }, credentialCount: 1 } ] }); assert.equal(JSON.stringify(getChannelPoolSummary(config)).includes('sk-one'), false); }); it('rejects provider manifests whose base profile conflicts with the channel profile', () => { assert.throws( () => parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'custom', OPENAI_CHANNEL_1_BASE_URL: 'https://custom.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_UPSTREAM_PROFILE: 'openai-compatible', OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ id: 'custom_provider', base_profile: 'matsca', modes: { generate: { submit: { path: '/images/generations' } } } }) }), /base_profile 必须和该渠道 upstream profile 一致/ ); }); }); describe('createChannelRouter', () => { const config = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'a', OPENAI_CHANNEL_1_BASE_URL: 'https://a.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'a1,a2', OPENAI_CHANNEL_2_ID: 'b', OPENAI_CHANNEL_2_BASE_URL: 'https://b.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'b1' }); it('keeps sticky selection stable for the same affinity key', () => { const router = createChannelRouter({ ...config, strategy: 'sticky' }); const first = router.select({ affinityKey: 'client-a' }); const second = router.select({ affinityKey: 'client-a' }); assert.equal(first.id, second.id); assert.ok(config.credentials.some((credential) => credential.id === first.id)); }); it('cycles through credentials with round robin', () => { const router = createChannelRouter({ ...config, strategy: 'round_robin' }); assert.deepEqual( [router.select().id, router.select().id, router.select().id, router.select().id], ['a#0', 'a#1', 'b#0', 'a#0'] ); }); it('uses injected random source for random routing', () => { const router = createChannelRouter({ ...config, strategy: 'random', random: () => 0.75 }); assert.equal(router.select().id, 'b#0'); }); it('selects only channels that support the requested mode', () => { const modeConfig = parseChannelPoolConfig({ OPENAI_ROUTING_STRATEGY: 'round_robin', OPENAI_CHANNEL_1_ID: 'images', OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-images', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream', OPENAI_CHANNEL_2_ID: 'responses', OPENAI_CHANNEL_2_BASE_URL: 'https://responses.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'sk-responses', OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-sse' }); const router = createChannelRouter(modeConfig); assert.equal(router.select({ requestMode: 'responses-sse' }).channelId, 'responses'); assert.equal(router.select({ requestMode: 'images-non-stream' }).channelId, 'images'); assert.throws(() => router.select({ requestMode: 'images-sse' }), /支持 images-sse/); }); it('selects a request mode from channel priority when callers provide candidates', () => { const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'images', OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'images-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse', OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY: 'images-sse,images-non-stream' }); const router = createChannelRouter(requestModeConfig); assert.deepEqual( router.selectWithRequestModes({ affinityKey: 'same-user', requestModes: ['images-non-stream', 'images-sse'] }), { credential: requestModeConfig.credentials[0], requestMode: 'images-sse', preferredRequestMode: 'images-sse', requestModePriority: ['images-sse', 'images-non-stream'] } ); }); it('selects the lowest-cost request mode before routing across split-mode channels', () => { const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'sse', OPENAI_CHANNEL_1_BASE_URL: 'https://sse.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sse-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse', OPENAI_CHANNEL_2_ID: 'json', OPENAI_CHANNEL_2_BASE_URL: 'https://json.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'json-key', OPENAI_CHANNEL_2_REQUEST_MODES: 'images-non-stream' }); const router = createChannelRouter({ ...requestModeConfig, strategy: 'round_robin' }); assert.deepEqual( router.selectWithRequestModes({ affinityKey: 'same-user', requestModes: ['images-non-stream', 'images-sse'] }), { credential: requestModeConfig.credentials[1], requestMode: 'images-non-stream', preferredRequestMode: 'images-non-stream', requestModePriority: ['images-non-stream', 'images-sse'] } ); }); it('uses a shared explicit request mode priority before routing across split-mode channels', () => { const requestModeConfig = parseChannelPoolConfig({ OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: 'images-sse,images-non-stream', OPENAI_CHANNEL_1_ID: 'sse', OPENAI_CHANNEL_1_BASE_URL: 'https://sse.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sse-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse', OPENAI_CHANNEL_2_ID: 'json', OPENAI_CHANNEL_2_BASE_URL: 'https://json.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'json-key', OPENAI_CHANNEL_2_REQUEST_MODES: 'images-non-stream' }); const router = createChannelRouter({ ...requestModeConfig, strategy: 'round_robin' }); assert.deepEqual( router.selectWithRequestModes({ affinityKey: 'same-user', requestModes: ['images-non-stream', 'images-sse'] }), { credential: requestModeConfig.credentials[0], requestMode: 'images-sse', preferredRequestMode: 'images-sse', requestModePriority: ['images-sse', 'images-non-stream'] } ); }); it('uses a local request mode priority before global priority across split-mode channels', () => { const requestModeConfig = parseChannelPoolConfig({ OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: 'images-sse,images-non-stream', OPENAI_CHANNEL_1_ID: 'sse', OPENAI_CHANNEL_1_BASE_URL: 'https://sse.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'sse-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse', OPENAI_CHANNEL_2_ID: 'json', OPENAI_CHANNEL_2_BASE_URL: 'https://json.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'json-key', OPENAI_CHANNEL_2_REQUEST_MODES: 'images-non-stream', OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY: 'images-non-stream' }); const router = createChannelRouter({ ...requestModeConfig, strategy: 'round_robin' }); assert.deepEqual( router.selectWithRequestModes({ affinityKey: 'same-user', requestModes: ['images-non-stream', 'images-sse'] }), { credential: requestModeConfig.credentials[1], requestMode: 'images-non-stream', preferredRequestMode: 'images-non-stream', requestModePriority: ['images-non-stream'] } ); }); it('uses a mixed channel local priority before default low-cost priority on sibling channels', () => { const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse', OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY: 'images-sse,images-non-stream', OPENAI_CHANNEL_2_ID: 'json', OPENAI_CHANNEL_2_BASE_URL: 'https://json.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'json-key', OPENAI_CHANNEL_2_REQUEST_MODES: 'images-non-stream' }); const router = createChannelRouter({ ...requestModeConfig, strategy: 'round_robin' }); assert.deepEqual( router.selectWithRequestModes({ affinityKey: 'same-user', requestModes: ['images-non-stream', 'images-sse'] }), { credential: requestModeConfig.credentials[0], requestMode: 'images-sse', preferredRequestMode: 'images-sse', requestModePriority: ['images-sse', 'images-non-stream'] } ); }); it('uses the local channel request mode priority for a single mixed-mode channel', () => { const requestModeConfig = parseChannelPoolConfig({ OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: 'images-sse,images-non-stream', OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse', OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY: 'images-non-stream,images-sse' }); const router = createChannelRouter(requestModeConfig); assert.deepEqual( router.selectWithRequestModes({ affinityKey: 'same-user', requestModes: ['images-non-stream', 'images-sse'] }), { credential: requestModeConfig.credentials[0], requestMode: 'images-non-stream', preferredRequestMode: 'images-non-stream', requestModePriority: ['images-non-stream', 'images-sse'] } ); }); it('skips a failed credential until the cooldown expires', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now }); const failed = router.select(); assert.equal(failed.id, 'a#0'); router.reportFailure(failed); assert.equal(router.select().id, 'a#1'); assert.equal(router.select().id, 'b#0'); assert.equal(router.select().id, 'a#1'); now = 1100; assert.equal(router.select().id, 'b#0'); assert.equal(router.select().id, 'a#0'); }); it('fails explicitly when every credential is cooling down', () => { const router = createChannelRouter({ ...config, failureCooldownMs: 100, now: () => 1000 }); config.credentials.forEach((credential) => router.reportFailure(credential)); assert.throws(() => router.select({ affinityKey: 'client-a' }), /没有可用的健康渠道凭证/); }); it('keeps credentials routable when failure cooldown is disabled', () => { const router = createChannelRouter({ ...config, failureCooldownEnabled: false, failureCooldownMs: 100, now: () => 1000, requireProbeForRecovery: true }); const report = router.reportFailure(config.credentials[0], { scope: 'channel' }); assert.equal(report.cooldownApplied, false); assert.deepEqual(router.getRecoveryProbeCandidates(), []); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'channel' } }); const selected = router.select({ affinityKey: 'client-a' }); assert.ok(config.credentials.some((credential) => credential.id === selected.id)); }); it('uses a 30 second default failure cooldown', () => { const router = createChannelRouter({ ...config, now: () => 1000 }); const report = router.reportFailure(config.credentials[0]); assert.equal(report.cooldownApplied, true); assert.equal(report.retryAfterMs, 30_000); assert.equal(report.cooldownUntil, 31_000); }); it('reports healthy capacity after credentials enter and leave cooldown', () => { let now = 1000; const router = createChannelRouter({ ...config, failureCooldownMs: 100, now: () => now }); const failed = config.credentials[0]; assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0 }); router.reportFailure(failed); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 2, unhealthyCredentialCount: 1, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'credential' } }); now = 1100; assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'credential' } }); }); it('reports healthy channel capacity when some credentials or whole channels are cooling down', () => { let now = 1000; const router = createChannelRouter({ ...config, failureCooldownMs: 100, now: () => now }); router.reportFailure(config.credentials[0]); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 2, unhealthyCredentialCount: 1, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'credential' } }); router.reportFailure(config.credentials[2], { scope: 'channel' }); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 1, unhealthyCredentialCount: 2, channelCount: 2, healthyChannelCount: 1, unhealthyChannelCount: 1, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'channel' } }); now = 1100; assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'channel' } }); }); it('reports configured and effective request modes separately', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'images', OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'images-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse', OPENAI_CHANNEL_2_ID: 'responses', OPENAI_CHANNEL_2_BASE_URL: 'https://responses.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'responses-key', OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now }); assert.deepEqual(router.getRequestModeHealthSummary(), { configuredRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'], effectiveRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'], defaultRequestModePriority: ['images-non-stream', 'images-sse', 'responses-non-stream', 'responses-sse'], modes: [ { mode: 'images-non-stream', configuredCredentialCount: 1, healthyCredentialCount: 1, configuredChannelCount: 1, healthyChannelCount: 1 }, { mode: 'images-sse', configuredCredentialCount: 1, healthyCredentialCount: 1, configuredChannelCount: 1, healthyChannelCount: 1 }, { mode: 'responses-non-stream', configuredCredentialCount: 0, healthyCredentialCount: 0, configuredChannelCount: 0, healthyChannelCount: 0 }, { mode: 'responses-sse', configuredCredentialCount: 1, healthyCredentialCount: 1, configuredChannelCount: 1, healthyChannelCount: 1 } ], effectiveRequestModesByChannel: [ { channelId: 'images', requestModes: ['images-non-stream', 'images-sse'], requestModePriority: ['images-non-stream', 'images-sse'] }, { channelId: 'responses', requestModes: ['responses-sse'], requestModePriority: ['responses-sse'] } ] }); router.reportFailure(requestModeConfig.credentials[1], { scope: 'channel' }); assert.deepEqual(router.getRequestModeHealthSummary(), { configuredRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'], effectiveRequestModes: ['images-non-stream', 'images-sse'], defaultRequestModePriority: ['images-non-stream', 'images-sse', 'responses-non-stream', 'responses-sse'], modes: [ { mode: 'images-non-stream', configuredCredentialCount: 1, healthyCredentialCount: 1, configuredChannelCount: 1, healthyChannelCount: 1 }, { mode: 'images-sse', configuredCredentialCount: 1, healthyCredentialCount: 1, configuredChannelCount: 1, healthyChannelCount: 1 }, { mode: 'responses-non-stream', configuredCredentialCount: 0, healthyCredentialCount: 0, configuredChannelCount: 0, healthyChannelCount: 0 }, { mode: 'responses-sse', configuredCredentialCount: 1, healthyCredentialCount: 0, configuredChannelCount: 1, healthyChannelCount: 0 } ], effectiveRequestModesByChannel: [ { channelId: 'images', requestModes: ['images-non-stream', 'images-sse'], requestModePriority: ['images-non-stream', 'images-sse'] } ] }); now = 1100; assert.deepEqual(router.getRequestModeHealthSummary().effectiveRequestModes, [ 'images-non-stream', 'images-sse', 'responses-sse' ]); }); it('cools only the failed request mode when failure reports include a request mode', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now }); const credential = requestModeConfig.credentials[0]; const report = router.reportFailure(credential, { scope: 'channel', requestMode: 'images-sse', reason: { at: now, scope: 'channel', status: 524 } }); assert.deepEqual(report.target, { channelId: 'mixed', requestMode: 'images-sse' }); assert.deepEqual(report.reason, { at: 1000, scope: 'channel', status: 524, requestMode: 'images-sse' }); assert.throws(() => router.select({ requestMode: 'images-sse' }), /支持 images-sse/); assert.equal(router.select({ requestMode: 'images-non-stream' }).id, credential.id); assert.deepEqual(router.getHealthSummary(), { credentialCount: 1, healthyCredentialCount: 1, unhealthyCredentialCount: 0, channelCount: 1, healthyChannelCount: 1, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'channel', status: 524, requestMode: 'images-sse' } }); assert.deepEqual(router.getRequestModeHealthSummary(), { configuredRequestModes: ['images-non-stream', 'images-sse'], effectiveRequestModes: ['images-non-stream'], defaultRequestModePriority: ['images-non-stream', 'images-sse', 'responses-non-stream', 'responses-sse'], modes: [ { mode: 'images-non-stream', configuredCredentialCount: 1, healthyCredentialCount: 1, configuredChannelCount: 1, healthyChannelCount: 1 }, { mode: 'images-sse', configuredCredentialCount: 1, healthyCredentialCount: 0, configuredChannelCount: 1, healthyChannelCount: 0 }, { mode: 'responses-non-stream', configuredCredentialCount: 0, healthyCredentialCount: 0, configuredChannelCount: 0, healthyChannelCount: 0 }, { mode: 'responses-sse', configuredCredentialCount: 0, healthyCredentialCount: 0, configuredChannelCount: 0, healthyChannelCount: 0 } ], effectiveRequestModesByChannel: [ { channelId: 'mixed', requestModes: ['images-non-stream'], requestModePriority: ['images-non-stream'] } ] }); now = 1100; assert.equal(router.select({ requestMode: 'images-sse' }).id, credential.id); assert.deepEqual(router.getRequestModeHealthSummary().effectiveRequestModes, [ 'images-non-stream', 'images-sse' ]); }); it('uses per-channel cooldown when a channel defines a longer window', () => { let now = 1000; const channelConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'a', OPENAI_CHANNEL_1_BASE_URL: 'https://a.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'a1,a2', OPENAI_CHANNEL_1_FAILURE_COOLDOWN_MS: '250', OPENAI_CHANNEL_2_ID: 'b', OPENAI_CHANNEL_2_BASE_URL: 'https://b.example.com/v1', OPENAI_CHANNEL_2_API_KEYS: 'b1' }); const router = createChannelRouter({ ...channelConfig, failureCooldownMs: 100, now: () => now }); router.reportFailure(channelConfig.credentials[0], { scope: 'channel' }); now = 1100; assert.equal(router.select({ affinityKey: 'client-a' }).channelId, 'b'); now = 1250; assert.ok(router.getHealthSummary().healthyCredentialCount >= 2); }); it('falls forward from a cooled sticky credential to the next healthy credential', () => { const router = createChannelRouter({ ...config, strategy: 'sticky', failureCooldownMs: 100, now: () => 1000 }); const sticky = router.select({ affinityKey: 'client-a' }); router.reportFailure(sticky); const replacement = router.select({ affinityKey: 'client-a' }); assert.notEqual(replacement.id, sticky.id); assert.ok(config.credentials.some((credential) => credential.id === replacement.id)); }); it('does not change health when a non-credential error is ignored by the caller', () => { const router = createChannelRouter({ ...config, failureCooldownMs: 100, now: () => 1000 }); assert.equal(isCredentialFailure({ status: 400, code: 'invalid_request_error' }), false); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0 }); }); it('cools every credential in the failed channel while preserving other channels', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now }); const failed = router.select(); assert.equal(failed.id, 'a#0'); router.reportFailure(failed, { scope: 'channel' }); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 1, unhealthyCredentialCount: 2, channelCount: 2, healthyChannelCount: 1, unhealthyChannelCount: 1, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'channel' } }); assert.equal(router.select().id, 'b#0'); assert.equal(router.select().id, 'b#0'); now = 1100; assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'channel' } }); }); it('requires a successful recovery probe before a cooled credential returns to user traffic', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = router.select(); assert.equal(failed.id, 'a#0'); router.reportFailure(failed); now = 1100; assert.deepEqual([router.select().id, router.select().id, router.select().id], ['a#1', 'b#0', 'a#1']); const candidates = router.getRecoveryProbeCandidates(); assert.deepEqual( candidates.map((candidate) => ({ scope: candidate.scope, credentialId: candidate.credential.id })), [{ scope: 'credential', credentialId: 'a#0' }] ); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 2, unhealthyCredentialCount: 1, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 1, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1000, scope: 'credential' } }); router.reportRecoveryProbeSuccess(candidates[0]); assert.equal(router.select().id, 'b#0'); assert.equal(router.select().id, 'a#0'); }); it('requires channel recovery probes and re-cools the channel after a failed probe', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); router.reportFailure(config.credentials[0], { scope: 'channel' }); now = 1100; assert.equal(router.select().id, 'b#0'); assert.equal(router.select().id, 'b#0'); assert.deepEqual( router.getRecoveryProbeCandidates().map((candidate) => ({ scope: candidate.scope, credentialId: candidate.credential.id })), [{ scope: 'channel', credentialId: 'a#0' }] ); router.reportRecoveryProbeFailure(router.getRecoveryProbeCandidates()[0], { at: now, scope: 'channel', status: 503, code: 'probe_failed' }); assert.deepEqual(router.getRecoveryProbeCandidates(), []); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 1, unhealthyCredentialCount: 2, channelCount: 2, healthyChannelCount: 1, unhealthyChannelCount: 1, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 1, lastFailure: { at: 1100, scope: 'channel', status: 503, code: 'probe_failed' } }); now = 1200; const retryCandidate = router.getRecoveryProbeCandidates()[0]; assert.equal(retryCandidate.scope, 'channel'); assert.equal(retryCandidate.credential.id, 'a#0'); router.reportRecoveryProbeSuccess(retryCandidate); assert.deepEqual(router.getHealthSummary(), { credentialCount: 3, healthyCredentialCount: 3, unhealthyCredentialCount: 0, channelCount: 2, healthyChannelCount: 2, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 0, lastFailure: { at: 1100, scope: 'channel', status: 503, code: 'probe_failed' } }); }); it('requires recovery probes for request-mode cooldowns', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = requestModeConfig.credentials[0]; router.reportFailure(failed, { scope: 'channel', requestMode: 'images-sse' }); now = 1100; assert.equal(router.select({ requestMode: 'images-non-stream' }).id, failed.id); assert.throws(() => router.select({ requestMode: 'images-sse' }), /支持 images-sse/); assert.deepEqual( router.getRecoveryProbeCandidates().map((candidate) => ({ scope: candidate.scope, credentialId: candidate.credential.id, requestMode: candidate.requestMode })), [{ scope: 'channel', credentialId: 'mixed#0', requestMode: 'images-sse' }] ); assert.deepEqual(router.getHealthSummary(), { credentialCount: 1, healthyCredentialCount: 1, unhealthyCredentialCount: 0, channelCount: 1, healthyChannelCount: 1, unhealthyChannelCount: 0, pendingRecoveryProbeCredentialCount: 0, pendingRecoveryProbeChannelCount: 1, lastFailure: { at: 1000, scope: 'channel', requestMode: 'images-sse' } }); const candidate = router.getRecoveryProbeCandidates()[0]; assert.equal(router.reportRecoveryProbeSuccess(candidate), true); assert.equal(router.select({ requestMode: 'images-sse' }).id, failed.id); }); it('uses a request-mode-capable sibling credential for channel request-mode recovery probes', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-a,mixed-b', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const [first, second] = requestModeConfig.credentials; router.reportFailure(first, { scope: 'channel', requestMode: 'images-sse' }); now = 1010; router.reportFailure(first, { requestMode: 'images-sse' }); now = 1100; assert.deepEqual( router.getRecoveryProbeCandidates().map((candidate) => ({ scope: candidate.scope, credentialId: candidate.credential.id, requestMode: candidate.requestMode })), [{ scope: 'channel', credentialId: second.id, requestMode: 'images-sse' }] ); assert.equal(router.reportRecoveryProbeSuccess(router.getRecoveryProbeCandidates()[0]), true); assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 1); }); it('queues the channel recovery probe before request-mode probes for the same channel', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = requestModeConfig.credentials[0]; router.reportFailure(failed, { scope: 'channel' }); router.reportFailure(failed, { scope: 'channel', requestMode: 'images-sse' }); now = 1100; assert.deepEqual( router.getRecoveryProbeCandidates().map((candidate) => ({ scope: candidate.scope, credentialId: candidate.credential.id, requestMode: candidate.requestMode })), [{ scope: 'channel', credentialId: failed.id, requestMode: undefined }] ); }); it('queues the credential recovery probe before request-mode probes for the same credential', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = requestModeConfig.credentials[0]; router.reportFailure(failed); router.reportFailure(failed, { requestMode: 'images-sse' }); now = 1100; assert.deepEqual( router.getRecoveryProbeCandidates().map((candidate) => ({ scope: candidate.scope, credentialId: candidate.credential.id, requestMode: candidate.requestMode })), [{ scope: 'credential', credentialId: failed.id, requestMode: undefined }] ); }); it('clears older credential request-mode probes when a channel request-mode probe recovers', () => { let now = 1000; const requestModeConfig = parseChannelPoolConfig({ OPENAI_CHANNEL_1_ID: 'mixed', OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1', OPENAI_CHANNEL_1_API_KEYS: 'mixed-a,mixed-b', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' }); const router = createChannelRouter({ ...requestModeConfig, failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const [first, second] = requestModeConfig.credentials; router.reportFailure(first, { requestMode: 'images-sse' }); router.reportFailure(second, { requestMode: 'images-sse' }); now = 1010; router.reportFailure(first, { scope: 'channel', requestMode: 'images-sse' }); now = 1110; const candidate = router.getRecoveryProbeCandidates()[0]; assert.equal(router.reportRecoveryProbeSuccess(candidate), true); assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 0); assert.equal(router.select({ requestMode: 'images-sse' }).channelId, 'mixed'); }); it('ignores stale recovery probe success after the credential fails again', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = config.credentials[0]; router.reportFailure(failed); now = 1100; const staleCandidate = router.getRecoveryProbeCandidates()[0]; now = 1110; router.reportFailure(failed); router.reportRecoveryProbeSuccess(staleCandidate); assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 1); assert.throws(() => selectOnlyCredential(router, failed.id), /没有可用的健康渠道凭证/); now = 1210; const freshCandidate = router.getRecoveryProbeCandidates()[0]; assert.equal(freshCandidate.credential.id, failed.id); router.reportRecoveryProbeSuccess(freshCandidate); assert.equal(selectOnlyCredential(router, failed.id).id, failed.id); }); it('ignores stale recovery probe success after the channel fails again', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = config.credentials[0]; router.reportFailure(failed, { scope: 'channel' }); now = 1100; const staleCandidate = router.getRecoveryProbeCandidates()[0]; now = 1110; router.reportFailure(failed, { scope: 'channel' }); router.reportRecoveryProbeSuccess(staleCandidate); assert.equal(router.getHealthSummary().pendingRecoveryProbeChannelCount, 1); assert.deepEqual( config.credentials.filter((credential) => credential.channelId === 'a').filter(canSelect(router)), [] ); now = 1210; const freshCandidate = router.getRecoveryProbeCandidates()[0]; assert.equal(freshCandidate.scope, 'channel'); router.reportRecoveryProbeSuccess(freshCandidate); assert.deepEqual( config.credentials .filter((credential) => credential.channelId === 'a') .filter(canSelect(router)) .map((credential) => credential.id), ['a#0', 'a#1'] ); }); it('clears the selected credential probe when its channel probe proves the same credential recovered', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = config.credentials[0]; router.reportFailure(failed); now = 1010; router.reportFailure(failed, { scope: 'channel' }); now = 1110; const candidate = router.getRecoveryProbeCandidates()[0]; assert.equal(candidate.scope, 'channel'); assert.equal(candidate.credential.id, failed.id); assert.equal(router.reportRecoveryProbeSuccess(candidate), true); assert.equal(router.getHealthSummary().pendingRecoveryProbeChannelCount, 0); assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 0); assert.equal(selectOnlyCredential(router, failed.id).id, failed.id); }); it('keeps a selected credential pending when its failure is newer than the channel probe', () => { let now = 1000; const router = createChannelRouter({ ...config, strategy: 'round_robin', failureCooldownMs: 100, now: () => now, requireProbeForRecovery: true }); const failed = config.credentials[0]; router.reportFailure(failed, { scope: 'channel' }); now = 1100; const candidate = router.getRecoveryProbeCandidates()[0]; now = 1110; router.reportFailure(failed); assert.equal(router.reportRecoveryProbeSuccess(candidate), true); assert.equal(router.getHealthSummary().pendingRecoveryProbeChannelCount, 0); assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 1); assert.throws(() => selectOnlyCredential(router, failed.id), /没有可用的健康渠道凭证/); }); it('stores sanitized failure diagnostics without exposing API keys', () => { const router = createChannelRouter({ ...config, failureCooldownMs: 100, now: () => 1000 }); router.reportFailure(config.credentials[0], { scope: 'channel', reason: { at: 1000, scope: 'channel', status: 524, code: 'cf_timeout', requestId: 'req_123', message: 'A timeout occurred' } }); const summary = router.getHealthSummary(); assert.deepEqual(summary.lastFailure, { at: 1000, scope: 'channel', status: 524, code: 'cf_timeout', requestId: 'req_123', message: 'A timeout occurred' }); assert.equal(JSON.stringify(summary).includes('a1'), false); }); it('returns a public failure summary without upstream message text', () => { const reason = describeChannelFailure( { status: 524, code: 'cf_timeout', requestID: 'req_123', message: 'upstream message with private details' }, 'channel', 1000 ); assert.deepEqual(toPublicChannelFailure({ ...reason, requestMode: 'images-sse' }), { at: 1000, scope: 'channel', status: 524, code: 'cf_timeout', requestId: 'req_123', requestMode: 'images-sse' }); }); it('captures requestId from non-OpenAI error casing', () => { const reason = describeChannelFailure( { status: 500, requestId: 'req_lowercase' }, 'channel', 1000 ); assert.equal(reason.requestId, 'req_lowercase'); }); }); function canSelect(router: ReturnType) { return (credential: { id: string }) => { try { selectOnlyCredential(router, credential.id); return true; } catch { return false; } }; } function selectOnlyCredential(router: ReturnType, credentialId: string) { for (let attempt = 0; attempt < 10; attempt += 1) { const credential = router.select(); if (credential.id === credentialId) return credential; } throw new RequestValidationError('当前没有可用的健康渠道凭证。', 503); } describe('resolveEffectiveCredential', () => { it('uses request API key while preserving legacy server base URL when request URL is blank', () => { const credential = resolveEffectiveCredential({ requestApiKey: 'sk-browser', requestApiBaseUrl: '', legacyBaseUrl: 'https://legacy.example.com/v1', legacyUpstreamProxyUrl: 'http://proxy.example:8080/', selectedCredential: { id: 'server#0', channelId: 'server', apiKey: 'sk-server', baseUrl: 'https://server.example.com/v1', upstreamProfile: 'openai-compatible' } }); assert.deepEqual(credential, { apiKey: 'sk-browser', baseUrl: 'https://legacy.example.com/v1', upstreamProxyUrl: 'http://proxy.example:8080/', upstreamProfile: 'openai-compatible' }); }); it('uses selected server credential when request API key is blank', () => { const selectedCredential = { id: 'server#0', channelId: 'server', apiKey: 'sk-server', baseUrl: 'https://server.example.com/v1', upstreamProfile: 'openai-compatible' } as const; assert.deepEqual( resolveEffectiveCredential({ requestApiKey: '', requestApiBaseUrl: '', legacyBaseUrl: 'https://legacy.example.com/v1', selectedCredential }), { apiKey: 'sk-server', baseUrl: 'https://server.example.com/v1', upstreamProfile: 'openai-compatible', upstreamHeaders: undefined, selectedCredential } ); }); it('does not send a selected server credential to a request-supplied base URL without a request key', () => { const selectedCredential = { id: 'server#0', channelId: 'server', apiKey: 'sk-server', baseUrl: 'https://server.example.com/v1', upstreamProfile: 'openai-compatible', upstreamHeaders: { 'X-App-ID': 'server-app' } } as const; assert.deepEqual( resolveEffectiveCredential({ requestApiKey: '', requestApiBaseUrl: 'https://attacker.example.com/v1', selectedCredential }), { apiKey: 'sk-server', baseUrl: 'https://server.example.com/v1', upstreamProfile: 'openai-compatible', upstreamHeaders: { 'X-App-ID': 'server-app' }, selectedCredential } ); }); }); describe('isCredentialFailure', () => { it('treats auth, quota, and rate limit responses as credential failures', () => { assert.equal(isCredentialFailure({ status: 401 }), true); assert.equal(isCredentialFailure({ status: 403 }), true); assert.equal(isCredentialFailure({ status: 429 }), true); assert.equal(isCredentialFailure({ code: 'insufficient_quota' }), true); assert.equal(isCredentialFailure({ error: { code: 'invalid_api_key' } }), true); }); it('does not mark request validation errors as credential failures', () => { assert.equal(isCredentialFailure({ status: 400, code: 'invalid_request_error' }), false); assert.equal(isCredentialFailure(new Error('prompt is required')), false); }); it('prefers explicit credential error codes even when a gateway wraps them in 5xx status', () => { assert.equal(isCredentialFailure({ status: 503, error: { code: 'invalid_api_key' } }), true); assert.equal(isCredentialFailure({ status: 500, code: 'insufficient_quota' }), true); }); it('does not treat local channel capacity queue errors as credential failures', () => { assert.equal(isCredentialFailure(createCapacityQueueError()), false); }); }); describe('isChannelRequestModeFailure', () => { it('treats Responses image_generation-disabled 403 as a request-mode failure', () => { assert.equal( isChannelRequestModeFailure( new Error('status_code=403, Image generation is not enabled for this group'), 'responses-sse' ), true ); assert.equal( isChannelRequestModeFailure( { status: 403, error: { message: 'Image generation is not enabled for this group' } }, 'responses-non-stream' ), true ); }); it('does not apply the Responses image_generation-disabled rule to Images API modes', () => { assert.equal( isChannelRequestModeFailure( { status: 403, message: 'Image generation is not enabled for this group' }, 'images-sse' ), false ); }); }); describe('isChannelFailure', () => { it('treats upstream 5xx and CDN timeout statuses as channel failures', () => { [500, 502, 503, 504, 520, 522, 523, 524].forEach((status) => { assert.equal(isChannelFailure({ status }), true); }); }); it('does not treat credential or request errors as channel failures', () => { assert.equal(isChannelFailure({ status: 400 }), false); assert.equal(isChannelFailure({ status: 401 }), false); assert.equal(isChannelFailure({ status: 429 }), false); assert.equal(isChannelFailure({ status: 503, error: { code: 'invalid_api_key' } }), false); assert.equal(isChannelFailure(new Error('prompt is required')), false); }); it('does not treat local validation errors as channel failures even when they use a 500 response', () => { assert.equal(isChannelFailure(new RequestValidationError('local config error', 500)), false); }); it('does not treat local channel capacity queue errors as channel failures', () => { assert.equal(isChannelFailure(createCapacityQueueError()), false); }); it('treats upstream connection and timeout failures as channel failures', () => { const connectionError = Object.assign(new Error('Connection error.'), { name: 'APIConnectionError' }); const timeoutError = Object.assign(new Error('Request timed out.'), { name: 'APIConnectionTimeoutError' }); const dnsError = Object.assign(new Error('getaddrinfo ENOTFOUND api.example.com'), { code: 'ENOTFOUND' }); const nestedConnectionError = Object.assign(new Error('Connection error.'), { cause: Object.assign(new TypeError('fetch failed'), { cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:65535'), { code: 'ECONNREFUSED' }) }) }); const messageOnlyConnectionError = new Error('Connection error.'); assert.equal(isChannelFailure(connectionError), true); assert.equal(isChannelFailure(timeoutError), true); assert.equal(isChannelFailure(dnsError), true); assert.equal(isChannelFailure(nestedConnectionError), true); assert.equal(isChannelFailure(messageOnlyConnectionError), true); }); }); function createCapacityQueueError(): ChannelCapacityQueueError { return new ChannelCapacityQueueError({ code: 'channel_capacity_queue_timeout', message: '渠道凭证并发队列等待超时,请稍后重试。', status: 429, retryable: true, details: { credential_id: 'official#0', queue_position: 1, max_wait_ms: 420000 } }); }