remote-coder 0.4.1__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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/monitoring/model.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Final
|
|
12
|
+
|
|
13
|
+
from app.ai.usage import extract_runner_usage, format_token_usage, merge_token_usage
|
|
14
|
+
from app.jobs.schemas import Job
|
|
15
|
+
from app.models import ModelName
|
|
16
|
+
|
|
17
|
+
_CLI_TIMEOUT_SEC: Final[int] = 25
|
|
18
|
+
_RECENT_JOB_LIMIT: Final[int] = 50
|
|
19
|
+
_LOG_READ_LIMIT: Final[int] = 120_000
|
|
20
|
+
_LOCAL_USAGE_FILE_LIMIT: Final[int] = 80
|
|
21
|
+
_LOCAL_USAGE_LINE_LIMIT: Final[int] = 5000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RecentUsageSummary:
|
|
26
|
+
inspected_jobs: int
|
|
27
|
+
latest_job_id: str | None = None
|
|
28
|
+
latest_status: str | None = None
|
|
29
|
+
latest_finished_at: datetime | None = None
|
|
30
|
+
actual_model: str | None = None
|
|
31
|
+
token_metrics: dict[str, int] | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LocalQuotaWindow:
|
|
36
|
+
label: str
|
|
37
|
+
used_percent: float
|
|
38
|
+
remaining_percent: float
|
|
39
|
+
resets_at: datetime | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class LocalUsageSnapshot:
|
|
44
|
+
source: str
|
|
45
|
+
observed_at: datetime | None = None
|
|
46
|
+
actual_model: str | None = None
|
|
47
|
+
token_metrics: dict[str, int] | None = None
|
|
48
|
+
quota_windows: tuple[LocalQuotaWindow, ...] = ()
|
|
49
|
+
plan_type: str | None = None
|
|
50
|
+
requests_today: int | None = None
|
|
51
|
+
remaining_note: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_model_monitor(
|
|
55
|
+
model: ModelName,
|
|
56
|
+
timeout_seconds: int = _CLI_TIMEOUT_SEC,
|
|
57
|
+
*,
|
|
58
|
+
recent_jobs: Iterable[Job] | None = None,
|
|
59
|
+
chat_id: int | None = None,
|
|
60
|
+
project: str | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
provider = _USAGE_PROVIDERS[model]
|
|
63
|
+
body = provider.format_monitor(timeout_seconds)
|
|
64
|
+
|
|
65
|
+
local_usage = _format_local_usage_section(provider.read_local_usage())
|
|
66
|
+
usage = _format_recent_usage_section(
|
|
67
|
+
_summarize_recent_usage(recent_jobs, model=model, chat_id=chat_id, project=project)
|
|
68
|
+
)
|
|
69
|
+
sections = [body]
|
|
70
|
+
if local_usage:
|
|
71
|
+
sections.append(local_usage)
|
|
72
|
+
if usage:
|
|
73
|
+
sections.append(usage)
|
|
74
|
+
return "\n\n".join(sections)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _summarize_recent_usage(
|
|
78
|
+
recent_jobs: Iterable[Job] | None,
|
|
79
|
+
*,
|
|
80
|
+
model: ModelName,
|
|
81
|
+
chat_id: int | None,
|
|
82
|
+
project: str | None,
|
|
83
|
+
) -> RecentUsageSummary | None:
|
|
84
|
+
if recent_jobs is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
matched: list[Job] = []
|
|
88
|
+
for job in recent_jobs:
|
|
89
|
+
if chat_id is not None and job.request.chat_id != chat_id:
|
|
90
|
+
continue
|
|
91
|
+
if project is not None and job.request.project != project:
|
|
92
|
+
continue
|
|
93
|
+
if job.request.model != model:
|
|
94
|
+
continue
|
|
95
|
+
matched.append(job)
|
|
96
|
+
if len(matched) >= _RECENT_JOB_LIMIT:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if not matched:
|
|
100
|
+
return RecentUsageSummary(inspected_jobs=0)
|
|
101
|
+
|
|
102
|
+
latest = matched[0]
|
|
103
|
+
actual_model: str | None = None
|
|
104
|
+
totals: dict[str, int] = {}
|
|
105
|
+
for job in matched:
|
|
106
|
+
text = _read_observable_job_text(job)
|
|
107
|
+
usage = extract_runner_usage(text)
|
|
108
|
+
if actual_model is None:
|
|
109
|
+
actual_model = job.runner_actual_model or usage.actual_model
|
|
110
|
+
merge_token_usage(totals, job.runner_token_usage or usage.token_usage)
|
|
111
|
+
|
|
112
|
+
return RecentUsageSummary(
|
|
113
|
+
inspected_jobs=len(matched),
|
|
114
|
+
latest_job_id=latest.id,
|
|
115
|
+
latest_status=latest.status.value,
|
|
116
|
+
latest_finished_at=latest.finished_at,
|
|
117
|
+
actual_model=actual_model,
|
|
118
|
+
token_metrics=totals or None,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_observable_job_text(job: Job) -> str:
|
|
123
|
+
if job.log_path is not None:
|
|
124
|
+
log_text = _read_log_excerpt(job.log_path)
|
|
125
|
+
if log_text:
|
|
126
|
+
return log_text
|
|
127
|
+
parts = [job.runner_stdout_summary or "", job.runner_stderr_summary or ""]
|
|
128
|
+
return "\n".join(part for part in parts if part)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_log_excerpt(path: Path) -> str:
|
|
132
|
+
try:
|
|
133
|
+
with path.open("r", encoding="utf-8", errors="replace") as file:
|
|
134
|
+
return file.read(_LOG_READ_LIMIT)
|
|
135
|
+
except OSError:
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _format_recent_usage_section(summary: RecentUsageSummary | None) -> str | None:
|
|
140
|
+
if summary is None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
lines = ["Recent job usage"]
|
|
144
|
+
if summary.inspected_jobs == 0:
|
|
145
|
+
lines.append("- No completed/running jobs for this chat/project/model.")
|
|
146
|
+
lines.append("- Model details/tokens appear only when available in CLI output or local logs.")
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
latest_bits = [summary.latest_job_id or "-"]
|
|
150
|
+
if summary.latest_status:
|
|
151
|
+
latest_bits.append(summary.latest_status)
|
|
152
|
+
if summary.latest_finished_at:
|
|
153
|
+
latest_bits.append(summary.latest_finished_at.isoformat())
|
|
154
|
+
lines.append(f"- Recent job: {' / '.join(latest_bits)}")
|
|
155
|
+
lines.append(f"- Inspected jobs: {summary.inspected_jobs}")
|
|
156
|
+
if summary.actual_model:
|
|
157
|
+
lines.append(f"- Observed detailed model: {summary.actual_model}")
|
|
158
|
+
else:
|
|
159
|
+
lines.append("- Observed detailed model: selected by CLI default/settings (not found in logs)")
|
|
160
|
+
if summary.token_metrics:
|
|
161
|
+
lines.append(f"- Observed tokens: {format_token_usage(summary.token_metrics)}")
|
|
162
|
+
else:
|
|
163
|
+
lines.append("- Observed tokens: no token usage pattern found in logs.")
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _format_local_usage_section(snapshot: LocalUsageSnapshot | None) -> str | None:
|
|
168
|
+
if snapshot is None:
|
|
169
|
+
return "Local usage/quota snapshot\n- No local CLI usage logs found."
|
|
170
|
+
|
|
171
|
+
lines = ["Local usage/quota snapshot", f"- Source: {snapshot.source}"]
|
|
172
|
+
if snapshot.observed_at:
|
|
173
|
+
lines.append(f"- Observed at: {snapshot.observed_at.astimezone().isoformat(timespec='seconds')}")
|
|
174
|
+
if snapshot.plan_type:
|
|
175
|
+
lines.append(f"- Plan/account type: {snapshot.plan_type}")
|
|
176
|
+
if snapshot.actual_model:
|
|
177
|
+
lines.append(f"- Observed detailed model: {snapshot.actual_model}")
|
|
178
|
+
if snapshot.token_metrics:
|
|
179
|
+
formatted = format_token_usage(snapshot.token_metrics)
|
|
180
|
+
if formatted:
|
|
181
|
+
lines.append(f"- Observed tokens: {formatted}")
|
|
182
|
+
if snapshot.requests_today is not None:
|
|
183
|
+
lines.append(f"- Requests today from local logs: {snapshot.requests_today:,}")
|
|
184
|
+
if snapshot.quota_windows:
|
|
185
|
+
for window in snapshot.quota_windows:
|
|
186
|
+
reset = ""
|
|
187
|
+
if window.resets_at is not None:
|
|
188
|
+
reset = f", reset {window.resets_at.astimezone().isoformat(timespec='minutes')}"
|
|
189
|
+
lines.append(
|
|
190
|
+
f"- {window.label}: remaining {window.remaining_percent:g}% "
|
|
191
|
+
f"(used {window.used_percent:g}%{reset})"
|
|
192
|
+
)
|
|
193
|
+
elif snapshot.remaining_note:
|
|
194
|
+
lines.append(f"- Remaining quota: {snapshot.remaining_note}")
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _iter_recent_files(root: Path, pattern: str) -> list[Path]:
|
|
199
|
+
if not root.exists():
|
|
200
|
+
return []
|
|
201
|
+
try:
|
|
202
|
+
files = [p for p in root.rglob(pattern) if p.is_file()]
|
|
203
|
+
except OSError:
|
|
204
|
+
return []
|
|
205
|
+
files.sort(key=lambda p: _safe_mtime(p), reverse=True)
|
|
206
|
+
return files[:_LOCAL_USAGE_FILE_LIMIT]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_jsonl_objects(path: Path) -> list[dict[str, Any]]:
|
|
210
|
+
try:
|
|
211
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
212
|
+
except OSError:
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
objects: list[dict[str, Any]] = []
|
|
216
|
+
for line in lines[-_LOCAL_USAGE_LINE_LIMIT:]:
|
|
217
|
+
try:
|
|
218
|
+
item = json.loads(line)
|
|
219
|
+
except json.JSONDecodeError:
|
|
220
|
+
continue
|
|
221
|
+
if isinstance(item, dict):
|
|
222
|
+
objects.append(item)
|
|
223
|
+
return objects
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _normalize_token_dict(raw: object) -> dict[str, int]:
|
|
227
|
+
if not isinstance(raw, dict):
|
|
228
|
+
return {}
|
|
229
|
+
labels = {
|
|
230
|
+
"input_tokens": "input",
|
|
231
|
+
"cache_creation_input_tokens": "cache write",
|
|
232
|
+
"cache_read_input_tokens": "cache read",
|
|
233
|
+
"cached_input_tokens": "cached",
|
|
234
|
+
"output_tokens": "output",
|
|
235
|
+
"reasoning_output_tokens": "reasoning",
|
|
236
|
+
"total_tokens": "total",
|
|
237
|
+
"input": "input",
|
|
238
|
+
"output": "output",
|
|
239
|
+
"cached": "cached",
|
|
240
|
+
"thoughts": "thoughts",
|
|
241
|
+
"tool": "tool",
|
|
242
|
+
"total": "total",
|
|
243
|
+
}
|
|
244
|
+
metrics: dict[str, int] = {}
|
|
245
|
+
for key, value in raw.items():
|
|
246
|
+
normalized = labels.get(str(key))
|
|
247
|
+
parsed = _int_or_none(value)
|
|
248
|
+
if normalized is not None and parsed is not None:
|
|
249
|
+
metrics[normalized] = metrics.get(normalized, 0) + parsed
|
|
250
|
+
return metrics
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_datetime(raw: object) -> datetime | None:
|
|
254
|
+
if not isinstance(raw, str) or not raw:
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
258
|
+
except ValueError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _datetime_from_epoch(raw: object) -> datetime | None:
|
|
263
|
+
value = _int_or_none(raw)
|
|
264
|
+
if value is None:
|
|
265
|
+
return None
|
|
266
|
+
try:
|
|
267
|
+
return datetime.fromtimestamp(value, tz=timezone.utc)
|
|
268
|
+
except (OSError, ValueError, OverflowError):
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _format_window_label(minutes: int) -> str:
|
|
273
|
+
if minutes == 300:
|
|
274
|
+
return "5-hour limit"
|
|
275
|
+
if minutes == 10080:
|
|
276
|
+
return "Weekly limit"
|
|
277
|
+
if minutes % 1440 == 0:
|
|
278
|
+
return f"{minutes // 1440}-day limit"
|
|
279
|
+
if minutes % 60 == 0:
|
|
280
|
+
return f"{minutes // 60}-hour limit"
|
|
281
|
+
return f"{minutes}-minute limit"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _compact_home(path: Path) -> str:
|
|
285
|
+
home = Path.home()
|
|
286
|
+
try:
|
|
287
|
+
return "~/" + str(path.resolve().relative_to(home.resolve()))
|
|
288
|
+
except (OSError, ValueError):
|
|
289
|
+
return str(path)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _safe_mtime(path: Path) -> float:
|
|
293
|
+
try:
|
|
294
|
+
return path.stat().st_mtime
|
|
295
|
+
except OSError:
|
|
296
|
+
return 0.0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _is_newer(candidate: datetime | None, current: datetime | None) -> bool:
|
|
300
|
+
if current is None:
|
|
301
|
+
return True
|
|
302
|
+
if candidate is None:
|
|
303
|
+
return False
|
|
304
|
+
return candidate > current
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _int_or_none(raw: object) -> int | None:
|
|
308
|
+
if isinstance(raw, bool):
|
|
309
|
+
return None
|
|
310
|
+
if isinstance(raw, int):
|
|
311
|
+
return raw
|
|
312
|
+
if isinstance(raw, float):
|
|
313
|
+
return int(raw)
|
|
314
|
+
if isinstance(raw, str) and raw.strip().isdigit():
|
|
315
|
+
return int(raw.strip())
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _float_or_none(raw: object) -> float | None:
|
|
320
|
+
if isinstance(raw, bool):
|
|
321
|
+
return None
|
|
322
|
+
if isinstance(raw, int | float):
|
|
323
|
+
return float(raw)
|
|
324
|
+
if isinstance(raw, str):
|
|
325
|
+
try:
|
|
326
|
+
return float(raw.strip())
|
|
327
|
+
except ValueError:
|
|
328
|
+
return None
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _string_or_none(raw: object) -> str | None:
|
|
333
|
+
if not isinstance(raw, str):
|
|
334
|
+
return None
|
|
335
|
+
value = raw.strip()
|
|
336
|
+
return value or None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class ModelUsageProvider(ABC):
|
|
340
|
+
@abstractmethod
|
|
341
|
+
def format_monitor(self, timeout_seconds: int) -> str:
|
|
342
|
+
raise NotImplementedError
|
|
343
|
+
|
|
344
|
+
@abstractmethod
|
|
345
|
+
def read_local_usage(self) -> LocalUsageSnapshot | None:
|
|
346
|
+
raise NotImplementedError
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ClaudeUsageProvider(ModelUsageProvider):
|
|
350
|
+
def format_monitor(self, timeout_seconds: int) -> str:
|
|
351
|
+
lines: list[str] = ["[Claude]"]
|
|
352
|
+
try:
|
|
353
|
+
proc = subprocess.run(
|
|
354
|
+
["claude", "auth", "status", "--text"],
|
|
355
|
+
capture_output=True,
|
|
356
|
+
text=True,
|
|
357
|
+
timeout=timeout_seconds,
|
|
358
|
+
shell=False,
|
|
359
|
+
)
|
|
360
|
+
except FileNotFoundError:
|
|
361
|
+
lines.append("CLI: `claude` command not found. Check Claude Code CLI installation and PATH.")
|
|
362
|
+
return "\n".join(lines)
|
|
363
|
+
except subprocess.TimeoutExpired:
|
|
364
|
+
lines.append("`claude auth status --text` timed out.")
|
|
365
|
+
return "\n".join(lines)
|
|
366
|
+
|
|
367
|
+
out = (proc.stdout or "").strip()
|
|
368
|
+
err = (proc.stderr or "").strip()
|
|
369
|
+
if proc.returncode == 0 and out:
|
|
370
|
+
snippet = out if len(out) <= 2500 else out[:2500].rstrip() + "\n...(omitted)"
|
|
371
|
+
lines.append("auth status (--text):")
|
|
372
|
+
lines.append(snippet)
|
|
373
|
+
else:
|
|
374
|
+
lines.extend(self._auth_fallback_json(timeout_seconds, proc.returncode, err or out))
|
|
375
|
+
return "\n".join(lines)
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _auth_fallback_json(timeout_seconds: int, prev_code: int, prev_msg: str) -> list[str]:
|
|
379
|
+
lines: list[str] = []
|
|
380
|
+
try:
|
|
381
|
+
proc = subprocess.run(
|
|
382
|
+
["claude", "auth", "status"],
|
|
383
|
+
capture_output=True,
|
|
384
|
+
text=True,
|
|
385
|
+
timeout=timeout_seconds,
|
|
386
|
+
shell=False,
|
|
387
|
+
)
|
|
388
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
389
|
+
lines.append(f"auth status failed (previous exit {prev_code}): {prev_msg[:400]}")
|
|
390
|
+
return lines
|
|
391
|
+
|
|
392
|
+
raw = (proc.stdout or "").strip()
|
|
393
|
+
if proc.returncode != 0 or not raw:
|
|
394
|
+
lines.append(f"auth status failed (exit {proc.returncode}): {(proc.stderr or prev_msg)[:400]}")
|
|
395
|
+
return lines
|
|
396
|
+
try:
|
|
397
|
+
data = json.loads(raw)
|
|
398
|
+
except json.JSONDecodeError:
|
|
399
|
+
lines.append("auth status: non-JSON output (first 400 chars).")
|
|
400
|
+
lines.append(raw[:400])
|
|
401
|
+
return lines
|
|
402
|
+
|
|
403
|
+
safe_keys = (
|
|
404
|
+
"logged_in",
|
|
405
|
+
"authenticated",
|
|
406
|
+
"account",
|
|
407
|
+
"email",
|
|
408
|
+
"subscription",
|
|
409
|
+
"plan",
|
|
410
|
+
"organization",
|
|
411
|
+
)
|
|
412
|
+
picked: dict[str, object] = {}
|
|
413
|
+
if isinstance(data, dict):
|
|
414
|
+
for k in safe_keys:
|
|
415
|
+
if k in data:
|
|
416
|
+
picked[k] = data[k]
|
|
417
|
+
lines.append("auth status (JSON summary, sensitive values excluded):")
|
|
418
|
+
lines.append(json.dumps(picked, ensure_ascii=False, indent=2) if picked else "{}")
|
|
419
|
+
else:
|
|
420
|
+
lines.append("auth status: unexpected JSON shape.")
|
|
421
|
+
return lines
|
|
422
|
+
|
|
423
|
+
def read_local_usage(self) -> LocalUsageSnapshot | None:
|
|
424
|
+
root = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
|
|
425
|
+
projects = root / "projects"
|
|
426
|
+
newest: tuple[Path, dict[str, Any], dict[str, Any], datetime | None] | None = None
|
|
427
|
+
for path in _iter_recent_files(projects, "*.jsonl"):
|
|
428
|
+
for item in _read_jsonl_objects(path):
|
|
429
|
+
message = item.get("message") if isinstance(item.get("message"), dict) else {}
|
|
430
|
+
usage = message.get("usage") if isinstance(message.get("usage"), dict) else {}
|
|
431
|
+
if usage:
|
|
432
|
+
observed = _parse_datetime(item.get("timestamp"))
|
|
433
|
+
if _is_newer(observed, newest[3] if newest else None):
|
|
434
|
+
newest = (path, item, message, observed)
|
|
435
|
+
if newest is None:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
path, _item, message, observed = newest
|
|
439
|
+
return LocalUsageSnapshot(
|
|
440
|
+
source=_compact_home(path),
|
|
441
|
+
observed_at=observed,
|
|
442
|
+
actual_model=_string_or_none(message.get("model")),
|
|
443
|
+
token_metrics=_normalize_token_dict(message.get("usage")) or None,
|
|
444
|
+
remaining_note="Claude local transcripts include session tokens but not account remaining quota snapshots.",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class CodexUsageProvider(ModelUsageProvider):
|
|
449
|
+
def format_monitor(self, timeout_seconds: int) -> str:
|
|
450
|
+
lines: list[str] = ["[Codex]"]
|
|
451
|
+
try:
|
|
452
|
+
proc = subprocess.run(
|
|
453
|
+
["codex", "--version"],
|
|
454
|
+
capture_output=True,
|
|
455
|
+
text=True,
|
|
456
|
+
timeout=timeout_seconds,
|
|
457
|
+
shell=False,
|
|
458
|
+
)
|
|
459
|
+
except FileNotFoundError:
|
|
460
|
+
lines.append("CLI: `codex` command not found. Check Codex CLI installation and PATH.")
|
|
461
|
+
return "\n".join(lines)
|
|
462
|
+
except subprocess.TimeoutExpired:
|
|
463
|
+
lines.append("`codex --version` timed out.")
|
|
464
|
+
return "\n".join(lines)
|
|
465
|
+
|
|
466
|
+
ver = (proc.stdout or proc.stderr or "").strip()
|
|
467
|
+
if ver:
|
|
468
|
+
snippet = ver if len(ver) <= 500 else ver[:500] + "..."
|
|
469
|
+
lines.append(f"CLI version:\n{snippet}")
|
|
470
|
+
else:
|
|
471
|
+
lines.append(f"Version check failed (exit {proc.returncode}).")
|
|
472
|
+
return "\n".join(lines)
|
|
473
|
+
|
|
474
|
+
def read_local_usage(self) -> LocalUsageSnapshot | None:
|
|
475
|
+
root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
476
|
+
sessions = root / "sessions"
|
|
477
|
+
newest: tuple[Path, dict[str, Any], datetime | None] | None = None
|
|
478
|
+
for path in _iter_recent_files(sessions, "*.jsonl"):
|
|
479
|
+
for item in _read_jsonl_objects(path):
|
|
480
|
+
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
|
|
481
|
+
if not isinstance(payload, dict):
|
|
482
|
+
continue
|
|
483
|
+
if "rate_limits" not in payload and "info" not in payload:
|
|
484
|
+
continue
|
|
485
|
+
observed = _parse_datetime(item.get("timestamp"))
|
|
486
|
+
if _is_newer(observed, newest[2] if newest else None):
|
|
487
|
+
newest = (path, item, observed)
|
|
488
|
+
if newest is None:
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
path, item, observed = newest
|
|
492
|
+
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
|
|
493
|
+
info = payload.get("info") if isinstance(payload.get("info"), dict) else {}
|
|
494
|
+
rate_limits = payload.get("rate_limits") if isinstance(payload.get("rate_limits"), dict) else {}
|
|
495
|
+
token_usage = _normalize_token_dict(info.get("total_token_usage"))
|
|
496
|
+
windows = self._quota_windows(rate_limits)
|
|
497
|
+
return LocalUsageSnapshot(
|
|
498
|
+
source=_compact_home(path),
|
|
499
|
+
observed_at=observed,
|
|
500
|
+
token_metrics=token_usage or None,
|
|
501
|
+
quota_windows=tuple(windows),
|
|
502
|
+
plan_type=_string_or_none(rate_limits.get("plan_type")),
|
|
503
|
+
remaining_note=None if windows else "No rate_limits snapshot found in Codex session logs.",
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _quota_windows(rate_limits: dict[str, Any]) -> list[LocalQuotaWindow]:
|
|
508
|
+
windows: list[LocalQuotaWindow] = []
|
|
509
|
+
for key, fallback in (("primary", "primary window"), ("secondary", "secondary window")):
|
|
510
|
+
raw = rate_limits.get(key)
|
|
511
|
+
if not isinstance(raw, dict):
|
|
512
|
+
continue
|
|
513
|
+
used = _float_or_none(raw.get("used_percent"))
|
|
514
|
+
if used is None:
|
|
515
|
+
continue
|
|
516
|
+
minutes = _int_or_none(raw.get("window_minutes"))
|
|
517
|
+
label = _format_window_label(minutes) if minutes is not None else fallback
|
|
518
|
+
windows.append(
|
|
519
|
+
LocalQuotaWindow(
|
|
520
|
+
label=label,
|
|
521
|
+
used_percent=used,
|
|
522
|
+
remaining_percent=max(0.0, 100.0 - used),
|
|
523
|
+
resets_at=_datetime_from_epoch(raw.get("resets_at")),
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
return windows
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class GeminiUsageProvider(ModelUsageProvider):
|
|
530
|
+
def format_monitor(self, timeout_seconds: int) -> str:
|
|
531
|
+
lines: list[str] = ["[Gemini]"]
|
|
532
|
+
try:
|
|
533
|
+
proc = subprocess.run(
|
|
534
|
+
["gemini", "--version"],
|
|
535
|
+
capture_output=True,
|
|
536
|
+
text=True,
|
|
537
|
+
timeout=timeout_seconds,
|
|
538
|
+
shell=False,
|
|
539
|
+
)
|
|
540
|
+
except FileNotFoundError:
|
|
541
|
+
lines.append("CLI: `gemini` command not found. Check Gemini CLI installation and PATH.")
|
|
542
|
+
lines.extend(self._footer())
|
|
543
|
+
return "\n".join(lines)
|
|
544
|
+
except subprocess.TimeoutExpired:
|
|
545
|
+
lines.append("`gemini --version` timed out.")
|
|
546
|
+
lines.extend(self._footer())
|
|
547
|
+
return "\n".join(lines)
|
|
548
|
+
|
|
549
|
+
ver = (proc.stdout or proc.stderr or "").strip()
|
|
550
|
+
if ver:
|
|
551
|
+
snippet = ver if len(ver) <= 500 else ver[:500] + "..."
|
|
552
|
+
lines.append(f"CLI version:\n{snippet}")
|
|
553
|
+
else:
|
|
554
|
+
lines.append(f"Version check failed (exit {proc.returncode}).")
|
|
555
|
+
|
|
556
|
+
lines.extend(self._footer())
|
|
557
|
+
return "\n".join(lines)
|
|
558
|
+
|
|
559
|
+
@staticmethod
|
|
560
|
+
def _footer() -> list[str]:
|
|
561
|
+
return [
|
|
562
|
+
"",
|
|
563
|
+
"Install: npm install -g @google/gemini-cli",
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
def read_local_usage(self) -> LocalUsageSnapshot | None:
|
|
567
|
+
root = Path(os.environ.get("GEMINI_HOME", Path.home() / ".gemini"))
|
|
568
|
+
newest: tuple[Path, dict[str, Any], datetime | None] | None = None
|
|
569
|
+
today = datetime.now().astimezone().date()
|
|
570
|
+
requests_today = 0
|
|
571
|
+
for path in _iter_recent_files(root, "*.jsonl"):
|
|
572
|
+
for item in _read_jsonl_objects(path):
|
|
573
|
+
if item.get("type") != "gemini" or not isinstance(item.get("tokens"), dict):
|
|
574
|
+
continue
|
|
575
|
+
observed = _parse_datetime(item.get("timestamp"))
|
|
576
|
+
if observed and observed.astimezone().date() == today:
|
|
577
|
+
requests_today += 1
|
|
578
|
+
if _is_newer(observed, newest[2] if newest else None):
|
|
579
|
+
newest = (path, item, observed)
|
|
580
|
+
if newest is None:
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
path, item, observed = newest
|
|
584
|
+
return LocalUsageSnapshot(
|
|
585
|
+
source=_compact_home(path),
|
|
586
|
+
observed_at=observed,
|
|
587
|
+
actual_model=_string_or_none(item.get("model")),
|
|
588
|
+
token_metrics=_normalize_token_dict(item.get("tokens")) or None,
|
|
589
|
+
requests_today=requests_today,
|
|
590
|
+
remaining_note="Gemini local chat logs include requests/tokens but not account remaining quota snapshots.",
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
_USAGE_PROVIDERS: dict[ModelName, ModelUsageProvider] = {
|
|
595
|
+
ModelName.CLAUDE: ClaudeUsageProvider(),
|
|
596
|
+
ModelName.CODEX: CodexUsageProvider(),
|
|
597
|
+
ModelName.GEMINI: GeminiUsageProvider(),
|
|
598
|
+
}
|
app/projects/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from app.projects.registry import (
|
|
2
|
+
ProjectRecord,
|
|
3
|
+
ProjectRegistry,
|
|
4
|
+
compute_token_hash,
|
|
5
|
+
compute_token_hash_prefix,
|
|
6
|
+
mask_bot_token,
|
|
7
|
+
normalize_webhook_token_hash_path_segment,
|
|
8
|
+
projects_config_path_for_settings,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ProjectRecord",
|
|
13
|
+
"ProjectRegistry",
|
|
14
|
+
"compute_token_hash",
|
|
15
|
+
"compute_token_hash_prefix",
|
|
16
|
+
"mask_bot_token",
|
|
17
|
+
"normalize_webhook_token_hash_path_segment",
|
|
18
|
+
"projects_config_path_for_settings",
|
|
19
|
+
]
|