codex-usage-tracking 0.3.0__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.
- codex_usage_tracker/__init__.py +7 -0
- codex_usage_tracker/__main__.py +6 -0
- codex_usage_tracker/allowance.py +759 -0
- codex_usage_tracker/api_payloads.py +90 -0
- codex_usage_tracker/cli.py +1326 -0
- codex_usage_tracker/context.py +410 -0
- codex_usage_tracker/costing.py +176 -0
- codex_usage_tracker/dashboard.py +389 -0
- codex_usage_tracker/diagnostics.py +624 -0
- codex_usage_tracker/formatting.py +225 -0
- codex_usage_tracker/json_contracts.py +350 -0
- codex_usage_tracker/mcp_server.py +371 -0
- codex_usage_tracker/models.py +92 -0
- codex_usage_tracker/parser.py +491 -0
- codex_usage_tracker/paths.py +18 -0
- codex_usage_tracker/plugin_data/__init__.py +1 -0
- codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
- codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
- codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
- codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
- codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
- codex_usage_tracker/plugin_installer.py +312 -0
- codex_usage_tracker/pricing.py +57 -0
- codex_usage_tracker/pricing_config.py +223 -0
- codex_usage_tracker/pricing_estimates.py +44 -0
- codex_usage_tracker/pricing_openai.py +253 -0
- codex_usage_tracker/projects.py +347 -0
- codex_usage_tracker/recommendations.py +270 -0
- codex_usage_tracker/reports.py +637 -0
- codex_usage_tracker/schema.py +71 -0
- codex_usage_tracker/server.py +400 -0
- codex_usage_tracker/store.py +666 -0
- codex_usage_tracker/support.py +147 -0
- codex_usage_tracker/threads.py +183 -0
- codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
- codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
- codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
- codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
- codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
- codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Public pricing facade for config, source parsing, estimates, and costing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codex_usage_tracker.costing import (
|
|
6
|
+
annotate_rows_with_efficiency,
|
|
7
|
+
efficiency_flags,
|
|
8
|
+
estimate_cache_savings_usd,
|
|
9
|
+
estimate_cost_usd,
|
|
10
|
+
summarize_pricing_coverage,
|
|
11
|
+
)
|
|
12
|
+
from codex_usage_tracker.pricing_config import (
|
|
13
|
+
PRICING_SCHEMA,
|
|
14
|
+
PRICING_TEMPLATE,
|
|
15
|
+
PricingConfig,
|
|
16
|
+
load_pricing_config,
|
|
17
|
+
pin_pricing_snapshot,
|
|
18
|
+
write_pricing_template,
|
|
19
|
+
)
|
|
20
|
+
from codex_usage_tracker.pricing_estimates import (
|
|
21
|
+
ESTIMATED_MODEL_PRICES,
|
|
22
|
+
OPENAI_CODEX_LAUNCH_URL,
|
|
23
|
+
OPENAI_CODEX_RATE_CARD_URL,
|
|
24
|
+
OPENAI_GPT_53_CODEX_MODEL_URL,
|
|
25
|
+
)
|
|
26
|
+
from codex_usage_tracker.pricing_openai import (
|
|
27
|
+
OPENAI_PRICING_MD_URL,
|
|
28
|
+
VALID_PRICING_TIERS,
|
|
29
|
+
PricingParseError,
|
|
30
|
+
PricingUpdateResult,
|
|
31
|
+
parse_openai_pricing_markdown,
|
|
32
|
+
update_pricing_from_openai_docs,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ESTIMATED_MODEL_PRICES",
|
|
37
|
+
"OPENAI_CODEX_LAUNCH_URL",
|
|
38
|
+
"OPENAI_CODEX_RATE_CARD_URL",
|
|
39
|
+
"OPENAI_GPT_53_CODEX_MODEL_URL",
|
|
40
|
+
"OPENAI_PRICING_MD_URL",
|
|
41
|
+
"PRICING_SCHEMA",
|
|
42
|
+
"PRICING_TEMPLATE",
|
|
43
|
+
"VALID_PRICING_TIERS",
|
|
44
|
+
"PricingConfig",
|
|
45
|
+
"PricingParseError",
|
|
46
|
+
"PricingUpdateResult",
|
|
47
|
+
"annotate_rows_with_efficiency",
|
|
48
|
+
"efficiency_flags",
|
|
49
|
+
"estimate_cache_savings_usd",
|
|
50
|
+
"estimate_cost_usd",
|
|
51
|
+
"load_pricing_config",
|
|
52
|
+
"parse_openai_pricing_markdown",
|
|
53
|
+
"pin_pricing_snapshot",
|
|
54
|
+
"summarize_pricing_coverage",
|
|
55
|
+
"update_pricing_from_openai_docs",
|
|
56
|
+
"write_pricing_template",
|
|
57
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Local pricing config loading and template writing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from codex_usage_tracker.paths import DEFAULT_PRICING_PATH
|
|
12
|
+
|
|
13
|
+
PRICING_SCHEMA = "codex-usage-tracker-pricing-v1"
|
|
14
|
+
PRICING_TEMPLATE = {
|
|
15
|
+
"_comment": (
|
|
16
|
+
"Fill in current prices in USD per 1 million tokens. The tracker does "
|
|
17
|
+
"not fetch pricing during normal reports. Prefer update-pricing when "
|
|
18
|
+
"you want to cache current OpenAI-published rates locally."
|
|
19
|
+
),
|
|
20
|
+
"models": {
|
|
21
|
+
"replace-with-model-name": {
|
|
22
|
+
"input_per_million": 0.0,
|
|
23
|
+
"cached_input_per_million": 0.0,
|
|
24
|
+
"output_per_million": 0.0,
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"aliases": {
|
|
28
|
+
"local-codex-model-label": "official-openai-model-id",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class PricingConfig:
|
|
35
|
+
"""Parsed local model pricing config."""
|
|
36
|
+
|
|
37
|
+
path: Path
|
|
38
|
+
models: dict[str, dict[str, float]]
|
|
39
|
+
loaded: bool
|
|
40
|
+
aliases: dict[str, str] | None = None
|
|
41
|
+
estimated_models: set[str] | None = None
|
|
42
|
+
source: dict[str, Any] | None = None
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
def rates_for(self, model: object) -> dict[str, float] | None:
|
|
46
|
+
if not isinstance(model, str) or not model:
|
|
47
|
+
return None
|
|
48
|
+
direct = self.models.get(model)
|
|
49
|
+
if direct is not None:
|
|
50
|
+
return direct
|
|
51
|
+
alias_target = (self.aliases or {}).get(model)
|
|
52
|
+
if not alias_target:
|
|
53
|
+
return None
|
|
54
|
+
return self.models.get(alias_target)
|
|
55
|
+
|
|
56
|
+
def priced_as(self, model: object) -> str | None:
|
|
57
|
+
if not isinstance(model, str) or not model:
|
|
58
|
+
return None
|
|
59
|
+
if model in self.models:
|
|
60
|
+
return model
|
|
61
|
+
alias_target = (self.aliases or {}).get(model)
|
|
62
|
+
if alias_target and alias_target in self.models:
|
|
63
|
+
return alias_target
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def is_estimated_model(self, model: object) -> bool:
|
|
67
|
+
priced_as = self.priced_as(model)
|
|
68
|
+
return bool(priced_as and priced_as in (self.estimated_models or set()))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_pricing_config(path: Path = DEFAULT_PRICING_PATH) -> PricingConfig:
|
|
72
|
+
"""Load optional local pricing without contacting external services."""
|
|
73
|
+
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return PricingConfig(path=path, models={}, loaded=False)
|
|
76
|
+
try:
|
|
77
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
models = parse_models(raw)
|
|
79
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
80
|
+
return PricingConfig(path=path, models={}, loaded=False, error=str(exc))
|
|
81
|
+
source = raw.get("_source") if isinstance(raw, dict) else None
|
|
82
|
+
aliases = parse_aliases(raw)
|
|
83
|
+
return PricingConfig(
|
|
84
|
+
path=path,
|
|
85
|
+
models=models,
|
|
86
|
+
loaded=True,
|
|
87
|
+
aliases=aliases,
|
|
88
|
+
estimated_models=parse_estimated_models(raw),
|
|
89
|
+
source=source if isinstance(source, dict) else None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_pricing_template(path: Path = DEFAULT_PRICING_PATH, force: bool = False) -> Path:
|
|
94
|
+
"""Write a local pricing template for user-maintained cost estimates."""
|
|
95
|
+
|
|
96
|
+
if path.exists() and not force:
|
|
97
|
+
raise FileExistsError(f"Pricing config already exists: {path}")
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
path.write_text(json.dumps(PRICING_TEMPLATE, indent=2) + "\n", encoding="utf-8")
|
|
100
|
+
return path
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def pin_pricing_snapshot(
|
|
104
|
+
*,
|
|
105
|
+
source_path: Path = DEFAULT_PRICING_PATH,
|
|
106
|
+
output_path: Path,
|
|
107
|
+
force: bool = False,
|
|
108
|
+
) -> Path:
|
|
109
|
+
"""Copy the current local pricing config to a reproducible report snapshot."""
|
|
110
|
+
|
|
111
|
+
config = load_pricing_config(source_path)
|
|
112
|
+
if config.error:
|
|
113
|
+
raise ValueError(f"pricing config is invalid: {config.error}")
|
|
114
|
+
if not config.loaded:
|
|
115
|
+
raise FileNotFoundError(f"pricing config does not exist: {source_path}")
|
|
116
|
+
output_path = output_path.expanduser()
|
|
117
|
+
if output_path.exists() and not force:
|
|
118
|
+
raise FileExistsError(f"pricing snapshot already exists: {output_path}")
|
|
119
|
+
raw = json.loads(source_path.expanduser().read_text(encoding="utf-8"))
|
|
120
|
+
if not isinstance(raw, dict):
|
|
121
|
+
raise ValueError("pricing config must be a JSON object")
|
|
122
|
+
source_payload = raw.get("_source")
|
|
123
|
+
source: dict[str, Any] = source_payload if isinstance(source_payload, dict) else {}
|
|
124
|
+
raw["_source"] = {
|
|
125
|
+
**source,
|
|
126
|
+
"pinned": True,
|
|
127
|
+
"pinned_at": datetime.now(timezone.utc)
|
|
128
|
+
.replace(microsecond=0)
|
|
129
|
+
.isoformat()
|
|
130
|
+
.replace("+00:00", "Z"),
|
|
131
|
+
"pin_note": "Use this file with --pricing for reproducible historical reports.",
|
|
132
|
+
}
|
|
133
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
output_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
135
|
+
return output_path
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_models(raw: object) -> dict[str, dict[str, float]]:
|
|
139
|
+
if not isinstance(raw, dict):
|
|
140
|
+
raise ValueError("pricing config must be a JSON object")
|
|
141
|
+
model_payload = raw.get("models", raw)
|
|
142
|
+
if not isinstance(model_payload, dict):
|
|
143
|
+
raise ValueError("pricing config 'models' must be an object")
|
|
144
|
+
|
|
145
|
+
models: dict[str, dict[str, float]] = {}
|
|
146
|
+
for model, rates in model_payload.items():
|
|
147
|
+
if not isinstance(model, str) or model.startswith("_"):
|
|
148
|
+
continue
|
|
149
|
+
if not isinstance(rates, dict):
|
|
150
|
+
continue
|
|
151
|
+
input_rate = _required_rate(rates, "input_per_million", model)
|
|
152
|
+
cached_rate = _optional_rate(rates, "cached_input_per_million")
|
|
153
|
+
output_rate = _required_rate(rates, "output_per_million", model)
|
|
154
|
+
models[model] = {
|
|
155
|
+
"input_per_million": float(input_rate),
|
|
156
|
+
"cached_input_per_million": float(
|
|
157
|
+
cached_rate if cached_rate is not None else input_rate
|
|
158
|
+
),
|
|
159
|
+
"output_per_million": float(output_rate),
|
|
160
|
+
}
|
|
161
|
+
return models
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_aliases(raw: object) -> dict[str, str]:
|
|
165
|
+
if not isinstance(raw, dict):
|
|
166
|
+
return {}
|
|
167
|
+
aliases = raw.get("aliases")
|
|
168
|
+
if not isinstance(aliases, dict):
|
|
169
|
+
return {}
|
|
170
|
+
parsed: dict[str, str] = {}
|
|
171
|
+
for source, target in aliases.items():
|
|
172
|
+
if isinstance(source, str) and isinstance(target, str) and source and target:
|
|
173
|
+
parsed[source] = target
|
|
174
|
+
return parsed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_estimated_models(raw: object) -> set[str]:
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
return set()
|
|
180
|
+
model_payload = raw.get("models", raw)
|
|
181
|
+
if not isinstance(model_payload, dict):
|
|
182
|
+
return set()
|
|
183
|
+
return {
|
|
184
|
+
model
|
|
185
|
+
for model, rates in model_payload.items()
|
|
186
|
+
if isinstance(model, str) and isinstance(rates, dict) and rates.get("estimated") is True
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_existing_aliases(path: Path) -> dict[str, str]:
|
|
191
|
+
if not path.exists():
|
|
192
|
+
return {}
|
|
193
|
+
try:
|
|
194
|
+
return parse_aliases(json.loads(path.read_text(encoding="utf-8")))
|
|
195
|
+
except (OSError, TypeError, json.JSONDecodeError):
|
|
196
|
+
return {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _required_rate(rates: dict[str, Any], key: str, model: str) -> float:
|
|
200
|
+
value = _optional_rate(rates, key)
|
|
201
|
+
if value is None:
|
|
202
|
+
raise ValueError(f"missing {key} for model {model}")
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _optional_rate(rates: dict[str, Any], key: str) -> float | None:
|
|
207
|
+
value = rates.get(key)
|
|
208
|
+
if value is None:
|
|
209
|
+
return None
|
|
210
|
+
parsed = _number(value)
|
|
211
|
+
if parsed < 0:
|
|
212
|
+
raise ValueError(f"{key} cannot be negative")
|
|
213
|
+
return parsed
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _number(value: object) -> float:
|
|
217
|
+
if isinstance(value, bool):
|
|
218
|
+
return float(int(value))
|
|
219
|
+
if isinstance(value, int | float):
|
|
220
|
+
return float(value)
|
|
221
|
+
if isinstance(value, str) and value.strip():
|
|
222
|
+
return float(value)
|
|
223
|
+
return 0.0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Explicitly marked internal Codex model pricing estimates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
OPENAI_CODEX_LAUNCH_URL = "https://openai.com/index/introducing-codex/"
|
|
6
|
+
OPENAI_GPT_53_CODEX_MODEL_URL = "https://developers.openai.com/api/docs/models/gpt-5.3-codex"
|
|
7
|
+
OPENAI_CODEX_RATE_CARD_URL = "https://help.openai.com/en/articles/20001106-codex-rate-card"
|
|
8
|
+
|
|
9
|
+
PricingEstimateValue = float | bool | str
|
|
10
|
+
|
|
11
|
+
ESTIMATED_MODEL_PRICES: dict[str, dict[str, PricingEstimateValue]] = {
|
|
12
|
+
"codex-auto-review": {
|
|
13
|
+
"input_per_million": 1.5,
|
|
14
|
+
"cached_input_per_million": 0.375,
|
|
15
|
+
"output_per_million": 6.0,
|
|
16
|
+
"estimated": True,
|
|
17
|
+
"estimate_basis_model": "codex-mini-latest",
|
|
18
|
+
"estimate_source_url": OPENAI_CODEX_LAUNCH_URL,
|
|
19
|
+
"estimate_reason": (
|
|
20
|
+
"codex-auto-review is an internal Codex model label without a public "
|
|
21
|
+
"pricing row; estimate uses OpenAI-published codex-mini-latest rates."
|
|
22
|
+
),
|
|
23
|
+
},
|
|
24
|
+
"gpt-5.3-codex-spark": {
|
|
25
|
+
"input_per_million": 1.75,
|
|
26
|
+
"cached_input_per_million": 0.175,
|
|
27
|
+
"output_per_million": 14.0,
|
|
28
|
+
"estimated": True,
|
|
29
|
+
"estimate_basis_model": "gpt-5.3-codex",
|
|
30
|
+
"estimate_source_url": OPENAI_GPT_53_CODEX_MODEL_URL,
|
|
31
|
+
"estimate_reference_url": OPENAI_CODEX_RATE_CARD_URL,
|
|
32
|
+
"estimate_reason": (
|
|
33
|
+
"GPT-5.3-Codex-Spark is listed by OpenAI as a research preview "
|
|
34
|
+
"without final Codex credit rates; estimate uses the published "
|
|
35
|
+
"GPT-5.3-Codex text-token rates until Spark rates are finalized."
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def estimated_model_prices() -> dict[str, dict[str, PricingEstimateValue]]:
|
|
42
|
+
"""Return a copy of configured internal model estimates."""
|
|
43
|
+
|
|
44
|
+
return {model: dict(rates) for model, rates in ESTIMATED_MODEL_PRICES.items()}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""OpenAI pricing source fetching, parsing, and cache updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.error import URLError
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from codex_usage_tracker import __version__
|
|
17
|
+
from codex_usage_tracker.paths import DEFAULT_PRICING_PATH
|
|
18
|
+
from codex_usage_tracker.pricing_config import PRICING_SCHEMA, load_existing_aliases
|
|
19
|
+
from codex_usage_tracker.pricing_estimates import ESTIMATED_MODEL_PRICES, estimated_model_prices
|
|
20
|
+
|
|
21
|
+
OPENAI_PRICING_MD_URL = "https://developers.openai.com/api/docs/pricing.md"
|
|
22
|
+
VALID_PRICING_TIERS = ("standard", "batch", "flex", "priority")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PricingParseError(ValueError):
|
|
26
|
+
"""Raised when the OpenAI pricing Markdown structure cannot be parsed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PricingUpdateResult:
|
|
31
|
+
"""Result from refreshing the local pricing cache."""
|
|
32
|
+
|
|
33
|
+
path: Path
|
|
34
|
+
source_url: str
|
|
35
|
+
tier: str
|
|
36
|
+
fetched_at: str
|
|
37
|
+
model_count: int
|
|
38
|
+
estimated_model_count: int = 0
|
|
39
|
+
backup_path: Path | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update_pricing_from_openai_docs(
|
|
43
|
+
path: Path = DEFAULT_PRICING_PATH,
|
|
44
|
+
*,
|
|
45
|
+
tier: str = "standard",
|
|
46
|
+
source_url: str = OPENAI_PRICING_MD_URL,
|
|
47
|
+
fetch_text: Callable[[str], str] | None = None,
|
|
48
|
+
include_estimates: bool = True,
|
|
49
|
+
) -> PricingUpdateResult:
|
|
50
|
+
"""Fetch OpenAI-published pricing rows and cache them in the local config."""
|
|
51
|
+
|
|
52
|
+
if tier not in VALID_PRICING_TIERS:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"unknown pricing tier {tier!r}; expected one of {', '.join(VALID_PRICING_TIERS)}"
|
|
55
|
+
)
|
|
56
|
+
fetcher = fetch_text or _fetch_text
|
|
57
|
+
text = fetcher(source_url)
|
|
58
|
+
parsed_models = parse_openai_pricing_markdown(text, tier=tier)
|
|
59
|
+
if not parsed_models:
|
|
60
|
+
raise PricingParseError(
|
|
61
|
+
f"pricing source schema changed: no text-token pricing rows were parsed "
|
|
62
|
+
f"from {source_url} for tier {tier!r}"
|
|
63
|
+
)
|
|
64
|
+
models: dict[str, dict[str, Any]] = {
|
|
65
|
+
model: dict(rates) for model, rates in parsed_models.items()
|
|
66
|
+
}
|
|
67
|
+
aliases = load_existing_aliases(path)
|
|
68
|
+
estimated_model_count = 0
|
|
69
|
+
if include_estimates:
|
|
70
|
+
models.update(estimated_model_prices())
|
|
71
|
+
estimated_model_count = len(ESTIMATED_MODEL_PRICES)
|
|
72
|
+
|
|
73
|
+
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
74
|
+
payload = {
|
|
75
|
+
"_schema": PRICING_SCHEMA,
|
|
76
|
+
"_source": {
|
|
77
|
+
"name": "OpenAI Developers pricing docs",
|
|
78
|
+
"url": source_url,
|
|
79
|
+
"tier": tier,
|
|
80
|
+
"fetched_at": fetched_at,
|
|
81
|
+
"model_count": len(models),
|
|
82
|
+
"official_model_count": len(models) - estimated_model_count,
|
|
83
|
+
"estimated_model_count": estimated_model_count,
|
|
84
|
+
},
|
|
85
|
+
"models": models,
|
|
86
|
+
}
|
|
87
|
+
if aliases:
|
|
88
|
+
payload["aliases"] = aliases
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
backup_path = _backup_existing_pricing(path)
|
|
91
|
+
tmp_path = path.with_name(f"{path.name}.tmp")
|
|
92
|
+
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
93
|
+
tmp_path.replace(path)
|
|
94
|
+
return PricingUpdateResult(
|
|
95
|
+
path=path,
|
|
96
|
+
source_url=source_url,
|
|
97
|
+
tier=tier,
|
|
98
|
+
fetched_at=fetched_at,
|
|
99
|
+
model_count=len(models),
|
|
100
|
+
estimated_model_count=estimated_model_count,
|
|
101
|
+
backup_path=backup_path,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_openai_pricing_markdown(
|
|
106
|
+
markdown: str, *, tier: str = "standard"
|
|
107
|
+
) -> dict[str, dict[str, float]]:
|
|
108
|
+
"""Parse text-token rows from OpenAI's pricing markdown for one service tier."""
|
|
109
|
+
|
|
110
|
+
if tier not in VALID_PRICING_TIERS:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"unknown pricing tier {tier!r}; expected one of {', '.join(VALID_PRICING_TIERS)}"
|
|
113
|
+
)
|
|
114
|
+
rows_block = _extract_text_token_rows_block(markdown, tier)
|
|
115
|
+
models: dict[str, dict[str, float]] = {}
|
|
116
|
+
for match in _OPENAI_PRICE_ROW_RE.finditer(rows_block):
|
|
117
|
+
model = _normalize_model_name(match.group("model"))
|
|
118
|
+
input_rate = _parse_openai_price_value(match.group("input"))
|
|
119
|
+
cached_rate = _parse_openai_price_value(match.group("cached"))
|
|
120
|
+
output_rate = _parse_openai_price_value(match.group("output"))
|
|
121
|
+
if not model or input_rate is None or output_rate is None:
|
|
122
|
+
continue
|
|
123
|
+
if cached_rate is None:
|
|
124
|
+
cached_rate = input_rate
|
|
125
|
+
models[model] = {
|
|
126
|
+
"input_per_million": input_rate,
|
|
127
|
+
"cached_input_per_million": cached_rate,
|
|
128
|
+
"output_per_million": output_rate,
|
|
129
|
+
}
|
|
130
|
+
if not models:
|
|
131
|
+
raise PricingParseError(
|
|
132
|
+
f"pricing source schema changed: tier {tier!r} rows block contained no "
|
|
133
|
+
"parseable text-token pricing rows"
|
|
134
|
+
)
|
|
135
|
+
return models
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_OPENAI_PRICE_ROW_RE = re.compile(
|
|
139
|
+
r"""\[
|
|
140
|
+
\s*"(?P<model>[^"]+)"\s*,
|
|
141
|
+
\s*(?P<input>[^,\]\n]+)\s*,
|
|
142
|
+
\s*(?P<cached>[^,\]\n]+)\s*,
|
|
143
|
+
\s*(?P<output>[^,\]\n]+)\s*
|
|
144
|
+
\]""",
|
|
145
|
+
re.VERBOSE,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _fetch_text(url: str) -> str:
|
|
150
|
+
request = Request(
|
|
151
|
+
url,
|
|
152
|
+
headers={
|
|
153
|
+
"Accept": "text/markdown,text/plain;q=0.9,*/*;q=0.1",
|
|
154
|
+
"User-Agent": f"codex-usage-tracker/{__version__}",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
with urlopen(request, timeout=20) as response:
|
|
159
|
+
return response.read().decode("utf-8")
|
|
160
|
+
except URLError as exc:
|
|
161
|
+
raise RuntimeError(f"could not fetch pricing source {url}: {exc}") from exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _backup_existing_pricing(path: Path) -> Path | None:
|
|
165
|
+
if not path.exists():
|
|
166
|
+
return None
|
|
167
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
168
|
+
backup_path = path.with_name(f"{path.name}.{stamp}.bak")
|
|
169
|
+
shutil.copy2(path, backup_path)
|
|
170
|
+
return backup_path
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _extract_text_token_rows_block(markdown: str, tier: str) -> str:
|
|
174
|
+
tier_marker = f'tier="{tier}"'
|
|
175
|
+
tier_index = markdown.find(tier_marker)
|
|
176
|
+
if tier_index == -1:
|
|
177
|
+
raise PricingParseError(
|
|
178
|
+
f"pricing source schema changed: could not find text-token tier marker {tier_marker!r}"
|
|
179
|
+
)
|
|
180
|
+
search_end = _pricing_component_end(markdown, tier_index)
|
|
181
|
+
rows_marker_index = markdown.find("rows={[", tier_index, search_end)
|
|
182
|
+
if rows_marker_index == -1:
|
|
183
|
+
raise PricingParseError(
|
|
184
|
+
f"pricing source schema changed: tier {tier!r} does not contain a rows={{[ block"
|
|
185
|
+
)
|
|
186
|
+
bracket_index = markdown.find("[", rows_marker_index, search_end)
|
|
187
|
+
if bracket_index == -1:
|
|
188
|
+
raise PricingParseError(
|
|
189
|
+
f"pricing source schema changed: tier {tier!r} has a malformed rows block"
|
|
190
|
+
)
|
|
191
|
+
end_index = _find_matching_bracket(markdown, bracket_index)
|
|
192
|
+
if end_index > search_end:
|
|
193
|
+
raise PricingParseError(
|
|
194
|
+
f"pricing source schema changed: tier {tier!r} rows block extends past its component"
|
|
195
|
+
)
|
|
196
|
+
return markdown[bracket_index + 1 : end_index]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _pricing_component_end(markdown: str, tier_index: int) -> int:
|
|
200
|
+
candidates = [
|
|
201
|
+
index
|
|
202
|
+
for index in (
|
|
203
|
+
markdown.find("/>", tier_index),
|
|
204
|
+
markdown.find("</TextTokenPricingTables>", tier_index),
|
|
205
|
+
markdown.find("<TextTokenPricingTables", tier_index + 1),
|
|
206
|
+
)
|
|
207
|
+
if index != -1
|
|
208
|
+
]
|
|
209
|
+
return min(candidates) if candidates else len(markdown)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _find_matching_bracket(text: str, start_index: int) -> int:
|
|
213
|
+
depth = 0
|
|
214
|
+
quote: str | None = None
|
|
215
|
+
escaped = False
|
|
216
|
+
for index in range(start_index, len(text)):
|
|
217
|
+
char = text[index]
|
|
218
|
+
if quote:
|
|
219
|
+
if escaped:
|
|
220
|
+
escaped = False
|
|
221
|
+
elif char == "\\":
|
|
222
|
+
escaped = True
|
|
223
|
+
elif char == quote:
|
|
224
|
+
quote = None
|
|
225
|
+
continue
|
|
226
|
+
if char in {'"', "'", "`"}:
|
|
227
|
+
quote = char
|
|
228
|
+
elif char == "[":
|
|
229
|
+
depth += 1
|
|
230
|
+
elif char == "]":
|
|
231
|
+
depth -= 1
|
|
232
|
+
if depth == 0:
|
|
233
|
+
return index
|
|
234
|
+
raise PricingParseError("pricing source schema changed: rows block is unterminated")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _normalize_model_name(model: str) -> str:
|
|
238
|
+
return re.sub(r"\s+\([^)]*context length[^)]*\)\s*$", "", model.strip(), flags=re.I)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_openai_price_value(value: str) -> float | None:
|
|
242
|
+
normalized = value.strip()
|
|
243
|
+
if normalized in {"", "null", "undefined", "-", '""', "''", '"-"', "'-'"}:
|
|
244
|
+
return None
|
|
245
|
+
if (
|
|
246
|
+
len(normalized) >= 2
|
|
247
|
+
and normalized[0] == normalized[-1]
|
|
248
|
+
and normalized[0] in {'"', "'"}
|
|
249
|
+
):
|
|
250
|
+
normalized = normalized[1:-1].strip()
|
|
251
|
+
if normalized in {"", "-", "Free"}:
|
|
252
|
+
return None
|
|
253
|
+
return float(normalized.replace("_", ""))
|