headroom/tests/test_integrations/langchain
Priyanshu Sharma 359004646b
fix(langchain): disable streaming on wrapped model during ainvoke() (#1287)
## Description

When a wrapped `ChatOpenAI` model is configured with `streaming=True`,
calling `ainvoke()` (the non-streaming async API) on the resulting
`HeadroomChatModel` crashes with `AttributeError: 'AsyncStream' object
has no attribute 'model_dump'`. This happens because `_agenerate()`
passes through to the wrapped model's `_agenerate()`, which — when
`streaming=True` — returns a raw OpenAI SDK `AsyncStream` object instead
of a LangChain `ChatResult`. The caller then tries to call
`.model_dump()` on the stream, which doesn't have that method.

`_agenerate()` now detects `streaming=True` on the wrapped model and
temporarily disables it for the duration of the non-streaming call, then
restores it in a `finally` block.

Closes #1285

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/integrations/langchain/chat_model.py`: Modified
`_agenerate()` to detect `streaming=True` on the wrapped model,
temporarily set it to `False` for the duration of the non-streaming
call, and restore it in a `finally` block (even on exceptions).
Gracefully handles models without a `streaming` attribute or immutable
fields.
- `tests/test_integrations/langchain/test_chat_model.py`: Added
`TestAinvokeStreamingTrue` with 5 test cases covering the core fix,
streaming state restoration, exception safety, and passthrough for
models without `streaming`.
- `CHANGELOG.md`: Added bug fix entry under Unreleased → Bug Fixes.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_integrations/langchain/test_chat_model.py -k TestAinvokeStreamingTrue
5 passed, 39 deselected in 4.14s

$ python -m pytest tests/test_integrations/langchain/test_chat_model.py -k "not Ollama and not RealLangChain"
35 passed, 9 deselected in 4.62s

$ ruff check headroom/integrations/langchain/chat_model.py tests/test_integrations/langchain/test_chat_model.py
All checks passed!

$ ruff format --check headroom/integrations/langchain/chat_model.py tests/test_integrations/langchain/test_chat_model.py
2 files already formatted
```

Verification that tests catch the bug (reverted only `chat_model.py`,
ran tests):

```text
test_agenerate_returns_chatresult_with_streaming_true FAILED
  assert False = isinstance(<FakeAsyncStream object>, ChatResult)
test_streaming_disabled_during_agenerate_call FAILED
  assert [True] == [False]  # streaming was NOT disabled during the call
```

## Real Behavior Proof

- Environment: Linux 6.17.0, Python 3.11.14, langchain-core 1.4.8,
pytest 9.1.1, pytest-asyncio 1.4.0
- Exact command / steps: `uv pip install -e ".[dev,langchain]"` then
`python -m pytest tests/test_integrations/langchain/test_chat_model.py
-k TestAinvokeStreamingTrue` then full module suite with `-k "not Ollama
and not RealLangChain"`
- Observed result: 5/5 new tests pass, 35/35 existing tests pass, lint
clean. Tests fail without the fix (2 failures matching the bug).
- Not tested: Real OpenAI API calls (no API key available). Mock-based
test simulates `ChatOpenAI`'s streaming behavior faithfully — when
`streaming=True`, `_agenerate` returns an `AsyncStream`-like object;
when `streaming=False`, it returns a proper `ChatResult`.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

- `mypy` was not run as it is not part of the local dev dependencies in
this environment. The fix is straightforward attribute access with
`getattr`/`setattr` and does not introduce new type complexities.
- The fix is minimal: `ainvoke()` is the non-streaming API, so it should
never trigger streaming. Temporarily disabling `streaming` on the
wrapped model is the safest approach — the setting is always restored in
a `finally` block.
- If `streaming` is an immutable (frozen pydantic) field, the code
catches the exception and falls through without crashing. The caller
would need to disable `streaming` on the wrapped model directly in that
case.
2026-06-22 22:11:39 -05:00
..
__init__.py Add seamless LangChain integration 2026-01-14 16:03:34 -08:00
test_agents.py fix: improve error handling and add comprehensive test coverage 2026-01-27 16:08:36 -08:00
test_chat_model.py fix(langchain): disable streaming on wrapped model during ainvoke() (#1287) 2026-06-22 22:11:39 -05:00
test_evals.py test(langchain): seed random per-test to fix flaky first/last anchor eval 2026-04-29 19:20:34 -07:00
test_extended.py Add seamless LangChain integration 2026-01-14 16:03:34 -08:00
test_langchain_live.py Fix LangChain tool_call argument handling for varied message formats 2026-02-27 20:09:18 -08:00
test_langgraph.py fix: remove unused imports in test_langgraph.py (ruff F401) 2026-03-30 09:27:49 -07:00
test_memory.py fix: B1 — retire ICM, RollingWindow, scoring, relevance + dependents 2026-05-02 12:23:17 -07:00
test_retriever.py fix: improve error handling and add comprehensive test coverage 2026-01-27 16:08:36 -08:00
test_streaming.py fix: improve error handling and add comprehensive test coverage 2026-01-27 16:08:36 -08:00