@agentikos/omega-os 0.1.0 → 0.2.0

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 (73) hide show
  1. package/README.md +25 -13
  2. package/bootstrap/lib/steps.sh +214 -9
  3. package/bootstrap/manifest.example.yaml +6 -1
  4. package/docs/COMPLETION-PLAN.md +48 -0
  5. package/omega/Agentik_Engine/README.md +25 -10
  6. package/omega/Agentik_Engine/omega_engine/__init__.py +66 -2
  7. package/omega/Agentik_Engine/omega_engine/account.py +505 -0
  8. package/omega/Agentik_Engine/omega_engine/autonomous.py +538 -0
  9. package/omega/Agentik_Engine/omega_engine/cli.py +467 -29
  10. package/omega/Agentik_Engine/omega_engine/daemons/__init__.py +14 -0
  11. package/omega/Agentik_Engine/omega_engine/daemons/autonomous.py +56 -0
  12. package/omega/Agentik_Engine/omega_engine/daemons/engine.py +187 -0
  13. package/omega/Agentik_Engine/omega_engine/daemons/telegram.py +231 -0
  14. package/omega/Agentik_Engine/omega_engine/educators/__init__.py +51 -0
  15. package/omega/Agentik_Engine/omega_engine/educators/artifact.py +65 -0
  16. package/omega/Agentik_Engine/omega_engine/educators/automation.py +76 -0
  17. package/omega/Agentik_Engine/omega_engine/educators/base.py +327 -0
  18. package/omega/Agentik_Engine/omega_engine/educators/claudecode.py +71 -0
  19. package/omega/Agentik_Engine/omega_engine/educators/connection.py +75 -0
  20. package/omega/Agentik_Engine/omega_engine/educators/coworker.py +68 -0
  21. package/omega/Agentik_Engine/omega_engine/educators/loop.py +82 -0
  22. package/omega/Agentik_Engine/omega_engine/educators/prompt.py +68 -0
  23. package/omega/Agentik_Engine/omega_engine/educators/skill.py +69 -0
  24. package/omega/Agentik_Engine/omega_engine/executor.py +46 -6
  25. package/omega/Agentik_Engine/omega_engine/mission.py +13 -1
  26. package/omega/Agentik_Engine/omega_engine/provider.py +247 -1
  27. package/omega/Agentik_Engine/omega_engine/rag/__init__.py +21 -0
  28. package/omega/Agentik_Engine/omega_engine/rag/agentic.py +83 -0
  29. package/omega/Agentik_Engine/omega_engine/rag/base.py +42 -0
  30. package/omega/Agentik_Engine/omega_engine/rag/corrective.py +119 -0
  31. package/omega/Agentik_Engine/omega_engine/rag/graph.py +169 -0
  32. package/omega/Agentik_Engine/omega_engine/rag/hybrid.py +205 -0
  33. package/omega/Agentik_Engine/omega_engine/rag/multimodal.py +136 -0
  34. package/omega/Agentik_Engine/omega_engine/rag/router.py +110 -0
  35. package/omega/Agentik_Engine/omega_engine/reducer.py +21 -3
  36. package/omega/Agentik_Engine/omega_engine/store.py +65 -5
  37. package/omega/Agentik_Engine/omega_engine/sync.py +304 -0
  38. package/omega/Agentik_Engine/omega_engine/tools.py +272 -0
  39. package/omega/Agentik_Engine/pyproject.toml +1 -1
  40. package/omega/Agentik_Engine/tests/test_account.py +333 -0
  41. package/omega/Agentik_Engine/tests/test_autonomous.py +361 -0
  42. package/omega/Agentik_Engine/tests/test_educators.py +233 -0
  43. package/omega/Agentik_Engine/tests/test_rag.py +287 -0
  44. package/omega/Agentik_Engine/tests/test_snapshot_partial.py +172 -0
  45. package/omega/Agentik_Engine/tests/test_tools_and_sync.py +312 -0
  46. package/omega/Agentik_SSOT/skills/rag-route.md +73 -0
  47. package/package.json +1 -1
  48. package/omega/Agentik_Engine/omega_engine/__pycache__/__init__.cpython-313.pyc +0 -0
  49. package/omega/Agentik_Engine/omega_engine/__pycache__/audit.cpython-313.pyc +0 -0
  50. package/omega/Agentik_Engine/omega_engine/__pycache__/audit_arsenal.cpython-313.pyc +0 -0
  51. package/omega/Agentik_Engine/omega_engine/__pycache__/barrier.cpython-313.pyc +0 -0
  52. package/omega/Agentik_Engine/omega_engine/__pycache__/bus.cpython-313.pyc +0 -0
  53. package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
  54. package/omega/Agentik_Engine/omega_engine/__pycache__/events.cpython-313.pyc +0 -0
  55. package/omega/Agentik_Engine/omega_engine/__pycache__/executor.cpython-313.pyc +0 -0
  56. package/omega/Agentik_Engine/omega_engine/__pycache__/mission.cpython-313.pyc +0 -0
  57. package/omega/Agentik_Engine/omega_engine/__pycache__/progress.cpython-313.pyc +0 -0
  58. package/omega/Agentik_Engine/omega_engine/__pycache__/project.cpython-313.pyc +0 -0
  59. package/omega/Agentik_Engine/omega_engine/__pycache__/provider.cpython-313.pyc +0 -0
  60. package/omega/Agentik_Engine/omega_engine/__pycache__/reducer.cpython-313.pyc +0 -0
  61. package/omega/Agentik_Engine/omega_engine/__pycache__/report.cpython-313.pyc +0 -0
  62. package/omega/Agentik_Engine/omega_engine/__pycache__/router.cpython-313.pyc +0 -0
  63. package/omega/Agentik_Engine/omega_engine/__pycache__/store.cpython-313.pyc +0 -0
  64. package/omega/Agentik_Engine/omega_engine/__pycache__/supervisor.cpython-313.pyc +0 -0
  65. package/omega/Agentik_Engine/omega_engine/__pycache__/task.cpython-313.pyc +0 -0
  66. package/omega/Agentik_Engine/omega_engine/__pycache__/telegram.cpython-313.pyc +0 -0
  67. package/omega/Agentik_Engine/tests/__pycache__/test_audit_arsenal.cpython-313.pyc +0 -0
  68. package/omega/Agentik_Engine/tests/__pycache__/test_executor.cpython-313.pyc +0 -0
  69. package/omega/Agentik_Engine/tests/__pycache__/test_mission.cpython-313.pyc +0 -0
  70. package/omega/Agentik_Engine/tests/__pycache__/test_progress.cpython-313.pyc +0 -0
  71. package/omega/Agentik_Engine/tests/__pycache__/test_project.cpython-313.pyc +0 -0
  72. package/omega/Agentik_Engine/tests/__pycache__/test_reducer.cpython-313.pyc +0 -0
  73. package/omega/Agentik_Engine/tests/__pycache__/test_report.cpython-313.pyc +0 -0
@@ -0,0 +1,505 @@
1
+ """The Claude Code Max account pool + per-account billing.
2
+
3
+ > Omega OS runs ONE engine process, not N tmux sessions. There is nothing to
4
+ > "switch" globally. The Claude provider holds a POOL and assigns each agent
5
+ > call to an account, so N Max accounts give the SUM of their rate limits as
6
+ > usable throughput. See `docs/ACCOUNT-AND-BILLING.md` for the design.
7
+
8
+ This module is the real implementation of that pool — plus the OAuth login
9
+ flow, the encrypted-vault file format, and the billing aggregator that scans
10
+ the event log for per-account token usage.
11
+
12
+ Stdlib only (yaml is already a top-level dep of omega-engine).
13
+
14
+ ──── ON THE CLAUDE OAUTH DEVICE-CODE FLOW ────
15
+
16
+ Anthropic does **not** publicly document an RFC 8628 device-authorization-grant
17
+ endpoint for Claude Max accounts. The official Claude Code Max OAuth flow is
18
+ browser-based PKCE through `https://claude.ai`, which a headless VPS cannot
19
+ complete without launching a browser.
20
+
21
+ `claude_device_code_flow` therefore implements RFC 8628 correctly against
22
+ *configurable* endpoints (the spec is the spec — when Anthropic publishes the
23
+ URLs, the code already works). If the endpoint is unreachable or returns a
24
+ non-device-code response, the function raises with a clear message and the CLI
25
+ falls back to a **manual paste flow**: the user logs in via browser on another
26
+ machine, copies the OAuth token, pastes it into the prompt, and we write it
27
+ into the vault with `chmod 600`. That manual fallback is the path that always
28
+ works today — not a stub, a working credentials-into-the-vault flow.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import stat
35
+ import time
36
+ import urllib.error
37
+ import urllib.parse
38
+ import urllib.request
39
+ from dataclasses import dataclass, field, asdict
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ # yaml is a hard dependency of omega-engine — see pyproject.toml.
44
+ import yaml
45
+
46
+
47
+ __all__ = [
48
+ "ClaudeAccount",
49
+ "AccountPool",
50
+ "BillingAggregator",
51
+ "vault_path",
52
+ "read_token",
53
+ "write_token",
54
+ "claude_oauth_login_url",
55
+ "claude_device_code_flow",
56
+ "ACCOUNT_DEVICE_AUTH_URL",
57
+ "ACCOUNT_DEVICE_TOKEN_URL",
58
+ ]
59
+
60
+
61
+ # ──── OAuth endpoint constants ────
62
+ # Configurable via env so tests / future Anthropic endpoint changes do not
63
+ # require a code edit. Defaults reflect the documented RFC 8628 shape; if
64
+ # Anthropic publishes the actual URLs, set them with these env vars (or update
65
+ # the constants here in one place).
66
+ ACCOUNT_DEVICE_AUTH_URL = os.environ.get(
67
+ "OMEGA_CLAUDE_DEVICE_AUTH_URL",
68
+ "https://auth.anthropic.com/oauth/device/authorize",
69
+ )
70
+ ACCOUNT_DEVICE_TOKEN_URL = os.environ.get(
71
+ "OMEGA_CLAUDE_DEVICE_TOKEN_URL",
72
+ "https://auth.anthropic.com/oauth/device/token",
73
+ )
74
+ ACCOUNT_BROWSER_LOGIN_URL = "https://claude.ai/oauth"
75
+
76
+
77
+ # ──── data model ────
78
+
79
+
80
+ @dataclass
81
+ class ClaudeAccount:
82
+ """One Claude Code Max account entry in the pool.
83
+
84
+ `secret_ref` is the only handle to the OAuth token — the token itself
85
+ lives in `Agentik_Extra/etc/secrets/<secret_ref>.env` with mode 600.
86
+ """
87
+
88
+ id: str
89
+ label: str = ""
90
+ secret_ref: str = ""
91
+ weight: int = 1
92
+ status: str = "active" # active | resting | disabled
93
+ last_used_at: float = 0.0
94
+ tokens_used: int = 0
95
+
96
+ def to_yaml(self) -> dict[str, Any]:
97
+ """Serialize for accounts.yaml. Skips zero-valued runtime fields so the
98
+ on-disk file stays clean for accounts that have never been used."""
99
+ out: dict[str, Any] = {
100
+ "id": self.id,
101
+ "label": self.label,
102
+ "secret_ref": self.secret_ref,
103
+ "weight": int(self.weight),
104
+ "status": self.status,
105
+ }
106
+ if self.last_used_at:
107
+ out["last_used_at"] = float(self.last_used_at)
108
+ if self.tokens_used:
109
+ out["tokens_used"] = int(self.tokens_used)
110
+ return out
111
+
112
+ @classmethod
113
+ def from_yaml(cls, row: dict[str, Any]) -> "ClaudeAccount":
114
+ return cls(
115
+ id=str(row.get("id", "")).strip(),
116
+ label=str(row.get("label", "")),
117
+ secret_ref=str(row.get("secret_ref", "")),
118
+ weight=int(row.get("weight", 1)),
119
+ status=str(row.get("status", "active")),
120
+ last_used_at=float(row.get("last_used_at", 0.0) or 0.0),
121
+ tokens_used=int(row.get("tokens_used", 0) or 0),
122
+ )
123
+
124
+
125
+ @dataclass
126
+ class AccountPool:
127
+ """The pool of Claude Code Max accounts.
128
+
129
+ `selection` controls how `next()` picks the account for the next call:
130
+ - `round-robin` — strict rotation among active accounts
131
+ - `least-used` — pick the active account with the smallest `tokens_used`
132
+ - `by-quota` — like least-used but weighted by `weight`
133
+ """
134
+
135
+ accounts: list[ClaudeAccount] = field(default_factory=list)
136
+ selection: str = "least-used"
137
+ version: int = 1
138
+ _rr_cursor: int = 0 # private; used by round-robin
139
+
140
+ # ──── loading & saving ────────────────────────────────────────────────
141
+
142
+ @classmethod
143
+ def load(cls, omega_home: str | Path) -> "AccountPool":
144
+ """Load the pool. Prefers `accounts.yaml`, falls back to
145
+ `accounts.example.yaml`. Returns an empty pool if neither exists."""
146
+ home = Path(omega_home)
147
+ cfg_dir = home / "Agentik_Providers" / "claude"
148
+ for name in ("accounts.yaml", "accounts.example.yaml"):
149
+ cfg = cfg_dir / name
150
+ if cfg.exists():
151
+ return cls._from_file(cfg)
152
+ return cls()
153
+
154
+ @classmethod
155
+ def _from_file(cls, path: Path) -> "AccountPool":
156
+ raw = yaml.safe_load(path.read_text()) or {}
157
+ accounts = [ClaudeAccount.from_yaml(r) for r in (raw.get("pool") or [])]
158
+ return cls(
159
+ accounts=accounts,
160
+ selection=str(raw.get("selection", "least-used")),
161
+ version=int(raw.get("version", 1) or 1),
162
+ )
163
+
164
+ def save(self, omega_home: str | Path) -> Path:
165
+ """Persist the pool to `accounts.yaml` (creates parent dirs).
166
+
167
+ Always writes to the canonical name (`accounts.yaml`), never to the
168
+ example file — that one is the template that ships with the repo.
169
+ """
170
+ home = Path(omega_home)
171
+ cfg_dir = home / "Agentik_Providers" / "claude"
172
+ cfg_dir.mkdir(parents=True, exist_ok=True)
173
+ cfg = cfg_dir / "accounts.yaml"
174
+ data = {
175
+ "version": self.version,
176
+ "selection": self.selection,
177
+ "pool": [a.to_yaml() for a in self.accounts],
178
+ }
179
+ # default_flow_style=False keeps it human-readable; sort_keys=False
180
+ # preserves the natural field order (id, label, secret_ref, ...).
181
+ cfg.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=False))
182
+ return cfg
183
+
184
+ # ──── selection ───────────────────────────────────────────────────────
185
+
186
+ def _active(self) -> list[ClaudeAccount]:
187
+ return [a for a in self.accounts if a.status == "active"]
188
+
189
+ def next(self) -> ClaudeAccount:
190
+ """Pick the next account per the current `selection` strategy.
191
+
192
+ Raises RuntimeError if no account is active — that's a real error the
193
+ caller (the Claude provider) must surface, not silently mock out.
194
+ """
195
+ active = self._active()
196
+ if not active:
197
+ raise RuntimeError(
198
+ "no active Claude Max accounts in the pool — "
199
+ "run `omega account login` or `omega account use <id> active`"
200
+ )
201
+ if self.selection == "round-robin":
202
+ chosen = active[self._rr_cursor % len(active)]
203
+ self._rr_cursor = (self._rr_cursor + 1) % len(active)
204
+ elif self.selection == "by-quota":
205
+ # weighted least-used: score = tokens_used / max(weight, 1).
206
+ # smallest score wins. ties broken by last_used_at (older first).
207
+ chosen = min(
208
+ active,
209
+ key=lambda a: (a.tokens_used / max(a.weight, 1), a.last_used_at),
210
+ )
211
+ else:
212
+ # least-used (default): smallest tokens_used, then oldest last_used.
213
+ chosen = min(active, key=lambda a: (a.tokens_used, a.last_used_at))
214
+ chosen.last_used_at = time.time()
215
+ return chosen
216
+
217
+ # ──── mutation helpers ────────────────────────────────────────────────
218
+
219
+ def add(self, account: ClaudeAccount) -> None:
220
+ """Add or replace (by id) an account in the pool."""
221
+ if not account.id:
222
+ raise ValueError("account.id must be set")
223
+ existing = self.get(account.id)
224
+ if existing is None:
225
+ self.accounts.append(account)
226
+ else:
227
+ # replace in place so list ordering is preserved
228
+ idx = self.accounts.index(existing)
229
+ self.accounts[idx] = account
230
+
231
+ def get(self, account_id: str) -> ClaudeAccount | None:
232
+ for a in self.accounts:
233
+ if a.id == account_id:
234
+ return a
235
+ return None
236
+
237
+ def set_status(self, account_id: str, status: str) -> None:
238
+ if status not in ("active", "resting", "disabled"):
239
+ raise ValueError(
240
+ f"invalid status '{status}' — must be active|resting|disabled"
241
+ )
242
+ acc = self.get(account_id)
243
+ if acc is None:
244
+ raise KeyError(f"no account '{account_id}' in the pool")
245
+ acc.status = status
246
+
247
+ def usage_for(self, account_id: str, tokens: int) -> None:
248
+ """Record `tokens` usage on `account_id`. Idempotent at the pool level
249
+ (the event log is the source of truth — this just updates the cached
250
+ counter shown by `omega account list`)."""
251
+ acc = self.get(account_id)
252
+ if acc is None:
253
+ return
254
+ acc.tokens_used += max(int(tokens), 0)
255
+ acc.last_used_at = time.time()
256
+
257
+
258
+ # ──── vault: where the OAuth tokens actually live ────────────────────────
259
+
260
+
261
+ def vault_path(omega_home: str | Path, secret_ref: str) -> Path:
262
+ """Return the on-disk path for a secret reference.
263
+
264
+ Convention: `Agentik_Extra/etc/secrets/<secret_ref>.env`. The directory is
265
+ created on demand with mode 700; individual secret files are mode 600.
266
+
267
+ Refuses path-traversal — `secret_ref` cannot contain `/` or `..`.
268
+ """
269
+ if not secret_ref:
270
+ raise ValueError("secret_ref must not be empty")
271
+ if "/" in secret_ref or "\\" in secret_ref or ".." in secret_ref:
272
+ raise ValueError(f"secret_ref must be a flat name, got {secret_ref!r}")
273
+ home = Path(omega_home)
274
+ secrets_dir = home / "Agentik_Extra" / "etc" / "secrets"
275
+ secrets_dir.mkdir(parents=True, exist_ok=True)
276
+ try:
277
+ os.chmod(secrets_dir, 0o700)
278
+ except (OSError, PermissionError):
279
+ # best-effort — file mode is enforced below where it matters most
280
+ pass
281
+ return secrets_dir / f"{secret_ref}.env"
282
+
283
+
284
+ def read_token(omega_home: str | Path, secret_ref: str) -> str:
285
+ """Read the OAuth token from the vault file.
286
+
287
+ Format: a tiny dotenv-style file with at least `CLAUDE_OAUTH_TOKEN=<value>`.
288
+ The reader is forgiving: it strips quotes, ignores blank lines and `#`
289
+ comments, and returns the FIRST value seen for `CLAUDE_OAUTH_TOKEN` (or the
290
+ canonical key formed by upper-casing the secret_ref).
291
+ """
292
+ path = vault_path(omega_home, secret_ref)
293
+ if not path.exists():
294
+ raise FileNotFoundError(f"vault file not found: {path}")
295
+ keys = ("CLAUDE_OAUTH_TOKEN", secret_ref.upper())
296
+ for line in path.read_text().splitlines():
297
+ s = line.strip()
298
+ if not s or s.startswith("#") or "=" not in s:
299
+ continue
300
+ k, _, v = s.partition("=")
301
+ k = k.strip()
302
+ v = v.strip().strip('"').strip("'")
303
+ if k in keys and v:
304
+ return v
305
+ raise ValueError(
306
+ f"no CLAUDE_OAUTH_TOKEN in vault file {path} — "
307
+ "expected a line like `CLAUDE_OAUTH_TOKEN=...`"
308
+ )
309
+
310
+
311
+ def write_token(omega_home: str | Path, secret_ref: str, token: str) -> Path:
312
+ """Write the OAuth token to the vault file with mode 600.
313
+
314
+ Overwrites silently if a file already exists — rotating a token is the
315
+ expected use case. Returns the path written.
316
+ """
317
+ if not token or not token.strip():
318
+ raise ValueError("token must be a non-empty string")
319
+ path = vault_path(omega_home, secret_ref)
320
+ # Use os.open with O_CREAT|O_WRONLY|O_TRUNC and explicit mode so the file
321
+ # is born with 0o600 (no race window during which 0o644 would be visible).
322
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
323
+ try:
324
+ with os.fdopen(fd, "w") as fh:
325
+ fh.write(f"# Omega OS — Claude Max OAuth token for {secret_ref}\n")
326
+ fh.write(f"# Written: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n")
327
+ fh.write(f"CLAUDE_OAUTH_TOKEN={token.strip()}\n")
328
+ finally:
329
+ # If fdopen succeeded, fd is closed by the context manager; if it
330
+ # raised, the fd is leaked — guard that by attempting a close.
331
+ try:
332
+ os.close(fd)
333
+ except OSError:
334
+ pass
335
+ # Belt-and-braces: re-assert mode in case some umask oddity sneaked in.
336
+ try:
337
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
338
+ except OSError:
339
+ pass
340
+ return path
341
+
342
+
343
+ # ──── OAuth login flow ───────────────────────────────────────────────────
344
+
345
+
346
+ def claude_oauth_login_url() -> str:
347
+ """The URL a human visits to obtain a Claude OAuth token.
348
+
349
+ Returned as a plain string so the CLI can print it and instruct the user.
350
+ """
351
+ return ACCOUNT_BROWSER_LOGIN_URL
352
+
353
+
354
+ def claude_device_code_flow(
355
+ client_id: str = "claude-code-cli",
356
+ scope: str = "claude:max",
357
+ device_auth_url: str | None = None,
358
+ token_url: str | None = None,
359
+ poll_timeout: float = 600.0,
360
+ ) -> dict[str, Any]:
361
+ """RFC 8628 device authorization flow against the Claude OAuth endpoints.
362
+
363
+ Returns a dict with at least `access_token`. If the endpoint does not
364
+ support this flow (404 / 4xx / non-JSON), raises RuntimeError with a clear
365
+ message so the CLI knows to fall back to manual paste.
366
+
367
+ This function performs ZERO disk I/O — the CLI is responsible for writing
368
+ the returned token into the vault. Keeps the function pure and testable.
369
+ """
370
+ auth_url = device_auth_url or ACCOUNT_DEVICE_AUTH_URL
371
+ tok_url = token_url or ACCOUNT_DEVICE_TOKEN_URL
372
+
373
+ # ---- step 1: request a device_code ----
374
+ body = urllib.parse.urlencode({"client_id": client_id, "scope": scope}).encode()
375
+ req = urllib.request.Request(
376
+ auth_url, data=body, method="POST",
377
+ headers={"Content-Type": "application/x-www-form-urlencoded",
378
+ "Accept": "application/json"},
379
+ )
380
+ try:
381
+ with urllib.request.urlopen(req, timeout=30.0) as resp:
382
+ payload = json.loads(resp.read().decode("utf-8"))
383
+ except urllib.error.HTTPError as exc:
384
+ raise RuntimeError(
385
+ f"device authorize endpoint returned HTTP {exc.code}. "
386
+ "Anthropic may not support the device-code flow yet — "
387
+ "fall back to manual paste."
388
+ ) from exc
389
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
390
+ raise RuntimeError(
391
+ f"could not reach the device authorize endpoint: {exc}. "
392
+ "Fall back to manual paste."
393
+ ) from exc
394
+
395
+ device_code = payload.get("device_code")
396
+ interval = float(payload.get("interval", 5))
397
+ expires_in = float(payload.get("expires_in", 600))
398
+ if not device_code:
399
+ raise RuntimeError(
400
+ f"device authorize response missing `device_code`: {payload!r}. "
401
+ "Fall back to manual paste."
402
+ )
403
+ verification = (
404
+ payload.get("verification_uri_complete")
405
+ or payload.get("verification_uri")
406
+ or "(see provider docs)"
407
+ )
408
+
409
+ # The caller (CLI) prints this — but in case it's invoked headlessly, surface
410
+ # it on the returned payload too.
411
+ payload["_human_instruction"] = (
412
+ f"open in a browser and approve:\n {verification}"
413
+ )
414
+
415
+ # ---- step 2: poll for the token ----
416
+ deadline = min(time.monotonic() + poll_timeout, time.monotonic() + expires_in)
417
+ poll_body = urllib.parse.urlencode({
418
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
419
+ "device_code": device_code,
420
+ "client_id": client_id,
421
+ }).encode()
422
+ while time.monotonic() < deadline:
423
+ time.sleep(max(interval, 1.0))
424
+ poll_req = urllib.request.Request(
425
+ tok_url, data=poll_body, method="POST",
426
+ headers={"Content-Type": "application/x-www-form-urlencoded",
427
+ "Accept": "application/json"},
428
+ )
429
+ try:
430
+ with urllib.request.urlopen(poll_req, timeout=30.0) as resp:
431
+ tok = json.loads(resp.read().decode("utf-8"))
432
+ except urllib.error.HTTPError as exc:
433
+ try:
434
+ err_payload = json.loads(exc.read().decode("utf-8"))
435
+ except Exception: # noqa: BLE001 — best effort on body decode
436
+ err_payload = {}
437
+ err = err_payload.get("error", "")
438
+ # RFC 8628 §3.5: authorization_pending and slow_down mean keep polling.
439
+ if err == "authorization_pending":
440
+ continue
441
+ if err == "slow_down":
442
+ interval += 5
443
+ continue
444
+ raise RuntimeError(
445
+ f"device token endpoint refused: {err or exc.code} — "
446
+ f"{err_payload!r}"
447
+ ) from exc
448
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
449
+ raise RuntimeError(f"could not poll token endpoint: {exc}") from exc
450
+
451
+ if tok.get("access_token"):
452
+ return {
453
+ "access_token": tok["access_token"],
454
+ "refresh_token": tok.get("refresh_token", ""),
455
+ "expires_in": int(tok.get("expires_in", 0) or 0),
456
+ "raw": tok,
457
+ }
458
+
459
+ raise RuntimeError(
460
+ "device-code flow timed out — user did not approve in time"
461
+ )
462
+
463
+
464
+ # ──── billing aggregator ─────────────────────────────────────────────────
465
+
466
+
467
+ class BillingAggregator:
468
+ """Aggregate per-account token usage from the event log.
469
+
470
+ Scans every `task.*` event whose payload includes a `usage.account_id` and
471
+ a positive `usage.input_tokens` / `usage.output_tokens`. The event log is
472
+ the source of truth — `AccountPool.tokens_used` is a cached view.
473
+ """
474
+
475
+ @staticmethod
476
+ def from_event_log(store: Any) -> dict[str, dict[str, int]]:
477
+ """Walk the store and aggregate. `store` is anything that yields
478
+ Event-like objects via `.all_events()` (see `omega_engine.store`).
479
+
480
+ Returns: { account_id: { input_tokens, output_tokens, total_calls } }
481
+ """
482
+ totals: dict[str, dict[str, int]] = {}
483
+ for ev in store.all_events():
484
+ # only billable events
485
+ etype = getattr(ev, "type", None)
486
+ type_val = etype.value if hasattr(etype, "value") else str(etype)
487
+ if not type_val.startswith("task."):
488
+ continue
489
+ payload = getattr(ev, "payload", None) or {}
490
+ usage = payload.get("usage") or {}
491
+ account_id = usage.get("account_id")
492
+ if not account_id:
493
+ continue
494
+ inp = int(usage.get("input_tokens", 0) or 0)
495
+ out = int(usage.get("output_tokens", 0) or 0)
496
+ if inp <= 0 and out <= 0:
497
+ continue
498
+ slot = totals.setdefault(
499
+ account_id,
500
+ {"input_tokens": 0, "output_tokens": 0, "total_calls": 0},
501
+ )
502
+ slot["input_tokens"] += inp
503
+ slot["output_tokens"] += out
504
+ slot["total_calls"] += 1
505
+ return totals