feat(llm): add LangChain adapter and framework registry#1759
feat(llm): add LangChain adapter and framework registry#1759Pouyanpi wants to merge 2 commits intofeat/langchain-decouple/stack-1-canonical-typesfrom
Conversation
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.
Greptile SummaryThis PR is the second stack in the LangChain decoupling series, introducing a Key observations:
|
| 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
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- 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
7cac41e to
3982772
Compare
Part of the LangChain decoupling stack:
Description
LangChainLLMAdapterwrappingBaseChatModelbehindLLMModelprotocolLangChainFrameworkimplementingcreate_model()register_framework()/get_framework()/set_default_framework()chatmessage_to_langchain_message()andchatmessages_to_langchain_messages()conversion utilsprovider_urlextracts endpoint from LangChain LLM attributesPure additive. No existing files modified except
message_utils.py.