diff --git a/.riot/requirements/109a45b.txt b/.riot/requirements/109a45b.txt new file mode 100644 index 00000000000..872aeb990af --- /dev/null +++ b/.riot/requirements/109a45b.txt @@ -0,0 +1,75 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/109a45b.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.65.4 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==1.68.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/10a8045.txt b/.riot/requirements/10a8045.txt deleted file mode 100644 index 3fc3f00339f..00000000000 --- a/.riot/requirements/10a8045.txt +++ /dev/null @@ -1,71 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/10a8045.in -# -aiohappyeyeballs==2.6.1 -aiohttp==3.11.18 -aiosignal==1.3.2 -annotated-types==0.7.0 -anyio==4.9.0 -attrs==25.3.0 -boto3==1.38.2 -botocore==1.38.2 -certifi==2025.1.31 -charset-normalizer==3.4.1 -click==8.1.8 -coverage[toml]==7.8.0 -distro==1.9.0 -filelock==3.18.0 -frozenlist==1.6.0 -fsspec==2025.3.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -huggingface-hub==0.30.2 -hypothesis==6.45.0 -idna==3.10 -importlib-metadata==8.6.1 -iniconfig==2.1.0 -jinja2==3.1.6 -jiter==0.9.0 -jmespath==1.0.1 -jsonschema==4.23.0 -jsonschema-specifications==2025.4.1 -litellm==1.65.4 -markupsafe==3.0.2 -mock==5.2.0 -multidict==6.4.3 -openai==1.68.2 -opentracing==2.4.0 -packaging==25.0 -pluggy==1.5.0 -propcache==0.3.1 -pydantic==2.11.3 -pydantic-core==2.33.1 -pytest==8.3.5 -pytest-asyncio==0.26.0 -pytest-cov==6.1.1 -pytest-mock==3.14.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 -pyyaml==6.0.2 -referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 -rpds-py==0.24.0 -s3transfer==0.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tiktoken==0.9.0 -tokenizers==0.21.1 -tqdm==4.67.1 -typing-extensions==4.13.2 -typing-inspection==0.4.0 -urllib3==2.4.0 -vcrpy==7.0.0 -wrapt==1.17.2 -yarl==1.20.0 -zipp==3.21.0 diff --git a/.riot/requirements/1229e9a.txt b/.riot/requirements/1229e9a.txt new file mode 100644 index 00000000000..6aee70bfcbc --- /dev/null +++ b/.riot/requirements/1229e9a.txt @@ -0,0 +1,75 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1229e9a.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.65.4 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==1.68.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/12b8cfa.txt b/.riot/requirements/12b8cfa.txt deleted file mode 100644 index 13a94da4b9a..00000000000 --- a/.riot/requirements/12b8cfa.txt +++ /dev/null @@ -1,74 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/12b8cfa.in -# -aiohappyeyeballs==2.6.1 -aiohttp==3.11.18 -aiosignal==1.3.2 -annotated-types==0.7.0 -anyio==4.9.0 -async-timeout==5.0.1 -attrs==25.3.0 -boto3==1.38.2 -botocore==1.38.2 -certifi==2025.1.31 -charset-normalizer==3.4.1 -click==8.1.8 -coverage[toml]==7.8.0 -distro==1.9.0 -exceptiongroup==1.2.2 -filelock==3.18.0 -frozenlist==1.6.0 -fsspec==2025.3.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -huggingface-hub==0.30.2 -hypothesis==6.45.0 -idna==3.10 -importlib-metadata==8.6.1 -iniconfig==2.1.0 -jinja2==3.1.6 -jiter==0.9.0 -jmespath==1.0.1 -jsonschema==4.23.0 -jsonschema-specifications==2025.4.1 -litellm==1.65.4 -markupsafe==3.0.2 -mock==5.2.0 -multidict==6.4.3 -openai==1.68.2 -opentracing==2.4.0 -packaging==25.0 -pluggy==1.5.0 -propcache==0.3.1 -pydantic==2.11.3 -pydantic-core==2.33.1 -pytest==8.3.5 -pytest-asyncio==0.26.0 -pytest-cov==6.1.1 -pytest-mock==3.14.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 -pyyaml==6.0.2 -referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 -rpds-py==0.24.0 -s3transfer==0.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tiktoken==0.9.0 -tokenizers==0.21.1 -tomli==2.2.1 -tqdm==4.67.1 -typing-extensions==4.13.2 -typing-inspection==0.4.0 -urllib3==1.26.20 -vcrpy==7.0.0 -wrapt==1.17.2 -yarl==1.20.0 -zipp==3.21.0 diff --git a/.riot/requirements/1747447.txt b/.riot/requirements/1747447.txt deleted file mode 100644 index a8cca0eee37..00000000000 --- a/.riot/requirements/1747447.txt +++ /dev/null @@ -1,74 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.10 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/1747447.in -# -aiohappyeyeballs==2.6.1 -aiohttp==3.11.18 -aiosignal==1.3.2 -annotated-types==0.7.0 -anyio==4.9.0 -async-timeout==5.0.1 -attrs==25.3.0 -boto3==1.38.2 -botocore==1.38.2 -certifi==2025.1.31 -charset-normalizer==3.4.1 -click==8.1.8 -coverage[toml]==7.8.0 -distro==1.9.0 -exceptiongroup==1.2.2 -filelock==3.18.0 -frozenlist==1.6.0 -fsspec==2025.3.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -huggingface-hub==0.30.2 -hypothesis==6.45.0 -idna==3.10 -importlib-metadata==8.6.1 -iniconfig==2.1.0 -jinja2==3.1.6 -jiter==0.9.0 -jmespath==1.0.1 -jsonschema==4.23.0 -jsonschema-specifications==2025.4.1 -litellm==1.65.4 -markupsafe==3.0.2 -mock==5.2.0 -multidict==6.4.3 -openai==1.68.2 -opentracing==2.4.0 -packaging==25.0 -pluggy==1.5.0 -propcache==0.3.1 -pydantic==2.11.3 -pydantic-core==2.33.1 -pytest==8.3.5 -pytest-asyncio==0.26.0 -pytest-cov==6.1.1 -pytest-mock==3.14.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 -pyyaml==6.0.2 -referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 -rpds-py==0.24.0 -s3transfer==0.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tiktoken==0.9.0 -tokenizers==0.21.1 -tomli==2.2.1 -tqdm==4.67.1 -typing-extensions==4.13.2 -typing-inspection==0.4.0 -urllib3==2.4.0 -vcrpy==7.0.0 -wrapt==1.17.2 -yarl==1.20.0 -zipp==3.21.0 diff --git a/.riot/requirements/1e893b9.txt b/.riot/requirements/1e893b9.txt new file mode 100644 index 00000000000..1bdfa0fe2b9 --- /dev/null +++ b/.riot/requirements/1e893b9.txt @@ -0,0 +1,77 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1e893b9.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +fastuuid==0.14.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +grpcio==1.76.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.80.16 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==2.15.0 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/27afe82.txt b/.riot/requirements/27afe82.txt new file mode 100644 index 00000000000..8346f973277 --- /dev/null +++ b/.riot/requirements/27afe82.txt @@ -0,0 +1,77 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/27afe82.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +fastuuid==0.14.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +grpcio==1.76.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.80.16 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==2.15.0 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/4061c90.txt b/.riot/requirements/4061c90.txt new file mode 100644 index 00000000000..6176c22da00 --- /dev/null +++ b/.riot/requirements/4061c90.txt @@ -0,0 +1,75 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/4061c90.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.65.4 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==1.68.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/8d10412.txt b/.riot/requirements/8d10412.txt new file mode 100644 index 00000000000..6f589032231 --- /dev/null +++ b/.riot/requirements/8d10412.txt @@ -0,0 +1,81 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/8d10412.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +async-timeout==5.0.1 +attrs==25.4.0 +backports-asyncio-runner==1.2.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.1.8 +coverage[toml]==7.10.7 +distro==1.9.0 +exceptiongroup==1.3.1 +fastuuid==0.14.0 +filelock==3.19.1 +frozenlist==1.8.0 +fsspec==2025.10.0 +grpcio==1.76.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.1.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.25.1 +jsonschema-specifications==2025.9.1 +litellm==1.80.16 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==2.15.0 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==8.4.2 +pytest-asyncio==1.2.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.36.2 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.27.1 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tomli==2.4.0 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==1.26.20 +vcrpy==7.0.0 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/a417cc8.txt b/.riot/requirements/a417cc8.txt deleted file mode 100644 index dd16fba8b49..00000000000 --- a/.riot/requirements/a417cc8.txt +++ /dev/null @@ -1,71 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.13 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/a417cc8.in -# -aiohappyeyeballs==2.6.1 -aiohttp==3.11.18 -aiosignal==1.3.2 -annotated-types==0.7.0 -anyio==4.9.0 -attrs==25.3.0 -boto3==1.38.2 -botocore==1.38.2 -certifi==2025.1.31 -charset-normalizer==3.4.1 -click==8.1.8 -coverage[toml]==7.8.0 -distro==1.9.0 -filelock==3.18.0 -frozenlist==1.6.0 -fsspec==2025.3.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -huggingface-hub==0.30.2 -hypothesis==6.45.0 -idna==3.10 -importlib-metadata==8.6.1 -iniconfig==2.1.0 -jinja2==3.1.6 -jiter==0.9.0 -jmespath==1.0.1 -jsonschema==4.23.0 -jsonschema-specifications==2025.4.1 -litellm==1.65.4 -markupsafe==3.0.2 -mock==5.2.0 -multidict==6.4.3 -openai==1.68.2 -opentracing==2.4.0 -packaging==25.0 -pluggy==1.5.0 -propcache==0.3.1 -pydantic==2.11.3 -pydantic-core==2.33.1 -pytest==8.3.5 -pytest-asyncio==0.26.0 -pytest-cov==6.1.1 -pytest-mock==3.14.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 -pyyaml==6.0.2 -referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 -rpds-py==0.24.0 -s3transfer==0.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tiktoken==0.9.0 -tokenizers==0.21.1 -tqdm==4.67.1 -typing-extensions==4.13.2 -typing-inspection==0.4.0 -urllib3==2.4.0 -vcrpy==7.0.0 -wrapt==1.17.2 -yarl==1.20.0 -zipp==3.21.0 diff --git a/.riot/requirements/a971ee3.txt b/.riot/requirements/a971ee3.txt new file mode 100644 index 00000000000..21ea94542d3 --- /dev/null +++ b/.riot/requirements/a971ee3.txt @@ -0,0 +1,79 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/a971ee3.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +async-timeout==5.0.1 +attrs==25.4.0 +backports-asyncio-runner==1.2.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +exceptiongroup==1.3.1 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.65.4 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==1.68.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tomli==2.4.0 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/c88628f.txt b/.riot/requirements/c88628f.txt deleted file mode 100644 index 950c7fa7174..00000000000 --- a/.riot/requirements/c88628f.txt +++ /dev/null @@ -1,71 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/c88628f.in -# -aiohappyeyeballs==2.6.1 -aiohttp==3.11.18 -aiosignal==1.3.2 -annotated-types==0.7.0 -anyio==4.9.0 -attrs==25.3.0 -boto3==1.38.2 -botocore==1.38.2 -certifi==2025.1.31 -charset-normalizer==3.4.1 -click==8.1.8 -coverage[toml]==7.8.0 -distro==1.9.0 -filelock==3.18.0 -frozenlist==1.6.0 -fsspec==2025.3.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -huggingface-hub==0.30.2 -hypothesis==6.45.0 -idna==3.10 -importlib-metadata==8.6.1 -iniconfig==2.1.0 -jinja2==3.1.6 -jiter==0.9.0 -jmespath==1.0.1 -jsonschema==4.23.0 -jsonschema-specifications==2025.4.1 -litellm==1.65.4 -markupsafe==3.0.2 -mock==5.2.0 -multidict==6.4.3 -openai==1.68.2 -opentracing==2.4.0 -packaging==25.0 -pluggy==1.5.0 -propcache==0.3.1 -pydantic==2.11.3 -pydantic-core==2.33.1 -pytest==8.3.5 -pytest-asyncio==0.26.0 -pytest-cov==6.1.1 -pytest-mock==3.14.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 -pyyaml==6.0.2 -referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 -rpds-py==0.24.0 -s3transfer==0.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tiktoken==0.9.0 -tokenizers==0.21.1 -tqdm==4.67.1 -typing-extensions==4.13.2 -typing-inspection==0.4.0 -urllib3==2.4.0 -vcrpy==7.0.0 -wrapt==1.17.2 -yarl==1.20.0 -zipp==3.21.0 diff --git a/.riot/requirements/d728b27.txt b/.riot/requirements/d728b27.txt new file mode 100644 index 00000000000..9f54fa98ea0 --- /dev/null +++ b/.riot/requirements/d728b27.txt @@ -0,0 +1,77 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/d728b27.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +fastuuid==0.14.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +grpcio==1.76.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.80.16 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==2.15.0 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/d8bb960.txt b/.riot/requirements/d8bb960.txt new file mode 100644 index 00000000000..477853ff347 --- /dev/null +++ b/.riot/requirements/d8bb960.txt @@ -0,0 +1,81 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/d8bb960.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +async-timeout==5.0.1 +attrs==25.4.0 +backports-asyncio-runner==1.2.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +coverage[toml]==7.13.1 +distro==1.9.0 +exceptiongroup==1.3.1 +fastuuid==0.14.0 +filelock==3.20.3 +frozenlist==1.8.0 +fsspec==2026.1.0 +grpcio==1.76.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +litellm==1.80.16 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==2.15.0 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.30.0 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tomli==2.4.0 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==2.6.3 +vcrpy==8.1.1 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/.riot/requirements/fc54849.txt b/.riot/requirements/fc54849.txt new file mode 100644 index 00000000000..6a20e8bc93e --- /dev/null +++ b/.riot/requirements/fc54849.txt @@ -0,0 +1,79 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/fc54849.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +async-timeout==5.0.1 +attrs==25.4.0 +backports-asyncio-runner==1.2.0 +boto3==1.42.30 +botocore==1.42.30 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.1.8 +coverage[toml]==7.10.7 +distro==1.9.0 +exceptiongroup==1.3.1 +filelock==3.19.1 +frozenlist==1.8.0 +fsspec==2025.10.0 +h11==0.16.0 +hf-xet==1.2.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.3.2 +hypothesis==6.45.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.1.0 +jinja2==3.1.6 +jiter==0.12.0 +jmespath==1.0.1 +jsonschema==4.25.1 +jsonschema-specifications==2025.9.1 +litellm==1.65.4 +markupsafe==3.0.3 +mock==5.2.0 +multidict==6.7.0 +openai==1.68.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +propcache==0.4.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pygments==2.19.2 +pytest==8.4.2 +pytest-asyncio==1.2.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +pyyaml==6.0.3 +referencing==0.36.2 +regex==2026.1.15 +requests==2.32.5 +rpds-py==0.27.1 +s3transfer==0.16.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tomli==2.4.0 +tqdm==4.67.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +urllib3==1.26.20 +vcrpy==7.0.0 +wrapt==2.0.1 +yarl==1.22.0 +zipp==3.23.0 diff --git a/ddtrace/contrib/integration_registry/registry.yaml b/ddtrace/contrib/integration_registry/registry.yaml index eab9894e550..79c462e145c 100644 --- a/ddtrace/contrib/integration_registry/registry.yaml +++ b/ddtrace/contrib/integration_registry/registry.yaml @@ -517,7 +517,7 @@ integrations: tested_versions_by_dependency: litellm: min: 1.65.4 - max: 1.65.4 + max: 1.80.16 - integration_name: logbook is_external_package: true diff --git a/ddtrace/contrib/internal/litellm/patch.py b/ddtrace/contrib/internal/litellm/patch.py index 4d6c6ef6032..4ea6a9c791b 100644 --- a/ddtrace/contrib/internal/litellm/patch.py +++ b/ddtrace/contrib/internal/litellm/patch.py @@ -29,6 +29,23 @@ def _supported_versions() -> Dict[str, str]: return {"litellm": "*"} +def _handle_router_stream_response(resp, span, kwargs, instance, integration, args, is_async=False): + """ + Handle router streaming responses with fallback for different wrapper types. + + In litellm>=1.74.15, router streaming responses may be wrapped in FallbackStreamWrapper + (for mid-stream fallback support) or other types that don't expose the .handler attribute. + """ + if hasattr(resp, "handler") and hasattr(resp.handler, "add_span"): + resp.handler.add_span(span, kwargs, instance) + return resp + + # Fallback: wrap the response in our own traced stream for compatibility + kwargs[LITELLM_ROUTER_INSTANCE_KEY] = instance + handler_class = LiteLLMAsyncStreamHandler if is_async else LiteLLMStreamHandler + return make_traced_stream(resp, handler_class(integration, span, args, kwargs)) + + @with_traced_module def traced_completion(litellm, pin, func, instance, args, kwargs): operation = func.__name__ @@ -110,7 +127,7 @@ def traced_router_completion(litellm, pin, func, instance, args, kwargs): try: resp = func(*args, **kwargs) if stream: - resp.handler.add_span(span, kwargs, instance) + return _handle_router_stream_response(resp, span, kwargs, instance, integration, args, is_async=False) return resp except Exception: span.set_exc_info(*sys.exc_info()) @@ -141,7 +158,7 @@ async def traced_router_acompletion(litellm, pin, func, instance, args, kwargs): try: resp = await func(*args, **kwargs) if stream: - resp.handler.add_span(span, kwargs, instance) + return _handle_router_stream_response(resp, span, kwargs, instance, integration, args, is_async=True) return resp except Exception: span.set_exc_info(*sys.exc_info()) diff --git a/ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h b/ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h index a73d8b353da..137b3668a75 100644 --- a/ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h +++ b/ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #if defined PL_LINUX @@ -126,6 +127,585 @@ class ThreadInfo // ---------------------------------------------------------------------------- +// We make this a reference to a heap-allocated object so that we can avoid +// the destruction on exit. We are in charge of cleaning up the object. Note +// that the object will leak, but this is not a problem. +inline std::unordered_map& thread_info_map = + *(new std::unordered_map()); // indexed by thread_id + +inline std::mutex thread_info_map_lock; + +// ---------------------------------------------------------------------------- +inline void +ThreadInfo::unwind(PyThreadState* tstate) +{ + unwind_python_stack(tstate, python_stack); + + if (asyncio_loop) { + // unwind_tasks returns a [[nodiscard]] Result. + // We cast it to void to ignore failures. + (void)unwind_tasks(tstate); + } else { + // We make the assumption that gevent and asyncio are not mixed + // together to keep the logic here simple. We can always revisit this + // should there be a substantial demand for it. + unwind_greenlets(tstate, native_id); + } +} + +// ---------------------------------------------------------------------------- +inline Result +ThreadInfo::unwind_tasks(PyThreadState* tstate) +{ + // The size of the "pure Python" stack (before asyncio Frames). + // Defaults to the full Python stack size (and updated if we find the "_run" Frame) + size_t upper_python_stack_size = python_stack.size(); + + // Check if the Python stack contains "_run". + // To avoid having to do string comparisons every time we unwind Tasks, we keep track + // of the cache key of the "_run" Frame. + static std::optional frame_cache_key; + if (!frame_cache_key) { + for (size_t i = 0; i < python_stack.size(); i++) { + const auto& frame = python_stack[i].get(); + const auto& frame_name = string_table.lookup(frame.name)->get(); + +#if PY_VERSION_HEX >= 0x030b0000 + // After Python 3.11, function names in Frames are qualified with e.g. the class name, so we + // can use the qualified name to identify the "_run" Frame. + constexpr std::string_view _run = "Handle._run"; + auto is_run_frame = frame_name == _run; +#else + // Before Python 3.11, function names in Frames are not qualified, so we + // can use the filename to identify the "_run" Frame. + constexpr std::string_view asyncio_runners_py = "asyncio/events.py"; + constexpr std::string_view _run = "_run"; + auto filename = string_table.lookup(frame.filename)->get(); + auto is_asyncio = filename.rfind(asyncio_runners_py) == filename.size() - asyncio_runners_py.size(); + auto is_run_frame = is_asyncio && (frame_name.rfind(_run) == frame_name.size() - _run.size()); +#endif + if (is_run_frame) { + // Although Frames are stored in an LRUCache, the cache key is ALWAYS the same + // even if the Frame gets evicted from the cache. + // This means we can keep the cache key and re-use it to determine + // whether we see the "_run" Frame in the Python stack. + frame_cache_key = frame.cache_key; + upper_python_stack_size = python_stack.size() - i; + break; + } + } + } else { + for (size_t i = 0; i < python_stack.size(); i++) { + const auto& frame = python_stack[i].get(); + if (frame.cache_key == *frame_cache_key) { + upper_python_stack_size = python_stack.size() - i; + break; + } + } + } + + std::vector leaf_tasks; + std::unordered_set parent_tasks; + std::unordered_map waitee_map; // Indexed by task origin + std::unordered_map origin_map; // Indexed by task origin + static std::unordered_set previous_task_objects; + + auto maybe_all_tasks = get_all_tasks(tstate); + if (!maybe_all_tasks) { + return ErrorKind::TaskInfoError; + } + + auto all_tasks = std::move(*maybe_all_tasks); + { + std::lock_guard lock(task_link_map_lock); + + // Clean up the task_link_map. Remove entries associated to tasks that + // no longer exist. + std::unordered_set all_task_origins; + std::transform(all_tasks.cbegin(), + all_tasks.cend(), + std::inserter(all_task_origins, all_task_origins.begin()), + [](const TaskInfo::Ptr& task) { return task->origin; }); + + std::vector to_remove; + for (auto kv : task_link_map) { + if (all_task_origins.find(kv.first) == all_task_origins.end()) + to_remove.push_back(kv.first); + } + for (auto key : to_remove) { + // Only remove the link if the Child Task previously existed; otherwise it's a Task that + // has just been created and that wasn't in all_tasks when we took the snapshot. + if (auto it = previous_task_objects.find(key); it != previous_task_objects.end()) { + task_link_map.erase(key); + } + } + + // Determine the parent tasks from the gather links. + std::transform(task_link_map.cbegin(), + task_link_map.cend(), + std::inserter(parent_tasks, parent_tasks.begin()), + [](const std::pair& kv) { return kv.second; }); + + // Copy all Task object pointers into previous_task_objects + previous_task_objects.clear(); + for (const auto& task : all_tasks) { + previous_task_objects.insert(task->origin); + } + } + + for (auto& task : all_tasks) { + origin_map.emplace(task->origin, std::ref(*task)); + + if (task->waiter != nullptr) + waitee_map.emplace(task->waiter->origin, std::ref(*task)); + else if (parent_tasks.find(task->origin) == parent_tasks.end()) { + leaf_tasks.push_back(std::ref(*task)); + } + } + + // Make sure the on CPU task is first + for (size_t i = 0; i < leaf_tasks.size(); i++) { + if (leaf_tasks[i].get().is_on_cpu) { + if (i > 0) { + std::swap(leaf_tasks[i], leaf_tasks[0]); + } + break; + } + } + + for (auto& leaf_task : leaf_tasks) { + auto stack_info = std::make_unique(leaf_task.get().name, leaf_task.get().is_on_cpu); + auto& stack = stack_info->stack; + + for (auto current_task = leaf_task;;) { + auto& task = current_task.get(); + + auto task_stack_size = task.unwind(stack); + if (task.is_on_cpu) { + // Get the "bottom" part of the Python synchronous Stack, that is to say the + // synchronous functions and coroutines called by the Task's outermost coroutine + // The number of Frames to push is the total number of Frames in the Python stack, from which we + // subtract the number of Frames in the "upper Python stack" (asyncio machinery + sync entrypoint) + // This gives us [outermost coroutine, ... , innermost coroutine, outermost sync function, ... , + // innermost sync function] + // TODO: This may be incorrect if the Task that we know is on CPU does not match the Task that + // actually was on CPU when the Python Thread Stack was captured. One way to work around this + // may be to look at every Task Stack and match it against the Thread Stack. This would be + // somewhat costly though, and so far I have not seen a single instance of this race condition. + size_t frames_to_push = + (python_stack.size() > task_stack_size) ? python_stack.size() - task_stack_size : 0; + for (size_t i = 0; i < frames_to_push; i++) { + const auto& python_frame = python_stack[frames_to_push - i - 1]; + stack.push_front(python_frame); + } + } + + // Add the task name frame + stack.push_back(Frame::get(task.name)); + + // Get the next task in the chain + PyObject* task_origin = task.origin; + if (auto maybe_waitee = waitee_map.find(task_origin); maybe_waitee != waitee_map.end()) { + current_task = maybe_waitee->second; + continue; + } + + { + // Check for, e.g., gather links + std::lock_guard lock(task_link_map_lock); + + if (auto maybe_parent = task_link_map.find(task_origin); maybe_parent != task_link_map.end()) { + if (auto maybe_origin = origin_map.find(maybe_parent->second); maybe_origin != origin_map.end()) { + current_task = maybe_origin->second; + continue; + } + } + } + + break; + } + + // Finish off with the remaining thread stack + // If we have seen an on-CPU Task, then upper_python_stack_size will be set and will include the sync entry + // point and the asyncio machinery Frames. Otherwise, we are in `select` (idle) and we should push all the + // Frames. + + // There could be a race condition where relevant partial Python Thread Stack ends up being different from the + // one we saw in TaskInfo::unwind. This is extremely unlikely, I believe, but failing to account for it would + // cause an underflow, so let's be conservative. + size_t start_index = 0; + if (python_stack.size() >= upper_python_stack_size) { + start_index = python_stack.size() - upper_python_stack_size; + } + for (size_t i = start_index; i < python_stack.size(); i++) { + const auto& python_frame = python_stack[i]; + stack.push_back(python_frame); + } + + current_tasks.push_back(std::move(stack_info)); + } + + return Result::ok(); +} + +// ---------------------------------------------------------------------------- +#if PY_VERSION_HEX >= 0x030e0000 +inline Result +ThreadInfo::get_tasks_from_thread_linked_list(std::vector& tasks) +{ + if (this->tstate_addr == 0 || this->asyncio_loop == 0) { + return ErrorKind::TaskInfoError; + } + + // Calculate thread state's asyncio_tasks_head remote address + // Note: Since 3.13+, every PyThreadState is actually allocated as a _PyThreadStateImpl. + // We use PyThreadState* everywhere and cast to _PyThreadStateImpl* only when we need + // to access asyncio_tasks_head (which is only available in Python 3.14+). + // Since tstate_addr is a remote address, we calculate the offset and add it to the address. + // get_tasks_from_linked_list will handle copying the head node from remote memory internally. + constexpr size_t asyncio_tasks_head_offset = offsetof(_PyThreadStateImpl, asyncio_tasks_head); + uintptr_t head_addr = this->tstate_addr + asyncio_tasks_head_offset; + + return get_tasks_from_linked_list(head_addr, tasks); +} + +inline Result +ThreadInfo::get_tasks_from_interpreter_linked_list(PyThreadState* tstate, std::vector& tasks) +{ + if (tstate == nullptr || tstate->interp == nullptr || this->asyncio_loop == 0) { + return ErrorKind::TaskInfoError; + } + + constexpr size_t asyncio_tasks_head_offset = offsetof(PyInterpreterState, asyncio_tasks_head); + uintptr_t head_addr = reinterpret_cast(tstate->interp) + asyncio_tasks_head_offset; + + return get_tasks_from_linked_list(head_addr, tasks); +} + +inline Result +ThreadInfo::get_tasks_from_linked_list(uintptr_t head_addr, std::vector& tasks) +{ + if (head_addr == 0 || this->asyncio_loop == 0) { + return ErrorKind::TaskInfoError; + } + + // Copy head node struct from remote memory to local memory + struct llist_node head_node_local; + if (copy_type(reinterpret_cast(head_addr), head_node_local)) { + return ErrorKind::TaskInfoError; + } + + // Check if list is empty (head points to itself in circular list) + uintptr_t head_addr_uint = head_addr; + uintptr_t next_as_uint = reinterpret_cast(head_node_local.next); + uintptr_t prev_as_uint = reinterpret_cast(head_node_local.prev); + if (next_as_uint == head_addr_uint && prev_as_uint == head_addr_uint) { + return Result::ok(); + } + + struct llist_node current_node = head_node_local; // Start with head node + uintptr_t current_node_addr = head_addr; // Address of current node + + // Copied from CPython's _remote_debugging_module.c: MAX_ITERATIONS + const size_t MAX_ITERATIONS = 1 << 16; + size_t iteration_count = 0; + + // Iterate over linked-list. The linked list is circular, so we stop + // when we're back at head. + while (reinterpret_cast(current_node.next) != head_addr_uint) { + // Safety: prevent infinite loops + if (++iteration_count > MAX_ITERATIONS) { + return ErrorKind::TaskInfoError; + } + + if (current_node.next == nullptr) { + return ErrorKind::TaskInfoError; // nullptr pointer - invalid list + } + + uintptr_t next_node_addr = reinterpret_cast(current_node.next); + + // Calculate task_addr from current_node.next + size_t task_node_offset_val = offsetof(TaskObj, task_node); + uintptr_t task_addr_uint = next_node_addr - task_node_offset_val; + + // Create TaskInfo for the task + auto maybe_task_info = TaskInfo::create(reinterpret_cast(task_addr_uint)); + if (maybe_task_info) { + auto& task_info = *maybe_task_info; + if (task_info->loop == reinterpret_cast(this->asyncio_loop)) { + tasks.push_back(std::move(task_info)); + } + } + + // Read next node from current_node.next into current_node + if (copy_type(reinterpret_cast(next_node_addr), current_node)) { + return ErrorKind::TaskInfoError; // Failed to read next node + } + current_node_addr = next_node_addr; // Update address for next iteration + } + + return Result::ok(); +} + +inline Result> +ThreadInfo::get_all_tasks(PyThreadState* tstate) +{ + std::vector tasks; + if (this->asyncio_loop == 0) + return tasks; + + // Python 3.14+: Native tasks are in linked-list per thread AND per interpreter + // CPython iterates over both: + // 1. Per-thread list: tstate->asyncio_tasks_head (active tasks) + // 2. Per-interpreter list: interp->asyncio_tasks_head (lingering tasks) + // First, get tasks from this thread's linked-list (if tstate_addr is set) + // Note: We continue processing even if one source fails to maximize partial results + if (tstate != nullptr && this->tstate_addr != 0) { + (void)get_tasks_from_thread_linked_list(tasks); + + // Second, get tasks from interpreter's linked-list (lingering tasks) + (void)get_tasks_from_interpreter_linked_list(tstate, tasks); + } + + // Handle third-party tasks from Python _scheduled_tasks WeakSet + // In Python 3.14+, _scheduled_tasks is a Python-level weakref.WeakSet() that only contains + // tasks that don't inherit from asyncio.Task. Native asyncio.Task instances are stored + // in linked-lists (handled above) and are NOT added to _scheduled_tasks. + // This is typically empty in practice, but we handle it for completeness. + if (asyncio_scheduled_tasks != nullptr) { + if (auto maybe_scheduled_tasks_set = MirrorSet::create(asyncio_scheduled_tasks)) { + auto scheduled_tasks_set = std::move(*maybe_scheduled_tasks_set); + if (auto maybe_scheduled_tasks = scheduled_tasks_set.as_unordered_set()) { + auto scheduled_tasks = std::move(*maybe_scheduled_tasks); + for (auto task_addr : scheduled_tasks) { + // In WeakSet.data (set), elements are the Task objects themselves + auto maybe_task_info = TaskInfo::create(reinterpret_cast(task_addr)); + if (maybe_task_info && + (*maybe_task_info)->loop == reinterpret_cast(this->asyncio_loop)) { + tasks.push_back(std::move(*maybe_task_info)); + } + } + } + } + } + + if (asyncio_eager_tasks != NULL) { + auto maybe_eager_tasks_set = MirrorSet::create(asyncio_eager_tasks); + if (!maybe_eager_tasks_set) { + return ErrorKind::TaskInfoError; + } + + auto eager_tasks_set = std::move(*maybe_eager_tasks_set); + + auto maybe_eager_tasks = eager_tasks_set.as_unordered_set(); + if (!maybe_eager_tasks) { + return ErrorKind::TaskInfoError; + } + + auto eager_tasks = std::move(*maybe_eager_tasks); + for (auto task_addr : eager_tasks) { + auto maybe_task_info = TaskInfo::create(reinterpret_cast(task_addr)); + if (maybe_task_info) { + if ((*maybe_task_info)->loop == reinterpret_cast(this->asyncio_loop)) { + tasks.push_back(std::move(*maybe_task_info)); + } + } + } + } + + return tasks; +} +#else +// Pre-Python 3.14: get_all_tasks uses WeakSet approach +inline Result> +ThreadInfo::get_all_tasks(PyThreadState*) +{ + std::vector tasks; + if (this->asyncio_loop == 0) + return tasks; + + auto maybe_scheduled_tasks_set = MirrorSet::create(asyncio_scheduled_tasks); + if (!maybe_scheduled_tasks_set) { + return ErrorKind::TaskInfoError; + } + + auto scheduled_tasks_set = std::move(*maybe_scheduled_tasks_set); + auto maybe_scheduled_tasks = scheduled_tasks_set.as_unordered_set(); + if (!maybe_scheduled_tasks) { + return ErrorKind::TaskInfoError; + } + + auto scheduled_tasks = std::move(*maybe_scheduled_tasks); + for (auto task_wr_addr : scheduled_tasks) { + PyWeakReference task_wr; + if (copy_type(task_wr_addr, task_wr)) + continue; + + auto maybe_task_info = TaskInfo::create(reinterpret_cast(task_wr.wr_object)); + if (maybe_task_info) { + if ((*maybe_task_info)->loop == reinterpret_cast(this->asyncio_loop)) { + tasks.push_back(std::move(*maybe_task_info)); + } + } + } + + if (asyncio_eager_tasks != NULL) { + auto maybe_eager_tasks_set = MirrorSet::create(asyncio_eager_tasks); + if (!maybe_eager_tasks_set) { + return ErrorKind::TaskInfoError; + } + + auto eager_tasks_set = std::move(*maybe_eager_tasks_set); + + auto maybe_eager_tasks = eager_tasks_set.as_unordered_set(); + if (!maybe_eager_tasks) { + return ErrorKind::TaskInfoError; + } + + auto eager_tasks = std::move(*maybe_eager_tasks); + for (auto task_addr : eager_tasks) { + auto maybe_task_info = TaskInfo::create(reinterpret_cast(task_addr)); + if (maybe_task_info) { + if ((*maybe_task_info)->loop == reinterpret_cast(this->asyncio_loop)) { + tasks.push_back(std::move(*maybe_task_info)); + } + } + } + } + + return tasks; +} +#endif // PY_VERSION_HEX >= 0x030e0000 + +// ---------------------------------------------------------------------------- +inline void +ThreadInfo::unwind_greenlets(PyThreadState* tstate, unsigned long cur_native_id) +{ + const std::lock_guard guard(greenlet_info_map_lock); + + if (greenlet_thread_map.find(cur_native_id) == greenlet_thread_map.end()) + return; + + std::unordered_set parent_greenlets; + + // Collect all parent greenlets + std::transform(greenlet_parent_map.cbegin(), + greenlet_parent_map.cend(), + std::inserter(parent_greenlets, parent_greenlets.begin()), + [](const std::pair& kv) { return kv.second; }); + + // Unwind the leaf greenlets + for (auto& greenlet_info : greenlet_info_map) { + auto greenlet_id = greenlet_info.first; + auto& greenlet = greenlet_info.second; + + if (parent_greenlets.contains(greenlet_id)) + continue; + + auto frame = greenlet->frame; + if (frame == FRAME_NOT_SET) { + // The greenlet has not been started yet or has finished + continue; + } + + bool on_cpu = frame == Py_None; + + auto stack_info = std::make_unique(greenlet->name, on_cpu); + auto& stack = stack_info->stack; + + greenlet->unwind(frame, tstate, stack); + + // Unwind the parent greenlets + for (;;) { + auto parent_greenlet_info = greenlet_parent_map.find(greenlet_id); + if (parent_greenlet_info == greenlet_parent_map.end()) + break; + + auto parent_greenlet_id = parent_greenlet_info->second; + + auto parent_greenlet = greenlet_info_map.find(parent_greenlet_id); + if (parent_greenlet == greenlet_info_map.end()) + break; + + auto parent_frame = parent_greenlet->second->frame; + if (parent_frame == FRAME_NOT_SET || parent_frame == Py_None) + break; + + parent_greenlet->second->unwind(parent_frame, tstate, stack); + + // Move up the greenlet chain + greenlet_id = parent_greenlet_id; + } + + current_greenlets.push_back(std::move(stack_info)); + } +} + +// ---------------------------------------------------------------------------- +inline Result +ThreadInfo::sample(int64_t iid, PyThreadState* tstate, microsecond_t delta) +{ + Renderer::get().render_thread_begin(tstate, name, delta, thread_id, native_id); + + microsecond_t previous_cpu_time = cpu_time; + auto update_cpu_time_success = update_cpu_time(); + if (!update_cpu_time_success) { + return ErrorKind::CpuTimeError; + } + + Renderer::get().render_cpu_time(is_running() ? cpu_time - previous_cpu_time : 0); + + this->unwind(tstate); + + // Render in this order of priority + // 1. asyncio Tasks stacks (if any) + // 2. Greenlets stacks (if any) + // 3. The normal thread stack (if no asyncio tasks or greenlets) + if (!current_tasks.empty()) { + for (auto& task_stack_info : current_tasks) { + auto maybe_task_name = string_table.lookup(task_stack_info->task_name); + if (!maybe_task_name) { + return ErrorKind::ThreadInfoError; + } + + const auto& task_name = maybe_task_name->get(); + Renderer::get().render_task_begin(task_name, task_stack_info->on_cpu); + Renderer::get().render_stack_begin(pid, iid, name); + + task_stack_info->stack.render(); + + Renderer::get().render_stack_end(MetricType::Time, delta); + } + + current_tasks.clear(); + } else if (!current_greenlets.empty()) { + for (auto& greenlet_stack : current_greenlets) { + auto maybe_task_name = string_table.lookup(greenlet_stack->task_name); + if (!maybe_task_name) { + return ErrorKind::ThreadInfoError; + } + + const auto& task_name = maybe_task_name->get(); + Renderer::get().render_task_begin(task_name, greenlet_stack->on_cpu); + Renderer::get().render_stack_begin(pid, iid, name); + + auto& stack = greenlet_stack->stack; + stack.render(); + + Renderer::get().render_stack_end(MetricType::Time, delta); + } + + current_greenlets.clear(); + } else { + Renderer::get().render_stack_begin(pid, iid, name); + python_stack.render(); + Renderer::get().render_stack_end(MetricType::Time, delta); + } + + return Result::ok(); +} + +// ---------------------------------------------------------------------------- using PyThreadStateCallback = std::function; void diff --git a/releasenotes/notes/llm-obs-litellm-add-router-span-info-10d6e1d5149e8407.yaml b/releasenotes/notes/llm-obs-litellm-add-router-span-info-10d6e1d5149e8407.yaml new file mode 100644 index 00000000000..eccccd0c1c2 --- /dev/null +++ b/releasenotes/notes/llm-obs-litellm-add-router-span-info-10d6e1d5149e8407.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + litellm: This fix resolves an issue where litellm>=1.74.15 wrapped router streaming responses in ``FallbackStreamWrapper`` (introduced for mid-stream fallback support) that caused an ``AttributeError`` when attempting to access the ``.handler`` attribute. The integration now gracefully handles both the original response format and wrapped responses by falling back to ddtrace's own stream wrapping when needed. diff --git a/riotfile.py b/riotfile.py index 34159e0c9b0..453ce079ee5 100644 --- a/riotfile.py +++ b/riotfile.py @@ -2967,13 +2967,25 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT command="pytest {cmdargs} tests/contrib/litellm", pys=select_pys(min_version="3.9", max_version="3.13"), pkgs={ - "litellm": "==1.65.4", "vcrpy": latest, "pytest-asyncio": latest, "botocore": latest, "boto3": latest, - "openai": "==1.68.2", }, + venvs=[ + Venv( + pkgs={ + "litellm": "==1.65.4", + "openai": "==1.68.2", + }, + ), + Venv( + pkgs={ + "litellm": "==1.80.16", + "openai": ">=2.8.0", + }, + ), + ], ), Venv( name="anthropic", diff --git a/tests/contrib/litellm/cassettes/completion_anthropic.yaml b/tests/contrib/litellm/cassettes/completion_anthropic.yaml index a719ac0af83..d920a46ee28 100644 --- a/tests/contrib/litellm/cassettes/completion_anthropic.yaml +++ b/tests/contrib/litellm/cassettes/completion_anthropic.yaml @@ -1,39 +1,38 @@ interactions: - request: - body: '{"model": "claude-3-5-sonnet-20240620", "messages": [{"role": "user", "content": + body: '{"model": "claude-sonnet-4-5-20250929", "messages": [{"role": "user", "content": [{"type": "text", "text": "Hey, what is up?"}]}], "max_tokens": 4096}' headers: + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '150' + Host: + - api.anthropic.com + User-Agent: + - litellm/1.65.4 accept: - application/json - accept-encoding: - - gzip, deflate anthropic-version: - '2023-06-01' - connection: - - keep-alive - content-length: - - '150' content-type: - application/json - host: - - api.anthropic.com - user-agent: - - litellm/1.63.12 method: POST uri: https://api.anthropic.com/v1/messages response: body: string: !!binary | - H4sIAAAAAAAAA2RQTW/UQAz9K8aXvcyibNoCygWVU5dyQagnhCJ3xiSjztpp7Gm7Wu1/RwlUAnGy - 9L70nk+YE3Z4sKFvdrd37Xhf33+gT5+/yXT7ZUdPX9s7DOjHiRcVm9HAGHDWsgBkls1JHAMeNHHB - DmOhmnh7sb3amoqwb9umvWzetQ0GjCrO4th9P72GOr8s9vV0eMOl6Bu4NiCB6z0UkqHSwLDGB9hD - Utk4jPTEMPFsKlSAXyaeM0tkA53hJ3PJMliA++qw3xxg5JnBFUYuExy1wnP2EUiO8FjZPKusRtcp - R1sEmwQlP6yelC1Ws7dwo88QSWAPv3evQa6Jjh/x/COguU79zGQq2CFL6r3Ogn8I48e6NMROaikB - 6/rK7oRZpuq96wOLYbe7CBgpjtzHmWlp1v8raF75mSn9z2n1v5HLq/P5FwAAAP//AwAsXlFX5AEA - AA== + H4sIAAAAAAAA/3WQzU7EIBSFXwXZuGlNW+1iujHpbCYu3EwmJhpDEG4GxhYqXNRm0nf3dmLjX9wA + Oefjcg5H3nsNHW+46mTSkEfvHGB+ldd5VVR1sapWPONWE9HHvSjKXXgaWqzW27W53222NwdZt21L + DI4DzBTEKPdAQvDdLMgYbUTpkCTlHQKdmofjwiO8z85pa/gGxjN265H1SZmMHVJEZiAAk06zAFKP + DD1TRuIFu6P1PLI0sDeLho0+XfPpMeMR/SCIpSo0EZwWmILjn0aElwRO0dMudV3G0yltc+TWDQkF + +mdwkTflJaWVyoBQNAqtd+InUCz+nOo/b7k7z4fBQA9BdqLu//Jfbml+u1PGfcLvUlVSGwivVoFA + C4F6zl+sZdB8mj4AW7X2WtUBAAA= headers: CF-RAY: - - 923fd79c4a4a7cfc-EWR + - 9c13ef8b8f837c17-BOS Connection: - keep-alive Content-Encoding: @@ -41,45 +40,39 @@ interactions: Content-Type: - application/json Date: - - Fri, 21 Mar 2025 19:26:40 GMT + - Wed, 21 Jan 2026 04:05:54 GMT Server: - cloudflare Transfer-Encoding: - chunked X-Robots-Tag: - none - anthropic-organization-id: - - 0280e0cf-573a-4392-b276-1b73319958fb anthropic-ratelimit-input-tokens-limit: - - '20000' + - '20000000' anthropic-ratelimit-input-tokens-remaining: - - '20000' + - '20000000' anthropic-ratelimit-input-tokens-reset: - - '2025-03-21T19:26:40Z' + - '2026-01-21T04:05:53Z' anthropic-ratelimit-output-tokens-limit: - - '4000' + - '2500000' anthropic-ratelimit-output-tokens-remaining: - - '4000' + - '2500000' anthropic-ratelimit-output-tokens-reset: - - '2025-03-21T19:26:40Z' - anthropic-ratelimit-requests-limit: - - '5' - anthropic-ratelimit-requests-remaining: - - '4' - anthropic-ratelimit-requests-reset: - - '2025-03-21T19:26:52Z' + - '2026-01-21T04:05:54Z' anthropic-ratelimit-tokens-limit: - - '24000' + - '22500000' anthropic-ratelimit-tokens-remaining: - - '24000' + - '22500000' anthropic-ratelimit-tokens-reset: - - '2025-03-21T19:26:40Z' + - '2026-01-21T04:05:53Z' cf-cache-status: - DYNAMIC request-id: - - req_01RRDNDcX3wjQFEMkLiTep47 - via: - - 1.1 google + - req_011CXKrywDmBQp6dUJy8utgK + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '1956' status: code: 200 message: OK diff --git a/tests/contrib/litellm/cassettes/completion_anthropic_v1_74_15.yaml b/tests/contrib/litellm/cassettes/completion_anthropic_v1_74_15.yaml new file mode 100644 index 00000000000..bead4d246ff --- /dev/null +++ b/tests/contrib/litellm/cassettes/completion_anthropic_v1_74_15.yaml @@ -0,0 +1,81 @@ +interactions: +- request: + body: '{"model": "claude-sonnet-4-5-20250929", "messages": [{"role": "user", "content": + [{"type": "text", "text": "Hey, what is up?"}]}], "max_tokens": 64000}' + headers: + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '151' + Host: + - api.anthropic.com + User-Agent: + - litellm/1.80.16 + accept: + - application/json + anthropic-version: + - '2023-06-01' + content-type: + - application/json + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: !!binary | + H4sIAAAAAAAA/3VRy07DMBD8FeMLlxQloT00lwouIJC4IF5CyLLsVWxI1qm9hkZV/h27UPESl/Vq + Zjy7o93y3mnoeMNVJ6OGWXCIQLP5bDGry3pRLuslL7jVSdGHVpTV/bqWrrq5Hm4v1Bk9zGlzeYoy + aWgcIKsgBNlCArzrMiBDsIEkUoKUQ4LUNY/bvZ5gk5nd0/BzGA/YlSPWR2UK9hwDMQMemETNPEg9 + MnJMGUlH7C7Vw8DiwN4sGTa6uGInOJKx2DKbBnkIlPvW5eqwYM5/WAY5ZsjYFZ+eCh7IDSK5p/Bp + B0AtKHrkn0SAdQRUaVmMXVfwuMvXbLnFIZIg9wIYeFMdp3xSGRAqWZF1KH4Kyj2fc/zH7f9mfxgM + 9OBlJxb9X/0XW5nf7FRwF+k7dFynNOBfrQJBFnzKmY+ipdd8mt4BqsoTaAcCAAA= + headers: + CF-RAY: + - 9c13ee9819f54cd8-BOS + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 21 Jan 2026 04:05:15 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Robots-Tag: + - none + anthropic-organization-id: + - 4257e925-ee99-4ee8-9c62-8e53716d5203 + anthropic-ratelimit-input-tokens-limit: + - '20000000' + anthropic-ratelimit-input-tokens-remaining: + - '20000000' + anthropic-ratelimit-input-tokens-reset: + - '2026-01-21T04:05:15Z' + anthropic-ratelimit-output-tokens-limit: + - '2500000' + anthropic-ratelimit-output-tokens-remaining: + - '2500000' + anthropic-ratelimit-output-tokens-reset: + - '2026-01-21T04:05:15Z' + anthropic-ratelimit-tokens-limit: + - '22500000' + anthropic-ratelimit-tokens-remaining: + - '22500000' + anthropic-ratelimit-tokens-reset: + - '2026-01-21T04:05:15Z' + cf-cache-status: + - DYNAMIC + request-id: + - req_011CXKrw4jsAzbQrs9S3uRTo + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '2041' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/contrib/litellm/test_litellm.py b/tests/contrib/litellm/test_litellm.py index 32b0a1f0b8f..201388dff7d 100644 --- a/tests/contrib/litellm/test_litellm.py +++ b/tests/contrib/litellm/test_litellm.py @@ -1,5 +1,7 @@ import pytest +from ddtrace.contrib.internal.litellm.patch import get_version +from ddtrace.internal.utils.version import parse_version from tests.contrib.litellm.utils import get_cassette_name from tests.utils import override_global_config @@ -90,12 +92,23 @@ async def test_litellm_atext_completion(litellm, snapshot_context, request_vcr, pass -@pytest.mark.parametrize("model", ["command-r", "anthropic/claude-3-5-sonnet-20240620"]) +@pytest.mark.parametrize("model", ["command-r", "anthropic/claude-sonnet-4-5-20250929"]) def test_litellm_completion_different_models(litellm, snapshot_context, request_vcr, model): + model_base = model.split("/")[0] + is_new_litellm = parse_version(get_version()) >= (1, 74, 15) + + if model == "command-r" and is_new_litellm: + pytest.skip("Cassette not yet generated for command-r on litellm >= 1.74.15") + + if is_new_litellm: + cassette_name = f"completion_{model_base}_v1_74_15.yaml" + else: + cassette_name = f"completion_{model_base}.yaml" + with snapshot_context( token="tests.contrib.litellm.test_litellm.test_litellm_completion", ignores=["meta.litellm.request.model"] ): - with request_vcr.use_cassette(f"completion_{model.split('/')[0]}.yaml"): + with request_vcr.use_cassette(cassette_name): messages = [{"content": "Hey, what is up?", "role": "user"}] litellm.completion( model=model, @@ -173,3 +186,59 @@ async def test_litellm_router_atext_completion(litellm, snapshot_context, reques if stream: async for _ in resp: pass + + +def test_litellm_router_stream_handler_attribute(litellm, request_vcr, router, test_spans): + """ + Regression test for litellm>=1.74.15 FallbackStreamWrapper compatibility. + Ensures spans are properly finished when handler attribute is not available. + """ + with request_vcr.use_cassette(get_cassette_name(stream=True, n=1)): + messages = [{"content": "Hey, what is up?", "role": "user"}] + resp = router.completion( + model="gpt-3.5-turbo", + messages=messages, + stream=True, + ) + + # The response should be consumable without AttributeError + chunks_received = 0 + for chunk in resp: + chunks_received += 1 + + assert chunks_received > 0, "Should have received at least one chunk" + + # Verify that a span was created and finished + spans = test_spans.pop_traces() + assert len(spans) > 0, "Should have created at least one trace" + assert len(spans[0]) > 0, "Should have created at least one span" + span = spans[0][0] + assert span.duration > 0, "Span should be finished with a duration set" + + +async def test_litellm_router_astream_handler_attribute(litellm, request_vcr, router, test_spans): + """ + Regression test for litellm>=1.74.15 FallbackStreamWrapper compatibility (async). + Ensures spans are properly finished when handler attribute is not available. + """ + with request_vcr.use_cassette(get_cassette_name(stream=True, n=1)): + messages = [{"content": "Hey, what is up?", "role": "user"}] + resp = await router.acompletion( + model="gpt-3.5-turbo", + messages=messages, + stream=True, + ) + + # The response should be consumable without AttributeError + chunks_received = 0 + async for chunk in resp: + chunks_received += 1 + + assert chunks_received > 0, "Should have received at least one chunk" + + # Verify that a span was created and finished + spans = test_spans.pop_traces() + assert len(spans) > 0, "Should have created at least one trace" + assert len(spans[0]) > 0, "Should have created at least one span" + span = spans[0][0] + assert span.duration > 0, "Span should be finished with a duration set"