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/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.")