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
* @type {boolean} */
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
* @type {boolean} */
this.supportsCacheControl;
@ -118,13 +115,16 @@ class AnthropicClient extends BaseClient {
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
this.isClaudeLatest =
/claude-[3-9]/.test(modelMatch) || /claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch);
this.isLegacyOutput = !(
/claude-3[-.]5-sonnet/.test(modelMatch) || /claude-3[-.]7/.test(modelMatch)
const isLegacyOutput = !(
/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);
if (
this.isLegacyOutput &&
isLegacyOutput &&
this.modelOptions.maxOutputTokens &&
this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default
) {

View file

@ -514,6 +514,34 @@ describe('AnthropicClient', () => {
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', () => {
const client = new AnthropicClient('test-api-key');
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2;

View file

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

View file

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

View file

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

View file

@ -28,6 +28,13 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
const hostAndProtocol = process.env.DOMAIN_SERVER;
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 { useFormContext, useWatch, Controller } from 'react-hook-form';
import {
getSettingsKeys,
alternateName,
agentParamSettings,
getSettingsKeys,
SettingDefinition,
agentParamSettings,
} from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
@ -211,7 +211,7 @@ export default function ModelPanel({
{/* Model Parameters */}
{parameters && (
<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 */}
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}
{parameters.map((setting) => {

View file

@ -2,10 +2,10 @@ import { RotateCcw } from 'lucide-react';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import {
excludedKeys,
getSettingsKeys,
tConvoUpdateSchema,
paramSettings,
getSettingsKeys,
SettingDefinition,
tConvoUpdateSchema,
} from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider';
import { SaveAsPresetDialog } from '~/components/Endpoints';
@ -140,7 +140,7 @@ export default function Parameters() {
return (
<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 */}
{/* 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 {
useRef,
useMemo,
useState,
useEffect,
@ -6,10 +7,10 @@ import {
useContext,
useCallback,
createContext,
useRef,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { debounce } from 'lodash';
import { useRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { setTokenHeader, SystemRoles } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import {
@ -47,27 +48,31 @@ const AuthContextProvider = ({
const navigate = useNavigate();
const setUserContext = useCallback(
(userContext: TUserContext) => {
const { token, isAuthenticated, user, redirect } = userContext;
setUser(user);
setToken(token);
//@ts-ignore - ok for token to be undefined initially
setTokenHeader(token);
setIsAuthenticated(isAuthenticated);
// Use a custom redirect if set
const finalRedirect = logoutRedirectRef.current || redirect;
// Clear the stored redirect
logoutRedirectRef.current = undefined;
if (finalRedirect == null) {
return;
}
if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) {
window.location.href = finalRedirect;
} else {
navigate(finalRedirect, { replace: true });
}
},
const setUserContext = useMemo(
() =>
debounce((userContext: TUserContext) => {
const { token, isAuthenticated, user, redirect } = userContext;
setUser(user);
setToken(token);
//@ts-ignore - ok for token to be undefined initially
setTokenHeader(token);
setIsAuthenticated(isAuthenticated);
// Use a custom redirect if set
const finalRedirect = logoutRedirectRef.current || redirect;
// Clear the stored redirect
logoutRedirectRef.current = undefined;
if (finalRedirect == null) {
return;
}
if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) {
window.location.href = finalRedirect;
} else {
navigate(finalRedirect, { replace: true });
}
}, 50),
[navigate, setUser],
);
const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) });

View file

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