Skip to content

Commit c25543f

Browse files
committed
refactor(otel): declare sampling params on BaseLLM + honor stop overrides + dict chunk id
1 parent 0797b38 commit c25543f

3 files changed

Lines changed: 147 additions & 17 deletions

File tree

lib/crewai/src/crewai/llm.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,8 @@ def _handle_streaming_response(
745745

746746
if isinstance(chunk, ModelResponseBase):
747747
response_id = chunk.id
748+
elif isinstance(chunk, dict):
749+
response_id = chunk.get("id")
748750

749751
chunk_finish, chunk_id = self._extract_finish_reason_and_response_id(
750752
chunk
@@ -1461,7 +1463,12 @@ async def _ahandle_streaming_response(
14611463
async for chunk in await litellm.acompletion(**params):
14621464
chunk_count += 1
14631465
chunk_content = None
1464-
response_id = chunk.id if isinstance(chunk, ModelResponseBase) else None
1466+
if isinstance(chunk, ModelResponseBase):
1467+
response_id = chunk.id
1468+
elif isinstance(chunk, dict):
1469+
response_id = chunk.get("id")
1470+
else:
1471+
response_id = None
14651472

14661473
chunk_finish, chunk_id = self._extract_finish_reason_and_response_id(
14671474
chunk

lib/crewai/src/crewai/llms/base_llm.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ class BaseLLM(BaseModel, ABC):
150150
llm_type: str = "base"
151151
model: str
152152
temperature: float | None = None
153+
top_p: float | None = None
154+
max_tokens: int | None = None
155+
stream: bool | None = None
156+
seed: int | None = None
157+
frequency_penalty: float | None = None
158+
presence_penalty: float | None = None
159+
n: int | None = None
153160
api_key: str | None = None
154161
base_url: str | None = None
155162
provider: str = Field(default="openai")
@@ -484,34 +491,27 @@ def _emit_call_started_event(
484491
) -> None:
485492
"""Emit LLM call started event.
486493
487-
Sampling params default to introspecting ``self`` (``self.temperature``,
488-
``self.top_p``, ``self.stop`` -> ``stop_sequences``, ...) so providers
489-
don't need to thread them through every emission site. Explicit
490-
kwargs override the introspection.
491494
"""
492495
from crewai.utilities.serialization import to_serializable
493496

494497
if temperature is None:
495-
temperature = getattr(self, "temperature", None)
498+
temperature = self.temperature
496499
if top_p is None:
497-
top_p = getattr(self, "top_p", None)
500+
top_p = self.top_p
498501
if max_tokens is None:
499-
max_tokens = getattr(self, "max_tokens", None)
502+
max_tokens = self.max_tokens
500503
if stream is None:
501-
stream = getattr(self, "stream", None)
504+
stream = self.stream
502505
if seed is None:
503-
seed = getattr(self, "seed", None)
506+
seed = self.seed
504507
if stop_sequences is None:
505-
stop_attr = getattr(self, "stop", None) or getattr(
506-
self, "stop_sequences", None
507-
)
508-
stop_sequences = stop_attr or None
508+
stop_sequences = self.stop_sequences or None
509509
if frequency_penalty is None:
510-
frequency_penalty = getattr(self, "frequency_penalty", None)
510+
frequency_penalty = self.frequency_penalty
511511
if presence_penalty is None:
512-
presence_penalty = getattr(self, "presence_penalty", None)
512+
presence_penalty = self.presence_penalty
513513
if n is None:
514-
n = getattr(self, "n", None)
514+
n = self.n
515515

516516
crewai_event_bus.emit(
517517
self,

lib/crewai/tests/events/test_llm_finish_reason_response_id.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
LLMCallCompletedEvent,
1010
LLMCallStartedEvent,
1111
LLMCallType,
12+
LLMStreamChunkEvent,
1213
)
1314
from crewai.llm import LLM
1415
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
@@ -222,6 +223,128 @@ def test_explicit_kwargs_override_introspection(self, mock_emit):
222223
assert event.temperature == 0.9
223224

224225

226+
class TestBaseLLMSamplingParamFields:
227+
# Regression: PR #5945 review feedback. Sampling params are declared as
228+
# typed fields on BaseLLM so ``_emit_call_started_event`` reads them via
229+
# plain attribute access instead of getattr/hasattr fallbacks. Kwargs
230+
# like ``n=1`` bind directly to the typed field via Pydantic; there is
231+
# no promotion from ``additional_params``.
232+
def test_sampling_kwargs_bind_to_typed_fields(self, mock_emit):
233+
from crewai.llms.providers.openai.completion import OpenAICompletion
234+
235+
llm = LLM(model="gpt-4", n=1, temperature=0.5, seed=42)
236+
237+
assert isinstance(llm, OpenAICompletion)
238+
assert llm.n == 1
239+
assert llm.temperature == 0.5
240+
assert llm.seed == 42
241+
assert "n" not in llm.additional_params
242+
assert "temperature" not in llm.additional_params
243+
assert "seed" not in llm.additional_params
244+
245+
llm._emit_call_started_event(messages="hi")
246+
247+
event = mock_emit.call_args[1]["event"]
248+
assert isinstance(event, LLMCallStartedEvent)
249+
assert event.n == 1
250+
assert event.temperature == 0.5
251+
assert event.seed == 42
252+
253+
def test_additional_params_are_not_promoted_to_typed_fields(self, mock_emit):
254+
# Callers who pass sampling params through ``additional_params``
255+
# opt out of typed-field semantics. We intentionally do NOT promote
256+
# those values back into ``self.n`` / ``self.temperature``, so the
257+
# emitter sees ``None`` for those attributes. If a caller wants the
258+
# value surfaced in telemetry, they pass it as a kwarg.
259+
llm = LLM(
260+
model="gpt-4",
261+
additional_params={"n": 1, "temperature": 0.5, "seed": 42},
262+
)
263+
264+
assert llm.n is None
265+
assert llm.temperature is None
266+
assert llm.seed is None
267+
assert llm.additional_params == {"n": 1, "temperature": 0.5, "seed": 42}
268+
269+
llm._emit_call_started_event(messages="hi")
270+
271+
event = mock_emit.call_args[1]["event"]
272+
assert isinstance(event, LLMCallStartedEvent)
273+
assert event.n is None
274+
assert event.temperature is None
275+
assert event.seed is None
276+
277+
def test_emit_uses_call_scoped_stop_override(self, mock_emit):
278+
from crewai.llms.base_llm import call_stop_override
279+
280+
llm = _StubLLM(model="test-model", stop=["A"])
281+
282+
with call_stop_override(llm, ["X"]):
283+
llm._emit_call_started_event(messages="hi")
284+
285+
event = mock_emit.call_args[1]["event"]
286+
assert isinstance(event, LLMCallStartedEvent)
287+
assert event.stop_sequences == ["X"]
288+
# Instance-level stop is never mutated by the override.
289+
assert llm.stop == ["A"]
290+
291+
292+
class TestStreamingDictChunkResponseIdPropagation:
293+
# Regression: PR #5945 coderabbitai feedback. The streaming loop only
294+
# extracted ``chunk.id`` for ``ModelResponseBase`` instances; dict-shaped
295+
# chunks (LiteLLM emits these in some configs) silently dropped the id
296+
# and ``LLMStreamChunkEvent.response_id`` came through as ``None``.
297+
def _dict_chunks(self) -> list[dict[str, Any]]:
298+
return [
299+
{
300+
"id": "test-chunk-id",
301+
"choices": [{"delta": {"content": "hi"}, "finish_reason": None}],
302+
},
303+
{
304+
"id": "test-chunk-id",
305+
"choices": [{"delta": {"content": " there"}, "finish_reason": "stop"}],
306+
},
307+
]
308+
309+
def _stream_event_response_ids(self, mock_emit) -> list[str | None]:
310+
return [
311+
call.kwargs["event"].response_id
312+
for call in mock_emit.call_args_list
313+
if isinstance(call.kwargs.get("event"), LLMStreamChunkEvent)
314+
]
315+
316+
def test_sync_dict_chunk_id_propagates_to_stream_event(self, mock_emit):
317+
llm = LLM(model="gpt-4o-mini", is_litellm=True, stream=True)
318+
319+
with patch(
320+
"crewai.llm.litellm.completion",
321+
return_value=iter(self._dict_chunks()),
322+
):
323+
llm.call("anything")
324+
325+
ids = self._stream_event_response_ids(mock_emit)
326+
assert ids, "expected at least one LLMStreamChunkEvent"
327+
assert all(rid == "test-chunk-id" for rid in ids), ids
328+
329+
@pytest.mark.asyncio
330+
async def test_async_dict_chunk_id_propagates_to_stream_event(self, mock_emit):
331+
llm = LLM(model="gpt-4o-mini", is_litellm=True, stream=True)
332+
333+
async def _aiter():
334+
for chunk in self._dict_chunks():
335+
yield chunk
336+
337+
async def _acompletion(*_args, **_kwargs):
338+
return _aiter()
339+
340+
with patch("crewai.llm.litellm.acompletion", side_effect=_acompletion):
341+
await llm.acall("anything")
342+
343+
ids = self._stream_event_response_ids(mock_emit)
344+
assert ids, "expected at least one LLMStreamChunkEvent"
345+
assert all(rid == "test-chunk-id" for rid in ids), ids
346+
347+
225348
class TestEmitCallCompletedEventPassesFinishReasonAndResponseId:
226349
def test_passes_through_to_event(self, mock_emit):
227350
llm = _StubLLM(model="test-model")

0 commit comments

Comments
 (0)