vellum-ai 1.2.1__py3-none-any.whl → 1.2.3__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.
Files changed (128) hide show
  1. vellum/__init__.py +40 -0
  2. vellum/client/core/client_wrapper.py +2 -2
  3. vellum/client/core/pydantic_utilities.py +3 -2
  4. vellum/client/reference.md +16 -0
  5. vellum/client/resources/workflow_executions/client.py +28 -4
  6. vellum/client/resources/workflow_executions/raw_client.py +32 -2
  7. vellum/client/types/__init__.py +40 -0
  8. vellum/client/types/audio_input_request.py +30 -0
  9. vellum/client/types/delimiter_chunker_config.py +20 -0
  10. vellum/client/types/delimiter_chunker_config_request.py +20 -0
  11. vellum/client/types/delimiter_chunking.py +21 -0
  12. vellum/client/types/delimiter_chunking_request.py +21 -0
  13. vellum/client/types/document_index_chunking.py +4 -1
  14. vellum/client/types/document_index_chunking_request.py +2 -1
  15. vellum/client/types/document_input_request.py +30 -0
  16. vellum/client/types/execution_audio_vellum_value.py +31 -0
  17. vellum/client/types/execution_document_vellum_value.py +31 -0
  18. vellum/client/types/execution_image_vellum_value.py +31 -0
  19. vellum/client/types/execution_vellum_value.py +8 -0
  20. vellum/client/types/execution_video_vellum_value.py +31 -0
  21. vellum/client/types/image_input_request.py +30 -0
  22. vellum/client/types/logical_operator.py +1 -0
  23. vellum/client/types/node_input_compiled_audio_value.py +23 -0
  24. vellum/client/types/node_input_compiled_document_value.py +23 -0
  25. vellum/client/types/node_input_compiled_image_value.py +23 -0
  26. vellum/client/types/node_input_compiled_video_value.py +23 -0
  27. vellum/client/types/node_input_variable_compiled_value.py +8 -0
  28. vellum/client/types/prompt_deployment_input_request.py +13 -1
  29. vellum/client/types/prompt_request_audio_input.py +26 -0
  30. vellum/client/types/prompt_request_document_input.py +26 -0
  31. vellum/client/types/prompt_request_image_input.py +26 -0
  32. vellum/client/types/prompt_request_input.py +13 -1
  33. vellum/client/types/prompt_request_video_input.py +26 -0
  34. vellum/client/types/video_input_request.py +30 -0
  35. vellum/prompts/blocks/compilation.py +13 -11
  36. vellum/types/audio_input_request.py +3 -0
  37. vellum/types/delimiter_chunker_config.py +3 -0
  38. vellum/types/delimiter_chunker_config_request.py +3 -0
  39. vellum/types/delimiter_chunking.py +3 -0
  40. vellum/types/delimiter_chunking_request.py +3 -0
  41. vellum/types/document_input_request.py +3 -0
  42. vellum/types/execution_audio_vellum_value.py +3 -0
  43. vellum/types/execution_document_vellum_value.py +3 -0
  44. vellum/types/execution_image_vellum_value.py +3 -0
  45. vellum/types/execution_video_vellum_value.py +3 -0
  46. vellum/types/image_input_request.py +3 -0
  47. vellum/types/node_input_compiled_audio_value.py +3 -0
  48. vellum/types/node_input_compiled_document_value.py +3 -0
  49. vellum/types/node_input_compiled_image_value.py +3 -0
  50. vellum/types/node_input_compiled_video_value.py +3 -0
  51. vellum/types/prompt_request_audio_input.py +3 -0
  52. vellum/types/prompt_request_document_input.py +3 -0
  53. vellum/types/prompt_request_image_input.py +3 -0
  54. vellum/types/prompt_request_video_input.py +3 -0
  55. vellum/types/video_input_request.py +3 -0
  56. vellum/workflows/context.py +27 -9
  57. vellum/workflows/emitters/vellum_emitter.py +16 -69
  58. vellum/workflows/events/context.py +53 -78
  59. vellum/workflows/events/node.py +5 -5
  60. vellum/workflows/events/relational_threads.py +41 -0
  61. vellum/workflows/events/tests/test_basic_workflow.py +50 -0
  62. vellum/workflows/events/tests/test_event.py +1 -0
  63. vellum/workflows/events/workflow.py +15 -1
  64. vellum/workflows/expressions/contains.py +7 -0
  65. vellum/workflows/expressions/tests/test_contains.py +175 -0
  66. vellum/workflows/graph/graph.py +52 -8
  67. vellum/workflows/graph/tests/test_graph.py +17 -0
  68. vellum/workflows/integrations/mcp_service.py +35 -5
  69. vellum/workflows/integrations/tests/test_mcp_service.py +81 -0
  70. vellum/workflows/nodes/bases/base.py +0 -1
  71. vellum/workflows/nodes/core/error_node/node.py +4 -0
  72. vellum/workflows/nodes/core/inline_subworkflow_node/tests/test_node.py +35 -0
  73. vellum/workflows/nodes/core/map_node/node.py +7 -0
  74. vellum/workflows/nodes/core/map_node/tests/test_node.py +19 -0
  75. vellum/workflows/nodes/displayable/bases/utils.py +4 -2
  76. vellum/workflows/nodes/displayable/final_output_node/node.py +4 -0
  77. vellum/workflows/nodes/displayable/subworkflow_deployment_node/node.py +88 -2
  78. vellum/workflows/nodes/displayable/tool_calling_node/node.py +1 -0
  79. vellum/workflows/nodes/displayable/tool_calling_node/tests/test_node.py +85 -1
  80. vellum/workflows/nodes/displayable/tool_calling_node/tests/test_utils.py +12 -0
  81. vellum/workflows/nodes/displayable/tool_calling_node/utils.py +5 -2
  82. vellum/workflows/ports/node_ports.py +3 -0
  83. vellum/workflows/ports/port.py +8 -11
  84. vellum/workflows/state/context.py +47 -2
  85. vellum/workflows/types/definition.py +4 -4
  86. vellum/workflows/utils/uuids.py +15 -0
  87. vellum/workflows/utils/vellum_variables.py +5 -3
  88. vellum/workflows/workflows/base.py +1 -0
  89. {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/METADATA +1 -1
  90. {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/RECORD +128 -82
  91. vellum_ee/workflows/display/nodes/base_node_display.py +19 -10
  92. vellum_ee/workflows/display/nodes/vellum/api_node.py +1 -4
  93. vellum_ee/workflows/display/nodes/vellum/code_execution_node.py +1 -4
  94. vellum_ee/workflows/display/nodes/vellum/conditional_node.py +1 -4
  95. vellum_ee/workflows/display/nodes/vellum/error_node.py +1 -3
  96. vellum_ee/workflows/display/nodes/vellum/final_output_node.py +1 -3
  97. vellum_ee/workflows/display/nodes/vellum/guardrail_node.py +1 -4
  98. vellum_ee/workflows/display/nodes/vellum/inline_prompt_node.py +1 -8
  99. vellum_ee/workflows/display/nodes/vellum/inline_subworkflow_node.py +1 -4
  100. vellum_ee/workflows/display/nodes/vellum/map_node.py +1 -4
  101. vellum_ee/workflows/display/nodes/vellum/merge_node.py +1 -4
  102. vellum_ee/workflows/display/nodes/vellum/note_node.py +2 -4
  103. vellum_ee/workflows/display/nodes/vellum/prompt_deployment_node.py +1 -4
  104. vellum_ee/workflows/display/nodes/vellum/search_node.py +1 -4
  105. vellum_ee/workflows/display/nodes/vellum/subworkflow_deployment_node.py +1 -4
  106. vellum_ee/workflows/display/nodes/vellum/templating_node.py +1 -4
  107. vellum_ee/workflows/display/nodes/vellum/tests/test_code_execution_node.py +1 -0
  108. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_api_node_serialization.py +4 -0
  109. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_code_execution_node_serialization.py +12 -0
  110. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_conditional_node_serialization.py +16 -0
  111. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_error_node_serialization.py +5 -0
  112. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_guardrail_node_serialization.py +4 -0
  113. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_inline_subworkflow_serialization.py +4 -0
  114. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_map_node_serialization.py +4 -0
  115. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_merge_node_serialization.py +4 -0
  116. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_prompt_deployment_serialization.py +12 -0
  117. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_search_node_serialization.py +4 -0
  118. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_subworkflow_deployment_serialization.py +4 -0
  119. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_templating_node_serialization.py +4 -0
  120. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_terminal_node_serialization.py +5 -0
  121. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_tool_calling_node_composio_serialization.py +1 -0
  122. vellum_ee/workflows/display/tests/workflow_serialization/test_complex_terminal_node_serialization.py +5 -0
  123. vellum_ee/workflows/display/utils/events.py +24 -0
  124. vellum_ee/workflows/display/utils/tests/test_events.py +69 -0
  125. vellum_ee/workflows/tests/test_server.py +95 -0
  126. {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/LICENSE +0 -0
  127. {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/WHEEL +0 -0
  128. {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,175 @@
1
+ import pytest
2
+
3
+ from vellum.workflows.constants import undefined
4
+ from vellum.workflows.descriptors.exceptions import InvalidExpressionException
5
+ from vellum.workflows.expressions.contains import ContainsExpression
6
+ from vellum.workflows.references.constant import ConstantValueReference
7
+ from vellum.workflows.state.base import BaseState
8
+
9
+
10
+ class TestState(BaseState):
11
+ dict_value: dict = {"key": "value"}
12
+ list_value: list = [1, 2, 3]
13
+ string_value: str = "hello world"
14
+
15
+
16
+ def test_dict_contains_dict_raises_error():
17
+ """
18
+ Tests that ContainsExpression raises clear error for dict-contains-dict scenarios.
19
+ """
20
+ state = TestState()
21
+ lhs_dict = {"foo": "bar"}
22
+ rhs_dict = {"foo": "bar"}
23
+
24
+ expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
25
+
26
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
27
+ expression.resolve(state)
28
+
29
+
30
+ def test_dict_contains_different_dict_raises_error():
31
+ """
32
+ Tests that ContainsExpression raises clear error for different dict-contains-dict scenarios.
33
+ """
34
+ state = TestState()
35
+ lhs_dict = {"foo": "bar"}
36
+ rhs_dict = {"hello": "world"}
37
+
38
+ expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
39
+
40
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
41
+ expression.resolve(state)
42
+
43
+
44
+ def test_string_contains_dict_raises_error():
45
+ """
46
+ Tests that ContainsExpression raises clear error for string-contains-dict scenarios.
47
+ """
48
+ state = TestState()
49
+ lhs_string = 'Response: {"status": "success"} was returned'
50
+ rhs_dict = {"status": "success"}
51
+
52
+ expression = ContainsExpression(lhs=lhs_string, rhs=rhs_dict)
53
+
54
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
55
+ expression.resolve(state)
56
+
57
+
58
+ def test_nested_dict_contains_dict_raises_error():
59
+ """
60
+ Tests that ContainsExpression raises clear error for nested dict scenarios.
61
+ """
62
+ state = TestState()
63
+ lhs_dict = {"user": {"name": "john", "age": 30}}
64
+ rhs_dict = {"age": 30, "name": "john"}
65
+
66
+ expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
67
+
68
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
69
+ expression.resolve(state)
70
+
71
+
72
+ def test_list_contains_string():
73
+ """
74
+ Tests that ContainsExpression preserves original list functionality.
75
+ """
76
+ state = TestState()
77
+
78
+ expression = TestState.list_value.contains(2)
79
+ result = expression.resolve(state)
80
+
81
+ assert result is True
82
+
83
+
84
+ def test_string_contains_substring():
85
+ """
86
+ Tests that ContainsExpression preserves original string functionality.
87
+ """
88
+ state = TestState()
89
+
90
+ expression = TestState.string_value.contains("world")
91
+ result = expression.resolve(state)
92
+
93
+ assert result is True
94
+
95
+
96
+ def test_set_contains_item():
97
+ """
98
+ Tests that ContainsExpression works with sets.
99
+ """
100
+ state = TestState()
101
+ lhs_set = {1, 2, 3}
102
+ rhs_item = 2
103
+
104
+ expression = ContainsExpression(lhs=lhs_set, rhs=rhs_item)
105
+ result = expression.resolve(state)
106
+
107
+ assert result is True
108
+
109
+
110
+ def test_tuple_contains_item():
111
+ """
112
+ Tests that ContainsExpression works with tuples.
113
+ """
114
+ state = TestState()
115
+ lhs_tuple = (1, 2, 3)
116
+ rhs_item = 2
117
+
118
+ expression = ContainsExpression(lhs=lhs_tuple, rhs=rhs_item)
119
+ result = expression.resolve(state)
120
+
121
+ assert result is True
122
+
123
+
124
+ def test_invalid_lhs_type():
125
+ """
126
+ Tests that ContainsExpression raises exception for invalid LHS types.
127
+ """
128
+
129
+ class NoContainsSupport:
130
+ pass
131
+
132
+ state = TestState()
133
+ no_contains_obj = NoContainsSupport()
134
+ expression = ContainsExpression(lhs=no_contains_obj, rhs="test")
135
+
136
+ with pytest.raises(
137
+ InvalidExpressionException, match="Expected a LHS that supported `contains`, got `NoContainsSupport`"
138
+ ):
139
+ expression.resolve(state)
140
+
141
+
142
+ def test_undefined_lhs_returns_false():
143
+ """
144
+ Tests that ContainsExpression returns False for undefined LHS.
145
+ """
146
+ state = TestState()
147
+ expression = ContainsExpression(lhs=undefined, rhs="test")
148
+
149
+ result = expression.resolve(state)
150
+
151
+ assert result is False
152
+
153
+
154
+ def test_contains_with_constant_value_reference():
155
+ """
156
+ Tests ContainsExpression with ConstantValueReference for valid operations.
157
+ """
158
+ state = TestState()
159
+ lhs_ref = ConstantValueReference([1, 2, 3])
160
+ rhs_ref = ConstantValueReference(2)
161
+
162
+ expression: ContainsExpression = ContainsExpression(lhs=lhs_ref, rhs=rhs_ref)
163
+ result = expression.resolve(state)
164
+
165
+ assert result is True
166
+
167
+
168
+ def test_expression_metadata():
169
+ """
170
+ Tests that ContainsExpression has correct name and types properties.
171
+ """
172
+ expression = ContainsExpression(lhs=[1, 2, 3], rhs=2)
173
+
174
+ assert expression.name == "[1, 2, 3] contains 2"
175
+ assert expression.types == (bool,)
@@ -9,40 +9,67 @@ if TYPE_CHECKING:
9
9
  from vellum.workflows.nodes.bases.base import BaseNode
10
10
  from vellum.workflows.ports.port import Port
11
11
 
12
+
13
+ class NoPortsNode:
14
+ """Wrapper for nodes that have no ports defined."""
15
+
16
+ def __init__(self, node_class: Type["BaseNode"]):
17
+ self.node_class = node_class
18
+
19
+ def __repr__(self) -> str:
20
+ return self.node_class.__name__
21
+
22
+ def __rshift__(self, other: "GraphTarget") -> "Graph":
23
+ raise ValueError(
24
+ f"Cannot create edges from {self.node_class.__name__} because it has no ports defined. "
25
+ f"Nodes with empty Ports classes cannot be connected to other nodes."
26
+ )
27
+
28
+
12
29
  GraphTargetOfSets = Union[
13
30
  Set[NodeType],
14
31
  Set["Graph"],
15
32
  Set["Port"],
16
- Set[Union[Type["BaseNode"], "Graph", "Port"]],
33
+ Set[Union[Type["BaseNode"], "Graph", "Port", "NoPortsNode"]],
17
34
  ]
18
35
 
19
36
  GraphTarget = Union[
20
37
  Type["BaseNode"],
21
38
  "Port",
22
39
  "Graph",
40
+ "NoPortsNode",
23
41
  GraphTargetOfSets,
24
42
  ]
25
43
 
26
44
 
27
45
  class Graph:
28
- _entrypoints: Set["Port"]
46
+ _entrypoints: Set[Union["Port", "NoPortsNode"]]
29
47
  _edges: List[Edge]
30
- _terminals: Set["Port"]
31
-
32
- def __init__(self, entrypoints: Set["Port"], edges: List[Edge], terminals: Set["Port"]):
48
+ _terminals: Set[Union["Port", "NoPortsNode"]]
49
+
50
+ def __init__(
51
+ self,
52
+ entrypoints: Set[Union["Port", "NoPortsNode"]],
53
+ edges: List[Edge],
54
+ terminals: Set[Union["Port", "NoPortsNode"]],
55
+ ):
33
56
  self._edges = edges
34
57
  self._entrypoints = entrypoints
35
58
  self._terminals = terminals
36
59
 
37
60
  @staticmethod
38
61
  def from_port(port: "Port") -> "Graph":
39
- ports = {port}
62
+ ports: Set[Union["Port", "NoPortsNode"]] = {port}
40
63
  return Graph(entrypoints=ports, edges=[], terminals=ports)
41
64
 
42
65
  @staticmethod
43
66
  def from_node(node: Type["BaseNode"]) -> "Graph":
44
67
  ports = {port for port in node.Ports}
45
- return Graph(entrypoints=ports, edges=[], terminals=ports)
68
+ if not ports:
69
+ no_ports_node = NoPortsNode(node)
70
+ return Graph(entrypoints={no_ports_node}, edges=[], terminals={no_ports_node})
71
+ ports_set: Set[Union["Port", "NoPortsNode"]] = set(ports)
72
+ return Graph(entrypoints=ports_set, edges=[], terminals=ports_set)
46
73
 
47
74
  @staticmethod
48
75
  def from_set(targets: GraphTargetOfSets) -> "Graph":
@@ -73,10 +100,19 @@ class Graph:
73
100
  if not self._edges and not self._entrypoints:
74
101
  raise ValueError("Graph instance can only create new edges from nodes within existing edges")
75
102
 
103
+ if self._terminals and all(isinstance(terminal, NoPortsNode) for terminal in self._terminals):
104
+ terminal_names = [terminal.node_class.__name__ for terminal in self._terminals]
105
+ raise ValueError(
106
+ f"Cannot create edges from graph because all terminal nodes have no ports defined: "
107
+ f"{', '.join(terminal_names)}. Nodes with empty Ports classes cannot be connected to other nodes."
108
+ )
109
+
76
110
  if isinstance(other, set):
77
111
  new_terminals = set()
78
112
  for elem in other:
79
113
  for final_output_node in self._terminals:
114
+ if isinstance(final_output_node, NoPortsNode):
115
+ continue
80
116
  if isinstance(elem, Graph):
81
117
  midgraph = final_output_node >> set(elem.entrypoints)
82
118
  self._extend_edges(midgraph.edges)
@@ -98,6 +134,8 @@ class Graph:
98
134
 
99
135
  if isinstance(other, Graph):
100
136
  for final_output_node in self._terminals:
137
+ if isinstance(final_output_node, NoPortsNode):
138
+ continue
101
139
  midgraph = final_output_node >> set(other.entrypoints)
102
140
  self._extend_edges(midgraph.edges)
103
141
  self._extend_edges(other.edges)
@@ -106,6 +144,8 @@ class Graph:
106
144
 
107
145
  if hasattr(other, "Ports"):
108
146
  for final_output_node in self._terminals:
147
+ if isinstance(final_output_node, NoPortsNode):
148
+ continue
109
149
  subgraph = final_output_node >> other
110
150
  self._extend_edges(subgraph.edges)
111
151
  self._terminals = {port for port in other.Ports}
@@ -113,6 +153,8 @@ class Graph:
113
153
 
114
154
  # other is a Port
115
155
  for final_output_node in self._terminals:
156
+ if isinstance(final_output_node, NoPortsNode):
157
+ continue
116
158
  subgraph = final_output_node >> other
117
159
  self._extend_edges(subgraph.edges)
118
160
  self._terminals = {other}
@@ -238,8 +280,10 @@ class Graph:
238
280
 
239
281
  return "\n".join(lines)
240
282
 
241
- def _get_port_name(self, port: "Port") -> str:
283
+ def _get_port_name(self, port: Union["Port", "NoPortsNode"]) -> str:
242
284
  """Get a readable name for a port."""
285
+ if isinstance(port, NoPortsNode):
286
+ return f"{port.node_class.__name__} (no ports)"
243
287
  try:
244
288
  if hasattr(port, "node_class") and hasattr(port.node_class, "__name__"):
245
289
  node_name = port.node_class.__name__
@@ -583,3 +583,20 @@ def test_graph__str_single_node():
583
583
  # THEN it shows the single node
584
584
  assert "SingleNode.default" in result
585
585
  assert "Graph:" in result
586
+
587
+
588
+ def test_graph__from_node_with_empty_ports():
589
+ """
590
+ Tests that building a graph from a single node with empty Ports class generates 1 node.
591
+ """
592
+
593
+ # GIVEN a node with an empty Ports class
594
+ class NodeWithEmptyPorts(BaseNode):
595
+ class Ports(BaseNode.Ports):
596
+ pass
597
+
598
+ # WHEN we create a graph from the node
599
+ graph = Graph.from_node(NodeWithEmptyPorts)
600
+
601
+ # THEN the graph should have exactly 1 node
602
+ assert len(list(graph.nodes)) == 1
@@ -73,7 +73,7 @@ class MCPHttpClient:
73
73
  # Prepare headers
74
74
  headers = {
75
75
  "Content-Type": "application/json",
76
- "Accept": "application/json",
76
+ "Accept": "application/json, text/event-stream",
77
77
  }
78
78
 
79
79
  # Include session ID if we have one
@@ -88,11 +88,41 @@ class MCPHttpClient:
88
88
  # Check for session ID in response headers
89
89
  if "Mcp-Session-Id" in response.headers:
90
90
  self.session_id = response.headers["Mcp-Session-Id"]
91
- logger.debug(f"Received session ID: {self.session_id}")
92
91
 
93
- # Handle JSON response
94
- response_data = response.json()
95
- logger.debug(f"Received response: {json.dumps(response_data, indent=2)}")
92
+ # Handle response based on content type
93
+ content_type = response.headers.get("content-type", "").lower()
94
+
95
+ if "text/event-stream" in content_type:
96
+ # Handle SSE response
97
+ response_text = response.text
98
+
99
+ # Parse SSE format to extract JSON data
100
+ lines = response_text.strip().split("\n")
101
+ json_data = None
102
+
103
+ for line in lines:
104
+ if line.startswith("data: "):
105
+ data_content = line[6:] # Remove 'data: ' prefix
106
+ if data_content.strip() and data_content != "[DONE]":
107
+ try:
108
+ json_data = json.loads(data_content)
109
+ break
110
+ except json.JSONDecodeError:
111
+ continue
112
+
113
+ if json_data is None:
114
+ raise Exception("No valid JSON data found in SSE response")
115
+
116
+ response_data = json_data
117
+ else:
118
+ # Handle regular JSON response
119
+ if not response.text.strip():
120
+ raise Exception("Empty response received from server")
121
+
122
+ try:
123
+ response_data = response.json()
124
+ except json.JSONDecodeError as e:
125
+ raise Exception(f"Invalid JSON response: {str(e)}")
96
126
 
97
127
  if "error" in response_data:
98
128
  raise Exception(f"MCP Error: {response_data['error']}")
@@ -0,0 +1,81 @@
1
+ import asyncio
2
+ import json
3
+ from unittest import mock
4
+
5
+ from vellum.workflows.integrations.mcp_service import MCPHttpClient
6
+
7
+
8
+ def test_mcp_http_client_sse_response():
9
+ """Test that SSE responses are correctly parsed to JSON"""
10
+ # GIVEN an SSE response from the server
11
+ sample_sse_response = (
12
+ "event: message\n"
13
+ 'data: {"result":{"protocolVersion":"2025-06-18",'
14
+ '"capabilities":{"tools":{"listChanged":true}},'
15
+ '"serverInfo":{"name":"TestServer","version":"1.0.0"},'
16
+ '"instructions":"Test server for unit tests."},'
17
+ '"jsonrpc":"2.0","id":1}\n\n'
18
+ )
19
+ expected_json = {
20
+ "result": {
21
+ "protocolVersion": "2025-06-18",
22
+ "capabilities": {"tools": {"listChanged": True}},
23
+ "serverInfo": {"name": "TestServer", "version": "1.0.0"},
24
+ "instructions": "Test server for unit tests.",
25
+ },
26
+ "jsonrpc": "2.0",
27
+ "id": 1,
28
+ }
29
+
30
+ with mock.patch("vellum.workflows.integrations.mcp_service.httpx.AsyncClient") as mock_client_class:
31
+ mock_client = mock.AsyncMock()
32
+ mock_client_class.return_value = mock_client
33
+
34
+ mock_response = mock.Mock()
35
+ mock_response.headers = {"content-type": "text/event-stream"}
36
+ mock_response.text = sample_sse_response
37
+ mock_client.post.return_value = mock_response
38
+
39
+ # WHEN we send a request through the MCP client
40
+ async def test_request():
41
+ async with MCPHttpClient("https://test.server.com", {}) as client:
42
+ result = await client._send_request("initialize", {"test": "params"})
43
+ return result
44
+
45
+ result = asyncio.run(test_request())
46
+
47
+ # THEN the SSE response should be parsed correctly to JSON
48
+ assert result == expected_json
49
+
50
+ # AND the request should have been made with correct headers
51
+ mock_client.post.assert_called_once()
52
+ call_args = mock_client.post.call_args
53
+ assert call_args[1]["headers"]["Accept"] == "application/json, text/event-stream"
54
+ assert call_args[1]["headers"]["Content-Type"] == "application/json"
55
+
56
+
57
+ def test_mcp_http_client_json_response():
58
+ """Test that regular JSON responses still work"""
59
+ # GIVEN a regular JSON response from the server
60
+ sample_json_response = {"result": {"test": "data"}, "jsonrpc": "2.0", "id": 1}
61
+
62
+ with mock.patch("vellum.workflows.integrations.mcp_service.httpx.AsyncClient") as mock_client_class:
63
+ mock_client = mock.AsyncMock()
64
+ mock_client_class.return_value = mock_client
65
+
66
+ mock_response = mock.Mock()
67
+ mock_response.headers = {"content-type": "application/json"}
68
+ mock_response.text = json.dumps(sample_json_response)
69
+ mock_response.json.return_value = sample_json_response
70
+ mock_client.post.return_value = mock_response
71
+
72
+ # WHEN we send a request through the MCP client
73
+ async def test_request():
74
+ async with MCPHttpClient("https://test.server.com", {}) as client:
75
+ result = await client._send_request("initialize", {"test": "params"})
76
+ return result
77
+
78
+ result = asyncio.run(test_request())
79
+
80
+ # THEN the JSON response should be returned as expected
81
+ assert result == sample_json_response
@@ -125,7 +125,6 @@ class BaseNodeMeta(ABCMeta):
125
125
  # Add cls to relevant nested classes, since python should've been doing this by default
126
126
  for port in node_class.Ports:
127
127
  port.node_class = node_class
128
- port.validate()
129
128
 
130
129
  node_class.Execution.node_class = node_class
131
130
  node_class.Trigger.node_class = node_class
@@ -4,6 +4,7 @@ from vellum.client.types.vellum_error import VellumError
4
4
  from vellum.workflows.errors.types import WorkflowError, WorkflowErrorCode, vellum_error_to_workflow_error
5
5
  from vellum.workflows.exceptions import NodeException
6
6
  from vellum.workflows.nodes.bases.base import BaseNode
7
+ from vellum.workflows.ports import NodePorts
7
8
 
8
9
 
9
10
  class ErrorNode(BaseNode):
@@ -15,6 +16,9 @@ class ErrorNode(BaseNode):
15
16
 
16
17
  error: ClassVar[Union[str, WorkflowError, VellumError]]
17
18
 
19
+ class Ports(NodePorts):
20
+ pass
21
+
18
22
  def run(self) -> BaseNode.Outputs:
19
23
  if isinstance(self.error, str):
20
24
  raise NodeException(message=self.error, code=WorkflowErrorCode.USER_DEFINED_ERROR)
@@ -9,6 +9,7 @@ from vellum.workflows.nodes.core.try_node.node import TryNode
9
9
  from vellum.workflows.outputs.base import BaseOutput
10
10
  from vellum.workflows.state.base import BaseState
11
11
  from vellum.workflows.workflows.base import BaseWorkflow
12
+ from vellum.workflows.workflows.event_filters import all_workflow_event_filter
12
13
 
13
14
 
14
15
  class Inputs(BaseInputs):
@@ -143,3 +144,37 @@ def test_inline_subworkflow_node__with_adornment():
143
144
  outputs = list(node.run())
144
145
 
145
146
  assert outputs[-1].name == "final_output" and outputs[-1].value == "hello"
147
+
148
+
149
+ @pytest.mark.skip(reason="Enable after we set is_dynamic on the subworkflow class")
150
+ def test_inline_subworkflow_node__is_dynamic_subworkflow():
151
+ """Test that InlineSubworkflowNode sets is_dynamic=True on the subworkflow class"""
152
+
153
+ # GIVEN a subworkflow class
154
+ class TestSubworkflow(BaseWorkflow[BaseInputs, BaseState]):
155
+ graph = MyInnerNode
156
+
157
+ class Outputs(BaseWorkflow.Outputs):
158
+ out = MyInnerNode.Outputs.out
159
+
160
+ # AND a node that uses this subworkflow
161
+ class TestNode(InlineSubworkflowNode):
162
+ subworkflow = TestSubworkflow
163
+
164
+ # AND a workflow that uses this node
165
+ class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
166
+ graph = TestNode
167
+
168
+ class Outputs(BaseWorkflow.Outputs):
169
+ out = TestNode.Outputs.out
170
+
171
+ # WHEN the workflow is executed
172
+ workflow = TestWorkflow()
173
+ events = list(workflow.stream(event_filter=all_workflow_event_filter))
174
+
175
+ # AND we should find workflow execution initiated events
176
+ initiated_events = [event for event in events if event.name == "workflow.execution.initiated"]
177
+ assert len(initiated_events) == 2 # Main workflow + inline workflow
178
+
179
+ assert initiated_events[0].body.workflow_definition.is_dynamic is False # Main workflow
180
+ assert initiated_events[1].body.workflow_definition.is_dynamic is True # Inline workflow
@@ -30,6 +30,7 @@ from vellum.workflows.outputs.base import BaseOutput
30
30
  from vellum.workflows.references.output import OutputReference
31
31
  from vellum.workflows.state.context import WorkflowContext
32
32
  from vellum.workflows.types.generics import StateType
33
+ from vellum.workflows.utils.uuids import uuid4_from_hash
33
34
  from vellum.workflows.workflows.event_filters import all_workflow_event_filter
34
35
 
35
36
  if TYPE_CHECKING:
@@ -211,4 +212,10 @@ class MapNode(BaseAdornmentNode[StateType], Generic[StateType, MapNodeItemType])
211
212
  annotation = List[parameter_type] # type: ignore[valid-type]
212
213
 
213
214
  previous_annotations = {prev: annotation for prev in outputs_class.__annotations__ if not prev.startswith("_")}
215
+ # Map node output is a list of the same type so we use annotation=List[parameter_type] and not reference
216
+ # class Outputs(BaseOutputs):
217
+ # value: List[str]
214
218
  outputs_class.__annotations__ = {**previous_annotations, reference.name: annotation}
219
+
220
+ output_id = uuid4_from_hash(f"{cls.__id__}|{reference.name}")
221
+ cls.__output_ids__[reference.name] = output_id
@@ -119,6 +119,7 @@ def test_map_node__inner_try():
119
119
  # THEN the workflow should succeed
120
120
  assert outputs[-1].name == "final_output"
121
121
  assert len(outputs[-1].value) == 2
122
+ assert len(SimpleMapNode.__output_ids__) == 1
122
123
 
123
124
 
124
125
  def test_map_node__nested_map_node():
@@ -275,3 +276,21 @@ def test_map_node__shared_state_race_condition():
275
276
  # AND all results should be in correct order
276
277
  expected_result = ["a!", "b!", "c!", "d!", "e!", "f!"]
277
278
  assert final_result == expected_result, f"Failed on run {index}"
279
+
280
+
281
+ def test_map_node__output_ids():
282
+ class TestNode(BaseNode):
283
+ class Outputs(BaseOutputs):
284
+ value: str
285
+
286
+ class SimpleMapNodeWorkflow(BaseWorkflow[MapNode.SubworkflowInputs, BaseState]):
287
+ graph = TestNode
288
+
289
+ class Outputs(BaseWorkflow.Outputs):
290
+ final_output = TestNode.Outputs.value
291
+
292
+ class TestMapNode(MapNode):
293
+ items = [1, 2, 3]
294
+ subworkflow = SimpleMapNodeWorkflow
295
+
296
+ assert len(TestMapNode.__output_ids__) == 1
@@ -28,6 +28,8 @@ from vellum.client.types.string_vellum_value_request import StringVellumValueReq
28
28
  from vellum.client.types.vellum_error import VellumError
29
29
  from vellum.client.types.vellum_value import VellumValue
30
30
  from vellum.client.types.vellum_value_request import VellumValueRequest
31
+ from vellum.client.types.video_vellum_value import VideoVellumValue
32
+ from vellum.client.types.video_vellum_value_request import VideoVellumValueRequest
31
33
  from vellum.workflows.errors.types import WorkflowError, workflow_error_to_vellum_error
32
34
  from vellum.workflows.state.encoder import DefaultStateEncoder
33
35
 
@@ -36,7 +38,7 @@ VELLUM_VALUE_REQUEST_TUPLE = (
36
38
  NumberVellumValueRequest,
37
39
  JsonVellumValueRequest,
38
40
  AudioVellumValueRequest,
39
- # VideoVellumValueRequest,
41
+ VideoVellumValueRequest,
40
42
  ImageVellumValueRequest,
41
43
  FunctionCallVellumValueRequest,
42
44
  ErrorVellumValueRequest,
@@ -80,7 +82,7 @@ def primitive_to_vellum_value(value: Any) -> VellumValue:
80
82
  NumberVellumValue,
81
83
  JsonVellumValue,
82
84
  AudioVellumValue,
83
- # VideoVellumValue,
85
+ VideoVellumValue,
84
86
  ImageVellumValue,
85
87
  FunctionCallVellumValue,
86
88
  ErrorVellumValue,
@@ -4,6 +4,7 @@ from vellum.workflows.constants import undefined
4
4
  from vellum.workflows.nodes.bases import BaseNode
5
5
  from vellum.workflows.nodes.bases.base import BaseNodeMeta
6
6
  from vellum.workflows.nodes.utils import cast_to_output_type
7
+ from vellum.workflows.ports import NodePorts
7
8
  from vellum.workflows.types import MergeBehavior
8
9
  from vellum.workflows.types.generics import StateType
9
10
  from vellum.workflows.types.utils import get_original_base
@@ -47,6 +48,9 @@ class FinalOutputNode(BaseNode[StateType], Generic[StateType, _OutputType], meta
47
48
  class Trigger(BaseNode.Trigger):
48
49
  merge_behavior = MergeBehavior.AWAIT_ANY
49
50
 
51
+ class Ports(NodePorts):
52
+ pass
53
+
50
54
  class Outputs(BaseNode.Outputs):
51
55
  # We use our mypy plugin to override the _OutputType with the actual output type
52
56
  # for downstream references to this output.