From a5f4bee6f9f7746f41894b5ca1051a33d10f294a Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:21:30 +0530 Subject: [PATCH 01/11] feat: add opt-in TTL caching for get_prompt Co-authored-by: Cursor --- netra/__init__.py | 9 +++++ netra/cache.py | 39 ++++++++++++++++++++ netra/config.py | 5 +++ netra/prompts/api.py | 34 +++++++++++++++-- tests/test_cache.py | 67 ++++++++++++++++++++++++++++++++++ tests/test_prompts_cache.py | 73 +++++++++++++++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 netra/cache.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_prompts_cache.py diff --git a/netra/__init__.py b/netra/__init__.py index ec21ebf5..8f8c7130 100644 --- a/netra/__init__.py +++ b/netra/__init__.py @@ -76,6 +76,7 @@ def init( metrics_export_interval_ms: Optional[int] = None, export_auto_metrics: Optional[bool] = None, root_instruments: Optional[AbstractSet[NetraInstruments]] = None, + cache_ttl_seconds: Optional[int] = None, ) -> None: """ Thread-safe initialization of Netra. @@ -110,6 +111,8 @@ def init( span is blocked, its entire subtree is discarded. Pass a set containing ``NetraInstruments.ALL`` to allow all instrumentations to produce root spans (legacy behaviour). + cache_ttl_seconds: Default TTL in seconds for opt-in read caches + (default: 60, env: NETRA_CACHE_TTL_SECONDS) Returns: None @@ -134,6 +137,7 @@ def init( enable_metrics=enable_metrics, metrics_export_interval_ms=metrics_export_interval_ms, export_auto_metrics=export_auto_metrics, + cache_ttl_seconds=cache_ttl_seconds, ) # Configure logging based on debug mode @@ -297,6 +301,11 @@ def shutdown(cls) -> None: cls.simulation.close() except Exception: pass + if hasattr(cls, "prompts") and cls.prompts is not None: + try: + cls.prompts.clear_cache() + except Exception: + pass @classmethod def get_meter(cls, name: str = "netra", version: Optional[str] = None) -> otel_metrics.Meter: diff --git a/netra/cache.py b/netra/cache.py new file mode 100644 index 00000000..0b8bcd54 --- /dev/null +++ b/netra/cache.py @@ -0,0 +1,39 @@ +import threading +import time +from typing import Dict, Generic, Optional, Tuple, TypeVar + +T = TypeVar("T") + + +class TTLCache(Generic[T]): + """In-memory TTL cache for SDK read API responses.""" + + def __init__(self, default_ttl: int = 60) -> None: + self._default_ttl = default_ttl + self._store: Dict[str, Tuple[T, float]] = {} + self._lock = threading.Lock() + + def get(self, key: str) -> Optional[T]: + with self._lock: + entry = self._store.get(key) + if entry is None: + return None + value, expires_at = entry + if time.monotonic() > expires_at: + del self._store[key] + return None + return value + + def set(self, key: str, value: T, ttl: Optional[int] = None) -> None: + ttl_seconds = self._default_ttl if ttl is None else ttl + expires_at = time.monotonic() + ttl_seconds + with self._lock: + self._store[key] = (value, expires_at) + + def invalidate(self, key: str) -> None: + with self._lock: + self._store.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._store.clear() diff --git a/netra/config.py b/netra/config.py index fe233a9e..935ad0ce 100644 --- a/netra/config.py +++ b/netra/config.py @@ -35,6 +35,7 @@ def __init__( enable_metrics: Optional[bool] = None, metrics_export_interval_ms: Optional[int] = None, export_auto_metrics: Optional[bool] = None, + cache_ttl_seconds: Optional[int] = None, ): """ Initialize the configuration. @@ -52,6 +53,7 @@ def __init__( enable_metrics: Whether to enable custom metrics export via OTLP (default: False) metrics_export_interval_ms: How often to push metrics to the collector in ms (default: 60000) export_auto_metrics: Whether to export OTel auto-instrumented system metrics (default: False) + cache_ttl_seconds: Default TTL in seconds for opt-in SDK read caches (default: 60, env: NETRA_CACHE_TTL_SECONDS) """ self.app_name = self._get_app_name(app_name) self.otlp_endpoint = self._get_otlp_endpoint() @@ -77,6 +79,9 @@ def __init__( self.metrics_export_interval_ms = self._get_int_config( metrics_export_interval_ms, "NETRA_METRICS_EXPORT_INTERVAL", default=60000 ) + self.cache_ttl_seconds = self._get_int_config( + cache_ttl_seconds, "NETRA_CACHE_TTL_SECONDS", default=60 + ) self._set_trace_content_env() diff --git a/netra/prompts/api.py b/netra/prompts/api.py index c85ed714..b173b96b 100644 --- a/netra/prompts/api.py +++ b/netra/prompts/api.py @@ -1,6 +1,7 @@ import logging -from typing import Any +from typing import Any, Optional +from netra.cache import TTLCache from netra.config import Config from netra.prompts.client import PromptsHttpClient @@ -21,20 +22,45 @@ def __init__(self, cfg: Config) -> None: """ self._config = cfg self._client = PromptsHttpClient(cfg) + self._cache: TTLCache[Any] = TTLCache(default_ttl=cfg.cache_ttl_seconds) - def get_prompt(self, name: str, label: str = "production") -> Any: + def clear_cache(self) -> None: + """Clear all cached prompt entries.""" + self._cache.clear() + + def get_prompt( + self, + name: str, + label: str = "production", + use_cache: bool = False, + cache_ttl: Optional[int] = None, + ) -> Any: """ Fetch a prompt version by name and label. Args: name: Name of the prompt label: Label of the prompt version (default: "production") + use_cache: When True, read/write the in-memory cache (default: False) + cache_ttl: Per-call cache TTL in seconds (default: init cache_ttl_seconds) Returns: - Prompt version data or empty dict if not found + Prompt version data or None/empty dict if not found """ if not name: logger.error("netra.prompts: name is required to fetch a prompt") return None - return self._client.get_prompt_version(prompt_name=name, label=label) + cache_key = f"prompt:{name}:{label}" + + if use_cache: + cached = self._cache.get(cache_key) + if cached is not None: + return cached + + result = self._client.get_prompt_version(prompt_name=name, label=label) + + if use_cache and result is not None and result != {}: + self._cache.set(cache_key, result, cache_ttl) + + return result diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 00000000..68135045 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,67 @@ +from unittest.mock import patch + +import pytest + +from netra.cache import TTLCache + + +class TestTTLCache: + def test_get_returns_none_for_missing_key(self) -> None: + cache = TTLCache[str]() + assert cache.get("missing") is None + + def test_set_and_get_returns_stored_value_before_ttl_expires(self) -> None: + cache = TTLCache[str](default_ttl=60) + cache.set("key", "value") + assert cache.get("key") == "value" + + def test_get_returns_none_after_ttl_expires(self) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 1.1]): + cache = TTLCache[str](default_ttl=1) + cache.set("key", "value") + assert cache.get("key") is None + + def test_per_entry_ttl_override_expires_independently_of_default(self) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 0.0, 1.1, 1.1]): + cache = TTLCache[str](default_ttl=60) + cache.set("short", "a", ttl=1) + cache.set("long", "b", ttl=60) + assert cache.get("short") is None + assert cache.get("long") == "b" + + def test_clear_removes_all_entries(self) -> None: + cache = TTLCache[str]() + cache.set("a", "1") + cache.set("b", "2") + cache.clear() + assert cache.get("a") is None + assert cache.get("b") is None + + def test_invalidate_removes_single_entry(self) -> None: + cache = TTLCache[str]() + cache.set("a", "1") + cache.set("b", "2") + cache.invalidate("a") + assert cache.get("a") is None + assert cache.get("b") == "2" + + def test_thread_safe_concurrent_access(self) -> None: + cache = TTLCache[int](default_ttl=60) + errors: list[Exception] = [] + + def worker(i: int) -> None: + try: + cache.set(f"key-{i}", i) + assert cache.get(f"key-{i}") == i + except Exception as exc: + errors.append(exc) + + import threading + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not errors diff --git a/tests/test_prompts_cache.py b/tests/test_prompts_cache.py new file mode 100644 index 00000000..2197c2d4 --- /dev/null +++ b/tests/test_prompts_cache.py @@ -0,0 +1,73 @@ +from unittest.mock import MagicMock + +import pytest + +from netra.config import Config +from netra.prompts.api import Prompts + + +@pytest.fixture +def prompts() -> Prompts: + cfg = Config(cache_ttl_seconds=60) + client = MagicMock() + instance = Prompts(cfg) + instance._client = client + return instance + + +class TestPromptsGetPromptCaching: + def test_use_cache_omitted_calls_http_every_time(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt") + prompts.get_prompt("my-prompt") + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_use_cache_true_second_call_skips_http(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + first = prompts.get_prompt("my-prompt", use_cache=True) + second = prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 1 + assert first == {"template": "v1"} + assert second == {"template": "v1"} + + def test_use_cache_true_different_labels_use_separate_entries(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.side_effect = [ + {"template": "prod"}, + {"template": "staging"}, + ] + + prod = prompts.get_prompt("my-prompt", label="production", use_cache=True) + staging = prompts.get_prompt("my-prompt", label="staging", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + assert prod == {"template": "prod"} + assert staging == {"template": "staging"} + + def test_api_failure_does_not_store_in_cache(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {} + + prompts.get_prompt("my-prompt", use_cache=True) + prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_use_cache_false_with_cache_ttl_ignores_cache(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=False, cache_ttl=30) + prompts.get_prompt("my-prompt", use_cache=False, cache_ttl=30) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_clear_cache_forces_next_call_to_hit_http(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=True) + prompts.clear_cache() + prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 From e3230f8cdee5bf8901c981e5abe0ebd37d815aba Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:39:48 +0530 Subject: [PATCH 02/11] update doc --- CHANGELOG.md | 5 +++++ README.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3092ad6..85079f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning. +## [0.1.96] - 2026-07-09 + +- **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Configure the default TTL via `cache_ttl_seconds` in `Netra.init()` or the `NETRA_CACHE_TTL_SECONDS` environment variable. Use `Netra.prompts.clear_cache()` to invalidate cached entries. + ## [0.1.95] - 2026-06-26 - **Added get_all_datasets with tag as optional param** - If tag is provided, we get details of all the datasets with that particular tag attached. @@ -309,4 +313,5 @@ Users can be now overwrite the input and ouput attributes of spans created by in - Added utility to set input and output data for any active span in a trace +[0.1.96]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main [0.1.95]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main diff --git a/README.md b/README.md index 08395a0a..fddd8e2f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - πŸ“ˆ **Session Management**: Track user sessions and custom attributes - 🌐 **HTTP Client Instrumentation**: Automatic tracing for aiohttp and httpx - πŸ’Ύ **Vector Database Support**: Weaviate, Qdrant, and other vector DB instrumentation +- πŸ“‹ **Prompt Management**: Fetch managed prompts from Netra with optional in-memory TTL caching ## πŸ“¦ Installation @@ -49,6 +50,7 @@ Netra.init( trace_content=True, environment="Your Application environment", instruments={InstrumentSet.OPENAI, InstrumentSet.ANTHROPIC}, + cache_ttl_seconds=60, # default TTL for opt-in prompt caching (env: NETRA_CACHE_TTL_SECONDS) ) ``` @@ -319,6 +321,40 @@ Action tracking follows this schema: ] ``` +## πŸ“‹ Prompt Management + +Fetch managed prompt versions from Netra via `Netra.prompts`. Caching is opt-in and disabled by default. + +```python +from netra import Netra +from netra.instrumentation.instruments import InstrumentSet + +Netra.init( + app_name="My App", + instruments={InstrumentSet.OPENAI}, + cache_ttl_seconds=60, # default TTL for cached prompt reads +) + +# Fetch a prompt (calls the API on every request by default) +prompt = Netra.prompts.get_prompt("my-prompt", label="production") + +# Opt in to in-memory caching to reduce API calls +prompt = Netra.prompts.get_prompt("my-prompt", label="production", use_cache=True) + +# Override TTL for a single call (seconds) +prompt = Netra.prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=300) + +# Clear cached entries after updating a prompt +Netra.prompts.clear_cache() +``` + +Caching notes: + +- `use_cache` defaults to `False`; enable it per call when you want caching. +- Cache keys are scoped by prompt `name` and `label`. +- Empty or failed responses are not stored in the cache. +- The prompt cache is cleared automatically when `Netra.shutdown()` is called. + ## πŸ”§ Advanced Configuration ### Environment Variables @@ -337,6 +373,7 @@ Netra SDK can be configured using the following environment variables: | `NETRA_TRACE_CONTENT` | Whether to capture prompt/completion content (`true`/`false`) | `true` | | `NETRA_ENV` | Deployment environment (e.g., `prod`, `staging`, `dev`) | `local` | | `NETRA_RESOURCE_ATTRS` | JSON string of custom resource attributes | `{}` | +| `NETRA_CACHE_TTL_SECONDS` | Default TTL in seconds for opt-in SDK read caches (e.g. `get_prompt`) | `60` | #### Standard OpenTelemetry Variables From 6b3cc1881ec826eafe795de610bffc041a23ec59 Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:19:21 +0530 Subject: [PATCH 03/11] remove 0.1.95 changelog --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85079f2f..4ec531d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,6 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Configure the default TTL via `cache_ttl_seconds` in `Netra.init()` or the `NETRA_CACHE_TTL_SECONDS` environment variable. Use `Netra.prompts.clear_cache()` to invalidate cached entries. -## [0.1.95] - 2026-06-26 - -- **Added get_all_datasets with tag as optional param** - If tag is provided, we get details of all the datasets with that particular tag attached. - ## [0.1.94] - 2026-06-22 - **Introduce synthetic usage spans to fix cost calculation in the Claude Agent SDK** β€” Create separate spans for each model's usage to provide more accurate cost reporting. If separate spans cannot be created, usage is recorded on the main span as a fallback. @@ -314,4 +310,3 @@ Users can be now overwrite the input and ouput attributes of spans created by in - Added utility to set input and output data for any active span in a trace [0.1.96]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main -[0.1.95]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main From 596061fca1bdbf87b78c8d57195a9c317962e772 Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:11:01 +0530 Subject: [PATCH 04/11] fix: address PR review feedback for get_prompt caching Restore the 0.1.95 changelog entry, bump to 0.1.96, skip cache writes for non-positive TTL, and add shutdown and edge-case tests. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++ netra/cache.py | 2 ++ netra/version.py | 2 +- pyproject.toml | 2 +- tests/test_cache.py | 7 ++++ tests/test_prompts_cache.py | 70 ++++++++++++++++++++++++++++++++++++- 6 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec531d1..85079f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Configure the default TTL via `cache_ttl_seconds` in `Netra.init()` or the `NETRA_CACHE_TTL_SECONDS` environment variable. Use `Netra.prompts.clear_cache()` to invalidate cached entries. +## [0.1.95] - 2026-06-26 + +- **Added get_all_datasets with tag as optional param** - If tag is provided, we get details of all the datasets with that particular tag attached. + ## [0.1.94] - 2026-06-22 - **Introduce synthetic usage spans to fix cost calculation in the Claude Agent SDK** β€” Create separate spans for each model's usage to provide more accurate cost reporting. If separate spans cannot be created, usage is recorded on the main span as a fallback. @@ -310,3 +314,4 @@ Users can be now overwrite the input and ouput attributes of spans created by in - Added utility to set input and output data for any active span in a trace [0.1.96]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main +[0.1.95]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main diff --git a/netra/cache.py b/netra/cache.py index 0b8bcd54..fdd79c5a 100644 --- a/netra/cache.py +++ b/netra/cache.py @@ -26,6 +26,8 @@ def get(self, key: str) -> Optional[T]: def set(self, key: str, value: T, ttl: Optional[int] = None) -> None: ttl_seconds = self._default_ttl if ttl is None else ttl + if ttl_seconds <= 0: + return expires_at = time.monotonic() + ttl_seconds with self._lock: self._store[key] = (value, expires_at) diff --git a/netra/version.py b/netra/version.py index a7059c5b..8569c099 100644 --- a/netra/version.py +++ b/netra/version.py @@ -1 +1 @@ -__version__ = "0.1.95" +__version__ = "0.1.96" diff --git a/pyproject.toml b/pyproject.toml index e0da22e0..0643b89e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "netra-sdk" -version = "0.1.95" +version = "0.1.96" description = "A Python SDK for AI application observability that provides OpenTelemetry-based monitoring, tracing, and PII protection for LLM and vector database applications. Enables easy instrumentation, session tracking, and privacy-focused data collection for AI systems in production environments." authors = [ {name = "Sooraj Thomas",email = "sooraj@keyvalue.systems"} diff --git a/tests/test_cache.py b/tests/test_cache.py index 68135045..6827dcca 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -45,6 +45,13 @@ def test_invalidate_removes_single_entry(self) -> None: assert cache.get("a") is None assert cache.get("b") == "2" + def test_set_with_zero_or_negative_ttl_does_not_store(self) -> None: + cache = TTLCache[str](default_ttl=60) + cache.set("zero", "a", ttl=0) + cache.set("negative", "b", ttl=-1) + assert cache.get("zero") is None + assert cache.get("negative") is None + def test_thread_safe_concurrent_access(self) -> None: cache = TTLCache[int](default_ttl=60) errors: list[Exception] = [] diff --git a/tests/test_prompts_cache.py b/tests/test_prompts_cache.py index 2197c2d4..35d39785 100644 --- a/tests/test_prompts_cache.py +++ b/tests/test_prompts_cache.py @@ -1,7 +1,8 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest +from netra import Netra from netra.config import Config from netra.prompts.api import Prompts @@ -55,6 +56,35 @@ def test_api_failure_does_not_store_in_cache(self, prompts: Prompts) -> None: assert prompts._client.get_prompt_version.call_count == 2 + def test_api_none_response_does_not_store_in_cache(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = None + + prompts.get_prompt("my-prompt", use_cache=True) + prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_per_call_cache_ttl_expires_before_default(self, prompts: Prompts) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 0.0, 1.1, 1.1]): + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=1) + assert prompts._client.get_prompt_version.call_count == 1 + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=1) + assert prompts._client.get_prompt_version.call_count == 1 + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=1) + assert prompts._client.get_prompt_version.call_count == 2 + + def test_zero_cache_ttl_skips_cache_write(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=0) + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=0) + + assert prompts._client.get_prompt_version.call_count == 2 + def test_use_cache_false_with_cache_ttl_ignores_cache(self, prompts: Prompts) -> None: prompts._client.get_prompt_version.return_value = {"template": "v1"} @@ -71,3 +101,41 @@ def test_clear_cache_forces_next_call_to_hit_http(self, prompts: Prompts) -> Non prompts.get_prompt("my-prompt", use_cache=True) assert prompts._client.get_prompt_version.call_count == 2 + + +class TestPromptsCacheShutdown: + def setup_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + def teardown_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + @patch("netra.init_instrumentations") + @patch("netra.Tracer") + @patch("netra.Config") + def test_shutdown_clears_prompt_cache( + self, + mock_config: MagicMock, + mock_tracer: MagicMock, + mock_init_instrumentations: MagicMock, + ) -> None: + mock_cfg = MagicMock() + mock_cfg.cache_ttl_seconds = 60 + mock_config.return_value = mock_cfg + + Netra.init() + + mock_client = MagicMock() + mock_client.get_prompt_version.return_value = {"template": "v1"} + Netra.prompts._client = mock_client + + Netra.prompts.get_prompt("my-prompt", use_cache=True) + Netra.prompts.get_prompt("my-prompt", use_cache=True) + assert mock_client.get_prompt_version.call_count == 1 + + Netra.shutdown() + + Netra.prompts.get_prompt("my-prompt", use_cache=True) + assert mock_client.get_prompt_version.call_count == 2 From 42fa59dee44ca8ad62d45f4697cd79c8be62cfae Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:11:56 +0530 Subject: [PATCH 05/11] fix --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85079f2f..f39b0a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -314,4 +314,3 @@ Users can be now overwrite the input and ouput attributes of spans created by in - Added utility to set input and output data for any active span in a trace [0.1.96]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main -[0.1.95]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main From 695ee6abb56ce142d8aef31d5dca10e23c9d6413 Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:38:51 +0530 Subject: [PATCH 06/11] fix: align instrumentation tests with current APIs and close LiteLLM/FastAPI gaps Co-authored-by: Cursor --- netra/instrumentation/fastapi/utils.py | 1 + netra/instrumentation/litellm/__init__.py | 6 + tests/test_fastapi_instrumentation.py | 12 +- tests/test_google_genai_instrumentation.py | 126 ++------------ tests/test_input_scanner.py | 18 +- tests/test_litellm_instrumentation.py | 193 ++++++++------------- tests/test_netra_init.py | 19 +- tests/test_openai_instrumentation.py | 85 ++------- tests/test_span_wrapper.py | 6 +- tests/test_tracer.py | 22 +++ 10 files changed, 166 insertions(+), 322 deletions(-) diff --git a/netra/instrumentation/fastapi/utils.py b/netra/instrumentation/fastapi/utils.py index ae82c6ba..31060fac 100644 --- a/netra/instrumentation/fastapi/utils.py +++ b/netra/instrumentation/fastapi/utils.py @@ -134,6 +134,7 @@ def build_request_url(scope: Dict[str, Any]) -> str: url = f"{scheme}://{host}{path}" else: url = f"{scheme}://{host}:{port}{path}" + else: url = path if query_string: diff --git a/netra/instrumentation/litellm/__init__.py b/netra/instrumentation/litellm/__init__.py index 02a9654f..0d9ac643 100644 --- a/netra/instrumentation/litellm/__init__.py +++ b/netra/instrumentation/litellm/__init__.py @@ -115,6 +115,12 @@ def _uninstrument(self, **kwargs): # type: ignore[no-untyped-def] except (AttributeError, ModuleNotFoundError): logger.error("Failed to uninstrument LiteLLM completions") + try: + unwrap("litellm", "responses") + unwrap("litellm", "aresponses") + except (AttributeError, ModuleNotFoundError): + logger.error("Failed to uninstrument LiteLLM responses") + try: unwrap("litellm", "embedding") unwrap("litellm", "aembedding") diff --git a/tests/test_fastapi_instrumentation.py b/tests/test_fastapi_instrumentation.py index 18a6a711..4d947991 100644 --- a/tests/test_fastapi_instrumentation.py +++ b/tests/test_fastapi_instrumentation.py @@ -358,8 +358,8 @@ def test_get_default_span_details_with_route_and_method( span_name, attributes = get_default_span_details(scope) - assert span_name == "GET /test/path" - assert attributes["http.route"] == "/test/path" + assert span_name == "GET" + assert attributes == {} @patch("netra.instrumentation.fastapi.utils.get_route_details") @patch("netra.instrumentation.fastapi.utils.sanitize_method") @@ -386,8 +386,8 @@ def test_get_default_span_details_other_method( span_name, attributes = get_default_span_details(scope) - assert span_name == "HTTP /test/path" - assert attributes["http.route"] == "/test/path" + assert span_name == "HTTP" + assert attributes == {} class TestHeaderSanitization: @@ -759,7 +759,7 @@ async def mock_receive() -> dict: mock_extract.return_value = Mock() mock_ctx.attach.return_value = Mock() - asyncio.get_event_loop().run_until_complete(middleware(scope, mock_receive, mock_send)) + asyncio.run(middleware(scope, mock_receive, mock_send)) mock_extract.assert_called_once_with(carrier=scope, getter=_asgi_getter) @@ -804,7 +804,7 @@ def test_non_http_scope_skips_propagation(self) -> None: scope = {"type": "websocket", "headers": []} - asyncio.get_event_loop().run_until_complete(middleware(scope, AsyncMock(), AsyncMock())) + asyncio.run(middleware(scope, AsyncMock(), AsyncMock())) mock_app.assert_called_once() mock_tracer.start_as_current_span.assert_not_called() diff --git a/tests/test_google_genai_instrumentation.py b/tests/test_google_genai_instrumentation.py index 5921e954..ed2ed89b 100644 --- a/tests/test_google_genai_instrumentation.py +++ b/tests/test_google_genai_instrumentation.py @@ -1,53 +1,32 @@ """ -Unit tests for GoogleGenAiInstrumentor class. +Unit tests for NetraGoogleGenAiInstrumentor class. Minimal tests focusing on core functionality and happy path scenarios. """ from typing import Collection from unittest.mock import Mock, patch -from netra.instrumentation.google_genai import ( - GoogleGenAiInstrumentor, - is_async_streaming_response, - is_streaming_response, - should_send_prompts, -) +from netra.instrumentation.google_genai import NetraGoogleGenAiInstrumentor -class TestGoogleGenAiInstrumentor: - """Test GoogleGenAiInstrumentor core functionality.""" +class TestNetraGoogleGenAiInstrumentor: + """Test NetraGoogleGenAiInstrumentor core functionality.""" def test_initialization(self): - """Test GoogleGenAiInstrumentor initialization.""" - # Act - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + """Test NetraGoogleGenAiInstrumentor initialization.""" + instrumentor = NetraGoogleGenAiInstrumentor() - # Assert assert instrumentor is not None assert hasattr(instrumentor, "_instrument") assert hasattr(instrumentor, "_uninstrument") assert hasattr(instrumentor, "instrumentation_dependencies") - def test_initialization_with_exception_logger(self): - """Test GoogleGenAiInstrumentor initialization with custom exception logger.""" - # Arrange - mock_logger = Mock() - - # Act - instrumentor = GoogleGenAiInstrumentor(exception_logger=mock_logger) - - # Assert - assert instrumentor is not None - def test_instrumentation_dependencies(self): """Test instrumentation_dependencies returns correct packages.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() - # Act dependencies = instrumentor.instrumentation_dependencies() - # Assert assert isinstance(dependencies, Collection) assert "google-genai >= 0.1.0" in dependencies @@ -55,119 +34,36 @@ def test_instrumentation_dependencies(self): @patch("netra.instrumentation.google_genai.wrap_function_wrapper") def test_instrument_with_default_parameters(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with default parameters.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument() - # Assert mock_get_tracer.assert_called_once() - # Should wrap all methods defined in WRAPPED_METHODS (8 methods) assert mock_wrap_function.call_count == 8 @patch("netra.instrumentation.google_genai.get_tracer") @patch("netra.instrumentation.google_genai.wrap_function_wrapper") def test_instrument_with_custom_tracer_provider(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with custom tracer provider.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() mock_tracer_provider = Mock() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument(tracer_provider=mock_tracer_provider) - # Assert mock_get_tracer.assert_called_once_with( - "netra.instrumentation.google_genai", mock_get_tracer.call_args[0][1], mock_tracer_provider # version + "netra.instrumentation.google_genai", mock_get_tracer.call_args[0][1], mock_tracer_provider ) assert mock_wrap_function.call_count == 8 @patch("netra.instrumentation.google_genai.unwrap") def test_uninstrument(self, mock_unwrap): """Test _uninstrument method unwraps all wrapped methods.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() - # Act instrumentor._uninstrument() - # Assert - # Should unwrap all methods defined in WRAPPED_METHODS (8 methods) assert mock_unwrap.call_count == 8 - - -class TestUtilityFunctions: - """Test utility functions in the google_genai instrumentation module.""" - - @patch.dict("os.environ", {"TRACELOOP_TRACE_CONTENT": "true"}) - def test_should_send_prompts_true_from_env(self): - """Test should_send_prompts returns True when environment variable is set.""" - # Act - result = should_send_prompts() - - # Assert - assert result is True - - def test_should_send_prompts_default(self): - """Test should_send_prompts returns default value when no environment variable is set.""" - # Act - result = should_send_prompts() - - # Assert - assert result is True # Default behavior when no env var is set - - def test_is_streaming_response_with_generator(self): - """Test is_streaming_response returns True for generator objects.""" - - # Arrange - def sample_generator(): - yield 1 - yield 2 - - generator = sample_generator() - - # Act - result = is_streaming_response(generator) - - # Assert - assert result is True - - def test_is_streaming_response_with_non_generator(self): - """Test is_streaming_response returns False for non-generator objects.""" - # Act - result = is_streaming_response("not a generator") - - # Assert - assert result is False - - def test_is_async_streaming_response_with_async_generator(self): - """Test is_async_streaming_response returns True for async generator objects.""" - - # Arrange - async def sample_async_generator(): - yield 1 - yield 2 - - async_generator = sample_async_generator() - - # Act - result = is_async_streaming_response(async_generator) - - # Assert - assert result is True - - # Cleanup - async_generator.aclose() - - def test_is_async_streaming_response_with_non_async_generator(self): - """Test is_async_streaming_response returns False for non-async generator objects.""" - # Act - result = is_async_streaming_response("not an async generator") - - # Assert - assert result is False diff --git a/tests/test_input_scanner.py b/tests/test_input_scanner.py index 9716706e..5568917f 100644 --- a/tests/test_input_scanner.py +++ b/tests/test_input_scanner.py @@ -172,16 +172,18 @@ def test_get_scanner_with_invalid_threshold_type(self) -> None: def test_get_scanner_with_llm_guard_available(self) -> None: """Test _get_scanner when llm_guard is available.""" - with patch("netra.scanner.PromptInjection") as mock_prompt_injection: - mock_scanner = Mock(spec=Scanner) - mock_prompt_injection.return_value = mock_scanner + mock_match_module = Mock() + mock_match_module.MatchType = Mock(FULL="full") + with patch.dict("sys.modules", {"llm_guard.input_scanners.prompt_injection": mock_match_module}): + with patch("netra.scanner.PromptInjection") as mock_prompt_injection: + mock_scanner = Mock(spec=Scanner) + mock_prompt_injection.return_value = mock_scanner - result = InputScanner._get_scanner(ScannerType.PROMPT_INJECTION, match_type="custom") + result = InputScanner._get_scanner(ScannerType.PROMPT_INJECTION, match_type="custom") - assert result == mock_scanner - # Check that custom match_type was passed - call_args = mock_prompt_injection.call_args - assert call_args.kwargs["match_type"] == "custom" + assert result == mock_scanner + call_args = mock_prompt_injection.call_args + assert call_args.kwargs["match_type"] == "custom" def test_get_scanner_with_llm_guard_unavailable(self) -> None: """Test _get_scanner when llm_guard is not available.""" diff --git a/tests/test_litellm_instrumentation.py b/tests/test_litellm_instrumentation.py index 45b59813..5893830f 100644 --- a/tests/test_litellm_instrumentation.py +++ b/tests/test_litellm_instrumentation.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from opentelemetry.semconv_ai import SpanAttributes from netra.instrumentation.litellm import LiteLLMInstrumentor, should_suppress_instrumentation from netra.instrumentation.litellm.wrappers import ( @@ -38,40 +39,27 @@ def test_instrumentation_dependencies(self): assert isinstance(dependencies, Collection) assert "litellm >= 1.0.0" in dependencies + @patch("netra.instrumentation.litellm.wrap_function_wrapper") @patch("netra.instrumentation.litellm.get_tracer") @patch("netra.instrumentation.litellm.logger") - def test_instrument_with_default_parameters(self, mock_logger, mock_get_tracer): + def test_instrument_with_default_parameters(self, mock_logger, mock_get_tracer, mock_wrap): """Test _instrument method with default parameters.""" # Arrange instrumentor = LiteLLMInstrumentor() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Mock litellm module - mock_litellm = Mock() - mock_litellm.completion = Mock() - mock_litellm.acompletion = AsyncMock() - mock_litellm.embedding = Mock() - mock_litellm.aembedding = AsyncMock() - mock_litellm.image_generation = Mock() - mock_litellm.aimage_generation = AsyncMock() - - with patch.dict("sys.modules", {"litellm": mock_litellm}): - # Act - instrumentor._instrument() + # Act + instrumentor._instrument() - # Assert - mock_get_tracer.assert_called_once() - # Verify original functions are stored - assert hasattr(instrumentor, "_original_completion") - assert hasattr(instrumentor, "_original_acompletion") - assert hasattr(instrumentor, "_original_embedding") - assert hasattr(instrumentor, "_original_aembedding") - assert hasattr(instrumentor, "_original_image_generation") - assert hasattr(instrumentor, "_original_aimage_generation") + # Assert + mock_get_tracer.assert_called_once() + # completion, acompletion, responses, aresponses, embedding, aembedding, image_generation, aimage_generation + assert mock_wrap.call_count == 8 + @patch("netra.instrumentation.litellm.wrap_function_wrapper") @patch("netra.instrumentation.litellm.get_tracer") - def test_instrument_with_custom_tracer_provider(self, mock_get_tracer): + def test_instrument_with_custom_tracer_provider(self, mock_get_tracer, mock_wrap): """Test _instrument method with custom tracer provider.""" # Arrange instrumentor = LiteLLMInstrumentor() @@ -79,85 +67,60 @@ def test_instrument_with_custom_tracer_provider(self, mock_get_tracer): mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Mock litellm module - mock_litellm = Mock() - mock_litellm.completion = Mock() - mock_litellm.acompletion = AsyncMock() - mock_litellm.embedding = Mock() - mock_litellm.aembedding = AsyncMock() - mock_litellm.image_generation = Mock() - mock_litellm.aimage_generation = AsyncMock() - - with patch.dict("sys.modules", {"litellm": mock_litellm}): - # Act - instrumentor._instrument(tracer_provider=mock_tracer_provider) + # Act + instrumentor._instrument(tracer_provider=mock_tracer_provider) - # Assert - mock_get_tracer.assert_called_once_with( - "netra.instrumentation.litellm", mock_get_tracer.call_args[0][1], mock_tracer_provider - ) + # Assert + mock_get_tracer.assert_called_once_with( + "netra.instrumentation.litellm", mock_get_tracer.call_args[0][1], mock_tracer_provider + ) + assert mock_wrap.call_count == 8 + @patch("netra.instrumentation.litellm.wrap_function_wrapper", side_effect=ImportError("No module named 'litellm'")) @patch("netra.instrumentation.litellm.logger") - def test_instrument_with_import_error(self, mock_logger): + def test_instrument_with_import_error(self, mock_logger, mock_wrap): """Test _instrument method handles import error gracefully.""" # Arrange instrumentor = LiteLLMInstrumentor() - with patch("netra.instrumentation.litellm.get_tracer"), patch.dict("sys.modules", {"litellm": None}): - with patch("builtins.__import__", side_effect=ImportError("No module named 'litellm'")): - # Act - instrumentor._instrument() + with patch("netra.instrumentation.litellm.get_tracer"): + # Act + instrumentor._instrument() - # Assert - mock_logger.error.assert_called_once() + # Assert + assert mock_logger.error.called - def test_uninstrument(self): - """Test _uninstrument method restores original functions.""" + @patch("netra.instrumentation.litellm.unwrap") + def test_uninstrument(self, mock_unwrap): + """Test _uninstrument method unwraps LiteLLM functions.""" # Arrange instrumentor = LiteLLMInstrumentor() - mock_litellm = Mock() - original_completion = Mock() - original_acompletion = AsyncMock() - original_embedding = Mock() - original_aembedding = AsyncMock() - original_image_generation = Mock() - original_aimage_generation = AsyncMock() - - # Set up original functions - instrumentor._original_completion = original_completion - instrumentor._original_acompletion = original_acompletion - instrumentor._original_embedding = original_embedding - instrumentor._original_aembedding = original_aembedding - instrumentor._original_image_generation = original_image_generation - instrumentor._original_aimage_generation = original_aimage_generation - - with patch.dict("sys.modules", {"litellm": mock_litellm}): - # Act - instrumentor._uninstrument() - # Assert - assert mock_litellm.completion == original_completion - assert mock_litellm.acompletion == original_acompletion - assert mock_litellm.embedding == original_embedding - assert mock_litellm.aembedding == original_aembedding - assert mock_litellm.image_generation == original_image_generation - assert mock_litellm.aimage_generation == original_aimage_generation - - def test_uninstrument_with_import_error(self): + # Act + instrumentor._uninstrument() + + # Assert β€” same eight methods that _instrument wraps + assert mock_unwrap.call_count == 8 + + @patch("netra.instrumentation.litellm.unwrap", side_effect=ModuleNotFoundError("litellm")) + @patch("netra.instrumentation.litellm.logger") + def test_uninstrument_with_import_error(self, mock_logger, mock_unwrap): """Test _uninstrument method handles import error gracefully.""" # Arrange instrumentor = LiteLLMInstrumentor() - with patch.dict("sys.modules", {"litellm": None}): - with patch("builtins.__import__", side_effect=ImportError("No module named 'litellm'")): - # Act & Assert - should not raise exception - instrumentor._uninstrument() + # Act + instrumentor._uninstrument() + + # Assert + assert mock_logger.error.called class TestWrappers: """Test wrapper functionality in the LiteLLM instrumentation module.""" - def test_completion_wrapper_non_streaming(self): + @patch("netra.instrumentation.litellm.wrappers.record_span_timing") + def test_completion_wrapper_non_streaming(self, mock_record_timing): """Test completion_wrapper for non-streaming requests.""" from netra.instrumentation.litellm.wrappers import completion_wrapper @@ -269,7 +232,8 @@ async def mock_wrapped(*args, **kwargs): # Verify wrapper creation doesn't call tracer methods yet mock_tracer.start_span.assert_not_called() - def test_embedding_wrapper(self): + @patch("netra.instrumentation.litellm.wrappers.record_span_timing") + def test_embedding_wrapper(self, mock_record_timing): """Test embedding_wrapper for embedding requests.""" from netra.instrumentation.litellm.wrappers import embedding_wrapper @@ -316,7 +280,8 @@ async def mock_wrapped(*args, **kwargs): assert callable(wrapper) mock_tracer.start_as_current_span.assert_not_called() # Should not be called until wrapper is invoked - def test_image_generation_wrapper(self): + @patch("netra.instrumentation.litellm.wrappers.record_span_timing") + def test_image_generation_wrapper(self, mock_record_timing): """Test image_generation_wrapper for image generation requests.""" from netra.instrumentation.litellm.wrappers import image_generation_wrapper @@ -414,7 +379,7 @@ def test_is_streaming_response_with_non_generator(self): assert is_streaming_response({"key": "value"}) is False assert is_streaming_response(b"bytes") is False - @patch("netra.instrumentation.litellm.context_api.get_value") + @patch("netra.instrumentation.litellm.utils.context_api.get_value") def test_should_suppress_instrumentation_true(self, mock_get_value): """Test should_suppress_instrumentation returns True when suppression is enabled.""" # Arrange @@ -426,7 +391,7 @@ def test_should_suppress_instrumentation_true(self, mock_get_value): # Assert assert result is True - @patch("netra.instrumentation.litellm.context_api.get_value") + @patch("netra.instrumentation.litellm.utils.context_api.get_value") def test_should_suppress_instrumentation_false(self, mock_get_value): """Test should_suppress_instrumentation returns False when suppression is disabled.""" # Arrange @@ -504,12 +469,13 @@ def test_set_request_attributes_chat(self): set_request_attributes(mock_span, kwargs, "chat") # Assert - mock_span.set_attribute.assert_any_call("llm.request.type", "chat") - mock_span.set_attribute.assert_any_call("gen_ai.system", "LiteLLM") - mock_span.set_attribute.assert_any_call("gen_ai.request.model", "gpt-4") - mock_span.set_attribute.assert_any_call("gen_ai.request.temperature", 0.7) - mock_span.set_attribute.assert_any_call("gen_ai.request.max_tokens", 100) - mock_span.set_attribute.assert_any_call("gen_ai.stream", False) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TYPE, "chat") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MODEL, "gpt-4") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TEMPERATURE, 0.7) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MAX_TOKENS, 100) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_IS_STREAMING, False) + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_PROMPTS}.0.content", "Hello") def test_set_request_attributes_embedding(self): """Test set_request_attributes for embedding.""" @@ -522,9 +488,8 @@ def test_set_request_attributes_embedding(self): set_request_attributes(mock_span, kwargs, "embedding") # Assert - mock_span.set_attribute.assert_any_call("llm.request.type", "embedding") - mock_span.set_attribute.assert_any_call("gen_ai.system", "LiteLLM") - mock_span.set_attribute.assert_any_call("gen_ai.request.model", "text-embedding-ada-002") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TYPE, "embedding") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MODEL, "text-embedding-ada-002") def test_set_request_attributes_image_generation(self): """Test set_request_attributes for image generation.""" @@ -537,11 +502,8 @@ def test_set_request_attributes_image_generation(self): set_request_attributes(mock_span, kwargs, "image_generation") # Assert - mock_span.set_attribute.assert_any_call("llm.request.type", "image_generation") - mock_span.set_attribute.assert_any_call("gen_ai.prompt", "A sunset") - mock_span.set_attribute.assert_any_call("gen_ai.request.n", 1) - mock_span.set_attribute.assert_any_call("gen_ai.request.size", "1024x1024") - mock_span.set_attribute.assert_any_call("gen_ai.request.quality", "hd") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TYPE, "image_generation") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MODEL, "dall-e-3") def test_set_response_attributes_chat(self): """Test set_response_attributes for chat completion.""" @@ -556,14 +518,15 @@ def test_set_response_attributes_chat(self): } # Act - set_response_attributes(mock_span, response_dict, "chat") + set_response_attributes(mock_span, response_dict) # Assert - mock_span.set_attribute.assert_any_call("gen_ai.response.model", "gpt-4") - mock_span.set_attribute.assert_any_call("gen_ai.response.id", "chatcmpl-123") - mock_span.set_attribute.assert_any_call("gen_ai.usage.prompt_tokens", 10) - mock_span.set_attribute.assert_any_call("gen_ai.usage.completion_tokens", 20) - mock_span.set_attribute.assert_any_call("llm.usage.total_tokens", 30) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_RESPONSE_MODEL, "gpt-4") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_PROMPT_TOKENS, 10) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, 20) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_TOTAL_TOKENS, 30) + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant") + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_COMPLETIONS}.0.content", "Hello!") def test_set_response_attributes_embedding(self): """Test set_response_attributes for embedding.""" @@ -577,12 +540,12 @@ def test_set_response_attributes_embedding(self): } # Act - set_response_attributes(mock_span, response_dict, "embedding") + set_response_attributes(mock_span, response_dict) # Assert - mock_span.set_attribute.assert_any_call("gen_ai.response.model", "text-embedding-ada-002") - mock_span.set_attribute.assert_any_call("gen_ai.response.embeddings.0.index", 0) - mock_span.set_attribute.assert_any_call("gen_ai.response.embeddings.0.dimensions", 3) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_RESPONSE_MODEL, "text-embedding-ada-002") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_PROMPT_TOKENS, 5) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_TOTAL_TOKENS, 5) def test_set_response_attributes_image_generation(self): """Test set_response_attributes for image generation.""" @@ -590,17 +553,15 @@ def test_set_response_attributes_image_generation(self): mock_span = Mock() mock_span.is_recording.return_value = True response_dict = { - "data": [{"url": "https://example.com/image.png", "revised_prompt": "A beautiful sunset over mountains"}] + "model": "dall-e-3", + "data": [{"url": "https://example.com/image.png", "revised_prompt": "A beautiful sunset over mountains"}], } # Act - set_response_attributes(mock_span, response_dict, "image_generation") + set_response_attributes(mock_span, response_dict) # Assert - mock_span.set_attribute.assert_any_call("gen_ai.response.images.0.url", "https://example.com/image.png") - mock_span.set_attribute.assert_any_call( - "gen_ai.response.images.0.revised_prompt", "A beautiful sunset over mountains" - ) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_RESPONSE_MODEL, "dall-e-3") def test_set_request_attributes_not_recording(self): """Test set_request_attributes when span is not recording.""" @@ -623,7 +584,7 @@ def test_set_response_attributes_not_recording(self): response_dict = {"model": "gpt-4"} # Act - set_response_attributes(mock_span, response_dict, "chat") + set_response_attributes(mock_span, response_dict) # Assert mock_span.set_attribute.assert_not_called() diff --git a/tests/test_netra_init.py b/tests/test_netra_init.py index daf2fc4d..93398deb 100644 --- a/tests/test_netra_init.py +++ b/tests/test_netra_init.py @@ -12,6 +12,7 @@ from netra import Netra from netra.config import Config +from netra.instrumentation.instruments import DEFAULT_INSTRUMENTS class TestNetraInitialization: @@ -54,12 +55,16 @@ def test_init_with_default_parameters( headers=None, disable_batch=None, trace_content=None, + debug_mode=None, + enable_root_span=None, resource_attributes=None, environment=None, - enable_root_span=None, enable_scrubbing=None, - debug_mode=None, blocked_spans=None, + enable_metrics=None, + metrics_export_interval_ms=None, + export_auto_metrics=None, + cache_ttl_seconds=None, ) # Verify Tracer was initialized @@ -69,7 +74,7 @@ def test_init_with_default_parameters( mock_init_instrumentations.assert_called_once_with( should_enrich_metrics=True, base64_image_uploader=None, - instruments=None, + instruments=DEFAULT_INSTRUMENTS, block_instruments=None, ) @@ -87,12 +92,16 @@ def test_init_with_custom_parameters( "headers": "key1=value1,key2=value2", "disable_batch": True, "trace_content": False, + "debug_mode": True, + "enable_root_span": False, "resource_attributes": {"env": "test", "version": "1.0.0"}, "environment": "testing", - "enable_root_span": False, "enable_scrubbing": None, - "debug_mode": True, "blocked_spans": None, + "enable_metrics": None, + "metrics_export_interval_ms": None, + "export_auto_metrics": None, + "cache_ttl_seconds": None, } app_name = "test-app" diff --git a/tests/test_openai_instrumentation.py b/tests/test_openai_instrumentation.py index 158f0835..9d38e5df 100644 --- a/tests/test_openai_instrumentation.py +++ b/tests/test_openai_instrumentation.py @@ -6,11 +6,8 @@ from typing import Collection from unittest.mock import MagicMock, Mock, patch -from netra.instrumentation.openai import ( - NetraOpenAIInstrumentor, - is_streaming_response, - should_suppress_instrumentation, -) +from netra.instrumentation.openai import NetraOpenAIInstrumentor +from netra.instrumentation.openai.utils import should_suppress_instrumentation class TestNetraOpenAIInstrumentor: @@ -18,10 +15,8 @@ class TestNetraOpenAIInstrumentor: def test_initialization(self): """Test NetraOpenAIInstrumentor initialization.""" - # Act instrumentor = NetraOpenAIInstrumentor() - # Assert assert instrumentor is not None assert hasattr(instrumentor, "_instrument") assert hasattr(instrumentor, "_uninstrument") @@ -29,13 +24,10 @@ def test_initialization(self): def test_instrumentation_dependencies(self): """Test instrumentation_dependencies returns correct packages.""" - # Arrange instrumentor = NetraOpenAIInstrumentor() - # Act dependencies = instrumentor.instrumentation_dependencies() - # Assert assert isinstance(dependencies, Collection) assert "openai >= 1.0.0" in dependencies @@ -43,63 +35,54 @@ def test_instrumentation_dependencies(self): @patch("netra.instrumentation.openai.wrap_function_wrapper") def test_instrument_with_default_parameters(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with default parameters.""" - # Arrange instrumentor = NetraOpenAIInstrumentor() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument() - # Assert mock_get_tracer.assert_called_once() - # Should wrap all methods (chat, completion, embeddings, responses) - assert mock_wrap_function.call_count >= 6 # At least 6 methods are wrapped + # chat x2, embeddings x2, responses x2 + assert mock_wrap_function.call_count == 6 @patch("netra.instrumentation.openai.get_tracer") @patch("netra.instrumentation.openai.wrap_function_wrapper") def test_instrument_with_custom_tracer_provider(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with custom tracer provider.""" - # Arrange instrumentor = NetraOpenAIInstrumentor() mock_tracer_provider = Mock() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument(tracer_provider=mock_tracer_provider) - # Assert mock_get_tracer.assert_called_once_with( - "netra.instrumentation.openai", mock_get_tracer.call_args[0][1], mock_tracer_provider # version + "netra.instrumentation.openai", mock_get_tracer.call_args[0][1], mock_tracer_provider ) - assert mock_wrap_function.call_count >= 6 + assert mock_wrap_function.call_count == 6 @patch("netra.instrumentation.openai.unwrap") def test_uninstrument(self, mock_unwrap): - """Test _uninstrument method unwraps all wrapped methods.""" - # Arrange + """Test _uninstrument method unwraps all OpenAI methods it targets.""" instrumentor = NetraOpenAIInstrumentor() - # Act instrumentor._uninstrument() - # Assert - # Should unwrap all methods (chat, completion, embeddings, responses) - assert mock_unwrap.call_count >= 6 + # chat x2, completions x2, embeddings x2, responses x2 + assert mock_unwrap.call_count == 8 class TestWrappers: """Test wrapper functionality in the OpenAI instrumentation module.""" - def test_chat_wrapper_non_streaming(self): - """Test chat_wrapper for non-streaming requests.""" + @patch("netra.instrumentation.openai.wrappers.record_span_timing") + def test_chat_wrapper_non_streaming(self, mock_record_timing): + """Test chat_wrapper for non-streaming requests starts a span and returns the wrapped result.""" from netra.instrumentation.openai.wrappers import chat_wrapper - # Arrange mock_tracer = Mock() mock_span_context = MagicMock() - mock_span_context.__enter__.return_value + mock_span_context.__enter__.return_value = Mock() mock_tracer.start_as_current_span.return_value = mock_span_context wrapped = Mock(return_value={"id": "test-id", "choices": [{"message": {"content": "test"}}]}) @@ -109,20 +92,17 @@ def test_chat_wrapper_non_streaming(self): wrapper = chat_wrapper(mock_tracer) - # Act result = wrapper(wrapped, instance, args, kwargs) - # Assert wrapped.assert_called_once_with(*args, **kwargs) mock_tracer.start_as_current_span.assert_called_once() assert result == wrapped.return_value @patch("netra.instrumentation.openai.wrappers.StreamingWrapper") def test_chat_wrapper_streaming(self, mock_streaming_wrapper_class): - """Test chat_wrapper for streaming requests.""" + """Test chat_wrapper for streaming requests wraps the response in StreamingWrapper.""" from netra.instrumentation.openai.wrappers import chat_wrapper - # Arrange mock_tracer = Mock() mock_span = Mock() mock_tracer.start_span.return_value = mock_span @@ -136,16 +116,13 @@ def generator(): args = () kwargs = {"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}], "stream": True} - # Mock the StreamingWrapper to return a simple object mock_wrapper_instance = Mock() mock_streaming_wrapper_class.return_value = mock_wrapper_instance wrapper = chat_wrapper(mock_tracer) - # Act result = wrapper(wrapped, instance, args, kwargs) - # Assert wrapped.assert_called_once_with(*args, **kwargs) mock_tracer.start_span.assert_called_once() mock_streaming_wrapper_class.assert_called_once() @@ -155,50 +132,20 @@ def generator(): class TestUtilityFunctions: """Test utility functions in the openai instrumentation module.""" - def test_is_streaming_response_with_generator(self): - """Test is_streaming_response returns True for generator objects.""" - - # Arrange - def sample_generator(): - yield 1 - yield 2 - - generator = sample_generator() - - # Act - result = is_streaming_response(generator) - - # Assert - assert result is True - - def test_is_streaming_response_with_non_generator(self): - """Test is_streaming_response returns False for non-generator objects.""" - # Act - result = is_streaming_response("not a generator") - - # Assert - assert result is False - - @patch("netra.instrumentation.openai.context_api.get_value") + @patch("netra.instrumentation.openai.utils.context_api.get_value") def test_should_suppress_instrumentation_true(self, mock_get_value): """Test should_suppress_instrumentation returns True when suppression is enabled.""" - # Arrange mock_get_value.return_value = True - # Act result = should_suppress_instrumentation() - # Assert assert result is True - @patch("netra.instrumentation.openai.context_api.get_value") + @patch("netra.instrumentation.openai.utils.context_api.get_value") def test_should_suppress_instrumentation_false(self, mock_get_value): """Test should_suppress_instrumentation returns False when suppression is disabled.""" - # Arrange mock_get_value.return_value = False - # Act result = should_suppress_instrumentation() - # Assert assert result is False diff --git a/tests/test_span_wrapper.py b/tests/test_span_wrapper.py index 7f8c896c..351e494d 100644 --- a/tests/test_span_wrapper.py +++ b/tests/test_span_wrapper.py @@ -122,7 +122,7 @@ def test_span_wrapper_initialization_with_defaults(self, mock_get_tracer): span_wrapper = SpanWrapper("test_span") assert span_wrapper.name == "test_span" - assert span_wrapper.attributes == {} + assert span_wrapper.attributes == {"netra.span.type": "SPAN"} assert span_wrapper.start_time is None assert span_wrapper.end_time is None assert span_wrapper.status == "pending" @@ -160,7 +160,7 @@ def test_span_wrapper_initialization_with_none_attributes(self, mock_get_tracer) span_wrapper = SpanWrapper("test_span", attributes=None) - assert span_wrapper.attributes == {} + assert span_wrapper.attributes == {"netra.span.type": "SPAN"} class TestSpanWrapperAttributeSetters: @@ -407,7 +407,7 @@ def test_enter_method(self, mock_logger, mock_time): args, kwargs = self.mock_tracer.start_as_current_span.call_args assert kwargs["name"] == "test_span" assert kwargs["kind"] == SpanKind.CLIENT - assert kwargs["attributes"] == {"initial_key": "initial_value"} + assert kwargs["attributes"] == {"initial_key": "initial_value", "netra.span.type": "SPAN"} assert self.span_wrapper.span is self.mock_span # Verify return value diff --git a/tests/test_tracer.py b/tests/test_tracer.py index e3daee86..489dc562 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -12,6 +12,8 @@ class TestTracerInitialization: """Test tracer initialization and setup.""" + @patch("netra.tracer.FilteringSpanExporter", side_effect=lambda exporter, patterns: exporter) + @patch("netra.tracer.TrialAwareOTLPExporter", side_effect=lambda exporter: exporter) @patch("netra.tracer.trace") @patch("netra.tracer.TracerProvider") @patch("netra.tracer.Resource") @@ -26,6 +28,8 @@ def test_tracer_initialization_with_otlp_endpoint( mock_resource, mock_tracer_provider, mock_trace, + mock_trial_exporter, + mock_filtering_exporter, ): """Test tracer initialization with OTLP endpoint.""" # Arrange @@ -43,6 +47,7 @@ def test_tracer_initialization_with_otlp_endpoint( mock_resource.return_value = mock_resource_instance mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider mock_exporter = Mock() @@ -54,6 +59,8 @@ def test_tracer_initialization_with_otlp_endpoint( mock_session_proc = Mock() mock_session_processor.return_value = mock_session_proc + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) @@ -80,6 +87,7 @@ def test_tracer_initialization_with_otlp_endpoint( # Verify global tracer provider is set mock_trace.set_tracer_provider.assert_called_once_with(mock_provider) + @patch("netra.tracer.FilteringSpanExporter", side_effect=lambda exporter, patterns: exporter) @patch("netra.tracer.trace") @patch("netra.tracer.TracerProvider") @patch("netra.tracer.Resource") @@ -94,6 +102,7 @@ def test_tracer_initialization_with_console_fallback( mock_resource, mock_tracer_provider, mock_trace, + mock_filtering_exporter, ): """Test tracer initialization with console exporter fallback.""" # Arrange @@ -111,6 +120,7 @@ def test_tracer_initialization_with_console_fallback( mock_resource.return_value = mock_resource_instance mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider mock_exporter = Mock() @@ -119,6 +129,8 @@ def test_tracer_initialization_with_console_fallback( mock_simple_proc = Mock() mock_simple_processor.return_value = mock_simple_proc + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) @@ -165,8 +177,11 @@ def test_tracer_initialization_with_minimal_config( mock_resource.return_value = mock_resource_instance mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) @@ -318,6 +333,8 @@ def test_tracer_with_custom_resource_attributes( } mock_resource.assert_called_once_with(attributes=expected_attrs) + @patch("netra.tracer.FilteringSpanExporter", side_effect=lambda exporter, patterns: exporter) + @patch("netra.tracer.TrialAwareOTLPExporter", side_effect=lambda exporter: exporter) @patch("netra.tracer.trace") @patch("netra.tracer.TracerProvider") @patch("netra.tracer.Resource") @@ -332,6 +349,8 @@ def test_tracer_with_batch_disabled( mock_resource, mock_tracer_provider, mock_trace, + mock_trial_exporter, + mock_filtering_exporter, ): """Test tracer initialization with batch processing disabled.""" # Arrange @@ -346,6 +365,7 @@ def test_tracer_with_batch_disabled( mock_config.blocked_spans = [] mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider mock_exporter = Mock() @@ -354,6 +374,8 @@ def test_tracer_with_batch_disabled( mock_simple_proc = Mock() mock_simple_processor.return_value = mock_simple_proc + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) From bc2bb78d7f7fad41b0478fbb4b84f09ff113b12f Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:07:26 +0530 Subject: [PATCH 07/11] feat(models): add opt-in TTL caching for get_model_pricing --- docs/sdk-caching/phase-2-spec.md | 236 +++++++++++++++++++++++++++++++ netra/__init__.py | 5 + netra/models/api.py | 31 +++- tests/test_models_cache.py | 172 ++++++++++++++++++++++ 4 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 docs/sdk-caching/phase-2-spec.md create mode 100644 tests/test_models_cache.py diff --git a/docs/sdk-caching/phase-2-spec.md b/docs/sdk-caching/phase-2-spec.md new file mode 100644 index 00000000..7967fa40 --- /dev/null +++ b/docs/sdk-caching/phase-2-spec.md @@ -0,0 +1,236 @@ +--- +name: SDK Caching Phase 2 β€” netra-sdk-py +status: ready-to-implement +repo: netra-sdk-py +phase: 2 +derived_from: docs/superpowers/specs/2026-07-10-sdk-caching-phase-2-design.md +--- + +# Phase 2 Spec β€” `netra-sdk-py` (Models caching) + +> **Implement from this document** for the Python SDK PR. +> **Design:** [Phase 2 design](../../superpowers/specs/2026-07-10-sdk-caching-phase-2-design.md) +> **Shared Phase 1 contract:** [sdk-caching spec](./spec.md) +> **Sibling:** [JS Phase 2 spec](./phase-2-js.md) + +## Goal + +Add opt-in in-memory caching to the **existing** `Netra.models.get_model_pricing()` API, matching Phase 1 prompts caching and the JS Phase 2 behavior. + +This PR does **not** add a new Models HTTP surface β€” `netra/models/` already calls `GET /sdk/models`. + +## Non-negotiable conventions (match existing code) + +| Convention | Follow | +|---|---| +| Module layout | Keep `netra/models/{api,client,__init__}.py` β€” extend `api.py` only for cache wiring | +| HTTP | Leave `ModelsHttpClient` behavior intact unless a bug blocks correct unwrap | +| Logging | `logging.getLogger(__name__)` / existing `netra.models` messages | +| Naming | snake_case: `use_cache`, `cache_ttl` | +| Cache | Reuse `netra.cache.TTLCache` (thread-safe). Do **not** add a second cache type | +| Init | `Models` already constructed in `Netra.init()` β€” no new init flags | +| Shutdown | Best-effort `clear_cache()` next to prompts in `Netra.shutdown()` | +| Tests | `pytest` + `unittest.mock`; mirror `tests/test_prompts_cache.py` | +| Style | Type hints, docstrings on public methods; conventional commits per `CONTRIBUTING.md` | + +## Current behavior (preserve) + +```python +# netra/models/api.py today +def get_model_pricing(self, name: Optional[str] = None) -> List[Any] | Any: + result = self._client.get_model_pricing(name=name) + # unwraps ApiResponse { data: [...] } β†’ list + ... +``` + +Client returns `{}` on failure; API may return `[]` on bad shape, or the raw non-dict result. + +**Backward compatibility:** Existing callers with only `name` (or no args) must behave identically β€” always HTTP, no cache. + +## Public API (after change) + +```python +MODEL_PRICING_CACHE_TTL_SECONDS = 300 # module-level constant in api.py (or models/constants) + +def get_model_pricing( + self, + name: Optional[str] = None, + use_cache: bool = False, + cache_ttl: Optional[int] = None, +) -> Any: + ... + +def clear_cache(self) -> None: + """Clear all cached model pricing entries.""" +``` + +| Param | Default | Meaning | +|---|---|---| +| `name` | `None` | Optional filter; cache key uses `"all"` when omitted | +| `use_cache` | `False` | Opt-in read/write cache | +| `cache_ttl` | `None` | Per-call TTL seconds; when `None` and caching, use **300** (Models-owned), **not** `Config.cache_ttl_seconds` | + +### Behavior matrix + +| `use_cache` | `cache_ttl` | Behavior | +|---|---|---| +| `False` | any | Always HTTP. No cache read/write. | +| `True` | `None` | Read cache β†’ miss β†’ fetch β†’ store with **300s** | +| `True` | `N` | Read cache β†’ miss β†’ fetch β†’ store with `N` seconds (`TTLCache` already skips write when `ttl <= 0`) | + +### Cache key + +```text +model:pricing:{name or "all"} +``` + +### What to cache / not cache + +| Result | Cache? | +|---|---| +| Non-empty `list` | Yes | +| Empty `list` `[]` | Yes (successful empty result) | +| `None` | No | +| `{}` from client failure | No (treat as failure β€” same spirit as prompts) | +| Non-list unexpected value after unwrap | No (keep current error logging / return path; do not store) | + +Match prompts’ guard style: + +```python +# prompts today +if use_cache and result is not None and result != {}: + self._cache.set(...) +``` + +For models, prefer: + +```python +if use_cache and isinstance(result, list): + self._cache.set(cache_key, result, cache_ttl) +``` + +so only successful list payloads are stored (including `[]`). + +## Files to change + +| File | Change | +|---|---| +| `netra/models/api.py` | Instantiate `TTLCache(default_ttl=MODEL_PRICING_CACHE_TTL_SECONDS)`; wire `get_model_pricing`; add `clear_cache()` | +| `netra/__init__.py` | In `shutdown()`, clear `models` cache when present (alongside prompts) | +| `tests/test_models_cache.py` | **New** β€” mirror prompts cache tests for models | +| `tests/test_netra_init.py` | Extend only if shutdown/init assertions need models cache coverage | + +### Do not change (unless required for correctness) + +- `netra/models/client.py` HTTP path / timeout (`NETRA_MODELS_TIMEOUT`, default 10s) +- `netra/cache.py` API +- Prompts default TTL / `cache_ttl_seconds` semantics +- JS SDK (separate PR / repo) + +## Implementation sketch + +```python +from netra.cache import TTLCache + +MODEL_PRICING_CACHE_TTL_SECONDS = 300 + + +class Models: + def __init__(self, config: Config) -> None: + self._config = config + self._client = ModelsHttpClient(config) + self._cache: TTLCache[Any] = TTLCache(default_ttl=MODEL_PRICING_CACHE_TTL_SECONDS) + + def clear_cache(self) -> None: + self._cache.clear() + + def get_model_pricing( + self, + name: Optional[str] = None, + use_cache: bool = False, + cache_ttl: Optional[int] = None, + ) -> Any: + cache_key = f"model:pricing:{name or 'all'}" + + if use_cache: + cached = self._cache.get(cache_key) + if cached is not None: + return cached + + result = self._client.get_model_pricing(name=name) + + # Existing unwrap logic (keep as-is)... + items = ... # list or failure sentinel + + if use_cache and isinstance(items, list): + self._cache.set(cache_key, items, cache_ttl) + + return items +``` + +**Important:** Construct cache with `MODEL_PRICING_CACHE_TTL_SECONDS`, not `cfg.cache_ttl_seconds`. + +### Shutdown + +```python +if hasattr(cls, "models") and cls.models is not None: + try: + cls.models.clear_cache() + except Exception: + pass +``` + +Place next to the existing prompts `clear_cache()` block. + +## Acceptance criteria + +### Functional + +- [ ] Callers without `use_cache` unchanged (HTTP every time) +- [ ] `use_cache=True` second call with same `name` skips HTTP +- [ ] `name=None` / omitted uses key `model:pricing:all` +- [ ] Different names β†’ different keys +- [ ] `None` / `{}` / non-list failures not cached +- [ ] Empty list `[]` may be cached +- [ ] Default TTL 300s when `cache_ttl` omitted +- [ ] Per-call `cache_ttl` overrides; `cache_ttl=0` does not retain entry (`TTLCache` behavior) +- [ ] `clear_cache()` and `Netra.shutdown()` force refetch on next cached call +- [ ] Prompts still use `cache_ttl_seconds` (no regression) + +### Tests (minimum β€” mirror `test_prompts_cache.py`) + +- [ ] `use_cache` omitted β†’ HTTP every time +- [ ] `use_cache=True` β†’ second call skips HTTP +- [ ] Different `name` β†’ separate entries +- [ ] Failure (`{}` / `None`) not cached +- [ ] `use_cache=False` with `cache_ttl` ignores cache +- [ ] Per-call TTL expiry via mocked `time.monotonic` +- [ ] `clear_cache` forces HTTP +- [ ] Shutdown clears models cache (init/shutdown fixture pattern from prompts tests) + +### Clean code / reuse checklist + +- [ ] Reuse `TTLCache` only β€” no new cache class +- [ ] Named constant for 300s TTL +- [ ] Public `clear_cache()` β€” no private `_cache` access from `Netra.shutdown()` +- [ ] Preserve existing response unwrap logic; don’t rewrite client unless broken +- [ ] Docstrings updated for new params + +## Usage example + +```python +Netra.init() + +Netra.models.get_model_pricing() +Netra.models.get_model_pricing("gpt-4o") + +Netra.models.get_model_pricing(use_cache=True) +Netra.models.get_model_pricing("gpt-4o", use_cache=True, cache_ttl=60) +``` + +## Out of scope + +- Backend Redis / invalidation +- Changing Models HTTP client URL or auth +- Adding JS Models module (see sibling spec) +- New init config keys for models TTL diff --git a/netra/__init__.py b/netra/__init__.py index 8f8c7130..0f5a12bb 100644 --- a/netra/__init__.py +++ b/netra/__init__.py @@ -306,6 +306,11 @@ def shutdown(cls) -> None: cls.prompts.clear_cache() except Exception: pass + if hasattr(cls, "models") and cls.models is not None: + try: + cls.models.clear_cache() + except Exception: + pass @classmethod def get_meter(cls, name: str = "netra", version: Optional[str] = None) -> otel_metrics.Meter: diff --git a/netra/models/api.py b/netra/models/api.py index 81ce7c94..25236993 100644 --- a/netra/models/api.py +++ b/netra/models/api.py @@ -1,11 +1,14 @@ import logging from typing import Any, List, Optional +from netra.cache import TTLCache from netra.config import Config from netra.models.client import ModelsHttpClient logger = logging.getLogger(__name__) +MODEL_PRICING_CACHE_TTL_SECONDS = 300 + class Models: """Public entry-point exposed as Netra.models""" @@ -19,25 +22,51 @@ def __init__(self, config: Config) -> None: """ self._config = config self._client = ModelsHttpClient(config) + self._cache: TTLCache[Any] = TTLCache(default_ttl=MODEL_PRICING_CACHE_TTL_SECONDS) + + def clear_cache(self) -> None: + """Clear all cached model pricing entries.""" + self._cache.clear() - def get_model_pricing(self, name: Optional[str] = None) -> List[Any] | Any: + def get_model_pricing( + self, + name: Optional[str] = None, + use_cache: bool = False, + cache_ttl: Optional[int] = None, + ) -> List[Any] | Any: """ Fetch models for the project associated with the configured API key. Args: name: Optional model name to filter results. + use_cache: When True, read/write the in-memory cache (default: False). + cache_ttl: Per-call cache TTL in seconds (default: 300). Returns: List of model dicts from the API response, or None on failure. """ + cache_key = f"model:pricing:{name or 'all'}" + + if use_cache: + cached = self._cache.get(cache_key) + if cached is not None: + return cached + result = self._client.get_model_pricing(name=name) if not isinstance(result, dict): return result + # Client failure sentinel is {}; do not treat as a successful empty list. + if not result: + return [] + items = result.get("data", []) or [] if not isinstance(items, list): logger.error("netra.models: Unexpected response format; 'data' is not a list") return [] + if use_cache: + self._cache.set(cache_key, items, cache_ttl) + return items diff --git a/tests/test_models_cache.py b/tests/test_models_cache.py new file mode 100644 index 00000000..f742e4b9 --- /dev/null +++ b/tests/test_models_cache.py @@ -0,0 +1,172 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from netra import Netra +from netra.config import Config +from netra.models.api import MODEL_PRICING_CACHE_TTL_SECONDS, Models + + +@pytest.fixture +def models() -> Models: + cfg = Config(cache_ttl_seconds=60) + client = MagicMock() + instance = Models(cfg) + instance._client = client + return instance + + +class TestModelsGetModelPricingCaching: + def test_use_cache_omitted_calls_http_every_time(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing() + models.get_model_pricing() + + assert models._client.get_model_pricing.call_count == 2 + + def test_use_cache_true_second_call_skips_http(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + first = models.get_model_pricing(use_cache=True) + second = models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 1 + assert first == [{"name": "gpt-4o"}] + assert second == [{"name": "gpt-4o"}] + + def test_use_cache_true_different_names_use_separate_entries(self, models: Models) -> None: + models._client.get_model_pricing.side_effect = [ + {"data": [{"name": "gpt-4o"}]}, + {"data": [{"name": "claude-3"}]}, + ] + + gpt = models.get_model_pricing("gpt-4o", use_cache=True) + claude = models.get_model_pricing("claude-3", use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + assert gpt == [{"name": "gpt-4o"}] + assert claude == [{"name": "claude-3"}] + + def test_name_none_uses_all_cache_key(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(name=None, use_cache=True) + + assert models._client.get_model_pricing.call_count == 1 + + def test_empty_list_is_cached(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": []} + + first = models.get_model_pricing(use_cache=True) + second = models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 1 + assert first == [] + assert second == [] + + def test_api_failure_empty_dict_does_not_store_in_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {} + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_api_none_response_does_not_store_in_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = None + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_non_list_data_does_not_store_in_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": {"name": "bad"}} + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_per_call_cache_ttl_expires_before_default(self, models: Models) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 0.0, 1.1, 1.1]): + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True, cache_ttl=1) + assert models._client.get_model_pricing.call_count == 1 + + models.get_model_pricing(use_cache=True, cache_ttl=1) + assert models._client.get_model_pricing.call_count == 1 + + models.get_model_pricing(use_cache=True, cache_ttl=1) + assert models._client.get_model_pricing.call_count == 2 + + def test_zero_cache_ttl_skips_cache_write(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True, cache_ttl=0) + models.get_model_pricing(use_cache=True, cache_ttl=0) + + assert models._client.get_model_pricing.call_count == 2 + + def test_use_cache_false_with_cache_ttl_ignores_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=False, cache_ttl=30) + models.get_model_pricing(use_cache=False, cache_ttl=30) + + assert models._client.get_model_pricing.call_count == 2 + + def test_clear_cache_forces_next_call_to_hit_http(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True) + models.clear_cache() + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_default_ttl_is_models_owned_constant(self, models: Models) -> None: + assert MODEL_PRICING_CACHE_TTL_SECONDS == 300 + assert models._cache._default_ttl == MODEL_PRICING_CACHE_TTL_SECONDS + assert models._cache._default_ttl != 60 + + +class TestModelsCacheShutdown: + def setup_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + def teardown_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + @patch("netra.init_instrumentations") + @patch("netra.Tracer") + @patch("netra.Config") + def test_shutdown_clears_models_cache( + self, + mock_config: MagicMock, + mock_tracer: MagicMock, + mock_init_instrumentations: MagicMock, + ) -> None: + mock_cfg = MagicMock() + mock_cfg.cache_ttl_seconds = 60 + mock_config.return_value = mock_cfg + + Netra.init() + + mock_client = MagicMock() + mock_client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + Netra.models._client = mock_client + + Netra.models.get_model_pricing(use_cache=True) + Netra.models.get_model_pricing(use_cache=True) + assert mock_client.get_model_pricing.call_count == 1 + + Netra.shutdown() + + Netra.models.get_model_pricing(use_cache=True) + assert mock_client.get_model_pricing.call_count == 2 From 886e58b31e1adf0bea0def0f313576054ee0b4fd Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:14:01 +0530 Subject: [PATCH 08/11] chore: remove phase-2-spec from models caching commit --- docs/sdk-caching/phase-2-spec.md | 236 ------------------------------- 1 file changed, 236 deletions(-) delete mode 100644 docs/sdk-caching/phase-2-spec.md diff --git a/docs/sdk-caching/phase-2-spec.md b/docs/sdk-caching/phase-2-spec.md deleted file mode 100644 index 7967fa40..00000000 --- a/docs/sdk-caching/phase-2-spec.md +++ /dev/null @@ -1,236 +0,0 @@ ---- -name: SDK Caching Phase 2 β€” netra-sdk-py -status: ready-to-implement -repo: netra-sdk-py -phase: 2 -derived_from: docs/superpowers/specs/2026-07-10-sdk-caching-phase-2-design.md ---- - -# Phase 2 Spec β€” `netra-sdk-py` (Models caching) - -> **Implement from this document** for the Python SDK PR. -> **Design:** [Phase 2 design](../../superpowers/specs/2026-07-10-sdk-caching-phase-2-design.md) -> **Shared Phase 1 contract:** [sdk-caching spec](./spec.md) -> **Sibling:** [JS Phase 2 spec](./phase-2-js.md) - -## Goal - -Add opt-in in-memory caching to the **existing** `Netra.models.get_model_pricing()` API, matching Phase 1 prompts caching and the JS Phase 2 behavior. - -This PR does **not** add a new Models HTTP surface β€” `netra/models/` already calls `GET /sdk/models`. - -## Non-negotiable conventions (match existing code) - -| Convention | Follow | -|---|---| -| Module layout | Keep `netra/models/{api,client,__init__}.py` β€” extend `api.py` only for cache wiring | -| HTTP | Leave `ModelsHttpClient` behavior intact unless a bug blocks correct unwrap | -| Logging | `logging.getLogger(__name__)` / existing `netra.models` messages | -| Naming | snake_case: `use_cache`, `cache_ttl` | -| Cache | Reuse `netra.cache.TTLCache` (thread-safe). Do **not** add a second cache type | -| Init | `Models` already constructed in `Netra.init()` β€” no new init flags | -| Shutdown | Best-effort `clear_cache()` next to prompts in `Netra.shutdown()` | -| Tests | `pytest` + `unittest.mock`; mirror `tests/test_prompts_cache.py` | -| Style | Type hints, docstrings on public methods; conventional commits per `CONTRIBUTING.md` | - -## Current behavior (preserve) - -```python -# netra/models/api.py today -def get_model_pricing(self, name: Optional[str] = None) -> List[Any] | Any: - result = self._client.get_model_pricing(name=name) - # unwraps ApiResponse { data: [...] } β†’ list - ... -``` - -Client returns `{}` on failure; API may return `[]` on bad shape, or the raw non-dict result. - -**Backward compatibility:** Existing callers with only `name` (or no args) must behave identically β€” always HTTP, no cache. - -## Public API (after change) - -```python -MODEL_PRICING_CACHE_TTL_SECONDS = 300 # module-level constant in api.py (or models/constants) - -def get_model_pricing( - self, - name: Optional[str] = None, - use_cache: bool = False, - cache_ttl: Optional[int] = None, -) -> Any: - ... - -def clear_cache(self) -> None: - """Clear all cached model pricing entries.""" -``` - -| Param | Default | Meaning | -|---|---|---| -| `name` | `None` | Optional filter; cache key uses `"all"` when omitted | -| `use_cache` | `False` | Opt-in read/write cache | -| `cache_ttl` | `None` | Per-call TTL seconds; when `None` and caching, use **300** (Models-owned), **not** `Config.cache_ttl_seconds` | - -### Behavior matrix - -| `use_cache` | `cache_ttl` | Behavior | -|---|---|---| -| `False` | any | Always HTTP. No cache read/write. | -| `True` | `None` | Read cache β†’ miss β†’ fetch β†’ store with **300s** | -| `True` | `N` | Read cache β†’ miss β†’ fetch β†’ store with `N` seconds (`TTLCache` already skips write when `ttl <= 0`) | - -### Cache key - -```text -model:pricing:{name or "all"} -``` - -### What to cache / not cache - -| Result | Cache? | -|---|---| -| Non-empty `list` | Yes | -| Empty `list` `[]` | Yes (successful empty result) | -| `None` | No | -| `{}` from client failure | No (treat as failure β€” same spirit as prompts) | -| Non-list unexpected value after unwrap | No (keep current error logging / return path; do not store) | - -Match prompts’ guard style: - -```python -# prompts today -if use_cache and result is not None and result != {}: - self._cache.set(...) -``` - -For models, prefer: - -```python -if use_cache and isinstance(result, list): - self._cache.set(cache_key, result, cache_ttl) -``` - -so only successful list payloads are stored (including `[]`). - -## Files to change - -| File | Change | -|---|---| -| `netra/models/api.py` | Instantiate `TTLCache(default_ttl=MODEL_PRICING_CACHE_TTL_SECONDS)`; wire `get_model_pricing`; add `clear_cache()` | -| `netra/__init__.py` | In `shutdown()`, clear `models` cache when present (alongside prompts) | -| `tests/test_models_cache.py` | **New** β€” mirror prompts cache tests for models | -| `tests/test_netra_init.py` | Extend only if shutdown/init assertions need models cache coverage | - -### Do not change (unless required for correctness) - -- `netra/models/client.py` HTTP path / timeout (`NETRA_MODELS_TIMEOUT`, default 10s) -- `netra/cache.py` API -- Prompts default TTL / `cache_ttl_seconds` semantics -- JS SDK (separate PR / repo) - -## Implementation sketch - -```python -from netra.cache import TTLCache - -MODEL_PRICING_CACHE_TTL_SECONDS = 300 - - -class Models: - def __init__(self, config: Config) -> None: - self._config = config - self._client = ModelsHttpClient(config) - self._cache: TTLCache[Any] = TTLCache(default_ttl=MODEL_PRICING_CACHE_TTL_SECONDS) - - def clear_cache(self) -> None: - self._cache.clear() - - def get_model_pricing( - self, - name: Optional[str] = None, - use_cache: bool = False, - cache_ttl: Optional[int] = None, - ) -> Any: - cache_key = f"model:pricing:{name or 'all'}" - - if use_cache: - cached = self._cache.get(cache_key) - if cached is not None: - return cached - - result = self._client.get_model_pricing(name=name) - - # Existing unwrap logic (keep as-is)... - items = ... # list or failure sentinel - - if use_cache and isinstance(items, list): - self._cache.set(cache_key, items, cache_ttl) - - return items -``` - -**Important:** Construct cache with `MODEL_PRICING_CACHE_TTL_SECONDS`, not `cfg.cache_ttl_seconds`. - -### Shutdown - -```python -if hasattr(cls, "models") and cls.models is not None: - try: - cls.models.clear_cache() - except Exception: - pass -``` - -Place next to the existing prompts `clear_cache()` block. - -## Acceptance criteria - -### Functional - -- [ ] Callers without `use_cache` unchanged (HTTP every time) -- [ ] `use_cache=True` second call with same `name` skips HTTP -- [ ] `name=None` / omitted uses key `model:pricing:all` -- [ ] Different names β†’ different keys -- [ ] `None` / `{}` / non-list failures not cached -- [ ] Empty list `[]` may be cached -- [ ] Default TTL 300s when `cache_ttl` omitted -- [ ] Per-call `cache_ttl` overrides; `cache_ttl=0` does not retain entry (`TTLCache` behavior) -- [ ] `clear_cache()` and `Netra.shutdown()` force refetch on next cached call -- [ ] Prompts still use `cache_ttl_seconds` (no regression) - -### Tests (minimum β€” mirror `test_prompts_cache.py`) - -- [ ] `use_cache` omitted β†’ HTTP every time -- [ ] `use_cache=True` β†’ second call skips HTTP -- [ ] Different `name` β†’ separate entries -- [ ] Failure (`{}` / `None`) not cached -- [ ] `use_cache=False` with `cache_ttl` ignores cache -- [ ] Per-call TTL expiry via mocked `time.monotonic` -- [ ] `clear_cache` forces HTTP -- [ ] Shutdown clears models cache (init/shutdown fixture pattern from prompts tests) - -### Clean code / reuse checklist - -- [ ] Reuse `TTLCache` only β€” no new cache class -- [ ] Named constant for 300s TTL -- [ ] Public `clear_cache()` β€” no private `_cache` access from `Netra.shutdown()` -- [ ] Preserve existing response unwrap logic; don’t rewrite client unless broken -- [ ] Docstrings updated for new params - -## Usage example - -```python -Netra.init() - -Netra.models.get_model_pricing() -Netra.models.get_model_pricing("gpt-4o") - -Netra.models.get_model_pricing(use_cache=True) -Netra.models.get_model_pricing("gpt-4o", use_cache=True, cache_ttl=60) -``` - -## Out of scope - -- Backend Redis / invalidation -- Changing Models HTTP client URL or auth -- Adding JS Models module (see sibling spec) -- New init config keys for models TTL From eba71efb0b8ce558ea2c31e90e8430fd06ec7933 Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:39:29 +0530 Subject: [PATCH 09/11] docs(models): fix get_model_pricing returns docs and note cache mutation Co-authored-by: Cursor --- netra/models/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netra/models/api.py b/netra/models/api.py index 25236993..ab1a746f 100644 --- a/netra/models/api.py +++ b/netra/models/api.py @@ -43,7 +43,9 @@ def get_model_pricing( cache_ttl: Per-call cache TTL in seconds (default: 300). Returns: - List of model dicts from the API response, or None on failure. + List of model dicts from the API response, or empty list on failure. + When use_cache is True, do not mutate the returned list or nested + dicts/prices β€” the same objects may be served on later cache hits. """ cache_key = f"model:pricing:{name or 'all'}" From be6fa6dbe8959bad57932491f1b49c15f073c65e Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:58:44 +0530 Subject: [PATCH 10/11] NET-1329 simplify cache TTL to module const and per-call override (#329) * NET-1329 simplify cache TTL to module const and per-call override Co-authored-by: Cursor * test(prompts): assert default cache TTL is prompts-owned constant Co-authored-by: Cursor --------- Co-authored-by: Cursor --- CHANGELOG.md | 2 +- README.md | 8 +++----- netra/__init__.py | 4 ---- netra/config.py | 5 ----- netra/prompts/api.py | 6 ++++-- tests/test_models_cache.py | 3 +-- tests/test_netra_init.py | 2 -- tests/test_prompts_cache.py | 10 +++++++--- 8 files changed, 16 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f39b0a94..1bd6a4df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [0.1.96] - 2026-07-09 -- **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Configure the default TTL via `cache_ttl_seconds` in `Netra.init()` or the `NETRA_CACHE_TTL_SECONDS` environment variable. Use `Netra.prompts.clear_cache()` to invalidate cached entries. +- **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `PROMPT_CACHE_TTL_SECONDS` (60); override per call with `cache_ttl`. Use `Netra.prompts.clear_cache()` to invalidate cached entries. ## [0.1.95] - 2026-06-26 diff --git a/README.md b/README.md index fddd8e2f..9e0ed1f0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ Netra.init( trace_content=True, environment="Your Application environment", instruments={InstrumentSet.OPENAI, InstrumentSet.ANTHROPIC}, - cache_ttl_seconds=60, # default TTL for opt-in prompt caching (env: NETRA_CACHE_TTL_SECONDS) ) ``` @@ -323,7 +322,7 @@ Action tracking follows this schema: ## πŸ“‹ Prompt Management -Fetch managed prompt versions from Netra via `Netra.prompts`. Caching is opt-in and disabled by default. +Fetch managed prompt versions from Netra via `Netra.prompts`. Caching is opt-in and disabled by default. Default TTL is **60 seconds** (`PROMPT_CACHE_TTL_SECONDS`); override per call with `cache_ttl`. ```python from netra import Netra @@ -332,13 +331,12 @@ from netra.instrumentation.instruments import InstrumentSet Netra.init( app_name="My App", instruments={InstrumentSet.OPENAI}, - cache_ttl_seconds=60, # default TTL for cached prompt reads ) # Fetch a prompt (calls the API on every request by default) prompt = Netra.prompts.get_prompt("my-prompt", label="production") -# Opt in to in-memory caching to reduce API calls +# Opt in to in-memory caching to reduce API calls (default TTL: 60s) prompt = Netra.prompts.get_prompt("my-prompt", label="production", use_cache=True) # Override TTL for a single call (seconds) @@ -351,6 +349,7 @@ Netra.prompts.clear_cache() Caching notes: - `use_cache` defaults to `False`; enable it per call when you want caching. +- Default TTL is the module constant `PROMPT_CACHE_TTL_SECONDS` (60); override with `cache_ttl`. - Cache keys are scoped by prompt `name` and `label`. - Empty or failed responses are not stored in the cache. - The prompt cache is cleared automatically when `Netra.shutdown()` is called. @@ -373,7 +372,6 @@ Netra SDK can be configured using the following environment variables: | `NETRA_TRACE_CONTENT` | Whether to capture prompt/completion content (`true`/`false`) | `true` | | `NETRA_ENV` | Deployment environment (e.g., `prod`, `staging`, `dev`) | `local` | | `NETRA_RESOURCE_ATTRS` | JSON string of custom resource attributes | `{}` | -| `NETRA_CACHE_TTL_SECONDS` | Default TTL in seconds for opt-in SDK read caches (e.g. `get_prompt`) | `60` | #### Standard OpenTelemetry Variables diff --git a/netra/__init__.py b/netra/__init__.py index 0f5a12bb..d36b346e 100644 --- a/netra/__init__.py +++ b/netra/__init__.py @@ -76,7 +76,6 @@ def init( metrics_export_interval_ms: Optional[int] = None, export_auto_metrics: Optional[bool] = None, root_instruments: Optional[AbstractSet[NetraInstruments]] = None, - cache_ttl_seconds: Optional[int] = None, ) -> None: """ Thread-safe initialization of Netra. @@ -111,8 +110,6 @@ def init( span is blocked, its entire subtree is discarded. Pass a set containing ``NetraInstruments.ALL`` to allow all instrumentations to produce root spans (legacy behaviour). - cache_ttl_seconds: Default TTL in seconds for opt-in read caches - (default: 60, env: NETRA_CACHE_TTL_SECONDS) Returns: None @@ -137,7 +134,6 @@ def init( enable_metrics=enable_metrics, metrics_export_interval_ms=metrics_export_interval_ms, export_auto_metrics=export_auto_metrics, - cache_ttl_seconds=cache_ttl_seconds, ) # Configure logging based on debug mode diff --git a/netra/config.py b/netra/config.py index 935ad0ce..fe233a9e 100644 --- a/netra/config.py +++ b/netra/config.py @@ -35,7 +35,6 @@ def __init__( enable_metrics: Optional[bool] = None, metrics_export_interval_ms: Optional[int] = None, export_auto_metrics: Optional[bool] = None, - cache_ttl_seconds: Optional[int] = None, ): """ Initialize the configuration. @@ -53,7 +52,6 @@ def __init__( enable_metrics: Whether to enable custom metrics export via OTLP (default: False) metrics_export_interval_ms: How often to push metrics to the collector in ms (default: 60000) export_auto_metrics: Whether to export OTel auto-instrumented system metrics (default: False) - cache_ttl_seconds: Default TTL in seconds for opt-in SDK read caches (default: 60, env: NETRA_CACHE_TTL_SECONDS) """ self.app_name = self._get_app_name(app_name) self.otlp_endpoint = self._get_otlp_endpoint() @@ -79,9 +77,6 @@ def __init__( self.metrics_export_interval_ms = self._get_int_config( metrics_export_interval_ms, "NETRA_METRICS_EXPORT_INTERVAL", default=60000 ) - self.cache_ttl_seconds = self._get_int_config( - cache_ttl_seconds, "NETRA_CACHE_TTL_SECONDS", default=60 - ) self._set_trace_content_env() diff --git a/netra/prompts/api.py b/netra/prompts/api.py index b173b96b..81f15747 100644 --- a/netra/prompts/api.py +++ b/netra/prompts/api.py @@ -7,6 +7,8 @@ logger = logging.getLogger(__name__) +PROMPT_CACHE_TTL_SECONDS = 60 + class Prompts: """ @@ -22,7 +24,7 @@ def __init__(self, cfg: Config) -> None: """ self._config = cfg self._client = PromptsHttpClient(cfg) - self._cache: TTLCache[Any] = TTLCache(default_ttl=cfg.cache_ttl_seconds) + self._cache: TTLCache[Any] = TTLCache(default_ttl=PROMPT_CACHE_TTL_SECONDS) def clear_cache(self) -> None: """Clear all cached prompt entries.""" @@ -42,7 +44,7 @@ def get_prompt( name: Name of the prompt label: Label of the prompt version (default: "production") use_cache: When True, read/write the in-memory cache (default: False) - cache_ttl: Per-call cache TTL in seconds (default: init cache_ttl_seconds) + cache_ttl: Per-call cache TTL in seconds (default: PROMPT_CACHE_TTL_SECONDS) Returns: Prompt version data or None/empty dict if not found diff --git a/tests/test_models_cache.py b/tests/test_models_cache.py index f742e4b9..ecdaca92 100644 --- a/tests/test_models_cache.py +++ b/tests/test_models_cache.py @@ -9,7 +9,7 @@ @pytest.fixture def models() -> Models: - cfg = Config(cache_ttl_seconds=60) + cfg = Config() client = MagicMock() instance = Models(cfg) instance._client = client @@ -153,7 +153,6 @@ def test_shutdown_clears_models_cache( mock_init_instrumentations: MagicMock, ) -> None: mock_cfg = MagicMock() - mock_cfg.cache_ttl_seconds = 60 mock_config.return_value = mock_cfg Netra.init() diff --git a/tests/test_netra_init.py b/tests/test_netra_init.py index 93398deb..e223777b 100644 --- a/tests/test_netra_init.py +++ b/tests/test_netra_init.py @@ -64,7 +64,6 @@ def test_init_with_default_parameters( enable_metrics=None, metrics_export_interval_ms=None, export_auto_metrics=None, - cache_ttl_seconds=None, ) # Verify Tracer was initialized @@ -101,7 +100,6 @@ def test_init_with_custom_parameters( "enable_metrics": None, "metrics_export_interval_ms": None, "export_auto_metrics": None, - "cache_ttl_seconds": None, } app_name = "test-app" diff --git a/tests/test_prompts_cache.py b/tests/test_prompts_cache.py index 35d39785..f06de871 100644 --- a/tests/test_prompts_cache.py +++ b/tests/test_prompts_cache.py @@ -4,12 +4,12 @@ from netra import Netra from netra.config import Config -from netra.prompts.api import Prompts +from netra.prompts.api import PROMPT_CACHE_TTL_SECONDS, Prompts @pytest.fixture def prompts() -> Prompts: - cfg = Config(cache_ttl_seconds=60) + cfg = Config() client = MagicMock() instance = Prompts(cfg) instance._client = client @@ -102,6 +102,11 @@ def test_clear_cache_forces_next_call_to_hit_http(self, prompts: Prompts) -> Non assert prompts._client.get_prompt_version.call_count == 2 + def test_default_ttl_is_prompts_owned_constant(self, prompts: Prompts) -> None: + assert PROMPT_CACHE_TTL_SECONDS == 60 + assert prompts._cache._default_ttl == PROMPT_CACHE_TTL_SECONDS + assert prompts._cache._default_ttl != 300 + class TestPromptsCacheShutdown: def setup_method(self) -> None: @@ -122,7 +127,6 @@ def test_shutdown_clears_prompt_cache( mock_init_instrumentations: MagicMock, ) -> None: mock_cfg = MagicMock() - mock_cfg.cache_ttl_seconds = 60 mock_config.return_value = mock_cfg Netra.init() From 7180a3ba0b73fde762952fedf957aac7d8f03999 Mon Sep 17 00:00:00 2001 From: Akhilesh Nair <72593014+AkhileshNair2201@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:31:28 +0530 Subject: [PATCH 11/11] docs: document opt-in TTL caching for get_model_pricing Co-authored-by: Cursor --- CHANGELOG.md | 1 + README.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd6a4df..86a8e9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [0.1.96] - 2026-07-09 - **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `PROMPT_CACHE_TTL_SECONDS` (60); override per call with `cache_ttl`. Use `Netra.prompts.clear_cache()` to invalidate cached entries. +- **Add opt-in TTL caching for `get_model_pricing`** - `Netra.models.get_model_pricing` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `MODEL_PRICING_CACHE_TTL_SECONDS` (300); override per call with `cache_ttl`. Use `Netra.models.clear_cache()` to invalidate cached entries. ## [0.1.95] - 2026-06-26 diff --git a/README.md b/README.md index 9e0ed1f0..798702c1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 🌐 **HTTP Client Instrumentation**: Automatic tracing for aiohttp and httpx - πŸ’Ύ **Vector Database Support**: Weaviate, Qdrant, and other vector DB instrumentation - πŸ“‹ **Prompt Management**: Fetch managed prompts from Netra with optional in-memory TTL caching +- πŸ’° **Model Pricing**: Fetch model pricing from Netra with optional in-memory TTL caching ## πŸ“¦ Installation @@ -354,6 +355,44 @@ Caching notes: - Empty or failed responses are not stored in the cache. - The prompt cache is cleared automatically when `Netra.shutdown()` is called. +## πŸ’° Model Pricing + +Fetch model details and pricing for your project via `Netra.models`. Caching is opt-in and disabled by default. Default TTL is **300 seconds** (`MODEL_PRICING_CACHE_TTL_SECONDS`); override per call with `cache_ttl`. + +```python +from netra import Netra +from netra.instrumentation.instruments import InstrumentSet + +Netra.init( + app_name="My App", + instruments={InstrumentSet.OPENAI}, +) + +# Fetch all model pricing (calls the API on every request by default) +models = Netra.models.get_model_pricing() + +# Fetch pricing for a specific model +models = Netra.models.get_model_pricing(name="gpt-4o") + +# Opt in to in-memory caching to reduce API calls (default TTL: 300s) +models = Netra.models.get_model_pricing(use_cache=True) + +# Override TTL for a single call (seconds) +models = Netra.models.get_model_pricing(use_cache=True, cache_ttl=600) + +# Clear cached entries after pricing updates +Netra.models.clear_cache() +``` + +Caching notes: + +- `use_cache` defaults to `False`; enable it per call when you want caching. +- Default TTL is the module constant `MODEL_PRICING_CACHE_TTL_SECONDS` (300); override with `cache_ttl`. +- Cache keys are scoped by model `name` (or `"all"` when no name is passed). +- Empty or failed responses are not stored in the cache. +- When `use_cache` is `True`, do not mutate the returned list or nested dicts β€” the same objects may be served on later cache hits. +- The model pricing cache is cleared automatically when `Netra.shutdown()` is called. + ## πŸ”§ Advanced Configuration ### Environment Variables