feat: Add OpenAI Verbosity Parameter (#8929)

* WIP: Verbosity OpenAI Parameter

* 🔧 chore: remove unused import of extractEnvVariable from parsers.ts

*  feat: add comprehensive tests for getOpenAIConfig and enhance verbosity handling

* fix: Handling for maxTokens in GPT-5+ models and add corresponding tests

* feat: Implement GPT-5+ model handling in processMemory function
This commit is contained in:
Danny Avila 2025-08-07 20:49:40 -04:00 committed by GitHub
parent 486fe34a2b
commit 7147bce3c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 989 additions and 6 deletions

View file

@ -1129,6 +1129,12 @@ class AgentClient extends BaseClient {
delete clientOptions.maxTokens;
}
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
clientOptions = Object.assign(
Object.fromEntries(
Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)),

View file

@ -728,6 +728,178 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('getOptions method - GPT-5+ model handling', () => {
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-5',
},
};
mockReq = {
app: {
locals: {},
},
user: {
id: 'user-123',
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
client = new AgentClient(mockOptions);
});
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should handle GPT-5+ models with existing modelKwargs', () => {
const clientOptions = {
model: 'gpt-6',
maxTokens: 1500,
temperature: 0.8,
modelKwargs: {
customParam: 'value',
},
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toEqual({
customParam: 'value',
max_completion_tokens: 1500,
});
});
it('should not modify maxTokens for non-GPT-5+ models', () => {
const clientOptions = {
model: 'gpt-4',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
// Should not be modified since it's GPT-4
expect(clientOptions.maxTokens).toBe(2048);
expect(clientOptions.modelKwargs).toBeUndefined();
});
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not transform if maxTokens is null or undefined', () => {
const testCases = [
{ model: 'gpt-5', maxTokens: null },
{ model: 'gpt-5', maxTokens: undefined },
{ model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
];
testCases.forEach(({ model, maxTokens }, index) => {
const clientOptions = {
model,
maxTokens,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (index < 2) {
// null or undefined cases
expect(clientOptions.maxTokens).toBe(maxTokens);
expect(clientOptions.modelKwargs).toBeUndefined();
} else {
// 0 case - should transform
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
}
});
});
});
describe('runMemory method', () => {
let client;
let mockReq;

View file

@ -231,6 +231,7 @@
"com_endpoint_openai_prompt_prefix_placeholder": "Set custom instructions to include in System Message. Default: none",
"com_endpoint_openai_reasoning_effort": "Reasoning models only: constrains effort on reasoning. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. 'Minimal' produces very few reasoning tokens for fastest time-to-first-token, especially well-suited for coding and instruction following.",
"com_endpoint_openai_reasoning_summary": "Responses API only: A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. Set to none,auto, concise, or detailed.",
"com_endpoint_openai_verbosity": "Constrains the verbosity of the model's response. Lower values will result in more concise responses, while higher values will result in more verbose responses. Currently supported values are low, medium, and high.",
"com_endpoint_openai_resend": "Resend all previously attached images. Note: this can significantly increase token cost and you may experience errors with many image attachments.",
"com_endpoint_openai_resend_files": "Resend all previously attached files. Note: this will increase token cost and you may experience errors with many attachments.",
"com_endpoint_openai_stop": "Up to 4 sequences where the API will stop generating further tokens.",
@ -269,6 +270,7 @@
"com_endpoint_prompt_prefix_placeholder": "Set custom instructions or context. Ignored if empty.",
"com_endpoint_reasoning_effort": "Reasoning Effort",
"com_endpoint_reasoning_summary": "Reasoning Summary",
"com_endpoint_verbosity": "Verbosity",
"com_endpoint_save_as_preset": "Save As Preset",
"com_endpoint_search": "Search endpoint by name",
"com_endpoint_search_endpoint_models": "Search {{0}} models...",

View file

@ -1,5 +1,8 @@
import { Tools, type MemoryArtifact } from 'librechat-data-provider';
import { createMemoryTool } from '../memory';
import { Response } from 'express';
import { Providers } from '@librechat/agents';
import { Tools } from 'librechat-data-provider';
import type { MemoryArtifact } from 'librechat-data-provider';
import { createMemoryTool, processMemory } from '../memory';
// Mock the logger
jest.mock('winston', () => ({
@ -25,6 +28,22 @@ jest.mock('~/utils', () => ({
},
}));
// Mock the Run module
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
Run: {
create: jest.fn(),
},
Providers: {
OPENAI: 'openai',
ANTHROPIC: 'anthropic',
AZURE: 'azure',
},
GraphEvents: {
TOOL_END: 'tool_end',
},
}));
describe('createMemoryTool', () => {
let mockSetMemory: jest.Mock;
@ -163,3 +182,220 @@ describe('createMemoryTool', () => {
});
});
});
describe('processMemory - GPT-5+ handling', () => {
let mockSetMemory: jest.Mock;
let mockDeleteMemory: jest.Mock;
let mockRes: Partial<Response>;
beforeEach(() => {
jest.clearAllMocks();
mockSetMemory = jest.fn().mockResolvedValue({ ok: true });
mockDeleteMemory = jest.fn().mockResolvedValue({ ok: true });
mockRes = {
headersSent: false,
write: jest.fn(),
};
// Setup the Run.create mock
const { Run } = jest.requireMock('@librechat/agents');
(Run.create as jest.Mock).mockResolvedValue({
processStream: jest.fn().mockResolvedValue('Memory processed'),
});
});
it('should remove temperature for GPT-5 models', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-5',
temperature: 0.7, // This should be removed
maxTokens: 1000, // This should be moved to modelKwargs
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-5',
modelKwargs: {
max_completion_tokens: 1000,
},
}),
}),
}),
);
// Verify temperature was removed
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
expect(callArgs.graphConfig.llmConfig.temperature).toBeUndefined();
expect(callArgs.graphConfig.llmConfig.maxTokens).toBeUndefined();
});
it('should handle GPT-5+ models with existing modelKwargs', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-6',
temperature: 0.8,
maxTokens: 2000,
modelKwargs: {
customParam: 'value',
},
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-6',
modelKwargs: {
customParam: 'value',
max_completion_tokens: 2000,
},
}),
}),
}),
);
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
expect(callArgs.graphConfig.llmConfig.temperature).toBeUndefined();
expect(callArgs.graphConfig.llmConfig.maxTokens).toBeUndefined();
});
it('should not modify non-GPT-5+ models', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-4',
temperature: 0.7,
maxTokens: 1000,
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-4',
temperature: 0.7,
maxTokens: 1000,
}),
}),
}),
);
// Verify nothing was moved to modelKwargs for GPT-4
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
expect(callArgs.graphConfig.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle various GPT-5+ model formats', async () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-9', shouldTransform: true },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
];
for (const { model, shouldTransform } of testCases) {
jest.clearAllMocks();
const { Run } = jest.requireMock('@librechat/agents');
(Run.create as jest.Mock).mockResolvedValue({
processStream: jest.fn().mockResolvedValue('Memory processed'),
});
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model,
temperature: 0.5,
maxTokens: 1500,
},
});
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
const llmConfig = callArgs.graphConfig.llmConfig;
if (shouldTransform) {
expect(llmConfig.temperature).toBeUndefined();
expect(llmConfig.maxTokens).toBeUndefined();
expect(llmConfig.modelKwargs?.max_completion_tokens).toBe(1500);
} else {
expect(llmConfig.temperature).toBe(0.5);
expect(llmConfig.maxTokens).toBe(1500);
expect(llmConfig.modelKwargs).toBeUndefined();
}
}
});
it('should use default model (gpt-4.1-mini) without temperature removal when no llmConfig provided', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
// No llmConfig provided
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-4.1-mini',
temperature: 0.4, // Default temperature should remain
}),
}),
}),
);
});
});

View file

@ -5,8 +5,10 @@ import { Tools } from 'librechat-data-provider';
import { logger } from '@librechat/data-schemas';
import { Run, Providers, GraphEvents } from '@librechat/agents';
import type {
OpenAIClientOptions,
StreamEventData,
ToolEndCallback,
ClientOptions,
EventHandler,
ToolEndData,
LLMConfig,
@ -332,7 +334,7 @@ ${memory ?? 'No existing memories'}`;
disableStreaming: true,
};
const finalLLMConfig = {
const finalLLMConfig: ClientOptions = {
...defaultLLMConfig,
...llmConfig,
/**
@ -342,6 +344,20 @@ ${memory ?? 'No existing memories'}`;
disableStreaming: true,
};
// Handle GPT-5+ models
if ('model' in finalLLMConfig && /\bgpt-[5-9]\b/i.test(finalLLMConfig.model ?? '')) {
// Remove temperature for GPT-5+ models
delete finalLLMConfig.temperature;
// Move maxTokens to modelKwargs for GPT-5+ models
if ('maxTokens' in finalLLMConfig && finalLLMConfig.maxTokens != null) {
const modelKwargs = (finalLLMConfig as OpenAIClientOptions).modelKwargs ?? {};
modelKwargs.max_completion_tokens = finalLLMConfig.maxTokens;
delete finalLLMConfig.maxTokens;
(finalLLMConfig as OpenAIClientOptions).modelKwargs = modelKwargs;
}
}
const artifactPromises: Promise<TAttachment | null>[] = [];
const memoryCallback = createMemoryCallback({ res, artifactPromises });
const customHandlers = {

View file

@ -0,0 +1,424 @@
import { ReasoningEffort, ReasoningSummary, Verbosity } from 'librechat-data-provider';
import type { RequestInit } from 'undici';
import { getOpenAIConfig } from './llm';
describe('getOpenAIConfig', () => {
const mockApiKey = 'test-api-key';
it('should create basic config with default values', () => {
const result = getOpenAIConfig(mockApiKey);
expect(result.llmConfig).toMatchObject({
streaming: true,
model: '',
apiKey: mockApiKey,
});
expect(result.configOptions).toEqual({});
expect(result.tools).toEqual([]);
});
it('should apply model options', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
max_tokens: 1000,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
modelKwargs: {
max_completion_tokens: 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', () => {
const addParams = {
temperature: 0.5, // known param
topP: 0.9, // known param
customParam1: 'value1', // unknown param
customParam2: { nested: true }, // unknown param
maxTokens: 500, // known param
};
const result = getOpenAIConfig(mockApiKey, { addParams });
expect(result.llmConfig.temperature).toBe(0.5);
expect(result.llmConfig.topP).toBe(0.9);
expect(result.llmConfig.maxTokens).toBe(500);
expect(result.llmConfig.modelKwargs).toEqual({
customParam1: 'value1',
customParam2: { nested: true },
});
});
it('should not add modelKwargs if all params are known', () => {
const addParams = {
temperature: 0.5,
topP: 0.9,
maxTokens: 500,
};
const result = getOpenAIConfig(mockApiKey, { addParams });
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle empty addParams', () => {
const result = getOpenAIConfig(mockApiKey, { addParams: {} });
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle reasoning params for useResponsesApi', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
};
const result = getOpenAIConfig(mockApiKey, {
modelOptions: { ...modelOptions, useResponsesApi: true },
});
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
});
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_summary).toBeUndefined();
});
it('should handle reasoning params without useResponsesApi', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBe(
ReasoningEffort.high,
);
expect(result.llmConfig.reasoning).toBeUndefined();
});
it('should handle OpenRouter configuration', () => {
const reverseProxyUrl = 'https://openrouter.ai/api/v1';
const result = getOpenAIConfig(mockApiKey, { reverseProxyUrl });
expect(result.configOptions?.baseURL).toBe(reverseProxyUrl);
expect(result.configOptions?.defaultHeaders).toMatchObject({
'HTTP-Referer': 'https://librechat.ai',
'X-Title': 'LibreChat',
});
expect(result.llmConfig.include_reasoning).toBe(true);
expect(result.provider).toBe('openrouter');
});
it('should handle Azure configuration', () => {
const azure = {
azureOpenAIApiInstanceName: 'test-instance',
azureOpenAIApiDeploymentName: 'test-deployment',
azureOpenAIApiVersion: '2023-05-15',
azureOpenAIApiKey: 'azure-key',
};
const result = getOpenAIConfig(mockApiKey, { azure });
expect(result.llmConfig).toMatchObject({
...azure,
model: 'test-deployment',
});
});
it('should handle web search model option', () => {
const modelOptions = {
model: 'gpt-5',
web_search: true,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.useResponsesApi).toBe(true);
expect(result.tools).toEqual([{ type: 'web_search_preview' }]);
});
it('should drop params for search models', () => {
const modelOptions = {
model: 'gpt-4o-search',
temperature: 0.7,
frequency_penalty: 0.5,
max_tokens: 1000,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.temperature).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).frequency_penalty).toBeUndefined();
expect(result.llmConfig.maxTokens).toBe(1000); // max_tokens is allowed
});
it('should handle custom dropParams', () => {
const modelOptions = {
temperature: 0.7,
topP: 0.9,
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, {
modelOptions,
dropParams: ['temperature', 'customParam'],
});
expect(result.llmConfig.temperature).toBeUndefined();
expect(result.llmConfig.topP).toBe(0.9);
expect((result.llmConfig as Record<string, unknown>).customParam).toBeUndefined();
});
it('should handle proxy configuration', () => {
const proxy = 'http://proxy.example.com:8080';
const result = getOpenAIConfig(mockApiKey, { proxy });
expect(result.configOptions?.fetchOptions).toBeDefined();
expect((result.configOptions?.fetchOptions as RequestInit).dispatcher).toBeDefined();
});
it('should handle headers and defaultQuery', () => {
const headers = { 'X-Custom-Header': 'value' };
const defaultQuery = { customParam: 'value' };
const result = getOpenAIConfig(mockApiKey, {
reverseProxyUrl: 'https://api.example.com',
headers,
defaultQuery,
});
expect(result.configOptions?.baseURL).toBe('https://api.example.com');
expect(result.configOptions?.defaultHeaders).toEqual(headers);
expect(result.configOptions?.defaultQuery).toEqual(defaultQuery);
});
it('should handle verbosity parameter in modelKwargs', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
verbosity: Verbosity.high,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
});
expect(result.llmConfig.modelKwargs).toEqual({
verbosity: Verbosity.high,
});
});
it('should allow addParams to override verbosity in modelKwargs', () => {
const modelOptions = {
model: 'gpt-5',
verbosity: Verbosity.low,
};
const addParams = {
temperature: 0.8,
verbosity: Verbosity.high, // This should override the one from modelOptions
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, { modelOptions, addParams });
expect(result.llmConfig.temperature).toBe(0.8);
expect(result.llmConfig.modelKwargs).toEqual({
verbosity: Verbosity.high, // Should be overridden by addParams
customParam: 'value',
});
});
it('should not create modelKwargs if verbosity is empty or null', () => {
const testCases = [
{ verbosity: null },
{ verbosity: Verbosity.none },
{ verbosity: undefined },
];
testCases.forEach((modelOptions) => {
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
});
it('should nest verbosity under text when useResponsesApi is enabled', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
verbosity: Verbosity.low,
useResponsesApi: true,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
useResponsesApi: true,
});
expect(result.llmConfig.modelKwargs).toEqual({
text: {
verbosity: Verbosity.low,
},
});
});
it('should handle verbosity correctly when addParams overrides with useResponsesApi', () => {
const modelOptions = {
model: 'gpt-5',
verbosity: Verbosity.low,
useResponsesApi: true,
};
const addParams = {
verbosity: Verbosity.high,
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, { modelOptions, addParams });
expect(result.llmConfig.modelKwargs).toEqual({
text: {
verbosity: Verbosity.high, // Should be overridden by addParams
},
customParam: 'value',
});
});
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5+ models', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
max_tokens: 2048,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
});
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
max_completion_tokens: 2048,
});
});
it('should handle GPT-5+ models with existing modelKwargs', () => {
const modelOptions = {
model: 'gpt-6',
max_tokens: 1000,
verbosity: Verbosity.low,
};
const addParams = {
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, { modelOptions, addParams });
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
verbosity: Verbosity.low,
customParam: 'value',
max_completion_tokens: 1000,
});
});
it('should not move maxTokens for non-GPT-5+ models', () => {
const modelOptions = {
model: 'gpt-4',
temperature: 0.7,
max_tokens: 2048,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-4',
temperature: 0.7,
maxTokens: 2048,
});
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle GPT-5+ models with verbosity and useResponsesApi', () => {
const modelOptions = {
model: 'gpt-5',
max_tokens: 1500,
verbosity: Verbosity.medium,
useResponsesApi: true,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
text: {
verbosity: Verbosity.medium,
},
max_completion_tokens: 1500,
});
});
it('should handle complex addParams with mixed known and unknown params', () => {
const addParams = {
// Known params
model: 'gpt-4-turbo',
temperature: 0.8,
topP: 0.95,
frequencyPenalty: 0.2,
presencePenalty: 0.1,
maxTokens: 2048,
stop: ['\\n\\n', 'END'],
stream: false,
// Unknown params
custom_instruction: 'Be concise',
response_style: 'formal',
domain_specific: {
medical: true,
terminology: 'advanced',
},
};
const result = getOpenAIConfig(mockApiKey, { addParams });
// Check known params are in llmConfig
expect(result.llmConfig).toMatchObject({
model: 'gpt-4-turbo',
temperature: 0.8,
topP: 0.95,
frequencyPenalty: 0.2,
presencePenalty: 0.1,
maxTokens: 2048,
stop: ['\\n\\n', 'END'],
stream: false,
});
// Check unknown params are in modelKwargs
expect(result.llmConfig.modelKwargs).toEqual({
custom_instruction: 'Be concise',
response_style: 'formal',
domain_specific: {
medical: true,
terminology: 'advanced',
},
});
});
});

View file

@ -8,6 +8,62 @@ import type * as t from '~/types';
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
import { isEnabled } from '~/utils/common';
export const knownOpenAIParams = new Set([
// Constructor/Instance Parameters
'model',
'modelName',
'temperature',
'topP',
'frequencyPenalty',
'presencePenalty',
'n',
'logitBias',
'stop',
'stopSequences',
'user',
'timeout',
'stream',
'maxTokens',
'maxCompletionTokens',
'logprobs',
'topLogprobs',
'apiKey',
'organization',
'audio',
'modalities',
'reasoning',
'zdrEnabled',
'service_tier',
'supportsStrictToolCalling',
'useResponsesApi',
'configuration',
// Call-time Options
'tools',
'tool_choice',
'functions',
'function_call',
'response_format',
'seed',
'stream_options',
'parallel_tool_calls',
'strict',
'prediction',
'promptIndex',
// Responses API specific
'text',
'truncation',
'include',
'previous_response_id',
// LangChain specific
'__includeRawResponse',
'maxConcurrency',
'maxRetries',
'verbose',
'streaming',
'streamUsage',
'disableStreaming',
]);
function hasReasoningParams({
reasoning_effort,
reasoning_summary,
@ -44,7 +100,7 @@ export function getOpenAIConfig(
addParams,
dropParams,
} = options;
const { reasoning_effort, reasoning_summary, ...modelOptions } = _modelOptions;
const { reasoning_effort, reasoning_summary, verbosity, ...modelOptions } = _modelOptions;
const llmConfig: Partial<t.ClientOptions> &
Partial<t.OpenAIParameters> &
Partial<AzureOpenAIInput> = Object.assign(
@ -55,8 +111,23 @@ export function getOpenAIConfig(
modelOptions,
);
const modelKwargs: Record<string, unknown> = {};
let hasModelKwargs = false;
if (verbosity != null && verbosity !== '') {
modelKwargs.verbosity = verbosity;
hasModelKwargs = true;
}
if (addParams && typeof addParams === 'object') {
Object.assign(llmConfig, addParams);
for (const [key, value] of Object.entries(addParams)) {
if (knownOpenAIParams.has(key)) {
(llmConfig as Record<string, unknown>)[key] = value;
} else {
hasModelKwargs = true;
modelKwargs[key] = value;
}
}
}
let useOpenRouter = false;
@ -223,6 +294,21 @@ export function getOpenAIConfig(
});
}
if (modelKwargs.verbosity && llmConfig.useResponsesApi === true) {
modelKwargs.text = { verbosity: modelKwargs.verbosity };
delete modelKwargs.verbosity;
}
if (llmConfig.model && /\bgpt-[5-9]\b/i.test(llmConfig.model) && llmConfig.maxTokens != null) {
modelKwargs.max_completion_tokens = llmConfig.maxTokens;
delete llmConfig.maxTokens;
hasModelKwargs = true;
}
if (hasModelKwargs) {
llmConfig.modelKwargs = modelKwargs;
}
const result: t.LLMConfigResult = {
llmConfig,
configOptions,

View file

@ -1,4 +1,5 @@
import {
Verbosity,
ImageDetail,
EModelEndpoint,
openAISettings,
@ -286,6 +287,25 @@ const openAIParams: Record<string, SettingDefinition> = {
optionType: 'model',
columnSpan: 4,
},
verbosity: {
key: 'verbosity',
label: 'com_endpoint_verbosity',
labelCode: true,
description: 'com_endpoint_openai_verbosity',
descriptionCode: true,
type: 'enum',
default: Verbosity.none,
component: 'slider',
options: [Verbosity.none, Verbosity.low, Verbosity.medium, Verbosity.high],
enumMappings: {
[Verbosity.none]: 'com_ui_none',
[Verbosity.low]: 'com_ui_low',
[Verbosity.medium]: 'com_ui_medium',
[Verbosity.high]: 'com_ui_high',
},
optionType: 'model',
columnSpan: 4,
},
disableStreaming: {
key: 'disableStreaming',
label: 'com_endpoint_disable_streaming_label',
@ -641,6 +661,7 @@ const openAI: SettingsConfiguration = [
openAIParams.reasoning_effort,
openAIParams.useResponsesApi,
openAIParams.reasoning_summary,
openAIParams.verbosity,
openAIParams.disableStreaming,
];
@ -662,6 +683,7 @@ const openAICol2: SettingsConfiguration = [
baseDefinitions.imageDetail,
openAIParams.reasoning_effort,
openAIParams.reasoning_summary,
openAIParams.verbosity,
openAIParams.useResponsesApi,
openAIParams.web_search,
openAIParams.disableStreaming,

View file

@ -18,7 +18,6 @@ import {
compactAssistantSchema,
} from './schemas';
import { bedrockInputSchema } from './bedrock';
import { extractEnvVariable } from './utils';
import { alternateName } from './config';
type EndpointSchema =

View file

@ -126,6 +126,13 @@ export enum ReasoningSummary {
detailed = 'detailed',
}
export enum Verbosity {
none = '',
low = 'low',
medium = 'medium',
high = 'high',
}
export const imageDetailNumeric = {
[ImageDetail.low]: 0,
[ImageDetail.auto]: 1,
@ -141,6 +148,7 @@ export const imageDetailValue = {
export const eImageDetailSchema = z.nativeEnum(ImageDetail);
export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort);
export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary);
export const eVerbositySchema = z.nativeEnum(Verbosity);
export const defaultAssistantFormValues = {
assistant: '',
@ -636,6 +644,8 @@ export const tConversationSchema = z.object({
/* OpenAI: Reasoning models only */
reasoning_effort: eReasoningEffortSchema.optional().nullable(),
reasoning_summary: eReasoningSummarySchema.optional().nullable(),
/* OpenAI: Verbosity control */
verbosity: eVerbositySchema.optional().nullable(),
/* OpenAI: use Responses API */
useResponsesApi: z.boolean().optional(),
/* OpenAI Responses API / Anthropic API / Google API */
@ -743,6 +753,8 @@ export const tQueryParamsSchema = tConversationSchema
/** @endpoints openAI, custom, azureOpenAI */
reasoning_summary: true,
/** @endpoints openAI, custom, azureOpenAI */
verbosity: true,
/** @endpoints openAI, custom, azureOpenAI */
useResponsesApi: true,
/** @endpoints openAI, anthropic, google */
web_search: true,
@ -1078,6 +1090,7 @@ export const openAIBaseSchema = tConversationSchema.pick({
max_tokens: true,
reasoning_effort: true,
reasoning_summary: true,
verbosity: true,
useResponsesApi: true,
web_search: true,
disableStreaming: true,

View file

@ -40,6 +40,7 @@ export type TEndpointOption = Pick<
| 'resendFiles'
| 'imageDetail'
| 'reasoning_effort'
| 'verbosity'
| 'instructions'
| 'additional_instructions'
| 'append_current_datetime'

View file

@ -148,4 +148,8 @@ export const conversationPreset = {
reasoning_summary: {
type: String,
},
/** Verbosity control */
verbosity: {
type: String,
},
};

View file

@ -47,6 +47,7 @@ export interface IPreset extends Document {
max_tokens?: number;
reasoning_effort?: string;
reasoning_summary?: string;
verbosity?: string;
useResponsesApi?: boolean;
web_search?: boolean;
disableStreaming?: boolean;

View file

@ -46,6 +46,7 @@ export interface IConversation extends Document {
max_tokens?: number;
reasoning_effort?: string;
reasoning_summary?: string;
verbosity?: string;
useResponsesApi?: boolean;
web_search?: boolean;
disableStreaming?: boolean;