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.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
service_status.py ADDED
@@ -0,0 +1,383 @@
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
+ """Read public service-status feeds, never LLM usage APIs.
8
+
9
+ This module downloads only public Statuspage JSON. It does not call an LLM
10
+ usage API and therefore does not collect or infer account usage.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import logging
18
+ import os
19
+ import tempfile
20
+ import time
21
+ import urllib.request
22
+ from dataclasses import dataclass
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Any, Literal
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ CACHE_TTL_SECONDS = 300
30
+ FAILURE_RETRY_SECONDS = 60
31
+ MONITORING_SETTLED_SECONDS = 4 * 3600
32
+ OBSERVED_STALE_SECONDS = 24 * 3600
33
+ SUPPRESSIBLE_STATUSES = ("degraded_performance",)
34
+ _CLOSED_INCIDENTS = frozenset({"resolved", "postmortem"})
35
+ USER_AGENT = "usage/0.9"
36
+ # Statuspage summaries are tens of KB; cap the read so a broken endpoint cannot
37
+ # make us buffer an unbounded response.
38
+ MAX_RESPONSE_BYTES = 4 * 1024 * 1024
39
+ ALERT_STATE_PATH = Path(os.path.expanduser("~/.usage/service_alert_state.json"))
40
+
41
+ StatusSource = Literal["fetched", "cache", "stale", "fallback"]
42
+ _SEVERITY = {
43
+ "operational": 0,
44
+ "degraded_performance": 1,
45
+ "partial_outage": 2,
46
+ "major_outage": 3,
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ServiceStatusConfig:
52
+ """The public status-page details for one tool."""
53
+
54
+ service_name: str
55
+ status_url: str
56
+ incidents_url: str
57
+ component_names: tuple[str, ...]
58
+ cache_path: Path
59
+
60
+
61
+ # Both feeds use components.json, not summary.json. Statuspage's summary payload
62
+ # is truncated to the first 25 components by position: OpenAI publishes 34, so
63
+ # "Codex API" (position 27) is silently absent from it and _build_status() can
64
+ # only ever report "unknown". components.json returns the full list and carries
65
+ # the same top-level "components" key, so the parser is unchanged. Anthropic
66
+ # publishes 6 today and is unaffected, but it uses the same endpoint so a future
67
+ # component of theirs cannot quietly fall off the end either.
68
+ CLAUDE_STATUS = ServiceStatusConfig(
69
+ service_name="Claude",
70
+ status_url="https://status.claude.com/api/v2/components.json",
71
+ incidents_url="https://status.claude.com/api/v2/incidents.json",
72
+ component_names=("Claude Code", "Claude API (api.anthropic.com)"),
73
+ cache_path=Path(os.path.expanduser("~/.usage/anthropic_status_cache.json")),
74
+ )
75
+ CODEX_STATUS = ServiceStatusConfig(
76
+ service_name="Codex",
77
+ status_url="https://status.openai.com/api/v2/components.json",
78
+ incidents_url="https://status.openai.com/api/v2/incidents.json",
79
+ # Do not include shared OpenAI API components (for example Responses): they
80
+ # affect all API users and do not necessarily affect the Codex CLI. Nor the
81
+ # "Codex Web" / "Codex in ChatGPT Desktop" components, which track surfaces
82
+ # the CLI does not use.
83
+ component_names=("Codex API",),
84
+ cache_path=Path(os.path.expanduser("~/.usage/openai_status_cache.json")),
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ServiceStatus:
90
+ """Service state relevant to one supported tool."""
91
+
92
+ service_name: str
93
+ is_abnormal: bool
94
+ status: str
95
+ description: str
96
+ source: StatusSource
97
+
98
+
99
+ _last_failure_at: dict[str, float] = {}
100
+
101
+
102
+ def get_service_status(config: ServiceStatusConfig) -> ServiceStatus:
103
+ """Return the worst status among this tool's relevant components."""
104
+ cached = _read_cache(config)
105
+ if cached is not None:
106
+ return _build_status(config, cached, "cache")
107
+
108
+ stale_cached = _read_cache(config, allow_stale=True)
109
+ if _retry_is_delayed(config):
110
+ return _status_from_stale_or_fallback(config, stale_cached)
111
+
112
+ payload = _fetch_status(config)
113
+ if payload is not None:
114
+ status = _build_component_status(config, payload, "fetched")
115
+ if status.is_abnormal and status.status in SUPPRESSIBLE_STATUSES:
116
+ incidents = _fetch_incidents(config)
117
+ if incidents is not None:
118
+ payload["incidents"] = incidents
119
+ _write_cache(config, payload)
120
+ _clear_failure_retry(config)
121
+ return _apply_alert_suppression(config, payload, status)
122
+
123
+ _record_failure(config)
124
+ return _status_from_stale_or_fallback(config, stale_cached)
125
+
126
+
127
+ def _status_from_stale_or_fallback(
128
+ config: ServiceStatusConfig, payload: dict[str, Any] | None
129
+ ) -> ServiceStatus:
130
+ if payload is not None:
131
+ return _build_status(config, payload, "stale")
132
+ return ServiceStatus(config.service_name, False, "unknown", "Status unavailable.", "fallback")
133
+
134
+
135
+ def _build_status(
136
+ config: ServiceStatusConfig, payload: dict[str, Any], source: StatusSource
137
+ ) -> ServiceStatus:
138
+ status = _build_component_status(config, payload, source)
139
+ return _apply_alert_suppression(config, payload, status)
140
+
141
+
142
+ def _build_component_status(
143
+ config: ServiceStatusConfig, payload: dict[str, Any], source: StatusSource
144
+ ) -> ServiceStatus:
145
+ components = payload.get("components")
146
+ if not isinstance(components, list):
147
+ return ServiceStatus(config.service_name, False, "unknown", "Status unavailable.", source)
148
+
149
+ component_statuses = {
150
+ component.get("name"): component.get("status")
151
+ for component in components
152
+ if isinstance(component, dict)
153
+ and isinstance(component.get("name"), str)
154
+ and isinstance(component.get("status"), str)
155
+ }
156
+ statuses = tuple(component_statuses.get(name) for name in config.component_names)
157
+ valid_statuses = tuple(
158
+ status for status in statuses if isinstance(status, str) and status in _SEVERITY
159
+ )
160
+ if len(valid_statuses) != len(config.component_names):
161
+ return ServiceStatus(config.service_name, False, "unknown", "Status unavailable.", source)
162
+
163
+ worst_status = max(valid_statuses, key=lambda status: _SEVERITY[status])
164
+ affected = [
165
+ name
166
+ for name, status in zip(config.component_names, valid_statuses, strict=True)
167
+ if status != "operational"
168
+ ]
169
+ if not affected:
170
+ status = ServiceStatus(
171
+ config.service_name, False, worst_status, "Relevant components are operational.", source
172
+ )
173
+ else:
174
+ status = ServiceStatus(
175
+ config.service_name,
176
+ True,
177
+ worst_status,
178
+ f"{', '.join(affected)}: {worst_status}.",
179
+ source,
180
+ )
181
+ return status
182
+
183
+
184
+ def _apply_alert_suppression(
185
+ config: ServiceStatusConfig, payload: dict[str, Any], status: ServiceStatus
186
+ ) -> ServiceStatus:
187
+ observed_stale = _observe_status(config.service_name, status.status)
188
+ if not status.is_abnormal or status.status not in SUPPRESSIBLE_STATUSES:
189
+ return status
190
+
191
+ incidents = payload.get("incidents")
192
+ if isinstance(incidents, list):
193
+ incident_statuses = [
194
+ incident.get("status") for incident in incidents if isinstance(incident, dict)
195
+ ]
196
+ if any(value in {"investigating", "identified"} for value in incident_statuses):
197
+ return status
198
+
199
+ if incidents and len(incident_statuses) == len(incidents) and all(
200
+ value == "monitoring" for value in incident_statuses
201
+ ):
202
+ updated_times: list[datetime] = []
203
+ for incident in incidents:
204
+ updated_at = incident.get("updated_at")
205
+ if not isinstance(updated_at, str):
206
+ break
207
+ try:
208
+ updated_time = datetime.fromisoformat(updated_at)
209
+ except ValueError:
210
+ break
211
+ if updated_time.tzinfo is None:
212
+ break
213
+ updated_times.append(updated_time)
214
+ if len(updated_times) == len(incidents):
215
+ latest_update = max(updated_times)
216
+ age_seconds = (datetime.now(UTC) - latest_update).total_seconds()
217
+ if age_seconds > MONITORING_SETTLED_SECONDS:
218
+ return ServiceStatus(
219
+ status.service_name,
220
+ False,
221
+ status.status,
222
+ "Alert suppressed: all incidents have remained in monitoring "
223
+ "for more than 4 hours.",
224
+ status.source,
225
+ )
226
+
227
+ if observed_stale:
228
+ return ServiceStatus(
229
+ status.service_name,
230
+ False,
231
+ status.status,
232
+ "Alert suppressed: this status has been observed unchanged for more than 24 hours.",
233
+ status.source,
234
+ )
235
+ return status
236
+
237
+
238
+ def _observe_status(service_name: str, status: str) -> bool:
239
+ state = _read_alert_state()
240
+ now = time.time()
241
+ previous = state.get(service_name)
242
+ if (
243
+ isinstance(previous, dict)
244
+ and previous.get("status") == status
245
+ and isinstance(previous.get("first_seen_at"), int | float)
246
+ and not isinstance(previous.get("first_seen_at"), bool)
247
+ ):
248
+ first_seen_at = float(previous["first_seen_at"])
249
+ return now - first_seen_at > OBSERVED_STALE_SECONDS
250
+
251
+ state[service_name] = {"status": status, "first_seen_at": now}
252
+ _write_alert_state(state)
253
+ return False
254
+
255
+
256
+ def _read_alert_state() -> dict[str, Any]:
257
+ try:
258
+ with ALERT_STATE_PATH.open(encoding="utf-8") as file:
259
+ state = json.load(file)
260
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
261
+ logger.debug("failed to read service alert state: %s", exc)
262
+ return {}
263
+ return state if isinstance(state, dict) else {}
264
+
265
+
266
+ def _write_alert_state(state: dict[str, Any]) -> None:
267
+ tmp_path: str | None = None
268
+ try:
269
+ ALERT_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
270
+ fd, tmp_path = tempfile.mkstemp(dir=ALERT_STATE_PATH.parent, suffix=".tmp")
271
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
272
+ json.dump(state, file, ensure_ascii=False, indent=2, sort_keys=True)
273
+ os.replace(tmp_path, ALERT_STATE_PATH)
274
+ tmp_path = None
275
+ except OSError as exc:
276
+ logger.warning("failed to write service alert state: %s", exc)
277
+ finally:
278
+ if tmp_path is not None:
279
+ with contextlib.suppress(OSError):
280
+ os.unlink(tmp_path)
281
+
282
+
283
+ def _read_cache(
284
+ config: ServiceStatusConfig, *, allow_stale: bool = False
285
+ ) -> dict[str, Any] | None:
286
+ try:
287
+ if not allow_stale and (
288
+ time.time() - config.cache_path.stat().st_mtime
289
+ ) > CACHE_TTL_SECONDS:
290
+ return None
291
+ with config.cache_path.open(encoding="utf-8") as file:
292
+ payload = json.load(file)
293
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
294
+ logger.debug("failed to read %s status cache: %s", config.service_name, exc)
295
+ return None
296
+ return payload if isinstance(payload, dict) else None
297
+
298
+
299
+ def _fetch_status(config: ServiceStatusConfig) -> dict[str, Any] | None:
300
+ request = urllib.request.Request(config.status_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("status response exceeds the size limit")
306
+ payload = json.loads(raw.decode("utf-8"))
307
+ except (OSError, TimeoutError, UnicodeDecodeError, ValueError) as exc:
308
+ logger.warning(
309
+ "failed to fetch %s status from %s: %s",
310
+ config.service_name,
311
+ config.status_url,
312
+ exc,
313
+ )
314
+ return None
315
+ return payload if isinstance(payload, dict) else None
316
+
317
+
318
+ # Both feeds use incidents.json, not incidents/unresolved.json: OpenAI's status
319
+ # page answers 404 for the unresolved endpoint (verified 2026-08-13), and its
320
+ # summary.json has no "incidents" key at all, so this is the only incident
321
+ # source that works for Codex. Anthropic serves both; using one endpoint for
322
+ # both keeps a single payload shape.
323
+ def _fetch_incidents(config: ServiceStatusConfig) -> list[Any] | None:
324
+ """Fetch unresolved incidents without affecting component-status availability."""
325
+ request = urllib.request.Request(config.incidents_url, headers={"User-Agent": USER_AGENT})
326
+ try:
327
+ with urllib.request.urlopen(request, timeout=10) as response:
328
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
329
+ if len(raw) > MAX_RESPONSE_BYTES:
330
+ raise ValueError("incidents response exceeds the size limit")
331
+ payload = json.loads(raw.decode("utf-8"))
332
+ except (OSError, TimeoutError, UnicodeDecodeError, ValueError) as exc:
333
+ logger.debug(
334
+ "failed to fetch %s unresolved incidents from %s: %s",
335
+ config.service_name,
336
+ config.incidents_url,
337
+ exc,
338
+ )
339
+ return None
340
+ if not isinstance(payload, dict):
341
+ return None
342
+ incidents = payload.get("incidents")
343
+ if not isinstance(incidents, list):
344
+ return None
345
+ # incidents.json carries resolved history too. Drop the closed ones by
346
+ # denylist rather than keeping an allowlist of open states: an unfamiliar
347
+ # status then survives the filter and blocks suppression, which errs
348
+ # towards showing the banner.
349
+ return [
350
+ incident
351
+ for incident in incidents
352
+ if not (isinstance(incident, dict) and incident.get("status") in _CLOSED_INCIDENTS)
353
+ ]
354
+
355
+
356
+ def _write_cache(config: ServiceStatusConfig, payload: dict[str, Any]) -> None:
357
+ tmp_path: str | None = None
358
+ try:
359
+ config.cache_path.parent.mkdir(parents=True, exist_ok=True)
360
+ fd, tmp_path = tempfile.mkstemp(dir=config.cache_path.parent, suffix=".tmp")
361
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
362
+ json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
363
+ os.replace(tmp_path, config.cache_path)
364
+ tmp_path = None
365
+ except OSError as exc:
366
+ logger.warning("failed to write %s status cache: %s", config.service_name, exc)
367
+ finally:
368
+ if tmp_path is not None:
369
+ with contextlib.suppress(OSError):
370
+ os.unlink(tmp_path)
371
+
372
+
373
+ def _retry_is_delayed(config: ServiceStatusConfig) -> bool:
374
+ failed_at = _last_failure_at.get(config.service_name)
375
+ return failed_at is not None and time.monotonic() - failed_at < FAILURE_RETRY_SECONDS
376
+
377
+
378
+ def _record_failure(config: ServiceStatusConfig) -> None:
379
+ _last_failure_at[config.service_name] = time.monotonic()
380
+
381
+
382
+ def _clear_failure_retry(config: ServiceStatusConfig) -> None:
383
+ _last_failure_at.pop(config.service_name, None)