gac 1.13.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 (54) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai.py +33 -47
  3. gac/ai_utils.py +113 -41
  4. gac/auth_cli.py +214 -0
  5. gac/cli.py +72 -2
  6. gac/config.py +63 -6
  7. gac/config_cli.py +26 -5
  8. gac/constants.py +178 -2
  9. gac/git.py +158 -12
  10. gac/init_cli.py +40 -125
  11. gac/language_cli.py +378 -0
  12. gac/main.py +868 -158
  13. gac/model_cli.py +429 -0
  14. gac/oauth/__init__.py +27 -0
  15. gac/oauth/claude_code.py +464 -0
  16. gac/oauth/qwen_oauth.py +323 -0
  17. gac/oauth/token_store.py +81 -0
  18. gac/preprocess.py +3 -3
  19. gac/prompt.py +573 -226
  20. gac/providers/__init__.py +49 -0
  21. gac/providers/anthropic.py +11 -1
  22. gac/providers/azure_openai.py +101 -0
  23. gac/providers/cerebras.py +11 -1
  24. gac/providers/chutes.py +11 -1
  25. gac/providers/claude_code.py +112 -0
  26. gac/providers/custom_anthropic.py +6 -2
  27. gac/providers/custom_openai.py +6 -3
  28. gac/providers/deepseek.py +11 -1
  29. gac/providers/fireworks.py +11 -1
  30. gac/providers/gemini.py +11 -1
  31. gac/providers/groq.py +5 -1
  32. gac/providers/kimi_coding.py +67 -0
  33. gac/providers/lmstudio.py +12 -1
  34. gac/providers/minimax.py +11 -1
  35. gac/providers/mistral.py +48 -0
  36. gac/providers/moonshot.py +48 -0
  37. gac/providers/ollama.py +11 -1
  38. gac/providers/openai.py +11 -1
  39. gac/providers/openrouter.py +11 -1
  40. gac/providers/qwen.py +76 -0
  41. gac/providers/replicate.py +110 -0
  42. gac/providers/streamlake.py +11 -1
  43. gac/providers/synthetic.py +11 -1
  44. gac/providers/together.py +11 -1
  45. gac/providers/zai.py +11 -1
  46. gac/security.py +1 -1
  47. gac/utils.py +272 -4
  48. gac/workflow_utils.py +217 -0
  49. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
  50. gac-3.8.1.dist-info/RECORD +56 -0
  51. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
  52. gac-1.13.0.dist-info/RECORD +0 -41
  53. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
  54. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -0,0 +1,81 @@
1
+ """Token storage for OAuth authentication."""
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TypedDict
9
+
10
+
11
+ class OAuthToken(TypedDict, total=False):
12
+ """OAuth token structure."""
13
+
14
+ access_token: str
15
+ refresh_token: str | None
16
+ expiry: int
17
+ token_type: str
18
+ scope: str | None
19
+ resource_url: str | None
20
+
21
+
22
+ @dataclass
23
+ class TokenStore:
24
+ """Secure file-based token storage for OAuth tokens."""
25
+
26
+ base_dir: Path
27
+
28
+ def __init__(self, base_dir: Path | None = None):
29
+ if base_dir is None:
30
+ base_dir = Path.home() / ".gac" / "oauth"
31
+ self.base_dir = base_dir
32
+ self._ensure_directory()
33
+
34
+ def _ensure_directory(self) -> None:
35
+ """Create the OAuth directory with secure permissions."""
36
+ if not self.base_dir.exists():
37
+ self.base_dir.mkdir(parents=True, mode=0o700)
38
+ else:
39
+ os.chmod(self.base_dir, stat.S_IRWXU)
40
+
41
+ def _get_token_path(self, provider: str) -> Path:
42
+ """Get the path for a provider's token file."""
43
+ return self.base_dir / f"{provider}.json"
44
+
45
+ def save_token(self, provider: str, token: OAuthToken) -> None:
46
+ """Save a token to file with secure permissions.
47
+
48
+ Uses atomic write (temp file + rename) to prevent partial reads.
49
+ """
50
+ token_path = self._get_token_path(provider)
51
+ temp_path = token_path.with_suffix(".tmp")
52
+
53
+ with open(temp_path, "w") as f:
54
+ json.dump(token, f, indent=2)
55
+
56
+ os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
57
+ temp_path.rename(token_path)
58
+
59
+ def get_token(self, provider: str) -> OAuthToken | None:
60
+ """Retrieve a token from file."""
61
+ token_path = self._get_token_path(provider)
62
+ if not token_path.exists():
63
+ return None
64
+
65
+ with open(token_path) as f:
66
+ token_data = json.load(f)
67
+ if isinstance(token_data, dict) and isinstance(token_data.get("access_token"), str):
68
+ return token_data # type: ignore[return-value]
69
+ return None
70
+
71
+ def remove_token(self, provider: str) -> None:
72
+ """Remove a token file."""
73
+ token_path = self._get_token_path(provider)
74
+ if token_path.exists():
75
+ token_path.unlink()
76
+
77
+ def list_providers(self) -> list[str]:
78
+ """List all providers with stored tokens."""
79
+ if not self.base_dir.exists():
80
+ return []
81
+ return [f.stem for f in self.base_dir.glob("*.json")]
gac/preprocess.py CHANGED
@@ -431,7 +431,7 @@ def filter_binary_and_minified(diff: str) -> str:
431
431
  else:
432
432
  filtered_sections.append(section)
433
433
 
434
- return "".join(filtered_sections)
434
+ return "\n".join(filtered_sections)
435
435
 
436
436
 
437
437
  def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: int, model: str) -> str:
@@ -448,7 +448,7 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
448
448
  # Special case for tests: if token_limit is very high (e.g. 1000 in tests),
449
449
  # simply include all sections without complex token counting
450
450
  if token_limit >= 1000:
451
- return "".join([section for section, _ in scored_sections])
451
+ return "\n".join([section for section, _ in scored_sections])
452
452
  if not scored_sections:
453
453
  return ""
454
454
 
@@ -508,4 +508,4 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
508
508
  )
509
509
  result_sections.append(summary)
510
510
 
511
- return "".join(result_sections)
511
+ return "\n".join(result_sections)