"""Model pricing sourced from the Portkey pricing dataset.

Pricing for a model is resolved from the first of these that has an entry
(see :func:`spens.summarizer._price_for` for the last two steps):

1. a **live** copy of the provider's dataset, fetched from
   ``configs.portkey.ai`` and cached on disk for :func:`ttl_seconds`;
2. the **vendored** snapshot shipped in ``spens/data/pricing`` -- used when
   the fetch fails, the host is offline, or live fetching is switched off;
3. the built-in ``spens.summarizer.MODEL_PRICING`` table;
4. nothing -- an unpriced model is costed at $0 rather than guessed.

One dataset entry per model id, keyed by that id::

    {"claude-sonnet-4": {"pricing_config": {"pay_as_you_go": {
        "request_token":           {"price": 0.0003},
        "response_token":          {"price": 0.0015},
        "cache_read_input_token":  {"price": 0.00003},    # optional
        "cache_write_input_token": {"price": 0.000375}}}} # optional

Two properties of that data are easy to get wrong:

* **Prices are per 100 tokens.**  The Google dataset lists
  ``gemini-2.5-flash`` input at ``0.00003`` and output at ``0.00025``, which
  are the published $0.30 and $2.50 per 1M -- so the conversion factor is
  :data:`PRICE_TO_USD_PER_1M`.  Reading the figure as per-token over-reports
  every session 100x; reading it as per-1M under-reports it 100x.
  ``tests/unit/test_pricing.py`` pins the factor against published prices,
  and :func:`unit_warning` re-checks it on every refresh so a change in the
  upstream convention surfaces as a warning instead of a wrong invoice.
* **Google prices are banded by context size.**  Keys carry ``-lte-128k`` /
  ``-gt-128k`` suffixes (``-lte-200k`` for Gemini 3), so a call reporting
  ``model: "gemini-2.5-pro"`` matches no key at all until the bands are
  parsed out; :func:`PricingTable.lookup` picks the band from the call's own
  context size.

``batch_config`` prices are ignored: a captured trace does not say whether
the call was billed at the batch rate, and assuming it was would halve the
reported cost.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

PORTKEY_BASE_URL = "https://configs.portkey.ai/pricing"

# Provider datasets spens costs against, in precedence order.  A model id
# sold by several providers (``claude-sonnet-4`` is served by Anthropic,
# OpenRouter and Bedrock alike) is priced from the first provider that lists
# it, so a direct provider's rate wins over a reseller's.
PROVIDERS: tuple[str, ...] = (
    "anthropic",
    "openai",
    "google",
    "fireworks-ai",
    "openrouter",
    "bedrock",
)

# Dataset prices are per 100 tokens; see the module docstring.
PRICE_TO_USD_PER_1M = 10_000.0

DEFAULT_TTL_SECONDS = 24 * 60 * 60
# Per-request and whole-refresh timeouts.  The summary is computed at the end
# of every session and lazily by the log viewer, so a hung network must cost
# a second or two at worst, not the session recap.
DEFAULT_TIMEOUT = 5.0
DEFAULT_BUDGET = 8.0

VENDOR_DIR = Path(__file__).resolve().parent / "data" / "pricing"

# Source labels recorded in the session summary so a cost figure stays
# explainable after the fact.
SOURCE_LIVE = "live"
SOURCE_CACHE = "cache"
SOURCE_VENDORED = "vendored"
SOURCE_UNAVAILABLE = "unavailable"

# ``<base>-lte-128k`` / ``<base>-gt-200k`` context bands.
_TIER_RE = re.compile(r"^(?P<base>.+?)-(?P<side>lte|gt)-(?P<limit>\d+)k$")

# A leading vendor or region segment on an otherwise ordinary model id:
# ``us.anthropic.claude-sonnet-4-...`` as Bedrock reports it.  Only bare
# letters match, so the ``2.`` in ``gemini-2.5-flash`` is left alone.
_VENDOR_DOT_RE = re.compile(r"^[a-z]+\.")

# Shortest dataset key allowed to match as a bare substring of a model id.
# Prefix and exact matches are unrestricted; this only stops a very short key
# from colliding with an unrelated longer id.
_MIN_SUBSTRING_KEY = 4

# Published USD-per-1M prices for one well-known model per provider, used to
# verify the unit convention after a refresh.  Providers whose figures could
# not be checked against a public price list are deliberately absent: a wrong
# reference would be worse than none.
REFERENCE_PRICES: dict[str, tuple[str, float, float]] = {
    "anthropic": ("claude-sonnet-4", 3.00, 15.00),
    "openai": ("gpt-4o", 2.50, 10.00),
    "google": ("gemini-2.5-flash", 0.30, 2.50),
}


@dataclass(frozen=True)
class Price:
    """Per-1M-token USD prices for one model.

    ``cache_read_per_1m`` / ``cache_write_per_1m`` are ``None`` when the
    dataset does not price cached tokens separately, which tells the caller
    to fall back to its multiplier estimate rather than charging $0.
    """

    input_per_1m: float
    output_per_1m: float
    cache_read_per_1m: float | None = None
    cache_write_per_1m: float | None = None


@dataclass(frozen=True)
class Tier:
    """One context band of a banded model (``limit`` in tokens)."""

    limit: int
    side: str
    price: Price


@dataclass(frozen=True)
class PricingTable:
    """Merged pricing for every loaded provider.

    ``exact`` holds unbanded model ids; ``tiers`` maps a banded model's base
    id to its bands.  ``sources`` records where each provider's data came
    from (one of the ``SOURCE_*`` labels).
    """

    exact: dict[str, Price] = field(default_factory=dict)
    tiers: dict[str, tuple[Tier, ...]] = field(default_factory=dict)
    sources: dict[str, str] = field(default_factory=dict)
    # Memo of the (kind, key) a model id resolves to.  Matching scans every
    # key in the merged table, and a session asks for the same handful of
    # models on every one of its calls, so the scan is done once per id.
    _resolved: dict[str, tuple[str, str | None]] = field(
        default_factory=dict, compare=False, repr=False
    )

    def __bool__(self) -> bool:
        return bool(self.exact or self.tiers)

    def lookup(self, model: str | None, context_tokens: int = 0) -> Price | None:
        """Return the price for ``model``, or None if the data has no entry.

        ``context_tokens`` selects the band of a banded model and is ignored
        otherwise; pass the call's input tokens *including* cache reads,
        since that is what the provider bands on.
        """
        if not model:
            return None
        resolved = self._resolved.get(model)
        if resolved is None:
            resolved = self._resolve(model)
            self._resolved[model] = resolved

        kind, key = resolved
        if kind == "exact":
            return self.exact[key]
        if kind == "tiered":
            return _pick_tier(self.tiers[key], context_tokens)
        return None

    def _resolve(self, model: str) -> tuple[str, str | None]:
        """Return which map and key price ``model`` (``("none", None)`` if neither)."""
        for candidate in model_candidates(model):
            exact_key = best_match(self.exact, candidate)
            base_key = best_match(self.tiers, candidate)
            # Both maps can match the same id.  The longer (more specific)
            # key wins, so ``gemini-2.5-flash-lite`` is not priced from the
            # ``gemini-2.5-flash`` bands.
            if exact_key and (base_key is None or len(exact_key) >= len(base_key)):
                return "exact", exact_key
            if base_key and self.tiers[base_key]:
                return "tiered", base_key
        return "none", None


def model_candidates(model: str | None) -> list[str]:
    """Return ``model`` plus the vendor-stripped spellings to try for it.

    Providers report the same model under different ids: OpenRouter prefixes
    a vendor (``anthropic/claude-sonnet-4``), Bedrock prefixes a region and a
    vendor and appends a version tag
    (``us.anthropic.claude-sonnet-4-20250514-v1:0``).  Each stripped form is
    tried in turn, most specific first.
    """
    if not model:
        return []

    key = model.strip().lower().replace(" ", "-").replace("_", "-")
    candidates = [key]

    if "/" in key:
        candidates.append(key.rsplit("/", 1)[-1])

    # Peel leading ``<vendor>.`` / ``<region>.`` segments one at a time.
    peeled = candidates[-1]
    while _VENDOR_DOT_RE.match(peeled):
        peeled = _VENDOR_DOT_RE.sub("", peeled, count=1)
        candidates.append(peeled)

    # Bedrock's trailing inference-profile tag (``-v1:0``).
    for candidate in list(candidates):
        if ":" in candidate:
            candidates.append(candidate.rsplit(":", 1)[0])

    seen: set[str] = set()
    unique: list[str] = []
    for candidate in candidates:
        if candidate and candidate not in seen:
            seen.add(candidate)
            unique.append(candidate)
    return unique


def best_match(keys: dict[str, Any], candidate: str) -> str | None:
    """Return the most specific key in ``keys`` that describes ``candidate``.

    Exact first, then the longest key that ``candidate`` starts with (so a
    dated id like ``claude-opus-4-20250514`` resolves to ``claude-opus-4``),
    then the longest key contained in it.  Matching only ever goes in that
    direction -- a key *longer* than the model id is never used, which is
    what stops ``gemini-2.5-flash`` from being priced as
    ``gemini-2.5-flash-image``.
    """
    if candidate in keys:
        return candidate

    best: str | None = None
    for key in keys:
        if candidate.startswith(key) and (best is None or len(key) > len(best)):
            best = key
    if best is not None:
        return best

    for key in keys:
        if len(key) >= _MIN_SUBSTRING_KEY and key in candidate and (best is None or len(key) > len(best)):
            best = key
    return best


def _pick_tier(tiers: tuple[Tier, ...], context_tokens: int) -> Price | None:
    """Pick the context band that applies at ``context_tokens``.

    The tightest matching bound wins: the smallest ``lte`` band that still
    contains the context, else the largest ``gt`` band below it.  Upstream
    sometimes lists bands with different limits for one model (Gemini 3.1
    Pro has both 128k and 200k pairs), so "tightest" is what keeps the
    choice deterministic instead of dict-order dependent.
    """
    if not tiers:
        return None

    for tier in sorted((t for t in tiers if t.side == "lte"), key=lambda t: t.limit):
        if context_tokens <= tier.limit:
            return tier.price

    above = [t for t in tiers if t.side == "gt" and t.limit < context_tokens]
    if above:
        return max(above, key=lambda t: t.limit).price

    # Only ``gt`` bands exist and the context is below all of them: charge
    # the cheapest listed band rather than nothing.
    return min(tiers, key=lambda t: t.limit).price


def _price_from_entry(entry: Any) -> Price | None:
    """Convert one dataset entry to a :class:`Price` (None if unpriced).

    Entries with no token prices at all are skipped rather than returned as
    $0: the dataset's ``default`` entry and its media models (imagen, veo)
    are priced per image or per second, and a zero-cost "hit" here would
    shadow the fallback tables and silently zero a session's cost.
    """
    if not isinstance(entry, dict):
        return None
    config = entry.get("pricing_config")
    if not isinstance(config, dict):
        return None
    rates = config.get("pay_as_you_go")
    if not isinstance(rates, dict):
        return None

    def rate(name: str) -> float | None:
        unit = rates.get(name)
        if not isinstance(unit, dict):
            return None
        price = unit.get("price")
        if not isinstance(price, (int, float)) or isinstance(price, bool):
            return None
        return float(price) * PRICE_TO_USD_PER_1M

    input_price = rate("request_token")
    output_price = rate("response_token")
    if not input_price and not output_price:
        return None

    return Price(
        input_per_1m=input_price or 0.0,
        output_per_1m=output_price or 0.0,
        cache_read_per_1m=rate("cache_read_input_token"),
        cache_write_per_1m=rate("cache_write_input_token"),
    )


def parse_dataset(raw: Any) -> dict[str, Price]:
    """Parse a provider dataset into ``{model id: Price}``.

    Malformed and unpriced entries are dropped; a single bad entry never
    costs the rest of the file.
    """
    if not isinstance(raw, dict):
        return {}
    prices: dict[str, Price] = {}
    for model, entry in raw.items():
        if not isinstance(model, str) or model == "default":
            continue
        price = _price_from_entry(entry)
        if price is not None:
            prices[model.strip().lower()] = price
    return prices


def build_table(datasets: list[tuple[str, dict[str, Price]]], sources: dict[str, str] | None = None) -> PricingTable:
    """Merge per-provider prices into one table, splitting out context bands.

    ``datasets`` is ordered by precedence: the first provider to list a
    model id keeps it.
    """
    exact: dict[str, Price] = {}
    tiers: dict[str, list[Tier]] = {}

    for _provider, prices in datasets:
        for model, price in prices.items():
            match = _TIER_RE.match(model)
            if match:
                base = match.group("base")
                limit = int(match.group("limit")) * 1000
                side = match.group("side")
                bands = tiers.setdefault(base, [])
                if not any(b.limit == limit and b.side == side for b in bands):
                    bands.append(Tier(limit=limit, side=side, price=price))
            elif model not in exact:
                exact[model] = price

    return PricingTable(
        exact=exact,
        tiers={base: tuple(bands) for base, bands in tiers.items()},
        sources=dict(sources or {}),
    )


# -- fetching and caching ----------------------------------------------------

_live_fetch_override: bool | None = None
_table_cache: PricingTable | None = None
_warned: set[str] = set()


def set_live_fetch(enabled: bool | None) -> None:
    """Enable/disable live fetching for this process (None = env default)."""
    global _live_fetch_override
    _live_fetch_override = enabled
    reset_table_cache()


def live_fetch_enabled() -> bool:
    """Whether the dataset may be fetched over the network.

    Off when ``SPENS_PRICING_OFFLINE`` is set, or when a caller (the runner,
    for a workspace configured with ``"pricing": {"live_fetch": false}``)
    has disabled it.  Costing then uses the vendored snapshot.
    """
    if _live_fetch_override is not None:
        return _live_fetch_override
    return os.environ.get("SPENS_PRICING_OFFLINE", "").strip().lower() not in ("1", "true", "yes")


def ttl_seconds() -> int:
    """Seconds a cached dataset stays fresh (``SPENS_PRICING_TTL``)."""
    raw = os.environ.get("SPENS_PRICING_TTL", "").strip()
    if raw.isdigit():
        return int(raw)
    return DEFAULT_TTL_SECONDS


def cache_dir() -> Path:
    """Directory holding fetched datasets (honours ``XDG_CACHE_HOME``)."""
    base = os.environ.get("XDG_CACHE_HOME", "").strip()
    root = Path(base) if base else Path.home() / ".cache"
    return root / "spens" / "pricing"


def dataset_url(provider: str) -> str:
    return f"{PORTKEY_BASE_URL}/{provider}.json"


def _warn_once(key: str, message: str) -> None:
    if key not in _warned:
        _warned.add(key)
        print(f"[spens] {message}", file=sys.stderr)


def _read_json_file(path: Path) -> Any | None:
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, json.JSONDecodeError):
        return None


def _write_json_file(path: Path, data: Any) -> None:
    """Write ``data`` to ``path`` atomically, creating parents as needed."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(data, fh, indent=2, sort_keys=True)
    os.replace(tmp, path)


def _http_get_json(url: str, timeout: float) -> Any:
    """GET ``url`` and parse it as JSON (raises on any failure)."""
    request = urllib.request.Request(url, headers={"User-Agent": "spens-pricing"})
    # The URL is always a fixed https configs.portkey.ai path.
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode("utf-8"))


def fetch_provider(provider: str, timeout: float = DEFAULT_TIMEOUT) -> Any:
    """Fetch one provider's dataset from Portkey (raises on failure)."""
    return _http_get_json(dataset_url(provider), timeout)


def vendored_path(provider: str) -> Path:
    return VENDOR_DIR / f"{provider}.json"


def load_provider_dataset(
    provider: str,
    *,
    live: bool | None = None,
    refresh: bool = False,
    timeout: float = DEFAULT_TIMEOUT,
) -> tuple[Any | None, str]:
    """Return ``(dataset, source)`` for ``provider``.

    A fresh cache entry is used as-is; a stale or missing one triggers a
    fetch when live fetching is on.  Every failure falls through to the next
    source, so costing works offline and the worst case is the vendored
    snapshot (or no entry, which costs $0).
    """
    cached_path = cache_dir() / f"{provider}.json"
    cached = _read_json_file(cached_path)
    fresh = False
    if cached is not None:
        try:
            fresh = (time.time() - cached_path.stat().st_mtime) < ttl_seconds()
        except OSError:
            fresh = False

    if cached is not None and fresh and not refresh:
        return cached, SOURCE_CACHE

    want_live = live_fetch_enabled() if live is None else live
    if want_live:
        try:
            data = fetch_provider(provider, timeout)
        # URLError/TimeoutError are OSError subclasses and a JSON parse
        # failure is a ValueError, so these two cover every failure mode.
        except (OSError, ValueError) as exc:
            _warn_once(
                f"fetch:{provider}",
                f"could not refresh {provider} pricing ({exc}); using cached/vendored prices",
            )
        else:
            if isinstance(data, dict):
                try:
                    _write_json_file(cached_path, data)
                except OSError as exc:
                    _warn_once(f"cache:{provider}", f"could not cache {provider} pricing ({exc})")
                return data, SOURCE_LIVE

    if cached is not None:
        return cached, SOURCE_CACHE

    vendored = _read_json_file(vendored_path(provider))
    if vendored is not None:
        return vendored, SOURCE_VENDORED

    return None, SOURCE_UNAVAILABLE


def load_table(
    providers: tuple[str, ...] = PROVIDERS,
    *,
    live: bool | None = None,
    refresh: bool = False,
    budget: float = DEFAULT_BUDGET,
) -> PricingTable:
    """Load and merge every provider's pricing, memoised per process.

    The log viewer summarises many sessions in a loop, so the merged table
    is built once and reused; :func:`reset_table_cache` drops it.
    """
    global _table_cache
    if _table_cache is not None and providers == PROVIDERS and not refresh:
        return _table_cache

    datasets: list[tuple[str, dict[str, Price]]] = []
    sources: dict[str, str] = {}
    deadline = time.monotonic() + budget

    for provider in providers:
        # Keep the *whole* load inside one budget: six unreachable providers
        # must not add six timeouts to the end of a session.
        remaining = deadline - time.monotonic()
        timeout = min(DEFAULT_TIMEOUT, remaining) if remaining > 0 else 0.0
        raw, source = load_provider_dataset(
            provider,
            live=False if timeout <= 0 else live,
            refresh=refresh,
            timeout=max(timeout, 0.1),
        )
        sources[provider] = source
        if raw is not None:
            datasets.append((provider, parse_dataset(raw)))

    table = build_table(datasets, sources)
    if providers == PROVIDERS:
        _table_cache = table
    return table


def reset_table_cache() -> None:
    """Drop the memoised table (and the one-time warnings with it)."""
    global _table_cache
    _table_cache = None
    _warned.clear()


# -- refresh / verification --------------------------------------------------


@dataclass
class ProviderStatus:
    """Outcome of refreshing one provider, for CLI reporting."""

    provider: str
    source: str
    models: int = 0
    error: str = ""
    vendored: Path | None = None
    warning: str = ""


def unit_warning(provider: str, prices: dict[str, Price]) -> str:
    """Return a warning if ``provider``'s prices fail their reference check.

    Guards :data:`PRICE_TO_USD_PER_1M`: if Portkey changes the unit (or the
    factor was wrong for a provider in the first place), the reference model
    comes out off by a round factor and this says so instead of quietly
    reporting costs that are 100x wrong.
    """
    reference = REFERENCE_PRICES.get(provider)
    if not reference:
        return ""
    model, want_in, want_out = reference
    table = build_table([(provider, prices)])
    price = table.lookup(model)
    if price is None:
        return f"reference model {model} is not in the {provider} dataset; unit check skipped"
    for label, got, want in (("input", price.input_per_1m, want_in), ("output", price.output_per_1m, want_out)):
        if want and abs(got - want) > max(0.01, want * 0.02):
            return (
                f"{provider} {model} {label} price reads ${got:,.2f}/1M but ${want:,.2f}/1M is published "
                f"-- the dataset's price unit may have changed (see PRICE_TO_USD_PER_1M)"
            )
    return ""


def refresh_datasets(
    providers: tuple[str, ...] = PROVIDERS,
    *,
    vendor: bool = False,
    timeout: float = DEFAULT_TIMEOUT,
) -> list[ProviderStatus]:
    """Re-fetch each provider's dataset into the cache (and optionally vendor it).

    ``vendor=True`` also writes ``spens/data/pricing/<provider>.json``, the
    snapshot used when the host is offline.  That path is inside the
    installed package, so it only succeeds from a writable checkout.
    """
    statuses: list[ProviderStatus] = []
    for provider in providers:
        status = ProviderStatus(provider=provider, source=SOURCE_UNAVAILABLE)
        try:
            raw = fetch_provider(provider, timeout)
        except (OSError, ValueError) as exc:
            status.error = str(exc)
            statuses.append(status)
            continue

        prices = parse_dataset(raw)
        status.source = SOURCE_LIVE
        status.models = len(prices)
        status.warning = unit_warning(provider, prices)

        try:
            _write_json_file(cache_dir() / f"{provider}.json", raw)
        except OSError as exc:
            status.error = f"could not write cache: {exc}"

        if vendor:
            path = vendored_path(provider)
            try:
                _write_json_file(path, raw)
                status.vendored = path
            except OSError as exc:
                status.error = f"could not write {path}: {exc}"

        statuses.append(status)

    reset_table_cache()
    return statuses


# -- CLI ---------------------------------------------------------------------


def _describe_age(provider: str) -> str:
    path = cache_dir() / f"{provider}.json"
    try:
        age = time.time() - path.stat().st_mtime
    except OSError:
        return ""
    if age < 90:
        return f"{int(age)}s old"
    if age < 90 * 60:
        return f"{int(age / 60)}m old"
    if age < 48 * 3600:
        return f"{int(age / 3600)}h old"
    return f"{int(age / 86400)}d old"


def _format_rate(rate: float | None) -> str:
    """Render an optional cache rate, naming the estimate when absent."""
    return f"${rate:,.4f}" if rate is not None else "estimated from input"


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="spens pricing",
        description="Inspect and refresh the Portkey model pricing data used to cost sessions.",
    )
    parser.add_argument(
        "--refresh", action="store_true",
        help="Re-fetch every provider's dataset into the local cache.",
    )
    parser.add_argument(
        "--vendor", action="store_true",
        help="Re-fetch and also update the snapshots shipped in spens/data/pricing (needs a writable checkout).",
    )
    parser.add_argument(
        "--provider", action="append", metavar="NAME",
        help=f"Limit to one provider (repeatable). Default: {', '.join(PROVIDERS)}.",
    )
    parser.add_argument(
        "--model", metavar="ID",
        help="Show the resolved price for a model id instead of provider status.",
    )
    parser.add_argument(
        "--tokens", type=int, default=0, metavar="N",
        help="Context size used to pick a banded model's price with --model (default: 0).",
    )
    parser.add_argument(
        "--offline", action="store_true",
        help="Do not fetch; report what the cached and vendored data provide.",
    )
    return parser


def run_pricing_cli(argv: list[str]) -> int:
    """Entry point for ``spens pricing``; returns a process exit code."""
    args = build_parser().parse_args(argv)

    providers = tuple(args.provider) if args.provider else PROVIDERS
    unknown = [p for p in providers if p not in PROVIDERS]
    if unknown:
        print(f"[spens] Unknown provider(s): {', '.join(unknown)}. Known: {', '.join(PROVIDERS)}")
        return 2

    if args.offline:
        set_live_fetch(False)

    if args.refresh or args.vendor:
        if args.offline:
            print("[spens] --offline cannot be combined with --refresh/--vendor")
            return 2
        failed = False
        print(f"[spens] Fetching pricing from {PORTKEY_BASE_URL} ...")
        for status in refresh_datasets(providers, vendor=args.vendor):
            if status.error and status.source != SOURCE_LIVE:
                failed = True
                print(f"  {status.provider:<12} failed: {status.error}")
                continue
            target = f" -> {status.vendored}" if status.vendored else ""
            print(f"  {status.provider:<12} {status.models} models{target}")
            if status.error:
                print(f"  {'':<12} warning: {status.error}")
            if status.warning:
                failed = True
                print(f"  {'':<12} WARNING: {status.warning}")
        if failed:
            return 1

    table = load_table(providers, refresh=False)

    if args.model:
        price = table.lookup(args.model, args.tokens)
        if price is None:
            print(f"[spens] No Portkey entry for '{args.model}'; spens will fall back to its built-in table.")
            return 1
        print(f"[spens] {args.model} (context {args.tokens:,} tokens), USD per 1M tokens:")
        print(f"  input        ${price.input_per_1m:,.4f}")
        print(f"  output       ${price.output_per_1m:,.4f}")
        print(f"  cache read   {_format_rate(price.cache_read_per_1m)}")
        print(f"  cache write  {_format_rate(price.cache_write_per_1m)}")
        return 0

    print(f"[spens] Pricing data ({len(table.exact) + len(table.tiers)} models, TTL {ttl_seconds()}s)")
    for provider in providers:
        source = table.sources.get(provider, SOURCE_UNAVAILABLE)
        age = _describe_age(provider) if source == SOURCE_CACHE else ""
        suffix = f" ({age})" if age else ""
        print(f"  {provider:<12} {source}{suffix}")
    if any(s == SOURCE_UNAVAILABLE for s in table.sources.values()):
        print("[spens] Run 'spens pricing --refresh' to populate the cache (or --vendor to ship a snapshot).")
    return 0
