lfx-nightly 0.1.12.dev32__py3-none-any.whl → 0.1.12.dev34__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of lfx-nightly might be problematic. Click here for more details.
- lfx/base/agents/events.py +11 -7
- lfx/base/mcp/util.py +202 -133
- lfx/base/models/model.py +1 -1
- lfx/components/agents/mcp_component.py +11 -4
- lfx/components/nvidia/nvidia.py +2 -5
- lfx/components/ollama/ollama.py +26 -9
- lfx/custom/custom_component/component.py +40 -20
- lfx/interface/components.py +4 -2
- lfx/schema/log.py +1 -0
- lfx/schema/message.py +7 -3
- lfx/utils/image.py +7 -1
- {lfx_nightly-0.1.12.dev32.dist-info → lfx_nightly-0.1.12.dev34.dist-info}/METADATA +1 -1
- {lfx_nightly-0.1.12.dev32.dist-info → lfx_nightly-0.1.12.dev34.dist-info}/RECORD +15 -15
- {lfx_nightly-0.1.12.dev32.dist-info → lfx_nightly-0.1.12.dev34.dist-info}/WHEEL +0 -0
- {lfx_nightly-0.1.12.dev32.dist-info → lfx_nightly-0.1.12.dev34.dist-info}/entry_points.txt +0 -0
lfx/base/agents/events.py
CHANGED
|
@@ -80,7 +80,7 @@ async def handle_on_chain_start(
|
|
|
80
80
|
header={"title": "Input", "icon": "MessageSquare"},
|
|
81
81
|
)
|
|
82
82
|
agent_message.content_blocks[0].contents.append(text_content)
|
|
83
|
-
agent_message = await send_message_method(message=agent_message)
|
|
83
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
84
84
|
start_time = perf_counter()
|
|
85
85
|
return agent_message, start_time
|
|
86
86
|
|
|
@@ -151,7 +151,7 @@ async def handle_on_chain_end(
|
|
|
151
151
|
header={"title": "Output", "icon": "MessageSquare"},
|
|
152
152
|
)
|
|
153
153
|
agent_message.content_blocks[0].contents.append(text_content)
|
|
154
|
-
agent_message = await send_message_method(message=agent_message)
|
|
154
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
155
155
|
start_time = perf_counter()
|
|
156
156
|
return agent_message, start_time
|
|
157
157
|
|
|
@@ -190,7 +190,7 @@ async def handle_on_tool_start(
|
|
|
190
190
|
tool_blocks_map[tool_key] = tool_content
|
|
191
191
|
agent_message.content_blocks[0].contents.append(tool_content)
|
|
192
192
|
|
|
193
|
-
agent_message = await send_message_method(message=agent_message)
|
|
193
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
194
194
|
if agent_message.content_blocks and agent_message.content_blocks[0].contents:
|
|
195
195
|
tool_blocks_map[tool_key] = agent_message.content_blocks[0].contents[-1]
|
|
196
196
|
return agent_message, new_start_time
|
|
@@ -210,7 +210,7 @@ async def handle_on_tool_end(
|
|
|
210
210
|
|
|
211
211
|
if tool_content and isinstance(tool_content, ToolContent):
|
|
212
212
|
# Call send_message_method first to get the updated message structure
|
|
213
|
-
agent_message = await send_message_method(message=agent_message)
|
|
213
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
214
214
|
new_start_time = perf_counter()
|
|
215
215
|
|
|
216
216
|
# Now find and update the tool content in the current message
|
|
@@ -258,7 +258,7 @@ async def handle_on_tool_error(
|
|
|
258
258
|
tool_content.error = event["data"].get("error", "Unknown error")
|
|
259
259
|
tool_content.duration = _calculate_duration(start_time)
|
|
260
260
|
tool_content.header = {"title": f"Error using **{tool_content.name}**", "icon": "Hammer"}
|
|
261
|
-
agent_message = await send_message_method(message=agent_message)
|
|
261
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
262
262
|
start_time = perf_counter()
|
|
263
263
|
return agent_message, start_time
|
|
264
264
|
|
|
@@ -275,14 +275,14 @@ async def handle_on_chain_stream(
|
|
|
275
275
|
if output and isinstance(output, str | list):
|
|
276
276
|
agent_message.text = _extract_output_text(output)
|
|
277
277
|
agent_message.properties.state = "complete"
|
|
278
|
-
agent_message = await send_message_method(message=agent_message)
|
|
278
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
279
279
|
start_time = perf_counter()
|
|
280
280
|
elif isinstance(data_chunk, AIMessageChunk):
|
|
281
281
|
output_text = _extract_output_text(data_chunk.content)
|
|
282
282
|
if output_text and isinstance(agent_message.text, str):
|
|
283
283
|
agent_message.text += output_text
|
|
284
284
|
agent_message.properties.state = "partial"
|
|
285
|
-
agent_message = await send_message_method(message=agent_message)
|
|
285
|
+
agent_message = await send_message_method(message=agent_message, skip_db_update=True)
|
|
286
286
|
if not agent_message.text:
|
|
287
287
|
start_time = perf_counter()
|
|
288
288
|
return agent_message, start_time
|
|
@@ -346,13 +346,17 @@ async def process_agent_events(
|
|
|
346
346
|
async for event in agent_executor:
|
|
347
347
|
if event["event"] in TOOL_EVENT_HANDLERS:
|
|
348
348
|
tool_handler = TOOL_EVENT_HANDLERS[event["event"]]
|
|
349
|
+
# Use skip_db_update=True during streaming to avoid DB round-trips
|
|
349
350
|
agent_message, start_time = await tool_handler(
|
|
350
351
|
event, agent_message, tool_blocks_map, send_message_method, start_time
|
|
351
352
|
)
|
|
352
353
|
elif event["event"] in CHAIN_EVENT_HANDLERS:
|
|
353
354
|
chain_handler = CHAIN_EVENT_HANDLERS[event["event"]]
|
|
355
|
+
# Use skip_db_update=True during streaming to avoid DB round-trips
|
|
354
356
|
agent_message, start_time = await chain_handler(event, agent_message, send_message_method, start_time)
|
|
355
357
|
agent_message.properties.state = "complete"
|
|
358
|
+
# Final DB update with the complete message (skip_db_update=False by default)
|
|
359
|
+
agent_message = await send_message_method(message=agent_message)
|
|
356
360
|
except Exception as e:
|
|
357
361
|
raise ExceptionWithMessageError(agent_message, str(e)) from e
|
|
358
362
|
return await Message.create(**agent_message.model_dump())
|
lfx/base/mcp/util.py
CHANGED
|
@@ -28,8 +28,12 @@ HTTP_ERROR_STATUS_CODE = httpx_codes.BAD_REQUEST # HTTP status code for client
|
|
|
28
28
|
|
|
29
29
|
# HTTP status codes used in validation
|
|
30
30
|
HTTP_NOT_FOUND = 404
|
|
31
|
+
HTTP_METHOD_NOT_ALLOWED = 405
|
|
32
|
+
HTTP_NOT_ACCEPTABLE = 406
|
|
31
33
|
HTTP_BAD_REQUEST = 400
|
|
32
34
|
HTTP_INTERNAL_SERVER_ERROR = 500
|
|
35
|
+
HTTP_UNAUTHORIZED = 401
|
|
36
|
+
HTTP_FORBIDDEN = 403
|
|
33
37
|
|
|
34
38
|
# MCP Session Manager constants
|
|
35
39
|
settings = get_settings_service().settings
|
|
@@ -378,8 +382,8 @@ def _validate_node_installation(command: str) -> str:
|
|
|
378
382
|
|
|
379
383
|
async def _validate_connection_params(mode: str, command: str | None = None, url: str | None = None) -> None:
|
|
380
384
|
"""Validate connection parameters based on mode."""
|
|
381
|
-
if mode not in ["Stdio", "SSE"]:
|
|
382
|
-
msg = f"Invalid mode: {mode}. Must be either 'Stdio' or 'SSE'"
|
|
385
|
+
if mode not in ["Stdio", "Streamable_HTTP", "SSE"]:
|
|
386
|
+
msg = f"Invalid mode: {mode}. Must be either 'Stdio', 'Streamable_HTTP', or 'SSE'"
|
|
383
387
|
raise ValueError(msg)
|
|
384
388
|
|
|
385
389
|
if mode == "Stdio" and not command:
|
|
@@ -387,8 +391,8 @@ async def _validate_connection_params(mode: str, command: str | None = None, url
|
|
|
387
391
|
raise ValueError(msg)
|
|
388
392
|
if mode == "Stdio" and command:
|
|
389
393
|
_validate_node_installation(command)
|
|
390
|
-
if mode
|
|
391
|
-
msg = "URL is required for
|
|
394
|
+
if mode in ["Streamable_HTTP", "SSE"] and not url:
|
|
395
|
+
msg = f"URL is required for {mode} mode"
|
|
392
396
|
raise ValueError(msg)
|
|
393
397
|
|
|
394
398
|
|
|
@@ -400,6 +404,7 @@ class MCPSessionManager:
|
|
|
400
404
|
2. Maximum session limits per server to prevent resource exhaustion
|
|
401
405
|
3. Idle timeout for automatic session cleanup
|
|
402
406
|
4. Periodic cleanup of stale sessions
|
|
407
|
+
5. Transport preference caching to avoid retrying failed transports
|
|
403
408
|
"""
|
|
404
409
|
|
|
405
410
|
def __init__(self):
|
|
@@ -410,6 +415,9 @@ class MCPSessionManager:
|
|
|
410
415
|
self._context_to_session: dict[str, tuple[str, str]] = {}
|
|
411
416
|
# Reference count for each active (server_key, session_id)
|
|
412
417
|
self._session_refcount: dict[tuple[str, str], int] = {}
|
|
418
|
+
# Cache which transport works for each server to avoid retrying failed transports
|
|
419
|
+
# server_key -> "streamable_http" | "sse"
|
|
420
|
+
self._transport_preference: dict[str, str] = {}
|
|
413
421
|
self._cleanup_task = None
|
|
414
422
|
self._start_cleanup_task()
|
|
415
423
|
|
|
@@ -467,15 +475,16 @@ class MCPSessionManager:
|
|
|
467
475
|
env_str = str(sorted((connection_params.env or {}).items()))
|
|
468
476
|
key_input = f"{command_str}|{env_str}"
|
|
469
477
|
return f"stdio_{hash(key_input)}"
|
|
470
|
-
elif transport_type == "
|
|
478
|
+
elif transport_type == "streamable_http" and (
|
|
479
|
+
isinstance(connection_params, dict) and "url" in connection_params
|
|
480
|
+
):
|
|
471
481
|
# Include URL and headers for uniqueness
|
|
472
482
|
url = connection_params["url"]
|
|
473
483
|
headers = str(sorted((connection_params.get("headers", {})).items()))
|
|
474
484
|
key_input = f"{url}|{headers}"
|
|
475
|
-
return f"
|
|
485
|
+
return f"streamable_http_{hash(key_input)}"
|
|
476
486
|
|
|
477
487
|
# Fallback to a generic key
|
|
478
|
-
# TODO: add option for streamable HTTP in future.
|
|
479
488
|
return f"{transport_type}_{hash(str(connection_params))}"
|
|
480
489
|
|
|
481
490
|
async def _validate_session_connectivity(self, session) -> bool:
|
|
@@ -525,7 +534,7 @@ class MCPSessionManager:
|
|
|
525
534
|
"""Get or create a session with improved reuse strategy.
|
|
526
535
|
|
|
527
536
|
The key insight is that we should reuse sessions based on the server
|
|
528
|
-
identity (command + args for stdio, URL for
|
|
537
|
+
identity (command + args for stdio, URL for Streamable HTTP) rather than the context_id.
|
|
529
538
|
This prevents creating a new subprocess for each unique context.
|
|
530
539
|
"""
|
|
531
540
|
server_key = self._get_server_key(connection_params, transport_type)
|
|
@@ -578,17 +587,24 @@ class MCPSessionManager:
|
|
|
578
587
|
|
|
579
588
|
if transport_type == "stdio":
|
|
580
589
|
session, task = await self._create_stdio_session(session_id, connection_params)
|
|
581
|
-
|
|
582
|
-
|
|
590
|
+
actual_transport = "stdio"
|
|
591
|
+
elif transport_type == "streamable_http":
|
|
592
|
+
# Pass the cached transport preference if available
|
|
593
|
+
preferred_transport = self._transport_preference.get(server_key)
|
|
594
|
+
session, task, actual_transport = await self._create_streamable_http_session(
|
|
595
|
+
session_id, connection_params, preferred_transport
|
|
596
|
+
)
|
|
597
|
+
# Cache the transport that worked for future connections
|
|
598
|
+
self._transport_preference[server_key] = actual_transport
|
|
583
599
|
else:
|
|
584
600
|
msg = f"Unknown transport type: {transport_type}"
|
|
585
601
|
raise ValueError(msg)
|
|
586
602
|
|
|
587
|
-
# Store session info
|
|
603
|
+
# Store session info with the actual transport used
|
|
588
604
|
sessions[session_id] = {
|
|
589
605
|
"session": session,
|
|
590
606
|
"task": task,
|
|
591
|
-
"type":
|
|
607
|
+
"type": actual_transport,
|
|
592
608
|
"last_used": asyncio.get_event_loop().time(),
|
|
593
609
|
}
|
|
594
610
|
|
|
@@ -634,9 +650,9 @@ class MCPSessionManager:
|
|
|
634
650
|
self._background_tasks.add(task)
|
|
635
651
|
task.add_done_callback(self._background_tasks.discard)
|
|
636
652
|
|
|
637
|
-
# Wait for session to be ready
|
|
653
|
+
# Wait for session to be ready (use longer timeout for remote connections)
|
|
638
654
|
try:
|
|
639
|
-
session = await asyncio.wait_for(session_future, timeout=
|
|
655
|
+
session = await asyncio.wait_for(session_future, timeout=30.0)
|
|
640
656
|
except asyncio.TimeoutError as timeout_err:
|
|
641
657
|
# Clean up the failed task
|
|
642
658
|
if not task.done():
|
|
@@ -652,50 +668,136 @@ class MCPSessionManager:
|
|
|
652
668
|
|
|
653
669
|
return session, task
|
|
654
670
|
|
|
655
|
-
async def
|
|
656
|
-
|
|
671
|
+
async def _create_streamable_http_session(
|
|
672
|
+
self, session_id: str, connection_params, preferred_transport: str | None = None
|
|
673
|
+
):
|
|
674
|
+
"""Create a new Streamable HTTP session with SSE fallback as a background task to avoid context issues.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
session_id: Unique identifier for this session
|
|
678
|
+
connection_params: Connection parameters including URL, headers, timeouts
|
|
679
|
+
preferred_transport: If set to "sse", skip Streamable HTTP and go directly to SSE
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
tuple: (session, task, transport_used) where transport_used is "streamable_http" or "sse"
|
|
683
|
+
"""
|
|
657
684
|
import asyncio
|
|
658
685
|
|
|
659
686
|
from mcp.client.sse import sse_client
|
|
687
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
660
688
|
|
|
661
689
|
# Create a future to get the session
|
|
662
690
|
session_future: asyncio.Future[ClientSession] = asyncio.Future()
|
|
691
|
+
# Track which transport succeeded
|
|
692
|
+
used_transport: list[str] = []
|
|
663
693
|
|
|
664
694
|
async def session_task():
|
|
665
695
|
"""Background task that keeps the session alive."""
|
|
666
|
-
|
|
667
|
-
async with sse_client(
|
|
668
|
-
connection_params["url"],
|
|
669
|
-
connection_params["headers"],
|
|
670
|
-
connection_params["timeout_seconds"],
|
|
671
|
-
connection_params["sse_read_timeout_seconds"],
|
|
672
|
-
) as (read, write):
|
|
673
|
-
session = ClientSession(read, write)
|
|
674
|
-
async with session:
|
|
675
|
-
await session.initialize()
|
|
676
|
-
# Signal that session is ready
|
|
677
|
-
session_future.set_result(session)
|
|
696
|
+
streamable_error = None
|
|
678
697
|
|
|
679
|
-
|
|
680
|
-
|
|
698
|
+
# Skip Streamable HTTP if we know SSE works for this server
|
|
699
|
+
if preferred_transport != "sse":
|
|
700
|
+
# Try Streamable HTTP first with a quick timeout
|
|
701
|
+
try:
|
|
702
|
+
await logger.adebug(f"Attempting Streamable HTTP connection for session {session_id}")
|
|
703
|
+
# Use a shorter timeout for the initial connection attempt (2 seconds)
|
|
704
|
+
async with streamablehttp_client(
|
|
705
|
+
url=connection_params["url"],
|
|
706
|
+
headers=connection_params["headers"],
|
|
707
|
+
timeout=connection_params["timeout_seconds"],
|
|
708
|
+
) as (read, write, _):
|
|
709
|
+
session = ClientSession(read, write)
|
|
710
|
+
async with session:
|
|
711
|
+
# Initialize with a timeout to fail fast
|
|
712
|
+
await asyncio.wait_for(session.initialize(), timeout=2.0)
|
|
713
|
+
used_transport.append("streamable_http")
|
|
714
|
+
await logger.ainfo(f"Session {session_id} connected via Streamable HTTP")
|
|
715
|
+
# Signal that session is ready
|
|
716
|
+
session_future.set_result(session)
|
|
717
|
+
|
|
718
|
+
# Keep the session alive until cancelled
|
|
719
|
+
import anyio
|
|
720
|
+
|
|
721
|
+
event = anyio.Event()
|
|
722
|
+
try:
|
|
723
|
+
await event.wait()
|
|
724
|
+
except asyncio.CancelledError:
|
|
725
|
+
await logger.ainfo(f"Session {session_id} (Streamable HTTP) is shutting down")
|
|
726
|
+
except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001
|
|
727
|
+
# If Streamable HTTP fails or times out, try SSE as fallback immediately
|
|
728
|
+
streamable_error = e
|
|
729
|
+
error_type = "timed out" if isinstance(e, asyncio.TimeoutError) else "failed"
|
|
730
|
+
await logger.awarning(
|
|
731
|
+
f"Streamable HTTP {error_type} for session {session_id}: {e}. Falling back to SSE..."
|
|
732
|
+
)
|
|
733
|
+
else:
|
|
734
|
+
await logger.adebug(f"Skipping Streamable HTTP for session {session_id}, using cached SSE preference")
|
|
681
735
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
736
|
+
# Try SSE if Streamable HTTP failed or if SSE is preferred
|
|
737
|
+
if streamable_error is not None or preferred_transport == "sse":
|
|
738
|
+
try:
|
|
739
|
+
await logger.adebug(f"Attempting SSE connection for session {session_id}")
|
|
740
|
+
# Extract SSE read timeout from connection params, default to 30s if not present
|
|
741
|
+
sse_read_timeout = connection_params.get("sse_read_timeout_seconds", 30)
|
|
742
|
+
|
|
743
|
+
async with sse_client(
|
|
744
|
+
connection_params["url"],
|
|
745
|
+
connection_params["headers"],
|
|
746
|
+
connection_params["timeout_seconds"],
|
|
747
|
+
sse_read_timeout,
|
|
748
|
+
) as (read, write):
|
|
749
|
+
session = ClientSession(read, write)
|
|
750
|
+
async with session:
|
|
751
|
+
await session.initialize()
|
|
752
|
+
used_transport.append("sse")
|
|
753
|
+
fallback_msg = " (fallback)" if streamable_error else " (preferred)"
|
|
754
|
+
await logger.ainfo(f"Session {session_id} connected via SSE{fallback_msg}")
|
|
755
|
+
# Signal that session is ready
|
|
756
|
+
if not session_future.done():
|
|
757
|
+
session_future.set_result(session)
|
|
758
|
+
|
|
759
|
+
# Keep the session alive until cancelled
|
|
760
|
+
import anyio
|
|
761
|
+
|
|
762
|
+
event = anyio.Event()
|
|
763
|
+
try:
|
|
764
|
+
await event.wait()
|
|
765
|
+
except asyncio.CancelledError:
|
|
766
|
+
await logger.ainfo(f"Session {session_id} (SSE) is shutting down")
|
|
767
|
+
except Exception as sse_error: # noqa: BLE001
|
|
768
|
+
# Both transports failed (or just SSE if it was preferred)
|
|
769
|
+
if streamable_error:
|
|
770
|
+
await logger.aerror(
|
|
771
|
+
f"Both Streamable HTTP and SSE failed for session {session_id}. "
|
|
772
|
+
f"Streamable HTTP error: {streamable_error}. SSE error: {sse_error}"
|
|
773
|
+
)
|
|
774
|
+
if not session_future.done():
|
|
775
|
+
session_future.set_exception(
|
|
776
|
+
ValueError(
|
|
777
|
+
f"Failed to connect via Streamable HTTP ({streamable_error}) or SSE ({sse_error})"
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
else:
|
|
781
|
+
await logger.aerror(f"SSE connection failed for session {session_id}: {sse_error}")
|
|
782
|
+
if not session_future.done():
|
|
783
|
+
session_future.set_exception(ValueError(f"Failed to connect via SSE: {sse_error}"))
|
|
690
784
|
|
|
691
785
|
# Start the background task
|
|
692
786
|
task = asyncio.create_task(session_task())
|
|
693
787
|
self._background_tasks.add(task)
|
|
694
788
|
task.add_done_callback(self._background_tasks.discard)
|
|
695
789
|
|
|
696
|
-
# Wait for session to be ready
|
|
790
|
+
# Wait for session to be ready (use longer timeout for remote connections)
|
|
697
791
|
try:
|
|
698
|
-
session = await asyncio.wait_for(session_future, timeout=
|
|
792
|
+
session = await asyncio.wait_for(session_future, timeout=30.0)
|
|
793
|
+
# Log which transport was used
|
|
794
|
+
if used_transport:
|
|
795
|
+
transport_used = used_transport[0]
|
|
796
|
+
await logger.ainfo(f"Session {session_id} successfully established using {transport_used}")
|
|
797
|
+
return session, task, transport_used
|
|
798
|
+
# This shouldn't happen, but handle it just in case
|
|
799
|
+
msg = f"Session {session_id} established but transport not recorded"
|
|
800
|
+
raise ValueError(msg)
|
|
699
801
|
except asyncio.TimeoutError as timeout_err:
|
|
700
802
|
# Clean up the failed task
|
|
701
803
|
if not task.done():
|
|
@@ -705,12 +807,10 @@ class MCPSessionManager:
|
|
|
705
807
|
with contextlib.suppress(asyncio.CancelledError):
|
|
706
808
|
await task
|
|
707
809
|
self._background_tasks.discard(task)
|
|
708
|
-
msg = f"Timeout waiting for SSE session {session_id} to initialize"
|
|
810
|
+
msg = f"Timeout waiting for Streamable HTTP/SSE session {session_id} to initialize"
|
|
709
811
|
await logger.aerror(msg)
|
|
710
812
|
raise ValueError(msg) from timeout_err
|
|
711
813
|
|
|
712
|
-
return session, task
|
|
713
|
-
|
|
714
814
|
async def _cleanup_session_by_id(self, server_key: str, session_id: str):
|
|
715
815
|
"""Clean up a specific session by server key and session ID."""
|
|
716
816
|
if server_key not in self.sessions_by_server:
|
|
@@ -1056,7 +1156,7 @@ class MCPStdioClient:
|
|
|
1056
1156
|
await self.disconnect()
|
|
1057
1157
|
|
|
1058
1158
|
|
|
1059
|
-
class
|
|
1159
|
+
class MCPStreamableHttpClient:
|
|
1060
1160
|
def __init__(self, component_cache=None):
|
|
1061
1161
|
self.session: ClientSession | None = None
|
|
1062
1162
|
self._connection_params = None
|
|
@@ -1080,67 +1180,15 @@ class MCPSseClient:
|
|
|
1080
1180
|
self._component_cache.set("mcp_session_manager", session_manager)
|
|
1081
1181
|
return session_manager
|
|
1082
1182
|
|
|
1083
|
-
async def validate_url(self, url: str | None
|
|
1084
|
-
"""Validate the
|
|
1183
|
+
async def validate_url(self, url: str | None) -> tuple[bool, str]:
|
|
1184
|
+
"""Validate the Streamable HTTP URL before attempting connection."""
|
|
1085
1185
|
try:
|
|
1086
1186
|
parsed = urlparse(url)
|
|
1087
1187
|
if not parsed.scheme or not parsed.netloc:
|
|
1088
1188
|
return False, "Invalid URL format. Must include scheme (http/https) and host."
|
|
1089
|
-
|
|
1090
|
-
async with httpx.AsyncClient() as client:
|
|
1091
|
-
try:
|
|
1092
|
-
# For SSE endpoints, try a GET request with short timeout
|
|
1093
|
-
# Many SSE servers don't support HEAD requests and return 404
|
|
1094
|
-
response = await client.get(
|
|
1095
|
-
url, timeout=2.0, headers={"Accept": "text/event-stream", **(headers or {})}
|
|
1096
|
-
)
|
|
1097
|
-
|
|
1098
|
-
# For SSE, we expect the server to either:
|
|
1099
|
-
# 1. Start streaming (200)
|
|
1100
|
-
# 2. Return 404 if HEAD/GET without proper SSE handshake is not supported
|
|
1101
|
-
# 3. Return other status codes that we should handle gracefully
|
|
1102
|
-
|
|
1103
|
-
# Don't fail on 404 since many SSE endpoints return this for non-SSE requests
|
|
1104
|
-
if response.status_code == HTTP_NOT_FOUND:
|
|
1105
|
-
# This is likely an SSE endpoint that doesn't support regular GET
|
|
1106
|
-
# Let the actual SSE connection attempt handle this
|
|
1107
|
-
return True, ""
|
|
1108
|
-
|
|
1109
|
-
# Fail on client errors except 404, but allow server errors and redirects
|
|
1110
|
-
if (
|
|
1111
|
-
HTTP_BAD_REQUEST <= response.status_code < HTTP_INTERNAL_SERVER_ERROR
|
|
1112
|
-
and response.status_code != HTTP_NOT_FOUND
|
|
1113
|
-
):
|
|
1114
|
-
return False, f"Server returned client error status: {response.status_code}"
|
|
1115
|
-
|
|
1116
|
-
except httpx.TimeoutException:
|
|
1117
|
-
# Timeout on a short request might indicate the server is trying to stream
|
|
1118
|
-
# This is actually expected behavior for SSE endpoints
|
|
1119
|
-
return True, ""
|
|
1120
|
-
except httpx.NetworkError:
|
|
1121
|
-
return False, "Network error. Could not reach the server."
|
|
1122
|
-
else:
|
|
1123
|
-
return True, ""
|
|
1124
|
-
|
|
1125
|
-
except (httpx.HTTPError, ValueError, OSError) as e:
|
|
1189
|
+
except (ValueError, OSError) as e:
|
|
1126
1190
|
return False, f"URL validation error: {e!s}"
|
|
1127
|
-
|
|
1128
|
-
async def pre_check_redirect(self, url: str | None, headers: dict[str, str] | None = None) -> str | None:
|
|
1129
|
-
"""Check for redirects and return the final URL."""
|
|
1130
|
-
if url is None:
|
|
1131
|
-
return url
|
|
1132
|
-
try:
|
|
1133
|
-
async with httpx.AsyncClient(follow_redirects=False) as client:
|
|
1134
|
-
# Use GET with SSE headers instead of HEAD since many SSE servers don't support HEAD
|
|
1135
|
-
response = await client.get(
|
|
1136
|
-
url, timeout=2.0, headers={"Accept": "text/event-stream", **(headers or {})}
|
|
1137
|
-
)
|
|
1138
|
-
if response.status_code == httpx.codes.TEMPORARY_REDIRECT:
|
|
1139
|
-
return response.headers.get("Location", url)
|
|
1140
|
-
# Don't treat 404 as an error here - let the main connection handle it
|
|
1141
|
-
except (httpx.RequestError, httpx.HTTPError) as e:
|
|
1142
|
-
await logger.awarning(f"Error checking redirects: {e}")
|
|
1143
|
-
return url
|
|
1191
|
+
return True, ""
|
|
1144
1192
|
|
|
1145
1193
|
async def _connect_to_server(
|
|
1146
1194
|
self,
|
|
@@ -1149,27 +1197,31 @@ class MCPSseClient:
|
|
|
1149
1197
|
timeout_seconds: int = 30,
|
|
1150
1198
|
sse_read_timeout_seconds: int = 30,
|
|
1151
1199
|
) -> list[StructuredTool]:
|
|
1152
|
-
"""Connect to MCP server using
|
|
1200
|
+
"""Connect to MCP server using Streamable HTTP transport with SSE fallback (SDK style)."""
|
|
1153
1201
|
# Validate and sanitize headers early
|
|
1154
1202
|
validated_headers = _process_headers(headers)
|
|
1155
1203
|
|
|
1156
1204
|
if url is None:
|
|
1157
|
-
msg = "URL is required for SSE mode"
|
|
1205
|
+
msg = "URL is required for StreamableHTTP or SSE mode"
|
|
1158
1206
|
raise ValueError(msg)
|
|
1159
|
-
is_valid, error_msg = await self.validate_url(url, validated_headers)
|
|
1160
|
-
if not is_valid:
|
|
1161
|
-
msg = f"Invalid SSE URL ({url}): {error_msg}"
|
|
1162
|
-
raise ValueError(msg)
|
|
1163
|
-
|
|
1164
|
-
url = await self.pre_check_redirect(url, validated_headers)
|
|
1165
1207
|
|
|
1166
|
-
#
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1208
|
+
# Only validate URL if we don't have a cached session
|
|
1209
|
+
# This avoids expensive HTTP validation calls when reusing sessions
|
|
1210
|
+
if not self._connected or not self._connection_params:
|
|
1211
|
+
is_valid, error_msg = await self.validate_url(url)
|
|
1212
|
+
if not is_valid:
|
|
1213
|
+
msg = f"Invalid Streamable HTTP or SSE URL ({url}): {error_msg}"
|
|
1214
|
+
raise ValueError(msg)
|
|
1215
|
+
# Store connection parameters for later use in run_tool
|
|
1216
|
+
# Include SSE read timeout for fallback
|
|
1217
|
+
self._connection_params = {
|
|
1218
|
+
"url": url,
|
|
1219
|
+
"headers": validated_headers,
|
|
1220
|
+
"timeout_seconds": timeout_seconds,
|
|
1221
|
+
"sse_read_timeout_seconds": sse_read_timeout_seconds,
|
|
1222
|
+
}
|
|
1223
|
+
elif headers:
|
|
1224
|
+
self._connection_params["headers"] = validated_headers
|
|
1173
1225
|
|
|
1174
1226
|
# If no session context is set, create a default one
|
|
1175
1227
|
if not self._session_context:
|
|
@@ -1177,18 +1229,21 @@ class MCPSseClient:
|
|
|
1177
1229
|
import uuid
|
|
1178
1230
|
|
|
1179
1231
|
param_hash = uuid.uuid4().hex[:8]
|
|
1180
|
-
self._session_context = f"
|
|
1232
|
+
self._session_context = f"default_http_{param_hash}"
|
|
1181
1233
|
|
|
1182
|
-
# Get or create a persistent session
|
|
1234
|
+
# Get or create a persistent session (will try Streamable HTTP, then SSE fallback)
|
|
1183
1235
|
session = await self._get_or_create_session()
|
|
1184
1236
|
response = await session.list_tools()
|
|
1185
1237
|
self._connected = True
|
|
1186
1238
|
return response.tools
|
|
1187
1239
|
|
|
1188
|
-
async def connect_to_server(
|
|
1189
|
-
|
|
1240
|
+
async def connect_to_server(
|
|
1241
|
+
self, url: str, headers: dict[str, str] | None = None, sse_read_timeout_seconds: int = 30
|
|
1242
|
+
) -> list[StructuredTool]:
|
|
1243
|
+
"""Connect to MCP server using Streamable HTTP with SSE fallback transport (SDK style)."""
|
|
1190
1244
|
return await asyncio.wait_for(
|
|
1191
|
-
self._connect_to_server(url, headers
|
|
1245
|
+
self._connect_to_server(url, headers, sse_read_timeout_seconds=sse_read_timeout_seconds),
|
|
1246
|
+
timeout=get_settings_service().settings.mcp_server_timeout,
|
|
1192
1247
|
)
|
|
1193
1248
|
|
|
1194
1249
|
def set_session_context(self, context_id: str):
|
|
@@ -1204,12 +1259,14 @@ class MCPSseClient:
|
|
|
1204
1259
|
# Use cached session manager to get/create persistent session
|
|
1205
1260
|
session_manager = self._get_session_manager()
|
|
1206
1261
|
# Cache session so we can access server-assigned session_id later for DELETE
|
|
1207
|
-
self.session = await session_manager.get_session(
|
|
1262
|
+
self.session = await session_manager.get_session(
|
|
1263
|
+
self._session_context, self._connection_params, "streamable_http"
|
|
1264
|
+
)
|
|
1208
1265
|
return self.session
|
|
1209
1266
|
|
|
1210
1267
|
async def _terminate_remote_session(self) -> None:
|
|
1211
1268
|
"""Attempt to explicitly terminate the remote MCP session via HTTP DELETE (best-effort)."""
|
|
1212
|
-
# Only relevant for SSE transport
|
|
1269
|
+
# Only relevant for Streamable HTTP or SSE transport
|
|
1213
1270
|
if not self._connection_params or "url" not in self._connection_params:
|
|
1214
1271
|
return
|
|
1215
1272
|
|
|
@@ -1255,7 +1312,7 @@ class MCPSseClient:
|
|
|
1255
1312
|
import uuid
|
|
1256
1313
|
|
|
1257
1314
|
param_hash = uuid.uuid4().hex[:8]
|
|
1258
|
-
self._session_context = f"
|
|
1315
|
+
self._session_context = f"default_http_{param_hash}"
|
|
1259
1316
|
|
|
1260
1317
|
max_retries = 2
|
|
1261
1318
|
last_error_type = None
|
|
@@ -1326,7 +1383,7 @@ class MCPSseClient:
|
|
|
1326
1383
|
await logger.aerror(msg)
|
|
1327
1384
|
# Clean up failed session from cache
|
|
1328
1385
|
if self._session_context and self._component_cache:
|
|
1329
|
-
cache_key = f"
|
|
1386
|
+
cache_key = f"mcp_session_http_{self._session_context}"
|
|
1330
1387
|
self._component_cache.delete(cache_key)
|
|
1331
1388
|
self._connected = False
|
|
1332
1389
|
raise ValueError(msg) from e
|
|
@@ -1364,11 +1421,17 @@ class MCPSseClient:
|
|
|
1364
1421
|
await self.disconnect()
|
|
1365
1422
|
|
|
1366
1423
|
|
|
1424
|
+
# Backward compatibility: MCPSseClient is now an alias for MCPStreamableHttpClient
|
|
1425
|
+
# The new client supports both Streamable HTTP and SSE with automatic fallback
|
|
1426
|
+
MCPSseClient = MCPStreamableHttpClient
|
|
1427
|
+
|
|
1428
|
+
|
|
1367
1429
|
async def update_tools(
|
|
1368
1430
|
server_name: str,
|
|
1369
1431
|
server_config: dict,
|
|
1370
1432
|
mcp_stdio_client: MCPStdioClient | None = None,
|
|
1371
|
-
|
|
1433
|
+
mcp_streamable_http_client: MCPStreamableHttpClient | None = None,
|
|
1434
|
+
mcp_sse_client: MCPStreamableHttpClient | None = None, # Backward compatibility
|
|
1372
1435
|
) -> tuple[str, list[StructuredTool], dict[str, StructuredTool]]:
|
|
1373
1436
|
"""Fetch server config and update available tools."""
|
|
1374
1437
|
if server_config is None:
|
|
@@ -1377,11 +1440,17 @@ async def update_tools(
|
|
|
1377
1440
|
return "", [], {}
|
|
1378
1441
|
if mcp_stdio_client is None:
|
|
1379
1442
|
mcp_stdio_client = MCPStdioClient()
|
|
1380
|
-
|
|
1381
|
-
|
|
1443
|
+
|
|
1444
|
+
# Backward compatibility: accept mcp_sse_client parameter
|
|
1445
|
+
if mcp_streamable_http_client is None:
|
|
1446
|
+
mcp_streamable_http_client = mcp_sse_client if mcp_sse_client is not None else MCPStreamableHttpClient()
|
|
1382
1447
|
|
|
1383
1448
|
# Fetch server config from backend
|
|
1384
|
-
|
|
1449
|
+
# Determine mode from config, defaulting to Streamable_HTTP if URL present
|
|
1450
|
+
mode = server_config.get("mode", "")
|
|
1451
|
+
if not mode:
|
|
1452
|
+
mode = "Stdio" if "command" in server_config else "Streamable_HTTP" if "url" in server_config else ""
|
|
1453
|
+
|
|
1385
1454
|
command = server_config.get("command", "")
|
|
1386
1455
|
url = server_config.get("url", "")
|
|
1387
1456
|
tools = []
|
|
@@ -1394,7 +1463,7 @@ async def update_tools(
|
|
|
1394
1463
|
raise
|
|
1395
1464
|
|
|
1396
1465
|
# Determine connection type and parameters
|
|
1397
|
-
client: MCPStdioClient |
|
|
1466
|
+
client: MCPStdioClient | MCPStreamableHttpClient | None = None
|
|
1398
1467
|
if mode == "Stdio":
|
|
1399
1468
|
# Stdio connection
|
|
1400
1469
|
args = server_config.get("args", [])
|
|
@@ -1402,10 +1471,10 @@ async def update_tools(
|
|
|
1402
1471
|
full_command = " ".join([command, *args])
|
|
1403
1472
|
tools = await mcp_stdio_client.connect_to_server(full_command, env)
|
|
1404
1473
|
client = mcp_stdio_client
|
|
1405
|
-
elif mode
|
|
1406
|
-
# SSE
|
|
1407
|
-
tools = await
|
|
1408
|
-
client =
|
|
1474
|
+
elif mode in ["Streamable_HTTP", "SSE"]:
|
|
1475
|
+
# Streamable HTTP connection with SSE fallback
|
|
1476
|
+
tools = await mcp_streamable_http_client.connect_to_server(url, headers=headers)
|
|
1477
|
+
client = mcp_streamable_http_client
|
|
1409
1478
|
else:
|
|
1410
1479
|
logger.error(f"Invalid MCP server mode for '{server_name}': {mode}")
|
|
1411
1480
|
return "", [], {}
|
lfx/base/models/model.py
CHANGED
|
@@ -229,7 +229,7 @@ class LCModelComponent(Component):
|
|
|
229
229
|
system_message_added = True
|
|
230
230
|
runnable = prompt | runnable
|
|
231
231
|
else:
|
|
232
|
-
messages.append(input_value.to_lc_message())
|
|
232
|
+
messages.append(input_value.to_lc_message(self.name))
|
|
233
233
|
else:
|
|
234
234
|
messages.append(HumanMessage(content=input_value))
|
|
235
235
|
|
|
@@ -7,7 +7,12 @@ from typing import Any
|
|
|
7
7
|
from langchain_core.tools import StructuredTool # noqa: TC002
|
|
8
8
|
|
|
9
9
|
from lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set
|
|
10
|
-
from lfx.base.mcp.util import
|
|
10
|
+
from lfx.base.mcp.util import (
|
|
11
|
+
MCPStdioClient,
|
|
12
|
+
MCPStreamableHttpClient,
|
|
13
|
+
create_input_schema_from_json_schema,
|
|
14
|
+
update_tools,
|
|
15
|
+
)
|
|
11
16
|
from lfx.custom.custom_component.component_with_cache import ComponentWithCache
|
|
12
17
|
from lfx.inputs.inputs import InputTypes # noqa: TC001
|
|
13
18
|
from lfx.io import BoolInput, DropdownInput, McpInput, MessageTextInput, Output
|
|
@@ -32,7 +37,9 @@ class MCPToolsComponent(ComponentWithCache):
|
|
|
32
37
|
|
|
33
38
|
# Initialize clients with access to the component cache
|
|
34
39
|
self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)
|
|
35
|
-
self.
|
|
40
|
+
self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(
|
|
41
|
+
component_cache=self._shared_component_cache
|
|
42
|
+
)
|
|
36
43
|
|
|
37
44
|
def _ensure_cache_structure(self):
|
|
38
45
|
"""Ensure the cache has the required structure."""
|
|
@@ -207,7 +214,7 @@ class MCPToolsComponent(ComponentWithCache):
|
|
|
207
214
|
server_name=server_name,
|
|
208
215
|
server_config=server_config,
|
|
209
216
|
mcp_stdio_client=self.stdio_client,
|
|
210
|
-
|
|
217
|
+
mcp_streamable_http_client=self.streamable_http_client,
|
|
211
218
|
)
|
|
212
219
|
|
|
213
220
|
self.tool_names = [tool.name for tool in tool_list if hasattr(tool, "name")]
|
|
@@ -496,7 +503,7 @@ class MCPToolsComponent(ComponentWithCache):
|
|
|
496
503
|
session_context = self._get_session_context()
|
|
497
504
|
if session_context:
|
|
498
505
|
self.stdio_client.set_session_context(session_context)
|
|
499
|
-
self.
|
|
506
|
+
self.streamable_http_client.set_session_context(session_context)
|
|
500
507
|
|
|
501
508
|
exec_tool = self._tool_cache[self.tool]
|
|
502
509
|
tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]
|
lfx/components/nvidia/nvidia.py
CHANGED
|
@@ -24,11 +24,8 @@ class NVIDIAModelComponent(LCModelComponent):
|
|
|
24
24
|
except ImportError as e:
|
|
25
25
|
msg = "Please install langchain-nvidia-ai-endpoints to use the NVIDIA model."
|
|
26
26
|
raise ImportError(msg) from e
|
|
27
|
-
except Exception: # noqa: BLE001
|
|
28
|
-
logger.warning(
|
|
29
|
-
"Failed to connect to NVIDIA API. Model list may be unavailable."
|
|
30
|
-
" Please check your internet connection and API credentials."
|
|
31
|
-
)
|
|
27
|
+
except Exception as e: # noqa: BLE001
|
|
28
|
+
logger.warning(f"Failed to fetch NVIDIA models during initialization: {e}. Model list will be unavailable.")
|
|
32
29
|
all_models = []
|
|
33
30
|
|
|
34
31
|
inputs = [
|
lfx/components/ollama/ollama.py
CHANGED
|
@@ -32,8 +32,8 @@ class ChatOllamaComponent(LCModelComponent):
|
|
|
32
32
|
MessageTextInput(
|
|
33
33
|
name="base_url",
|
|
34
34
|
display_name="Base URL",
|
|
35
|
-
info="Endpoint of the Ollama API.",
|
|
36
|
-
value="",
|
|
35
|
+
info="Endpoint of the Ollama API. Defaults to http://localhost:11434 .",
|
|
36
|
+
value="http://localhost:11434",
|
|
37
37
|
real_time_refresh=True,
|
|
38
38
|
),
|
|
39
39
|
DropdownInput(
|
|
@@ -157,6 +157,18 @@ class ChatOllamaComponent(LCModelComponent):
|
|
|
157
157
|
mirostat_tau = self.mirostat_tau
|
|
158
158
|
|
|
159
159
|
transformed_base_url = transform_localhost_url(self.base_url)
|
|
160
|
+
|
|
161
|
+
# Check if URL contains /v1 suffix (OpenAI-compatible mode)
|
|
162
|
+
if transformed_base_url and transformed_base_url.rstrip("/").endswith("/v1"):
|
|
163
|
+
# Strip /v1 suffix and log warning
|
|
164
|
+
transformed_base_url = transformed_base_url.rstrip("/").removesuffix("/v1")
|
|
165
|
+
logger.warning(
|
|
166
|
+
"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, "
|
|
167
|
+
"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. "
|
|
168
|
+
"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. "
|
|
169
|
+
"Learn more at https://docs.ollama.com/openai#openai-compatibility"
|
|
170
|
+
)
|
|
171
|
+
|
|
160
172
|
# Mapping system settings to their corresponding values
|
|
161
173
|
llm_params = {
|
|
162
174
|
"base_url": transformed_base_url,
|
|
@@ -190,8 +202,8 @@ class ChatOllamaComponent(LCModelComponent):
|
|
|
190
202
|
output = ChatOllama(**llm_params)
|
|
191
203
|
except Exception as e:
|
|
192
204
|
msg = (
|
|
193
|
-
"Unable to connect to the Ollama API. "
|
|
194
|
-
"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again."
|
|
205
|
+
"Unable to connect to the Ollama API. "
|
|
206
|
+
"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again."
|
|
195
207
|
)
|
|
196
208
|
raise ValueError(msg) from e
|
|
197
209
|
|
|
@@ -201,6 +213,12 @@ class ChatOllamaComponent(LCModelComponent):
|
|
|
201
213
|
try:
|
|
202
214
|
async with httpx.AsyncClient() as client:
|
|
203
215
|
url = transform_localhost_url(url)
|
|
216
|
+
if not url:
|
|
217
|
+
return False
|
|
218
|
+
# Strip /v1 suffix if present, as Ollama API endpoints are at root level
|
|
219
|
+
url = url.rstrip("/").removesuffix("/v1")
|
|
220
|
+
if not url.endswith("/"):
|
|
221
|
+
url = url + "/"
|
|
204
222
|
return (await client.get(urljoin(url, "api/tags"))).status_code == HTTP_STATUS_OK
|
|
205
223
|
except httpx.RequestError:
|
|
206
224
|
return False
|
|
@@ -224,9 +242,6 @@ class ChatOllamaComponent(LCModelComponent):
|
|
|
224
242
|
build_config["mirostat_eta"]["value"] = 0.1
|
|
225
243
|
build_config["mirostat_tau"]["value"] = 5
|
|
226
244
|
|
|
227
|
-
if field_name in {"base_url", "model_name"} and not await self.is_valid_ollama_url(self.base_url):
|
|
228
|
-
msg = "Ollama is not running on the provided base URL. Please start Ollama and try again."
|
|
229
|
-
raise ValueError(msg)
|
|
230
245
|
if field_name in {"model_name", "base_url", "tool_model_enabled"}:
|
|
231
246
|
if await self.is_valid_ollama_url(self.base_url):
|
|
232
247
|
tool_model_enabled = build_config["tool_model_enabled"].get("value", False) or self.tool_model_enabled
|
|
@@ -264,8 +279,10 @@ class ChatOllamaComponent(LCModelComponent):
|
|
|
264
279
|
names cannot be retrieved.
|
|
265
280
|
"""
|
|
266
281
|
try:
|
|
267
|
-
#
|
|
268
|
-
base_url = base_url_value.rstrip("/")
|
|
282
|
+
# Strip /v1 suffix if present, as Ollama API endpoints are at root level
|
|
283
|
+
base_url = base_url_value.rstrip("/").removesuffix("/v1")
|
|
284
|
+
if not base_url.endswith("/"):
|
|
285
|
+
base_url = base_url + "/"
|
|
269
286
|
base_url = transform_localhost_url(base_url)
|
|
270
287
|
|
|
271
288
|
# Ollama REST API to return models
|
|
@@ -1548,7 +1548,16 @@ class Component(CustomComponent):
|
|
|
1548
1548
|
if not message.sender_name:
|
|
1549
1549
|
message.sender_name = MESSAGE_SENDER_NAME_AI
|
|
1550
1550
|
|
|
1551
|
-
async def send_message(self, message: Message, id_: str | None = None):
|
|
1551
|
+
async def send_message(self, message: Message, id_: str | None = None, *, skip_db_update: bool = False):
|
|
1552
|
+
"""Send a message with optional database update control.
|
|
1553
|
+
|
|
1554
|
+
Args:
|
|
1555
|
+
message: The message to send
|
|
1556
|
+
id_: Optional message ID
|
|
1557
|
+
skip_db_update: If True, only update in-memory and send event, skip DB write.
|
|
1558
|
+
Useful during streaming to avoid excessive DB round-trips.
|
|
1559
|
+
Note: This assumes the message already exists in the database with message.id set.
|
|
1560
|
+
"""
|
|
1552
1561
|
if self._should_skip_message(message):
|
|
1553
1562
|
return message
|
|
1554
1563
|
|
|
@@ -1558,26 +1567,37 @@ class Component(CustomComponent):
|
|
|
1558
1567
|
# Ensure required fields for message storage are set
|
|
1559
1568
|
self._ensure_message_required_fields(message)
|
|
1560
1569
|
|
|
1561
|
-
|
|
1570
|
+
# If skip_db_update is True and message already has an ID, skip the DB write
|
|
1571
|
+
# This path is used during agent streaming to avoid excessive DB round-trips
|
|
1572
|
+
if skip_db_update and message.id:
|
|
1573
|
+
# Create a fresh Message instance for consistency with normal flow
|
|
1574
|
+
stored_message = await Message.create(**message.model_dump())
|
|
1575
|
+
self._stored_message_id = stored_message.id
|
|
1576
|
+
# Still send the event to update the client in real-time
|
|
1577
|
+
# Note: If this fails, we don't need DB cleanup since we didn't write to DB
|
|
1578
|
+
await self._send_message_event(stored_message, id_=id_)
|
|
1579
|
+
else:
|
|
1580
|
+
# Normal flow: store/update in database
|
|
1581
|
+
stored_message = await self._store_message(message)
|
|
1562
1582
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1583
|
+
self._stored_message_id = stored_message.id
|
|
1584
|
+
try:
|
|
1585
|
+
complete_message = ""
|
|
1586
|
+
if (
|
|
1587
|
+
self._should_stream_message(stored_message, message)
|
|
1588
|
+
and message is not None
|
|
1589
|
+
and isinstance(message.text, AsyncIterator | Iterator)
|
|
1590
|
+
):
|
|
1591
|
+
complete_message = await self._stream_message(message.text, stored_message)
|
|
1592
|
+
stored_message.text = complete_message
|
|
1593
|
+
stored_message = await self._update_stored_message(stored_message)
|
|
1594
|
+
else:
|
|
1595
|
+
# Only send message event for non-streaming messages
|
|
1596
|
+
await self._send_message_event(stored_message, id_=id_)
|
|
1597
|
+
except Exception:
|
|
1598
|
+
# remove the message from the database
|
|
1599
|
+
await delete_message(stored_message.id)
|
|
1600
|
+
raise
|
|
1581
1601
|
self.status = stored_message
|
|
1582
1602
|
return stored_message
|
|
1583
1603
|
|
lfx/interface/components.py
CHANGED
|
@@ -95,8 +95,10 @@ def _process_single_module(modname: str) -> tuple[str, dict] | None:
|
|
|
95
95
|
"""
|
|
96
96
|
try:
|
|
97
97
|
module = importlib.import_module(modname)
|
|
98
|
-
except
|
|
99
|
-
|
|
98
|
+
except Exception as e: # noqa: BLE001
|
|
99
|
+
# Catch all exceptions during import to prevent component failures from crashing startup
|
|
100
|
+
# TODO: Surface these errors to the UI in a friendly manner
|
|
101
|
+
logger.error(f"Failed to import module {modname}: {e}", exc_info=True)
|
|
100
102
|
return None
|
|
101
103
|
# Extract the top-level subpackage name after "langflow.components."
|
|
102
104
|
# e.g., "langflow.components.Notion.add_content_to_page" -> "Notion"
|
lfx/schema/log.py
CHANGED
lfx/schema/message.py
CHANGED
|
@@ -121,9 +121,13 @@ class Message(Data):
|
|
|
121
121
|
|
|
122
122
|
def to_lc_message(
|
|
123
123
|
self,
|
|
124
|
+
model_name: str | None = None,
|
|
124
125
|
) -> BaseMessage:
|
|
125
126
|
"""Converts the Data to a BaseMessage.
|
|
126
127
|
|
|
128
|
+
Args:
|
|
129
|
+
model_name: The model name to use for conversion. Optional.
|
|
130
|
+
|
|
127
131
|
Returns:
|
|
128
132
|
BaseMessage: The converted BaseMessage.
|
|
129
133
|
"""
|
|
@@ -139,7 +143,7 @@ class Message(Data):
|
|
|
139
143
|
if self.sender == MESSAGE_SENDER_USER or not self.sender:
|
|
140
144
|
if self.files:
|
|
141
145
|
contents = [{"type": "text", "text": text}]
|
|
142
|
-
file_contents = self.get_file_content_dicts()
|
|
146
|
+
file_contents = self.get_file_content_dicts(model_name)
|
|
143
147
|
contents.extend(file_contents)
|
|
144
148
|
human_message = HumanMessage(content=contents)
|
|
145
149
|
else:
|
|
@@ -197,7 +201,7 @@ class Message(Data):
|
|
|
197
201
|
return value
|
|
198
202
|
|
|
199
203
|
# Keep this async method for backwards compatibility
|
|
200
|
-
def get_file_content_dicts(self):
|
|
204
|
+
def get_file_content_dicts(self, model_name: str | None = None):
|
|
201
205
|
content_dicts = []
|
|
202
206
|
try:
|
|
203
207
|
files = get_file_paths(self.files)
|
|
@@ -209,7 +213,7 @@ class Message(Data):
|
|
|
209
213
|
if isinstance(file, Image):
|
|
210
214
|
content_dicts.append(file.to_content_dict())
|
|
211
215
|
else:
|
|
212
|
-
content_dicts.append(create_image_content_dict(file))
|
|
216
|
+
content_dicts.append(create_image_content_dict(file, None, model_name))
|
|
213
217
|
return content_dicts
|
|
214
218
|
|
|
215
219
|
def load_lc_prompt(self):
|
lfx/utils/image.py
CHANGED
|
@@ -56,12 +56,15 @@ def create_data_url(image_path: str | Path, mime_type: str | None = None) -> str
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
@lru_cache(maxsize=50)
|
|
59
|
-
def create_image_content_dict(
|
|
59
|
+
def create_image_content_dict(
|
|
60
|
+
image_path: str | Path, mime_type: str | None = None, model_name: str | None = None
|
|
61
|
+
) -> dict:
|
|
60
62
|
"""Create a content dictionary for multimodal inputs from an image file.
|
|
61
63
|
|
|
62
64
|
Args:
|
|
63
65
|
image_path: Path to the image file
|
|
64
66
|
mime_type: MIME type of the image. If None, will be auto-detected
|
|
67
|
+
model_name: Optional model parameter to determine content dict structure
|
|
65
68
|
|
|
66
69
|
Returns:
|
|
67
70
|
Content dictionary with type and image_url fields
|
|
@@ -70,4 +73,7 @@ def create_image_content_dict(image_path: str | Path, mime_type: str | None = No
|
|
|
70
73
|
FileNotFoundError: If the image file doesn't exist
|
|
71
74
|
"""
|
|
72
75
|
data_url = create_data_url(image_path, mime_type)
|
|
76
|
+
|
|
77
|
+
if model_name == "OllamaModel":
|
|
78
|
+
return {"type": "image_url", "source_type": "url", "image_url": data_url}
|
|
73
79
|
return {"type": "image", "source_type": "url", "url": data_url}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lfx-nightly
|
|
3
|
-
Version: 0.1.12.
|
|
3
|
+
Version: 0.1.12.dev34
|
|
4
4
|
Summary: Langflow Executor - A lightweight CLI tool for executing and serving Langflow AI flows
|
|
5
5
|
Author-email: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
|
|
6
6
|
Requires-Python: <3.14,>=3.10
|
|
@@ -12,7 +12,7 @@ lfx/base/agents/callback.py,sha256=mjlT9ukBMVrfjYrHsJowqpY4g9hVGBVBIYhncLWr3tQ,3
|
|
|
12
12
|
lfx/base/agents/context.py,sha256=u0wboX1aRR22Ia8gY14WF12RjhE0Rxv9hPBiixT9DtQ,3916
|
|
13
13
|
lfx/base/agents/default_prompts.py,sha256=tUjfczwt4D5R1KozNOl1uSL2V2rSCZeUMS-cfV4Gwn0,955
|
|
14
14
|
lfx/base/agents/errors.py,sha256=4QY1AqSWZaOjq-iQRYH_aeCfH_hWECLQkiwybNXz66U,531
|
|
15
|
-
lfx/base/agents/events.py,sha256=
|
|
15
|
+
lfx/base/agents/events.py,sha256=jOhfGf_za5IYMvxRMvi1qqU0gw9b1imvDCOQP3r3wlo,14598
|
|
16
16
|
lfx/base/agents/utils.py,sha256=OcmtZx4BTFTyq2A3rta3WoJn98UzEYdEXoRLs8-mTVo,6511
|
|
17
17
|
lfx/base/agents/crewai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
18
|
lfx/base/agents/crewai/crew.py,sha256=TN1JyLXMpJc2yPH3tokhFmxKKYoJ4lMvmG19DmpKfeY,7953
|
|
@@ -52,7 +52,7 @@ lfx/base/langwatch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
|
|
|
52
52
|
lfx/base/langwatch/utils.py,sha256=N7rH3sRwgmNQzG0pKjj4wr_ans_drwtvkx4BQt-B0WA,457
|
|
53
53
|
lfx/base/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
54
|
lfx/base/mcp/constants.py,sha256=-1XnJxejlqm9zs1R91qGtOeX-_F1ZpdHVzCIqUCvhgE,62
|
|
55
|
-
lfx/base/mcp/util.py,sha256=
|
|
55
|
+
lfx/base/mcp/util.py,sha256=rHi-wC8tuUWCiI65AylJNE7dmO59K71R_W-ruN3MO50,69164
|
|
56
56
|
lfx/base/memory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
57
57
|
lfx/base/memory/memory.py,sha256=kZ-aZoHvRW4PJAgY1LUt5UBj7YXbos_aVBPGjC1EFCY,1835
|
|
58
58
|
lfx/base/memory/model.py,sha256=2oDORZV_l-DHLx9j9--wYprQUIYKOb8aTJpXmR1qOLw,1330
|
|
@@ -63,7 +63,7 @@ lfx/base/models/aws_constants.py,sha256=-Fa7T3wJqBaZhs80ATRgZP6yZ0Nsd1YYdZv9SfqT
|
|
|
63
63
|
lfx/base/models/chat_result.py,sha256=-MypS6_GKXOqWevtk0xwtrsEO4mIgpPAt7-EML5n0vA,2756
|
|
64
64
|
lfx/base/models/google_generative_ai_constants.py,sha256=EuFd77ZrrSr6YtSKtmEaq0Nfa4y45AbDe_cz_18nReE,2564
|
|
65
65
|
lfx/base/models/groq_constants.py,sha256=WOMpYRwJVrZavsi7zGJwRHJX8ZBvdtILUOmBFv0QIPQ,5536
|
|
66
|
-
lfx/base/models/model.py,sha256=
|
|
66
|
+
lfx/base/models/model.py,sha256=nJgExAvMJ5WMxCqC__Jc1GdkgJag4yrwC9nFtPEVupM,15324
|
|
67
67
|
lfx/base/models/model_input_constants.py,sha256=WrnkAmMTk4cMjjLgBzRffJDzow7LWRpfc5GsgdRxvU4,10748
|
|
68
68
|
lfx/base/models/model_metadata.py,sha256=tNFPiRqBJ0WPKdNEqBxuoKk0n8H_h0J--bCV5pk9k4o,1325
|
|
69
69
|
lfx/base/models/model_utils.py,sha256=RwXUSIw5gdRakQ-VGbLI1iT0CeeWrVSNTgUQIrrc6uE,474
|
|
@@ -111,7 +111,7 @@ lfx/components/agentql/__init__.py,sha256=Erl669Dzsk-SegsDPWTtkKbprMXVuv8UTCo5RE
|
|
|
111
111
|
lfx/components/agentql/agentql_api.py,sha256=N94yEK7ZuQCIsFBlr_8dqrJY-K1-KNb6QEEYfDIsDME,5569
|
|
112
112
|
lfx/components/agents/__init__.py,sha256=u1PH9Ui0dUgTdTZVP7cdVysCv4extdusKS_brcbE7Eg,1049
|
|
113
113
|
lfx/components/agents/agent.py,sha256=gUZ5RTgf_6G8Yi6QnQRuC4qn1rh2eeIF9MHQL8G-D3k,26592
|
|
114
|
-
lfx/components/agents/mcp_component.py,sha256=
|
|
114
|
+
lfx/components/agents/mcp_component.py,sha256=mE2HvbHcdkuWWylxmaNNZobbtgBRktOOakeGwUYs7Qs,25586
|
|
115
115
|
lfx/components/aiml/__init__.py,sha256=DNKB-HMFGFYmsdkON-s8557ttgBXVXADmS-BcuSQiIQ,1087
|
|
116
116
|
lfx/components/aiml/aiml.py,sha256=23Ineg1ajlCoqXgWgp50I20OnQbaleRNsw1c6IzPu3A,3877
|
|
117
117
|
lfx/components/aiml/aiml_embeddings.py,sha256=2uNwORuj55mxn2SfLbh7oAIfjuXwHbsnOqRjfMtQRqc,1095
|
|
@@ -400,7 +400,7 @@ lfx/components/notdiamond/notdiamond.py,sha256=om6_UB9n5rt1T-yXxgMFBPBEP2tJtnGC2
|
|
|
400
400
|
lfx/components/novita/__init__.py,sha256=i8RrVPX00S3RupAlZ078-mdGB7VHwvpdnL7IfsWWPIo,937
|
|
401
401
|
lfx/components/novita/novita.py,sha256=IULE3StkQwECxOR3HMJsEyE7cN5hwslxovvhMmquuNo,4368
|
|
402
402
|
lfx/components/nvidia/__init__.py,sha256=Phf45VUW7An5LnauqpB-lIRVwwBiQawZkoWbqBjQnWE,1756
|
|
403
|
-
lfx/components/nvidia/nvidia.py,sha256=
|
|
403
|
+
lfx/components/nvidia/nvidia.py,sha256=Ej8WbpJD5fWAtFsMa4qUiK1Xu6igL6SQYwM0hk5mPGo,6116
|
|
404
404
|
lfx/components/nvidia/nvidia_embedding.py,sha256=D97QOAgtZEzwHvBmDDShTmZhDAyN2SRbfb71515ib-g,2658
|
|
405
405
|
lfx/components/nvidia/nvidia_ingest.py,sha256=_wxmYNmRQ2kBfAxaXLykBIlKFXVGXEsTY22spVeoCCI,12065
|
|
406
406
|
lfx/components/nvidia/nvidia_rerank.py,sha256=zzl2skHxf2oXINDZBmG8-GbkTkc6EWtyMjyV8pVRAm4,2293
|
|
@@ -408,7 +408,7 @@ lfx/components/nvidia/system_assist.py,sha256=G8cgsLQxRBBnUt49_Uzxt7cdTNplVAzUlD
|
|
|
408
408
|
lfx/components/olivya/__init__.py,sha256=ilZR88huL3vnQHO27g4jsUkyIYSgN7RPOq8Corbi6xA,67
|
|
409
409
|
lfx/components/olivya/olivya.py,sha256=PDmsn8dBdSwAZUM2QGTyTwxGWsINCKaYR4yTjE-4lIQ,4192
|
|
410
410
|
lfx/components/ollama/__init__.py,sha256=fau8QcWs_eHO2MmtQ4coiKj9CzFA9X4hqFf541ekgXk,1068
|
|
411
|
-
lfx/components/ollama/ollama.py,sha256=
|
|
411
|
+
lfx/components/ollama/ollama.py,sha256=KSaBAdgyh1xmkIvVcBHRVwFwzBtR7wPkH4j3dlJnPC4,13946
|
|
412
412
|
lfx/components/ollama/ollama_embeddings.py,sha256=nvg-JQvue6j7tcrbbPeq1U_-LUj1MKawWbXxnnvJlWM,3976
|
|
413
413
|
lfx/components/openai/__init__.py,sha256=G4Fgw4pmmDohdIOmzaeSCGijzKjyqFXNJPLwlcUDZ3w,1113
|
|
414
414
|
lfx/components/openai/openai.py,sha256=imWO1tTJ0tTLqax1v5bNBPCRINTj2f2wN8j5G-a07GI,4505
|
|
@@ -563,7 +563,7 @@ lfx/custom/code_parser/__init__.py,sha256=qIwZQdEp1z7ldn0z-GY44wmwRaywN3L6VPoPt6
|
|
|
563
563
|
lfx/custom/code_parser/code_parser.py,sha256=QAqsp4QF607319dClK60BsaiwZLV55n0xeGR-DthSoE,14280
|
|
564
564
|
lfx/custom/custom_component/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
565
565
|
lfx/custom/custom_component/base_component.py,sha256=Pxi-qCocrGIwcG0x5fu-7ty1Py71bl_KG9Fku5SeO_M,4053
|
|
566
|
-
lfx/custom/custom_component/component.py,sha256=
|
|
566
|
+
lfx/custom/custom_component/component.py,sha256=6BIJxp6M8y1wCVUTGoQveYVDzeMDnuqcItcJpua_UUk,75795
|
|
567
567
|
lfx/custom/custom_component/component_with_cache.py,sha256=por6CiPL3EHdLp_DvfI7qz1n4tc1KkqMOJNbsxoqVaI,313
|
|
568
568
|
lfx/custom/custom_component/custom_component.py,sha256=u330P-UbRXKeO-ughl2rCyvEbgdq-igTNFYEoKQLJzI,22306
|
|
569
569
|
lfx/custom/directory_reader/__init__.py,sha256=eFjlhKjpt2Kha_sJ2EqWofLRbpvfOTjvDSCpdpaTqWk,77
|
|
@@ -612,7 +612,7 @@ lfx/inputs/input_mixin.py,sha256=r23bPxWb1Fo81kFU02a2KUGfPLNUhE6K9q1Zw4LH4Qw,108
|
|
|
612
612
|
lfx/inputs/inputs.py,sha256=y-SwcZhlEuVgLsOa2_x7wraUCaoZ3EV7_jrON9yuqPw,26220
|
|
613
613
|
lfx/inputs/validators.py,sha256=i_PyQHQUmNpeS-_jRJNNsP3WlTPMkCJk2iFmFt3_ijw,505
|
|
614
614
|
lfx/interface/__init__.py,sha256=hlivcb8kMhU_V8VeXClNfz5fRyF-u5PZZMXkgu0U5a0,211
|
|
615
|
-
lfx/interface/components.py,sha256=
|
|
615
|
+
lfx/interface/components.py,sha256=BotYhF246Ixm41AQb2aD5OJ7G8dIX_uE_55ZOrI4C70,20058
|
|
616
616
|
lfx/interface/listing.py,sha256=fCpnp1F4foldTEbt1sQcF2HNqsaUZZ5uEyIe_FDL42c,711
|
|
617
617
|
lfx/interface/run.py,sha256=m2u9r7K-v_FIe19GpwPUoaCeOMhA1iqp1k7Cy5G75uE,743
|
|
618
618
|
lfx/interface/utils.py,sha256=qKi2HRg-QgeI3hmXLMtG6DHBbaQPuVMW5-o9lcN7j0Q,3790
|
|
@@ -644,8 +644,8 @@ lfx/schema/encoders.py,sha256=7vlWHZnZuDv1UVuP9X7Xn8srP1HZqLygOmkps3EJyY0,332
|
|
|
644
644
|
lfx/schema/graph.py,sha256=o7qXhHZT4lEwjJZtlg4k9SNPgmMVZsZsclBbe8v_y6Y,1313
|
|
645
645
|
lfx/schema/image.py,sha256=WdaOT3bjkJaG28RpgmurtfcnOG7Hr2phZ27YXH25uHA,5970
|
|
646
646
|
lfx/schema/json_schema.py,sha256=UzMRSSAiLewJpf7B0XY4jPnPt0iskf61QUBxPdyiYys,6871
|
|
647
|
-
lfx/schema/log.py,sha256=
|
|
648
|
-
lfx/schema/message.py,sha256=
|
|
647
|
+
lfx/schema/log.py,sha256=TISQa44D4pL_-AOw9p0nOPV-7s6Phl-0yrpuZihhEsU,1981
|
|
648
|
+
lfx/schema/message.py,sha256=mHHTX9OCHCGpA4goniQXF7I2UqEOy744ZFS4LMzNrYk,18261
|
|
649
649
|
lfx/schema/openai_responses_schemas.py,sha256=drMCAlliefHfGRojBTMepPwk4DyEGh67naWvMPD10Sw,2596
|
|
650
650
|
lfx/schema/properties.py,sha256=ZRY6FUDfqpc5wQ-bi-ZuUUrusF9t-pt9fQa_FNPpia0,1356
|
|
651
651
|
lfx/schema/schema.py,sha256=XbIuvD64EdVljP1V32tsEL-ETXOQSFipMDaiMGzYttM,5079
|
|
@@ -712,14 +712,14 @@ lfx/utils/constants.py,sha256=4M8i93bROuQ7zmeKgfdNW85Znw7JFrK8KiagcDBpMRc,7036
|
|
|
712
712
|
lfx/utils/data_structure.py,sha256=xU3JNa_4jcGOVa_ctfMxiImEj6dKQQPE_zZsTAyy2T4,6888
|
|
713
713
|
lfx/utils/exceptions.py,sha256=RgIkI4uBssJsJUnuhluNGDSzdcuW5fnxPLhGfXYU9Uc,973
|
|
714
714
|
lfx/utils/helpers.py,sha256=0LE0barnVp-8Y5cCoDRzhDzesvXqgiT7IXP6vtTSyGE,889
|
|
715
|
-
lfx/utils/image.py,sha256=
|
|
715
|
+
lfx/utils/image.py,sha256=W9boQgz4WH3GOgLrYaRDz2CbX5Za8pzi044X3EKvYbI,2370
|
|
716
716
|
lfx/utils/lazy_load.py,sha256=UDtXi8N7NT9r-FRGxsLUfDtGU_X8yqt-RQqgpc9TqAw,394
|
|
717
717
|
lfx/utils/request_utils.py,sha256=A6vmwpr7f3ZUxHg6Sz2-BdUUsyAwg84-7N_DNoPC8_Q,518
|
|
718
718
|
lfx/utils/schemas.py,sha256=NbOtVQBrn4d0BAu-0H_eCTZI2CXkKZlRY37XCSmuJwc,3865
|
|
719
719
|
lfx/utils/util.py,sha256=Ww85wbr1-vjh2pXVtmTqoUVr6MXAW8S7eDx_Ys6HpE8,20696
|
|
720
720
|
lfx/utils/util_strings.py,sha256=nU_IcdphNaj6bAPbjeL-c1cInQPfTBit8mp5Y57lwQk,1686
|
|
721
721
|
lfx/utils/version.py,sha256=cHpbO0OJD2JQAvVaTH_6ibYeFbHJV0QDHs_YXXZ-bT8,671
|
|
722
|
-
lfx_nightly-0.1.12.
|
|
723
|
-
lfx_nightly-0.1.12.
|
|
724
|
-
lfx_nightly-0.1.12.
|
|
725
|
-
lfx_nightly-0.1.12.
|
|
722
|
+
lfx_nightly-0.1.12.dev34.dist-info/METADATA,sha256=gNTSkSXCMVTO-NThZUB9QYis4SFNxIME5n0h_BBMJFY,8290
|
|
723
|
+
lfx_nightly-0.1.12.dev34.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
724
|
+
lfx_nightly-0.1.12.dev34.dist-info/entry_points.txt,sha256=1724p3RHDQRT2CKx_QRzEIa7sFuSVO0Ux70YfXfoMT4,42
|
|
725
|
+
lfx_nightly-0.1.12.dev34.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|