cctally 1.21.3 → 1.22.1
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.
- package/CHANGELOG.md +23 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_config.py +135 -0
- package/bin/_cctally_core.py +120 -0
- package/bin/_cctally_dashboard.py +155 -23
- package/bin/_cctally_db.py +3 -0
- package/bin/_cctally_parser.py +2541 -0
- package/bin/_cctally_record.py +148 -0
- package/bin/_cctally_share.py +1707 -0
- package/bin/_lib_alerts_payload.py +50 -0
- package/bin/_lib_budget.py +133 -0
- package/bin/_lib_doctor.py +74 -0
- package/bin/_lib_pricing.py +213 -13
- package/bin/_lib_pricing_check.py +201 -0
- package/bin/cctally +1263 -4266
- package/bin/cctally-budget +4 -0
- package/dashboard/static/assets/index-BxmaYT1y.css +1 -0
- package/dashboard/static/assets/index-CLcd-Tnm.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-BJ16SzRL.js +0 -18
- package/dashboard/static/assets/index-C1xH9GBW.css +0 -1
|
@@ -192,3 +192,53 @@ def _build_alert_payload_five_hour(
|
|
|
192
192
|
"primary_model": primary_model,
|
|
193
193
|
},
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _alert_text_budget(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
|
|
198
|
+
"""Build (title, subtitle, body) for an equiv-$ budget threshold alert.
|
|
199
|
+
|
|
200
|
+
``week_start_at`` is an instant, but the budget alert text doesn't render
|
|
201
|
+
it (the subtitle is the threshold, the body the dollar progress) — so no
|
|
202
|
+
``format_display_dt`` call is needed here. ``tz`` is accepted for
|
|
203
|
+
signature parity with peer ``_alert_text_*`` builders and intentionally
|
|
204
|
+
unused.
|
|
205
|
+
"""
|
|
206
|
+
threshold = int(payload["threshold"])
|
|
207
|
+
title = "cctally - budget"
|
|
208
|
+
subtitle = f"{threshold}% of budget"
|
|
209
|
+
ctx = payload.get("context") or {}
|
|
210
|
+
spent = float(ctx.get("spent_usd") or 0.0)
|
|
211
|
+
budget = float(ctx.get("budget_usd") or 0.0)
|
|
212
|
+
consumption = float(ctx.get("consumption_pct") or 0.0)
|
|
213
|
+
body = f"${spent:,.2f} of ${budget:,.2f} ({consumption:.0f}% of budget)"
|
|
214
|
+
return title, subtitle, body
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_alert_payload_budget(
|
|
218
|
+
*,
|
|
219
|
+
threshold: int,
|
|
220
|
+
crossed_at_utc: str,
|
|
221
|
+
week_start_at: str,
|
|
222
|
+
budget_usd: float,
|
|
223
|
+
spent_usd: float,
|
|
224
|
+
consumption_pct: float,
|
|
225
|
+
) -> dict:
|
|
226
|
+
"""Build the alert payload for an equiv-$ budget threshold crossing.
|
|
227
|
+
|
|
228
|
+
See ``_build_alert_payload_weekly`` for the ``alerted_at == crossed_at``
|
|
229
|
+
rationale (set-then-dispatch invariant). ``axis: "budget"`` is the third
|
|
230
|
+
alert axis (Task 4 surfaces it in the dashboard Recent-alerts panel).
|
|
231
|
+
"""
|
|
232
|
+
return {
|
|
233
|
+
"id": f"budget:{week_start_at}:{threshold}",
|
|
234
|
+
"axis": "budget",
|
|
235
|
+
"threshold": int(threshold),
|
|
236
|
+
"crossed_at": crossed_at_utc,
|
|
237
|
+
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
238
|
+
"context": {
|
|
239
|
+
"week_start_at": week_start_at,
|
|
240
|
+
"budget_usd": float(budget_usd),
|
|
241
|
+
"spent_usd": float(spent_usd),
|
|
242
|
+
"consumption_pct": float(consumption_pct),
|
|
243
|
+
},
|
|
244
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Pure-function kernel for `cctally budget` (no I/O — every dep injected).
|
|
2
|
+
|
|
3
|
+
Mirrors the _lib_statusline.py / _lib_doctor.py / _lib_pricing_check.py
|
|
4
|
+
pattern. Re-exported on the cctally module. See
|
|
5
|
+
docs/superpowers/specs/2026-05-29-cctally-budget-design.md §3.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# Early in the week / no data → projections are unreliable; annotate LOW CONF
|
|
13
|
+
# (mirrors forecast's thin-data caution). Tunable single source of truth.
|
|
14
|
+
_BUDGET_LOW_CONF_ELAPSED_FRACTION = 0.15
|
|
15
|
+
# Fallback warn fraction when alert_thresholds is empty (alerts silenced) but
|
|
16
|
+
# we still render a verdict.
|
|
17
|
+
_BUDGET_DEFAULT_WARN_FRACTION = 0.90
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def project_linear(
|
|
21
|
+
current: float,
|
|
22
|
+
remaining: float,
|
|
23
|
+
rate_low: float,
|
|
24
|
+
rate_high: float,
|
|
25
|
+
) -> tuple[float, float]:
|
|
26
|
+
"""Project ``current + rate * remaining`` for a (low, high) rate band.
|
|
27
|
+
|
|
28
|
+
Pure; unit-agnostic — percent for forecast, dollars for budget. The caller
|
|
29
|
+
is responsible for passing ``rate_low <= rate_high`` if it wants ordered
|
|
30
|
+
output; this primitive does NOT sort (forecast sorts the outputs to stay a
|
|
31
|
+
byte-exact no-op vs its goldens; budget passes a pre-ordered band).
|
|
32
|
+
"""
|
|
33
|
+
return (current + rate_low * remaining, current + rate_high * remaining)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class BudgetInputs:
|
|
38
|
+
target_usd: float
|
|
39
|
+
spent_usd: float # cumulative equiv-$ this subscription week
|
|
40
|
+
recent_24h_usd: float # trailing-24h equiv-$ (recent-rate projection)
|
|
41
|
+
week_start_at: dt.datetime # effective (post-reset) week start, tz-aware
|
|
42
|
+
week_end_at: dt.datetime # tz-aware
|
|
43
|
+
now: dt.datetime # tz-aware
|
|
44
|
+
alert_thresholds: tuple[int, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class BudgetStatus:
|
|
49
|
+
spent_usd: float
|
|
50
|
+
remaining_usd: float # target - spent (may be < 0)
|
|
51
|
+
consumption_pct: float # spent / target * 100 (monotonic key)
|
|
52
|
+
elapsed_fraction: float # [0, 1]
|
|
53
|
+
projected_eow_low_usd: float
|
|
54
|
+
projected_eow_high_usd: float
|
|
55
|
+
verdict: str # "ok" | "warn" | "over"
|
|
56
|
+
daily_budget_remaining_usd: float # remaining / remaining-days
|
|
57
|
+
daily_pace_usd: float # current burn $/day (week-average)
|
|
58
|
+
low_confidence: bool
|
|
59
|
+
crossed_thresholds: tuple[int, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_budget_status(inputs: BudgetInputs) -> BudgetStatus:
|
|
63
|
+
"""Compute budget status from injected inputs. Pure; deterministic."""
|
|
64
|
+
target = float(inputs.target_usd)
|
|
65
|
+
spent = float(inputs.spent_usd)
|
|
66
|
+
|
|
67
|
+
total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
|
|
68
|
+
elapsed_seconds = (inputs.now - inputs.week_start_at).total_seconds()
|
|
69
|
+
# Clamp elapsed into [0, total] so a now before/after the window stays sane.
|
|
70
|
+
if total_seconds <= 0:
|
|
71
|
+
elapsed_seconds = 0.0
|
|
72
|
+
elapsed_fraction = 0.0
|
|
73
|
+
else:
|
|
74
|
+
elapsed_seconds = max(0.0, min(elapsed_seconds, total_seconds))
|
|
75
|
+
elapsed_fraction = elapsed_seconds / total_seconds
|
|
76
|
+
remaining_seconds = max(0.0, total_seconds - elapsed_seconds)
|
|
77
|
+
|
|
78
|
+
elapsed_hours = elapsed_seconds / 3600.0
|
|
79
|
+
remaining_hours = remaining_seconds / 3600.0
|
|
80
|
+
remaining_days = remaining_hours / 24.0
|
|
81
|
+
|
|
82
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
83
|
+
remaining_usd = target - spent
|
|
84
|
+
|
|
85
|
+
# Dollar rates ($/hour). Week-average from spend-so-far; recent from
|
|
86
|
+
# trailing-24h spend. Ordered band low<=high for project_linear.
|
|
87
|
+
rate_avg = (spent / elapsed_hours) if elapsed_hours > 0 else 0.0
|
|
88
|
+
rate_recent = float(inputs.recent_24h_usd) / 24.0
|
|
89
|
+
rate_low = min(rate_avg, rate_recent)
|
|
90
|
+
rate_high = max(rate_avg, rate_recent)
|
|
91
|
+
|
|
92
|
+
projected_low, projected_high = project_linear(
|
|
93
|
+
spent, remaining_hours, rate_low, rate_high
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
daily_pace_usd = rate_avg * 24.0
|
|
97
|
+
daily_budget_remaining_usd = (
|
|
98
|
+
(remaining_usd / remaining_days) if remaining_days > 0 else remaining_usd
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
thresholds = tuple(sorted(set(int(t) for t in inputs.alert_thresholds)))
|
|
102
|
+
if thresholds:
|
|
103
|
+
warn_fraction = min(thresholds) / 100.0
|
|
104
|
+
else:
|
|
105
|
+
warn_fraction = _BUDGET_DEFAULT_WARN_FRACTION
|
|
106
|
+
|
|
107
|
+
projected = max(projected_low, projected_high)
|
|
108
|
+
if spent > target or projected > target:
|
|
109
|
+
verdict = "over"
|
|
110
|
+
elif projected >= warn_fraction * target:
|
|
111
|
+
verdict = "warn"
|
|
112
|
+
else:
|
|
113
|
+
verdict = "ok"
|
|
114
|
+
|
|
115
|
+
low_confidence = (
|
|
116
|
+
elapsed_fraction < _BUDGET_LOW_CONF_ELAPSED_FRACTION or spent <= 0.0
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
crossed = tuple(t for t in thresholds if consumption_pct + 1e-9 >= t)
|
|
120
|
+
|
|
121
|
+
return BudgetStatus(
|
|
122
|
+
spent_usd=spent,
|
|
123
|
+
remaining_usd=remaining_usd,
|
|
124
|
+
consumption_pct=consumption_pct,
|
|
125
|
+
elapsed_fraction=elapsed_fraction,
|
|
126
|
+
projected_eow_low_usd=projected_low,
|
|
127
|
+
projected_eow_high_usd=projected_high,
|
|
128
|
+
verdict=verdict,
|
|
129
|
+
daily_budget_remaining_usd=daily_budget_remaining_usd,
|
|
130
|
+
daily_pace_usd=daily_pace_usd,
|
|
131
|
+
low_confidence=low_confidence,
|
|
132
|
+
crossed_thresholds=crossed,
|
|
133
|
+
)
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -130,6 +130,16 @@ class DoctorState:
|
|
|
130
130
|
cctally_reachable_on_path: Optional[bool] = None
|
|
131
131
|
symlinks_path_pinned: bool = False
|
|
132
132
|
install_is_brew: bool = False
|
|
133
|
+
# Pricing coverage (spec §5.1): the list[CoverageGap] of unpriced (Claude
|
|
134
|
+
# $0) / fallback (Codex gpt-5) models observed in the trailing 30-day
|
|
135
|
+
# window, populated by `doctor_gather_state` via `_pricing_observed_models`
|
|
136
|
+
# + `classify_coverage`. None means the cache could not be read (or the
|
|
137
|
+
# classification raised) — the check degrades to OK ("no cached usage to
|
|
138
|
+
# assess"), consistent with the kernel's degradation posture. Each element
|
|
139
|
+
# is a `_lib_pricing_check.CoverageGap` (provider/model/kind/entry_count/
|
|
140
|
+
# token_total); the kernel only reads `.kind`/`.model`/`.entry_count`/
|
|
141
|
+
# `.token_total`, so any duck-typed equivalent works for tests.
|
|
142
|
+
pricing_coverage: Optional[list] = None
|
|
133
143
|
|
|
134
144
|
|
|
135
145
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -761,6 +771,67 @@ def _check_data_post_credit_milestones(s: DoctorState) -> CheckResult:
|
|
|
761
771
|
)
|
|
762
772
|
|
|
763
773
|
|
|
774
|
+
def _check_pricing_coverage(s: DoctorState) -> CheckResult:
|
|
775
|
+
"""WARN when recent (30-day) session data contains a model cctally cannot
|
|
776
|
+
price exactly (spec §5.1).
|
|
777
|
+
|
|
778
|
+
Two gap kinds (classified upstream in `_lib_pricing_check.classify_coverage`,
|
|
779
|
+
populated by `doctor_gather_state`):
|
|
780
|
+
* ``unpriced`` — a Claude model `_resolve_model_pricing` returns None for;
|
|
781
|
+
it silently contributes $0 (the serious undercount failure mode).
|
|
782
|
+
* ``fallback`` — a Codex model approximated via `gpt-5` pricing.
|
|
783
|
+
|
|
784
|
+
``s.pricing_coverage is None`` means the cache could not be read (or the
|
|
785
|
+
classification raised) → OK ("no cached usage to assess"), matching the
|
|
786
|
+
rest of the kernel's degradation posture. An empty list → OK. Any gap →
|
|
787
|
+
WARN (a data-quality signal, deliberately NOT FAIL — doctor FAIL exits 2;
|
|
788
|
+
consistent with the other WARN-family Data checks).
|
|
789
|
+
|
|
790
|
+
``details`` is a structured dict (sibling-check convention): two lists of
|
|
791
|
+
``{model, entry_count, token_total}`` keyed by gap kind, so a `--json`
|
|
792
|
+
consumer can machine-read each gap. The human summary + remediation point
|
|
793
|
+
at `cctally pricing-check` and the pricing tables.
|
|
794
|
+
"""
|
|
795
|
+
gaps = s.pricing_coverage
|
|
796
|
+
if not gaps:
|
|
797
|
+
return CheckResult(
|
|
798
|
+
id="pricing.coverage", title="Coverage",
|
|
799
|
+
severity="ok",
|
|
800
|
+
summary="all observed models priced",
|
|
801
|
+
remediation=None,
|
|
802
|
+
details={"unpriced": [], "fallback": []},
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
def _row(g) -> dict:
|
|
806
|
+
return {
|
|
807
|
+
"model": g.model,
|
|
808
|
+
"entry_count": g.entry_count,
|
|
809
|
+
"token_total": g.token_total,
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
unpriced = [_row(g) for g in gaps if g.kind == "unpriced"]
|
|
813
|
+
fallback = [_row(g) for g in gaps if g.kind == "fallback"]
|
|
814
|
+
|
|
815
|
+
parts: list[str] = []
|
|
816
|
+
if unpriced:
|
|
817
|
+
parts.append(f"{len(unpriced)} unpriced (Claude $0)")
|
|
818
|
+
if fallback:
|
|
819
|
+
parts.append(f"{len(fallback)} fallback (Codex gpt-5)")
|
|
820
|
+
# Defensive: a gap whose kind is neither (shouldn't happen) still WARNs.
|
|
821
|
+
summary = "; ".join(parts) if parts else f"{len(gaps)} coverage gaps"
|
|
822
|
+
|
|
823
|
+
return CheckResult(
|
|
824
|
+
id="pricing.coverage", title="Coverage",
|
|
825
|
+
severity="warn",
|
|
826
|
+
summary=summary,
|
|
827
|
+
remediation=(
|
|
828
|
+
"Run `cctally pricing-check`, then update CLAUDE_MODEL_PRICING / "
|
|
829
|
+
"CODEX_MODEL_PRICING in bin/_lib_pricing.py"
|
|
830
|
+
),
|
|
831
|
+
details={"unpriced": unpriced, "fallback": fallback},
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
764
835
|
_LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
|
|
765
836
|
|
|
766
837
|
|
|
@@ -991,6 +1062,9 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
991
1062
|
("data.forked_buckets", "_check_data_forked_buckets"),
|
|
992
1063
|
("data.post_credit_milestones", "_check_data_post_credit_milestones"),
|
|
993
1064
|
)),
|
|
1065
|
+
("pricing", "Pricing", (
|
|
1066
|
+
("pricing.coverage", "_check_pricing_coverage"),
|
|
1067
|
+
)),
|
|
994
1068
|
("safety", "Safety", (
|
|
995
1069
|
("safety.dashboard_bind", "_check_safety_dashboard_bind"),
|
|
996
1070
|
("safety.config_json_valid", "_check_safety_config_json_valid"),
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -46,9 +46,28 @@ def _chip_for_model(name: str) -> str:
|
|
|
46
46
|
return "other"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
# Date the embedded pricing snapshots below were last verified against
|
|
50
|
+
# vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
|
|
51
|
+
# is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
|
|
52
|
+
PRICING_SNAPSHOT_DATE = "2026-05-30"
|
|
53
|
+
PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
|
|
54
|
+
|
|
55
|
+
# Canonical machine-readable pricing source (Claude values + Codex values).
|
|
56
|
+
LITELLM_PRICES_URL = (
|
|
57
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/"
|
|
58
|
+
"model_prices_and_context_window.json"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Deliberate divergences from LiteLLM the drift check must NOT flag. Each
|
|
62
|
+
# entry suppresses either a specific value mismatch ({"model","field","reason"})
|
|
63
|
+
# or an intentionally-omitted in-scope model ({"model","reason"} — no field).
|
|
64
|
+
# Guarded by `stale_allowlist_entries` (tests/test_pricing_check.py): an entry
|
|
65
|
+
# that no longer corresponds to a real divergence fails the suite.
|
|
66
|
+
PRICING_DRIFT_ALLOWLIST: list[dict] = []
|
|
67
|
+
|
|
49
68
|
# Anthropic API pricing snapshot:
|
|
50
69
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
51
|
-
# - Captured: 2026-05-
|
|
70
|
+
# - Captured: 2026-05-30 (see PRICING_SNAPSHOT_DATE)
|
|
52
71
|
# - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
|
|
53
72
|
# update in PRs touching this table.
|
|
54
73
|
CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
@@ -246,12 +265,12 @@ _unknown_model_warnings: set[str] = set()
|
|
|
246
265
|
#
|
|
247
266
|
# Codex (OpenAI) API pricing snapshot:
|
|
248
267
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
249
|
-
# - Captured: 2026-05-
|
|
250
|
-
# -
|
|
251
|
-
#
|
|
252
|
-
# back to `gpt-5`
|
|
253
|
-
#
|
|
254
|
-
# unknown model name.
|
|
268
|
+
# - Captured: 2026-05-30 (see PRICING_SNAPSHOT_DATE)
|
|
269
|
+
# - As of the 2026-05-30 sync (issue #123) this carries every openai-provider
|
|
270
|
+
# gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
|
|
271
|
+
# nothing missing. Models absent from this table still fall back to `gpt-5`
|
|
272
|
+
# pricing with isFallback=true (matches upstream's LEGACY_FALLBACK_MODEL
|
|
273
|
+
# behavior); a one-shot stderr warning is emitted per unknown model name.
|
|
255
274
|
#
|
|
256
275
|
# Billing rules:
|
|
257
276
|
# - reasoning_output_tokens is billed at the *output* rate (matches
|
|
@@ -333,12 +352,185 @@ CODEX_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
|
333
352
|
"output_cost_per_token": 4.5e-06,
|
|
334
353
|
},
|
|
335
354
|
"gpt-5.5": {
|
|
336
|
-
# Source:
|
|
337
|
-
# $5.00/M, cached $0.50/M, output $30.00/M.
|
|
338
|
-
#
|
|
355
|
+
# Source: LiteLLM model_prices_and_context_window.json (announced
|
|
356
|
+
# 2026-04-23). Input $5.00/M, cached $0.50/M, output $30.00/M. The
|
|
357
|
+
# above-272k tier ($10.00/M / $1.00/M / $45.00/M) was published in the
|
|
358
|
+
# 2026-05-30 sync (issue #123) and matches the dated alias below.
|
|
339
359
|
"input_cost_per_token": 5e-06,
|
|
340
360
|
"cache_read_input_token_cost": 5e-07,
|
|
341
361
|
"output_cost_per_token": 3e-05,
|
|
362
|
+
"input_cost_per_token_above_272k_tokens": 1e-05,
|
|
363
|
+
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
|
364
|
+
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
|
365
|
+
},
|
|
366
|
+
# ── Issue #123: full gpt-5.x LiteLLM sync (2026-05-30 snapshot) ──
|
|
367
|
+
# Exact model_prices_and_context_window.json values for every
|
|
368
|
+
# openai-provider gpt-5* model `pricing-check`'s scope flags but the
|
|
369
|
+
# curated set above didn't carry (bare/dated/-chat/-pro/-mini/-nano/
|
|
370
|
+
# -search variants). The four *-pro models omit cache_read upstream; we
|
|
371
|
+
# set it to input_cost_per_token / 4 (the documented LiteLLM cache
|
|
372
|
+
# fallback) so the table stays well-formed and cached-input turns price.
|
|
373
|
+
"gpt-5-2025-08-07": {
|
|
374
|
+
"input_cost_per_token": 1.25e-06,
|
|
375
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
376
|
+
"output_cost_per_token": 1e-05,
|
|
377
|
+
},
|
|
378
|
+
"gpt-5-chat": {
|
|
379
|
+
"input_cost_per_token": 1.25e-06,
|
|
380
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
381
|
+
"output_cost_per_token": 1e-05,
|
|
382
|
+
},
|
|
383
|
+
"gpt-5-chat-latest": {
|
|
384
|
+
"input_cost_per_token": 1.25e-06,
|
|
385
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
386
|
+
"output_cost_per_token": 1e-05,
|
|
387
|
+
},
|
|
388
|
+
"gpt-5-mini": {
|
|
389
|
+
"input_cost_per_token": 2.5e-07,
|
|
390
|
+
"cache_read_input_token_cost": 2.5e-08,
|
|
391
|
+
"output_cost_per_token": 2e-06,
|
|
392
|
+
},
|
|
393
|
+
"gpt-5-mini-2025-08-07": {
|
|
394
|
+
"input_cost_per_token": 2.5e-07,
|
|
395
|
+
"cache_read_input_token_cost": 2.5e-08,
|
|
396
|
+
"output_cost_per_token": 2e-06,
|
|
397
|
+
},
|
|
398
|
+
"gpt-5-nano": {
|
|
399
|
+
"input_cost_per_token": 5e-08,
|
|
400
|
+
"cache_read_input_token_cost": 5e-09,
|
|
401
|
+
"output_cost_per_token": 4e-07,
|
|
402
|
+
},
|
|
403
|
+
"gpt-5-nano-2025-08-07": {
|
|
404
|
+
"input_cost_per_token": 5e-08,
|
|
405
|
+
"cache_read_input_token_cost": 5e-09,
|
|
406
|
+
"output_cost_per_token": 4e-07,
|
|
407
|
+
},
|
|
408
|
+
"gpt-5-pro": {
|
|
409
|
+
# *-pro: LiteLLM omits cache_read; input/4 documented fallback.
|
|
410
|
+
"input_cost_per_token": 1.5e-05,
|
|
411
|
+
"cache_read_input_token_cost": 3.75e-06,
|
|
412
|
+
"output_cost_per_token": 0.00012,
|
|
413
|
+
},
|
|
414
|
+
"gpt-5-pro-2025-10-06": {
|
|
415
|
+
# *-pro: LiteLLM omits cache_read; input/4 documented fallback.
|
|
416
|
+
"input_cost_per_token": 1.5e-05,
|
|
417
|
+
"cache_read_input_token_cost": 3.75e-06,
|
|
418
|
+
"output_cost_per_token": 0.00012,
|
|
419
|
+
},
|
|
420
|
+
"gpt-5-search-api": {
|
|
421
|
+
"input_cost_per_token": 1.25e-06,
|
|
422
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
423
|
+
"output_cost_per_token": 1e-05,
|
|
424
|
+
},
|
|
425
|
+
"gpt-5-search-api-2025-10-14": {
|
|
426
|
+
"input_cost_per_token": 1.25e-06,
|
|
427
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
428
|
+
"output_cost_per_token": 1e-05,
|
|
429
|
+
},
|
|
430
|
+
"gpt-5.1": {
|
|
431
|
+
"input_cost_per_token": 1.25e-06,
|
|
432
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
433
|
+
"output_cost_per_token": 1e-05,
|
|
434
|
+
},
|
|
435
|
+
"gpt-5.1-2025-11-13": {
|
|
436
|
+
"input_cost_per_token": 1.25e-06,
|
|
437
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
438
|
+
"output_cost_per_token": 1e-05,
|
|
439
|
+
},
|
|
440
|
+
"gpt-5.1-chat-latest": {
|
|
441
|
+
"input_cost_per_token": 1.25e-06,
|
|
442
|
+
"cache_read_input_token_cost": 1.25e-07,
|
|
443
|
+
"output_cost_per_token": 1e-05,
|
|
444
|
+
},
|
|
445
|
+
"gpt-5.2-2025-12-11": {
|
|
446
|
+
"input_cost_per_token": 1.75e-06,
|
|
447
|
+
"cache_read_input_token_cost": 1.75e-07,
|
|
448
|
+
"output_cost_per_token": 1.4e-05,
|
|
449
|
+
},
|
|
450
|
+
"gpt-5.2-chat-latest": {
|
|
451
|
+
"input_cost_per_token": 1.75e-06,
|
|
452
|
+
"cache_read_input_token_cost": 1.75e-07,
|
|
453
|
+
"output_cost_per_token": 1.4e-05,
|
|
454
|
+
},
|
|
455
|
+
"gpt-5.2-pro": {
|
|
456
|
+
# *-pro: LiteLLM omits cache_read; input/4 documented fallback.
|
|
457
|
+
"input_cost_per_token": 2.1e-05,
|
|
458
|
+
"cache_read_input_token_cost": 5.25e-06,
|
|
459
|
+
"output_cost_per_token": 0.000168,
|
|
460
|
+
},
|
|
461
|
+
"gpt-5.2-pro-2025-12-11": {
|
|
462
|
+
# *-pro: LiteLLM omits cache_read; input/4 documented fallback.
|
|
463
|
+
"input_cost_per_token": 2.1e-05,
|
|
464
|
+
"cache_read_input_token_cost": 5.25e-06,
|
|
465
|
+
"output_cost_per_token": 0.000168,
|
|
466
|
+
},
|
|
467
|
+
"gpt-5.3-chat-latest": {
|
|
468
|
+
"input_cost_per_token": 1.75e-06,
|
|
469
|
+
"cache_read_input_token_cost": 1.75e-07,
|
|
470
|
+
"output_cost_per_token": 1.4e-05,
|
|
471
|
+
},
|
|
472
|
+
"gpt-5.4-2026-03-05": {
|
|
473
|
+
"input_cost_per_token": 2.5e-06,
|
|
474
|
+
"cache_read_input_token_cost": 2.5e-07,
|
|
475
|
+
"output_cost_per_token": 1.5e-05,
|
|
476
|
+
"input_cost_per_token_above_272k_tokens": 5e-06,
|
|
477
|
+
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
|
|
478
|
+
"output_cost_per_token_above_272k_tokens": 2.25e-05,
|
|
479
|
+
},
|
|
480
|
+
"gpt-5.4-mini-2026-03-17": {
|
|
481
|
+
"input_cost_per_token": 7.5e-07,
|
|
482
|
+
"cache_read_input_token_cost": 7.5e-08,
|
|
483
|
+
"output_cost_per_token": 4.5e-06,
|
|
484
|
+
},
|
|
485
|
+
"gpt-5.4-nano": {
|
|
486
|
+
"input_cost_per_token": 2e-07,
|
|
487
|
+
"cache_read_input_token_cost": 2e-08,
|
|
488
|
+
"output_cost_per_token": 1.25e-06,
|
|
489
|
+
},
|
|
490
|
+
"gpt-5.4-nano-2026-03-17": {
|
|
491
|
+
"input_cost_per_token": 2e-07,
|
|
492
|
+
"cache_read_input_token_cost": 2e-08,
|
|
493
|
+
"output_cost_per_token": 1.25e-06,
|
|
494
|
+
},
|
|
495
|
+
"gpt-5.4-pro": {
|
|
496
|
+
"input_cost_per_token": 3e-05,
|
|
497
|
+
"cache_read_input_token_cost": 3e-06,
|
|
498
|
+
"output_cost_per_token": 0.00018,
|
|
499
|
+
"input_cost_per_token_above_272k_tokens": 6e-05,
|
|
500
|
+
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
|
|
501
|
+
"output_cost_per_token_above_272k_tokens": 0.00027,
|
|
502
|
+
},
|
|
503
|
+
"gpt-5.4-pro-2026-03-05": {
|
|
504
|
+
"input_cost_per_token": 3e-05,
|
|
505
|
+
"cache_read_input_token_cost": 3e-06,
|
|
506
|
+
"output_cost_per_token": 0.00018,
|
|
507
|
+
"input_cost_per_token_above_272k_tokens": 6e-05,
|
|
508
|
+
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
|
|
509
|
+
"output_cost_per_token_above_272k_tokens": 0.00027,
|
|
510
|
+
},
|
|
511
|
+
"gpt-5.5-2026-04-23": {
|
|
512
|
+
"input_cost_per_token": 5e-06,
|
|
513
|
+
"cache_read_input_token_cost": 5e-07,
|
|
514
|
+
"output_cost_per_token": 3e-05,
|
|
515
|
+
"input_cost_per_token_above_272k_tokens": 1e-05,
|
|
516
|
+
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
|
517
|
+
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
|
518
|
+
},
|
|
519
|
+
"gpt-5.5-pro": {
|
|
520
|
+
"input_cost_per_token": 3e-05,
|
|
521
|
+
"cache_read_input_token_cost": 3e-06,
|
|
522
|
+
"output_cost_per_token": 0.00018,
|
|
523
|
+
"input_cost_per_token_above_272k_tokens": 6e-05,
|
|
524
|
+
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
|
|
525
|
+
"output_cost_per_token_above_272k_tokens": 0.00027,
|
|
526
|
+
},
|
|
527
|
+
"gpt-5.5-pro-2026-04-23": {
|
|
528
|
+
"input_cost_per_token": 3e-05,
|
|
529
|
+
"cache_read_input_token_cost": 3e-06,
|
|
530
|
+
"output_cost_per_token": 0.00018,
|
|
531
|
+
"input_cost_per_token_above_272k_tokens": 6e-05,
|
|
532
|
+
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
|
|
533
|
+
"output_cost_per_token_above_272k_tokens": 0.00027,
|
|
342
534
|
},
|
|
343
535
|
}
|
|
344
536
|
|
|
@@ -411,8 +603,16 @@ def _is_codex_fallback(model: str) -> bool:
|
|
|
411
603
|
return model not in CODEX_MODEL_PRICING
|
|
412
604
|
|
|
413
605
|
|
|
414
|
-
def _resolve_model_pricing(model: str) -> dict[str, Any] | None:
|
|
415
|
-
"""Look up pricing for a model name. Returns None if unknown.
|
|
606
|
+
def _resolve_model_pricing(model: str, warn: bool = True) -> dict[str, Any] | None:
|
|
607
|
+
"""Look up pricing for a model name. Returns None if unknown.
|
|
608
|
+
|
|
609
|
+
`warn=True` (default) emits a one-shot `[cost] unknown model` stderr warning
|
|
610
|
+
on a miss — correct for cost computation. Detection-only callers (e.g. the
|
|
611
|
+
doctor pricing-coverage scan, whose whole job is to find unpriced models)
|
|
612
|
+
pass `warn=False` so they don't fire the cost-engine warning as a side
|
|
613
|
+
effect, and don't poison `_unknown_model_warnings` (which would suppress a
|
|
614
|
+
later genuine cost-path warning for the same model).
|
|
615
|
+
"""
|
|
416
616
|
pricing = CLAUDE_MODEL_PRICING.get(model)
|
|
417
617
|
if pricing is not None:
|
|
418
618
|
return pricing
|
|
@@ -422,7 +622,7 @@ def _resolve_model_pricing(model: str) -> dict[str, Any] | None:
|
|
|
422
622
|
pricing = CLAUDE_MODEL_PRICING.get(stripped)
|
|
423
623
|
if pricing is not None:
|
|
424
624
|
return pricing
|
|
425
|
-
if model not in _unknown_model_warnings:
|
|
625
|
+
if warn and model not in _unknown_model_warnings:
|
|
426
626
|
_unknown_model_warnings.add(model)
|
|
427
627
|
_eprint(f"[cost] unknown model, treating cost as $0: {model}")
|
|
428
628
|
return None
|