mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🛟 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:
parent
d90567204e
commit
b922187abb
2 changed files with 143 additions and 1 deletions
99
packages/api/src/endpoints/config/providers.spec.ts
Normal file
99
packages/api/src/endpoints/config/providers.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue