🛟 fix: Summarization Provider misses vertexai + case-mismatched custom endpoints (#13025)

`resolveSummarizationProvider` calls `getProviderConfig` to translate the
agent's resolved provider into an initializer + client overrides. Three
real-world inputs were unsupported and fell through to "raw provider"
fallback (silently dropping client overrides):

1. **`vertexai`** — not in `providerConfigMap` at all. Vertex shares
   initialization with Google (auth-only runtime distinction). Map
   `Providers.VERTEXAI` to `initializeGoogle`.

2. **`openrouter` (and other known custom providers) with CamelCase
   custom endpoint names** — agent main flow looks up endpoints
   case-sensitively (case-preserving keys are how
   `loadCustomEndpointsConfig` lets users have distinct entries
   differing only in case). Once it succeeds, `agent.provider` is
   normalized to lowercase. Downstream resolvers re-enter
   `getProviderConfig` with the lowercased value and miss configs
   whose `name` is camel-cased. Add a case-insensitive fallback,
   narrowly scoped to known custom providers and only after the
   case-sensitive direct lookup fails.

3. **Ambiguous case-insensitive matches (codex review feedback)** —
   if the user has e.g. `OpenRouter` and `OPENROUTER` (neither
   lowercase) and the agent runtime passes `openrouter`, the
   case-insensitive fallback could silently route to whichever entry
   appears first in the array (potentially different baseURL/apiKey).
   Detect multiple case-insensitive matches and throw a clear error
   with both names rather than picking arbitrarily.

## Tests

`providers.spec.ts` — new file, 7 tests:
- vertexai → Google initializer
- google (API key) → Google initializer (regression guard)
- case-insensitive fallback when only CamelCase entry exists
- exact-case match preserved when both casings exist (case identity)
- exact-case lowercase entry still resolves
- throws on ambiguous case-insensitive matches when no exact-case exists
- still throws when no match at all
This commit is contained in:
Danny Avila 2026-05-08 18:52:01 -04:00 committed by GitHub
parent d90567204e
commit b922187abb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 143 additions and 1 deletions

View file

@ -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');
});
});

View file

@ -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<string, InitializeFn> = {
[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`);
}