Skip to content

fix: run before/after_llm_call hooks on the LiteLLM fallback's acall() - #6749

Open
LHMQ878 wants to merge 1 commit into
crewAIInc:mainfrom
LHMQ878:fix/litellm-fallback-acall-skips-llm-hooks
Open

fix: run before/after_llm_call hooks on the LiteLLM fallback's acall()#6749
LHMQ878 wants to merge 1 commit into
crewAIInc:mainfrom
LHMQ878:fix/litellm-fallback-acall-skips-llm-hooks

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown

Summary

crewai/llm.py is the LiteLLM fallback, used whenever no native provider matches the model and whenever is_litellm=True is passed. Its call() dispatches both LLM hook families; its acall() dispatched neither.

Because before_llm_call is the only interception point that can abort a call, a hook returning False on the async path did not merely fail to observe the request -- the request was issued and billed. after_llm_call silently stopped rewriting, so a redaction hook handed back the raw response.

Related to #6739 but a distinct seam. See the note on scope below.

Root cause

On main (ebe0082), in lib/crewai/src/crewai/llm.py:

method before_llm_call after_llm_call
call() (:1820) :1876 :1904
acall() (:1959)

llms/base_llm.py is not the fallback and does not cover this: it only defines _invoke_before_llm_call_hooks and _invoke_after_llm_call_hooks, has no callers of either, and its own acall() is raise NotImplementedError.

Proof, before any fix was written

No network. litellm.completion / litellm.acompletion are replaced with stubs that record whether a request left the process, so the observable is the provider call rather than the returned string:

blocking before_llm_call hook (returns False) + rewriting after hook:
  call   before:1 after:0  issued:no     -> ValueError: LLM call blocked by before_llm_call hook
  acall  before:0 after:0  issued:async  -> 'the model said something sensitive'

observing-only before hook + rewriting after hook:
  call   before:1 after:1  issued:sync   -> 'REDACTED BY HOOK'
  acall  before:0 after:0  issued:async  -> 'the model said something sensitive'

LLM(model=..., is_litellm=True) is required to reach this class -- without the flag the constructor returns a native provider. That is probably part of why the gap persisted: the obvious way to construct an LLM does not exercise it.

After this PR all four rows match their sync counterpart.

The change

acall() now mirrors call() exactly. The guard sits at the same point -- after the o1 role rewrite, before _prepare_completion_params:

if not self._invoke_before_llm_call_hooks(messages, from_agent):
    raise ValueError("LLM call blocked by before_llm_call hook")

and the two handler branches bind to result instead of returning directly, so the after dispatch has a join point:

if self._effective_stream():
    result = await self._ahandle_streaming_response(...)
else:
    result = await self._ahandle_non_streaming_response(...)

if isinstance(result, str):
    result = self._invoke_after_llm_call_hooks(messages, result, from_agent)

return result

That restructuring is why the source diff is 28 lines rather than two inserted ones: the async body had return await ... in each branch and no single place to dispatch from. The isinstance(result, str) guard is the sync path's, verbatim -- a tool-call result is not a response string and is not offered to the hook.

No double dispatch on the agent path: _invoke_before_llm_call_hooks returns True immediately when from_agent is not None, so the agent executor's dispatch remains the single source for agent-driven calls.

Scope, and why this is separate from #6737 / #6740

Same defect class, three different seams:

PR seam count
#6737 native providers' _ahandle_* response handlers (after) 8 handlers
#6740 native providers' acall() bodies (before) 5 providers
this the LiteLLM fallback's acall() (before and after) 1 method

Neither open PR touches llm.py, and this one touches neither provider file. Keeping it separate also keeps git blame honest about which seam each change fixed.

#6740's description asserted that this path "already had the guard on both paths." That was wrong, and it understated the bug -- it is corrected in that PR's body, since a reviewer could reasonably have acted on it.

Tests

TestLLMHooksOnTheLiteLLMFallbackAsyncPath in tests/hooks/test_llm_hooks.py, 5 tests.

The blocking test asserts issued == [], not just that a ValueError was raised. A test asserting only on the exception would also pass on an implementation that ran the hook after the provider had already been paid, which is the failure mode that makes this worth fixing.

$ pytest tests/hooks/test_llm_hooks.py
31 passed

$ pytest tests/hooks/
137 passed, 5 errors        # 132 passed, 5 errors on a clean tree

$ git stash push lib/crewai/src/crewai/llm.py     # tests unchanged
$ pytest tests/hooks/test_llm_hooks.py -k LiteLLMFallback
3 failed, 2 passed
    test_async_call_is_blocked_by_before_hook   DID NOT RAISE ValueError
    test_async_call_runs_after_hook             'the model answered' != 'REDACTED BY HOOK'
    test_async_call_before_hook_sees_the_messages   assert ([])

The two that pass either way are deliberate: one exercises the sync path, which was never broken, and one is a no-hook negative control proving the seam is inert when nothing is registered.

The 5 errors are pre-existing Windows tempdir-cleanup failures at teardown in test_crew_scoped_hooks.py and test_interception_conformance.py, identical on an unmodified tree.

ruff check and ruff format --check clean on both files.

crewai/llm.py is the fallback used whenever no native provider matches the
model, and via is_litellm=True. Its call() dispatches both hook families
(:1876 before, :1904 after); its acall() at :1959 dispatched neither.

before_llm_call is the only interception point that can abort a call, so a
hook returning False on the async path did not merely fail to observe the
request -- the request was issued and billed. after_llm_call silently stopped
rewriting, so a redaction hook returned the raw response.

Probe on ebe0082, litellm.completion/acompletion stubbed so the observable is
whether a request left the process:

  blocking before hook + rewriting after hook
    call   before:1 after:0  issued:no     -> ValueError: blocked by hook
    acall  before:0 after:0  issued:async  -> 'the model said something sensitive'

  observing before hook + rewriting after hook
    call   before:1 after:1  issued:sync   -> 'REDACTED BY HOOK'
    acall  before:0 after:0  issued:async  -> 'the model said something sensitive'

acall() now mirrors its sync twin exactly: the before guard sits after the o1
role rewrite and before _prepare_completion_params, and the two handler calls
bind to a result so the after dispatch can run on a str before returning. That
restructuring is why the source diff is larger than two inserted lines -- the
async body returned directly from each branch, with no join point to dispatch
from.

This is a different seam from the five provider acall() bodies in crewAIInc#6740 and the
eight handlers in crewAIInc#6737: same defect class, different file, and not covered by
either. crewAIInc#6740's body claimed this path already had the guard, which was wrong;
that claim is corrected there.

Adds TestLLMHooksOnTheLiteLLMFallbackAsyncPath. The stub records which litellm
entry point was reached, so the blocking test asserts issued == [] -- a test
that only asserted on the raised error would also pass on an implementation
that ran the hook after the provider had been paid. Reverting only llm.py fails
exactly the three async tests (3 failed / 2 passed); the sync test and the
no-hook negative control pass either way by design.

tests/hooks: 137 passed, up from 132. The 5 teardown errors are Windows
tempdir cleanup in test_crew_scoped_hooks.py and test_interception_conformance.py,
identical on a clean tree.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a973cdd-e3b6-41eb-897f-e612e5d3bec3

📥 Commits

Reviewing files that changed from the base of the PR and between ebe0082 and b47ae25.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/llm.py
  • lib/crewai/tests/hooks/test_llm_hooks.py

📝 Walkthrough

Walkthrough

Changes

Async LLM.acall now runs before-call hooks, unifies streaming and non-streaming results, and applies after-call hooks to string responses. LiteLLM fallback-path tests cover blocking, rewriting, message handling, provider calls, and no-hook behavior.

Async LLM hooks

Layer / File(s) Summary
Async hook lifecycle and unified result handling
lib/crewai/src/crewai/llm.py
Async calls invoke before-call hooks, execute either response path, apply after-call hooks to string results, and return the processed result.
LiteLLM fallback-path validation
lib/crewai/tests/hooks/test_llm_hooks.py
Tests cover blocked calls, rewritten async responses, visible messages, provider invocation tracking, and unchanged no-hook behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant AsyncLLM
  participant Hooks
  participant LiteLLM
  Caller->>AsyncLLM: acall(messages)
  AsyncLLM->>Hooks: run before-call hooks
  Hooks-->>AsyncLLM: allow or block
  AsyncLLM->>LiteLLM: execute request
  LiteLLM-->>AsyncLLM: response
  AsyncLLM->>Hooks: run after-call hooks
  Hooks-->>AsyncLLM: processed response
  AsyncLLM-->>Caller: return result
Loading

Suggested reviewers: lucasgomide, lorenzejay, greysonlalonde

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes fixing both hooks on the LiteLLM fallback's asynchronous call path.
Description check ✅ Passed The description directly explains the hook bug, implementation, scope, and tests for the LiteLLM fallback async path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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