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,464 @@
1
+ """Claude Code OAuth authentication utilities.
2
+
3
+ Implements PKCE OAuth flow for Claude Code subscriptions.
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ import secrets
10
+ import threading
11
+ import time
12
+ import webbrowser
13
+ from dataclasses import dataclass
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from typing import Any, TypedDict
16
+ from urllib.parse import parse_qs, urlencode, urlparse
17
+
18
+ import httpx
19
+
20
+ from gac.oauth.token_store import OAuthToken, TokenStore
21
+ from gac.utils import get_ssl_verify
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ClaudeCodeConfig(TypedDict):
27
+ """Type definition for Claude Code OAuth configuration."""
28
+
29
+ auth_url: str
30
+ token_url: str
31
+ api_base_url: str
32
+ client_id: str
33
+ scope: str
34
+ redirect_host: str
35
+ redirect_path: str
36
+ callback_port_range: tuple[int, int]
37
+ callback_timeout: int
38
+ anthropic_version: str
39
+
40
+
41
+ # Claude Code OAuth configuration
42
+ CLAUDE_CODE_CONFIG: ClaudeCodeConfig = {
43
+ "auth_url": "https://claude.ai/oauth/authorize",
44
+ "token_url": "https://console.anthropic.com/v1/oauth/token",
45
+ "api_base_url": "https://api.anthropic.com",
46
+ "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
47
+ "scope": "org:create_api_key user:profile user:inference",
48
+ "redirect_host": "http://localhost",
49
+ "redirect_path": "callback",
50
+ "callback_port_range": (8765, 8795),
51
+ "callback_timeout": 180,
52
+ "anthropic_version": "2023-06-01",
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class OAuthContext:
58
+ """Runtime state for an in-progress OAuth flow."""
59
+
60
+ state: str
61
+ code_verifier: str
62
+ code_challenge: str
63
+ created_at: float
64
+ redirect_uri: str | None = None
65
+
66
+
67
+ class _OAuthResult:
68
+ """Stores OAuth callback results."""
69
+
70
+ def __init__(self) -> None:
71
+ self.code: str | None = None
72
+ self.state: str | None = None
73
+ self.error: str | None = None
74
+
75
+
76
+ class _CallbackHandler(BaseHTTPRequestHandler):
77
+ """HTTP handler for OAuth callback."""
78
+
79
+ result: _OAuthResult
80
+ received_event: threading.Event
81
+
82
+ def do_GET(self) -> None: # noqa: N802
83
+ """Handle GET request from OAuth redirect."""
84
+ logger.info("OAuth callback received: path=%s", self.path)
85
+ parsed = urlparse(self.path)
86
+ params: dict[str, list[str]] = parse_qs(parsed.query)
87
+
88
+ code = params.get("code", [None])[0]
89
+ state = params.get("state", [None])[0]
90
+
91
+ if code and state:
92
+ self.result.code = code
93
+ self.result.state = state
94
+ success_html = _get_success_html()
95
+ self._write_response(200, success_html)
96
+ else:
97
+ self.result.error = "Missing code or state"
98
+ failure_html = _get_failure_html()
99
+ self._write_response(400, failure_html)
100
+
101
+ self.received_event.set()
102
+
103
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
104
+ """Suppress HTTP server logs."""
105
+ return
106
+
107
+ def _write_response(self, status: int, body: str) -> None:
108
+ """Write HTTP response."""
109
+ self.send_response(status)
110
+ self.send_header("Content-Type", "text/html; charset=utf-8")
111
+ self.end_headers()
112
+ self.wfile.write(body.encode("utf-8"))
113
+
114
+
115
+ def _get_success_html() -> str:
116
+ """Return HTML for successful authentication."""
117
+ return """
118
+ <!DOCTYPE html>
119
+ <html>
120
+ <head>
121
+ <title>Authentication Successful</title>
122
+ <style>
123
+ body { font-family: system-ui; text-align: center; padding: 50px; }
124
+ h1 { color: #10a37f; }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <h1>✓ Authentication Successful!</h1>
129
+ <p>You can close this window and return to your terminal.</p>
130
+ </body>
131
+ </html>
132
+ """
133
+
134
+
135
+ def _get_failure_html() -> str:
136
+ """Return HTML for failed authentication."""
137
+ return """
138
+ <!DOCTYPE html>
139
+ <html>
140
+ <head>
141
+ <title>Authentication Failed</title>
142
+ <style>
143
+ body { font-family: system-ui; text-align: center; padding: 50px; }
144
+ h1 { color: #ef4444; }
145
+ </style>
146
+ </head>
147
+ <body>
148
+ <h1>✗ Authentication Failed</h1>
149
+ <p>Missing authorization code. Please try again.</p>
150
+ </body>
151
+ </html>
152
+ """
153
+
154
+
155
+ def _urlsafe_b64encode(data: bytes) -> str:
156
+ """Base64url encode without padding."""
157
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
158
+
159
+
160
+ def _generate_code_verifier() -> str:
161
+ """Generate PKCE code verifier."""
162
+ return _urlsafe_b64encode(secrets.token_bytes(64))
163
+
164
+
165
+ def _compute_code_challenge(code_verifier: str) -> str:
166
+ """Compute PKCE code challenge from verifier."""
167
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
168
+ return _urlsafe_b64encode(digest)
169
+
170
+
171
+ def prepare_oauth_context() -> OAuthContext:
172
+ """Create a new OAuth PKCE context."""
173
+ state = secrets.token_urlsafe(32)
174
+ code_verifier = _generate_code_verifier()
175
+ code_challenge = _compute_code_challenge(code_verifier)
176
+ return OAuthContext(
177
+ state=state,
178
+ code_verifier=code_verifier,
179
+ code_challenge=code_challenge,
180
+ created_at=time.time(),
181
+ )
182
+
183
+
184
+ def build_authorization_url(context: OAuthContext) -> str:
185
+ """Build the Claude authorization URL with PKCE parameters."""
186
+ if not context.redirect_uri:
187
+ raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
188
+
189
+ params = {
190
+ "response_type": "code",
191
+ "client_id": CLAUDE_CODE_CONFIG["client_id"],
192
+ "redirect_uri": context.redirect_uri,
193
+ "scope": CLAUDE_CODE_CONFIG["scope"],
194
+ "state": context.state,
195
+ "code": "true",
196
+ "code_challenge": context.code_challenge,
197
+ "code_challenge_method": "S256",
198
+ }
199
+ return f"{CLAUDE_CODE_CONFIG['auth_url']}?{urlencode(params)}"
200
+
201
+
202
+ def _start_callback_server(context: OAuthContext) -> tuple[HTTPServer, _OAuthResult, threading.Event] | None:
203
+ """Start local HTTP server to receive OAuth callback."""
204
+ port_range = CLAUDE_CODE_CONFIG["callback_port_range"]
205
+
206
+ for port in range(port_range[0], port_range[1] + 1):
207
+ try:
208
+ server = HTTPServer(("localhost", port), _CallbackHandler)
209
+ context.redirect_uri = f"{CLAUDE_CODE_CONFIG['redirect_host']}:{port}/{CLAUDE_CODE_CONFIG['redirect_path']}"
210
+ result = _OAuthResult()
211
+ event = threading.Event()
212
+ _CallbackHandler.result = result
213
+ _CallbackHandler.received_event = event
214
+
215
+ def run_server(srv: HTTPServer = server) -> None:
216
+ with srv:
217
+ srv.serve_forever()
218
+
219
+ threading.Thread(target=run_server, daemon=True).start()
220
+ return server, result, event
221
+ except OSError:
222
+ continue
223
+
224
+ logger.error("Could not start OAuth callback server; all candidate ports are in use")
225
+ return None
226
+
227
+
228
+ def exchange_code_for_tokens(auth_code: str, context: OAuthContext) -> dict[str, Any] | None:
229
+ """Exchange authorization code for access tokens."""
230
+ if not context.redirect_uri:
231
+ raise RuntimeError("Redirect URI missing from OAuth context")
232
+
233
+ payload = {
234
+ "grant_type": "authorization_code",
235
+ "client_id": CLAUDE_CODE_CONFIG["client_id"],
236
+ "code": auth_code,
237
+ "state": context.state,
238
+ "code_verifier": context.code_verifier,
239
+ "redirect_uri": context.redirect_uri,
240
+ }
241
+
242
+ headers = {
243
+ "Content-Type": "application/json",
244
+ "Accept": "application/json",
245
+ "anthropic-beta": "oauth-2025-04-20",
246
+ }
247
+
248
+ logger.info("Exchanging code for tokens: %s", CLAUDE_CODE_CONFIG["token_url"])
249
+ try:
250
+ response = httpx.post(
251
+ CLAUDE_CODE_CONFIG["token_url"],
252
+ json=payload,
253
+ headers=headers,
254
+ timeout=30,
255
+ verify=get_ssl_verify(),
256
+ )
257
+ logger.info("Token exchange response: %s", response.status_code)
258
+ if response.status_code == 200:
259
+ tokens: dict[str, Any] = response.json()
260
+ # Add expiry timestamp if not present
261
+ if "expires_at" not in tokens and "expires_in" in tokens:
262
+ tokens["expires_at"] = time.time() + tokens["expires_in"]
263
+ return tokens
264
+ logger.error("Token exchange failed: %s - %s", response.status_code, response.text)
265
+ except Exception as exc:
266
+ logger.error("Token exchange error: %s", exc)
267
+ return None
268
+
269
+
270
+ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
271
+ """Perform full OAuth flow and return tokens."""
272
+ context = prepare_oauth_context()
273
+
274
+ # Start callback server
275
+ started = _start_callback_server(context)
276
+ if not started:
277
+ if not quiet:
278
+ print("❌ Could not start OAuth callback server; all ports are in use")
279
+ return None
280
+
281
+ server, result, event = started
282
+ redirect_uri = context.redirect_uri
283
+
284
+ if not redirect_uri:
285
+ if not quiet:
286
+ print("❌ Failed to assign redirect URI for OAuth flow")
287
+ server.shutdown()
288
+ return None
289
+
290
+ # Build auth URL and open browser
291
+ auth_url = build_authorization_url(context)
292
+
293
+ if not quiet:
294
+ print("\n🔐 Opening browser for Claude Code OAuth authentication...")
295
+ print(f" If it doesn't open automatically, visit: {auth_url}\n")
296
+ print(f" Listening for callback on {redirect_uri}")
297
+ print(" (Waiting up to 3 minutes...)\n")
298
+
299
+ try:
300
+ webbrowser.open(auth_url)
301
+ except Exception as exc:
302
+ logger.warning("Failed to open browser automatically: %s", exc)
303
+ if not quiet:
304
+ print(f"⚠️ Failed to open browser automatically: {exc}")
305
+ print(f" Please open the URL manually: {auth_url}\n")
306
+
307
+ # Wait for callback
308
+ timeout = CLAUDE_CODE_CONFIG["callback_timeout"]
309
+ if not event.wait(timeout=timeout):
310
+ if not quiet:
311
+ print("❌ OAuth callback timed out. Please try again.")
312
+ server.shutdown()
313
+ return None
314
+
315
+ server.shutdown()
316
+
317
+ # Check for errors
318
+ if result.error:
319
+ if not quiet:
320
+ print(f"❌ OAuth callback error: {result.error}")
321
+ return None
322
+
323
+ # Validate state
324
+ if result.state != context.state:
325
+ if not quiet:
326
+ print("❌ State mismatch detected; aborting authentication for security")
327
+ return None
328
+
329
+ # Exchange code for tokens
330
+ if not quiet:
331
+ print("✓ Authorization code received")
332
+ print(" Exchanging for access token...\n")
333
+
334
+ tokens = exchange_code_for_tokens(result.code, context) # type: ignore[arg-type]
335
+ if not tokens:
336
+ if not quiet:
337
+ print("❌ Token exchange failed. Please try again.")
338
+ return None
339
+
340
+ if not quiet:
341
+ print("✓ Claude Code authentication successful!")
342
+
343
+ return tokens
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
353
+
354
+
355
+ def is_token_expired() -> bool:
356
+ """Check if the stored Claude Code token has expired.
357
+
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
369
+
370
+ # Consider token expired if it expires within 5 minutes
371
+ current_time = time.time()
372
+ return current_time >= (expiry - 300)
373
+
374
+
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...")
389
+
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")
394
+
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()
408
+ try:
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)
422
+ # Also update the current environment so the token is immediately available
423
+ os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = access_token
424
+ return True
425
+ except Exception as exc:
426
+ logger.error("Failed to save token: %s", exc)
427
+ return False
428
+
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
+
444
+ def authenticate_and_save(quiet: bool = False) -> bool:
445
+ """Perform OAuth flow and save token."""
446
+ tokens = perform_oauth_flow(quiet=quiet)
447
+ if not tokens:
448
+ return False
449
+
450
+ access_token = tokens.get("access_token")
451
+ if not access_token:
452
+ if not quiet:
453
+ print("❌ No access token returned from authentication")
454
+ return False
455
+
456
+ if not save_token(access_token, token_data=tokens):
457
+ if not quiet:
458
+ print("❌ Failed to save access token")
459
+ return False
460
+
461
+ if not quiet:
462
+ print("✓ Access token saved to ~/.gac/oauth/claude-code.json")
463
+
464
+ return True