euron-code 0.1.0__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.
- euron_code/__init__.py +3 -0
- euron_code/__main__.py +4 -0
- euron_code/agent.py +595 -0
- euron_code/api.py +364 -0
- euron_code/auth.py +115 -0
- euron_code/cli.py +351 -0
- euron_code/config.py +207 -0
- euron_code/context.py +131 -0
- euron_code/credentials.py +197 -0
- euron_code/doctor.py +88 -0
- euron_code/keys.py +92 -0
- euron_code/memory.py +97 -0
- euron_code/permissions.py +245 -0
- euron_code/prompts.py +98 -0
- euron_code/repl.py +425 -0
- euron_code/sessions.py +186 -0
- euron_code/tools/__init__.py +33 -0
- euron_code/tools/base.py +167 -0
- euron_code/tools/db.py +368 -0
- euron_code/tools/fs.py +412 -0
- euron_code/tools/ignore.py +75 -0
- euron_code/tools/search.py +204 -0
- euron_code/tools/shell.py +214 -0
- euron_code/tools/task.py +41 -0
- euron_code/tools/todo.py +64 -0
- euron_code/tools/web.py +112 -0
- euron_code/ui.py +269 -0
- euron_code/usage_view.py +108 -0
- euron_code-0.1.0.dist-info/METADATA +278 -0
- euron_code-0.1.0.dist-info/RECORD +33 -0
- euron_code-0.1.0.dist-info/WHEEL +4 -0
- euron_code-0.1.0.dist-info/entry_points.txt +2 -0
- euron_code-0.1.0.dist-info/licenses/LICENSE +23 -0
euron_code/api.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""HTTP client for the Euron Code gateway (CLI_AND_ACCESS_SPEC.md §3, §4).
|
|
2
|
+
|
|
3
|
+
Only one model request is ever in flight per client: the gateway allows one generation per
|
|
4
|
+
user by default and answers a second one with `user_queue_full`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import platform
|
|
12
|
+
import queue
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from euron_code import __version__, config
|
|
21
|
+
|
|
22
|
+
NO_PLUS_MESSAGE = (
|
|
23
|
+
"You don't have Euron Plus access. Euron Code is included with Euron Plus and Super 30. "
|
|
24
|
+
"Get Plus at https://euron.one/plus"
|
|
25
|
+
)
|
|
26
|
+
UPGRADE_URL = "https://euron.one/plus"
|
|
27
|
+
ME_CACHE_TTL = 60.0
|
|
28
|
+
|
|
29
|
+
AUTH_CODES = {"missing_api_key", "invalid_api_key", "api_key_revoked", "api_key_expired"}
|
|
30
|
+
BUSY_CODES = {"queue_full", "user_queue_full", "queue_timeout", "no_capacity"}
|
|
31
|
+
UNAVAILABLE_CODES = {"code_agent_disabled", "auth_unavailable"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- errors
|
|
35
|
+
|
|
36
|
+
class EuronError(Exception):
|
|
37
|
+
"""Base error. `exit_code` is used by the CLI."""
|
|
38
|
+
|
|
39
|
+
exit_code = 1
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None,
|
|
42
|
+
retry_after: Optional[float] = None):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.message = message
|
|
45
|
+
self.status = status
|
|
46
|
+
self.code = code
|
|
47
|
+
self.retry_after = retry_after
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuthError(EuronError):
|
|
51
|
+
exit_code = 4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotEntitledError(EuronError):
|
|
55
|
+
exit_code = 3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class QuotaExceededError(EuronError):
|
|
59
|
+
exit_code = 5
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitedError(EuronError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BusyError(EuronError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnavailableError(EuronError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ContextTooLongError(EuronError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NetworkError(EuronError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MidStreamError(EuronError):
|
|
83
|
+
"""The stream failed after output was already shown. Never retried automatically."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Interrupted(Exception):
|
|
87
|
+
"""The user pressed Esc / Ctrl-C during a request."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
91
|
+
if not value:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return max(0.0, float(value))
|
|
95
|
+
except ValueError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def error_from_body(status: int, body: Any, retry_after: Optional[float] = None) -> EuronError:
|
|
100
|
+
"""Map a gateway error body to a typed exception.
|
|
101
|
+
|
|
102
|
+
Accepts the OpenAI shape {error:{message,type,code}} and the dashboard shape {code,message}.
|
|
103
|
+
"""
|
|
104
|
+
code = None
|
|
105
|
+
message = None
|
|
106
|
+
if isinstance(body, dict):
|
|
107
|
+
err = body.get("error") if isinstance(body.get("error"), dict) else body
|
|
108
|
+
code = err.get("code") or (body.get("error") if isinstance(body.get("error"), str) else None)
|
|
109
|
+
message = err.get("message")
|
|
110
|
+
if code is None and isinstance(err.get("status"), str):
|
|
111
|
+
code = err.get("status")
|
|
112
|
+
code = str(code) if code is not None else None
|
|
113
|
+
message = message or f"HTTP {status}"
|
|
114
|
+
kw = dict(status=status, code=code, retry_after=retry_after)
|
|
115
|
+
if code == "no_plus_access" or (status == 403 and code in (None, "forbidden")):
|
|
116
|
+
return NotEntitledError(NO_PLUS_MESSAGE, **kw)
|
|
117
|
+
if status == 401 or code in AUTH_CODES:
|
|
118
|
+
return AuthError(message, **kw)
|
|
119
|
+
if code == "daily_token_limit":
|
|
120
|
+
return QuotaExceededError(message, **kw)
|
|
121
|
+
if code == "rate_limit_exceeded":
|
|
122
|
+
return RateLimitedError(message, **kw)
|
|
123
|
+
if code in BUSY_CODES:
|
|
124
|
+
return BusyError(message, **kw)
|
|
125
|
+
if code in UNAVAILABLE_CODES:
|
|
126
|
+
return UnavailableError(message, **kw)
|
|
127
|
+
if status == 413 or code == "context_length_exceeded" or (
|
|
128
|
+
status == 400 and "context length" in message.lower()
|
|
129
|
+
):
|
|
130
|
+
return ContextTooLongError(message, **kw)
|
|
131
|
+
if status == 503:
|
|
132
|
+
return BusyError(message, **kw)
|
|
133
|
+
if status == 429:
|
|
134
|
+
return RateLimitedError(message, **kw)
|
|
135
|
+
return EuronError(message, **kw)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _error_from_response(resp: httpx.Response) -> EuronError:
|
|
139
|
+
try:
|
|
140
|
+
body = resp.json()
|
|
141
|
+
except ValueError:
|
|
142
|
+
body = {"error": {"message": (resp.text or "")[:300] or f"HTTP {resp.status_code}"}}
|
|
143
|
+
return error_from_body(resp.status_code, body, _parse_retry_after(resp.headers.get("retry-after")))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------- client
|
|
147
|
+
|
|
148
|
+
def user_agent() -> str:
|
|
149
|
+
return f"euron-code/{__version__} ({platform.system()}; Python {platform.python_version()})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Client:
|
|
153
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
154
|
+
transport: Optional[httpx.BaseTransport] = None):
|
|
155
|
+
self.base_url = (base_url or config.base_url()).rstrip("/")
|
|
156
|
+
self.api_key = api_key
|
|
157
|
+
self.me_fetched_at = 0.0
|
|
158
|
+
self._lock = threading.Lock() # one model request at a time
|
|
159
|
+
self._http = httpx.Client(
|
|
160
|
+
timeout=httpx.Timeout(connect=15.0, read=300.0, write=60.0, pool=15.0),
|
|
161
|
+
headers={"User-Agent": user_agent()},
|
|
162
|
+
transport=transport,
|
|
163
|
+
follow_redirects=False,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
self._http.close()
|
|
168
|
+
|
|
169
|
+
def _headers(self) -> Dict[str, str]:
|
|
170
|
+
if not self.api_key:
|
|
171
|
+
raise AuthError("You are not logged in. Run `euron login`.", status=401, code="missing_api_key")
|
|
172
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
173
|
+
|
|
174
|
+
def _request(self, method: str, path: str, *, auth: bool = True, **kw: Any) -> httpx.Response:
|
|
175
|
+
headers = kw.pop("headers", {})
|
|
176
|
+
if auth:
|
|
177
|
+
headers.update(self._headers())
|
|
178
|
+
try:
|
|
179
|
+
return self._http.request(method, self.base_url + path, headers=headers, **kw)
|
|
180
|
+
except httpx.TimeoutException as e:
|
|
181
|
+
raise NetworkError(f"Request to Euron Code timed out ({type(e).__name__}).") from None
|
|
182
|
+
except httpx.HTTPError as e:
|
|
183
|
+
raise NetworkError(f"Cannot reach Euron Code at {self.base_url}: {e}") from None
|
|
184
|
+
|
|
185
|
+
def _json(self, method: str, path: str, **kw: Any) -> Any:
|
|
186
|
+
resp = self._request(method, path, **kw)
|
|
187
|
+
if resp.status_code >= 400:
|
|
188
|
+
raise _error_from_response(resp)
|
|
189
|
+
try:
|
|
190
|
+
return resp.json()
|
|
191
|
+
except ValueError:
|
|
192
|
+
raise EuronError(f"Unexpected non-JSON reply from {path} (HTTP {resp.status_code}).")
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------ device flow (§3)
|
|
195
|
+
|
|
196
|
+
def device_start(self) -> Dict[str, Any]:
|
|
197
|
+
body = {
|
|
198
|
+
"clientName": "euron-code",
|
|
199
|
+
"clientVersion": __version__,
|
|
200
|
+
"hostname": socket.gethostname(),
|
|
201
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
202
|
+
}
|
|
203
|
+
return self._json("POST", "/device/start", auth=False, json=body)
|
|
204
|
+
|
|
205
|
+
def device_poll(self, device_code: str) -> Tuple[str, Dict[str, Any]]:
|
|
206
|
+
"""Return (status, body). status: pending | slow_down | denied | expired | approved."""
|
|
207
|
+
resp = self._request("POST", "/device/token", auth=False, json={"deviceCode": device_code})
|
|
208
|
+
try:
|
|
209
|
+
body = resp.json()
|
|
210
|
+
except ValueError:
|
|
211
|
+
body = {}
|
|
212
|
+
status = body.get("status") if isinstance(body, dict) else None
|
|
213
|
+
if resp.status_code == 200 and status == "approved":
|
|
214
|
+
return "approved", body
|
|
215
|
+
mapping = {428: "pending", 429: "slow_down", 403: "denied", 410: "expired"}
|
|
216
|
+
if status in ("pending", "slow_down", "denied", "expired"):
|
|
217
|
+
return status, body
|
|
218
|
+
if resp.status_code in mapping:
|
|
219
|
+
return mapping[resp.status_code], body
|
|
220
|
+
raise _error_from_response(resp)
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------ account (§4)
|
|
223
|
+
|
|
224
|
+
def _cache_path(self):
|
|
225
|
+
h = hashlib.sha256(((self.api_key or "") + "|" + self.base_url).encode()).hexdigest()[:20]
|
|
226
|
+
return config.euron_home() / "cache" / f"me-{h}.json"
|
|
227
|
+
|
|
228
|
+
def me(self, use_cache: bool = True) -> Dict[str, Any]:
|
|
229
|
+
path = self._cache_path()
|
|
230
|
+
if use_cache:
|
|
231
|
+
try:
|
|
232
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
233
|
+
cached = json.load(f)
|
|
234
|
+
if time.time() - float(cached.get("fetchedAt", 0)) < ME_CACHE_TTL:
|
|
235
|
+
self.me_fetched_at = float(cached["fetchedAt"])
|
|
236
|
+
return cached["data"]
|
|
237
|
+
except (OSError, ValueError, KeyError):
|
|
238
|
+
pass
|
|
239
|
+
data = self._json("GET", "/v1/me")
|
|
240
|
+
self.me_fetched_at = time.time()
|
|
241
|
+
try:
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
244
|
+
json.dump({"fetchedAt": time.time(), "data": data}, f)
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
return data
|
|
248
|
+
|
|
249
|
+
def invalidate_me_cache(self) -> None:
|
|
250
|
+
try:
|
|
251
|
+
self._cache_path().unlink()
|
|
252
|
+
except OSError:
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
def usage(self, days: int = 30) -> Dict[str, Any]:
|
|
256
|
+
return self._json("GET", "/v1/usage", params={"days": days})
|
|
257
|
+
|
|
258
|
+
def models(self) -> List[Dict[str, Any]]:
|
|
259
|
+
data = self._json("GET", "/v1/models")
|
|
260
|
+
return list(data.get("data") or []) if isinstance(data, dict) else []
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------ chat (§4)
|
|
263
|
+
|
|
264
|
+
def chat(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
265
|
+
"""Non-streaming completion (used for compaction summaries)."""
|
|
266
|
+
with self._lock:
|
|
267
|
+
body = dict(payload)
|
|
268
|
+
body["stream"] = False
|
|
269
|
+
return self._json("POST", "/v1/chat/completions", json=body)
|
|
270
|
+
|
|
271
|
+
def stream_chat(self, payload: Dict[str, Any], cancel: Optional[threading.Event] = None,
|
|
272
|
+
on_wait: Optional[Callable[[], None]] = None) -> Iterator[Dict[str, Any]]:
|
|
273
|
+
"""Yield parsed SSE chunks (dicts). Runs the socket read in a worker thread so the
|
|
274
|
+
caller can interrupt (Esc / Ctrl-C) even while the request waits in the queue.
|
|
275
|
+
|
|
276
|
+
Raises a typed EuronError for HTTP errors and for SSE `data: {"error":...}` frames.
|
|
277
|
+
"""
|
|
278
|
+
if not self._lock.acquire(blocking=False):
|
|
279
|
+
raise EuronError("Internal error: a model request is already running.")
|
|
280
|
+
q: "queue.Queue[Tuple[str, Any]]" = queue.Queue()
|
|
281
|
+
holder: Dict[str, Any] = {}
|
|
282
|
+
body = dict(payload)
|
|
283
|
+
body["stream"] = True
|
|
284
|
+
body.setdefault("stream_options", {"include_usage": True})
|
|
285
|
+
|
|
286
|
+
def worker() -> None:
|
|
287
|
+
try:
|
|
288
|
+
with self._http.stream("POST", self.base_url + "/v1/chat/completions",
|
|
289
|
+
headers=self._headers(), json=body) as resp:
|
|
290
|
+
holder["resp"] = resp
|
|
291
|
+
if resp.status_code >= 400:
|
|
292
|
+
resp.read()
|
|
293
|
+
q.put(("error", _error_from_response(resp)))
|
|
294
|
+
return
|
|
295
|
+
for line in resp.iter_lines():
|
|
296
|
+
if cancel is not None and cancel.is_set():
|
|
297
|
+
return
|
|
298
|
+
if not line or line.startswith(":"):
|
|
299
|
+
q.put(("tick", None)) # keep-alive comment: ignore
|
|
300
|
+
continue
|
|
301
|
+
if not line.startswith("data:"):
|
|
302
|
+
continue
|
|
303
|
+
data = line[5:].strip()
|
|
304
|
+
if data == "[DONE]":
|
|
305
|
+
q.put(("done", None))
|
|
306
|
+
return
|
|
307
|
+
try:
|
|
308
|
+
obj = json.loads(data)
|
|
309
|
+
except ValueError:
|
|
310
|
+
continue
|
|
311
|
+
if isinstance(obj, dict) and obj.get("error"):
|
|
312
|
+
q.put(("error", error_from_body(int(obj["error"].get("status") or 500)
|
|
313
|
+
if isinstance(obj["error"], dict) else 500, obj)))
|
|
314
|
+
return
|
|
315
|
+
q.put(("chunk", obj))
|
|
316
|
+
q.put(("done", None))
|
|
317
|
+
except httpx.TimeoutException:
|
|
318
|
+
q.put(("error", NetworkError("The connection to Euron Code timed out.")))
|
|
319
|
+
except httpx.HTTPError as e:
|
|
320
|
+
q.put(("error", NetworkError(f"Connection to Euron Code failed: {e}")))
|
|
321
|
+
except Exception as e: # pragma: no cover - defensive
|
|
322
|
+
q.put(("error", EuronError(f"Unexpected error while streaming: {e}")))
|
|
323
|
+
|
|
324
|
+
t = threading.Thread(target=worker, name="euron-stream", daemon=True)
|
|
325
|
+
t.start()
|
|
326
|
+
try:
|
|
327
|
+
while True:
|
|
328
|
+
if cancel is not None and cancel.is_set():
|
|
329
|
+
raise Interrupted()
|
|
330
|
+
try:
|
|
331
|
+
kind, val = q.get(timeout=0.1)
|
|
332
|
+
except queue.Empty:
|
|
333
|
+
if on_wait:
|
|
334
|
+
on_wait()
|
|
335
|
+
continue
|
|
336
|
+
if kind == "chunk":
|
|
337
|
+
yield val
|
|
338
|
+
elif kind == "tick":
|
|
339
|
+
if on_wait:
|
|
340
|
+
on_wait()
|
|
341
|
+
elif kind == "error":
|
|
342
|
+
raise val
|
|
343
|
+
elif kind == "done":
|
|
344
|
+
return
|
|
345
|
+
finally:
|
|
346
|
+
resp = holder.get("resp")
|
|
347
|
+
if resp is not None and t.is_alive():
|
|
348
|
+
try:
|
|
349
|
+
resp.close()
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
t.join(timeout=2.0)
|
|
353
|
+
self._lock.release()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def plan_label(access: Dict[str, Any]) -> str:
|
|
357
|
+
parts = []
|
|
358
|
+
if access.get("plus"):
|
|
359
|
+
parts.append("Euron Plus")
|
|
360
|
+
if access.get("super30"):
|
|
361
|
+
parts.append("Super 30")
|
|
362
|
+
if access.get("pilot"):
|
|
363
|
+
parts.append("Pilot")
|
|
364
|
+
return " + ".join(parts) if parts else ("Entitled" if access.get("entitled") else "No plan")
|
euron_code/auth.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""`euron login` (device flow, spec §3), `logout`, `whoami`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from typing import Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from euron_code import api, credentials
|
|
11
|
+
from euron_code.api import EuronError, NetworkError, NotEntitledError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _open_browser(url: str) -> bool:
|
|
15
|
+
if os.environ.get("EURON_NO_BROWSER"):
|
|
16
|
+
return False
|
|
17
|
+
try:
|
|
18
|
+
return bool(webbrowser.open(url, new=2))
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def device_login(client: api.Client, ui, *, open_browser: bool = True,
|
|
24
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
25
|
+
clock: Callable[[], float] = time.monotonic) -> credentials.Credential:
|
|
26
|
+
start = client.device_start()
|
|
27
|
+
code = start.get("userCode", "")
|
|
28
|
+
url = start.get("verificationUriComplete") or start.get("verificationUri") or ""
|
|
29
|
+
interval = float(start.get("interval") or 5)
|
|
30
|
+
expires = float(start.get("expiresIn") or 600)
|
|
31
|
+
device_code = start["deviceCode"]
|
|
32
|
+
|
|
33
|
+
ui.info("")
|
|
34
|
+
ui.info(f"To sign in, open this page and confirm the code [bold]{code}[/bold]:")
|
|
35
|
+
ui.info(f" {url}")
|
|
36
|
+
opened = open_browser and _open_browser(url)
|
|
37
|
+
if opened:
|
|
38
|
+
ui.dim(" (opened in your browser)")
|
|
39
|
+
else:
|
|
40
|
+
ui.dim(" (copy the link into a browser where you are logged in to euron.one)")
|
|
41
|
+
ui.info("")
|
|
42
|
+
|
|
43
|
+
deadline = clock() + expires
|
|
44
|
+
net_failures = 0
|
|
45
|
+
with ui.console.status("Waiting for you to approve in the browser... (Ctrl-C to cancel)") \
|
|
46
|
+
if ui.console.is_terminal else _null():
|
|
47
|
+
while True:
|
|
48
|
+
if clock() > deadline:
|
|
49
|
+
raise EuronError("The login code expired. Run `euron login` again.")
|
|
50
|
+
sleep(interval)
|
|
51
|
+
try:
|
|
52
|
+
status, body = client.device_poll(device_code)
|
|
53
|
+
except NetworkError:
|
|
54
|
+
net_failures += 1
|
|
55
|
+
if net_failures > 5:
|
|
56
|
+
raise
|
|
57
|
+
continue
|
|
58
|
+
net_failures = 0
|
|
59
|
+
if status == "pending":
|
|
60
|
+
continue
|
|
61
|
+
if status == "slow_down":
|
|
62
|
+
interval += 5 # RFC 8628 3.5
|
|
63
|
+
continue
|
|
64
|
+
if status == "denied":
|
|
65
|
+
raise EuronError("Login was denied in the browser.")
|
|
66
|
+
if status == "expired":
|
|
67
|
+
raise EuronError("The login code expired. Run `euron login` again.")
|
|
68
|
+
if status == "approved":
|
|
69
|
+
key = body.get("apiKey")
|
|
70
|
+
if not key:
|
|
71
|
+
raise EuronError("The server approved the login but sent no key. Try again.")
|
|
72
|
+
cred = credentials.Credential(api_key=key, base_url=client.base_url, user=body.get("user") or {})
|
|
73
|
+
return cred
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _null:
|
|
77
|
+
def __enter__(self):
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *a):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def login(ui, base_url: Optional[str] = None, open_browser: bool = True) -> Dict:
|
|
85
|
+
"""Full login: device flow, save the key, then check entitlement via /v1/me.
|
|
86
|
+
Raises NotEntitledError (exit 3) when the account has no Plus / Super 30."""
|
|
87
|
+
c = api.Client(base_url=base_url)
|
|
88
|
+
try:
|
|
89
|
+
cred = device_login(c, ui, open_browser=open_browser)
|
|
90
|
+
finally:
|
|
91
|
+
c.close()
|
|
92
|
+
store = credentials.save(cred)
|
|
93
|
+
authed = api.Client(api_key=cred.api_key, base_url=cred.base_url)
|
|
94
|
+
try:
|
|
95
|
+
me = authed.me(use_cache=False)
|
|
96
|
+
finally:
|
|
97
|
+
authed.close()
|
|
98
|
+
user = me.get("user") or cred.user or {}
|
|
99
|
+
ui.info(f"[green]Logged in[/green] as {user.get('name') or ''} <{user.get('email') or ''}>"
|
|
100
|
+
f" (key {credentials.mask_key(cred.api_key)}, stored in {store})")
|
|
101
|
+
if not (me.get("access") or {}).get("entitled"):
|
|
102
|
+
raise NotEntitledError(api.NO_PLUS_MESSAGE, status=403, code="no_plus_access")
|
|
103
|
+
return me
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def logout(ui, base_url: Optional[str] = None) -> None:
|
|
107
|
+
url = base_url or api.config.base_url()
|
|
108
|
+
cred = credentials.load(url)
|
|
109
|
+
if cred:
|
|
110
|
+
api.Client(api_key=cred.api_key, base_url=url).invalidate_me_cache()
|
|
111
|
+
if credentials.delete(url):
|
|
112
|
+
ui.info("Logged out. The key was removed from this computer.")
|
|
113
|
+
ui.dim("To revoke it on the server too, delete it at https://euron.one/code-agent (API keys).")
|
|
114
|
+
else:
|
|
115
|
+
ui.info("You were not logged in.")
|