mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
* fix(langsmith): json.dumps with default=str so non-serializable metadata does not crash batch flush Serialize the runs/batch payload with json.dumps(default=str, allow_nan=False) and send it as content= with an explicit Content-Type, so datetime, Decimal and similar metadata values no longer raise TypeError and drop the batch. Forward content= on the AsyncHTTPHandler retry path so a retried batch re-sends the identical body Replaces #39133, which was cut from the retired staging branch and conflicts with main Co-authored-by: Damien Smrt <dsmrt@users.noreply.github.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(langsmith): drop test docstrings and replace monkeypatch with a client-injecting handler Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(langsmith): add live e2e for non-native metadata reaching LangSmith Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(langsmith): scope the e2e docstring to the values the test injects Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(http_handler): close injected retry clients Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): deselect the LangSmith live e2e on the stage-mirror stack Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yucheng <yucheng@berri.ai> Co-authored-by: Damien Smrt <dsmrt@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
"""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)
|