stravinsky 0.1.12__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.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +5 -0
- mcp_bridge/auth/__init__.py +32 -0
- mcp_bridge/auth/cli.py +208 -0
- mcp_bridge/auth/oauth.py +418 -0
- mcp_bridge/auth/openai_oauth.py +350 -0
- mcp_bridge/auth/token_store.py +195 -0
- mcp_bridge/config/__init__.py +14 -0
- mcp_bridge/config/hooks.py +174 -0
- mcp_bridge/prompts/__init__.py +18 -0
- mcp_bridge/prompts/delphi.py +110 -0
- mcp_bridge/prompts/dewey.py +183 -0
- mcp_bridge/prompts/document_writer.py +155 -0
- mcp_bridge/prompts/explore.py +118 -0
- mcp_bridge/prompts/frontend.py +112 -0
- mcp_bridge/prompts/multimodal.py +58 -0
- mcp_bridge/prompts/stravinsky.py +329 -0
- mcp_bridge/server.py +866 -0
- mcp_bridge/tools/__init__.py +31 -0
- mcp_bridge/tools/agent_manager.py +665 -0
- mcp_bridge/tools/background_tasks.py +166 -0
- mcp_bridge/tools/code_search.py +301 -0
- mcp_bridge/tools/continuous_loop.py +67 -0
- mcp_bridge/tools/lsp/__init__.py +29 -0
- mcp_bridge/tools/lsp/tools.py +526 -0
- mcp_bridge/tools/model_invoke.py +233 -0
- mcp_bridge/tools/project_context.py +141 -0
- mcp_bridge/tools/session_manager.py +302 -0
- mcp_bridge/tools/skill_loader.py +212 -0
- mcp_bridge/tools/task_runner.py +97 -0
- mcp_bridge/utils/__init__.py +1 -0
- stravinsky-0.1.12.dist-info/METADATA +198 -0
- stravinsky-0.1.12.dist-info/RECORD +34 -0
- stravinsky-0.1.12.dist-info/WHEEL +4 -0
- stravinsky-0.1.12.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI Codex OAuth 2.0 Implementation
|
|
3
|
+
|
|
4
|
+
Implements OAuth authentication for ChatGPT Plus/Pro subscriptions.
|
|
5
|
+
Uses the exact same OAuth flow as opencode-openai-codex-auth.
|
|
6
|
+
|
|
7
|
+
Port from: https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/lib/auth/auth.ts
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
import threading
|
|
15
|
+
import webbrowser
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# OAuth constants (from openai/codex via opencode-openai-codex-auth)
|
|
26
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
27
|
+
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" # Note: /oauth/authorize
|
|
28
|
+
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
|
29
|
+
REDIRECT_URI = "http://localhost:1455/auth/callback"
|
|
30
|
+
SCOPE = "openid profile email offline_access"
|
|
31
|
+
|
|
32
|
+
# Callback configuration
|
|
33
|
+
OPENAI_CALLBACK_PORT = 1455
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PKCEPair:
|
|
38
|
+
"""PKCE verifier and challenge pair."""
|
|
39
|
+
verifier: str
|
|
40
|
+
challenge: str
|
|
41
|
+
method: str = "S256"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TokenResult:
|
|
46
|
+
"""OAuth token exchange result."""
|
|
47
|
+
access_token: str
|
|
48
|
+
refresh_token: str
|
|
49
|
+
expires_in: int
|
|
50
|
+
token_type: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def generate_pkce_pair() -> PKCEPair:
|
|
54
|
+
"""Generate PKCE verifier and challenge pair using S256."""
|
|
55
|
+
# Generate verifier (43+ chars recommended)
|
|
56
|
+
verifier = secrets.token_urlsafe(32)
|
|
57
|
+
|
|
58
|
+
# SHA-256 hash and base64url encode
|
|
59
|
+
challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
60
|
+
challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
|
|
61
|
+
|
|
62
|
+
return PKCEPair(verifier=verifier, challenge=challenge, method="S256")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_state() -> str:
|
|
66
|
+
"""Generate a random state value for OAuth flow."""
|
|
67
|
+
return secrets.token_hex(16)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_auth_url(redirect_uri: str = REDIRECT_URI) -> tuple[str, str, str]:
|
|
71
|
+
"""
|
|
72
|
+
Build OpenAI OAuth authorization URL with PKCE.
|
|
73
|
+
|
|
74
|
+
Exact port from opencode-openai-codex-auth createAuthorizationFlow()
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Tuple of (auth_url, pkce_verifier, state)
|
|
78
|
+
"""
|
|
79
|
+
pkce = generate_pkce_pair()
|
|
80
|
+
state = create_state()
|
|
81
|
+
|
|
82
|
+
# Build URL exactly as opencode-openai-codex-auth does
|
|
83
|
+
params = {
|
|
84
|
+
"response_type": "code",
|
|
85
|
+
"client_id": CLIENT_ID,
|
|
86
|
+
"redirect_uri": redirect_uri,
|
|
87
|
+
"scope": SCOPE,
|
|
88
|
+
"code_challenge": pkce.challenge,
|
|
89
|
+
"code_challenge_method": "S256",
|
|
90
|
+
"state": state,
|
|
91
|
+
"id_token_add_organizations": "true",
|
|
92
|
+
"codex_cli_simplified_flow": "true",
|
|
93
|
+
"originator": "codex_cli_rs",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
url = f"{AUTHORIZE_URL}?{urlencode(params)}"
|
|
97
|
+
return url, pkce.verifier, state
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def exchange_code(
|
|
101
|
+
code: str,
|
|
102
|
+
verifier: str,
|
|
103
|
+
redirect_uri: str = REDIRECT_URI,
|
|
104
|
+
) -> TokenResult:
|
|
105
|
+
"""
|
|
106
|
+
Exchange authorization code for tokens.
|
|
107
|
+
|
|
108
|
+
Exact port from opencode-openai-codex-auth exchangeAuthorizationCode()
|
|
109
|
+
"""
|
|
110
|
+
data = {
|
|
111
|
+
"grant_type": "authorization_code",
|
|
112
|
+
"client_id": CLIENT_ID,
|
|
113
|
+
"code": code,
|
|
114
|
+
"code_verifier": verifier,
|
|
115
|
+
"redirect_uri": redirect_uri,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
with httpx.Client() as client:
|
|
119
|
+
response = client.post(
|
|
120
|
+
TOKEN_URL,
|
|
121
|
+
data=data,
|
|
122
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if response.status_code != 200:
|
|
126
|
+
text = response.text
|
|
127
|
+
print(f"[openai-oauth] code->token failed: {response.status_code} {text}")
|
|
128
|
+
raise Exception(f"Token exchange failed: {response.status_code} - {text}")
|
|
129
|
+
|
|
130
|
+
result = response.json()
|
|
131
|
+
|
|
132
|
+
access_token = result.get("access_token")
|
|
133
|
+
refresh_token = result.get("refresh_token")
|
|
134
|
+
expires_in = result.get("expires_in")
|
|
135
|
+
|
|
136
|
+
if not access_token or not refresh_token or not isinstance(expires_in, int):
|
|
137
|
+
print(f"[openai-oauth] token response missing fields: {result}")
|
|
138
|
+
raise Exception("Token response missing required fields")
|
|
139
|
+
|
|
140
|
+
return TokenResult(
|
|
141
|
+
access_token=access_token,
|
|
142
|
+
refresh_token=refresh_token,
|
|
143
|
+
expires_in=expires_in,
|
|
144
|
+
token_type=result.get("token_type", "Bearer"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def refresh_access_token(refresh_token: str) -> TokenResult:
|
|
149
|
+
"""
|
|
150
|
+
Refresh access token using refresh token.
|
|
151
|
+
|
|
152
|
+
Exact port from opencode-openai-codex-auth refreshAccessToken()
|
|
153
|
+
"""
|
|
154
|
+
data = {
|
|
155
|
+
"grant_type": "refresh_token",
|
|
156
|
+
"refresh_token": refresh_token,
|
|
157
|
+
"client_id": CLIENT_ID,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
with httpx.Client() as client:
|
|
161
|
+
response = client.post(
|
|
162
|
+
TOKEN_URL,
|
|
163
|
+
data=data,
|
|
164
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if response.status_code != 200:
|
|
168
|
+
text = response.text
|
|
169
|
+
print(f"[openai-oauth] Token refresh failed: {response.status_code} {text}")
|
|
170
|
+
raise Exception(f"Token refresh failed: {response.status_code} - {text}")
|
|
171
|
+
|
|
172
|
+
result = response.json()
|
|
173
|
+
|
|
174
|
+
access_token = result.get("access_token")
|
|
175
|
+
refresh_token_new = result.get("refresh_token")
|
|
176
|
+
expires_in = result.get("expires_in")
|
|
177
|
+
|
|
178
|
+
if not access_token or not refresh_token_new or not isinstance(expires_in, int):
|
|
179
|
+
print(f"[openai-oauth] Token refresh response missing fields: {result}")
|
|
180
|
+
raise Exception("Token refresh response missing required fields")
|
|
181
|
+
|
|
182
|
+
return TokenResult(
|
|
183
|
+
access_token=access_token,
|
|
184
|
+
refresh_token=refresh_token_new,
|
|
185
|
+
expires_in=expires_in,
|
|
186
|
+
token_type=result.get("token_type", "Bearer"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ============= Browser Flow =============
|
|
191
|
+
|
|
192
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
193
|
+
"""HTTP request handler for OAuth callback."""
|
|
194
|
+
|
|
195
|
+
callback_result: dict[str, Any] = {}
|
|
196
|
+
server_ready = threading.Event()
|
|
197
|
+
|
|
198
|
+
def log_message(self, format, *args):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def do_GET(self):
|
|
202
|
+
parsed = urlparse(self.path)
|
|
203
|
+
|
|
204
|
+
# Handle /auth/callback path (matching REDIRECT_URI)
|
|
205
|
+
if parsed.path == "/auth/callback":
|
|
206
|
+
params = parse_qs(parsed.query)
|
|
207
|
+
|
|
208
|
+
OAuthCallbackHandler.callback_result = {
|
|
209
|
+
"code": params.get("code", [""])[0],
|
|
210
|
+
"state": params.get("state", [""])[0],
|
|
211
|
+
"error": params.get("error", [None])[0],
|
|
212
|
+
"error_description": params.get("error_description", [""])[0],
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if OAuthCallbackHandler.callback_result["code"]:
|
|
216
|
+
# Success page - styled like opencode-openai-codex-auth
|
|
217
|
+
body = b"""<!DOCTYPE html>
|
|
218
|
+
<html>
|
|
219
|
+
<head>
|
|
220
|
+
<title>Login Successful</title>
|
|
221
|
+
<style>
|
|
222
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
223
|
+
display: flex; justify-content: center; align-items: center; min-height: 100vh;
|
|
224
|
+
margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
|
225
|
+
.card { background: white; padding: 3rem; border-radius: 16px; text-align: center;
|
|
226
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3); max-width: 400px; }
|
|
227
|
+
h1 { color: #10a37f; margin-bottom: 1rem; }
|
|
228
|
+
p { color: #666; }
|
|
229
|
+
</style>
|
|
230
|
+
</head>
|
|
231
|
+
<body>
|
|
232
|
+
<div class="card">
|
|
233
|
+
<h1>✓ Login Successful!</h1>
|
|
234
|
+
<p>You can close this window and return to the terminal.</p>
|
|
235
|
+
</div>
|
|
236
|
+
</body>
|
|
237
|
+
</html>"""
|
|
238
|
+
else:
|
|
239
|
+
error = OAuthCallbackHandler.callback_result.get("error", "unknown")
|
|
240
|
+
error_desc = OAuthCallbackHandler.callback_result.get("error_description", "")
|
|
241
|
+
body = f"""<!DOCTYPE html>
|
|
242
|
+
<html>
|
|
243
|
+
<head><title>Login Failed</title>
|
|
244
|
+
<style>
|
|
245
|
+
body {{ font-family: -apple-system, sans-serif; display: flex; justify-content: center;
|
|
246
|
+
align-items: center; min-height: 100vh; margin: 0; background: #f5f5f5; }}
|
|
247
|
+
.card {{ background: white; padding: 3rem; border-radius: 16px; text-align: center;
|
|
248
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.1); max-width: 400px; }}
|
|
249
|
+
h1 {{ color: #ef4444; }}
|
|
250
|
+
</style>
|
|
251
|
+
</head>
|
|
252
|
+
<body>
|
|
253
|
+
<div class="card">
|
|
254
|
+
<h1>Login Failed</h1>
|
|
255
|
+
<p><strong>Error:</strong> {error}</p>
|
|
256
|
+
<p>{error_desc}</p>
|
|
257
|
+
<p>Please check the terminal for more details.</p>
|
|
258
|
+
</div>
|
|
259
|
+
</body>
|
|
260
|
+
</html>""".encode()
|
|
261
|
+
|
|
262
|
+
self.send_response(200)
|
|
263
|
+
self.send_header("Content-Type", "text/html")
|
|
264
|
+
self.send_header("Content-Length", str(len(body)))
|
|
265
|
+
self.end_headers()
|
|
266
|
+
self.wfile.write(body)
|
|
267
|
+
|
|
268
|
+
OAuthCallbackHandler.server_ready.set()
|
|
269
|
+
else:
|
|
270
|
+
self.send_response(404)
|
|
271
|
+
self.end_headers()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def perform_oauth_flow(timeout: int = 300) -> TokenResult:
|
|
275
|
+
"""
|
|
276
|
+
Perform OpenAI OAuth browser flow.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
timeout: Timeout in seconds
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Token result with access and refresh tokens.
|
|
283
|
+
"""
|
|
284
|
+
OAuthCallbackHandler.callback_result = {}
|
|
285
|
+
OAuthCallbackHandler.server_ready.clear()
|
|
286
|
+
|
|
287
|
+
# Try to use the required port (1455)
|
|
288
|
+
try:
|
|
289
|
+
server = HTTPServer(("localhost", OPENAI_CALLBACK_PORT), OAuthCallbackHandler)
|
|
290
|
+
port = OPENAI_CALLBACK_PORT
|
|
291
|
+
redirect_uri = REDIRECT_URI
|
|
292
|
+
except OSError as e:
|
|
293
|
+
print(f"\n⚠️ Cannot bind to port {OPENAI_CALLBACK_PORT}: {e}")
|
|
294
|
+
print(" This port is required for OpenAI OAuth.")
|
|
295
|
+
print(" Please stop any process using this port (e.g., Codex CLI)")
|
|
296
|
+
print(" Or use the Codex CLI directly: codex login")
|
|
297
|
+
raise Exception(f"Port {OPENAI_CALLBACK_PORT} is required but unavailable")
|
|
298
|
+
|
|
299
|
+
server_thread = threading.Thread(target=lambda: server.handle_request())
|
|
300
|
+
server_thread.daemon = True
|
|
301
|
+
server_thread.start()
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
auth_url, verifier, state = build_auth_url(redirect_uri)
|
|
305
|
+
|
|
306
|
+
print(f"\n🔐 Opening browser for OpenAI authentication...")
|
|
307
|
+
print(f"\nIf browser doesn't open, visit:")
|
|
308
|
+
print(f"{auth_url}\n")
|
|
309
|
+
|
|
310
|
+
webbrowser.open(auth_url)
|
|
311
|
+
|
|
312
|
+
if not OAuthCallbackHandler.server_ready.wait(timeout):
|
|
313
|
+
raise Exception("OAuth callback timeout - no response received")
|
|
314
|
+
|
|
315
|
+
result = OAuthCallbackHandler.callback_result
|
|
316
|
+
|
|
317
|
+
if result.get("error"):
|
|
318
|
+
error_desc = result.get("error_description", "")
|
|
319
|
+
raise Exception(f"OAuth error: {result['error']} - {error_desc}")
|
|
320
|
+
|
|
321
|
+
if not result.get("code"):
|
|
322
|
+
raise Exception("No authorization code received")
|
|
323
|
+
|
|
324
|
+
# Verify state matches
|
|
325
|
+
if result.get("state") != state:
|
|
326
|
+
print(f"[openai-oauth] State mismatch: expected {state}, got {result.get('state')}")
|
|
327
|
+
# Continue anyway - some OAuth servers don't return state
|
|
328
|
+
|
|
329
|
+
print("📝 Exchanging code for tokens...")
|
|
330
|
+
tokens = exchange_code(result["code"], verifier, redirect_uri)
|
|
331
|
+
|
|
332
|
+
print(f"✓ Successfully authenticated with OpenAI")
|
|
333
|
+
|
|
334
|
+
return tokens
|
|
335
|
+
|
|
336
|
+
finally:
|
|
337
|
+
server.server_close()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
try:
|
|
342
|
+
result = perform_oauth_flow()
|
|
343
|
+
print(f"\nAccess Token: {result.access_token[:30]}...")
|
|
344
|
+
if result.refresh_token:
|
|
345
|
+
print(f"Refresh Token: {result.refresh_token[:20]}...")
|
|
346
|
+
print(f"Expires In: {result.expires_in}s")
|
|
347
|
+
except Exception as e:
|
|
348
|
+
print(f"\n✗ OAuth failed: {e}")
|
|
349
|
+
import sys
|
|
350
|
+
sys.exit(1)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secure token storage using system keyring.
|
|
3
|
+
|
|
4
|
+
Stores OAuth tokens securely using the OS keyring:
|
|
5
|
+
- macOS: Keychain
|
|
6
|
+
- Linux: Secret Service (GNOME Keyring, KWallet)
|
|
7
|
+
- Windows: Windows Credential Locker
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
import keyring
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenData(TypedDict, total=False):
|
|
18
|
+
"""OAuth token data structure."""
|
|
19
|
+
|
|
20
|
+
access_token: str
|
|
21
|
+
refresh_token: str
|
|
22
|
+
expires_at: float # Unix timestamp
|
|
23
|
+
token_type: str
|
|
24
|
+
scope: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenStore:
|
|
28
|
+
"""
|
|
29
|
+
Secure storage for OAuth tokens using system keyring.
|
|
30
|
+
|
|
31
|
+
Each provider (gemini, openai) stores its tokens separately.
|
|
32
|
+
Tokens are serialized as JSON for storage.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
SERVICE_NAME = "stravinsky"
|
|
36
|
+
|
|
37
|
+
def __init__(self, service_name: str | None = None):
|
|
38
|
+
"""Initialize the token store.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
service_name: Override the default service name for testing.
|
|
42
|
+
"""
|
|
43
|
+
self.service_name = service_name or self.SERVICE_NAME
|
|
44
|
+
|
|
45
|
+
def _key(self, provider: str) -> str:
|
|
46
|
+
"""Generate the keyring key for a provider."""
|
|
47
|
+
return f"{self.service_name}-{provider}"
|
|
48
|
+
|
|
49
|
+
def get_token(self, provider: str) -> TokenData | None:
|
|
50
|
+
"""
|
|
51
|
+
Retrieve stored token for a provider.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
provider: The provider name (e.g., 'gemini', 'openai')
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
TokenData if found and valid, None otherwise.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
data = keyring.get_password(self.service_name, self._key(provider))
|
|
61
|
+
if not data:
|
|
62
|
+
return None
|
|
63
|
+
return json.loads(data)
|
|
64
|
+
except (json.JSONDecodeError, keyring.errors.KeyringError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def set_token(
|
|
68
|
+
self,
|
|
69
|
+
provider: str,
|
|
70
|
+
token: TokenData | None = None,
|
|
71
|
+
*,
|
|
72
|
+
access_token: str | None = None,
|
|
73
|
+
refresh_token: str | None = None,
|
|
74
|
+
expires_at: float | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Store a token for a provider.
|
|
78
|
+
|
|
79
|
+
Can be called with a TokenData dict or individual parameters.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
provider: The provider name (e.g., 'gemini', 'openai')
|
|
83
|
+
token: The token data dict to store (optional)
|
|
84
|
+
access_token: Access token string (optional)
|
|
85
|
+
refresh_token: Refresh token string (optional)
|
|
86
|
+
expires_at: Expiry timestamp (optional)
|
|
87
|
+
"""
|
|
88
|
+
if token is not None:
|
|
89
|
+
data = json.dumps(token)
|
|
90
|
+
else:
|
|
91
|
+
token_data: TokenData = {}
|
|
92
|
+
if access_token:
|
|
93
|
+
token_data["access_token"] = access_token
|
|
94
|
+
if refresh_token:
|
|
95
|
+
token_data["refresh_token"] = refresh_token
|
|
96
|
+
if expires_at:
|
|
97
|
+
token_data["expires_at"] = expires_at
|
|
98
|
+
data = json.dumps(token_data)
|
|
99
|
+
keyring.set_password(self.service_name, self._key(provider), data)
|
|
100
|
+
|
|
101
|
+
def delete_token(self, provider: str) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Delete stored token for a provider.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
provider: The provider name (e.g., 'gemini', 'openai')
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if deleted, False if not found.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
keyring.delete_password(self.service_name, self._key(provider))
|
|
113
|
+
return True
|
|
114
|
+
except keyring.errors.PasswordDeleteError:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def has_valid_token(self, provider: str) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Check if a valid (non-expired) token exists for a provider.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
provider: The provider name
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if a valid token exists.
|
|
126
|
+
"""
|
|
127
|
+
token = self.get_token(provider)
|
|
128
|
+
if not token:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Check if token has expired
|
|
132
|
+
expires_at = token.get("expires_at", 0)
|
|
133
|
+
if expires_at > 0 and time.time() > expires_at:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
return "access_token" in token
|
|
137
|
+
|
|
138
|
+
def get_access_token(self, provider: str) -> str | None:
|
|
139
|
+
"""
|
|
140
|
+
Get the access token for a provider, if valid.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
provider: The provider name
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Access token string if valid, None otherwise.
|
|
147
|
+
"""
|
|
148
|
+
token = self.get_token(provider)
|
|
149
|
+
if not token:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Check expiration
|
|
153
|
+
expires_at = token.get("expires_at", 0)
|
|
154
|
+
if expires_at > 0 and time.time() > expires_at:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
return token.get("access_token")
|
|
158
|
+
|
|
159
|
+
def needs_refresh(self, provider: str, buffer_seconds: int = 300) -> bool:
|
|
160
|
+
"""
|
|
161
|
+
Check if a token needs refreshing.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
provider: The provider name
|
|
165
|
+
buffer_seconds: Refresh this many seconds before actual expiry
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True if token needs refresh or doesn't exist.
|
|
169
|
+
"""
|
|
170
|
+
token = self.get_token(provider)
|
|
171
|
+
if not token:
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
expires_at = token.get("expires_at", 0)
|
|
175
|
+
if expires_at <= 0:
|
|
176
|
+
return False # No expiry set, assume valid
|
|
177
|
+
|
|
178
|
+
return time.time() > (expires_at - buffer_seconds)
|
|
179
|
+
|
|
180
|
+
def update_access_token(
|
|
181
|
+
self, provider: str, access_token: str, expires_in: int | None = None
|
|
182
|
+
) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Update just the access token (after refresh).
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
provider: The provider name
|
|
188
|
+
access_token: New access token
|
|
189
|
+
expires_in: Seconds until expiration (optional)
|
|
190
|
+
"""
|
|
191
|
+
token = self.get_token(provider) or {}
|
|
192
|
+
token["access_token"] = access_token
|
|
193
|
+
if expires_in:
|
|
194
|
+
token["expires_at"] = time.time() + expires_in
|
|
195
|
+
self.set_token(provider, token)
|