diff --git a/examples/langchain_thinking.py b/examples/langchain_thinking.py new file mode 100644 index 00000000..05673df0 --- /dev/null +++ b/examples/langchain_thinking.py @@ -0,0 +1,69 @@ +""" +Example: langchain_messages() against a Claude model with extended thinking turned on. + +With thinking on, Anthropic returns ``content`` as a list of blocks — a ``thinking`` block followed +by a ``text`` block — instead of a plain string. A handler that only reads string content reports +an empty response for these runs while the tokens are still spent, so this example fails loudly +when no text comes back. + +Anthropic omits thinking from the turn that follows a tool result, so give this a prompt the model +can answer on its own — a run that goes through the tool loop ends on a plain string and never +reaches the block-shaped content this exercises. + +Usage (via main.py): + python main.py langchain-thinking "Reason it out yourself without any tools: what is 17 times 23?" +""" + +from __future__ import annotations + +import json +from typing import Any + +from examples.tools import ( + fetch_launchdarkly_documentation, + get_preferences, + search_ld_documentation, +) +from examples.utils import new_context, write_output +from launchdarkly_ai_langchain_messages import create_langchain_messages_handler +from launchdarkly_ai_server import config + +# Anthropic requires max_tokens to exceed the thinking budget. +_THINKING_BUDGET_TOKENS = 1024 +_MAX_TOKENS = 4096 + + +async def run(key: str, user_input: str) -> None: + from langchain_anthropic import ChatAnthropic + + def build_model(ai_config: Any) -> Any: + model = ai_config.get("model") or {} + raw = model.get("parameters") + parameters: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {} + return ChatAnthropic( + timeout=None, + stop=None, + **parameters, + model_name=str(model.get("name") or "claude-sonnet-4-5"), + thinking={"type": "enabled", "budget_tokens": _THINKING_BUDGET_TOKENS}, + max_tokens_to_sample=_MAX_TOKENS, + ) + + response = await config( + key=key, + handler=create_langchain_messages_handler(llm=build_model), + tool_handlers={ + "get-user-preferences": get_preferences, + "search-ld-documentation": search_ld_documentation, + "fetch-launchdarkly-documentation": fetch_launchdarkly_documentation, + }, + ).invoke(user_input, new_context(), variables={"user_input": user_input}) + + if not (response.response or "").strip(): + raise RuntimeError( + "Model returned no text. A thinking-enabled model returns content as a list of " + "blocks, and the handler dropped it." + ) + + print(json.dumps(response, indent=2, default=str)) + write_output(response) diff --git a/main.py b/main.py index 6befa5ea..66dd15a7 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ python main.py claude-agents launch-darkly-documentation-summarizer "What is the LaunchDarkly AI SDK?" python main.py openai-agents launch-darkly-documentation-summarizer-open-ai-only "What is the LaunchDarkly AI SDK?" python main.py langchain-agents launch-darkly-documentation-summarizer "What is the LaunchDarkly AI SDK?" + python main.py langchain-thinking launch-darkly-documentation-summarizer-messages-claude "Reason it out yourself without any tools: what is 17 times 23?" python main.py native-graph travel-agent-flow "Book me a flight to Paris" python main.py native-graph-langchain travel-agent-flow "Book me a flight to Paris" """ @@ -54,6 +55,7 @@ "langchain": "examples.langchain_example", "langchain-agents": "examples.langchain_agents_example", "langchain-messages": "examples.langchain_messages_example", + "langchain-thinking": "examples.langchain_thinking", "native-graph": "examples.native_graph", "native-graph-langchain": "examples.native_graph_langchain", } diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index f67dcc2f..163a656d 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -10,6 +10,7 @@ SpanMessage, SpanMessagePart, ToolDefinitionInput, + lang_chain_content_text, lang_chain_finish_reasons, lang_chain_span_messages, set_input_content_attributes, @@ -153,6 +154,7 @@ "create_run_usage", "end_span_once", "end_unfinished_spans", + "lang_chain_content_text", "lang_chain_finish_reasons", "lang_chain_span_messages", "lang_chain_span_usage", diff --git a/packages/client/src/launchdarkly_ai_server/content.py b/packages/client/src/launchdarkly_ai_server/content.py index fb5fc00b..99eaf1c9 100644 --- a/packages/client/src/launchdarkly_ai_server/content.py +++ b/packages/client/src/launchdarkly_ai_server/content.py @@ -302,7 +302,7 @@ def lang_chain_span_messages( msg_type = ( str(get_type()) if callable(get_type) else str(_get(raw, "type") or "") ) - text = _lang_chain_content_text(_get(raw, "content")) + text = lang_chain_content_text(_get(raw, "content")) if msg_type in ("system", "developer"): if text: @@ -358,7 +358,7 @@ def lang_chain_span_messages( return ("\n".join(system) if system else None, converted) -def _lang_chain_content_text(content: Any) -> str: +def lang_chain_content_text(content: Any) -> str: """LangChain message content is a string, or a list holding typed blocks and bare strings. LangChain types it as ``str | list[str | dict]``, so a bare string inside the list is what the diff --git a/packages/langchain-agents/README.md b/packages/langchain-agents/README.md index ba764dd1..803c0371 100644 --- a/packages/langchain-agents/README.md +++ b/packages/langchain-agents/README.md @@ -46,6 +46,18 @@ from launchdarkly_ai_langchain_agents import create_langchain_agents_handler handler = create_langchain_agents_handler(ChatAnthropic(model="claude-opus-4-5")) ``` +A constructed instance cannot see flag parameters. Pass a function instead if the model should +be built after evaluation: + +```python +handler = create_langchain_agents_handler( + lambda config: ChatAnthropic( + model=config["model"]["name"], + **(config["model"].get("parameters") or {}), + ) +) +``` + ### Convenience wrapper ```python diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index 0e13ebcf..682f0622 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -21,6 +21,7 @@ create_run_usage, end_span_once, end_unfinished_spans, + lang_chain_content_text, lang_chain_span_messages, lang_chain_span_usage, parse_template, @@ -133,23 +134,48 @@ def _build_initial_messages( return messages +def _model_constructor_kwargs( + config: AiConfigRep, fallback_name: str +) -> dict[str, Any]: + raw = (config.get("model") or {}).get("parameters") + parameters = dict(raw) if isinstance(raw, dict) else {} + parameters["model"] = (config.get("model") or {}).get("name") or fallback_name + return parameters + + +def _is_model_factory(llm: Any) -> bool: + """LangChain models are callable, so ``callable`` is not enough to spot a factory.""" + return callable(llm) and not hasattr(llm, "invoke") and not hasattr(llm, "ainvoke") + + def _make_default_chat_model(config: AiConfigRep) -> Any: """ Instantiate the appropriate LangChain chat model based on ``config.provider.name``. Falls back to ``ChatOpenAI`` when the provider is not recognised. Requires the matching ``langchain-`` integration package to be installed. + ``model.parameters`` are passed through unchanged. """ import importlib provider = ((config.get("provider") or {}).get("name") or "openai").lower() - model_name = (config.get("model") or {}).get("name", "") if provider == "anthropic": lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( - model=model_name or "claude-3-5-sonnet-20241022" + **_model_constructor_kwargs(config, "claude-3-5-sonnet-20241022") ) lc_openai = importlib.import_module("langchain_openai") - return lc_openai.ChatOpenAI(model=model_name or "gpt-4o") + return lc_openai.ChatOpenAI(**_model_constructor_kwargs(config, "gpt-4o")) + + +async def _resolve_base_model(config: AiConfigRep, llm: Any) -> Any: + if llm is None: + return _make_default_chat_model(config) + if _is_model_factory(llm): + model = llm(config) + if asyncio.iscoroutine(model): + return await model + return model + return llm def _run_usage_from_messages(messages: list[Any]) -> Any: @@ -178,6 +204,9 @@ def create_langchain_agents_handler( ) -> ProviderHandler: """Creates a ``ProviderHandler`` for LangChain via ``create_react_agent``. + Pass *llm* as a chat model instance, or as a function ``(config) -> model`` that is + called after flag evaluation so ``model.parameters`` can be applied unchanged. + Set *capture_content* to put prompts, model output, tool arguments and tool results on the emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, meaning models, token counts, timings and tool names, until a caller asks for more. @@ -229,9 +258,7 @@ async def _call_impl( system_instructions=system_prompt, messages=lang_chain_span_messages(initial_messages)[1], ) - base_model = llm - if base_model is None: - base_model = _make_default_chat_model(config) + base_model = await _resolve_base_model(config, llm) langgraph_prebuilt = importlib.import_module("langgraph.prebuilt") create_react_agent = langgraph_prebuilt.create_react_agent @@ -261,20 +288,11 @@ async def _call_impl( run_usage = span_callbacks.run_usage last_msg = msgs[-1] if msgs else None - output = ( - (last_msg.content if isinstance(last_msg.content, str) else "") - if last_msg - else "" - ) + output = lang_chain_content_text(last_msg.content) if last_msg else "" # Built through the same conversion the chat span uses, not from `output`. A chat model - # may return content as a list of blocks, and `output` is deliberately blank for that - # case because it is also what this function returns to the caller. Reading it here made - # the root record an empty completion while its own chat child held the real text, so the - # two spans described the same reply differently. - # - # The blank return value is a separate question. It predates this work and is not - # telemetry, so it stays as it is. + # may return content as a list of blocks. Keeping that conversion here preserves + # non-text parts in telemetry while the caller-facing output contains visible text. set_output_content_attributes( span, capture_content, @@ -396,9 +414,7 @@ async def _stream_gen( system_instructions=system_prompt, messages=lang_chain_span_messages(initial_messages)[1], ) - base_model = llm - if base_model is None: - base_model = _make_default_chat_model(config) + base_model = await _resolve_base_model(config, llm) langgraph_prebuilt = importlib.import_module("langgraph.prebuilt") create_react_agent = langgraph_prebuilt.create_react_agent @@ -430,7 +446,7 @@ async def _stream_gen( if usage: run_usage.add(lang_chain_span_usage(usage)) if getattr(msg, "type", None) == "ai": - text = msg.content if isinstance(msg.content, str) else "" + text = lang_chain_content_text(msg.content) if text: yield {"type": "chunk", "text": text} full_output = text diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index d04524cf..fd03223f 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -196,9 +196,11 @@ async def _traverse_node(node: GraphNode) -> None: chat_model = model_factory(node) else: lc_openai = importlib.import_module("langchain_openai") - chat_model = lc_openai.ChatOpenAI( - model=node.config.get("model", {}).get("name", "gpt-4o") - ) + model_cfg = node.config.get("model") or {} + raw = model_cfg.get("parameters") + kwargs = dict(raw) if isinstance(raw, dict) else {} + kwargs["model"] = model_cfg.get("name") or "gpt-4o" + chat_model = lc_openai.ChatOpenAI(**kwargs) regular_tools = _build_node_tools(node, tool_handlers) diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 5e51558d..5165f026 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -35,7 +35,7 @@ def _make_config(**kwargs: Any) -> dict[str, Any]: def _make_ai_msg( - content: str = "answer", input_tokens: int = 10, output_tokens: int = 5 + content: Any = "answer", input_tokens: int = 10, output_tokens: int = 5 ) -> Any: msg = MagicMock() msg.content = content @@ -182,6 +182,19 @@ def test_multiple_calls_return_independent_instances(self) -> None: h2 = create_langchain_agents_handler() assert h1 is not h2 + @pytest.mark.asyncio + async def test_returns_text_from_mixed_thinking_and_text_blocks(self) -> None: + mocks = _make_langchain_mock() + mocks["_ai_msg"].content = [ + {"type": "thinking", "thinking": "internal reasoning"}, + {"type": "text", "text": "visible answer"}, + ] + with _patch_lc(mocks), patch.object(spans_mod, "_HAS_OTEL", False): + result = await create_langchain_agents_handler(llm=MagicMock())( + _make_config(), "q" + ) + assert result["output"] == "visible answer" + # --------------------------------------------------------------------------- # §1.2 Prompt construction @@ -495,7 +508,7 @@ def _llm_type(self) -> str: def _ai_message( - content: str = "", + content: Any = "", input_tokens: int = 10, output_tokens: int = 5, tool_calls: list[dict[str, Any]] | None = None, @@ -1231,6 +1244,30 @@ async def _mock_astream(*a: Any, **kw: Any) -> AsyncIterator[Any]: done_events = [e for e in events if e.get("type") == "done"] assert len(done_events) == 1 + @pytest.mark.asyncio + async def test_streams_text_while_ignoring_thinking_blocks(self) -> None: + mocks = _make_langchain_mock() + msg = _make_ai_msg( + [ + {"type": "thinking", "thinking": "internal reasoning"}, + {"type": "text", "text": "visible answer"}, + ] + ) + + async def _mock_astream(*a: Any, **kw: Any) -> AsyncIterator[Any]: + yield {"agent": {"messages": [msg]}} + + mocks["_agent"].astream = _mock_astream + with _patch_lc(mocks), patch.object(spans_mod, "_HAS_OTEL", False): + events = [ + event + async for event in await create_langchain_agents_handler( + llm=MagicMock() + ).stream(_make_config(), "q") + ] + assert {"type": "chunk", "text": "visible answer"} in events + assert events[-1]["output"] == "visible answer" + @pytest.mark.asyncio async def test_generator_throws_on_provider_error(self) -> None: mocks = _make_langchain_mock() @@ -2055,3 +2092,112 @@ async def test_a_reply_in_content_blocks_still_reaches_the_root(self) -> None: assert rec.root.attributes["gen_ai.completion.0.content"] == "a typed block" assert "a typed block" in str(rec.root.attributes["gen_ai.output.messages"]) + + +class TestModelSource: + @pytest.mark.asyncio + async def test_factory_receives_config_and_returned_model_is_used(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("from-factory")]) + seen: list[Any] = [] + + def factory(config: Any) -> Any: + seen.append(config) + return llm + + cfg = { + **BASE_CONFIG, + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "max_tokens": 512}, + }, + } + with ctx: + result = await create_langchain_agents_handler(factory)(cfg, "q") + assert seen[0]["model"]["parameters"] == {"temperature": 0.2, "max_tokens": 512} + assert result["output"] == "from-factory" + + @pytest.mark.asyncio + async def test_prebuilt_instance_is_used_as_is(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("from-instance")]) + with ctx: + result = await create_langchain_agents_handler(llm)( + { + **BASE_CONFIG, + "model": {"name": "gpt-4o", "parameters": {"temperature": 0.2}}, + }, + "q", + ) + assert result["output"] == "from-instance" + + @pytest.mark.asyncio + async def test_default_openai_constructor_receives_parameters(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("default-openai")]) + ctor = MagicMock(return_value=llm) + cfg = { + **BASE_CONFIG, + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "max_tokens": 512}, + }, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs == { + "temperature": 0.2, + "max_tokens": 512, + "model": "gpt-4o", + } + + @pytest.mark.asyncio + async def test_default_anthropic_constructor_receives_parameters(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("default-anthropic")]) + ctor = MagicMock(return_value=llm) + cfg = { + **BASE_CONFIG, + "provider": {"name": "Anthropic"}, + "model": {"name": "claude-sonnet-4-5", "parameters": {"temperature": 0.1}}, + } + with ( + ctx, + patch.dict( + "sys.modules", {"langchain_anthropic": MagicMock(ChatAnthropic=ctor)} + ), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs == { + "temperature": 0.1, + "model": "claude-sonnet-4-5", + } + + @pytest.mark.asyncio + async def test_factory_is_resolved_on_the_streaming_path(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("streamed")]) + seen: list[Any] = [] + + def factory(config: Any) -> Any: + seen.append(config) + return llm + + cfg = { + **BASE_CONFIG, + "model": {"name": "gpt-4o", "parameters": {"temperature": 0.2}}, + } + with ctx: + events = [ + e + async for e in await create_langchain_agents_handler(factory).stream( + cfg, "q", {}, {} + ) + ] + assert seen[0]["model"]["parameters"] == {"temperature": 0.2} + assert any( + e.get("type") == "chunk" and e.get("text") == "streamed" for e in events + ) diff --git a/packages/langchain-agents/tests/test_native_graph.py b/packages/langchain-agents/tests/test_native_graph.py index 39f6b47b..c814618b 100644 --- a/packages/langchain-agents/tests/test_native_graph.py +++ b/packages/langchain-agents/tests/test_native_graph.py @@ -258,6 +258,44 @@ async def test_disabled_graph_throws(self) -> None: with pytest.raises(ValueError, match="disabled"): await to_lang_graph(_make_def_promise(graph_def)).invoke("hi") + @pytest.mark.asyncio + async def test_default_chat_openai_receives_model_parameters(self) -> None: + ai_msg = _make_ai_msg("final") + mocks = _make_langgraph_mocks(ai_msg) + graph_def = _make_graph_def( + nodes={ + "root": { + "key": "root", + "config": { + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "max_tokens": 512}, + }, + "instructions": "help", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + ) + + async def _visit(fn: Any, ctx: Any = None) -> None: + if graph_def.root is not None: + await fn(graph_def.root) + + graph_def.traverse = _visit + + with _patch_imports(mocks): + await to_lang_graph(_make_def_promise(graph_def)).invoke("hi") + + mocks["langchain_openai"].ChatOpenAI.assert_called_once() + assert mocks["langchain_openai"].ChatOpenAI.call_args.kwargs == { + "temperature": 0.2, + "max_tokens": 512, + "model": "gpt-4o", + } + @pytest.mark.asyncio async def test_null_root_throws(self) -> None: ai_msg = _make_ai_msg() diff --git a/packages/langchain-messages/README.md b/packages/langchain-messages/README.md index 547d0bbc..36a9ac96 100644 --- a/packages/langchain-messages/README.md +++ b/packages/langchain-messages/README.md @@ -42,6 +42,18 @@ from launchdarkly_ai_langchain_messages import create_langchain_messages_handler handler = create_langchain_messages_handler(ChatAnthropic(model="claude-opus-4-5")) ``` +A constructed instance cannot see flag parameters. Pass a function instead if the model should +be built after evaluation: + +```python +handler = create_langchain_messages_handler( + lambda config: ChatAnthropic( + model=config["model"]["name"], + **(config["model"].get("parameters") or {}), + ) +) +``` + ### Convenience wrapper ```python diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index f5dcd95b..8240da4a 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -18,6 +18,7 @@ create_run_usage, end_span_once, end_unfinished_spans, + lang_chain_content_text, lang_chain_finish_reasons, lang_chain_span_messages, lang_chain_span_usage, @@ -170,21 +171,47 @@ def _is_coroutine(fn: Any) -> bool: _MAX_STEPS = 10 +def _model_constructor_kwargs( + config: AiConfigRep, fallback_name: str +) -> dict[str, Any]: + raw = (config.get("model") or {}).get("parameters") + parameters = dict(raw) if isinstance(raw, dict) else {} + # Name from the config always wins over a colliding ``model`` key in the parameter bag. + parameters["model"] = (config.get("model") or {}).get("name") or fallback_name + return parameters + + +def _is_model_factory(llm: Any) -> bool: + """LangChain models are callable, so ``callable`` is not enough to spot a factory.""" + return callable(llm) and not hasattr(llm, "invoke") and not hasattr(llm, "ainvoke") + + def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: """ Instantiate the appropriate LangChain chat model based on ``config.provider.name``. Falls back to ``ChatOpenAI`` when the provider is not recognised. Requires the matching ``langchain-`` integration package to be installed. + ``model.parameters`` are passed through unchanged. """ provider = config.get("provider", {}).get("name", "openai").lower() - model_name = config.get("model", {}).get("name", "") if provider == "anthropic": lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( - model=model_name or "claude-3-5-sonnet-20241022" + **_model_constructor_kwargs(config, "claude-3-5-sonnet-20241022") ) lc_openai = importlib.import_module("langchain_openai") - return lc_openai.ChatOpenAI(model=model_name or "gpt-4o") + return lc_openai.ChatOpenAI(**_model_constructor_kwargs(config, "gpt-4o")) + + +async def _resolve_base_model(config: AiConfigRep, llm: Any, importlib: Any) -> Any: + if llm is None: + return _make_default_chat_model(config, importlib) + if _is_model_factory(llm): + model = llm(config) + if asyncio.iscoroutine(model): + return await model + return model + return llm async def _run_structured_turn( @@ -281,8 +308,10 @@ def create_langchain_messages_handler( """ Creates a ``ProviderHandler`` for LangChain (chat models). Requires ``langchain-openai`` or another LangChain integration to be installed. - Pass *llm* to use a specific chat model; omit to default to - ``ChatOpenAI(model=)`` resolved at call time. + Pass *llm* as a chat model instance, or as a function ``(config) -> model`` that is + called after flag evaluation so ``model.parameters`` can be applied unchanged. Omit + to default to ``ChatOpenAI`` / ``ChatAnthropic`` constructed at call time from the + config's model name and parameters. Set *capture_content* to put prompts, model output, tool arguments and tool results on the emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, @@ -325,9 +354,7 @@ async def _call_impl( system_instructions=system_instructions, messages=span_messages, ) - base_model = ( - llm if llm is not None else _make_default_chat_model(config, importlib) - ) + base_model = await _resolve_base_model(config, llm, importlib) tool_defs = _build_tools(config.get("tools") or {}) output_format = config.get("outputFormat") @@ -479,11 +506,7 @@ async def _call_impl( run_usage=run_usage, ) else: - output = ( - response.content - if isinstance(response.content, str) - else "" - ) + output = lang_chain_content_text(response.content) break if steps >= _MAX_STEPS: @@ -643,7 +666,7 @@ async def _stream_gen( """ import importlib - base_model = llm if llm is not None else _make_default_chat_model(config, importlib) + base_model = await _resolve_base_model(config, llm, importlib) span = start_root_span(config, variables) parent = parent_context_of(span) @@ -723,7 +746,7 @@ async def _stream_gen( chunk_stream = tool_model.astream(conversation_messages) open_chunk_stream = chunk_stream async for chunk in chunk_stream: - text = chunk.content if isinstance(chunk.content, str) else "" + text = lang_chain_content_text(chunk.content) if text: yield {"type": "chunk", "text": text} accumulated_content += text diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index 0fc86c1e..a260e013 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -5,6 +5,7 @@ from __future__ import annotations +import sys from collections.abc import AsyncGenerator from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch @@ -24,7 +25,7 @@ class FakeAIMessage: def __init__( self, - content: str = "Hello", + content: Any = "Hello", tool_calls: list[dict[str, Any]] | None = None, input_tokens: int = 10, output_tokens: int = 5, @@ -54,7 +55,7 @@ def _get_type(self) -> str: return "ai" -def _make_llm(response_content: str = "Hello") -> MagicMock: +def _make_llm(response_content: Any = "Hello") -> MagicMock: """Creates a mock LangChain LLM.""" llm = MagicMock() ai_msg = FakeAIMessage(response_content) @@ -299,6 +300,18 @@ async def test_path_c_both_instructions_and_messages_messages_wins(self) -> None assert "from-messages" in all_content assert "Be helpful" not in all_content + async def test_returns_text_from_mixed_thinking_and_text_blocks(self) -> None: + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + content = [ + {"type": "thinking", "thinking": "internal reasoning"}, + {"type": "text", "text": "visible answer"}, + ] + result = await create_langchain_messages_handler(llm=_make_llm(content))( + CONFIG, "q" + ) + assert result["output"] == "visible answer" + # --------------------------------------------------------------------------- # §1.3 Tool conversion @@ -1130,7 +1143,7 @@ def test_callable_without_extra_kwargs(self) -> None: class TestStreaming: def _make_streaming_llm( - self, chunks: list[str], input_tok: int = 5, output_tok: int = 3 + self, chunks: list[Any], input_tok: int = 5, output_tok: int = 3 ) -> MagicMock: llm = MagicMock() llm.bind_tools = MagicMock(return_value=llm) @@ -1163,6 +1176,26 @@ async def test_yields_chunk_events(self) -> None: assert chunks[0]["text"] == "hello " assert chunks[1]["text"] == "world" + async def test_streams_text_while_ignoring_thinking_blocks(self) -> None: + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = self._make_streaming_llm( + [ + [ + {"type": "thinking", "thinking": "internal reasoning"}, + {"type": "text", "text": "visible answer"}, + ] + ] + ) + events = [ + event + async for event in await create_langchain_messages_handler(llm=llm).stream( + CONFIG, "q" + ) + ] + assert {"type": "chunk", "text": "visible answer"} in events + assert events[-1]["output"] == "visible answer" + async def test_yields_exactly_one_done_event(self) -> None: from launchdarkly_ai_langchain_messages import create_langchain_messages_handler @@ -2220,3 +2253,132 @@ def _counting(span: Any, capture: bool, *a: Any, **k: Any) -> None: pass assert calls["n"] == 0 + + +class TestModelSource: + @pytest.mark.asyncio + async def test_factory_receives_config_and_returned_model_is_used(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("from-factory") + seen: list[Any] = [] + + def factory(config: Any) -> Any: + seen.append(config) + return llm + + cfg = { + **CONFIG, + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "max_tokens": 512}, + }, + } + with ctx: + result = await create_langchain_messages_handler(llm=factory)( + cfg, "q", {}, {} + ) + assert seen[0]["model"]["parameters"] == {"temperature": 0.2, "max_tokens": 512} + assert result["output"] == "from-factory" + llm.ainvoke.assert_awaited() + + @pytest.mark.asyncio + async def test_prebuilt_instance_is_used_as_is(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("from-instance") + with ctx: + result = await create_langchain_messages_handler(llm=llm)( + { + **CONFIG, + "model": {"name": "gpt-4o", "parameters": {"temperature": 0.2}}, + }, + "q", + {}, + {}, + ) + assert result["output"] == "from-instance" + llm.ainvoke.assert_awaited() + + @pytest.mark.asyncio + async def test_default_openai_constructor_receives_parameters(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("default-openai") + ctor = MagicMock(return_value=llm) + cfg = { + **CONFIG, + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "max_tokens": 512}, + }, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs == { + "temperature": 0.2, + "max_tokens": 512, + "model": "gpt-4o", + } + + @pytest.mark.asyncio + async def test_default_anthropic_constructor_receives_parameters(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("default-anthropic") + ctor = MagicMock(return_value=llm) + cfg = { + **CONFIG, + "provider": {"name": "Anthropic"}, + "model": { + "name": "claude-sonnet-4-5", + "parameters": {"temperature": 0.1, "thinking": {"type": "enabled"}}, + }, + } + with ( + ctx, + patch.dict( + sys.modules, {"langchain_anthropic": MagicMock(ChatAnthropic=ctor)} + ), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs == { + "temperature": 0.1, + "thinking": {"type": "enabled"}, + "model": "claude-sonnet-4-5", + } + + @pytest.mark.asyncio + async def test_factory_is_resolved_on_the_streaming_path(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("streamed") + seen: list[Any] = [] + + def factory(config: Any) -> Any: + seen.append(config) + return llm + + cfg = { + **CONFIG, + "model": {"name": "gpt-4o", "parameters": {"temperature": 0.2}}, + } + with ctx: + events = [ + e + async for e in await create_langchain_messages_handler( + llm=factory + ).stream(cfg, "q", {}, {}) + ] + assert seen[0]["model"]["parameters"] == {"temperature": 0.2} + assert any( + e.get("type") == "chunk" and e.get("text") == "streamed" for e in events + )