veris-ai 1.12.2__tar.gz → 1.13.0__tar.gz

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 veris-ai might be problematic. Click here for more details.

Files changed (43) hide show
  1. {veris_ai-1.12.2 → veris_ai-1.13.0}/PKG-INFO +1 -1
  2. {veris_ai-1.12.2 → veris_ai-1.13.0}/pyproject.toml +1 -1
  3. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/agents_wrapper.py +13 -4
  4. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/api_client.py +18 -0
  5. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/tool_mock.py +195 -9
  6. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/utils.py +186 -0
  7. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_agents_wrapper_simple.py +15 -4
  8. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_mcp_protocol_server_mocked.py +8 -2
  9. veris_ai-1.13.0/tests/test_side_effects.py +950 -0
  10. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_tool_mock.py +24 -20
  11. {veris_ai-1.12.2 → veris_ai-1.13.0}/uv.lock +1 -1
  12. {veris_ai-1.12.2 → veris_ai-1.13.0}/.cursor/rules/documentation-management.mdc +0 -0
  13. {veris_ai-1.12.2 → veris_ai-1.13.0}/.github/workflows/release.yml +0 -0
  14. {veris_ai-1.12.2 → veris_ai-1.13.0}/.github/workflows/test.yml +0 -0
  15. {veris_ai-1.12.2 → veris_ai-1.13.0}/.gitignore +0 -0
  16. {veris_ai-1.12.2 → veris_ai-1.13.0}/.pre-commit-config.yaml +0 -0
  17. {veris_ai-1.12.2 → veris_ai-1.13.0}/CHANGELOG.md +0 -0
  18. {veris_ai-1.12.2 → veris_ai-1.13.0}/CLAUDE.md +0 -0
  19. {veris_ai-1.12.2 → veris_ai-1.13.0}/LICENSE +0 -0
  20. {veris_ai-1.12.2 → veris_ai-1.13.0}/README.md +0 -0
  21. {veris_ai-1.12.2 → veris_ai-1.13.0}/examples/README.md +0 -0
  22. {veris_ai-1.12.2 → veris_ai-1.13.0}/examples/__init__.py +0 -0
  23. {veris_ai-1.12.2 → veris_ai-1.13.0}/examples/import_options.py +0 -0
  24. {veris_ai-1.12.2 → veris_ai-1.13.0}/examples/openai_agents_example.py +0 -0
  25. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/README.md +0 -0
  26. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/__init__.py +0 -0
  27. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/jaeger_interface/README.md +0 -0
  28. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/jaeger_interface/__init__.py +0 -0
  29. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/jaeger_interface/client.py +0 -0
  30. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/jaeger_interface/models.py +0 -0
  31. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/models.py +0 -0
  32. {veris_ai-1.12.2 → veris_ai-1.13.0}/src/veris_ai/observability.py +0 -0
  33. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/README.md +0 -0
  34. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/__init__.py +0 -0
  35. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/conftest.py +0 -0
  36. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/fixtures/__init__.py +0 -0
  37. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/fixtures/http_server.py +0 -0
  38. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/fixtures/simple_app.py +0 -0
  39. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_agents_wrapper_extract.py +0 -0
  40. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_helpers.py +0 -0
  41. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_token_decoding.py +0 -0
  42. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_utils.py +0 -0
  43. {veris_ai-1.12.2 → veris_ai-1.13.0}/tests/test_veris_runner_tool_options.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veris-ai
3
- Version: 1.12.2
3
+ Version: 1.13.0
4
4
  Summary: A Python package for Veris AI tools
5
5
  Project-URL: Homepage, https://github.com/veris-ai/veris-python-sdk
6
6
  Project-URL: Bug Tracker, https://github.com/veris-ai/veris-python-sdk/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "veris-ai"
7
- version = "1.12.2"
7
+ version = "1.13.0"
8
8
  description = "A Python package for Veris AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,5 +1,6 @@
1
1
  """OpenAI Agents wrapper for automatic tool mocking via Veris SDK."""
2
2
 
3
+ import inspect
3
4
  import json
4
5
  import logging
5
6
  from collections.abc import Callable
@@ -9,7 +10,7 @@ from agents import RunContextWrapper, RunResult, Runner as OpenAIRunner
9
10
  from pydantic import BaseModel
10
11
 
11
12
  from veris_ai import veris
12
- from veris_ai.tool_mock import mock_tool_call
13
+ from veris_ai.tool_mock import mock_tool_call, mock_tool_call_async
13
14
  from veris_ai.models import ToolCallOptions
14
15
 
15
16
  logger = logging.getLogger(__name__)
@@ -86,7 +87,17 @@ def _wrap(
86
87
  thread_id = veris.thread_id
87
88
  the_func = tool_functions.get(tool_id)
88
89
  if the_func and session_id:
89
- # mock_tool_call is synchronous, don't await it
90
+ # Check if async or sync, call appropriate version
91
+ if inspect.iscoroutinefunction(the_func):
92
+ # Use async version (non-blocking)
93
+ return await mock_tool_call_async(
94
+ the_func,
95
+ session_id,
96
+ json.loads(parameters),
97
+ tool_options.get(tool_name_inner),
98
+ thread_id=thread_id,
99
+ )
100
+ # Use sync version for sync functions
90
101
  return mock_tool_call(
91
102
  the_func,
92
103
  session_id,
@@ -191,8 +202,6 @@ def _find_user_function_in_closure(closure: tuple) -> Callable | None:
191
202
  Returns:
192
203
  The user function if found, None otherwise
193
204
  """
194
- import inspect
195
-
196
205
  # List of module prefixes that indicate library/framework code
197
206
  library_modules = ("json", "inspect", "agents", "pydantic", "openai", "typing")
198
207
 
@@ -60,6 +60,24 @@ class SimulatorAPIClient:
60
60
  response.raise_for_status()
61
61
  return response.json() if response.content else None
62
62
 
63
+ async def post_async(self, endpoint: str, payload: dict[str, Any]) -> Any: # noqa: ANN401
64
+ """Make an asynchronous POST request to the specified endpoint.
65
+
66
+ This method uses httpx.AsyncClient and is safe to call from async functions
67
+ without blocking the event loop.
68
+ """
69
+ headers = self._build_headers()
70
+ # Validate endpoint URL; raise ConnectError for non-absolute URLs to
71
+ # mirror connection failures in tests when base URL is intentionally invalid.
72
+ if not endpoint.startswith(("http://", "https://")):
73
+ error_msg = f"Invalid endpoint URL (not absolute): {endpoint}"
74
+ raise httpx.ConnectError(error_msg)
75
+
76
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
77
+ response = await client.post(endpoint, json=payload, headers=headers)
78
+ response.raise_for_status()
79
+ return response.json() if response.content else None
80
+
63
81
  @property
64
82
  def tool_mock_endpoint(self) -> str:
65
83
  """Get the tool mock endpoint URL."""
@@ -17,7 +17,14 @@ from typing import (
17
17
 
18
18
  from veris_ai.models import ResponseExpectation, ToolCallOptions
19
19
  from veris_ai.api_client import get_api_client
20
- from veris_ai.utils import convert_to_type, extract_json_schema, get_function_parameters
20
+ from veris_ai.utils import (
21
+ convert_to_type,
22
+ execute_callback,
23
+ extract_json_schema,
24
+ get_function_parameters,
25
+ get_input_parameters,
26
+ launch_callback_task,
27
+ )
21
28
 
22
29
  logger = logging.getLogger(__name__)
23
30
 
@@ -206,14 +213,14 @@ class VerisSDK:
206
213
  return await func(*args, **kwargs)
207
214
  parameters = get_function_parameters(func, args, kwargs)
208
215
  logger.info(f"Spying on function: {func.__name__}")
209
- log_tool_call(
216
+ await log_tool_call_async(
210
217
  session_id=session_id,
211
218
  function_name=func.__name__,
212
219
  parameters=parameters,
213
220
  docstring=inspect.getdoc(func) or "",
214
221
  )
215
222
  result = await func(*args, **kwargs)
216
- log_tool_response(session_id=session_id, response=result)
223
+ await log_tool_response_async(session_id=session_id, response=result)
217
224
  return result
218
225
 
219
226
  @wraps(func)
@@ -243,8 +250,18 @@ class VerisSDK:
243
250
  mode: Literal["tool", "function"] = "tool",
244
251
  expects_response: bool | None = None,
245
252
  cache_response: bool | None = None,
253
+ input_callback: Callable[..., Any] | None = None,
254
+ output_callback: Callable[[Any], Any] | None = None,
246
255
  ) -> Callable:
247
- """Decorator for mocking tool calls."""
256
+ """Decorator for mocking tool calls.
257
+
258
+ Args:
259
+ mode: Whether to treat the function as a tool or function
260
+ expects_response: Whether the function expects a response
261
+ cache_response: Whether to cache the response
262
+ input_callback: Callable that receives input parameters as individual arguments
263
+ output_callback: Callable that receives the output value
264
+ """
248
265
  response_expectation = (
249
266
  ResponseExpectation.NONE
250
267
  if (expects_response is False or (expects_response is None and mode == "function"))
@@ -273,9 +290,11 @@ class VerisSDK:
273
290
  f"No session ID found, executing original function: {func.__name__}"
274
291
  )
275
292
  return await func(*args, **kwargs)
293
+
294
+ # Perform the mock call first
276
295
  parameters = get_function_parameters(func, args, kwargs)
277
296
  thread_id = _thread_id_context.get()
278
- return mock_tool_call(
297
+ result = await mock_tool_call_async(
279
298
  func,
280
299
  session_id,
281
300
  parameters,
@@ -283,6 +302,13 @@ class VerisSDK:
283
302
  thread_id,
284
303
  )
285
304
 
305
+ # Launch callbacks as background tasks (non-blocking)
306
+ input_params = get_input_parameters(func, args, kwargs)
307
+ launch_callback_task(input_callback, input_params, unpack=True)
308
+ launch_callback_task(output_callback, result, unpack=False)
309
+
310
+ return result
311
+
286
312
  @wraps(func)
287
313
  def sync_wrapper(
288
314
  *args: tuple[object, ...],
@@ -295,9 +321,11 @@ class VerisSDK:
295
321
  f"No session ID found, executing original function: {func.__name__}"
296
322
  )
297
323
  return func(*args, **kwargs)
324
+
325
+ # Perform the mock call first
298
326
  parameters = get_function_parameters(func, args, kwargs)
299
327
  thread_id = _thread_id_context.get()
300
- return mock_tool_call(
328
+ result = mock_tool_call(
301
329
  func,
302
330
  session_id,
303
331
  parameters,
@@ -305,13 +333,31 @@ class VerisSDK:
305
333
  thread_id,
306
334
  )
307
335
 
336
+ # Execute callbacks synchronously (can't use async tasks in sync context)
337
+ input_params = get_input_parameters(func, args, kwargs)
338
+ execute_callback(input_callback, input_params, unpack=True)
339
+ execute_callback(output_callback, result, unpack=False)
340
+
341
+ return result
342
+
308
343
  # Return the appropriate wrapper based on whether the function is async
309
344
  return async_wrapper if is_async else sync_wrapper
310
345
 
311
346
  return decorator
312
347
 
313
- def stub(self, return_value: Any) -> Callable: # noqa: ANN401
314
- """Decorator for stubbing toolw calls."""
348
+ def stub(
349
+ self,
350
+ return_value: Any, # noqa: ANN401
351
+ input_callback: Callable[..., Any] | None = None,
352
+ output_callback: Callable[[Any], Any] | None = None,
353
+ ) -> Callable:
354
+ """Decorator for stubbing tool calls.
355
+
356
+ Args:
357
+ return_value: The value to return when the function is stubbed
358
+ input_callback: Callable that receives input parameters as individual arguments
359
+ output_callback: Callable that receives the output value
360
+ """
315
361
 
316
362
  def decorator(func: Callable) -> Callable:
317
363
  # Check if the original function is async
@@ -327,7 +373,14 @@ class VerisSDK:
327
373
  f"No session ID found, executing original function: {func.__name__}"
328
374
  )
329
375
  return await func(*args, **kwargs)
376
+
330
377
  logger.info(f"Stubbing function: {func.__name__}")
378
+
379
+ # Launch callbacks as background tasks (non-blocking)
380
+ input_params = get_input_parameters(func, args, kwargs)
381
+ launch_callback_task(input_callback, input_params, unpack=True)
382
+ launch_callback_task(output_callback, return_value, unpack=False)
383
+
331
384
  return return_value
332
385
 
333
386
  @wraps(func)
@@ -337,7 +390,14 @@ class VerisSDK:
337
390
  f"No session ID found, executing original function: {func.__name__}"
338
391
  )
339
392
  return func(*args, **kwargs)
393
+
340
394
  logger.info(f"Stubbing function: {func.__name__}")
395
+
396
+ # Execute callbacks synchronously (can't use async tasks in sync context)
397
+ input_params = get_input_parameters(func, args, kwargs)
398
+ execute_callback(input_callback, input_params, unpack=True)
399
+ execute_callback(output_callback, return_value, unpack=False)
400
+
341
401
  return return_value
342
402
 
343
403
  # Return the appropriate wrapper based on whether the function is async
@@ -358,7 +418,7 @@ def mock_tool_call(
358
418
  options: ToolCallOptions | None = None,
359
419
  thread_id: str | None = None,
360
420
  ) -> object:
361
- """Mock tool call.
421
+ """Mock tool call (synchronous).
362
422
 
363
423
  Args:
364
424
  func: Function being mocked
@@ -424,6 +484,84 @@ def mock_tool_call(
424
484
  return convert_to_type(mock_result, return_type_obj)
425
485
 
426
486
 
487
+ @tenacity.retry(
488
+ stop=tenacity.stop_after_attempt(3),
489
+ wait=tenacity.wait_exponential(multiplier=1, min=4, max=10),
490
+ reraise=True,
491
+ )
492
+ async def mock_tool_call_async(
493
+ func: Callable,
494
+ session_id: str, # noqa: ARG001
495
+ parameters: dict[str, dict[str, str]],
496
+ options: ToolCallOptions | None = None,
497
+ thread_id: str | None = None,
498
+ ) -> object:
499
+ """Mock tool call (asynchronous).
500
+
501
+ Args:
502
+ func: Function being mocked
503
+ session_id: Session ID (kept for backwards compatibility, not used)
504
+ parameters: Function parameters
505
+ options: Tool call options
506
+ thread_id: Thread ID to use as session_id in API request (required)
507
+
508
+ Raises:
509
+ ValueError: If thread_id is not provided
510
+ """
511
+ if thread_id is None:
512
+ raise ValueError(
513
+ "thread_id is required for mocking. "
514
+ "Use parse_token() to set both session_id and thread_id."
515
+ )
516
+
517
+ options = options or ToolCallOptions()
518
+ api_client = get_api_client()
519
+ endpoint = api_client.tool_mock_endpoint
520
+
521
+ logger.info(f"Simulating function: {func.__name__}")
522
+
523
+ type_hints = get_type_hints(func)
524
+
525
+ # Extract return type object (not just the name)
526
+ return_type_obj = type_hints.pop("return", Any)
527
+ # Get function docstring
528
+ docstring = inspect.getdoc(func) or ""
529
+
530
+ # Use thread_id as session_id in the payload
531
+ payload_session_id = thread_id
532
+ # Clean up parameters for V3 - just send values, not the nested dict
533
+ clean_params: dict[str, Any] = {}
534
+ for key, value in parameters.items():
535
+ if isinstance(value, dict) and "value" in value:
536
+ # Extract just the value from the nested structure
537
+ clean_params[key] = value["value"]
538
+ else:
539
+ # Already clean or unexpected format
540
+ clean_params[key] = value
541
+
542
+ # Determine response expectation
543
+ payload = {
544
+ "session_id": payload_session_id,
545
+ "response_expectation": options.response_expectation.value,
546
+ "cache_response": bool(options.cache_response),
547
+ "tool_call": {
548
+ "function_name": func.__name__,
549
+ "parameters": clean_params,
550
+ "return_type": json.dumps(extract_json_schema(return_type_obj)),
551
+ "docstring": docstring,
552
+ },
553
+ }
554
+
555
+ mock_result = await api_client.post_async(endpoint, payload)
556
+ logger.info(f"Mock response: {mock_result}")
557
+
558
+ if isinstance(mock_result, str):
559
+ with suppress(json.JSONDecodeError):
560
+ mock_result = json.loads(mock_result)
561
+ return convert_to_type(mock_result, return_type_obj)
562
+ return convert_to_type(mock_result, return_type_obj)
563
+
564
+
427
565
  def log_tool_call(
428
566
  session_id: str,
429
567
  function_name: str,
@@ -456,6 +594,38 @@ def log_tool_call(
456
594
  logger.warning(f"Failed to log tool call for {function_name}: {e}")
457
595
 
458
596
 
597
+ async def log_tool_call_async(
598
+ session_id: str,
599
+ function_name: str,
600
+ parameters: dict[str, dict[str, str]],
601
+ docstring: str,
602
+ ) -> None:
603
+ """Log tool call asynchronously to the VERIS logging endpoint."""
604
+ api_client = get_api_client()
605
+ endpoint = api_client.get_log_tool_call_endpoint(session_id)
606
+
607
+ # Clean up parameters for V3 - just send values, not the nested dict
608
+ clean_params: dict[str, Any] = {}
609
+ for key, value in parameters.items():
610
+ if isinstance(value, dict) and "value" in value:
611
+ # Extract just the value from the nested structure
612
+ clean_params[key] = value["value"]
613
+ else:
614
+ # Already clean or unexpected format
615
+ clean_params[key] = value
616
+
617
+ payload = {
618
+ "function_name": function_name,
619
+ "parameters": clean_params,
620
+ "docstring": docstring,
621
+ }
622
+ try:
623
+ await api_client.post_async(endpoint, payload)
624
+ logger.debug(f"Tool call logged for {function_name}")
625
+ except Exception as e:
626
+ logger.warning(f"Failed to log tool call for {function_name}: {e}")
627
+
628
+
459
629
  def log_tool_response(session_id: str, response: Any) -> None: # noqa: ANN401
460
630
  """Log tool response synchronously to the VERIS logging endpoint."""
461
631
  api_client = get_api_client()
@@ -472,4 +642,20 @@ def log_tool_response(session_id: str, response: Any) -> None: # noqa: ANN401
472
642
  logger.warning(f"Failed to log tool response: {e}")
473
643
 
474
644
 
645
+ async def log_tool_response_async(session_id: str, response: Any) -> None: # noqa: ANN401
646
+ """Log tool response asynchronously to the VERIS logging endpoint."""
647
+ api_client = get_api_client()
648
+ endpoint = api_client.get_log_tool_response_endpoint(session_id)
649
+
650
+ payload = {
651
+ "response": json.dumps(response, default=str),
652
+ }
653
+
654
+ try:
655
+ await api_client.post_async(endpoint, payload)
656
+ logger.debug("Tool response logged")
657
+ except Exception as e:
658
+ logger.warning(f"Failed to log tool response: {e}")
659
+
660
+
475
661
  veris = VerisSDK()
@@ -1,4 +1,6 @@
1
+ import asyncio
1
2
  import inspect
3
+ import logging
2
4
  import sys
3
5
  import types
4
6
  import typing
@@ -18,6 +20,8 @@ from collections.abc import Callable
18
20
 
19
21
  from pydantic import BaseModel
20
22
 
23
+ logger = logging.getLogger(__name__)
24
+
21
25
 
22
26
  def convert_to_type(value: object, target_type: type) -> object:
23
27
  """Convert a value to the specified type."""
@@ -303,3 +307,185 @@ def get_function_parameters(
303
307
  "type": str(get_type_hints(func).get(param_name, Any)),
304
308
  }
305
309
  return params_info
310
+
311
+
312
+ # Callback utility functions
313
+
314
+
315
+ def get_input_parameters(func: Callable, args: tuple, kwargs: dict) -> dict[str, Any]:
316
+ """Get the actual input parameters for callbacks.
317
+
318
+ Args:
319
+ func: The function being called
320
+ args: Positional arguments
321
+ kwargs: Keyword arguments
322
+
323
+ Returns:
324
+ Dictionary of parameter names to their actual values (not stringified),
325
+ excluding self and cls parameters. Preserves ctx if present.
326
+ """
327
+ sig = inspect.signature(func)
328
+ bound_args = sig.bind(*args, **kwargs)
329
+ bound_args.apply_defaults()
330
+
331
+ # Remove only self and cls - preserve ctx and all other parameters
332
+ params = dict(bound_args.arguments)
333
+ params.pop("self", None)
334
+ params.pop("cls", None)
335
+
336
+ return params
337
+
338
+
339
+ def filter_callback_parameters(callback: Callable, params: dict[str, Any]) -> dict[str, Any]:
340
+ """Filter parameters to match what the callback can accept.
341
+
342
+ Args:
343
+ callback: The callback function to inspect
344
+ params: Dictionary of all available parameters
345
+
346
+ Returns:
347
+ Filtered dictionary containing only parameters the callback accepts
348
+ """
349
+ try:
350
+ sig = inspect.signature(callback)
351
+
352
+ # Check if callback accepts **kwargs (VAR_KEYWORD parameter)
353
+ has_var_keyword = any(
354
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
355
+ )
356
+
357
+ # If callback accepts **kwargs, pass all parameters
358
+ if has_var_keyword:
359
+ return params
360
+
361
+ # Otherwise, filter to only include parameters the callback accepts
362
+ accepted_params = {}
363
+ for param_name in sig.parameters:
364
+ if param_name in params:
365
+ accepted_params[param_name] = params[param_name]
366
+
367
+ return accepted_params
368
+ except (ValueError, TypeError):
369
+ # If we can't inspect the signature, pass all parameters
370
+ # and let the callback handle it (will fail if incompatible)
371
+ return params
372
+
373
+
374
+ def execute_callback(
375
+ callback: Callable | None,
376
+ data: Any, # noqa: ANN401
377
+ unpack: bool = False,
378
+ ) -> None:
379
+ """Execute a callback synchronously if provided.
380
+
381
+ Args:
382
+ callback: The callback callable to execute
383
+ data: The data to pass to the callback
384
+ unpack: If True and data is a dict, unpack it as keyword arguments
385
+
386
+ Note:
387
+ Exceptions in callbacks are caught and logged to prevent breaking the main flow.
388
+ """
389
+ if callback is None:
390
+ return
391
+
392
+ try:
393
+ if unpack and isinstance(data, dict):
394
+ # Filter parameters to match callback signature
395
+ filtered_data = filter_callback_parameters(callback, data)
396
+ callback(**filtered_data)
397
+ else:
398
+ callback(data)
399
+ except Exception as e:
400
+ logger.warning(f"Callback execution failed: {e}", exc_info=True)
401
+
402
+
403
+ async def execute_callback_async(
404
+ callback: Callable | None,
405
+ data: Any, # noqa: ANN401
406
+ unpack: bool = False,
407
+ ) -> None:
408
+ """Execute a callback asynchronously if provided.
409
+
410
+ Handles both sync and async callback callables.
411
+
412
+ Args:
413
+ callback: The callback callable to execute (can be sync or async)
414
+ data: The data to pass to the callback
415
+ unpack: If True and data is a dict, unpack it as keyword arguments
416
+
417
+ Note:
418
+ Exceptions in callbacks are caught and logged to prevent breaking the main flow.
419
+ """
420
+ if callback is None:
421
+ return
422
+
423
+ try:
424
+ if inspect.iscoroutinefunction(callback):
425
+ if unpack and isinstance(data, dict):
426
+ # Filter parameters to match callback signature
427
+ filtered_data = filter_callback_parameters(callback, data)
428
+ await callback(**filtered_data)
429
+ else:
430
+ await callback(data)
431
+ else:
432
+ if unpack and isinstance(data, dict):
433
+ # Filter parameters to match callback signature
434
+ filtered_data = filter_callback_parameters(callback, data)
435
+ result = callback(**filtered_data)
436
+ else:
437
+ result = callback(data)
438
+ # If the result is a coroutine (can happen with functools.partial), await it
439
+ if inspect.iscoroutine(result):
440
+ await result
441
+ except Exception as e:
442
+ logger.warning(f"Callback execution failed: {e}", exc_info=True)
443
+
444
+
445
+ def launch_callback_task(
446
+ callback: Callable | None,
447
+ data: Any, # noqa: ANN401
448
+ unpack: bool = False,
449
+ ) -> None:
450
+ """Launch a callback as a background task (fire-and-forget).
451
+
452
+ Args:
453
+ callback: The callback callable to execute (can be sync or async)
454
+ data: The data to pass to the callback
455
+ unpack: If True and data is a dict, unpack it as keyword arguments
456
+
457
+ Note:
458
+ This launches the callback without blocking. Errors are logged but won't
459
+ affect the main execution flow.
460
+ """
461
+ if callback is None:
462
+ return
463
+
464
+ async def _run_callback() -> None:
465
+ """Wrapper to run callback with error handling."""
466
+ try:
467
+ if inspect.iscoroutinefunction(callback):
468
+ if unpack and isinstance(data, dict):
469
+ # Filter parameters to match callback signature
470
+ filtered_data = filter_callback_parameters(callback, data)
471
+ await callback(**filtered_data)
472
+ else:
473
+ await callback(data)
474
+ else:
475
+ if unpack and isinstance(data, dict):
476
+ # Filter parameters to match callback signature
477
+ filtered_data = filter_callback_parameters(callback, data)
478
+ result = callback(**filtered_data)
479
+ else:
480
+ result = callback(data)
481
+ if inspect.iscoroutine(result):
482
+ await result
483
+ except Exception as e:
484
+ logger.warning(f"Callback execution failed: {e}", exc_info=True)
485
+
486
+ # Create task without awaiting (fire-and-forget)
487
+ try:
488
+ asyncio.create_task(_run_callback())
489
+ except RuntimeError:
490
+ # If no event loop is running, log a warning
491
+ logger.warning("Cannot launch callback task: no event loop running")
@@ -87,18 +87,29 @@ def mock_veris_endpoint():
87
87
  """Fixture that mocks the veris SDK's HTTP request method."""
88
88
  calls = []
89
89
 
90
- def mock_request(endpoint, payload):
91
- """Mock the post_sync method."""
90
+ async def mock_request_async(endpoint, payload):
91
+ """Mock the post_async method."""
92
92
  # Record the call
93
93
  calls.append({"endpoint": endpoint, "payload": payload})
94
94
 
95
95
  # Return a distinctive mocked value
96
96
  return {"result": 999}
97
97
 
98
- # Patch the post_sync method of the API client
98
+ def mock_request_sync(endpoint, payload):
99
+ """Mock the post method."""
100
+ # Record the call
101
+ calls.append({"endpoint": endpoint, "payload": payload})
102
+
103
+ # Return a distinctive mocked value
104
+ return {"result": 999}
105
+
106
+ # Patch both sync and async methods of the API client
99
107
  from veris_ai.api_client import get_api_client
100
108
 
101
- with patch.object(get_api_client(), "post_sync", side_effect=mock_request):
109
+ with (
110
+ patch.object(get_api_client(), "post", side_effect=mock_request_sync),
111
+ patch.object(get_api_client(), "post_async", side_effect=mock_request_async),
112
+ ):
102
113
  yield {"calls": calls}
103
114
 
104
115
 
@@ -62,10 +62,16 @@ def run_server_with_mock(server_port: int) -> None: # noqa: C901
62
62
  # Return error response if something goes wrong
63
63
  return {"error": str(e)}
64
64
 
65
- # Patch the API client's post method
65
+ # Patch the API client's post methods (both sync and async)
66
66
  from veris_ai.api_client import get_api_client
67
67
 
68
- with patch.object(get_api_client(), "post", side_effect=mock_post):
68
+ async def mock_post_async(endpoint, payload):
69
+ return mock_post(endpoint, payload)
70
+
71
+ with (
72
+ patch.object(get_api_client(), "post", side_effect=mock_post),
73
+ patch.object(get_api_client(), "post_async", side_effect=mock_post_async),
74
+ ):
69
75
  # Configure the server
70
76
  fastapi = make_simple_fastapi_app()
71
77
  veris.set_fastapi_mcp(