Skip to content

feat(types): add framework-agnostic LLM type system#1745

Merged
Pouyanpi merged 8 commits intodevelopfrom
feat/langchain-decouple/stack-1-canonical-types
Apr 13, 2026
Merged

feat(types): add framework-agnostic LLM type system#1745
Pouyanpi merged 8 commits intodevelopfrom
feat/langchain-decouple/stack-1-canonical-types

Conversation

@Pouyanpi
Copy link
Copy Markdown
Collaborator

@Pouyanpi Pouyanpi commented Mar 25, 2026

Part of the LangChain decoupling stack:

  1. stack-1: canonical types (this PR)
  2. stack-2: adapter and framework registry (feat(llm): add LangChain adapter and framework registry #1759)
  3. stack-3: pipeline rewrite + caller migration (refactor(llm)!: atomic switch to LLMModel protocol #1760)
  4. stack-4: rename generate/stream to generate_async/stream_async (refactor(llm): rename generate/stream to generate_async/stream_async #1769)
  5. stack-5: remove LangChain imports from core modules (refactor(llm): remove LangChain imports from core modules #1770)
  6. stack-6: move LangChain implementations into integrations/langchain/ (refactor(llm): move LangChain implementations into integrations/langchain/ #1772)
  7. stack-7: framework-owned provider registry (refactor(llm): framework-owned provider registry #1773)
  8. stack-8: framework-agnostic test infrastructure (TODO)

Description

Introduce nemoguardrails/types.py with provider-independent data types and protocols that replace direct LangChain type dependencies in core code.

  • Add nemoguardrails/types.py with framework-agnostic data types (ChatMessage, Role, ToolCall, ToolCallFunction, LLMResponse, LLMResponseChunk, UsageInfo, FinishReason) and protocols (LLMModel, LLMFramework)
  • ChatMessage.from_dict() handles both OpenAI nested and legacy flat tool call formats, JSON string argument parsing, and role aliases (bot, human, developer)
  • ChatMessage.to_dict() / from_dict() roundtrip preserves all fields including provider_metadata
  • LLMModel protocol defines the generate() / stream() contract that all LLM adapters must implement
  • LLMFramework protocol defines the create_model() factory contract for pluggable backends

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR introduces nemoguardrails/types.py — a new module of framework-agnostic data types and protocols (ChatMessage, Role, ToolCall, LLMResponse, LLMResponseChunk, LLMModel, LLMFramework) as the first step in decoupling the core pipeline from LangChain. The concerns raised in prior review rounds (docstring accuracy for provider_metadata, missing JSON-type validation for tool call arguments) have been correctly addressed in the current implementation.

Confidence Score: 5/5

Safe to merge — no blocking issues found; all prior review concerns have been addressed.

Both files are new additions with no regressions. Previous P1 concerns (docstring accuracy, JSON argument type validation) are correctly resolved in the current implementation. Remaining observations are below P2 threshold and do not affect correctness or reliability.

No files require special attention.

Important Files Changed

Filename Overview
nemoguardrails/types.py New framework-agnostic type system; clean implementation with correct validation, role aliases, and provider_metadata handling via extra-key capture.
tests/test_types.py Comprehensive test suite covering all data types, Protocol conformance, roundtrip serialization, role aliases, and edge-case argument validation.

Class Diagram

%%{init: {'theme': 'neutral'}}%%
classDiagram
    class Role {
        <<enumeration>>
        USER = "user"
        ASSISTANT = "assistant"
        SYSTEM = "system"
        TOOL = "tool"
    }

    class ToolCallFunction {
        +str name
        +Dict arguments
    }

    class ToolCall {
        +str id
        +str type
        +ToolCallFunction function
        +to_dict() Dict
    }

    class UsageInfo {
        +int input_tokens
        +int output_tokens
        +int total_tokens
        +Optional~int~ reasoning_tokens
        +Optional~int~ cached_tokens
    }

    class ChatMessage {
        +Role role
        +Optional content
        +Optional~List~ToolCall~~ tool_calls
        +Optional~str~ tool_call_id
        +Optional~str~ name
        +Dict provider_metadata
        +from_user(content) ChatMessage$
        +from_assistant(content) ChatMessage$
        +from_system(content) ChatMessage$
        +from_tool(content, tool_call_id) ChatMessage$
        +to_dict() Dict
        +from_dict(d) ChatMessage$
    }

    class LLMResponse {
        +str content
        +Optional~str~ reasoning
        +Optional~List~ToolCall~~ tool_calls
        +Optional~str~ model
        +Optional~FinishReason~ finish_reason
        +Optional~UsageInfo~ usage
        +Optional~Dict~ provider_metadata
    }

    class LLMResponseChunk {
        +Optional~str~ delta_content
        +Optional~str~ delta_reasoning
        +Optional~List~ToolCall~~ delta_tool_calls
        +Optional~FinishReason~ finish_reason
        +Optional~UsageInfo~ usage
    }

    class LLMModel {
        <<Protocol>>
        +generate(prompt, stop, **kwargs) LLMResponse
        +stream(prompt, stop, **kwargs) AsyncIterator
        +model_name str
        +provider_name Optional~str~
        +provider_url Optional~str~
    }

    class LLMFramework {
        <<Protocol>>
        +create_model(model_name, provider_name, model_kwargs) LLMModel
    }

    ChatMessage --> Role
    ChatMessage --> ToolCall
    ToolCall --> ToolCallFunction
    LLMResponse --> ToolCall
    LLMResponse --> UsageInfo
    LLMResponseChunk --> ToolCall
    LLMResponseChunk --> UsageInfo
    LLMModel --> LLMResponse : generates
    LLMModel --> LLMResponseChunk : streams
    LLMFramework --> LLMModel : creates
Loading

Reviews (9): Last reviewed commit: "final fixes" | Re-trigger Greptile

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

Introduced a new nemoguardrails/types.py module defining core LLM data structures and backend interfaces, including Role enum, ToolCall/ToolCallFunction dataclasses, ChatMessage with serialization/deserialization, LLMResponse/Chunk, and LLMModel/LLMFramework protocols. Added comprehensive test suite validating all types.

Changes

Cohort / File(s) Summary
Core LLM Types Module
nemoguardrails/types.py
Defines Role enum, ToolCallFunction, ToolCall, UsageInfo, and ChatMessage dataclasses with factory constructors and bidirectional serialization (to_dict/from_dict). Handles role aliases, nested/legacy tool-call formats, JSON-encoded arguments, and provider metadata. Introduces LLMResponse/LLMResponseChunk dataclasses for complete/streaming outputs. Defines LLMModel and LLMFramework runtime-checkable protocols for backend interfaces.
Types Module Test Suite
tests/test_types.py
Comprehensive pytest validation of Role, ToolCallFunction, ToolCall, ChatMessage, LLMResponse, LLMResponseChunk, and UsageInfo. Covers factory constructors, field inclusion/omission rules, nested and legacy tool-call parsing, JSON argument handling, role alias mapping, provider metadata capture, round-trip serialization, and protocol satisfaction via mock classes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing a framework-agnostic LLM type system with new types and protocols in nemoguardrails/types.py.
Test Results For Major Changes ✅ Passed PR introduces major changes with comprehensive test suite (429 lines in test_types.py) validating new LLM type system, protocols, and serialization behavior.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/langchain-decouple/stack-1-canonical-types

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_types.py (1)

170-313: Add regression tests for unknown-key metadata capture and non-object JSON arguments.

Please add assertions that:

  1. unknown top-level keys are merged into provider_metadata, and
  2. JSON arguments that decode to non-objects (e.g., "[]") raise ValueError.
    These lock the critical from_dict edge behavior.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_types.py` around lines 170 - 313, Add two regression assertions to
the test suite for ChatMessage.from_dict: (1) call ChatMessage.from_dict with an
extra unknown top-level key (e.g., "unexpected_key": "v") and assert the
resulting msg.provider_metadata contains that key/value (reference
ChatMessage.from_dict and provider_metadata); (2) add a test case where a
tool_call.function.arguments is a JSON string that decodes to a non-object
(e.g., "[]") and assert ChatMessage.from_dict raises ValueError (reference
ChatMessage.from_dict and ToolCall.function.arguments). Ensure these are added
near the existing tool_calls/arguments tests so they run alongside related
cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nemoguardrails/types.py`:
- Around line 149-155: After deserializing raw_args (via json.loads) into
args_dict, ensure the result is a mapping/dict and not a JSON array/primitive;
if args_dict is not an instance of dict (e.g., list, int, str), raise a
ValueError indicating "Tool call arguments must be a JSON object" and include
the offending raw_args; update the branch handling raw_args (referencing
raw_args, args_dict, json.loads) to perform this type check and error raise so
downstream adapters only receive Dict[str, Any].
- Around line 121-175: from_dict in ChatMessage currently ignores unknown
top-level keys instead of preserving them in provider_metadata; update
ChatMessage.from_dict to collect all keys from the input dict `d` that are not
the known keys ("role", "content", "tool_calls", "tool_call_id", "name",
"provider_metadata") and merge them into the returned provider_metadata (also
merging any existing d.get("provider_metadata") dict), so provider-specific
fields are preserved. Keep existing behavior for parsing role, tool_calls
(ToolCall/ToolCallFunction), and JSON argument decoding, but ensure the final
cls(...) call uses the merged provider_metadata dict.

---

Nitpick comments:
In `@tests/test_types.py`:
- Around line 170-313: Add two regression assertions to the test suite for
ChatMessage.from_dict: (1) call ChatMessage.from_dict with an extra unknown
top-level key (e.g., "unexpected_key": "v") and assert the resulting
msg.provider_metadata contains that key/value (reference ChatMessage.from_dict
and provider_metadata); (2) add a test case where a tool_call.function.arguments
is a JSON string that decodes to a non-object (e.g., "[]") and assert
ChatMessage.from_dict raises ValueError (reference ChatMessage.from_dict and
ToolCall.function.arguments). Ensure these are added near the existing
tool_calls/arguments tests so they run alongside related cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 33cc0dc4-9b96-4c75-9b0f-8edfa02c00c0

📥 Commits

Reviewing files that changed from the base of the PR and between 94fc57e and 50e1d53.

📒 Files selected for processing (2)
  • nemoguardrails/types.py
  • tests/test_types.py

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Pouyanpi Pouyanpi marked this pull request as draft March 25, 2026 13:21
@Pouyanpi Pouyanpi marked this pull request as ready for review March 25, 2026 13:44
@Pouyanpi Pouyanpi force-pushed the feat/langchain-decouple/stack-1-canonical-types branch from 4bb2ddf to 8f7e8d9 Compare March 25, 2026 13:44
@NVIDIA-NeMo NVIDIA-NeMo deleted a comment from greptile-apps bot Mar 25, 2026
@Pouyanpi Pouyanpi self-assigned this Mar 30, 2026
@Pouyanpi Pouyanpi added the enhancement New feature or request label Mar 30, 2026
@Pouyanpi Pouyanpi requested a review from tgasser-nv March 30, 2026 08:17
Pouyanpi added 5 commits April 1, 2026 18:29
Introduce nemoguardrails/types.py with provider-independent data types
and protocols that replace direct LangChain type dependencies in core
code.
@Pouyanpi Pouyanpi force-pushed the feat/langchain-decouple/stack-1-canonical-types branch from 8563b47 to 320d754 Compare April 2, 2026 10:52
Only LangChain needs mode (chat vs text). Other frameworks ignore it.
Move mode into model_kwargs so each framework extracts what it needs.
@Pouyanpi
Copy link
Copy Markdown
Collaborator Author

Pouyanpi commented Apr 8, 2026

thanks @tgasser-nv, all the comments are addressed/resolved. For the tool.id i've opened this PR: #1775

Copy link
Copy Markdown
Collaborator

@tgasser-nv tgasser-nv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Only concern is the impedance mismatch between the Model pydantic object and provider_name, prodivder_url. Why do we need a second slightly different way of representing the same thing? Are you intending to deprecate Model in the future?

@Pouyanpi
Copy link
Copy Markdown
Collaborator Author

Thanks @tgasser-nv . re your question I see it this way: Model is the user facing config contract. LLMModel is the adapter author contract (Python protocol which we use internally and adapter authors could implement it).

provider_name/model_name/provider_url are explicit for someone implementing an adapter and conform to our existing internals: llm_call() parameters (model_name, model_provider), LLMCallInfo fields (llm_model_name, llm_provider_name), LLMCallException error context (model=, provider=, endpoint=), and OpenTelemetry tracing spans (GenAIAttributes.GEN_AI_PROVIDER_NAME). The protocol surfaces what the codebase already names this way.

The mapping happens in exactly one place (llmrails.py, init_llm_model(model_name=main_model.model, provider_name=main_model.engine, kwargs=kwargs)). There's no ongoing translation or lookup. config is read once at startup, the adapter is created, and from that point only LLMModel is used.

When you review the other PRs in the stack it becomes clearer how these fit together, but if it's still a point of debate we can rename in a follow-up PR before the release.

Copy link
Copy Markdown
Collaborator

@tgasser-nv tgasser-nv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Please take a look at the feedback, only a few small cleanups needed before meging

@Pouyanpi
Copy link
Copy Markdown
Collaborator Author

a final note about engine vs provider: we can have openai engine with a nim , vllm or a litellm proxy server.

@Pouyanpi Pouyanpi linked an issue Apr 13, 2026 that may be closed by this pull request
@Pouyanpi Pouyanpi merged commit 1ca7510 into develop Apr 13, 2026
7 checks passed
@Pouyanpi Pouyanpi deleted the feat/langchain-decouple/stack-1-canonical-types branch April 13, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants