fix: run before/after_llm_call hooks on the LiteLLM fallback's acall() - #6749
Open
LHMQ878 wants to merge 1 commit into
Open
fix: run before/after_llm_call hooks on the LiteLLM fallback's acall()#6749LHMQ878 wants to merge 1 commit into
LHMQ878 wants to merge 1 commit into
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughChangesAsync Async LLM hooks
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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
crewai/llm.pyis the LiteLLM fallback, used whenever no native provider matches the model and wheneveris_litellm=Trueis passed. Itscall()dispatches both LLM hook families; itsacall()dispatched neither.Because
before_llm_callis the only interception point that can abort a call, a hook returningFalseon the async path did not merely fail to observe the request -- the request was issued and billed.after_llm_callsilently 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), inlib/crewai/src/crewai/llm.py:before_llm_callafter_llm_callcall()(:1820):1876:1904acall()(:1959)llms/base_llm.pyis not the fallback and does not cover this: it only defines_invoke_before_llm_call_hooksand_invoke_after_llm_call_hooks, has no callers of either, and its ownacall()israise NotImplementedError.Proof, before any fix was written
No network.
litellm.completion/litellm.acompletionare replaced with stubs that record whether a request left the process, so the observable is the provider call rather than the returned string: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 anLLMdoes not exercise it.After this PR all four rows match their sync counterpart.
The change
acall()now mirrorscall()exactly. The guard sits at the same point -- after theo1role rewrite, before_prepare_completion_params:and the two handler branches bind to
resultinstead of returning directly, so theafterdispatch has a join point: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. Theisinstance(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_hooksreturnsTrueimmediately whenfrom_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:
_ahandle_*response handlers (after)acall()bodies (before)acall()(beforeandafter)Neither open PR touches
llm.py, and this one touches neither provider file. Keeping it separate also keepsgit blamehonest 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
TestLLMHooksOnTheLiteLLMFallbackAsyncPathintests/hooks/test_llm_hooks.py, 5 tests.The blocking test asserts
issued == [], not just that aValueErrorwas 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.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.pyandtest_interception_conformance.py, identical on an unmodified tree.ruff checkandruff format --checkclean on both files.