|
9 | 9 | LLMCallCompletedEvent, |
10 | 10 | LLMCallStartedEvent, |
11 | 11 | LLMCallType, |
| 12 | + LLMStreamChunkEvent, |
12 | 13 | ) |
13 | 14 | from crewai.llm import LLM |
14 | 15 | 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): |
222 | 223 | assert event.temperature == 0.9 |
223 | 224 |
|
224 | 225 |
|
| 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 | + |
225 | 348 | class TestEmitCallCompletedEventPassesFinishReasonAndResponseId: |
226 | 349 | def test_passes_through_to_event(self, mock_emit): |
227 | 350 | llm = _StubLLM(model="test-model") |
|
0 commit comments