rosa-llm 0.2.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.
rosa_llm/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Public API for the standalone rosa-llm package."""
2
+
3
+ from .client import Client, create_client
4
+ from .config import Config
5
+ from .errors import (
6
+ AuthenticationError,
7
+ ConfigurationError,
8
+ IncompleteResponse,
9
+ LLMError,
10
+ PermissionDenied,
11
+ ProviderError,
12
+ UnsupportedFeature,
13
+ UsageLimitError,
14
+ )
15
+ from .provenance import package_info
16
+ from .types import Message, Response, StreamEvent, Tool, ToolCall
17
+
18
+ __all__ = [
19
+ "AuthenticationError",
20
+ "Client",
21
+ "Config",
22
+ "ConfigurationError",
23
+ "IncompleteResponse",
24
+ "LLMError",
25
+ "Message",
26
+ "PermissionDenied",
27
+ "ProviderError",
28
+ "Response",
29
+ "StreamEvent",
30
+ "Tool",
31
+ "ToolCall",
32
+ "UnsupportedFeature",
33
+ "UsageLimitError",
34
+ "create_client",
35
+ "package_info",
36
+ ]
rosa_llm/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
rosa_llm/auth.py ADDED
@@ -0,0 +1,497 @@
1
+ """Official public-client ChatGPT plan OAuth. See docs/protocol.md for sources."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+ import time
7
+ import webbrowser
8
+ from dataclasses import dataclass, field
9
+ from http.server import BaseHTTPRequestHandler, HTTPServer
10
+ from typing import Callable
11
+ from urllib.parse import parse_qs, urlencode, urlsplit
12
+
13
+ import httpx
14
+ import jwt
15
+
16
+ from .errors import AuthenticationError, LLMError, PermissionDenied
17
+ from .http import json_object, safe_identifier
18
+ from .storage import CredentialStore
19
+
20
+ ISSUER = "https://auth.openai.com"
21
+ AUTHORIZE = ISSUER + "/api/accounts/authorize"
22
+ TOKEN = ISSUER + "/api/accounts/oauth/token"
23
+ DISCOVERY = ISSUER + "/.well-known/openid-configuration"
24
+ RESOURCE = "https://api.openai.com/v1"
25
+ SCOPES = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
26
+ PLAN_SCOPES = {"resource.invoke", "chatgpt.tokens.use.direct"}
27
+
28
+
29
+ @dataclass(repr=False)
30
+ class PendingLogin:
31
+ client_id: str
32
+ redirect_uri: str
33
+ host_id: str
34
+ state: str = field(default_factory=lambda: secrets.token_urlsafe(32))
35
+ nonce: str = field(default_factory=lambda: secrets.token_urlsafe(32))
36
+ verifier: str = field(default_factory=lambda: secrets.token_urlsafe(64))
37
+ created_at: float = field(default_factory=time.time)
38
+ consumed: bool = False
39
+
40
+ @property
41
+ def challenge(self) -> str:
42
+ return (
43
+ base64.urlsafe_b64encode(hashlib.sha256(self.verifier.encode()).digest())
44
+ .rstrip(b"=")
45
+ .decode()
46
+ )
47
+
48
+ def url(
49
+ self, previous: dict | None = None, *, hints: bool = False, consent: bool = False
50
+ ) -> str:
51
+ params = {
52
+ "client_id": self.client_id,
53
+ "ext_agent_host_id": self.host_id,
54
+ "response_type": "code",
55
+ "redirect_uri": self.redirect_uri,
56
+ "scope": SCOPES,
57
+ "resource": RESOURCE,
58
+ "state": self.state,
59
+ "nonce": self.nonce,
60
+ "code_challenge_method": "S256",
61
+ "code_challenge": self.challenge,
62
+ }
63
+ if self.client_id == "dynamic_agent_client":
64
+ params["agent_name_hint"] = "rosa-llm"
65
+ elif previous and hints:
66
+ # Never print URLs containing the retained ID token.
67
+ if previous.get("id_token"):
68
+ params["id_token_hint"] = previous["id_token"]
69
+ if previous.get("email"):
70
+ params["login_hint"] = previous["email"]
71
+ if consent:
72
+ params["prompt"] = "consent"
73
+ return AUTHORIZE + "?" + urlencode(params)
74
+
75
+
76
+ def parse_callback(query: str, pending: PendingLogin) -> tuple[str, str]:
77
+ if pending.consumed or time.time() - pending.created_at > 300:
78
+ raise AuthenticationError("Login attempt expired or already consumed; start login again.")
79
+ pending.consumed = True
80
+ params = parse_qs(query, keep_blank_values=True)
81
+ if any(len(values) != 1 for values in params.values()):
82
+ raise AuthenticationError("OAuth callback contains duplicate parameters.")
83
+ state = params.get("state", [""])[0]
84
+ if not secrets.compare_digest(state.encode(), pending.state.encode()):
85
+ raise AuthenticationError("OAuth state mismatch; start login again.")
86
+ if "error" in params:
87
+ raise PermissionDenied("Authorization was denied; no credentials were changed.")
88
+ client_id = params.get("client_id", [pending.client_id])[0]
89
+ if pending.client_id == "dynamic_agent_client":
90
+ if not client_id or client_id == "dynamic_agent_client":
91
+ raise AuthenticationError("Registration callback did not supply an issued client ID.")
92
+ elif client_id != pending.client_id:
93
+ raise AuthenticationError("Callback client ID does not match the selected registration.")
94
+ code = params.get("code", [""])[0]
95
+ if not code:
96
+ raise AuthenticationError("OAuth callback did not contain a code.")
97
+ return code, client_id
98
+
99
+
100
+ class OAuthManager:
101
+ def __init__(self, store: CredentialStore, http: httpx.Client, profile: str = "default"):
102
+ self.store, self.http, self.profile = store, http, profile
103
+
104
+ def _record(self) -> dict:
105
+ record = self.store.read("credentials.json").get(self.profile, {})
106
+ if not isinstance(record, dict):
107
+ raise AuthenticationError(
108
+ "Invalid saved profile; restore credentials or authorize again."
109
+ )
110
+ if record:
111
+ if not all(
112
+ isinstance(record.get(key), str) and record[key] for key in ("client_id", "subject")
113
+ ):
114
+ raise AuthenticationError("Saved profile is missing its registration identity.")
115
+ if (
116
+ not isinstance(record.get("scopes", []), list)
117
+ or not all(isinstance(scope, str) for scope in record.get("scopes", []))
118
+ or not isinstance(record.get("expires_at", 0), (float, int))
119
+ ):
120
+ raise AuthenticationError("Saved profile has invalid scopes or expiry information.")
121
+ return record
122
+
123
+ def _invalidate(self, record: dict):
124
+ record = {
125
+ k: v
126
+ for k, v in record.items()
127
+ if k not in {"access_token", "refresh_token", "id_token", "pending_id_token"}
128
+ }
129
+ record["needs_login"] = True
130
+ self._save(record)
131
+
132
+ def _validate_pending_identity(self, record: dict):
133
+ token = record.get("pending_id_token")
134
+ if not token:
135
+ return
136
+ try:
137
+ claims = self.validate_identity(token, record["client_id"])
138
+ if claims["sub"] != record["subject"]:
139
+ raise AuthenticationError("Refreshed token identity changed; authorize again.")
140
+ except AuthenticationError:
141
+ self._invalidate(record)
142
+ raise
143
+ # Network/JWKS outages leave the rotated tokens pending and do not permit inference.
144
+ record["id_token"] = record.pop("pending_id_token")
145
+ self._save(record)
146
+
147
+ def _save(self, record: dict):
148
+ records = self.store.read("credentials.json")
149
+ records[self.profile] = record
150
+ self.store.write("credentials.json", records)
151
+
152
+ def status(self) -> dict:
153
+ with self.store.lock():
154
+ record = self._record()
155
+ pending_validation = bool(record.get("pending_id_token"))
156
+ authenticated = bool(record.get("access_token")) and not pending_validation
157
+ return {
158
+ "profile": self.profile,
159
+ "authenticated": authenticated,
160
+ "identity_authenticated": bool(record.get("id_token")),
161
+ "identity_validation_pending": pending_validation,
162
+ "plan_permission": authenticated and PLAN_SCOPES.issubset(record.get("scopes", [])),
163
+ "expired": record.get("expires_at", 0) <= time.time(),
164
+ "renewable": bool(record.get("refresh_token")),
165
+ "remote_permission_checked": False,
166
+ }
167
+
168
+ def _discovery(self) -> dict:
169
+ doc = json_object(self.http.get(DISCOVERY, follow_redirects=False))
170
+ if doc.get("issuer") != ISSUER:
171
+ raise AuthenticationError("OIDC discovery issuer mismatch.")
172
+ # Discovery cannot redirect secrets to an arbitrary origin.
173
+ for name in ("jwks_uri", "revocation_endpoint"):
174
+ endpoint = doc.get(name)
175
+ if endpoint and (
176
+ urlsplit(endpoint).scheme != "https"
177
+ or urlsplit(endpoint).netloc != "auth.openai.com"
178
+ ):
179
+ raise AuthenticationError("OIDC discovery returned an untrusted endpoint.")
180
+ return doc
181
+
182
+ def validate_identity(self, token: str, client_id: str, nonce: str | None = None) -> dict:
183
+ try:
184
+ doc = self._discovery()
185
+ jwks = json_object(self.http.get(doc["jwks_uri"], follow_redirects=False))
186
+ header = jwt.get_unverified_header(token)
187
+ # Algorithms are chosen by the application, never trusted from the JWT alone.
188
+ if header.get("alg") != "RS256":
189
+ raise ValueError
190
+ keys = [
191
+ key
192
+ for key in jwks["keys"]
193
+ if key.get("kid") == header.get("kid")
194
+ and key.get("kty") == "RSA"
195
+ and key.get("use", "sig") == "sig"
196
+ ]
197
+ if len(keys) != 1:
198
+ raise ValueError
199
+ key = jwt.PyJWK.from_dict(keys[0], algorithm="RS256")
200
+ claims = jwt.decode(
201
+ token,
202
+ key.key,
203
+ algorithms=["RS256"],
204
+ issuer=ISSUER,
205
+ audience=client_id,
206
+ leeway=5,
207
+ options={"require": ["exp", "iat", "sub", "iss", "aud"]},
208
+ )
209
+ if not isinstance(claims["sub"], str) or not claims["sub"]:
210
+ raise ValueError
211
+ if nonce is not None and (
212
+ not isinstance(claims.get("nonce"), str)
213
+ or not secrets.compare_digest(claims["nonce"], nonce)
214
+ ):
215
+ raise ValueError
216
+ if isinstance(claims["aud"], list) and len(claims["aud"]) > 1:
217
+ if claims.get("azp") != client_id:
218
+ raise ValueError
219
+ return claims
220
+ except (jwt.PyJWTError, KeyError, TypeError, ValueError):
221
+ raise AuthenticationError(
222
+ "ID token signature, identity, expiry, or nonce is invalid."
223
+ ) from None
224
+
225
+ def _tokens(self, form: dict) -> dict:
226
+ response = self.http.post(TOKEN, data=form, follow_redirects=False)
227
+ if not response.is_success:
228
+ try:
229
+ error = response.json().get("error")
230
+ except (ValueError, AttributeError):
231
+ error = None
232
+ if isinstance(error, dict):
233
+ error = error.get("code")
234
+ if isinstance(error, str) and error in {
235
+ "invalid_grant",
236
+ "invalid_client",
237
+ "invalid_refresh_token",
238
+ "token_expired",
239
+ "refresh_token_expired",
240
+ "refresh_token_invalidated",
241
+ "refresh_token_reused",
242
+ }:
243
+ raise AuthenticationError(
244
+ "Session or authorization code is invalid; run rosa-llm login.",
245
+ status_code=response.status_code,
246
+ provider_code=safe_identifier(error),
247
+ request_id=safe_identifier(response.headers.get("x-request-id")),
248
+ kind="http_error",
249
+ )
250
+ return json_object(response)
251
+
252
+ @staticmethod
253
+ def _token_record(tokens: dict, old: dict | None = None) -> dict:
254
+ old = old or {}
255
+ if (
256
+ not isinstance(tokens.get("access_token"), str)
257
+ or not tokens["access_token"]
258
+ or str(tokens.get("token_type", "")).lower() != "bearer"
259
+ ):
260
+ raise AuthenticationError("Token endpoint returned an invalid access token response.")
261
+ try:
262
+ lifetime = float(tokens["expires_in"])
263
+ if not 0 < lifetime <= 86400:
264
+ raise ValueError
265
+ except (KeyError, ValueError, TypeError):
266
+ raise AuthenticationError(
267
+ "Token endpoint returned invalid expiry information."
268
+ ) from None
269
+ if not isinstance(tokens.get("refresh_token"), str) or not tokens["refresh_token"]:
270
+ raise AuthenticationError(
271
+ "No rotating refresh token was returned; authorize offline access."
272
+ )
273
+ scope = tokens.get("scope")
274
+ scopes = scope.split() if isinstance(scope, str) else old.get("scopes", [])
275
+ if not PLAN_SCOPES.issubset(scopes):
276
+ raise PermissionDenied(
277
+ "Identity login succeeded but ChatGPT plan inference was not granted."
278
+ )
279
+ return {
280
+ **old,
281
+ "access_token": tokens["access_token"],
282
+ "refresh_token": tokens["refresh_token"],
283
+ "token_type": "Bearer",
284
+ "expires_at": time.time() + lifetime,
285
+ "scopes": scopes,
286
+ "saved_at": time.time(),
287
+ "needs_login": False,
288
+ }
289
+
290
+ def finish_login(self, pending: PendingLogin, query: str, previous: dict | None = None):
291
+ code, client_id = parse_callback(query, pending)
292
+ tokens = self._tokens(
293
+ {
294
+ "grant_type": "authorization_code",
295
+ "client_id": client_id,
296
+ "code": code,
297
+ "code_verifier": pending.verifier,
298
+ "redirect_uri": pending.redirect_uri,
299
+ "resource": RESOURCE,
300
+ }
301
+ )
302
+ claims = self.validate_identity(tokens.get("id_token", ""), client_id, pending.nonce)
303
+ if previous and (
304
+ claims["sub"] != previous["subject"] or client_id != previous["client_id"]
305
+ ):
306
+ raise AuthenticationError(
307
+ "Signed-in identity differs from the selected profile; use a new profile."
308
+ )
309
+ identity = {
310
+ "subject": claims["sub"],
311
+ "email": claims.get("email"),
312
+ "issuer": ISSUER,
313
+ "client_id": client_id,
314
+ "id_token": tokens["id_token"],
315
+ "ext_agent_host_id": pending.host_id,
316
+ }
317
+ scope = tokens.get("scope")
318
+ if not isinstance(scope, str) or not PLAN_SCOPES.issubset(scope.split()):
319
+ # Retain a new identity for reauthorization; preserve a valid old session.
320
+ with self.store.lock():
321
+ if self._record() != (previous or {}):
322
+ raise AuthenticationError("Profile changed during login; start login again.")
323
+ if not previous or not previous.get("access_token"):
324
+ self._save(
325
+ {
326
+ **identity,
327
+ "scopes": scope.split() if isinstance(scope, str) else [],
328
+ "expires_at": 0,
329
+ }
330
+ )
331
+ raise PermissionDenied(
332
+ "Identity login succeeded but plan inference was not granted; authorize plan usage."
333
+ )
334
+ record = {**self._token_record(tokens), **identity}
335
+ with self.store.lock():
336
+ if self._record() != (previous or {}):
337
+ raise AuthenticationError("Profile changed during login; start login again.")
338
+ self._save(record)
339
+
340
+ def access_token(self) -> str:
341
+ # Re-read AFTER acquiring lock: a second process sees the first process's rotated tokens.
342
+ with self.store.lock():
343
+ record = self._record()
344
+ if record.get("id_token") and not PLAN_SCOPES.issubset(record.get("scopes", [])):
345
+ raise PermissionDenied(
346
+ "Identity is signed in, but ChatGPT plan inference is disabled."
347
+ )
348
+ if not record.get("access_token") or record.get("needs_login"):
349
+ raise AuthenticationError("Human authentication required: run rosa-llm login.")
350
+ if not PLAN_SCOPES.issubset(record.get("scopes", [])):
351
+ raise PermissionDenied("This login has no ChatGPT plan inference permission.")
352
+ self._validate_pending_identity(record)
353
+ if record.get("expires_at", 0) > time.time() + 30:
354
+ return record["access_token"]
355
+ if not record.get("refresh_token"):
356
+ raise AuthenticationError("Session cannot be renewed; run rosa-llm login.")
357
+ try:
358
+ tokens = self._tokens(
359
+ {
360
+ "grant_type": "refresh_token",
361
+ "client_id": record["client_id"],
362
+ "refresh_token": record["refresh_token"],
363
+ "resource": RESOURCE,
364
+ }
365
+ )
366
+ except AuthenticationError:
367
+ self._invalidate(record)
368
+ raise
369
+ try:
370
+ updated = self._token_record(tokens, record)
371
+ except (AuthenticationError, PermissionDenied):
372
+ # The old refresh token may already be consumed; never keep retrying it.
373
+ self._invalidate(record)
374
+ raise
375
+ if tokens.get("id_token"):
376
+ updated["pending_id_token"] = tokens["id_token"]
377
+ # Persist rotation before fetching validation keys: a network failure must not
378
+ # lose the replacement refresh token or retry its already-consumed predecessor.
379
+ self._save(updated)
380
+ self._validate_pending_identity(updated)
381
+ return updated["access_token"]
382
+
383
+ def logout(self) -> dict:
384
+ confirmed = True
385
+ with self.store.lock():
386
+ record = self._record()
387
+ if record.get("refresh_token"):
388
+ confirmed = False
389
+ try:
390
+ endpoint = self._discovery()["revocation_endpoint"]
391
+ for attempt in range(3):
392
+ try:
393
+ response = self.http.post(
394
+ endpoint,
395
+ follow_redirects=False,
396
+ data={
397
+ "token": record["refresh_token"],
398
+ "token_type_hint": "refresh_token",
399
+ "client_id": record["client_id"],
400
+ },
401
+ )
402
+ if response.status_code == 200:
403
+ confirmed = True
404
+ break
405
+ if response.status_code < 500:
406
+ break
407
+ except httpx.TransportError:
408
+ pass
409
+ if attempt < 2:
410
+ time.sleep(0.2 * 2**attempt)
411
+ except (httpx.HTTPError, KeyError, LLMError):
412
+ pass
413
+ for key in ("access_token", "refresh_token", "id_token", "pending_id_token"):
414
+ record.pop(key, None)
415
+ if record:
416
+ self._save(record)
417
+ return {
418
+ "profile": self.profile,
419
+ "authenticated": False,
420
+ "remote_revocation_confirmed": confirmed,
421
+ }
422
+
423
+ def login(
424
+ self,
425
+ *,
426
+ port: int = 1455,
427
+ open_browser: bool = True,
428
+ consent: bool = False,
429
+ announce: Callable[[str], None] = print,
430
+ timeout: float = 180,
431
+ ):
432
+ """Loopback only. Docker uses host networking, never a public callback bind."""
433
+ host_id = self.store.host_id()
434
+ with self.store.lock():
435
+ previous = self._record()
436
+ manager = self
437
+ result: dict = {}
438
+
439
+ class CallbackHandler(BaseHTTPRequestHandler):
440
+ def log_message(self, format, *args):
441
+ pass # BaseHTTPRequestHandler would otherwise log callback secrets.
442
+
443
+ def do_GET(self):
444
+ parsed = urlsplit(self.path)
445
+ if parsed.path != "/auth/callback":
446
+ self.send_error(404)
447
+ return
448
+ try:
449
+ manager.finish_login(pending, parsed.query, previous)
450
+ result["ok"] = True
451
+ body = b"Sign-in complete. You can close this tab."
452
+ except Exception as error:
453
+ result["error"] = error
454
+ body = b"Sign-in failed. Check the terminal for a safe error description."
455
+ self.send_response(200 if result.get("ok") else 400)
456
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
457
+ self.send_header("Cache-Control", "no-store")
458
+ self.send_header("Referrer-Policy", "no-referrer")
459
+ self.send_header("Content-Length", str(len(body)))
460
+ self.end_headers()
461
+ self.wfile.write(body)
462
+
463
+ class LocalServer(HTTPServer):
464
+ def get_request(self):
465
+ connection, address = super().get_request()
466
+ connection.settimeout(3)
467
+ return connection, address
468
+
469
+ def handle_error(self, request, client_address):
470
+ pass # No tracebacks with request/token details.
471
+
472
+ with LocalServer(("127.0.0.1", port), CallbackHandler) as server:
473
+ server.timeout = 0.5
474
+ pending = PendingLogin(
475
+ previous.get("client_id", "dynamic_agent_client"),
476
+ f"http://127.0.0.1:{server.server_port}/auth/callback",
477
+ host_id,
478
+ )
479
+ opened = False
480
+ if open_browser:
481
+ try:
482
+ opened = webbrowser.open(pending.url(previous, hints=True, consent=consent))
483
+ except webbrowser.Error:
484
+ pass
485
+ if not opened:
486
+ announce("Open this authorization URL in your browser (keep it private):")
487
+ announce(pending.url(consent=consent)) # No saved identity hints in output.
488
+ announce("Waiting for local callback; complete authorization in the browser.")
489
+ deadline = time.monotonic() + timeout
490
+ while not result and time.monotonic() < deadline:
491
+ server.handle_request()
492
+ if "error" in result:
493
+ raise result["error"]
494
+ if not result.get("ok"):
495
+ raise AuthenticationError(
496
+ "Login timed out; check loopback forwarding and try again."
497
+ )