Skip to content

Commit 28dc4f9

Browse files
Add LLM routing metadata to generation observability 🤖🤖🤖
Signed-off-by: Clement Pakkam Isaac <232634418+cpakkamisaac-sae@users.noreply.github.com>
1 parent ec4bd58 commit 28dc4f9

5 files changed

Lines changed: 334 additions & 4 deletions

File tree

examples/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ class DataAgent(Agent, llm=llm):
547547

548548
### LLM cascading resolution
549549

550-
Configure LLMs at any granularity — class default, method override, instance override — for cost/latency optimization, A/B testing, or gradual rollouts.
550+
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.
551551

552552
```python
553553
class MyAgent(Agent, llm=default_llm): # 1. class default
@@ -557,6 +557,7 @@ class MyAgent(Agent, llm=default_llm): # 1. class default
557557
...
558558

559559
agent = MyAgent(llm=different_llm) # 4. instance override
560+
result = await agent.complex_task(llm=temporary_llm) # 5. per-call override
560561
```
561562

562563
### Event-driven history

src/nooa/runtime/actor.py

Lines changed: 23 additions & 3 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):
@@ -2530,13 +2532,18 @@ async def _execute_with_generation(
25302532
method_name: str,
25312533
) -> Any:
25322534
"""Execute a method that needs LLM generation."""
2535+
base_method = getattr(method, "__func__", method)
2536+
try:
2537+
has_user_llm_param = "llm" in inspect.signature(method).parameters
2538+
except (TypeError, ValueError):
2539+
has_user_llm_param = False
2540+
25332541
# Extract framework parameters (don't pass to generated method)
25342542
call_strategy = kwargs.pop("_strategy", None)
2535-
call_llm = kwargs.pop("llm", None)
2543+
call_llm = kwargs.pop("llm", _MISSING) if not has_user_llm_param else _MISSING
25362544
call_session_locals = kwargs.pop("_session_locals", None)
25372545

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

25422549
# DEBUG: Log strategy retrieval for nested method debugging
@@ -2559,9 +2566,18 @@ async def _execute_with_generation(
25592566

25602567
# Resolve LLM client with priority: call-level > @strategy decorator > agent's default
25612568
plan_llm = getattr(base_method, "_plan_llm", None)
2562-
llm_client = call_llm or plan_llm or getattr(self.agent, "_llm", None)
2569+
if call_llm is not _MISSING and call_llm is not None:
2570+
llm_client = call_llm
2571+
llm_selection_source = "call_site"
2572+
elif plan_llm is not None:
2573+
llm_client = plan_llm
2574+
llm_selection_source = "decorator"
2575+
else:
2576+
llm_client = getattr(self.agent, "_llm", None)
2577+
llm_selection_source = "agent_default"
25632578
if llm_client is None:
25642579
raise RuntimeError(f"No LLM client available for {method_name}")
2580+
llm_model_name = getattr(llm_client, "model", "") or ""
25652581

25662582
# Resolve truncation config: method-level @strategy(truncation=...) > agent-level
25672583
method_truncation = getattr(base_method, "_strategy_truncation", None)
@@ -2605,6 +2621,10 @@ async def _execute_with_generation(
26052621
generation_id=generation_id,
26062622
parent_generation_id=parent_generation_id,
26072623
agent_call_id=self._agent_call_id,
2624+
**{
2625+
"llm.model_name": llm_model_name,
2626+
"llm.selection_source": llm_selection_source,
2627+
},
26082628
**strategy_kwargs, # Add strategy config parameters
26092629
)
26102630

src/nooa/runtime/method_wrapper.py

Lines changed: 12 additions & 0 deletions
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
@@ -34,6 +35,8 @@
3435

3536
logger = logging.getLogger(__name__)
3637

38+
_MISSING = object()
39+
3740

3841
async def _flush_litellm_journal() -> None:
3942
# Three yields drain litellm's GLOBAL_LOGGING_WORKER chain before joining the
@@ -117,12 +120,21 @@ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
117120
# Strip framework kwargs before validation — they're consumed by
118121
# _execute_with_generation, not the user's method signature.
119122
_fw_session_locals = kwargs.pop("_session_locals", None)
123+
_fw_llm = _MISSING
124+
try:
125+
_has_user_llm_param = "llm" in inspect.signature(original_func).parameters
126+
except (TypeError, ValueError):
127+
_has_user_llm_param = False
128+
if not _has_user_llm_param:
129+
_fw_llm = kwargs.pop("llm", _MISSING)
120130
try:
121131
ArgumentValidator().validate(original_func, args, kwargs, _tc)
122132
finally:
123133
# Restore so _execute_with_generation can pop them
124134
if _fw_session_locals is not None:
125135
kwargs["_session_locals"] = _fw_session_locals
136+
if _fw_llm is not _MISSING:
137+
kwargs["llm"] = _fw_llm
126138

127139
# Strategy resolution if not provided
128140
if resolved_strategy is None:
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""LLM routing controls and generation observability."""
4+
5+
from __future__ import annotations
6+
7+
from collections.abc import Iterator
8+
from typing import Any, cast
9+
10+
import pytest
11+
12+
from nooa import Agent, strategy
13+
from nooa.runtime.hooks import set_hooks
14+
from nooa.strategies import PredictStrategy
15+
from nooa.unifiedllm import FakeLLMClient, LLMResponse
16+
17+
18+
def _resp(value: str) -> LLMResponse:
19+
return LLMResponse(
20+
raw_response=None,
21+
content=f'{{"value": "{value}"}}',
22+
tool_calls=[],
23+
finish_reason="stop",
24+
assistant_message={"role": "assistant", "content": f'{{"value": "{value}"}}'},
25+
)
26+
27+
28+
def _llm(model: str, value: str) -> FakeLLMClient:
29+
client = FakeLLMClient(scripted_responses=[_resp(value)])
30+
client.model = model
31+
return client
32+
33+
34+
class RoutingHooks:
35+
"""Capture generation hook metadata for routing assertions."""
36+
37+
def __init__(self) -> None:
38+
self.generations: list[dict[str, Any]] = []
39+
40+
def before_generation(
41+
self,
42+
agent: Any,
43+
method_name: str,
44+
strategy: str,
45+
generation_id: str,
46+
parent_generation_id: str | None,
47+
**kwargs: Any,
48+
) -> dict[str, Any]:
49+
self.generations.append(
50+
{
51+
"method_name": method_name,
52+
"strategy": strategy,
53+
"generation_id": generation_id,
54+
**kwargs,
55+
}
56+
)
57+
return {"generation_id": generation_id}
58+
59+
def after_generation(
60+
self,
61+
agent: Any,
62+
method_name: str,
63+
result: Any,
64+
exception: Exception | None,
65+
context: Any,
66+
generation_id: str,
67+
) -> None:
68+
pass
69+
70+
def on_messages_built(
71+
self,
72+
agent: Any,
73+
method_name: str,
74+
messages: list[dict[str, Any]],
75+
generation_id: str,
76+
**kwargs: Any,
77+
) -> None:
78+
pass
79+
80+
def before_agent_call(
81+
self,
82+
agent: Any,
83+
method_name: str,
84+
args: tuple[Any, ...],
85+
kwargs: dict[str, Any],
86+
call_id: str,
87+
parent_call_id: str | None,
88+
**extra: Any,
89+
) -> dict[str, Any]:
90+
return {"call_id": call_id}
91+
92+
def after_agent_call(
93+
self,
94+
agent: Any,
95+
method_name: str,
96+
result: Any,
97+
exception: Exception | None,
98+
context: Any,
99+
**kwargs: Any,
100+
) -> None:
101+
pass
102+
103+
def before_code_execution(
104+
self,
105+
agent: Any,
106+
code: str,
107+
execution_id: str,
108+
generation_id: str | None = None,
109+
**kwargs: Any,
110+
) -> dict[str, Any]:
111+
return {"execution_id": execution_id}
112+
113+
def after_code_execution(
114+
self,
115+
agent: Any,
116+
code: str,
117+
result: Any,
118+
exception: Exception | None,
119+
context: Any,
120+
execution_id: str,
121+
**kwargs: Any,
122+
) -> None:
123+
pass
124+
125+
def before_method_invocation(
126+
self,
127+
agent: Any,
128+
method_name: str,
129+
args: tuple[Any, ...],
130+
kwargs: dict[str, Any],
131+
invocation_id: str,
132+
**extra: Any,
133+
) -> dict[str, Any]:
134+
return {"invocation_id": invocation_id}
135+
136+
def after_method_invocation(
137+
self,
138+
agent: Any,
139+
method_name: str,
140+
result: Any,
141+
exception: Exception | None,
142+
context: Any,
143+
invocation_id: str,
144+
**kwargs: Any,
145+
) -> None:
146+
pass
147+
148+
def before_tool_execution(
149+
self,
150+
agent: Any,
151+
tool_name: str,
152+
arguments: dict[str, Any],
153+
execution_id: str,
154+
generation_id: str | None = None,
155+
**kwargs: Any,
156+
) -> dict[str, Any]:
157+
return {"execution_id": execution_id}
158+
159+
def after_tool_execution(
160+
self,
161+
agent: Any,
162+
tool_name: str,
163+
arguments: dict[str, Any],
164+
result: Any,
165+
exception: Exception | None,
166+
context: Any,
167+
execution_id: str,
168+
**kwargs: Any,
169+
) -> None:
170+
pass
171+
172+
173+
@pytest.fixture
174+
def routing_hooks() -> Iterator[RoutingHooks]:
175+
hooks = RoutingHooks()
176+
set_hooks(cast(Any, hooks))
177+
try:
178+
yield hooks
179+
finally:
180+
set_hooks(None)
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_call_site_llm_override_wins_and_is_observable(routing_hooks: RoutingHooks) -> None:
185+
default_llm = _llm("default-model", "default")
186+
decorator_llm = _llm("decorator-model", "decorator")
187+
override_llm = _llm("override-model", "override")
188+
189+
class RoutingAgent(Agent, llm=default_llm):
190+
@strategy(PredictStrategy(), llm=decorator_llm)
191+
async def summarize(self, text: str) -> str:
192+
"""Summarize text."""
193+
...
194+
195+
agent = RoutingAgent()
196+
197+
assert await agent.summarize("hello", llm=override_llm) == "override" # pyright: ignore[reportCallIssue]
198+
199+
assert default_llm.call_count == 0
200+
assert decorator_llm.call_count == 0
201+
assert override_llm.call_count == 1
202+
assert routing_hooks.generations[-1]["llm.model_name"] == "override-model"
203+
assert routing_hooks.generations[-1]["llm.selection_source"] == "call_site"
204+
205+
206+
@pytest.mark.asyncio
207+
async def test_decorator_llm_selection_is_observable(routing_hooks: RoutingHooks) -> None:
208+
default_llm = _llm("default-model", "default")
209+
decorator_llm = _llm("decorator-model", "decorator")
210+
211+
class RoutingAgent(Agent, llm=default_llm):
212+
@strategy(PredictStrategy(), llm=decorator_llm)
213+
async def summarize(self, text: str) -> str:
214+
"""Summarize text."""
215+
...
216+
217+
agent = RoutingAgent()
218+
219+
assert await agent.summarize("hello") == "decorator"
220+
221+
assert default_llm.call_count == 0
222+
assert decorator_llm.call_count == 1
223+
assert routing_hooks.generations[-1]["llm.model_name"] == "decorator-model"
224+
assert routing_hooks.generations[-1]["llm.selection_source"] == "decorator"
225+
226+
227+
@pytest.mark.asyncio
228+
async def test_agent_default_llm_selection_is_observable(routing_hooks: RoutingHooks) -> None:
229+
default_llm = _llm("default-model", "default")
230+
231+
class RoutingAgent(Agent, llm=default_llm):
232+
@strategy(PredictStrategy())
233+
async def summarize(self, text: str) -> str:
234+
"""Summarize text."""
235+
...
236+
237+
agent = RoutingAgent()
238+
239+
assert await agent.summarize("hello") == "default"
240+
241+
assert default_llm.call_count == 1
242+
assert routing_hooks.generations[-1]["llm.model_name"] == "default-model"
243+
assert routing_hooks.generations[-1]["llm.selection_source"] == "agent_default"
244+
245+
246+
@pytest.mark.asyncio
247+
async def test_user_parameter_named_llm_is_not_consumed_as_framework_override(
248+
routing_hooks: RoutingHooks,
249+
) -> None:
250+
default_llm = _llm("default-model", "ok")
251+
252+
class RoutingAgent(Agent, llm=default_llm):
253+
@strategy(PredictStrategy())
254+
async def summarize(self, llm: str) -> str:
255+
"""Summarize using the user-supplied label."""
256+
...
257+
258+
agent = RoutingAgent()
259+
260+
assert await agent.summarize(llm="customer-visible-label") == "ok"
261+
262+
assert default_llm.call_count == 1
263+
assert routing_hooks.generations[-1]["llm.model_name"] == "default-model"
264+
assert routing_hooks.generations[-1]["llm.selection_source"] == "agent_default"

0 commit comments

Comments
 (0)