convo-cli 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.
Files changed (53) hide show
  1. convo/__init__.py +3 -0
  2. convo/__main__.py +110 -0
  3. convo/bootstrap.py +273 -0
  4. convo/core/__init__.py +1 -0
  5. convo/core/config.py +53 -0
  6. convo/core/contracts.py +160 -0
  7. convo/core/exceptions.py +5 -0
  8. convo/db/__init__.py +1 -0
  9. convo/db/base.py +13 -0
  10. convo/db/chunks.py +132 -0
  11. convo/db/conversations.py +167 -0
  12. convo/db/documents.py +34 -0
  13. convo/db/models.py +81 -0
  14. convo/db/session.py +27 -0
  15. convo/llm/__init__.py +0 -0
  16. convo/llm/auth.py +504 -0
  17. convo/llm/client.py +207 -0
  18. convo/llm/credentials.py +192 -0
  19. convo/llm/models.py +71 -0
  20. convo/llm/streaming.py +62 -0
  21. convo/migrations/env.py +43 -0
  22. convo/migrations/script.py.mako +22 -0
  23. convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
  24. convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
  25. convo/migrations/versions/README.md +3 -0
  26. convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
  27. convo/pipeline/__init__.py +0 -0
  28. convo/pipeline/chat.py +129 -0
  29. convo/pipeline/chunking.py +42 -0
  30. convo/pipeline/embeddings.py +83 -0
  31. convo/pipeline/index.py +160 -0
  32. convo/pipeline/ingestion.py +46 -0
  33. convo/pipeline/reranking.py +63 -0
  34. convo/pipeline/routing.py +64 -0
  35. convo/pipeline/sessions.py +40 -0
  36. convo/pipeline/worker.py +226 -0
  37. convo/prompts/__init__.py +0 -0
  38. convo/prompts/answers.py +97 -0
  39. convo/prompts/schema.py +97 -0
  40. convo/ui/__init__.py +1 -0
  41. convo/ui/app.py +752 -0
  42. convo/ui/commands.py +37 -0
  43. convo/ui/files.py +110 -0
  44. convo/ui/models.py +84 -0
  45. convo/ui/preview.py +20 -0
  46. convo/ui/sessions.py +97 -0
  47. convo/ui/theme.tcss +64 -0
  48. convo/ui/thinking.py +70 -0
  49. convo_cli-0.1.0.dist-info/METADATA +160 -0
  50. convo_cli-0.1.0.dist-info/RECORD +53 -0
  51. convo_cli-0.1.0.dist-info/WHEEL +4 -0
  52. convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
  53. convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/llm/auth.py ADDED
@@ -0,0 +1,504 @@
1
+ """Sign in with ChatGPT and refresh its locally stored OAuth credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import math
11
+ import secrets
12
+ import time
13
+ import webbrowser
14
+ from dataclasses import dataclass, replace
15
+ from typing import Any
16
+ from urllib.parse import parse_qs, urlencode, urlsplit
17
+
18
+ import httpx
19
+ import jwt
20
+
21
+ from convo.core.contracts import ReasoningEffort
22
+ from convo.core.exceptions import BackendNotConnected
23
+ from convo.llm.credentials import (
24
+ AccountProfile,
25
+ AccountProfileStore,
26
+ CredentialStore,
27
+ )
28
+
29
+ AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize"
30
+ TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token"
31
+ OIDC_DISCOVERY_URL = "https://auth.openai.com/.well-known/openid-configuration"
32
+ RESOURCE = "https://api.openai.com/v1"
33
+ SCOPES = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
34
+ DIRECT_USE_SCOPE = "chatgpt.tokens.use.direct"
35
+ CALLBACK_PATH = "/auth/callback"
36
+ TOKEN_KEY = "chatgpt.oauth.tokens"
37
+ CALLBACK_TIMEOUT_SECONDS = 180
38
+ HTTP_TIMEOUT_SECONDS = 20
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AuthorizationResult:
43
+ code: str
44
+ client_id: str
45
+
46
+
47
+ class ChatGPTAuth:
48
+ """Own the loopback OAuth flow and the local ChatGPT account connection."""
49
+
50
+ def __init__(
51
+ self,
52
+ credentials: CredentialStore,
53
+ profile_store: AccountProfileStore | None = None,
54
+ ) -> None:
55
+ self.credentials = credentials
56
+ self.profiles = profile_store or AccountProfileStore()
57
+ self._callback_future: asyncio.Future[AuthorizationResult] | None = None
58
+ self._login_lock = asyncio.Lock()
59
+
60
+ async def login(self) -> str:
61
+ """Open the system browser, validate OAuth, and save the account locally."""
62
+ async with self._login_lock:
63
+ return await self._login()
64
+
65
+ async def _login(self) -> str:
66
+ try:
67
+ profile = self._load_profile()
68
+ old_tokens = self._load_tokens(required=False)
69
+ result, redirect_uri, nonce, verifier = await self._authorize(profile, old_tokens)
70
+ payload = await self._post_token(
71
+ {
72
+ "grant_type": "authorization_code",
73
+ "client_id": result.client_id,
74
+ "code": result.code,
75
+ "redirect_uri": redirect_uri,
76
+ "code_verifier": verifier,
77
+ "resource": RESOURCE,
78
+ }
79
+ )
80
+ identity, issuer = await self._verify_id_token(
81
+ self._required_string(payload, "id_token"), result.client_id, nonce
82
+ )
83
+ if profile is not None and identity["sub"] != profile.subject:
84
+ raise BackendNotConnected(
85
+ "This ChatGPT registration belongs to a different account. "
86
+ "Remove the saved Convo account before switching accounts."
87
+ )
88
+
89
+ scopes = _parse_scopes(payload.get("scope"))
90
+ self._require_direct_use(scopes)
91
+ self._validate_token_type(payload)
92
+ expires_in = self._expires_in(payload)
93
+ token_record = {
94
+ "access_token": self._required_string(payload, "access_token"),
95
+ "refresh_token": self._required_string(payload, "refresh_token"),
96
+ "id_token": self._required_string(payload, "id_token"),
97
+ }
98
+ new_profile = AccountProfile(
99
+ client_id=result.client_id,
100
+ subject=identity["sub"],
101
+ email=_optional_claim(identity, "email"),
102
+ name=_optional_claim(identity, "name")
103
+ or _optional_claim(identity, "preferred_username"),
104
+ issuer=issuer,
105
+ scopes=tuple(sorted(scopes)),
106
+ expires_at=time.time() + expires_in,
107
+ selected_model=profile.selected_model if profile else None,
108
+ selected_model_name=profile.selected_model_name if profile else None,
109
+ selected_reasoning_effort=profile.selected_reasoning_effort if profile else None,
110
+ )
111
+ self._save_connection(token_record, new_profile)
112
+ return new_profile.email or new_profile.name or new_profile.subject
113
+ except BackendNotConnected:
114
+ raise
115
+ except (httpx.HTTPError, OSError, ValueError, jwt.PyJWTError, KeyError, TypeError) as error:
116
+ # Raw HTTP/JWT exceptions can contain request details. Keep the UI response safe.
117
+ raise BackendNotConnected(
118
+ "ChatGPT sign-in could not be completed. Check the connection and retry /login."
119
+ ) from error
120
+
121
+ async def access_token(self) -> str:
122
+ """Return a valid, plan-enabled token, refreshing it when near expiry."""
123
+ profile = self._load_profile()
124
+ if profile is None:
125
+ raise BackendNotConnected("Connect your ChatGPT account with /login")
126
+ self._require_direct_use(set(profile.scopes))
127
+ tokens = self._load_tokens(required=True)
128
+ assert tokens is not None
129
+ access_token = tokens.get("access_token")
130
+ if not isinstance(access_token, str) or not access_token:
131
+ raise BackendNotConnected("Saved ChatGPT credentials are incomplete. Run /login again.")
132
+ if profile.expires_at > time.time() + 60:
133
+ return access_token
134
+ return await self._refresh(profile, tokens)
135
+
136
+ def account_profile(self) -> AccountProfile | None:
137
+ """Return the non-secret signed-in account metadata, if available."""
138
+ return self._load_profile()
139
+
140
+ def save_generation_preferences(
141
+ self, model: str, display_name: str, effort: ReasoningEffort | None
142
+ ) -> None:
143
+ """Persist model and reasoning together without altering OAuth credentials."""
144
+ profile = self._load_profile()
145
+ if profile is None:
146
+ raise BackendNotConnected("Connect your ChatGPT account with /login")
147
+ self.profiles.save(
148
+ replace(
149
+ profile,
150
+ selected_model=model,
151
+ selected_model_name=display_name,
152
+ selected_reasoning_effort=effort,
153
+ )
154
+ )
155
+
156
+ async def _authorize(
157
+ self,
158
+ profile: AccountProfile | None,
159
+ old_tokens: dict[str, str] | None,
160
+ ) -> tuple[AuthorizationResult, str, str, str]:
161
+ state = secrets.token_urlsafe(32)
162
+ nonce = secrets.token_urlsafe(32)
163
+ verifier = secrets.token_urlsafe(64)
164
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
165
+ client_id = profile.client_id if profile else "dynamic_agent_client"
166
+ if self._callback_future is not None:
167
+ raise BackendNotConnected("A ChatGPT sign-in is already in progress.")
168
+
169
+ server = await asyncio.start_server(
170
+ lambda reader, writer: self._handle_callback(reader, writer, state, client_id),
171
+ host="127.0.0.1",
172
+ port=0,
173
+ limit=16 * 1024,
174
+ )
175
+ try:
176
+ socket = server.sockets[0]
177
+ port = socket.getsockname()[1]
178
+ redirect_uri = f"http://127.0.0.1:{port}{CALLBACK_PATH}"
179
+ params = {
180
+ "client_id": client_id,
181
+ "redirect_uri": redirect_uri,
182
+ "response_type": "code",
183
+ "scope": SCOPES,
184
+ "resource": RESOURCE,
185
+ "state": state,
186
+ "nonce": nonce,
187
+ "code_challenge": challenge.decode("ascii").rstrip("="),
188
+ "code_challenge_method": "S256",
189
+ "ext_agent_host_id": self.profiles.host_id(),
190
+ }
191
+ if profile is None:
192
+ params["agent_name_hint"] = "convo"
193
+ else:
194
+ email = profile.email
195
+ old_id_token = old_tokens.get("id_token") if old_tokens else None
196
+ if email:
197
+ params["login_hint"] = email
198
+ if old_id_token:
199
+ params["id_token_hint"] = old_id_token
200
+ authorization_url = f"{AUTHORIZE_URL}?{urlencode(params)}"
201
+ callback_future: asyncio.Future[AuthorizationResult] = (
202
+ asyncio.get_running_loop().create_future()
203
+ )
204
+ # Store the future only for the duration of this single authorization attempt.
205
+ self._callback_future = callback_future
206
+ opened = await asyncio.to_thread(webbrowser.open, authorization_url, 1, True)
207
+ if not opened:
208
+ raise BackendNotConnected(
209
+ "Could not open a browser for ChatGPT sign-in. Check your desktop browser and retry."
210
+ )
211
+ try:
212
+ result = await asyncio.wait_for(callback_future, timeout=CALLBACK_TIMEOUT_SECONDS)
213
+ except TimeoutError as error:
214
+ raise BackendNotConnected(
215
+ "Timed out waiting for ChatGPT sign-in. Run /login and try again."
216
+ ) from error
217
+ if profile is None and result.client_id == "dynamic_agent_client":
218
+ raise BackendNotConnected("ChatGPT did not return the registered client ID.")
219
+ return AuthorizationResult(result.code, result.client_id), redirect_uri, nonce, verifier
220
+ finally:
221
+ self._callback_future = None
222
+ server.close()
223
+ await server.wait_closed()
224
+
225
+ async def _handle_callback(
226
+ self,
227
+ reader: asyncio.StreamReader,
228
+ writer: asyncio.StreamWriter,
229
+ expected_state: str,
230
+ requested_client_id: str,
231
+ ) -> None:
232
+ response_status = "400 Bad Request"
233
+ response_body = "Sign-in callback was not accepted. Return to Convo and retry /login."
234
+ should_resolve = False
235
+ try:
236
+ request_line = await asyncio.wait_for(reader.readline(), timeout=10)
237
+ try:
238
+ method, target, _version = request_line.decode("ascii").strip().split(" ", 2)
239
+ except (UnicodeDecodeError, ValueError):
240
+ method, target = "", ""
241
+ total_header_bytes = 0
242
+ while True:
243
+ line = await asyncio.wait_for(reader.readline(), timeout=10)
244
+ total_header_bytes += len(line)
245
+ if not line or line in (b"\r\n", b"\n"):
246
+ break
247
+ if total_header_bytes > 16 * 1024:
248
+ raise ValueError("Callback headers are too large")
249
+ parsed = urlsplit(target)
250
+ if method != "GET" or parsed.path != CALLBACK_PATH:
251
+ response_status = "404 Not Found"
252
+ response_body = "Unknown Convo callback path. You may close this tab."
253
+ else:
254
+ query = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True)
255
+ state_values = query.get("state", [])
256
+ if len(state_values) != 1 or not hmac.compare_digest(
257
+ state_values[0], expected_state
258
+ ):
259
+ response_body = "This sign-in callback did not match the pending request."
260
+ else:
261
+ errors = query.get("error", [])
262
+ if errors:
263
+ response_status = "200 OK"
264
+ response_body = "ChatGPT sign-in was canceled. You may close this tab."
265
+ future = self._callback_future
266
+ if future is not None and not future.done():
267
+ future.set_exception(
268
+ BackendNotConnected("ChatGPT sign-in was canceled or denied.")
269
+ )
270
+ should_resolve = True
271
+ else:
272
+ codes = query.get("code", [])
273
+ client_ids = query.get("client_id", [])
274
+ if len(codes) != 1 or not codes[0]:
275
+ raise ValueError("Callback did not contain one authorization code")
276
+ if len(client_ids) > 1:
277
+ raise ValueError("Callback contained duplicate client IDs")
278
+ callback_client_id = client_ids[0] if client_ids else requested_client_id
279
+ if not callback_client_id or (
280
+ requested_client_id != "dynamic_agent_client"
281
+ and callback_client_id != requested_client_id
282
+ ):
283
+ raise ValueError("Callback client ID did not match the request")
284
+ response_status = "200 OK"
285
+ response_body = "ChatGPT is connected to Convo. You may close this tab."
286
+ future = self._callback_future
287
+ if future is not None and not future.done():
288
+ future.set_result(AuthorizationResult(codes[0], callback_client_id))
289
+ should_resolve = True
290
+ except (TimeoutError, ValueError):
291
+ response_status = "400 Bad Request"
292
+ finally:
293
+ body = (
294
+ "<!doctype html><html><meta charset='utf-8'><title>Convo</title>"
295
+ f"<body style='font:16px sans-serif;padding:2rem'>{response_body}</body></html>"
296
+ ).encode()
297
+ writer.write(
298
+ f"HTTP/1.1 {response_status}\r\n"
299
+ "Content-Type: text/html; charset=utf-8\r\n"
300
+ f"Content-Length: {len(body)}\r\n"
301
+ "Connection: close\r\n\r\n".encode("ascii")
302
+ + body
303
+ )
304
+ try:
305
+ await writer.drain()
306
+ finally:
307
+ writer.close()
308
+ await writer.wait_closed()
309
+ if should_resolve:
310
+ return
311
+
312
+ async def _post_token(self, data: dict[str, str]) -> dict[str, Any]:
313
+ async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
314
+ response = await client.post(TOKEN_URL, data=data)
315
+ response.raise_for_status()
316
+ payload = response.json()
317
+ if not isinstance(payload, dict):
318
+ raise ValueError("Token endpoint returned an invalid response")
319
+ return payload
320
+
321
+ async def _verify_id_token(
322
+ self, id_token: str, client_id: str, nonce: str | None
323
+ ) -> tuple[dict[str, Any], str]:
324
+ async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
325
+ response = await client.get(OIDC_DISCOVERY_URL)
326
+ response.raise_for_status()
327
+ discovery = response.json()
328
+ if not isinstance(discovery, dict):
329
+ raise ValueError("OpenID discovery response is invalid")
330
+ issuer = discovery.get("issuer")
331
+ jwks_uri = discovery.get("jwks_uri")
332
+ if not isinstance(issuer, str) or not isinstance(jwks_uri, str):
333
+ raise ValueError("OpenID discovery document is missing required fields")
334
+ jwks_url = urlsplit(jwks_uri)
335
+ if jwks_url.scheme != "https" or jwks_url.hostname != "auth.openai.com":
336
+ raise ValueError("OpenID discovery returned an untrusted signing-key URL")
337
+ keys_response = await client.get(jwks_uri)
338
+ keys_response.raise_for_status()
339
+ key_set = keys_response.json()
340
+
341
+ if not isinstance(key_set, dict) or not isinstance(key_set.get("keys"), list):
342
+ raise ValueError("OpenID signing-key response is invalid")
343
+ header = jwt.get_unverified_header(id_token)
344
+ key_id = header.get("kid")
345
+ if header.get("alg") != "RS256" or not isinstance(key_id, str):
346
+ raise ValueError("ID token uses an unsupported signing key")
347
+ jwk = next(
348
+ (
349
+ item
350
+ for item in key_set["keys"]
351
+ if isinstance(item, dict) and item.get("kid") == key_id
352
+ ),
353
+ None,
354
+ )
355
+ if jwk is None:
356
+ raise ValueError("ID token signing key was not found")
357
+ signing_key = jwt.PyJWK.from_dict(jwk, algorithm="RS256").key
358
+ claims = jwt.decode(
359
+ id_token,
360
+ signing_key,
361
+ algorithms=["RS256"],
362
+ audience=client_id,
363
+ issuer=issuer,
364
+ options={"require": ["exp", "iat", "sub", "iss", "aud"]},
365
+ )
366
+ if not isinstance(claims.get("sub"), str) or not claims["sub"]:
367
+ raise ValueError("ID token has no subject")
368
+ if nonce is not None and not hmac.compare_digest(str(claims.get("nonce", "")), nonce):
369
+ raise ValueError("ID token nonce did not match")
370
+ return claims, issuer
371
+
372
+ async def _refresh(self, profile: AccountProfile, tokens: dict[str, str]) -> str:
373
+ refresh_token = tokens.get("refresh_token")
374
+ if not refresh_token:
375
+ raise BackendNotConnected("ChatGPT session expired. Run /login again.")
376
+ try:
377
+ payload = await self._post_token(
378
+ {
379
+ "grant_type": "refresh_token",
380
+ "client_id": profile.client_id,
381
+ "refresh_token": refresh_token,
382
+ "resource": RESOURCE,
383
+ }
384
+ )
385
+ scopes = _parse_scopes(payload.get("scope")) or set(profile.scopes)
386
+ self._require_direct_use(scopes)
387
+ self._validate_token_type(payload)
388
+ expires_in = self._expires_in(payload)
389
+ access_token = self._required_string(payload, "access_token")
390
+ new_refresh_token = payload.get("refresh_token", refresh_token)
391
+ if not isinstance(new_refresh_token, str) or not new_refresh_token:
392
+ raise ValueError("Refresh endpoint returned no refresh token")
393
+ new_id_token = payload.get("id_token", tokens.get("id_token", ""))
394
+ if not isinstance(new_id_token, str) or not new_id_token:
395
+ raise ValueError("Saved ID token is missing")
396
+ if payload.get("id_token"):
397
+ identity, issuer = await self._verify_id_token(
398
+ new_id_token, profile.client_id, nonce=None
399
+ )
400
+ if identity["sub"] != profile.subject:
401
+ raise ValueError("Refreshed ID token belongs to a different account")
402
+ if issuer != profile.issuer:
403
+ raise ValueError("Refreshed ID token issuer changed")
404
+ token_record = {
405
+ "access_token": access_token,
406
+ "refresh_token": new_refresh_token,
407
+ "id_token": new_id_token,
408
+ }
409
+ updated_profile = replace(
410
+ profile,
411
+ scopes=tuple(sorted(scopes)),
412
+ expires_at=time.time() + expires_in,
413
+ )
414
+ self._save_connection(token_record, updated_profile)
415
+ return access_token
416
+ except BackendNotConnected:
417
+ raise
418
+ except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as error:
419
+ raise BackendNotConnected(
420
+ "ChatGPT session expired. Run /login to reconnect your account."
421
+ ) from error
422
+
423
+ def _save_connection(self, tokens: dict[str, str], profile: AccountProfile) -> None:
424
+ try:
425
+ self.credentials.save(TOKEN_KEY, json.dumps(tokens, separators=(",", ":")))
426
+ self.profiles.save(profile)
427
+ except Exception as error: # keyring backends expose platform-specific exceptions
428
+ raise BackendNotConnected(
429
+ "Could not securely save ChatGPT credentials. Check your operating-system keyring."
430
+ ) from error
431
+
432
+ def _load_profile(self) -> AccountProfile | None:
433
+ try:
434
+ return self.profiles.load()
435
+ except (OSError, ValueError) as error:
436
+ raise BackendNotConnected(
437
+ "Saved ChatGPT profile is unreadable. Fix or remove it, then run /login again."
438
+ ) from error
439
+
440
+ def _load_tokens(self, *, required: bool) -> dict[str, str] | None:
441
+ try:
442
+ raw = self.credentials.load(TOKEN_KEY)
443
+ except Exception as error: # keyring backends expose platform-specific exceptions
444
+ raise BackendNotConnected(
445
+ "Could not access the operating-system keyring for ChatGPT credentials."
446
+ ) from error
447
+ if raw is None:
448
+ if required:
449
+ raise BackendNotConnected("Connect your ChatGPT account with /login")
450
+ return None
451
+ try:
452
+ tokens = json.loads(raw)
453
+ except json.JSONDecodeError as error:
454
+ raise BackendNotConnected(
455
+ "Saved ChatGPT credentials are invalid. Run /login again."
456
+ ) from error
457
+ if not isinstance(tokens, dict) or not all(
458
+ isinstance(tokens.get(key), str)
459
+ for key in ("access_token", "refresh_token", "id_token")
460
+ ):
461
+ raise BackendNotConnected("Saved ChatGPT credentials are incomplete. Run /login again.")
462
+ return tokens
463
+
464
+ @staticmethod
465
+ def _required_string(payload: dict[str, Any], name: str) -> str:
466
+ value = payload.get(name)
467
+ if not isinstance(value, str) or not value:
468
+ raise ValueError(f"Token response is missing {name}")
469
+ return value
470
+
471
+ @staticmethod
472
+ def _expires_in(payload: dict[str, Any]) -> int:
473
+ value = payload.get("expires_in")
474
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
475
+ raise ValueError("Token response has an invalid expiry")
476
+ if not math.isfinite(value) or value <= 0:
477
+ raise ValueError("Token response has an invalid expiry")
478
+ return int(value)
479
+
480
+ @staticmethod
481
+ def _validate_token_type(payload: dict[str, Any]) -> None:
482
+ token_type = payload.get("token_type", "Bearer")
483
+ if not isinstance(token_type, str) or token_type.casefold() != "bearer":
484
+ raise ValueError("Token endpoint returned an unsupported token type")
485
+
486
+ @staticmethod
487
+ def _require_direct_use(scopes: set[str]) -> None:
488
+ if DIRECT_USE_SCOPE not in scopes:
489
+ raise BackendNotConnected(
490
+ "This ChatGPT account did not grant direct model access. Run /login and allow it."
491
+ )
492
+
493
+
494
+ def _parse_scopes(value: object) -> set[str]:
495
+ if isinstance(value, str):
496
+ return set(value.split())
497
+ if isinstance(value, list) and all(isinstance(scope, str) for scope in value):
498
+ return set(value)
499
+ return set()
500
+
501
+
502
+ def _optional_claim(claims: dict[str, Any], key: str) -> str | None:
503
+ value = claims.get(key)
504
+ return value if isinstance(value, str) and value else None