diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index 2386b184e54..492c52233cd 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -10,6 +10,7 @@ UNSUPPORTED: Final = re.compile( r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$" r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$" r"|^tests/e2e/logging/test_otel_v2_langfuse_generation_output_e2e\.py$" + r"|^tests/e2e/logging/test_langsmith_batch_serialization_e2e\.py$" ) HARNESS: Final = re.compile( r"^tests/e2e/[A-Za-z0-9_.-]+\.(py|ini)$" diff --git a/litellm/integrations/langsmith.py b/litellm/integrations/langsmith.py index 352fcdf90f3..991b0411ee8 100644 --- a/litellm/integrations/langsmith.py +++ b/litellm/integrations/langsmith.py @@ -1,6 +1,7 @@ #### What this does #### # On success, logs events to Langsmith import asyncio +import json import os import random import traceback @@ -415,7 +416,7 @@ class LangsmithLogger(CustomBatchLogger): langsmith_api_key: Final = credentials["LANGSMITH_API_KEY"] langsmith_tenant_id: Final = credentials.get("LANGSMITH_TENANT_ID") url: Final = self._add_endpoint_to_url(langsmith_api_base, "runs/batch") - headers: Final = {"x-api-key": langsmith_api_key} + headers: Final = {"x-api-key": langsmith_api_key, "Content-Type": "application/json"} if langsmith_tenant_id: headers["x-tenant-id"] = langsmith_tenant_id elements_to_log: Final = [queue_object["data"] for queue_object in queue_objects] @@ -426,7 +427,7 @@ class LangsmithLogger(CustomBatchLogger): verbose_logger.debug("[LANGSMITH MOCK] Mock mode enabled - API calls will be intercepted") response: Final = await self.async_httpx_client.post( url=url, - json={"post": elements_to_log}, + content=json.dumps({"post": elements_to_log}, default=str, allow_nan=False), headers=headers, ) response.raise_for_status() diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index fcc05e54bc5..8ea46a6b261 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -845,6 +845,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + content=content, ) finally: await new_client.aclose() @@ -985,6 +986,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + content=content, ) finally: await new_client.aclose() @@ -1051,6 +1053,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + content=content, ) finally: await new_client.aclose() diff --git a/tests/e2e/coverage_registry/logging.yaml b/tests/e2e/coverage_registry/logging.yaml index 1f2f1d64711..7c83e4d3aea 100644 --- a/tests/e2e/coverage_registry/logging.yaml +++ b/tests/e2e/coverage_registry/logging.yaml @@ -13,6 +13,7 @@ - {id: logging.otel.failure.exports_metric, module: logging, tier: P0, event: failure, assertions: [exports_metric], exercised_on: [chat_completions, messages], source: "integrations/otel/logger.py", rationale: "Error spans for observability continuity"} - {id: logging.braintrust.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/braintrust_logging.py", rationale: "Evals platform spend"} - {id: logging.langsmith.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/langsmith.py", rationale: "LangChain ecosystem"} +- {id: logging.langsmith.success.serializes_non_native_metadata, module: logging, tier: P1, event: success, assertions: [serializes_non_native_metadata], exercised_on: [sdk], source: "integrations/langsmith.py", rationale: "datetime/Decimal/UUID metadata used to TypeError in json.dumps and drop the whole batch (LIT-8310)"} - {id: logging.arize.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, embeddings], source: "integrations/arize/arize.py", rationale: "ML-ops observability"} - {id: logging.mlflow.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions], source: "integrations/mlflow.py", rationale: "Experiment tracking cost/run"} - {id: logging.opik.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions], source: "integrations/opik/opik.py", rationale: "Eval platform spend/case"} diff --git a/tests/e2e/logging/test_langsmith_batch_serialization_e2e.py b/tests/e2e/logging/test_langsmith_batch_serialization_e2e.py new file mode 100644 index 00000000000..874b2b6a045 --- /dev/null +++ b/tests/e2e/logging/test_langsmith_batch_serialization_e2e.py @@ -0,0 +1,125 @@ +"""Live e2e: a LangSmith batch whose metadata holds non JSON-native Python values +(datetime, Decimal) must reach the real LangSmith API instead of dying in +json.dumps and dropping the whole batch. Only the SDK path can put such values +into the batch (the proxy JSON-decodes request metadata), so this test drives +litellm.acompletion in-process against the real OpenAI API with a LangsmithLogger +injected per request, flushes the batch, and reads the run back by id through +LangSmith's own API. Nothing is mocked. +""" + +from __future__ import annotations + +import asyncio +import datetime +import decimal +import os +import time +import uuid +from dataclasses import dataclass +from typing import Final + +import pytest +from e2e_config import CHEAP_OPENAI_MODEL, POLL_INTERVAL, POLL_TIMEOUT, unique_marker +from e2e_http import Headers, Success, get_external +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +import litellm +from litellm.integrations.langsmith import LangsmithLogger + +pytestmark = pytest.mark.e2e + + +class LangsmithHeaders(Headers): + x_api_key: str = Field(serialization_alias="x-api-key") + + +class LangsmithRunExtra(BaseModel): + model_config = ConfigDict(extra="allow") + requester_metadata: dict[str, JsonValue] | None = None + + +class LangsmithRun(BaseModel): + id: str + session_name: str | None = None + extra: LangsmithRunExtra + + +@dataclass(frozen=True, slots=True) +class LangsmithCreds: + api_key: str + base_url: str + project: str + + +def load_langsmith_creds() -> LangsmithCreds: + api_key = os.getenv("LANGSMITH_API_KEY") + if not api_key: + pytest.fail("LangSmith e2e requires LANGSMITH_API_KEY; missing credentials is a hard failure, not a skip") + if os.getenv("LANGSMITH_MOCK"): + pytest.fail("LANGSMITH_MOCK is set; this e2e must hit the real LangSmith API") + return LangsmithCreds( + api_key=api_key, + base_url=(os.getenv("LANGSMITH_BASE_URL") or "https://api.smith.langchain.com").rstrip("/"), + project=os.getenv("LANGSMITH_PROJECT") or "litellm-e2e", + ) + + +def _fetch_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun | None: + result = get_external( + f"{creds.base_url}/runs/{run_id}", + response_type=LangsmithRun, + headers=LangsmithHeaders(x_api_key=creds.api_key), + ) + match result: + case Success(data=run): + return run + case _: + return None + + +def _poll_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun: + deadline: Final = time.monotonic() + POLL_TIMEOUT + while time.monotonic() < deadline: + run = _fetch_run(creds, run_id) + if run is not None: + return run + time.sleep(POLL_INTERVAL) + pytest.fail(f"LangSmith run {run_id} never appeared within {POLL_TIMEOUT}s; the batch flush dropped it") + + +class TestLangsmithBatchSerialization: + @pytest.mark.asyncio + @pytest.mark.covers("logging.langsmith.success.serializes_non_native_metadata") + async def test_non_json_native_metadata_reaches_langsmith(self) -> None: + creds: Final = load_langsmith_creds() + logger: Final = LangsmithLogger( + langsmith_api_key=creds.api_key, langsmith_project=creds.project, langsmith_base_url=creds.base_url + ) + assert not logger.is_mock_mode, "LangsmithLogger initialised in mock mode; this e2e needs the real API" + marker: Final = unique_marker() + run_id: Final = uuid.uuid4() + created_at: Final = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + spend: Final = decimal.Decimal("0.0042") + response: Final = await litellm.acompletion( + model=f"openai/{CHEAP_OPENAI_MODEL}", + messages=[{"role": "user", "content": f"Reply with the single word ok ({marker})"}], + max_completion_tokens=5, + callbacks=[logger], + metadata={"run_id": str(run_id), "metadata": {"marker": marker, "created_at": created_at, "spend": spend}}, + ) + assert isinstance(response, litellm.ModelResponse) and response.id, ( + "a non-streaming completion must return a ModelResponse before the batch flush is meaningful" + ) + enqueue_deadline: Final = time.monotonic() + POLL_TIMEOUT + while time.monotonic() < enqueue_deadline and len(logger.log_queue) == 0: + await asyncio.sleep(0.5) + assert len(logger.log_queue) == 1, ( + f"the completion must be queued for the LangSmith batch, got {len(logger.log_queue)} queued entries" + ) + await logger.async_send_batch() + run: Final = _poll_run(creds, run_id) + requester_metadata: Final = run.extra.requester_metadata + assert requester_metadata is not None, "the run must carry the caller metadata under extra.requester_metadata" + assert requester_metadata["marker"] == marker + assert requester_metadata["created_at"] == str(created_at) + assert requester_metadata["spend"] == str(spend) diff --git a/tests/logging_callback_tests/test_langsmith_unit_test.py b/tests/logging_callback_tests/test_langsmith_unit_test.py index 17cd63d8974..341f71f3b4a 100644 --- a/tests/logging_callback_tests/test_langsmith_unit_test.py +++ b/tests/logging_callback_tests/test_langsmith_unit_test.py @@ -356,10 +356,12 @@ async def test_langsmith_key_based_logging(): # tenant_id should not be in headers if not provided assert "x-tenant-id" not in call_args[1]["headers"] + assert call_args[1]["headers"]["Content-Type"] == "application/json" + # Verify the request body contains the expected data - request_body = call_args[1]["json"] + request_body = json.loads(call_args[1]["content"]) assert "post" in request_body - assert len(request_body["post"]) == 1 # Should contain one run + assert len(request_body["post"]) == 1 # EXPECTED BODY expected_body = { @@ -404,7 +406,7 @@ async def test_langsmith_key_based_logging(): } # Print both bodies for debugging - actual_body = call_args[1]["json"] + actual_body = json.loads(call_args[1]["content"]) print("\nExpected body:") print(json.dumps(expected_body, indent=2)) print("\nActual body:") diff --git a/tests/test_litellm/integrations/test_langsmith_init.py b/tests/test_litellm/integrations/test_langsmith_init.py index f56d2310e73..9c0650baee6 100644 --- a/tests/test_litellm/integrations/test_langsmith_init.py +++ b/tests/test_litellm/integrations/test_langsmith_init.py @@ -1,13 +1,17 @@ import asyncio +import json import os +from datetime import datetime, timezone +from decimal import Decimal from typing import Final from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest - import litellm from litellm.integrations.langsmith import LangsmithLogger +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.types.integrations.langsmith import LangsmithQueueObject @@ -219,6 +223,121 @@ class TestLangsmithLoggerInit: assert len(logger.log_queue) == 1 +class TestLangsmithBatchSerialization: + async def _logger(self, transport_handler, tenant_id=None): + logger = LangsmithLogger( + langsmith_api_key="test-key", + langsmith_project="test-project", + langsmith_base_url="https://api.smith.langchain.com", + langsmith_tenant_id=tenant_id, + ) + if logger._flush_task is not None: + logger._flush_task.cancel() + handler = AsyncHTTPHandler() + await handler.client.aclose() + handler.client = httpx.AsyncClient( + transport=httpx.MockTransport(transport_handler) + ) + logger.async_httpx_client = handler + return logger + + @staticmethod + def _capturing_transport(captured): + async def handle(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, request=request, json={"ok": True}) + + return handle + + def _queue(self, logger, extra): + return [ + LangsmithQueueObject( + data={"id": "run-1", "name": "LLMRun", "extra": extra}, + credentials=logger.default_credentials, + ) + ] + + @pytest.mark.asyncio + async def test_datetime_and_decimal_metadata_reach_langsmith_as_strings(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger(self._capturing_transport(captured)) + logger.log_queue = self._queue( + logger, + { + "created_at": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + "spend": Decimal("0.0042"), + }, + ) + + await logger.async_send_batch() + + assert len(captured) == 1, "batch was dropped instead of being sent" + body = json.loads(captured[0].content) + assert body["post"][0]["extra"] == { + "created_at": "2026-01-02 03:04:05+00:00", + "spend": "0.0042", + } + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_nan_metadata_is_dropped_instead_of_shipping_invalid_json(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger(self._capturing_transport(captured)) + logger.log_queue = self._queue(logger, {"score": float("nan")}) + + await logger.async_send_batch() + + assert captured == [], ( + "nan metadata must abort the batch: a bare NaN token is invalid JSON and LangSmith rejects it" + ) + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_batch_declares_json_content_type(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger(self._capturing_transport(captured)) + logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"}) + + await logger.async_send_batch() + + assert captured[0].headers["content-type"] == "application/json", ( + "a content= body carries no implicit content type; LangSmith refuses it without this header" + ) + assert captured[0].url.path.endswith("/api/v1/runs/batch") + assert captured[0].headers["x-api-key"] == "test-key" + assert "x-tenant-id" not in captured[0].headers + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_tenant_id_is_forwarded_on_the_batch_request(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger( + self._capturing_transport(captured), tenant_id="tenant-1" + ) + logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"}) + + await logger.async_send_batch() + + assert captured[0].headers["x-tenant-id"] == "tenant-1" + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_langsmith_error_response_does_not_propagate(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + + async def reject(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(422, request=request, text="bad run") + + logger = await self._logger(reject) + logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"}) + + await logger.async_send_batch() + + assert len(captured) == 1, "the batch never left the process" + await logger.async_httpx_client.client.aclose() + + class TestLangsmithPrepareLogData: """Regression test for #24001: _prepare_log_data must inject usage_metadata into outputs so LangSmith's Cost column is populated.""" @@ -544,12 +663,10 @@ async def test_events_appended_during_flush_are_not_dropped(): credentials=logger.default_credentials, data={"id": "late"} ) - async def fake_post( - url: str, json: dict[str, list[dict[str, str]]], headers: dict[str, str] - ) -> MagicMock: + async def fake_post(url: str, content: str, headers: dict[str, str]) -> MagicMock: if not sent_batches: logger.log_queue.append(late_event) - sent_batches.append(json["post"]) + sent_batches.append(json.loads(content)["post"]) response = MagicMock() response.status_code = 200 response.raise_for_status = MagicMock() diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 33272a1a9e4..8358d15d30e 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -6,6 +6,8 @@ import pathlib import ssl import threading import weakref +from collections.abc import Callable, Mapping +from typing import Final from unittest.mock import MagicMock, patch import certifi @@ -23,6 +25,7 @@ from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, get_ssl_configuration, ) +from litellm.types.llms.custom_http import VerifyTypes @pytest.mark.asyncio @@ -1396,6 +1399,47 @@ async def test_finalizer_on_live_loop_disposes_foreign_loop_session_without_sche assert session.closed +class _RetryClientHandler(AsyncHTTPHandler): + def __init__(self, first: httpx.AsyncClient, retry: httpx.AsyncClient) -> None: + self._retry_client: Final = retry + super().__init__() + self.client = first + + def create_client( + self, + timeout: float | httpx.Timeout | None = None, + event_hooks: Mapping[str, list[Callable[..., object]]] | None = None, + ssl_verify: VerifyTypes | None = None, + shared_session: ClientSession | None = None, + ) -> httpx.AsyncClient: + return self._retry_client + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"]) +async def test_connection_error_retry_forwards_content(method: str): + captured: list[bytes] = [] # mutable-ok: async closure capture buffer + + async def raise_connection_error(request: httpx.Request) -> httpx.Response: + raise httpx.RemoteProtocolError("connection dropped", request=request) + + async def capture_and_succeed(request: httpx.Request) -> httpx.Response: + captured.append(request.content) + return httpx.Response(200, request=request) + + first: Final = httpx.AsyncClient(transport=httpx.MockTransport(raise_connection_error)) + retry: Final = httpx.AsyncClient(transport=httpx.MockTransport(capture_and_succeed)) + async with first, retry: + handler: Final = _RetryClientHandler(first=first, retry=retry) + + body = b'{"post": ["run1"]}' + await getattr(handler, method)("https://api.example.com/runs/batch", content=body) + + assert captured == [body], "the retried request must carry the same content= body" + await handler.close() + + + @pytest.fixture def forward_proxy_server(): """Plain HTTP forward proxy that records the absolute URIs it is asked to fetch."""