litellm/tests/test_litellm/llms/langflow/test_langflow_a2a.py
Sameer Kankute ae7ac72331
feat(agents): add LangFlow agent provider with A2A session bridging (#28963)
* feat(agents): add LangFlow agent provider with A2A session bridging

Register LangFlow as a completion provider and agent type (UI + /api/v1/run),
and map A2A contextId to LangFlow session_id for multi-turn conversations.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(providers): document langflow in provider_endpoints_support.json

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agents): address Greptile review for LangFlow integration

Move A2A contextId→session_id mapping into LangFlow A2A provider config,
add langflow.svg logo, remove live integration test, use model for token count.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(langflow): prevent flow_id override via request optional_params

Derive flow_id only from the authorized model name and reject flow_id
kwargs so callers cannot invoke a different LangFlow run endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(langflow): remove redundant flow_id branch in _get_flow_id

* fix(langflow): surface an error when the run response has no extractable message

Previously the response parser returned the raw JSON blob as the assistant
message when it could not find message text, silently presenting an
unparseable payload as a valid answer. It now returns None and the caller
raises a LangFlowError so the failure is visible to the client.

* fix(langflow): URL-encode flow_id path segment to prevent path injection

flow_id is taken from the model suffix and interpolated into
/api/v1/run/{flow_id}. Without path-segment encoding a model such as
langflow/../../x (or one containing ?) could move the request off the run
endpoint to another path on the configured LangFlow server using the
operator x-api-key. Encode the segment with quote(safe="") so it always
stays a single path segment.

* fix(langflow): reject empty flow_id from model name

* fix(langflow): return stripped flow_id so validation matches URL path

* fix(langflow): reject caller-supplied tweaks to prevent flow component override

* fix(langflow): reject caller-supplied tweaks injected via extra_body

The transform_request guard only inspected optional_params, but extra_body
is popped before transform_request runs and merged into the request body
afterward, letting a caller reintroduce tweaks and override the
operator-configured LangFlow flow components. Validate the final request
body in sign_request so tweaks cannot reach LangFlow through extra_body.

* test(langflow): move provider tests into mirrored coverage path

The langflow tests lived under tests/llm_translation/, whose CircleCI job
runs without --cov and uploads nothing to Codecov, so none of the new
langflow code counted toward patch coverage (codecov/patch reported 9.78%
of the diff hit against a 70.83% target).

Relocate them to tests/test_litellm/llms/langflow/, which the GitHub
Actions provider job runs with --cov=./litellm and uploads, and add
regression tests for the previously untested happy paths (transform_response
building the ModelResponse with usage, non-JSON body handling, last-user
message extraction, outputs-dict response shape, sign_request pass-through,
error class and stream flags). Patch coverage on the diff is now ~88%.

* fix(langflow): require litellm_params in A2A config instead of silent empty fallback

* fix(langflow): scope A2A session_id to the authenticated key

The LangFlow A2A bridge used the LangFlow session_id verbatim from the
client-controlled A2A contextId, so two distinct virtual keys authorized for
the same agent could read or append to each other's LangFlow conversation
memory by reusing a contextId.

Hand the authenticated key hash to the completion bridge through litellm_params
and namespace the forwarded session_id with it. The same key keeps a stable
session across turns, while different keys can no longer collide on a shared
contextId. The principal is hashed before it is embedded in the session_id, so
the stored token is never sent to the LangFlow backend; the original contextId
is preserved as a suffix for operator-side correlation.

* fix(langflow): wire authenticated key hash through A2A bridge and tests

Define A2A_USER_API_KEY_HASH_PARAM in the completion bridge handler, strip it
before litellm.acompletion, inject the authenticated key hash at the proxy A2A
endpoint, and add regression tests for per-key LangFlow session scoping.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-06-02 14:45:56 -07:00

159 lines
5.6 KiB
Python

from unittest.mock import AsyncMock, patch
import pytest
from litellm.a2a_protocol.litellm_completion_bridge.handler import (
A2A_USER_API_KEY_HASH_PARAM,
)
from litellm.a2a_protocol.providers.config_manager import A2AProviderConfigManager
from litellm.llms.langflow.a2a import merge_a2a_session_into_litellm_params
def test_merge_a2a_session_into_litellm_params():
merged = merge_a2a_session_into_litellm_params(
{"custom_llm_provider": "langflow", "model": "langflow/flow-1"},
{"message": {"contextId": "shared-session-99"}},
)
assert merged["session_id"] == "shared-session-99"
def test_merge_a2a_session_is_scoped_per_principal():
"""The LangFlow session must be bound to the authenticated key so two
distinct keys cannot share memory by reusing the same A2A contextId, while
the same key keeps a stable session across turns."""
base = {"custom_llm_provider": "langflow", "model": "langflow/flow-1"}
params = {"message": {"contextId": "ctx-1"}}
key_a = merge_a2a_session_into_litellm_params(base, params, "hash-a")["session_id"]
key_a_again = merge_a2a_session_into_litellm_params(base, params, "hash-a")[
"session_id"
]
key_b = merge_a2a_session_into_litellm_params(base, params, "hash-b")["session_id"]
assert key_a == key_a_again, "same key + contextId must stay on one session"
assert key_a != key_b, "different keys must not collide on the same contextId"
assert key_a != "ctx-1", "raw client contextId must not be used verbatim"
assert key_a.endswith("-ctx-1"), "original contextId kept for correlation"
assert "hash-a" not in key_a, "raw principal must not be sent to LangFlow"
def test_merge_a2a_session_without_context_id_is_noop():
merged = merge_a2a_session_into_litellm_params(
{"custom_llm_provider": "langflow", "model": "langflow/flow-1"},
{"message": {"role": "user"}},
)
assert "session_id" not in merged
def test_langflow_a2a_provider_config_registered():
cfg = A2AProviderConfigManager.get_provider_config(
custom_llm_provider="langflow",
model="langflow/flow-1",
)
assert cfg is not None
assert cfg.__class__.__name__ == "LangFlowA2AConfig"
@pytest.mark.asyncio
async def test_langflow_a2a_config_passes_session_id_to_completion():
from litellm.a2a_protocol.providers.langflow.config import LangFlowA2AConfig
mock_response = type(
"R",
(),
{
"choices": [
type(
"C",
(),
{"message": type("M", (), {"content": "ok"})()},
)()
]
},
)()
with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion:
mock_acompletion.return_value = mock_response
await LangFlowA2AConfig().handle_non_streaming(
request_id="req-1",
params={
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "hi"}],
"contextId": "shared-session-99",
}
},
litellm_params={
"custom_llm_provider": "langflow",
"model": "langflow/flow-1",
"api_base": "http://localhost:7860",
},
api_base="http://localhost:7860",
)
assert (
mock_acompletion.call_args.kwargs.get("session_id") == "shared-session-99"
)
@pytest.mark.asyncio
async def test_langflow_a2a_config_scopes_session_by_authenticated_key():
from litellm.a2a_protocol.providers.langflow.config import LangFlowA2AConfig
mock_response = type(
"R",
(),
{"choices": [type("C", (), {"message": type("M", (), {"content": "ok"})()})()]},
)()
with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion:
mock_acompletion.return_value = mock_response
await LangFlowA2AConfig().handle_non_streaming(
request_id="req-1",
params={
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "hi"}],
"contextId": "ctx-1",
}
},
litellm_params={
"custom_llm_provider": "langflow",
"model": "langflow/flow-1",
"api_base": "http://localhost:7860",
A2A_USER_API_KEY_HASH_PARAM: "hashed-key-1",
},
api_base="http://localhost:7860",
)
forwarded = mock_acompletion.call_args.kwargs
assert forwarded.get("session_id") != "ctx-1"
assert forwarded.get("session_id").endswith("-ctx-1")
assert (
A2A_USER_API_KEY_HASH_PARAM not in forwarded
), "internal principal param must not leak to the LLM call"
@pytest.mark.asyncio
async def test_langflow_a2a_config_requires_litellm_params_non_streaming():
from litellm.a2a_protocol.providers.langflow.config import LangFlowA2AConfig
with pytest.raises(ValueError, match="litellm_params is required"):
await LangFlowA2AConfig().handle_non_streaming(
request_id="req-1",
params={"message": {"contextId": "shared-session-99"}},
)
@pytest.mark.asyncio
async def test_langflow_a2a_config_requires_litellm_params_streaming():
from litellm.a2a_protocol.providers.langflow.config import LangFlowA2AConfig
with pytest.raises(ValueError, match="litellm_params is required"):
async for _ in LangFlowA2AConfig().handle_streaming(
request_id="req-1",
params={"message": {"contextId": "shared-session-99"}},
):
pass