mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-30 14:25:19 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
a7cf1ae27b
241 changed files with 25653 additions and 3303 deletions
|
|
@ -122,6 +122,38 @@ describe('getLLMConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" beta header for claude-opus-4-5 model', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-opus-4-5',
|
||||
promptCache: true,
|
||||
};
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
expect(clientOptions?.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
const defaultHeaders = clientOptions?.defaultHeaders as Record<string, string>;
|
||||
expect(defaultHeaders['anthropic-beta']).toBe('prompt-caching-2024-07-31');
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" beta header for claude-opus-4-5 model formats', () => {
|
||||
const modelVariations = [
|
||||
'claude-opus-4-5',
|
||||
'claude-opus-4-5-20250420',
|
||||
'claude-opus-4.5',
|
||||
'anthropic/claude-opus-4-5',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const modelOptions = { model, promptCache: true };
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
expect(clientOptions?.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
const defaultHeaders = clientOptions?.defaultHeaders as Record<string, string>;
|
||||
expect(defaultHeaders['anthropic-beta']).toBe('prompt-caching-2024-07-31');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT include topK and topP for Claude-3.7 models with thinking enabled (decimal notation)', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
|
|
@ -707,6 +739,7 @@ describe('getLLMConfig', () => {
|
|||
{ model: 'claude-haiku-4-5-20251001', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-opus-4-1', expectedMaxTokens: 32000 },
|
||||
{ model: 'claude-opus-4-1-20250805', expectedMaxTokens: 32000 },
|
||||
{ model: 'claude-opus-4-5', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-sonnet-4-20250514', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-opus-4-0', expectedMaxTokens: 32000 },
|
||||
];
|
||||
|
|
@ -771,6 +804,17 @@ describe('getLLMConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should default Claude Opus 4.5 model to 64K tokens', () => {
|
||||
const testCases = ['claude-opus-4-5', 'claude-opus-4-5-20250420', 'claude-opus-4.5'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default future Claude 4.x Sonnet/Haiku models to 64K (future-proofing)', () => {
|
||||
const testCases = ['claude-sonnet-4-20250514', 'claude-sonnet-4-9', 'claude-haiku-4-8'];
|
||||
|
||||
|
|
@ -782,15 +826,24 @@ describe('getLLMConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should default future Claude 4.x Opus models to 32K (future-proofing)', () => {
|
||||
const testCases = ['claude-opus-4-0', 'claude-opus-4-7'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
it('should default future Claude 4.x Opus models (future-proofing)', () => {
|
||||
// opus-4-0 through opus-4-4 get 32K
|
||||
const opus32kModels = ['claude-opus-4-0', 'claude-opus-4-1', 'claude-opus-4-4'];
|
||||
opus32kModels.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(32000);
|
||||
});
|
||||
|
||||
// opus-4-5+ get 64K
|
||||
const opus64kModels = ['claude-opus-4-5', 'claude-opus-4-7', 'claude-opus-4-10'];
|
||||
opus64kModels.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle explicit maxOutputTokens override for Claude 4.x models', () => {
|
||||
|
|
@ -908,7 +961,7 @@ describe('getLLMConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should future-proof Claude 5.x Opus models with 32K default', () => {
|
||||
it('should future-proof Claude 5.x Opus models with 64K default', () => {
|
||||
const testCases = [
|
||||
'claude-opus-5',
|
||||
'claude-opus-5-0',
|
||||
|
|
@ -920,28 +973,28 @@ describe('getLLMConfig', () => {
|
|||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(32000);
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should future-proof Claude 6-9.x models with correct defaults', () => {
|
||||
const testCases = [
|
||||
// Claude 6.x
|
||||
// Claude 6.x - All get 64K since they're version 5+
|
||||
{ model: 'claude-sonnet-6', expected: 64000 },
|
||||
{ model: 'claude-haiku-6-0', expected: 64000 },
|
||||
{ model: 'claude-opus-6-1', expected: 32000 },
|
||||
{ model: 'claude-opus-6-1', expected: 64000 }, // opus 6+ gets 64K
|
||||
// Claude 7.x
|
||||
{ model: 'claude-sonnet-7-20270101', expected: 64000 },
|
||||
{ model: 'claude-haiku-7.5', expected: 64000 },
|
||||
{ model: 'claude-opus-7', expected: 32000 },
|
||||
{ model: 'claude-opus-7', expected: 64000 }, // opus 7+ gets 64K
|
||||
// Claude 8.x
|
||||
{ model: 'claude-sonnet-8', expected: 64000 },
|
||||
{ model: 'claude-haiku-8-2', expected: 64000 },
|
||||
{ model: 'claude-opus-8-latest', expected: 32000 },
|
||||
{ model: 'claude-opus-8-latest', expected: 64000 }, // opus 8+ gets 64K
|
||||
// Claude 9.x
|
||||
{ model: 'claude-sonnet-9', expected: 64000 },
|
||||
{ model: 'claude-haiku-9', expected: 64000 },
|
||||
{ model: 'claude-opus-9', expected: 32000 },
|
||||
{ model: 'claude-opus-9', expected: 64000 }, // opus 9+ gets 64K
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, expected }) => {
|
||||
|
|
|
|||
|
|
@ -121,9 +121,12 @@ export function getSafetySettings(
|
|||
export function getGoogleConfig(
|
||||
credentials: string | t.GoogleCredentials | undefined,
|
||||
options: t.GoogleConfigOptions = {},
|
||||
acceptRawApiKey = false,
|
||||
) {
|
||||
let creds: t.GoogleCredentials = {};
|
||||
if (typeof credentials === 'string') {
|
||||
if (acceptRawApiKey && typeof credentials === 'string') {
|
||||
creds[AuthKeys.GOOGLE_API_KEY] = credentials;
|
||||
} else if (typeof credentials === 'string') {
|
||||
try {
|
||||
creds = JSON.parse(credentials);
|
||||
} catch (err: unknown) {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,26 @@ describe('getOpenAIConfig - Google Compatibility', () => {
|
|||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out googleSearch when web_search is only in modelOptions (not explicitly in addParams/defaultParams)', () => {
|
||||
const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' });
|
||||
const endpoint = 'Gemini (Custom)';
|
||||
const options = {
|
||||
modelOptions: {
|
||||
model: 'gemini-2.0-flash-exp',
|
||||
web_search: true,
|
||||
},
|
||||
customParams: {
|
||||
defaultParamsEndpoint: 'google',
|
||||
},
|
||||
reverseProxyUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(apiKey, options, endpoint);
|
||||
|
||||
/** googleSearch should be filtered out since web_search was not explicitly added via addParams or defaultParams */
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle web_search with mixed Google and OpenAI params in addParams', () => {
|
||||
const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' });
|
||||
const endpoint = 'Gemini (Custom)';
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
it('should apply model options', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
};
|
||||
|
|
@ -34,14 +34,11 @@ describe('getOpenAIConfig', () => {
|
|||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
modelKwargs: {
|
||||
max_completion_tokens: 1000,
|
||||
},
|
||||
maxTokens: 1000,
|
||||
});
|
||||
expect((result.llmConfig as Record<string, unknown>).max_tokens).toBeUndefined();
|
||||
expect((result.llmConfig as Record<string, unknown>).maxTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should separate known and unknown params from addParams', () => {
|
||||
|
|
@ -286,7 +283,7 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
it('should ignore non-boolean web_search values in addParams', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
};
|
||||
|
||||
|
|
@ -399,7 +396,7 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
it('should handle verbosity parameter in modelKwargs', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
verbosity: Verbosity.high,
|
||||
};
|
||||
|
|
@ -407,7 +404,7 @@ describe('getOpenAIConfig', () => {
|
|||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
});
|
||||
expect(result.llmConfig.modelKwargs).toEqual({
|
||||
|
|
@ -417,7 +414,7 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
it('should allow addParams to override verbosity in modelKwargs', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
verbosity: Verbosity.low,
|
||||
};
|
||||
|
||||
|
|
@ -451,7 +448,7 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
it('should nest verbosity under text when useResponsesApi is enabled', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
verbosity: Verbosity.low,
|
||||
useResponsesApi: true,
|
||||
|
|
@ -460,7 +457,7 @@ describe('getOpenAIConfig', () => {
|
|||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
useResponsesApi: true,
|
||||
});
|
||||
|
|
@ -496,7 +493,6 @@ describe('getOpenAIConfig', () => {
|
|||
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5+ models', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
};
|
||||
|
||||
|
|
@ -504,7 +500,6 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
temperature: 0.7,
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBeUndefined();
|
||||
expect(result.llmConfig.modelKwargs).toEqual({
|
||||
|
|
@ -1684,7 +1679,7 @@ describe('getOpenAIConfig', () => {
|
|||
it('should not override existing modelOptions with defaultParams', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.9,
|
||||
},
|
||||
customParams: {
|
||||
|
|
@ -1697,7 +1692,7 @@ describe('getOpenAIConfig', () => {
|
|||
});
|
||||
|
||||
expect(result.llmConfig.temperature).toBe(0.9);
|
||||
expect(result.llmConfig.modelKwargs?.max_completion_tokens).toBe(1000);
|
||||
expect(result.llmConfig.maxTokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('should allow addParams to override defaultParams', () => {
|
||||
|
|
@ -1845,7 +1840,7 @@ describe('getOpenAIConfig', () => {
|
|||
it('should preserve order: defaultParams < addParams < modelOptions', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.9,
|
||||
},
|
||||
customParams: {
|
||||
|
|
@ -1863,7 +1858,7 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
expect(result.llmConfig.temperature).toBe(0.9);
|
||||
expect(result.llmConfig.topP).toBe(0.8);
|
||||
expect(result.llmConfig.modelKwargs?.max_completion_tokens).toBe(500);
|
||||
expect(result.llmConfig.maxTokens).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,23 +77,29 @@ export function getOpenAIConfig(
|
|||
headers = Object.assign(headers ?? {}, transformed.configOptions?.defaultHeaders);
|
||||
}
|
||||
} else if (isGoogle) {
|
||||
const googleResult = getGoogleConfig(apiKey, {
|
||||
modelOptions,
|
||||
reverseProxyUrl: baseURL ?? undefined,
|
||||
authHeader: true,
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
});
|
||||
const googleResult = getGoogleConfig(
|
||||
apiKey,
|
||||
{
|
||||
modelOptions,
|
||||
reverseProxyUrl: baseURL ?? undefined,
|
||||
authHeader: true,
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
},
|
||||
true,
|
||||
);
|
||||
/** Transform handles addParams/dropParams - it knows about OpenAI params */
|
||||
const transformed = transformToOpenAIConfig({
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
tools: googleResult.tools,
|
||||
llmConfig: googleResult.llmConfig,
|
||||
fromEndpoint: EModelEndpoint.google,
|
||||
});
|
||||
llmConfig = transformed.llmConfig;
|
||||
tools = googleResult.tools;
|
||||
tools = transformed.tools;
|
||||
} else {
|
||||
const openaiResult = getOpenAILLMConfig({
|
||||
azure,
|
||||
|
|
|
|||
602
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
602
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
import {
|
||||
Verbosity,
|
||||
EModelEndpoint,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
} from 'librechat-data-provider';
|
||||
import { getOpenAILLMConfig, extractDefaultParams, applyDefaultParams } from './llm';
|
||||
import type * as t from '~/types';
|
||||
|
||||
describe('getOpenAILLMConfig', () => {
|
||||
describe('Basic Configuration', () => {
|
||||
it('should create a basic configuration with required fields', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key');
|
||||
expect(result.llmConfig).toHaveProperty('model', 'gpt-4');
|
||||
expect(result.llmConfig).toHaveProperty('streaming', true);
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle model options including temperature and penalties', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
});
|
||||
|
||||
it('should handle max_tokens conversion to maxTokens', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 4096,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(result.llmConfig).not.toHaveProperty('max_tokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI Reasoning Models (o1/o3/gpt-5)', () => {
|
||||
const reasoningModels = [
|
||||
'o1',
|
||||
'o1-mini',
|
||||
'o1-preview',
|
||||
'o1-pro',
|
||||
'o3',
|
||||
'o3-mini',
|
||||
'gpt-5',
|
||||
'gpt-5-pro',
|
||||
'gpt-5-turbo',
|
||||
];
|
||||
|
||||
const excludedParams = [
|
||||
'frequencyPenalty',
|
||||
'presencePenalty',
|
||||
'temperature',
|
||||
'topP',
|
||||
'logitBias',
|
||||
'n',
|
||||
'logprobs',
|
||||
];
|
||||
|
||||
it.each(reasoningModels)(
|
||||
'should exclude unsupported parameters for reasoning model: %s',
|
||||
(model) => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
logitBias: { '50256': -100 },
|
||||
n: 2,
|
||||
logprobs: true,
|
||||
} as Partial<t.OpenAIParameters>,
|
||||
});
|
||||
|
||||
excludedParams.forEach((param) => {
|
||||
expect(result.llmConfig).not.toHaveProperty(param);
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('model', model);
|
||||
expect(result.llmConfig).toHaveProperty('streaming', true);
|
||||
},
|
||||
);
|
||||
|
||||
it('should preserve maxTokens for reasoning models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
});
|
||||
|
||||
it('should preserve other valid parameters for reasoning models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
max_tokens: 8192,
|
||||
stop: ['END'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 8192);
|
||||
expect(result.llmConfig).toHaveProperty('stop', ['END']);
|
||||
});
|
||||
|
||||
it('should handle GPT-5 max_tokens conversion to max_completion_tokens', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
max_tokens: 8192,
|
||||
stop: ['END'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('max_completion_tokens', 8192);
|
||||
expect(result.llmConfig).not.toHaveProperty('maxTokens');
|
||||
expect(result.llmConfig).toHaveProperty('stop', ['END']);
|
||||
});
|
||||
|
||||
it('should combine user dropParams with reasoning exclusion params', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'o3-mini',
|
||||
temperature: 0.7,
|
||||
stop: ['END'],
|
||||
},
|
||||
dropParams: ['stop'],
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).not.toHaveProperty('stop');
|
||||
});
|
||||
|
||||
it('should NOT exclude parameters for non-reasoning models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4-turbo',
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should NOT exclude parameters for gpt-5.x versioned models (they support sampling params)', () => {
|
||||
const versionedModels = ['gpt-5.1', 'gpt-5.1-turbo', 'gpt-5.2', 'gpt-5.5-preview'];
|
||||
|
||||
versionedModels.forEach((model) => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT exclude parameters for gpt-5-chat (it supports sampling params)', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5-chat',
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should handle reasoning models with reasoning_effort parameter', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI Web Search Models', () => {
|
||||
it('should exclude parameters for gpt-4o search models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4o-search-preview',
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
seed: 42,
|
||||
} as Partial<t.OpenAIParameters>,
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).not.toHaveProperty('top_p');
|
||||
expect(result.llmConfig).not.toHaveProperty('seed');
|
||||
});
|
||||
|
||||
it('should preserve max_tokens for search models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4o-search',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web Search Functionality', () => {
|
||||
it('should enable web search with Responses API', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('useResponsesApi', true);
|
||||
expect(result.tools).toContainEqual({ type: 'web_search' });
|
||||
});
|
||||
|
||||
it('should handle web search with OpenRouter', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
useOpenRouter: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('plugins', [{ id: 'web' }]);
|
||||
expect(result.llmConfig).toHaveProperty('include_reasoning', true);
|
||||
});
|
||||
|
||||
it('should disable web search via dropParams', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
},
|
||||
dropParams: ['web_search'],
|
||||
});
|
||||
|
||||
expect(result.tools).not.toContainEqual({ type: 'web_search' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GPT-5 max_tokens Handling', () => {
|
||||
it('should convert maxTokens to max_completion_tokens for GPT-5 models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
max_tokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('max_completion_tokens', 8192);
|
||||
expect(result.llmConfig).not.toHaveProperty('maxTokens');
|
||||
});
|
||||
|
||||
it('should convert maxTokens to max_output_tokens for GPT-5 with Responses API', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
max_tokens: 8192,
|
||||
},
|
||||
addParams: {
|
||||
useResponsesApi: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('max_output_tokens', 8192);
|
||||
expect(result.llmConfig).not.toHaveProperty('maxTokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reasoning Parameters', () => {
|
||||
it('should handle reasoning_effort for OpenAI endpoint', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
|
||||
});
|
||||
|
||||
it('should use reasoning object for non-OpenAI endpoints', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: 'custom',
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.concise,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning');
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.high,
|
||||
summary: ReasoningSummary.concise,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use reasoning object when useResponsesApi is true', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.medium,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
},
|
||||
addParams: {
|
||||
useResponsesApi: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning');
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.medium,
|
||||
summary: ReasoningSummary.detailed,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default and Add Parameters', () => {
|
||||
it('should apply default parameters when fields are undefined', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
defaultParams: {
|
||||
temperature: 0.5,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should NOT override existing values with default parameters', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.8,
|
||||
},
|
||||
defaultParams: {
|
||||
temperature: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.8);
|
||||
});
|
||||
|
||||
it('should apply addParams and override defaults', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
defaultParams: {
|
||||
temperature: 0.5,
|
||||
},
|
||||
addParams: {
|
||||
temperature: 0.9,
|
||||
seed: 42,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.9);
|
||||
expect(result.llmConfig).toHaveProperty('seed', 42);
|
||||
});
|
||||
|
||||
it('should handle unknown params via modelKwargs', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
addParams: {
|
||||
custom_param: 'custom_value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('custom_param', 'custom_value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drop Parameters', () => {
|
||||
it('should drop specified parameters', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
},
|
||||
dropParams: ['temperature'],
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenRouter Configuration', () => {
|
||||
it('should include include_reasoning for OpenRouter', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
useOpenRouter: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('include_reasoning', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verbosity Handling', () => {
|
||||
it('should add verbosity to modelKwargs', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
verbosity: Verbosity.high,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('verbosity', Verbosity.high);
|
||||
});
|
||||
|
||||
it('should convert verbosity to text object with Responses API', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
verbosity: Verbosity.low,
|
||||
},
|
||||
addParams: {
|
||||
useResponsesApi: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('text', { verbosity: Verbosity.low });
|
||||
expect(result.llmConfig.modelKwargs).not.toHaveProperty('verbosity');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractDefaultParams', () => {
|
||||
it('should extract default values from param definitions', () => {
|
||||
const paramDefinitions = [
|
||||
{ key: 'temperature', default: 0.7 },
|
||||
{ key: 'maxTokens', default: 4096 },
|
||||
{ key: 'noDefault' },
|
||||
];
|
||||
|
||||
const result = extractDefaultParams(paramDefinitions);
|
||||
|
||||
expect(result).toEqual({
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for undefined or non-array input', () => {
|
||||
expect(extractDefaultParams(undefined)).toBeUndefined();
|
||||
expect(extractDefaultParams(null as unknown as undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = extractDefaultParams([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDefaultParams', () => {
|
||||
it('should apply defaults only when field is undefined', () => {
|
||||
const target: Record<string, unknown> = {
|
||||
temperature: 0.8,
|
||||
maxTokens: undefined,
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
temperature: 0.5,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9,
|
||||
};
|
||||
|
||||
applyDefaultParams(target, defaults);
|
||||
|
||||
expect(target).toEqual({
|
||||
temperature: 0.8,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -259,9 +259,35 @@ export function getOpenAILLMConfig({
|
|||
}
|
||||
|
||||
/**
|
||||
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
|
||||
* Note: OpenAI reasoning models (o1/o3/gpt-5) do not support temperature and other sampling parameters
|
||||
* Exception: gpt-5-chat and versioned models like gpt-5.1 DO support these parameters
|
||||
*/
|
||||
if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
|
||||
if (
|
||||
modelOptions.model &&
|
||||
/\b(o[13]|gpt-5)(?!\.|-chat)(?:-|$)/.test(modelOptions.model as string)
|
||||
) {
|
||||
const reasoningExcludeParams = [
|
||||
'frequencyPenalty',
|
||||
'presencePenalty',
|
||||
'temperature',
|
||||
'topP',
|
||||
'logitBias',
|
||||
'n',
|
||||
'logprobs',
|
||||
];
|
||||
|
||||
const updatedDropParams = dropParams || [];
|
||||
const combinedDropParams = [...new Set([...updatedDropParams, ...reasoningExcludeParams])];
|
||||
|
||||
combinedDropParams.forEach((param) => {
|
||||
if (param in llmConfig) {
|
||||
delete llmConfig[param as keyof t.OAIClientOptions];
|
||||
}
|
||||
});
|
||||
} else if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
|
||||
/**
|
||||
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
|
||||
*/
|
||||
const searchExcludeParams = [
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
|
|
|
|||
|
|
@ -1,28 +1,48 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { GoogleAIToolType } from '@langchain/google-common';
|
||||
import type { ClientOptions } from '@librechat/agents';
|
||||
import type * as t from '~/types';
|
||||
import { knownOpenAIParams } from './llm';
|
||||
|
||||
const anthropicExcludeParams = new Set(['anthropicApiUrl']);
|
||||
const googleExcludeParams = new Set(['safetySettings', 'location', 'baseUrl', 'customHeaders']);
|
||||
const googleExcludeParams = new Set([
|
||||
'safetySettings',
|
||||
'location',
|
||||
'baseUrl',
|
||||
'customHeaders',
|
||||
'thinkingConfig',
|
||||
'thinkingBudget',
|
||||
'includeThoughts',
|
||||
]);
|
||||
|
||||
/** Google-specific tool types that have no OpenAI-compatible equivalent */
|
||||
const googleToolsToFilter = new Set(['googleSearch']);
|
||||
|
||||
export type ConfigTools = Array<Record<string, unknown>> | Array<GoogleAIToolType>;
|
||||
|
||||
/**
|
||||
* Transforms a Non-OpenAI LLM config to an OpenAI-conformant config.
|
||||
* Non-OpenAI parameters are moved to modelKwargs.
|
||||
* Also extracts configuration options that belong in configOptions.
|
||||
* Handles addParams and dropParams for parameter customization.
|
||||
* Filters out provider-specific tools that have no OpenAI equivalent.
|
||||
*/
|
||||
export function transformToOpenAIConfig({
|
||||
tools,
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
llmConfig,
|
||||
fromEndpoint,
|
||||
}: {
|
||||
tools?: ConfigTools;
|
||||
addParams?: Record<string, unknown>;
|
||||
dropParams?: string[];
|
||||
defaultParams?: Record<string, unknown>;
|
||||
llmConfig: ClientOptions;
|
||||
fromEndpoint: string;
|
||||
}): {
|
||||
tools: ConfigTools;
|
||||
llmConfig: t.OAIClientOptions;
|
||||
configOptions: Partial<t.OpenAIConfiguration>;
|
||||
} {
|
||||
|
|
@ -58,18 +78,9 @@ export function transformToOpenAIConfig({
|
|||
hasModelKwargs = true;
|
||||
continue;
|
||||
} else if (isGoogle && key === 'authOptions') {
|
||||
// Handle Google authOptions
|
||||
modelKwargs = Object.assign({}, modelKwargs, value as Record<string, unknown>);
|
||||
hasModelKwargs = true;
|
||||
continue;
|
||||
} else if (
|
||||
isGoogle &&
|
||||
(key === 'thinkingConfig' || key === 'thinkingBudget' || key === 'includeThoughts')
|
||||
) {
|
||||
// Handle Google thinking configuration
|
||||
modelKwargs = Object.assign({}, modelKwargs, { [key]: value });
|
||||
hasModelKwargs = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (knownOpenAIParams.has(key)) {
|
||||
|
|
@ -121,7 +132,34 @@ export function transformToOpenAIConfig({
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out provider-specific tools that have no OpenAI equivalent.
|
||||
* Exception: If web_search was explicitly enabled via addParams or defaultParams,
|
||||
* preserve googleSearch tools (pass through in Google-native format).
|
||||
*/
|
||||
const webSearchExplicitlyEnabled =
|
||||
addParams?.web_search === true || defaultParams?.web_search === true;
|
||||
|
||||
const filterGoogleTool = (tool: unknown): boolean => {
|
||||
if (!isGoogle) {
|
||||
return true;
|
||||
}
|
||||
if (typeof tool !== 'object' || tool === null) {
|
||||
return false;
|
||||
}
|
||||
const toolKeys = Object.keys(tool as Record<string, unknown>);
|
||||
const isGoogleSpecificTool = toolKeys.some((key) => googleToolsToFilter.has(key));
|
||||
/** Preserve googleSearch if web_search was explicitly enabled */
|
||||
if (isGoogleSpecificTool && webSearchExplicitlyEnabled) {
|
||||
return true;
|
||||
}
|
||||
return !isGoogleSpecificTool;
|
||||
};
|
||||
|
||||
const filteredTools = Array.isArray(tools) ? tools.filter(filterGoogleTool) : [];
|
||||
|
||||
return {
|
||||
tools: filteredTools,
|
||||
llmConfig: openAIConfig as t.OAIClientOptions,
|
||||
configOptions,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue