🖥️ feat: Code Interpreter API for Non-Agent Endpoints (#6803)

* fix: Prevent parsing 'undefined' string in useLocalStorage initialization

* feat: first pass, code interpreter badge

* feat: Integrate API key authentication and default checked value in Code Interpreter Badge

* refactor: Rename showMCPServers to showEphemeralBadges and update related components, memoize values in useChatBadges

* refactor: Enhance AttachFileChat to support ephemeral agents in file attachment logic

* fix: Add baseURL configuration option to legacy function call

* refactor: Update dependency array in useDragHelpers to include handleFiles

* refactor: Update isEphemeralAgent function to accept optional endpoint parameter

* refactor: Update file handling to support ephemeral agents in AttachFileMenu and useDragHelpers

* fix: improve compatibility issues with OpenAI usage field handling in createRun function

* refactor: usage field compatibility

* fix: ensure mcp servers are no longer "selected" if mcp servers are now unavailable
This commit is contained in:
Danny Avila 2025-04-09 16:11:16 -04:00 committed by GitHub
parent 5d668748f9
commit 24c0433dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 311 additions and 48 deletions

View file

@ -34,6 +34,7 @@ function createLLM({
let credentials = { openAIApiKey };
let configuration = {
apiKey: openAIApiKey,
...(configOptions.basePath && { baseURL: configOptions.basePath }),
};
/** @type {AzureOptions} */

View file

@ -1,6 +1,6 @@
const mongoose = require('mongoose');
const { agentSchema } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { SystemRoles, Tools } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
@ -51,16 +51,22 @@ const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) =
const mcpServers = new Set(req.body.ephemeralAgent?.mcp);
/** @type {string[]} */
const tools = [];
if (req.body.ephemeralAgent?.execute_code === true) {
tools.push(Tools.execute_code);
}
for (const toolName of Object.keys(availableTools)) {
if (!toolName.includes(mcp_delimiter)) {
continue;
}
const mcpServer = toolName.split(mcp_delimiter)?.[1];
if (mcpServer && mcpServers.has(mcpServer)) {
tools.push(toolName);
if (mcpServers.size > 0) {
for (const toolName of Object.keys(availableTools)) {
if (!toolName.includes(mcp_delimiter)) {
continue;
}
const mcpServer = toolName.split(mcp_delimiter)?.[1];
if (mcpServer && mcpServers.has(mcpServer)) {
tools.push(toolName);
}
}
}
const instructions = req.body.promptPrefix;
return {
id: agent_id,

View file

@ -11,6 +11,13 @@ const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider
* @typedef {import('@librechat/agents').IState} IState
*/
const customProviders = new Set([
Providers.XAI,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
]);
/**
* Creates a new Run instance with custom handlers and configuration.
*
@ -43,8 +50,11 @@ async function createRun({
agent.model_parameters,
);
/** Resolves Mistral type strictness due to new OpenAI usage field */
if (agent.endpoint?.toLowerCase().includes(KnownEndpoints.mistral)) {
/** Resolves issues with new OpenAI usage field */
if (
customProviders.has(agent.provider) ||
(agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider)
) {
llmConfig.streamUsage = false;
llmConfig.usage = true;
}

View file

@ -10,6 +10,7 @@ import React, {
} from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import CodeInterpreter from './CodeInterpreter';
import type { BadgeItem } from '~/common';
import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
@ -17,7 +18,7 @@ import MCPSelect from './MCPSelect';
import store from '~/store';
interface BadgeRowProps {
showMCPServers?: boolean;
showEphemeralBadges?: boolean;
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void;
conversationId?: string | null;
@ -131,7 +132,13 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
}
};
function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat }: BadgeRowProps) {
function BadgeRow({
showEphemeralBadges,
conversationId,
onChange,
onToggle,
isInChat,
}: BadgeRowProps) {
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
const [dragState, dispatch] = useReducer(dragReducer, {
draggedBadge: null,
@ -146,7 +153,7 @@ function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat
const animationFrame = useRef<number | null>(null);
const containerRectRef = useRef<DOMRect | null>(null);
const allBadges = useChatBadges() || [];
const allBadges = useChatBadges();
const isEditing = useRecoilValue(store.isEditingBadges);
const badges = useMemo(
@ -345,7 +352,12 @@ function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat
/>
</div>
)}
{showMCPServers === true && <MCPSelect conversationId={conversationId} />}
{showEphemeralBadges === true && (
<>
<CodeInterpreter conversationId={conversationId} />
<MCPSelect conversationId={conversationId} />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"

View file

@ -289,7 +289,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<AttachFileChat disableInputs={disableInputs} />
</div>
<BadgeRow
showMCPServers={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
conversationId={conversation?.conversationId ?? Constants.NEW_CONVO}
onChange={setBadges}
isInChat={

View file

@ -0,0 +1,104 @@
import debounce from 'lodash/debounce';
import React, { memo, useMemo, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { TerminalSquareIcon } from 'lucide-react';
import {
Tools,
AuthType,
Constants,
LocalStorageKeys,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks';
import CheckboxButton from '~/components/ui/CheckboxButton';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const isCodeToggleEnabled = useMemo(() => {
return ephemeralAgent?.execute_code ?? false;
}, [ephemeralAgent?.execute_code]);
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.execute_code },
{
retry: 1,
},
);
const authType = useMemo(() => data?.message ?? false, [data?.message]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useCodeApiKeyForm({});
const setValue = useCallback(
(isChecked: boolean) => {
setEphemeralAgent((prev) => ({
...prev,
execute_code: isChecked,
}));
},
[setEphemeralAgent],
);
const [runCode, setRunCode] = useLocalStorage<boolean>(
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
isCodeToggleEnabled,
setValue,
);
const handleChange = useCallback(
(isChecked: boolean) => {
if (!isAuthenticated) {
setIsDialogOpen(true);
return;
}
setRunCode(isChecked);
},
[setRunCode, setIsDialogOpen, isAuthenticated],
);
const debouncedChange = useMemo(
() => debounce(handleChange, 50, { leading: true }),
[handleChange],
);
if (!canRunCode) {
return null;
}
return (
<>
<CheckboxButton
className="max-w-fit"
defaultChecked={runCode}
setValue={debouncedChange}
label={localize('com_assistants_code_interpreter')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
icon={<TerminalSquareIcon className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
);
}
export default memo(CodeInterpreter);

View file

@ -1,24 +1,31 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
Constants,
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
isEphemeralAgent,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { useGetFileConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
import store from '~/store';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const key = conversation?.conversationId ?? Constants.NEW_CONVO;
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key));
const isAgents = useMemo(
() => isAgentsEndpoint(_endpoint) || isEphemeralAgent(_endpoint, ephemeralAgent),
[_endpoint, ephemeralAgent],
);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),

View file

@ -18,7 +18,9 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
});
const capabilities = useMemo(
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],

View file

@ -1,4 +1,4 @@
import React, { memo, useCallback } from 'react';
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import { useAvailableToolsQuery } from '~/data-provider';
@ -10,8 +10,12 @@ import { useLocalize } from '~/hooks';
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
const localize = useLocalize();
const hasSetFetched = useRef(false);
const key = conversationId ?? Constants.NEW_CONVO;
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const mcpState = useMemo(() => {
return ephemeralAgent?.mcp ?? [];
}, [ephemeralAgent?.mcp]);
const setSelectedValues = useCallback(
(values: string[] | null | undefined) => {
if (!values) {
@ -29,10 +33,10 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
);
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
`${LocalStorageKeys.LAST_MCP_}${key}`,
ephemeralAgent?.mcp ?? [],
mcpState,
setSelectedValues,
);
const { data: mcpServers } = useAvailableToolsQuery(EModelEndpoint.agents, {
const { data: mcpServers, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data) => {
const serverNames = new Set<string>();
data.forEach((tool) => {
@ -45,6 +49,20 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
},
});
useEffect(() => {
if (hasSetFetched.current) {
return;
}
if (!isFetched) {
return;
}
hasSetFetched.current = true;
if ((mcpServers?.length ?? 0) > 0) {
return;
}
setMCPValues([]);
}, [isFetched, setMCPValues, mcpServers?.length]);
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
if (values.length === 0) {
@ -70,8 +88,8 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
placeholder={localize('com_ui_mcp_servers')}
popoverClassName="min-w-[200px]"
className="badge-icon h-full min-w-[150px]"
popoverClassName="min-w-fit"
className="badge-icon min-w-fit"
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-shadow md:w-full size-9 p-2 md:p-3 bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"

View file

@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { Checkbox, useStoreState, useCheckboxStore } from '@ariakit/react';
import { cn } from '~/utils';
export default function CheckboxButton({
label,
icon,
setValue,
className,
defaultChecked,
isCheckedClassName,
}: {
label: string;
className?: string;
icon?: React.ReactNode;
defaultChecked?: boolean;
isCheckedClassName?: string;
setValue?: (isChecked: boolean) => void;
}) {
const checkbox = useCheckboxStore();
const isChecked = useStoreState(checkbox, (state) => state?.value);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
if (typeof isChecked !== 'boolean') {
return;
}
setValue?.(!isChecked);
};
useEffect(() => {
if (defaultChecked) {
checkbox.setValue(defaultChecked);
}
}, [defaultChecked, checkbox]);
return (
<Checkbox
store={checkbox}
onChange={onChange}
defaultChecked={defaultChecked}
className={cn(
// Base styling from MultiSelect's selectClassName
'group relative inline-flex items-center justify-center gap-1.5',
'rounded-full border border-border-medium text-sm font-medium',
'size-9 p-2 transition-shadow md:w-full md:p-3',
'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
// Checked state styling
isChecked && isCheckedClassName && isCheckedClassName,
// Additional custom classes
className,
)}
render={<button type="button" aria-label={label} />}
>
{/* Icon if provided */}
{icon && <span className="icon-md text-text-primary">{icon}</span>}
{/* Show the label on larger screens */}
<span className="hidden truncate md:block">{label}</span>
</Checkbox>
);
}

View file

@ -66,7 +66,7 @@ export default function MultiSelect<T extends string>({
};
return (
<div className={cn('h-full', className)}>
<div className={className}>
<SelectProvider value={selectedValues} setValue={handleValueChange}>
{label && (
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
@ -82,9 +82,10 @@ export default function MultiSelect<T extends string>({
selectClassName,
selectedValues.length > 0 && selectItemsClassName != null && selectItemsClassName,
)}
onChange={(e) => e.stopPropagation()}
>
{selectIcon && selectIcon}
<span className="hidden truncate md:block">
<span className="mr-auto hidden truncate md:block">
{renderSelectedValues(selectedValues, placeholder)}
</span>
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />

View file

@ -4,22 +4,28 @@ import { useRecoilValue } from 'recoil';
import { NativeTypes } from 'react-dnd-html5-backend';
import { useQueryClient } from '@tanstack/react-query';
import {
isAgentsEndpoint,
EModelEndpoint,
AgentCapabilities,
Constants,
QueryKeys,
EModelEndpoint,
isAgentsEndpoint,
isEphemeralAgent,
AgentCapabilities,
} from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { DropTargetMonitor } from 'react-dnd';
import useFileHandling from './useFileHandling';
import store from '~/store';
import store, { ephemeralAgentByConvoId } from '~/store';
export default function useDragHelpers() {
const queryClient = useQueryClient();
const { handleFiles } = useFileHandling();
const [showModal, setShowModal] = useState(false);
const [draggedFiles, setDraggedFiles] = useState<File[]>([]);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const key = useMemo(
() => conversation?.conversationId ?? Constants.NEW_CONVO,
[conversation?.conversationId],
);
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key));
const handleOptionSelect = (toolResource: string | undefined) => {
handleFiles(draggedFiles, toolResource);
@ -28,10 +34,16 @@ export default function useDragHelpers() {
};
const isAgents = useMemo(
() => isAgentsEndpoint(conversation?.endpoint),
[conversation?.endpoint],
() =>
isAgentsEndpoint(conversation?.endpoint) ||
isEphemeralAgent(conversation?.endpoint, ephemeralAgent),
[conversation?.endpoint, ephemeralAgent],
);
const { handleFiles } = useFileHandling({
overrideEndpoint: isAgents ? EModelEndpoint.agents : undefined,
});
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
accept: [NativeTypes.FILE],
@ -61,7 +73,7 @@ export default function useDragHelpers() {
canDrop: monitor.canDrop(),
}),
}),
[],
[handleFiles],
);
return {

View file

@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useRecoilCallback } from 'recoil';
import { useRecoilValue } from 'recoil';
import { MessageCircleDashed, Box } from 'lucide-react';
import type { BadgeItem } from '~/common';
import { useLocalize } from '~/hooks';
import { useLocalize, TranslationKeys } from '~/hooks';
import store from '~/store';
interface ChatBadgeConfig {
@ -25,15 +26,22 @@ const badgeConfig: ReadonlyArray<ChatBadgeConfig> = [
export default function useChatBadges(): BadgeItem[] {
const localize = useLocalize();
const activeBadges = useRecoilValue(store.chatBadges) as Array<{ id: string }>;
const activeBadgeIds = new Set(activeBadges.map((badge) => badge.id));
return badgeConfig.map((cfg) => ({
id: cfg.id,
label: localize(cfg.label),
icon: cfg.icon,
atom: cfg.atom,
isAvailable: activeBadgeIds.has(cfg.id),
}));
const activeBadgeIds = useMemo(
() => new Set(activeBadges.map((badge) => badge.id)),
[activeBadges],
);
const allBadges = useMemo(() => {
return (
badgeConfig.map((cfg) => ({
id: cfg.id,
label: localize(cfg.label as TranslationKeys),
icon: cfg.icon,
atom: cfg.atom,
isAvailable: activeBadgeIds.has(cfg.id),
})) || []
);
}, [activeBadgeIds, localize]);
return allBadges;
}
export function useResetChatBadges() {

View file

@ -21,7 +21,7 @@ export default function useLocalStorage<T>(
localStorage.setItem(key, JSON.stringify(defaultValue));
}
const initialValue = item ? JSON.parse(item) : defaultValue;
const initialValue = item && item !== 'undefined' ? JSON.parse(item) : defaultValue;
setValue(initialValue);
if (globalSetState) {
globalSetState(initialValue);

View file

@ -32,6 +32,7 @@ export function clearLocalStorage(skipFirst?: boolean) {
}
if (
key.startsWith(LocalStorageKeys.LAST_MCP_) ||
key.startsWith(LocalStorageKeys.LAST_CODE_TOGGLE_) ||
key.startsWith(LocalStorageKeys.ASST_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.AGENT_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.LAST_CONVO_SETUP) ||

View file

@ -1285,6 +1285,8 @@ export enum LocalStorageKeys {
SHOW_ANALYSIS_CODE = 'showAnalysisCode',
/** Last selected MCP values per conversation ID */
LAST_MCP_ = 'LAST_MCP_',
/** Last checked toggle for Code Interpreter API per conversation ID */
LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_',
}
export enum ForkOptions {

View file

@ -19,20 +19,20 @@ export default function createPayload(submission: t.TSubmission) {
};
let server = EndpointURLs[endpointType ?? endpoint];
const isEphemeralAgent = (ephemeralAgent?.mcp?.length ?? 0) > 0 && !s.isAgentsEndpoint(endpoint);
const isEphemeral = s.isEphemeralAgent(endpoint, ephemeralAgent);
if (isEdited && s.isAssistantsEndpoint(endpoint)) {
server += '/modify';
} else if (isEdited) {
server = server.replace('/ask/', '/edit/');
} else if (isEphemeralAgent) {
} else if (isEphemeral) {
server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`;
}
const payload: t.TPayload = {
...userMessage,
...endpointOption,
ephemeralAgent: isEphemeralAgent ? ephemeralAgent : undefined,
ephemeralAgent: isEphemeral ? ephemeralAgent : undefined,
isContinued: !!(isEdited && isContinued),
conversationId,
isTemporary,

View file

@ -1,6 +1,7 @@
import { z } from 'zod';
import { Tools } from './types/assistants';
import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants';
import type { TEphemeralAgent } from './types';
import type { TFile } from './types/files';
export const isUUID = z.string().uuid();
@ -88,6 +89,21 @@ export const isAgentsEndpoint = (_endpoint?: EModelEndpoint.agents | null | stri
return endpoint === EModelEndpoint.agents;
};
export const isEphemeralAgent = (
endpoint?: EModelEndpoint.agents | null | string,
ephemeralAgent?: TEphemeralAgent | null,
) => {
if (!ephemeralAgent) {
return false;
}
if (isAgentsEndpoint(endpoint)) {
return false;
}
const hasMCPSelected = (ephemeralAgent?.mcp?.length ?? 0) > 0;
const hasCodeSelected = (ephemeralAgent?.execute_code ?? false) === true;
return hasMCPSelected || hasCodeSelected;
};
export const isParamEndpoint = (
endpoint: EModelEndpoint | string,
endpointType?: EModelEndpoint | string,

View file

@ -42,7 +42,8 @@ export type TEndpointOption = {
};
export type TEphemeralAgent = {
mcp: string[];
mcp?: string[];
execute_code?: boolean;
};
export type TPayload = Partial<TMessage> &