Skip to content

fix(langchain): normalize tool definitions to OpenAI function format#1758

Open
milanagm wants to merge 2 commits into
langfuse:mainfrom
milanagm:feature/lfe-8472-bug-issue-rendering-tool-calls-made-with-langchain-10
Open

fix(langchain): normalize tool definitions to OpenAI function format#1758
milanagm wants to merge 2 commits into
langfuse:mainfrom
milanagm:feature/lfe-8472-bug-issue-rendering-tool-calls-made-with-langchain-10

Conversation

@milanagm

@milanagm milanagm commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Tool definitions from invocation_params["tools"] are forwarded into the generation input without format normalization. Setups that bypass bind_tools()model.bind(tools=...), tools passed as invoke kwargs, or custom model integrations — deliver raw BaseTool instances to the callback. These get serialized as their attribute dict (args_schema, return_direct, verbose, ...) and are not recognized as tool definitions by the Langfuse UI, which expects the OpenAI function format ({"type": "function", "function": {...}}).

The standard path (bind_tools() / create_agent) is unaffected: LangChain converts tools at bind time there. Verified against both current package versions and the versions available when the issue was filed.

Fix

CallbackHandler.__on_llm_action now normalizes each tool definition before appending it to the generation input:

  • definitions already in OpenAI function format pass through unchanged (identity, no cost on the standard path)
  • everything else is converted via langchain_core.utils.function_calling.convert_to_openai_tool
  • if conversion fails, the original value is kept (never worse than before)

Tests

  • 4 unit tests for _normalize_tool_definition (pass-through identity, BaseTool conversion, bare function schema wrapping, unconvertible fallback)
  • 2 end-to-end tests through the full handler with the in-memory exporter: the broken path (.bind(tools=...)) is normalized, the standard path (bind_tools()) stays untouched

Fixes langfuse/langfuse#11850

Greptile Summary

This PR adds best-effort normalization of tool definitions captured in invocation_params[\"tools\"], converting raw BaseTool instances and bare function-schema dicts into the OpenAI function format ({\"type\": \"function\", \"function\": {...}}) before they are stored in the Langfuse generation input. Definitions that are already in the expected format pass through unchanged, so the standard bind_tools() path is unaffected.

  • A new _normalize_tool_definition helper in utils.py handles three cases: identity pass-through for already-valid OpenAI dicts, conversion via langchain_core.utils.function_calling.convert_to_openai_tool, and silent fallback to the original value if conversion fails.
  • CallbackHandler.__on_llm_action is updated to call this helper for each tool before appending it to the prompts list.
  • Six new tests cover unit-level normalization behavior and two end-to-end scenarios through the full handler.

Confidence Score: 4/5

The change is narrowly scoped and additive — tools already in the right format pass through unchanged, so existing integrations are not disturbed.

The normalization logic and its two-path design are sound. The deferred import inside the function body violates the team's module-level import convention, and the bare except Exception masks both import failures and unexpected conversion errors without any diagnostic output, making future regressions harder to debug.

langfuse/langchain/utils.py — the deferred import and broad exception handler in _normalize_tool_definition

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["__on_llm_action called"] --> B["Extract invocation_params['tools']"]
    B --> C{tools is a non-empty list?}
    C -- No --> D["Skip tool normalization"]
    C -- Yes --> E["For each tool: _normalize_tool_definition(tool)"]
    E --> F{Is dict with type='function' and function key?}
    F -- Yes --> G["Return tool unchanged (identity pass-through)"]
    F -- No --> H["try: convert_to_openai_tool(tool)"]
    H --> I{Conversion successful?}
    I -- Yes --> J["Return normalized OpenAI-format dict"]
    I -- No --> K["except Exception: Return original tool unchanged"]
    G --> L["Append {role: tool, content: normalized} to prompts"]
    J --> L
    K --> L
    D --> M["Continue with model_name extraction and span creation"]
    L --> M
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["__on_llm_action called"] --> B["Extract invocation_params['tools']"]
    B --> C{tools is a non-empty list?}
    C -- No --> D["Skip tool normalization"]
    C -- Yes --> E["For each tool: _normalize_tool_definition(tool)"]
    E --> F{Is dict with type='function' and function key?}
    F -- Yes --> G["Return tool unchanged (identity pass-through)"]
    F -- No --> H["try: convert_to_openai_tool(tool)"]
    H --> I{Conversion successful?}
    I -- Yes --> J["Return normalized OpenAI-format dict"]
    I -- No --> K["except Exception: Return original tool unchanged"]
    G --> L["Append {role: tool, content: normalized} to prompts"]
    J --> L
    K --> L
    D --> M["Continue with model_name extraction and span creation"]
    L --> M
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
langfuse/langchain/utils.py:31
**Import placed inside function body**

The team's convention is to move imports to the top of the module rather than inside functions or methods. Because `langfuse/langchain/utils.py` is part of the langchain integration, `langchain_core` is a prerequisite for using this module at all, so there is no meaningful graceful-degradation benefit from deferring it. If the import fails at the module level, the error will surface explicitly instead of being silently absorbed by the `except Exception` handler — which currently makes it impossible to distinguish "langchain_core not installed" from "conversion logic raised an unexpected exception".

### Issue 2 of 2
langfuse/langchain/utils.py:30-35
The `except Exception` swallows every possible failure — including `ImportError`, `TypeError`, and any unexpected exception from `convert_to_openai_tool` — with no log or diagnostic. A narrow catch (`(ImportError, ValueError, TypeError)`) keeps the silent-fallback intent for expected failure modes while letting genuinely unexpected exceptions propagate, making bugs easier to spot during development. At minimum, logging the exception at DEBUG level before returning the original would preserve observability.

```suggestion
    try:
        from langchain_core.utils.function_calling import convert_to_openai_tool

        return convert_to_openai_tool(tool)
    except Exception:
        langfuse_logger.debug(
            "Could not normalize tool definition to OpenAI format; keeping original.",
            exc_info=True,
        )
        return tool
```

Reviews (1): Last reviewed commit: "fix(langchain): normalize tool definitio..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Rule used - Move imports to the top of the module instead of p... (source)

Learned From
langfuse/langfuse-python#1387

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Comment thread langfuse/langchain/utils.py
milanagm added 2 commits July 15, 2026 08:58
Tool definitions from invocation_params["tools"] were forwarded into the
generation input without format normalization. Setups that bypass
bind_tools() (model.bind(tools=...), tools passed as invoke kwargs, or
custom model integrations) deliver raw BaseTool instances, which were
serialized as their attribute dict (args_schema, return_direct, ...) and
not recognized as tool definitions by the Langfuse UI.
The CallbackHandler now normalizes each tool definition before appending
it: OpenAI-format dicts pass through unchanged, everything else is
converted via langchain_core's convert_to_openai_tool with a fallback to
the original value.
Fixes #11850
@milanagm milanagm force-pushed the feature/lfe-8472-bug-issue-rendering-tool-calls-made-with-langchain-10 branch from b100d31 to 4587d8d Compare July 15, 2026 06:58
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.

bug: Issue rendering tool calls made with Langchain 1.0

1 participant