Skip to content

feat(llm): add LangChain adapter and framework registry#1759

Draft
Pouyanpi wants to merge 2 commits intofeat/langchain-decouple/stack-1-canonical-typesfrom
feat/langchain-decouple/stack-2-adapter-layer
Draft

feat(llm): add LangChain adapter and framework registry#1759
Pouyanpi wants to merge 2 commits intofeat/langchain-decouple/stack-1-canonical-typesfrom
feat/langchain-decouple/stack-2-adapter-layer

Conversation

@Pouyanpi
Copy link
Copy Markdown
Collaborator

@Pouyanpi Pouyanpi commented Apr 2, 2026

Part of the LangChain decoupling stack:

  1. stack-1: canonical types(feat(types): add framework-agnostic LLM type system #1745)
  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)

Description

  • LangChainLLMAdapter wrapping BaseChatModel behind LLMModel protocol
  • LangChainFramework implementing create_model()
  • Framework registry with register_framework() / get_framework() / set_default_framework()
  • chatmessage_to_langchain_message() and chatmessages_to_langchain_messages() conversion utils
  • provider_url extracts endpoint from LangChain LLM attributes

Pure additive. No existing files modified except message_utils.py.

Add LangChainLLMAdapter wrapping BaseChatModel behind the LLMModel
protocol, LangChainFramework implementing create_model(), and a
pluggable framework registry. Pure additive, no existing files
modified. Part of the LangChain decoupling epic

feat(llm): implement provider_url on LangChainLLMAdapter

Extract endpoint URL from the underlying LangChain LLM by checking
common base URL attributes and the nested client object.
@Pouyanpi Pouyanpi self-assigned this Apr 2, 2026
@Pouyanpi Pouyanpi marked this pull request as draft April 2, 2026 12:51
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 2, 2026

Greptile Summary

This PR is the second stack in the LangChain decoupling series, introducing a LangChainLLMAdapter that wraps any LangChain BaseChatModel behind a framework-agnostic LLMModel protocol, a LangChainFramework factory, a lazy-loaded framework registry (register_framework / get_framework / set_default_framework), and ChatMessage↔LangChain message conversion utilities. The changes are purely additive — only message_utils.py from an existing file is modified.

Key observations:

  • P1 — Multi-modal content not normalised: _langchain_response_to_llm_response and _langchain_chunk_to_llm_response_chunk both check if content is None before falling back, but AIMessage.content for Anthropic Claude (and other multi-modal providers) can be a List[Union[str, dict]] containing text, thinking, and tool-use blocks. Because a list is not None, it is forwarded directly into LLMResponse.content: str / LLMResponseChunk.delta_content: Optional[str], violating the type contract and causing failures in any downstream code that concatenates or formats the content string.
  • Previously flagged issues (silent HumanMessage fallback, missing total_tokens fallback, set_default_framework accepting unknown names, isinstance(model_kwargs, Dict)) appear to have been addressed in this revision.
  • The framework registry correctly guards lazy-registered frameworks (_LAZY_FRAMEWORKS) and raises clearly on unknown names.
  • Test coverage is solid, but no test exercises a multi-modal (list-content) LangChain response."

Confidence Score: 4/5

Safe to merge after addressing the list-content normalisation bug in the response/chunk converters.

One P1 issue remains: multi-modal LangChain responses (notably Anthropic Claude with thinking or tool-use blocks) produce list-typed AIMessage.content that is forwarded unchecked into LLMResponse.content: str, breaking downstream string operations. All previously flagged concerns appear resolved in this revision. The rest of the code is well-structured with good test coverage.

nemoguardrails/integrations/langchain/llm_adapter.py — specifically _langchain_response_to_llm_response (line 284) and _langchain_chunk_to_llm_response_chunk (line 371) need content-list normalisation.

Important Files Changed

Filename Overview
nemoguardrails/integrations/langchain/llm_adapter.py New adapter wrapping LangChain BaseChatModel behind LLMModel protocol; P1 issue: list-typed content from multi-modal/Claude responses is passed directly to LLMResponse.content (typed str), causing downstream type violations.
nemoguardrails/integrations/langchain/message_utils.py Adds ChatMessage↔LangChain message conversion utilities; now raises ValueError for unknown roles (previously silently fell back to HumanMessage — that concern is resolved).
nemoguardrails/llm/frameworks.py New framework registry with lazy LangChain registration; set_default_framework now validates against _LAZY_FRAMEWORKS, resolving the prior silent-acceptance concern.
tests/llm/test_frameworks.py Good test coverage for registry lifecycle, lazy init, env-var default, duplicate registration, and unknown-key errors.
tests/test_langchain_llm_adapter.py Comprehensive adapter tests covering generate, stream, bind, reasoning-model filtering, and tool-call mapping; no test exercises list-content responses from multi-modal models.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Adapter as LangChainLLMAdapter
    participant Registry as frameworks.py
    participant LC as LangChain BaseChatModel
    participant Conv as message_utils.py

    Caller->>Registry: get_framework("langchain")
    Registry-->>Caller: LangChainFramework (lazy init)

    Caller->>Registry: framework.create_model(model_name, provider_name, kwargs)
    Registry->>LC: init_langchain_model(...)
    LC-->>Registry: BaseChatModel instance
    Registry-->>Caller: LangChainLLMAdapter(raw_llm)

    Caller->>Adapter: await generate(prompt, **kwargs)
    Adapter->>Adapter: _filter_reasoning_model_params(kwargs)
    Adapter->>Adapter: llm.bind(**filtered_kwargs)
    Adapter->>Conv: chatmessages_to_langchain_messages(prompt)
    Conv-->>Adapter: List[BaseMessage]
    Adapter->>LC: await ainvoke(messages, stop=stop)
    LC-->>Adapter: AIMessage
    Adapter->>Adapter: _langchain_response_to_llm_response(response)
    Adapter-->>Caller: LLMResponse

    Caller->>Adapter: stream(prompt, **kwargs)
    Adapter->>LC: astream(messages, stop=stop)
    loop Each chunk
        LC-->>Adapter: AIMessageChunk
        Adapter->>Adapter: _langchain_chunk_to_llm_response_chunk(chunk)
        Adapter-->>Caller: LLMResponseChunk
    end
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: nemoguardrails/integrations/langchain/llm_adapter.py
Line: 284-295

Comment:
**List content from multi-modal responses not handled**

`LLMResponse.content` is typed as `str`, but LangChain's `AIMessage.content` can be a `List[Union[str, dict]]` when a model returns multi-modal or structured content — most notably Anthropic Claude when thinking blocks, tool-use blocks, or image responses are present. In those cases the falsy check `if content is None` passes (a list is not `None`), so the list is forwarded directly into `LLMResponse(content=<list>)`, violating the `str` contract and likely causing `TypeError` or assertion failures in downstream consumers that concatenate or format the content.

The same problem affects `_langchain_chunk_to_llm_response_chunk` (line 372–376), where `LLMResponseChunk.delta_content: Optional[str]` would receive a list for streaming multi-modal chunks.

Consider normalising the value before assignment:

```python
# In _langchain_response_to_llm_response
content = getattr(response, "content", None)
if isinstance(content, list):
    # Extract text from content blocks; tool-use blocks are handled via tool_calls
    content = "".join(
        block.get("text", "") if isinstance(block, dict) else str(block)
        for block in content
        if not (isinstance(block, dict) and block.get("type") in ("tool_use", "tool_call"))
    )
elif content is None:
    content = str(response)
```

Apply the same normalisation in `_langchain_chunk_to_llm_response_chunk`.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix(llm): address review feedback on ada..." | Re-trigger Greptile

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 2, 2026

Codecov Report

❌ Patch coverage is 82.70042% with 41 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...moguardrails/integrations/langchain/llm_adapter.py 83.93% 31 Missing ⚠️
...guardrails/integrations/langchain/message_utils.py 41.17% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Pouyanpi Pouyanpi added this to the v0.22.0 milestone Apr 2, 2026
@Pouyanpi Pouyanpi marked this pull request as ready for review April 2, 2026 13:51
- Raise ValueError for unsupported ChatMessage roles
- Return "community" for 2-segment langchain_community paths
- Log debug when temperature is stripped for reasoning models
- Validate set_default_framework against known frameworks
- Fall back to input+output for missing total_tokens
- Use builtin dict in isinstance check
@Pouyanpi Pouyanpi force-pushed the feat/langchain-decouple/stack-2-adapter-layer branch from 7cac41e to 3982772 Compare April 2, 2026 13:52
@Pouyanpi Pouyanpi marked this pull request as draft April 2, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant