vellum-ai 1.3.0__py3-none-any.whl → 1.3.2__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 (41) hide show
  1. vellum/__init__.py +6 -0
  2. vellum/client/README.md +5 -5
  3. vellum/client/__init__.py +20 -0
  4. vellum/client/core/client_wrapper.py +2 -2
  5. vellum/client/raw_client.py +20 -0
  6. vellum/client/reference.md +61 -27
  7. vellum/client/resources/ad_hoc/client.py +29 -29
  8. vellum/client/resources/ad_hoc/raw_client.py +13 -13
  9. vellum/client/resources/events/client.py +69 -33
  10. vellum/client/resources/events/raw_client.py +13 -9
  11. vellum/client/types/__init__.py +6 -0
  12. vellum/client/types/create_workflow_event_request.py +7 -0
  13. vellum/client/types/deprecated_prompt_request_input.py +8 -0
  14. vellum/client/types/event_create_response.py +5 -0
  15. vellum/client/types/logical_operator.py +1 -0
  16. vellum/client/types/processing_failure_reason_enum.py +3 -1
  17. vellum/client/types/slim_document.py +1 -0
  18. vellum/client/types/workflow_input.py +31 -0
  19. vellum/types/create_workflow_event_request.py +3 -0
  20. vellum/types/deprecated_prompt_request_input.py +3 -0
  21. vellum/types/workflow_input.py +3 -0
  22. vellum/workflows/constants.py +3 -0
  23. vellum/workflows/events/node.py +1 -0
  24. vellum/workflows/events/tests/test_event.py +1 -0
  25. vellum/workflows/events/workflow.py +10 -2
  26. vellum/workflows/nodes/core/templating_node/tests/test_templating_node.py +16 -0
  27. vellum/workflows/nodes/displayable/code_execution_node/tests/test_node.py +3 -13
  28. vellum/workflows/nodes/displayable/tool_calling_node/utils.py +11 -0
  29. vellum/workflows/nodes/displayable/web_search_node/tests/__init__.py +0 -0
  30. vellum/workflows/nodes/displayable/web_search_node/tests/test_node.py +319 -0
  31. vellum/workflows/nodes/tests/test_utils.py +23 -0
  32. vellum/workflows/nodes/utils.py +14 -0
  33. vellum/workflows/runner/runner.py +33 -12
  34. vellum/workflows/types/code_execution_node_wrappers.py +2 -1
  35. {vellum_ai-1.3.0.dist-info → vellum_ai-1.3.2.dist-info}/METADATA +1 -1
  36. {vellum_ai-1.3.0.dist-info → vellum_ai-1.3.2.dist-info}/RECORD +41 -32
  37. vellum_ee/workflows/display/tests/workflow_serialization/test_web_search_node_serialization.py +81 -0
  38. vellum_ee/workflows/display/utils/events.py +3 -0
  39. {vellum_ai-1.3.0.dist-info → vellum_ai-1.3.2.dist-info}/LICENSE +0 -0
  40. {vellum_ai-1.3.0.dist-info → vellum_ai-1.3.2.dist-info}/WHEEL +0 -0
  41. {vellum_ai-1.3.0.dist-info → vellum_ai-1.3.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,319 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock
3
+
4
+ import requests
5
+
6
+ from vellum.workflows.errors.types import WorkflowErrorCode
7
+ from vellum.workflows.exceptions import NodeException
8
+ from vellum.workflows.inputs import BaseInputs
9
+ from vellum.workflows.state import BaseState
10
+ from vellum.workflows.state.base import StateMeta
11
+
12
+ from ..node import WebSearchNode
13
+
14
+
15
+ @pytest.fixture
16
+ def base_node_setup(vellum_client):
17
+ """Basic node setup with required inputs."""
18
+
19
+ class Inputs(BaseInputs):
20
+ query: str
21
+ api_key: str
22
+ num_results: int
23
+
24
+ class State(BaseState):
25
+ pass
26
+
27
+ class TestableWebSearchNode(WebSearchNode):
28
+ query = Inputs.query
29
+ api_key = Inputs.api_key
30
+ num_results = Inputs.num_results
31
+
32
+ state = State(meta=StateMeta(workflow_inputs=Inputs(query="test query", api_key="test_api_key", num_results=3)))
33
+ context = MagicMock()
34
+ context.vellum_client = vellum_client
35
+ node = TestableWebSearchNode(state=state, context=context)
36
+ return node
37
+
38
+
39
+ def test_successful_search_with_results(base_node_setup, requests_mock):
40
+ """Test successful SerpAPI search with typical organic results."""
41
+ # GIVEN a mock SerpAPI response with organic results
42
+ mock_response = {
43
+ "organic_results": [
44
+ {
45
+ "title": "First Result",
46
+ "snippet": "This is the first search result snippet",
47
+ "link": "https://example1.com",
48
+ "position": 1,
49
+ },
50
+ {
51
+ "title": "Second Result",
52
+ "snippet": "This is the second search result snippet",
53
+ "link": "https://example2.com",
54
+ "position": 2,
55
+ },
56
+ {
57
+ "title": "Third Result",
58
+ "snippet": "This is the third search result snippet",
59
+ "link": "https://example3.com",
60
+ "position": 3,
61
+ },
62
+ ]
63
+ }
64
+
65
+ requests_mock.get("https://serpapi.com/search", json=mock_response)
66
+
67
+ # WHEN we run the node
68
+ outputs = base_node_setup.run()
69
+
70
+ # THEN the text output should be properly formatted
71
+ expected_text = (
72
+ "First Result: This is the first search result snippet\n\n"
73
+ "Second Result: This is the second search result snippet\n\n"
74
+ "Third Result: This is the third search result snippet"
75
+ )
76
+ assert outputs.text == expected_text
77
+
78
+ # AND URLs should be extracted correctly
79
+ assert outputs.urls == ["https://example1.com", "https://example2.com", "https://example3.com"]
80
+
81
+ # AND raw results should be preserved
82
+ assert outputs.results == mock_response["organic_results"]
83
+
84
+ # AND the request should have the correct parameters
85
+ assert requests_mock.last_request.qs == {
86
+ "q": ["test query"],
87
+ "api_key": ["test_api_key"],
88
+ "num": ["3"],
89
+ "engine": ["google"],
90
+ }
91
+
92
+
93
+ def test_search_with_location_parameter(base_node_setup, requests_mock):
94
+ """Test that location parameter is properly passed to SerpAPI."""
95
+ # GIVEN a location parameter is set
96
+ base_node_setup.location = "New York, NY"
97
+
98
+ requests_mock.get("https://serpapi.com/search", json={"organic_results": []})
99
+
100
+ # WHEN we run the node
101
+ base_node_setup.run()
102
+
103
+ # THEN the location parameter should be included (URL encoding may lowercase)
104
+ assert "location" in requests_mock.last_request.qs
105
+ assert requests_mock.last_request.qs["location"][0].lower() == "new york, ny"
106
+
107
+
108
+ def test_authentication_error_401(base_node_setup, requests_mock):
109
+ """Test 401 authentication error raises NodeException with INVALID_INPUTS."""
110
+ # GIVEN SerpAPI returns a 401 authentication error
111
+ requests_mock.get("https://serpapi.com/search", status_code=401)
112
+
113
+ # WHEN we run the node
114
+ with pytest.raises(NodeException) as exc_info:
115
+ base_node_setup.run()
116
+
117
+ # THEN it should raise the appropriate error
118
+ assert exc_info.value.code == WorkflowErrorCode.INVALID_INPUTS
119
+ assert "Invalid API key" in str(exc_info.value)
120
+
121
+
122
+ def test_rate_limit_error_429(base_node_setup, requests_mock):
123
+ """Test 429 rate limit error raises NodeException with PROVIDER_ERROR."""
124
+ # GIVEN SerpAPI returns a 429 rate limit error
125
+ requests_mock.get("https://serpapi.com/search", status_code=429)
126
+
127
+ # WHEN we run the node
128
+ with pytest.raises(NodeException) as exc_info:
129
+ base_node_setup.run()
130
+
131
+ # THEN it should raise the appropriate error
132
+ assert exc_info.value.code == WorkflowErrorCode.PROVIDER_ERROR
133
+ assert "Rate limit exceeded" in str(exc_info.value)
134
+
135
+
136
+ def test_server_error_500(base_node_setup, requests_mock):
137
+ """Test 500+ server errors raise NodeException with PROVIDER_ERROR."""
138
+ # GIVEN SerpAPI returns a 500 server error
139
+ requests_mock.get("https://serpapi.com/search", status_code=500)
140
+
141
+ # WHEN we run the node
142
+ with pytest.raises(NodeException) as exc_info:
143
+ base_node_setup.run()
144
+
145
+ # THEN it should raise the appropriate error
146
+ assert exc_info.value.code == WorkflowErrorCode.PROVIDER_ERROR
147
+ assert "SerpAPI error: HTTP 500" in str(exc_info.value)
148
+
149
+
150
+ def test_invalid_json_response(base_node_setup, requests_mock):
151
+ """Test non-JSON response raises appropriate NodeException."""
152
+ # GIVEN SerpAPI returns non-JSON content
153
+ requests_mock.get("https://serpapi.com/search", text="Not JSON")
154
+
155
+ # WHEN we run the node
156
+ with pytest.raises(NodeException) as exc_info:
157
+ base_node_setup.run()
158
+
159
+ # THEN it should raise the appropriate error
160
+ assert exc_info.value.code == WorkflowErrorCode.PROVIDER_ERROR
161
+ assert "Invalid JSON response" in str(exc_info.value)
162
+
163
+
164
+ def test_serpapi_error_in_response(base_node_setup, requests_mock):
165
+ """Test SerpAPI error field in response raises NodeException."""
166
+ # GIVEN SerpAPI returns an error in the response body
167
+ requests_mock.get("https://serpapi.com/search", json={"error": "Invalid search parameters"})
168
+
169
+ # WHEN we run the node
170
+ with pytest.raises(NodeException) as exc_info:
171
+ base_node_setup.run()
172
+
173
+ # THEN it should raise the appropriate error
174
+ assert exc_info.value.code == WorkflowErrorCode.PROVIDER_ERROR
175
+ assert "Invalid search parameters" in str(exc_info.value)
176
+
177
+
178
+ def test_empty_query_validation(vellum_client):
179
+ """Test empty query raises validation error."""
180
+
181
+ # GIVEN a node with an empty query
182
+ class TestNode(WebSearchNode):
183
+ query = ""
184
+ api_key = "test_key"
185
+ num_results = 10
186
+
187
+ context = MagicMock()
188
+ context.vellum_client = vellum_client
189
+ node = TestNode(state=BaseState(meta=StateMeta(workflow_inputs=BaseInputs())), context=context)
190
+
191
+ # WHEN we run the node
192
+ with pytest.raises(NodeException) as exc_info:
193
+ node.run()
194
+
195
+ # THEN it should raise a validation error
196
+ assert exc_info.value.code == WorkflowErrorCode.INVALID_INPUTS
197
+ assert "Query is required" in str(exc_info.value)
198
+
199
+
200
+ def test_missing_api_key_validation(vellum_client):
201
+ """Test missing API key raises validation error."""
202
+
203
+ # GIVEN a node with no API key
204
+ class TestNode(WebSearchNode):
205
+ query = "test query"
206
+ api_key = None
207
+ num_results = 10
208
+
209
+ context = MagicMock()
210
+ context.vellum_client = vellum_client
211
+ node = TestNode(state=BaseState(meta=StateMeta(workflow_inputs=BaseInputs())), context=context)
212
+
213
+ # WHEN we run the node
214
+ with pytest.raises(NodeException) as exc_info:
215
+ node.run()
216
+
217
+ # THEN it should raise a validation error
218
+ assert exc_info.value.code == WorkflowErrorCode.INVALID_INPUTS
219
+ assert "API key is required" in str(exc_info.value)
220
+
221
+
222
+ def test_invalid_num_results_validation(vellum_client):
223
+ """Test invalid num_results raises validation error."""
224
+
225
+ # GIVEN a node with invalid num_results
226
+ class TestNode(WebSearchNode):
227
+ query = "test query"
228
+ api_key = "test_key"
229
+ num_results = -1
230
+
231
+ context = MagicMock()
232
+ context.vellum_client = vellum_client
233
+ node = TestNode(state=BaseState(meta=StateMeta(workflow_inputs=BaseInputs())), context=context)
234
+
235
+ # WHEN we run the node
236
+ with pytest.raises(NodeException) as exc_info:
237
+ node.run()
238
+
239
+ # THEN it should raise a validation error
240
+ assert exc_info.value.code == WorkflowErrorCode.INVALID_INPUTS
241
+ assert "num_results must be a positive integer" in str(exc_info.value)
242
+
243
+
244
+ def test_empty_organic_results(base_node_setup, requests_mock):
245
+ """Test handling of empty search results."""
246
+ # GIVEN SerpAPI returns no organic results
247
+ requests_mock.get("https://serpapi.com/search", json={"organic_results": []})
248
+
249
+ # WHEN we run the node
250
+ outputs = base_node_setup.run()
251
+
252
+ # THEN all outputs should be empty
253
+ assert outputs.text == ""
254
+ assert outputs.urls == []
255
+ assert outputs.results == []
256
+
257
+
258
+ def test_missing_fields_in_results(base_node_setup, requests_mock):
259
+ """Test handling of missing title, snippet, or link fields."""
260
+ # GIVEN SerpAPI returns results with missing fields
261
+ mock_response = {
262
+ "organic_results": [
263
+ {
264
+ "title": "Only Title",
265
+ # Missing snippet and link
266
+ },
267
+ {
268
+ "snippet": "Only snippet, no title or link"
269
+ # Missing title and link
270
+ },
271
+ {
272
+ "title": "Title with link",
273
+ "link": "https://example.com",
274
+ # Missing snippet
275
+ },
276
+ {
277
+ # All fields missing - should be skipped
278
+ "position": 4
279
+ },
280
+ ]
281
+ }
282
+
283
+ requests_mock.get("https://serpapi.com/search", json=mock_response)
284
+
285
+ # WHEN we run the node
286
+ outputs = base_node_setup.run()
287
+
288
+ # THEN text should handle missing fields gracefully
289
+ expected_text = "Only Title\n\n" "Only snippet, no title or link\n\n" "Title with link"
290
+ assert outputs.text == expected_text
291
+
292
+ # AND URLs should only include valid links
293
+ assert outputs.urls == ["https://example.com"]
294
+
295
+
296
+ def test_request_timeout_handling(base_node_setup, requests_mock):
297
+ """Test network timeout raises appropriate error."""
298
+ # GIVEN a network timeout occurs
299
+ requests_mock.get("https://serpapi.com/search", exc=requests.exceptions.Timeout("Connection timed out"))
300
+
301
+ # WHEN we run the node
302
+ with pytest.raises(NodeException) as exc_info:
303
+ base_node_setup.run()
304
+
305
+ # THEN it should raise a provider error
306
+ assert exc_info.value.code == WorkflowErrorCode.PROVIDER_ERROR
307
+ assert "HTTP request failed" in str(exc_info.value)
308
+
309
+
310
+ def test_user_agent_header_included(base_node_setup, requests_mock):
311
+ """Test that User-Agent header from vellum_client is included."""
312
+ # GIVEN a successful request
313
+ requests_mock.get("https://serpapi.com/search", json={"organic_results": []})
314
+
315
+ # WHEN we run the node
316
+ base_node_setup.run()
317
+
318
+ # THEN the User-Agent header should be included
319
+ assert requests_mock.last_request.headers["User-Agent"] == "vellum-python-sdk/1.0.0"
@@ -150,3 +150,26 @@ def test_cast_to_output_type_none_value(output_type, expected_result):
150
150
  """Test that cast_to_output_type returns appropriate default values when None is provided."""
151
151
  result = cast_to_output_type(None, output_type)
152
152
  assert result == expected_result
153
+
154
+
155
+ @pytest.mark.parametrize(
156
+ "input_value,expected_result",
157
+ [
158
+ ('{"name": "Alice", "age": 30}', {"name": "Alice", "age": 30}),
159
+ ("[1, 2, 3]", [1, 2, 3]),
160
+ ("invalid json", "invalid json"),
161
+ ([1, 2, 3], [1, 2, 3]),
162
+ ({"already": "dict"}, {"already": "dict"}),
163
+ ],
164
+ ids=[
165
+ "valid_json_object",
166
+ "valid_json_array",
167
+ "invalid_json_string",
168
+ "non_string_list",
169
+ "non_string_dict",
170
+ ],
171
+ )
172
+ def test_cast_to_output_type_any_json_parsing(input_value, expected_result):
173
+ """Test that cast_to_output_type attempts JSON parsing for Any type and falls back gracefully."""
174
+ result = cast_to_output_type(input_value, Any)
175
+ assert result == expected_result
@@ -8,6 +8,7 @@ from typing import Any, Callable, Dict, ForwardRef, List, Optional, Type, TypeVa
8
8
  from pydantic import BaseModel, create_model
9
9
 
10
10
  from vellum.client.types.function_call import FunctionCall
11
+ from vellum.workflows.constants import undefined
11
12
  from vellum.workflows.errors.types import WorkflowErrorCode
12
13
  from vellum.workflows.exceptions import NodeException
13
14
  from vellum.workflows.inputs.base import BaseInputs
@@ -253,6 +254,19 @@ def cast_to_output_type(result: Any, output_type: Any) -> Any:
253
254
  if result is None:
254
255
  return _get_default_value(output_type)
255
256
 
257
+ if result is undefined:
258
+ return _get_default_value(output_type)
259
+
260
+ # Attempt JSON parse if type is Any
261
+ if output_type is Any:
262
+ if isinstance(result, str):
263
+ try:
264
+ return json.loads(result)
265
+ except (json.JSONDecodeError, TypeError):
266
+ # If JSON parsing fails, fall back to original result
267
+ pass
268
+ return result
269
+
256
270
  clean_output_type = _clean_output_type(output_type)
257
271
  DynamicModel = create_model("Output", output_type=(clean_output_type, ...))
258
272
 
@@ -404,6 +404,7 @@ class WorkflowRunner(Generic[StateType]):
404
404
  )
405
405
  except NodeException as e:
406
406
  logger.info(e)
407
+ captured_traceback = traceback.format_exc()
407
408
 
408
409
  self._workflow_event_inner_queue.put(
409
410
  NodeExecutionRejectedEvent(
@@ -412,12 +413,14 @@ class WorkflowRunner(Generic[StateType]):
412
413
  body=NodeExecutionRejectedBody(
413
414
  node_definition=node.__class__,
414
415
  error=e.error,
416
+ traceback=captured_traceback,
415
417
  ),
416
418
  parent=execution.parent_context,
417
419
  )
418
420
  )
419
421
  except WorkflowInitializationException as e:
420
422
  logger.info(e)
423
+ captured_traceback = traceback.format_exc()
421
424
  self._workflow_event_inner_queue.put(
422
425
  NodeExecutionRejectedEvent(
423
426
  trace_id=execution.trace_id,
@@ -425,6 +428,7 @@ class WorkflowRunner(Generic[StateType]):
425
428
  body=NodeExecutionRejectedBody(
426
429
  node_definition=node.__class__,
427
430
  error=e.error,
431
+ traceback=captured_traceback,
428
432
  ),
429
433
  parent=execution.parent_context,
430
434
  )
@@ -574,7 +578,7 @@ class WorkflowRunner(Generic[StateType]):
574
578
  )
575
579
  worker_thread.start()
576
580
 
577
- def _handle_work_item_event(self, event: WorkflowEvent) -> Optional[WorkflowError]:
581
+ def _handle_work_item_event(self, event: WorkflowEvent) -> Optional[NodeExecutionRejectedEvent]:
578
582
  active_node = self._active_nodes_by_execution_id.get(event.span_id)
579
583
  if not active_node:
580
584
  return None
@@ -582,7 +586,7 @@ class WorkflowRunner(Generic[StateType]):
582
586
  node = active_node.node
583
587
  if event.name == "node.execution.rejected":
584
588
  self._active_nodes_by_execution_id.pop(event.span_id)
585
- return event.error
589
+ return event
586
590
 
587
591
  if event.name == "node.execution.streaming":
588
592
  for workflow_output_descriptor in self.workflow.Outputs:
@@ -708,13 +712,24 @@ class WorkflowRunner(Generic[StateType]):
708
712
  parent=self._execution_context.parent_context,
709
713
  )
710
714
 
711
- def _reject_workflow_event(self, error: WorkflowError) -> WorkflowExecutionRejectedEvent:
715
+ def _reject_workflow_event(
716
+ self, error: WorkflowError, captured_traceback: Optional[str] = None
717
+ ) -> WorkflowExecutionRejectedEvent:
718
+ if captured_traceback is None:
719
+ try:
720
+ captured_traceback = traceback.format_exc()
721
+ if captured_traceback.strip() == "NoneType: None":
722
+ captured_traceback = None
723
+ except Exception:
724
+ pass
725
+
712
726
  return WorkflowExecutionRejectedEvent(
713
727
  trace_id=self._execution_context.trace_id,
714
728
  span_id=self._initial_state.meta.span_id,
715
729
  body=WorkflowExecutionRejectedBody(
716
730
  workflow_definition=self.workflow.__class__,
717
731
  error=error,
732
+ traceback=captured_traceback,
718
733
  ),
719
734
  parent=self._execution_context.parent_context,
720
735
  )
@@ -758,22 +773,26 @@ class WorkflowRunner(Generic[StateType]):
758
773
  else:
759
774
  self._concurrency_queue.put((self._initial_state, node_cls, None))
760
775
  except NodeException as e:
761
- self._workflow_event_outer_queue.put(self._reject_workflow_event(e.error))
776
+ captured_traceback = traceback.format_exc()
777
+ self._workflow_event_outer_queue.put(self._reject_workflow_event(e.error, captured_traceback))
762
778
  return
763
779
  except WorkflowInitializationException as e:
764
- self._workflow_event_outer_queue.put(self._reject_workflow_event(e.error))
780
+ captured_traceback = traceback.format_exc()
781
+ self._workflow_event_outer_queue.put(self._reject_workflow_event(e.error, captured_traceback))
765
782
  return
766
783
  except Exception:
767
784
  err_message = f"An unexpected error occurred while initializing node {node_cls.__name__}"
768
785
  logger.exception(err_message)
786
+ captured_traceback = traceback.format_exc()
769
787
  self._workflow_event_outer_queue.put(
770
788
  self._reject_workflow_event(
771
789
  WorkflowError(code=WorkflowErrorCode.INTERNAL_ERROR, message=err_message),
790
+ captured_traceback,
772
791
  )
773
792
  )
774
793
  return
775
794
 
776
- rejection_error: Optional[WorkflowError] = None
795
+ rejection_event: Optional[NodeExecutionRejectedEvent] = None
777
796
 
778
797
  while True:
779
798
  if not self._active_nodes_by_execution_id:
@@ -784,9 +803,9 @@ class WorkflowRunner(Generic[StateType]):
784
803
  self._workflow_event_outer_queue.put(event)
785
804
 
786
805
  with execution_context(parent_context=current_parent, trace_id=self._execution_context.trace_id):
787
- rejection_error = self._handle_work_item_event(event)
806
+ rejection_event = self._handle_work_item_event(event)
788
807
 
789
- if rejection_error:
808
+ if rejection_event:
790
809
  break
791
810
 
792
811
  # Handle any remaining events
@@ -795,9 +814,9 @@ class WorkflowRunner(Generic[StateType]):
795
814
  self._workflow_event_outer_queue.put(event)
796
815
 
797
816
  with execution_context(parent_context=current_parent, trace_id=self._execution_context.trace_id):
798
- rejection_error = self._handle_work_item_event(event)
817
+ rejection_event = self._handle_work_item_event(event)
799
818
 
800
- if rejection_error:
819
+ if rejection_event:
801
820
  break
802
821
  except Empty:
803
822
  pass
@@ -817,8 +836,10 @@ class WorkflowRunner(Generic[StateType]):
817
836
  )
818
837
  return
819
838
 
820
- if rejection_error:
821
- self._workflow_event_outer_queue.put(self._reject_workflow_event(rejection_error))
839
+ if rejection_event:
840
+ self._workflow_event_outer_queue.put(
841
+ self._reject_workflow_event(rejection_event.error, rejection_event.body.traceback)
842
+ )
822
843
  return
823
844
 
824
845
  fulfilled_outputs = self.workflow.Outputs()
@@ -1,4 +1,5 @@
1
1
  from vellum.client.types.function_call import FunctionCall
2
+ from vellum.workflows.constants import undefined
2
3
 
3
4
 
4
5
  class StringValueWrapper(str):
@@ -71,7 +72,7 @@ class DictWrapper(dict):
71
72
  # several values as VellumValue objects, we use the "value" key to return itself
72
73
  return self
73
74
 
74
- raise AttributeError(f"dict has no key: '{attr}'")
75
+ return undefined
75
76
 
76
77
  item = super().__getitem__(attr)
77
78
  if not isinstance(item, DictWrapper) and not isinstance(item, ListWrapper):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: vellum-ai
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary:
5
5
  License: MIT
6
6
  Requires-Python: >=3.9,<4.0