usage-cli 0.29.32__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
pricing.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal, Protocol
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
LITELLM_PRICING_URL = (
|
|
25
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
|
|
26
|
+
)
|
|
27
|
+
DEFAULT_CACHE_PATH = Path(os.path.expanduser("~/.usage/pricing_cache.json"))
|
|
28
|
+
DEFAULT_LEGACY_CACHE_PATH = Path(os.path.expanduser("~/.claude/pricing_cache.json"))
|
|
29
|
+
CACHE_PATH = DEFAULT_CACHE_PATH
|
|
30
|
+
LEGACY_CACHE_PATH = DEFAULT_LEGACY_CACHE_PATH
|
|
31
|
+
CACHE_TTL_DAYS = 7
|
|
32
|
+
FALLBACK_RETRY_SECONDS = 600
|
|
33
|
+
MISSING_MODEL_REFRESH_SECONDS = FALLBACK_RETRY_SECONDS
|
|
34
|
+
USER_AGENT = "usage/0.9"
|
|
35
|
+
# The date when the hard-coded fallback table was last manually checked upstream.
|
|
36
|
+
FALLBACK_PRICING_AS_OF: str = "2026-08-14"
|
|
37
|
+
# Anthropic's official cache write/read prices relative to input pricing. These
|
|
38
|
+
# are only fallbacks for missing upstream fields and may be wrong for other providers.
|
|
39
|
+
CACHE_WRITE_COST_MULTIPLIER = 1.25
|
|
40
|
+
CACHE_READ_COST_MULTIPLIER = 0.1
|
|
41
|
+
# The upstream table is a few MB; cap the read so a broken or hostile mirror
|
|
42
|
+
# cannot make us buffer an unbounded response.
|
|
43
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
44
|
+
PROVIDER_PREFIXES = (
|
|
45
|
+
"openai/",
|
|
46
|
+
"anthropic/",
|
|
47
|
+
"bedrock/",
|
|
48
|
+
"azure/",
|
|
49
|
+
"vertex_ai/",
|
|
50
|
+
"vertex/",
|
|
51
|
+
"google/",
|
|
52
|
+
)
|
|
53
|
+
DATE_SUFFIX_RE = re.compile(r"-(?:\d{8}|\d{4}-\d{2}-\d{2})$")
|
|
54
|
+
|
|
55
|
+
PricingTable = dict[str, dict[str, float]]
|
|
56
|
+
PricingSource = Literal["cache", "stale", "fetched", "fallback"]
|
|
57
|
+
|
|
58
|
+
_pricing_cache: tuple[PricingTable, PricingSource, float] | None = None
|
|
59
|
+
_pricing_cache_lock = threading.Lock()
|
|
60
|
+
_pricing_warm_up_in_progress = False
|
|
61
|
+
_pricing_miss_refresh_at: float | None = None
|
|
62
|
+
_pricing_miss_refresh_lock = threading.Lock()
|
|
63
|
+
_model_key_cache_lock = threading.Lock()
|
|
64
|
+
_model_key_cache: dict[int, tuple[PricingTable, dict[str, str | None]]] = {}
|
|
65
|
+
_fallback_warning_lock = threading.Lock()
|
|
66
|
+
_fallback_warning_emitted = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _CostEntry(Protocol):
|
|
70
|
+
model: str
|
|
71
|
+
input_tokens: int
|
|
72
|
+
output_tokens: int
|
|
73
|
+
cache_creation_tokens: int
|
|
74
|
+
cache_read_tokens: int
|
|
75
|
+
cost_usd: float | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def calculate_cost(entry: _CostEntry) -> float:
|
|
79
|
+
if entry.cost_usd is not None:
|
|
80
|
+
return entry.cost_usd
|
|
81
|
+
|
|
82
|
+
pricing = get_pricing()
|
|
83
|
+
model_key = _resolve_model_key_cached(entry.model, pricing)
|
|
84
|
+
if model_key is None:
|
|
85
|
+
_request_pricing_refresh_for_missing_model()
|
|
86
|
+
return 0.0
|
|
87
|
+
|
|
88
|
+
model_pricing = pricing[model_key]
|
|
89
|
+
input_cost = model_pricing.get("input_cost_per_token", 0.0)
|
|
90
|
+
output_cost = model_pricing.get("output_cost_per_token", 0.0)
|
|
91
|
+
cache_creation_cost = model_pricing.get(
|
|
92
|
+
"cache_creation_input_token_cost",
|
|
93
|
+
input_cost * CACHE_WRITE_COST_MULTIPLIER,
|
|
94
|
+
)
|
|
95
|
+
cache_read_cost = model_pricing.get(
|
|
96
|
+
"cache_read_input_token_cost",
|
|
97
|
+
input_cost * CACHE_READ_COST_MULTIPLIER,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
cost = (
|
|
101
|
+
entry.input_tokens * input_cost
|
|
102
|
+
+ entry.output_tokens * output_cost
|
|
103
|
+
+ entry.cache_creation_tokens * cache_creation_cost
|
|
104
|
+
+ entry.cache_read_tokens * cache_read_cost
|
|
105
|
+
)
|
|
106
|
+
return cost
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_model_priced(model: str) -> bool:
|
|
110
|
+
"""Check if a model has pricing information available."""
|
|
111
|
+
pricing = get_pricing()
|
|
112
|
+
model_key = _resolve_model_key_cached(model, pricing)
|
|
113
|
+
if model_key is None:
|
|
114
|
+
_request_pricing_refresh_for_missing_model()
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolve_model_key_cached(model: str, pricing: PricingTable) -> str | None:
|
|
120
|
+
with _model_key_cache_lock:
|
|
121
|
+
per_table_cache = _model_key_cache.get(id(pricing))
|
|
122
|
+
if per_table_cache is None or per_table_cache[0] is not pricing:
|
|
123
|
+
model_cache: dict[str, str | None] = {}
|
|
124
|
+
_model_key_cache[id(pricing)] = (pricing, model_cache)
|
|
125
|
+
else:
|
|
126
|
+
model_cache = per_table_cache[1]
|
|
127
|
+
|
|
128
|
+
if model in model_cache:
|
|
129
|
+
return model_cache[model]
|
|
130
|
+
|
|
131
|
+
resolved = _resolve_model_key(model, pricing)
|
|
132
|
+
model_cache[model] = resolved
|
|
133
|
+
return resolved
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_pricing() -> PricingTable:
|
|
137
|
+
global _pricing_cache
|
|
138
|
+
now = time.monotonic()
|
|
139
|
+
with _pricing_cache_lock:
|
|
140
|
+
cached_entry = _pricing_cache
|
|
141
|
+
if cached_entry is not None:
|
|
142
|
+
pricing, source, cached_at = cached_entry
|
|
143
|
+
if _memory_cache_is_fresh(source, cached_at, now):
|
|
144
|
+
return pricing
|
|
145
|
+
warm_up_pricing()
|
|
146
|
+
return pricing
|
|
147
|
+
|
|
148
|
+
pricing, source = _load_pricing_with_source()
|
|
149
|
+
with _pricing_cache_lock:
|
|
150
|
+
_pricing_cache = (pricing, source, now)
|
|
151
|
+
if source in {"stale", "fallback"}:
|
|
152
|
+
warm_up_pricing()
|
|
153
|
+
return pricing
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _memory_cache_is_fresh(source: PricingSource, cached_at: float, now: float) -> bool:
|
|
157
|
+
if source == "fallback":
|
|
158
|
+
return (now - cached_at) <= FALLBACK_RETRY_SECONDS
|
|
159
|
+
if source == "stale":
|
|
160
|
+
return False
|
|
161
|
+
return (now - cached_at) <= CACHE_TTL_DAYS * 86400
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def warm_up_pricing(
|
|
165
|
+
on_ready: Callable[[], None] | None = None,
|
|
166
|
+
*,
|
|
167
|
+
force: bool = False,
|
|
168
|
+
) -> None:
|
|
169
|
+
global _pricing_warm_up_in_progress
|
|
170
|
+
with _pricing_cache_lock:
|
|
171
|
+
if _pricing_warm_up_in_progress:
|
|
172
|
+
return
|
|
173
|
+
_pricing_warm_up_in_progress = True
|
|
174
|
+
|
|
175
|
+
thread = threading.Thread(
|
|
176
|
+
target=_warm_up_pricing_worker,
|
|
177
|
+
args=(on_ready, force),
|
|
178
|
+
daemon=True,
|
|
179
|
+
)
|
|
180
|
+
thread.start()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _warm_up_pricing_worker(
|
|
184
|
+
on_ready: Callable[[], None] | None,
|
|
185
|
+
force: bool,
|
|
186
|
+
) -> None:
|
|
187
|
+
global _pricing_cache, _pricing_warm_up_in_progress
|
|
188
|
+
try:
|
|
189
|
+
with _pricing_cache_lock:
|
|
190
|
+
baseline = _pricing_cache
|
|
191
|
+
if baseline is None:
|
|
192
|
+
baseline_pricing, baseline_source = _load_pricing_with_source()
|
|
193
|
+
baseline = (baseline_pricing, baseline_source, time.monotonic())
|
|
194
|
+
|
|
195
|
+
_, source, cached_at = baseline
|
|
196
|
+
if (
|
|
197
|
+
not force
|
|
198
|
+
and source == "cache"
|
|
199
|
+
and _memory_cache_is_fresh(source, cached_at, time.monotonic())
|
|
200
|
+
):
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
fetched = _fetch_pricing()
|
|
204
|
+
if not fetched:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
_write_cache(fetched)
|
|
208
|
+
now = time.monotonic()
|
|
209
|
+
with _pricing_cache_lock:
|
|
210
|
+
previous = _pricing_cache or baseline
|
|
211
|
+
pricing, source, _ = previous
|
|
212
|
+
should_notify = source in {"stale", "fallback"} or pricing != fetched
|
|
213
|
+
_pricing_cache = (fetched, "fetched", now)
|
|
214
|
+
|
|
215
|
+
if should_notify and on_ready is not None:
|
|
216
|
+
on_ready()
|
|
217
|
+
except Exception:
|
|
218
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
219
|
+
logger.warning("failed to warm up pricing", exc_info=True)
|
|
220
|
+
finally:
|
|
221
|
+
with _pricing_cache_lock:
|
|
222
|
+
_pricing_warm_up_in_progress = False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _set_pricing_cache_for_test(
|
|
226
|
+
value: tuple[PricingTable, PricingSource, float] | None,
|
|
227
|
+
) -> None:
|
|
228
|
+
global _pricing_cache
|
|
229
|
+
with _pricing_cache_lock:
|
|
230
|
+
_pricing_cache = value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _get_pricing_cache_for_test() -> tuple[PricingTable, PricingSource, float] | None:
|
|
234
|
+
with _pricing_cache_lock:
|
|
235
|
+
return _pricing_cache
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _reset_pricing_warm_up_for_test() -> None:
|
|
239
|
+
global _fallback_warning_emitted, _pricing_miss_refresh_at, _pricing_warm_up_in_progress
|
|
240
|
+
with _pricing_cache_lock:
|
|
241
|
+
_pricing_warm_up_in_progress = False
|
|
242
|
+
with _pricing_miss_refresh_lock:
|
|
243
|
+
_pricing_miss_refresh_at = None
|
|
244
|
+
with _fallback_warning_lock:
|
|
245
|
+
_fallback_warning_emitted = False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _load_pricing() -> PricingTable:
|
|
249
|
+
pricing, _ = _load_pricing_with_source()
|
|
250
|
+
return pricing
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_pricing_with_source() -> tuple[PricingTable, PricingSource]:
|
|
254
|
+
cached = _read_cache()
|
|
255
|
+
if cached:
|
|
256
|
+
return cached, "cache"
|
|
257
|
+
|
|
258
|
+
stale_cached = _read_cache(allow_stale=True)
|
|
259
|
+
if stale_cached:
|
|
260
|
+
return stale_cached, "stale"
|
|
261
|
+
|
|
262
|
+
_warn_fallback_pricing_once()
|
|
263
|
+
return _fallback_pricing(), "fallback"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _warn_fallback_pricing_once() -> None:
|
|
267
|
+
global _fallback_warning_emitted
|
|
268
|
+
with _fallback_warning_lock:
|
|
269
|
+
if _fallback_warning_emitted:
|
|
270
|
+
return
|
|
271
|
+
_fallback_warning_emitted = True
|
|
272
|
+
logger.warning(
|
|
273
|
+
"using offline fallback pricing table last verified %s",
|
|
274
|
+
FALLBACK_PRICING_AS_OF,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _read_cache(*, allow_stale: bool = False) -> PricingTable | None:
|
|
279
|
+
use_legacy = CACHE_PATH == DEFAULT_CACHE_PATH or LEGACY_CACHE_PATH != DEFAULT_LEGACY_CACHE_PATH
|
|
280
|
+
path = CACHE_PATH if CACHE_PATH.exists() or not use_legacy else LEGACY_CACHE_PATH
|
|
281
|
+
cache_mtime: float | None = None
|
|
282
|
+
with contextlib.suppress(OSError):
|
|
283
|
+
cache_mtime = path.stat().st_mtime
|
|
284
|
+
if cache_mtime is None:
|
|
285
|
+
return None
|
|
286
|
+
if not allow_stale and (time.time() - cache_mtime) > CACHE_TTL_DAYS * 86400:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
with contextlib.suppress(OSError), path.open(encoding="utf-8") as file:
|
|
290
|
+
try:
|
|
291
|
+
return _normalize_pricing(json.load(file))
|
|
292
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
293
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
294
|
+
logger.warning("failed to decode pricing cache %s", path, exc_info=True)
|
|
295
|
+
return None
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _fetch_pricing() -> PricingTable | None:
|
|
300
|
+
request = urllib.request.Request(LITELLM_PRICING_URL, headers={"User-Agent": USER_AGENT})
|
|
301
|
+
try:
|
|
302
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
303
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
304
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
305
|
+
raise ValueError("pricing response exceeds the size limit")
|
|
306
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
307
|
+
except (OSError, UnicodeDecodeError, ValueError, TimeoutError):
|
|
308
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
309
|
+
logger.warning("failed to fetch pricing from %s", LITELLM_PRICING_URL, exc_info=True)
|
|
310
|
+
return None
|
|
311
|
+
return _normalize_pricing(payload)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _write_cache(pricing: PricingTable) -> None:
|
|
315
|
+
tmp_path: str | None = None
|
|
316
|
+
try:
|
|
317
|
+
with contextlib.suppress(OSError):
|
|
318
|
+
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
fd, tmp_path = tempfile.mkstemp(dir=CACHE_PATH.parent, suffix=".tmp")
|
|
320
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
321
|
+
json.dump(pricing, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
322
|
+
os.replace(tmp_path, CACHE_PATH)
|
|
323
|
+
tmp_path = None
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
logger.warning("failed to write pricing cache: %s", exc)
|
|
326
|
+
return
|
|
327
|
+
finally:
|
|
328
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
329
|
+
with contextlib.suppress(OSError):
|
|
330
|
+
os.unlink(tmp_path)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _normalize_pricing(payload: Any) -> PricingTable | None:
|
|
334
|
+
if not isinstance(payload, dict):
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
pricing: PricingTable = {}
|
|
338
|
+
for model, raw_info in payload.items():
|
|
339
|
+
if not isinstance(model, str) or not isinstance(raw_info, dict):
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
info: dict[str, float] = {}
|
|
343
|
+
for key in (
|
|
344
|
+
"input_cost_per_token",
|
|
345
|
+
"output_cost_per_token",
|
|
346
|
+
"cache_creation_input_token_cost",
|
|
347
|
+
"cache_read_input_token_cost",
|
|
348
|
+
):
|
|
349
|
+
value = raw_info.get(key)
|
|
350
|
+
if isinstance(value, int | float):
|
|
351
|
+
info[key] = float(value)
|
|
352
|
+
|
|
353
|
+
if info:
|
|
354
|
+
pricing[model] = info
|
|
355
|
+
|
|
356
|
+
return pricing or None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _resolve_model_key(model: str, pricing: PricingTable) -> str | None:
|
|
360
|
+
if model in pricing:
|
|
361
|
+
return model
|
|
362
|
+
|
|
363
|
+
normalized = _normalize_model_name(model)
|
|
364
|
+
if normalized in pricing:
|
|
365
|
+
return normalized
|
|
366
|
+
|
|
367
|
+
prefix_matches = [
|
|
368
|
+
key
|
|
369
|
+
for key in pricing
|
|
370
|
+
if key.startswith(normalized)
|
|
371
|
+
and (len(key) == len(normalized) or key[len(normalized)] == "-")
|
|
372
|
+
]
|
|
373
|
+
if prefix_matches:
|
|
374
|
+
return sorted(prefix_matches, key=lambda key: (len(key), key))[0]
|
|
375
|
+
|
|
376
|
+
logger.debug("pricing: no match for model=%s", model)
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _normalize_model_name(model: str) -> str:
|
|
381
|
+
normalized = model.strip().lower()
|
|
382
|
+
for prefix in PROVIDER_PREFIXES:
|
|
383
|
+
if normalized.startswith(prefix):
|
|
384
|
+
normalized = normalized[len(prefix) :]
|
|
385
|
+
break
|
|
386
|
+
return DATE_SUFFIX_RE.sub("", normalized)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _fallback_pricing() -> PricingTable:
|
|
390
|
+
return {
|
|
391
|
+
"claude-opus-4-6": {
|
|
392
|
+
"input_cost_per_token": 5e-6,
|
|
393
|
+
"output_cost_per_token": 25e-6,
|
|
394
|
+
"cache_creation_input_token_cost": 6.25e-6,
|
|
395
|
+
"cache_read_input_token_cost": 0.5e-6,
|
|
396
|
+
},
|
|
397
|
+
"claude-opus-4-7": {
|
|
398
|
+
"input_cost_per_token": 5e-6,
|
|
399
|
+
"output_cost_per_token": 25e-6,
|
|
400
|
+
"cache_creation_input_token_cost": 6.25e-6,
|
|
401
|
+
"cache_read_input_token_cost": 0.5e-6,
|
|
402
|
+
},
|
|
403
|
+
"claude-opus-4-8": {
|
|
404
|
+
"input_cost_per_token": 5e-6,
|
|
405
|
+
"output_cost_per_token": 25e-6,
|
|
406
|
+
"cache_creation_input_token_cost": 6.25e-6,
|
|
407
|
+
"cache_read_input_token_cost": 0.5e-6,
|
|
408
|
+
},
|
|
409
|
+
"claude-opus-5": {
|
|
410
|
+
"input_cost_per_token": 5e-6,
|
|
411
|
+
"output_cost_per_token": 25e-6,
|
|
412
|
+
"cache_creation_input_token_cost": 6.25e-6,
|
|
413
|
+
"cache_read_input_token_cost": 0.5e-6,
|
|
414
|
+
},
|
|
415
|
+
"claude-sonnet-4-6": {
|
|
416
|
+
"input_cost_per_token": 3e-6,
|
|
417
|
+
"output_cost_per_token": 15e-6,
|
|
418
|
+
"cache_creation_input_token_cost": 3.75e-6,
|
|
419
|
+
"cache_read_input_token_cost": 0.3e-6,
|
|
420
|
+
},
|
|
421
|
+
"claude-sonnet-5": {
|
|
422
|
+
"input_cost_per_token": 2e-6,
|
|
423
|
+
"output_cost_per_token": 10e-6,
|
|
424
|
+
"cache_creation_input_token_cost": 2.5e-6,
|
|
425
|
+
"cache_read_input_token_cost": 0.2e-6,
|
|
426
|
+
},
|
|
427
|
+
"claude-fable-5": {
|
|
428
|
+
"input_cost_per_token": 10e-6,
|
|
429
|
+
"output_cost_per_token": 50e-6,
|
|
430
|
+
"cache_creation_input_token_cost": 12.5e-6,
|
|
431
|
+
"cache_read_input_token_cost": 1e-6,
|
|
432
|
+
},
|
|
433
|
+
"claude-haiku-4-5-20251001": {
|
|
434
|
+
"input_cost_per_token": 1e-6,
|
|
435
|
+
"output_cost_per_token": 5e-6,
|
|
436
|
+
"cache_creation_input_token_cost": 1.25e-6,
|
|
437
|
+
"cache_read_input_token_cost": 0.1e-6,
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _request_pricing_refresh_for_missing_model() -> None:
|
|
443
|
+
global _pricing_miss_refresh_at
|
|
444
|
+
now = time.monotonic()
|
|
445
|
+
with _pricing_miss_refresh_lock:
|
|
446
|
+
if (
|
|
447
|
+
_pricing_miss_refresh_at is not None
|
|
448
|
+
and (now - _pricing_miss_refresh_at) < MISSING_MODEL_REFRESH_SECONDS
|
|
449
|
+
):
|
|
450
|
+
return
|
|
451
|
+
_pricing_miss_refresh_at = now
|
|
452
|
+
warm_up_pricing(force=True)
|
project_resolver.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__all__ = ["project_from_encoded_path", "resolve_project_name"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=256)
|
|
18
|
+
def resolve_project_name(cwd: str | Path) -> str:
|
|
19
|
+
"""Resolve a cwd to its canonical project name, including git worktrees."""
|
|
20
|
+
if not str(cwd):
|
|
21
|
+
return "unknown"
|
|
22
|
+
path = Path(os.path.expanduser(str(cwd))).resolve(strict=False)
|
|
23
|
+
return _resolve_project_name(str(path))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=256)
|
|
27
|
+
def _resolve_project_name(normalized_cwd: str) -> str:
|
|
28
|
+
fallback = Path(normalized_cwd).name or "unknown"
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["git", "-C", normalized_cwd, "worktree", "list", "--porcelain"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
check=False,
|
|
34
|
+
text=True,
|
|
35
|
+
# Force UTF-8 instead of the locale default: a .app launched via
|
|
36
|
+
# LaunchServices has no LANG set, so text=True would decode git's
|
|
37
|
+
# output as ASCII and crash on non-ASCII (e.g. Chinese) repo paths.
|
|
38
|
+
encoding="utf-8",
|
|
39
|
+
errors="replace",
|
|
40
|
+
timeout=3,
|
|
41
|
+
)
|
|
42
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
43
|
+
return fallback
|
|
44
|
+
|
|
45
|
+
if result.returncode != 0 or result.stderr or not result.stdout:
|
|
46
|
+
return fallback
|
|
47
|
+
|
|
48
|
+
lines = result.stdout.splitlines()
|
|
49
|
+
first_line = lines[0] if lines else ""
|
|
50
|
+
prefix = "worktree "
|
|
51
|
+
if not first_line.startswith(prefix):
|
|
52
|
+
return fallback
|
|
53
|
+
|
|
54
|
+
main_path = first_line.removeprefix(prefix).strip()
|
|
55
|
+
if not main_path:
|
|
56
|
+
return fallback
|
|
57
|
+
return Path(main_path).name or fallback
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def project_from_encoded_path(jsonl_path: Path, projects_dir: Path) -> str:
|
|
61
|
+
"""Decode a Claude Code project name from a sessions JSONL path under projects_dir."""
|
|
62
|
+
try:
|
|
63
|
+
project_dir = jsonl_path.relative_to(projects_dir).parts[0]
|
|
64
|
+
except (IndexError, ValueError):
|
|
65
|
+
return "unknown"
|
|
66
|
+
|
|
67
|
+
parts = [part for part in project_dir.split("-") if part]
|
|
68
|
+
if not parts:
|
|
69
|
+
return "unknown"
|
|
70
|
+
|
|
71
|
+
root, start = _encoded_path_root(parts)
|
|
72
|
+
slash_candidate = root.joinpath(*parts[start:])
|
|
73
|
+
if slash_candidate.is_dir():
|
|
74
|
+
return slash_candidate.name or "unknown"
|
|
75
|
+
|
|
76
|
+
existing_project = _existing_encoded_project_path(parts)
|
|
77
|
+
if existing_project is not None:
|
|
78
|
+
return existing_project.name or "unknown"
|
|
79
|
+
|
|
80
|
+
fallback = project_dir.removeprefix("-")
|
|
81
|
+
return fallback or "unknown"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _existing_encoded_project_path(parts: list[str]) -> Path | None:
|
|
85
|
+
def search(index: int, current: Path) -> Path | None:
|
|
86
|
+
for end in range(index + 1, len(parts) + 1):
|
|
87
|
+
candidate = current / "-".join(parts[index:end])
|
|
88
|
+
if not candidate.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
if end == len(parts):
|
|
91
|
+
return candidate
|
|
92
|
+
result = search(end, candidate)
|
|
93
|
+
if result is not None:
|
|
94
|
+
return result
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
root, start = _encoded_path_root(parts)
|
|
98
|
+
return search(start, root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _encoded_path_root(parts: list[str]) -> tuple[Path, int]:
|
|
102
|
+
"""Return the filesystem root and first encoded component to search."""
|
|
103
|
+
if os.sep == "\\":
|
|
104
|
+
drive = parts[0]
|
|
105
|
+
# Claude Code encodes every non-alphanumeric character as "-", so
|
|
106
|
+
# "C:\Users\me" arrives as "C--Users-me" and the drive survives only
|
|
107
|
+
# as a bare letter; accept "C:" too for robustness.
|
|
108
|
+
if len(drive) == 1 and drive.isalpha():
|
|
109
|
+
return Path(f"{drive}:{os.sep}"), 1
|
|
110
|
+
if len(drive) == 2 and drive[0].isalpha() and drive[1] == ":":
|
|
111
|
+
return Path(drive + os.sep), 1
|
|
112
|
+
return Path(os.sep), 0
|