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.
- gac/__version__.py +1 -1
- gac/ai_utils.py +47 -0
- gac/auth_cli.py +181 -36
- gac/cli.py +13 -0
- gac/config.py +54 -0
- gac/constants.py +7 -0
- gac/main.py +53 -11
- gac/model_cli.py +65 -10
- gac/oauth/__init__.py +26 -0
- gac/oauth/claude_code.py +87 -20
- gac/oauth/qwen_oauth.py +323 -0
- gac/oauth/token_store.py +81 -0
- gac/prompt.py +16 -4
- gac/providers/__init__.py +3 -0
- gac/providers/anthropic.py +11 -1
- gac/providers/azure_openai.py +5 -1
- gac/providers/cerebras.py +11 -1
- gac/providers/chutes.py +11 -1
- gac/providers/claude_code.py +11 -1
- gac/providers/custom_anthropic.py +5 -1
- gac/providers/custom_openai.py +5 -1
- gac/providers/deepseek.py +11 -1
- gac/providers/fireworks.py +11 -1
- gac/providers/gemini.py +11 -1
- gac/providers/groq.py +5 -1
- gac/providers/kimi_coding.py +5 -1
- gac/providers/lmstudio.py +12 -1
- gac/providers/minimax.py +11 -1
- gac/providers/mistral.py +11 -1
- gac/providers/moonshot.py +11 -1
- gac/providers/ollama.py +11 -1
- gac/providers/openai.py +11 -1
- gac/providers/openrouter.py +11 -1
- gac/providers/qwen.py +76 -0
- gac/providers/replicate.py +14 -2
- gac/providers/streamlake.py +11 -1
- gac/providers/synthetic.py +11 -1
- gac/providers/together.py +11 -1
- gac/providers/zai.py +11 -1
- gac/utils.py +30 -1
- gac/workflow_utils.py +3 -8
- {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/METADATA +6 -4
- gac-3.8.1.dist-info/RECORD +56 -0
- gac-3.6.0.dist-info/RECORD +0 -53
- {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/WHEEL +0 -0
- {gac-3.6.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
- {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 == "
|
|
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 == "
|
|
134
|
-
provider_key = "
|
|
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
|
|
278
|
+
from gac.oauth.claude_code import authenticate_and_save
|
|
279
|
+
from gac.oauth.token_store import TokenStore
|
|
273
280
|
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
344
|
-
"""
|
|
345
|
-
|
|
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
|
|
349
|
-
"""
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
361
|
-
"""
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
462
|
+
print("✓ Access token saved to ~/.gac/oauth/claude-code.json")
|
|
396
463
|
|
|
397
464
|
return True
|
gac/oauth/qwen_oauth.py
ADDED
|
@@ -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
|