mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
✨ 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:
parent
486fe34a2b
commit
7147bce3c3
14 changed files with 989 additions and 6 deletions
|
|
@ -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
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
424
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
424
packages/api/src/endpoints/openai/llm.spec.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
compactAssistantSchema,
|
||||
} from './schemas';
|
||||
import { bedrockInputSchema } from './bedrock';
|
||||
import { extractEnvVariable } from './utils';
|
||||
import { alternateName } from './config';
|
||||
|
||||
type EndpointSchema =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export type TEndpointOption = Pick<
|
|||
| 'resendFiles'
|
||||
| 'imageDetail'
|
||||
| 'reasoning_effort'
|
||||
| 'verbosity'
|
||||
| 'instructions'
|
||||
| 'additional_instructions'
|
||||
| 'append_current_datetime'
|
||||
|
|
|
|||
|
|
@ -148,4 +148,8 @@ export const conversationPreset = {
|
|||
reasoning_summary: {
|
||||
type: String,
|
||||
},
|
||||
/** Verbosity control */
|
||||
verbosity: {
|
||||
type: String,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue