Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.1.81",
"@librechat/agents": "^3.1.83",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
Expand Down
45 changes: 45 additions & 0 deletions api/server/services/Config/loadCustomConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const axios = require('axios');
const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy');
const { loadYaml } = require('@librechat/api');
const { Providers } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const {
configSchema,
Expand All @@ -17,6 +18,48 @@ const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');

let i = 0;

const OPENROUTER_PROMPT_CACHE_DEFAULT = {
key: 'promptCache',
default: true,
};

function includesOpenRouter(value) {
return typeof value === 'string' && value.toLowerCase().includes(Providers.OPENROUTER);
}

function isOpenRouterEndpoint(endpoint) {
return includesOpenRouter(endpoint.name) || includesOpenRouter(endpoint.baseURL);
}

function shouldPreserveCustomParams(customParams) {
const defaultEndpoint = customParams?.defaultParamsEndpoint;
return (
defaultEndpoint && defaultEndpoint !== 'custom' && defaultEndpoint !== Providers.OPENROUTER
);
}

function addOpenRouterDefaults(endpoint) {
if (!isOpenRouterEndpoint(endpoint)) {
return;
}

if (shouldPreserveCustomParams(endpoint.customParams)) {
return;
}

const customParams = endpoint.customParams ?? {};
const paramDefinitions = customParams.paramDefinitions ?? [];
const hasPromptCache = paramDefinitions.some((param) => param.key === 'promptCache');

endpoint.customParams = {
...customParams,
defaultParamsEndpoint: Providers.OPENROUTER,
paramDefinitions: hasPromptCache
? paramDefinitions
: [...paramDefinitions, OPENROUTER_PROMPT_CACHE_DEFAULT],
};
}

/**
* Load custom configuration files and caches the object if the `cache` field at root is true.
* Validation via parsing the config file with the config schema.
Expand Down Expand Up @@ -119,6 +162,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
}
}

(customConfig.endpoints?.custom ?? []).forEach(addOpenRouterDefaults);

(customConfig.endpoints?.custom ?? [])
.filter((endpoint) => endpoint.customParams)
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
Expand Down
123 changes: 120 additions & 3 deletions api/server/services/Config/loadCustomConfig.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@ jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
paramSettings: { foo: {}, bar: {}, custom: {} },
paramSettings: {
foo: {},
bar: {},
custom: {},
openrouter: [
{
key: 'promptCache',
type: 'boolean',
component: 'switch',
default: true,
},
],
},
agentParamSettings: {
custom: [],
google: [
Expand Down Expand Up @@ -195,7 +207,8 @@ describe('loadCustomConfig', () => {
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
});

it('should log the loaded custom config', async () => {
Expand Down Expand Up @@ -297,7 +310,7 @@ describe('loadCustomConfig', () => {
it('throws an error when defaultParamsEndpoint is not provided', async () => {
const malformedCustomParams = { defaultParamsEndpoint: undefined };
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, google',
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, openrouter, google',
);
});

Expand Down Expand Up @@ -340,5 +353,109 @@ describe('loadCustomConfig', () => {
},
]);
});

it('adds OpenRouter promptCache defaults when custom endpoint name is OpenRouter', async () => {
const openRouterConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'OpenRouter',
apiKey: 'user_provided',
baseURL: 'https://proxy.example.com/v1',
models: {
default: ['anthropic/claude-sonnet-4.6'],
},
},
],
},
};
loadYaml.mockReturnValue(openRouterConfig);

const parsedConfig = await loadCustomConfig();
expect(parsedConfig.endpoints.custom[0].customParams).toEqual({
defaultParamsEndpoint: 'openrouter',
paramDefinitions: [
{
columnSpan: 1,
component: 'switch',
default: true,
key: 'promptCache',
label: 'promptCache',
optionType: 'custom',
type: 'boolean',
},
],
});
});

it('adds OpenRouter promptCache defaults when custom endpoint URL is OpenRouter', async () => {
const openRouterConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'Company Gateway',
apiKey: 'user_provided',
baseURL: 'https://openrouter.ai/api/v1',
models: {
default: ['anthropic/claude-sonnet-4.6'],
},
},
],
},
};
loadYaml.mockReturnValue(openRouterConfig);

const parsedConfig = await loadCustomConfig();
expect(parsedConfig.endpoints.custom[0].customParams).toMatchObject({
defaultParamsEndpoint: 'openrouter',
paramDefinitions: [
{
default: true,
key: 'promptCache',
},
],
});
});

it('preserves explicit OpenRouter promptCache defaults', async () => {
const openRouterConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'OpenRouter',
apiKey: 'user_provided',
baseURL: 'https://openrouter.ai/api/v1',
models: {
default: ['anthropic/claude-sonnet-4.6'],
},
customParams: {
defaultParamsEndpoint: 'openrouter',
paramDefinitions: [{ key: 'promptCache', default: false }],
},
},
],
},
};
loadYaml.mockReturnValue(openRouterConfig);

const parsedConfig = await loadCustomConfig();
expect(parsedConfig.endpoints.custom[0].customParams.paramDefinitions).toEqual([
{
columnSpan: 1,
component: 'switch',
default: false,
key: 'promptCache',
label: 'promptCache',
optionType: 'custom',
type: 'boolean',
},
]);
});
});
});
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.1.81",
"@librechat/agents": "^3.1.83",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@smithy/node-http-handler": "^4.4.5",
Expand Down
18 changes: 17 additions & 1 deletion packages/api/src/agents/run.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Providers } from '@librechat/agents';
import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages';
import { extractDiscoveredToolsFromHistory } from './run';

import { extractDiscoveredToolsFromHistory, getReasoningKey } from './run';

describe('extractDiscoveredToolsFromHistory', () => {
it('extracts tool names from tool_search JSON output', () => {
Expand Down Expand Up @@ -131,3 +133,17 @@ describe('extractDiscoveredToolsFromHistory', () => {
expect(discovered.size).toBe(0);
});
});

describe('getReasoningKey', () => {
it('detects OpenRouter baseURL case-insensitively', () => {
const llmConfig = {
configuration: {
baseURL: 'https://gateway.example/v1/OpenRouter',
},
} as Parameters<typeof getReasoningKey>[1];

const reasoningKey = getReasoningKey(Providers.OPENAI, llmConfig);

expect(reasoningKey).toBe('reasoning');
});
});
8 changes: 6 additions & 2 deletions packages/api/src/agents/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ const customProviders = new Set([
KnownEndpoints.ollama,
]);

function includesOpenRouter(value?: string | null): boolean {
return typeof value === 'string' && value.toLowerCase().includes(KnownEndpoints.openrouter);
}

export function getReasoningKey(
provider: Providers,
llmConfig: t.RunLLMConfig,
Expand All @@ -219,8 +223,8 @@ export function getReasoningKey(
if (provider === Providers.GOOGLE) {
reasoningKey = 'reasoning';
} else if (
llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter) ||
(agentEndpoint && agentEndpoint.toLowerCase().includes(KnownEndpoints.openrouter))
includesOpenRouter(llmConfig.configuration?.baseURL) ||
includesOpenRouter(agentEndpoint)
) {
reasoningKey = 'reasoning';
} else if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => {
streaming: true,
model: 'qwen/qwen3-max',
include_reasoning: true,
promptCache: true,
apiKey: 'sk-xxxx',
},
configOptions: {
Expand Down
Loading
Loading