Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3238a7e
feat(pricing): add models.dev catalog client with 24h cache
elkaix Jun 7, 2026
4213b74
test(pricing): fix mock setup and add stale-cache test for models_dev
elkaix Jun 7, 2026
8f2b51a
feat(pricing): consult models.dev catalog in get_cost_usd, fallback t…
elkaix Jun 7, 2026
e2f50bd
feat(pricing): refresh models.dev catalog on /usage and /stats
elkaix Jun 7, 2026
4cf9b40
feat(usage): add session cost panel to /usage output
elkaix Jun 7, 2026
6c35f44
fix(usage): render cost panel as Rich Panel, fix blocking I/O in asyn…
elkaix Jun 7, 2026
8e6f249
fix: replace x86_64 rg binary with arch-aware lookup, fix VISUAL env …
elkaix Jun 8, 2026
3c56dba
feat(usage): add AlibabaAdapter for DashScope quota and rate-limit data
elkaix Jun 8, 2026
ddf61b1
fix(usage): remap dashscope-us quota host, fix misleading no-data note
elkaix Jun 8, 2026
ff54195
feat(usage): show local token stats in alibaba panel, fall back to qu…
elkaix Jun 8, 2026
16f0187
refactor(soul): extract FlowRunner into its own module
elkaix Jun 8, 2026
f25b012
fix(shell): stop /login emitting un-awaited redraw_in_future warning
elkaix Jun 8, 2026
cabecde
test(wire): cover legacy (headerless) wire-file version detection
elkaix Jun 8, 2026
11d459a
build(deps): document click and ruff version pins inline
elkaix Jun 8, 2026
4a78ade
docs: add staged PythinkerSoul decomposition plan
elkaix Jun 8, 2026
9051eb0
docs(changelog): note quieter /login (redraw warning fix)
elkaix Jun 8, 2026
bedbadf
fix: address CodeRabbit and ruff findings across six modules
elkaix Jun 8, 2026
14651f4
fix: ruff formatting, prohibited param name, silent exception swallow
elkaix Jun 8, 2026
fbf130c
fix: ruff format two test files; replace _do_fetch mock with new_clie…
elkaix Jun 8, 2026
e3c626a
fix: address remaining CodeRabbit findings on PR #84
elkaix Jun 8, 2026
98c8567
fix(types): resolve pyright errors failing the CI check job
elkaix Jun 8, 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Quieter `/login`.** Logging in no longer prints a `RuntimeWarning` about an un-awaited `redraw_in_future` coroutine. The prompt redraw throttle now uses a coroutine-free path (`max_render_postpone_time`), eliminating the warning emitted during the login prompt handoff.

## 0.37.0 (2026-06-07)

- **Agent runtime tool visibility hardening.** `PythinkerToolset` now filters the tools advertised to the model by active execution policy, permission profile, root/subagent role, and plan-mode state while preserving execution-time guards as defense in depth.
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ dependencies = [
"pyyaml==6.0.3",
"rich==15.0.0",
"certifi>=2025.10.5",
"click==8.3.0",
"pyperclip==1.11.0",
# Pinned: click 8.4.x regresses pyright (`click.Option` typed partially
# unknown, ~95 errors in `make check`). Unpin once that type regression is
# fixed upstream. Mirrored as a Dependabot ignore in .github/dependabot.yml.
"click==8.3.0",
"streamingjson==0.0.5",
"trafilatura==2.0.0",
# lxml is used by trafilatura/htmldate/justext; keep pinned for binary wheels.
Expand Down Expand Up @@ -73,6 +76,9 @@ dev = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-cov>=6.0",
# Pinned: ruff 0.15's formatter reflow fails `make check`. Unpin once the
# formatting churn is resolved. Mirrored as a Dependabot ignore in
# .github/dependabot.yml.
"ruff>=0.14.10,<0.15",
]

Expand Down
187 changes: 187 additions & 0 deletions src/pythinker_code/models_dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
from __future__ import annotations

import asyncio
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast

import aiohttp

from pythinker_code.utils.aiohttp import new_client_session
from pythinker_code.utils.logging import logger

_MODELS_DEV_URL = "https://models.dev/api.json"
_TTL_SECONDS = 86_400 # 24 hours

# Canonical providers win over regional/cloud variants when the same
# bare model id appears under multiple providers.
_CANONICAL_PROVIDERS = {
"anthropic",
"openai",
"google",
"deepseek",
"z-ai",
"moonshot",
"minimax",
"meta",
"mistral",
"cohere",
"x-ai",
}

# Module-level lock: only one network fetch runs at a time.
_refresh_lock = asyncio.Lock()

# In-process memo: (mtime_ns, catalog_dict)
_catalog_cache: dict[str, Any] = {}


@dataclass(frozen=True)
class ModelPrice:
input: float # USD / 1M tokens
output: float
cache_read: float # 0.0 if absent
cache_write: float # 0.0 if absent


def _get_cache_path() -> Path:
"""Return ~/.pythinker/model-pricing/models-dev.json (or $PYTHINKER_DIR variant)."""
base = Path(os.environ.get("PYTHINKER_DIR") or Path.home() / ".pythinker")
return base / "model-pricing" / "models-dev.json"


def _coerce_cost(raw: object) -> float:
"""Coerce a cost field to float; returns 0.0 for None, raises on non-numeric."""
if raw is None:
return 0.0
if isinstance(raw, (int, float, str)):
return float(raw)
raise TypeError(f"non-numeric cost value: {raw!r}")


def _flatten_catalog(raw: dict[str, Any]) -> dict[str, ModelPrice]:
"""Flatten provider→models hierarchy into {model_id: ModelPrice}.

Canonical providers win over regional/compat variants.
Model ids containing '@' (version-tagged) are excluded.
context_over_200k tiered pricing is ignored.
Models with any non-numeric cost field are skipped.
"""
canonical: dict[str, ModelPrice] = {}
fallback: dict[str, ModelPrice] = {}

for provider_id, provider_data in raw.items():
if not isinstance(provider_data, dict):
continue
models = cast(dict[str, Any], provider_data).get("models")
if not isinstance(models, dict):
continue
target = canonical if provider_id in _CANONICAL_PROVIDERS else fallback
for model_id, model_data in sorted(cast(dict[str, Any], models).items()):
if "@" in model_id:
continue
if not isinstance(model_data, dict):
continue
cost = cast(dict[str, Any], model_data).get("cost")
if not isinstance(cost, dict):
continue
cost_map = cast(dict[str, Any], cost)
try:
price = ModelPrice(
input=_coerce_cost(cost_map.get("input")),
output=_coerce_cost(cost_map.get("output")),
cache_read=_coerce_cost(cost_map.get("cache_read")),
cache_write=_coerce_cost(cost_map.get("cache_write")),
)
except (TypeError, ValueError):
continue
if model_id not in target:
target[model_id] = price

merged = {**fallback, **canonical}
return merged


def load_catalog() -> dict[str, ModelPrice]:
"""Sync. Return flattened {model_id: ModelPrice} from disk cache.

Returns {} when no cache file exists or the file is unreadable/corrupt.
Memoises by file mtime_ns — re-parses only after a successful refresh.
"""
cache_path = _get_cache_path()
try:
mtime_ns = cache_path.stat().st_mtime_ns
except OSError:
return {}

cached = _catalog_cache.get("entry")
if cached is not None and cached[0] == mtime_ns:
return cached[1] # type: ignore[return-value]

try:
raw = json.loads(cache_path.read_text(encoding="utf-8"))
except Exception:
return {}

# Valid-but-non-object JSON (e.g. a top-level list) must not escape the
# {} fallback contract — _flatten_catalog assumes a provider mapping.
if not isinstance(raw, dict):
return {}

result = _flatten_catalog(cast(dict[str, Any], raw))
_catalog_cache["entry"] = (mtime_ns, result)
return result


async def _do_fetch(cache_path: Path) -> bool:
"""Fetch models.dev/api.json and write it atomically to *cache_path*."""
tmp_path = cache_path.with_suffix(".tmp")
try:
async with (
new_client_session() as session,
session.get(
_MODELS_DEV_URL,
timeout=aiohttp.ClientTimeout(total=10),
raise_for_status=True,
) as resp,
):
text = await resp.text()
# Validate it's parseable JSON before writing.
json.loads(text)
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(text, encoding="utf-8")
os.replace(tmp_path, cache_path)
return True
except Exception as exc:
tmp_path.unlink(missing_ok=True)
logger.debug("models.dev fetch failed: {error}", error=exc)
return False


async def refresh_catalog(*, force: bool = False) -> bool:
"""Async. Fetch models.dev/api.json if cache is missing or stale (>24h).

Returns True when cache is valid (fresh or just refreshed), False on
network/write failure. Never raises.
"""
cache_path = _get_cache_path()
if not force:
try:
age = time.time() - cache_path.stat().st_mtime
if age < _TTL_SECONDS:
return True
except OSError:
pass

async with _refresh_lock:
if not force:
try:
age = time.time() - cache_path.stat().st_mtime
if age < _TTL_SECONDS:
return True
except OSError:
pass
return await _do_fetch(cache_path)
Loading
Loading