1111 lines
40 KiB
Python
1111 lines
40 KiB
Python
import asyncio
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import mlflow
|
|
from mlflow.entities import SpanType
|
|
from mlflow.gateway.constants import MLFLOW_GATEWAY_CALLER_HEADER
|
|
from mlflow.gateway.schemas.chat import StreamResponsePayload
|
|
from mlflow.gateway.tracing_utils import (
|
|
_extract_caller,
|
|
_get_model_span_info,
|
|
aggregate_anthropic_messages_stream_chunks,
|
|
aggregate_chat_stream_chunks,
|
|
aggregate_gemini_stream_generate_content_chunks,
|
|
aggregate_openai_responses_stream_chunks,
|
|
maybe_traced_gateway_call,
|
|
)
|
|
from mlflow.store.tracking.gateway.entities import GatewayEndpointConfig
|
|
from mlflow.tracing.client import TracingClient
|
|
from mlflow.tracing.constant import SpanAttributeKey, TraceMetadataKey
|
|
from mlflow.tracing.distributed import get_tracing_context_headers_for_http_request
|
|
from mlflow.tracking.fluent import _get_experiment_id
|
|
from mlflow.types.chat import ChatChoiceDelta, ChatChunkChoice, ChatUsage, Function, ToolCallDelta
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway_experiment_id():
|
|
experiment_name = "gateway-test-endpoint"
|
|
experiment = mlflow.get_experiment_by_name(experiment_name)
|
|
if experiment is not None:
|
|
return experiment.experiment_id
|
|
return mlflow.create_experiment(experiment_name)
|
|
|
|
|
|
def get_traces():
|
|
return TracingClient().search_traces(locations=[_get_experiment_id()])
|
|
|
|
|
|
@pytest.fixture
|
|
def endpoint_config():
|
|
return GatewayEndpointConfig(
|
|
endpoint_id="test-endpoint-id",
|
|
endpoint_name="test-endpoint",
|
|
experiment_id=_get_experiment_id(),
|
|
usage_tracking=True,
|
|
models=[],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def endpoint_config_no_experiment():
|
|
return GatewayEndpointConfig(
|
|
endpoint_id="test-endpoint-id",
|
|
endpoint_name="test-endpoint",
|
|
experiment_id=None,
|
|
models=[],
|
|
)
|
|
|
|
|
|
async def mock_async_func(payload):
|
|
return {"result": "success", "payload": payload}
|
|
|
|
|
|
def _make_chunk(
|
|
content=None,
|
|
finish_reason=None,
|
|
id="chunk-1",
|
|
model="test-model",
|
|
created=1700000000,
|
|
usage=None,
|
|
tool_calls=None,
|
|
role="assistant",
|
|
choice_index=0,
|
|
):
|
|
delta = ChatChoiceDelta(role=role, content=content, tool_calls=tool_calls)
|
|
choice = ChatChunkChoice(index=choice_index, finish_reason=finish_reason, delta=delta)
|
|
return StreamResponsePayload(
|
|
id=id,
|
|
created=created,
|
|
model=model,
|
|
choices=[choice],
|
|
usage=usage,
|
|
)
|
|
|
|
|
|
def test_aggregate_chat_stream_chunks_aggregates_content():
|
|
chunks = [
|
|
_make_chunk(content="Hello"),
|
|
_make_chunk(content=" "),
|
|
_make_chunk(content="world"),
|
|
_make_chunk(content=None, finish_reason="stop"),
|
|
]
|
|
result = aggregate_chat_stream_chunks(chunks)
|
|
|
|
assert result["object"] == "chat.completion"
|
|
assert result["model"] == "test-model"
|
|
assert result["choices"][0]["message"]["role"] == "assistant"
|
|
assert result["choices"][0]["message"]["content"] == "Hello world"
|
|
assert result["choices"][0]["finish_reason"] == "stop"
|
|
|
|
|
|
def test_aggregate_chat_stream_chunks_with_usage():
|
|
usage = ChatUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15)
|
|
chunks = [
|
|
_make_chunk(content="Hi"),
|
|
_make_chunk(content=None, finish_reason="stop", usage=usage),
|
|
]
|
|
result = aggregate_chat_stream_chunks(chunks)
|
|
|
|
assert result["choices"][0]["message"]["content"] == "Hi"
|
|
assert result["usage"] == {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 5,
|
|
"total_tokens": 15,
|
|
}
|
|
|
|
|
|
def test_aggregate_chat_stream_chunks_empty():
|
|
assert aggregate_chat_stream_chunks([]) is None
|
|
|
|
|
|
def test_aggregate_chat_stream_chunks_defaults_finish_reason():
|
|
chunks = [_make_chunk(content="Hi")]
|
|
result = aggregate_chat_stream_chunks(chunks)
|
|
|
|
assert result["choices"][0]["finish_reason"] == "stop"
|
|
|
|
|
|
def test_reduce_chat_stream_chunks_aggregates_tool_calls():
|
|
chunks = [
|
|
# First chunk: tool call id, type, and function name
|
|
_make_chunk(
|
|
tool_calls=[
|
|
ToolCallDelta(
|
|
index=0,
|
|
id="call_abc",
|
|
type="function",
|
|
function=Function(name="get_weather", arguments=""),
|
|
),
|
|
],
|
|
),
|
|
# Subsequent chunks: argument fragments
|
|
_make_chunk(
|
|
tool_calls=[
|
|
ToolCallDelta(index=0, function=Function(arguments='{"loc')),
|
|
],
|
|
),
|
|
_make_chunk(
|
|
tool_calls=[
|
|
ToolCallDelta(index=0, function=Function(arguments='ation": "SF"}')),
|
|
],
|
|
),
|
|
_make_chunk(finish_reason="tool_calls"),
|
|
]
|
|
result = aggregate_chat_stream_chunks(chunks)
|
|
|
|
assert result["choices"][0]["message"]["content"] is None
|
|
assert result["choices"][0]["finish_reason"] == "tool_calls"
|
|
|
|
tool_calls = result["choices"][0]["message"]["tool_calls"]
|
|
assert len(tool_calls) == 1
|
|
assert tool_calls[0]["id"] == "call_abc"
|
|
assert tool_calls[0]["type"] == "function"
|
|
assert tool_calls[0]["function"]["name"] == "get_weather"
|
|
assert tool_calls[0]["function"]["arguments"] == '{"location": "SF"}'
|
|
|
|
|
|
def test_reduce_chat_stream_chunks_derives_role_from_delta():
|
|
chunks = [
|
|
_make_chunk(role="developer", content="Hello"),
|
|
_make_chunk(role=None, content=" world"),
|
|
_make_chunk(role=None, finish_reason="stop"),
|
|
]
|
|
result = aggregate_chat_stream_chunks(chunks)
|
|
|
|
assert result["choices"][0]["message"]["role"] == "developer"
|
|
|
|
|
|
def test_reduce_chat_stream_chunks_multiple_choice_indices():
|
|
chunks = [
|
|
_make_chunk(content="Hi", choice_index=0),
|
|
_make_chunk(content="Hey", choice_index=1),
|
|
_make_chunk(content=" there", choice_index=0),
|
|
_make_chunk(content=" you", choice_index=1),
|
|
_make_chunk(finish_reason="stop", choice_index=0),
|
|
_make_chunk(finish_reason="stop", choice_index=1),
|
|
]
|
|
result = aggregate_chat_stream_chunks(chunks)
|
|
|
|
assert len(result["choices"]) == 2
|
|
assert result["choices"][0]["index"] == 0
|
|
assert result["choices"][0]["message"]["content"] == "Hi there"
|
|
assert result["choices"][1]["index"] == 1
|
|
assert result["choices"][1]["message"]["content"] == "Hey you"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_basic(endpoint_config):
|
|
traced_func = maybe_traced_gateway_call(mock_async_func, endpoint_config)
|
|
result = await traced_func({"input": "test"})
|
|
|
|
assert result == {"result": "success", "payload": {"input": "test"}}
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
trace = traces[0]
|
|
|
|
# Find the gateway span
|
|
span_name_to_span = {span.name: span for span in trace.data.spans}
|
|
assert f"gateway/{endpoint_config.endpoint_name}" in span_name_to_span
|
|
|
|
gateway_span = span_name_to_span[f"gateway/{endpoint_config.endpoint_name}"]
|
|
assert gateway_span.attributes.get("endpoint_id") == "test-endpoint-id"
|
|
assert gateway_span.attributes.get("endpoint_name") == "test-endpoint"
|
|
# Input should be unwrapped (not nested under "payload" key)
|
|
assert gateway_span.inputs == {"input": "test"}
|
|
# No user metadata should be present in trace
|
|
assert trace.info.request_metadata.get(TraceMetadataKey.AUTH_USERNAME) is None
|
|
assert trace.info.request_metadata.get(TraceMetadataKey.AUTH_USER_ID) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_with_user_metadata(endpoint_config):
|
|
traced_func = maybe_traced_gateway_call(
|
|
mock_async_func,
|
|
endpoint_config,
|
|
metadata={
|
|
TraceMetadataKey.AUTH_USERNAME: "alice",
|
|
TraceMetadataKey.AUTH_USER_ID: "123",
|
|
},
|
|
)
|
|
result = await traced_func({"input": "test"})
|
|
|
|
assert result == {"result": "success", "payload": {"input": "test"}}
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
trace = traces[0]
|
|
|
|
span_name_to_span = {span.name: span for span in trace.data.spans}
|
|
gateway_span = span_name_to_span[f"gateway/{endpoint_config.endpoint_name}"]
|
|
|
|
assert gateway_span.attributes.get("endpoint_id") == "test-endpoint-id"
|
|
assert gateway_span.attributes.get("endpoint_name") == "test-endpoint"
|
|
# Input should be unwrapped (not nested under "payload" key)
|
|
assert gateway_span.inputs == {"input": "test"}
|
|
# User metadata should be in trace info, not span attributes
|
|
assert trace.info.request_metadata.get(TraceMetadataKey.AUTH_USERNAME) == "alice"
|
|
assert trace.info.request_metadata.get(TraceMetadataKey.AUTH_USER_ID) == "123"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_without_usage_tracking(endpoint_config_no_experiment):
|
|
traced_func = maybe_traced_gateway_call(
|
|
mock_async_func,
|
|
endpoint_config_no_experiment,
|
|
metadata={
|
|
TraceMetadataKey.AUTH_USERNAME: "alice",
|
|
TraceMetadataKey.AUTH_USER_ID: "123",
|
|
},
|
|
)
|
|
|
|
# When usage_tracking is False, maybe_traced_gateway_call returns the original function
|
|
assert traced_func is mock_async_func
|
|
|
|
result = await traced_func({"input": "test"})
|
|
assert result == {"result": "success", "payload": {"input": "test"}}
|
|
|
|
# No traces should be created
|
|
traces = get_traces()
|
|
assert len(traces) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_with_output_reducer(endpoint_config):
|
|
async def mock_async_stream(payload):
|
|
yield _make_chunk(content="Hello")
|
|
yield _make_chunk(content=" world")
|
|
yield _make_chunk(
|
|
content=None,
|
|
finish_reason="stop",
|
|
usage=ChatUsage(prompt_tokens=5, completion_tokens=2, total_tokens=7),
|
|
)
|
|
|
|
traced_func = maybe_traced_gateway_call(
|
|
mock_async_stream,
|
|
endpoint_config,
|
|
output_reducer=aggregate_chat_stream_chunks,
|
|
)
|
|
|
|
# Consume the stream
|
|
chunks = [
|
|
chunk async for chunk in traced_func({"messages": [{"role": "user", "content": "hi"}]})
|
|
]
|
|
assert len(chunks) == 3
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
trace = traces[0]
|
|
|
|
span_name_to_span = {span.name: span for span in trace.data.spans}
|
|
gateway_span = span_name_to_span[f"gateway/{endpoint_config.endpoint_name}"]
|
|
|
|
# Input should be unwrapped (not nested under "payload" key)
|
|
assert gateway_span.inputs == {"messages": [{"role": "user", "content": "hi"}]}
|
|
|
|
# The output should be the reduced aggregated response, not raw chunks
|
|
output = gateway_span.outputs
|
|
assert output["object"] == "chat.completion"
|
|
assert output["choices"][0]["message"]["content"] == "Hello world"
|
|
assert output["choices"][0]["finish_reason"] == "stop"
|
|
assert output["usage"]["total_tokens"] == 7
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_with_message_format(endpoint_config):
|
|
async def mock_async_func(payload):
|
|
return {"id": "msg_1", "type": "message", "role": "assistant", "content": []}
|
|
|
|
traced_func = maybe_traced_gateway_call(
|
|
mock_async_func,
|
|
endpoint_config,
|
|
message_format="anthropic",
|
|
)
|
|
await traced_func({"messages": [{"role": "user", "content": "hi"}]})
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
trace = traces[0]
|
|
|
|
span_name_to_span = {span.name: span for span in trace.data.spans}
|
|
gateway_span = span_name_to_span[f"gateway/{endpoint_config.endpoint_name}"]
|
|
assert gateway_span.get_attribute("mlflow.message.format") == "anthropic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_with_payload_kwarg(endpoint_config):
|
|
async def mock_passthrough_func(action, payload, headers=None):
|
|
return {"result": "success", "action": action, "payload": payload}
|
|
|
|
traced_func = maybe_traced_gateway_call(mock_passthrough_func, endpoint_config)
|
|
result = await traced_func(
|
|
action="test_action", payload={"messages": [{"role": "user", "content": "hi"}]}, headers={}
|
|
)
|
|
|
|
assert result["result"] == "success"
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
trace = traces[0]
|
|
|
|
span_name_to_span = {span.name: span for span in trace.data.spans}
|
|
gateway_span = span_name_to_span[f"gateway/{endpoint_config.endpoint_name}"]
|
|
|
|
# Input should be unwrapped to just the payload dict
|
|
assert gateway_span.inputs == {"messages": [{"role": "user", "content": "hi"}]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for distributed tracing helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_model_span_info_reads_child_span(endpoint_config):
|
|
async def func_with_child_span(payload):
|
|
with mlflow.start_span("provider/openai/gpt-4", span_type=SpanType.LLM) as child:
|
|
child.set_attributes({
|
|
SpanAttributeKey.CHAT_USAGE: {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
},
|
|
SpanAttributeKey.MODEL: "gpt-4",
|
|
SpanAttributeKey.MODEL_PROVIDER: "openai",
|
|
})
|
|
return {"result": "ok"}
|
|
|
|
traced = maybe_traced_gateway_call(func_with_child_span, endpoint_config)
|
|
await traced({"input": "test"})
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
gateway_trace_id = traces[0].info.trace_id
|
|
|
|
# After the trace is exported, spans are removed from InMemoryTraceManager,
|
|
# so we expect empty here. The actual reading happens inside the wrapper
|
|
# while the trace is still in memory.
|
|
assert _get_model_span_info(gateway_trace_id) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests for distributed tracing via traceparent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_with_traceparent(gateway_experiment_id):
|
|
ep_config = GatewayEndpointConfig(
|
|
endpoint_id="test-endpoint-id",
|
|
endpoint_name="test-endpoint",
|
|
experiment_id=gateway_experiment_id,
|
|
usage_tracking=True,
|
|
models=[],
|
|
)
|
|
|
|
async def func_with_usage(payload):
|
|
with mlflow.start_span("provider/openai/gpt-4", span_type=SpanType.LLM) as child:
|
|
child.set_attributes({
|
|
SpanAttributeKey.CHAT_USAGE: {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
},
|
|
SpanAttributeKey.MODEL: "gpt-4",
|
|
SpanAttributeKey.MODEL_PROVIDER: "openai",
|
|
})
|
|
return {"result": "ok"}
|
|
|
|
# Step 1: Agent creates span and generates traceparent headers
|
|
with mlflow.start_span("agent-root") as agent_span:
|
|
headers = get_tracing_context_headers_for_http_request()
|
|
agent_trace_id = agent_span.trace_id
|
|
agent_span_id = agent_span.span_id
|
|
|
|
# Step 2: Gateway processes request (no active agent span, simulating separate server)
|
|
traced = maybe_traced_gateway_call(func_with_usage, ep_config, request_headers=headers)
|
|
result = await traced({"input": "test"})
|
|
|
|
assert result == {"result": "ok"}
|
|
|
|
# Flush to ensure all spans are written (batch processor may be active)
|
|
mlflow.flush_trace_async_logging(terminate=True)
|
|
|
|
# Gateway trace should exist in the gateway experiment
|
|
gateway_traces = TracingClient().search_traces(locations=[gateway_experiment_id])
|
|
assert len(gateway_traces) == 1
|
|
gateway_trace_id = gateway_traces[0].info.trace_id
|
|
|
|
# The gateway trace should be separate from the agent trace
|
|
assert gateway_trace_id != agent_trace_id
|
|
|
|
# Agent trace should contain two distributed spans (gateway + provider)
|
|
agent_trace = mlflow.get_trace(agent_trace_id)
|
|
assert agent_trace is not None
|
|
|
|
spans_by_name = {s.name: s for s in agent_trace.data.spans}
|
|
assert "agent-root" in spans_by_name
|
|
assert f"gateway/{ep_config.endpoint_name}" in spans_by_name
|
|
assert "provider/openai/gpt-4" in spans_by_name
|
|
|
|
# Gateway span: child of agent root, has endpoint attrs + link
|
|
gw_span = spans_by_name[f"gateway/{ep_config.endpoint_name}"]
|
|
assert gw_span.parent_id == agent_span_id
|
|
assert gw_span.attributes.get("endpoint_id") == ep_config.endpoint_id
|
|
assert gw_span.attributes.get("endpoint_name") == ep_config.endpoint_name
|
|
assert gw_span.attributes.get(SpanAttributeKey.LINKED_GATEWAY_TRACE_ID) == gateway_trace_id
|
|
|
|
# Provider span: child of gateway span, has provider attrs
|
|
provider_span = spans_by_name["provider/openai/gpt-4"]
|
|
assert provider_span.parent_id == gw_span.span_id
|
|
assert provider_span.attributes.get(SpanAttributeKey.CHAT_USAGE) == {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
}
|
|
assert provider_span.attributes.get(SpanAttributeKey.MODEL) == "gpt-4"
|
|
assert provider_span.attributes.get(SpanAttributeKey.MODEL_PROVIDER) == "openai"
|
|
|
|
# Provider span should preserve timing from the gateway trace
|
|
gateway_provider_span = next(
|
|
s for s in gateway_traces[0].data.spans if s.name == "provider/openai/gpt-4"
|
|
)
|
|
assert provider_span.start_time_ns == gateway_provider_span.start_time_ns
|
|
assert provider_span.end_time_ns == gateway_provider_span.end_time_ns
|
|
|
|
# Neither span should have request/response payloads
|
|
assert gw_span.inputs is None
|
|
assert gw_span.outputs is None
|
|
assert provider_span.inputs is None
|
|
assert provider_span.outputs is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_streaming_with_traceparent(gateway_experiment_id):
|
|
ep_config = GatewayEndpointConfig(
|
|
endpoint_id="test-endpoint-id",
|
|
endpoint_name="test-endpoint",
|
|
experiment_id=gateway_experiment_id,
|
|
usage_tracking=True,
|
|
models=[],
|
|
)
|
|
|
|
async def mock_stream_with_usage(payload):
|
|
with mlflow.start_span("provider/openai/gpt-4", span_type=SpanType.LLM) as child:
|
|
child.set_attributes({
|
|
SpanAttributeKey.CHAT_USAGE: {
|
|
"input_tokens": 20,
|
|
"output_tokens": 10,
|
|
"total_tokens": 30,
|
|
},
|
|
SpanAttributeKey.MODEL: "gpt-4",
|
|
SpanAttributeKey.MODEL_PROVIDER: "openai",
|
|
})
|
|
yield _make_chunk(content="Hello")
|
|
yield _make_chunk(content=" world", finish_reason="stop")
|
|
|
|
# Agent creates headers
|
|
with mlflow.start_span("agent-root") as agent_span:
|
|
headers = get_tracing_context_headers_for_http_request()
|
|
agent_trace_id = agent_span.trace_id
|
|
agent_span_id = agent_span.span_id
|
|
|
|
# Gateway processes request (separate context)
|
|
traced = maybe_traced_gateway_call(
|
|
mock_stream_with_usage,
|
|
ep_config,
|
|
output_reducer=aggregate_chat_stream_chunks,
|
|
request_headers=headers,
|
|
)
|
|
chunks = [chunk async for chunk in traced({"input": "test"})]
|
|
|
|
assert len(chunks) == 2
|
|
|
|
# Flush to ensure all spans are written (batch processor may be active)
|
|
mlflow.flush_trace_async_logging(terminate=True)
|
|
|
|
# Gateway trace should exist
|
|
gateway_traces = TracingClient().search_traces(locations=[gateway_experiment_id])
|
|
assert len(gateway_traces) == 1
|
|
gateway_trace_id = gateway_traces[0].info.trace_id
|
|
assert gateway_trace_id != agent_trace_id
|
|
|
|
# Agent trace should contain two distributed spans (gateway + provider)
|
|
agent_trace = mlflow.get_trace(agent_trace_id)
|
|
assert agent_trace is not None
|
|
|
|
spans_by_name = {s.name: s for s in agent_trace.data.spans}
|
|
assert "agent-root" in spans_by_name
|
|
assert f"gateway/{ep_config.endpoint_name}" in spans_by_name
|
|
assert "provider/openai/gpt-4" in spans_by_name
|
|
|
|
# Gateway span: child of agent root, has endpoint attrs + link
|
|
gw_span = spans_by_name[f"gateway/{ep_config.endpoint_name}"]
|
|
assert gw_span.parent_id == agent_span_id
|
|
assert gw_span.attributes.get("endpoint_id") == ep_config.endpoint_id
|
|
assert gw_span.attributes.get("endpoint_name") == ep_config.endpoint_name
|
|
assert gw_span.attributes.get(SpanAttributeKey.LINKED_GATEWAY_TRACE_ID) == gateway_trace_id
|
|
|
|
# Provider span: child of gateway span, has provider attrs
|
|
provider_span = spans_by_name["provider/openai/gpt-4"]
|
|
assert provider_span.parent_id == gw_span.span_id
|
|
assert provider_span.attributes.get(SpanAttributeKey.CHAT_USAGE) == {
|
|
"input_tokens": 20,
|
|
"output_tokens": 10,
|
|
"total_tokens": 30,
|
|
}
|
|
assert provider_span.attributes.get(SpanAttributeKey.MODEL) == "gpt-4"
|
|
assert provider_span.attributes.get(SpanAttributeKey.MODEL_PROVIDER) == "openai"
|
|
|
|
# Provider span should preserve timing from the gateway trace
|
|
gateway_provider_span = next(
|
|
s for s in gateway_traces[0].data.spans if s.name == "provider/openai/gpt-4"
|
|
)
|
|
assert provider_span.start_time_ns == gateway_provider_span.start_time_ns
|
|
assert provider_span.end_time_ns == gateway_provider_span.end_time_ns
|
|
|
|
# Neither span should have request/response payloads
|
|
assert gw_span.inputs is None
|
|
assert gw_span.outputs is None
|
|
assert provider_span.inputs is None
|
|
assert provider_span.outputs is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_traced_gateway_call_with_traceparent_multiple_providers(gateway_experiment_id):
|
|
ep_config = GatewayEndpointConfig(
|
|
endpoint_id="test-endpoint-id",
|
|
endpoint_name="test-endpoint",
|
|
experiment_id=gateway_experiment_id,
|
|
usage_tracking=True,
|
|
models=[],
|
|
)
|
|
|
|
async def func_with_multiple_providers(payload):
|
|
with mlflow.start_span("provider/openai/gpt-4", span_type=SpanType.LLM) as child:
|
|
child.set_attributes({
|
|
SpanAttributeKey.CHAT_USAGE: {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
},
|
|
SpanAttributeKey.MODEL: "gpt-4",
|
|
SpanAttributeKey.MODEL_PROVIDER: "openai",
|
|
})
|
|
with mlflow.start_span("provider/anthropic/claude-3", span_type=SpanType.LLM) as child:
|
|
child.set_attributes({
|
|
SpanAttributeKey.CHAT_USAGE: {
|
|
"input_tokens": 20,
|
|
"output_tokens": 10,
|
|
"total_tokens": 30,
|
|
},
|
|
SpanAttributeKey.MODEL: "claude-3",
|
|
SpanAttributeKey.MODEL_PROVIDER: "anthropic",
|
|
})
|
|
return {"result": "ok"}
|
|
|
|
with mlflow.start_span("agent-root") as agent_span:
|
|
headers = get_tracing_context_headers_for_http_request()
|
|
agent_trace_id = agent_span.trace_id
|
|
|
|
traced = maybe_traced_gateway_call(
|
|
func_with_multiple_providers, ep_config, request_headers=headers
|
|
)
|
|
await traced({"input": "test"})
|
|
|
|
mlflow.flush_trace_async_logging()
|
|
agent_trace = mlflow.get_trace(agent_trace_id)
|
|
assert agent_trace is not None
|
|
|
|
spans_by_name = {s.name: s for s in agent_trace.data.spans}
|
|
gw_span = spans_by_name[f"gateway/{ep_config.endpoint_name}"]
|
|
|
|
# Both provider spans should be children of the gateway span
|
|
provider_openai = spans_by_name["provider/openai/gpt-4"]
|
|
assert provider_openai.parent_id == gw_span.span_id
|
|
assert provider_openai.attributes.get(SpanAttributeKey.MODEL) == "gpt-4"
|
|
assert provider_openai.attributes.get(SpanAttributeKey.MODEL_PROVIDER) == "openai"
|
|
assert provider_openai.attributes.get(SpanAttributeKey.CHAT_USAGE) == {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
}
|
|
|
|
provider_anthropic = spans_by_name["provider/anthropic/claude-3"]
|
|
assert provider_anthropic.parent_id == gw_span.span_id
|
|
assert provider_anthropic.attributes.get(SpanAttributeKey.MODEL) == "claude-3"
|
|
assert provider_anthropic.attributes.get(SpanAttributeKey.MODEL_PROVIDER) == "anthropic"
|
|
assert provider_anthropic.attributes.get(SpanAttributeKey.CHAT_USAGE) == {
|
|
"input_tokens": 20,
|
|
"output_tokens": 10,
|
|
"total_tokens": 30,
|
|
}
|
|
|
|
# Provider spans should preserve timing from the gateway trace
|
|
gateway_traces = TracingClient().search_traces(locations=[gateway_experiment_id])
|
|
assert len(gateway_traces) == 1
|
|
gw_spans_by_name = {s.name: s for s in gateway_traces[0].data.spans}
|
|
|
|
gw_openai = gw_spans_by_name["provider/openai/gpt-4"]
|
|
assert provider_openai.start_time_ns == gw_openai.start_time_ns
|
|
assert provider_openai.end_time_ns == gw_openai.end_time_ns
|
|
|
|
gw_anthropic = gw_spans_by_name["provider/anthropic/claude-3"]
|
|
assert provider_anthropic.start_time_ns == gw_anthropic.start_time_ns
|
|
assert provider_anthropic.end_time_ns == gw_anthropic.end_time_ns
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for aggregate_anthropic_messages_stream_chunks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _sse(event: dict[str, Any]) -> bytes:
|
|
"""Encode a single event dict as an SSE data line."""
|
|
return f"data: {json.dumps(event)}\n".encode()
|
|
|
|
|
|
def _msg_start(msg_id: str, model: str, input_tokens: int | None = None) -> bytes:
|
|
usage = {"input_tokens": input_tokens} if input_tokens is not None else {}
|
|
return _sse({
|
|
"type": "message_start",
|
|
"message": {"id": msg_id, "model": model, "role": "assistant", "usage": usage},
|
|
})
|
|
|
|
|
|
def _text_block_start(index: int) -> bytes:
|
|
return _sse({
|
|
"type": "content_block_start",
|
|
"index": index,
|
|
"content_block": {"type": "text", "text": ""},
|
|
})
|
|
|
|
|
|
def _text_delta(index: int, text: str) -> bytes:
|
|
return _sse({
|
|
"type": "content_block_delta",
|
|
"index": index,
|
|
"delta": {"type": "text_delta", "text": text},
|
|
})
|
|
|
|
|
|
def _tool_block_start(index: int, tool_id: str, name: str) -> bytes:
|
|
return _sse({
|
|
"type": "content_block_start",
|
|
"index": index,
|
|
"content_block": {"type": "tool_use", "id": tool_id, "name": name, "input": {}},
|
|
})
|
|
|
|
|
|
def _tool_delta(index: int, partial_json: str) -> bytes:
|
|
return _sse({
|
|
"type": "content_block_delta",
|
|
"index": index,
|
|
"delta": {"type": "input_json_delta", "partial_json": partial_json},
|
|
})
|
|
|
|
|
|
def _msg_delta(stop_reason: str, output_tokens: int, stop_sequence: str | None = None) -> bytes:
|
|
return _sse({
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": stop_reason, "stop_sequence": stop_sequence},
|
|
"usage": {"output_tokens": output_tokens},
|
|
})
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_empty():
|
|
assert aggregate_anthropic_messages_stream_chunks([]) is None
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_no_parseable_events():
|
|
chunks = [b"event: ping\n", b"data: [DONE]\n"]
|
|
assert aggregate_anthropic_messages_stream_chunks(chunks) is None
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_text():
|
|
chunks = [
|
|
_msg_start("msg_1", "claude-3-5-sonnet-20241022", input_tokens=10),
|
|
_text_block_start(0),
|
|
_text_delta(0, "Hello"),
|
|
_text_delta(0, " world"),
|
|
_sse({"type": "content_block_stop", "index": 0}),
|
|
_msg_delta("end_turn", output_tokens=5, stop_sequence=None),
|
|
_sse({"type": "message_stop"}),
|
|
]
|
|
result = aggregate_anthropic_messages_stream_chunks(chunks)
|
|
|
|
assert result["id"] == "msg_1"
|
|
assert result["type"] == "message"
|
|
assert result["role"] == "assistant"
|
|
assert result["model"] == "claude-3-5-sonnet-20241022"
|
|
assert result["stop_reason"] == "end_turn"
|
|
assert result["stop_sequence"] is None
|
|
assert result["content"] == [{"type": "text", "text": "Hello world"}]
|
|
assert result["usage"] == {"input_tokens": 10, "output_tokens": 5}
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_tool_use():
|
|
chunks = [
|
|
_msg_start("msg_2", "claude-3-5-sonnet-20241022", input_tokens=20),
|
|
_tool_block_start(0, "toolu_abc", "get_weather"),
|
|
_tool_delta(0, '{"city"'),
|
|
_tool_delta(0, ': "Paris"}'),
|
|
_sse({"type": "content_block_stop", "index": 0}),
|
|
_msg_delta("tool_use", output_tokens=15, stop_sequence=None),
|
|
]
|
|
result = aggregate_anthropic_messages_stream_chunks(chunks)
|
|
|
|
assert result["stop_reason"] == "tool_use"
|
|
assert len(result["content"]) == 1
|
|
block = result["content"][0]
|
|
assert block["type"] == "tool_use"
|
|
assert block["id"] == "toolu_abc"
|
|
assert block["name"] == "get_weather"
|
|
assert block["input"] == {"city": "Paris"}
|
|
assert result["usage"] == {"input_tokens": 20, "output_tokens": 15}
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_mixed_content():
|
|
chunks = [
|
|
_msg_start("msg_3", "claude-3-5-sonnet-20241022", input_tokens=30),
|
|
_text_block_start(0),
|
|
_text_delta(0, "Let me check that."),
|
|
_sse({"type": "content_block_stop", "index": 0}),
|
|
_tool_block_start(1, "toolu_xyz", "search"),
|
|
_tool_delta(1, '{"q": "mlflow"}'),
|
|
_sse({"type": "content_block_stop", "index": 1}),
|
|
_msg_delta("tool_use", output_tokens=25, stop_sequence=None),
|
|
]
|
|
result = aggregate_anthropic_messages_stream_chunks(chunks)
|
|
|
|
assert len(result["content"]) == 2
|
|
assert result["content"][0] == {"type": "text", "text": "Let me check that."}
|
|
assert result["content"][1] == {
|
|
"type": "tool_use",
|
|
"id": "toolu_xyz",
|
|
"name": "search",
|
|
"input": {"q": "mlflow"},
|
|
}
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_multiple_chunks_per_sse():
|
|
# Multiple SSE events packed into one bytes chunk (newline-separated)
|
|
combined = (
|
|
_msg_start("msg_4", "claude-3-5-sonnet-20241022", input_tokens=5)
|
|
+ _text_block_start(0)
|
|
+ _text_delta(0, "Hi")
|
|
+ _msg_delta("end_turn", output_tokens=2, stop_sequence=None)
|
|
)
|
|
result = aggregate_anthropic_messages_stream_chunks([combined])
|
|
|
|
assert result["id"] == "msg_4"
|
|
assert result["content"] == [{"type": "text", "text": "Hi"}]
|
|
assert result["usage"] == {"input_tokens": 5, "output_tokens": 2}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw_json", "expected_input"),
|
|
[
|
|
('{"key": "val"}', {"key": "val"}),
|
|
("", {}),
|
|
("not-valid-json", {}),
|
|
],
|
|
)
|
|
def test_aggregate_anthropic_messages_stream_chunks_tool_input_edge_cases(raw_json, expected_input):
|
|
chunks = [
|
|
_msg_start("msg_5", "claude-3-5-sonnet-20241022"),
|
|
_tool_block_start(0, "t1", "fn"),
|
|
]
|
|
if raw_json:
|
|
chunks.append(_tool_delta(0, raw_json))
|
|
chunks.append(_msg_delta("tool_use", output_tokens=1))
|
|
|
|
result = aggregate_anthropic_messages_stream_chunks(chunks)
|
|
assert result["content"][0]["input"] == expected_input
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_split_sse_lines():
|
|
# Simulate an aiohttp byte chunk that splits a "data:" SSE line mid-way.
|
|
# All events should still be parsed correctly after concatenation.
|
|
msg_start_bytes = _msg_start("msg_split", "claude-3-5-sonnet-20241022", input_tokens=3)
|
|
mid = len(msg_start_bytes) // 2
|
|
chunks = [
|
|
msg_start_bytes[:mid],
|
|
msg_start_bytes[mid:] + _msg_delta("end_turn", output_tokens=1),
|
|
]
|
|
result = aggregate_anthropic_messages_stream_chunks(chunks)
|
|
|
|
assert result is not None
|
|
assert result["id"] == "msg_split"
|
|
assert result["usage"] == {"input_tokens": 3, "output_tokens": 1}
|
|
|
|
|
|
def test_aggregate_anthropic_messages_stream_chunks_cache_tokens():
|
|
chunks = [
|
|
_sse({
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_cache",
|
|
"model": "claude-3-5-sonnet-20241022",
|
|
"role": "assistant",
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"cache_read_input_tokens": 5,
|
|
"cache_creation_input_tokens": 2,
|
|
},
|
|
},
|
|
}),
|
|
_msg_delta("end_turn", output_tokens=8),
|
|
]
|
|
result = aggregate_anthropic_messages_stream_chunks(chunks)
|
|
|
|
assert result["usage"] == {
|
|
"input_tokens": 10,
|
|
"cache_read_input_tokens": 5,
|
|
"cache_creation_input_tokens": 2,
|
|
"output_tokens": 8,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for aggregate_openai_responses_stream_chunks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_RESPONSES_CREATED = (
|
|
b'data: {"type":"response.created","response":{"id":"resp_1","object":"response",'
|
|
b'"created_at":1741290958,"status":"in_progress","output":[],"usage":null}}\n'
|
|
)
|
|
_RESPONSES_TEXT_DELTA = (
|
|
b'data: {"type":"response.output_text.delta","item_id":"msg_1",'
|
|
b'"output_index":0,"content_index":0,"delta":"Hi"}\n'
|
|
)
|
|
_RESPONSES_TEXT_DONE = (
|
|
b'data: {"type":"response.output_text.done","item_id":"msg_1",'
|
|
b'"output_index":0,"content_index":0,"text":"Hi there!"}\n'
|
|
)
|
|
_RESPONSES_COMPLETED = (
|
|
b'data: {"type":"response.completed","response":{"id":"resp_1","object":"response",'
|
|
b'"created_at":1741290958,"status":"completed",'
|
|
b'"output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant",'
|
|
b'"content":[{"type":"output_text","text":"Hi there!","annotations":[]}]}],'
|
|
b'"usage":{"input_tokens":37,"output_tokens":11,"total_tokens":48}}}\n'
|
|
)
|
|
|
|
|
|
def test_aggregate_openai_responses_stream_chunks_empty():
|
|
assert aggregate_openai_responses_stream_chunks([]) is None
|
|
|
|
|
|
def test_aggregate_openai_responses_stream_chunks_no_completed_event():
|
|
chunks = [_RESPONSES_CREATED, _RESPONSES_TEXT_DELTA]
|
|
assert aggregate_openai_responses_stream_chunks(chunks) is None
|
|
|
|
|
|
def test_aggregate_openai_responses_stream_chunks_basic():
|
|
chunks = [
|
|
_RESPONSES_CREATED,
|
|
_RESPONSES_TEXT_DELTA,
|
|
_RESPONSES_TEXT_DONE,
|
|
_RESPONSES_COMPLETED,
|
|
]
|
|
result = aggregate_openai_responses_stream_chunks(chunks)
|
|
|
|
assert result["id"] == "resp_1"
|
|
assert result["object"] == "response"
|
|
assert result["status"] == "completed"
|
|
assert len(result["output"]) == 1
|
|
assert result["output"][0]["role"] == "assistant"
|
|
assert result["output"][0]["content"][0]["text"] == "Hi there!"
|
|
assert result["usage"] == {"input_tokens": 37, "output_tokens": 11, "total_tokens": 48}
|
|
|
|
|
|
def test_aggregate_openai_responses_stream_chunks_split_sse_lines():
|
|
# Simulate aiohttp yielding a chunk that splits the data: line mid-way.
|
|
mid = len(_RESPONSES_COMPLETED) // 2
|
|
chunks = [
|
|
_RESPONSES_CREATED,
|
|
_RESPONSES_COMPLETED[:mid],
|
|
_RESPONSES_COMPLETED[mid:],
|
|
]
|
|
result = aggregate_openai_responses_stream_chunks(chunks)
|
|
|
|
assert result is not None
|
|
assert result["id"] == "resp_1"
|
|
assert result["status"] == "completed"
|
|
|
|
|
|
def test_aggregate_openai_responses_stream_chunks_returns_completed_response():
|
|
# When multiple events are packed into a single bytes chunk, the
|
|
# completed response is still extracted correctly.
|
|
combined = _RESPONSES_CREATED + _RESPONSES_TEXT_DELTA + _RESPONSES_COMPLETED
|
|
result = aggregate_openai_responses_stream_chunks([combined])
|
|
|
|
assert result["status"] == "completed"
|
|
assert result["usage"]["total_tokens"] == 48
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for aggregate_gemini_stream_generate_content_chunks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _gemini_sse(event: dict[str, Any]) -> bytes:
|
|
return f"data: {json.dumps(event)}\n".encode()
|
|
|
|
|
|
def _gemini_text_chunk(text: str, finish_reason: str | None = None) -> bytes:
|
|
candidate: dict[str, Any] = {"content": {"parts": [{"text": text}], "role": "model"}}
|
|
if finish_reason:
|
|
candidate["finishReason"] = finish_reason
|
|
return _gemini_sse({"candidates": [candidate]})
|
|
|
|
|
|
def test_aggregate_gemini_stream_chunks_empty():
|
|
assert aggregate_gemini_stream_generate_content_chunks([]) is None
|
|
|
|
|
|
def test_aggregate_gemini_stream_chunks_no_parseable_events():
|
|
chunks = [b"event: ping\n", b"data: [DONE]\n"]
|
|
assert aggregate_gemini_stream_generate_content_chunks(chunks) is None
|
|
|
|
|
|
def test_aggregate_gemini_stream_chunks_text():
|
|
chunks = [
|
|
_gemini_text_chunk("Hello"),
|
|
_gemini_text_chunk(" world", finish_reason="STOP"),
|
|
_gemini_sse({
|
|
"usageMetadata": {
|
|
"promptTokenCount": 10,
|
|
"candidatesTokenCount": 5,
|
|
"totalTokenCount": 15,
|
|
}
|
|
}),
|
|
]
|
|
result = aggregate_gemini_stream_generate_content_chunks(chunks)
|
|
|
|
assert len(result["candidates"]) == 1
|
|
cand = result["candidates"][0]
|
|
assert cand["content"]["parts"] == [{"text": "Hello world"}]
|
|
assert cand["content"]["role"] == "model"
|
|
assert cand["finishReason"] == "STOP"
|
|
assert result["usageMetadata"] == {
|
|
"promptTokenCount": 10,
|
|
"candidatesTokenCount": 5,
|
|
"totalTokenCount": 15,
|
|
}
|
|
|
|
|
|
def test_aggregate_gemini_stream_chunks_tool_call():
|
|
chunks = [
|
|
_gemini_sse({
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}
|
|
],
|
|
"role": "model",
|
|
},
|
|
"finishReason": "STOP",
|
|
"index": 0,
|
|
}
|
|
]
|
|
}),
|
|
_gemini_sse({
|
|
"usageMetadata": {
|
|
"promptTokenCount": 8,
|
|
"candidatesTokenCount": 12,
|
|
"totalTokenCount": 20,
|
|
}
|
|
}),
|
|
]
|
|
result = aggregate_gemini_stream_generate_content_chunks(chunks)
|
|
|
|
cand = result["candidates"][0]
|
|
assert cand["content"]["parts"] == [
|
|
{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}
|
|
]
|
|
assert cand["finishReason"] == "STOP"
|
|
|
|
|
|
def test_aggregate_gemini_stream_chunks_split_sse_lines():
|
|
chunk_bytes = _gemini_text_chunk("Hi", finish_reason="STOP")
|
|
mid = len(chunk_bytes) // 2
|
|
result = aggregate_gemini_stream_generate_content_chunks([chunk_bytes[:mid], chunk_bytes[mid:]])
|
|
|
|
assert result is not None
|
|
assert result["candidates"][0]["content"]["parts"] == [{"text": "Hi"}]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("finish_reasons", "expected"),
|
|
[
|
|
([None, None, "STOP"], "STOP"),
|
|
([None, "stop", None], "stop"),
|
|
([None, None, None], None),
|
|
],
|
|
)
|
|
def test_aggregate_gemini_stream_chunks_finish_reason(finish_reasons, expected):
|
|
chunks = [_gemini_text_chunk(f"t{i}", finish_reason=fr) for i, fr in enumerate(finish_reasons)]
|
|
result = aggregate_gemini_stream_generate_content_chunks(chunks)
|
|
assert result["candidates"][0]["finishReason"] == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_caller tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("headers", "expected"),
|
|
[
|
|
(None, None),
|
|
({}, None),
|
|
({"User-Agent": "openai-python/1.0.0"}, "openai-python"),
|
|
({"user-agent": "claude-cli/1.2.3"}, "claude-cli"),
|
|
({"User-Agent": "GeminiCLI/1.0 (Linux; x64)"}, "GeminiCLI"),
|
|
({"User-Agent": "anthropic/0.20.0 CPython/3.11.0 Darwin/23.0.0"}, "anthropic"),
|
|
(
|
|
{MLFLOW_GATEWAY_CALLER_HEADER: "judge", "User-Agent": "openai-python/1.0.0"},
|
|
"judge",
|
|
),
|
|
({MLFLOW_GATEWAY_CALLER_HEADER: "judge"}, "judge"),
|
|
({"User-Agent": " "}, None),
|
|
],
|
|
)
|
|
def test_extract_caller(headers, expected):
|
|
assert _extract_caller(headers) == expected
|
|
|
|
|
|
def test_maybe_traced_gateway_call_records_caller(endpoint_config):
|
|
async def fake_func(payload):
|
|
return {"ok": True}
|
|
|
|
traced = maybe_traced_gateway_call(
|
|
fake_func,
|
|
endpoint_config,
|
|
request_headers={"User-Agent": "openai-python/1.0.0"},
|
|
)
|
|
|
|
asyncio.run(traced({"prompt": "hi"}))
|
|
|
|
traces = get_traces()
|
|
assert traces
|
|
assert traces[0].info.request_metadata.get(TraceMetadataKey.GATEWAY_CALLER) == "openai-python"
|
|
|
|
|
|
def test_maybe_traced_gateway_call_no_caller_when_no_headers(endpoint_config):
|
|
async def fake_func(payload):
|
|
return {"ok": True}
|
|
|
|
traced = maybe_traced_gateway_call(fake_func, endpoint_config, request_headers=None)
|
|
|
|
asyncio.run(traced({"prompt": "hi"}))
|
|
|
|
traces = get_traces()
|
|
assert traces
|
|
assert TraceMetadataKey.GATEWAY_CALLER not in (traces[0].info.request_metadata or {})
|