credentials.py
1 """ 2 Credentials Checker 3 4 Checks for local CLI credentials before running verification. 5 Supports Gemini CLI and Codex CLI authentication. 6 """ 7 8 import json 9 import os 10 from pathlib import Path 11 from typing import Optional, Tuple 12 13 # Credential file locations 14 GEMINI_CREDS_PATH = Path.home() / ".gemini" / "oauth_creds.json" 15 CODEX_CREDS_PATH = Path.home() / ".codex" / "auth.json" 16 ANTHROPIC_CREDS_PATH = Path.home() / ".anthropic" / "auth.json" 17 CLAUDE_CODE_CREDS_PATH = Path.home() / ".claude.json" 18 CLAUDE_CODE_CREDS_PATH_V2 = Path.home() / ".claude" / ".credentials.json" 19 20 # Alternative env var names (fallback) 21 GEMINI_ENV_VARS = ["GOOGLE_API_KEY", "GEMINI_API_KEY"] 22 OPENAI_ENV_VARS = ["OPENAI_API_KEY"] 23 ANTHROPIC_ENV_VARS = ["ANTHROPIC_API_KEY"] 24 25 26 def check_gemini_credentials() -> Tuple[bool, Optional[str]]: 27 """ 28 Check if Gemini CLI credentials are available. 29 30 Returns: 31 Tuple of (has_credentials, error_message) 32 """ 33 # Check OAuth creds file first (from `gemini auth`) 34 if GEMINI_CREDS_PATH.exists(): 35 try: 36 with open(GEMINI_CREDS_PATH, 'r') as f: 37 creds = json.load(f) 38 # Check if token exists and isn't empty 39 if creds.get('refresh_token') or creds.get('access_token'): 40 return True, None 41 except (json.JSONDecodeError, IOError): 42 pass 43 44 # Fallback: check environment variables 45 for env_var in GEMINI_ENV_VARS: 46 if os.environ.get(env_var): 47 return True, None 48 49 return False, f"""Gemini credentials not found. 50 51 Setup options: 52 1. Run: gemini auth login 53 2. Or set environment variable: export GOOGLE_API_KEY=<your-key> 54 55 Credential locations checked: 56 - {GEMINI_CREDS_PATH} 57 - Environment: {', '.join(GEMINI_ENV_VARS)}""" 58 59 60 def check_codex_credentials() -> Tuple[bool, Optional[str]]: 61 """ 62 Check if Codex CLI credentials are available. 63 64 Returns: 65 Tuple of (has_credentials, error_message) 66 """ 67 # Check auth.json from `codex auth` 68 if CODEX_CREDS_PATH.exists(): 69 try: 70 with open(CODEX_CREDS_PATH, 'r') as f: 71 creds = json.load(f) 72 # Check if we have valid auth data 73 # Codex stores key as OPENAI_API_KEY or api_key 74 if (creds.get('OPENAI_API_KEY') or creds.get('api_key') or 75 creds.get('access_token') or creds.get('refresh_token') or 76 creds.get('tokens')): 77 return True, None 78 except (json.JSONDecodeError, IOError): 79 pass 80 81 # Fallback: check environment variables 82 for env_var in OPENAI_ENV_VARS: 83 if os.environ.get(env_var): 84 return True, None 85 86 return False, f"""Codex/OpenAI credentials not found. 87 88 Setup options: 89 1. Run: codex auth login 90 2. Or set environment variable: export OPENAI_API_KEY=<your-key> 91 92 Credential locations checked: 93 - {CODEX_CREDS_PATH} 94 - Environment: {', '.join(OPENAI_ENV_VARS)}""" 95 96 97 def check_anthropic_credentials() -> Tuple[bool, Optional[str]]: 98 """ 99 Check if Anthropic/Claude access is available. 100 101 Uses Claude CLI which leverages existing OAuth session. 102 103 Returns: 104 Tuple of (has_credentials, error_message) 105 """ 106 # First check if Claude CLI is available (preferred - uses existing session) 107 try: 108 from providers.anthropic import check_claude_cli 109 has_cli, _ = check_claude_cli() 110 if has_cli: 111 return True, None 112 except ImportError: 113 pass 114 115 # Fallback: Check environment variable 116 for env_var in ANTHROPIC_ENV_VARS: 117 if os.environ.get(env_var): 118 return True, None 119 120 # Fallback: Check ~/.anthropic/auth.json 121 if ANTHROPIC_CREDS_PATH.exists(): 122 try: 123 with open(ANTHROPIC_CREDS_PATH, 'r') as f: 124 creds = json.load(f) 125 if creds.get('api_key'): 126 return True, None 127 except (json.JSONDecodeError, IOError): 128 pass 129 130 return False, f"""Claude CLI not found or not authenticated. 131 132 The spec-verify tool uses the Claude CLI to leverage your existing session. 133 134 Setup: 135 1. Install Claude CLI: https://claude.ai/code 136 2. Authenticate: claude login 137 138 Alternative (API key): 139 export ANTHROPIC_API_KEY=<your-key>""" 140 141 142 def check_credentials(provider: str) -> Tuple[bool, Optional[str]]: 143 """ 144 Check credentials for specified provider. 145 146 Args: 147 provider: 'gemini', 'openai', or 'anthropic' 148 149 Returns: 150 Tuple of (has_credentials, error_message) 151 """ 152 if provider == "gemini": 153 return check_gemini_credentials() 154 elif provider == "openai": 155 return check_codex_credentials() 156 elif provider == "anthropic": 157 return check_anthropic_credentials() 158 else: 159 return False, f"Unknown provider: {provider}" 160 161 162 def get_gemini_api_key() -> Optional[str]: 163 """ 164 Get Gemini API key from local credentials. 165 166 Returns: 167 API key string or None 168 """ 169 # Try env vars first (they take precedence) 170 for env_var in GEMINI_ENV_VARS: 171 key = os.environ.get(env_var) 172 if key: 173 return key 174 175 # Try OAuth - for OAuth we return None as the client uses tokens 176 # The google-generativeai library handles OAuth automatically 177 if GEMINI_CREDS_PATH.exists(): 178 return "OAUTH_CREDS" # Signal to use OAuth flow 179 180 return None 181 182 183 def get_openai_api_key() -> Optional[str]: 184 """ 185 Get OpenAI API key from local credentials. 186 187 Returns: 188 API key string, "OAUTH_TOKENS" for token-based auth, or None 189 """ 190 # Try env vars first 191 for env_var in OPENAI_ENV_VARS: 192 key = os.environ.get(env_var) 193 if key: 194 return key 195 196 # Try Codex auth.json 197 if CODEX_CREDS_PATH.exists(): 198 try: 199 with open(CODEX_CREDS_PATH, 'r') as f: 200 creds = json.load(f) 201 # Codex stores key as OPENAI_API_KEY or api_key 202 if creds.get('OPENAI_API_KEY'): 203 return creds['OPENAI_API_KEY'] 204 if creds.get('api_key'): 205 return creds['api_key'] 206 # Codex may use OAuth tokens instead 207 tokens = creds.get('tokens', {}) 208 if tokens.get('access_token'): 209 return "OAUTH_TOKENS" 210 except (json.JSONDecodeError, IOError): 211 pass 212 213 return None 214 215 216 def get_codex_access_token() -> Optional[str]: 217 """ 218 Get Codex OAuth access token for API calls. 219 220 Returns: 221 Access token string or None 222 """ 223 if not CODEX_CREDS_PATH.exists(): 224 return None 225 226 try: 227 with open(CODEX_CREDS_PATH, 'r') as f: 228 creds = json.load(f) 229 tokens = creds.get('tokens', {}) 230 return tokens.get('access_token') 231 except (json.JSONDecodeError, IOError): 232 return None 233 234 235 def get_anthropic_api_key() -> Optional[str]: 236 """ 237 Get Anthropic API key from local credentials. 238 239 Returns: 240 API key string or None 241 """ 242 # Try env vars first 243 for env_var in ANTHROPIC_ENV_VARS: 244 key = os.environ.get(env_var) 245 if key: 246 return key 247 248 # Try ~/.anthropic/auth.json 249 if ANTHROPIC_CREDS_PATH.exists(): 250 try: 251 with open(ANTHROPIC_CREDS_PATH, 'r') as f: 252 creds = json.load(f) 253 if creds.get('api_key'): 254 return creds['api_key'] 255 except (json.JSONDecodeError, IOError): 256 pass 257 258 # Try ~/.claude.json 259 if CLAUDE_CODE_CREDS_PATH.exists(): 260 try: 261 with open(CLAUDE_CODE_CREDS_PATH, 'r') as f: 262 creds = json.load(f) 263 if creds.get('apiKey'): 264 return creds['apiKey'] 265 if creds.get('api_key'): 266 return creds['api_key'] 267 except (json.JSONDecodeError, IOError): 268 pass 269 270 return None 271 272 273 def preflight_check_gemini() -> Tuple[bool, Optional[str]]: 274 """ 275 Make a test API call to verify Gemini access works. 276 277 This ensures: 278 - Credentials are valid (not expired) 279 - Account has API access 280 - No interactive prompts will occur 281 282 Returns: 283 Tuple of (success, error_message) 284 """ 285 try: 286 import google.generativeai as genai 287 except ImportError: 288 return False, "google-generativeai package not installed. Install with: pip install google-generativeai" 289 290 api_key = get_gemini_api_key() 291 292 if api_key == "OAUTH_CREDS": 293 # Setup OAuth credentials 294 try: 295 from google.oauth2.credentials import Credentials 296 from google.auth.transport.requests import Request 297 298 with open(GEMINI_CREDS_PATH, 'r') as f: 299 oauth_creds = json.load(f) 300 301 creds = Credentials( 302 token=oauth_creds.get('access_token'), 303 refresh_token=oauth_creds.get('refresh_token'), 304 token_uri="https://oauth2.googleapis.com/token", 305 client_id=oauth_creds.get('client_id'), 306 client_secret=oauth_creds.get('client_secret'), 307 ) 308 309 # Refresh if needed 310 if creds.expired and creds.refresh_token: 311 creds.refresh(Request()) 312 313 genai.configure(credentials=creds) 314 except Exception as e: 315 return False, f"OAuth setup failed: {e}" 316 elif api_key: 317 genai.configure(api_key=api_key) 318 else: 319 return False, "No API key found" 320 321 # Make a minimal test request 322 try: 323 model = genai.GenerativeModel('gemini-2.0-flash') 324 response = model.generate_content( 325 "Reply with exactly: OK", 326 generation_config=genai.types.GenerationConfig( 327 max_output_tokens=10, 328 temperature=0, 329 ), 330 ) 331 332 # Check we got a response 333 if response and response.text: 334 return True, None 335 else: 336 return False, "Empty response from API" 337 338 except Exception as e: 339 error_msg = str(e) 340 341 # Parse common errors 342 if "API_KEY_INVALID" in error_msg or "invalid" in error_msg.lower(): 343 return False, f"Invalid API key or expired OAuth token. Re-run: gemini auth login" 344 elif "PERMISSION_DENIED" in error_msg or "SCOPE_INSUFFICIENT" in error_msg: 345 return False, f"""Permission denied or insufficient OAuth scopes. 346 347 The Gemini CLI OAuth tokens don't have the right scopes for the Python SDK. 348 You need a Gemini API key instead. 349 350 Get one from: https://aistudio.google.com/apikey 351 Then set: export GOOGLE_API_KEY=<your-key>""" 352 elif "QUOTA" in error_msg or "rate" in error_msg.lower(): 353 return False, f"Rate limit or quota exceeded. Wait and retry." 354 elif "requires" in error_msg.lower() and "consent" in error_msg.lower(): 355 return False, f"Interactive consent required. Re-run: gemini auth login" 356 else: 357 return False, f"API test failed: {error_msg}" 358 359 360 def preflight_check_openai() -> Tuple[bool, Optional[str]]: 361 """ 362 Make a test API call to verify OpenAI access works. 363 364 This ensures: 365 - API key is valid 366 - Account has API access and quota 367 - No interactive prompts will occur 368 369 Returns: 370 Tuple of (success, error_message) 371 """ 372 try: 373 from openai import OpenAI 374 except ImportError: 375 return False, "openai package not installed. Install with: pip install openai" 376 377 api_key = get_openai_api_key() 378 379 if not api_key: 380 return False, "No API key found" 381 382 # Handle OAuth token-based auth from Codex CLI 383 if api_key == "OAUTH_TOKENS": 384 access_token = get_codex_access_token() 385 if not access_token: 386 return False, "OAuth tokens found but access_token is missing. Re-run: codex auth login" 387 api_key = access_token 388 389 # Make a minimal test request 390 try: 391 client = OpenAI(api_key=api_key) 392 response = client.chat.completions.create( 393 model="gpt-4", # Use same model as config for preflight 394 messages=[{"role": "user", "content": "Reply with exactly: OK"}], 395 max_tokens=10, 396 temperature=0, 397 ) 398 399 # Check we got a response 400 if response and response.choices and response.choices[0].message.content: 401 return True, None 402 else: 403 return False, "Empty response from API" 404 405 except Exception as e: 406 error_msg = str(e) 407 408 # Parse common errors 409 if "invalid_api_key" in error_msg.lower() or "incorrect api key" in error_msg.lower(): 410 return False, f"Invalid API key or token. Re-run: codex auth login" 411 elif "permission" in error_msg.lower(): 412 return False, f"Permission denied. Check API access for your account." 413 elif "insufficient_quota" in error_msg.lower(): 414 return False, f"""OpenAI quota exceeded. 415 416 Your account has no remaining quota. Options: 417 1. Add billing: https://platform.openai.com/account/billing 418 2. Use a different API key with active quota 419 3. Set: export OPENAI_API_KEY=<key-with-quota>""" 420 elif "quota" in error_msg.lower() or "rate" in error_msg.lower(): 421 return False, f"Rate limit exceeded. Wait a moment and retry." 422 elif "billing" in error_msg.lower(): 423 return False, f"Billing issue. Check your OpenAI account has active billing." 424 else: 425 return False, f"API test failed: {error_msg}" 426 427 428 def preflight_check_anthropic() -> Tuple[bool, Optional[str]]: 429 """ 430 Make a test call to verify Claude/Anthropic access works. 431 432 Uses Claude CLI for preflight to leverage existing OAuth session. 433 434 Returns: 435 Tuple of (success, error_message) 436 """ 437 # Try Claude CLI first (uses existing session) 438 try: 439 from providers.anthropic import preflight_claude_cli 440 return preflight_claude_cli() 441 except ImportError: 442 pass 443 444 # Fallback to API key approach 445 try: 446 from anthropic import Anthropic 447 except ImportError: 448 return False, "Neither Claude CLI nor anthropic package available." 449 450 api_key = get_anthropic_api_key() 451 452 if not api_key: 453 return False, "No API key found and Claude CLI not available." 454 455 # Make a minimal test request 456 try: 457 client = Anthropic(api_key=api_key) 458 response = client.messages.create( 459 model="claude-sonnet-4-20250514", 460 max_tokens=10, 461 messages=[{"role": "user", "content": "Reply with exactly: OK"}], 462 ) 463 464 if response and response.content and response.content[0].text: 465 return True, None 466 else: 467 return False, "Empty response from API" 468 469 except Exception as e: 470 error_msg = str(e) 471 472 if "invalid_api_key" in error_msg.lower() or "authentication" in error_msg.lower(): 473 return False, f"Invalid API key. Check your ANTHROPIC_API_KEY." 474 elif "rate" in error_msg.lower(): 475 return False, f"Rate limit exceeded. Wait a moment and retry." 476 else: 477 return False, f"API test failed: {error_msg}" 478 479 480 def preflight_check(provider: str) -> Tuple[bool, Optional[str]]: 481 """ 482 Run preflight check for the specified provider. 483 484 Makes a test API call to verify credentials work and 485 no interactive prompts will be required. 486 487 Args: 488 provider: 'gemini', 'openai', or 'anthropic' 489 490 Returns: 491 Tuple of (success, error_message) 492 """ 493 if provider == "gemini": 494 return preflight_check_gemini() 495 elif provider == "openai": 496 return preflight_check_openai() 497 elif provider == "anthropic": 498 return preflight_check_anthropic() 499 else: 500 return False, f"Unknown provider: {provider}"