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,71 @@
|
|
|
1
|
+
"""SQLite schema metadata for aggregate usage events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class UsageColumn:
|
|
11
|
+
"""One persisted usage_events column."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
declaration: str
|
|
15
|
+
alter_type: str
|
|
16
|
+
repairable: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
USAGE_EVENT_COLUMNS = (
|
|
20
|
+
UsageColumn("record_id", "TEXT PRIMARY KEY", "TEXT"),
|
|
21
|
+
UsageColumn("session_id", "TEXT NOT NULL", "TEXT"),
|
|
22
|
+
UsageColumn("thread_name", "TEXT", "TEXT", repairable=True),
|
|
23
|
+
UsageColumn("session_updated_at", "TEXT", "TEXT", repairable=True),
|
|
24
|
+
UsageColumn("event_timestamp", "TEXT NOT NULL", "TEXT"),
|
|
25
|
+
UsageColumn("source_file", "TEXT NOT NULL", "TEXT"),
|
|
26
|
+
UsageColumn("line_number", "INTEGER NOT NULL", "INTEGER"),
|
|
27
|
+
UsageColumn("turn_id", "TEXT", "TEXT", repairable=True),
|
|
28
|
+
UsageColumn("turn_timestamp", "TEXT", "TEXT", repairable=True),
|
|
29
|
+
UsageColumn("cwd", "TEXT", "TEXT", repairable=True),
|
|
30
|
+
UsageColumn("model", "TEXT", "TEXT", repairable=True),
|
|
31
|
+
UsageColumn("effort", "TEXT", "TEXT", repairable=True),
|
|
32
|
+
UsageColumn("current_date", "TEXT", "TEXT", repairable=True),
|
|
33
|
+
UsageColumn("timezone", "TEXT", "TEXT", repairable=True),
|
|
34
|
+
UsageColumn("thread_source", "TEXT", "TEXT", repairable=True),
|
|
35
|
+
UsageColumn("subagent_type", "TEXT", "TEXT", repairable=True),
|
|
36
|
+
UsageColumn("agent_role", "TEXT", "TEXT", repairable=True),
|
|
37
|
+
UsageColumn("agent_nickname", "TEXT", "TEXT", repairable=True),
|
|
38
|
+
UsageColumn("parent_session_id", "TEXT", "TEXT", repairable=True),
|
|
39
|
+
UsageColumn("parent_thread_name", "TEXT", "TEXT", repairable=True),
|
|
40
|
+
UsageColumn("parent_session_updated_at", "TEXT", "TEXT", repairable=True),
|
|
41
|
+
UsageColumn("model_context_window", "INTEGER", "INTEGER", repairable=True),
|
|
42
|
+
UsageColumn("input_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
43
|
+
UsageColumn("cached_input_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
44
|
+
UsageColumn("output_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
45
|
+
UsageColumn("reasoning_output_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
46
|
+
UsageColumn("total_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
47
|
+
UsageColumn("cumulative_input_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
48
|
+
UsageColumn("cumulative_cached_input_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
49
|
+
UsageColumn("cumulative_output_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
50
|
+
UsageColumn("cumulative_reasoning_output_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
51
|
+
UsageColumn("cumulative_total_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
52
|
+
UsageColumn("uncached_input_tokens", "INTEGER NOT NULL", "INTEGER"),
|
|
53
|
+
UsageColumn("cache_ratio", "REAL NOT NULL", "REAL"),
|
|
54
|
+
UsageColumn("reasoning_output_ratio", "REAL NOT NULL", "REAL"),
|
|
55
|
+
UsageColumn("context_window_percent", "REAL NOT NULL", "REAL"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
USAGE_EVENT_COLUMN_NAMES = tuple(column.name for column in USAGE_EVENT_COLUMNS)
|
|
59
|
+
USAGE_EVENT_CREATE_COLUMNS_SQL = ",\n ".join(
|
|
60
|
+
f"{column.name} {column.declaration}" for column in USAGE_EVENT_COLUMNS
|
|
61
|
+
)
|
|
62
|
+
USAGE_EVENT_SCHEMA_CHECKSUM = hashlib.sha256(
|
|
63
|
+
"|".join(f"{column.name}:{column.declaration}" for column in USAGE_EVENT_COLUMNS).encode(
|
|
64
|
+
"utf-8"
|
|
65
|
+
)
|
|
66
|
+
).hexdigest()
|
|
67
|
+
USAGE_EVENT_REPAIR_COLUMNS = {
|
|
68
|
+
column.name: column.alter_type
|
|
69
|
+
for column in USAGE_EVENT_COLUMNS
|
|
70
|
+
if column.repairable
|
|
71
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Local dashboard server with lazy context API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import webbrowser
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from functools import partial
|
|
13
|
+
from http import HTTPStatus
|
|
14
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from ipaddress import ip_address
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import parse_qs, urlparse
|
|
18
|
+
|
|
19
|
+
from codex_usage_tracker.context import DEFAULT_CONTEXT_CHARS, load_call_context
|
|
20
|
+
from codex_usage_tracker.dashboard import dashboard_payload, generate_dashboard
|
|
21
|
+
from codex_usage_tracker.paths import (
|
|
22
|
+
DEFAULT_ALLOWANCE_PATH,
|
|
23
|
+
DEFAULT_CODEX_HOME,
|
|
24
|
+
DEFAULT_DASHBOARD_PATH,
|
|
25
|
+
DEFAULT_PRICING_PATH,
|
|
26
|
+
DEFAULT_PROJECTS_PATH,
|
|
27
|
+
DEFAULT_RATE_CARD_PATH,
|
|
28
|
+
DEFAULT_THRESHOLDS_PATH,
|
|
29
|
+
)
|
|
30
|
+
from codex_usage_tracker.store import refresh_usage_index
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def serve_dashboard(
|
|
34
|
+
db_path: Path,
|
|
35
|
+
output_path: Path = DEFAULT_DASHBOARD_PATH,
|
|
36
|
+
pricing_path: Path = DEFAULT_PRICING_PATH,
|
|
37
|
+
allowance_path: Path = DEFAULT_ALLOWANCE_PATH,
|
|
38
|
+
rate_card_path: Path = DEFAULT_RATE_CARD_PATH,
|
|
39
|
+
limit: int = 5000,
|
|
40
|
+
since: str | None = None,
|
|
41
|
+
host: str = "127.0.0.1",
|
|
42
|
+
port: int = 8765,
|
|
43
|
+
context_chars: int = DEFAULT_CONTEXT_CHARS,
|
|
44
|
+
open_browser: bool = False,
|
|
45
|
+
codex_home: Path = DEFAULT_CODEX_HOME,
|
|
46
|
+
include_archived: bool = False,
|
|
47
|
+
context_api: str = "explicit",
|
|
48
|
+
thresholds_path: Path = DEFAULT_THRESHOLDS_PATH,
|
|
49
|
+
projects_path: Path = DEFAULT_PROJECTS_PATH,
|
|
50
|
+
privacy_mode: str = "normal",
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Generate and serve the dashboard plus a localhost-only context endpoint."""
|
|
53
|
+
|
|
54
|
+
_validate_loopback_host(host)
|
|
55
|
+
_validate_context_api_mode(context_api)
|
|
56
|
+
api_token = secrets.token_urlsafe(32)
|
|
57
|
+
context_api_enabled = context_api != "disabled"
|
|
58
|
+
output = generate_dashboard(
|
|
59
|
+
db_path=db_path,
|
|
60
|
+
output_path=output_path,
|
|
61
|
+
limit=limit,
|
|
62
|
+
pricing_path=pricing_path,
|
|
63
|
+
allowance_path=allowance_path,
|
|
64
|
+
rate_card_path=rate_card_path,
|
|
65
|
+
since=since,
|
|
66
|
+
api_token=api_token,
|
|
67
|
+
context_api_enabled=context_api_enabled,
|
|
68
|
+
thresholds_path=thresholds_path,
|
|
69
|
+
projects_path=projects_path,
|
|
70
|
+
privacy_mode=privacy_mode,
|
|
71
|
+
include_archived=include_archived,
|
|
72
|
+
)
|
|
73
|
+
handler = partial(
|
|
74
|
+
_UsageDashboardHandler,
|
|
75
|
+
directory=str(output.parent),
|
|
76
|
+
db_path=db_path,
|
|
77
|
+
pricing_path=pricing_path,
|
|
78
|
+
allowance_path=allowance_path,
|
|
79
|
+
rate_card_path=rate_card_path,
|
|
80
|
+
thresholds_path=thresholds_path,
|
|
81
|
+
projects_path=projects_path,
|
|
82
|
+
privacy_mode=privacy_mode,
|
|
83
|
+
limit=limit,
|
|
84
|
+
since=since,
|
|
85
|
+
codex_home=codex_home,
|
|
86
|
+
include_archived=include_archived,
|
|
87
|
+
dashboard_name=output.name,
|
|
88
|
+
context_chars=context_chars,
|
|
89
|
+
api_token=api_token,
|
|
90
|
+
context_api_enabled=context_api_enabled,
|
|
91
|
+
refresh_lock=threading.Lock(),
|
|
92
|
+
)
|
|
93
|
+
server = ThreadingHTTPServer((host, port), handler)
|
|
94
|
+
url = f"http://{_url_host(host)}:{port}/{output.name}"
|
|
95
|
+
print(f"Serving Codex usage dashboard at {url}")
|
|
96
|
+
context_mode = "enabled for explicit row actions" if context_api_enabled else "disabled"
|
|
97
|
+
print("Aggregate rows refresh through /api/usage with a per-server token.")
|
|
98
|
+
print(f"Raw context API is {context_mode}; context is never embedded in the dashboard HTML.")
|
|
99
|
+
if open_browser:
|
|
100
|
+
webbrowser.open(url)
|
|
101
|
+
try:
|
|
102
|
+
server.serve_forever()
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
print("\nStopping dashboard server.")
|
|
105
|
+
finally:
|
|
106
|
+
server.server_close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _UsageDashboardHandler(SimpleHTTPRequestHandler):
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*args: object,
|
|
113
|
+
db_path: Path,
|
|
114
|
+
pricing_path: Path,
|
|
115
|
+
allowance_path: Path,
|
|
116
|
+
thresholds_path: Path,
|
|
117
|
+
projects_path: Path,
|
|
118
|
+
limit: int,
|
|
119
|
+
since: str | None,
|
|
120
|
+
codex_home: Path,
|
|
121
|
+
include_archived: bool,
|
|
122
|
+
dashboard_name: str,
|
|
123
|
+
context_chars: int,
|
|
124
|
+
api_token: str,
|
|
125
|
+
context_api_enabled: bool,
|
|
126
|
+
refresh_lock: threading.Lock,
|
|
127
|
+
privacy_mode: str = "normal",
|
|
128
|
+
rate_card_path: Path = DEFAULT_RATE_CARD_PATH,
|
|
129
|
+
**kwargs: object,
|
|
130
|
+
) -> None:
|
|
131
|
+
self._db_path = db_path
|
|
132
|
+
self._pricing_path = pricing_path
|
|
133
|
+
self._allowance_path = allowance_path
|
|
134
|
+
self._rate_card_path = rate_card_path
|
|
135
|
+
self._thresholds_path = thresholds_path
|
|
136
|
+
self._projects_path = projects_path
|
|
137
|
+
self._privacy_mode = privacy_mode
|
|
138
|
+
self._limit = limit
|
|
139
|
+
self._since = since
|
|
140
|
+
self._codex_home = codex_home
|
|
141
|
+
self._include_archived = include_archived
|
|
142
|
+
self._dashboard_name = dashboard_name
|
|
143
|
+
self._context_chars = context_chars
|
|
144
|
+
self._api_token = api_token
|
|
145
|
+
self._context_api_enabled = context_api_enabled
|
|
146
|
+
self._refresh_lock = refresh_lock
|
|
147
|
+
super().__init__(*args, **kwargs)
|
|
148
|
+
|
|
149
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib hook name
|
|
150
|
+
parsed = urlparse(self.path)
|
|
151
|
+
if not self._request_origin_allowed():
|
|
152
|
+
self._send_json(HTTPStatus.FORBIDDEN, {"error": "Request host or origin is not allowed"})
|
|
153
|
+
return
|
|
154
|
+
if parsed.path == "/api/context":
|
|
155
|
+
self._handle_context(parsed.query)
|
|
156
|
+
return
|
|
157
|
+
if parsed.path == "/api/usage":
|
|
158
|
+
self._handle_usage(parsed.query)
|
|
159
|
+
return
|
|
160
|
+
if parsed.path == "/":
|
|
161
|
+
self.path = f"/{self._dashboard_name}"
|
|
162
|
+
super().do_GET()
|
|
163
|
+
|
|
164
|
+
def end_headers(self) -> None:
|
|
165
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
166
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
167
|
+
self.send_header(
|
|
168
|
+
"Content-Security-Policy",
|
|
169
|
+
"default-src 'self'; script-src 'self'; "
|
|
170
|
+
"style-src 'self'; connect-src 'self'; "
|
|
171
|
+
"img-src 'self' data:; object-src 'none'; base-uri 'none'",
|
|
172
|
+
)
|
|
173
|
+
super().end_headers()
|
|
174
|
+
|
|
175
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
176
|
+
if self.path.startswith("/api/usage"):
|
|
177
|
+
return
|
|
178
|
+
super().log_message(format, *args)
|
|
179
|
+
|
|
180
|
+
def _handle_context(self, query: str) -> None:
|
|
181
|
+
params = parse_qs(query)
|
|
182
|
+
if not self._context_api_enabled:
|
|
183
|
+
self._send_json(
|
|
184
|
+
HTTPStatus.FORBIDDEN,
|
|
185
|
+
{"error": "Context API is disabled for this dashboard server."},
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
if not self._has_valid_api_token(params):
|
|
189
|
+
self._send_json(HTTPStatus.FORBIDDEN, {"error": "Valid API token is required"})
|
|
190
|
+
return
|
|
191
|
+
record_id = _first(params.get("record_id"))
|
|
192
|
+
if not record_id:
|
|
193
|
+
self._send_json(
|
|
194
|
+
HTTPStatus.BAD_REQUEST,
|
|
195
|
+
{"error": "record_id is required"},
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
include_tool_output = _truthy(_first(params.get("include_tool_output")))
|
|
199
|
+
try:
|
|
200
|
+
payload = load_call_context(
|
|
201
|
+
record_id=record_id,
|
|
202
|
+
db_path=self._db_path,
|
|
203
|
+
max_chars=self._context_chars,
|
|
204
|
+
include_tool_output=include_tool_output,
|
|
205
|
+
)
|
|
206
|
+
except sqlite3.Error as exc:
|
|
207
|
+
self._send_json(
|
|
208
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
209
|
+
{"error": f"Database error while loading context: {exc}"},
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
self._send_json(HTTPStatus.NOT_FOUND, {"error": str(exc)})
|
|
214
|
+
return
|
|
215
|
+
except FileNotFoundError as exc:
|
|
216
|
+
self._send_json(HTTPStatus.NOT_FOUND, {"error": str(exc)})
|
|
217
|
+
return
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
self._send_json(
|
|
220
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
221
|
+
{"error": f"Could not read source log: {exc}"},
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
self._send_json(HTTPStatus.OK, payload)
|
|
225
|
+
|
|
226
|
+
def _handle_usage(self, query: str) -> None:
|
|
227
|
+
params = parse_qs(query)
|
|
228
|
+
limit = _parse_limit(_first(params.get("limit")), self._limit)
|
|
229
|
+
offset = _parse_offset(_first(params.get("offset")))
|
|
230
|
+
include_archived = _parse_bool(_first(params.get("include_archived")), self._include_archived)
|
|
231
|
+
refresh_result = None
|
|
232
|
+
try:
|
|
233
|
+
if _truthy(_first(params.get("refresh"))):
|
|
234
|
+
if not self._has_valid_api_token(params):
|
|
235
|
+
self._send_json(
|
|
236
|
+
HTTPStatus.FORBIDDEN,
|
|
237
|
+
{"error": "Valid API token is required for refresh"},
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
with self._refresh_lock:
|
|
241
|
+
result = refresh_usage_index(
|
|
242
|
+
codex_home=self._codex_home,
|
|
243
|
+
db_path=self._db_path,
|
|
244
|
+
include_archived=include_archived,
|
|
245
|
+
)
|
|
246
|
+
refresh_result = {
|
|
247
|
+
"scanned_files": result.scanned_files,
|
|
248
|
+
"parsed_events": result.parsed_events,
|
|
249
|
+
"skipped_events": result.skipped_events,
|
|
250
|
+
"inserted_or_updated_events": result.inserted_or_updated_events,
|
|
251
|
+
"db_path": result.db_path,
|
|
252
|
+
"parser_diagnostics": result.parser_diagnostics,
|
|
253
|
+
"include_archived": include_archived,
|
|
254
|
+
}
|
|
255
|
+
payload = dashboard_payload(
|
|
256
|
+
db_path=self._db_path,
|
|
257
|
+
limit=limit,
|
|
258
|
+
offset=offset,
|
|
259
|
+
pricing_path=self._pricing_path,
|
|
260
|
+
allowance_path=self._allowance_path,
|
|
261
|
+
rate_card_path=self._rate_card_path,
|
|
262
|
+
thresholds_path=self._thresholds_path,
|
|
263
|
+
projects_path=self._projects_path,
|
|
264
|
+
privacy_mode=self._privacy_mode,
|
|
265
|
+
since=self._since,
|
|
266
|
+
api_token=self._api_token,
|
|
267
|
+
context_api_enabled=self._context_api_enabled,
|
|
268
|
+
include_archived=include_archived,
|
|
269
|
+
)
|
|
270
|
+
except sqlite3.Error as exc:
|
|
271
|
+
self._send_json(
|
|
272
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
273
|
+
{"error": f"Database error while reading usage data: {exc}"},
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
self._send_json(
|
|
278
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
279
|
+
{"error": f"Could not read aggregate dashboard data: {exc}"},
|
|
280
|
+
)
|
|
281
|
+
return
|
|
282
|
+
payload["refreshed_at"] = _utc_now()
|
|
283
|
+
payload["refresh_result"] = refresh_result
|
|
284
|
+
self._send_json(HTTPStatus.OK, payload)
|
|
285
|
+
|
|
286
|
+
def _request_origin_allowed(self) -> bool:
|
|
287
|
+
if not _allowed_loopback_host(_host_header_name(self.headers.get("Host"))):
|
|
288
|
+
return False
|
|
289
|
+
origin = self.headers.get("Origin")
|
|
290
|
+
if not origin:
|
|
291
|
+
return True
|
|
292
|
+
parsed = urlparse(origin)
|
|
293
|
+
if parsed.scheme not in {"http", "https"}:
|
|
294
|
+
return False
|
|
295
|
+
if not _allowed_loopback_host(parsed.hostname):
|
|
296
|
+
return False
|
|
297
|
+
return parsed.port is None or parsed.port == self.server.server_port
|
|
298
|
+
|
|
299
|
+
def _has_valid_api_token(self, params: dict[str, list[str]]) -> bool:
|
|
300
|
+
provided = self.headers.get("X-Codex-Usage-Token") or _first(params.get("api_token")) or ""
|
|
301
|
+
return hmac.compare_digest(str(provided), self._api_token)
|
|
302
|
+
|
|
303
|
+
def _send_json(self, status: HTTPStatus, payload: dict[str, object]) -> None:
|
|
304
|
+
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
|
305
|
+
self.send_response(status)
|
|
306
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
307
|
+
self.send_header("Cache-Control", "no-store")
|
|
308
|
+
self.send_header("Content-Length", str(len(body)))
|
|
309
|
+
self.end_headers()
|
|
310
|
+
self.wfile.write(body)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _first(values: list[str] | None) -> str | None:
|
|
314
|
+
return values[0] if values else None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _truthy(value: str | None) -> bool:
|
|
318
|
+
return str(value or "").lower() in {"1", "true", "yes", "on"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_bool(value: str | None, default: bool) -> bool:
|
|
322
|
+
if value is None or value == "":
|
|
323
|
+
return default
|
|
324
|
+
normalized = value.lower()
|
|
325
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
326
|
+
return True
|
|
327
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
328
|
+
return False
|
|
329
|
+
return default
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_limit(value: str | None, default: int | None) -> int | None:
|
|
333
|
+
if value is None or value == "":
|
|
334
|
+
return default
|
|
335
|
+
if value.lower() == "all":
|
|
336
|
+
return None
|
|
337
|
+
try:
|
|
338
|
+
limit = int(value)
|
|
339
|
+
except ValueError:
|
|
340
|
+
return default
|
|
341
|
+
if limit <= 0:
|
|
342
|
+
return None
|
|
343
|
+
return limit
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_offset(value: str | None) -> int:
|
|
347
|
+
if value is None or value == "":
|
|
348
|
+
return 0
|
|
349
|
+
try:
|
|
350
|
+
offset = int(value)
|
|
351
|
+
except ValueError:
|
|
352
|
+
return 0
|
|
353
|
+
return max(offset, 0)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _utc_now() -> str:
|
|
357
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _validate_loopback_host(host: str) -> None:
|
|
361
|
+
if host == "localhost":
|
|
362
|
+
return
|
|
363
|
+
try:
|
|
364
|
+
address = ip_address(host)
|
|
365
|
+
except ValueError as exc:
|
|
366
|
+
raise ValueError(
|
|
367
|
+
"serve-dashboard --host must be localhost, 127.0.0.1, or ::1"
|
|
368
|
+
) from exc
|
|
369
|
+
if not address.is_loopback:
|
|
370
|
+
raise ValueError("serve-dashboard refuses to expose raw context off localhost")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _validate_context_api_mode(mode: str) -> None:
|
|
374
|
+
if mode not in {"explicit", "disabled"}:
|
|
375
|
+
raise ValueError("--context-api must be explicit or disabled")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _allowed_loopback_host(host: str | None) -> bool:
|
|
379
|
+
if not host:
|
|
380
|
+
return False
|
|
381
|
+
if host == "localhost":
|
|
382
|
+
return True
|
|
383
|
+
try:
|
|
384
|
+
return ip_address(host).is_loopback
|
|
385
|
+
except ValueError:
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _host_header_name(value: str | None) -> str | None:
|
|
390
|
+
if not value:
|
|
391
|
+
return None
|
|
392
|
+
host = value.strip()
|
|
393
|
+
if host.startswith("["):
|
|
394
|
+
end = host.find("]")
|
|
395
|
+
return host[1:end] if end > 0 else None
|
|
396
|
+
return host.split(":", 1)[0]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _url_host(host: str) -> str:
|
|
400
|
+
return f"[{host}]" if ":" in host and not host.startswith("[") else host
|