From 3af818f00f92a423625141f16edbd259e588661d Mon Sep 17 00:00:00 2001 From: Jordan Date: Thu, 15 Oct 2026 17:06:55 -0400 Subject: [PATCH 1/2] feat(otel): add nOps preset for OpenTelemetry v2 --- litellm/__init__.py | 1 + litellm/integrations/callback_configs.json | 21 + litellm/integrations/otel/model/config.py | 1 + litellm/integrations/otel/presets/__init__.py | 3 + litellm/integrations/otel/presets/nops.py | 48 ++ .../custom_logger_registry.py | 1 + litellm/litellm_core_utils/litellm_logging.py | 37 ++ .../_experimental/out/assets/logos/nops.png | Bin 0 -> 33315 bytes litellm/proxy/_types.py | 6 + .../health_endpoints/_health_endpoints.py | 1 + .../observability/test_nops_delivery.py | 516 ++++++++++++++++++ .../integrations/otel/test_otel_v2_presets.py | 49 ++ .../test_litellm_logging.py | 131 +++++ .../public/assets/logos/nops.png | Bin 0 -> 33315 bytes .../src/components/callback_info_helpers.tsx | 12 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 16 files changed, 828 insertions(+), 1 deletion(-) create mode 100644 litellm/integrations/otel/presets/nops.py create mode 100644 litellm/proxy/_experimental/out/assets/logos/nops.png create mode 100644 tests/integration/observability/test_nops_delivery.py create mode 100644 ui/litellm-dashboard/public/assets/logos/nops.png diff --git a/litellm/__init__.py b/litellm/__init__.py index 0fe3d091c2a..6ab5a4b43fc 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -156,6 +156,7 @@ _custom_logger_compatible_callbacks_literal = Literal[ "compression_interception", "newrelic", "signoz", + "nops", ] cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = None logged_real_time_event_types: Optional[Union[List[str], Literal["*"]]] = None diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index 38928f67f42..4ccb506dfca 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -330,6 +330,27 @@ }, "description": "New Relic AI Monitoring Integration" }, + { + "id": "nops", + "displayName": "nOps", + "logo": "nops.png", + "supports_key_team_logging": false, + "dynamic_params": { + "NOPS_API_KEY": { + "type": "password", + "ui_name": "API Key", + "description": "nOps API key with the telemetry:write permission (Inform + Operate plan)", + "required": true + }, + "NOPS_OTEL_ENDPOINT": { + "type": "text", + "ui_name": "OTel Endpoint", + "description": "nOps OTLP endpoint. Leave blank to use https://api.nops.io/otel", + "required": false + } + }, + "description": "nOps Logging Integration - sends LiteLLM traces (cost, tokens, model, team) to nOps" + }, { "id": "openmeter", "displayName": "OpenMeter", diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index cca12b885c1..a13e9bb6f22 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -44,6 +44,7 @@ class ExporterOwner(str, Enum): AGENTOPS = "agentops" NEWRELIC = "newrelic" SIGNOZ = "signoz" + NOPS = "nops" class _OTelV2Flag(BaseSettings): diff --git a/litellm/integrations/otel/presets/__init__.py b/litellm/integrations/otel/presets/__init__.py index 7c891c29409..ed6b07203bd 100644 --- a/litellm/integrations/otel/presets/__init__.py +++ b/litellm/integrations/otel/presets/__init__.py @@ -26,6 +26,7 @@ from litellm.integrations.otel.presets.newrelic import ( newrelic_dynamic_headers, newrelic_preset, ) +from litellm.integrations.otel.presets.nops import nops_preset from litellm.integrations.otel.presets.phoenix import ( phoenix_preset, phoenix_project_headers, @@ -49,6 +50,7 @@ PRESET_BY_CALLBACK: Final[Mapping[str, Preset]] = MappingProxyType( "langtrace": langtrace_preset, "levo": levo_preset, "newrelic": newrelic_preset, + "nops": nops_preset, "signoz": signoz_preset, "weave_otel": weave_preset, } @@ -159,6 +161,7 @@ __all__ = [ "langtrace_preset", "levo_preset", "newrelic_preset", + "nops_preset", "phoenix_preset", "project_routing_headers", "signoz_preset", diff --git a/litellm/integrations/otel/presets/nops.py b/litellm/integrations/otel/presets/nops.py new file mode 100644 index 00000000000..2f351cabdda --- /dev/null +++ b/litellm/integrations/otel/presets/nops.py @@ -0,0 +1,48 @@ +from types import MappingProxyType +from typing import Final + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from litellm.integrations.otel.model.config import ( + ExporterOwner, + ExporterSpec, + OpenTelemetryV2Config, +) +from litellm.integrations.otel.presets.utils import ensure_mappers + +NOPS_API_KEY_ENV: Final = "NOPS_API_KEY" +NOPS_OTEL_ENDPOINT_ENV: Final = "NOPS_OTEL_ENDPOINT" +NOPS_DEFAULT_OTEL_ENDPOINT: Final = "https://api.nops.io/otel" + + +class _NopsSettings(BaseSettings): + model_config = SettingsConfigDict(case_sensitive=False, extra="ignore", frozen=True) + + endpoint: str | None = Field(default=None, validation_alias=NOPS_OTEL_ENDPOINT_ENV) + api_key: str | None = Field(default=None, validation_alias=NOPS_API_KEY_ENV) + + +def nops_preset( + *, + config_overrides: OpenTelemetryV2Config | None = None, + allow_missing_credentials: bool = False, +) -> OpenTelemetryV2Config: + settings: Final = _NopsSettings() + base: Final = config_overrides or OpenTelemetryV2Config() + key: Final = settings.api_key + spec: Final = ExporterSpec( + kind="otlp_http", + endpoint=settings.endpoint or NOPS_DEFAULT_OTEL_ENDPOINT, + headers=(f"Authorization=Bearer {key}" if key else None), + owner=ExporterOwner.NOPS, + requires_headers=True, + ) + return base.model_copy( + update=MappingProxyType( + { + "exporters": (*base.exporters, spec), + "mapper_names": ensure_mappers(base.mapper_names, "genai"), + } + ) + ) diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py index 74badc2c8c1..c20d77e3e0d 100644 --- a/litellm/litellm_core_utils/custom_logger_registry.py +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -97,6 +97,7 @@ class CustomLoggerRegistry: "weave_otel": OpenTelemetry, "levo": OpenTelemetry, "signoz": OpenTelemetry, + "nops": OpenTelemetry, "mlflow": MlflowLogger, "langfuse": LangfusePromptManagement, "otel": OpenTelemetry, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index e2eed3cf6d5..781e3c53091 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5484,6 +5484,43 @@ def build_custom_logger_compatible_class( _in_memory_loggers.append(_signoz_logger) return _signoz_logger + elif logging_integration == "nops": + from litellm.integrations.otel.presets.nops import ( + NOPS_API_KEY_ENV, + NOPS_DEFAULT_OTEL_ENDPOINT, + NOPS_OTEL_ENDPOINT_ENV, + ) + + _nops_key: Final = os.getenv(NOPS_API_KEY_ENV) + if not _nops_key: + raise ValueError(f"{NOPS_API_KEY_ENV} not found in environment variables") + + _nops_v2: Final = _maybe_construct_otel_v2("nops", _in_memory_loggers) + if _nops_v2 is not None: + return _nops_v2 + + from litellm.integrations.opentelemetry import ( + CAPTURE_MODE_NO_CONTENT, + OpenTelemetry, + OpenTelemetryConfig, + ) + + _nops_base: Final = (os.getenv(NOPS_OTEL_ENDPOINT_ENV) or NOPS_DEFAULT_OTEL_ENDPOINT).rstrip("/") + _nops_config: Final = OpenTelemetryConfig( + exporter="otlp_http", + endpoint=(_nops_base if _nops_base.endswith("/v1/traces") else f"{_nops_base}/v1/traces"), + headers=f"Authorization=Bearer {_nops_key}", + capture_message_content=( + os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") or CAPTURE_MODE_NO_CONTENT + ), + ) + for callback in _in_memory_loggers: + if isinstance(callback, OpenTelemetry) and callback.callback_name == "nops": + return callback + _nops_logger: Final = OpenTelemetry(config=_nops_config, callback_name="nops") + _in_memory_loggers.append(_nops_logger) + return _nops_logger + elif logging_integration == "mlflow": for callback in _in_memory_loggers: if isinstance(callback, MlflowLogger): diff --git a/litellm/proxy/_experimental/out/assets/logos/nops.png b/litellm/proxy/_experimental/out/assets/logos/nops.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa8e0a6b31543aa8980cdaf58325f6df0793989 GIT binary patch literal 33315 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 str: + return "nops-" + uuid.uuid4().hex + + +def _chat_reply(identity: str, stream: bool) -> Reply: + if not stream: + return Reply( + body=json.dumps( + { + "id": identity, + "object": "chat.completion", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": REPLY_TEXT}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}, + } + ).encode() + ) + chunk: Final = {"id": identity, "object": "chat.completion.chunk", "created": 1, "model": "gpt-4o-mini"} + return Reply( + content_type="text/event-stream", + chunks=( + b"data: " + + json.dumps( + {**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": REPLY_TEXT}}]} + ).encode() + + b"\n\n", + b"data: " + + json.dumps({**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}).encode() + + b"\n\n", + b"data: " + + json.dumps( + {**chunk, "choices": [], "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}} + ).encode() + + b"\n\n", + b"data: [DONE]\n\n", + ), + ) + + +def _responses_reply(identity: str) -> Reply: + return Reply( + body=json.dumps( + { + "id": identity, + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "id": "msg_" + identity, + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": REPLY_TEXT, "annotations": []}], + } + ], + "usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9}, + } + ).encode() + ) + + +def _upstream(request: Request) -> Reply: + found: Final = MARKER.search(request.body) + if found is None: + return Reply(status=404, body=b'{"error":"no marker"}') + marker: Final = found.group(0).decode() + if request.target.endswith("/responses"): + return _responses_reply(f"resp_{marker}") + stream: Final = object_value(JSON.validate_json(request.body)).get("stream") is True + return _chat_reply(f"chatcmpl-{marker}", stream) + + +def _decoded_responses_id(identity: str) -> str: + try: + return base64.b64decode(identity.removeprefix("resp_").encode()).decode() + except (ValueError, UnicodeDecodeError): + return identity + + +def _canonical_id(identity: str) -> str: + return _decoded_responses_id(identity).rpartition("response_id:")[2] + + +def _text_at(payload: JsonValue, *path: str) -> str: + if not path: + return string_value(payload) + return _text_at(object_value(payload)[path[0]], *path[1:]) + + +def _body_id(response: httpx.Response) -> str: + return _text_at(JSON.validate_json(response.content), "id") + + +@dataclass(frozen=True, slots=True) +class Span: + target: str + authorization: str | None + attributes: Mapping[str, str] + + +def _attribute_text(value: AnyValue) -> str: + match value.WhichOneof("value"): + case "string_value": + return value.string_value + case "int_value": + return str(value.int_value) + case "double_value": + return str(value.double_value) + case "bool_value": + return str(value.bool_value) + case _: + return "" + + +@dataclass(frozen=True, slots=True) +class Collector: + wire: Wire + rejection: threading.Event + missing: threading.Event + accepted: Sequence[Request] + refused: Sequence[Request] + guard: threading.Lock + + def refused_batch_carrying(self, response_id: str) -> Request: + def carrying() -> tuple[Request, ...]: + with self.guard: + return tuple(batch for batch in self.refused if response_id.encode() in batch.body) + + return eventually(carrying, lambda found: len(found) >= 1, seconds=30)[0] + + def bodies(self) -> tuple[bytes, ...]: + with self.guard: + return tuple(batch.body for batch in self.accepted) + + def spans(self) -> tuple[Span, ...]: + with self.guard: + batches: Final = tuple(batch for batch in self.accepted if batch.target.endswith("/v1/traces")) + return tuple( + Span( + batch.target, + batch.headers.get("authorization"), + {attribute.key: _attribute_text(attribute.value) for attribute in span.attributes}, + ) + for batch in batches + for resource in ExportTraceServiceRequest.FromString(batch.body).resource_spans + for scope in resource.scope_spans + for span in scope.spans + ) + + def spans_for(self, response_id: str) -> tuple[Span, ...]: + return tuple( + span + for span in self.spans() + if RESPONSE_ID in span.attributes + and _canonical_id(span.attributes[RESPONSE_ID]) == _canonical_id(response_id) + ) + + def single_span(self, response_id: str) -> Span: + found: Final = eventually( + lambda: self.spans_for(response_id), lambda spans: len(spans) == 1, seconds=30, return_last_on_timeout=True + ) + assert len(found) == 1, f"{len(found)} spans for {response_id} at the sink" + return found[0] + + def landed(self, response_ids: Sequence[str]) -> dict[str, int]: + spans: Final = self.spans() + return { + _canonical_id(identity): sum( + 1 + for span in spans + if RESPONSE_ID in span.attributes + and _canonical_id(span.attributes[RESPONSE_ID]) == _canonical_id(identity) + ) + for identity in response_ids + } + + +@pytest.fixture(scope="session") +def sink() -> Iterator[Collector]: + rejection: Final = threading.Event() + missing: Final = threading.Event() + accepted: Final[deque[Request]] = deque() # mutable-ok: the sink thread records each accepted batch as it arrives + refused: Final[deque[Request]] = deque() # mutable-ok: the sink thread records each refused batch as it arrives + guard: Final = threading.Lock() + + def refuse(request: Request, status: int, body: bytes) -> Reply: + with guard: + refused.append(request) + return Reply(status=status, body=body) + + def receive(request: Request) -> Reply: + if rejection.is_set(): + return refuse(request, 403, b'{"error":"forbidden"}') + if missing.is_set(): + return refuse(request, 404, b'{"error":"not found"}') + with guard: + accepted.append(request) + return Reply() + + with wire_server(receive) as wire: + yield Collector(wire, rejection, missing, accepted, refused, guard) + + +@pytest.fixture(scope="session") +def provider() -> Iterator[Wire]: + with wire_server(_upstream) as wire: + yield wire + + +@dataclass(frozen=True, slots=True) +class Rig: + proxy: Gateway + process: OwnedProxy + model: str + upstream: Wire + sink: Collector + + def openai_client(self) -> openai.OpenAI: + return openai.OpenAI(base_url=str(self.proxy.client.base_url) + "/v1", api_key=self.proxy.key, max_retries=0) + + def async_openai_client(self) -> openai.AsyncOpenAI: + return openai.AsyncOpenAI( + base_url=str(self.proxy.client.base_url) + "/v1", api_key=self.proxy.key, max_retries=0 + ) + + def anthropic_client(self) -> anthropic.Anthropic: + return anthropic.Anthropic(base_url=str(self.proxy.client.base_url), api_key=self.proxy.key, max_retries=0) + + def chat(self, content: str) -> httpx.Response: + return self.proxy.request( + "POST", + "/v1/chat/completions", + {"model": self.model, "messages": [{"role": "user", "content": content}], "cache": {"no-cache": True}}, + ) + + def upstream_bodies(self, marker: str) -> tuple[dict[str, JsonValue], ...]: + return tuple( + object_value(JSON.validate_json(request.body)) + for request in self.upstream.drain() + if marker.encode() in request.body + ) + + def spend_rows(self, response_id: str) -> tuple[dict[str, JsonValue], ...]: + return tuple( + eventually( + lambda: read_rows( + 'SELECT request_id, spend FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (response_id,) + ), + lambda values: len(values) == 1, + seconds=70, + ) + ) + + +@dataclass(frozen=True, slots=True) +class RigFactory: + provider: Wire + sink: Collector + directory: Path + otel_v2: bool + endpoint: str + api_key: str | None = OPERATOR_KEY + + def config_path(self) -> Path: + loaded: Final = object_value( + JSON.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + ) + config: Final = { + **loaded, + "litellm_settings": {**object_value(loaded["litellm_settings"]), "callbacks": ["nops"]}, + } + path: Final = self.directory / f"nops-{uuid.uuid4().hex}.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + def overrides(self) -> dict[str, str]: + return { + "LITELLM_OTEL_V2": "1" if self.otel_v2 else "0", + "OTEL_BSP_SCHEDULE_DELAY": "300", + "NOPS_OTEL_ENDPOINT": self.endpoint, + **({"NOPS_API_KEY": self.api_key} if self.api_key is not None else {}), + } + + def start(self) -> Iterator[Rig]: + with ( + gateway_from_environment() as gateway, + owned_proxy_process( + gateway, + self.directory, + self.overrides(), + config=self.config_path(), + remove_environment=( + "NOPS_API_KEY", + "NOPS_OTEL_ENDPOINT", + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", + ), + workers=2, + ) as owned, + owned.gateway.scenario() as scenario, + ): + model: Final = scenario.model(api_base=self.provider.url + "/v1") + yield Rig(owned.gateway, owned, model, self.provider, self.sink) + + +@pytest.fixture(scope="session") +def rig(provider: Wire, sink: Collector, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + yield from RigFactory(provider, sink, tmp_path_factory.mktemp("nops"), False, sink.wire.url).start() + + +@pytest.fixture(scope="session") +def v2_rig(provider: Wire, sink: Collector, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + yield from RigFactory(provider, sink, tmp_path_factory.mktemp("nops-v2"), True, sink.wire.url).start() + + +def _assert_bearer_span(rig: Rig, response_id: str, marker: str) -> Span: + span: Final = rig.sink.single_span(response_id) + assert span.target == "/v1/traces", span + assert span.authorization == f"Bearer {OPERATOR_KEY}", span + bodies: Final = rig.upstream_bodies(marker) + assert len(bodies) == 1, bodies + assert OPERATOR_KEY not in json.dumps(bodies[0]), bodies[0] + return span + + +def test_nops_is_registered_as_an_opentelemetry_callback(rig: Rig) -> None: + listed: Final = rig.proxy.request("GET", "/active/callbacks") + assert listed.status_code == 200, listed.text + assert "OpenTelemetry" in json.dumps(listed.json()), listed.text + assert "NOPS_API_KEY not found" not in rig.process.log.read_text() + + +def test_chat_completion_sdk_span_lands_at_the_sink_with_the_bearer_key(rig: Rig) -> None: + marker: Final = _marker() + completion: Final = rig.openai_client().chat.completions.create( + model=rig.model, messages=[{"role": "user", "content": marker}] + ) + assert completion.id == f"chatcmpl-{marker}" + span: Final = _assert_bearer_span(rig, completion.id, marker) + assert span.attributes.get("gen_ai.request.model") or span.attributes.get("llm.request.model"), span + rows: Final = rig.spend_rows(completion.id) + assert rows[0]["request_id"] == completion.id, rows + + +def test_chat_stream_async_sdk_span_lands_once_after_the_stream_is_consumed(rig: Rig) -> None: + marker: Final = _marker() + + async def consume() -> frozenset[str]: + stream: Final = await rig.async_openai_client().chat.completions.create( + model=rig.model, messages=[{"role": "user", "content": marker}], stream=True + ) + return frozenset([chunk.id async for chunk in stream]) + + identities: Final = asyncio.run(consume()) + assert identities == {f"chatcmpl-{marker}"}, identities + _assert_bearer_span(rig, f"chatcmpl-{marker}", marker) + + +def test_messages_sdk_span_lands_at_the_sink_with_the_bearer_key(rig: Rig) -> None: + marker: Final = _marker() + message: Final = rig.anthropic_client().messages.create( + model=rig.model, max_tokens=16, messages=[{"role": "user", "content": marker}] + ) + _assert_bearer_span(rig, message.id, marker) + + +def test_responses_sdk_span_lands_at_the_sink_with_the_bearer_key(rig: Rig) -> None: + marker: Final = _marker() + response: Final = rig.openai_client().responses.create(model=rig.model, input=marker) + _assert_bearer_span(rig, response.id, marker) + + +def test_v2_flag_on_still_delivers_the_span_with_the_bearer_key(v2_rig: Rig) -> None: + marker: Final = _marker() + response: Final = v2_rig.chat(marker) + assert response.status_code == 200, response.text + _assert_bearer_span(v2_rig, _body_id(response), marker) + + +def _assert_span_without_content(rig: Rig) -> None: + marker: Final = _marker() + prompt: Final = "prompt-" + uuid.uuid4().hex + response: Final = rig.chat(f"{marker} {prompt}") + assert response.status_code == 200, response.text + span: Final = rig.sink.single_span(_body_id(response)) + assert "gen_ai.input.messages" not in span.attributes, span + assert "gen_ai.output.messages" not in span.attributes, span + bodies: Final = rig.sink.bodies() + assert not any(prompt.encode() in body for body in bodies) + assert not any(REPLY_TEXT.encode() in body for body in bodies) + + +def test_legacy_span_carries_no_prompt_or_response_content_by_default(rig: Rig) -> None: + _assert_span_without_content(rig) + + +def test_v2_span_carries_no_prompt_or_response_content_by_default(v2_rig: Rig) -> None: + _assert_span_without_content(v2_rig) + + +def test_endpoint_already_ending_in_v1_traces_is_not_doubled( + provider: Wire, sink: Collector, tmp_path_factory: pytest.TempPathFactory +) -> None: + factory: Final = RigFactory( + provider, sink, tmp_path_factory.mktemp("nops-suffixed"), False, sink.wire.url + "/v1/traces" + ) + suffixed: Final = next(started := factory.start()) + marker: Final = _marker() + response: Final = suffixed.chat(marker) + assert response.status_code == 200, response.text + span: Final = suffixed.sink.single_span(_body_id(response)) + assert span.target == "/v1/traces", span + assert tuple(started) == () + + +def test_three_identical_requests_produce_one_span_each(rig: Rig) -> None: + markers: Final = tuple(_marker() for _ in range(3)) + responses: Final = tuple(rig.chat(marker) for marker in markers) + assert all(response.status_code == 200 for response in responses), [response.text for response in responses] + identities: Final = tuple(_body_id(response) for response in responses) + landed: Final = eventually( + lambda: rig.sink.landed(identities), lambda seen: all(count >= 1 for count in seen.values()), seconds=30 + ) + assert landed == {identity: 1 for identity in identities}, landed + assert rig.sink.landed(identities) == landed + + +def test_health_services_accepts_nops(rig: Rig) -> None: + response: Final = rig.proxy.request("GET", "/health/services", params={"service": "nops"}) + assert response.status_code == 200, response.text + assert "nops" in _text_at(JSON.validate_json(response.content), "message"), response.text + + +def test_sink_answering_403_drops_those_spans_and_later_spans_still_land(rig: Rig) -> None: + rig.sink.rejection.set() + try: + rejected: Final = rig.chat(_marker()) + assert rejected.status_code == 200, rejected.text + rig.sink.refused_batch_carrying(_body_id(rejected)) + finally: + rig.sink.rejection.clear() + later: Final = rig.chat(_marker()) + assert later.status_code == 200, later.text + rig.sink.single_span(_body_id(later)) + assert rig.sink.landed((_body_id(rejected),)) == {_body_id(rejected): 0} + + +def test_sink_answering_404_drops_those_spans_and_later_spans_still_land(rig: Rig) -> None: + rig.sink.missing.set() + try: + dropped: Final = rig.chat(_marker()) + assert dropped.status_code == 200, dropped.text + rig.sink.refused_batch_carrying(_body_id(dropped)) + finally: + rig.sink.missing.clear() + later: Final = rig.chat(_marker()) + assert later.status_code == 200, later.text + rig.sink.single_span(_body_id(later)) + assert rig.sink.landed((_body_id(dropped),)) == {_body_id(dropped): 0} + + +@pytest.mark.parametrize("api_key", [None, ""], ids=["unset", "empty"]) +def test_missing_api_key_fails_loudly_at_boot( + provider: Wire, sink: Collector, tmp_path_factory: pytest.TempPathFactory, api_key: str | None +) -> None: + factory: Final = RigFactory(provider, sink, tmp_path_factory.mktemp("nops-nokey"), False, sink.wire.url, api_key) + started: Final = factory.start() + broken: Final = next(started) + try: + response: Final = broken.chat(_marker()) + assert response.status_code == 200, response.text + eventually( + lambda: broken.process.log.read_text(), + lambda text: "NOPS_API_KEY not found in environment variables" in text, + seconds=30, + ) + assert sink.landed((_body_id(response),)) == {_body_id(response): 0} + finally: + with pytest.raises(StopIteration): + next(started) diff --git a/tests/unit/integrations/otel/test_otel_v2_presets.py b/tests/unit/integrations/otel/test_otel_v2_presets.py index 64366626784..328f62b255b 100644 --- a/tests/unit/integrations/otel/test_otel_v2_presets.py +++ b/tests/unit/integrations/otel/test_otel_v2_presets.py @@ -2,6 +2,8 @@ event loop: the preset does no network I/O, and a custom exporter mints the JWT lazily on its first export (in the BatchSpanProcessor worker thread).""" +from typing import Final + import httpx import pytest @@ -278,3 +280,50 @@ def test_signoz_preset_accepts_the_factory_call_shape(monkeypatch): cfg = PRESET_BY_CALLBACK["signoz"](allow_missing_credentials=True) assert any(e.owner == ExporterOwner.SIGNOZ for e in cfg.exporters) + + +@pytest.mark.parametrize("endpoint", [None, ""], ids=["unset", "empty"]) +def test_nops_preset_defaults_to_the_nops_endpoint_with_a_bearer_key(monkeypatch, endpoint): + monkeypatch.setenv("NOPS_API_KEY", "nops-env-key") + if endpoint is None: + monkeypatch.delenv("NOPS_OTEL_ENDPOINT", raising=False) + else: + monkeypatch.setenv("NOPS_OTEL_ENDPOINT", endpoint) + from litellm.integrations.otel.model.config import ExporterOwner + from litellm.integrations.otel.plumbing.providers import _exports_to_the_wire, _otlp_traces_endpoint + from litellm.integrations.otel.presets import PRESET_BY_CALLBACK + + cfg: Final = PRESET_BY_CALLBACK["nops"](allow_missing_credentials=True) + spec: Final = next(e for e in cfg.exporters if e.owner == ExporterOwner.NOPS) + assert spec.kind == "otlp_http" + assert _otlp_traces_endpoint(spec.endpoint) == "https://api.nops.io/otel/v1/traces" + assert spec.headers == "Authorization=Bearer nops-env-key" + assert _exports_to_the_wire(spec) is True + + +@pytest.mark.parametrize( + "endpoint", + ["http://clara.local:8080/otel", "http://clara.local:8080/otel/", "http://clara.local:8080/otel/v1/traces"], + ids=["base", "trailing-slash", "signal-path"], +) +def test_nops_preset_endpoint_override(monkeypatch, endpoint): + monkeypatch.setenv("NOPS_API_KEY", "nops-env-key") + monkeypatch.setenv("NOPS_OTEL_ENDPOINT", endpoint) + from litellm.integrations.otel.model.config import ExporterOwner + from litellm.integrations.otel.plumbing.providers import _otlp_traces_endpoint + from litellm.integrations.otel.presets.nops import nops_preset + + spec: Final = next(e for e in nops_preset().exporters if e.owner == ExporterOwner.NOPS) + assert _otlp_traces_endpoint(spec.endpoint) == "http://clara.local:8080/otel/v1/traces" + + +def test_nops_preset_without_key_skips_the_exporter(monkeypatch): + monkeypatch.delenv("NOPS_API_KEY", raising=False) + monkeypatch.delenv("NOPS_OTEL_ENDPOINT", raising=False) + from litellm.integrations.otel.model.config import ExporterOwner + from litellm.integrations.otel.plumbing.providers import _exports_to_the_wire + from litellm.integrations.otel.presets.nops import nops_preset + + spec: Final = next(e for e in nops_preset().exporters if e.owner == ExporterOwner.NOPS) + assert spec.headers is None + assert _exports_to_the_wire(spec) is False diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index 9d651b4ae3e..012d0d5ebcb 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -11095,6 +11095,137 @@ def test_uncached_price_keeps_an_earlier_price_failure(pr44187_priced_model: str assert logging_obj.model_call_details["response_cost_failure_debug_information"] == earlier_failure +def test_nops_dispatch_prefers_otel_v2_when_flag_on(monkeypatch): + from litellm.integrations.otel.logger import OpenTelemetryV2 + from litellm.integrations.otel.model.config import ExporterOwner, is_otel_v2_enabled + from litellm.integrations.otel.plumbing.providers import _otlp_traces_endpoint + from litellm.litellm_core_utils import litellm_logging as logging_module + + logging_module._in_memory_loggers.clear() + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + monkeypatch.setenv("NOPS_API_KEY", "test-key") + monkeypatch.delenv("NOPS_OTEL_ENDPOINT", raising=False) + is_otel_v2_enabled.cache_clear() + try: + v2_logger: Final = logging_module._init_custom_logger_compatible_class( + logging_integration="nops", + internal_usage_cache=None, + llm_router=None, + custom_logger_init_args={}, + ) + assert isinstance(v2_logger, OpenTelemetryV2) + assert v2_logger.callback_name == "nops" + spec: Final = next(e for e in v2_logger.config.exporters if e.owner == ExporterOwner.NOPS) + assert spec.kind == "otlp_http" + assert _otlp_traces_endpoint(spec.endpoint) == "https://api.nops.io/otel/v1/traces" + assert spec.headers == "Authorization=Bearer test-key" + again: Final = logging_module._init_custom_logger_compatible_class( + logging_integration="nops", + internal_usage_cache=None, + llm_router=None, + custom_logger_init_args={}, + ) + assert again is v2_logger + finally: + logging_module._in_memory_loggers.clear() + monkeypatch.delenv("LITELLM_OTEL_V2", raising=False) + is_otel_v2_enabled.cache_clear() + + +@pytest.mark.parametrize( + ("endpoint", "expected"), + [ + (None, "https://api.nops.io/otel/v1/traces"), + ("", "https://api.nops.io/otel/v1/traces"), + ("http://clara.local:8080/otel/", "http://clara.local:8080/otel/v1/traces"), + ("http://clara.local:8080/otel/v1/traces", "http://clara.local:8080/otel/v1/traces"), + ], + ids=["default", "empty", "override", "signal-path"], +) +def test_nops_dispatch_keeps_legacy_otel_when_flag_off(monkeypatch, endpoint, expected): + from litellm.integrations.opentelemetry import OpenTelemetry + from litellm.integrations.otel.model.config import is_otel_v2_enabled + from litellm.litellm_core_utils import litellm_logging as logging_module + + logging_module._in_memory_loggers.clear() + monkeypatch.delenv("LITELLM_OTEL_V2", raising=False) + monkeypatch.setenv("NOPS_API_KEY", "legacy-key") + if endpoint is None: + monkeypatch.delenv("NOPS_OTEL_ENDPOINT", raising=False) + else: + monkeypatch.setenv("NOPS_OTEL_ENDPOINT", endpoint) + monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", raising=False) + monkeypatch.setattr(litellm, "turn_off_message_logging", False) + is_otel_v2_enabled.cache_clear() + try: + legacy: Final = logging_module._init_custom_logger_compatible_class( + logging_integration="nops", + internal_usage_cache=None, + llm_router=None, + custom_logger_init_args={}, + ) + assert isinstance(legacy, OpenTelemetry) + assert legacy.callback_name == "nops" + assert legacy.config.exporter == "otlp_http" + assert legacy.config.endpoint == expected + assert legacy.config.headers == "Authorization=Bearer legacy-key" + assert legacy._capture_in_span() is False + assert legacy._capture_in_event() is False + assert "OTEL_EXPORTER_OTLP_TRACES_HEADERS" not in os.environ + again: Final = logging_module._init_custom_logger_compatible_class( + logging_integration="nops", + internal_usage_cache=None, + llm_router=None, + custom_logger_init_args={}, + ) + assert again is legacy + logging_module._in_memory_loggers.clear() + monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_AND_EVENT") + opted_in: Final = logging_module._init_custom_logger_compatible_class( + logging_integration="nops", + internal_usage_cache=None, + llm_router=None, + custom_logger_init_args={}, + ) + assert isinstance(opted_in, OpenTelemetry) + assert opted_in._capture_in_span() is True + assert opted_in._capture_in_event() is True + finally: + logging_module._in_memory_loggers.clear() + is_otel_v2_enabled.cache_clear() + + +@pytest.mark.parametrize("flag", ["true", "false"], ids=["v2", "legacy"]) +@pytest.mark.parametrize("key", [None, ""], ids=["unset", "empty"]) +def test_nops_dispatch_requires_an_api_key(monkeypatch, flag, key): + from litellm.integrations.otel.model.config import is_otel_v2_enabled + from litellm.litellm_core_utils import litellm_logging as logging_module + + logging_module._in_memory_loggers.clear() + monkeypatch.setenv("LITELLM_OTEL_V2", flag) + if key is None: + monkeypatch.delenv("NOPS_API_KEY", raising=False) + else: + monkeypatch.setenv("NOPS_API_KEY", key) + is_otel_v2_enabled.cache_clear() + try: + with pytest.raises(ValueError, match="NOPS_API_KEY not found in environment variables"): + logging_module.build_custom_logger_compatible_class("nops", internal_usage_cache=None, llm_router=None) + created: Final = logging_module._init_custom_logger_compatible_class( + logging_integration="nops", + internal_usage_cache=None, + llm_router=None, + custom_logger_init_args={}, + ) + assert created is None + assert not [cb for cb in logging_module._in_memory_loggers if getattr(cb, "callback_name", None) == "nops"] + finally: + logging_module._in_memory_loggers.clear() + monkeypatch.delenv("LITELLM_OTEL_V2", raising=False) + is_otel_v2_enabled.cache_clear() + + def test_enterprise_alerting_loggers_resolves_real_classes(): from litellm_enterprise.enterprise_callbacks.pagerduty.pagerduty import PagerDutyAlerting from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ResendEmailLogger diff --git a/ui/litellm-dashboard/public/assets/logos/nops.png b/ui/litellm-dashboard/public/assets/logos/nops.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa8e0a6b31543aa8980cdaf58325f6df0793989 GIT binary patch literal 33315 zcmeFYRd^lEk|nyu%xp1R%)G_SWHB={ZLut7*FK_GXCCj{ zt?#Rf$Q8LFG9%-mDyu3&NkI|`{tG++06>zK5>o*H!2YUW09fe1Lf5(2{I7tt5SAAP z0BYh8-i)CBwnC>loD1Jc18dIL@wCB`F}0B;Qz@11LQ*dm;RRrpg(ch`YYfZrLYo`i%1=xl1vt0E@xZ~EUBKbfVg zt0ONHlZS@~qX!$KgR=z_3l9$u6EiClD=Wj_9t=%YhOZOWjnD$uW0DurcT1@yG2<)r_ zq25hPjR-{a_~FlAf}D@F{+6aeyFsp4Qp#g;}{Do zt1-d(QNB0sn!N!F9(g1~>|$)3rA@`J{Hy=E_vGs&~#( zM~GVW<7ZCCL3*6SB*&6TRmaq>0@W^yx#4(s>US0h6X-$2Xgpjt=Kmk~|1uu}9jwgn zch-};&tZA5Gb1;> z26BfMg6wGwfwx8z!NdA<3Ms zbm9%LWs6Gnv8mpGR(IBnOg*FIs=~1qcs61T!JAkt!w|4AH>1vep+0j%H$RRHe{j)D z@@cP4(^{`7+eQ%AVx&jcrzF!mvpZth9PBe2;clqM`seum+ZNPN-N@}>n1zEuWwK(H ze6j}p;StguasqHbA5b-5%IOWe|0imH=GDAz#etYe;j@pQsr?Qj2bm}O`_u7ss-heI zMSuTilZU*0TXpjj`1lx;jyy`IVJ)v871h03bL|v!F>`P-tBLPo?KiJT^h89?VnfgF z9Ya|NP8jAe2JDcL_hvoNrnU1MaZ$oP-yuxh=hX*sCnQ22=q%xT(fi`b>@BhX9bIfu zorV!>7w)^;>4~9V`1sT^jAnU(;xf~XvG3$Z8e`FziR~HkMx0Kwhwv+5O4Y^dJxbTp z@!>d=K6=Jwgt6F8Kg0;Nga&N#^OoJPY4-pNcru%%$<1mp+4@nZ@K@VIPm`rJfS z`EEs|PdkAv*Bt7<6fRJrFiv)XS~5GoVBF9-CUqfc#6s9U?#n1P1M^sF9Vvg>bbv-# zXBvn|HVqgIPJk20TlOuflgOKVX^bC#tXrQ}PEi=2vo>Gzj?o5oSu(%dd@fLDv$x%y zZR!&dbV5a)P9*H3HNFgU-%h0{UM1U~*5U?uItKWq`dQZMzTK`|lM6au%2xVYF2C+- z^d7_vCF6lMxCERr?3cUjS@}VP2isAO1ABaNLMdGReqvH&bb@R9nV*P$XAXumF^0^;J?*^k;Uh@v*uUogAc)d&pG3DPU z>QR1RvC*7K&<>4qYOiH{I$T0`xj-6aX3SRLHGrbe0E7Sd`YR-1Skp~E4^O=Xi=>oD zW!B!^5vVnB*^6=J&z4rzn}f-oex@mzGpU3f^ zxCNM;%GZ*xPGcUy+qs01YmT>NsLb6ExdZ7bpMw|FKV>lVK-tW3i=6KBt+Gr1o}{iG za{*;!PAecZ>s31!@F{-e>}-u$whKMOSKufC3=<`RM#0U9#bm8y${CO&sQ+gBHl;wZm z%(kc#if``u#^d6h ziRn2(H3rLJPXG2y^7C+Hg>93QzcoOI$}|c==ze|^_>%vKZtXl^k>;r&YXv&%lx6J6 zS`P7d-<&eIUL3M!;agb8#m(R%;>QDR<1+#+STf!)#&=Q}sGmTvv8>k%051QRAF-@y z2d!Bt(ljrI&R_uYOK3|ySKY$)aUA};AGhn1r~Tvg+&yG~9Kon1kIi@TB48e_(A0JP zWw-aK#nr|O;$_7Yiau)^!JpdnU6<%o=hU|^l#=NR0vSrfNYp!~PSKCspNPbsPv_6J z_pu8-Ee0KzmwJzeaT!@!IrqE7ofqrdYL}Ur{634;p0*fY81bSMP4gOZ78O7do9E$R zp(eSR**yJ{@Ua_pyxe{x?^7KIA6Y@~vaE&}gTF_*Zw)?==5XqcJrp$RJ`pwid=%p5 zqN@&IJ4dU998>fAPI@X>^08}8CE|S&T4ZXH7pU-Z74k5n*fiZb{Tj5}??WB*{-h=n zVce|p30~(*PU=1#4D_rt87cT+xJx&YGo;92OFqd#ZjfrxSIaP-Lw&h4J~;)2X2EZS z8DwWMrZmYH%`GVgu;}Q0R46_lqUia5>B$y!*|!@DdahVbhp04FtS&2kRLxkbOdAX# zx>g{C&@3}~Nfo%gdKzc@b{OGAgcDGh^Y@m(-|5*_z@IJqY0K&pe2f>Xy@^Li>h{+~ zJH?`1z_1goB*!v5pQtHTiyJ_6F-6JYt>(*zEr1SogH$(LPhqmt-pl91VIy*i0kWS4 zgUj#mr3>v+!Uu=y=Js0pfQ|Lox0Zz{f{iCR%%69r-dE{|`1X@y3-?FwzV*lA z@BN-l&(%jkN4wh9C|!lAbxwY6Aii%ZM*8gW_5<&TSDL$7V;CQvaSl(rDT~g=#V=|R z9!5Y(3bRY01*87$=)UPk6LSRPoVVXsSH8}%JneS;9|O1~1QeOmJF+l>BO(iojxCq~ z$)8X?6IR3cerdX#*CV$OKCq;kx*S;(pHaV`e;XYCQe|-R_jS4%E@OV~g3!3K7>2QF z|GBE>+$d2FE_g@;5!aFH3r2T0i-?~-a6?o1F7|Z@isXJarTt^uVhWkcVc`Cq=Bb(b zCFMQxCwY)2uK}+%9_ueLvZ7!JPegJ?T9qL!NsV|lPI*dHpW&N`zE)8p4xyemQokQu z#7u_{zM~mec1@}Rl>#1j&&_)sSeMc3rI!^=$hNi2N3>Uo42;oa7x>9_ zD^31;Ohg~?*lsDjozhM;LJMNOq;{RdNg-)#U*|}fewM4d9m{UhZvIMX9+5ziKUDSH zveJ%wHP!``M?Il!E}Wt2e6HYZd%xnwc+QU^Glg0ot7VOI&d+Jm{4UOJ7*(7Vfw#0J zoev2CToP`^klu}fZ}aIjn(+3BwjAF$2wYCk`E2jI#_C+^>ahy)zVP8*z1?6K(?z9& zw)(9-w^zSVx;2rM4-!1Hv3#FK}OjDqh;N`5c1AXP@_t}ME5_k#G>v-oz@xAF`(v(Lwh|xnIisR}E zXwVoXlg1qHuVp#h(=lf0qkgC+>9Tcp-Ih(5QOHNM@5NB{Ft%m_{@_uNG&g>IN_?cy z)70G$vn6l(lYspZEJEBb3#|s2l}%&iM64!zPsg6d9}57msovKDV=kL3WFvP zkYmVp0*8G8=WWydw)-e}v-5m+W%l$7){EJzE*b+5NWejGYIf)N3iUAm_k_6Y*FbfXrI&=y?G6n?fezdBrVq5# zJaJpkMM}j6f%*<%`WA2@u$H993+g#0{~P(Zi^oQiO}|9VBUGl%)hPeRw4K#tir5M} z@Jm9ob~_^?6tA3Xgzhnek0J|=+h_b4yroe|@iP6NKYOp?dXFfUCaHfb(I)p4KpQO* z623+M=xhDL@J>Y3Oy)2%5QcX*5_p*Vlt&88>m=$@x{=3tQ^*@^-t?^TnPmt+ZCr59 zVCvM$ASby!b{i=>gzEWX&`ULuBbO~u##Lm$4$Jlxp7P}f=!6uMMf?v0ogDh zb~2){JYC}?%0U#U0Pgc-osV4LT)Hpn^L^XwCz!Nqk} zS-a~m*LC|h#-A!TGK6uBv%pxtjN3Nl{i>vFpMXE)|Le; z9tGrK@H8_Y?HGoFQ?V;lGAWsFw?U@-Jb#&iG-?L(Q?m5Ba$WXqaC)GYhmM_H1})bs-AO2Fcm`lu1MO$He|Kg4p7GZmPa@?B@Gj+hiQe{9^cGwg1>PB zZXXH%d@tKM+)>Qqahnl{w$Jzp*!Uk1l{yDR1uU z>=WC~`%@TADG=iLnv%NbRPm98dkDD(_#&gWjE=r(BVemh4_ z`7R#?Im48N)ECu_f8oJ44C}|1^uvKQ>axM=f-8)Y^M`p+R%U&Ii`qUTW7g zCVY*CKS0{q4j+GgNco=49Sp>f9jb^kWRU#Y+d9AKUXthGD?Ht^iz9UI)&z+B@!_Pa zE>DF#3*(ciSR=&i7u$aXc{v*`Lo}ELeE|PehcGfQOi(!ZlxDSK(4qH)8bW?RzG1mu zK&ALOGVPd|2ofnqzJDBtjw-#%xT?=M$@E zl(LqU7qxQ_ZA}`>MrU7GR`%;Wmp^7s!sh!jx8dr8y4yqnD2c8RE(AISgPF=`RMn5q z;qb@toIR9JhQUB0fTl<%uIIi)Bx6ybzUQt-7>co4F?Tlt>zsz_A@aCD5n^5b=>;2J zAjf!WH{_VO{s|%ur?yrk(Fis1Yq)!+j}mGl9)IsnA>ctoRJ8HI-u8lE!%Y{lov{oS z1KXh+Pv@o&%7M4V%=0=OKYrvPUyjPd!AXi^PNz3Q+K^N#ron_z<0jS=0s3m6G`G}C zp^ur(6f7{<=|_$kkvYpA^T?M)2E}rQZa?ChXbPYc+?!NCTVWkr%5u@f^6`<-1GRTL zc|y>P7ol2`qb{gRReY_AwXB{dZyHP9{Ke~CC?t854N4?Ikt$Y(3NdBAmKPi{=DRLC z6g>5tUes|t)-?_}i=v6FrV1ujUqv+H6gC zmGHKMoPgugaM0tQTd1{n?qiEH@PJFZjb6b$7a)%ayf3O3r`bFR3IaYfq9L_&7an}H44Ee$dEj5s8TVM(n=fH+EzKH3`%>7-0s^^|vx>3{9ali~sHmyrHS ze4zY>ClgtOld_o{1(%JOX3%s$3wY=#oVBNtmt^cOdM1wxGdON29C6m(<1Rq`P3E2? zR6fjNT9UJP+$gA*)$zFXb1I7odrGU)e5$os7ii}mApW+Ms}1S6%2dt> z)eCl~Mj&)!h+>bBkkCu6B&bpOIb;#?Yj@S21o>5@Z>Q{&OI@Ge*DkZNyrDKKypEkY z{U=>px`~U@bVofZtsS;T{X@HaKyHptc>~VRVN1SSKMgWJu7~{hH!KYNJq&<34+wyq zhny~R=Z6Fo#mQ_v`AHy<&x6}Q{_yBX$Mf0NVfW5DM|GdD9H^Zr3L})RkGb%!k~pD4 zjQL_ZFaSxq=K(h^^ja5naKWd)w_RWh8-8fdArgY7+Bd-5GU=vJfmj6*j!1IqY7f)y zRR1{qTsW`=W!wF;*5tP`&xF8H;XI`$`wiUX^-biZKfX||3;BEsBP8=O(>E9zN@r_Pp1O7Una zJNG5r=2v~22~Enkn_Dq+3MD74;{|38A2MW-`LZ<8zW0vZO2Ce6fKnO!MBC-)WfhkX zywXNVH_{ym=+>V0o&$XwdsyBVNlp*4zMl?&L)&VxProDtpEO_jPw1g^`gnpKh!tYv z3<3p(!wf)uZCs278Y#|@9ygbhk?K7J#d0KOWL zkCz;v>$$Dx$i!3a+Qj2HqYw5+=*7en1k&GK(5bB@$CRZlQMweW4)V17NZYtBWCUN#EOKtqJg$jsB9ymmRd~{+rmA=-I#UO zScoS%vDnhqD61-Cf$&qasAhHQ==#cL!bBK#F;ezqIQc{fyZkCLqvjmkF?zc3*_~1~ zssHlliE|IZ^p5^vD&S4~+SVN|{_Q>?Xl2Fb^A2OvbMt(uV+g`qgaN?hdwgQ(e*Qd_ zPAKQ_Iq|69ttgS@iTzRB&o+a>rHCV`D4wDl5^QAX68L+Qxwe}7hmiasK1S*0+noNs z=GH+A!N%zxX8O01(^Qx^LzVzfmP59OX|^ujKulpnOgcWw#)S$@$7<2B{YX?7=Dm@q z=0E|{Ot%+37~1&cp#BIyN7Xec`X|!Tq3wcZ(wxd-lj|@7+cA`hf@h-aBUlY31wUn| zT!S9CQw)uFO)>gNMW-|BTJrEPL1)gNtf`1hM2ZA~RFqa6{N|t*< z@FTGy`Z_2mMLxyivy8gq@cX6DRczS>`5=Zd7-)ry-?eYAx2G<2Nn(HpcIh5jCfzLY;NeSBj{T1v~4)E1XJ1I3y>N{AauDle zHF6!CJJaT%wb?{}yh4%l#GJn=No7H_2ggqSHdhU5MpesLYR|K*5}K8^^Jw%Xaa1&W zzak`i4ce*t^VqZNwJpqpA$9}E<8n}?5Su$|d!8G2AnVA!X?$QZbZg6>Lb zY;FOrcYw6&nw%9IP!?g`dP=WA?kfrv5Mw^Irdm^B-Qb9K8@w(l3%t+OF|pBk;1Olr zb&QqD|ENvz(J0W`ImB6hx_`o#3oV+ye8Rn?W4qzkNpz8gKI_^TV^RYJyXY4pd!6Ix*uQJwN zbPSnijGOA}oWC$^Hxs6HU?@gT+)Vt?vP)UR#X2D_*%|b!!<30m{@b#n{gk+JF1$yii7ie_uIu)Li6#&Pt4yq{{tAE!eT;N7pUW0it0?Sh|sP;bJMK~?=~A0bcV zXk6RZ)Gts=pwBh?lA1-R5R5Ek&HU6j`$Kpxs_@OY(#{7?cA>2<2*>Q8yyW?QQmm}f zG-7tRkc2=w@eLMO2^VyoIx8{lkibgR({ADX|Pfp>Ma=={SZxE@qGdcMt2(DH`u95E>@q{(nqUVEkCo z|9Ci0-`vK2*I@Ud#N0&iz295%JEtbbLFlH__xNz3Dnl^%zUlvBO|dQBe71vQ$c@T; zz2Ek-*v!hTQg+&-@V%^N4=q8QH%igb4E^^Wg|rf52k|q$@B`J%d z>{pIcR7GiN`B8kvJw`6xG8R$;b{TG^_>mCw?{{4Rqte9%r_evlVJmP-4@y z5s}M8gYS$Q8oI@hif6f}bzWlUx5D+?Hhx9Mwy-ytOldma#I;5ALAW#vpAdWo8vk+# zKYg+c+zj*CMrs0nwsy@!ZS7b29=)ubjLTlzua{j>XLcfDd|G9uzaa73QD*G;8dc1@ z|C;IC){y59i+!k0r^O&x@sM$M45TK+=>F~`ojw|#@+`8ur=dO<8K(bBv2dk^QB99y zIJxg!YouOgH&YLR?HHX?9Gx@mXgPyfw!Z>+cIo&$_-k3w6mSgv-z`%Fq1| ztdJ6j*EHtkF}i-!_lE9$##3~>LKf@avs<9{!NA#N%Px=@; zjHIgDCOioNDMPYBZl|Ora#)LMOkCoE1-X-3WTHRu&eYj;k}%V(b8$| zq7?&~+>Q(*i)}OL2t88uzE(<|%!s6i4^q0J)b%iOyYKFFPdecm1A!cveT;7pqf_k% zLAR#7EeEQt5TIupMuEpSpBE&}1A~yJyX=w2^JgZ)Oy6fT*>H+aZlm+~PoDj}ZyYu3 z%dIl68?K3m=y)x~;W!y{HK^es)}c%N5HuVqC51!ZtXto{=F}YfYX!V$%dwEi(#@+} zM~}hBj@D^>GquP{)2o-UYKsIyvJX}N03Bh78MI>+A4~V=x0u5x<^o8_!-Xb%=?uQx zDPO}R*H$}B#|$J>YcTDSl z(6AYN+$^jdCouXfxVF5kEpz!}IeS~(J5IeX^ntB+zP`E36gy?`;;WD)mP8Aa0P%vI zS*v2QJC->J9yIx&m;VUN zkC6K6(+079`@M@#hM8QSL@SQR=MO;4OQQszgraA7D>8yH z6EQte`tMZd6;fTL4>V216Zs`NWX~`%_HuU;j<4&s6Sq2Hjv*tFQv*Xgk^*i(Nbo~W zQ(hj1lU&yuv|)Ye>l%_Us%KPxQN*|v@T-L?&m&|X^G}B6`F5~`PwQtAMF(axO;|Ie zYvGuBuSXiFXyX|`BO%pp4jnuF-5FtYdL`y;+K#aOjiphTd)>UaJvSq=_<{?kjMOYG z1xo+|$5Of6#I#v~+k1tpju)(UR;0#C4XRwMIgFX7QJt>{ref ztgjA)l~V#gdp3`+vV!`-fUy5|!eSvl{FK9q-Q&9N(&7j|e`UxaKskiPSx!6&efR9icG=JgdP@R)urH3uKZ z%2h#E=|VRpvWyTV>rN7}5sMe~K+`v&vHV4-Tg}_~HIaj?y^qzSbv6wHL}9qt1nlpo zC>K`-;#L`>+IEQ=rGGt1#@RZt=!S1oiWLQ*pt55Gm|VtzE3Q~L1BSi6|Cr=3a$w09 z^`o=_Jo+}q2mOfk2#o=6W9HQu70@rJF6rfRQ+x&u*f9G2eSmnd(`@>50j{)*eV!Quf}fToFgi8r=atxg zB^w@O`12I;GYTY*X^*ISj}SQ(T3*x_T$xn1=KKc?wkfqPQWWnUqvbcj%XZr)s+Za~ zQN2EZJI`uj1j8*OcYe!``J`X&0;*}Q94MWghd7Ps zhN^MH821~eni;GSQWv~?j6V4&hOgzdpIm zmudCmdH1SwdSnFIyZj;ACBzt&1{usY90w3PDX(68{NgH(Bg+7I>qoRbih|u#oD#( zUrnK<5=~s0AiK$g@$JyAULimMg!vX)5^O-(R$GRkK4X8WiAdii{N zEZE)M+vv~Jl(uM+#oHw*qi&h+PBB}an=}~8oQJ9L8ViQEx@{IX7fE<925@_#NSB0m z{9*Pxn}hNbb<74O_7RjDuI$)FR+v^*c}v)pl|VzNLEvrtT;+~ z7KPJnu(ikTu8{ikBclpqgmEXPX^yEFL|=JXlb$rgJ#Eh_qU3+CW%Oj$vs@tvX46?^ z+)I{G_xxC)Tju?yduV!b%aztJp2KnCyEbT}{wlp;muASzjt$|Zu4eh_Xs`ijA-mMhJQ zkl?Ii=tVjrw%-fs+g^>epH9C$}%o1?TddMixsUGpi`nr(97iICeh z(c^2d6=jh{<{kBPju`uM^jS&Ai|ufT1%i|CG&k!jr|azG24Mr{Z9KK5+*z z7gg0g<@WAt`&xDEUsi?Er|~Y3wy%DpRcW1-xr}RtwtSXq8iR(-H`*`8WlP|5vyE|e zG(Dxj1E{x)EK4p{S;K61^&OSQIj=2NUL^R&p}Hb(%JM|(OdMOv;MQiWatF_(Oqt)m zo@LwF&H<<<#jXidxQn9v{wBuD{lP4OvQ*GlfNcN3J@H=fVgp7q{yaLGR_W>^yyh*l zfOHLas;jV`d|0cCe7xT*>Oa*4)^)*wlkHNS{;~8lbFOnQ%kN9LF?35I4yj-X;>n{T zr87>EiHko;G%}{G#!!_LaZD_+!1ES)+4^yb8m^eG)Q^tvvsfkSd^^hS%Dq~(f7tjN z`|7MVviMbGZ8=q=@nth(wTjR>j`Ez5A41U)77>l{T8oM3h`l67djyVf39+~+eUIW3 zmWHd0*m#0LRy>@OLT3{NiSB;t3su6Mntd3Q$}ddZZJBnes>lQhn-3R8;Np0h4`)*O zVPjTKgjzz5K0L^&3W0&@U4~Eo9npSiS zEw0wLG|MnBg_bWQR&()WVdp+eruZ<~5-`>e7t@^+hw?SXW=x~Bd_=;zHtjD&cUtUv z;e&2ESV5zu3(Jj@qzM5O@;sjs2wgpiAzGe3rM-Tq{Sm3*0e?_n$>s(T(z z5UW?ke=*kR2Pe-VaFa_!XG2u9`mm7-FG)Z^F&$%FwTdada(^QLe7-I2?6f!xE`Bb& zmGM&IXBlo))O~eLOeFQ9+^>&jXpDuKF{$K9i7ebM5uzp3noSl1$+bHcO_oK&?Xbqf zmu4m(hyCjH&FDg98PzBK{P;N(^ zVz^2nlD;{T=CUmloRit@!@(#XmwpF`^f>}1t!HZ~ zywl0CR-&*Tsco-XyO7!=i{It#{QSYWS7)ztKz9)IRjI?^PGzvCUi z7xb{e0zXFJ5u)-d&5cY2sn#p8SS#zx$?JK`a%n_CTwReap0vx>PtiY_Cb#(VC8ovA z>!K*)Cb`I1bIz1BAvl4HjAe@qb5>O^M*8~#$D*W6$mqWIBJOpR6@0n@j$0Px#fu*h zoTt!i7?4L(OB-z`&Fx8YW^p@AY;Tbnp7~a>aB&VllGLA)=JTTR8#YP=Ajy3mrTYoS z8i{y0bvD7v3lE91x{w9e-Z^ffey#Chb|luDQnN8G_$CieC+EALlkspG03V?-b`Wjq zOGCkyf{K;I7XgXgBsoe{INz+PiSmJ?jK%n6*4*i%P@_=bKj;`LWGQ^UeaEiYsr&td zASq;0dmaxG$_ZcQ5R-pr!AUMS4}{?bm-X^+0wpU!P5GTk!)AxQrm50}f9Bm}5vv_& zm8rA(+PqvNxc1c~M)&aHRNs5s<*EsCBQWNKvdw?6%Rz`DThcVB@mqodM&>40kyES@ zq-+N*UR&}asEd@ABs zx~PUMbaUUNDqkM+)Yd;6DQc1@ByA)~!{jipc1NU{laIfRqAKn`)8D6)r}faYKYPX|UuttCb93(>lr~}F zvf$q{l;LLyuJ#Bz_gD8CSD%0xV82 zqP(Nm4CT{wYi1dMF5OX0$!OM}t`#~FOm>B(ac077futNI4U<%<6lBROdj3|<7vNm> zCrso|=p{|@6NH07Ynh&mCy69=dd}#)udH>vX?2$U4_Bxrh2=$$QoKS@opBcB7oQB} z^9>j|lAU-kq-B@!CoD31CJ{TyUultdq$b#A)c!Wc9#u067Md$p_*D%zbOtT0ww}QE zw!a@e58fAc*GRtE6qD6xtbiRbFqIN8((ZthDS^vC5swaqTP#xh1x5D2b?K( zInAbl$3pQd)gkMn z#U%i1+PtxHN`>jG^2ZIBAC2$S!i=kBBiX855os4KSk0=(E>qq!MoyjD@eqX2ov~5h z3TbfZ&6cSj!}J1@#n2|^*=kng0M*xg&$%hi-^LS+7|=e!#w)b`5^P?fO{U^;Cko+d z=7Lq$IrBop>KRbvxI;r4l(?M0AetLGARjev1uldKtHrEjoJoUUDvYXZD0P(QSD`}F zDp7%pvFU=~)F6cv-2`2sx=*}rOLl%VkMun?Vcj@6kmp$LZGEe>LGR6u$Fl}KgJ^wT z@K-NDha-xA{beVKk0!p&!Wbg+V=Og1B;45`vDUt@&)(4D;32Q+z(tl=(Em+*Zsn{@ z{jF}@3f0VCFCBv(iV5DXqoH<0(LKZR{uDzqRBmR*qS|_P$tIYb*t|fr$8Z48T7C&C zMWqI#Ab)0FekfOWYV2xP8i#skI4eWOstX}M4}gwhR3z5pAw0?ih-}|#HFwM4ZN6)B z_bHZO;@05v<5xV2}(49 z`wjKW`}0qMrdUO6^xyNIzYO3;HUg&FA5E^*B(z0;$HVg1-lWLD$khmC!B-WyQQ}0x(8T$lHBkxpDUY*F({u5wUS6BSIgx$PuWkgJ zL9c&*yPza`+dn9yQ`9}xeBuR+Rht{DI_qQY^64K1}1Fv7q0bp#4)ZSGK)a|t97Z#L`U9G;F$#{4W8ojoJ6e$iLZVfXGxTfl8rF zU@8a#V8z&oO4Yjn7Xlh2gZW5Caq%h^IPXpSZ=<9WVln0MfpIVCgg%2-$4}&huBZXw zO?HL2UVP={&=g=-kwS0>fB!&y$_n$O+WZ!-Hwa0HK`H-iEjb%7yN-geQl`!8$DWU! zBf(%y1TfJrTNh!nZAW>r<3s4)JDR~!EQ0Yk>g)3R-P_Qki<|ob5*O~%s#2}TNa7=_ z(NTGo;)R&oh>@B49u#RiCVb+rcNU&_-@&LUPN8iZLan}UZWU0!52n>Xr)$~DurhRb4AWtPg;-3+8L6FzV8(@9WRy6 zg%BUA9BS0O^V@my=FbWG@_|95tTEO(`*z9W>57uWsoUAn*Ax2$jTfR(_0&zI>}kO1k6X-JVB!L&G$uhmvKt-8eZECQjtM>5WWNDCD=JF{B@7Ett8 zh;vlMEqAGvL+KZ2Ps1l66*|#rk$cC&Xi_fQ(Sc|LF1qt=Ez4*1IJm-l_+LY6Au2Q) z@mX{?j|-M+Mr03~h>Jop#-wpj=p@dR=3Z+L8hx%vmJGB_bcody61`>%)M9800CV#f z71PXLazDG^J(xy~PlCn#sF@&$+V#kwxGjmmKZXR;bmO*P_Q#+$U)Ee5CDanC zF>aXyJscaC=CM(iq`$JhcVzDml;XuGh047Q%CJ1nNWwj_r+k+RgxP_|T7=tTofL@4 zSbtE;ZW_CPVYh>6#(PJtb+1s$N95%tluDErcgEd0VT3KpvZy zFD!6+hJVcd^I4i{vOMBvwN*gcp`D8;tr&e^eyU7p0rP~qc>gJp(>~oV89my@v9Gie z;solCnjNbwUlCDU*QVUDWDDN>+TjZ(ZQX(K>MDcyhMM-QvY#EU?<7;kb5?kKkKH>V&=)=a;bc$&GZfz z(!<${ID;W{f4+*>Eh$mqOU8E*%zA}MfGH3XjY@$?Y`kY&-IT{$a(x5P6_s8UT7T!- zEqcfwmigv{+Q?Yh7*}zf?#&nC_N8fTpDX?{P+v>5-?-9i6?dI1XvOV2P;F>3Ds$+F- zxEBO`vdtLL^{(s%Q^(^*_?je)IKPESif<~!{W`nrQFz1Rgw5}1qWO9=hG zH4RSmF&8>)e@bb@O{s-L1eEFs7Ea=pk@L)T8w7DgoK*OUz|ODYy{AZt(x5eT!Y3|; zWg|9`4e+xgjg_6+mh}0B4`;yg7`j;E%{lNbZa)qiJ4TgXFP*%Kop{SmQ>*qv(gPWn zhyZiXJHH-QTUS~y1i}zQ_a+}7rnB316;sg|8k^n@Hn-+J*SSVMg7bf$1WX!c&yANI zL`NqZYqT!0{E3e_oOeeeyWMv?fm1MU_DJ^WqBr4Hxz5USbt0ic_9)=U91~NV6dP^5 z&A*FWISA+7&OnYDq?cwVu(27~Y(YOZL}u^8lE4>398INFYQl!YMr>v`<(svqMj@g@ z(NVi&f=IKzFgA#xx#{O6^Ps(0+Gp_u3gNoBLx64dkH|Dzia@n9eBN2)Faf%Px|7Oq{X6t^q-PvJpCClEMtYXn}|D=ee*KOW~UKPX;X>+aVCWe zsShSEc%vd zIq_brU|xn3DaH(v*Oz?=k14|L{v0qV|3X94Oijb=vI;6WYq=ljTnat8zg4-Pf;cnc z%E{5hCe!_nLux(1Hts87szR?1nW*0?KJ8-VYi6FQvKCS9uSXfslWx>gIhoILwd_MZ zpQq6#D^cZD6S;YdSB-)L()H2C!=d!;jno#XDQ2RSX7=Tbq!L3}&dg*P-n=L>B67k&^2v zm?F?Er0GpPw~3SZF*!1MU*NmP6``ok_p|>`;Y79ugyK)PKc3|=Vxc`J(kTKEFMc#C*OSCM)XpxzBDTgfdYizjpx>IPvzuYvTPOnvWB4aIS9DOS=HY>g6QLg$)cpU zq4xzk*uhFCX*S?cT0W+1r<<{0gX}Xxb_EK?TS5{W8Da30Mr0@G1ZwqEHe)Cqc3NkaBCz zN^(o5^c))T3@jH>l0a9q+!AzY3--b0I!vqi{T_!K+ z^#`?!OnI?VuTT%$rAF1I9-8@&xH7gy#9YhvPfDEBUdlh;i4It+7DQ!&8=(+&V`wqG zWLPmY*@nl;kVKhLSvEYZDl#Hg=KzTwtFHUlX{X|wVc~}b6RS9V0D$l8e^~%$RA#ef zOL~K>hUu5SdeCiTZ+H0!lp8OR$Z1HMz3qt};8Y!tDz&&-DUP}kw$1+Nn!byKKKjEU z!rM78CJ1+~PL4`Aph@WdOGQ{vPLu@POu!v?cU{SujC+v1|FX2(UqSLgwWE4%tgM+8 zlNf~+TqzlHnGxID+wJ|*6ezilxXf_fg@i%{>Zdjld*G&8kGv%TCyp+VHE_a9Jb-Ae*4sy^hNNK-`QHc}e&hx4(P-n` zi;Uoy6dB*u#r(SFzP~gn!VuCK8ly{5p4IQ^`@MEqxT2sC+xA3IzqkjFP0CM9inbWG z=))l^%OUzNZ;Xi`wrl2oTcevRh^MK_V5l|e&(LLj@A|t#siHGDY z>IgZ*08L9+368qLAymB-UxerMl_w4mnA`WtiSaUVMl4cXK_Yz^>8t5+$j{t0zwUr7 z-_!qOUV2#!#|<~;XXN9~m$*n~fn@oJv#6#xakngt@Z5MaOnTnQ+n>Mn-z?MF@HVDy zg4E>4^pA~WTfeP&valHXEuCjOx9i-z!-M}VUUMc9?UYrnvO68_HP&7X{<76Zh6O`A zjQugJ5nZUum!-+Ifp(Su7@^eH!gXJdupGKECnw|3YTKoFPK~CxR7G;vCL_ENlD*Ul z#6`Gd?t`diy$~b_`Z6(GUdpp zlh0Ji%RIXV6bIqUYUXW21*GjefeBUHC>y#dTjd=1B%vKGaKBGJGIS>?+YZe%YfPGe zt8>koPhdhs{&&)U`WMKbgM_?Tt%Ea<&-5UZCj4^^kxly7#>K7IMk!>_m%tNWR{I(M zBvG!qNBWPrm)ux*Rf=%VcF2`52p2S))I{J69i6JM?oV&v#hhB8t(uYmbg$VWFa8m43|H+|BH# z;KXJq2^Jn-SuAM+&-{U@7Q$xK$PLwe9WgXm)3 zx(W=K5aX(-zz*cn|12U&jugb!X=v@v1`{ZjHpz*@y>eoL^fN{)9BoGbx#^S2VKm2` zoN!awq*6_^ngQ`G5OL84FGmu|%!|FO0iJg-nBPp{L;75fP z%)YLuOfOk44l&1}lsU-*ou^JIdFoGeYAeBdeS#94K3F-U22hN;^m#jex3pAK6&LAs z=Hn$x%1wGHN{`EkRq0D8FpkEsRyR~d6xwvcDeY(yXnTKlx1+EQOw`#HUQwTO)O>1S zH4Gq%tE4QsU2GT;|NHDt1lNt~Xo0p5URI!?p-7>hB-9ZkDH~1*UOUUV-1VIA&RXqN zN<{>5y5C<=xGwq{qUU^3^#Bc&!iDsO;lY^IDxdMu{~n<6jou1B$h!521i#d)i?9m zml?&zA#q|g`M=&97QdF7{j$tjhK$xjdn-{;D`G-0@Q{S0>sHF~JRIYPrMc7!kc(@{ zcmG^V^pGy}f21;Xz`K&(3)7<~YB-VB5m$^v0!A`-w5ZXtyL`)`gkEdxS++{ooy#ic z3DB(~&@^Ym#caU1&sx$p@%30#}2{K6E9|LFp%9l9T>YfNb<)xm)sA zqNGfV(E_dWF2x#R@q!1>RkbK*&Z1UPLxd{VN=;e-yKj!` zKb{ju3Gm6^BSsHXyo$V5n!su(AiDoulW9BJ7$62 zY%VdK2t;5D;!yJb%W57mW!@=3bv3CcrwbHhn;2W;1??3cC~k>`(7w(de1BJi(P9dn zL21&RrynU*sGg}iZNM8HCII|0=`UUPP01saG!i2YqU?MxGW@13GUpX!i=N}`b*O7{ z`8)fbiBkpQ6+#ZCUfas0wgPCew7beRt#sxF%knfR8Kq^DF)1#QvQZbciv)yJBicQW zGCxgiJ3U7o~SPMb%=zM%lVv3gkSo`dD>03!l zMQ%?TiAGl8ZWRLmKM3iOQCm)3=7rW=08}4#Xx5#1b;*vFLSqk^A1@w_0Cqvxq~>g5t(|b4KUKX%HYie zPxRj7QH6i4@)+*MxXBt1u^1S;?$-aeJhR^UjUX+JF80xq0Cf_;$=jYTc|n+zpESo_ zuVNmJqJ2QY%#mRelgi`2av{`>lqGJ-jH#~%$$*ZMq*%+tU$Ub0%N&m9`DoJdDH7uj=#SR4fXcTUettT8bYkS!K-i6pUx z3QkO!+(C}($W|YSpJ!LZe`40YT+U&}^H36N!Zc=h!W*vF5;Z}-Y9EaHfEU}C5DH5j> zj!EY6pSU_aT0X$|?N)E)`m7A9{kZFiNwi!{6Dc3DJ@m?)93rTme1sXvylPSGzG;pq zoLd90f}pD|FoptLFz5NV3A()q-r#~{M1!h$&8zoT48}K{^5>`;P*9!w z^9$w~jpi0BTm0N|?i-C^-$Md!-J!eNka{8@xD-nv4z68B*huRjQ|OX$E49-BU z?t@zHhPSmCKJ0eFMaRy{IUwZxDw{6DV?#J+s;Jd>g6BYBeSYPN*JS1x|`GBkm!Xmh=OqVg`JiDJ@pvOvl`_7|vj> z+I{Ra0Ou)p;9nQu0g4$L>&t@jnF~ry+S6K7_e^hIZ>c83lnV` ztKVeRt}#>Ydh^-INv{)@t`y1jIEqd#U?EAFkfdk5uufwn8BISMHga10Y(hF9QmkJS zB_dk>;0!T8-_5aE)@ULrSlI!OYZ4w^=0C5FSxSp~=oRRYGcG)t#c8WIn0s7NZsgt4 zm8ff=3G^bNwNU`H--u1jAWq!(yQgFr1;aTK66K8Y_i?^SDG%(}V_HH}c(6oCEH>AUu5jAL}-&sB^f$2fm}$YVRjf!DsxfDWejn?4Bl5Q!;f1 z!PO-&k!d{Y@O3nk5?)dU?T*zAwcgJA=L=LBZ_ilI7Vj8FOttWu&4Q3X;Z(wf6w5?U;jS%^xXsuXDuIwPSji-E6>J4ng z_CnzaqIgfUT4*os_|Sky$^jxe45ru5;qejuCys-hv~WTw!g%6lXY8C6x~@DV&}hwa zyYhuobr-{9m8ONu`3vCE=P?^rZ!V)CPtE|SPDxact%&R2QGOFN?Ju6V! zO#`&;R_lK(z3oTE0k+o%gEocHwQh6Jl z22KYO{J-NSE*WX19R`4R1SkkP@Oz9KQD-`3)ZLv3_)xYEHhri0gB5hg_Ma=&J60^d zyD?DN?S%hqDI{_8aBTV8IJ4;3tz8(E?JTc7aD}ao@p*9$bo~zx!)^xh!2GZ_umVBz zX1`X-{%GQu_)u%#hY!SJ_eUakY z6RD_&=FCIF=d_|qaY|*_v!}yzWwcQGx0+*QRJAI|#ipEU#RyhKV-K)1vu(d#p-%01 z>CF?o4%J4IB+SBfK*cd9H0O>C@p5c7B)2`B@w}(O?BC<$eweaVWH$HhjRbtrV#0uC zkN&4aL`G!!@&p*|pEjh~{2S-R07G5K?YZBSmnm!VDoyf;!^XcW2@;=Dn>n5}KlulG zdALwohMIXK-ly_ugo&z*=5vM4<#T2}Fj15U8bNLj4sfq1(TdpV;U+L%8R}E-$0;r> zXS$2rS>dRn{+EcrO%^&LP3OlHM-b|7;eDcD7*wbF9|DM7GUkX|vuIO&W&is3bAB^M zKYVhPI~?a3X?t{J=t%$-@q^6Rq>mfm9;{SGn;$DmMhofJAS+d)6$71FC^mBS%*_uWtF4pzM~e)J;|uJ_qG#Sv(4lAG%OD(-mvpK zX2`JsYAY71s^eq~O@HpB=%ZwV3B7tl1Ozz+wui)0P8Xuz1$N$OjnuS_5E4MkA(Cd* zR7KNnxU2@w+Dt?Ca-rnO+lZ|v$~Xg^jmbc|$%#TG(dq_ct_98+#BThXi3^nk2=tQb zNU}c*$=#X{q!FQ#sZABo|Blv{;i0F~0TuWIEI%vNLyZ2-+$=409~;<3~zzp)ju zV9A7GcccgnYR==|Dgk|eL~5-$aV`sO2@3-=M<{jJ)Mz*?C#z2jolr!rMF)7hgi|nE zIZp>tSP5~2CvorJ8rOAj;d>B8Ff6=gsdYITg9KUKY;}z$AuB1=b>P^|1FMy}rBD%? zPpJvD(Ie)a&Y7OhF6SEoriLU4#xr+RV*G$Z)Sk#HioYq|fT{JwqGPA$Fx2-h0G~k$ zA8InB4%t$JdQ+oaRE}x0)iHJi^O^M6nT6r+hraTS*=?^nXe>>kwA|2mGN3IkoO=?b zOL|s`10CXIV^;kP-|7H2ak9`VUG?z+F-P?WjIv?|P58wd{IHbGKHbc>2j2$-LHoNdoNhT&~BA+=zD_DSS@Y5Re~K}_b$~{+e+vB)1=>n zh=WFp#I(hgb2rK2`ec~lld59M3%mr4Rvu@!|9KGASH9E905PC;R(*r?TZAfnHUnx_L=e6M<9tFDk?hA*_`60+aMRT9iYkbW zLK|22n$>eJDDk=fIQ)aEQxoq>8ukua3MGGcRArFmn^MAx%yJg%T<_FpfS7@#C$3%p z6&Kcd%V|rYYG?eC`o1fQAZJY-1_6DEi(ztNs9+heOjmZA}QxO4DU0qXe(n9 zuxD_0DcPJgoJ(V!7nho5Y-?)UBi}~R#tdJZuH#6E^Kmm#eVgN-&5KxWrqB{w)p=?6 zpYi^~0r0TA`&flJH()0upxmS$`+iF~jEUce;e}r!@1$NDGxjHo-Zc%3jewD^@t69B zA!lvn<%T8w#6SR zRrQw;Dh?svH)JrSxV<)wzacVHIrpktnl@>qITRJa*}&YG$x%gWB^|+mwWrW4Md#v9 z%rup^+hs_J!viv(KeV+~8Jvi0VUt?e>xLB!7DNP%y;N^ue1;nI{Hr5JooVAbc}a8% z7!)tWD4fArU0nVvV?$V^^;WCI)!_oHPuAuoD*VV{M~+?Bt_0H&`y9-=jv6eu7WdJ~PU87wvN4h0+om znk$a1CK(+#4QDh4<)kN~qN7Qg^J_V8L!}H$?-*7tDQKaCjH#@6>f+E7x@-m$e90!3 zIC`s)dVZH09z;pfMSF9r0<2umUPeM~znC2Log9sZ+az=Uin+6^tl?TpZNV75XYG|K z*r$6{=?F0$VC?6wl}B(LX`2~>Ff@x(L`JBxPJY~tsWbA!_>hIbz z8GhZ9DH2B1It0ycI}_ktohr_ngAy6H7Y<8-z#8K z6hwU<^juB~rjO_Zotau;Klr|?bc~`w*$lbyw|hyyuM}0BqjlK>7hvT(q)p$Eq1^4l z*&mA>OhLryj!(#NgTws8-70^a{&jH_V*W!JzUNLl&?=^KJcoiCgGdD|nGsRNTZD-Q z<*zrEs67fN@yb?)S*tcyZHA!>IED|*0veDaYs=<6;d$inrPWMj{>J9753s-G_4sBD zZ9DE#JqT48DekpbljGkYf(M*K;R>uE;F+eipFAv@Ms>wnUi-6h;;ox*7)_heO99O~ zuIX@zbtx;K_s^QO?H2b#Ci9f&v@giHUcNIom7>gT3sHxU*`H+HwW5YbVS4V}+y9Ww zdgw*vf@&G&7CLrI{OOd1IR8L}3B2f#(F@e1%Bkn)4wGtt_|gczS9Ur`{EK|%?a(gv zMeD?5nKHEq(D z_ah_rwHV@lOj&sF?TbPJztLPJZTswSPUg$xW{9AjEOi;;k0@zpA^w^g<(ExHrUd7h zVAa5cHmh$$6+a)tiFu@#dYU%OaWX~)d3wl^o{E(jYGqBRa+FqBq0^{ltI!P}mg~Vr zTEf^Law*)$no!N0p8+8VW06`=S(6y%} z@C2RI(W0JZ%i1_`(^483hr-w#x+6Qa!{hTB&NK`Y2M;J14O}oWzhN^KSSw6o_d7~b zmr8UikME(C?r;3DD8nm+)o1W$mHQsdaRoio2h3<+h#4RYsZJP807*!ApE9-qj3s<| z@o8PzY_c!y-7cpcHz+8fM91!YZFX(1tCO9&^&qKVs61=B6yCK&s~7CjG=C9A4|fCN zAP5Kcah8b^Ua6;6ccE;>e;ec)lX6NtAEdX;6$aNaE1)UMl<8)Aqh%t(bx5oZET~;I zp{wQ-w!0jNFD#w$;xH6YIL|ySz}zUXb)UG)N|yL9&cmE(VZr_JlEhzvt62n#I7aSn z*u6b#Te3KgPCsKj;NW0Frpx4AAo+zAqR069&FyeXPvKc$faw>$Wt{r9fjHOG24ZT@ zYV+H*mS7;l)#a`2yyegGWcN|2b*}{Pgwab?-OPZ$(?g z_)$WoyC+VLMb_N?Pm;->8cx_v`HjXPM3uHFT!1XR1~_~dRYu^03cifbfka*}AjZ&o zY$<6}8ip2f$UZeifTU+R(kx6oRYKstFmdzL1Ge#;hFco;=E#Q0DeIJCSbGi=F69&J z;gx?o>R}>wRlmZ9?E6Gj z_l4JAe%pBX0AR<2+WLg6XTvLd%(Dkk;%eF{M=G}+xd-R0!aNo1pAq=8T|~v`?pOw; zX?XZ;)ijr9jXR2N$XRJpkK|afyyuu{b_dq5N(wYX=v~s>3y~;Rnhp?a)Iq-4nAx_P z&k*loOfPD3O|=Ri4!3`~y;WobsSmHI9qC>I^29?Q*@cd03+!fVXpIPp)3N}s@m|$> zcEJ|L4O^{vU4~EkP-aj95e!g1cmimkEw1eic0rt=mO$Zphy??n9f%clbV;kUOSb>z z%q(ol9M!S35rkoEuxwO4aiT7b;GZSRQw-uKg|PNYr3`=1@gWhowG|UF#uN?w?f&b` z&H`-FjvNDCpGus@HX_R>D~k736U)O@a+T?csfPI5*LR=!uOgQw>mPTz1}+aL^Ohu{ zkC}#NkNXHpb_FxOt=En0;L`@r4Wo#k?}mJy)crkAWv}<6ySUV;BK?e6L~V7jXOL4c zux7{z?9!|DzM7WmP@Ang#4Dq^uThr6g-lFcR=w*4tjY-LBs9?pjWSDmD~Q?GQ!qXC zIXzS)r$}g?f{gb4w3LSE!^M~C>fV2*n)Za;wUvx7?3TR|X4%B$x^Jy{581sEJa4=1 z8TBpivuye*+l*suPf33ZvOuR@lcz*Y^1X$yfYdK?RClku-VK`Oaz7$bL)<*$o<$== z{ZVf$wF2xJw0Gu1-Op4+$BhICHRQ)kcfJ^D}KdeZ^SJ$feaAD%pIV}EOjmf#tS;~wq^R>AhMZ( zi&?$91CSMgE|`S~d#QX7;$p7g_2*r_PJ&&&dO&v3)RC3FRLR8RcQujq@d|nNfB2aS z3!+G~OdAwvH`vuf&8D$Kht|N=l_nqYIuBgNDNLl@pV?!h`>LjjThPhiDNnX*u%ynd zvn;k2NiA3*rY_si7%)Nv7xe@KD~3sh`GsNIkxT3Y@r@O~XfAv7|u=k#=rGvLwIyDQySz^rflo+qOS82f4HA0y}@ zqOaXH0=`1FlE0wgQljd(!Sd%Ust<40vNfuVdWJ?_8MiEi2m4MAyA1Xs+(A)Fc|y%q z^5wkV{nrat3DcE*pHuQ4;BImP!Kr-ZbD%mGN!ynl+s^JO!I;!TG-eUa-)`nIji39!`Z{#ze1Y6kdGJpIQbVo= zC7z@S-NX8aNE6X6N(ja=RJDj9be{_G|3O&hXsvK^L@-#}5|wB$7zjjyoVb!FI5N`B zAeU7{zf4FX@yo0TPWe0TLDME{)i?2wb)KATdb)VOPCB#ZichOWa`m zzBJmrf?ea3X*!HTA|%B3Hn;}u*X=g}J_TuHyf5wZ&GU2Ws$7(OOu&8roA)q$9-azE z;Biz?QhpSp9ABo?DmIpR)}yx8%^9A3907|*0U{qL4dCj<_7_y9%YZc2mJBN4!?lNl z4LU>Hg`GBKorsM$NZJC6EXD(O7hJyaiaT~S#!c8MBCE+WFcANR`%I+i#wb}#Aat;f zRs|VaS8xR%Sn$Kv(s zkB0~?5wU6iv)@Pb>))j24d|K;qV(% z6%S&BL0;mYKM|0s^28A&zW0o!r4I@)bAaRdPyaef@Iq8k4LW$QCcv4qwt$>+wy0q~ z+oW2fzqA~WZ%IsesxY;*l2GdPxwi(RRKsal$OcT)L`vHQu}fQ%PjhY^W;E#J=m7?@ zp-GixZM~FSz61rvT|DtW#~w`K7@x+piXwgNdWM~qZ9=7fxzb1E!p-uIoBqSXxzoFL zlE^g6*eoNu{VTx}IkFP{N!1F2T<-UiGj3Xz{!hdC`^do{QngxC*M)Wfo-W}Ihr-9x zcvAqWq7kEZ#U_^vqhp4?{V}v}}bAF*KHA)}S0?sS9hZO@qk!_G}ZzK5uRbV|YcnJcmJ5coxO7uoo zTNGLFP@c^f=U}H?o={9;h4-1IT&DYN8!PJ6fAwv6XdY2=y4 zNb~`}pZ|mBF8-XA@%vsL-xe5DK>%Wgcmk&g$HIcXzp6XY4aQ!l{g0fqnHSZ=bgdGN zaV;n#^WCAN2Z=|pVuqNt6JNMZf!dpNhHQEf(8}UwEsbH*31Y-fYp3vYN~phtB&^L1 zl@HctaQ>^SA?9PROv?e;((+-1uvrmKY>*0epU6Gfi>!eDe&*Ryq)VIr*nt@sAsY{- z8}hFW3Z}4MXr7Sdo(8af;}#N^~ovwegPgeN}#)WrV={u$~W0cpdmIz0TF zS8MhI%EeizbrhZg9Ao&JJ|-L?{^v9Qey8Hgm!1^DKrJ82dPZ;AqU>sk~WIvDG~&l z8SPNx5wxlX>{khZ`Ry8p(9~jcSxPX#dSVmgqfti7O*h&KnSql} zZKxw_QyH9cLtkcEy~|qK0boZL)fbUWNni&&mq4uYPj04MM&mx*&^8*?<~#^2Pw+z zDv%9aGcZ67G{@2apC_8iGd~8|Adxw-IKicORHoPzU}>MSWr)+yA0_V^b(vuT-U9o6 znSxv#Z{$O7|Bl?*Rq2IrKn4%!W7O?_f*|YEG`N_x!lY{*P%(=NzUF_Wia19t71btT zB3P>+n@sDoSccVr`fdB_=cg)ko35vWr}N%tnD-r|A;Ztl#apyat?Rd1pmynyFi1Dz z!ro);;r%4A-PZYH-ZUr&*MIvp|8(~D`4DzrW!R_i7Z#kf`&kU-;W?O;keZ*e(RJjp zDu&6FtQPGT^t=0g-^>b@<}jvXM24!d6%m^g5L2>(fH}Q;QJ)#tGN3$#7K?GEObSI? zGfyTTYa2u(d*Zh{qFkQ)Jv19d@l%LNi$V?l9J3L$$^j<$b>c+PRATDfMPfWaF z@fzplMU}g_2*mt7GEZnrEP`3y?##ou8-8GSI7mKk*UgdXoseRz>UK=^gYRK=FS`w# z>YKfFJtJLvbiy2_>HXe?|EFf+;Ej+_hx4Z>X{!v=$$*Cr!20R-Gj{AYR~mIYLh0l7 zlNOR0fU-MD3a3UG(9X2nwGrK_e4-A{>_K#b0;{ytsZ*>9+3=Dh(!{D|hT<5J;Fw&J zBms+_VLwN>Po{ac_QpUVdfk+i+VvnA_t>Zacp7uO6nktVpPY0I6Iz( z=*@7d3fO4r5>`oIoVV=??~cfN4Ij$?g*ayqg0-r^5TPi)oqYr!3#BagG2YR5Oq*1< zp7)6@ICw+CM?RVT0x`jm@XO038ZmHhU=|Z)?e?*W|Dq^PqDE&BPeK6unNE zk-)mfLy69slll?ozN#Ddd%E$J(u4ZZQJ6!ukM7nldv(azF-oem4gF>B181Y-2$w{2 zz}@MW5?;VVal(1?>9}ztI9DeJaQ2fEh6Q>!d?R5z6^=tk?k^? z!xIIQpOqYV-u#zc?FZxRA+k)57qv%`X3$81Cl7 z+?hShmn8;B;C5&*kP|v>=}yiFEX-uLc%0w&3zm-#Js(F_(%OSruODX?PPkGTXky%g zCq16Bj>xI=%K$W^GpfZm-2f>&6>4g6yWa0RadPCx(jrx=dgK{nIca$~24n7i@A&Gz zSMH6U=2hnp_)8N)-$aTg(_U_<C27=$4r1hPvlSTmP_ zwV1T8FPBTsjkbcFSH_hWI~&Y}2TOumkp-*{tN3)vSQE!1E4{*n3MgV4`V=ZMv1S~s zMDn$a&en8`R;hqR@1cxdMRGkmjn=2%6)ZRb=bT(O!aRBHP3WNd#EyKcNjwvzOsLJC zX2GtL$}_U3eEg7p_rfP{&ok{z77j(?SAR@-(DHRplhBC_G|jC~+iYh(76k*b{6(G0kqVtmraQ=bfZgi+FA{-@AN_q{IR^H>lf14 zJw7SFPG@mi#=L-Y8Jo7N44>1cegA=4zEMauz`ZH%{u>|E!sM!R>|u7((`8+U@0S3M z9tKJMt0f+^=66YLa1o^Hr^QE6ao>5J)Yk%`1ReDkDvS~v2!u*JwUE)jg2GX|>;ut3 zQdy?L@h?Flh>GMm{|JPJLzA!TuXZ;x_uN;$+YA> zRz)t!dup{*^kS_TNknt2q%gq`jYJTp2fcu}w|Fx)$f02Dr_IU>&?4sb;S}Th+l>bA zuq0}F5f9Q9R#~W=dPqOfS~{y@as(ei;nQgb=5&k7N<<*L*3@i-Jsnhp#_3yHC%o=;NZAGcsnNp2C1B9@M&d-YIBXNwboJS(x3s zBtP(v7Q6t0-LasY=yRigXQjQQ3>m7sqLhk|$)<)W^2L=fvky8KnsBU{ppx}!lS0iJDpa(3Q6Uu1`rqU#kw^xZGivWr&hW*LTTRce1UB zzxT;uUMsKaP$-#i;em^kmR+wVd%v$$2V7>ylBNgiVIZ69mZe^Q`nj^9EXm|WyYG2s zm)FyL>Q8^q^y|L$0EGh=V^{SQ$YmaY^bdz(%#LVdJ?P&(MvxfDTy&!PaY;-krh!%TRsF~S{%DFj)k0Rf!bVZ3$E`CvTTvSIy9HrK>Ind`-22#5!X6MuwA zG&XB#YJ%gOPl3SC5vzY)VC^(9!;7RtU10qs0D$_NuAv z8XwdM2(zteSwlZoIR!_c3I-xIKFE?eux+ zcq533e3-p4ISMxvLeXiA27KR5OswyUkw46?%q$JRl|y|Ml3l8DsJ^j+xJe|ra)>43 z4kFB&?MnNjSdWzPF=EP4(WqSO>1J5bomCjgP-0>*JfloYv1By)ka`$Q+{c0v>eyzW z;3)0{-{TsO2$ENev(yN{{{Ac0#syemsKTKB3CDj;oP2Oel|PJaO(AfhTxUUDWmpg} zc2P=VX>5^IqVcCP1|Ni(P+|A<)FlV7|GOVTom0_#UTY8h!1Fb`q7YlKRx^R2c2r1O z{R^cELy`*X!Coy>Re45tbdp1GS6gt`d>Y_hUy=SGr zS7`Cba*?rWI_BYPNi7wghT=)UXVz&zdtTF{S94uT>}@7lXI5XTJlB^p_bWM8QG2k0 zlZuaW6)}@>S4OVg0`FfX%I75z~n6$ zpfHsJ0`D@&Wm+LkGYWz_4e&)BUZ5_e^pFT9zBTf6<&)D=LY|KZfUCBC5uN@xljp%L zXC{I^FEk&#>yaKU329Y6cna6mYp1{*zasCCMw641QHHC&x1U#FmctHIv6B0%^wplHe~jofcdquJAz7i{kNo?Q|`B!~r<(8g#B+VKD*)Hh1tC}i%tGh} z)5KU~wF#VA$`ZKoViBgNpkBFiGk8lQhPUuw(f6ed;>lotB_ZEGv6?EW7tDWU=qCeU zX=1*?-&?sO7M$Ye!DpcPXP0~e!wneAldN;ocFzaM|*-$irF9*)rc@vi%eFID8 z0yB}m)lP~-J6R?N9c;E1i~(+|hKN~IlOcUM8?LUZ{I8WEx8}!#Vjd`^FJ=x56&w?= zP1>!HB`zl=c;PO(?6l%6K?6^(GNvrWmz*G!ct0<1sizF`NCbn|<%AboHsMgk37{7%{-Y(UA~%HT`$BO!)a)HXNQzpI*K%O^qstemeNH%g z=M`sru9}K?Y?^~aNHw`{UXSTOO#?ac>^kP!rU-&M!ZqGV;@MtY9+g&a{=cFnEjlQcY$c|42npWQWV~~l0c&^Uf^$}?{Ye9f#w*uw}6Kk7Hx9B zU#jdvW;+xBqe1l;fqRCavM{(6u;cPi8k<*dlpeoM%6du87dtKl+flO?Tz zDBc6@D-#g2EA3>s@;CS)De~Pe2Av*(v<(fLR~;X2`dIWbJ{T)};o>j7A@(?-oLd*Y zf_IkHvC!bm?qMN#5acHWa-beRKllIM`NNKfjr;SL5si<_ zS^oJOWpbEmUub7o3R`D}o@>hl2A(=O&Mo%2{%udnTn~RQrK?MH`*`&$>3bgau@~vv z4q55nSr_d=&1nIva~XY=`FBEiNg@Zq^dH+0`p{G3d&;)s%AyurWmXMBLQLJ0#sN(J zZ2B@lC3yIcAjN(_MCTx-)rW|o)dYg%ukg$=U8zb#>L|V%%ndU>hL_-sNrk3`l)>A$vLbAKNG9qGNEnzya>oWH7m?Gok_{tfc=0@D)-NrKDO zGT*vpN6xhi2RE1Bc8=p%HNY*PzHiN1@=*MA)1C8kbZPmwSQ58=_n_s`dAN6#<&fe5 zj=H?%Kvj%b3^#C=^d7owks3J%gKtXz>rM&X#Qz(7cThR)M|%#@@8`2uS0MDLlxw{i z!Lj=^H%Ys7&1*NJY~|(j-GFd(gy+8b@se@mr5-=J7J8uMloNs2eV5>K6HSWvt@!tS zFfA3Hk1c_P;b1-^P*ktD9LaLQltDYcGCEnJ#K?LPhP5RO6`c00G+`Pq8*Mmsy`*t6 z3V(vb`1RATjl*c_qph@!NZE1WY?_Y4jf+u=j%j!~J$ns~(DxmG z>#fuFytKt;@VR-!%(mS4@9oM~8HL#`W#qvu_vNj~Sa(t7WKa8Zv%czoZ(3Xg!L*c# zP^(C2){G2`fw{)r@mgq`O#>AAkU3$5&}{L}toFlv)_KJdl?u`32*$ zPtrcLBBWi19l!s<(IO_`_4RAkAdOJ}Ic9RM=PqvPuL0Yo@3!)*>n?j6N7ndmv_?C2 z&1OtiDEa-;uQzVcGCiHNqfhi{Uq48Bj6Cd=M+$nK9Y^Lax0qMT#87#^{fC((<&iQ6 zupuZ$qWf6Nkv!dr{4@#x%Gvaz1Vtd(V|@VRQP6jNZDr8V`-4c@Yt4XZlxMwNo}opZ zw$D^;I8C7K#!MSY?`|y1rajuoFup2ZCqn)0Fs-%&^B$x=ji6S*WF=On6dIRF#zEXq z0j`H+zZgz1B|a$cnN|i~rL+~Z;d>qHszIwwq{4fvop6yX-W|>Tm<4s2x3|>J+a7)@ zkZbVIS`(1n6CSe*Y7Q^`UxAga|6CA|;iIoQ?YeY~(_Q^1*VxqcR+q*TN71Hw&v>lp z)UReOuba@Ym(V>%9`uPG;U!P~#!%F5Qo1IjkN4+B_wBiQ#?7M4#-i_*?xy#2 z$O{FnFK}v$r+&c&YVh1u2DZ&0%nOM<);&k_uIDKnJQPpgo$T5uU-E3Ei8Y98p45VD z)r5E+S(RVx_gD#Gt)d5gDOUU(LKRz~wH-l?t-;#Qy6@lLy!Um`N(zi2EK}aAwT_{(|njt<) zuC_RD(#MWm{`>itjY55c7iJh~Nb<+WX8{>J_8Mc-HEKy}x(5G^u(-eM`&g>kYP{r=9W)R|qA{^I3s|ltDb>@2ko!Df%GlQQ>uQU^%^ZXK#sfb*R>>dR}+rs$_3m@ zqjpSnaIb}gv-yau`SL7>jQXx&NHlI&o?U;Yew={5#C~jj&`d;*fpQNxr9(Y{BSpiGf7m^J7CsWubvFqHd0h<%3@g2|n7x|y-CW;^5 z3$E4cu?Lj(_p=WDY4Wlc5uWQbbb;f#0}Ys}$f42>9BdBKaLkyTl^b|MrgQ!x0Cz3fijTP>1G>4h=# zs`b?gq?G+sroZ2ohu(-J1=L~zv#?&d-7ea? zuYV(j Date: Tue, 20 Oct 2026 10:23:11 -0400 Subject: [PATCH 2/2] Update litellm/litellm_core_utils/litellm_logging.py Co-authored-by: veria-ai[bot] <224490171+veria-ai[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/litellm_logging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 781e3c53091..b465283d853 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5508,6 +5508,7 @@ def build_custom_logger_compatible_class( _nops_base: Final = (os.getenv(NOPS_OTEL_ENDPOINT_ENV) or NOPS_DEFAULT_OTEL_ENDPOINT).rstrip("/") _nops_config: Final = OpenTelemetryConfig( exporter="otlp_http", + skip_set_global=True, endpoint=(_nops_base if _nops_base.endswith("/v1/traces") else f"{_nops_base}/v1/traces"), headers=f"Authorization=Bearer {_nops_key}", capture_message_content=(