Skip to content

Commit 575bf06

Browse files
authored
refactor: extract FlowRunner, fix /login warning, harden wire/deps (#84)
Extract FlowRunner from PythinkerSoul, quiet the /login redraw_in_future warning, and harden wire/deps. Resolves all CodeRabbit review findings and fixes the pyright errors that were failing the CI check job.
1 parent 1a831c0 commit 575bf06

22 files changed

Lines changed: 1891 additions & 225 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **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.
19+
1820
## 0.37.0 (2026-06-07)
1921

2022
- **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.

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ dependencies = [
3434
"pyyaml==6.0.3",
3535
"rich==15.0.0",
3636
"certifi>=2025.10.5",
37-
"click==8.3.0",
3837
"pyperclip==1.11.0",
38+
# Pinned: click 8.4.x regresses pyright (`click.Option` typed partially
39+
# unknown, ~95 errors in `make check`). Unpin once that type regression is
40+
# fixed upstream. Mirrored as a Dependabot ignore in .github/dependabot.yml.
41+
"click==8.3.0",
3942
"streamingjson==0.0.5",
4043
"trafilatura==2.0.0",
4144
# lxml is used by trafilatura/htmldate/justext; keep pinned for binary wheels.
@@ -73,6 +76,9 @@ dev = [
7376
"pytest>=9.0.3",
7477
"pytest-asyncio>=1.3.0",
7578
"pytest-cov>=6.0",
79+
# Pinned: ruff 0.15's formatter reflow fails `make check`. Unpin once the
80+
# formatting churn is resolved. Mirrored as a Dependabot ignore in
81+
# .github/dependabot.yml.
7682
"ruff>=0.14.10,<0.15",
7783
]
7884

src/pythinker_code/models_dev.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
import os
6+
import time
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
from typing import Any, cast
10+
11+
import aiohttp
12+
13+
from pythinker_code.utils.aiohttp import new_client_session
14+
from pythinker_code.utils.logging import logger
15+
16+
_MODELS_DEV_URL = "https://models.dev/api.json"
17+
_TTL_SECONDS = 86_400 # 24 hours
18+
19+
# Canonical providers win over regional/cloud variants when the same
20+
# bare model id appears under multiple providers.
21+
_CANONICAL_PROVIDERS = {
22+
"anthropic",
23+
"openai",
24+
"google",
25+
"deepseek",
26+
"z-ai",
27+
"moonshot",
28+
"minimax",
29+
"meta",
30+
"mistral",
31+
"cohere",
32+
"x-ai",
33+
}
34+
35+
# Module-level lock: only one network fetch runs at a time.
36+
_refresh_lock = asyncio.Lock()
37+
38+
# In-process memo: (mtime_ns, catalog_dict)
39+
_catalog_cache: dict[str, Any] = {}
40+
41+
42+
@dataclass(frozen=True)
43+
class ModelPrice:
44+
input: float # USD / 1M tokens
45+
output: float
46+
cache_read: float # 0.0 if absent
47+
cache_write: float # 0.0 if absent
48+
49+
50+
def _get_cache_path() -> Path:
51+
"""Return ~/.pythinker/model-pricing/models-dev.json (or $PYTHINKER_DIR variant)."""
52+
base = Path(os.environ.get("PYTHINKER_DIR") or Path.home() / ".pythinker")
53+
return base / "model-pricing" / "models-dev.json"
54+
55+
56+
def _coerce_cost(raw: object) -> float:
57+
"""Coerce a cost field to float; returns 0.0 for None, raises on non-numeric."""
58+
if raw is None:
59+
return 0.0
60+
if isinstance(raw, (int, float, str)):
61+
return float(raw)
62+
raise TypeError(f"non-numeric cost value: {raw!r}")
63+
64+
65+
def _flatten_catalog(raw: dict[str, Any]) -> dict[str, ModelPrice]:
66+
"""Flatten provider→models hierarchy into {model_id: ModelPrice}.
67+
68+
Canonical providers win over regional/compat variants.
69+
Model ids containing '@' (version-tagged) are excluded.
70+
context_over_200k tiered pricing is ignored.
71+
Models with any non-numeric cost field are skipped.
72+
"""
73+
canonical: dict[str, ModelPrice] = {}
74+
fallback: dict[str, ModelPrice] = {}
75+
76+
for provider_id, provider_data in raw.items():
77+
if not isinstance(provider_data, dict):
78+
continue
79+
models = cast(dict[str, Any], provider_data).get("models")
80+
if not isinstance(models, dict):
81+
continue
82+
target = canonical if provider_id in _CANONICAL_PROVIDERS else fallback
83+
for model_id, model_data in sorted(cast(dict[str, Any], models).items()):
84+
if "@" in model_id:
85+
continue
86+
if not isinstance(model_data, dict):
87+
continue
88+
cost = cast(dict[str, Any], model_data).get("cost")
89+
if not isinstance(cost, dict):
90+
continue
91+
cost_map = cast(dict[str, Any], cost)
92+
try:
93+
price = ModelPrice(
94+
input=_coerce_cost(cost_map.get("input")),
95+
output=_coerce_cost(cost_map.get("output")),
96+
cache_read=_coerce_cost(cost_map.get("cache_read")),
97+
cache_write=_coerce_cost(cost_map.get("cache_write")),
98+
)
99+
except (TypeError, ValueError):
100+
continue
101+
if model_id not in target:
102+
target[model_id] = price
103+
104+
merged = {**fallback, **canonical}
105+
return merged
106+
107+
108+
def load_catalog() -> dict[str, ModelPrice]:
109+
"""Sync. Return flattened {model_id: ModelPrice} from disk cache.
110+
111+
Returns {} when no cache file exists or the file is unreadable/corrupt.
112+
Memoises by file mtime_ns — re-parses only after a successful refresh.
113+
"""
114+
cache_path = _get_cache_path()
115+
try:
116+
mtime_ns = cache_path.stat().st_mtime_ns
117+
except OSError:
118+
return {}
119+
120+
cached = _catalog_cache.get("entry")
121+
if cached is not None and cached[0] == mtime_ns:
122+
return cached[1] # type: ignore[return-value]
123+
124+
try:
125+
raw = json.loads(cache_path.read_text(encoding="utf-8"))
126+
except Exception:
127+
return {}
128+
129+
# Valid-but-non-object JSON (e.g. a top-level list) must not escape the
130+
# {} fallback contract — _flatten_catalog assumes a provider mapping.
131+
if not isinstance(raw, dict):
132+
return {}
133+
134+
result = _flatten_catalog(cast(dict[str, Any], raw))
135+
_catalog_cache["entry"] = (mtime_ns, result)
136+
return result
137+
138+
139+
async def _do_fetch(cache_path: Path) -> bool:
140+
"""Fetch models.dev/api.json and write it atomically to *cache_path*."""
141+
tmp_path = cache_path.with_suffix(".tmp")
142+
try:
143+
async with (
144+
new_client_session() as session,
145+
session.get(
146+
_MODELS_DEV_URL,
147+
timeout=aiohttp.ClientTimeout(total=10),
148+
raise_for_status=True,
149+
) as resp,
150+
):
151+
text = await resp.text()
152+
# Validate it's parseable JSON before writing.
153+
json.loads(text)
154+
cache_path.parent.mkdir(parents=True, exist_ok=True)
155+
tmp_path.write_text(text, encoding="utf-8")
156+
os.replace(tmp_path, cache_path)
157+
return True
158+
except Exception as exc:
159+
tmp_path.unlink(missing_ok=True)
160+
logger.debug("models.dev fetch failed: {error}", error=exc)
161+
return False
162+
163+
164+
async def refresh_catalog(*, force: bool = False) -> bool:
165+
"""Async. Fetch models.dev/api.json if cache is missing or stale (>24h).
166+
167+
Returns True when cache is valid (fresh or just refreshed), False on
168+
network/write failure. Never raises.
169+
"""
170+
cache_path = _get_cache_path()
171+
if not force:
172+
try:
173+
age = time.time() - cache_path.stat().st_mtime
174+
if age < _TTL_SECONDS:
175+
return True
176+
except OSError:
177+
pass
178+
179+
async with _refresh_lock:
180+
if not force:
181+
try:
182+
age = time.time() - cache_path.stat().st_mtime
183+
if age < _TTL_SECONDS:
184+
return True
185+
except OSError:
186+
pass
187+
return await _do_fetch(cache_path)

0 commit comments

Comments
 (0)