gac 2.3.0__py3-none-any.whl → 2.7.0__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 gac might be problematic. Click here for more details.
- gac/__version__.py +1 -1
- gac/ai.py +4 -2
- gac/ai_utils.py +1 -0
- gac/auth_cli.py +69 -0
- gac/cli.py +14 -1
- gac/config.py +2 -0
- gac/constants.py +1 -0
- gac/git.py +69 -12
- gac/init_cli.py +175 -19
- gac/language_cli.py +170 -2
- gac/main.py +57 -8
- gac/oauth/__init__.py +1 -0
- gac/oauth/claude_code.py +397 -0
- gac/providers/__init__.py +2 -0
- gac/providers/claude_code.py +102 -0
- gac/providers/custom_anthropic.py +1 -1
- gac/utils.py +104 -3
- gac/workflow_utils.py +5 -2
- {gac-2.3.0.dist-info → gac-2.7.0.dist-info}/METADATA +29 -10
- {gac-2.3.0.dist-info → gac-2.7.0.dist-info}/RECORD +23 -19
- {gac-2.3.0.dist-info → gac-2.7.0.dist-info}/WHEEL +0 -0
- {gac-2.3.0.dist-info → gac-2.7.0.dist-info}/entry_points.txt +0 -0
- {gac-2.3.0.dist-info → gac-2.7.0.dist-info}/licenses/LICENSE +0 -0
gac/oauth/claude_code.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
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 pathlib import Path
|
|
16
|
+
from typing import Any, TypedDict
|
|
17
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeCodeConfig(TypedDict):
|
|
25
|
+
"""Type definition for Claude Code OAuth configuration."""
|
|
26
|
+
|
|
27
|
+
auth_url: str
|
|
28
|
+
token_url: str
|
|
29
|
+
api_base_url: str
|
|
30
|
+
client_id: str
|
|
31
|
+
scope: str
|
|
32
|
+
redirect_host: str
|
|
33
|
+
redirect_path: str
|
|
34
|
+
callback_port_range: tuple[int, int]
|
|
35
|
+
callback_timeout: int
|
|
36
|
+
anthropic_version: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Claude Code OAuth configuration
|
|
40
|
+
CLAUDE_CODE_CONFIG: ClaudeCodeConfig = {
|
|
41
|
+
"auth_url": "https://claude.ai/oauth/authorize",
|
|
42
|
+
"token_url": "https://console.anthropic.com/v1/oauth/token",
|
|
43
|
+
"api_base_url": "https://api.anthropic.com",
|
|
44
|
+
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
45
|
+
"scope": "org:create_api_key user:profile user:inference",
|
|
46
|
+
"redirect_host": "http://localhost",
|
|
47
|
+
"redirect_path": "callback",
|
|
48
|
+
"callback_port_range": (8765, 8795),
|
|
49
|
+
"callback_timeout": 180,
|
|
50
|
+
"anthropic_version": "2023-06-01",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class OAuthContext:
|
|
56
|
+
"""Runtime state for an in-progress OAuth flow."""
|
|
57
|
+
|
|
58
|
+
state: str
|
|
59
|
+
code_verifier: str
|
|
60
|
+
code_challenge: str
|
|
61
|
+
created_at: float
|
|
62
|
+
redirect_uri: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _OAuthResult:
|
|
66
|
+
"""Stores OAuth callback results."""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.code: str | None = None
|
|
70
|
+
self.state: str | None = None
|
|
71
|
+
self.error: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
75
|
+
"""HTTP handler for OAuth callback."""
|
|
76
|
+
|
|
77
|
+
result: _OAuthResult
|
|
78
|
+
received_event: threading.Event
|
|
79
|
+
|
|
80
|
+
def do_GET(self) -> None: # noqa: N802
|
|
81
|
+
"""Handle GET request from OAuth redirect."""
|
|
82
|
+
logger.info("OAuth callback received: path=%s", self.path)
|
|
83
|
+
parsed = urlparse(self.path)
|
|
84
|
+
params: dict[str, list[str]] = parse_qs(parsed.query)
|
|
85
|
+
|
|
86
|
+
code = params.get("code", [None])[0]
|
|
87
|
+
state = params.get("state", [None])[0]
|
|
88
|
+
|
|
89
|
+
if code and state:
|
|
90
|
+
self.result.code = code
|
|
91
|
+
self.result.state = state
|
|
92
|
+
success_html = _get_success_html()
|
|
93
|
+
self._write_response(200, success_html)
|
|
94
|
+
else:
|
|
95
|
+
self.result.error = "Missing code or state"
|
|
96
|
+
failure_html = _get_failure_html()
|
|
97
|
+
self._write_response(400, failure_html)
|
|
98
|
+
|
|
99
|
+
self.received_event.set()
|
|
100
|
+
|
|
101
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
|
|
102
|
+
"""Suppress HTTP server logs."""
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
def _write_response(self, status: int, body: str) -> None:
|
|
106
|
+
"""Write HTTP response."""
|
|
107
|
+
self.send_response(status)
|
|
108
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
109
|
+
self.end_headers()
|
|
110
|
+
self.wfile.write(body.encode("utf-8"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_success_html() -> str:
|
|
114
|
+
"""Return HTML for successful authentication."""
|
|
115
|
+
return """
|
|
116
|
+
<!DOCTYPE html>
|
|
117
|
+
<html>
|
|
118
|
+
<head>
|
|
119
|
+
<title>Authentication Successful</title>
|
|
120
|
+
<style>
|
|
121
|
+
body { font-family: system-ui; text-align: center; padding: 50px; }
|
|
122
|
+
h1 { color: #10a37f; }
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<h1>✓ Authentication Successful!</h1>
|
|
127
|
+
<p>You can close this window and return to your terminal.</p>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_failure_html() -> str:
|
|
134
|
+
"""Return HTML for failed authentication."""
|
|
135
|
+
return """
|
|
136
|
+
<!DOCTYPE html>
|
|
137
|
+
<html>
|
|
138
|
+
<head>
|
|
139
|
+
<title>Authentication Failed</title>
|
|
140
|
+
<style>
|
|
141
|
+
body { font-family: system-ui; text-align: center; padding: 50px; }
|
|
142
|
+
h1 { color: #ef4444; }
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<h1>✗ Authentication Failed</h1>
|
|
147
|
+
<p>Missing authorization code. Please try again.</p>
|
|
148
|
+
</body>
|
|
149
|
+
</html>
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _urlsafe_b64encode(data: bytes) -> str:
|
|
154
|
+
"""Base64url encode without padding."""
|
|
155
|
+
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _generate_code_verifier() -> str:
|
|
159
|
+
"""Generate PKCE code verifier."""
|
|
160
|
+
return _urlsafe_b64encode(secrets.token_bytes(64))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _compute_code_challenge(code_verifier: str) -> str:
|
|
164
|
+
"""Compute PKCE code challenge from verifier."""
|
|
165
|
+
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
|
166
|
+
return _urlsafe_b64encode(digest)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def prepare_oauth_context() -> OAuthContext:
|
|
170
|
+
"""Create a new OAuth PKCE context."""
|
|
171
|
+
state = secrets.token_urlsafe(32)
|
|
172
|
+
code_verifier = _generate_code_verifier()
|
|
173
|
+
code_challenge = _compute_code_challenge(code_verifier)
|
|
174
|
+
return OAuthContext(
|
|
175
|
+
state=state,
|
|
176
|
+
code_verifier=code_verifier,
|
|
177
|
+
code_challenge=code_challenge,
|
|
178
|
+
created_at=time.time(),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_authorization_url(context: OAuthContext) -> str:
|
|
183
|
+
"""Build the Claude authorization URL with PKCE parameters."""
|
|
184
|
+
if not context.redirect_uri:
|
|
185
|
+
raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
|
|
186
|
+
|
|
187
|
+
params = {
|
|
188
|
+
"response_type": "code",
|
|
189
|
+
"client_id": CLAUDE_CODE_CONFIG["client_id"],
|
|
190
|
+
"redirect_uri": context.redirect_uri,
|
|
191
|
+
"scope": CLAUDE_CODE_CONFIG["scope"],
|
|
192
|
+
"state": context.state,
|
|
193
|
+
"code": "true",
|
|
194
|
+
"code_challenge": context.code_challenge,
|
|
195
|
+
"code_challenge_method": "S256",
|
|
196
|
+
}
|
|
197
|
+
return f"{CLAUDE_CODE_CONFIG['auth_url']}?{urlencode(params)}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _start_callback_server(context: OAuthContext) -> tuple[HTTPServer, _OAuthResult, threading.Event] | None:
|
|
201
|
+
"""Start local HTTP server to receive OAuth callback."""
|
|
202
|
+
port_range = CLAUDE_CODE_CONFIG["callback_port_range"]
|
|
203
|
+
|
|
204
|
+
for port in range(port_range[0], port_range[1] + 1):
|
|
205
|
+
try:
|
|
206
|
+
server = HTTPServer(("localhost", port), _CallbackHandler)
|
|
207
|
+
context.redirect_uri = f"{CLAUDE_CODE_CONFIG['redirect_host']}:{port}/{CLAUDE_CODE_CONFIG['redirect_path']}"
|
|
208
|
+
result = _OAuthResult()
|
|
209
|
+
event = threading.Event()
|
|
210
|
+
_CallbackHandler.result = result
|
|
211
|
+
_CallbackHandler.received_event = event
|
|
212
|
+
|
|
213
|
+
def run_server(srv: HTTPServer = server) -> None:
|
|
214
|
+
with srv:
|
|
215
|
+
srv.serve_forever()
|
|
216
|
+
|
|
217
|
+
threading.Thread(target=run_server, daemon=True).start()
|
|
218
|
+
return server, result, event
|
|
219
|
+
except OSError:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
logger.error("Could not start OAuth callback server; all candidate ports are in use")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def exchange_code_for_tokens(auth_code: str, context: OAuthContext) -> dict[str, Any] | None:
|
|
227
|
+
"""Exchange authorization code for access tokens."""
|
|
228
|
+
if not context.redirect_uri:
|
|
229
|
+
raise RuntimeError("Redirect URI missing from OAuth context")
|
|
230
|
+
|
|
231
|
+
payload = {
|
|
232
|
+
"grant_type": "authorization_code",
|
|
233
|
+
"client_id": CLAUDE_CODE_CONFIG["client_id"],
|
|
234
|
+
"code": auth_code,
|
|
235
|
+
"state": context.state,
|
|
236
|
+
"code_verifier": context.code_verifier,
|
|
237
|
+
"redirect_uri": context.redirect_uri,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
headers = {
|
|
241
|
+
"Content-Type": "application/json",
|
|
242
|
+
"Accept": "application/json",
|
|
243
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
logger.info("Exchanging code for tokens: %s", CLAUDE_CODE_CONFIG["token_url"])
|
|
247
|
+
try:
|
|
248
|
+
response = httpx.post(
|
|
249
|
+
CLAUDE_CODE_CONFIG["token_url"],
|
|
250
|
+
json=payload,
|
|
251
|
+
headers=headers,
|
|
252
|
+
timeout=30,
|
|
253
|
+
)
|
|
254
|
+
logger.info("Token exchange response: %s", response.status_code)
|
|
255
|
+
if response.status_code == 200:
|
|
256
|
+
tokens: dict[str, Any] = response.json()
|
|
257
|
+
# Add expiry timestamp if not present
|
|
258
|
+
if "expires_at" not in tokens and "expires_in" in tokens:
|
|
259
|
+
tokens["expires_at"] = time.time() + tokens["expires_in"]
|
|
260
|
+
return tokens
|
|
261
|
+
logger.error("Token exchange failed: %s - %s", response.status_code, response.text)
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
logger.error("Token exchange error: %s", exc)
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
|
|
268
|
+
"""Perform full OAuth flow and return tokens."""
|
|
269
|
+
context = prepare_oauth_context()
|
|
270
|
+
|
|
271
|
+
# Start callback server
|
|
272
|
+
started = _start_callback_server(context)
|
|
273
|
+
if not started:
|
|
274
|
+
if not quiet:
|
|
275
|
+
print("❌ Could not start OAuth callback server; all ports are in use")
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
server, result, event = started
|
|
279
|
+
redirect_uri = context.redirect_uri
|
|
280
|
+
|
|
281
|
+
if not redirect_uri:
|
|
282
|
+
if not quiet:
|
|
283
|
+
print("❌ Failed to assign redirect URI for OAuth flow")
|
|
284
|
+
server.shutdown()
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
# Build auth URL and open browser
|
|
288
|
+
auth_url = build_authorization_url(context)
|
|
289
|
+
|
|
290
|
+
if not quiet:
|
|
291
|
+
print("\n🔐 Opening browser for Claude Code OAuth authentication...")
|
|
292
|
+
print(f" If it doesn't open automatically, visit: {auth_url}\n")
|
|
293
|
+
print(f" Listening for callback on {redirect_uri}")
|
|
294
|
+
print(" (Waiting up to 3 minutes...)\n")
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
webbrowser.open(auth_url)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
logger.warning("Failed to open browser automatically: %s", exc)
|
|
300
|
+
if not quiet:
|
|
301
|
+
print(f"⚠️ Failed to open browser automatically: {exc}")
|
|
302
|
+
print(f" Please open the URL manually: {auth_url}\n")
|
|
303
|
+
|
|
304
|
+
# Wait for callback
|
|
305
|
+
timeout = CLAUDE_CODE_CONFIG["callback_timeout"]
|
|
306
|
+
if not event.wait(timeout=timeout):
|
|
307
|
+
if not quiet:
|
|
308
|
+
print("❌ OAuth callback timed out. Please try again.")
|
|
309
|
+
server.shutdown()
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
server.shutdown()
|
|
313
|
+
|
|
314
|
+
# Check for errors
|
|
315
|
+
if result.error:
|
|
316
|
+
if not quiet:
|
|
317
|
+
print(f"❌ OAuth callback error: {result.error}")
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
# Validate state
|
|
321
|
+
if result.state != context.state:
|
|
322
|
+
if not quiet:
|
|
323
|
+
print("❌ State mismatch detected; aborting authentication for security")
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
# Exchange code for tokens
|
|
327
|
+
if not quiet:
|
|
328
|
+
print("✓ Authorization code received")
|
|
329
|
+
print(" Exchanging for access token...\n")
|
|
330
|
+
|
|
331
|
+
tokens = exchange_code_for_tokens(result.code, context) # type: ignore[arg-type]
|
|
332
|
+
if not tokens:
|
|
333
|
+
if not quiet:
|
|
334
|
+
print("❌ Token exchange failed. Please try again.")
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
if not quiet:
|
|
338
|
+
print("✓ Claude Code authentication successful!")
|
|
339
|
+
|
|
340
|
+
return tokens
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_token_storage_path() -> Path:
|
|
344
|
+
"""Get path for storing OAuth tokens."""
|
|
345
|
+
return Path.home() / ".gac.env"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def load_stored_token() -> str | None:
|
|
349
|
+
"""Load stored access token from .gac.env."""
|
|
350
|
+
from dotenv import dotenv_values
|
|
351
|
+
|
|
352
|
+
env_path = get_token_storage_path()
|
|
353
|
+
if not env_path.exists():
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
env_vars = dotenv_values(str(env_path))
|
|
357
|
+
return env_vars.get("CLAUDE_CODE_ACCESS_TOKEN")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def save_token(access_token: str) -> bool:
|
|
361
|
+
"""Save access token to .gac.env and update environment."""
|
|
362
|
+
import os
|
|
363
|
+
|
|
364
|
+
from dotenv import set_key
|
|
365
|
+
|
|
366
|
+
env_path = get_token_storage_path()
|
|
367
|
+
try:
|
|
368
|
+
set_key(str(env_path), "CLAUDE_CODE_ACCESS_TOKEN", access_token)
|
|
369
|
+
# Also update the current environment so the token is immediately available
|
|
370
|
+
os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = access_token
|
|
371
|
+
return True
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
logger.error("Failed to save token: %s", exc)
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def authenticate_and_save(quiet: bool = False) -> bool:
|
|
378
|
+
"""Perform OAuth flow and save token."""
|
|
379
|
+
tokens = perform_oauth_flow(quiet=quiet)
|
|
380
|
+
if not tokens:
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
access_token = tokens.get("access_token")
|
|
384
|
+
if not access_token:
|
|
385
|
+
if not quiet:
|
|
386
|
+
print("❌ No access token returned from authentication")
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
if not save_token(access_token):
|
|
390
|
+
if not quiet:
|
|
391
|
+
print("❌ Failed to save access token")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
if not quiet:
|
|
395
|
+
print(f"✓ Access token saved to {get_token_storage_path()}")
|
|
396
|
+
|
|
397
|
+
return True
|
gac/providers/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from .anthropic import call_anthropic_api
|
|
4
4
|
from .cerebras import call_cerebras_api
|
|
5
5
|
from .chutes import call_chutes_api
|
|
6
|
+
from .claude_code import call_claude_code_api
|
|
6
7
|
from .custom_anthropic import call_custom_anthropic_api
|
|
7
8
|
from .custom_openai import call_custom_openai_api
|
|
8
9
|
from .deepseek import call_deepseek_api
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"call_anthropic_api",
|
|
25
26
|
"call_cerebras_api",
|
|
26
27
|
"call_chutes_api",
|
|
28
|
+
"call_claude_code_api",
|
|
27
29
|
"call_custom_anthropic_api",
|
|
28
30
|
"call_custom_openai_api",
|
|
29
31
|
"call_deepseek_api",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Claude Code provider implementation.
|
|
2
|
+
|
|
3
|
+
This provider allows users with Claude Code subscriptions to use their OAuth tokens
|
|
4
|
+
instead of paying for the expensive Anthropic API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from gac.errors import AIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def call_claude_code_api(model: str, messages: list[dict], temperature: float, max_tokens: int) -> str:
|
|
15
|
+
"""Call Claude Code API using OAuth token.
|
|
16
|
+
|
|
17
|
+
This provider uses the Claude Code subscription OAuth token instead of the Anthropic API key.
|
|
18
|
+
It authenticates using Bearer token authentication with the special anthropic-beta header.
|
|
19
|
+
|
|
20
|
+
Environment variables:
|
|
21
|
+
CLAUDE_CODE_ACCESS_TOKEN: OAuth access token from Claude Code authentication
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
model: Model name (e.g., 'claude-sonnet-4-5')
|
|
25
|
+
messages: List of message dictionaries with 'role' and 'content' keys
|
|
26
|
+
temperature: Sampling temperature (0.0-1.0)
|
|
27
|
+
max_tokens: Maximum tokens in response
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Generated text response
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
AIError: If authentication fails or API call fails
|
|
34
|
+
"""
|
|
35
|
+
access_token = os.getenv("CLAUDE_CODE_ACCESS_TOKEN")
|
|
36
|
+
if not access_token:
|
|
37
|
+
raise AIError.authentication_error(
|
|
38
|
+
"CLAUDE_CODE_ACCESS_TOKEN not found in environment variables. "
|
|
39
|
+
"Please authenticate with Claude Code and set this token."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
url = "https://api.anthropic.com/v1/messages"
|
|
43
|
+
headers = {
|
|
44
|
+
"Authorization": f"Bearer {access_token}",
|
|
45
|
+
"anthropic-version": "2023-06-01",
|
|
46
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
47
|
+
"content-type": "application/json",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Convert messages to Anthropic format
|
|
51
|
+
# IMPORTANT: Claude Code OAuth tokens require the system message to be EXACTLY
|
|
52
|
+
# "You are Claude Code, Anthropic's official CLI for Claude." with NO additional content.
|
|
53
|
+
# Any other instructions must be moved to the user message.
|
|
54
|
+
anthropic_messages = []
|
|
55
|
+
system_instructions = ""
|
|
56
|
+
|
|
57
|
+
for msg in messages:
|
|
58
|
+
if msg["role"] == "system":
|
|
59
|
+
system_instructions = msg["content"]
|
|
60
|
+
else:
|
|
61
|
+
anthropic_messages.append({"role": msg["role"], "content": msg["content"]})
|
|
62
|
+
|
|
63
|
+
# Claude Code requires this exact system message, nothing more
|
|
64
|
+
system_message = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
65
|
+
|
|
66
|
+
# Move any system instructions into the first user message
|
|
67
|
+
if system_instructions and anthropic_messages:
|
|
68
|
+
# Prepend system instructions to the first user message
|
|
69
|
+
first_user_msg = anthropic_messages[0]
|
|
70
|
+
first_user_msg["content"] = f"{system_instructions}\n\n{first_user_msg['content']}"
|
|
71
|
+
|
|
72
|
+
data = {
|
|
73
|
+
"model": model,
|
|
74
|
+
"messages": anthropic_messages,
|
|
75
|
+
"temperature": temperature,
|
|
76
|
+
"max_tokens": max_tokens,
|
|
77
|
+
"system": system_message,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
response = httpx.post(url, headers=headers, json=data, timeout=120)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
response_data = response.json()
|
|
84
|
+
content = response_data["content"][0]["text"]
|
|
85
|
+
if content is None:
|
|
86
|
+
raise AIError.model_error("Claude Code API returned null content")
|
|
87
|
+
if content == "":
|
|
88
|
+
raise AIError.model_error("Claude Code API returned empty content")
|
|
89
|
+
return content
|
|
90
|
+
except httpx.HTTPStatusError as e:
|
|
91
|
+
if e.response.status_code == 401:
|
|
92
|
+
raise AIError.authentication_error(
|
|
93
|
+
f"Claude Code authentication failed: {e.response.text}. "
|
|
94
|
+
"Your token may have expired. Please re-authenticate."
|
|
95
|
+
) from e
|
|
96
|
+
if e.response.status_code == 429:
|
|
97
|
+
raise AIError.rate_limit_error(f"Claude Code API rate limit exceeded: {e.response.text}") from e
|
|
98
|
+
raise AIError.model_error(f"Claude Code API error: {e.response.status_code} - {e.response.text}") from e
|
|
99
|
+
except httpx.TimeoutException as e:
|
|
100
|
+
raise AIError.timeout_error(f"Claude Code API request timed out: {str(e)}") from e
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise AIError.model_error(f"Error calling Claude Code API: {str(e)}") from e
|
|
@@ -30,7 +30,7 @@ def call_custom_anthropic_api(model: str, messages: list[dict], temperature: flo
|
|
|
30
30
|
CUSTOM_ANTHROPIC_VERSION: API version header (optional, defaults to '2023-06-01')
|
|
31
31
|
|
|
32
32
|
Args:
|
|
33
|
-
model: The model to use (e.g., 'claude-
|
|
33
|
+
model: The model to use (e.g., 'claude-sonnet-4-5', 'claude-haiku-4-5')
|
|
34
34
|
messages: List of message dictionaries with 'role' and 'content' keys
|
|
35
35
|
temperature: Controls randomness (0.0-1.0)
|
|
36
36
|
max_tokens: Maximum tokens in the response
|
gac/utils.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Utility functions for gac."""
|
|
2
2
|
|
|
3
|
+
import locale
|
|
3
4
|
import logging
|
|
4
5
|
import subprocess
|
|
6
|
+
import sys
|
|
5
7
|
|
|
6
8
|
from rich.console import Console
|
|
7
9
|
from rich.theme import Theme
|
|
@@ -65,18 +67,47 @@ def print_message(message: str, level: str = "info") -> None:
|
|
|
65
67
|
console.print(message, style=level)
|
|
66
68
|
|
|
67
69
|
|
|
68
|
-
def
|
|
70
|
+
def get_safe_encodings() -> list[str]:
|
|
71
|
+
"""Get a list of safe encodings to try for subprocess calls, in order of preference.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of encoding strings to try, with UTF-8 first
|
|
75
|
+
"""
|
|
76
|
+
encodings = ["utf-8"]
|
|
77
|
+
|
|
78
|
+
# Add locale encoding as fallback
|
|
79
|
+
locale_encoding = locale.getpreferredencoding(False)
|
|
80
|
+
if locale_encoding and locale_encoding not in encodings:
|
|
81
|
+
encodings.append(locale_encoding)
|
|
82
|
+
|
|
83
|
+
# Windows-specific fallbacks
|
|
84
|
+
if sys.platform == "win32":
|
|
85
|
+
windows_encodings = ["cp65001", "cp936", "cp1252"] # UTF-8, GBK, Windows-1252
|
|
86
|
+
for enc in windows_encodings:
|
|
87
|
+
if enc not in encodings:
|
|
88
|
+
encodings.append(enc)
|
|
89
|
+
|
|
90
|
+
# Final fallback to system default
|
|
91
|
+
if "utf-8" not in encodings:
|
|
92
|
+
encodings.append("utf-8")
|
|
93
|
+
|
|
94
|
+
return encodings
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_subprocess_with_encoding(
|
|
69
98
|
command: list[str],
|
|
99
|
+
encoding: str,
|
|
70
100
|
silent: bool = False,
|
|
71
101
|
timeout: int = 60,
|
|
72
102
|
check: bool = True,
|
|
73
103
|
strip_output: bool = True,
|
|
74
104
|
raise_on_error: bool = True,
|
|
75
105
|
) -> str:
|
|
76
|
-
"""Run a
|
|
106
|
+
"""Run subprocess with a specific encoding, handling encoding errors gracefully.
|
|
77
107
|
|
|
78
108
|
Args:
|
|
79
109
|
command: List of command arguments
|
|
110
|
+
encoding: Specific encoding to use
|
|
80
111
|
silent: If True, suppress debug logging
|
|
81
112
|
timeout: Command timeout in seconds
|
|
82
113
|
check: Whether to check return code (for compatibility)
|
|
@@ -91,7 +122,7 @@ def run_subprocess(
|
|
|
91
122
|
subprocess.CalledProcessError: If the command fails and raise_on_error is True
|
|
92
123
|
"""
|
|
93
124
|
if not silent:
|
|
94
|
-
logger.debug(f"Running command: {' '.join(command)}")
|
|
125
|
+
logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
|
|
95
126
|
|
|
96
127
|
try:
|
|
97
128
|
result = subprocess.run(
|
|
@@ -100,6 +131,8 @@ def run_subprocess(
|
|
|
100
131
|
text=True,
|
|
101
132
|
check=False,
|
|
102
133
|
timeout=timeout,
|
|
134
|
+
encoding=encoding,
|
|
135
|
+
errors="replace", # Replace problematic characters instead of crashing
|
|
103
136
|
)
|
|
104
137
|
|
|
105
138
|
should_raise = result.returncode != 0 and (check or raise_on_error)
|
|
@@ -123,6 +156,11 @@ def run_subprocess(
|
|
|
123
156
|
if raise_on_error:
|
|
124
157
|
raise
|
|
125
158
|
return ""
|
|
159
|
+
except UnicodeError as e:
|
|
160
|
+
# This should be rare with errors="replace", but handle it just in case
|
|
161
|
+
if not silent:
|
|
162
|
+
logger.debug(f"Encoding error with {encoding}: {e}")
|
|
163
|
+
raise
|
|
126
164
|
except Exception as e:
|
|
127
165
|
if not silent:
|
|
128
166
|
logger.debug(f"Command error: {e}")
|
|
@@ -132,6 +170,69 @@ def run_subprocess(
|
|
|
132
170
|
return ""
|
|
133
171
|
|
|
134
172
|
|
|
173
|
+
def run_subprocess(
|
|
174
|
+
command: list[str],
|
|
175
|
+
silent: bool = False,
|
|
176
|
+
timeout: int = 60,
|
|
177
|
+
check: bool = True,
|
|
178
|
+
strip_output: bool = True,
|
|
179
|
+
raise_on_error: bool = True,
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Run a subprocess command safely and return the output, trying multiple encodings.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
command: List of command arguments
|
|
185
|
+
silent: If True, suppress debug logging
|
|
186
|
+
timeout: Command timeout in seconds
|
|
187
|
+
check: Whether to check return code (for compatibility)
|
|
188
|
+
strip_output: Whether to strip whitespace from output
|
|
189
|
+
raise_on_error: Whether to raise an exception on error
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Command output as string
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
GacError: If the command times out
|
|
196
|
+
subprocess.CalledProcessError: If the command fails and raise_on_error is True
|
|
197
|
+
|
|
198
|
+
Note:
|
|
199
|
+
Tries multiple encodings in order: utf-8, locale encoding, platform-specific fallbacks
|
|
200
|
+
This prevents UnicodeDecodeError on systems with non-UTF-8 locales (e.g., Chinese Windows)
|
|
201
|
+
"""
|
|
202
|
+
encodings = get_safe_encodings()
|
|
203
|
+
last_exception = None
|
|
204
|
+
|
|
205
|
+
for encoding in encodings:
|
|
206
|
+
try:
|
|
207
|
+
return run_subprocess_with_encoding(
|
|
208
|
+
command=command,
|
|
209
|
+
encoding=encoding,
|
|
210
|
+
silent=silent,
|
|
211
|
+
timeout=timeout,
|
|
212
|
+
check=check,
|
|
213
|
+
strip_output=strip_output,
|
|
214
|
+
raise_on_error=raise_on_error,
|
|
215
|
+
)
|
|
216
|
+
except UnicodeError as e:
|
|
217
|
+
last_exception = e
|
|
218
|
+
if not silent:
|
|
219
|
+
logger.debug(f"Failed to decode with {encoding}: {e}")
|
|
220
|
+
continue
|
|
221
|
+
except (subprocess.CalledProcessError, GacError, subprocess.TimeoutExpired):
|
|
222
|
+
# These are not encoding-related errors, so don't retry with other encodings
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
# If we get here, all encodings failed with UnicodeError
|
|
226
|
+
if not silent:
|
|
227
|
+
logger.error(f"Failed to decode command output with any encoding: {encodings}")
|
|
228
|
+
|
|
229
|
+
# Raise the last UnicodeError we encountered
|
|
230
|
+
if last_exception:
|
|
231
|
+
raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
|
|
232
|
+
else:
|
|
233
|
+
raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
|
|
234
|
+
|
|
235
|
+
|
|
135
236
|
def edit_commit_message_inplace(message: str) -> str | None:
|
|
136
237
|
"""Edit commit message in-place using rich terminal editing.
|
|
137
238
|
|