Skip to content

Commit 498d5b0

Browse files
Add LLM routing metadata to generation observability
2 parents 4c875d1 + b0a719b commit 498d5b0

5 files changed

Lines changed: 388 additions & 11 deletions

File tree

examples/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ class DataAgent(Agent, llm=llm):
567567

568568
### LLM cascading resolution
569569

570-
Configure LLMs at any granularity — class default, method override, instance override — for cost/latency optimization, A/B testing, or gradual rollouts.
570+
Configure LLMs at any granularity — class default, method override, instance override, or per-call override — for cost/latency optimization, A/B testing, or gradual rollouts.
571571

572572
```python
573573
class MyAgent(Agent, llm=default_llm): # 1. class default

src/nooa/runtime/actor.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262

6363
logger = logging.getLogger(__name__)
6464

65+
_MISSING = object()
66+
6567

6668
@contextmanager
6769
def _harness_metrics_lifecycle(should_trace: bool):
@@ -401,6 +403,12 @@ def _strip_blocked_modules(
401403
"current_method", default=None
402404
)
403405
_current_llm_var: contextvars.ContextVar[Any] = contextvars.ContextVar("current_llm", default=None)
406+
_current_llm_model_name_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
407+
"current_llm_model_name", default=None
408+
)
409+
_current_llm_selection_source_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
410+
"current_llm_selection_source", default=None
411+
)
404412

405413
# Context variable for the resolved truncation config for the current generation call.
406414
# Set by _execute_with_generation() so that method-level @strategy(truncation=...)
@@ -1953,6 +1961,14 @@ async def execute_nested(
19531961
if hasattr(strategy, "prefill") and getattr(strategy, "prefill") is not None: # noqa: B009
19541962
strategy_kwargs["has_prefill"] = True
19551963

1964+
llm_model_name = _current_llm_model_name_var.get()
1965+
llm_selection_source = _current_llm_selection_source_var.get()
1966+
llm_kwargs = {}
1967+
if llm_model_name is not None:
1968+
llm_kwargs["llm.model_name"] = llm_model_name
1969+
if llm_selection_source is not None:
1970+
llm_kwargs["llm.selection_source"] = llm_selection_source
1971+
19561972
# Call generation hooks (skip for non-traceable strategies like TemplateStrategy)
19571973
should_trace = strategy.traceable
19581974
hook_context = None
@@ -1965,6 +1981,7 @@ async def execute_nested(
19651981
generation_id=generation_id,
19661982
parent_generation_id=parent_generation_id,
19671983
agent_call_id=self._agent_call_id,
1984+
**llm_kwargs,
19681985
**strategy_kwargs, # Add strategy config parameters
19691986
)
19701987

@@ -2530,13 +2547,18 @@ async def _execute_with_generation(
25302547
method_name: str,
25312548
) -> Any:
25322549
"""Execute a method that needs LLM generation."""
2550+
base_method = getattr(method, "__func__", method)
2551+
try:
2552+
has_user_llm_param = "llm" in inspect.signature(method).parameters
2553+
except (TypeError, ValueError):
2554+
has_user_llm_param = False
2555+
25332556
# Extract framework parameters (don't pass to generated method)
25342557
call_strategy = kwargs.pop("_strategy", None)
2535-
call_llm = kwargs.pop("llm", None)
2558+
call_llm = kwargs.pop("llm", _MISSING) if not has_user_llm_param else _MISSING
25362559
call_session_locals = kwargs.pop("_session_locals", None)
25372560

25382561
# Get strategy with priority: call-level > decorator > default
2539-
base_method = getattr(method, "__func__", method)
25402562
decorator_strategy = getattr(base_method, "_plan_strategy", None)
25412563

25422564
# DEBUG: Log strategy retrieval for nested method debugging
@@ -2561,17 +2583,21 @@ async def _execute_with_generation(
25612583
# A @strategy(llm=...) value may be a callable resolved against the agent
25622584
# instance; only invoke it when it would actually be used, so a call-level
25632585
# override doesn't trigger someone else's resolver side effects.
2564-
if call_llm is not None:
2586+
plan_llm = getattr(base_method, "_plan_llm", None)
2587+
if call_llm is not _MISSING and call_llm is not None:
25652588
llm_client = call_llm
2566-
else:
2567-
plan_llm = getattr(base_method, "_plan_llm", None)
2568-
if plan_llm is not None:
2569-
from nooa.method_llm import resolve_method_llm
2589+
llm_selection_source = "call_site"
2590+
elif plan_llm is not None:
2591+
from nooa.method_llm import resolve_method_llm
25702592

2571-
plan_llm = resolve_method_llm(plan_llm, self.agent, method_name)
2572-
llm_client = plan_llm or getattr(self.agent, "_llm", None)
2593+
llm_client = resolve_method_llm(plan_llm, self.agent, method_name)
2594+
llm_selection_source = "decorator"
2595+
else:
2596+
llm_client = getattr(self.agent, "_llm", None)
2597+
llm_selection_source = "agent_default"
25732598
if llm_client is None:
25742599
raise RuntimeError(f"No LLM client available for {method_name}")
2600+
llm_model_name = getattr(llm_client, "model", "") or ""
25752601

25762602
# Resolve truncation config: method-level @strategy(truncation=...) > agent-level
25772603
method_truncation = getattr(base_method, "_strategy_truncation", None)
@@ -2615,6 +2641,10 @@ async def _execute_with_generation(
26152641
generation_id=generation_id,
26162642
parent_generation_id=parent_generation_id,
26172643
agent_call_id=self._agent_call_id,
2644+
**{
2645+
"llm.model_name": llm_model_name,
2646+
"llm.selection_source": llm_selection_source,
2647+
},
26182648
**strategy_kwargs, # Add strategy config parameters
26192649
)
26202650

@@ -2709,6 +2739,10 @@ async def _execute_with_generation(
27092739
call_token = _current_call_var.set(call)
27102740
method_token = _current_method_var.set(method)
27112741
llm_token = _current_llm_var.set(llm_client)
2742+
llm_model_name_token = _current_llm_model_name_var.set(llm_model_name)
2743+
llm_selection_source_token = _current_llm_selection_source_var.set(
2744+
llm_selection_source
2745+
)
27122746
truncation_token = _current_truncation_config_var.set(resolved_truncation)
27132747
event_format_token = _current_event_format_var.set(
27142748
resolved_truncation.event_format.model_dump()
@@ -2749,6 +2783,8 @@ async def _execute_with_generation(
27492783
_current_call_var.reset(call_token)
27502784
_current_method_var.reset(method_token)
27512785
_current_llm_var.reset(llm_token)
2786+
_current_llm_model_name_var.reset(llm_model_name_token)
2787+
_current_llm_selection_source_var.reset(llm_selection_source_token)
27522788
_current_truncation_config_var.reset(truncation_token)
27532789
_current_event_format_var.reset(event_format_token)
27542790
_decorator_context_var.reset(decorator_ctx_token)

src/nooa/runtime/method_wrapper.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import asyncio
14+
import inspect
1415
import logging
1516
from collections.abc import Callable
1617
from functools import wraps
@@ -127,9 +128,15 @@ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
127128
if hasattr(self, "runtime"):
128129
_fw_kwargs = {
129130
_name: kwargs.pop(_name)
130-
for _name in ("_session_locals", "_strategy", "llm")
131+
for _name in ("_session_locals", "_strategy")
131132
if _name in kwargs
132133
}
134+
try:
135+
_has_user_llm_param = "llm" in inspect.signature(original_func).parameters
136+
except (TypeError, ValueError):
137+
_has_user_llm_param = False
138+
if not _has_user_llm_param and "llm" in kwargs:
139+
_fw_kwargs["llm"] = kwargs.pop("llm")
133140
try:
134141
ArgumentValidator().validate(original_func, args, kwargs, _tc)
135142
finally:

0 commit comments

Comments
 (0)