Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion langfuse/api/commons/types/base_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ class BaseScore(UniversalBaseModel):
Comment on the score
"""

metadata: typing.Any = pydantic.Field()
metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(
default=None
)
"""
Metadata associated with the score
"""
Expand Down
4 changes: 3 additions & 1 deletion langfuse/api/commons/types/base_score_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ class BaseScoreV1(UniversalBaseModel):
Comment on the score
"""

metadata: typing.Any = pydantic.Field()
metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(
default=None
)
"""
Metadata associated with the score
"""
Expand Down
4 changes: 3 additions & 1 deletion langfuse/api/commons/types/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ class Observation(UniversalBaseModel):
The version of the observation
"""

metadata: typing.Any = pydantic.Field()
metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(
default=None
)
"""
Additional metadata of the observation
"""
Expand Down
4 changes: 3 additions & 1 deletion langfuse/api/commons/types/observation_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ class ObservationV2(UniversalBaseModel):
The output data of the observation
"""

metadata: typing.Optional[typing.Any] = pydantic.Field(default=None)
metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(
default=None
)
"""
Additional metadata of the observation
"""
Expand Down
10 changes: 5 additions & 5 deletions langfuse/api/commons/types/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class Score_Numeric(Base):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
Comment thread
niklassemmler marked this conversation as resolved.
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -100,7 +100,7 @@ class Score_Categorical(Base):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -146,7 +146,7 @@ class Score_Boolean(Base):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -192,7 +192,7 @@ class Score_Correction(Base):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -237,7 +237,7 @@ class Score_Text(Base):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down
8 changes: 4 additions & 4 deletions langfuse/api/commons/types/score_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ScoreV1_Numeric(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -73,7 +73,7 @@ class ScoreV1_Categorical(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -111,7 +111,7 @@ class ScoreV1_Boolean(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -148,7 +148,7 @@ class ScoreV1_Text(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down
6 changes: 4 additions & 2 deletions langfuse/api/commons/types/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ class Trace(UniversalBaseModel):
The user identifier associated with the trace
"""

metadata: typing.Optional[typing.Any] = pydantic.Field(default=None)
metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(
default=None
)
"""
The metadata associated with the trace. Can be any JSON.
The metadata associated with the trace. Values can be any JSON; non-object metadata sent at ingestion is returned under the `metadata` key.
"""

tags: typing.List[str] = pydantic.Field()
Expand Down
10 changes: 5 additions & 5 deletions langfuse/api/scores/types/get_scores_response_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class GetScoresResponseData_Numeric(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -92,7 +92,7 @@ class GetScoresResponseData_Categorical(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -139,7 +139,7 @@ class GetScoresResponseData_Boolean(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -186,7 +186,7 @@ class GetScoresResponseData_Correction(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down Expand Up @@ -232,7 +232,7 @@ class GetScoresResponseData_Text(UniversalBaseModel):
typing.Optional[str], FieldMetadata(alias="authorUserId")
] = None
comment: typing.Optional[str] = None
metadata: typing.Any
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
config_id: typing_extensions.Annotated[
typing.Optional[str], FieldMetadata(alias="configId")
] = None
Expand Down
166 changes: 166 additions & 0 deletions tests/unit/test_api_metadata_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Response parsing of read-side ``metadata`` on generated API models.

The server returns read-side metadata as a JSON object, ``null``, or omits it.
These tests pin that contract so a future regeneration that makes the field
required or narrows its values is caught.
"""

import typing

import pydantic
import pytest

from langfuse.api import (
BaseScore,
BaseScoreV1,
GetScoresResponseData,
Observation,
ObservationV2,
Score,
ScoreV1,
Trace,
)
from langfuse.api.core import parse_obj_as

TIMESTAMP = "2026-01-01T00:00:00.000Z"

SCORE_BASE: typing.Dict[str, typing.Any] = {
"id": "score-1",
"name": "quality",
"source": "API",
"timestamp": TIMESTAMP,
"createdAt": TIMESTAMP,
"updatedAt": TIMESTAMP,
"environment": "default",
}
SCORE_V1_BASE = {**SCORE_BASE, "traceId": "trace-1"}

SCORE_VARIANTS: typing.Dict[str, typing.Dict[str, typing.Any]] = {
"NUMERIC": {"value": 0.5},
"CATEGORICAL": {"value": 1, "stringValue": "good"},
"BOOLEAN": {"value": 1, "stringValue": "True"},
"CORRECTION": {"value": 0, "stringValue": "corrected output"},
"TEXT": {"stringValue": "free text"},
}
SCORE_V1_DATA_TYPES = ["NUMERIC", "CATEGORICAL", "BOOLEAN", "TEXT"]


def _score_payload(
base: typing.Dict[str, typing.Any], data_type: str
) -> typing.Dict[str, typing.Any]:
return {**base, "dataType": data_type, **SCORE_VARIANTS[data_type]}


CASES: typing.List[typing.Any] = [
pytest.param(
Trace,
{
"id": "trace-1",
"timestamp": TIMESTAMP,
"tags": [],
"public": False,
"environment": "default",
},
id="Trace",
),
pytest.param(
Observation,
{
"id": "obs-1",
"type": "SPAN",
"startTime": TIMESTAMP,
"modelParameters": {},
"input": None,
"output": None,
"usage": {"input": 0, "output": 0, "total": 0},
"level": "DEFAULT",
"usageDetails": {},
"costDetails": {},
"environment": "default",
},
id="Observation",
),
pytest.param(
ObservationV2,
{
"id": "obs-1",
"startTime": TIMESTAMP,
"projectId": "project-1",
"type": "SPAN",
},
id="ObservationV2",
),
pytest.param(BaseScore, SCORE_BASE, id="BaseScore"),
pytest.param(BaseScoreV1, SCORE_V1_BASE, id="BaseScoreV1"),
*[
pytest.param(Score, _score_payload(SCORE_BASE, dt), id=f"Score-{dt}")
for dt in SCORE_VARIANTS
],
*[
pytest.param(
GetScoresResponseData,
_score_payload(SCORE_BASE, dt),
id=f"GetScoresResponseData-{dt}",
)
for dt in SCORE_VARIANTS
],
*[
pytest.param(ScoreV1, _score_payload(SCORE_V1_BASE, dt), id=f"ScoreV1-{dt}")
for dt in SCORE_V1_DATA_TYPES
],
]

NESTED_METADATA = {
"str": "value",
"int": 1,
"float": 1.5,
"bool": True,
"null": None,
"list": [1, "two", {"three": 3}],
"nested": {"deeper": {"key": ["a", "b"]}},
}


@pytest.mark.parametrize(("type_", "payload"), CASES)
def test_metadata_omitted_parses_as_none(type_, payload):
assert "metadata" not in payload

parsed = parse_obj_as(type_, payload)

assert parsed.metadata is None


@pytest.mark.parametrize(("type_", "payload"), CASES)
def test_metadata_null_parses_as_none(type_, payload):
parsed = parse_obj_as(type_, {**payload, "metadata": None})

assert parsed.metadata is None


@pytest.mark.parametrize(("type_", "payload"), CASES)
def test_metadata_empty_object_parses(type_, payload):
parsed = parse_obj_as(type_, {**payload, "metadata": {}})

assert parsed.metadata == {}


@pytest.mark.parametrize(("type_", "payload"), CASES)
def test_metadata_object_with_nested_json_values_parses(type_, payload):
parsed = parse_obj_as(type_, {**payload, "metadata": NESTED_METADATA})

assert parsed.metadata == NESTED_METADATA


@pytest.mark.parametrize(("type_", "payload"), CASES)
@pytest.mark.parametrize(
"metadata",
["a string", 1, 1.5, True, ["a", "list"]],
ids=["str", "int", "float", "bool", "list"],
)
def test_metadata_non_object_is_rejected(type_, payload, metadata):
with pytest.raises(pydantic.ValidationError) as exc_info:
parse_obj_as(type_, {**payload, "metadata": metadata})

# The discriminated unions prefix the location with the variant tag.
assert exc_info.value.errors()
assert all("metadata" in error["loc"] for error in exc_info.value.errors())
Loading