g4f 6.9.10__py3-none-any.whl → 7.0.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.
@@ -0,0 +1,1505 @@
1
+ """
2
+ Antigravity Provider for gpt4free
3
+
4
+ Provides access to Google's Antigravity API (Code Assist) supporting:
5
+ - Gemini 2.5 (Pro/Flash) with thinkingBudget
6
+ - Gemini 3 (Pro/Flash) with thinkingLevel
7
+ - Claude (Sonnet 4.5 / Opus 4.5) via Antigravity proxy
8
+
9
+ Uses OAuth2 authentication with Antigravity-specific credentials.
10
+ Supports endpoint fallback chain for reliability.
11
+ Includes interactive OAuth login flow with PKCE support.
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import json
17
+ import base64
18
+ import time
19
+ import secrets
20
+ import hashlib
21
+ import asyncio
22
+ import webbrowser
23
+ import threading
24
+ from pathlib import Path
25
+ from typing import Any, AsyncGenerator, Dict, List, Optional, Union, Tuple
26
+ from urllib.parse import urlencode, parse_qs, urlparse
27
+ from http.server import HTTPServer, BaseHTTPRequestHandler
28
+
29
+ import aiohttp
30
+ from aiohttp import ClientSession, ClientTimeout
31
+
32
+ from ...typing import AsyncResult, Messages, MediaListType
33
+ from ...errors import MissingAuthError
34
+ from ...image.copy_images import save_response_media
35
+ from ...image import to_bytes, is_data_an_media
36
+ from ...providers.response import Usage, ImageResponse, ToolCalls, Reasoning
37
+ from ..base_provider import AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin
38
+ from ..helper import get_connector, get_system_prompt, format_media_prompt
39
+ from ... import debug
40
+
41
+
42
+ def get_antigravity_oauth_creds_path():
43
+ """Get the default path for Antigravity OAuth credentials."""
44
+ return Path.home() / ".antigravity" / "oauth_creds.json"
45
+
46
+
47
+ # OAuth configuration
48
+ ANTIGRAVITY_REDIRECT_URI = "http://localhost:51121/oauthcallback"
49
+ ANTIGRAVITY_SCOPES = [
50
+ "https://www.googleapis.com/auth/cloud-platform",
51
+ "https://www.googleapis.com/auth/userinfo.email",
52
+ "https://www.googleapis.com/auth/userinfo.profile",
53
+ "https://www.googleapis.com/auth/cclog",
54
+ "https://www.googleapis.com/auth/experimentsandconfigs",
55
+ ]
56
+ OAUTH_CALLBACK_PORT = 51121
57
+ OAUTH_CALLBACK_PATH = "/oauthcallback"
58
+
59
+
60
+ def generate_pkce_pair() -> Tuple[str, str]:
61
+ """
62
+ Generate a PKCE (Proof Key for Code Exchange) verifier and challenge pair.
63
+
64
+ Returns:
65
+ Tuple of (verifier, challenge) where:
66
+ - verifier: Random 43-128 character string
67
+ - challenge: Base64URL-encoded SHA256 hash of verifier
68
+ """
69
+ # Generate a random verifier (43-128 characters)
70
+ verifier = secrets.token_urlsafe(32)
71
+
72
+ # Create SHA256 hash of verifier
73
+ digest = hashlib.sha256(verifier.encode('ascii')).digest()
74
+
75
+ # Base64URL encode (no padding)
76
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
77
+
78
+ return verifier, challenge
79
+
80
+
81
+ def encode_oauth_state(verifier: str, project_id: str = "") -> str:
82
+ """Encode OAuth state parameter with PKCE verifier and project ID."""
83
+ payload = {"verifier": verifier, "projectId": project_id}
84
+ return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')
85
+
86
+
87
+ def decode_oauth_state(state: str) -> Dict[str, str]:
88
+ """Decode OAuth state parameter back to verifier and project ID."""
89
+ # Add padding if needed
90
+ padded = state + '=' * (4 - len(state) % 4) if len(state) % 4 else state
91
+ # Convert URL-safe base64 to standard
92
+ normalized = padded.replace('-', '+').replace('_', '/')
93
+ try:
94
+ decoded = base64.b64decode(normalized).decode('utf-8')
95
+ parsed = json.loads(decoded)
96
+ return {
97
+ "verifier": parsed.get("verifier", ""),
98
+ "projectId": parsed.get("projectId", "")
99
+ }
100
+ except Exception:
101
+ return {"verifier": "", "projectId": ""}
102
+
103
+
104
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
105
+ """HTTP request handler for OAuth callback."""
106
+
107
+ callback_result: Optional[Dict[str, str]] = None
108
+ callback_error: Optional[str] = None
109
+
110
+ def log_message(self, format, *args):
111
+ """Suppress default logging."""
112
+ pass
113
+
114
+ def do_GET(self):
115
+ """Handle GET request for OAuth callback."""
116
+ parsed = urlparse(self.path)
117
+
118
+ if parsed.path != OAUTH_CALLBACK_PATH:
119
+ self.send_error(404, "Not Found")
120
+ return
121
+
122
+ params = parse_qs(parsed.query)
123
+ code = params.get("code", [None])[0]
124
+ state = params.get("state", [None])[0]
125
+ error = params.get("error", [None])[0]
126
+
127
+ if error:
128
+ OAuthCallbackHandler.callback_error = error
129
+ self._send_error_response(error)
130
+ elif code and state:
131
+ OAuthCallbackHandler.callback_result = {"code": code, "state": state}
132
+ self._send_success_response()
133
+ else:
134
+ OAuthCallbackHandler.callback_error = "Missing code or state parameter"
135
+ self._send_error_response("Missing parameters")
136
+
137
+ def _send_success_response(self):
138
+ """Send success HTML response."""
139
+ html = """<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <title>Authentication Successful</title>
145
+ <style>
146
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
147
+ display: flex; justify-content: center; align-items: center; height: 100vh;
148
+ margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
149
+ .container { background: white; padding: 3rem; border-radius: 1rem;
150
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3); text-align: center; max-width: 400px; }
151
+ h1 { color: #10B981; margin-bottom: 1rem; }
152
+ p { color: #6B7280; line-height: 1.6; }
153
+ .icon { font-size: 4rem; margin-bottom: 1rem; }
154
+ </style>
155
+ </head>
156
+ <body>
157
+ <div class="container">
158
+ <div class="icon">✅</div>
159
+ <h1>Authentication Successful!</h1>
160
+ <p>You have successfully authenticated with Google.<br>You can close this window and return to your terminal.</p>
161
+ </div>
162
+ </body>
163
+ </html>"""
164
+ self.send_response(200)
165
+ self.send_header("Content-Type", "text/html; charset=utf-8")
166
+ self.send_header("Content-Length", len(html.encode()))
167
+ self.end_headers()
168
+ self.wfile.write(html.encode())
169
+
170
+ def _send_error_response(self, error: str):
171
+ """Send error HTML response."""
172
+ html = f"""<!DOCTYPE html>
173
+ <html lang="en">
174
+ <head>
175
+ <meta charset="utf-8">
176
+ <title>Authentication Failed</title>
177
+ <style>
178
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
179
+ display: flex; justify-content: center; align-items: center; height: 100vh;
180
+ margin: 0; background: #FEE2E2; }}
181
+ .container {{ background: white; padding: 3rem; border-radius: 1rem;
182
+ box-shadow: 0 10px 40px rgba(0,0,0,0.1); text-align: center; }}
183
+ h1 {{ color: #EF4444; }}
184
+ p {{ color: #6B7280; }}
185
+ </style>
186
+ </head>
187
+ <body>
188
+ <div class="container">
189
+ <h1>❌ Authentication Failed</h1>
190
+ <p>Error: {error}</p>
191
+ <p>Please try again.</p>
192
+ </div>
193
+ </body>
194
+ </html>"""
195
+ self.send_response(400)
196
+ self.send_header("Content-Type", "text/html; charset=utf-8")
197
+ self.send_header("Content-Length", len(html.encode()))
198
+ self.end_headers()
199
+ self.wfile.write(html.encode())
200
+
201
+
202
+ class OAuthCallbackServer:
203
+ """Local HTTP server to capture OAuth callback."""
204
+
205
+ def __init__(self, port: int = OAUTH_CALLBACK_PORT, timeout: float = 300.0):
206
+ self.port = port
207
+ self.timeout = timeout
208
+ self.server: Optional[HTTPServer] = None
209
+ self._thread: Optional[threading.Thread] = None
210
+ self._stop_flag = False
211
+
212
+ def start(self) -> bool:
213
+ """Start the callback server. Returns True if successful."""
214
+ try:
215
+ # Reset any previous results
216
+ OAuthCallbackHandler.callback_result = None
217
+ OAuthCallbackHandler.callback_error = None
218
+ self._stop_flag = False
219
+
220
+ self.server = HTTPServer(("localhost", self.port), OAuthCallbackHandler)
221
+ self.server.timeout = 0.5 # Short timeout for responsive shutdown
222
+
223
+ self._thread = threading.Thread(target=self._serve, daemon=True)
224
+ self._thread.start()
225
+ return True
226
+ except OSError as e:
227
+ debug.log(f"Failed to start OAuth callback server: {e}")
228
+ return False
229
+
230
+ def _serve(self):
231
+ """Serve requests until shutdown or result received."""
232
+ start_time = time.time()
233
+ while not self._stop_flag and self.server:
234
+ if time.time() - start_time > self.timeout:
235
+ break
236
+ if OAuthCallbackHandler.callback_result or OAuthCallbackHandler.callback_error:
237
+ # Give browser time to receive response
238
+ time.sleep(0.3)
239
+ break
240
+ try:
241
+ self.server.handle_request()
242
+ except Exception:
243
+ break
244
+
245
+ def wait_for_callback(self) -> Optional[Dict[str, str]]:
246
+ """Wait for OAuth callback and return result."""
247
+ # Poll for result instead of blocking on thread join
248
+ start_time = time.time()
249
+ while time.time() - start_time < self.timeout:
250
+ if OAuthCallbackHandler.callback_result or OAuthCallbackHandler.callback_error:
251
+ break
252
+ time.sleep(0.1)
253
+
254
+ # Signal thread to stop
255
+ self._stop_flag = True
256
+
257
+ if self._thread:
258
+ self._thread.join(timeout=2.0)
259
+
260
+ if OAuthCallbackHandler.callback_error:
261
+ raise RuntimeError(f"OAuth error: {OAuthCallbackHandler.callback_error}")
262
+
263
+ return OAuthCallbackHandler.callback_result
264
+
265
+ def stop(self):
266
+ """Stop the callback server."""
267
+ self._stop_flag = True
268
+ if self.server:
269
+ try:
270
+ self.server.server_close()
271
+ except Exception:
272
+ pass
273
+ self.server = None
274
+
275
+
276
+ # Antigravity base URLs with fallback order
277
+ # For streaming/generation: prefer production (most stable)
278
+ # For discovery: sandbox daily may work faster
279
+ BASE_URLS = [
280
+ "https://cloudcode-pa.googleapis.com/v1internal",
281
+ "https://daily-cloudcode-pa.googleapis.com/v1internal",
282
+ "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal",
283
+ ]
284
+
285
+ # Production URL (most reliable for generation)
286
+ PRODUCTION_URL = "https://cloudcode-pa.googleapis.com/v1internal"
287
+
288
+ # Required headers for Antigravity API calls
289
+ # These headers are CRITICAL for gemini-3-pro-high/low to work
290
+ # User-Agent matches official Antigravity Electron client
291
+ ANTIGRAVITY_HEADERS = {
292
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/1.104.0 Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36",
293
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
294
+ "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
295
+ }
296
+
297
+ # Headers for auth/discovery calls (uses different User-Agent for tier detection)
298
+ ANTIGRAVITY_AUTH_HEADERS = {
299
+ "User-Agent": "google-api-nodejs-client/10.3.0",
300
+ "X-Goog-Api-Client": "gl-node/22.18.0",
301
+ "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
302
+ }
303
+
304
+
305
+ class AntigravityAuthManager(AuthFileMixin):
306
+ """
307
+ Handles OAuth2 authentication for Google's Antigravity API.
308
+
309
+ Uses Antigravity-specific OAuth credentials and supports endpoint fallback.
310
+ Manages token caching, refresh, and API calls with automatic retry on 401.
311
+ """
312
+ parent = "Antigravity"
313
+
314
+ OAUTH_REFRESH_URL = "https://oauth2.googleapis.com/token"
315
+ # Antigravity OAuth credentials
316
+ OAUTH_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
317
+ OAUTH_CLIENT_SECRET = "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"
318
+ TOKEN_BUFFER_TIME = 5 * 60 # seconds, 5 minutes
319
+ KV_TOKEN_KEY = "antigravity_oauth_token_cache"
320
+
321
+ def __init__(self, env: Dict[str, Any]):
322
+ self.env = env
323
+ self._access_token: Optional[str] = None
324
+ self._expiry: Optional[float] = None # Unix timestamp in seconds
325
+ self._token_cache = {} # In-memory cache
326
+ self._working_base_url: Optional[str] = None # Cache working endpoint
327
+ self._project_id: Optional[str] = None # Cached project ID from credentials
328
+
329
+ async def initialize_auth(self) -> None:
330
+ """
331
+ Initialize authentication by using cached token, or refreshing if needed.
332
+ Raises RuntimeError if no valid token can be obtained.
333
+ """
334
+ # Try cached token from in-memory cache
335
+ cached = await self._get_cached_token()
336
+ now = time.time()
337
+ if cached:
338
+ expires_at = cached["expiry_date"] / 1000 # ms to seconds
339
+ if expires_at - now > self.TOKEN_BUFFER_TIME:
340
+ self._access_token = cached["access_token"]
341
+ self._expiry = expires_at
342
+ return # Use cached token if valid
343
+
344
+ # Try loading from cache file or default path
345
+ path = AntigravityAuthManager.get_cache_file()
346
+ if not path.exists():
347
+ path = get_antigravity_oauth_creds_path()
348
+
349
+ if path.exists():
350
+ try:
351
+ with path.open("r") as f:
352
+ creds = json.load(f)
353
+ except Exception as e:
354
+ raise RuntimeError(f"Failed to read OAuth credentials from {path}: {e}")
355
+ else:
356
+ # Parse credentials from environment
357
+ if "ANTIGRAVITY_SERVICE_ACCOUNT" not in self.env:
358
+ raise RuntimeError(
359
+ "ANTIGRAVITY_SERVICE_ACCOUNT environment variable not set. "
360
+ f"Please set it or create credentials at {get_antigravity_oauth_creds_path()}"
361
+ )
362
+ creds = json.loads(self.env["ANTIGRAVITY_SERVICE_ACCOUNT"])
363
+
364
+ # Store project_id from credentials if available
365
+ if creds.get("project_id"):
366
+ self._project_id = creds["project_id"]
367
+
368
+ refresh_token = creds.get("refresh_token")
369
+ access_token = creds.get("access_token")
370
+ expiry_date = creds.get("expiry_date") # milliseconds since epoch
371
+
372
+ # Use original access token if still valid
373
+ if access_token and expiry_date:
374
+ expires_at = expiry_date / 1000
375
+ if expires_at - now > self.TOKEN_BUFFER_TIME:
376
+ self._access_token = access_token
377
+ self._expiry = expires_at
378
+ await self._cache_token(access_token, expiry_date)
379
+ return
380
+
381
+ # Otherwise, refresh token
382
+ if not refresh_token:
383
+ raise RuntimeError("No refresh token found in credentials.")
384
+
385
+ await self._refresh_and_cache_token(refresh_token)
386
+
387
+ async def _refresh_and_cache_token(self, refresh_token: str) -> None:
388
+ """Refresh the OAuth token and cache it."""
389
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
390
+ data = {
391
+ "client_id": self.OAUTH_CLIENT_ID,
392
+ "client_secret": self.OAUTH_CLIENT_SECRET,
393
+ "refresh_token": refresh_token,
394
+ "grant_type": "refresh_token",
395
+ }
396
+
397
+ async with aiohttp.ClientSession() as session:
398
+ async with session.post(self.OAUTH_REFRESH_URL, data=data, headers=headers) as resp:
399
+ if resp.status != 200:
400
+ text = await resp.text()
401
+ raise RuntimeError(f"Token refresh failed: {text}")
402
+ resp_data = await resp.json()
403
+ access_token = resp_data.get("access_token")
404
+ expires_in = resp_data.get("expires_in", 3600) # seconds
405
+
406
+ if not access_token:
407
+ raise RuntimeError("No access_token in refresh response.")
408
+
409
+ self._access_token = access_token
410
+ self._expiry = time.time() + expires_in
411
+
412
+ expiry_date_ms = int(self._expiry * 1000) # milliseconds
413
+ await self._cache_token(access_token, expiry_date_ms)
414
+
415
+ async def _cache_token(self, access_token: str, expiry_date: int) -> None:
416
+ """Cache token in memory."""
417
+ token_data = {
418
+ "access_token": access_token,
419
+ "expiry_date": expiry_date,
420
+ "cached_at": int(time.time() * 1000), # ms
421
+ }
422
+ self._token_cache[self.KV_TOKEN_KEY] = token_data
423
+
424
+ async def _get_cached_token(self) -> Optional[Dict[str, Any]]:
425
+ """Return in-memory cached token if present and still valid."""
426
+ cached = self._token_cache.get(self.KV_TOKEN_KEY)
427
+ if cached:
428
+ expires_at = cached["expiry_date"] / 1000
429
+ if expires_at - time.time() > self.TOKEN_BUFFER_TIME:
430
+ return cached
431
+ return None
432
+
433
+ async def clear_token_cache(self) -> None:
434
+ """Clear the token cache."""
435
+ self._access_token = None
436
+ self._expiry = None
437
+ self._token_cache.pop(self.KV_TOKEN_KEY, None)
438
+
439
+ def get_access_token(self) -> Optional[str]:
440
+ """Return current valid access token or None."""
441
+ if (
442
+ self._access_token is not None
443
+ and self._expiry is not None
444
+ and self._expiry - time.time() > self.TOKEN_BUFFER_TIME
445
+ ):
446
+ return self._access_token
447
+ return None
448
+
449
+ def get_project_id(self) -> Optional[str]:
450
+ """Return cached project ID from credentials."""
451
+ return self._project_id
452
+
453
+ async def call_endpoint(
454
+ self,
455
+ method: str,
456
+ body: Dict[str, Any],
457
+ is_retry: bool = False,
458
+ use_auth_headers: bool = False
459
+ ) -> Any:
460
+ """
461
+ Call Antigravity API endpoint with JSON body and endpoint fallback.
462
+
463
+ Tries each base URL in order until one succeeds.
464
+ Automatically retries once on 401 Unauthorized by refreshing auth.
465
+ """
466
+ if not self.get_access_token():
467
+ await self.initialize_auth()
468
+
469
+ headers = {
470
+ "Content-Type": "application/json",
471
+ "Authorization": f"Bearer {self.get_access_token()}",
472
+ **(ANTIGRAVITY_AUTH_HEADERS if use_auth_headers else ANTIGRAVITY_HEADERS),
473
+ }
474
+
475
+ # Try cached working URL first, then fallback chain
476
+ urls_to_try = []
477
+ if self._working_base_url:
478
+ urls_to_try.append(self._working_base_url)
479
+ urls_to_try.extend([url for url in BASE_URLS if url != self._working_base_url])
480
+
481
+ last_error = None
482
+ for base_url in urls_to_try:
483
+ url = f"{base_url}:{method}"
484
+ try:
485
+ async with aiohttp.ClientSession() as session:
486
+ async with session.post(url, headers=headers, json=body, timeout=30) as resp:
487
+ if resp.status == 401 and not is_retry:
488
+ # Token likely expired, clear and retry once
489
+ await self.clear_token_cache()
490
+ await self.initialize_auth()
491
+ return await self.call_endpoint(method, body, is_retry=True, use_auth_headers=use_auth_headers)
492
+ elif resp.ok:
493
+ self._working_base_url = base_url # Cache working URL
494
+ return await resp.json()
495
+ else:
496
+ last_error = f"HTTP {resp.status}: {await resp.text()}"
497
+ debug.log(f"Antigravity endpoint {base_url} returned {resp.status}")
498
+ except Exception as e:
499
+ last_error = str(e)
500
+ debug.log(f"Antigravity endpoint {base_url} failed: {e}")
501
+ continue
502
+
503
+ raise RuntimeError(f"All Antigravity endpoints failed. Last error: {last_error}")
504
+
505
+ def get_working_base_url(self) -> str:
506
+ """Get the cached working base URL or default to first in list."""
507
+ return self._working_base_url or BASE_URLS[0]
508
+
509
+ @classmethod
510
+ def build_authorization_url(cls, project_id: str = "") -> Tuple[str, str, str]:
511
+ """
512
+ Build OAuth authorization URL with PKCE.
513
+
514
+ Returns:
515
+ Tuple of (authorization_url, verifier, state)
516
+ """
517
+ verifier, challenge = generate_pkce_pair()
518
+ state = encode_oauth_state(verifier, project_id)
519
+
520
+ params = {
521
+ "client_id": cls.OAUTH_CLIENT_ID,
522
+ "response_type": "code",
523
+ "redirect_uri": ANTIGRAVITY_REDIRECT_URI,
524
+ "scope": " ".join(ANTIGRAVITY_SCOPES),
525
+ "code_challenge": challenge,
526
+ "code_challenge_method": "S256",
527
+ "state": state,
528
+ "access_type": "offline",
529
+ "prompt": "consent",
530
+ }
531
+
532
+ url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
533
+ return url, verifier, state
534
+
535
+ @classmethod
536
+ async def exchange_code_for_tokens(
537
+ cls,
538
+ code: str,
539
+ state: str,
540
+ ) -> Dict[str, Any]:
541
+ """
542
+ Exchange authorization code for access and refresh tokens.
543
+
544
+ Args:
545
+ code: Authorization code from OAuth callback
546
+ state: State parameter containing PKCE verifier
547
+
548
+ Returns:
549
+ Dict containing tokens and user info
550
+ """
551
+ decoded_state = decode_oauth_state(state)
552
+ verifier = decoded_state.get("verifier", "")
553
+ project_id = decoded_state.get("projectId", "")
554
+
555
+ if not verifier:
556
+ raise RuntimeError("Missing PKCE verifier in state parameter")
557
+
558
+ start_time = time.time()
559
+
560
+ # Exchange code for tokens
561
+ async with aiohttp.ClientSession() as session:
562
+ token_data = {
563
+ "client_id": cls.OAUTH_CLIENT_ID,
564
+ "client_secret": cls.OAUTH_CLIENT_SECRET,
565
+ "code": code,
566
+ "grant_type": "authorization_code",
567
+ "redirect_uri": ANTIGRAVITY_REDIRECT_URI,
568
+ "code_verifier": verifier,
569
+ }
570
+
571
+ async with session.post(
572
+ "https://oauth2.googleapis.com/token",
573
+ data=token_data,
574
+ headers={
575
+ "Content-Type": "application/x-www-form-urlencoded",
576
+ "User-Agent": "google-api-nodejs-client/10.3.0",
577
+ }
578
+ ) as resp:
579
+ if not resp.ok:
580
+ error_text = await resp.text()
581
+ raise RuntimeError(f"Token exchange failed: {error_text}")
582
+
583
+ token_response = await resp.json()
584
+
585
+ access_token = token_response.get("access_token")
586
+ refresh_token = token_response.get("refresh_token")
587
+ expires_in = token_response.get("expires_in", 3600)
588
+
589
+ if not access_token or not refresh_token:
590
+ raise RuntimeError("Missing tokens in response")
591
+
592
+ # Get user info
593
+ email = None
594
+ async with session.get(
595
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
596
+ headers={"Authorization": f"Bearer {access_token}"}
597
+ ) as resp:
598
+ if resp.ok:
599
+ user_info = await resp.json()
600
+ email = user_info.get("email")
601
+
602
+ # Discover project ID if not provided
603
+ effective_project_id = project_id
604
+ if not effective_project_id:
605
+ effective_project_id = await cls._fetch_project_id(session, access_token)
606
+
607
+ expires_at = int((start_time + expires_in) * 1000) # milliseconds
608
+
609
+ return {
610
+ "access_token": access_token,
611
+ "refresh_token": refresh_token,
612
+ "expiry_date": expires_at,
613
+ "email": email,
614
+ "project_id": effective_project_id,
615
+ }
616
+
617
+ @classmethod
618
+ async def _fetch_project_id(cls, session: aiohttp.ClientSession, access_token: str) -> str:
619
+ """Fetch project ID from Antigravity API."""
620
+ headers = {
621
+ "Authorization": f"Bearer {access_token}",
622
+ "Content-Type": "application/json",
623
+ **ANTIGRAVITY_AUTH_HEADERS,
624
+ }
625
+
626
+ load_request = {
627
+ "metadata": {
628
+ "ideType": "IDE_UNSPECIFIED",
629
+ "platform": "PLATFORM_UNSPECIFIED",
630
+ "pluginType": "GEMINI",
631
+ }
632
+ }
633
+
634
+ # Try endpoints in order with short timeout
635
+ timeout = aiohttp.ClientTimeout(total=10)
636
+ for base_url in BASE_URLS:
637
+ try:
638
+ url = f"{base_url}:loadCodeAssist"
639
+ async with session.post(url, headers=headers, json=load_request, timeout=timeout) as resp:
640
+ if resp.ok:
641
+ data = await resp.json()
642
+ project = data.get("cloudaicompanionProject")
643
+ if isinstance(project, dict):
644
+ project = project.get("id")
645
+ if project:
646
+ return project
647
+ except asyncio.TimeoutError:
648
+ debug.log(f"Project discovery timed out at {base_url}")
649
+ continue
650
+ except Exception as e:
651
+ debug.log(f"Project discovery failed at {base_url}: {e}")
652
+ continue
653
+
654
+ return ""
655
+
656
+ @classmethod
657
+ async def interactive_login(
658
+ cls,
659
+ project_id: str = "",
660
+ no_browser: bool = False,
661
+ timeout: float = 300.0,
662
+ ) -> Dict[str, Any]:
663
+ """
664
+ Perform interactive OAuth login flow.
665
+
666
+ This opens a browser for Google OAuth and captures the callback locally.
667
+
668
+ Args:
669
+ project_id: Optional GCP project ID
670
+ no_browser: If True, don't auto-open browser (print URL instead)
671
+ timeout: Timeout in seconds for OAuth callback
672
+
673
+ Returns:
674
+ Dict containing tokens and user info
675
+ """
676
+ # Build authorization URL
677
+ auth_url, verifier, state = cls.build_authorization_url(project_id)
678
+
679
+ print("\n" + "=" * 60)
680
+ print("Antigravity OAuth Login")
681
+ print("=" * 60)
682
+
683
+ # Try to start local callback server
684
+ callback_server = OAuthCallbackServer(timeout=timeout)
685
+ server_started = callback_server.start()
686
+
687
+ if server_started and not no_browser:
688
+ print(f"\nOpening browser for authentication...")
689
+ print(f"If browser doesn't open, visit this URL:\n")
690
+ print(f"{auth_url}\n")
691
+
692
+ # Try to open browser
693
+ try:
694
+ webbrowser.open(auth_url)
695
+ except Exception as e:
696
+ print(f"Could not open browser automatically: {e}")
697
+ print("Please open the URL above manually.\n")
698
+ else:
699
+ if not server_started:
700
+ print(f"\nCould not start local callback server on port {OAUTH_CALLBACK_PORT}.")
701
+ print("You may need to close any application using that port.\n")
702
+
703
+ print(f"\nPlease open this URL in your browser:\n")
704
+ print(f"{auth_url}\n")
705
+
706
+ if server_started:
707
+ print("Waiting for authentication callback...")
708
+
709
+ try:
710
+ callback_result = callback_server.wait_for_callback()
711
+
712
+ if not callback_result:
713
+ raise RuntimeError("OAuth callback timed out")
714
+
715
+ code = callback_result.get("code")
716
+ callback_state = callback_result.get("state")
717
+
718
+ if not code:
719
+ raise RuntimeError("No authorization code received")
720
+
721
+ print("\n✓ Authorization code received. Exchanging for tokens...")
722
+
723
+ # Exchange code for tokens
724
+ tokens = await cls.exchange_code_for_tokens(code, callback_state or state)
725
+
726
+ print(f"✓ Authentication successful!")
727
+ if tokens.get("email"):
728
+ print(f" Logged in as: {tokens['email']}")
729
+ if tokens.get("project_id"):
730
+ print(f" Project ID: {tokens['project_id']}")
731
+
732
+ return tokens
733
+
734
+ finally:
735
+ callback_server.stop()
736
+ else:
737
+ # Manual flow - ask user to paste the redirect URL or code
738
+ print("\nAfter completing authentication, you'll be redirected to a localhost URL.")
739
+ print("Copy and paste the full redirect URL or just the authorization code below:\n")
740
+
741
+ user_input = input("Paste redirect URL or code: ").strip()
742
+
743
+ if not user_input:
744
+ raise RuntimeError("No input provided")
745
+
746
+ # Parse the input
747
+ if user_input.startswith("http"):
748
+ parsed = urlparse(user_input)
749
+ params = parse_qs(parsed.query)
750
+ code = params.get("code", [None])[0]
751
+ callback_state = params.get("state", [state])[0]
752
+ else:
753
+ # Assume it's just the code
754
+ code = user_input
755
+ callback_state = state
756
+
757
+ if not code:
758
+ raise RuntimeError("Could not extract authorization code")
759
+
760
+ print("\nExchanging code for tokens...")
761
+ tokens = await cls.exchange_code_for_tokens(code, callback_state)
762
+
763
+ print(f"✓ Authentication successful!")
764
+ if tokens.get("email"):
765
+ print(f" Logged in as: {tokens['email']}")
766
+
767
+ return tokens
768
+
769
+ @classmethod
770
+ async def login_and_save(
771
+ cls,
772
+ project_id: str = "",
773
+ no_browser: bool = False,
774
+ credentials_path: Optional[Path] = None,
775
+ ) -> "AntigravityAuthManager":
776
+ """
777
+ Perform interactive login and save credentials to file.
778
+
779
+ Args:
780
+ project_id: Optional GCP project ID
781
+ no_browser: If True, don't auto-open browser
782
+ credentials_path: Path to save credentials (default: g4f cache or ~/.antigravity/oauth_creds.json)
783
+
784
+ Returns:
785
+ AntigravityAuthManager instance with loaded credentials
786
+ """
787
+ tokens = await cls.interactive_login(project_id=project_id, no_browser=no_browser)
788
+
789
+ # Prepare credentials for saving
790
+ creds = {
791
+ "access_token": tokens["access_token"],
792
+ "refresh_token": tokens["refresh_token"],
793
+ "expiry_date": tokens["expiry_date"],
794
+ "email": tokens.get("email"),
795
+ "project_id": tokens.get("project_id"),
796
+ "client_id": cls.OAUTH_CLIENT_ID,
797
+ "client_secret": cls.OAUTH_CLIENT_SECRET,
798
+ }
799
+
800
+ # Save credentials - use provided path, or g4f cache file, or default path
801
+ if credentials_path:
802
+ path = credentials_path
803
+ else:
804
+ # Prefer g4f cache location (checked first by initialize_auth)
805
+ path = cls.get_cache_file()
806
+
807
+ path.parent.mkdir(parents=True, exist_ok=True)
808
+
809
+ with path.open("w") as f:
810
+ json.dump(creds, f, indent=2)
811
+
812
+ # Set restrictive permissions on Unix
813
+ try:
814
+ path.chmod(0o600)
815
+ except Exception:
816
+ pass
817
+
818
+ print(f"\n✓ Credentials saved to: {path}")
819
+ print("=" * 60 + "\n")
820
+
821
+ # Create and return auth manager
822
+ auth_manager = cls(env=os.environ)
823
+ auth_manager._access_token = tokens["access_token"]
824
+ auth_manager._expiry = tokens["expiry_date"] / 1000
825
+
826
+ return auth_manager
827
+
828
+
829
+ class AntigravityProvider:
830
+ """
831
+ Internal provider class for Antigravity API communication.
832
+
833
+ Handles message formatting, project discovery, and streaming content generation.
834
+ """
835
+ url = "https://cloud.google.com/code-assist"
836
+
837
+ def __init__(self, env: dict, auth_manager: AntigravityAuthManager):
838
+ self.env = env
839
+ self.auth_manager = auth_manager
840
+ self._project_id: Optional[str] = None
841
+
842
+ async def discover_project_id(self) -> str:
843
+ """Discover the GCP project ID for API calls."""
844
+ # Check environment variable first
845
+ if self.env.get("ANTIGRAVITY_PROJECT_ID"):
846
+ return self.env["ANTIGRAVITY_PROJECT_ID"]
847
+
848
+ # Check cached project ID
849
+ if self._project_id:
850
+ return self._project_id
851
+
852
+ # Check auth manager's cached project ID (from credentials file)
853
+ auth_project_id = self.auth_manager.get_project_id()
854
+ if auth_project_id:
855
+ self._project_id = auth_project_id
856
+ return auth_project_id
857
+
858
+ # Fall back to API discovery
859
+ try:
860
+ load_response = await self.auth_manager.call_endpoint(
861
+ "loadCodeAssist",
862
+ {
863
+ "cloudaicompanionProject": "default-project",
864
+ "metadata": {"duetProject": "default-project"},
865
+ },
866
+ use_auth_headers=True,
867
+ )
868
+
869
+ # Handle both string and object formats for cloudaicompanionProject
870
+ project = load_response.get("cloudaicompanionProject")
871
+ if isinstance(project, dict):
872
+ project = project.get("id")
873
+
874
+ if project:
875
+ self._project_id = project
876
+ return project
877
+
878
+ raise RuntimeError(
879
+ "Project ID discovery failed - set ANTIGRAVITY_PROJECT_ID in environment."
880
+ )
881
+ except Exception as e:
882
+ debug.error(f"Failed to discover project ID: {e}")
883
+ raise RuntimeError(
884
+ "Could not discover project ID. Ensure authentication or set ANTIGRAVITY_PROJECT_ID."
885
+ )
886
+
887
+ @staticmethod
888
+ def _messages_to_gemini_format(messages: list, media: MediaListType) -> List[Dict[str, Any]]:
889
+ """Convert OpenAI-style messages to Gemini format."""
890
+ format_messages = []
891
+ for msg in messages:
892
+ role = "model" if msg["role"] == "assistant" else "user"
893
+
894
+ # Handle tool role (OpenAI style)
895
+ if msg["role"] == "tool":
896
+ parts = [
897
+ {
898
+ "functionResponse": {
899
+ "name": msg.get("tool_call_id", "unknown_function"),
900
+ "response": {
901
+ "result": (
902
+ msg["content"]
903
+ if isinstance(msg["content"], str)
904
+ else json.dumps(msg["content"])
905
+ )
906
+ },
907
+ }
908
+ }
909
+ ]
910
+
911
+ # Handle assistant messages with tool calls
912
+ elif msg["role"] == "assistant" and msg.get("tool_calls"):
913
+ parts = []
914
+ if isinstance(msg["content"], str) and msg["content"].strip():
915
+ parts.append({"text": msg["content"]})
916
+ for tool_call in msg["tool_calls"]:
917
+ if tool_call.get("type") == "function":
918
+ parts.append(
919
+ {
920
+ "functionCall": {
921
+ "name": tool_call["function"]["name"],
922
+ "args": json.loads(tool_call["function"]["arguments"]),
923
+ }
924
+ }
925
+ )
926
+
927
+ # Handle string content
928
+ elif isinstance(msg["content"], str):
929
+ parts = [{"text": msg["content"]}]
930
+
931
+ # Handle array content (possibly multimodal)
932
+ elif isinstance(msg["content"], list):
933
+ parts = []
934
+ for content in msg["content"]:
935
+ ctype = content.get("type")
936
+ if ctype == "text":
937
+ parts.append({"text": content["text"]})
938
+ elif ctype == "image_url":
939
+ image_url = content.get("image_url", {}).get("url")
940
+ if not image_url:
941
+ continue
942
+ if image_url.startswith("data:"):
943
+ # Inline base64 data image
944
+ prefix, b64data = image_url.split(",", 1)
945
+ mime_type = prefix.split(":")[1].split(";")[0]
946
+ parts.append({"inlineData": {"mimeType": mime_type, "data": b64data}})
947
+ else:
948
+ parts.append(
949
+ {
950
+ "fileData": {
951
+ "mimeType": "image/jpeg",
952
+ "fileUri": image_url,
953
+ }
954
+ }
955
+ )
956
+ else:
957
+ parts = [{"text": str(msg["content"])}]
958
+
959
+ format_messages.append({"role": role, "parts": parts})
960
+
961
+ # Handle media attachments
962
+ if media:
963
+ if not format_messages:
964
+ format_messages.append({"role": "user", "parts": []})
965
+ for media_data, filename in media:
966
+ if isinstance(media_data, str):
967
+ if not filename:
968
+ filename = media_data
969
+ extension = filename.split(".")[-1].replace("jpg", "jpeg")
970
+ format_messages[-1]["parts"].append(
971
+ {
972
+ "fileData": {
973
+ "mimeType": f"image/{extension}",
974
+ "fileUri": media_data,
975
+ }
976
+ }
977
+ )
978
+ else:
979
+ media_data = to_bytes(media_data)
980
+ format_messages[-1]["parts"].append({
981
+ "inlineData": {
982
+ "mimeType": is_data_an_media(media_data, filename),
983
+ "data": base64.b64encode(media_data).decode()
984
+ }
985
+ })
986
+
987
+ return format_messages
988
+
989
+ async def stream_content(
990
+ self,
991
+ model: str,
992
+ messages: Messages,
993
+ *,
994
+ proxy: Optional[str] = None,
995
+ thinking_budget: Optional[int] = None,
996
+ tools: Optional[List[dict]] = None,
997
+ tool_choice: Optional[str] = None,
998
+ max_tokens: Optional[int] = None,
999
+ temperature: Optional[float] = None,
1000
+ top_p: Optional[float] = None,
1001
+ stop: Optional[Union[str, List[str]]] = None,
1002
+ presence_penalty: Optional[float] = None,
1003
+ frequency_penalty: Optional[float] = None,
1004
+ seed: Optional[int] = None,
1005
+ response_format: Optional[Dict[str, Any]] = None,
1006
+ **kwargs
1007
+ ) -> AsyncGenerator:
1008
+ """Stream content generation from Antigravity API."""
1009
+ # Convert user-facing model name to internal API name
1010
+ if model in Antigravity.model_aliases:
1011
+ model = Antigravity.model_aliases[model]
1012
+
1013
+ await self.auth_manager.initialize_auth()
1014
+
1015
+ project_id = await self.discover_project_id()
1016
+
1017
+ # Convert messages to Gemini format
1018
+ contents = self._messages_to_gemini_format(
1019
+ [m for m in messages if m["role"] not in ["developer", "system"]],
1020
+ media=kwargs.get("media", None)
1021
+ )
1022
+ system_prompt = get_system_prompt(messages)
1023
+ request_data = {}
1024
+ if system_prompt:
1025
+ request_data["system_instruction"] = {"parts": {"text": system_prompt}}
1026
+
1027
+ # Convert OpenAI-style tools to Gemini format
1028
+ gemini_tools = None
1029
+ function_declarations = []
1030
+ if tools:
1031
+ for tool in tools:
1032
+ if tool.get("type") == "function" and "function" in tool:
1033
+ func = tool["function"]
1034
+ function_declarations.append({
1035
+ "name": func.get("name"),
1036
+ "description": func.get("description", ""),
1037
+ "parameters": func.get("parameters", {})
1038
+ })
1039
+ if function_declarations:
1040
+ gemini_tools = [{"functionDeclarations": function_declarations}]
1041
+
1042
+ # Build generation config
1043
+ generation_config = {
1044
+ "maxOutputTokens": max_tokens or 32000, # Antigravity default
1045
+ "temperature": temperature,
1046
+ "topP": top_p,
1047
+ "stop": stop,
1048
+ "presencePenalty": presence_penalty,
1049
+ "frequencyPenalty": frequency_penalty,
1050
+ "seed": seed,
1051
+ }
1052
+
1053
+ # Handle response format
1054
+ if response_format is not None and response_format.get("type") == "json_object":
1055
+ generation_config["responseMimeType"] = "application/json"
1056
+
1057
+ # Handle thinking configuration
1058
+ if thinking_budget:
1059
+ generation_config["thinkingConfig"] = {
1060
+ "thinkingBudget": thinking_budget,
1061
+ "includeThoughts": True
1062
+ }
1063
+
1064
+ # Compose request body with required Antigravity fields
1065
+ req_body = {
1066
+ "model": model,
1067
+ "project": project_id,
1068
+ "userAgent": "antigravity",
1069
+ "requestType": "agent",
1070
+ "requestId": f"req-{secrets.token_hex(8)}",
1071
+ "request": {
1072
+ "contents": contents,
1073
+ "generationConfig": generation_config,
1074
+ "tools": gemini_tools,
1075
+ **request_data
1076
+ },
1077
+ }
1078
+
1079
+ # Add tool config if specified
1080
+ if tool_choice and gemini_tools:
1081
+ req_body["request"]["toolConfig"] = {
1082
+ "functionCallingConfig": {
1083
+ "mode": tool_choice.upper(),
1084
+ "allowedFunctionNames": [fd["name"] for fd in function_declarations]
1085
+ }
1086
+ }
1087
+
1088
+ # Remove None values recursively
1089
+ def clean_none(d):
1090
+ if isinstance(d, dict):
1091
+ return {k: clean_none(v) for k, v in d.items() if v is not None}
1092
+ if isinstance(d, list):
1093
+ return [clean_none(x) for x in d if x is not None]
1094
+ return d
1095
+
1096
+ req_body = clean_none(req_body)
1097
+
1098
+ headers = {
1099
+ "Content-Type": "application/json",
1100
+ "Authorization": f"Bearer {self.auth_manager.get_access_token()}",
1101
+ **ANTIGRAVITY_HEADERS,
1102
+ }
1103
+
1104
+ # Use production URL for streaming (most reliable)
1105
+ base_url = PRODUCTION_URL
1106
+ url = f"{base_url}:streamGenerateContent?alt=sse"
1107
+
1108
+ # Streaming SSE parsing helper
1109
+ async def parse_sse_stream(stream: aiohttp.StreamReader) -> AsyncGenerator[Dict[str, Any], None]:
1110
+ """Parse SSE stream yielding parsed JSON objects."""
1111
+ buffer = ""
1112
+ object_buffer = ""
1113
+
1114
+ async for chunk_bytes in stream.iter_any():
1115
+ chunk = chunk_bytes.decode()
1116
+ buffer += chunk
1117
+ lines = buffer.split("\n")
1118
+ buffer = lines.pop() # Save last incomplete line back
1119
+
1120
+ for line in lines:
1121
+ line = line.strip()
1122
+ if line == "":
1123
+ # Empty line indicates end of SSE message -> parse object buffer
1124
+ if object_buffer:
1125
+ try:
1126
+ yield json.loads(object_buffer)
1127
+ except Exception as e:
1128
+ debug.error(f"Error parsing SSE JSON: {e}")
1129
+ object_buffer = ""
1130
+ elif line.startswith("data: "):
1131
+ object_buffer += line[6:]
1132
+
1133
+ # Final parse when stream ends
1134
+ if object_buffer:
1135
+ try:
1136
+ yield json.loads(object_buffer)
1137
+ except Exception as e:
1138
+ debug.error(f"Error parsing final SSE JSON: {e}")
1139
+
1140
+ timeout = ClientTimeout(total=None) # No total timeout
1141
+ connector = get_connector(None, proxy)
1142
+
1143
+ async with ClientSession(headers=headers, timeout=timeout, connector=connector) as session:
1144
+ async with session.post(url, json=req_body) as resp:
1145
+ if not resp.ok:
1146
+ if resp.status == 401:
1147
+ raise MissingAuthError("Unauthorized (401) from Antigravity API")
1148
+ error_body = await resp.text()
1149
+ raise RuntimeError(f"Antigravity API error {resp.status}: {error_body}")
1150
+
1151
+ usage_metadata = {}
1152
+ async for json_data in parse_sse_stream(resp.content):
1153
+ # Process JSON data according to Gemini API structure
1154
+ candidates = json_data.get("response", {}).get("candidates", [])
1155
+ usage_metadata = json_data.get("response", {}).get("usageMetadata", usage_metadata)
1156
+
1157
+ if not candidates:
1158
+ continue
1159
+
1160
+ candidate = candidates[0]
1161
+ content = candidate.get("content", {})
1162
+ parts = content.get("parts", [])
1163
+
1164
+ tool_calls = []
1165
+
1166
+ for part in parts:
1167
+ # Real thinking chunks
1168
+ if part.get("thought") is True and "text" in part:
1169
+ yield Reasoning(part["text"])
1170
+
1171
+ # Function calls from Gemini
1172
+ elif "functionCall" in part:
1173
+ tool_calls.append(part["functionCall"])
1174
+
1175
+ # Text content
1176
+ elif "text" in part:
1177
+ yield part["text"]
1178
+
1179
+ # Inline media data
1180
+ elif "inlineData" in part:
1181
+ async for media in save_response_media(part["inlineData"], format_media_prompt(messages)):
1182
+ yield media
1183
+
1184
+ # File data (e.g. external image)
1185
+ elif "fileData" in part:
1186
+ file_data = part["fileData"]
1187
+ yield ImageResponse(file_data.get("fileUri"))
1188
+
1189
+ if tool_calls:
1190
+ # Convert Gemini tool calls to OpenAI format
1191
+ openai_tool_calls = []
1192
+ for i, tc in enumerate(tool_calls):
1193
+ openai_tool_calls.append({
1194
+ "id": f"call_{i}_{tc.get('name', 'unknown')}",
1195
+ "type": "function",
1196
+ "function": {
1197
+ "name": tc.get("name"),
1198
+ "arguments": json.dumps(tc.get("args", {}))
1199
+ }
1200
+ })
1201
+ yield ToolCalls(openai_tool_calls)
1202
+
1203
+ if usage_metadata:
1204
+ yield Usage(**usage_metadata)
1205
+
1206
+
1207
+ class Antigravity(AsyncGeneratorProvider, ProviderModelMixin):
1208
+ """
1209
+ Antigravity Provider for gpt4free.
1210
+
1211
+ Provides access to Google's Antigravity API (Code Assist) supporting:
1212
+ - Gemini 2.5 Pro/Flash with extended thinking
1213
+ - Gemini 3 Pro/Flash (preview)
1214
+ - Claude Sonnet 4.5 / Opus 4.5 via Antigravity proxy
1215
+
1216
+ Requires OAuth2 credentials. Set ANTIGRAVITY_SERVICE_ACCOUNT environment
1217
+ variable or create credentials at ~/.antigravity/oauth_creds.json
1218
+ """
1219
+ label = "Google Antigravity"
1220
+ login_url = "https://cloud.google.com/code-assist"
1221
+ url = "https://antigravity.google"
1222
+
1223
+ default_model = "gemini-3-pro-preview"
1224
+ fallback_models = [
1225
+ # Gemini 2.5 models
1226
+ "gemini-2.5-pro",
1227
+ "gemini-2.5-flash",
1228
+ "gemini-2.5-flash-lite",
1229
+ # Gemini 3 models
1230
+ "gemini-3-pro-preview",
1231
+ "gemini-3-flash",
1232
+ # Claude models (via Antigravity proxy)
1233
+ "claude-sonnet-4.5",
1234
+ "claude-opus-4.5",
1235
+ ]
1236
+
1237
+ # Model aliases for compatibility
1238
+ model_aliases = {
1239
+ "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p",
1240
+ "gemini-3-pro-image-preview": "gemini-3-pro-image",
1241
+ "claude-sonnet-4-5": "claude-sonnet-4.5",
1242
+ "claude-opus-4-5": "claude-opus-4.5",
1243
+ }
1244
+
1245
+ working = True
1246
+ supports_message_history = True
1247
+ supports_system_message = True
1248
+ needs_auth = True
1249
+ active_by_default = True
1250
+
1251
+ auth_manager: AntigravityAuthManager = None
1252
+ _dynamic_models: List[str] = None
1253
+
1254
+ @classmethod
1255
+ def get_models(cls, **kwargs) -> List[str]:
1256
+ """Return available models, fetching dynamically from API if authenticated."""
1257
+ # Try to fetch models dynamically if we have credentials
1258
+ if not cls.models and cls.has_credentials():
1259
+ try:
1260
+ import asyncio
1261
+ cls._dynamic_models = asyncio.get_event_loop().run_until_complete(
1262
+ cls._fetch_models()
1263
+ )
1264
+ except RuntimeError:
1265
+ # No event loop running, try creating one
1266
+ try:
1267
+ cls._dynamic_models = asyncio.run(cls._fetch_models())
1268
+ except Exception as e:
1269
+ debug.log(f"Failed to fetch dynamic models: {e}")
1270
+ except Exception as e:
1271
+ debug.log(f"Failed to fetch dynamic models: {e}")
1272
+
1273
+ # Update live status
1274
+ if cls.live == 0:
1275
+ if cls.auth_manager is None:
1276
+ cls.auth_manager = AntigravityAuthManager(env=os.environ)
1277
+ if cls.auth_manager.get_access_token() is not None:
1278
+ cls.live += 1
1279
+
1280
+ return cls.models if cls.models else cls.fallback_models
1281
+
1282
+ @classmethod
1283
+ async def create_async_generator(
1284
+ cls,
1285
+ model: str,
1286
+ messages: Messages,
1287
+ stream: bool = False,
1288
+ media: MediaListType = None,
1289
+ tools: Optional[list] = None,
1290
+ **kwargs
1291
+ ) -> AsyncResult:
1292
+ """Create an async generator for streaming responses."""
1293
+ if cls.auth_manager is None:
1294
+ cls.auth_manager = AntigravityAuthManager(env=os.environ)
1295
+
1296
+ # Apply model alias if needed
1297
+ if model in cls.model_aliases:
1298
+ model = cls.model_aliases[model]
1299
+
1300
+ # Initialize Antigravity provider with auth manager and environment
1301
+ provider = AntigravityProvider(env=os.environ, auth_manager=cls.auth_manager)
1302
+
1303
+ async for chunk in provider.stream_content(
1304
+ model=model,
1305
+ messages=messages,
1306
+ stream=stream,
1307
+ media=media,
1308
+ tools=tools,
1309
+ **kwargs
1310
+ ):
1311
+ yield chunk
1312
+
1313
+ @classmethod
1314
+ async def login(
1315
+ cls,
1316
+ project_id: str = "",
1317
+ no_browser: bool = False,
1318
+ credentials_path: Optional[Path] = None,
1319
+ ) -> "AntigravityAuthManager":
1320
+ """
1321
+ Perform interactive OAuth login and save credentials.
1322
+
1323
+ This is the main entry point for authenticating with Antigravity.
1324
+
1325
+ Args:
1326
+ project_id: Optional GCP project ID
1327
+ no_browser: If True, don't auto-open browser
1328
+ credentials_path: Path to save credentials
1329
+
1330
+ Returns:
1331
+ AntigravityAuthManager with active credentials
1332
+
1333
+ Example:
1334
+ >>> import asyncio
1335
+ >>> from g4f.Provider.needs_auth import Antigravity
1336
+ >>> asyncio.run(Antigravity.login())
1337
+ """
1338
+ auth_manager = await AntigravityAuthManager.login_and_save(
1339
+ project_id=project_id,
1340
+ no_browser=no_browser,
1341
+ credentials_path=credentials_path,
1342
+ )
1343
+ cls.auth_manager = auth_manager
1344
+ return auth_manager
1345
+
1346
+ @classmethod
1347
+ def has_credentials(cls) -> bool:
1348
+ """Check if valid credentials exist."""
1349
+ # Check g4f cache file (checked first by initialize_auth)
1350
+ cache_path = AntigravityAuthManager.get_cache_file()
1351
+ if cache_path.exists():
1352
+ return True
1353
+
1354
+ # Check default path (~/.antigravity/oauth_creds.json)
1355
+ default_path = get_antigravity_oauth_creds_path()
1356
+ if default_path.exists():
1357
+ return True
1358
+
1359
+ # Check environment variable
1360
+ if "ANTIGRAVITY_SERVICE_ACCOUNT" in os.environ:
1361
+ return True
1362
+
1363
+ return False
1364
+
1365
+ @classmethod
1366
+ def get_credentials_path(cls) -> Path:
1367
+ """Get the path where credentials are stored or should be stored."""
1368
+ # Check g4f cache file first (matches initialize_auth order)
1369
+ cache_path = AntigravityAuthManager.get_cache_file()
1370
+ if cache_path.exists():
1371
+ return cache_path
1372
+
1373
+ # Check default path
1374
+ default_path = get_antigravity_oauth_creds_path()
1375
+ if default_path.exists():
1376
+ return default_path
1377
+
1378
+ # Return cache path as the preferred location for new credentials
1379
+ return cache_path
1380
+
1381
+
1382
+ async def main():
1383
+ """CLI entry point for Antigravity authentication."""
1384
+ import argparse
1385
+
1386
+ parser = argparse.ArgumentParser(
1387
+ description="Antigravity OAuth Authentication for gpt4free",
1388
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1389
+ epilog="""
1390
+ Examples:
1391
+ %(prog)s login # Interactive login with browser
1392
+ %(prog)s login --no-browser # Manual login (paste URL)
1393
+ %(prog)s login --project-id ID # Login with specific project
1394
+ %(prog)s status # Check authentication status
1395
+ %(prog)s logout # Remove saved credentials
1396
+ """
1397
+ )
1398
+
1399
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
1400
+
1401
+ # Login command
1402
+ login_parser = subparsers.add_parser("login", help="Authenticate with Google")
1403
+ login_parser.add_argument(
1404
+ "--project-id", "-p",
1405
+ default="",
1406
+ help="Google Cloud project ID (optional, auto-discovered if not set)"
1407
+ )
1408
+ login_parser.add_argument(
1409
+ "--no-browser", "-n",
1410
+ action="store_true",
1411
+ help="Don't auto-open browser, print URL instead"
1412
+ )
1413
+
1414
+ # Status command
1415
+ subparsers.add_parser("status", help="Check authentication status")
1416
+
1417
+ # Logout command
1418
+ subparsers.add_parser("logout", help="Remove saved credentials")
1419
+
1420
+ args = parser.parse_args()
1421
+
1422
+ if args.command == "login":
1423
+ try:
1424
+ await Antigravity.login(
1425
+ project_id=args.project_id,
1426
+ no_browser=args.no_browser,
1427
+ )
1428
+ except KeyboardInterrupt:
1429
+ print("\n\nLogin cancelled.")
1430
+ sys.exit(1)
1431
+ except Exception as e:
1432
+ print(f"\n❌ Login failed: {e}")
1433
+ sys.exit(1)
1434
+
1435
+ elif args.command == "status":
1436
+ print("\nAntigravity Authentication Status")
1437
+ print("=" * 40)
1438
+
1439
+ if Antigravity.has_credentials():
1440
+ creds_path = Antigravity.get_credentials_path()
1441
+ print(f"✓ Credentials found at: {creds_path}")
1442
+
1443
+ # Try to read and display some info
1444
+ try:
1445
+ with creds_path.open() as f:
1446
+ creds = json.load(f)
1447
+
1448
+ if creds.get("email"):
1449
+ print(f" Email: {creds['email']}")
1450
+ if creds.get("project_id"):
1451
+ print(f" Project: {creds['project_id']}")
1452
+
1453
+ expiry = creds.get("expiry_date")
1454
+ if expiry:
1455
+ expiry_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(expiry / 1000))
1456
+ if expiry / 1000 > time.time():
1457
+ print(f" Token expires: {expiry_time}")
1458
+ else:
1459
+ print(f" Token expired: {expiry_time} (will auto-refresh)")
1460
+ except Exception as e:
1461
+ print(f" (Could not read credential details: {e})")
1462
+ else:
1463
+ print("✗ No credentials found")
1464
+ print(f"\nRun 'antigravity login' to authenticate.")
1465
+
1466
+ print()
1467
+
1468
+ elif args.command == "logout":
1469
+ print("\nAntigravity Logout")
1470
+ print("=" * 40)
1471
+
1472
+ removed = False
1473
+
1474
+ # Remove cache file
1475
+ cache_path = AntigravityAuthManager.get_cache_file()
1476
+ if cache_path.exists():
1477
+ cache_path.unlink()
1478
+ print(f"✓ Removed: {cache_path}")
1479
+ removed = True
1480
+
1481
+ # Remove default credentials file
1482
+ default_path = get_antigravity_oauth_creds_path()
1483
+ if default_path.exists():
1484
+ default_path.unlink()
1485
+ print(f"✓ Removed: {default_path}")
1486
+ removed = True
1487
+
1488
+ if removed:
1489
+ print("\n✓ Credentials removed successfully.")
1490
+ else:
1491
+ print("No credentials found to remove.")
1492
+
1493
+ print()
1494
+
1495
+ else:
1496
+ parser.print_help()
1497
+
1498
+
1499
+ def cli_main():
1500
+ """Synchronous CLI entry point for setup.py console_scripts."""
1501
+ asyncio.run(main())
1502
+
1503
+
1504
+ if __name__ == "__main__":
1505
+ cli_main()