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.
- g4f/Provider/DeepInfra.py +0 -2
- g4f/Provider/PollinationsAI.py +1 -1
- g4f/Provider/Yupp.py +305 -152
- g4f/Provider/needs_auth/Antigravity.py +1505 -0
- g4f/Provider/needs_auth/GeminiCLI.py +573 -2
- g4f/Provider/needs_auth/OpenaiChat.py +2 -0
- g4f/Provider/needs_auth/__init__.py +1 -0
- g4f/Provider/qwen/QwenCode.py +162 -2
- g4f/Provider/yupp/constants.py +33 -0
- g4f/Provider/yupp/token_extractor.py +384 -0
- g4f/cli/client.py +3 -2
- g4f/errors.py +1 -1
- g4f/gui/server/api.py +0 -2
- g4f/models.py +1 -1
- g4f/providers/asyncio.py +2 -2
- {g4f-6.9.10.dist-info → g4f-7.0.1.dist-info}/METADATA +2 -2
- {g4f-6.9.10.dist-info → g4f-7.0.1.dist-info}/RECORD +21 -18
- g4f-7.0.1.dist-info/entry_points.txt +6 -0
- g4f-6.9.10.dist-info/entry_points.txt +0 -3
- {g4f-6.9.10.dist-info → g4f-7.0.1.dist-info}/WHEEL +0 -0
- {g4f-6.9.10.dist-info → g4f-7.0.1.dist-info}/licenses/LICENSE +0 -0
- {g4f-6.9.10.dist-info → g4f-7.0.1.dist-info}/top_level.txt +0 -0
|
@@ -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()
|