From 25280939814fb67b43f142be2029095eec353771 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 10:49:51 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=94=A7=20fix:=20resolve=20custom-endp?= =?UTF-8?q?oint=20providers=20for=20summarization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `summarization.provider` in `librechat.yaml` is set to a custom-endpoint name (e.g. `Ollama`), the string was passed verbatim to the agents SDK, which only knows a fixed set of provider names and threw `Unsupported LLM provider: Ollama`. Before shaping the summarization config for the SDK, resolve the provider through `getProviderConfig`: custom-endpoint labels are remapped to the underlying SDK provider (e.g. `openAI`) and the endpoint's baseURL/apiKey are injected into `parameters` so the summarization model reaches the right backend, even when summarization targets a different custom endpoint than the main agent. Unknown names and names that appear with no matching endpoint flow through unchanged so the SDK can surface a clear error. User-provided credentials and unresolved env-var references are skipped rather than forwarded, letting the SDK's self-summarize path reuse the agent's own clientOptions. Ref: LibreChat Discussion #12614 --- api/server/controllers/agents/client.js | 1 + api/server/controllers/agents/openai.js | 1 + api/server/controllers/agents/responses.js | 2 + .../__tests__/run-summarization.test.ts | 168 ++++++++++++++++++ packages/api/src/agents/run.ts | 98 +++++++++- 5 files changed, 266 insertions(+), 4 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 5f8aebb56d70..71fd843b0f16 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -836,6 +836,7 @@ class AgentClient extends BaseClient { requestBody: config.configurable.requestBody, user: createSafeUser(this.options.req?.user), summarizationConfig: appConfig?.summarization, + appConfig, tokenCounter, }); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 9fa3af82c303..3cc7e93138ae 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -477,6 +477,7 @@ const OpenAIChatCompletionController = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, + appConfig, signal: abortController.signal, customHandlers: handlers, requestBody: { diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 7abddf5e2f74..dba04e92c51f 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -492,6 +492,7 @@ const createResponse = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, + appConfig, signal: abortController.signal, customHandlers: handlers, requestBody: { @@ -655,6 +656,7 @@ const createResponse = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, + appConfig, signal: abortController.signal, customHandlers: handlers, requestBody: { diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 2bc0da253a98..dc6d9cefc64f 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -1,4 +1,6 @@ +import type { AppConfig } from '@librechat/data-schemas'; import type { SummarizationConfig } from 'librechat-data-provider'; +import { EModelEndpoint } from 'librechat-data-provider'; import { createRun } from '~/agents/run'; // Mock winston logger @@ -57,6 +59,7 @@ async function callAndCapture( agents?: ReturnType[]; summarizationConfig?: SummarizationConfig; initialSummary?: { text: string; tokenCount: number }; + appConfig?: AppConfig; } = {}, ) { const agents = opts.agents ?? [makeAgent()]; @@ -67,6 +70,7 @@ async function callAndCapture( signal, summarizationConfig: opts.summarizationConfig, initialSummary: opts.initialSummary, + appConfig: opts.appConfig, streaming: true, streamUsage: true, }); @@ -77,6 +81,17 @@ async function callAndCapture( return callArgs.graphConfig.agents as Array>; } +/** Minimal AppConfig with a single custom endpoint for testing provider resolution. */ +function makeAppConfig( + customEndpoints: Array<{ name: string; baseURL: string; apiKey: string }>, +): AppConfig { + return { + endpoints: { + [EModelEndpoint.custom]: customEndpoints, + }, + } as unknown as AppConfig; +} + beforeEach(() => { jest.clearAllMocks(); }); @@ -297,3 +312,156 @@ describe('initialSummary passthrough', () => { expect(agents[0].initialSummary).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Suite 7: custom-endpoint provider resolution +// --------------------------------------------------------------------------- +describe('custom-endpoint provider resolution', () => { + it('remaps a custom endpoint name to openAI and injects baseURL/apiKey', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('openAI'); + expect(config.model).toBe('llama3'); + + const parameters = config.parameters as Record; + expect(parameters).toMatchObject({ + configuration: { baseURL: 'http://localhost:11434/v1' }, + apiKey: 'ollama-key', + }); + }); + + it('is case-insensitive when matching custom endpoint names', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('openAI'); + expect((config.parameters as Record).apiKey).toBe('ollama-key'); + }); + + it('leaves known SDK providers untouched', async () => { + const appConfig = makeAppConfig([]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'anthropic', model: 'claude' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('anthropic'); + expect(config.parameters).toBeUndefined(); + }); + + it('preserves unknown provider names when appConfig is missing', async () => { + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('Ollama'); + expect(config.parameters).toBeUndefined(); + }); + + it('leaves unrecognized names untouched when no matching custom endpoint exists', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'nonexistent', model: 'foo' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('nonexistent'); + expect(config.parameters).toBeUndefined(); + }); + + it('extracts ${ENV_VAR} references in custom endpoint credentials', async () => { + process.env.TEST_OLLAMA_KEY = 'resolved-key-value'; + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'http://localhost:11434/v1', + apiKey: '${TEST_OLLAMA_KEY}', + }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + expect(parameters.apiKey).toBe('resolved-key-value'); + delete process.env.TEST_OLLAMA_KEY; + }); + + it('skips override when apiKey is marked user_provided', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'user_provided' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + // Provider still remapped to the SDK-recognized name... + expect(config.provider).toBe('openAI'); + // ...but credentials are not forwarded (async user lookup not supported here; + // SDK's self-summarize path will reuse the agent's clientOptions). + expect(config.parameters).toBeUndefined(); + }); + + it('skips override when env var reference cannot be resolved', async () => { + delete process.env.UNSET_TEST_KEY; + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'http://localhost:11434/v1', + apiKey: '${UNSET_TEST_KEY}', + }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('openAI'); + expect(config.parameters).toBeUndefined(); + }); + + it('merges overrides alongside user-supplied parameters', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { + provider: 'Ollama', + model: 'llama3', + parameters: { temperature: 0.2 }, + }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + expect(parameters).toMatchObject({ + temperature: 0.2, + configuration: { baseURL: 'http://localhost:11434/v1' }, + apiKey: 'ollama-key', + }); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index b6b5e6a14d90..b8e951da2b70 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,5 +1,10 @@ import { Run, Providers, Constants } from '@librechat/agents'; -import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider'; +import { + envVarRegex, + KnownEndpoints, + extractEnvVariable, + providerEndpointMap, +} from 'librechat-data-provider'; import type { SummarizationConfig as AgentSummarizationConfig, MultiAgentGraphConfig, @@ -15,9 +20,11 @@ import type { } from '@librechat/agents'; import type { Agent, SummarizationConfig } from 'librechat-data-provider'; import type { BaseMessage } from '@langchain/core/messages'; -import type { IUser } from '@librechat/data-schemas'; +import type { AppConfig, IUser } from '@librechat/data-schemas'; import type * as t from '~/types'; +import { getProviderConfig } from '~/endpoints/config/providers'; import { resolveHeaders, createSafeUser } from '~/utils/env'; +import { isUserProvided } from '~/utils/common'; /** Expected shape of JSON tool search results */ interface ToolSearchJsonResult { @@ -186,26 +193,102 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +/** Client-option overrides for summarization models targeting custom endpoints. */ +interface SummarizationClientOverrides { + configuration?: { baseURL: string }; + apiKey?: string; +} + +/** + * Resolves a summarization provider string (which may be a custom-endpoint name + * like "Ollama") into the SDK-recognized provider and any baseURL/apiKey + * overrides required to talk to that endpoint. + * + * Without this step, a `summarization.provider: "Ollama"` entry in + * `librechat.yaml` flows verbatim to the agents SDK, which only knows a fixed + * set of provider names and throws "Unsupported LLM provider: Ollama". + */ +function resolveSummarizationProvider( + rawProvider: string, + appConfig: AppConfig | undefined, +): { + provider: string; + clientOverrides?: SummarizationClientOverrides; +} { + if (!appConfig || !isNonEmptyString(rawProvider)) { + return { provider: rawProvider }; + } + try { + const { overrideProvider, customEndpointConfig } = getProviderConfig({ + provider: rawProvider, + appConfig, + }); + if (!customEndpointConfig) { + return { provider: overrideProvider }; + } + const rawApiKey = customEndpointConfig.apiKey ?? ''; + const rawBaseURL = customEndpointConfig.baseURL ?? ''; + /** + * User-provided credentials require an async DB lookup and expiry checks + * that are out of scope here. Fall back to the remapped provider and let + * the SDK's self-summarize path reuse the agent's own client options. + */ + if (isUserProvided(rawApiKey) || isUserProvided(rawBaseURL)) { + return { provider: overrideProvider }; + } + const apiKey = extractEnvVariable(rawApiKey); + const baseURL = extractEnvVariable(rawBaseURL); + if (!apiKey || !baseURL || apiKey.match(envVarRegex) || baseURL.match(envVarRegex)) { + return { provider: overrideProvider }; + } + return { + provider: overrideProvider, + clientOverrides: { + configuration: { baseURL }, + apiKey, + }, + }; + } catch { + return { provider: rawProvider }; + } +} + /** Shapes a SummarizationConfig into the format expected by AgentInputs. */ function shapeSummarizationConfig( config: SummarizationConfig | undefined, fallbackProvider: string, fallbackModel: string | undefined, + appConfig: AppConfig | undefined, ) { - const provider = config?.provider ?? fallbackProvider; + const rawProvider = config?.provider ?? fallbackProvider; + const { provider, clientOverrides } = resolveSummarizationProvider(rawProvider, appConfig); const model = config?.model ?? fallbackModel; const trigger = config?.trigger?.type && config?.trigger?.value ? { type: config.trigger.type, value: config.trigger.value } : undefined; + /** + * Custom-endpoint overrides (baseURL/apiKey) are merged into `parameters` so + * that the SDK's `buildSummarizationClientConfig` spreads them onto the + * summarization client options. This is required when summarization targets + * a different custom endpoint than the main agent. + */ + const parameters = + clientOverrides != null + ? { + ...(config?.parameters ?? {}), + ...clientOverrides, + } + : config?.parameters; + return { enabled: config?.enabled !== false && isNonEmptyString(provider) && isNonEmptyString(model), config: { trigger, provider, model, - parameters: config?.parameters, + parameters, prompt: config?.prompt, updatePrompt: config?.updatePrompt, reserveRatio: config?.reserveRatio, @@ -260,6 +343,7 @@ export async function createRun({ summarizationConfig, initialSummary, calibrationRatio, + appConfig, streaming = true, streamUsage = true, }: { @@ -277,6 +361,11 @@ export async function createRun({ initialSummary?: { text: string; tokenCount: number }; /** Calibration ratio from previous run's contextMeta, seeds the pruner EMA */ calibrationRatio?: number; + /** + * Resolved app config. Used to translate custom-endpoint provider names + * (e.g. "Ollama") in the summarization config to SDK-recognized providers. + */ + appConfig?: AppConfig; } & Pick): Promise< Run > { @@ -307,6 +396,7 @@ export async function createRun({ agent.summarization ?? summarizationConfig, provider as string, selfModel, + appConfig, ); const llmConfig: t.RunLLMConfig = Object.assign( From 60f5ad6593e2ba9a677791eb56145d215ca2ccec Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 11:08:24 -0400 Subject: [PATCH 2/9] address: widen unresolved-env-var guard, fix test naming - Reject summarization overrides when the extracted baseURL/apiKey still contains any `${...}` placeholder, including prefix/suffix patterns like `https://${UNSET}.example.com` that `envVarRegex` (exact-match) missed. - Rename the "case-insensitive" test to reflect that only `Ollama` is normalized via `normalizeEndpointName`; add coverage proving other custom-endpoint names match case-sensitively. --- .../__tests__/run-summarization.test.ts | 46 ++++++++++++++++++- packages/api/src/agents/run.ts | 27 ++++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index dc6d9cefc64f..30b8cd89eab3 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -337,7 +337,7 @@ describe('custom-endpoint provider resolution', () => { }); }); - it('is case-insensitive when matching custom endpoint names', async () => { + it('matches Ollama case-insensitively (via normalizeEndpointName)', async () => { const appConfig = makeAppConfig([ { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, ]); @@ -351,6 +351,30 @@ describe('custom-endpoint provider resolution', () => { expect((config.parameters as Record).apiKey).toBe('ollama-key'); }); + it('resolves non-Ollama endpoints on exact-case match', async () => { + const appConfig = makeAppConfig([ + { name: 'Together', baseURL: 'https://api.together.ai/v1', apiKey: 'together-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Together', model: 'mixtral' }, + appConfig, + }); + expect((agents[0].summarizationConfig as Record).provider).toBe('openAI'); + }); + + it('does not match non-Ollama endpoints with different casing', async () => { + const appConfig = makeAppConfig([ + { name: 'Together', baseURL: 'https://api.together.ai/v1', apiKey: 'together-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'together', model: 'mixtral' }, + appConfig, + }); + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('together'); + expect(config.parameters).toBeUndefined(); + }); + it('leaves known SDK providers untouched', async () => { const appConfig = makeAppConfig([]); const agents = await callAndCapture({ @@ -443,6 +467,26 @@ describe('custom-endpoint provider resolution', () => { expect(config.parameters).toBeUndefined(); }); + it('skips override when partial env var reference (prefix/suffix) stays unresolved', async () => { + delete process.env.UNSET_TEST_SEGMENT; + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'https://${UNSET_TEST_SEGMENT}.example.com/v1', + apiKey: 'ollama-key', + }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('openAI'); + /** Even though the baseURL is a partial-match pattern, it must not be forwarded. */ + expect(config.parameters).toBeUndefined(); + }); + it('merges overrides alongside user-supplied parameters', async () => { const appConfig = makeAppConfig([ { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index b8e951da2b70..d63e9845cc15 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,10 +1,5 @@ import { Run, Providers, Constants } from '@librechat/agents'; -import { - envVarRegex, - KnownEndpoints, - extractEnvVariable, - providerEndpointMap, -} from 'librechat-data-provider'; +import { KnownEndpoints, extractEnvVariable, providerEndpointMap } from 'librechat-data-provider'; import type { SummarizationConfig as AgentSummarizationConfig, MultiAgentGraphConfig, @@ -193,6 +188,13 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +const UNRESOLVED_ENV_VAR_PLACEHOLDER = /\$\{[^}]+\}/; + +/** True if the string still contains a `${VAR}` placeholder after env extraction. */ +function hasUnresolvedPlaceholder(value: string): boolean { + return UNRESOLVED_ENV_VAR_PLACEHOLDER.test(value); +} + /** Client-option overrides for summarization models targeting custom endpoints. */ interface SummarizationClientOverrides { configuration?: { baseURL: string }; @@ -238,7 +240,18 @@ function resolveSummarizationProvider( } const apiKey = extractEnvVariable(rawApiKey); const baseURL = extractEnvVariable(rawBaseURL); - if (!apiKey || !baseURL || apiKey.match(envVarRegex) || baseURL.match(envVarRegex)) { + /** + * `extractEnvVariable` leaves any unresolved `${VAR}` placeholder in place + * — including in the middle of a prefix/suffix string — when the env var + * is missing. Reject overrides whenever either value still contains any + * unresolved placeholder so the SDK doesn't receive a broken URL/key. + */ + if ( + !apiKey || + !baseURL || + hasUnresolvedPlaceholder(apiKey) || + hasUnresolvedPlaceholder(baseURL) + ) { return { provider: overrideProvider }; } return { From e68e7c8f750cf1158abd3862f5af09caf45352af Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 11:27:45 -0400 Subject: [PATCH 3/9] address: use req.config in responses.js; forward full endpoint options - `responses.js` relied on a module-level `appConfig` set via `setAppConfig`, which is never called anywhere. Use `req.config` directly so the summarization provider resolver actually runs on the responses route. - Route the custom endpoint config through `getOpenAIConfig` so summarization inherits the same `headers`, `defaultQuery`, `addParams`/`dropParams`, and `customParams` transforms (Anthropic/Google/etc.) that `initializeCustom` applies for the main agent flow. Strip the stale `model`/`modelName` defaults so `summarization.model` still wins. --- api/server/controllers/agents/responses.js | 4 +- .../__tests__/run-summarization.test.ts | 41 ++++++++++++++++- packages/api/src/agents/run.ts | 46 +++++++++++++++---- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index dba04e92c51f..6789698a2af9 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -492,7 +492,7 @@ const createResponse = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, - appConfig, + appConfig: req.config, signal: abortController.signal, customHandlers: handlers, requestBody: { @@ -656,7 +656,7 @@ const createResponse = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, - appConfig, + appConfig: req.config, signal: abortController.signal, customHandlers: handlers, requestBody: { diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 30b8cd89eab3..7f260a656e86 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -504,8 +504,47 @@ describe('custom-endpoint provider resolution', () => { const parameters = config.parameters as Record; expect(parameters).toMatchObject({ temperature: 0.2, - configuration: { baseURL: 'http://localhost:11434/v1' }, apiKey: 'ollama-key', }); + const configuration = parameters.configuration as Record; + expect(configuration.baseURL).toBe('http://localhost:11434/v1'); + }); + + it('forwards custom-endpoint headers as configuration.defaultHeaders', async () => { + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'http://localhost:11434/v1', + apiKey: 'ollama-key', + headers: { 'X-Custom-Header': 'value-123' }, + } as unknown as { name: string; baseURL: string; apiKey: string }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + const configuration = parameters.configuration as Record; + const defaultHeaders = configuration.defaultHeaders as Record; + expect(defaultHeaders['X-Custom-Header']).toBe('value-123'); + }); + + it('does not leak model/modelName from getOpenAIConfig defaults', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.model).toBe('llama3'); + const parameters = config.parameters as Record; + /** Summarization.model must win — parameters must not carry a stale model/modelName. */ + expect(parameters.model).toBeUndefined(); + expect(parameters.modelName).toBeUndefined(); }); }); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index d63e9845cc15..dca47e8d13e3 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -18,6 +18,7 @@ import type { BaseMessage } from '@langchain/core/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type * as t from '~/types'; import { getProviderConfig } from '~/endpoints/config/providers'; +import { getOpenAIConfig } from '~/endpoints/openai/config'; import { resolveHeaders, createSafeUser } from '~/utils/env'; import { isUserProvided } from '~/utils/common'; @@ -196,14 +197,11 @@ function hasUnresolvedPlaceholder(value: string): boolean { } /** Client-option overrides for summarization models targeting custom endpoints. */ -interface SummarizationClientOverrides { - configuration?: { baseURL: string }; - apiKey?: string; -} +type SummarizationClientOverrides = Record; /** * Resolves a summarization provider string (which may be a custom-endpoint name - * like "Ollama") into the SDK-recognized provider and any baseURL/apiKey + * like "Ollama") into the SDK-recognized provider and any client-option * overrides required to talk to that endpoint. * * Without this step, a `summarization.provider: "Ollama"` entry in @@ -254,12 +252,42 @@ function resolveSummarizationProvider( ) { return { provider: overrideProvider }; } + /** + * Run the endpoint config through `getOpenAIConfig` so summarization + * inherits the same `headers`, `defaultQuery`, `addParams`/`dropParams`, + * and `customParams` transforms that `initializeCustom` applies for the + * main agent flow. Without this, summarization drops endpoint-specific + * behavior (e.g. Anthropic/Google param transforms, required headers) + * that the main agent relied on. + */ + const { llmConfig, configOptions } = getOpenAIConfig( + apiKey, + { + reverseProxyUrl: baseURL, + headers: customEndpointConfig.headers, + addParams: customEndpointConfig.addParams, + dropParams: customEndpointConfig.dropParams, + customParams: customEndpointConfig.customParams, + directEndpoint: customEndpointConfig.directEndpoint, + }, + rawProvider, + ); + const clientOverrides: SummarizationClientOverrides = { + ...llmConfig, + }; + if (configOptions) { + clientOverrides.configuration = configOptions; + } + /** + * `model`/`modelName` on `llmConfig` default to whatever `getOpenAIConfig` + * produces from empty modelOptions. Strip them so the user-supplied + * `summarization.model` wins. + */ + delete clientOverrides.model; + delete clientOverrides.modelName; return { provider: overrideProvider, - clientOverrides: { - configuration: { baseURL }, - apiKey, - }, + clientOverrides, }; } catch { return { provider: rawProvider }; From 7bfd0416454b22ef9953f8c4cbe31c40ec8eb5e5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 11:39:45 -0400 Subject: [PATCH 4/9] address: skip overrides when summarization matches agent endpoint When `summarization.provider` resolves to the same custom endpoint as the main agent, rely on the SDK's self-summarize path (which reuses `agentContext.clientOptions` unchanged) rather than injecting overrides. Otherwise the shallow spread of `clientOverrides.configuration` would replace the agent's request-resolved state (dynamic headers, proxy/fetch options) with yaml-only config. Only applies when summarization targets a *different* endpoint from the agent; the yaml config is all we have in that case, so overrides still flow through. --- .../__tests__/run-summarization.test.ts | 55 +++++++++++++++++++ packages/api/src/agents/run.ts | 39 +++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 7f260a656e86..d935d5433dbd 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -531,6 +531,61 @@ describe('custom-endpoint provider resolution', () => { expect(defaultHeaders['X-Custom-Header']).toBe('value-123'); }); + it('skips overrides when summarization targets the same endpoint as the agent', async () => { + /** + * When summarization provider matches the agent's endpoint, we rely on + * the SDK's self-summarize path (which reuses agentContext.clientOptions). + * Overriding here would shallow-replace the agent's resolved configuration + * (dynamic headers, proxy/fetch options) with yaml-only config. + */ + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + agents: [makeAgent({ provider: 'openAI', endpoint: 'Ollama' })], + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.provider).toBe('openAI'); + /** No overrides injected — SDK will pull from agentContext.clientOptions. */ + expect(config.parameters).toBeUndefined(); + }); + + it('skips overrides when endpoints differ only by case for Ollama', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + agents: [makeAgent({ provider: 'openAI', endpoint: 'Ollama' })], + summarizationConfig: { provider: 'ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + expect(config.parameters).toBeUndefined(); + }); + + it('applies overrides when summarization targets a different endpoint than the agent', async () => { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + { name: 'Together', baseURL: 'https://api.together.ai/v1', apiKey: 'together-key' }, + ]); + const agents = await callAndCapture({ + agents: [makeAgent({ provider: 'openAI', endpoint: 'Ollama' })], + summarizationConfig: { provider: 'Together', model: 'mixtral' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + expect(parameters.apiKey).toBe('together-key'); + expect((parameters.configuration as Record).baseURL).toBe( + 'https://api.together.ai/v1', + ); + }); + it('does not leak model/modelName from getOpenAIConfig defaults', async () => { const appConfig = makeAppConfig([ { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index dca47e8d13e3..cd7457c8121e 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -196,6 +196,17 @@ function hasUnresolvedPlaceholder(value: string): boolean { return UNRESOLVED_ENV_VAR_PLACEHOLDER.test(value); } +/** + * Lightweight endpoint-name normalizer for comparing summarization-provider + * strings against the agent's endpoint. Mirrors `normalizeEndpointName` in + * `librechat-data-provider` (only Ollama is special-cased to be + * case-insensitive), so an agent on `"Ollama"` and summarization on `"ollama"` + * are treated as the same endpoint. + */ +function normalizedEndpointName(name: string): string { + return name.toLowerCase() === 'ollama' ? 'ollama' : name; +} + /** Client-option overrides for summarization models targeting custom endpoints. */ type SummarizationClientOverrides = Record; @@ -300,9 +311,25 @@ function shapeSummarizationConfig( fallbackProvider: string, fallbackModel: string | undefined, appConfig: AppConfig | undefined, + agentEndpoint: string | undefined, ) { const rawProvider = config?.provider ?? fallbackProvider; - const { provider, clientOverrides } = resolveSummarizationProvider(rawProvider, appConfig); + /** + * When the summarization provider resolves to the same custom endpoint as + * the main agent, skip client-option overrides. The SDK's self-summarize + * path will reuse `agentContext.clientOptions` as-is, preserving any + * request-resolved dynamic headers, fetch/proxy options, and other state + * that `getOpenAIConfig` produced from raw yaml config does not capture. + */ + const isSameEndpointAsAgent = + agentEndpoint != null && + isNonEmptyString(rawProvider) && + normalizedEndpointName(rawProvider) === normalizedEndpointName(agentEndpoint); + + const { provider, clientOverrides } = isSameEndpointAsAgent + ? { provider: fallbackProvider, clientOverrides: undefined } + : resolveSummarizationProvider(rawProvider, appConfig); + const model = config?.model ?? fallbackModel; const trigger = config?.trigger?.type && config?.trigger?.value @@ -310,10 +337,11 @@ function shapeSummarizationConfig( : undefined; /** - * Custom-endpoint overrides (baseURL/apiKey) are merged into `parameters` so - * that the SDK's `buildSummarizationClientConfig` spreads them onto the - * summarization client options. This is required when summarization targets - * a different custom endpoint than the main agent. + * Custom-endpoint overrides are merged into `parameters` so the SDK's + * `buildSummarizationClientConfig` spreads them onto the summarization + * client options. Only applied when summarization targets a *different* + * custom endpoint than the main agent; the same-endpoint case leaves + * `parameters` untouched so `agentContext.clientOptions` wins. */ const parameters = clientOverrides != null @@ -438,6 +466,7 @@ export async function createRun({ provider as string, selfModel, appConfig, + agent.endpoint ?? undefined, ); const llmConfig: t.RunLLMConfig = Object.assign( From 828126f7aca115db8ce85fe2041b7c8fdfa02cb1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 11:50:39 -0400 Subject: [PATCH 5/9] address: preserve raw provider when overrides cannot be built When summarization points at a different custom endpoint than the agent and we can't resolve the endpoint's credentials (user_provided, or a still-unresolved `${VAR}` after env extraction), remapping to `openAI` without overrides would silently route summaries to the default OpenAI client. Preserve the raw provider name so the SDK raises a clear "Unsupported LLM provider" error (now also logged, via the agents SDK defense-in-depth fix) instead of sending traffic to the wrong backend. --- .../__tests__/run-summarization.test.ts | 21 +++++++++++-------- packages/api/src/agents/run.ts | 15 +++++++------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index d935d5433dbd..b9c820d20fce 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -431,7 +431,7 @@ describe('custom-endpoint provider resolution', () => { delete process.env.TEST_OLLAMA_KEY; }); - it('skips override when apiKey is marked user_provided', async () => { + it('keeps raw provider when apiKey is marked user_provided', async () => { const appConfig = makeAppConfig([ { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'user_provided' }, ]); @@ -441,14 +441,17 @@ describe('custom-endpoint provider resolution', () => { }); const config = agents[0].summarizationConfig as Record; - // Provider still remapped to the SDK-recognized name... - expect(config.provider).toBe('openAI'); - // ...but credentials are not forwarded (async user lookup not supported here; - // SDK's self-summarize path will reuse the agent's clientOptions). + /** + * Keep the raw name so the SDK raises "Unsupported LLM provider: Ollama" + * rather than silently remapping to `openAI` and routing summaries to the + * default backend. (User-provided creds cannot be resolved here — the + * async DB lookup is out of scope for this synchronous code path.) + */ + expect(config.provider).toBe('Ollama'); expect(config.parameters).toBeUndefined(); }); - it('skips override when env var reference cannot be resolved', async () => { + it('keeps raw provider when env var reference cannot be resolved', async () => { delete process.env.UNSET_TEST_KEY; const appConfig = makeAppConfig([ { @@ -463,11 +466,11 @@ describe('custom-endpoint provider resolution', () => { }); const config = agents[0].summarizationConfig as Record; - expect(config.provider).toBe('openAI'); + expect(config.provider).toBe('Ollama'); expect(config.parameters).toBeUndefined(); }); - it('skips override when partial env var reference (prefix/suffix) stays unresolved', async () => { + it('keeps raw provider when partial env var reference (prefix/suffix) stays unresolved', async () => { delete process.env.UNSET_TEST_SEGMENT; const appConfig = makeAppConfig([ { @@ -482,7 +485,7 @@ describe('custom-endpoint provider resolution', () => { }); const config = agents[0].summarizationConfig as Record; - expect(config.provider).toBe('openAI'); + expect(config.provider).toBe('Ollama'); /** Even though the baseURL is a partial-match pattern, it must not be forwarded. */ expect(config.parameters).toBeUndefined(); }); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index cd7457c8121e..4ed72f98985e 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -241,19 +241,22 @@ function resolveSummarizationProvider( const rawBaseURL = customEndpointConfig.baseURL ?? ''; /** * User-provided credentials require an async DB lookup and expiry checks - * that are out of scope here. Fall back to the remapped provider and let - * the SDK's self-summarize path reuse the agent's own client options. + * that are out of scope here. Keep the raw provider so the SDK surfaces + * a clear "Unsupported LLM provider" error rather than silently + * remapping to `openAI` and routing summaries to the default backend. + * Callers wanting user-provided summarization against a non-agent + * endpoint must hit the same endpoint as the agent (handled upstream). */ if (isUserProvided(rawApiKey) || isUserProvided(rawBaseURL)) { - return { provider: overrideProvider }; + return { provider: rawProvider }; } const apiKey = extractEnvVariable(rawApiKey); const baseURL = extractEnvVariable(rawBaseURL); /** * `extractEnvVariable` leaves any unresolved `${VAR}` placeholder in place * — including in the middle of a prefix/suffix string — when the env var - * is missing. Reject overrides whenever either value still contains any - * unresolved placeholder so the SDK doesn't receive a broken URL/key. + * is missing. If the value is still broken, keep the raw provider so the + * SDK errors out loudly instead of forwarding a malformed URL/key. */ if ( !apiKey || @@ -261,7 +264,7 @@ function resolveSummarizationProvider( hasUnresolvedPlaceholder(apiKey) || hasUnresolvedPlaceholder(baseURL) ) { - return { provider: overrideProvider }; + return { provider: rawProvider }; } /** * Run the endpoint config through `getOpenAIConfig` so summarization From a39ab108da0ef79837049753928e53408e8d9ef3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 12:03:12 -0400 Subject: [PATCH 6/9] address: resolve endpoint headers and forward PROXY to summarization - Custom-endpoint `headers` now flow through `resolveHeaders` before reaching `getOpenAIConfig`, matching the main agent path. This ensures templated values like `\${PORTKEY_API_KEY}` or `{{LIBRECHAT_BODY_...}}` are substituted for summarization requests instead of being forwarded literally. - `PROXY` env var is now passed into `getOpenAIConfig` so cross-endpoint summarization honors outbound proxy dispatchers configured for the rest of the deployment. --- .../__tests__/run-summarization.test.ts | 58 +++++++++++++++++++ packages/api/src/agents/run.ts | 25 +++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index b9c820d20fce..7856c996ea0f 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -534,6 +534,64 @@ describe('custom-endpoint provider resolution', () => { expect(defaultHeaders['X-Custom-Header']).toBe('value-123'); }); + it('runs custom-endpoint headers through resolveHeaders (not forwarded raw)', async () => { + const { resolveHeaders } = jest.requireMock('~/utils/env') as { + resolveHeaders: jest.Mock; + }; + resolveHeaders.mockClear(); + + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'http://localhost:11434/v1', + apiKey: 'ollama-key', + headers: { Authorization: 'Bearer ${TEST_PORTKEY_KEY}' }, + } as unknown as { name: string; baseURL: string; apiKey: string }, + ]); + await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + /** + * Templated header values must go through the same `resolveHeaders` + * pipeline the main agent flow uses, so `${VAR}`/`{{BODY_FIELD}}` + * references don't get forwarded verbatim to the summarization backend. + */ + const call = resolveHeaders.mock.calls.find( + (args: unknown[]) => + (args[0] as { headers?: Record }).headers?.Authorization === + 'Bearer ${TEST_PORTKEY_KEY}', + ); + expect(call).toBeDefined(); + }); + + it('forwards PROXY env var into summarization client configuration', async () => { + const originalProxy = process.env.PROXY; + process.env.PROXY = 'http://proxy.internal:3128'; + try { + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + const configuration = parameters.configuration as Record; + /** getOpenAIConfig wires proxy through to fetchOptions.dispatcher (undici ProxyAgent). */ + expect(configuration.fetchOptions).toBeDefined(); + } finally { + if (originalProxy === undefined) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + } + }); + it('skips overrides when summarization targets the same endpoint as the agent', async () => { /** * When summarization provider matches the agent's endpoint, we rely on diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 4ed72f98985e..d62c40a3c903 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -222,6 +222,7 @@ type SummarizationClientOverrides = Record; function resolveSummarizationProvider( rawProvider: string, appConfig: AppConfig | undefined, + headerContext: { user?: IUser; requestBody?: t.RequestBody }, ): { provider: string; clientOverrides?: SummarizationClientOverrides; @@ -266,19 +267,35 @@ function resolveSummarizationProvider( ) { return { provider: rawProvider }; } + /** + * Resolve templated header values (e.g. `${PORTKEY_API_KEY}`, + * `{{LIBRECHAT_BODY_PARENTMESSAGEID}}`) before handing them to + * `getOpenAIConfig`, matching the agent main flow where `resolveHeaders` + * runs on `llmConfig.configuration.defaultHeaders`. + */ + const resolvedHeaders = + customEndpointConfig.headers != null + ? resolveHeaders({ + headers: customEndpointConfig.headers as Record, + user: createSafeUser(headerContext.user), + body: headerContext.requestBody, + }) + : undefined; /** * Run the endpoint config through `getOpenAIConfig` so summarization * inherits the same `headers`, `defaultQuery`, `addParams`/`dropParams`, * and `customParams` transforms that `initializeCustom` applies for the * main agent flow. Without this, summarization drops endpoint-specific * behavior (e.g. Anthropic/Google param transforms, required headers) - * that the main agent relied on. + * that the main agent relied on. `proxy` is forwarded so outbound proxy + * dispatchers (`PROXY` env var) apply to cross-endpoint summarization. */ const { llmConfig, configOptions } = getOpenAIConfig( apiKey, { reverseProxyUrl: baseURL, - headers: customEndpointConfig.headers, + proxy: process.env.PROXY ?? null, + headers: resolvedHeaders, addParams: customEndpointConfig.addParams, dropParams: customEndpointConfig.dropParams, customParams: customEndpointConfig.customParams, @@ -315,6 +332,7 @@ function shapeSummarizationConfig( fallbackModel: string | undefined, appConfig: AppConfig | undefined, agentEndpoint: string | undefined, + headerContext: { user?: IUser; requestBody?: t.RequestBody }, ) { const rawProvider = config?.provider ?? fallbackProvider; /** @@ -331,7 +349,7 @@ function shapeSummarizationConfig( const { provider, clientOverrides } = isSameEndpointAsAgent ? { provider: fallbackProvider, clientOverrides: undefined } - : resolveSummarizationProvider(rawProvider, appConfig); + : resolveSummarizationProvider(rawProvider, appConfig, headerContext); const model = config?.model ?? fallbackModel; const trigger = @@ -470,6 +488,7 @@ export async function createRun({ selfModel, appConfig, agent.endpoint ?? undefined, + { user, requestBody }, ); const llmConfig: t.RunLLMConfig = Object.assign( From 56ab4b5c9e2706f56611c6aacb3eaff3edd0355b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 12:13:16 -0400 Subject: [PATCH 7/9] address: user summarization parameters win over endpoint defaults Flip the merge order so `summarization.parameters` from yaml override `clientOverrides` defaults (which come from `getOpenAIConfig` and always include `streaming: true` etc.). A user who sets `parameters.streaming: false` in their config should still see non-streaming summarization for providers that require it. --- .../__tests__/run-summarization.test.ts | 26 +++++++++++++++++++ packages/api/src/agents/run.ts | 7 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 7856c996ea0f..21a42a82e68b 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -647,6 +647,32 @@ describe('custom-endpoint provider resolution', () => { ); }); + it('user-supplied summarization.parameters override endpoint defaults', async () => { + /** + * `getOpenAIConfig` defaults `streaming: true`, but a user who sets + * `summarization.parameters.streaming: false` in their config has + * explicitly opted out; the user's setting must win over endpoint + * defaults injected from the custom endpoint config. + */ + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { + provider: 'Ollama', + model: 'llama3', + parameters: { streaming: false }, + }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + expect(parameters.streaming).toBe(false); + /** Endpoint defaults still injected for the rest. */ + expect(parameters.apiKey).toBe('ollama-key'); + }); + it('does not leak model/modelName from getOpenAIConfig defaults', async () => { const appConfig = makeAppConfig([ { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index d62c40a3c903..5d5fb12a3cc9 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -363,12 +363,17 @@ function shapeSummarizationConfig( * client options. Only applied when summarization targets a *different* * custom endpoint than the main agent; the same-endpoint case leaves * `parameters` untouched so `agentContext.clientOptions` wins. + * + * Order matters: `clientOverrides` supplies endpoint defaults (baseURL, + * apiKey, headers, transforms), then explicit user `summarization.parameters` + * are spread on top so settings like `streaming: false` still win over + * `getOpenAIConfig`'s defaults. */ const parameters = clientOverrides != null ? { - ...(config?.parameters ?? {}), ...clientOverrides, + ...(config?.parameters ?? {}), } : config?.parameters; From ea64dd2620ea78e5b7c9403e42084f15662346a2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 19 Apr 2026 22:51:17 -0400 Subject: [PATCH 8/9] address: review feedback (logging, dead code, DRY, types, deep-merge) - Log error in the resolveSummarizationProvider catch-all so programming bugs in getProviderConfig/getOpenAIConfig/resolveHeaders surface in operator logs instead of falling through silently. - Drop dead `setAppConfig`/`appConfig` infrastructure in responses.js and fix adjacent `allowedProviders` reference that also relied on the never-initialized module-level appConfig. Uses `req.config` directly. - Import canonical `normalizeEndpointName` from librechat-data-provider instead of duplicating it locally. - Replace `SummarizationClientOverrides = Record` with an explicit interface covering the known fields. - Deep-merge `configuration` when user-supplied `summarization.parameters. configuration` overlaps the resolved endpoint configuration, so user additions (e.g. `defaultQuery`) don't wipe out `baseURL`/`defaultHeaders`. - Wrap `process.env` mutations in test in `try/finally` so a failed assertion doesn't leak env state into subsequent tests. - Drop `as unknown as AppConfig` in test helper; fixture now matches the `AppConfig` shape directly using a `Partial` union. - Trim JSDoc that restated the name it was attached to. --- api/server/controllers/agents/responses.js | 14 +-- .../__tests__/run-summarization.test.ts | 98 ++++++++++++++----- packages/api/src/agents/run.ts | 64 ++++++++---- 3 files changed, 122 insertions(+), 54 deletions(-) diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 6789698a2af9..ca2b8e0dd95c 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -41,17 +41,6 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too const { findAccessibleResources } = require('~/server/services/PermissionService'); const db = require('~/models'); -/** @type {import('@librechat/api').AppConfig | null} */ -let appConfig = null; - -/** - * Set the app config for the controller - * @param {import('@librechat/api').AppConfig} config - */ -function setAppConfig(config) { - appConfig = config; -} - /** * Creates a tool loader function for the agent. * @param {AbortSignal} signal - The abort signal @@ -333,7 +322,7 @@ const createResponse = async (req, res) => { // Build allowed providers set const allowedProviders = new Set( - appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders, + req.config?.endpoints?.[EModelEndpoint.agents]?.allowedProviders, ); // Create tool loader @@ -941,5 +930,4 @@ module.exports = { createResponse, getResponse, listModels, - setAppConfig, }; diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 21a42a82e68b..1c24669c2bca 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -1,6 +1,6 @@ import type { AppConfig } from '@librechat/data-schemas'; -import type { SummarizationConfig } from 'librechat-data-provider'; -import { EModelEndpoint } from 'librechat-data-provider'; +import type { SummarizationConfig, TEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, FileSources } from 'librechat-data-provider'; import { createRun } from '~/agents/run'; // Mock winston logger @@ -82,14 +82,27 @@ async function callAndCapture( } /** Minimal AppConfig with a single custom endpoint for testing provider resolution. */ -function makeAppConfig( - customEndpoints: Array<{ name: string; baseURL: string; apiKey: string }>, -): AppConfig { +type TestCustomEndpoint = Partial & { + name: string; + baseURL: string; + apiKey: string; +}; + +/** + * Minimal AppConfig fixture for testing. Only `endpoints` is read by + * `resolveSummarizationProvider`; other required AppConfig fields are + * filled with empty/default values so the shape matches without needing + * `as unknown as AppConfig`. + */ +function makeAppConfig(customEndpoints: TestCustomEndpoint[]): AppConfig { return { + config: {}, + fileStrategy: FileSources.local, + imageOutputType: 'png', endpoints: { [EModelEndpoint.custom]: customEndpoints, }, - } as unknown as AppConfig; + }; } beforeEach(() => { @@ -413,22 +426,25 @@ describe('custom-endpoint provider resolution', () => { it('extracts ${ENV_VAR} references in custom endpoint credentials', async () => { process.env.TEST_OLLAMA_KEY = 'resolved-key-value'; - const appConfig = makeAppConfig([ - { - name: 'Ollama', - baseURL: 'http://localhost:11434/v1', - apiKey: '${TEST_OLLAMA_KEY}', - }, - ]); - const agents = await callAndCapture({ - summarizationConfig: { provider: 'Ollama', model: 'llama3' }, - appConfig, - }); + try { + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'http://localhost:11434/v1', + apiKey: '${TEST_OLLAMA_KEY}', + }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { provider: 'Ollama', model: 'llama3' }, + appConfig, + }); - const config = agents[0].summarizationConfig as Record; - const parameters = config.parameters as Record; - expect(parameters.apiKey).toBe('resolved-key-value'); - delete process.env.TEST_OLLAMA_KEY; + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + expect(parameters.apiKey).toBe('resolved-key-value'); + } finally { + delete process.env.TEST_OLLAMA_KEY; + } }); it('keeps raw provider when apiKey is marked user_provided', async () => { @@ -520,7 +536,7 @@ describe('custom-endpoint provider resolution', () => { baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key', headers: { 'X-Custom-Header': 'value-123' }, - } as unknown as { name: string; baseURL: string; apiKey: string }, + }, ]); const agents = await callAndCapture({ summarizationConfig: { provider: 'Ollama', model: 'llama3' }, @@ -546,7 +562,7 @@ describe('custom-endpoint provider resolution', () => { baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key', headers: { Authorization: 'Bearer ${TEST_PORTKEY_KEY}' }, - } as unknown as { name: string; baseURL: string; apiKey: string }, + }, ]); await callAndCapture({ summarizationConfig: { provider: 'Ollama', model: 'llama3' }, @@ -647,6 +663,42 @@ describe('custom-endpoint provider resolution', () => { ); }); + it('deep-merges user configuration with endpoint-resolved configuration', async () => { + /** + * User-supplied `parameters.configuration.defaultQuery` must merge with — + * not replace — the resolved `configuration` (baseURL, defaultHeaders). + */ + const appConfig = makeAppConfig([ + { + name: 'Ollama', + baseURL: 'http://localhost:11434/v1', + apiKey: 'ollama-key', + headers: { 'X-Required-Header': 'keep-me' }, + }, + ]); + const agents = await callAndCapture({ + summarizationConfig: { + provider: 'Ollama', + model: 'llama3', + parameters: { + configuration: { defaultQuery: { 'api-version': '2024-06-01' } }, + } as unknown as SummarizationConfig['parameters'], + }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const parameters = config.parameters as Record; + const configuration = parameters.configuration as Record; + /** Endpoint defaults preserved... */ + expect(configuration.baseURL).toBe('http://localhost:11434/v1'); + expect((configuration.defaultHeaders as Record)['X-Required-Header']).toBe( + 'keep-me', + ); + /** ...alongside the user's additions. */ + expect(configuration.defaultQuery).toEqual({ 'api-version': '2024-06-01' }); + }); + it('user-supplied summarization.parameters override endpoint defaults', async () => { /** * `getOpenAIConfig` defaults `streaming: true`, but a user who sets diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 5d5fb12a3cc9..0ab507082a8c 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,5 +1,10 @@ import { Run, Providers, Constants } from '@librechat/agents'; -import { KnownEndpoints, extractEnvVariable, providerEndpointMap } from 'librechat-data-provider'; +import { + KnownEndpoints, + extractEnvVariable, + providerEndpointMap, + normalizeEndpointName, +} from 'librechat-data-provider'; import type { SummarizationConfig as AgentSummarizationConfig, MultiAgentGraphConfig, @@ -13,6 +18,7 @@ import type { IState, LCTool, } from '@librechat/agents'; +import { logger } from '@librechat/data-schemas'; import type { Agent, SummarizationConfig } from 'librechat-data-provider'; import type { BaseMessage } from '@langchain/core/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; @@ -191,24 +197,43 @@ function isNonEmptyString(value: unknown): value is string { const UNRESOLVED_ENV_VAR_PLACEHOLDER = /\$\{[^}]+\}/; -/** True if the string still contains a `${VAR}` placeholder after env extraction. */ function hasUnresolvedPlaceholder(value: string): boolean { return UNRESOLVED_ENV_VAR_PLACEHOLDER.test(value); } +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + /** - * Lightweight endpoint-name normalizer for comparing summarization-provider - * strings against the agent's endpoint. Mirrors `normalizeEndpointName` in - * `librechat-data-provider` (only Ollama is special-cased to be - * case-insensitive), so an agent on `"Ollama"` and summarization on `"ollama"` - * are treated as the same endpoint. + * Merges user-supplied summarization parameters on top of endpoint-resolved + * overrides. User params win for top-level keys; `configuration` is + * deep-merged so user additions (e.g. `defaultQuery`) don't wipe out the + * resolved `baseURL`/`defaultHeaders`/`fetchOptions`. */ -function normalizedEndpointName(name: string): string { - return name.toLowerCase() === 'ollama' ? 'ollama' : name; +function mergeParameters( + overrides: SummarizationClientOverrides, + userParams: SummarizationConfig['parameters'], +): Record { + const merged: Record = { ...overrides, ...(userParams ?? {}) }; + const userConfiguration = (userParams as Record | undefined)?.configuration; + if (isPlainObject(overrides.configuration) && isPlainObject(userConfiguration)) { + merged.configuration = { ...overrides.configuration, ...userConfiguration }; + } + return merged; } -/** Client-option overrides for summarization models targeting custom endpoints. */ -type SummarizationClientOverrides = Record; +/** + * Mirrors `getOpenAIConfig`'s `llmConfig` shape (plus its `configOptions` + * assigned to `configuration`). Index signature covers fields that the + * helper emits dynamically per provider variant. + */ +interface SummarizationClientOverrides { + apiKey?: string; + streaming?: boolean; + configuration?: t.OpenAIConfiguration; + [key: string]: unknown; +} /** * Resolves a summarization provider string (which may be a custom-endpoint name @@ -320,7 +345,11 @@ function resolveSummarizationProvider( provider: overrideProvider, clientOverrides, }; - } catch { + } catch (error) { + logger.warn( + `[resolveSummarizationProvider] failed to resolve "${rawProvider}"; falling back to raw provider`, + error, + ); return { provider: rawProvider }; } } @@ -345,7 +374,7 @@ function shapeSummarizationConfig( const isSameEndpointAsAgent = agentEndpoint != null && isNonEmptyString(rawProvider) && - normalizedEndpointName(rawProvider) === normalizedEndpointName(agentEndpoint); + normalizeEndpointName(rawProvider) === normalizeEndpointName(agentEndpoint); const { provider, clientOverrides } = isSameEndpointAsAgent ? { provider: fallbackProvider, clientOverrides: undefined } @@ -367,14 +396,13 @@ function shapeSummarizationConfig( * Order matters: `clientOverrides` supplies endpoint defaults (baseURL, * apiKey, headers, transforms), then explicit user `summarization.parameters` * are spread on top so settings like `streaming: false` still win over - * `getOpenAIConfig`'s defaults. + * `getOpenAIConfig`'s defaults. `configuration` is deep-merged so a user + * adding e.g. `configuration.defaultQuery` keeps the resolved `baseURL` + * and `defaultHeaders` rather than replacing the whole object. */ const parameters = clientOverrides != null - ? { - ...clientOverrides, - ...(config?.parameters ?? {}), - } + ? mergeParameters(clientOverrides, config?.parameters) : config?.parameters; return { From fff08e177f63f6a1be4020c9665cfe2212cbac92 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 19 Apr 2026 22:57:44 -0400 Subject: [PATCH 9/9] =?UTF-8?q?address:=20review=20nits=20=E2=80=94=20impo?= =?UTF-8?q?rt=20order,=20local=20test=20type,=20conflict=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `import { logger }` up into the package value-imports section so it no longer sits between `import type` blocks. - Replace `as unknown as SummarizationConfig['parameters']` in the deep-merge test with a named `TestSummarizationParameters` type and a single narrowing cast at the call site, making intent explicit. - Add a test proving that user-supplied `configuration.baseURL` wins over the resolved endpoint baseURL, locking in the deep-merge's user-wins-on-conflict semantics that the previous suite only exercised additively. --- .../__tests__/run-summarization.test.ts | 48 +++++++++++++++++-- packages/api/src/agents/run.ts | 2 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 1c24669c2bca..550f6c2a3768 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -88,6 +88,16 @@ type TestCustomEndpoint = Partial & { apiKey: string; }; +/** + * Shape of summarization parameters used in tests. The LibreChat config + * schema restricts yaml `parameters` to primitive values, but the SDK + * passes any record through as-is — tests need the wider shape to exercise + * cross-endpoint `configuration` merging. + */ +type TestSummarizationParameters = Record & { + configuration?: Record; +}; + /** * Minimal AppConfig fixture for testing. Only `endpoints` is read by * `resolveSummarizationProvider`; other required AppConfig fields are @@ -676,20 +686,21 @@ describe('custom-endpoint provider resolution', () => { headers: { 'X-Required-Header': 'keep-me' }, }, ]); + const parameters: TestSummarizationParameters = { + configuration: { defaultQuery: { 'api-version': '2024-06-01' } }, + }; const agents = await callAndCapture({ summarizationConfig: { provider: 'Ollama', model: 'llama3', - parameters: { - configuration: { defaultQuery: { 'api-version': '2024-06-01' } }, - } as unknown as SummarizationConfig['parameters'], + parameters: parameters as SummarizationConfig['parameters'], }, appConfig, }); const config = agents[0].summarizationConfig as Record; - const parameters = config.parameters as Record; - const configuration = parameters.configuration as Record; + const resolvedParameters = config.parameters as Record; + const configuration = resolvedParameters.configuration as Record; /** Endpoint defaults preserved... */ expect(configuration.baseURL).toBe('http://localhost:11434/v1'); expect((configuration.defaultHeaders as Record)['X-Required-Header']).toBe( @@ -699,6 +710,33 @@ describe('custom-endpoint provider resolution', () => { expect(configuration.defaultQuery).toEqual({ 'api-version': '2024-06-01' }); }); + it('user-supplied configuration.baseURL overrides resolved baseURL', async () => { + /** + * Deep-merge still lets user keys win on conflict — if a user explicitly + * sets `configuration.baseURL` in their summarization parameters, it + * must override the baseURL resolved from the endpoint config. + */ + const appConfig = makeAppConfig([ + { name: 'Ollama', baseURL: 'http://localhost:11434/v1', apiKey: 'ollama-key' }, + ]); + const parameters: TestSummarizationParameters = { + configuration: { baseURL: 'https://user-override.example.com/v1' }, + }; + const agents = await callAndCapture({ + summarizationConfig: { + provider: 'Ollama', + model: 'llama3', + parameters: parameters as SummarizationConfig['parameters'], + }, + appConfig, + }); + + const config = agents[0].summarizationConfig as Record; + const resolvedParameters = config.parameters as Record; + const configuration = resolvedParameters.configuration as Record; + expect(configuration.baseURL).toBe('https://user-override.example.com/v1'); + }); + it('user-supplied summarization.parameters override endpoint defaults', async () => { /** * `getOpenAIConfig` defaults `streaming: true`, but a user who sets diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 0ab507082a8c..6d0fdfb87889 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,3 +1,4 @@ +import { logger } from '@librechat/data-schemas'; import { Run, Providers, Constants } from '@librechat/agents'; import { KnownEndpoints, @@ -18,7 +19,6 @@ import type { IState, LCTool, } from '@librechat/agents'; -import { logger } from '@librechat/data-schemas'; import type { Agent, SummarizationConfig } from 'librechat-data-provider'; import type { BaseMessage } from '@langchain/core/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas';