From 8a654dc8b14f7de0a5a7aaacff3b7c50fbac664c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 9 May 2026 11:46:09 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20feat:=20Add=20OpenRouter=20Promp?= =?UTF-8?q?t=20Cache=20Setting=20(#13029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add OpenRouter prompt cache setting * fix: type OpenRouter schema lookup * fix: honor proxied OpenRouter prompt cache * refactor: flatten endpoint schema fallback * chore: Bump `@librechat/agents` to version 3.1.82 * fix: Default OpenRouter prompt cache params * test: Align OpenRouter config expectations * test: Update OpenRouter default cache expectation * fix: Align OpenRouter Detection * chore: Bump `@librechat/agents` to version 3.1.83 * docs: Remove OpenRouter prompt cache setup note * refactor: Use provider enum for OpenRouter defaults * style: Format OpenRouter defaults guard --- api/package.json | 2 +- .../services/Config/loadCustomConfig.js | 45 +++++++ .../services/Config/loadCustomConfig.spec.js | 123 +++++++++++++++++- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/agents/run.spec.ts | 18 ++- packages/api/src/agents/run.ts | 8 +- .../openai/config.backward-compat.spec.ts | 1 + .../api/src/endpoints/openai/config.spec.ts | 87 +++++++++++++ packages/api/src/endpoints/openai/config.ts | 34 ++++- packages/api/src/endpoints/openai/llm.spec.ts | 62 +++++++++ packages/api/src/endpoints/openai/llm.ts | 24 +++- packages/api/src/types/openai.ts | 1 + packages/data-provider/specs/parsers.spec.ts | 41 +++++- .../data-provider/src/parameterSettings.ts | 8 ++ packages/data-provider/src/parsers.ts | 54 +++++--- packages/data-provider/src/schemas.ts | 5 + 17 files changed, 487 insertions(+), 38 deletions(-) diff --git a/api/package.json b/api/package.json index cd5a8c563e..3c864d1e20 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index db25049957..c914754974 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -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, @@ -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. @@ -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)); diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index f7f11dc8f6..3fce8777e3 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -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: [ @@ -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 () => { @@ -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', ); }); @@ -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', + }, + ]); + }); }); }); diff --git a/package-lock.json b/package-lock.json index 070081c688..93452126a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,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", @@ -12088,9 +12088,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.81", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.81.tgz", - "integrity": "sha512-5Ivq0qltFin+C63JyeVk254WxtxF1q2cict8BEaHisIIxNGn+DmNaL9SiK9GCmVVsi3mwGQA/wv6vEHWdiPvLw==", + "version": "3.1.83", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.83.tgz", + "integrity": "sha512-6d+GOrR9ORe0a+ofwcJLWXbEP5VIurKxu4bWnOPpLMj5+rPDeTPgfGrmGwPrREQPTkFWpMb8VkEb7iP2ve3XzA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -44658,7 +44658,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", diff --git a/packages/api/package.json b/packages/api/package.json index 699ee937c1..ab0c76608f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index 3e7e4283b0..59f8959f39 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -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', () => { @@ -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[1]; + + const reasoningKey = getReasoningKey(Providers.OPENAI, llmConfig); + + expect(reasoningKey).toBe('reasoning'); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 1551419718..73fa9e5efc 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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, @@ -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 ( diff --git a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts index 374fe1d188..f19540481f 100644 --- a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts +++ b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts @@ -80,6 +80,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => { streaming: true, model: 'qwen/qwen3-max', include_reasoning: true, + promptCache: true, apiKey: 'sk-xxxx', }, configOptions: { diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index 46ad6a6295..baea92b3d9 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -201,6 +201,7 @@ describe('getOpenAIConfig', () => { 'X-OpenRouter-Categories': 'general-chat,personal-agent', }); expect(result.llmConfig.include_reasoning).toBe(true); + expect(result.llmConfig.promptCache).toBe(true); expect(result.provider).toBe('openrouter'); }); @@ -784,6 +785,46 @@ describe('getOpenAIConfig', () => { const result = getOpenAIConfig(mockApiKey, {}, 'openrouter'); expect(result.llmConfig.include_reasoning).toBe(true); + expect(result.llmConfig.promptCache).toBe(true); + expect(result.provider).toBe('openrouter'); + }); + + it('should detect OpenRouter from baseURL case-insensitively', () => { + const result = getOpenAIConfig(mockApiKey, { + reverseProxyUrl: 'https://gateway.example/v1/OpenRouter', + }); + + expect(result.llmConfig.include_reasoning).toBe(true); + expect(result.llmConfig.promptCache).toBe(true); + expect(result.provider).toBe('openrouter'); + }); + + it('should preserve explicit promptCache false for OpenRouter', () => { + const result = getOpenAIConfig( + mockApiKey, + { + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'promptCache', default: false }], + }, + }, + 'openrouter', + ); + + expect(result.llmConfig.promptCache).toBeUndefined(); + expect(result.provider).toBe('openrouter'); + }); + + it('should honor dropParams for the OpenRouter promptCache default', () => { + const result = getOpenAIConfig( + mockApiKey, + { + dropParams: ['promptCache'], + }, + 'openrouter', + ); + + expect(result.llmConfig.promptCache).toBeUndefined(); expect(result.provider).toBe('openrouter'); }); @@ -1464,6 +1505,10 @@ describe('getOpenAIConfig', () => { top_k: 50, repetition_penalty: 1.1, }, + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'promptCache', default: true }], + }, modelOptions: { model: 'anthropic/claude-3-sonnet', user: 'openrouter-user', @@ -1482,6 +1527,7 @@ describe('getOpenAIConfig', () => { temperature: 0.7, maxTokens: 4000, apiKey: apiKey, + promptCache: true, }); expect(result.llmConfig.include_reasoning).toBeUndefined(); expect(result.llmConfig.modelKwargs).toMatchObject({ @@ -1496,6 +1542,47 @@ describe('getOpenAIConfig', () => { }); expect(result.provider).toBe('openrouter'); }); + + it('should honor OpenRouter defaults for proxied custom endpoint names', () => { + const endpoint = 'company-gateway'; + const apiKey = 'sk-proxy-key'; + const baseURL = 'https://llm-proxy.example.com/v1'; + + const result = getOpenAIConfig( + apiKey, + { + reverseProxyUrl: baseURL, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'promptCache', default: true }], + }, + modelOptions: { + model: 'anthropic/claude-sonnet-4.6', + reasoning_effort: ReasoningEffort.high, + }, + }, + endpoint, + ); + + expect(result.llmConfig).toMatchObject({ + model: 'anthropic/claude-sonnet-4.6', + apiKey, + promptCache: true, + }); + expect(result.llmConfig.include_reasoning).toBeUndefined(); + expect(result.llmConfig.modelKwargs).toMatchObject({ + reasoning: { effort: ReasoningEffort.high }, + }); + expect(result.configOptions?.baseURL).toBe(baseURL); + expect(result.configOptions?.defaultHeaders).toMatchObject({ + 'X-OpenRouter-Title': 'LibreChat', + Authorization: `Bearer ${apiKey}`, + }); + expect(result.provider).toBe('openrouter'); + }); }); describe('Production-like Azure Scenarios', () => { diff --git a/packages/api/src/endpoints/openai/config.ts b/packages/api/src/endpoints/openai/config.ts index 69aed82d80..b239a2b7d0 100644 --- a/packages/api/src/endpoints/openai/config.ts +++ b/packages/api/src/endpoints/openai/config.ts @@ -11,6 +11,29 @@ import { createFetch } from '~/utils/generators'; type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise; +const OPENROUTER_DEFAULT_PARAMS = { promptCache: true }; + +function includesOpenRouter(value?: string | null): boolean { + return typeof value === 'string' && value.toLowerCase().includes(KnownEndpoints.openrouter); +} + +function getDefaultParams({ + customDefaultParams, + useOpenRouter, +}: { + customDefaultParams?: Record; + useOpenRouter: boolean; +}): Record | undefined { + if (!useOpenRouter) { + return customDefaultParams; + } + + return { + ...OPENROUTER_DEFAULT_PARAMS, + ...customDefaultParams, + }; +} + function mergeHeadersPreservingAnthropicBeta( headers: Record | undefined, defaultHeaders: Record, @@ -54,24 +77,25 @@ export function getOpenAIConfig( reverseProxyUrl: baseURL, } = options; - /** Extract default params from customParams.paramDefinitions */ - const defaultParams = extractDefaultParams(options.customParams?.paramDefinitions); - let llmConfig: t.OAIClientOptions; let tools: t.LLMConfigResult['tools']; const isAnthropic = options.customParams?.defaultParamsEndpoint === EModelEndpoint.anthropic; const isGoogle = options.customParams?.defaultParamsEndpoint === EModelEndpoint.google; + const isOpenRouter = options.customParams?.defaultParamsEndpoint === KnownEndpoints.openrouter; const useOpenRouter = !isAnthropic && !isGoogle && - ((baseURL && baseURL.includes(KnownEndpoints.openrouter)) || - (endpoint != null && endpoint.toLowerCase().includes(KnownEndpoints.openrouter))); + (isOpenRouter || includesOpenRouter(baseURL) || includesOpenRouter(endpoint)); const isVercel = !isAnthropic && !isGoogle && ((baseURL && baseURL.includes('ai-gateway.vercel.sh')) || (endpoint != null && endpoint.toLowerCase().includes(KnownEndpoints.vercel))); + const defaultParams = getDefaultParams({ + customDefaultParams: extractDefaultParams(options.customParams?.paramDefinitions), + useOpenRouter: Boolean(useOpenRouter), + }); let azure = options.azure; let headers = options.headers; diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts index a78cc4b87d..5b2bb845fe 100644 --- a/packages/api/src/endpoints/openai/llm.spec.ts +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -675,6 +675,68 @@ describe('getOpenAILLMConfig', () => { expect(result.llmConfig).toHaveProperty('include_reasoning', true); expect(result.llmConfig).not.toHaveProperty('reasoning'); }); + + it('should pass promptCache only for OpenRouter', () => { + const openRouterResult = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'anthropic/claude-sonnet-4.6', + promptCache: true, + } as Partial, + }); + const openAIResult = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: false, + modelOptions: { + model: 'gpt-4o', + promptCache: true, + } as Partial, + }); + + expect(openRouterResult.llmConfig).toHaveProperty('promptCache', true); + expect(openRouterResult.llmConfig.modelKwargs).toBeUndefined(); + expect(openAIResult.llmConfig).not.toHaveProperty('promptCache'); + expect(openAIResult.llmConfig.modelKwargs).toBeUndefined(); + }); + + it('should resolve OpenRouter promptCache default/add/drop params', () => { + const enabled = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + defaultParams: { promptCache: true }, + modelOptions: { + model: 'anthropic/claude-sonnet-4.6', + }, + }); + const disabled = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + defaultParams: { promptCache: true }, + addParams: { promptCache: false }, + modelOptions: { + model: 'anthropic/claude-sonnet-4.6', + }, + }); + const dropped = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + defaultParams: { promptCache: true }, + dropParams: ['promptCache'], + modelOptions: { + model: 'anthropic/claude-sonnet-4.6', + }, + }); + + expect(enabled.llmConfig).toHaveProperty('promptCache', true); + expect(disabled.llmConfig).not.toHaveProperty('promptCache'); + expect(dropped.llmConfig).not.toHaveProperty('promptCache'); + }); }); describe('Verbosity Handling', () => { diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index 9351a42cc2..ebdaddfbc8 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -150,10 +150,11 @@ export function getOpenAILLMConfig({ reasoning_summary, verbosity, web_search, + promptCache, frequency_penalty, presence_penalty, ...modelOptions - } = cleanedModelOptions; + } = cleanedModelOptions as Partial; const llmConfig = Object.assign( { @@ -179,17 +180,23 @@ export function getOpenAILLMConfig({ } let enableWebSearch = web_search; + let enablePromptCache = promptCache; /** Apply defaultParams first - only if fields are undefined */ if (defaultParams && typeof defaultParams === 'object') { for (const [key, value] of Object.entries(defaultParams)) { - /** Handle web_search separately - don't add to config */ if (key === 'web_search') { if (enableWebSearch === undefined && typeof value === 'boolean') { enableWebSearch = value; } continue; } + if (key === 'promptCache') { + if (enablePromptCache === undefined && typeof value === 'boolean') { + enablePromptCache = value; + } + continue; + } if (knownOpenAIParams.has(key)) { applyDefaultParams(llmConfig as Record, { [key]: value }); @@ -206,13 +213,18 @@ export function getOpenAILLMConfig({ /** Apply addParams - can override defaultParams */ if (addParams && typeof addParams === 'object') { for (const [key, value] of Object.entries(addParams)) { - /** Handle web_search directly here instead of adding to modelKwargs or llmConfig */ if (key === 'web_search') { if (typeof value === 'boolean') { enableWebSearch = value; } continue; } + if (key === 'promptCache') { + if (typeof value === 'boolean') { + enablePromptCache = value; + } + continue; + } if (knownOpenAIParams.has(key)) { (llmConfig as Record)[key] = value; } else { @@ -263,6 +275,9 @@ export function getOpenAILLMConfig({ if (dropParams && dropParams.includes('web_search')) { enableWebSearch = false; } + if (dropParams && dropParams.includes('promptCache')) { + enablePromptCache = false; + } if (useOpenRouter && enableWebSearch) { /** OpenRouter expects web search as a plugins parameter */ @@ -273,6 +288,9 @@ export function getOpenAILLMConfig({ llmConfig.useResponsesApi = true; tools.push({ type: 'web_search' }); } + if (useOpenRouter && enablePromptCache === true) { + llmConfig.promptCache = true; + } /** * Note: OpenAI reasoning models (o1/o3/gpt-5) do not support temperature and other sampling parameters diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index 03fbb13b9e..b82cde86f8 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -30,6 +30,7 @@ export type OpenAIConfiguration = OpenAIClientOptions['configuration']; export type OAIClientOptions = OpenAIClientOptions & { include_reasoning?: boolean; + promptCache?: boolean; _lc_stream_delay?: number; }; diff --git a/packages/data-provider/specs/parsers.spec.ts b/packages/data-provider/specs/parsers.spec.ts index 8f0cf71e8e..b67b77a79d 100644 --- a/packages/data-provider/specs/parsers.spec.ts +++ b/packages/data-provider/specs/parsers.spec.ts @@ -1,6 +1,6 @@ import { replaceSpecialVars, parseConvo, parseCompactConvo, parseTextParts } from '../src/parsers'; import { specialVariables } from '../src/config'; -import { EModelEndpoint } from '../src/schemas'; +import { EModelEndpoint, Providers } from '../src/schemas'; import { ContentTypes } from '../src/types/runs'; import type { TMessageContentParts } from '../src/types/assistants'; import type { TUser, TConversation } from '../src/types'; @@ -409,6 +409,26 @@ describe('parseConvo - defaultParamsEndpoint', () => { expect(result?.topK).toBe(40); }); + test('should preserve promptCache when defaultParamsEndpoint is openrouter', () => { + const conversation: Partial = { + model: 'anthropic/claude-sonnet-4.6', + temperature: 0.7, + max_tokens: 8192, + promptCache: true, + }; + + const result = parseConvo({ + endpoint: 'OpenRouter' as EModelEndpoint, + endpointType: EModelEndpoint.custom, + conversation, + defaultParamsEndpoint: Providers.OPENROUTER, + }); + + expect(result).not.toBeNull(); + expect(result?.max_tokens).toBe(8192); + expect(result?.promptCache).toBe(true); + }); + test('should not strip fields from non-custom endpoints that already have a schema', () => { const conversation: Partial = { model: 'gpt-4o', @@ -527,6 +547,25 @@ describe('parseCompactConvo - defaultParamsEndpoint', () => { expect(result?.maxOutputTokens).toBe(8192); }); + test('should preserve promptCache when compacting OpenRouter custom endpoints', () => { + const conversation: Partial = { + model: 'anthropic/claude-sonnet-4.6', + promptCache: true, + iconURL: 'https://example.com/icon.png', + }; + + const result = parseCompactConvo({ + endpoint: 'OpenRouter' as EModelEndpoint, + endpointType: EModelEndpoint.custom, + conversation, + defaultParamsEndpoint: Providers.OPENROUTER, + }); + + expect(result).not.toBeNull(); + expect(result?.promptCache).toBe(true); + expect(result?.['iconURL']).toBeUndefined(); + }); + test('should fall back to endpointType when defaultParamsEndpoint is null', () => { const conversation: Partial = { model: 'gpt-4o', diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 6bc8c2b162..e9b4f36f11 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -6,6 +6,7 @@ import { EModelEndpoint, openAISettings, googleSettings, + Providers, ReasoningEffort, AnthropicEffort, ReasoningSummary, @@ -791,6 +792,8 @@ const openAI: SettingsConfiguration = [ librechat.fileTokenLimit, ]; +const openRouter: SettingsConfiguration = [...openAI, anthropic.promptCache]; + const openAICol1: SettingsConfiguration = [ baseDefinitions.model as SettingDefinition, librechat.modelLabel, @@ -1050,6 +1053,7 @@ export const paramSettings: Record = [EModelEndpoint.openAI]: openAI, [EModelEndpoint.azureOpenAI]: openAI, [EModelEndpoint.custom]: openAI, + [Providers.OPENROUTER]: openRouter, [EModelEndpoint.anthropic]: anthropicConfig, [`${EModelEndpoint.bedrock}-${BedrockProviders.Anthropic}`]: bedrockAnthropic, [`${EModelEndpoint.bedrock}-${BedrockProviders.MistralAI}`]: bedrockMistral, @@ -1086,6 +1090,10 @@ export const presetSettings: Record< [EModelEndpoint.openAI]: openAIColumns, [EModelEndpoint.azureOpenAI]: openAIColumns, [EModelEndpoint.custom]: openAIColumns, + [Providers.OPENROUTER]: { + col1: openAICol1, + col2: [...openAICol2, anthropic.promptCache], + }, [EModelEndpoint.anthropic]: { col1: anthropicCol1, col2: anthropicCol2, diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 76cf11f5c7..684b233e48 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -6,8 +6,10 @@ import type * as t from './types'; import { ContentTypes } from './types/runs'; import { openAISchema, + openRouterSchema, googleSchema, EModelEndpoint, + Providers, anthropicSchema, assistantSchema, // agentsSchema, @@ -20,6 +22,7 @@ import { alternateName } from './config'; type EndpointSchema = | typeof openAISchema + | typeof openRouterSchema | typeof googleSchema | typeof anthropicSchema | typeof assistantSchema @@ -27,11 +30,13 @@ type EndpointSchema = | typeof bedrockInputSchema; export type EndpointSchemaKey = EModelEndpoint; +type EndpointSchemaLookupKey = EModelEndpoint | Providers.OPENROUTER; -const endpointSchemas: Record = { +const endpointSchemas: Record = { [EModelEndpoint.openAI]: openAISchema, [EModelEndpoint.azureOpenAI]: openAISchema, [EModelEndpoint.custom]: openAISchema, + [Providers.OPENROUTER]: openRouterSchema, [EModelEndpoint.google]: googleSchema, [EModelEndpoint.anthropic]: anthropicSchema, [EModelEndpoint.assistants]: assistantSchema, @@ -40,6 +45,24 @@ const endpointSchemas: Record = { [EModelEndpoint.bedrock]: bedrockInputSchema, }; +const isEndpointSchemaLookupKey = (value?: string | null): value is EndpointSchemaLookupKey => + value != null && Object.prototype.hasOwnProperty.call(endpointSchemas, value); + +const getFallbackEndpointSchema = ( + schemas: Record, + endpointType?: EndpointSchemaKey | null, + defaultParamsEndpoint?: string | null, +): TSchema | undefined => { + if (!endpointType) { + return undefined; + } + + const overrideSchema = isEndpointSchemaLookupKey(defaultParamsEndpoint) + ? schemas[defaultParamsEndpoint] + : undefined; + return overrideSchema ?? schemas[endpointType]; +}; + // const schemaCreators: Record EndpointSchema> = { // [EModelEndpoint.google]: createGoogleSchema, // }; @@ -152,17 +175,15 @@ export const parseConvo = ({ possibleValues?: TPossibleValues; defaultParamsEndpoint?: string | null; }) => { - let schema = endpointSchemas[endpoint] as EndpointSchema | undefined; + const primarySchema = endpointSchemas[endpoint] as EndpointSchema | undefined; - if (!schema && !endpointType) { + if (!primarySchema && !endpointType) { throw new Error(`Unknown endpoint: ${endpoint}`); - } else if (!schema) { - const overrideSchema = defaultParamsEndpoint - ? endpointSchemas[defaultParamsEndpoint as EndpointSchemaKey] - : undefined; - schema = overrideSchema ?? (endpointType ? endpointSchemas[endpointType] : undefined); } + const schema = + primarySchema ?? + getFallbackEndpointSchema(endpointSchemas, endpointType, defaultParamsEndpoint); const convo = schema?.parse(conversation) as s.TConversation | undefined; const { models } = possibleValues ?? {}; @@ -289,13 +310,15 @@ type CompactEndpointSchema = | typeof compactAssistantSchema | typeof compactAgentsSchema | typeof compactGoogleSchema + | typeof openRouterSchema | typeof anthropicSchema | typeof bedrockInputSchema; -const compactEndpointSchemas: Record = { +const compactEndpointSchemas: Record = { [EModelEndpoint.openAI]: openAISchema, [EModelEndpoint.azureOpenAI]: openAISchema, [EModelEndpoint.custom]: openAISchema, + [Providers.OPENROUTER]: openRouterSchema, [EModelEndpoint.assistants]: compactAssistantSchema, [EModelEndpoint.azureAssistants]: compactAssistantSchema, [EModelEndpoint.agents]: compactAgentsSchema, @@ -321,17 +344,16 @@ export const parseCompactConvo = ({ throw new Error(`undefined endpoint: ${endpoint}`); } - let schema = compactEndpointSchemas[endpoint] as CompactEndpointSchema | undefined; + const primarySchema = compactEndpointSchemas[endpoint] as CompactEndpointSchema | undefined; - if (!schema && !endpointType) { + if (!primarySchema && !endpointType) { throw new Error(`Unknown endpoint: ${endpoint}`); - } else if (!schema) { - const overrideSchema = defaultParamsEndpoint - ? compactEndpointSchemas[defaultParamsEndpoint as EndpointSchemaKey] - : undefined; - schema = overrideSchema ?? (endpointType ? compactEndpointSchemas[endpointType] : undefined); } + const schema = + primarySchema ?? + getFallbackEndpointSchema(compactEndpointSchemas, endpointType, defaultParamsEndpoint); + if (!schema) { throw new Error(`Unknown endpointType: ${endpointType}`); } diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index c9f35d5d4b..bf9c119eec 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1250,6 +1250,11 @@ export const openAISchema = openAIBaseSchema .transform((obj: Partial) => removeNullishValues(obj, true)) .catch(() => ({})); +export const openRouterSchema = openAIBaseSchema + .merge(tConversationSchema.pick({ promptCache: true })) + .transform((obj: Partial) => removeNullishValues(obj, true)) + .catch(() => ({})); + export const compactGoogleSchema = googleBaseSchema .transform((obj) => { const newObj: Partial = { ...obj };