gac 3.6.0__py3-none-any.whl → 3.8.1__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 (47) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai_utils.py +47 -0
  3. gac/auth_cli.py +181 -36
  4. gac/cli.py +13 -0
  5. gac/config.py +54 -0
  6. gac/constants.py +7 -0
  7. gac/main.py +53 -11
  8. gac/model_cli.py +65 -10
  9. gac/oauth/__init__.py +26 -0
  10. gac/oauth/claude_code.py +87 -20
  11. gac/oauth/qwen_oauth.py +323 -0
  12. gac/oauth/token_store.py +81 -0
  13. gac/prompt.py +16 -4
  14. gac/providers/__init__.py +3 -0
  15. gac/providers/anthropic.py +11 -1
  16. gac/providers/azure_openai.py +5 -1
  17. gac/providers/cerebras.py +11 -1
  18. gac/providers/chutes.py +11 -1
  19. gac/providers/claude_code.py +11 -1
  20. gac/providers/custom_anthropic.py +5 -1
  21. gac/providers/custom_openai.py +5 -1
  22. gac/providers/deepseek.py +11 -1
  23. gac/providers/fireworks.py +11 -1
  24. gac/providers/gemini.py +11 -1
  25. gac/providers/groq.py +5 -1
  26. gac/providers/kimi_coding.py +5 -1
  27. gac/providers/lmstudio.py +12 -1
  28. gac/providers/minimax.py +11 -1
  29. gac/providers/mistral.py +11 -1
  30. gac/providers/moonshot.py +11 -1
  31. gac/providers/ollama.py +11 -1
  32. gac/providers/openai.py +11 -1
  33. gac/providers/openrouter.py +11 -1
  34. gac/providers/qwen.py +76 -0
  35. gac/providers/replicate.py +14 -2
  36. gac/providers/streamlake.py +11 -1
  37. gac/providers/synthetic.py +11 -1
  38. gac/providers/together.py +11 -1
  39. gac/providers/zai.py +11 -1
  40. gac/utils.py +30 -1
  41. gac/workflow_utils.py +3 -8
  42. {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/METADATA +6 -4
  43. gac-3.8.1.dist-info/RECORD +56 -0
  44. gac-3.6.0.dist-info/RECORD +0 -53
  45. {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/WHEEL +0 -0
  46. {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
  47. {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +0 -0
gac/model_cli.py CHANGED
@@ -84,7 +84,7 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
84
84
  ("Azure OpenAI", "gpt-5-mini"),
85
85
  ("Cerebras", "zai-glm-4.6"),
86
86
  ("Chutes", "zai-org/GLM-4.6-FP8"),
87
- ("Claude Code", "claude-sonnet-4-5"),
87
+ ("Claude Code (OAuth)", "claude-sonnet-4-5"),
88
88
  ("Custom (Anthropic)", ""),
89
89
  ("Custom (OpenAI)", ""),
90
90
  ("DeepSeek", "deepseek-chat"),
@@ -99,6 +99,7 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
99
99
  ("Ollama", "gemma3"),
100
100
  ("OpenAI", "gpt-5-mini"),
101
101
  ("OpenRouter", "openrouter/auto"),
102
+ ("Qwen.ai (OAuth)", "qwen3-coder-plus"),
102
103
  ("Replicate", "openai/gpt-oss-120b"),
103
104
  ("Streamlake", ""),
104
105
  ("Synthetic.new", "hf:zai-org/GLM-4.6"),
@@ -116,22 +117,27 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
116
117
  provider_key = provider.lower().replace(".", "").replace(" ", "-").replace("(", "").replace(")", "")
117
118
 
118
119
  is_azure_openai = provider_key == "azure-openai"
119
- is_claude_code = provider_key == "claude-code"
120
+ is_claude_code = provider_key == "claude-code-oauth"
120
121
  is_custom_anthropic = provider_key == "custom-anthropic"
121
122
  is_custom_openai = provider_key == "custom-openai"
122
123
  is_lmstudio = provider_key == "lm-studio"
123
124
  is_ollama = provider_key == "ollama"
125
+ is_qwen = provider_key == "qwenai-oauth"
124
126
  is_streamlake = provider_key == "streamlake"
125
127
  is_zai = provider_key in ("zai", "zai-coding")
126
128
 
127
- if provider_key == "minimaxio":
129
+ if provider_key == "claude-code-oauth":
130
+ provider_key = "claude-code"
131
+ elif provider_key == "kimi-for-coding":
132
+ provider_key = "kimi-coding"
133
+ elif provider_key == "minimaxio":
128
134
  provider_key = "minimax"
129
- elif provider_key == "syntheticnew":
130
- provider_key = "synthetic"
131
135
  elif provider_key == "moonshot-ai":
132
136
  provider_key = "moonshot"
133
- elif provider_key == "kimi-for-coding":
134
- provider_key = "kimi-coding"
137
+ elif provider_key == "qwenai-oauth":
138
+ provider_key = "qwen"
139
+ elif provider_key == "syntheticnew":
140
+ provider_key = "synthetic"
135
141
 
136
142
  if is_streamlake:
137
143
  endpoint_id = _prompt_required_text("Enter the Streamlake inference endpoint ID (required):")
@@ -269,10 +275,12 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
269
275
 
270
276
  # Handle Claude Code OAuth separately
271
277
  if is_claude_code:
272
- from gac.oauth.claude_code import authenticate_and_save, load_stored_token
278
+ from gac.oauth.claude_code import authenticate_and_save
279
+ from gac.oauth.token_store import TokenStore
273
280
 
274
- existing_token = load_stored_token()
275
- if existing_token:
281
+ token_store = TokenStore()
282
+ existing_token_data = token_store.get_token("claude-code")
283
+ if existing_token_data:
276
284
  click.echo("\n✓ Claude Code access token already configured.")
277
285
  action = questionary.select(
278
286
  "What would you like to do?",
@@ -305,6 +313,53 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
305
313
  return False
306
314
  return True
307
315
 
316
+ # Handle Qwen OAuth separately
317
+ if is_qwen:
318
+ from gac.oauth import QwenOAuthProvider, TokenStore
319
+
320
+ token_store = TokenStore()
321
+ qwen_token = token_store.get_token("qwen")
322
+ if qwen_token:
323
+ click.echo("\n✓ Qwen access token already configured.")
324
+ action = questionary.select(
325
+ "What would you like to do?",
326
+ choices=[
327
+ "Keep existing token",
328
+ "Re-authenticate (get new token)",
329
+ ],
330
+ use_shortcuts=True,
331
+ use_arrow_keys=True,
332
+ use_jk_keys=False,
333
+ ).ask()
334
+
335
+ if action is None or action.startswith("Keep existing"):
336
+ if action is None:
337
+ click.echo("Qwen configuration cancelled. Keeping existing token.")
338
+ else:
339
+ click.echo("Keeping existing Qwen token")
340
+ return True
341
+ else:
342
+ click.echo("\n🔐 Starting Qwen OAuth authentication...")
343
+ provider = QwenOAuthProvider(token_store)
344
+ try:
345
+ provider.initiate_auth(open_browser=True)
346
+ click.echo("✅ Qwen authentication completed successfully!")
347
+ return True
348
+ except Exception as e:
349
+ click.echo(f"❌ Qwen authentication failed: {e}")
350
+ return False
351
+ else:
352
+ click.echo("\n🔐 Starting Qwen OAuth authentication...")
353
+ click.echo(" (Your browser will open automatically)\n")
354
+ provider = QwenOAuthProvider(token_store)
355
+ try:
356
+ provider.initiate_auth(open_browser=True)
357
+ click.echo("\n✅ Qwen authentication completed successfully!")
358
+ return True
359
+ except Exception as e:
360
+ click.echo(f"\n❌ Qwen authentication failed: {e}")
361
+ return False
362
+
308
363
  # Determine API key name based on provider
309
364
  if is_lmstudio:
310
365
  api_key_name = "LMSTUDIO_API_KEY"
gac/oauth/__init__.py CHANGED
@@ -1 +1,27 @@
1
1
  """OAuth authentication utilities for GAC."""
2
+
3
+ from .claude_code import (
4
+ authenticate_and_save,
5
+ is_token_expired,
6
+ load_stored_token,
7
+ perform_oauth_flow,
8
+ refresh_token_if_expired,
9
+ remove_token,
10
+ save_token,
11
+ )
12
+ from .qwen_oauth import QwenDeviceFlow, QwenOAuthProvider
13
+ from .token_store import OAuthToken, TokenStore
14
+
15
+ __all__ = [
16
+ "authenticate_and_save",
17
+ "is_token_expired",
18
+ "load_stored_token",
19
+ "OAuthToken",
20
+ "perform_oauth_flow",
21
+ "QwenDeviceFlow",
22
+ "QwenOAuthProvider",
23
+ "refresh_token_if_expired",
24
+ "remove_token",
25
+ "save_token",
26
+ "TokenStore",
27
+ ]
gac/oauth/claude_code.py CHANGED
@@ -12,12 +12,14 @@ import time
12
12
  import webbrowser
13
13
  from dataclasses import dataclass
14
14
  from http.server import BaseHTTPRequestHandler, HTTPServer
15
- from pathlib import Path
16
15
  from typing import Any, TypedDict
17
16
  from urllib.parse import parse_qs, urlencode, urlparse
18
17
 
19
18
  import httpx
20
19
 
20
+ from gac.oauth.token_store import OAuthToken, TokenStore
21
+ from gac.utils import get_ssl_verify
22
+
21
23
  logger = logging.getLogger(__name__)
22
24
 
23
25
 
@@ -250,6 +252,7 @@ def exchange_code_for_tokens(auth_code: str, context: OAuthContext) -> dict[str,
250
252
  json=payload,
251
253
  headers=headers,
252
254
  timeout=30,
255
+ verify=get_ssl_verify(),
253
256
  )
254
257
  logger.info("Token exchange response: %s", response.status_code)
255
258
  if response.status_code == 200:
@@ -340,32 +343,82 @@ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
340
343
  return tokens
341
344
 
342
345
 
343
- def get_token_storage_path() -> Path:
344
- """Get path for storing OAuth tokens."""
345
- return Path.home() / ".gac.env"
346
+ def load_stored_token() -> str | None:
347
+ """Load stored access token from token store."""
348
+ store = TokenStore()
349
+ token = store.get_token("claude-code")
350
+ if token:
351
+ return token.get("access_token")
352
+ return None
346
353
 
347
354
 
348
- def load_stored_token() -> str | None:
349
- """Load stored access token from .gac.env."""
350
- from dotenv import dotenv_values
355
+ def is_token_expired() -> bool:
356
+ """Check if the stored Claude Code token has expired.
351
357
 
352
- env_path = get_token_storage_path()
353
- if not env_path.exists():
354
- return None
358
+ Returns True if the token is expired or close to expiring (within 5 minutes).
359
+ """
360
+ store = TokenStore()
361
+ token = store.get_token("claude-code")
362
+ if not token:
363
+ return True
364
+
365
+ expiry = token.get("expiry")
366
+ if not expiry:
367
+ # No expiry information, assume it's still valid
368
+ return False
355
369
 
356
- env_vars = dotenv_values(str(env_path))
357
- return env_vars.get("CLAUDE_CODE_ACCESS_TOKEN")
370
+ # Consider token expired if it expires within 5 minutes
371
+ current_time = time.time()
372
+ return current_time >= (expiry - 300)
358
373
 
359
374
 
360
- def save_token(access_token: str) -> bool:
361
- """Save access token to .gac.env and update environment."""
362
- import os
375
+ def refresh_token_if_expired(quiet: bool = True) -> bool:
376
+ """Refresh the Claude Code token if it has expired.
377
+
378
+ Args:
379
+ quiet: If True, suppress output messages
380
+
381
+ Returns:
382
+ True if token is valid (or was successfully refreshed), False otherwise
383
+ """
384
+ if not is_token_expired():
385
+ return True
386
+
387
+ if not quiet:
388
+ logger.info("Claude Code token expired, attempting to refresh...")
363
389
 
364
- from dotenv import set_key
390
+ # Perform OAuth flow to get a new token
391
+ success = authenticate_and_save(quiet=quiet)
392
+ if not success and not quiet:
393
+ logger.error("Failed to refresh Claude Code token")
365
394
 
366
- env_path = get_token_storage_path()
395
+ return success
396
+
397
+
398
+ def save_token(access_token: str, token_data: dict[str, Any] | None = None) -> bool:
399
+ """Save access token to token store.
400
+
401
+ Args:
402
+ access_token: The OAuth access token string
403
+ token_data: Optional full token response data (includes expiry info)
404
+ """
405
+ import os
406
+
407
+ store = TokenStore()
367
408
  try:
368
- set_key(str(env_path), "CLAUDE_CODE_ACCESS_TOKEN", access_token)
409
+ token: OAuthToken = {
410
+ "access_token": access_token,
411
+ "token_type": "Bearer",
412
+ }
413
+
414
+ # Add expiry information if available
415
+ if token_data:
416
+ if "expires_at" in token_data:
417
+ token["expiry"] = int(token_data["expires_at"])
418
+ elif "expires_in" in token_data:
419
+ token["expiry"] = int(time.time() + token_data["expires_in"])
420
+
421
+ store.save_token("claude-code", token)
369
422
  # Also update the current environment so the token is immediately available
370
423
  os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = access_token
371
424
  return True
@@ -374,6 +427,20 @@ def save_token(access_token: str) -> bool:
374
427
  return False
375
428
 
376
429
 
430
+ def remove_token() -> bool:
431
+ """Remove stored access token from token store."""
432
+ import os
433
+
434
+ store = TokenStore()
435
+ try:
436
+ store.remove_token("claude-code")
437
+ os.environ.pop("CLAUDE_CODE_ACCESS_TOKEN", None)
438
+ return True
439
+ except Exception as exc:
440
+ logger.error("Failed to remove token: %s", exc)
441
+ return False
442
+
443
+
377
444
  def authenticate_and_save(quiet: bool = False) -> bool:
378
445
  """Perform OAuth flow and save token."""
379
446
  tokens = perform_oauth_flow(quiet=quiet)
@@ -386,12 +453,12 @@ def authenticate_and_save(quiet: bool = False) -> bool:
386
453
  print("❌ No access token returned from authentication")
387
454
  return False
388
455
 
389
- if not save_token(access_token):
456
+ if not save_token(access_token, token_data=tokens):
390
457
  if not quiet:
391
458
  print("❌ Failed to save access token")
392
459
  return False
393
460
 
394
461
  if not quiet:
395
- print(f"✓ Access token saved to {get_token_storage_path()}")
462
+ print("✓ Access token saved to ~/.gac/oauth/claude-code.json")
396
463
 
397
464
  return True
@@ -0,0 +1,323 @@
1
+ """Qwen OAuth device flow implementation.
2
+
3
+ Implements OAuth 2.0 Device Authorization Grant (RFC 8628) with PKCE.
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import secrets
11
+ import time
12
+ import webbrowser
13
+ from dataclasses import dataclass, field
14
+
15
+ import httpx
16
+
17
+ from gac import __version__
18
+ from gac.errors import AIError
19
+ from gac.oauth.token_store import OAuthToken, TokenStore
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
24
+ USER_AGENT = f"gac/{__version__}"
25
+ QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code"
26
+ QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token"
27
+ QWEN_SCOPES = ["openid", "profile", "email", "model.completion"]
28
+
29
+
30
+ @dataclass
31
+ class DeviceCodeResponse:
32
+ """Response from the device authorization endpoint."""
33
+
34
+ device_code: str
35
+ user_code: str
36
+ verification_uri: str
37
+ verification_uri_complete: str | None
38
+ expires_in: int
39
+ interval: int = 5
40
+
41
+
42
+ @dataclass
43
+ class QwenDeviceFlow:
44
+ """Qwen OAuth device flow implementation with PKCE."""
45
+
46
+ client_id: str = QWEN_CLIENT_ID
47
+ authorization_endpoint: str = QWEN_DEVICE_CODE_ENDPOINT
48
+ token_endpoint: str = QWEN_TOKEN_ENDPOINT
49
+ scopes: list[str] = field(default_factory=lambda: QWEN_SCOPES.copy())
50
+ _pkce_verifier: str = field(default="", init=False)
51
+
52
+ def _generate_pkce(self) -> tuple[str, str]:
53
+ """Generate PKCE code verifier and challenge.
54
+
55
+ Returns:
56
+ Tuple of (verifier, challenge) strings.
57
+ """
58
+ verifier = secrets.token_urlsafe(32)
59
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
60
+ return verifier, challenge
61
+
62
+ def initiate_device_flow(self) -> DeviceCodeResponse:
63
+ """Initiate the device authorization flow.
64
+
65
+ Returns:
66
+ DeviceCodeResponse with device code and verification URIs.
67
+ """
68
+ verifier, challenge = self._generate_pkce()
69
+ self._pkce_verifier = verifier
70
+
71
+ params = {
72
+ "client_id": self.client_id,
73
+ "code_challenge": challenge,
74
+ "code_challenge_method": "S256",
75
+ }
76
+
77
+ if self.scopes:
78
+ params["scope"] = " ".join(self.scopes)
79
+
80
+ response = httpx.post(
81
+ self.authorization_endpoint,
82
+ data=params,
83
+ headers={
84
+ "Content-Type": "application/x-www-form-urlencoded",
85
+ "Accept": "application/json",
86
+ "User-Agent": USER_AGENT,
87
+ },
88
+ timeout=30,
89
+ )
90
+
91
+ if not response.is_success:
92
+ raise AIError.connection_error(f"Failed to initiate device flow: HTTP {response.status_code}")
93
+
94
+ data = response.json()
95
+ return DeviceCodeResponse(
96
+ device_code=data["device_code"],
97
+ user_code=data["user_code"],
98
+ verification_uri=data["verification_uri"],
99
+ verification_uri_complete=data.get("verification_uri_complete"),
100
+ expires_in=data["expires_in"],
101
+ interval=data.get("interval", 5),
102
+ )
103
+
104
+ def poll_for_token(self, device_code: str, max_duration: int = 900) -> OAuthToken:
105
+ """Poll the authorization server for an access token.
106
+
107
+ Args:
108
+ device_code: Device code from initiation response.
109
+ max_duration: Maximum polling duration in seconds (default 15 minutes).
110
+
111
+ Returns:
112
+ OAuthToken with access token and metadata.
113
+ """
114
+ start_time = time.time()
115
+ interval = 5
116
+
117
+ while time.time() - start_time < max_duration:
118
+ params = {
119
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
120
+ "device_code": device_code,
121
+ "client_id": self.client_id,
122
+ "code_verifier": self._pkce_verifier,
123
+ }
124
+
125
+ try:
126
+ response = httpx.post(
127
+ self.token_endpoint,
128
+ data=params,
129
+ headers={
130
+ "Content-Type": "application/x-www-form-urlencoded",
131
+ "Accept": "application/json",
132
+ "User-Agent": USER_AGENT,
133
+ },
134
+ timeout=30,
135
+ )
136
+
137
+ if response.is_success:
138
+ data = response.json()
139
+ now = int(time.time())
140
+ expires_in = data.get("expires_in", 3600)
141
+
142
+ return OAuthToken(
143
+ access_token=data["access_token"],
144
+ token_type="Bearer",
145
+ expiry=now + expires_in,
146
+ refresh_token=data.get("refresh_token"),
147
+ scope=data.get("scope"),
148
+ resource_url=data.get("resource_url"),
149
+ )
150
+
151
+ error_data = response.json()
152
+ error = error_data.get("error", "")
153
+
154
+ if error == "authorization_pending":
155
+ time.sleep(interval)
156
+ continue
157
+ elif error == "slow_down":
158
+ interval += 5
159
+ time.sleep(interval)
160
+ continue
161
+ elif error == "access_denied":
162
+ raise AIError.authentication_error("Authorization was denied by user")
163
+ elif error == "expired_token":
164
+ raise AIError.authentication_error("Device code expired. Please try again.")
165
+
166
+ raise AIError.connection_error(f"Token request failed: {response.status_code}")
167
+
168
+ except httpx.RequestError as e:
169
+ interval = int(min(interval * 1.5, 60))
170
+ logger.debug(f"Network error during polling, retrying in {interval}s: {e}")
171
+ time.sleep(interval)
172
+ continue
173
+
174
+ raise AIError.timeout_error("Authorization timeout exceeded. Please try again.")
175
+
176
+ def refresh_token(self, refresh_token: str) -> OAuthToken:
177
+ """Refresh an expired access token.
178
+
179
+ Args:
180
+ refresh_token: Valid refresh token.
181
+
182
+ Returns:
183
+ New OAuthToken with refreshed access token.
184
+ """
185
+ params = {
186
+ "grant_type": "refresh_token",
187
+ "refresh_token": refresh_token,
188
+ "client_id": self.client_id,
189
+ }
190
+
191
+ response = httpx.post(
192
+ self.token_endpoint,
193
+ data=params,
194
+ headers={
195
+ "Content-Type": "application/x-www-form-urlencoded",
196
+ "Accept": "application/json",
197
+ "User-Agent": USER_AGENT,
198
+ },
199
+ timeout=30,
200
+ )
201
+
202
+ if not response.is_success:
203
+ raise AIError.authentication_error(f"Token refresh failed: HTTP {response.status_code}")
204
+
205
+ data = response.json()
206
+ now = int(time.time())
207
+ expires_in = data.get("expires_in", 3600)
208
+
209
+ return OAuthToken(
210
+ access_token=data["access_token"],
211
+ token_type="Bearer",
212
+ expiry=now + expires_in - 30,
213
+ refresh_token=data.get("refresh_token") or refresh_token,
214
+ scope=data.get("scope"),
215
+ resource_url=data.get("resource_url"),
216
+ )
217
+
218
+
219
+ class QwenOAuthProvider:
220
+ """Qwen OAuth provider for authentication management."""
221
+
222
+ name = "qwen"
223
+
224
+ def __init__(self, token_store: TokenStore | None = None):
225
+ self.token_store = token_store or TokenStore()
226
+ self.device_flow = QwenDeviceFlow()
227
+
228
+ def _is_token_expired(self, token: OAuthToken) -> bool:
229
+ """Check if token is expired or near expiry (30-second buffer)."""
230
+ now = time.time()
231
+ buffer = 30
232
+ return token["expiry"] <= now + buffer
233
+
234
+ def initiate_auth(self, open_browser: bool = True) -> None:
235
+ """Initiate the OAuth authentication flow.
236
+
237
+ Args:
238
+ open_browser: Whether to automatically open the browser.
239
+ """
240
+ device_response = self.device_flow.initiate_device_flow()
241
+
242
+ auth_url = device_response.verification_uri_complete or (
243
+ f"{device_response.verification_uri}?user_code={device_response.user_code}"
244
+ )
245
+
246
+ print("\nQwen OAuth Authentication")
247
+ print("-" * 40)
248
+ print("Please visit the following URL to authorize:")
249
+ print(auth_url)
250
+ print(f"\nUser code: {device_response.user_code}")
251
+
252
+ if open_browser and self._should_launch_browser():
253
+ print("Opening browser for authentication...")
254
+ try:
255
+ webbrowser.open(auth_url)
256
+ except Exception as e:
257
+ logger.debug(f"Failed to open browser: {e}")
258
+ print("Failed to open browser automatically. Please open the URL manually.")
259
+
260
+ print("-" * 40)
261
+ print("Waiting for authorization...\n")
262
+
263
+ token = self.device_flow.poll_for_token(device_response.device_code)
264
+ self.token_store.save_token("qwen", token)
265
+
266
+ print("Authentication successful!")
267
+
268
+ def _should_launch_browser(self) -> bool:
269
+ """Check if we should launch a browser."""
270
+ if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"):
271
+ return False
272
+ if not os.getenv("DISPLAY") and os.name != "nt":
273
+ if os.uname().sysname != "Darwin":
274
+ return False
275
+ return True
276
+
277
+ def get_token(self) -> OAuthToken | None:
278
+ """Get the current access token, refreshing if needed."""
279
+ token = self.token_store.get_token("qwen")
280
+ if not token:
281
+ return None
282
+
283
+ if self._is_token_expired(token):
284
+ return self.refresh_if_needed()
285
+
286
+ return token
287
+
288
+ def refresh_if_needed(self) -> OAuthToken | None:
289
+ """Refresh the token if expired.
290
+
291
+ Returns:
292
+ Refreshed token or None if refresh fails.
293
+ """
294
+ current_token = self.token_store.get_token("qwen")
295
+ if not current_token:
296
+ return None
297
+
298
+ if self._is_token_expired(current_token):
299
+ refresh_token = current_token.get("refresh_token")
300
+ if refresh_token:
301
+ try:
302
+ refreshed_token = self.device_flow.refresh_token(refresh_token)
303
+ self.token_store.save_token("qwen", refreshed_token)
304
+ return refreshed_token
305
+ except Exception as e:
306
+ logger.debug(f"Token refresh failed: {e}")
307
+ self.token_store.remove_token("qwen")
308
+ return None
309
+ else:
310
+ self.token_store.remove_token("qwen")
311
+ return None
312
+
313
+ return current_token
314
+
315
+ def logout(self) -> None:
316
+ """Log out by removing stored tokens."""
317
+ self.token_store.remove_token("qwen")
318
+ print("Successfully logged out from Qwen")
319
+
320
+ def is_authenticated(self) -> bool:
321
+ """Check if we have a valid token."""
322
+ token = self.get_token()
323
+ return token is not None