Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eb39fd9
adding background update helper method
adtyavrdhn May 26, 2026
cb1e7b7
fix in structure, do not rely on ref count to see if updater is running
adtyavrdhn May 26, 2026
a5269c9
Merge branch 'main' of github.com:pydantic/genai-prices into genai-pr…
adtyavrdhn May 26, 2026
f28bb53
Make the shared background updater safe for library consumers
adtyavrdhn Jun 10, 2026
6046622
Make the shared background updater safe for library consumers
adtyavrdhn Jun 10, 2026
e263bd4
adding a comment for blocking on join for close
adtyavrdhn Jun 10, 2026
31e5f69
Merge branch 'genai-prices-update' of github.com:pydantic/genai-price…
adtyavrdhn Jun 10, 2026
58ac105
Let a manually started UpdatePrices take over the shared background u…
adtyavrdhn Jun 10, 2026
5856e2e
Tighten README ordering for shared-updater precedence docs
adtyavrdhn Jun 10, 2026
8da4144
Harden wait semantics and close review gaps in the shared updater
adtyavrdhn Jun 10, 2026
36b595f
Remove the GENAI_PRICES_DISABLE_AUTO_UPDATE kill switch
adtyavrdhn Jun 10, 2026
8ca8cb5
Make stopping the shared updater non-blocking with a fencing gate
adtyavrdhn Jun 10, 2026
89bcb07
Survive os.fork(): hold the lock across fork and restart the updater …
adtyavrdhn Jun 10, 2026
941f020
exception trace handling
adtyavrdhn Jun 18, 2026
2aef71e
Collapse the background updater into a process-global singleton
adtyavrdhn Jun 26, 2026
a5296fa
Update README for the singleton background updater
adtyavrdhn Jun 26, 2026
4193133
Address review: timeout conflict warning, stop() re-raises, drop ADR
adtyavrdhn Jun 26, 2026
189886b
Make UpdatePrices the ref-counted lease; drop update_prices_in_backgr…
adtyavrdhn Jun 29, 2026
c5762d1
Fix: discarded fetch must not report success to waiters (Codex review)
adtyavrdhn Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,42 @@ update_prices.stop() # stop updating prices

Only one `UpdatePrices` instance can be running at a time.

For libraries and integrations that want to opt into updating prices without creating duplicate background
threads, use `update_prices_in_background()`:

```py
from genai_prices import update_prices_in_background

update_prices_handle = update_prices_in_background()
...
update_prices_handle.close()
```

The first call starts a shared process-wide updater with default settings (hourly refresh from the default URL —
the shared updater is not configurable; if you need a custom URL or interval, use `UpdatePrices` directly). Later
calls reuse the same updater and return independent handles. The updater is stopped when the last handle is
closed, at which point prices revert to the data bundled with the installed package.

If an `UpdatePrices` instance has already been started manually, `update_prices_in_background()` does not start a
second updater and returns a handle that does nothing on close: prices are already being kept up to date, and the
manual updater's lifetime stays with whoever started it. Note that such a handle stays inert: if the manual
updater is later stopped, background updates stop with it — call `update_prices_in_background()` again to start a
new shared updater.

`UpdatePricesHandle.close()` is idempotent and never raises; errors from the background updater are logged instead.

`update_prices_in_background()` returns immediately and never blocks on the download. Until the first fetch
completes, `calc_price` keeps using the data bundled with the installed package, so prices for models released
after that snapshot may be missing for the first moments of the process. Once the fetch lands, every subsequent
calculation uses the fresh data — prices computed before then are not recalculated. If you need fresh prices
before calculating (e.g. in a short-lived script), call `wait_prices_updated_sync()` /
`wait_prices_updated_async()` after acquiring the handle.

To disable background updates entirely (e.g. in air-gapped environments, or when a library enables them on your
behalf), set the `GENAI_PRICES_DISABLE_AUTO_UPDATE` environment variable to any non-empty value:
`update_prices_in_background()` then returns a do-nothing handle and makes no network requests. This does not
affect manually created `UpdatePrices` instances.

If you'd like to wait for prices to be updated without access to the `UpdatePrices` instance, you can use the `wait_prices_updated_sync` function:

```py
Expand Down
19 changes: 17 additions & 2 deletions packages/python/genai_prices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,25 @@

from . import data_snapshot, types
from .types import Usage
from .update_prices import UpdatePrices, wait_prices_updated_async, wait_prices_updated_sync
from .update_prices import (
UpdatePrices,
UpdatePricesHandle,
update_prices_in_background,
wait_prices_updated_async,
wait_prices_updated_sync,
)

__version__ = _metadata_version('genai_prices')
__all__ = 'Usage', 'calc_price', 'UpdatePrices', 'wait_prices_updated_sync', 'wait_prices_updated_async', '__version__'
__all__ = (
'Usage',
'calc_price',
'UpdatePrices',
'wait_prices_updated_sync',
'wait_prices_updated_async',
'update_prices_in_background',
'UpdatePricesHandle',
'__version__',
)


@overload
Expand Down
150 changes: 138 additions & 12 deletions packages/python/genai_prices/update_prices.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import logging
import os
import threading
from dataclasses import dataclass, field
from time import time
Expand All @@ -13,13 +14,18 @@
__all__ = (
'DEFAULT_UPDATE_URL',
'UpdatePrices',
'UpdatePricesHandle',
'update_prices_in_background',
'wait_prices_updated_sync',
'wait_prices_updated_async',
)

logger = logging.getLogger('genai-prices')
DEFAULT_UPDATE_URL = 'https://raw.githubusercontent.com/pydantic/genai-prices/refs/heads/main/prices/data.json'
_global_update_prices: UpdatePrices | None = None
_managed_update_prices: UpdatePrices | None = None
_managed_update_prices_ref_count = 0
_global_update_prices_lock = threading.RLock()


def wait_prices_updated_sync(timeout: float | None = None) -> bool:
Expand All @@ -31,8 +37,11 @@ def wait_prices_updated_sync(timeout: float | None = None) -> bool:
Returns:
True if prices were updated, False otherwise.
"""
if _global_update_prices:
return _global_update_prices.wait(timeout)
with _global_update_prices_lock:
update_prices = _global_update_prices

if update_prices is not None:
return update_prices.wait(timeout)
return False


Expand All @@ -48,6 +57,100 @@ async def wait_prices_updated_async(timeout: float | None = None) -> bool:
return await asyncio.to_thread(wait_prices_updated_sync, timeout)


def update_prices_in_background() -> UpdatePricesHandle:
"""Update prices in the background using a shared, process-wide daemon thread.

The first call starts the shared updater; later calls reuse it and return independent handles.
The updater is stopped when the last handle is closed, at which point prices revert to the
data bundled with the installed package.

This function returns immediately and never blocks on the download: until the first fetch
completes, price calculations keep using the bundled data, and prices computed before then
are not recalculated once fresh data lands. Use `wait_prices_updated_sync` or
`wait_prices_updated_async` if you need fresh prices before calculating.

If an `UpdatePrices` instance has already been started manually, no second updater is started
and the returned handle does nothing on close — prices are already being kept up to date. Such
a handle stays inert: if the manual updater is later stopped, background updates stop with it,
and a new handle must be acquired to restart them.

Set the `GENAI_PRICES_DISABLE_AUTO_UPDATE` environment variable to any non-empty value to
disable this function entirely: it returns a do-nothing handle and no updater is started.

Returns:
A handle that stops the shared background updater when all handles have been closed.
"""
global _global_update_prices, _managed_update_prices, _managed_update_prices_ref_count

if os.environ.get('GENAI_PRICES_DISABLE_AUTO_UPDATE'):
return UpdatePricesHandle()

with _global_update_prices_lock:
if _managed_update_prices is not None:
_managed_update_prices_ref_count += 1
return UpdatePricesHandle(_managed_update_prices)

if _global_update_prices is not None:
# A manually started UpdatePrices is already keeping prices fresh; don't start a
# second updater and don't claim the manual one — its owner controls its lifetime.
return UpdatePricesHandle()

update_prices = UpdatePrices()
_global_update_prices = update_prices
_managed_update_prices = update_prices
_managed_update_prices_ref_count = 1
try:
update_prices._start_thread() # pyright: ignore[reportPrivateUsage]
except Exception:
_global_update_prices = None
_managed_update_prices = None
_managed_update_prices_ref_count = 0
raise

return UpdatePricesHandle(update_prices)


@dataclass
class UpdatePricesHandle:
"""A claim on the shared background updater started by `update_prices_in_background`.

A handle only releases the updater it was created for: handles constructed directly, or
outliving the updater they belong to, are inert and closing them does nothing.
"""

_update_prices: UpdatePrices | None = None
_closed: bool = field(default=False, init=False)

def close(self):
"""Release this handle's claim on the shared updater.

Stops the updater if this was the last open handle. Idempotent, and never raises:
errors from the background updater are logged instead.
"""
global _global_update_prices, _managed_update_prices, _managed_update_prices_ref_count

with _global_update_prices_lock:
if self._closed:
return

self._closed = True
if self._update_prices is None or _managed_update_prices is not self._update_prices:
return

_managed_update_prices_ref_count -= 1
if _managed_update_prices_ref_count > 0:
return

update_prices = self._update_prices
_global_update_prices = None
_managed_update_prices = None
_managed_update_prices_ref_count = 0
try:
update_prices._stop_thread() # pyright: ignore[reportPrivateUsage]
except Exception as e:
logger.error('Error from genai-prices background updater while closing (%s): %s', type(e).__name__, e)


@dataclass
class UpdatePrices:
"""Update prices in the background using a daemon thread.
Expand Down Expand Up @@ -75,22 +178,34 @@ def start(self, *, wait: bool | float = False):
"""
global _global_update_prices

with _global_update_prices_lock:
if self._thread is not None:
raise RuntimeError('UpdatePrices background task already started')

if _global_update_prices is not None:
raise RuntimeError(
'UpdatePrices global task already started, only one UpdatePrices can be active at a time'
)

_global_update_prices = self
try:
self._start_thread()
except Exception:
_global_update_prices = None
raise

if wait:
self.wait(timeout=30 if wait is True else wait)

def _start_thread(self) -> None:
if self._thread is not None:
raise RuntimeError('UpdatePrices background task already started')

if _global_update_prices is not None:
raise RuntimeError(
'UpdatePrices global task already started, only one UpdatePrices can be active at a time'
)

_global_update_prices = self
self._prices_updated.clear()
self._stop_event.clear()
self._background_exc = None
self._thread = threading.Thread(target=self._background_task, daemon=True, name='genai_prices:update')
self._thread.start()
if wait:
self.wait(timeout=30 if wait is True else wait)

def wait(self, timeout: float | None = None) -> bool:
"""Wait for the prices to be updated in the background task.
Expand All @@ -107,9 +222,20 @@ def wait(self, timeout: float | None = None) -> bool:

def stop(self):
"""Stop the background task."""
global _global_update_prices
global _global_update_prices, _managed_update_prices, _managed_update_prices_ref_count

with _global_update_prices_lock:
if self._thread is None:
return

if _global_update_prices is self:
_global_update_prices = None
if _managed_update_prices is self:
_managed_update_prices = None
_managed_update_prices_ref_count = 0
self._stop_thread()

_global_update_prices = None
def _stop_thread(self) -> None:
if self._thread is not None:
self._stop_event.set()
self._thread.join()
Expand Down
Loading