Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@ class AgentClient extends BaseClient {
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
summarizationConfig: appConfig?.summarization,
appConfig,
tokenCounter,
});

Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ const OpenAIChatCompletionController = async (req, res) => {
initialSummary,
runId: responseId,
summarizationConfig,
appConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
Expand Down
2 changes: 2 additions & 0 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ const createResponse = async (req, res) => {
initialSummary,
runId: responseId,
summarizationConfig,
appConfig,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use request app config when creating response runs

This passes the module-level appConfig into createRun, but in this file setAppConfig has no callers in the repository, so appConfig remains null on both response run paths. In that state resolveSummarizationProvider short-circuits and leaves custom summarization providers unresolved, so /api/agents/v1/responses still hits Unsupported LLM provider for configs like summarization.provider: "Ollama". Pass req.config here (as already done for summarizationConfig) so provider resolution can actually run.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — setAppConfig is indeed dead code. Fixed in e68e7c8f7: both createRun calls in responses.js now use req.config directly (matching what the same controller already does for summarizationConfig on line 283).

signal: abortController.signal,
customHandlers: handlers,
requestBody: {
Expand Down Expand Up @@ -655,6 +656,7 @@ const createResponse = async (req, res) => {
initialSummary,
runId: responseId,
summarizationConfig,
appConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
Expand Down
168 changes: 168 additions & 0 deletions packages/api/src/agents/__tests__/run-summarization.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -57,6 +59,7 @@ async function callAndCapture(
agents?: ReturnType<typeof makeAgent>[];
summarizationConfig?: SummarizationConfig;
initialSummary?: { text: string; tokenCount: number };
appConfig?: AppConfig;
} = {},
) {
const agents = opts.agents ?? [makeAgent()];
Expand All @@ -67,6 +70,7 @@ async function callAndCapture(
signal,
summarizationConfig: opts.summarizationConfig,
initialSummary: opts.initialSummary,
appConfig: opts.appConfig,
streaming: true,
streamUsage: true,
});
Expand All @@ -77,6 +81,17 @@ async function callAndCapture(
return callArgs.graphConfig.agents as Array<Record<string, unknown>>;
}

/** 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();
});
Expand Down Expand Up @@ -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<string, unknown>;
expect(config.provider).toBe('openAI');
expect(config.model).toBe('llama3');

const parameters = config.parameters as Record<string, unknown>;
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<string, unknown>;
expect(config.provider).toBe('openAI');
expect((config.parameters as Record<string, unknown>).apiKey).toBe('ollama-key');
});
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name suggests case-insensitive matching for custom endpoint names in general, but current endpoint matching is only normalized for Ollama (via normalizeEndpointName/getCustomEndpointConfig). Consider renaming the test to reflect the actual behavior (e.g. “Ollama is case-insensitive”), or add a second assertion covering a non-Ollama endpoint if the intent is truly case-insensitive across all custom endpoints.

Copilot uses AI. Check for mistakes.

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const parameters = config.parameters as Record<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
const parameters = config.parameters as Record<string, unknown>;
expect(parameters).toMatchObject({
temperature: 0.2,
configuration: { baseURL: 'http://localhost:11434/v1' },
apiKey: 'ollama-key',
});
});
});
98 changes: 94 additions & 4 deletions packages/api/src/agents/run.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
});
Comment on lines +258 to +262
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom-endpoint name resolution here ultimately relies on getCustomEndpointConfig() matching, which is case-sensitive for most endpoint names (only Ollama is normalized via normalizeEndpointName). That means summarization.provider: "mistral" won’t resolve an endpoint named Mistral, despite the PR description/tests implying case-insensitive matching. Either implement a case-insensitive lookup for custom endpoint labels before calling getProviderConfig, or adjust expectations/docs/tests to reflect the actual matching rules.

Copilot uses AI. Check for mistakes.
if (!customEndpointConfig) {
return { provider: overrideProvider };
}
const rawApiKey = customEndpointConfig.apiKey ?? '';
const rawBaseURL = customEndpointConfig.baseURL ?? '';
Comment on lines +266 to +267
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include full custom-endpoint options in summarization overrides

The override builder only reads apiKey and baseURL from customEndpointConfig, so summarization requests drop endpoint-specific behavior that normal custom endpoint initialization applies (for example customParams.defaultParamsEndpoint, headers, addParams/dropParams). As a result, summarization can still fail for custom endpoints that rely on Anthropic/Google parameter transforms or required headers, even though provider remapping succeeds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e68e7c8f7. The helper now runs the custom endpoint config through getOpenAIConfig (the same path initializeCustom uses for the main agent), so summarization inherits headers, defaultQuery, addParams/dropParams, and customParams (including Anthropic/Google param transforms). Added a test (forwards custom-endpoint headers as configuration.defaultHeaders) to guard against regressions, and a separate test (does not leak model/modelName from getOpenAIConfig defaults) to make sure the user-supplied summarization.model still wins.

/**
* 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 };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve self-summarize provider for user-provided creds

Returning overrideProvider in this branch remaps a custom endpoint name (for example, Ollama) to openAI even though no clientOverrides are attached. In @librechat/agents, the summarizer only reuses the agent’s existing clientOptions when summarizationConfig.provider matches agentContext.provider; after this remap they no longer match for custom endpoints, so summarization drops the resolved per-user key/baseURL and can route to the wrong backend or fail with missing credentials. This affects runs where custom endpoint credentials are user_provided.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the flag. I traced the code paths carefully and this case is actually handled:

By the time createRun runs, packages/api/src/agents/initialize.ts:331-333 already remaps agent.provider from the custom endpoint label (e.g. "Ollama") to the SDK provider ("openAI") via getProviderConfig, and agent.model_parameters is populated by initializeCustom with the resolved user-provided apiKey and configuration.baseURL (see packages/api/src/endpoints/custom/initialize.ts:95-175).

So in the user-provided branch of resolveSummarizationProvider:

  • agentContext.provider = "openAI"
  • summarization.provider = "openAI" (after our remap)
  • isSelfSummarize === true in the SDK, so baseOptions = { ...agentContext.clientOptions } inherits the user's apiKey/baseURL

The per-user credentials are preserved through the self-summarize baseOptions merge — we're not dropping them. Happy to wire up an explicit test for this path if you think it would help guard against regressions.

}
Comment on lines +276 to +278
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep raw provider when custom creds cannot be resolved

When a custom summarization provider uses user_provided credentials, this branch remaps it to openAI but omits clientOverrides; for runs where summarization targets a different endpoint than the active agent, that causes summarization to fall back to the agent/default OpenAI client instead of the configured custom endpoint, silently sending summaries to the wrong backend. In this failure mode, preserve the original provider (or raise an explicit error) rather than remapping to overrideProvider without endpoint options.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 828126f7a. In both fallback branches (user_provided creds, and still-unresolved ${VAR} after env extraction) the helper now returns rawProvider instead of overrideProvider. That way the SDK raises a clear Unsupported LLM provider: <name> error (and now logs it, thanks to agents#110) rather than silently routing summaries to the default OpenAI client when the endpoint differs from the agent. Updated the three relevant tests to lock this in.

const apiKey = extractEnvVariable(rawApiKey);
const baseURL = extractEnvVariable(rawBaseURL);
if (!apiKey || !baseURL || apiKey.match(envVarRegex) || baseURL.match(envVarRegex)) {
return { provider: overrideProvider };
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unresolved-env-var guard only checks envVarRegex (which matches strings of the form ${VAR}), but extractEnvVariable() also supports prefix/suffix patterns (e.g. prefix-${VAR}) and will return the placeholder unchanged when the env var is missing. In those cases apiKey.match(envVarRegex) / baseURL.match(envVarRegex) will be false and we’ll incorrectly forward an unresolved ${...} into the SDK overrides. Consider rejecting overrides whenever the extracted value still contains an ${...} segment (e.g. /\$\{[^}]+\}/), rather than only the exact-match regex.

Copilot uses AI. Check for mistakes.
}
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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep user summarization parameters from being overwritten

When custom-endpoint overrides are applied, this merge order makes clientOverrides win over user-provided summarization.parameters keys. clientOverrides comes from getOpenAIConfig, which always includes defaults like streaming: true, so a user setting such as parameters.streaming: false is silently ignored for cross-endpoint summarization. That changes runtime behavior (and can break providers that require non-streaming calls) even though the config explicitly requested otherwise.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 56ab4b5c9. Flipped the merge order so summarization.parameters from yaml override clientOverrides defaults (like getOpenAIConfig's default streaming: true). Added a test (user-supplied summarization.parameters override endpoint defaults) that asserts a user-set streaming: false survives the merge while endpoint defaults still apply elsewhere.

}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve existing client configuration when applying overrides

Applying clientOverrides with a shallow spread here can replace the entire configuration object coming from the initialized agent options. In the summarization path, the agents SDK later shallow-merges these parameters over agentContext.clientOptions, so custom-endpoint summarization can lose request-resolved headers and proxy/fetch options that were already set on the base client. This causes summarization calls to fail in deployments that rely on dynamic headers or proxy routing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — addressed in 7bfd04164. When summarization.provider resolves to the same custom endpoint as the agent (agent.endpoint), the helper now skips injecting overrides entirely and lets the SDK's self-summarize path reuse agentContext.clientOptions as-is — preserving request-resolved dynamic headers, proxy/fetch options, etc. Overrides only apply when summarization targets a different endpoint, where yaml-config is all we have. Added three tests to lock in this behavior (same endpoint skip, same endpoint case-insensitive for Ollama, different endpoint still applies).

: 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,
Expand Down Expand Up @@ -260,6 +343,7 @@ export async function createRun({
summarizationConfig,
initialSummary,
calibrationRatio,
appConfig,
streaming = true,
streamUsage = true,
}: {
Expand All @@ -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<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
Run<IState>
> {
Expand Down Expand Up @@ -307,6 +396,7 @@ export async function createRun({
agent.summarization ?? summarizationConfig,
provider as string,
selfModel,
appConfig,
);

const llmConfig: t.RunLLMConfig = Object.assign(
Expand Down
Loading