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.
@@ -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,7 @@
1
+ """ForgeSight cost model — token → USD via a vendored, refreshable pricing table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .table import ModelRates, PricingTable, TablePricingProvider
6
+
7
+ __all__ = ["ModelRates", "PricingTable", "TablePricingProvider"]
@@ -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
+ }