@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.
- package/README.md +25 -13
- package/bootstrap/lib/steps.sh +214 -9
- package/bootstrap/manifest.example.yaml +6 -1
- package/docs/COMPLETION-PLAN.md +48 -0
- package/omega/Agentik_Engine/README.md +25 -10
- package/omega/Agentik_Engine/omega_engine/__init__.py +66 -2
- package/omega/Agentik_Engine/omega_engine/account.py +505 -0
- package/omega/Agentik_Engine/omega_engine/autonomous.py +538 -0
- package/omega/Agentik_Engine/omega_engine/cli.py +467 -29
- package/omega/Agentik_Engine/omega_engine/daemons/__init__.py +14 -0
- package/omega/Agentik_Engine/omega_engine/daemons/autonomous.py +56 -0
- package/omega/Agentik_Engine/omega_engine/daemons/engine.py +187 -0
- package/omega/Agentik_Engine/omega_engine/daemons/telegram.py +231 -0
- package/omega/Agentik_Engine/omega_engine/educators/__init__.py +51 -0
- package/omega/Agentik_Engine/omega_engine/educators/artifact.py +65 -0
- package/omega/Agentik_Engine/omega_engine/educators/automation.py +76 -0
- package/omega/Agentik_Engine/omega_engine/educators/base.py +327 -0
- package/omega/Agentik_Engine/omega_engine/educators/claudecode.py +71 -0
- package/omega/Agentik_Engine/omega_engine/educators/connection.py +75 -0
- package/omega/Agentik_Engine/omega_engine/educators/coworker.py +68 -0
- package/omega/Agentik_Engine/omega_engine/educators/loop.py +82 -0
- package/omega/Agentik_Engine/omega_engine/educators/prompt.py +68 -0
- package/omega/Agentik_Engine/omega_engine/educators/skill.py +69 -0
- package/omega/Agentik_Engine/omega_engine/executor.py +46 -6
- package/omega/Agentik_Engine/omega_engine/mission.py +13 -1
- package/omega/Agentik_Engine/omega_engine/provider.py +247 -1
- package/omega/Agentik_Engine/omega_engine/rag/__init__.py +21 -0
- package/omega/Agentik_Engine/omega_engine/rag/agentic.py +83 -0
- package/omega/Agentik_Engine/omega_engine/rag/base.py +42 -0
- package/omega/Agentik_Engine/omega_engine/rag/corrective.py +119 -0
- package/omega/Agentik_Engine/omega_engine/rag/graph.py +169 -0
- package/omega/Agentik_Engine/omega_engine/rag/hybrid.py +205 -0
- package/omega/Agentik_Engine/omega_engine/rag/multimodal.py +136 -0
- package/omega/Agentik_Engine/omega_engine/rag/router.py +110 -0
- package/omega/Agentik_Engine/omega_engine/reducer.py +21 -3
- package/omega/Agentik_Engine/omega_engine/store.py +65 -5
- package/omega/Agentik_Engine/omega_engine/sync.py +304 -0
- package/omega/Agentik_Engine/omega_engine/tools.py +272 -0
- package/omega/Agentik_Engine/pyproject.toml +1 -1
- package/omega/Agentik_Engine/tests/test_account.py +333 -0
- package/omega/Agentik_Engine/tests/test_autonomous.py +361 -0
- package/omega/Agentik_Engine/tests/test_educators.py +233 -0
- package/omega/Agentik_Engine/tests/test_rag.py +287 -0
- package/omega/Agentik_Engine/tests/test_snapshot_partial.py +172 -0
- package/omega/Agentik_Engine/tests/test_tools_and_sync.py +312 -0
- package/omega/Agentik_SSOT/skills/rag-route.md +73 -0
- package/package.json +1 -1
- package/omega/Agentik_Engine/omega_engine/__pycache__/__init__.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/audit.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/audit_arsenal.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/barrier.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/bus.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/events.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/executor.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/mission.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/progress.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/project.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/provider.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/reducer.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/report.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/router.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/store.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/supervisor.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/task.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/telegram.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_audit_arsenal.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_executor.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_mission.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_progress.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_project.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_reducer.cpython-313.pyc +0 -0
- 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
|