src/__init__.py DELETED
File without changes
src/auth.py DELETED
@@ -1,598 +0,0 @@
1
- import os
2
- import json
3
- import base64
4
- import time
5
- import logging
6
- from datetime import datetime
7
- from fastapi import Request, HTTPException, Depends
8
- from fastapi.security import HTTPBasic
9
- from http.server import BaseHTTPRequestHandler, HTTPServer
10
- from urllib.parse import urlparse, parse_qs
11
-
12
- from google.oauth2.credentials import Credentials
13
- from google_auth_oauthlib.flow import Flow
14
- from google.auth.transport.requests import Request as GoogleAuthRequest
15
-
16
- from .utils import get_user_agent, get_client_metadata
17
- from .config import (
18
- CLIENT_ID, CLIENT_SECRET, SCOPES, CREDENTIAL_FILE,
19
- CODE_ASSIST_ENDPOINT, GEMINI_AUTH_PASSWORD
20
- )
21
-
22
- # --- Global State ---
23
- credentials = None
24
- user_project_id = None
25
- onboarding_complete = False
26
- credentials_from_env = False # Track if credentials came from environment variable
27
-
28
- security = HTTPBasic()
29
-
30
- class _OAuthCallbackHandler(BaseHTTPRequestHandler):
31
- auth_code = None
32
- def do_GET(self):
33
- query_components = parse_qs(urlparse(self.path).query)
34
- code = query_components.get("code", [None])[0]
35
- if code:
36
- _OAuthCallbackHandler.auth_code = code
37
- self.send_response(200)
38
- self.send_header("Content-type", "text/html")
39
- self.end_headers()
40
- self.wfile.write(b"<h1>OAuth authentication successful!</h1><p>You can close this window. Please check the proxy server logs to verify that onboarding completed successfully. No need to restart the proxy.</p>")
41
- else:
42
- self.send_response(400)
43
- self.send_header("Content-type", "text/html")
44
- self.end_headers()
45
- self.wfile.write(b"<h1>Authentication failed.</h1><p>Please try again.</p>")
46
-
47
- def authenticate_user(request: Request):
48
- """Authenticate the user with multiple methods."""
49
- # Check for API key in query parameters first (for Gemini client compatibility)
50
- api_key = request.query_params.get("key")
51
- if api_key and api_key == GEMINI_AUTH_PASSWORD:
52
- return "api_key_user"
53
-
54
- # Check for API key in x-goog-api-key header (Google SDK format)
55
- goog_api_key = request.headers.get("x-goog-api-key", "")
56
- if goog_api_key and goog_api_key == GEMINI_AUTH_PASSWORD:
57
- return "goog_api_key_user"
58
-
59
- # Check for API key in Authorization header (Bearer token format)
60
- auth_header = request.headers.get("authorization", "")
61
- if auth_header.startswith("Bearer "):
62
- bearer_token = auth_header[7:]
63
- if bearer_token == GEMINI_AUTH_PASSWORD:
64
- return "bearer_user"
65
-
66
- # Check for HTTP Basic Authentication
67
- if auth_header.startswith("Basic "):
68
- try:
69
- encoded_credentials = auth_header[6:]
70
- decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
71
- username, password = decoded_credentials.split(':', 1)
72
- if password == GEMINI_AUTH_PASSWORD:
73
- return username
74
- except Exception:
75
- pass
76
-
77
- # If none of the authentication methods work
78
- raise HTTPException(
79
- status_code=401,
80
- detail="Invalid authentication credentials. Use HTTP Basic Auth, Bearer token, 'key' query parameter, or 'x-goog-api-key' header.",
81
- headers={"WWW-Authenticate": "Basic"},
82
- )
83
-
84
- def save_credentials(creds, project_id=None):
85
- global credentials_from_env
86
-
87
- # Don't save credentials to file if they came from environment variable,
88
- # but still save project_id if provided and no file exists or file lacks project_id
89
- if credentials_from_env:
90
- if project_id and os.path.exists(CREDENTIAL_FILE):
91
- try:
92
- with open(CREDENTIAL_FILE, "r") as f:
93
- existing_data = json.load(f)
94
- # Only update project_id if it's missing from the file
95
- if "project_id" not in existing_data:
96
- existing_data["project_id"] = project_id
97
- with open(CREDENTIAL_FILE, "w") as f:
98
- json.dump(existing_data, f, indent=2)
99
- logging.info(f"Added project_id {project_id} to existing credential file")
100
- except Exception as e:
101
- logging.warning(f"Could not update project_id in credential file: {e}")
102
- return
103
-
104
- creds_data = {
105
- "client_id": CLIENT_ID,
106
- "client_secret": CLIENT_SECRET,
107
- "token": creds.token,
108
- "refresh_token": creds.refresh_token,
109
- "scopes": creds.scopes if creds.scopes else SCOPES,
110
- "token_uri": "https://oauth2.googleapis.com/token",
111
- }
112
-
113
- if creds.expiry:
114
- if creds.expiry.tzinfo is None:
115
- from datetime import timezone
116
- expiry_utc = creds.expiry.replace(tzinfo=timezone.utc)
117
- else:
118
- expiry_utc = creds.expiry
119
- # Keep the existing ISO format for backward compatibility, but ensure it's properly handled during loading
120
- creds_data["expiry"] = expiry_utc.isoformat()
121
-
122
- if project_id:
123
- creds_data["project_id"] = project_id
124
- elif os.path.exists(CREDENTIAL_FILE):
125
- try:
126
- with open(CREDENTIAL_FILE, "r") as f:
127
- existing_data = json.load(f)
128
- if "project_id" in existing_data:
129
- creds_data["project_id"] = existing_data["project_id"]
130
- except Exception:
131
- pass
132
-
133
-
134
- with open(CREDENTIAL_FILE, "w") as f:
135
- json.dump(creds_data, f, indent=2)
136
-
137
-
138
- def get_credentials(allow_oauth_flow=True):
139
- """Loads credentials matching gemini-cli OAuth2 flow."""
140
- global credentials, credentials_from_env, user_project_id
141
-
142
- if credentials and credentials.token:
143
- return credentials
144
-
145
- # Check for credentials in environment variable (JSON string)
146
- env_creds_json = os.getenv("GEMINI_CREDENTIALS")
147
- if env_creds_json:
148
- # First, check if we have a refresh token - if so, we should always be able to load credentials
149
- try:
150
- raw_env_creds_data = json.loads(env_creds_json)
151
-
152
- # SAFEGUARD: If refresh_token exists, we should always load credentials successfully
153
- if "refresh_token" in raw_env_creds_data and raw_env_creds_data["refresh_token"]:
154
- logging.info("Environment refresh token found - ensuring credentials load successfully")
155
-
156
- try:
157
- creds_data = raw_env_creds_data.copy()
158
-
159
- # Handle different credential formats
160
- if "access_token" in creds_data and "token" not in creds_data:
161
- creds_data["token"] = creds_data["access_token"]
162
-
163
- if "scope" in creds_data and "scopes" not in creds_data:
164
- creds_data["scopes"] = creds_data["scope"].split()
165
-
166
- # Handle problematic expiry formats that cause parsing errors
167
- if "expiry" in creds_data:
168
- expiry_str = creds_data["expiry"]
169
- # If expiry has timezone info that causes parsing issues, try to fix it
170
- if isinstance(expiry_str, str) and ("+00:00" in expiry_str or "Z" in expiry_str):
171
- try:
172
- # Try to parse and reformat the expiry to a format Google Credentials can handle
173
- from datetime import datetime
174
- if "+00:00" in expiry_str:
175
- # Handle ISO format with timezone offset
176
- parsed_expiry = datetime.fromisoformat(expiry_str)
177
- elif expiry_str.endswith("Z"):
178
- # Handle ISO format with Z suffix
179
- parsed_expiry = datetime.fromisoformat(expiry_str.replace('Z', '+00:00'))
180
- else:
181
- parsed_expiry = datetime.fromisoformat(expiry_str)
182
-
183
- # Convert to UTC timestamp format that Google Credentials library expects
184
- import time
185
- timestamp = parsed_expiry.timestamp()
186
- creds_data["expiry"] = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%SZ")
187
- logging.info(f"Converted environment expiry format from '{expiry_str}' to '{creds_data['expiry']}'")
188
- except Exception as expiry_error:
189
- logging.warning(f"Could not parse environment expiry format '{expiry_str}': {expiry_error}, removing expiry field")
190
- # Remove problematic expiry field - credentials will be treated as expired but still loadable
191
- del creds_data["expiry"]
192
-
193
- credentials = Credentials.from_authorized_user_info(creds_data, SCOPES)
194
- credentials_from_env = True # Mark as environment credentials
195
-
196
- # Extract project_id from environment credentials if available
197
- if "project_id" in raw_env_creds_data:
198
- user_project_id = raw_env_creds_data["project_id"]
199
- logging.info(f"Extracted project_id from environment credentials: {user_project_id}")
200
-
201
- # Try to refresh if expired and refresh token exists
202
- if credentials.expired and credentials.refresh_token:
203
- try:
204
- logging.info("Environment credentials expired, attempting refresh...")
205
- credentials.refresh(GoogleAuthRequest())
206
- logging.info("Environment credentials refreshed successfully")
207
- except Exception as refresh_error:
208
- logging.warning(f"Failed to refresh environment credentials: {refresh_error}")
209
- logging.info("Using existing environment credentials despite refresh failure")
210
- elif not credentials.expired:
211
- logging.info("Environment credentials are still valid, no refresh needed")
212
- elif not credentials.refresh_token:
213
- logging.warning("Environment credentials expired but no refresh token available")
214
-
215
- return credentials
216
-
217
- except Exception as parsing_error:
218
- # SAFEGUARD: Even if parsing fails, try to create minimal credentials with refresh token
219
- logging.warning(f"Failed to parse environment credentials normally: {parsing_error}")
220
- logging.info("Attempting to create minimal environment credentials with refresh token")
221
-
222
- try:
223
- minimal_creds_data = {
224
- "client_id": raw_env_creds_data.get("client_id", CLIENT_ID),
225
- "client_secret": raw_env_creds_data.get("client_secret", CLIENT_SECRET),
226
- "refresh_token": raw_env_creds_data["refresh_token"],
227
- "token_uri": "https://oauth2.googleapis.com/token",
228
- }
229
-
230
- credentials = Credentials.from_authorized_user_info(minimal_creds_data, SCOPES)
231
- credentials_from_env = True # Mark as environment credentials
232
-
233
- # Extract project_id from environment credentials if available
234
- if "project_id" in raw_env_creds_data:
235
- user_project_id = raw_env_creds_data["project_id"]
236
- logging.info(f"Extracted project_id from minimal environment credentials: {user_project_id}")
237
-
238
- # Force refresh since we don't have a valid token
239
- try:
240
- logging.info("Refreshing minimal environment credentials...")
241
- credentials.refresh(GoogleAuthRequest())
242
- logging.info("Minimal environment credentials refreshed successfully")
243
- return credentials
244
- except Exception as refresh_error:
245
- logging.error(f"Failed to refresh minimal environment credentials: {refresh_error}")
246
- # Even if refresh fails, return the credentials - they might still work
247
- return credentials
248
-
249
- except Exception as minimal_error:
250
- logging.error(f"Failed to create minimal environment credentials: {minimal_error}")
251
- # Fall through to file-based credentials
252
- else:
253
- logging.warning("No refresh token found in environment credentials")
254
- # Fall through to file-based credentials
255
-
256
- except Exception as e:
257
- logging.error(f"Failed to parse environment credentials JSON: {e}")
258
- # Fall through to file-based credentials
259
-
260
- # Check for credentials file (CREDENTIAL_FILE now includes GOOGLE_APPLICATION_CREDENTIALS path if set)
261
- if os.path.exists(CREDENTIAL_FILE):
262
- # First, check if we have a refresh token - if so, we should always be able to load credentials
263
- try:
264
- with open(CREDENTIAL_FILE, "r") as f:
265
- raw_creds_data = json.load(f)
266
-
267
- # SAFEGUARD: If refresh_token exists, we should always load credentials successfully
268
- if "refresh_token" in raw_creds_data and raw_creds_data["refresh_token"]:
269
- logging.info("Refresh token found - ensuring credentials load successfully")
270
-
271
- try:
272
- creds_data = raw_creds_data.copy()
273
-
274
- # Handle different credential formats
275
- if "access_token" in creds_data and "token" not in creds_data:
276
- creds_data["token"] = creds_data["access_token"]
277
-
278
- if "scope" in creds_data and "scopes" not in creds_data:
279
- creds_data["scopes"] = creds_data["scope"].split()
280
-
281
- # Handle problematic expiry formats that cause parsing errors
282
- if "expiry" in creds_data:
283
- expiry_str = creds_data["expiry"]
284
- # If expiry has timezone info that causes parsing issues, try to fix it
285
- if isinstance(expiry_str, str) and ("+00:00" in expiry_str or "Z" in expiry_str):
286
- try:
287
- # Try to parse and reformat the expiry to a format Google Credentials can handle
288
- from datetime import datetime
289
- if "+00:00" in expiry_str:
290
- # Handle ISO format with timezone offset
291
- parsed_expiry = datetime.fromisoformat(expiry_str)
292
- elif expiry_str.endswith("Z"):
293
- # Handle ISO format with Z suffix
294
- parsed_expiry = datetime.fromisoformat(expiry_str.replace('Z', '+00:00'))
295
- else:
296
- parsed_expiry = datetime.fromisoformat(expiry_str)
297
-
298
- # Convert to UTC timestamp format that Google Credentials library expects
299
- import time
300
- timestamp = parsed_expiry.timestamp()
301
- creds_data["expiry"] = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%SZ")
302
- logging.info(f"Converted expiry format from '{expiry_str}' to '{creds_data['expiry']}'")
303
- except Exception as expiry_error:
304
- logging.warning(f"Could not parse expiry format '{expiry_str}': {expiry_error}, removing expiry field")
305
- # Remove problematic expiry field - credentials will be treated as expired but still loadable
306
- del creds_data["expiry"]
307
-
308
- credentials = Credentials.from_authorized_user_info(creds_data, SCOPES)
309
- # Mark as environment credentials if GOOGLE_APPLICATION_CREDENTIALS was used
310
- credentials_from_env = bool(os.getenv("GOOGLE_APPLICATION_CREDENTIALS"))
311
-
312
- # Try to refresh if expired and refresh token exists
313
- if credentials.expired and credentials.refresh_token:
314
- try:
315
- logging.info("File-based credentials expired, attempting refresh...")
316
- credentials.refresh(GoogleAuthRequest())
317
- logging.info("File-based credentials refreshed successfully")
318
- save_credentials(credentials)
319
- except Exception as refresh_error:
320
- logging.warning(f"Failed to refresh file-based credentials: {refresh_error}")
321
- logging.info("Using existing file-based credentials despite refresh failure")
322
- elif not credentials.expired:
323
- logging.info("File-based credentials are still valid, no refresh needed")
324
- elif not credentials.refresh_token:
325
- logging.warning("File-based credentials expired but no refresh token available")
326
-
327
- return credentials
328
-
329
- except Exception as parsing_error:
330
- # SAFEGUARD: Even if parsing fails, try to create minimal credentials with refresh token
331
- logging.warning(f"Failed to parse credentials normally: {parsing_error}")
332
- logging.info("Attempting to create minimal credentials with refresh token")
333
-
334
- try:
335
- minimal_creds_data = {
336
- "client_id": raw_creds_data.get("client_id", CLIENT_ID),
337
- "client_secret": raw_creds_data.get("client_secret", CLIENT_SECRET),
338
- "refresh_token": raw_creds_data["refresh_token"],
339
- "token_uri": "https://oauth2.googleapis.com/token",
340
- }
341
-
342
- credentials = Credentials.from_authorized_user_info(minimal_creds_data, SCOPES)
343
- credentials_from_env = bool(os.getenv("GOOGLE_APPLICATION_CREDENTIALS"))
344
-
345
- # Force refresh since we don't have a valid token
346
- try:
347
- logging.info("Refreshing minimal credentials...")
348
- credentials.refresh(GoogleAuthRequest())
349
- logging.info("Minimal credentials refreshed successfully")
350
- save_credentials(credentials)
351
- return credentials
352
- except Exception as refresh_error:
353
- logging.error(f"Failed to refresh minimal credentials: {refresh_error}")
354
- # Even if refresh fails, return the credentials - they might still work
355
- return credentials
356
-
357
- except Exception as minimal_error:
358
- logging.error(f"Failed to create minimal credentials: {minimal_error}")
359
- # Fall through to new login as last resort
360
- else:
361
- logging.warning("No refresh token found in credentials file")
362
- # Fall through to new login
363
-
364
- except Exception as e:
365
- logging.error(f"Failed to read credentials file {CREDENTIAL_FILE}: {e}")
366
- # Fall through to new login only if file is completely unreadable
367
-
368
- # Only start OAuth flow if explicitly allowed
369
- if not allow_oauth_flow:
370
- logging.info("OAuth flow not allowed - returning None (credentials will be required on first request)")
371
- return None
372
-
373
- client_config = {
374
- "installed": {
375
- "client_id": CLIENT_ID,
376
- "client_secret": CLIENT_SECRET,
377
- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
378
- "token_uri": "https://oauth2.googleapis.com/token",
379
- }
380
- }
381
-
382
- flow = Flow.from_client_config(
383
- client_config,
384
- scopes=SCOPES,
385
- redirect_uri="http://localhost:8080"
386
- )
387
-
388
- flow.oauth2session.scope = SCOPES
389
-
390
- auth_url, _ = flow.authorization_url(
391
- access_type="offline",
392
- prompt="consent",
393
- include_granted_scopes='true'
394
- )
395
- print(f"\n{'='*80}")
396
- print(f"AUTHENTICATION REQUIRED")
397
- print(f"{'='*80}")
398
- print(f"Please open this URL in your browser to log in:")
399
- print(f"{auth_url}")
400
- print(f"{'='*80}\n")
401
- logging.info(f"Please open this URL in your browser to log in: {auth_url}")
402
-
403
- server = HTTPServer(("", 8080), _OAuthCallbackHandler)
404
- server.handle_request()
405
-
406
- auth_code = _OAuthCallbackHandler.auth_code
407
- if not auth_code:
408
- return None
409
-
410
- import oauthlib.oauth2.rfc6749.parameters
411
- original_validate = oauthlib.oauth2.rfc6749.parameters.validate_token_parameters
412
-
413
- def patched_validate(params):
414
- try:
415
- return original_validate(params)
416
- except Warning:
417
- pass
418
-
419
- oauthlib.oauth2.rfc6749.parameters.validate_token_parameters = patched_validate
420
-
421
- try:
422
- flow.fetch_token(code=auth_code)
423
- credentials = flow.credentials
424
- credentials_from_env = False # Mark as file-based credentials
425
- save_credentials(credentials)
426
- logging.info("Authentication successful! Credentials saved.")
427
- return credentials
428
- except Exception as e:
429
- logging.error(f"Authentication failed: {e}")
430
- return None
431
- finally:
432
- oauthlib.oauth2.rfc6749.parameters.validate_token_parameters = original_validate
433
-
434
- def onboard_user(creds, project_id):
435
- """Ensures the user is onboarded, matching gemini-cli setupUser behavior."""
436
- global onboarding_complete
437
- if onboarding_complete:
438
- return
439
-
440
- if creds.expired and creds.refresh_token:
441
- try:
442
- creds.refresh(GoogleAuthRequest())
443
- save_credentials(creds)
444
- except Exception as e:
445
- raise Exception(f"Failed to refresh credentials during onboarding: {str(e)}")
446
- headers = {
447
- "Authorization": f"Bearer {creds.token}",
448
- "Content-Type": "application/json",
449
- "User-Agent": get_user_agent(),
450
- }
451
-
452
- load_assist_payload = {
453
- "cloudaicompanionProject": project_id,
454
- "metadata": get_client_metadata(project_id),
455
- }
456
-
457
- try:
458
- import requests
459
- resp = requests.post(
460
- f"{CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist",
461
- data=json.dumps(load_assist_payload),
462
- headers=headers,
463
- )
464
- resp.raise_for_status()
465
- load_data = resp.json()
466
-
467
- tier = None
468
- if load_data.get("currentTier"):
469
- tier = load_data["currentTier"]
470
- else:
471
- for allowed_tier in load_data.get("allowedTiers", []):
472
- if allowed_tier.get("isDefault"):
473
- tier = allowed_tier
474
- break
475
-
476
- if not tier:
477
- tier = {
478
- "name": "",
479
- "description": "",
480
- "id": "legacy-tier",
481
- "userDefinedCloudaicompanionProject": True,
482
- }
483
-
484
- if tier.get("userDefinedCloudaicompanionProject") and not project_id:
485
- raise ValueError("This account requires setting the GOOGLE_CLOUD_PROJECT env var.")
486
-
487
- if load_data.get("currentTier"):
488
- onboarding_complete = True
489
- return
490
-
491
- onboard_req_payload = {
492
- "tierId": tier.get("id"),
493
- "cloudaicompanionProject": project_id,
494
- "metadata": get_client_metadata(project_id),
495
- }
496
-
497
- while True:
498
- onboard_resp = requests.post(
499
- f"{CODE_ASSIST_ENDPOINT}/v1internal:onboardUser",
500
- data=json.dumps(onboard_req_payload),
501
- headers=headers,
502
- )
503
- onboard_resp.raise_for_status()
504
- lro_data = onboard_resp.json()
505
-
506
- if lro_data.get("done"):
507
- onboarding_complete = True
508
- break
509
-
510
- time.sleep(5)
511
-
512
- except requests.exceptions.HTTPError as e:
513
- raise Exception(f"User onboarding failed. Please check your Google Cloud project permissions and try again. Error: {e.response.text if hasattr(e, 'response') else str(e)}")
514
- except Exception as e:
515
- raise Exception(f"User onboarding failed due to an unexpected error: {str(e)}")
516
-
517
- def get_user_project_id(creds):
518
- """Gets the user's project ID matching gemini-cli setupUser logic."""
519
- global user_project_id
520
-
521
- # Priority 1: Check environment variable first (always check, even if user_project_id is set)
522
- env_project_id = os.getenv("GOOGLE_CLOUD_PROJECT")
523
- if env_project_id:
524
- logging.info(f"Using project ID from GOOGLE_CLOUD_PROJECT environment variable: {env_project_id}")
525
- user_project_id = env_project_id
526
- save_credentials(creds, user_project_id)
527
- return user_project_id
528
-
529
- # If we already have a cached project_id and no env var override, use it
530
- if user_project_id:
531
- logging.info(f"Using cached project ID: {user_project_id}")
532
- return user_project_id
533
-
534
- # Priority 2: Check cached project ID in credential file
535
- if os.path.exists(CREDENTIAL_FILE):
536
- try:
537
- with open(CREDENTIAL_FILE, "r") as f:
538
- creds_data = json.load(f)
539
- cached_project_id = creds_data.get("project_id")
540
- if cached_project_id:
541
- logging.info(f"Using cached project ID from credential file: {cached_project_id}")
542
- user_project_id = cached_project_id
543
- return user_project_id
544
- except Exception as e:
545
- logging.warning(f"Could not read project_id from credential file: {e}")
546
-
547
- # Priority 3: Make API call to discover project ID
548
- # Ensure we have valid credentials for the API call
549
- if creds.expired and creds.refresh_token:
550
- try:
551
- logging.info("Refreshing credentials before project ID discovery...")
552
- creds.refresh(GoogleAuthRequest())
553
- save_credentials(creds)
554
- logging.info("Credentials refreshed successfully for project ID discovery")
555
- except Exception as e:
556
- logging.error(f"Failed to refresh credentials while getting project ID: {e}")
557
- # Continue with existing credentials - they might still work
558
-
559
- if not creds.token:
560
- raise Exception("No valid access token available for project ID discovery")
561
-
562
- headers = {
563
- "Authorization": f"Bearer {creds.token}",
564
- "Content-Type": "application/json",
565
- "User-Agent": get_user_agent(),
566
- }
567
-
568
- probe_payload = {
569
- "metadata": get_client_metadata(),
570
- }
571
-
572
- try:
573
- import requests
574
- logging.info("Attempting to discover project ID via API call...")
575
- resp = requests.post(
576
- f"{CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist",
577
- data=json.dumps(probe_payload),
578
- headers=headers,
579
- )
580
- resp.raise_for_status()
581
- data = resp.json()
582
- discovered_project_id = data.get("cloudaicompanionProject")
583
- if not discovered_project_id:
584
- raise ValueError("Could not find 'cloudaicompanionProject' in loadCodeAssist response.")
585
-
586
- logging.info(f"Discovered project ID via API: {discovered_project_id}")
587
- user_project_id = discovered_project_id
588
- save_credentials(creds, user_project_id)
589
-
590
- return user_project_id
591
- except requests.exceptions.HTTPError as e:
592
- logging.error(f"HTTP error during project ID discovery: {e}")
593
- if hasattr(e, 'response') and e.response:
594
- logging.error(f"Response status: {e.response.status_code}, body: {e.response.text}")
595
- raise Exception(f"Failed to discover project ID via API: {e}")
596
- except Exception as e:
597
- logging.error(f"Unexpected error during project ID discovery: {e}")
598
- raise Exception(f"Failed to discover project ID: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/config.py DELETED
@@ -1,242 +0,0 @@
1
- """
2
- Configuration constants for the Geminicli2api proxy server.
3
- Centralizes all configuration to avoid duplication across modules.
4
- """
5
- import os
6
-
7
- # API Endpoints
8
- CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
9
-
10
- # Client Configuration
11
- CLI_VERSION = "0.1.5" # Match current gemini-cli version
12
-
13
- # OAuth Configuration
14
- CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
15
- CLIENT_SECRET = "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"
16
- SCOPES = [
17
- "https://www.googleapis.com/auth/cloud-platform",
18
- "https://www.googleapis.com/auth/userinfo.email",
19
- "https://www.googleapis.com/auth/userinfo.profile",
20
- ]
21
-
22
- # File Paths
23
- SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24
- CREDENTIAL_FILE = os.path.join(SCRIPT_DIR, os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "oauth_creds.json"))
25
-
26
- # Authentication
27
- GEMINI_AUTH_PASSWORD = os.getenv("GEMINI_AUTH_PASSWORD", "123456")
28
-
29
- # Default Safety Settings for Google API
30
- DEFAULT_SAFETY_SETTINGS = [
31
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
32
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
33
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
34
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
35
- {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}
36
- ]
37
-
38
- # Base Models (without search variants)
39
- BASE_MODELS = [
40
- {
41
- "name": "models/gemini-2.5-pro-preview-05-06",
42
- "version": "001",
43
- "displayName": "Gemini 2.5 Pro Preview 05-06",
44
- "description": "Preview version of Gemini 2.5 Pro from May 6th",
45
- "inputTokenLimit": 1048576,
46
- "outputTokenLimit": 65535,
47
- "supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
48
- "temperature": 1.0,
49
- "maxTemperature": 2.0,
50
- "topP": 0.95,
51
- "topK": 64
52
- },
53
- {
54
- "name": "models/gemini-2.5-pro-preview-06-05",
55
- "version": "001",
56
- "displayName": "Gemini 2.5 Pro Preview 06-05",
57
- "description": "Preview version of Gemini 2.5 Pro from June 5th",
58
- "inputTokenLimit": 1048576,
59
- "outputTokenLimit": 65535,
60
- "supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
61
- "temperature": 1.0,
62
- "maxTemperature": 2.0,
63
- "topP": 0.95,
64
- "topK": 64
65
- },
66
- {
67
- "name": "models/gemini-2.5-pro",
68
- "version": "001",
69
- "displayName": "Gemini 2.5 Pro",
70
- "description": "Advanced multimodal model with enhanced capabilities",
71
- "inputTokenLimit": 1048576,
72
- "outputTokenLimit": 65535,
73
- "supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
74
- "temperature": 1.0,
75
- "maxTemperature": 2.0,
76
- "topP": 0.95,
77
- "topK": 64
78
- },
79
- {
80
- "name": "models/gemini-2.5-flash-preview-05-20",
81
- "version": "001",
82
- "displayName": "Gemini 2.5 Flash Preview 05-20",
83
- "description": "Preview version of Gemini 2.5 Flash from May 20th",
84
- "inputTokenLimit": 1048576,
85
- "outputTokenLimit": 65535,
86
- "supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
87
- "temperature": 1.0,
88
- "maxTemperature": 2.0,
89
- "topP": 0.95,
90
- "topK": 64
91
- },
92
- {
93
- "name": "models/gemini-2.5-flash-preview-04-17",
94
- "version": "001",
95
- "displayName": "Gemini 2.5 Flash Preview 04-17",
96
- "description": "Preview version of Gemini 2.5 Flash from April 17th",
97
- "inputTokenLimit": 1048576,
98
- "outputTokenLimit": 65535,
99
- "supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
100
- "temperature": 1.0,
101
- "maxTemperature": 2.0,
102
- "topP": 0.95,
103
- "topK": 64
104
- },
105
- {
106
- "name": "models/gemini-2.5-flash",
107
- "version": "001",
108
- "displayName": "Gemini 2.5 Flash",
109
- "description": "Fast and efficient multimodal model with latest improvements",
110
- "inputTokenLimit": 1048576,
111
- "outputTokenLimit": 65535,
112
- "supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
113
- "temperature": 1.0,
114
- "maxTemperature": 2.0,
115
- "topP": 0.95,
116
- "topK": 64
117
- }
118
- ]
119
-
120
- # Generate search variants for applicable models
121
- def _generate_search_variants():
122
- """Generate search variants for models that support content generation."""
123
- search_models = []
124
- for model in BASE_MODELS:
125
- # Only add search variants for models that support content generation
126
- if "generateContent" in model["supportedGenerationMethods"]:
127
- search_variant = model.copy()
128
- search_variant["name"] = model["name"] + "-search"
129
- search_variant["displayName"] = model["displayName"] + " with Google Search"
130
- search_variant["description"] = model["description"] + " (includes Google Search grounding)"
131
- search_models.append(search_variant)
132
- return search_models
133
-
134
- # Generate thinking variants for applicable models
135
- def _generate_thinking_variants():
136
- """Generate nothinking and maxthinking variants for models that support thinking."""
137
- thinking_models = []
138
- for model in BASE_MODELS:
139
- # Only add thinking variants for models that support content generation
140
- # and contain "gemini-2.5-flash" or "gemini-2.5-pro" in their name
141
- if ("generateContent" in model["supportedGenerationMethods"] and
142
- ("gemini-2.5-flash" in model["name"] or "gemini-2.5-pro" in model["name"])):
143
-
144
- # Add -nothinking variant
145
- nothinking_variant = model.copy()
146
- nothinking_variant["name"] = model["name"] + "-nothinking"
147
- nothinking_variant["displayName"] = model["displayName"] + " (No Thinking)"
148
- nothinking_variant["description"] = model["description"] + " (thinking disabled)"
149
- thinking_models.append(nothinking_variant)
150
-
151
- # Add -maxthinking variant
152
- maxthinking_variant = model.copy()
153
- maxthinking_variant["name"] = model["name"] + "-maxthinking"
154
- maxthinking_variant["displayName"] = model["displayName"] + " (Max Thinking)"
155
- maxthinking_variant["description"] = model["description"] + " (maximum thinking budget)"
156
- thinking_models.append(maxthinking_variant)
157
- return thinking_models
158
-
159
- # Generate combined variants (search + thinking combinations)
160
- def _generate_combined_variants():
161
- """Generate combined search and thinking variants."""
162
- combined_models = []
163
- for model in BASE_MODELS:
164
- # Only add combined variants for models that support content generation
165
- # and contain "gemini-2.5-flash" or "gemini-2.5-pro" in their name
166
- if ("generateContent" in model["supportedGenerationMethods"] and
167
- ("gemini-2.5-flash" in model["name"] or "gemini-2.5-pro" in model["name"])):
168
-
169
- # search + nothinking
170
- search_nothinking = model.copy()
171
- search_nothinking["name"] = model["name"] + "-search-nothinking"
172
- search_nothinking["displayName"] = model["displayName"] + " with Google Search (No Thinking)"
173
- search_nothinking["description"] = model["description"] + " (includes Google Search grounding, thinking disabled)"
174
- combined_models.append(search_nothinking)
175
-
176
- # search + maxthinking
177
- search_maxthinking = model.copy()
178
- search_maxthinking["name"] = model["name"] + "-search-maxthinking"
179
- search_maxthinking["displayName"] = model["displayName"] + " with Google Search (Max Thinking)"
180
- search_maxthinking["description"] = model["description"] + " (includes Google Search grounding, maximum thinking budget)"
181
- combined_models.append(search_maxthinking)
182
- return combined_models
183
-
184
- # Supported Models (includes base models, search variants, and thinking variants)
185
- # Combine all models and then sort them by name to group variants together
186
- all_models = BASE_MODELS + _generate_search_variants() + _generate_thinking_variants()
187
- SUPPORTED_MODELS = sorted(all_models, key=lambda x: x['name'])
188
-
189
- # Helper function to get base model name from any variant
190
- def get_base_model_name(model_name):
191
- """Convert variant model name to base model name."""
192
- # Remove all possible suffixes in order
193
- suffixes = ["-maxthinking", "-nothinking", "-search"]
194
- for suffix in suffixes:
195
- if model_name.endswith(suffix):
196
- return model_name[:-len(suffix)]
197
- return model_name
198
-
199
- # Helper function to check if model uses search grounding
200
- def is_search_model(model_name):
201
- """Check if model name indicates search grounding should be enabled."""
202
- return "-search" in model_name
203
-
204
- # Helper function to check if model uses no thinking
205
- def is_nothinking_model(model_name):
206
- """Check if model name indicates thinking should be disabled."""
207
- return "-nothinking" in model_name
208
-
209
- # Helper function to check if model uses max thinking
210
- def is_maxthinking_model(model_name):
211
- """Check if model name indicates maximum thinking budget should be used."""
212
- return "-maxthinking" in model_name
213
-
214
- # Helper function to get thinking budget for a model
215
- def get_thinking_budget(model_name):
216
- """Get the appropriate thinking budget for a model based on its name and variant."""
217
- base_model = get_base_model_name(model_name)
218
-
219
- if is_nothinking_model(model_name):
220
- if "gemini-2.5-flash" in base_model:
221
- return 0 # No thinking for flash
222
- elif "gemini-2.5-pro" in base_model:
223
- return 128 # Limited thinking for pro
224
- elif is_maxthinking_model(model_name):
225
- if "gemini-2.5-flash" in base_model:
226
- return 24576
227
- elif "gemini-2.5-pro" in base_model:
228
- return 32768
229
- else:
230
- # Default thinking budget for regular models
231
- return -1 # Default for all models
232
-
233
- # Helper function to check if thinking should be included in output
234
- def should_include_thoughts(model_name):
235
- """Check if thoughts should be included in the response."""
236
- if is_nothinking_model(model_name):
237
- # For nothinking mode, still include thoughts if it's a pro model
238
- base_model = get_base_model_name(model_name)
239
- return "gemini-2.5-pro" in base_model
240
- else:
241
- # For all other modes, include thoughts
242
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/gemini_routes.py DELETED
@@ -1,186 +0,0 @@
1
- """
2
- Gemini API Routes - Handles native Gemini API endpoints.
3
- This module provides native Gemini API endpoints that proxy directly to Google's API
4
- without any format transformations.
5
- """
6
- import json
7
- import logging
8
- from fastapi import APIRouter, Request, Response, Depends
9
-
10
- from .auth import authenticate_user
11
- from .google_api_client import send_gemini_request, build_gemini_payload_from_native
12
- from .config import SUPPORTED_MODELS
13
-
14
- router = APIRouter()
15
-
16
-
17
- @router.get("/v1beta/models")
18
- async def gemini_list_models(request: Request, username: str = Depends(authenticate_user)):
19
- """
20
- Native Gemini models endpoint.
21
- Returns available models in Gemini format, matching the official Gemini API.
22
- """
23
-
24
- try:
25
- logging.info("Gemini models list requested")
26
-
27
- models_response = {
28
- "models": SUPPORTED_MODELS
29
- }
30
-
31
- logging.info(f"Returning {len(SUPPORTED_MODELS)} Gemini models")
32
- return Response(
33
- content=json.dumps(models_response),
34
- status_code=200,
35
- media_type="application/json; charset=utf-8"
36
- )
37
- except Exception as e:
38
- logging.error(f"Failed to list Gemini models: {str(e)}")
39
- return Response(
40
- content=json.dumps({
41
- "error": {
42
- "message": f"Failed to list models: {str(e)}",
43
- "code": 500
44
- }
45
- }),
46
- status_code=500,
47
- media_type="application/json"
48
- )
49
-
50
-
51
- @router.api_route("/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
52
- async def gemini_proxy(request: Request, full_path: str, username: str = Depends(authenticate_user)):
53
- """
54
- Native Gemini API proxy endpoint.
55
- Handles all native Gemini API calls by proxying them directly to Google's API.
56
-
57
- This endpoint handles paths like:
58
- - /v1beta/models/{model}/generateContent
59
- - /v1beta/models/{model}/streamGenerateContent
60
- - /v1/models/{model}/generateContent
61
- - etc.
62
- """
63
-
64
- try:
65
- # Get the request body
66
- post_data = await request.body()
67
-
68
- # Determine if this is a streaming request
69
- is_streaming = "stream" in full_path.lower()
70
-
71
- # Extract model name from the path
72
- # Paths typically look like: v1beta/models/gemini-1.5-pro/generateContent
73
- model_name = _extract_model_from_path(full_path)
74
-
75
- logging.info(f"Gemini proxy request: path={full_path}, model={model_name}, stream={is_streaming}")
76
-
77
- if not model_name:
78
- logging.error(f"Could not extract model name from path: {full_path}")
79
- return Response(
80
- content=json.dumps({
81
- "error": {
82
- "message": f"Could not extract model name from path: {full_path}",
83
- "code": 400
84
- }
85
- }),
86
- status_code=400,
87
- media_type="application/json"
88
- )
89
-
90
- # Parse the incoming request
91
- try:
92
- if post_data:
93
- incoming_request = json.loads(post_data)
94
- else:
95
- incoming_request = {}
96
- except json.JSONDecodeError as e:
97
- logging.error(f"Invalid JSON in request body: {str(e)}")
98
- return Response(
99
- content=json.dumps({
100
- "error": {
101
- "message": "Invalid JSON in request body",
102
- "code": 400
103
- }
104
- }),
105
- status_code=400,
106
- media_type="application/json"
107
- )
108
-
109
- # Build the payload for Google API
110
- gemini_payload = build_gemini_payload_from_native(incoming_request, model_name)
111
-
112
- # Send the request to Google API
113
- response = send_gemini_request(gemini_payload, is_streaming=is_streaming)
114
-
115
- # Log the response status
116
- if hasattr(response, 'status_code'):
117
- if response.status_code != 200:
118
- logging.error(f"Gemini API returned error: status={response.status_code}")
119
- else:
120
- logging.info(f"Successfully processed Gemini request for model: {model_name}")
121
-
122
- return response
123
-
124
- except Exception as e:
125
- logging.error(f"Gemini proxy error: {str(e)}")
126
- return Response(
127
- content=json.dumps({
128
- "error": {
129
- "message": f"Proxy error: {str(e)}",
130
- "code": 500
131
- }
132
- }),
133
- status_code=500,
134
- media_type="application/json"
135
- )
136
-
137
-
138
- def _extract_model_from_path(path: str) -> str:
139
- """
140
- Extract the model name from a Gemini API path.
141
-
142
- Examples:
143
- - "v1beta/models/gemini-1.5-pro/generateContent" -> "gemini-1.5-pro"
144
- - "v1/models/gemini-2.0-flash/streamGenerateContent" -> "gemini-2.0-flash"
145
-
146
- Args:
147
- path: The API path
148
-
149
- Returns:
150
- Model name (just the model name, not prefixed with "models/") or None if not found
151
- """
152
- parts = path.split('/')
153
-
154
- # Look for the pattern: .../models/{model_name}/...
155
- try:
156
- models_index = parts.index('models')
157
- if models_index + 1 < len(parts):
158
- model_name = parts[models_index + 1]
159
- # Remove any action suffix like ":streamGenerateContent" or ":generateContent"
160
- if ':' in model_name:
161
- model_name = model_name.split(':')[0]
162
- # Return just the model name without "models/" prefix
163
- return model_name
164
- except ValueError:
165
- pass
166
-
167
- # If we can't find the pattern, return None
168
- return None
169
-
170
-
171
- @router.get("/v1/models")
172
- async def gemini_list_models_v1(request: Request, username: str = Depends(authenticate_user)):
173
- """
174
- Alternative models endpoint for v1 API version.
175
- Some clients might use /v1/models instead of /v1beta/models.
176
- """
177
- return await gemini_list_models(request, username)
178
-
179
-
180
- # Health check endpoint
181
- @router.get("/health")
182
- async def health_check():
183
- """
184
- Simple health check endpoint.
185
- """
186
- return {"status": "healthy", "service": "geminicli2api"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/google_api_client.py DELETED
@@ -1,335 +0,0 @@
1
- """
2
- Google API Client - Handles all communication with Google's Gemini API.
3
- This module is used by both OpenAI compatibility layer and native Gemini endpoints.
4
- """
5
- import json
6
- import logging
7
- import requests
8
- from fastapi import Response
9
- from fastapi.responses import StreamingResponse
10
- from google.auth.transport.requests import Request as GoogleAuthRequest
11
-
12
- from .auth import get_credentials, save_credentials, get_user_project_id, onboard_user
13
- from .utils import get_user_agent
14
- from .config import (
15
- CODE_ASSIST_ENDPOINT,
16
- DEFAULT_SAFETY_SETTINGS,
17
- get_base_model_name,
18
- is_search_model,
19
- get_thinking_budget,
20
- should_include_thoughts
21
- )
22
- import asyncio
23
-
24
-
25
- def send_gemini_request(payload: dict, is_streaming: bool = False) -> Response:
26
- """
27
- Send a request to Google's Gemini API.
28
-
29
- Args:
30
- payload: The request payload in Gemini format
31
- is_streaming: Whether this is a streaming request
32
-
33
- Returns:
34
- FastAPI Response object
35
- """
36
- # Get and validate credentials
37
- creds = get_credentials()
38
- if not creds:
39
- return Response(
40
- content="Authentication failed. Please restart the proxy to log in.",
41
- status_code=500
42
- )
43
-
44
-
45
- # Refresh credentials if needed
46
- if creds.expired and creds.refresh_token:
47
- try:
48
- creds.refresh(GoogleAuthRequest())
49
- save_credentials(creds)
50
- except Exception as e:
51
- return Response(
52
- content="Token refresh failed. Please restart the proxy to re-authenticate.",
53
- status_code=500
54
- )
55
- elif not creds.token:
56
- return Response(
57
- content="No access token. Please restart the proxy to re-authenticate.",
58
- status_code=500
59
- )
60
-
61
- # Get project ID and onboard user
62
- proj_id = get_user_project_id(creds)
63
- if not proj_id:
64
- return Response(content="Failed to get user project ID.", status_code=500)
65
-
66
- onboard_user(creds, proj_id)
67
-
68
- # Build the final payload with project info
69
- final_payload = {
70
- "model": payload.get("model"),
71
- "project": proj_id,
72
- "request": payload.get("request", {})
73
- }
74
-
75
- # Determine the action and URL
76
- action = "streamGenerateContent" if is_streaming else "generateContent"
77
- target_url = f"{CODE_ASSIST_ENDPOINT}/v1internal:{action}"
78
- if is_streaming:
79
- target_url += "?alt=sse"
80
-
81
- # Build request headers
82
- request_headers = {
83
- "Authorization": f"Bearer {creds.token}",
84
- "Content-Type": "application/json",
85
- "User-Agent": get_user_agent(),
86
- }
87
-
88
- final_post_data = json.dumps(final_payload)
89
-
90
- # Send the request
91
- try:
92
- if is_streaming:
93
- resp = requests.post(target_url, data=final_post_data, headers=request_headers, stream=True)
94
- return _handle_streaming_response(resp)
95
- else:
96
- resp = requests.post(target_url, data=final_post_data, headers=request_headers)
97
- return _handle_non_streaming_response(resp)
98
- except requests.exceptions.RequestException as e:
99
- logging.error(f"Request to Google API failed: {str(e)}")
100
- return Response(
101
- content=json.dumps({"error": {"message": f"Request failed: {str(e)}"}}),
102
- status_code=500,
103
- media_type="application/json"
104
- )
105
- except Exception as e:
106
- logging.error(f"Unexpected error during Google API request: {str(e)}")
107
- return Response(
108
- content=json.dumps({"error": {"message": f"Unexpected error: {str(e)}"}}),
109
- status_code=500,
110
- media_type="application/json"
111
- )
112
-
113
-
114
- def _handle_streaming_response(resp) -> StreamingResponse:
115
- """Handle streaming response from Google API."""
116
-
117
- # Check for HTTP errors before starting to stream
118
- if resp.status_code != 200:
119
- logging.error(f"Google API returned status {resp.status_code}: {resp.text}")
120
- error_message = f"Google API error: {resp.status_code}"
121
- try:
122
- error_data = resp.json()
123
- if "error" in error_data:
124
- error_message = error_data["error"].get("message", error_message)
125
- except:
126
- pass
127
-
128
- # Return error as a streaming response
129
- async def error_generator():
130
- error_response = {
131
- "error": {
132
- "message": error_message,
133
- "type": "invalid_request_error" if resp.status_code == 404 else "api_error",
134
- "code": resp.status_code
135
- }
136
- }
137
- yield f'data: {json.dumps(error_response)}\n\n'.encode('utf-8')
138
-
139
- response_headers = {
140
- "Content-Type": "text/event-stream",
141
- "Content-Disposition": "attachment",
142
- "Vary": "Origin, X-Origin, Referer",
143
- "X-XSS-Protection": "0",
144
- "X-Frame-Options": "SAMEORIGIN",
145
- "X-Content-Type-Options": "nosniff",
146
- "Server": "ESF"
147
- }
148
-
149
- return StreamingResponse(
150
- error_generator(),
151
- media_type="text/event-stream",
152
- headers=response_headers,
153
- status_code=resp.status_code
154
- )
155
-
156
- async def stream_generator():
157
- try:
158
- with resp:
159
- for chunk in resp.iter_lines():
160
- if chunk:
161
- if not isinstance(chunk, str):
162
- chunk = chunk.decode('utf-8')
163
-
164
- if chunk.startswith('data: '):
165
- chunk = chunk[len('data: '):]
166
-
167
- try:
168
- obj = json.loads(chunk)
169
-
170
- if "response" in obj:
171
- response_chunk = obj["response"]
172
- response_json = json.dumps(response_chunk, separators=(',', ':'))
173
- response_line = f"data: {response_json}\n\n"
174
- yield response_line.encode('utf-8')
175
- await asyncio.sleep(0)
176
- else:
177
- obj_json = json.dumps(obj, separators=(',', ':'))
178
- yield f"data: {obj_json}\n\n".encode('utf-8')
179
- except json.JSONDecodeError:
180
- continue
181
-
182
- except requests.exceptions.RequestException as e:
183
- logging.error(f"Streaming request failed: {str(e)}")
184
- error_response = {
185
- "error": {
186
- "message": f"Upstream request failed: {str(e)}",
187
- "type": "api_error",
188
- "code": 502
189
- }
190
- }
191
- yield f'data: {json.dumps(error_response)}\n\n'.encode('utf-8')
192
- except Exception as e:
193
- logging.error(f"Unexpected error during streaming: {str(e)}")
194
- error_response = {
195
- "error": {
196
- "message": f"An unexpected error occurred: {str(e)}",
197
- "type": "api_error",
198
- "code": 500
199
- }
200
- }
201
- yield f'data: {json.dumps(error_response)}\n\n'.encode('utf-8')
202
-
203
- response_headers = {
204
- "Content-Type": "text/event-stream",
205
- "Content-Disposition": "attachment",
206
- "Vary": "Origin, X-Origin, Referer",
207
- "X-XSS-Protection": "0",
208
- "X-Frame-Options": "SAMEORIGIN",
209
- "X-Content-Type-Options": "nosniff",
210
- "Server": "ESF"
211
- }
212
-
213
- return StreamingResponse(
214
- stream_generator(),
215
- media_type="text/event-stream",
216
- headers=response_headers
217
- )
218
-
219
-
220
- def _handle_non_streaming_response(resp) -> Response:
221
- """Handle non-streaming response from Google API."""
222
- if resp.status_code == 200:
223
- try:
224
- google_api_response = resp.text
225
- if google_api_response.startswith('data: '):
226
- google_api_response = google_api_response[len('data: '):]
227
- google_api_response = json.loads(google_api_response)
228
- standard_gemini_response = google_api_response.get("response")
229
- return Response(
230
- content=json.dumps(standard_gemini_response),
231
- status_code=200,
232
- media_type="application/json; charset=utf-8"
233
- )
234
- except (json.JSONDecodeError, AttributeError) as e:
235
- logging.error(f"Failed to parse Google API response: {str(e)}")
236
- return Response(
237
- content=resp.content,
238
- status_code=resp.status_code,
239
- media_type=resp.headers.get("Content-Type")
240
- )
241
- else:
242
- # Log the error details
243
- logging.error(f"Google API returned status {resp.status_code}: {resp.text}")
244
-
245
- # Try to parse error response and provide meaningful error message
246
- try:
247
- error_data = resp.json()
248
- if "error" in error_data:
249
- error_message = error_data["error"].get("message", f"API error: {resp.status_code}")
250
- error_response = {
251
- "error": {
252
- "message": error_message,
253
- "type": "invalid_request_error" if resp.status_code == 404 else "api_error",
254
- "code": resp.status_code
255
- }
256
- }
257
- return Response(
258
- content=json.dumps(error_response),
259
- status_code=resp.status_code,
260
- media_type="application/json"
261
- )
262
- except (json.JSONDecodeError, KeyError):
263
- pass
264
-
265
- # Fallback to original response if we can't parse the error
266
- return Response(
267
- content=resp.content,
268
- status_code=resp.status_code,
269
- media_type=resp.headers.get("Content-Type")
270
- )
271
-
272
-
273
- def build_gemini_payload_from_openai(openai_payload: dict) -> dict:
274
- """
275
- Build a Gemini API payload from an OpenAI-transformed request.
276
- This is used when OpenAI requests are converted to Gemini format.
277
- """
278
- # Extract model from the payload
279
- model = openai_payload.get("model")
280
-
281
- # Get safety settings or use defaults
282
- safety_settings = openai_payload.get("safetySettings", DEFAULT_SAFETY_SETTINGS)
283
-
284
- # Build the request portion
285
- request_data = {
286
- "contents": openai_payload.get("contents"),
287
- "systemInstruction": openai_payload.get("systemInstruction"),
288
- "cachedContent": openai_payload.get("cachedContent"),
289
- "tools": openai_payload.get("tools"),
290
- "toolConfig": openai_payload.get("toolConfig"),
291
- "safetySettings": safety_settings,
292
- "generationConfig": openai_payload.get("generationConfig", {}),
293
- }
294
-
295
- # Remove any keys with None values
296
- request_data = {k: v for k, v in request_data.items() if v is not None}
297
-
298
- return {
299
- "model": model,
300
- "request": request_data
301
- }
302
-
303
-
304
- def build_gemini_payload_from_native(native_request: dict, model_from_path: str) -> dict:
305
- """
306
- Build a Gemini API payload from a native Gemini request.
307
- This is used for direct Gemini API calls.
308
- """
309
- native_request["safetySettings"] = DEFAULT_SAFETY_SETTINGS
310
-
311
- if "generationConfig" not in native_request:
312
- native_request["generationConfig"] = {}
313
-
314
- if "thinkingConfig" not in native_request["generationConfig"]:
315
- native_request["generationConfig"]["thinkingConfig"] = {}
316
-
317
- # Configure thinking based on model variant
318
- thinking_budget = get_thinking_budget(model_from_path)
319
- include_thoughts = should_include_thoughts(model_from_path)
320
-
321
- native_request["generationConfig"]["thinkingConfig"]["includeThoughts"] = include_thoughts
322
- native_request["generationConfig"]["thinkingConfig"]["thinkingBudget"] = thinking_budget
323
-
324
- # Add Google Search grounding for search models
325
- if is_search_model(model_from_path):
326
- if "tools" not in native_request:
327
- native_request["tools"] = []
328
- # Add googleSearch tool if not already present
329
- if not any(tool.get("googleSearch") for tool in native_request["tools"]):
330
- native_request["tools"].append({"googleSearch": {}})
331
-
332
- return {
333
- "model": get_base_model_name(model_from_path), # Use base model name for API call
334
- "request": native_request
335
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/main.py DELETED
@@ -1,143 +0,0 @@
1
- import logging
2
- import os
3
- from fastapi import FastAPI, Request, Response
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from .gemini_routes import router as gemini_router
6
- from .openai_routes import router as openai_router
7
- from .auth import get_credentials, get_user_project_id, onboard_user
8
-
9
- # Load environment variables from .env file
10
- try:
11
- from dotenv import load_dotenv
12
- load_dotenv()
13
- logging.info("Environment variables loaded from .env file")
14
- except ImportError:
15
- logging.warning("python-dotenv not installed, .env file will not be loaded automatically")
16
- except Exception as e:
17
- logging.warning(f"Could not load .env file: {e}")
18
-
19
- # Configure logging
20
- logging.basicConfig(
21
- level=logging.INFO,
22
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
23
- )
24
-
25
- app = FastAPI()
26
-
27
- # Add CORS middleware for preflight requests
28
- app.add_middleware(
29
- CORSMiddleware,
30
- allow_origins=["*"], # Allow all origins
31
- allow_credentials=True,
32
- allow_methods=["*"], # Allow all methods
33
- allow_headers=["*"], # Allow all headers
34
- )
35
-
36
- @app.on_event("startup")
37
- async def startup_event():
38
- try:
39
- logging.info("Starting Gemini proxy server...")
40
-
41
- # Check if credentials exist
42
- import os
43
- from .config import CREDENTIAL_FILE
44
-
45
- env_creds_json = os.getenv("GEMINI_CREDENTIALS")
46
- creds_file_exists = os.path.exists(CREDENTIAL_FILE)
47
-
48
- if env_creds_json or creds_file_exists:
49
- try:
50
- # Try to load existing credentials without OAuth flow first
51
- creds = get_credentials(allow_oauth_flow=False)
52
- if creds:
53
- try:
54
- proj_id = get_user_project_id(creds)
55
- if proj_id:
56
- onboard_user(creds, proj_id)
57
- logging.info(f"Successfully onboarded with project ID: {proj_id}")
58
- logging.info("Gemini proxy server started successfully")
59
- logging.info("Authentication required - Password: see .env file")
60
- except Exception as e:
61
- logging.error(f"Setup failed: {str(e)}")
62
- logging.warning("Server started but may not function properly until setup issues are resolved.")
63
- else:
64
- logging.warning("Credentials file exists but could not be loaded. Server started - authentication will be required on first request.")
65
- except Exception as e:
66
- logging.error(f"Credential loading error: {str(e)}")
67
- logging.warning("Server started but credentials need to be set up.")
68
- else:
69
- # No credentials found - prompt user to authenticate
70
- logging.info("No credentials found. Starting OAuth authentication flow...")
71
- try:
72
- creds = get_credentials(allow_oauth_flow=True)
73
- if creds:
74
- try:
75
- proj_id = get_user_project_id(creds)
76
- if proj_id:
77
- onboard_user(creds, proj_id)
78
- logging.info(f"Successfully onboarded with project ID: {proj_id}")
79
- logging.info("Gemini proxy server started successfully")
80
- except Exception as e:
81
- logging.error(f"Setup failed: {str(e)}")
82
- logging.warning("Server started but may not function properly until setup issues are resolved.")
83
- else:
84
- logging.error("Authentication failed. Server started but will not function until credentials are provided.")
85
- except Exception as e:
86
- logging.error(f"Authentication error: {str(e)}")
87
- logging.warning("Server started but authentication failed.")
88
-
89
- logging.info("Authentication required - Password: see .env file")
90
-
91
- except Exception as e:
92
- logging.error(f"Startup error: {str(e)}")
93
- logging.warning("Server may not function properly.")
94
-
95
- @app.options("/{full_path:path}")
96
- async def handle_preflight(request: Request, full_path: str):
97
- """Handle CORS preflight requests without authentication."""
98
- return Response(
99
- status_code=200,
100
- headers={
101
- "Access-Control-Allow-Origin": "*",
102
- "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
103
- "Access-Control-Allow-Headers": "*",
104
- "Access-Control-Allow-Credentials": "true",
105
- }
106
- )
107
-
108
- # Root endpoint - no authentication required
109
- @app.get("/")
110
- async def root():
111
- """
112
- Root endpoint providing project information.
113
- No authentication required.
114
- """
115
- return {
116
- "name": "geminicli2api",
117
- "description": "OpenAI-compatible API proxy for Google's Gemini models via gemini-cli",
118
- "purpose": "Provides both OpenAI-compatible endpoints (/v1/chat/completions) and native Gemini API endpoints for accessing Google's Gemini models",
119
- "version": "1.0.0",
120
- "endpoints": {
121
- "openai_compatible": {
122
- "chat_completions": "/v1/chat/completions",
123
- "models": "/v1/models"
124
- },
125
- "native_gemini": {
126
- "models": "/v1beta/models",
127
- "generate": "/v1beta/models/{model}/generateContent",
128
- "stream": "/v1beta/models/{model}/streamGenerateContent"
129
- },
130
- "health": "/health"
131
- },
132
- "authentication": "Required for all endpoints except root and health",
133
- "repository": "https://github.com/user/geminicli2api"
134
- }
135
-
136
- # Health check endpoint for Docker/Hugging Face
137
- @app.get("/health")
138
- async def health_check():
139
- """Health check endpoint for container orchestration."""
140
- return {"status": "healthy", "service": "geminicli2api"}
141
-
142
- app.include_router(openai_router)
143
- app.include_router(gemini_router)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/models.py DELETED
@@ -1,72 +0,0 @@
1
- from pydantic import BaseModel, Field
2
- from typing import List, Optional, Union, Dict, Any
3
-
4
- # OpenAI Models
5
- class OpenAIChatMessage(BaseModel):
6
- role: str
7
- content: Union[str, List[Dict[str, Any]]]
8
- reasoning_content: Optional[str] = None
9
-
10
- class OpenAIChatCompletionRequest(BaseModel):
11
- model: str
12
- messages: List[OpenAIChatMessage]
13
- stream: bool = False
14
- temperature: Optional[float] = None
15
- top_p: Optional[float] = None
16
- max_tokens: Optional[int] = None
17
- stop: Optional[Union[str, List[str]]] = None
18
- frequency_penalty: Optional[float] = None
19
- presence_penalty: Optional[float] = None
20
- n: Optional[int] = None
21
- seed: Optional[int] = None
22
- response_format: Optional[Dict[str, Any]] = None
23
-
24
- class Config:
25
- extra = "allow" # Allow additional fields not explicitly defined
26
-
27
- class OpenAIChatCompletionChoice(BaseModel):
28
- index: int
29
- message: OpenAIChatMessage
30
- finish_reason: Optional[str] = None
31
-
32
- class OpenAIChatCompletionResponse(BaseModel):
33
- id: str
34
- object: str
35
- created: int
36
- model: str
37
- choices: List[OpenAIChatCompletionChoice]
38
-
39
- class OpenAIDelta(BaseModel):
40
- content: Optional[str] = None
41
- reasoning_content: Optional[str] = None
42
-
43
- class OpenAIChatCompletionStreamChoice(BaseModel):
44
- index: int
45
- delta: OpenAIDelta
46
- finish_reason: Optional[str] = None
47
-
48
- class OpenAIChatCompletionStreamResponse(BaseModel):
49
- id: str
50
- object: str
51
- created: int
52
- model: str
53
- choices: List[OpenAIChatCompletionStreamChoice]
54
-
55
- # Gemini Models
56
- class GeminiPart(BaseModel):
57
- text: str
58
-
59
- class GeminiContent(BaseModel):
60
- role: str
61
- parts: List[GeminiPart]
62
-
63
- class GeminiRequest(BaseModel):
64
- contents: List[GeminiContent]
65
-
66
- class GeminiCandidate(BaseModel):
67
- content: GeminiContent
68
- finish_reason: Optional[str] = None
69
- index: int
70
-
71
- class GeminiResponse(BaseModel):
72
- candidates: List[GeminiCandidate]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/openai_routes.py DELETED
@@ -1,305 +0,0 @@
1
- """
2
- OpenAI API Routes - Handles OpenAI-compatible endpoints.
3
- This module provides OpenAI-compatible endpoints that transform requests/responses
4
- and delegate to the Google API client.
5
- """
6
- import json
7
- import uuid
8
- import asyncio
9
- import logging
10
- from fastapi import APIRouter, Request, Response, Depends
11
- from fastapi.responses import StreamingResponse
12
-
13
- from .auth import authenticate_user
14
- from .models import OpenAIChatCompletionRequest
15
- from .openai_transformers import (
16
- openai_request_to_gemini,
17
- gemini_response_to_openai,
18
- gemini_stream_chunk_to_openai
19
- )
20
- from .google_api_client import send_gemini_request, build_gemini_payload_from_openai
21
-
22
- router = APIRouter()
23
-
24
-
25
- @router.post("/v1/chat/completions")
26
- async def openai_chat_completions(
27
- request: OpenAIChatCompletionRequest,
28
- http_request: Request,
29
- username: str = Depends(authenticate_user)
30
- ):
31
- """
32
- OpenAI-compatible chat completions endpoint.
33
- Transforms OpenAI requests to Gemini format, sends to Google API,
34
- and transforms responses back to OpenAI format.
35
- """
36
-
37
- try:
38
- logging.info(f"OpenAI chat completion request: model={request.model}, stream={request.stream}")
39
-
40
- # Transform OpenAI request to Gemini format
41
- gemini_request_data = openai_request_to_gemini(request)
42
-
43
- # Build the payload for Google API
44
- gemini_payload = build_gemini_payload_from_openai(gemini_request_data)
45
-
46
- except Exception as e:
47
- logging.error(f"Error processing OpenAI request: {str(e)}")
48
- return Response(
49
- content=json.dumps({
50
- "error": {
51
- "message": f"Request processing failed: {str(e)}",
52
- "type": "invalid_request_error",
53
- "code": 400
54
- }
55
- }),
56
- status_code=400,
57
- media_type="application/json"
58
- )
59
-
60
- if request.stream:
61
- # Handle streaming response
62
- async def openai_stream_generator():
63
- try:
64
- response = send_gemini_request(gemini_payload, is_streaming=True)
65
-
66
- if isinstance(response, StreamingResponse):
67
- response_id = "chatcmpl-" + str(uuid.uuid4())
68
- logging.info(f"Starting streaming response: {response_id}")
69
-
70
- async for chunk in response.body_iterator:
71
- if isinstance(chunk, bytes):
72
- chunk = chunk.decode('utf-8')
73
-
74
- if chunk.startswith('data: '):
75
- try:
76
- # Parse the Gemini streaming chunk
77
- chunk_data = chunk[6:] # Remove 'data: ' prefix
78
- gemini_chunk = json.loads(chunk_data)
79
-
80
- # Check if this is an error chunk
81
- if "error" in gemini_chunk:
82
- logging.error(f"Error in streaming response: {gemini_chunk['error']}")
83
- # Transform error to OpenAI format
84
- error_data = {
85
- "error": {
86
- "message": gemini_chunk["error"].get("message", "Unknown error"),
87
- "type": gemini_chunk["error"].get("type", "api_error"),
88
- "code": gemini_chunk["error"].get("code")
89
- }
90
- }
91
- yield f"data: {json.dumps(error_data)}\n\n"
92
- yield "data: [DONE]\n\n"
93
- return
94
-
95
- # Transform to OpenAI format
96
- openai_chunk = gemini_stream_chunk_to_openai(
97
- gemini_chunk,
98
- request.model,
99
- response_id
100
- )
101
-
102
- # Send as OpenAI streaming format
103
- yield f"data: {json.dumps(openai_chunk)}\n\n"
104
- await asyncio.sleep(0)
105
-
106
- except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
107
- logging.warning(f"Failed to parse streaming chunk: {str(e)}")
108
- continue
109
-
110
- # Send the final [DONE] marker
111
- yield "data: [DONE]\n\n"
112
- logging.info(f"Completed streaming response: {response_id}")
113
- else:
114
- # Error case - handle Response object with error
115
- error_msg = "Streaming request failed"
116
- status_code = 500
117
-
118
- if hasattr(response, 'status_code'):
119
- status_code = response.status_code
120
- error_msg += f" (status: {status_code})"
121
-
122
- if hasattr(response, 'body'):
123
- try:
124
- # Try to parse error response
125
- error_body = response.body
126
- if isinstance(error_body, bytes):
127
- error_body = error_body.decode('utf-8')
128
- error_data = json.loads(error_body)
129
- if "error" in error_data:
130
- error_msg = error_data["error"].get("message", error_msg)
131
- except:
132
- pass
133
-
134
- logging.error(f"Streaming request failed: {error_msg}")
135
- error_data = {
136
- "error": {
137
- "message": error_msg,
138
- "type": "invalid_request_error" if status_code == 404 else "api_error",
139
- "code": status_code
140
- }
141
- }
142
- yield f"data: {json.dumps(error_data)}\n\n"
143
- yield "data: [DONE]\n\n"
144
- except Exception as e:
145
- logging.error(f"Streaming error: {str(e)}")
146
- error_data = {
147
- "error": {
148
- "message": f"Streaming failed: {str(e)}",
149
- "type": "api_error",
150
- "code": 500
151
- }
152
- }
153
- yield f"data: {json.dumps(error_data)}\n\n"
154
- yield "data: [DONE]\n\n"
155
-
156
- return StreamingResponse(
157
- openai_stream_generator(),
158
- media_type="text/event-stream"
159
- )
160
-
161
- else:
162
- # Handle non-streaming response
163
- try:
164
- response = send_gemini_request(gemini_payload, is_streaming=False)
165
-
166
- if isinstance(response, Response) and response.status_code != 200:
167
- # Handle error responses from Google API
168
- logging.error(f"Gemini API error: status={response.status_code}")
169
-
170
- try:
171
- # Try to parse the error response and transform to OpenAI format
172
- error_body = response.body
173
- if isinstance(error_body, bytes):
174
- error_body = error_body.decode('utf-8')
175
-
176
- error_data = json.loads(error_body)
177
- if "error" in error_data:
178
- # Transform Google API error to OpenAI format
179
- openai_error = {
180
- "error": {
181
- "message": error_data["error"].get("message", f"API error: {response.status_code}"),
182
- "type": error_data["error"].get("type", "invalid_request_error" if response.status_code == 404 else "api_error"),
183
- "code": error_data["error"].get("code", response.status_code)
184
- }
185
- }
186
- return Response(
187
- content=json.dumps(openai_error),
188
- status_code=response.status_code,
189
- media_type="application/json"
190
- )
191
- except (json.JSONDecodeError, UnicodeDecodeError):
192
- pass
193
-
194
- # Fallback error response
195
- return Response(
196
- content=json.dumps({
197
- "error": {
198
- "message": f"API error: {response.status_code}",
199
- "type": "invalid_request_error" if response.status_code == 404 else "api_error",
200
- "code": response.status_code
201
- }
202
- }),
203
- status_code=response.status_code,
204
- media_type="application/json"
205
- )
206
-
207
- try:
208
- # Parse Gemini response and transform to OpenAI format
209
- gemini_response = json.loads(response.body)
210
- openai_response = gemini_response_to_openai(gemini_response, request.model)
211
-
212
- logging.info(f"Successfully processed non-streaming response for model: {request.model}")
213
- return openai_response
214
-
215
- except (json.JSONDecodeError, AttributeError) as e:
216
- logging.error(f"Failed to parse Gemini response: {str(e)}")
217
- return Response(
218
- content=json.dumps({
219
- "error": {
220
- "message": f"Failed to process response: {str(e)}",
221
- "type": "api_error",
222
- "code": 500
223
- }
224
- }),
225
- status_code=500,
226
- media_type="application/json"
227
- )
228
- except Exception as e:
229
- logging.error(f"Non-streaming request failed: {str(e)}")
230
- return Response(
231
- content=json.dumps({
232
- "error": {
233
- "message": f"Request failed: {str(e)}",
234
- "type": "api_error",
235
- "code": 500
236
- }
237
- }),
238
- status_code=500,
239
- media_type="application/json"
240
- )
241
-
242
-
243
- @router.get("/v1/models")
244
- async def openai_list_models(username: str = Depends(authenticate_user)):
245
- """
246
- OpenAI-compatible models endpoint.
247
- Returns available models in OpenAI format.
248
- """
249
-
250
- try:
251
- logging.info("OpenAI models list requested")
252
-
253
- # Convert our Gemini models to OpenAI format
254
- from .config import SUPPORTED_MODELS
255
-
256
- openai_models = []
257
- for model in SUPPORTED_MODELS:
258
- # Remove "models/" prefix for OpenAI compatibility
259
- model_id = model["name"].replace("models/", "")
260
- openai_models.append({
261
- "id": model_id,
262
- "object": "model",
263
- "created": 1677610602, # Static timestamp
264
- "owned_by": "google",
265
- "permission": [
266
- {
267
- "id": "modelperm-" + model_id.replace("/", "-"),
268
- "object": "model_permission",
269
- "created": 1677610602,
270
- "allow_create_engine": False,
271
- "allow_sampling": True,
272
- "allow_logprobs": False,
273
- "allow_search_indices": False,
274
- "allow_view": True,
275
- "allow_fine_tuning": False,
276
- "organization": "*",
277
- "group": None,
278
- "is_blocking": False
279
- }
280
- ],
281
- "root": model_id,
282
- "parent": None
283
- })
284
-
285
- logging.info(f"Returning {len(openai_models)} models")
286
- return {
287
- "object": "list",
288
- "data": openai_models
289
- }
290
-
291
- except Exception as e:
292
- logging.error(f"Failed to list models: {str(e)}")
293
- return Response(
294
- content=json.dumps({
295
- "error": {
296
- "message": f"Failed to list models: {str(e)}",
297
- "type": "api_error",
298
- "code": 500
299
- }
300
- }),
301
- status_code=500,
302
- media_type="application/json"
303
- )
304
-
305
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/openai_transformers.py DELETED
@@ -1,258 +0,0 @@
1
- """
2
- OpenAI Format Transformers - Handles conversion between OpenAI and Gemini API formats.
3
- This module contains all the logic for transforming requests and responses between the two formats.
4
- """
5
- import json
6
- import time
7
- import uuid
8
- from typing import Dict, Any
9
-
10
- from .models import OpenAIChatCompletionRequest, OpenAIChatCompletionResponse
11
- from .config import (
12
- DEFAULT_SAFETY_SETTINGS,
13
- is_search_model,
14
- get_base_model_name,
15
- get_thinking_budget,
16
- should_include_thoughts
17
- )
18
-
19
-
20
- def openai_request_to_gemini(openai_request: OpenAIChatCompletionRequest) -> Dict[str, Any]:
21
- """
22
- Transform an OpenAI chat completion request to Gemini format.
23
-
24
- Args:
25
- openai_request: OpenAI format request
26
-
27
- Returns:
28
- Dictionary in Gemini API format
29
- """
30
- contents = []
31
-
32
- # Process each message in the conversation
33
- for message in openai_request.messages:
34
- role = message.role
35
-
36
- # Map OpenAI roles to Gemini roles
37
- if role == "assistant":
38
- role = "model"
39
- elif role == "system":
40
- role = "user" # Gemini treats system messages as user messages
41
-
42
- # Handle different content types (string vs list of parts)
43
- if isinstance(message.content, list):
44
- parts = []
45
- for part in message.content:
46
- if part.get("type") == "text":
47
- parts.append({"text": part.get("text", "")})
48
- elif part.get("type") == "image_url":
49
- image_url = part.get("image_url", {}).get("url")
50
- if image_url:
51
- # Parse data URI: "data:image/jpeg;base64,{base64_image}"
52
- try:
53
- mime_type, base64_data = image_url.split(";")
54
- _, mime_type = mime_type.split(":")
55
- _, base64_data = base64_data.split(",")
56
- parts.append({
57
- "inlineData": {
58
- "mimeType": mime_type,
59
- "data": base64_data
60
- }
61
- })
62
- except ValueError:
63
- continue
64
- contents.append({"role": role, "parts": parts})
65
- else:
66
- # Simple text content
67
- contents.append({"role": role, "parts": [{"text": message.content}]})
68
-
69
- # Map OpenAI generation parameters to Gemini format
70
- generation_config = {}
71
- if openai_request.temperature is not None:
72
- generation_config["temperature"] = openai_request.temperature
73
- if openai_request.top_p is not None:
74
- generation_config["topP"] = openai_request.top_p
75
- if openai_request.max_tokens is not None:
76
- generation_config["maxOutputTokens"] = openai_request.max_tokens
77
- if openai_request.stop is not None:
78
- # Gemini supports stop sequences
79
- if isinstance(openai_request.stop, str):
80
- generation_config["stopSequences"] = [openai_request.stop]
81
- elif isinstance(openai_request.stop, list):
82
- generation_config["stopSequences"] = openai_request.stop
83
- if openai_request.frequency_penalty is not None:
84
- # Map frequency_penalty to Gemini's frequencyPenalty
85
- generation_config["frequencyPenalty"] = openai_request.frequency_penalty
86
- if openai_request.presence_penalty is not None:
87
- # Map presence_penalty to Gemini's presencePenalty
88
- generation_config["presencePenalty"] = openai_request.presence_penalty
89
- if openai_request.n is not None:
90
- # Map n (number of completions) to Gemini's candidateCount
91
- generation_config["candidateCount"] = openai_request.n
92
- if openai_request.seed is not None:
93
- # Gemini supports seed for reproducible outputs
94
- generation_config["seed"] = openai_request.seed
95
- if openai_request.response_format is not None:
96
- # Handle JSON mode if specified
97
- if openai_request.response_format.get("type") == "json_object":
98
- generation_config["responseMimeType"] = "application/json"
99
-
100
- # Build the request payload
101
- request_payload = {
102
- "contents": contents,
103
- "generationConfig": generation_config,
104
- "safetySettings": DEFAULT_SAFETY_SETTINGS,
105
- "model": get_base_model_name(openai_request.model) # Use base model name for API call
106
- }
107
-
108
- # Add Google Search grounding for search models
109
- if is_search_model(openai_request.model):
110
- request_payload["tools"] = [{"googleSearch": {}}]
111
-
112
- # Add thinking configuration for thinking models
113
- thinking_budget = get_thinking_budget(openai_request.model)
114
- if thinking_budget is not None:
115
- request_payload["generationConfig"]["thinkingConfig"] = {
116
- "thinkingBudget": thinking_budget,
117
- "includeThoughts": should_include_thoughts(openai_request.model)
118
- }
119
-
120
- return request_payload
121
-
122
-
123
- def gemini_response_to_openai(gemini_response: Dict[str, Any], model: str) -> Dict[str, Any]:
124
- """
125
- Transform a Gemini API response to OpenAI chat completion format.
126
-
127
- Args:
128
- gemini_response: Response from Gemini API
129
- model: Model name to include in response
130
-
131
- Returns:
132
- Dictionary in OpenAI chat completion format
133
- """
134
- choices = []
135
-
136
- for candidate in gemini_response.get("candidates", []):
137
- role = candidate.get("content", {}).get("role", "assistant")
138
-
139
- # Map Gemini roles back to OpenAI roles
140
- if role == "model":
141
- role = "assistant"
142
-
143
- # Extract and separate thinking tokens from regular content
144
- parts = candidate.get("content", {}).get("parts", [])
145
- content = ""
146
- reasoning_content = ""
147
-
148
- for part in parts:
149
- if not part.get("text"):
150
- continue
151
-
152
- # Check if this part contains thinking tokens
153
- if part.get("thought", False):
154
- reasoning_content += part.get("text", "")
155
- else:
156
- content += part.get("text", "")
157
-
158
- # Build message object
159
- message = {
160
- "role": role,
161
- "content": content,
162
- }
163
-
164
- # Add reasoning_content if there are thinking tokens
165
- if reasoning_content:
166
- message["reasoning_content"] = reasoning_content
167
-
168
- choices.append({
169
- "index": candidate.get("index", 0),
170
- "message": message,
171
- "finish_reason": _map_finish_reason(candidate.get("finishReason")),
172
- })
173
-
174
- return {
175
- "id": str(uuid.uuid4()),
176
- "object": "chat.completion",
177
- "created": int(time.time()),
178
- "model": model,
179
- "choices": choices,
180
- }
181
-
182
-
183
- def gemini_stream_chunk_to_openai(gemini_chunk: Dict[str, Any], model: str, response_id: str) -> Dict[str, Any]:
184
- """
185
- Transform a Gemini streaming response chunk to OpenAI streaming format.
186
-
187
- Args:
188
- gemini_chunk: Single chunk from Gemini streaming response
189
- model: Model name to include in response
190
- response_id: Consistent ID for this streaming response
191
-
192
- Returns:
193
- Dictionary in OpenAI streaming format
194
- """
195
- choices = []
196
-
197
- for candidate in gemini_chunk.get("candidates", []):
198
- role = candidate.get("content", {}).get("role", "assistant")
199
-
200
- # Map Gemini roles back to OpenAI roles
201
- if role == "model":
202
- role = "assistant"
203
-
204
- # Extract and separate thinking tokens from regular content
205
- parts = candidate.get("content", {}).get("parts", [])
206
- content = ""
207
- reasoning_content = ""
208
-
209
- for part in parts:
210
- if not part.get("text"):
211
- continue
212
-
213
- # Check if this part contains thinking tokens
214
- if part.get("thought", False):
215
- reasoning_content += part.get("text", "")
216
- else:
217
- content += part.get("text", "")
218
-
219
- # Build delta object
220
- delta = {}
221
- if content:
222
- delta["content"] = content
223
- if reasoning_content:
224
- delta["reasoning_content"] = reasoning_content
225
-
226
- choices.append({
227
- "index": candidate.get("index", 0),
228
- "delta": delta,
229
- "finish_reason": _map_finish_reason(candidate.get("finishReason")),
230
- })
231
-
232
- return {
233
- "id": response_id,
234
- "object": "chat.completion.chunk",
235
- "created": int(time.time()),
236
- "model": model,
237
- "choices": choices,
238
- }
239
-
240
-
241
- def _map_finish_reason(gemini_reason: str) -> str:
242
- """
243
- Map Gemini finish reasons to OpenAI finish reasons.
244
-
245
- Args:
246
- gemini_reason: Finish reason from Gemini API
247
-
248
- Returns:
249
- OpenAI-compatible finish reason
250
- """
251
- if gemini_reason == "STOP":
252
- return "stop"
253
- elif gemini_reason == "MAX_TOKENS":
254
- return "length"
255
- elif gemini_reason in ["SAFETY", "RECITATION"]:
256
- return "content_filter"
257
- else:
258
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/utils.py DELETED
@@ -1,38 +0,0 @@
1
- import platform
2
- from .config import CLI_VERSION
3
-
4
- def get_user_agent():
5
- """Generate User-Agent string matching gemini-cli format."""
6
- version = CLI_VERSION
7
- system = platform.system()
8
- arch = platform.machine()
9
- return f"GeminiCLI/{version} ({system}; {arch})"
10
-
11
- def get_platform_string():
12
- """Generate platform string matching gemini-cli format."""
13
- system = platform.system().upper()
14
- arch = platform.machine().upper()
15
-
16
- # Map to gemini-cli platform format
17
- if system == "DARWIN":
18
- if arch in ["ARM64", "AARCH64"]:
19
- return "DARWIN_ARM64"
20
- else:
21
- return "DARWIN_AMD64"
22
- elif system == "LINUX":
23
- if arch in ["ARM64", "AARCH64"]:
24
- return "LINUX_ARM64"
25
- else:
26
- return "LINUX_AMD64"
27
- elif system == "WINDOWS":
28
- return "WINDOWS_AMD64"
29
- else:
30
- return "PLATFORM_UNSPECIFIED"
31
-
32
- def get_client_metadata(project_id=None):
33
- return {
34
- "ideType": "IDE_UNSPECIFIED",
35
- "platform": get_platform_string(),
36
- "pluginType": "GEMINI",
37
- "duetProject": project_id,
38
- }