Skip to content

Commit f5836c0

Browse files
Add test LLM provider to support testing (#764)
## Description <!-- Note: The pull request title will be included in the CHANGELOG. --> <!-- Provide a standalone description of changes in this PR. --> <!-- Reference any issues closed by this PR with "closes #1234". All PRs should have an issue they close--> Closes [AIQ-439](https://jirasw.nvidia.com/browse/AIQ-439) * Added a deterministic testing LLM (nat_test_llm) in the nvidia_nat_test plugin to validate workflows without real API calls. Supports a cycling `response_seq` and artificial latency via `delay_ms`. * Made the testing LLM adaptable to all five core frameworks (`LangChain`, `LlamaIndex`, `CrewAI`, `SemanticKernel`, `Agno`) * Added related tutorial and documents. * Added the test LLM to the package loader (packages/nvidia_nat_test/src/nat/test/register.py). * Added unit tests for `nat_test_llm` behavior (cycling, latency, and stream yielding a single chunk). * Fixed a deprecation warning for `model_fields` and a small typo. ## By Submitting this PR I confirm: - I am familiar with the [Contributing Guidelines](https://github.com/NVIDIA/NeMo-Agent-Toolkit/blob/develop/docs/source/resources/contributing.md). - We require that all contributors "sign-off" on their commits. This certifies that the contribution is your original work, or you have rights to submit it under the same license, or a compatible license. - Any contribution which contains commits that are not Signed-Off will not be accepted. - When the PR is ready for review, new or existing tests cover these changes. - When the PR is ready for review, the documentation is up to date with these changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a deterministic test LLM provider for development/CI that cycles predefined responses with optional per-call delay and integrates with multiple LLM frameworks. - **Documentation** - New tutorial and provider docs with install, YAML examples, CLI and programmatic usage, and links to LLM configuration guidance. - **Tests** - Comprehensive cross-framework tests validating response cycling, delays, special characters, independent configs, and persistence. - **Refactor** - Improved dependency-graph field handling. - **Style** - Minor documentation typo fix. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Daniel Wang <[email protected]>
1 parent 528ea98 commit f5836c0

File tree

9 files changed

+706
-3
lines changed

9 files changed

+706
-3
lines changed

docs/source/tutorials/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ limitations under the License.
2424
./add-tools-to-a-workflow.md
2525
./create-a-new-workflow.md
2626
./build-a-demo-agent-workflow-using-cursor-rules.md
27+
./testing-with-nat-test-llm.md
2728
```
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!--
2+
SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
18+
# Testing with `nat_test_llm`
19+
20+
Use `nat_test_llm` to quickly validate workflows during development and CI. It yields deterministic, cycling responses and avoids real API calls. It is not intended for production use.
21+
22+
## Prerequisites
23+
24+
- Install the testing plugin package:
25+
26+
```bash
27+
uv pip install nvidia-nat-test
28+
```
29+
30+
## Minimal YAML
31+
32+
The following YAML config defines a testing LLM and a simple `chat_completion` workflow that uses it.
33+
34+
```yaml
35+
llms:
36+
main:
37+
_type: nat_test_llm
38+
response_seq: [alpha, beta, gamma]
39+
delay_ms: 0
40+
workflow:
41+
_type: chat_completion
42+
llm_name: main
43+
system_prompt: "Say only the answer."
44+
```
45+
46+
Save this as `config.yml`.
47+
48+
## Run from the CLI
49+
50+
```bash
51+
nat run --config_file config.yml --input "What is 1 + 2?"
52+
```
53+
54+
You should see a response corresponding to the first item in `response_seq` (for example, `alpha`). Repeated runs will cycle through the sequence (`alpha`, `beta`, `gamma`, then repeat).
55+
56+
## Run programmatically
57+
58+
```python
59+
from nat.runtime.loader import load_workflow
60+
61+
async def main():
62+
async with load_workflow("config.yml") as workflow:
63+
async with workflow.run("What is 1 + 2?") as runner:
64+
result = await runner.result()
65+
print(result)
66+
```
67+
68+
## Notes
69+
70+
- `nat_test_llm` is for development and CI only. Do not use it in production.
71+
- To implement your own provider, see: [Adding an LLM Provider](../extend/adding-an-llm-provider.md).
72+
- For more about configuring LLMs, see: [LLMs](../workflows/llms/index.md).

docs/source/workflows/llms/index.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ limitations under the License.
1919

2020
## Supported LLM Providers
2121

22-
NeMo Agent toolkit supports the following LLM providers:
22+
NVIDIA NeMo Agent toolkit supports the following LLM providers:
2323
| Provider | Type | Description |
2424
|----------|------|-------------|
2525
| [NVIDIA NIM](https://build.nvidia.com) | `nim` | NVIDIA Inference Microservice (NIM) |
@@ -128,6 +128,32 @@ The Azure OpenAI LLM provider is defined by the {py:class}`~nat.llm.azure_openai
128128
`temperature` is model-gated and may not be supported by all models. See [Gated Fields](../../extend/gated-fields.md) for details.
129129
:::
130130

131+
## Testing Provider
132+
### `nat_test_llm`
133+
`nat_test_llm` is a development and testing provider intended for examples and CI. It is not intended for production use.
134+
135+
* Installation: `uv pip install nvidia-nat-test`
136+
* Purpose: Deterministic cycling responses for quick validation
137+
* Not for production
138+
139+
Minimal YAML example with `chat_completion`:
140+
141+
```yaml
142+
llms:
143+
main:
144+
_type: nat_test_llm
145+
response_seq: [alpha, beta, gamma]
146+
delay_ms: 0
147+
workflow:
148+
_type: chat_completion
149+
llm_name: main
150+
system_prompt: "Say only the answer."
151+
```
152+
153+
* Learn how to add your own LLM provider: [Adding an LLM Provider](../../extend/adding-an-llm-provider.md)
154+
<!-- vale off -->
155+
* See a short tutorial using YAML and `nat_test_llm`: [Testing with nat_test_llm](../../tutorials/testing-with-nat-test-llm.md)
156+
<!-- vale on -->
131157

132158
```{toctree}
133159
:caption: LLMs
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
# pylint: disable=unused-argument,missing-class-docstring,missing-function-docstring,import-outside-toplevel
17+
# pylint: disable=too-few-public-methods
18+
19+
import asyncio
20+
import time
21+
from collections.abc import AsyncGenerator
22+
from collections.abc import Iterator
23+
from itertools import cycle as iter_cycle
24+
from typing import Any
25+
26+
from pydantic import Field
27+
28+
from nat.builder.builder import Builder
29+
from nat.builder.framework_enum import LLMFrameworkEnum
30+
from nat.builder.llm import LLMProviderInfo
31+
from nat.cli.register_workflow import register_llm_client
32+
from nat.cli.register_workflow import register_llm_provider
33+
from nat.data_models.llm import LLMBaseConfig
34+
35+
36+
class TestLLMConfig(LLMBaseConfig, name="nat_test_llm"):
37+
"""Test LLM configuration."""
38+
__test__ = False
39+
response_seq: list[str] = Field(
40+
default=[],
41+
description="Returns the next element in order (wraps)",
42+
)
43+
delay_ms: int = Field(default=0, ge=0, description="Artificial per-call delay in milliseconds to mimic latency")
44+
45+
46+
class _ResponseChooser:
47+
"""
48+
Helper class to choose the next response according to config using itertools.cycle and provide synchronous and
49+
asynchronous sleep functions.
50+
"""
51+
52+
def __init__(self, response_seq: list[str], delay_ms: int):
53+
self._cycler = iter_cycle(response_seq) if response_seq else None
54+
self._delay_ms = delay_ms
55+
56+
def next_response(self) -> str:
57+
"""Return the next response in the cycle, or an empty string if no responses are configured."""
58+
if self._cycler is None:
59+
return ""
60+
return next(self._cycler)
61+
62+
def sync_sleep(self) -> None:
63+
time.sleep(self._delay_ms / 1000.0)
64+
65+
async def async_sleep(self) -> None:
66+
await asyncio.sleep(self._delay_ms / 1000.0)
67+
68+
69+
@register_llm_provider(config_type=TestLLMConfig)
70+
async def test_llm_provider(config: TestLLMConfig, builder: Builder):
71+
"""Register the `nat_test_llm` provider for the NAT registry."""
72+
yield LLMProviderInfo(config=config, description="Test LLM provider")
73+
74+
75+
@register_llm_client(config_type=TestLLMConfig, wrapper_type=LLMFrameworkEnum.LANGCHAIN)
76+
async def test_llm_langchain(config: TestLLMConfig, builder: Builder):
77+
"""LLM client for LangChain."""
78+
79+
chooser = _ResponseChooser(response_seq=config.response_seq, delay_ms=config.delay_ms)
80+
81+
class LangChainTestLLM:
82+
83+
def invoke(self, messages: Any, **_kwargs: Any) -> str:
84+
chooser.sync_sleep()
85+
return chooser.next_response()
86+
87+
async def ainvoke(self, messages: Any, **_kwargs: Any) -> str:
88+
await chooser.async_sleep()
89+
return chooser.next_response()
90+
91+
def stream(self, messages: Any, **_kwargs: Any) -> Iterator[str]:
92+
chooser.sync_sleep()
93+
yield chooser.next_response()
94+
95+
async def astream(self, messages: Any, **_kwargs: Any) -> AsyncGenerator[str]:
96+
await chooser.async_sleep()
97+
yield chooser.next_response()
98+
99+
yield LangChainTestLLM()
100+
101+
102+
@register_llm_client(config_type=TestLLMConfig, wrapper_type=LLMFrameworkEnum.LLAMA_INDEX)
103+
async def test_llm_llama_index(config: TestLLMConfig, builder: Builder):
104+
105+
try:
106+
from llama_index.core.base.llms.types import ChatMessage
107+
from llama_index.core.base.llms.types import ChatResponse
108+
except ImportError as exc:
109+
raise ImportError("llama_index is required for using the test_llm with llama_index. "
110+
"Please install the `nvidia-nat-llama-index` package. ") from exc
111+
112+
chooser = _ResponseChooser(response_seq=config.response_seq, delay_ms=config.delay_ms)
113+
114+
class LITestLLM:
115+
116+
def chat(self, messages: list[Any] | None = None, **_kwargs: Any) -> ChatResponse:
117+
chooser.sync_sleep()
118+
return ChatResponse(message=ChatMessage(chooser.next_response()))
119+
120+
async def achat(self, messages: list[Any] | None = None, **_kwargs: Any) -> ChatResponse:
121+
await chooser.async_sleep()
122+
return ChatResponse(message=ChatMessage(chooser.next_response()))
123+
124+
def stream_chat(self, messages: list[Any] | None = None, **_kwargs: Any) -> Iterator[ChatResponse]:
125+
chooser.sync_sleep()
126+
yield ChatResponse(message=ChatMessage(chooser.next_response()))
127+
128+
async def astream_chat(self,
129+
messages: list[Any] | None = None,
130+
**_kwargs: Any) -> AsyncGenerator[ChatResponse, None]:
131+
await chooser.async_sleep()
132+
yield ChatResponse(message=ChatMessage(chooser.next_response()))
133+
134+
yield LITestLLM()
135+
136+
137+
@register_llm_client(config_type=TestLLMConfig, wrapper_type=LLMFrameworkEnum.CREWAI)
138+
async def test_llm_crewai(config: TestLLMConfig, builder: Builder):
139+
"""LLM client for CrewAI."""
140+
141+
chooser = _ResponseChooser(response_seq=config.response_seq, delay_ms=config.delay_ms)
142+
143+
class CrewAITestLLM:
144+
145+
def call(self, messages: list[dict[str, str]] | None = None, **kwargs: Any) -> str:
146+
chooser.sync_sleep()
147+
return chooser.next_response()
148+
149+
yield CrewAITestLLM()
150+
151+
152+
@register_llm_client(config_type=TestLLMConfig, wrapper_type=LLMFrameworkEnum.SEMANTIC_KERNEL)
153+
async def test_llm_semantic_kernel(config: TestLLMConfig, builder: Builder):
154+
"""LLM client for SemanticKernel."""
155+
156+
try:
157+
from semantic_kernel.contents.chat_message_content import ChatMessageContent
158+
from semantic_kernel.contents.utils.author_role import AuthorRole
159+
except ImportError as exc:
160+
raise ImportError("Semantic Kernel is required for using the test_llm with semantic_kernel. "
161+
"Please install the `nvidia-nat-semantic-kernel` package. ") from exc
162+
163+
chooser = _ResponseChooser(response_seq=config.response_seq, delay_ms=config.delay_ms)
164+
165+
class SKTestLLM:
166+
167+
async def get_chat_message_contents(self, chat_history: Any, **_kwargs: Any) -> list[ChatMessageContent]:
168+
await chooser.async_sleep()
169+
text = chooser.next_response()
170+
return [ChatMessageContent(role=AuthorRole.ASSISTANT, content=text)]
171+
172+
async def get_streaming_chat_message_contents(self, chat_history: Any,
173+
**_kwargs: Any) -> AsyncGenerator[ChatMessageContent, None]:
174+
await chooser.async_sleep()
175+
text = chooser.next_response()
176+
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content=text)
177+
178+
yield SKTestLLM()
179+
180+
181+
@register_llm_client(config_type=TestLLMConfig, wrapper_type=LLMFrameworkEnum.AGNO)
182+
async def test_llm_agno(config: TestLLMConfig, builder: Builder):
183+
"""LLM client for agno."""
184+
185+
chooser = _ResponseChooser(response_seq=config.response_seq, delay_ms=config.delay_ms)
186+
187+
class AgnoTestLLM:
188+
189+
def invoke(self, messages: Any | None = None, **_kwargs: Any) -> str:
190+
chooser.sync_sleep()
191+
return chooser.next_response()
192+
193+
async def ainvoke(self, messages: Any | None = None, **_kwargs: Any) -> str:
194+
await chooser.async_sleep()
195+
return chooser.next_response()
196+
197+
def invoke_stream(self, messages: Any | None = None, **_kwargs: Any) -> Iterator[str]:
198+
chooser.sync_sleep()
199+
yield chooser.next_response()
200+
201+
async def ainvoke_stream(self, messages: Any | None = None, **_kwargs: Any) -> AsyncGenerator[str, None]:
202+
await chooser.async_sleep()
203+
yield chooser.next_response()
204+
205+
yield AgnoTestLLM()

packages/nvidia_nat_test/src/nat/test/register.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@
2121
from . import embedder
2222
from . import functions
2323
from . import memory
24+
from . import llm
File renamed without changes.

0 commit comments

Comments
 (0)