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.
- flow_forge_ai/__init__.py +0 -0
- flow_forge_ai/config/__init__.py +0 -0
- flow_forge_ai/config/config_handler.py +97 -0
- flow_forge_ai/config/models.py +66 -0
- flow_forge_ai/context.py +121 -0
- flow_forge_ai/emitter.py +33 -0
- flow_forge_ai/instrumentation/__init__.py +24 -0
- flow_forge_ai/instrumentation/base.py +84 -0
- flow_forge_ai/instrumentation/httpx_instr.py +140 -0
- flow_forge_ai/instrumentation/models/__init__.py +0 -0
- flow_forge_ai/instrumentation/models/llm_payloads.py +76 -0
- flow_forge_ai/instrumentation/models/tool_payloads.py +85 -0
- flow_forge_ai/instrumentation/ollama_instr.py +258 -0
- flow_forge_ai/instrumentation/openai_instr.py +271 -0
- flow_forge_ai/instrumentation/requests_instr.py +139 -0
- flow_forge_ai/instrumentation/trace_tool.py +82 -0
- flow_forge_ai/instrumentation/utils.py +26 -0
- flow_forge_ai/instrumentation/workflow.py +39 -0
- flow_forge_ai/internal_logging/__init__.py +0 -0
- flow_forge_ai/internal_logging/logger.py +279 -0
- flow_forge_ai/internal_logging/logging_handler.py +60 -0
- flow_forge_ai/py.typed +0 -0
- flow_forge_ai/replay.py +166 -0
- flow_forge_ai/runtime.py +424 -0
- flow_forge_ai/sinks/__init__.py +24 -0
- flow_forge_ai/sinks/base.py +13 -0
- flow_forge_ai/sinks/composite_sink.py +34 -0
- flow_forge_ai/sinks/console_sink.py +33 -0
- flow_forge_ai/sinks/database_sink.py +166 -0
- flow_forge_ai/sinks/file_sink.py +29 -0
- flow_forge_ai/sinks/handlers/__init__.py +24 -0
- flow_forge_ai/sinks/handlers/jsonl_handler.py +113 -0
- flow_forge_ai/sinks/handlers/mongodb_handler.py +248 -0
- flow_forge_ai/sinks/handlers/mysql_handler.py +274 -0
- flow_forge_ai/sinks/handlers/postgres_handler.py +284 -0
- flow_forge_ai/sinks/handlers/resource_handler.py +154 -0
- flow_forge_ai/sinks/handlers/sqlite_handler.py +247 -0
- flow_forge_ai/sinks/log_sink.py +17 -0
- flow_forge_ai/sinks/memory_sink.py +26 -0
- flow_forge_ai/sinks/models/__init__.py +0 -0
- flow_forge_ai/sinks/models/event.py +63 -0
- flow_forge_ai/sinks/models/run.py +31 -0
- flow_forge_ai/sinks/models/step.py +31 -0
- flow_forge_ai/sinks/sink_router.py +34 -0
- flow_forge_ai/utils/__init__.py +0 -0
- flow_forge_ai/utils/decorators.py +12 -0
- flow_forge_ai/utils/toml.py +9 -0
- flow_forge_ai_sdk-0.1.0.dist-info/METADATA +307 -0
- flow_forge_ai_sdk-0.1.0.dist-info/RECORD +51 -0
- flow_forge_ai_sdk-0.1.0.dist-info/WHEEL +5 -0
- 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
|
+
}
|
flow_forge_ai/context.py
ADDED
|
@@ -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)
|
flow_forge_ai/emitter.py
ADDED
|
@@ -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
|
+
}
|