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.
Files changed (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
@@ -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
+ }
@@ -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
+ ]