fix: Debounce setUserContext and Default State Param for OpenID Auth (#7559)

* fix: Add default random state parameter to OpenID auth request for providers that require it; ensure passport strategy uses it

*  refactor: debounce setUserContext to avoid race condition

* refactor: Update OpenID authentication to use randomState from openid-client

* chore: linting in presetSettings type definition

* chore: import order in ModelPanel

* refactor: remove `isLegacyOutput` property from AnthropicClient since only used where defined, add latest models to non-legacy patterns, and remove from client cleanup

* refactor: adjust grid layout in Parameters component for improved responsiveness

* refactor: adjust grid layout in ModelPanel for improved display of model parameters

* test: add cases for maxOutputTokens handling in Claude 4 Sonnet and Opus models

* ci: mock loadCustomConfig in server tests and refactor OpenID route for improved authentication handling
This commit is contained in:
Danny Avila 2025-05-25 23:40:37 -04:00 committed by GitHub
parent deb8a00e27
commit c68cc0a550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 90 additions and 48 deletions

View file

@ -74,9 +74,6 @@ class AnthropicClient extends BaseClient {
/** Whether to use Messages API or Completions API /** Whether to use Messages API or Completions API
* @type {boolean} */ * @type {boolean} */
this.useMessages; this.useMessages;
/** Whether or not the model is limited to the legacy amount of output tokens
* @type {boolean} */
this.isLegacyOutput;
/** Whether or not the model supports Prompt Caching /** Whether or not the model supports Prompt Caching
* @type {boolean} */ * @type {boolean} */
this.supportsCacheControl; this.supportsCacheControl;
@ -118,13 +115,16 @@ class AnthropicClient extends BaseClient {
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic); const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
this.isClaudeLatest = this.isClaudeLatest =
/claude-[3-9]/.test(modelMatch) || /claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch); /claude-[3-9]/.test(modelMatch) || /claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch);
this.isLegacyOutput = !( const isLegacyOutput = !(
/claude-3[-.]5-sonnet/.test(modelMatch) || /claude-3[-.]7/.test(modelMatch) /claude-3[-.]5-sonnet/.test(modelMatch) ||
/claude-3[-.]7/.test(modelMatch) ||
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch) ||
/claude-[4-9]/.test(modelMatch)
); );
this.supportsCacheControl = this.options.promptCache && checkPromptCacheSupport(modelMatch); this.supportsCacheControl = this.options.promptCache && checkPromptCacheSupport(modelMatch);
if ( if (
this.isLegacyOutput && isLegacyOutput &&
this.modelOptions.maxOutputTokens && this.modelOptions.maxOutputTokens &&
this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default
) { ) {

View file

@ -514,6 +514,34 @@ describe('AnthropicClient', () => {
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue); expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
}); });
it('should not cap maxOutputTokens for Claude 4 Sonnet models', () => {
const client = new AnthropicClient('test-api-key');
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 10; // 40,960 tokens
client.setOptions({
modelOptions: {
model: 'claude-sonnet-4-20250514',
maxOutputTokens: highTokenValue,
},
});
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
});
it('should not cap maxOutputTokens for Claude 4 Opus models', () => {
const client = new AnthropicClient('test-api-key');
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 6; // 24,576 tokens (under 32K limit)
client.setOptions({
modelOptions: {
model: 'claude-opus-4-20250514',
maxOutputTokens: highTokenValue,
},
});
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
});
it('should cap maxOutputTokens for Claude 3.5 Haiku models', () => { it('should cap maxOutputTokens for Claude 3.5 Haiku models', () => {
const client = new AnthropicClient('test-api-key'); const client = new AnthropicClient('test-api-key');
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2; const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2;

View file

@ -140,9 +140,6 @@ function disposeClient(client) {
if (client.useMessages !== undefined) { if (client.useMessages !== undefined) {
client.useMessages = null; client.useMessages = null;
} }
if (client.isLegacyOutput !== undefined) {
client.isLegacyOutput = null;
}
if (client.supportsCacheControl !== undefined) { if (client.supportsCacheControl !== undefined) {
client.supportsCacheControl = null; client.supportsCacheControl = null;
} }

View file

@ -4,6 +4,10 @@ const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
jest.mock('~/server/services/Config/loadCustomConfig', () => {
return jest.fn(() => Promise.resolve({}));
});
describe('Server Configuration', () => { describe('Server Configuration', () => {
// Increase the default timeout to allow for Mongo cleanup // Increase the default timeout to allow for Mongo cleanup
jest.setTimeout(30_000); jest.setTimeout(30_000);

View file

@ -1,6 +1,7 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware // file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express'); const express = require('express');
const passport = require('passport'); const passport = require('passport');
const { randomState } = require('openid-client');
const { const {
checkBan, checkBan,
logHeaders, logHeaders,
@ -9,8 +10,8 @@ const {
checkDomainAllowed, checkDomainAllowed,
} = require('~/server/middleware'); } = require('~/server/middleware');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const { isEnabled } = require('~/server/utils'); const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
@ -103,12 +104,12 @@ router.get(
/** /**
* OpenID Routes * OpenID Routes
*/ */
router.get( router.get('/openid', (req, res, next) => {
'/openid', return passport.authenticate('openid', {
passport.authenticate('openid', {
session: false, session: false,
}), state: randomState(),
); })(req, res, next);
});
router.get( router.get(
'/openid/callback', '/openid/callback',

View file

@ -28,6 +28,13 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
const hostAndProtocol = process.env.DOMAIN_SERVER; const hostAndProtocol = process.env.DOMAIN_SERVER;
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`); return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
} }
authorizationRequestParams(req, options) {
const params = super.authorizationRequestParams(req, options);
if (options?.state && !params.has('state')) {
params.set('state', options.state);
}
return params;
}
} }
/** /**

View file

@ -2,10 +2,10 @@ import React, { useMemo, useEffect } from 'react';
import { ChevronLeft, RotateCcw } from 'lucide-react'; import { ChevronLeft, RotateCcw } from 'lucide-react';
import { useFormContext, useWatch, Controller } from 'react-hook-form'; import { useFormContext, useWatch, Controller } from 'react-hook-form';
import { import {
getSettingsKeys,
alternateName, alternateName,
agentParamSettings, getSettingsKeys,
SettingDefinition, SettingDefinition,
agentParamSettings,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
@ -211,7 +211,7 @@ export default function ModelPanel({
{/* Model Parameters */} {/* Model Parameters */}
{parameters && ( {parameters && (
<div className="h-auto max-w-full overflow-x-hidden p-2"> <div className="h-auto max-w-full overflow-x-hidden p-2">
<div className="grid grid-cols-4 gap-6"> <div className="grid grid-cols-2 gap-4">
{/* This is the parent element containing all settings */} {/* This is the parent element containing all settings */}
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */} {/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}
{parameters.map((setting) => { {parameters.map((setting) => {

View file

@ -2,10 +2,10 @@ import { RotateCcw } from 'lucide-react';
import React, { useMemo, useState, useEffect, useCallback } from 'react'; import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { import {
excludedKeys, excludedKeys,
getSettingsKeys,
tConvoUpdateSchema,
paramSettings, paramSettings,
getSettingsKeys,
SettingDefinition, SettingDefinition,
tConvoUpdateSchema,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider'; import type { TPreset } from 'librechat-data-provider';
import { SaveAsPresetDialog } from '~/components/Endpoints'; import { SaveAsPresetDialog } from '~/components/Endpoints';
@ -140,7 +140,7 @@ export default function Parameters() {
return ( return (
<div className="h-auto max-w-full overflow-x-hidden p-3"> <div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="grid grid-cols-4 gap-6"> <div className="grid grid-cols-2 gap-4">
{' '} {' '}
{/* This is the parent element containing all settings */} {/* This is the parent element containing all settings */}
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */} {/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}

View file

@ -1,4 +1,5 @@
import { import {
useRef,
useMemo, useMemo,
useState, useState,
useEffect, useEffect,
@ -6,10 +7,10 @@ import {
useContext, useContext,
useCallback, useCallback,
createContext, createContext,
useRef,
} from 'react'; } from 'react';
import { useNavigate } from 'react-router-dom'; import { debounce } from 'lodash';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { setTokenHeader, SystemRoles } from 'librechat-data-provider'; import { setTokenHeader, SystemRoles } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import { import {
@ -47,27 +48,31 @@ const AuthContextProvider = ({
const navigate = useNavigate(); const navigate = useNavigate();
const setUserContext = useCallback( const setUserContext = useMemo(
(userContext: TUserContext) => { () =>
const { token, isAuthenticated, user, redirect } = userContext; debounce((userContext: TUserContext) => {
setUser(user); const { token, isAuthenticated, user, redirect } = userContext;
setToken(token); setUser(user);
//@ts-ignore - ok for token to be undefined initially setToken(token);
setTokenHeader(token); //@ts-ignore - ok for token to be undefined initially
setIsAuthenticated(isAuthenticated); setTokenHeader(token);
// Use a custom redirect if set setIsAuthenticated(isAuthenticated);
const finalRedirect = logoutRedirectRef.current || redirect;
// Clear the stored redirect // Use a custom redirect if set
logoutRedirectRef.current = undefined; const finalRedirect = logoutRedirectRef.current || redirect;
if (finalRedirect == null) { // Clear the stored redirect
return; logoutRedirectRef.current = undefined;
}
if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) { if (finalRedirect == null) {
window.location.href = finalRedirect; return;
} else { }
navigate(finalRedirect, { replace: true });
} if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) {
}, window.location.href = finalRedirect;
} else {
navigate(finalRedirect, { replace: true });
}
}, 50),
[navigate, setUser], [navigate, setUser],
); );
const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) }); const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) });

View file

@ -682,9 +682,9 @@ const bedrockGeneralColumns = {
export const presetSettings: Record< export const presetSettings: Record<
string, string,
| { | {
col1: SettingsConfiguration; col1: SettingsConfiguration;
col2: SettingsConfiguration; col2: SettingsConfiguration;
} }
| undefined | undefined
> = { > = {
[EModelEndpoint.openAI]: openAIColumns, [EModelEndpoint.openAI]: openAIColumns,
@ -723,4 +723,4 @@ export const agentParamSettings: Record<string, SettingsConfiguration | undefine
acc[key] = value.col2; acc[key] = value.col2;
} }
return acc; return acc;
}, {}); }, {});