readmegen-agent 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.
- readmegen/__init__.py +8 -0
- readmegen/__main__.py +3 -0
- readmegen/auth/__init__.py +0 -0
- readmegen/auth/chatgpt.py +356 -0
- readmegen/auth/credentials.py +60 -0
- readmegen/cli.py +286 -0
- readmegen/concurrency.py +16 -0
- readmegen/config.py +65 -0
- readmegen/errors.py +17 -0
- readmegen/generation/__init__.py +0 -0
- readmegen/generation/agents.py +167 -0
- readmegen/generation/chatgpt_model.py +169 -0
- readmegen/generation/limits.py +61 -0
- readmegen/generation/tools.py +55 -0
- readmegen/providers.py +106 -0
- readmegen/service.py +54 -0
- readmegen/sources.py +264 -0
- readmegen/storage.py +29 -0
- readmegen/web/__init__.py +0 -0
- readmegen/web/app.py +258 -0
- readmegen/web/jobs.py +124 -0
- readmegen/web/server.py +104 -0
- readmegen/web/static/assets/index-4Z1VFxq0.css +1 -0
- readmegen/web/static/assets/index-DVWzzQ9X.js +8 -0
- readmegen/web/static/index.html +14 -0
- readmegen/web/static/readmegen.svg +5 -0
- readmegen_agent-0.2.0.dist-info/METADATA +198 -0
- readmegen_agent-0.2.0.dist-info/RECORD +31 -0
- readmegen_agent-0.2.0.dist-info/WHEEL +4 -0
- readmegen_agent-0.2.0.dist-info/entry_points.txt +2 -0
- readmegen_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
readmegen/__init__.py
ADDED
readmegen/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Official loopback OAuth/PKCE integration for local open-source installations.
|
|
2
|
+
|
|
3
|
+
Reference: https://developers.openai.com/siwc/token-sharing-open-source/sign-in
|
|
4
|
+
Tokens stay in the OS credential store or this process's memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import jwt
|
|
21
|
+
from filelock import FileLock
|
|
22
|
+
|
|
23
|
+
from readmegen.auth.credentials import CredentialStore
|
|
24
|
+
from readmegen.config import config_dir
|
|
25
|
+
from readmegen.errors import ConfigurationError
|
|
26
|
+
from readmegen.storage import write_json
|
|
27
|
+
|
|
28
|
+
ISSUER = "https://auth.openai.com"
|
|
29
|
+
RESOURCE = "https://api.openai.com/v1"
|
|
30
|
+
TOKEN_ENDPOINT = f"{ISSUER}/api/accounts/oauth/token"
|
|
31
|
+
PLAN_SCOPE = "chatgpt.tokens.use.direct"
|
|
32
|
+
LOGIN_TIMEOUT = 300
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class PendingLogin:
|
|
37
|
+
id: str
|
|
38
|
+
state: str
|
|
39
|
+
nonce: str
|
|
40
|
+
verifier: str
|
|
41
|
+
redirect_uri: str
|
|
42
|
+
profile: dict | None
|
|
43
|
+
persist: bool
|
|
44
|
+
server: HTTPServer
|
|
45
|
+
url: str = ""
|
|
46
|
+
result: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
completed: threading.Event = field(default_factory=threading.Event)
|
|
48
|
+
expires_at: float = field(default_factory=lambda: time.monotonic() + LOGIN_TIMEOUT)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ChatGPTAuth:
|
|
52
|
+
def __init__(self, credentials: CredentialStore) -> None:
|
|
53
|
+
self.credentials = credentials
|
|
54
|
+
self._pending: dict[str, PendingLogin] = {}
|
|
55
|
+
self._lock = threading.RLock()
|
|
56
|
+
self._session_profiles: set[str] = set()
|
|
57
|
+
|
|
58
|
+
def _metadata(self) -> dict:
|
|
59
|
+
path = config_dir() / "chatgpt.json"
|
|
60
|
+
try:
|
|
61
|
+
return (
|
|
62
|
+
json.loads(path.read_text())
|
|
63
|
+
if path.exists()
|
|
64
|
+
else {"host_id": f"urn:uuid:{uuid4()}", "profiles": {}}
|
|
65
|
+
)
|
|
66
|
+
except (OSError, ValueError) as error:
|
|
67
|
+
raise ConfigurationError("ChatGPT connection metadata is damaged.") from error
|
|
68
|
+
|
|
69
|
+
def _save_profile(self, profile_id: str, profile: dict) -> None:
|
|
70
|
+
with self._lock, FileLock(str(config_dir() / "chatgpt.lock")):
|
|
71
|
+
metadata = self._metadata()
|
|
72
|
+
metadata["profiles"][profile_id] = profile
|
|
73
|
+
write_json(config_dir() / "chatgpt.json", metadata)
|
|
74
|
+
|
|
75
|
+
def profiles(self) -> list[dict]:
|
|
76
|
+
return [{"id": key, **value} for key, value in self._metadata()["profiles"].items()]
|
|
77
|
+
|
|
78
|
+
def start(self, *, profile_id: str | None = None, persist: bool = True) -> PendingLogin:
|
|
79
|
+
config_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
80
|
+
with self._lock, FileLock(str(config_dir() / "chatgpt.lock")):
|
|
81
|
+
self._expire_pending()
|
|
82
|
+
if self._pending:
|
|
83
|
+
raise ConfigurationError("A ChatGPT sign-in is already in progress.")
|
|
84
|
+
metadata = self._metadata()
|
|
85
|
+
write_json(config_dir() / "chatgpt.json", metadata)
|
|
86
|
+
profile = metadata["profiles"].get(profile_id) if profile_id else None
|
|
87
|
+
if profile_id and not profile:
|
|
88
|
+
raise ConfigurationError("Unknown ChatGPT account. Choose a saved connection.")
|
|
89
|
+
login_id = uuid4().hex
|
|
90
|
+
state, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(3))
|
|
91
|
+
|
|
92
|
+
auth = self
|
|
93
|
+
|
|
94
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
95
|
+
def do_GET(self) -> None:
|
|
96
|
+
pending = auth._pending.get(login_id)
|
|
97
|
+
parsed = urlsplit(self.path)
|
|
98
|
+
query = parse_qs(parsed.query)
|
|
99
|
+
valid = (
|
|
100
|
+
pending is not None
|
|
101
|
+
and parsed.path == "/auth/callback"
|
|
102
|
+
and len(query.get("state", [])) == 1
|
|
103
|
+
and secrets.compare_digest(query["state"][0], state)
|
|
104
|
+
and not pending.completed.is_set()
|
|
105
|
+
)
|
|
106
|
+
if not valid:
|
|
107
|
+
self.send_error(400, "Invalid sign-in callback")
|
|
108
|
+
return
|
|
109
|
+
pending.result = {
|
|
110
|
+
key: values[0] for key, values in query.items() if len(values) == 1
|
|
111
|
+
}
|
|
112
|
+
pending.completed.set()
|
|
113
|
+
self.send_response(200)
|
|
114
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
115
|
+
self.send_header("Cache-Control", "no-store")
|
|
116
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
117
|
+
self.end_headers()
|
|
118
|
+
self.wfile.write(b"Return to ReadMeGen to finish connecting your account.")
|
|
119
|
+
|
|
120
|
+
def log_message(self, *_args: Any) -> None:
|
|
121
|
+
# Callback query strings contain authorization codes.
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
server = HTTPServer(("127.0.0.1", 0), CallbackHandler)
|
|
125
|
+
server.timeout = 0.5
|
|
126
|
+
server.socket.settimeout(2)
|
|
127
|
+
redirect_uri = f"http://127.0.0.1:{server.server_port}/auth/callback"
|
|
128
|
+
pending = PendingLogin(
|
|
129
|
+
login_id, state, nonce, verifier, redirect_uri, profile, persist, server
|
|
130
|
+
)
|
|
131
|
+
parameters = {
|
|
132
|
+
"client_id": profile["client_id"] if profile else "dynamic_agent_client",
|
|
133
|
+
"ext_agent_host_id": metadata["host_id"],
|
|
134
|
+
"response_type": "code",
|
|
135
|
+
"redirect_uri": redirect_uri,
|
|
136
|
+
"scope": f"openid profile email offline_access resource.invoke {PLAN_SCOPE}",
|
|
137
|
+
"resource": RESOURCE,
|
|
138
|
+
"state": state,
|
|
139
|
+
"nonce": nonce,
|
|
140
|
+
"code_challenge_method": "S256",
|
|
141
|
+
"code_challenge": base64.urlsafe_b64encode(
|
|
142
|
+
hashlib.sha256(verifier.encode()).digest()
|
|
143
|
+
)
|
|
144
|
+
.decode()
|
|
145
|
+
.rstrip("="),
|
|
146
|
+
}
|
|
147
|
+
if not profile:
|
|
148
|
+
parameters["agent_name_hint"] = "ReadMeGen"
|
|
149
|
+
pending.url = f"{ISSUER}/api/accounts/authorize?{urlencode(parameters)}"
|
|
150
|
+
self._pending[login_id] = pending
|
|
151
|
+
threading.Thread(target=self._listen, args=(pending,), daemon=True).start()
|
|
152
|
+
return pending
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _listen(pending: PendingLogin) -> None:
|
|
156
|
+
try:
|
|
157
|
+
while not pending.completed.is_set() and time.monotonic() < pending.expires_at:
|
|
158
|
+
pending.server.handle_request()
|
|
159
|
+
except OSError:
|
|
160
|
+
pass
|
|
161
|
+
finally:
|
|
162
|
+
pending.server.server_close()
|
|
163
|
+
|
|
164
|
+
def _expire_pending(self) -> None:
|
|
165
|
+
for login_id, pending in list(self._pending.items()):
|
|
166
|
+
if time.monotonic() >= pending.expires_at:
|
|
167
|
+
pending.completed.set()
|
|
168
|
+
self._pending.pop(login_id)
|
|
169
|
+
|
|
170
|
+
def cancel(self, login_id: str) -> None:
|
|
171
|
+
with self._lock:
|
|
172
|
+
if pending := self._pending.pop(login_id, None):
|
|
173
|
+
pending.completed.set()
|
|
174
|
+
|
|
175
|
+
def close(self) -> None:
|
|
176
|
+
for login_id in list(self._pending):
|
|
177
|
+
self.cancel(login_id)
|
|
178
|
+
|
|
179
|
+
def finish(self, login_id: str) -> str | None:
|
|
180
|
+
with self._lock:
|
|
181
|
+
pending = self._pending.get(login_id)
|
|
182
|
+
if not pending or time.monotonic() >= pending.expires_at:
|
|
183
|
+
self.cancel(login_id)
|
|
184
|
+
raise ConfigurationError("ChatGPT sign-in expired. Start sign-in again.")
|
|
185
|
+
if not pending.completed.is_set():
|
|
186
|
+
return None
|
|
187
|
+
self._pending.pop(login_id)
|
|
188
|
+
result = pending.result
|
|
189
|
+
if "error" in result or not result.get("code"):
|
|
190
|
+
raise ConfigurationError("ChatGPT sign-in was cancelled or denied.")
|
|
191
|
+
client_id = result.get("client_id") or (pending.profile or {}).get("client_id")
|
|
192
|
+
if not client_id or client_id == "dynamic_agent_client":
|
|
193
|
+
raise ConfigurationError("ChatGPT registration did not return a client ID.")
|
|
194
|
+
if pending.profile and client_id != pending.profile["client_id"]:
|
|
195
|
+
raise ConfigurationError("ChatGPT returned a different account registration.")
|
|
196
|
+
tokens = self._token_request(
|
|
197
|
+
{
|
|
198
|
+
"grant_type": "authorization_code",
|
|
199
|
+
"client_id": client_id,
|
|
200
|
+
"code": result["code"],
|
|
201
|
+
"code_verifier": pending.verifier,
|
|
202
|
+
"redirect_uri": pending.redirect_uri,
|
|
203
|
+
"resource": RESOURCE,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
identity = self._verify_identity(tokens.get("id_token", ""), client_id, pending.nonce)
|
|
207
|
+
if pending.profile and (
|
|
208
|
+
identity["sub"] != pending.profile["subject"]
|
|
209
|
+
or identity["iss"] != pending.profile["issuer"]
|
|
210
|
+
):
|
|
211
|
+
raise ConfigurationError("The signed-in identity does not match the saved connection.")
|
|
212
|
+
if PLAN_SCOPE not in tokens.get("scope", "").split():
|
|
213
|
+
raise ConfigurationError(
|
|
214
|
+
"Sign-in succeeded, but permission to use the ChatGPT plan was not granted."
|
|
215
|
+
)
|
|
216
|
+
profile_id = next(
|
|
217
|
+
(profile["id"] for profile in self.profiles() if profile["client_id"] == client_id),
|
|
218
|
+
uuid4().hex,
|
|
219
|
+
)
|
|
220
|
+
record = {
|
|
221
|
+
**tokens,
|
|
222
|
+
"client_id": client_id,
|
|
223
|
+
"expires_at": time.time() + int(tokens.get("expires_in", 3600)),
|
|
224
|
+
}
|
|
225
|
+
self.credentials.set(f"chatgpt:{profile_id}", json.dumps(record), persist=pending.persist)
|
|
226
|
+
if not pending.persist:
|
|
227
|
+
self._session_profiles.add(profile_id)
|
|
228
|
+
self._save_profile(
|
|
229
|
+
profile_id,
|
|
230
|
+
{
|
|
231
|
+
"client_id": client_id,
|
|
232
|
+
"subject": identity["sub"],
|
|
233
|
+
"issuer": identity["iss"],
|
|
234
|
+
"label": f"{identity.get('email', 'ChatGPT account')} · {client_id[-8:]}",
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
return profile_id
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _token_request(data: dict) -> dict:
|
|
241
|
+
try:
|
|
242
|
+
response = httpx.post(TOKEN_ENDPOINT, data=data, timeout=20)
|
|
243
|
+
response.raise_for_status()
|
|
244
|
+
tokens = response.json()
|
|
245
|
+
if not tokens.get("access_token") or tokens.get("token_type", "").lower() != "bearer":
|
|
246
|
+
raise ValueError("Invalid token response")
|
|
247
|
+
return tokens
|
|
248
|
+
except (httpx.HTTPError, ValueError, TypeError) as error:
|
|
249
|
+
raise ConfigurationError(
|
|
250
|
+
"ChatGPT authentication failed. Sign in again or check your connection."
|
|
251
|
+
) from error
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _verify_identity(token: str, client_id: str, nonce: str) -> dict:
|
|
255
|
+
try:
|
|
256
|
+
discovery = httpx.get(f"{ISSUER}/.well-known/openid-configuration", timeout=15)
|
|
257
|
+
discovery.raise_for_status()
|
|
258
|
+
uri = discovery.json()["jwks_uri"]
|
|
259
|
+
if urlsplit(uri).scheme != "https" or urlsplit(uri).hostname != "auth.openai.com":
|
|
260
|
+
raise ValueError("Unexpected JWKS host")
|
|
261
|
+
response = httpx.get(uri, timeout=15)
|
|
262
|
+
response.raise_for_status()
|
|
263
|
+
header = jwt.get_unverified_header(token)
|
|
264
|
+
key = next(item for item in response.json()["keys"] if item["kid"] == header["kid"])
|
|
265
|
+
identity = jwt.decode(
|
|
266
|
+
token,
|
|
267
|
+
jwt.PyJWK.from_dict(key).key,
|
|
268
|
+
algorithms=["RS256"],
|
|
269
|
+
issuer=ISSUER,
|
|
270
|
+
audience=client_id,
|
|
271
|
+
leeway=30,
|
|
272
|
+
options={"require": ["exp", "iss", "aud", "sub", "nonce"]},
|
|
273
|
+
)
|
|
274
|
+
if not secrets.compare_digest(identity["nonce"], nonce):
|
|
275
|
+
raise ValueError("Invalid nonce")
|
|
276
|
+
return identity
|
|
277
|
+
except (
|
|
278
|
+
httpx.HTTPError,
|
|
279
|
+
jwt.PyJWTError,
|
|
280
|
+
ValueError,
|
|
281
|
+
KeyError,
|
|
282
|
+
TypeError,
|
|
283
|
+
StopIteration,
|
|
284
|
+
) as error:
|
|
285
|
+
raise ConfigurationError(
|
|
286
|
+
"Could not verify the ChatGPT account identity. Start sign-in again."
|
|
287
|
+
) from error
|
|
288
|
+
|
|
289
|
+
def access_token(self, profile_id: str | None) -> str:
|
|
290
|
+
if not profile_id:
|
|
291
|
+
raise ConfigurationError("Connect a ChatGPT account with 'readmegen setup'.")
|
|
292
|
+
directory = config_dir()
|
|
293
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
294
|
+
with self._lock, FileLock(str(directory / "chatgpt-refresh.lock"), timeout=30):
|
|
295
|
+
value = self.credentials.get(f"chatgpt:{profile_id}")
|
|
296
|
+
if not value:
|
|
297
|
+
raise ConfigurationError("This ChatGPT session is disconnected. Sign in again.")
|
|
298
|
+
try:
|
|
299
|
+
record = json.loads(value)
|
|
300
|
+
if record["expires_at"] <= time.time() + 60:
|
|
301
|
+
if not record.get("refresh_token"):
|
|
302
|
+
raise ConfigurationError("This ChatGPT session expired. Sign in again.")
|
|
303
|
+
updated = self._token_request(
|
|
304
|
+
{
|
|
305
|
+
"grant_type": "refresh_token",
|
|
306
|
+
"client_id": record["client_id"],
|
|
307
|
+
"refresh_token": record["refresh_token"],
|
|
308
|
+
"resource": RESOURCE,
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
record.update(updated)
|
|
312
|
+
record["expires_at"] = time.time() + int(updated.get("expires_in", 3600))
|
|
313
|
+
self.credentials.set(
|
|
314
|
+
f"chatgpt:{profile_id}",
|
|
315
|
+
json.dumps(record),
|
|
316
|
+
persist=profile_id not in self._session_profiles,
|
|
317
|
+
)
|
|
318
|
+
if PLAN_SCOPE not in record.get("scope", "").split():
|
|
319
|
+
raise ConfigurationError("Enable ChatGPT plan usage by signing in again.")
|
|
320
|
+
return record["access_token"]
|
|
321
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
322
|
+
raise ConfigurationError(
|
|
323
|
+
"The ChatGPT session is invalid. Sign in again."
|
|
324
|
+
) from error
|
|
325
|
+
|
|
326
|
+
def logout(self, profile_id: str) -> bool:
|
|
327
|
+
"""Always clear locally; report whether remote revocation was confirmed."""
|
|
328
|
+
confirmed = True
|
|
329
|
+
value = self.credentials.get(f"chatgpt:{profile_id}")
|
|
330
|
+
try:
|
|
331
|
+
if value:
|
|
332
|
+
record = json.loads(value)
|
|
333
|
+
if record.get("refresh_token"):
|
|
334
|
+
discovery = httpx.get(f"{ISSUER}/.well-known/openid-configuration", timeout=15)
|
|
335
|
+
discovery.raise_for_status()
|
|
336
|
+
endpoint = discovery.json()["revocation_endpoint"]
|
|
337
|
+
if (
|
|
338
|
+
urlsplit(endpoint).scheme != "https"
|
|
339
|
+
or urlsplit(endpoint).hostname != "auth.openai.com"
|
|
340
|
+
):
|
|
341
|
+
raise ValueError("Unexpected revocation host")
|
|
342
|
+
response = httpx.post(
|
|
343
|
+
endpoint,
|
|
344
|
+
data={
|
|
345
|
+
"token": record["refresh_token"],
|
|
346
|
+
"token_type_hint": "refresh_token",
|
|
347
|
+
"client_id": record["client_id"],
|
|
348
|
+
},
|
|
349
|
+
timeout=15,
|
|
350
|
+
)
|
|
351
|
+
confirmed = response.status_code == 200
|
|
352
|
+
except (httpx.HTTPError, ValueError, KeyError):
|
|
353
|
+
confirmed = False
|
|
354
|
+
finally:
|
|
355
|
+
self.credentials.delete(f"chatgpt:{profile_id}")
|
|
356
|
+
return confirmed
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""OS credential storage with explicit, memory-only sessions on headless systems."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
from keyring.errors import KeyringError
|
|
8
|
+
|
|
9
|
+
from readmegen.config import Provider
|
|
10
|
+
from readmegen.errors import ConfigurationError
|
|
11
|
+
|
|
12
|
+
ENVIRONMENT_KEYS: dict[str, tuple[str, ...]] = {
|
|
13
|
+
"gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
|
14
|
+
"openai": ("OPENAI_API_KEY",),
|
|
15
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CredentialStore:
|
|
20
|
+
"""Never silently fall back to plaintext storage."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._session: dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
def get(self, name: str) -> str | None:
|
|
26
|
+
if name in self._session:
|
|
27
|
+
return self._session[name]
|
|
28
|
+
try:
|
|
29
|
+
return keyring.get_password("readmegen", name)
|
|
30
|
+
except (KeyringError, RuntimeError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def set(self, name: str, secret: str, *, persist: bool = True) -> None:
|
|
34
|
+
if persist:
|
|
35
|
+
try:
|
|
36
|
+
keyring.set_password("readmegen", name, secret)
|
|
37
|
+
except (KeyringError, RuntimeError) as error:
|
|
38
|
+
raise ConfigurationError(
|
|
39
|
+
"The OS credential store is unavailable. Use a session-only connection "
|
|
40
|
+
"or set the provider's API-key environment variable."
|
|
41
|
+
) from error
|
|
42
|
+
self._session.pop(name, None)
|
|
43
|
+
else:
|
|
44
|
+
self._session[name] = secret
|
|
45
|
+
|
|
46
|
+
def delete(self, name: str) -> None:
|
|
47
|
+
self._session.pop(name, None)
|
|
48
|
+
with contextlib.suppress(KeyringError, RuntimeError):
|
|
49
|
+
keyring.delete_password("readmegen", name)
|
|
50
|
+
|
|
51
|
+
def api_key(self, provider: Provider) -> str:
|
|
52
|
+
for name in ENVIRONMENT_KEYS[provider]:
|
|
53
|
+
if value := os.environ.get(name):
|
|
54
|
+
return value
|
|
55
|
+
value = self.get(f"api-key:{provider}")
|
|
56
|
+
if not value:
|
|
57
|
+
raise ConfigurationError(
|
|
58
|
+
f"No {provider} API key. Run 'readmegen setup' or set {ENVIRONMENT_KEYS[provider][0]}."
|
|
59
|
+
)
|
|
60
|
+
return value
|