forgesight-core 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.
- forgesight_core/__init__.py +72 -0
- forgesight_core/adapters/__init__.py +9 -0
- forgesight_core/adapters/base.py +43 -0
- forgesight_core/adapters/bridge.py +84 -0
- forgesight_core/adapters/guard.py +32 -0
- forgesight_core/config.py +180 -0
- forgesight_core/context.py +75 -0
- forgesight_core/cost/__init__.py +7 -0
- forgesight_core/cost/data/prices.json +66 -0
- forgesight_core/cost/table.py +283 -0
- forgesight_core/decorator.py +70 -0
- forgesight_core/exporters.py +76 -0
- forgesight_core/facade.py +183 -0
- forgesight_core/interceptors/__init__.py +8 -0
- forgesight_core/interceptors/content_gate.py +24 -0
- forgesight_core/interceptors/pii.py +61 -0
- forgesight_core/metrics/__init__.py +11 -0
- forgesight_core/metrics/config.py +39 -0
- forgesight_core/metrics/instruments.py +265 -0
- forgesight_core/processor.py +295 -0
- forgesight_core/py.typed +0 -0
- forgesight_core/scope.py +668 -0
- forgesight_core/testing/__init__.py +169 -0
- forgesight_core/testing/conformance.py +121 -0
- forgesight_core-0.1.0.dist-info/METADATA +47 -0
- forgesight_core-0.1.0.dist-info/RECORD +27 -0
- forgesight_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""ForgeSight runtime — instrumentation scopes, context propagation, dispatch.
|
|
2
|
+
|
|
3
|
+
Most users import the ``forgesight`` facade rather than this package directly.
|
|
4
|
+
Adapter authors (feat-019) use the context primitives and scopes here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .adapters import BaseAdapter, ScopeBridge, in_tool_call, tool_call_active
|
|
10
|
+
from .config import register, resolve
|
|
11
|
+
from .context import (
|
|
12
|
+
TelemetryContext,
|
|
13
|
+
current_context,
|
|
14
|
+
new_run_id,
|
|
15
|
+
new_span_id,
|
|
16
|
+
)
|
|
17
|
+
from .cost import PricingTable, TablePricingProvider
|
|
18
|
+
from .decorator import instrument
|
|
19
|
+
from .exporters import ConsoleExporter, InMemoryExporter
|
|
20
|
+
from .facade import Telemetry, configure, telemetry
|
|
21
|
+
from .interceptors import ContentCaptureGate, PIIRedactionInterceptor
|
|
22
|
+
from .metrics import AttributionMetricsConfig, MetricConfig, MetricsSubsystem
|
|
23
|
+
from .processor import Runtime, RuntimeConfig, get_runtime, reset_runtime
|
|
24
|
+
from .scope import (
|
|
25
|
+
LLMScope,
|
|
26
|
+
MCPScope,
|
|
27
|
+
RunScope,
|
|
28
|
+
StepScope,
|
|
29
|
+
ToolScope,
|
|
30
|
+
WorkflowScope,
|
|
31
|
+
current_run_scope,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AttributionMetricsConfig",
|
|
38
|
+
"BaseAdapter",
|
|
39
|
+
"ConsoleExporter",
|
|
40
|
+
"ContentCaptureGate",
|
|
41
|
+
"InMemoryExporter",
|
|
42
|
+
"LLMScope",
|
|
43
|
+
"MCPScope",
|
|
44
|
+
"MetricConfig",
|
|
45
|
+
"MetricsSubsystem",
|
|
46
|
+
"PIIRedactionInterceptor",
|
|
47
|
+
"PricingTable",
|
|
48
|
+
"RunScope",
|
|
49
|
+
"Runtime",
|
|
50
|
+
"RuntimeConfig",
|
|
51
|
+
"ScopeBridge",
|
|
52
|
+
"StepScope",
|
|
53
|
+
"TablePricingProvider",
|
|
54
|
+
"Telemetry",
|
|
55
|
+
"TelemetryContext",
|
|
56
|
+
"ToolScope",
|
|
57
|
+
"WorkflowScope",
|
|
58
|
+
"__version__",
|
|
59
|
+
"configure",
|
|
60
|
+
"current_context",
|
|
61
|
+
"current_run_scope",
|
|
62
|
+
"get_runtime",
|
|
63
|
+
"in_tool_call",
|
|
64
|
+
"instrument",
|
|
65
|
+
"new_run_id",
|
|
66
|
+
"new_span_id",
|
|
67
|
+
"register",
|
|
68
|
+
"reset_runtime",
|
|
69
|
+
"resolve",
|
|
70
|
+
"telemetry",
|
|
71
|
+
"tool_call_active",
|
|
72
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Adapter base + scope-bridge shared by every framework adapter (feat-019)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdapter
|
|
6
|
+
from .bridge import ScopeBridge
|
|
7
|
+
from .guard import in_tool_call, tool_call_active
|
|
8
|
+
|
|
9
|
+
__all__ = ["BaseAdapter", "ScopeBridge", "in_tool_call", "tool_call_active"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``BaseAdapter`` — the idempotent ``instrument`` / ``uninstrument`` bookkeeping.
|
|
2
|
+
|
|
3
|
+
A concrete adapter subclasses this, sets ``name``, and implements ``_subscribe`` /
|
|
4
|
+
``_unsubscribe`` (the framework-specific hook wiring). The guard here makes double
|
|
5
|
+
``instrument()`` a no-op and ``uninstrument()`` safe to call any time — the lifecycle
|
|
6
|
+
invariants the conformance suite (feat-011) checks. Satisfies the
|
|
7
|
+
:class:`~forgesight_api.FrameworkAdapter` Protocol structurally.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseAdapter:
|
|
14
|
+
"""Lifecycle bookkeeping for a framework adapter; subclasses do the hook wiring."""
|
|
15
|
+
|
|
16
|
+
name: str = "base"
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._instrumented = False
|
|
20
|
+
|
|
21
|
+
def instrument(self) -> None:
|
|
22
|
+
if self._instrumented:
|
|
23
|
+
return
|
|
24
|
+
self._subscribe()
|
|
25
|
+
self._instrumented = True
|
|
26
|
+
|
|
27
|
+
def uninstrument(self) -> None:
|
|
28
|
+
if not self._instrumented:
|
|
29
|
+
return
|
|
30
|
+
self._unsubscribe()
|
|
31
|
+
self._instrumented = False
|
|
32
|
+
|
|
33
|
+
def is_instrumented(self) -> bool:
|
|
34
|
+
return self._instrumented
|
|
35
|
+
|
|
36
|
+
# --- subclass hooks ---------------------------------------------------
|
|
37
|
+
def _subscribe(self) -> None:
|
|
38
|
+
"""Register the framework's native listeners. Override in a concrete adapter."""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def _unsubscribe(self) -> None:
|
|
42
|
+
"""Unregister the framework's native listeners. Override in a concrete adapter."""
|
|
43
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""``ScopeBridge`` — translate a framework's start/end callbacks onto SDK scopes.
|
|
2
|
+
|
|
3
|
+
Frameworks signal work as *start* then *end* callbacks, not ``with`` blocks. This bridge
|
|
4
|
+
opens the matching SDK scope on start and closes it on end, manually driving the scope's
|
|
5
|
+
context-manager protocol so nesting rides the SDK's ``TelemetryContext`` (contextvars) —
|
|
6
|
+
exactly as the runtime intends. Two addressing modes:
|
|
7
|
+
|
|
8
|
+
* **keyed** — frameworks that give each unit a run id (LangChain's ``run_id``): look the
|
|
9
|
+
open scope up by key on the end callback.
|
|
10
|
+
* **stacked** — frameworks without ids (the CrewAI event bus): per-kind LIFO, matching the
|
|
11
|
+
strictly-nested sequential execution order.
|
|
12
|
+
|
|
13
|
+
The bridge holds no framework types; the adapter constructs the scope and hands it over.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Hashable
|
|
19
|
+
from typing import Any, Protocol
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ManagedScope(Protocol):
|
|
23
|
+
def __enter__(self) -> Any: ...
|
|
24
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any: ...
|
|
25
|
+
def record_error(self, exc: BaseException, *, code: str | None = None) -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScopeBridge:
|
|
29
|
+
"""Open/close SDK scopes from start/end callbacks; nests via the runtime's contextvars."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._by_key: dict[Hashable, _ManagedScope] = {}
|
|
33
|
+
self._stacks: dict[str, list[_ManagedScope]] = {}
|
|
34
|
+
|
|
35
|
+
# --- keyed (frameworks with run ids) ---------------------------------
|
|
36
|
+
def enter_keyed(self, key: Hashable, scope: _ManagedScope) -> _ManagedScope:
|
|
37
|
+
scope.__enter__()
|
|
38
|
+
self._by_key[key] = scope
|
|
39
|
+
return scope
|
|
40
|
+
|
|
41
|
+
def get_keyed(self, key: Hashable) -> _ManagedScope | None:
|
|
42
|
+
return self._by_key.get(key)
|
|
43
|
+
|
|
44
|
+
def exit_keyed(
|
|
45
|
+
self, key: Hashable, *, error: BaseException | None = None
|
|
46
|
+
) -> _ManagedScope | None:
|
|
47
|
+
return self._close(self._by_key.pop(key, None), error)
|
|
48
|
+
|
|
49
|
+
# --- stacked (frameworks without ids — per-kind LIFO) ----------------
|
|
50
|
+
def enter_stacked(self, kind: str, scope: _ManagedScope) -> _ManagedScope:
|
|
51
|
+
scope.__enter__()
|
|
52
|
+
self._stacks.setdefault(kind, []).append(scope)
|
|
53
|
+
return scope
|
|
54
|
+
|
|
55
|
+
def peek_stacked(self, kind: str) -> _ManagedScope | None:
|
|
56
|
+
stack = self._stacks.get(kind)
|
|
57
|
+
return stack[-1] if stack else None
|
|
58
|
+
|
|
59
|
+
def exit_stacked(
|
|
60
|
+
self, kind: str, *, error: BaseException | None = None
|
|
61
|
+
) -> _ManagedScope | None:
|
|
62
|
+
stack = self._stacks.get(kind)
|
|
63
|
+
scope = stack.pop() if stack else None
|
|
64
|
+
return self._close(scope, error)
|
|
65
|
+
|
|
66
|
+
# --- cleanup ----------------------------------------------------------
|
|
67
|
+
def close_all(self) -> None:
|
|
68
|
+
"""Close every still-open scope (innermost first) — used on uninstrument."""
|
|
69
|
+
leftovers = list(self._by_key.values())
|
|
70
|
+
for stack in self._stacks.values():
|
|
71
|
+
leftovers.extend(stack)
|
|
72
|
+
for scope in reversed(leftovers):
|
|
73
|
+
self._close(scope, None)
|
|
74
|
+
self._by_key.clear()
|
|
75
|
+
self._stacks.clear()
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _close(scope: _ManagedScope | None, error: BaseException | None) -> _ManagedScope | None:
|
|
79
|
+
if scope is None:
|
|
80
|
+
return None
|
|
81
|
+
if error is not None:
|
|
82
|
+
scope.record_error(error)
|
|
83
|
+
scope.__exit__(None, None, None)
|
|
84
|
+
return scope
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Re-entrancy guard so a framework adapter never double-instruments a tool call.
|
|
2
|
+
|
|
3
|
+
When an inner span already covers a tool execution — an MCP ``tools/call`` (feat-016) or a
|
|
4
|
+
native ``tool_call`` — a framework adapter observing the *same* call must defer to that span
|
|
5
|
+
instead of opening a second ``execute_tool`` (otel mapping §4.3). The in-flight span marks
|
|
6
|
+
this contextvar; adapters check :func:`in_tool_call` on their tool-start hook and skip.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextvars
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
|
|
15
|
+
_IN_TOOL_CALL: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
16
|
+
"forgesight_in_tool_call", default=False
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def in_tool_call() -> bool:
|
|
21
|
+
"""True while a tool span is already open on this context (adapters defer to it)."""
|
|
22
|
+
return _IN_TOOL_CALL.get()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def tool_call_active() -> Iterator[None]:
|
|
27
|
+
"""Mark a tool span in flight so an adapter observing the same call defers (no double-span)."""
|
|
28
|
+
token = _IN_TOOL_CALL.set(True)
|
|
29
|
+
try:
|
|
30
|
+
yield
|
|
31
|
+
finally:
|
|
32
|
+
_IN_TOOL_CALL.reset(token)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Configuration: the named-integration registry + layered file/env loading.
|
|
2
|
+
|
|
3
|
+
Resolves names (``"console"``, ``"otel"``, ``"langfuse"``, …) to implementations via
|
|
4
|
+
an in-process registry plus the ``forgesight.<group>`` entry points (the plug-and-play
|
|
5
|
+
seam, P2). A name that resolves to nothing raises the matching ``*NotRegisteredError``
|
|
6
|
+
at ``configure()`` — fail-fast, never a silent mid-run drop (architecture §8).
|
|
7
|
+
|
|
8
|
+
Settings are layered file → env → kwargs (last wins); ``${VAR}`` / ``${VAR:-default}``
|
|
9
|
+
in the YAML file are interpolated from the environment. Implemented with dataclasses +
|
|
10
|
+
manual validation (no Pydantic) to keep the core dependency-light (P1).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from importlib import metadata
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from forgesight_api import (
|
|
25
|
+
EventListenerNotRegisteredError,
|
|
26
|
+
ExporterNotRegisteredError,
|
|
27
|
+
InterceptorNotRegisteredError,
|
|
28
|
+
PricingProviderNotRegisteredError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .cost import TablePricingProvider
|
|
32
|
+
from .exporters import ConsoleExporter, InMemoryExporter
|
|
33
|
+
from .interceptors import ContentCaptureGate, PIIRedactionInterceptor
|
|
34
|
+
|
|
35
|
+
_log = logging.getLogger("forgesight.config")
|
|
36
|
+
|
|
37
|
+
_ERRORS: dict[str, type[LookupError]] = {
|
|
38
|
+
"exporters": ExporterNotRegisteredError,
|
|
39
|
+
"interceptors": InterceptorNotRegisteredError,
|
|
40
|
+
"listeners": EventListenerNotRegisteredError,
|
|
41
|
+
"pricing": PricingProviderNotRegisteredError,
|
|
42
|
+
}
|
|
43
|
+
_REGISTRY: dict[str, dict[str, Callable[..., Any]]] = {group: {} for group in _ERRORS}
|
|
44
|
+
# Adapters resolve like the other groups but never fail-fast — a missing framework warns
|
|
45
|
+
# and is skipped (feat-019), so "adapters" has a registry slot but no entry in _ERRORS.
|
|
46
|
+
_REGISTRY["adapters"] = {}
|
|
47
|
+
|
|
48
|
+
# Built-in implementations resolve by name with no privileged path.
|
|
49
|
+
_REGISTRY["exporters"]["console"] = ConsoleExporter
|
|
50
|
+
_REGISTRY["exporters"]["in-memory"] = InMemoryExporter
|
|
51
|
+
_REGISTRY["interceptors"]["content-gate"] = ContentCaptureGate
|
|
52
|
+
_REGISTRY["interceptors"]["pii-redaction"] = PIIRedactionInterceptor
|
|
53
|
+
_REGISTRY["pricing"]["default"] = TablePricingProvider.from_vendored
|
|
54
|
+
|
|
55
|
+
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register(group: str, name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
59
|
+
"""Decorator: register an in-process factory under a group, resolvable by name."""
|
|
60
|
+
if group not in _REGISTRY:
|
|
61
|
+
raise ValueError(f"unknown registration group {group!r}; expected one of {list(_ERRORS)}")
|
|
62
|
+
|
|
63
|
+
def decorator(factory: Callable[..., Any]) -> Callable[..., Any]:
|
|
64
|
+
_REGISTRY[group][name] = factory
|
|
65
|
+
return factory
|
|
66
|
+
|
|
67
|
+
return decorator
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve(group: str, name: str, config: Mapping[str, object] | None = None) -> object:
|
|
71
|
+
"""Resolve ``name`` in ``group`` to an instance, or raise the group's error."""
|
|
72
|
+
factory = _REGISTRY[group].get(name) or _load_entry_point(group, name)
|
|
73
|
+
if factory is None:
|
|
74
|
+
raise _ERRORS[group](
|
|
75
|
+
f"No {group[:-1]} registered under name {name!r}. Expected an entry point in "
|
|
76
|
+
f"group 'forgesight.{group}' (did you `pip install` the integration package?)."
|
|
77
|
+
)
|
|
78
|
+
return factory(**dict(config)) if config else factory()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_entry_point(group: str, name: str) -> Callable[..., Any] | None:
|
|
82
|
+
try:
|
|
83
|
+
entry_points = metadata.entry_points(group=f"forgesight.{group}")
|
|
84
|
+
except Exception: # pragma: no cover - importlib metadata edge
|
|
85
|
+
return None
|
|
86
|
+
for entry_point in entry_points:
|
|
87
|
+
if entry_point.name == name:
|
|
88
|
+
loaded: Callable[..., Any] = entry_point.load()
|
|
89
|
+
return loaded
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_adapters(settings: Mapping[str, Any]) -> list[Any]:
|
|
94
|
+
"""Instantiate + instrument the adapters named in the ``adapters:`` config block (feat-019).
|
|
95
|
+
|
|
96
|
+
Driven by explicit config (not "instrument every installed adapter") so the SDK's own
|
|
97
|
+
process is never silently auto-instrumented. A named adapter resolves via the in-process
|
|
98
|
+
registry or the ``forgesight.adapters`` entry point; one whose framework isn't importable
|
|
99
|
+
warns and is skipped — never raises (P6). With ``auto_instrument: false`` the adapters are
|
|
100
|
+
created but left inert for the app to ``instrument()`` itself.
|
|
101
|
+
"""
|
|
102
|
+
block = settings.get("adapters")
|
|
103
|
+
if not isinstance(block, Mapping):
|
|
104
|
+
return []
|
|
105
|
+
auto_instrument = bool(block.get("auto_instrument", True))
|
|
106
|
+
adapters: list[Any] = []
|
|
107
|
+
for name, opts in block.items():
|
|
108
|
+
if name == "auto_instrument":
|
|
109
|
+
continue
|
|
110
|
+
if isinstance(opts, Mapping) and not opts.get("enabled", True):
|
|
111
|
+
continue
|
|
112
|
+
factory = _REGISTRY["adapters"].get(name) or _load_entry_point("adapters", name)
|
|
113
|
+
if factory is None:
|
|
114
|
+
_log.warning("adapter %r is enabled but not installed; skipping", name)
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
adapter = factory()
|
|
118
|
+
if auto_instrument:
|
|
119
|
+
adapter.instrument()
|
|
120
|
+
except Exception: # a framework that won't import must never fail configure() (P6)
|
|
121
|
+
_log.warning("adapter %r failed to load/instrument; skipping", name, exc_info=True)
|
|
122
|
+
continue
|
|
123
|
+
adapters.append(adapter)
|
|
124
|
+
return adapters
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def interpolate(value: object, env: Mapping[str, str]) -> object:
|
|
128
|
+
"""Substitute ``${VAR}`` / ``${VAR:-default}`` in strings, recursing into dicts/lists."""
|
|
129
|
+
if isinstance(value, str):
|
|
130
|
+
return _ENV_PATTERN.sub(lambda m: _sub(m, env), value)
|
|
131
|
+
if isinstance(value, dict):
|
|
132
|
+
return {k: interpolate(v, env) for k, v in value.items()}
|
|
133
|
+
if isinstance(value, list):
|
|
134
|
+
return [interpolate(v, env) for v in value]
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sub(match: re.Match[str], env: Mapping[str, str]) -> str:
|
|
139
|
+
var, default = match.group(1), match.group(2)
|
|
140
|
+
if var in env:
|
|
141
|
+
return env[var]
|
|
142
|
+
if default is not None:
|
|
143
|
+
return default
|
|
144
|
+
raise ValueError(f"config references ${{{var}}} which is not set and has no default")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_settings(
|
|
148
|
+
config_file: str | None = None, env: Mapping[str, str] | None = None
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Load the file layer (YAML + ``${ENV}``) then overlay ``FORGESIGHT_*`` env scalars."""
|
|
151
|
+
env = os.environ if env is None else env
|
|
152
|
+
settings: dict[str, Any] = {}
|
|
153
|
+
path = config_file or env.get("FORGESIGHT_CONFIG")
|
|
154
|
+
if path is None and os.path.exists("forgesight.yaml"):
|
|
155
|
+
path = "forgesight.yaml"
|
|
156
|
+
if path and os.path.exists(path):
|
|
157
|
+
with open(path, encoding="utf-8") as handle:
|
|
158
|
+
raw = yaml.safe_load(handle) or {}
|
|
159
|
+
loaded = interpolate(raw, env)
|
|
160
|
+
if isinstance(loaded, dict):
|
|
161
|
+
settings.update(loaded)
|
|
162
|
+
_env_overlay(settings, env)
|
|
163
|
+
return settings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _env_overlay(settings: dict[str, Any], env: Mapping[str, str]) -> None:
|
|
167
|
+
if "FORGESIGHT_SERVICE_NAME" in env:
|
|
168
|
+
settings["service_name"] = env["FORGESIGHT_SERVICE_NAME"]
|
|
169
|
+
if "FORGESIGHT_EXPORTERS" in env:
|
|
170
|
+
settings["exporters"] = [
|
|
171
|
+
s.strip() for s in env["FORGESIGHT_EXPORTERS"].split(",") if s.strip()
|
|
172
|
+
]
|
|
173
|
+
if "FORGESIGHT_CAPTURE_CONTENT" in env:
|
|
174
|
+
settings["capture_content"] = _as_bool(env["FORGESIGHT_CAPTURE_CONTENT"])
|
|
175
|
+
if "FORGESIGHT_SAMPLE_RATE" in env:
|
|
176
|
+
settings["sample_rate"] = float(env["FORGESIGHT_SAMPLE_RATE"])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _as_bool(value: str) -> bool:
|
|
180
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Per-run ambient state, propagated via ``contextvars``.
|
|
2
|
+
|
|
3
|
+
A :class:`TelemetryContext` carries the ids and accumulated run/step-scope metadata
|
|
4
|
+
for the active run. It rides on a :class:`~contextvars.ContextVar`, so it survives
|
|
5
|
+
``await`` boundaries and is *copied* into ``asyncio.gather`` / ``create_task``
|
|
6
|
+
children — which is what makes concurrent leaf calls attach to the right parent
|
|
7
|
+
without racing each other's ``current_span_id`` (P9, feat-002 §4.3).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextvars
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from forgesight_api import new_ulid
|
|
17
|
+
|
|
18
|
+
_SPAN_ID_BYTES = 8 # OTel span id is 8 bytes / 16 hex chars
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class TelemetryContext:
|
|
23
|
+
"""Ambient state for the active run. Read by every scope on enter."""
|
|
24
|
+
|
|
25
|
+
run_id: str
|
|
26
|
+
trace_id: str
|
|
27
|
+
parent_run_id: str | None = None
|
|
28
|
+
current_span_id: str | None = None # the parent span for the next child opened
|
|
29
|
+
context_id: str | None = None
|
|
30
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def child(self, *, current_span_id: str) -> TelemetryContext:
|
|
33
|
+
"""A copy with a new ``current_span_id`` and a *copied* metadata dict.
|
|
34
|
+
|
|
35
|
+
Used when entering a nested scope so a sibling scope's metadata writes never
|
|
36
|
+
leak across, while inherited run-scope metadata is preserved.
|
|
37
|
+
"""
|
|
38
|
+
return TelemetryContext(
|
|
39
|
+
run_id=self.run_id,
|
|
40
|
+
trace_id=self.trace_id,
|
|
41
|
+
parent_run_id=self.parent_run_id,
|
|
42
|
+
current_span_id=current_span_id,
|
|
43
|
+
context_id=self.context_id,
|
|
44
|
+
metadata=dict(self.metadata),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_CURRENT: contextvars.ContextVar[TelemetryContext | None] = contextvars.ContextVar(
|
|
49
|
+
"forgesight_current_context", default=None
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def current_context() -> TelemetryContext | None:
|
|
54
|
+
"""Return the active :class:`TelemetryContext`, or ``None`` outside any run."""
|
|
55
|
+
return _CURRENT.get()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_current_context(ctx: TelemetryContext | None) -> contextvars.Token[TelemetryContext | None]:
|
|
59
|
+
"""Bind ``ctx`` as the active context; returns a token to restore the previous one."""
|
|
60
|
+
return _CURRENT.set(ctx)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reset_current_context(token: contextvars.Token[TelemetryContext | None]) -> None:
|
|
64
|
+
"""Restore the context that was active before the matching :func:`set_current_context`."""
|
|
65
|
+
_CURRENT.reset(token)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def new_run_id() -> str:
|
|
69
|
+
"""Mint a ULID run id — the single place run ids are created (feat-001 format)."""
|
|
70
|
+
return new_ulid()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def new_span_id() -> str:
|
|
74
|
+
"""Mint a 16-hex-char (8-byte) span id."""
|
|
75
|
+
return os.urandom(_SPAN_ID_BYTES).hex()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"updated_at": "2026-06-14T00:00:00Z",
|
|
3
|
+
"models": {
|
|
4
|
+
"anthropic/claude-sonnet-4-5": {
|
|
5
|
+
"provider": "anthropic",
|
|
6
|
+
"input_cost_per_token": 3e-06,
|
|
7
|
+
"output_cost_per_token": 1.5e-05,
|
|
8
|
+
"cache_read_input_token_cost": 3e-07,
|
|
9
|
+
"cache_creation_input_token_cost": 3.75e-06,
|
|
10
|
+
"tiers": [
|
|
11
|
+
{
|
|
12
|
+
"name": "above-200k",
|
|
13
|
+
"priority": 1,
|
|
14
|
+
"when": { "input_tokens": { "gt": 200000 } },
|
|
15
|
+
"input_cost_per_token": 6e-06,
|
|
16
|
+
"output_cost_per_token": 2.25e-05
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"anthropic/claude-opus-4-1": {
|
|
21
|
+
"provider": "anthropic",
|
|
22
|
+
"input_cost_per_token": 1.5e-05,
|
|
23
|
+
"output_cost_per_token": 7.5e-05,
|
|
24
|
+
"cache_read_input_token_cost": 1.5e-06,
|
|
25
|
+
"cache_creation_input_token_cost": 1.875e-05
|
|
26
|
+
},
|
|
27
|
+
"anthropic/claude-haiku-4-5": {
|
|
28
|
+
"provider": "anthropic",
|
|
29
|
+
"input_cost_per_token": 1e-06,
|
|
30
|
+
"output_cost_per_token": 5e-06,
|
|
31
|
+
"cache_read_input_token_cost": 1e-07,
|
|
32
|
+
"cache_creation_input_token_cost": 1.25e-06
|
|
33
|
+
},
|
|
34
|
+
"openai/gpt-4o": {
|
|
35
|
+
"provider": "openai",
|
|
36
|
+
"input_cost_per_token": 2.5e-06,
|
|
37
|
+
"output_cost_per_token": 1e-05,
|
|
38
|
+
"cache_read_input_token_cost": 1.25e-06
|
|
39
|
+
},
|
|
40
|
+
"openai/gpt-4o-mini": {
|
|
41
|
+
"provider": "openai",
|
|
42
|
+
"input_cost_per_token": 1.5e-07,
|
|
43
|
+
"output_cost_per_token": 6e-07,
|
|
44
|
+
"cache_read_input_token_cost": 7.5e-08
|
|
45
|
+
},
|
|
46
|
+
"google/gemini-2.5-pro": {
|
|
47
|
+
"provider": "google",
|
|
48
|
+
"input_cost_per_token": 1.25e-06,
|
|
49
|
+
"output_cost_per_token": 1e-05,
|
|
50
|
+
"tiers": [
|
|
51
|
+
{
|
|
52
|
+
"name": "above-200k",
|
|
53
|
+
"priority": 1,
|
|
54
|
+
"when": { "input_tokens": { "gt": 200000 } },
|
|
55
|
+
"input_cost_per_token": 2.5e-06,
|
|
56
|
+
"output_cost_per_token": 1.5e-05
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"aliases": {
|
|
62
|
+
"claude-sonnet-4-5-latest": "anthropic/claude-sonnet-4-5",
|
|
63
|
+
"claude-sonnet-4-5": "anthropic/claude-sonnet-4-5",
|
|
64
|
+
"gpt-4o": "openai/gpt-4o"
|
|
65
|
+
}
|
|
66
|
+
}
|