diff --git a/docs/integrations/llms/pydanticai.md b/docs/integrations/llms/pydanticai.md index 05628d67e..8636a6476 100644 --- a/docs/integrations/llms/pydanticai.md +++ b/docs/integrations/llms/pydanticai.md @@ -56,3 +56,29 @@ You can also instrument a specific agent with `logfire.instrument_pydantic_ai(ag For more information, see the [`logfire.instrument_pydantic_ai()`][logfire.Logfire.instrument_pydantic_ai] reference or the [Pydantic AI docs on instrumenting](https://pydantic.dev/docs/ai/integrations/logfire/) with **Logfire**. + +## Keep model pricing up to date + +Pydantic AI uses [`genai-prices`](https://github.com/pydantic/genai-prices) to populate the +`operation.cost` span attribute. The model price catalogue is shipped as a snapshot inside the +package, mirroring the official provider pricing pages (e.g. +[Google Gemini API pricing](https://ai.google.dev/gemini-api/docs/pricing) and +[OpenAI API pricing](https://openai.com/api/pricing/)), so newly released models +(e.g. `gemini-3.5-flash`, `gpt-5.1`, …) only get a cost calculation once a new version of +`genai-prices` is released and pulled in. + +To always pick up new prices without waiting for a release, pass `update_genai_prices=True` +to `logfire.configure()`. A daemon thread will refresh the catalogue from upstream every hour +in the background: + +```python skip-run="true" skip-reason="external-connection" +import logfire + +logfire.configure(update_genai_prices=True) +logfire.instrument_pydantic_ai() +``` + +The same can be enabled via the `LOGFIRE_UPDATE_GENAI_PRICES=1` environment variable. + +The thread needs outbound HTTP access to `raw.githubusercontent.com` and is a no-op (with a +one-shot warning) when `genai-prices` is not installed in the environment. diff --git a/logfire-api/logfire_api/_internal/config.pyi b/logfire-api/logfire_api/_internal/config.pyi index 7cfc1f567..e10083113 100644 --- a/logfire-api/logfire_api/_internal/config.pyi +++ b/logfire-api/logfire_api/_internal/config.pyi @@ -127,7 +127,7 @@ class LocalVariablesOptions: class DeprecatedKwargs(TypedDict): ... -def configure(*, local: bool = False, send_to_logfire: bool | Literal['if-token-present'] | None = None, token: str | list[str] | None = None, api_key: str | None = None, service_name: str | None = None, service_version: str | None = None, environment: str | None = None, console: ConsoleOptions | Literal[False] | None = None, config_dir: Path | str | None = None, data_dir: Path | str | None = None, additional_span_processors: Sequence[SpanProcessor] | None = None, metrics: MetricsOptions | Literal[False] | None = None, scrubbing: ScrubbingOptions | Literal[False] | None = None, inspect_arguments: bool | None = None, sampling: SamplingOptions | None = None, min_level: int | LevelName | None = None, add_baggage_to_attributes: bool = True, code_source: CodeSource | None = None, variables: VariablesOptions | LocalVariablesOptions | None = None, distributed_tracing: bool | None = None, advanced: AdvancedOptions | None = None, **deprecated_kwargs: Unpack[DeprecatedKwargs]) -> Logfire: +def configure(*, local: bool = False, send_to_logfire: bool | Literal['if-token-present'] | None = None, token: str | list[str] | None = None, api_key: str | None = None, service_name: str | None = None, service_version: str | None = None, environment: str | None = None, console: ConsoleOptions | Literal[False] | None = None, config_dir: Path | str | None = None, data_dir: Path | str | None = None, additional_span_processors: Sequence[SpanProcessor] | None = None, metrics: MetricsOptions | Literal[False] | None = None, scrubbing: ScrubbingOptions | Literal[False] | None = None, inspect_arguments: bool | None = None, sampling: SamplingOptions | None = None, min_level: int | LevelName | None = None, add_baggage_to_attributes: bool = True, code_source: CodeSource | None = None, variables: VariablesOptions | LocalVariablesOptions | None = None, distributed_tracing: bool | None = None, update_genai_prices: bool | None = None, advanced: AdvancedOptions | None = None, **deprecated_kwargs: Unpack[DeprecatedKwargs]) -> Logfire: """Configure the logfire SDK. Args: @@ -200,6 +200,16 @@ def configure(*, local: bool = False, send_to_logfire: bool | Literal['if-token- See [Unintentional Distributed Tracing](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#unintentional-distributed-tracing) for more information. This setting always applies globally, and the last value set is used, including the default value. + update_genai_prices: If `True`, start a background daemon thread that periodically refreshes + the `genai-prices` model pricing snapshot from the upstream repository. This keeps + `operation.cost` attributes correct for newly released LLM models (e.g. `gemini-3.5-flash`) + without requiring a package upgrade. Defaults to `False`. + + Requires `genai-prices` to be installed (usually transitive via `pydantic-ai`); if it is + missing, a one-shot warning is emitted and the flag is silently ignored. The thread is + stopped via `atexit` on shutdown. + + Defaults to the `LOGFIRE_UPDATE_GENAI_PRICES` environment variable, or `False`. advanced: Advanced options primarily used for testing by Logfire developers. """ @@ -231,17 +241,18 @@ class _LogfireConfigData: code_source: CodeSource | None variables: VariablesOptions | LocalVariablesOptions | None distributed_tracing: bool | None + update_genai_prices: bool advanced: AdvancedOptions class LogfireConfig(_LogfireConfigData): - def __init__(self, send_to_logfire: bool | Literal['if-token-present'] | None = None, token: str | list[str] | None = None, api_key: str | None = None, service_name: str | None = None, service_version: str | None = None, environment: str | None = None, console: ConsoleOptions | Literal[False] | None = None, config_dir: Path | None = None, data_dir: Path | None = None, additional_span_processors: Sequence[SpanProcessor] | None = None, metrics: MetricsOptions | Literal[False] | None = None, scrubbing: ScrubbingOptions | Literal[False] | None = None, inspect_arguments: bool | None = None, sampling: SamplingOptions | None = None, min_level: int | LevelName | None = None, add_baggage_to_attributes: bool = True, variables: VariablesOptions | None = None, code_source: CodeSource | None = None, distributed_tracing: bool | None = None, advanced: AdvancedOptions | None = None) -> None: + def __init__(self, send_to_logfire: bool | Literal['if-token-present'] | None = None, token: str | list[str] | None = None, api_key: str | None = None, service_name: str | None = None, service_version: str | None = None, environment: str | None = None, console: ConsoleOptions | Literal[False] | None = None, config_dir: Path | None = None, data_dir: Path | None = None, additional_span_processors: Sequence[SpanProcessor] | None = None, metrics: MetricsOptions | Literal[False] | None = None, scrubbing: ScrubbingOptions | Literal[False] | None = None, inspect_arguments: bool | None = None, sampling: SamplingOptions | None = None, min_level: int | LevelName | None = None, add_baggage_to_attributes: bool = True, variables: VariablesOptions | None = None, code_source: CodeSource | None = None, distributed_tracing: bool | None = None, update_genai_prices: bool | None = None, advanced: AdvancedOptions | None = None) -> None: """Create a new LogfireConfig. Users should never need to call this directly, instead use `logfire.configure`. See `_LogfireConfigData` for parameter documentation. """ - def configure(self, send_to_logfire: bool | Literal['if-token-present'] | None, token: str | list[str] | None, api_key: str | None, service_name: str | None, service_version: str | None, environment: str | None, console: ConsoleOptions | Literal[False] | None, config_dir: Path | None, data_dir: Path | None, additional_span_processors: Sequence[SpanProcessor] | None, metrics: MetricsOptions | Literal[False] | None, scrubbing: ScrubbingOptions | Literal[False] | None, inspect_arguments: bool | None, sampling: SamplingOptions | None, min_level: int | LevelName | None, add_baggage_to_attributes: bool, code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, advanced: AdvancedOptions | None) -> None: ... + def configure(self, send_to_logfire: bool | Literal['if-token-present'] | None, token: str | list[str] | None, api_key: str | None, service_name: str | None, service_version: str | None, environment: str | None, console: ConsoleOptions | Literal[False] | None, config_dir: Path | None, data_dir: Path | None, additional_span_processors: Sequence[SpanProcessor] | None, metrics: MetricsOptions | Literal[False] | None, scrubbing: ScrubbingOptions | Literal[False] | None, inspect_arguments: bool | None, sampling: SamplingOptions | None, min_level: int | LevelName | None, add_baggage_to_attributes: bool, code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, update_genai_prices: bool | None, advanced: AdvancedOptions | None) -> None: ... def initialize(self) -> None: """Configure internals to start exporting traces and metrics.""" def force_flush(self, timeout_millis: int = 30000) -> bool: diff --git a/logfire-api/logfire_api/_internal/config_params.pyi b/logfire-api/logfire_api/_internal/config_params.pyi index ec1d4ce43..a05209d29 100644 --- a/logfire-api/logfire_api/_internal/config_params.pyi +++ b/logfire-api/logfire_api/_internal/config_params.pyi @@ -11,10 +11,9 @@ from pathlib import Path from typing import Any, TypeVar T = TypeVar('T') -slots_true: Incomplete PydanticPluginRecordValues: Incomplete -@dataclass(**slots_true) +@dataclass(slots=True) class ConfigParam: """A parameter that can be configured for a Logfire instance.""" env_vars: list[str] @@ -55,6 +54,7 @@ IGNORE_NO_CONFIG: Incomplete BASE_URL: Incomplete DISTRIBUTED_TRACING: Incomplete EMIT_CONFIGURATION_SPAN: Incomplete +UPDATE_GENAI_PRICES: Incomplete HTTPX_CAPTURE_ALL: Incomplete AIOHTTP_CLIENT_CAPTURE_ALL: Incomplete CONFIG_PARAMS: Incomplete diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 05c55488c..01f791bed 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -437,6 +437,7 @@ def configure( code_source: CodeSource | None = None, variables: VariablesOptions | LocalVariablesOptions | None = None, distributed_tracing: bool | None = None, + update_genai_prices: bool | None = None, advanced: AdvancedOptions | None = None, **deprecated_kwargs: Unpack[DeprecatedKwargs], ) -> Logfire: @@ -512,6 +513,16 @@ def configure( See [Unintentional Distributed Tracing](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#unintentional-distributed-tracing) for more information. This setting always applies globally, and the last value set is used, including the default value. + update_genai_prices: If `True`, start a background daemon thread that periodically refreshes + the `genai-prices` model pricing snapshot from the upstream repository. This keeps + `operation.cost` attributes correct for newly released LLM models (e.g. `gemini-3.5-flash`) + without requiring a package upgrade. Defaults to `False`. + + Requires `genai-prices` to be installed (usually transitive via `pydantic-ai`); if it is + missing, a one-shot warning is emitted and the flag is silently ignored. The thread is + stopped via `atexit` on shutdown. + + Defaults to the `LOGFIRE_UPDATE_GENAI_PRICES` environment variable, or `False`. advanced: Advanced options primarily used for testing by Logfire developers. """ from .. import DEFAULT_LOGFIRE_INSTANCE, Logfire @@ -645,6 +656,7 @@ def configure( code_source=code_source, variables=variables, distributed_tracing=distributed_tracing, + update_genai_prices=update_genai_prices, advanced=advanced, ) @@ -728,6 +740,9 @@ class _LogfireConfigData: distributed_tracing: bool | None """Whether to extract incoming trace context.""" + update_genai_prices: bool + """Whether to refresh `genai-prices` model pricing snapshot in the background.""" + advanced: AdvancedOptions """Advanced options primarily used for testing by Logfire developers.""" @@ -755,6 +770,7 @@ def _load_configuration( code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, + update_genai_prices: bool | None, advanced: AdvancedOptions | None, ) -> None: """Merge the given parameters with the environment variables file configurations.""" @@ -770,6 +786,7 @@ def _load_configuration( self.data_dir = param_manager.load_param('data_dir', data_dir) self.inspect_arguments = param_manager.load_param('inspect_arguments', inspect_arguments) self.distributed_tracing = param_manager.load_param('distributed_tracing', distributed_tracing) + self.update_genai_prices = param_manager.load_param('update_genai_prices', update_genai_prices) self.ignore_no_config = param_manager.load_param('ignore_no_config') min_level = param_manager.load_param('min_level', min_level) if min_level is None: @@ -892,6 +909,7 @@ def __init__( variables: VariablesOptions | None = None, code_source: CodeSource | None = None, distributed_tracing: bool | None = None, + update_genai_prices: bool | None = None, advanced: AdvancedOptions | None = None, ) -> None: """Create a new LogfireConfig. @@ -922,6 +940,7 @@ def __init__( code_source=code_source, variables=variables, distributed_tracing=distributed_tracing, + update_genai_prices=update_genai_prices, advanced=advanced, ) # initialize with no-ops so that we don't impact OTEL's global config just because logfire is installed @@ -936,6 +955,8 @@ def __init__( self._has_set_providers = False self._initialized = False self._lock = RLock() + # Background updater for genai-prices snapshot; created on demand in _initialize. + self._genai_prices_updater: Any = None def configure( self, @@ -958,6 +979,7 @@ def configure( code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, + update_genai_prices: bool | None, advanced: AdvancedOptions | None, ) -> None: with self._lock: @@ -982,6 +1004,7 @@ def configure( code_source, variables, distributed_tracing, + update_genai_prices, advanced, ) self.initialize() @@ -995,6 +1018,10 @@ def _initialize(self) -> None: if self._initialized: # pragma: no cover return + # If we are reconfiguring (e.g. configure called a second time), stop the previous + # genai-prices updater thread cleanly before any new one is created below. + self._stop_genai_prices_updater() + emscripten = platform_is_emscripten() with suppress_instrumentation(): @@ -1400,6 +1427,58 @@ def fix_pid(): # pragma: no cover self._ensure_flush_after_aws_lambda() + if not emscripten: + self._maybe_start_genai_prices_updater() + + def _maybe_start_genai_prices_updater(self) -> None: + """Start a background daemon to refresh the `genai-prices` snapshot. + + Only runs when `update_genai_prices=True`. Silently no-ops if `genai-prices` is not + installed (it is a transitive dep, typically via `pydantic-ai`). + """ + if not self.update_genai_prices: + return + if self._genai_prices_updater is not None: # pragma: no cover + return + try: + from genai_prices.update_prices import UpdatePrices + except ImportError: + warnings.warn( + 'logfire.configure(update_genai_prices=True) requires the `genai-prices` package ' + 'to be installed. Install it directly or pull it in transitively via `pydantic-ai`. ' + 'Auto-update is disabled for this session.', + stacklevel=2, + ) + return + except Exception as exc: # pragma: no cover + # genai-prices is installed but failed to import (e.g. due to an incompatible + # transitive dependency such as pydantic < 2.5 missing `Tag`). Don't crash + # `logfire.configure()` over a non-critical feature — degrade gracefully. + warnings.warn( + f'Failed to import `genai_prices.update_prices` ({exc!r}); ' + 'auto-update of model pricing is disabled for this session.', + stacklevel=2, + ) + return + try: + updater = UpdatePrices() + updater.start() + except Exception: # pragma: no cover + return + self._genai_prices_updater = updater + atexit.register(self._stop_genai_prices_updater) + + def _stop_genai_prices_updater(self) -> None: + """Stop the `genai-prices` background updater if it is running.""" + updater = self._genai_prices_updater + if updater is None: + return + self._genai_prices_updater = None + try: + updater.stop() + except Exception: # pragma: no cover + pass + def force_flush(self, timeout_millis: int = 30_000) -> bool: """Force flush all spans and metrics. @@ -1594,6 +1673,7 @@ def emit_configuration_span(config: LogfireConfig, logfire_instance: Logfire, *, 'min_level': config.min_level, 'add_baggage_to_attributes': config.add_baggage_to_attributes, 'distributed_tracing': config.distributed_tracing, + 'update_genai_prices': config.update_genai_prices, 'head_sample_rate': sampling.head if isinstance(sampling.head, (int, float)) else None, 'tail_sampling_enabled': sampling.tail is not None, 'code_source_set': config.code_source is not None, diff --git a/logfire/_internal/config_params.py b/logfire/_internal/config_params.py index 18492f4e1..cc654e7bf 100644 --- a/logfire/_internal/config_params.py +++ b/logfire/_internal/config_params.py @@ -108,6 +108,13 @@ class _DefaultCallback: """Whether to extract incoming trace context. By default, will extract but warn about it.""" EMIT_CONFIGURATION_SPAN = ConfigParam(env_vars=['LOGFIRE_EMIT_CONFIGURATION_SPAN'], allow_file_config=True, default=False, tp=bool) """Whether to emit a `Logfire configured` log span after `logfire.configure()`.""" +UPDATE_GENAI_PRICES = ConfigParam(env_vars=['LOGFIRE_UPDATE_GENAI_PRICES'], allow_file_config=True, default=False, tp=bool) +"""Whether to refresh the `genai-prices` model pricing snapshot in the background. + +When enabled, Logfire starts a daemon thread that periodically fetches the latest pricing +data from the `pydantic/genai-prices` repository, so that newly released models get correct +`operation.cost` attributes without requiring a package upgrade. Requires `genai-prices` to +be installed (usually transitively via `pydantic-ai`).""" # Instrumentation packages parameters HTTPX_CAPTURE_ALL = ConfigParam(env_vars=['LOGFIRE_HTTPX_CAPTURE_ALL'], allow_file_config=True, default=False, tp=bool) @@ -142,6 +149,7 @@ class _DefaultCallback: 'ignore_no_config': IGNORE_NO_CONFIG, 'distributed_tracing': DISTRIBUTED_TRACING, 'emit_configuration_span': EMIT_CONFIGURATION_SPAN, + 'update_genai_prices': UPDATE_GENAI_PRICES, # Instrumentation packages parameters 'httpx_capture_all': HTTPX_CAPTURE_ALL, 'aiohttp_client_capture_all': AIOHTTP_CLIENT_CAPTURE_ALL, diff --git a/tests/test_configure.py b/tests/test_configure.py index 610712a03..e1dbd2415 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1635,6 +1635,7 @@ def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], 'min_level': 0, 'add_baggage_to_attributes': False, 'distributed_tracing': True, + 'update_genai_prices': False, 'head_sample_rate': 1.0, 'tail_sampling_enabled': False, 'code_source_set': False, @@ -1666,6 +1667,94 @@ def test_configuration_span_enabled_via_env_var(monkeypatch: pytest.MonkeyPatch) assert GLOBAL_CONFIG.advanced.emit_configuration_span is True +def _require_genai_prices_update_prices() -> None: + """Skip the current test when `genai_prices.update_prices` cannot be imported. + + `pytest.importorskip` only catches `ImportError`. In some CI matrix entries + (e.g. pydantic 2.4), importing `genai_prices.update_prices` succeeds at the + top level but a transitive import raises `AttributeError` because it depends + on `pydantic.Tag` (added in pydantic 2.5). Skip in those cases too. + """ + import importlib + + try: + importlib.import_module('genai_prices.update_prices') + except Exception as exc: # pragma: no cover + pytest.skip(f'genai_prices.update_prices not importable in this environment: {exc!r}') + + +def test_update_genai_prices_default_off(config_kwargs: dict[str, Any]) -> None: + _require_genai_prices_update_prices() + with patch('genai_prices.update_prices.UpdatePrices') as MockUpdater: + configure(**config_kwargs) + MockUpdater.assert_not_called() + assert GLOBAL_CONFIG.update_genai_prices is False + assert GLOBAL_CONFIG._genai_prices_updater is None # pyright: ignore[reportPrivateUsage] + + +def test_update_genai_prices_enabled_starts_updater(config_kwargs: dict[str, Any]) -> None: + _require_genai_prices_update_prices() + with patch('genai_prices.update_prices.UpdatePrices') as MockUpdater: + configure(**config_kwargs, update_genai_prices=True) + MockUpdater.assert_called_once_with() + MockUpdater.return_value.start.assert_called_once_with() + assert GLOBAL_CONFIG.update_genai_prices is True + # Cleanup: stop the (mocked) updater so the atexit hook doesn't fire on the real one. + GLOBAL_CONFIG._stop_genai_prices_updater() # pyright: ignore[reportPrivateUsage] + + +def test_update_genai_prices_env_var(monkeypatch: pytest.MonkeyPatch, config_kwargs: dict[str, Any]) -> None: + _require_genai_prices_update_prices() + monkeypatch.setenv('LOGFIRE_UPDATE_GENAI_PRICES', '1') + with patch('genai_prices.update_prices.UpdatePrices') as MockUpdater: + configure(**config_kwargs) + MockUpdater.assert_called_once_with() + assert GLOBAL_CONFIG.update_genai_prices is True + GLOBAL_CONFIG._stop_genai_prices_updater() # pyright: ignore[reportPrivateUsage] + + +def test_update_genai_prices_graceful_when_not_installed( + config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + # Hide `genai_prices.update_prices` so the lazy import fails like it would on a + # fresh environment without `genai-prices` installed. + monkeypatch.setitem(sys.modules, 'genai_prices.update_prices', None) + with pytest.warns(UserWarning, match='requires the `genai-prices` package'): + configure(**config_kwargs, update_genai_prices=True) + assert GLOBAL_CONFIG.update_genai_prices is True + assert GLOBAL_CONFIG._genai_prices_updater is None # pyright: ignore[reportPrivateUsage] + + +def test_update_genai_prices_skipped_under_emscripten( + config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + _require_genai_prices_update_prices() + monkeypatch.setattr('logfire._internal.config.platform_is_emscripten', lambda: True) + with patch('genai_prices.update_prices.UpdatePrices') as MockUpdater: + configure(**config_kwargs, update_genai_prices=True) + MockUpdater.assert_not_called() + assert GLOBAL_CONFIG.update_genai_prices is True + assert GLOBAL_CONFIG._genai_prices_updater is None # pyright: ignore[reportPrivateUsage] + + +def test_update_genai_prices_reconfigure_resets_thread(config_kwargs: dict[str, Any]) -> None: + _require_genai_prices_update_prices() + with patch('genai_prices.update_prices.UpdatePrices') as MockUpdater: + first_instance = mock.MagicMock(name='first_updater') + second_instance = mock.MagicMock(name='second_updater') + MockUpdater.side_effect = [first_instance, second_instance] + + configure(**config_kwargs, update_genai_prices=True) + first_instance.start.assert_called_once_with() + first_instance.stop.assert_not_called() + + configure(**config_kwargs, update_genai_prices=True) + first_instance.stop.assert_called_once_with() + second_instance.start.assert_called_once_with() + + GLOBAL_CONFIG._stop_genai_prices_updater() # pyright: ignore[reportPrivateUsage] + + def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> None: script_path = Path(__file__).parent / 'import_used_for_tests' / 'open_span_at_shutdown.py'