diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index d8fda5159a..7a6d0cce18 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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)), diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 92b97ad006..5079bcebd8 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c44af0f4da..023e703c9a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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...", diff --git a/packages/api/src/agents/__tests__/memory.test.ts b/packages/api/src/agents/__tests__/memory.test.ts index 21dda8b0e4..7b69992792 100644 --- a/packages/api/src/agents/__tests__/memory.test.ts +++ b/packages/api/src/agents/__tests__/memory.test.ts @@ -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; + + 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 + }), + }), + }), + ); + }); +}); diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index e0c217cd84..298ebc04f6 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -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[] = []; const memoryCallback = createMemoryCallback({ res, artifactPromises }); const customHandlers = { diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts new file mode 100644 index 0000000000..79d9e05627 --- /dev/null +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -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).max_tokens).toBeUndefined(); + expect((result.llmConfig as Record).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).reasoning_effort).toBeUndefined(); + expect((result.llmConfig as Record).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).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).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).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', + }, + }); + }); +}); diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index 5f3b5b8276..e5c1b49c53 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -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 & Partial & Partial = Object.assign( @@ -55,8 +111,23 @@ export function getOpenAIConfig( modelOptions, ); + const modelKwargs: Record = {}; + 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)[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, diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index bebfa8377a..45604d885a 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -1,4 +1,5 @@ import { + Verbosity, ImageDetail, EModelEndpoint, openAISettings, @@ -286,6 +287,25 @@ const openAIParams: Record = { 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, diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 42379ad02e..7d4016449a 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -18,7 +18,6 @@ import { compactAssistantSchema, } from './schemas'; import { bedrockInputSchema } from './bedrock'; -import { extractEnvVariable } from './utils'; import { alternateName } from './config'; type EndpointSchema = diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 1b18ea24b8..71eae3824c 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -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, diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 5799ca50e8..b26e93ec80 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -40,6 +40,7 @@ export type TEndpointOption = Pick< | 'resendFiles' | 'imageDetail' | 'reasoning_effort' + | 'verbosity' | 'instructions' | 'additional_instructions' | 'append_current_datetime' diff --git a/packages/data-schemas/src/schema/defaults.ts b/packages/data-schemas/src/schema/defaults.ts index da986d729f..b432f283fb 100644 --- a/packages/data-schemas/src/schema/defaults.ts +++ b/packages/data-schemas/src/schema/defaults.ts @@ -148,4 +148,8 @@ export const conversationPreset = { reasoning_summary: { type: String, }, + /** Verbosity control */ + verbosity: { + type: String, + }, }; diff --git a/packages/data-schemas/src/schema/preset.ts b/packages/data-schemas/src/schema/preset.ts index c6ee8e6122..d95e7d132c 100644 --- a/packages/data-schemas/src/schema/preset.ts +++ b/packages/data-schemas/src/schema/preset.ts @@ -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; diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index cf2e1d2efc..fb8392c0f0 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -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;