diff --git a/packages/api/src/endpoints/config/providers.spec.ts b/packages/api/src/endpoints/config/providers.spec.ts new file mode 100644 index 0000000000..7d8ba9241b --- /dev/null +++ b/packages/api/src/endpoints/config/providers.spec.ts @@ -0,0 +1,99 @@ +import { Providers } from '@librechat/agents'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { getProviderConfig, providerConfigMap } from './providers'; + +const buildAppConfig = ( + customEndpoints: Array<{ name: string; baseURL?: string; apiKey?: string }>, +): AppConfig => + ({ + endpoints: { + [EModelEndpoint.custom]: customEndpoints, + }, + }) as unknown as AppConfig; + +describe('getProviderConfig', () => { + it('resolves the existing google (API key) path to initializeGoogle', () => { + // Regression guard: the API-key path uses `Providers.GOOGLE === 'google'`, + // which has always mapped via `EModelEndpoint.google`. Adding the + // `Providers.VERTEXAI` entry must not perturb this. + const result = getProviderConfig({ + provider: Providers.GOOGLE, + appConfig: buildAppConfig([]), + }); + + expect(result.overrideProvider).toBe(Providers.GOOGLE); + expect(result.getOptions).toBe(providerConfigMap[EModelEndpoint.google]); + expect(result.customEndpointConfig).toBeUndefined(); + }); + + it('vertexai resolves to the same initializer as google (issue #13006 follow-up)', () => { + const result = getProviderConfig({ + provider: Providers.VERTEXAI, + appConfig: buildAppConfig([]), + }); + + expect(result.overrideProvider).toBe(Providers.VERTEXAI); + expect(result.getOptions).toBe(providerConfigMap[EModelEndpoint.google]); + expect(result.customEndpointConfig).toBeUndefined(); + }); + + it('falls back case-insensitively when only a CamelCase match exists', () => { + // Agent runtime resolved provider to lowercase `"openrouter"`, but the + // user's `librechat.yaml` declared `name: "OpenRouter"`. + const appConfig = buildAppConfig([ + { name: 'OpenRouter', baseURL: 'https://openrouter.ai/api/v1', apiKey: 'sk-test' }, + ]); + + const result = getProviderConfig({ provider: 'openrouter', appConfig }); + + expect(result.overrideProvider).toBe(Providers.OPENROUTER); + expect(result.customEndpointConfig?.name).toBe('OpenRouter'); + }); + + it('prefers the exact-case match when both casings exist (preserves case-sensitive identity)', () => { + // Two distinct custom endpoints differing only in case is supported by + // `loadCustomEndpointsConfig` (the keys are case-preserving). Direct + // exact-case lookup should win — case-insensitive fallback must not + // shadow the user's intent. + const appConfig = buildAppConfig([ + { name: 'OpenRouter', baseURL: 'https://prod.example/v1', apiKey: 'prod' }, + { name: 'openrouter', baseURL: 'https://staging.example/v1', apiKey: 'staging' }, + ]); + + const result = getProviderConfig({ provider: 'openrouter', appConfig }); + + expect(result.customEndpointConfig?.baseURL).toBe('https://staging.example/v1'); + }); + + it('resolves an exact-case lowercase entry', () => { + const appConfig = buildAppConfig([ + { name: 'openrouter', baseURL: 'https://openrouter.ai/api/v1', apiKey: 'sk-test' }, + ]); + + const result = getProviderConfig({ provider: 'openrouter', appConfig }); + + expect(result.customEndpointConfig?.name).toBe('openrouter'); + }); + + it('throws on ambiguous case-insensitive matches when no exact-case entry exists (codex review)', () => { + // User has two distinct entries differing only in case, both + // non-lowercase. The agent runtime resolves provider to lowercase + // "openrouter" — neither matches case-sensitively, and silently + // picking array-first could route requests to the wrong baseURL/apiKey. + const appConfig = buildAppConfig([ + { name: 'OpenRouter', baseURL: 'https://prod.example/v1', apiKey: 'prod' }, + { name: 'OPENROUTER', baseURL: 'https://canary.example/v1', apiKey: 'canary' }, + ]); + + expect(() => getProviderConfig({ provider: 'openrouter', appConfig })).toThrow( + /ambiguous.*OpenRouter.*OPENROUTER/i, + ); + }); + + it('throws when openrouter has no matching custom endpoint at all', () => { + expect(() => + getProviderConfig({ provider: 'openrouter', appConfig: buildAppConfig([]) }), + ).toThrow('Provider openrouter not supported'); + }); +}); diff --git a/packages/api/src/endpoints/config/providers.ts b/packages/api/src/endpoints/config/providers.ts index 5e5151b548..8ef4073805 100644 --- a/packages/api/src/endpoints/config/providers.ts +++ b/packages/api/src/endpoints/config/providers.ts @@ -27,13 +27,21 @@ export function isKnownCustomProvider(provider?: string): boolean { } /** - * Provider configuration map mapping providers to their initialization functions + * Provider configuration map mapping providers to their initialization functions. + * + * `Providers.VERTEXAI` shares `initializeGoogle` because the runtime distinction + * is auth-only — the agent flow may resolve `agent.provider` to `vertexai` when + * a service account is configured, but summarization (and other downstream + * resolvers) get the same lowercase enum value passed back. Without this + * mapping `getProviderConfig` throws "Provider vertexai not supported" and + * summarization falls back to the raw provider, dropping client overrides. */ export const providerConfigMap: Record = { [Providers.XAI]: initializeCustom, [Providers.DEEPSEEK]: initializeCustom, [Providers.MOONSHOT]: initializeCustom, [Providers.OPENROUTER]: initializeCustom, + [Providers.VERTEXAI]: initializeGoogle, [EModelEndpoint.openAI]: initializeOpenAI, [EModelEndpoint.google]: initializeGoogle, [EModelEndpoint.bedrock]: initializeBedrock, @@ -87,6 +95,41 @@ export function getProviderConfig({ if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) { customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig }); + if (!customEndpointConfig && appConfig) { + /** + * Case-insensitive fallback for known custom providers only. + * + * The agent main flow looks up custom endpoints case-sensitively + * (case-preserving keys are how `loadCustomEndpointsConfig` lets + * users have e.g. `"OpenRouter"` and `"openrouter-staging"` as + * distinct entries). After it succeeds, `agent.provider` is + * normalized to the lowercase `Providers` enum value + * (e.g. `"openrouter"`). Downstream resolvers (summarization, + * title) re-enter `getProviderConfig` with that lowercase value, + * and the case-sensitive direct lookup misses configs whose + * `name` is camel-cased — the most common shape. + * + * Only fall back when the direct lookup already failed, so users + * with case-sensitive endpoint identity are unaffected — their + * exact-case match wins first. When multiple case-insensitive + * matches exist (e.g. both `OpenRouter` and `OPENROUTER`, neither + * lowercase), refuse to silently pick array-first; the caller's + * intent is ambiguous and either entry could route requests with + * different baseURL/apiKey. + */ + const customEndpoints = appConfig.endpoints?.[EModelEndpoint.custom] ?? []; + const target = provider.toLowerCase(); + const matches = customEndpoints.filter( + (endpointConfig) => (endpointConfig.name ?? '').toLowerCase() === target, + ); + if (matches.length > 1) { + const names = matches.map((m) => m.name ?? '').join(', '); + throw new Error( + `Provider ${provider} is ambiguous: multiple custom endpoints match case-insensitively (${names}). Rename one or use the exact-case provider value.`, + ); + } + customEndpointConfig = matches[0]; + } if (!customEndpointConfig) { throw new Error(`Provider ${provider} not supported`); }