flow-forge-ai-sdk 0.1.0__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 (51) hide show
  1. flow_forge_ai/__init__.py +0 -0
  2. flow_forge_ai/config/__init__.py +0 -0
  3. flow_forge_ai/config/config_handler.py +97 -0
  4. flow_forge_ai/config/models.py +66 -0
  5. flow_forge_ai/context.py +121 -0
  6. flow_forge_ai/emitter.py +33 -0
  7. flow_forge_ai/instrumentation/__init__.py +24 -0
  8. flow_forge_ai/instrumentation/base.py +84 -0
  9. flow_forge_ai/instrumentation/httpx_instr.py +140 -0
  10. flow_forge_ai/instrumentation/models/__init__.py +0 -0
  11. flow_forge_ai/instrumentation/models/llm_payloads.py +76 -0
  12. flow_forge_ai/instrumentation/models/tool_payloads.py +85 -0
  13. flow_forge_ai/instrumentation/ollama_instr.py +258 -0
  14. flow_forge_ai/instrumentation/openai_instr.py +271 -0
  15. flow_forge_ai/instrumentation/requests_instr.py +139 -0
  16. flow_forge_ai/instrumentation/trace_tool.py +82 -0
  17. flow_forge_ai/instrumentation/utils.py +26 -0
  18. flow_forge_ai/instrumentation/workflow.py +39 -0
  19. flow_forge_ai/internal_logging/__init__.py +0 -0
  20. flow_forge_ai/internal_logging/logger.py +279 -0
  21. flow_forge_ai/internal_logging/logging_handler.py +60 -0
  22. flow_forge_ai/py.typed +0 -0
  23. flow_forge_ai/replay.py +166 -0
  24. flow_forge_ai/runtime.py +424 -0
  25. flow_forge_ai/sinks/__init__.py +24 -0
  26. flow_forge_ai/sinks/base.py +13 -0
  27. flow_forge_ai/sinks/composite_sink.py +34 -0
  28. flow_forge_ai/sinks/console_sink.py +33 -0
  29. flow_forge_ai/sinks/database_sink.py +166 -0
  30. flow_forge_ai/sinks/file_sink.py +29 -0
  31. flow_forge_ai/sinks/handlers/__init__.py +24 -0
  32. flow_forge_ai/sinks/handlers/jsonl_handler.py +113 -0
  33. flow_forge_ai/sinks/handlers/mongodb_handler.py +248 -0
  34. flow_forge_ai/sinks/handlers/mysql_handler.py +274 -0
  35. flow_forge_ai/sinks/handlers/postgres_handler.py +284 -0
  36. flow_forge_ai/sinks/handlers/resource_handler.py +154 -0
  37. flow_forge_ai/sinks/handlers/sqlite_handler.py +247 -0
  38. flow_forge_ai/sinks/log_sink.py +17 -0
  39. flow_forge_ai/sinks/memory_sink.py +26 -0
  40. flow_forge_ai/sinks/models/__init__.py +0 -0
  41. flow_forge_ai/sinks/models/event.py +63 -0
  42. flow_forge_ai/sinks/models/run.py +31 -0
  43. flow_forge_ai/sinks/models/step.py +31 -0
  44. flow_forge_ai/sinks/sink_router.py +34 -0
  45. flow_forge_ai/utils/__init__.py +0 -0
  46. flow_forge_ai/utils/decorators.py +12 -0
  47. flow_forge_ai/utils/toml.py +9 -0
  48. flow_forge_ai_sdk-0.1.0.dist-info/METADATA +307 -0
  49. flow_forge_ai_sdk-0.1.0.dist-info/RECORD +51 -0
  50. flow_forge_ai_sdk-0.1.0.dist-info/WHEEL +5 -0
  51. flow_forge_ai_sdk-0.1.0.dist-info/top_level.txt +1 -0
File without changes
File without changes
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ import tomllib
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from flow_forge_ai.config.models import Config, InstrumentorConfig, RuntimeListenerConfig, SinkConfig
10
+ from flow_forge_ai.internal_logging.logger import get_logger
11
+ from flow_forge_ai.utils.toml import remove_none_values
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class ConfigHandler:
17
+ """Manages configuration for sinks and instrumentation settings."""
18
+
19
+ def __init__(self) -> None:
20
+ self.__data: Config = Config()
21
+
22
+ def _normalize(self, config: Config) -> None:
23
+ for sink in config.sinks:
24
+ if sink.options is not None:
25
+ for key, value in sink.options.items():
26
+ if isinstance(value, str) and value.startswith("env:"):
27
+ env_key = value[4:]
28
+ sink.options[key] = os.getenv(env_key)
29
+
30
+ def load_from_file(self, file_path: str) -> None:
31
+ """Load configuration from a TOML file."""
32
+ try:
33
+ with open(file_path, "rb") as f:
34
+ data = tomllib.load(f)
35
+ self.__data = Config(**data)
36
+ self._normalize(self.__data)
37
+ logger.info(f"Configuration loaded from {file_path}")
38
+ except FileNotFoundError:
39
+ logger.warning(f"Configuration file {file_path} not found. Using default settings.")
40
+ except Exception as e:
41
+ logger.error(f"Failed to load configuration from {file_path}: {e}")
42
+ raise
43
+
44
+ def list_instrumentors(self) -> list[InstrumentorConfig]:
45
+ """Get configuration for all instrumentors."""
46
+ return self.__data.instrumentors
47
+
48
+ def get_sink(self, sink_name: str) -> SinkConfig:
49
+ """Get configuration for a specific sink."""
50
+ sink = next((sink for sink in self.__data.sinks if sink.name == sink_name), None)
51
+ if not sink:
52
+ raise KeyError(f"Sink '{sink_name}' not found in configuration.")
53
+ return sink
54
+
55
+ def get_runtime_sink(self) -> SinkConfig:
56
+ """Get configuration for the runtime sink."""
57
+ if not self.__data.runtime or not self.__data.runtime.source_sink:
58
+ raise KeyError("Runtime source_sink is not configured.")
59
+ return self.get_sink(self.__data.runtime.source_sink)
60
+
61
+ def get_runtime_config(self) -> RuntimeListenerConfig:
62
+ """Return the :class:`~flow_forge_ai.config.models.RuntimeConfig`."""
63
+ return self.__data.runtime
64
+
65
+ def list_sinks(self) -> list[SinkConfig]:
66
+ """Get configuration for all sinks."""
67
+ return self.__data.sinks
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ """Return configuration as dictionary."""
71
+ res: dict[str, Any] = remove_none_values(self.__data.to_dict())
72
+ return res
73
+
74
+
75
+ @lru_cache
76
+ def get_config_handler(path: Optional[str] = None) -> ConfigHandler:
77
+ """
78
+ Load configuration from TOML file.
79
+
80
+ Parameters
81
+ ----------
82
+ path : Optional[str]
83
+ Path to the configuration file. If None, defaults to 'config.toml' in the current working directory.
84
+
85
+ Returns
86
+ -------
87
+ ConfigHandler
88
+ Configuration object with loaded values.
89
+ """
90
+ config_handler = ConfigHandler()
91
+ cfg_path = Path.cwd() / "config.toml" if path is None else Path(path)
92
+ if not cfg_path.exists():
93
+ # No default config found, return empty config
94
+ return config_handler
95
+ config_path = str(cfg_path)
96
+ config_handler.load_from_file(config_path)
97
+ return config_handler
@@ -0,0 +1,66 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, List, Optional
3
+
4
+
5
+ @dataclass
6
+ class InstrumentorConfig:
7
+ """Configuration for a single instrumentor."""
8
+ class_path: str
9
+ options: Optional[dict[str, Any]] = field(default_factory=dict)
10
+
11
+ def to_dict(self) -> dict[str, Any]:
12
+ return {
13
+ "class_path": self.class_path,
14
+ "options": self.options.copy() if self.options else None
15
+ }
16
+
17
+ @dataclass
18
+ class SinkConfig:
19
+ """Configuration for a single sink."""
20
+ name: str
21
+ class_path: str
22
+ options: Optional[dict[str, Any]] = field(default_factory=dict)
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return {
26
+ "name": self.name,
27
+ "class_path": self.class_path,
28
+ "options": self.options.copy() if self.options else None
29
+ }
30
+
31
+ @dataclass
32
+ class RuntimeListenerConfig:
33
+ """Configuration for runtime listener."""
34
+ enabled: bool = False
35
+ source_sink: Optional[str] = None
36
+ listener_host: Optional[str] = None
37
+ listener_port: Optional[int] = None
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "enabled": self.enabled,
42
+ "source_sink": self.source_sink,
43
+ "listener_host": self.listener_host,
44
+ "listener_port": self.listener_port,
45
+ }
46
+
47
+ @dataclass
48
+ class Config:
49
+ """Configuration for AI execution infrastructure."""
50
+ instrumentors: List[InstrumentorConfig] = field(default_factory=list)
51
+ sinks: List[SinkConfig] = field(default_factory=list)
52
+ runtime: RuntimeListenerConfig = field(default_factory=RuntimeListenerConfig)
53
+
54
+ def __post_init__(self) -> None:
55
+ self.instrumentors = [InstrumentorConfig(**instr) if isinstance(instr, dict) else instr for instr in self.instrumentors]
56
+ self.sinks = [SinkConfig(**sink) if isinstance(sink, dict) else sink for sink in self.sinks]
57
+ if isinstance(self.runtime, dict):
58
+ self.runtime = RuntimeListenerConfig(**self.runtime) # pylint: disable=not-a-mapping
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ """Convert Config dataclass to dictionary."""
62
+ return {
63
+ "instrumentors": [instr.to_dict() for instr in self.instrumentors],
64
+ "sinks": [sink.to_dict() for sink in self.sinks],
65
+ "runtime": self.runtime.to_dict() if self.runtime else None,
66
+ }
@@ -0,0 +1,121 @@
1
+ import uuid
2
+ import contextvars
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional
5
+
6
+
7
+ _workflow_id: contextvars.ContextVar[str] = contextvars.ContextVar("workflow_id", default="default_workflow")
8
+ _run_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("run_id", default=None)
9
+ _trace_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("trace_id", default=None)
10
+ _step_id: contextvars.ContextVar[int] = contextvars.ContextVar("step_id", default=0)
11
+ _step_id_alias: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("step_id_alias", default=None)
12
+ _span_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("span_id", default=None)
13
+
14
+ def get_workflow_id() -> str:
15
+ return _workflow_id.get()
16
+
17
+ def get_run_id() -> str:
18
+ return _run_id.get() or f"run_{uuid.uuid4()}"
19
+
20
+ def get_trace_id() -> str:
21
+ return _trace_id.get() or f"trace_{uuid.uuid4()}"
22
+
23
+ def get_step_id() -> str:
24
+ return _step_id_alias.get() or f"step_{_step_id.get()}"
25
+
26
+ def get_span_id() -> str:
27
+ return _span_id.get() or f"span_{uuid.uuid4()}"
28
+
29
+ def set_workflow_id(value: str) -> contextvars.Token:
30
+ return _workflow_id.set(value)
31
+
32
+ def set_run_id(value: str) -> contextvars.Token:
33
+ return _run_id.set(value)
34
+
35
+ def set_trace_id(value: str) -> contextvars.Token:
36
+ return _trace_id.set(value)
37
+
38
+ def set_step_id_alias(value: str) -> contextvars.Token:
39
+ return _step_id_alias.set(value)
40
+
41
+ def increment_step() -> int:
42
+ next_step = _step_id.get() + 1
43
+ _step_id.set(next_step)
44
+ return next_step
45
+
46
+ def set_span_id(value: str) -> contextvars.Token:
47
+ return _span_id.set(value)
48
+
49
+ def reset_workflow_id(token: contextvars.Token) -> None:
50
+ _workflow_id.reset(token)
51
+
52
+ def reset_run_id(token: contextvars.Token) -> None:
53
+ _run_id.reset(token)
54
+
55
+ def reset_trace_id(token: contextvars.Token) -> None:
56
+ _trace_id.reset(token)
57
+
58
+ def reset_step_id_alias(token: contextvars.Token) -> None:
59
+ _step_id_alias.reset(token)
60
+
61
+ def reset_step_id() -> None:
62
+ _step_id.set(0)
63
+
64
+ def reset_span_id(token: contextvars.Token) -> None:
65
+ _span_id.reset(token)
66
+
67
+
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ContextSnapshot:
72
+ run_id: Optional[str]
73
+ trace_id: Optional[str]
74
+ span_id: Optional[str]
75
+
76
+ @staticmethod
77
+ def capture() -> "ContextSnapshot":
78
+ return ContextSnapshot(
79
+ run_id=_run_id.get(),
80
+ trace_id=_trace_id.get(),
81
+ span_id=_span_id.get(),
82
+ )
83
+
84
+ def restore(self) -> None:
85
+ """Restore this snapshot into the current context (e.g. in a new thread)."""
86
+ if self.run_id is not None:
87
+ _run_id.set(self.run_id)
88
+ if self.trace_id is not None:
89
+ _trace_id.set(self.trace_id)
90
+ if self.span_id is not None:
91
+ _span_id.set(self.span_id)
92
+
93
+
94
+ class Span:
95
+ """
96
+ Context manager that sets a fresh span_id for its block and restores
97
+ the previous one on exit. Optionally starts a new trace as well.
98
+
99
+ Usage::
100
+
101
+ with Span(name="my-operation") as span:
102
+ do_work()
103
+ """
104
+
105
+ def __init__(self, name: str = "", new_trace: bool = False):
106
+ self.name = name
107
+ self.span_id = str(uuid.uuid4())
108
+ self.trace_id = str(uuid.uuid4()) if new_trace else (_trace_id.get() or str(uuid.uuid4()))
109
+ self._tok_span: Optional[contextvars.Token] = None
110
+ self._tok_trace: Optional[contextvars.Token] = None
111
+
112
+ def __enter__(self) -> "Span":
113
+ self._tok_trace = _trace_id.set(self.trace_id)
114
+ self._tok_span = _span_id.set(self.span_id)
115
+ return self
116
+
117
+ def __exit__(self, *_: Any) -> None:
118
+ if self._tok_span is not None:
119
+ _span_id.reset(self._tok_span)
120
+ if self._tok_trace is not None:
121
+ _trace_id.reset(self._tok_trace)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from flow_forge_ai.context import get_run_id, get_step_id, get_trace_id, get_span_id, get_workflow_id
6
+ from flow_forge_ai.sinks.models.event import EventType
7
+ from flow_forge_ai.sinks.sink_router import Event, SinkRouter, default_router
8
+
9
+
10
+ def emit_event(
11
+ event_type: EventType,
12
+ payload: dict[str, Any],
13
+ *,
14
+ router: SinkRouter = default_router,
15
+ **kwargs: Any
16
+ ) -> Event:
17
+ """
18
+ Build an :class:`Event` from the current context and *payload*,
19
+ then forward it to *router*.
20
+
21
+ Returns the emitted event (handy for testing assertions).
22
+ """
23
+ event = Event(
24
+ type = event_type,
25
+ payload = payload,
26
+ workflow_id = get_workflow_id(),
27
+ run_id = get_run_id(),
28
+ trace_id = get_trace_id(),
29
+ step_id=kwargs["step_id"] if "step_id" in kwargs else get_step_id(),
30
+ span_id = get_span_id(),
31
+ )
32
+ router.emit_event(event)
33
+ return event
@@ -0,0 +1,24 @@
1
+ import importlib
2
+
3
+ from flow_forge_ai.config.models import InstrumentorConfig
4
+ from flow_forge_ai.instrumentation.base import BaseInstrumentor
5
+ from flow_forge_ai.internal_logging.logger import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ def create_instrumentor(instr_config: InstrumentorConfig) -> BaseInstrumentor:
11
+ """Instantiates the instrumentor class specified in config."""
12
+ class_path = instr_config.class_path
13
+ if not class_path:
14
+ raise ValueError("No instrumentor class specified in config")
15
+ module_path, class_name = class_path.rsplit(".", 1)
16
+ try:
17
+ module = importlib.import_module(module_path)
18
+ instrumentor_class = getattr(module, class_name)
19
+ except (ImportError, AttributeError) as e:
20
+ raise ImportError(f"Could not load instrumentor '{class_path}': {e}") from e
21
+ if not issubclass(instrumentor_class, BaseInstrumentor):
22
+ raise TypeError(f"'{class_name}' must subclass BaseInstrumentor")
23
+ instance: BaseInstrumentor = instrumentor_class(**(instr_config.options or {}))
24
+ return instance
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ import functools
5
+ import inspect
6
+ from typing import Any, Callable
7
+
8
+ from flow_forge_ai.instrumentation.utils import step_guard
9
+ from flow_forge_ai.sinks.models.step import Step
10
+
11
+
12
+ class BaseInstrumentor(ABC):
13
+ """
14
+ Subclasses patch a target library on :meth:`install` and
15
+ restore original callables on :meth:`uninstall`.
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ self._patched = False
20
+ self._uninstall_hooks: list[Callable[[], None]] = []
21
+
22
+ def install(self) -> None:
23
+ """Apply monkey-patches. Safe to call multiple times (idempotent)."""
24
+ if self._patched:
25
+ return
26
+ if not self._is_available():
27
+ return
28
+ self._install()
29
+ self._patched = True
30
+
31
+ def uninstall(self) -> None:
32
+ """Uninstalls hooks and restores originals."""
33
+ if not self._patched:
34
+ return
35
+ for hook in self._uninstall_hooks:
36
+ hook()
37
+ self._uninstall_hooks.clear()
38
+ self._patched = False
39
+
40
+ def _wrap(self, fn: Callable) -> Callable:
41
+ if inspect.iscoroutinefunction(fn):
42
+ @functools.wraps(fn)
43
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
44
+ step = step_guard()
45
+ if step is not None:
46
+ return self._build_cached_response(step)
47
+ return await fn(*args, **kwargs)
48
+ return async_wrapper
49
+
50
+ @functools.wraps(fn)
51
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
52
+ step = step_guard()
53
+ if step is not None:
54
+ return self._build_cached_response(step)
55
+ return fn(*args, **kwargs)
56
+ return wrapper
57
+
58
+ def _patch(self, target: Any, attr: str) -> Callable:
59
+ def decorator(fn: Callable) -> Callable:
60
+ orig = getattr(target, attr)
61
+ setattr(target, attr, self._wrap(fn))
62
+ # register the revert alongside the patch
63
+ self._uninstall_hooks.append(lambda: setattr(target, attr, orig))
64
+
65
+ return fn
66
+ return decorator
67
+
68
+ @abstractmethod
69
+ def _is_available(self) -> bool:
70
+ """Return True if the target library is importable."""
71
+
72
+ @abstractmethod
73
+ def _install(self) -> None:
74
+ """Perform the actual patching."""
75
+
76
+ @abstractmethod
77
+ def _build_cached_response(self, step: Step) -> Any:
78
+ """Reconstruct a native library response from cached *step* data.
79
+
80
+ Called by wrappers when :func:`~flow_forge_ai.instrumentation.utils.step_guard`
81
+ indicates this step should be replayed rather than making a live call.
82
+ The implementation should locate the ``LLM_RESPONSE`` (or equivalent)
83
+ event in ``step.events`` and return the appropriate native object.
84
+ """
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Optional
5
+ from dataclasses import dataclass
6
+
7
+ from flow_forge_ai.emitter import emit_event
8
+ from flow_forge_ai.instrumentation.base import BaseInstrumentor
9
+ from flow_forge_ai.instrumentation.models.llm_payloads import LLMErrorPayload, LLMRequestPayload, LLMResponsePayload
10
+ from flow_forge_ai.instrumentation.utils import safe_headers
11
+ from flow_forge_ai.sinks.models.event import EventType
12
+ from flow_forge_ai.sinks.models.step import Step
13
+ from flow_forge_ai.internal_logging.logger import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ @dataclass(init=False)
19
+ class HttpxRequestPayload(LLMRequestPayload):
20
+ method: str
21
+
22
+ def __init__(self,
23
+ messages: list[str],
24
+ url: str,
25
+ method: str,
26
+ model: Optional[str] = None,
27
+ instructions: Optional[str] = None,
28
+ headers: Optional[dict[str, Any]] = None):
29
+ super().__init__(provider="request",
30
+ messages=messages,
31
+ url=url,
32
+ model=model,
33
+ instructions=instructions,
34
+ headers=headers)
35
+ self.method = method
36
+
37
+ @dataclass(init=False)
38
+ class HttpxResponsePayload(LLMResponsePayload):
39
+ status_code: int
40
+ headers: dict[str, Any]
41
+
42
+ def __init__(self,
43
+ response: list[str],
44
+ latency: int,
45
+ status_code: int,
46
+ headers: dict[str, Any]):
47
+ super().__init__(response=response,
48
+ latency=latency)
49
+ self.status_code = status_code
50
+ self.headers = headers
51
+
52
+
53
+ HttpxErrorPayload = LLMErrorPayload
54
+
55
+ class HttpxInstrumentor(BaseInstrumentor):
56
+
57
+ def __init__(self, max_body_bytes: Optional[int] = None):
58
+ super().__init__()
59
+ self._max_body_bytes = max_body_bytes
60
+
61
+ def _is_available(self) -> bool:
62
+ try:
63
+ import httpx
64
+ httpx_version = getattr(httpx, "__version__", None)
65
+ logger.info(f"httpx library detected, version: {httpx_version}")
66
+ return True
67
+ except ImportError:
68
+ return False
69
+
70
+ def _install(self) -> None:
71
+ import httpx
72
+
73
+ orig_send = httpx.Client.send
74
+
75
+ @self._patch(httpx.Client, "send")
76
+ def patched_sync(client_self: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
77
+ _emit_request(request, self._max_body_bytes)
78
+ start = time.perf_counter()
79
+ try:
80
+ response = orig_send(client_self, request, *args, **kwargs)
81
+ except Exception as exc:
82
+ _emit_error(type(exc).__name__, str(exc), int((time.perf_counter() - start) * 1000))
83
+ raise
84
+ _emit_response(response, int((time.perf_counter() - start) * 1000), self._max_body_bytes)
85
+ return response
86
+
87
+ orig_async_send = httpx.AsyncClient.send
88
+
89
+ @self._patch(httpx.AsyncClient, "send")
90
+ async def patched_async(client_self: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
91
+ _emit_request(request, self._max_body_bytes)
92
+ start = time.perf_counter()
93
+ try:
94
+ response = await orig_async_send(client_self, request, *args, **kwargs)
95
+ except Exception as exc:
96
+ _emit_error(type(exc).__name__, str(exc), int((time.perf_counter() - start) * 1000))
97
+ raise
98
+ _emit_response(response, int((time.perf_counter() - start) * 1000), self._max_body_bytes)
99
+ return response
100
+
101
+ def _build_cached_response(self, step: Step) -> Any:
102
+ import httpx
103
+
104
+ response_event = next((e for e in step.events if e.type == EventType.LLM_RESPONSE), None)
105
+ if response_event is None:
106
+ raise ValueError(f"No LLM_RESPONSE event found in step {step.id!r}")
107
+
108
+ payload = response_event.payload
109
+ content = "".join(payload.get("response") or []).encode()
110
+ return httpx.Response(
111
+ status_code=payload["status_code"],
112
+ headers=payload.get("headers") or {},
113
+ content=content,
114
+ )
115
+
116
+
117
+ def _emit_request(request: Any, max_body_bytes: Optional[int]) -> None:
118
+ payload = HttpxRequestPayload(
119
+ url=str(request.url),
120
+ headers=safe_headers(request.headers),
121
+ method=request.method,
122
+ messages=[request.content[:max_body_bytes].decode("utf-8", errors="replace")]
123
+ )
124
+ emit_event(EventType.LLM_REQUEST, payload.to_dict())
125
+
126
+ def _emit_response(response: Any, latency: int, max_body_bytes: Optional[int]) -> None:
127
+ payload = HttpxResponsePayload(
128
+ status_code=response.status_code,
129
+ latency=latency,
130
+ headers=safe_headers(response.headers),
131
+ response=[response.content[:max_body_bytes].decode("utf-8", errors="replace")]
132
+ )
133
+ emit_event(EventType.LLM_RESPONSE, payload.to_dict())
134
+
135
+ def _emit_error(error: str, detail: str, latency: int) -> None:
136
+ emit_event(EventType.LLM_ERROR, HttpxErrorPayload(
137
+ error=error,
138
+ detail=detail,
139
+ latency=latency,
140
+ ).to_dict())
File without changes
@@ -0,0 +1,76 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Optional
3
+
4
+
5
+ @dataclass(init=False)
6
+ class LLMRequestPayload:
7
+ _provider: str = field(repr=False)
8
+ model: Optional[str]
9
+ instructions: Optional[str]
10
+ messages: list[str]
11
+ url: str
12
+ headers: Optional[dict[str, Any]]
13
+
14
+ def __init__(self,
15
+ provider: str,
16
+ messages: list[str],
17
+ url: str,
18
+ model: Optional[str] = None,
19
+ instructions: Optional[str] = None,
20
+ headers: Optional[dict[str, Any]] = None):
21
+ self._provider = provider
22
+ self.messages = messages
23
+ self.url = url
24
+ self.model = model
25
+ self.instructions = instructions
26
+ self.headers = headers
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return {
30
+ "provider": self._provider,
31
+ "model": self.model,
32
+ "instructions": self.instructions,
33
+ "messages": self.messages,
34
+ "url": self.url,
35
+ "headers": self.headers.copy() if self.headers else None
36
+ }
37
+
38
+
39
+ @dataclass(init=False)
40
+ class LLMResponsePayload:
41
+ response: Any
42
+ latency: int
43
+
44
+ def __init__(self,
45
+ response: Any,
46
+ latency: int):
47
+ self.response = response
48
+ self.latency = latency
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "response": self.response,
53
+ "latency": self.latency
54
+ }
55
+
56
+
57
+ @dataclass(init=False)
58
+ class LLMErrorPayload:
59
+ error: str
60
+ detail: str
61
+ latency: int
62
+
63
+ def __init__(self,
64
+ error: str,
65
+ detail: str,
66
+ latency: int):
67
+ self.error = error
68
+ self.detail = detail
69
+ self.latency = latency
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {
73
+ "error": self.error,
74
+ "detail": self.detail,
75
+ "latency": self.latency
76
+ }