Spaces:
Paused
Paused
Commit
·
d12a6b6
1
Parent(s):
9963145
major refactor
Browse files- src/auth.py +18 -72
- src/config.py +170 -0
- src/gemini.py +0 -228
- src/gemini_request_builder.py +0 -68
- src/gemini_response_handler.py +0 -73
- src/gemini_routes.py +144 -0
- src/google_api_client.py +214 -0
- src/main.py +19 -14
- src/models.py +9 -0
- src/openai.py +0 -184
- src/openai_routes.py +180 -0
- src/openai_transformers.py +207 -0
- src/utils.py +1 -2
src/auth.py
CHANGED
|
@@ -13,19 +13,10 @@ from google_auth_oauthlib.flow import Flow
|
|
| 13 |
from google.auth.transport.requests import Request as GoogleAuthRequest
|
| 14 |
|
| 15 |
from .utils import get_user_agent, get_client_metadata
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
SCOPES = [
|
| 21 |
-
"https://www.googleapis.com/auth/cloud-platform",
|
| 22 |
-
"https://www.googleapis.com/auth/userinfo.email",
|
| 23 |
-
"https://www.googleapis.com/auth/userinfo.profile",
|
| 24 |
-
]
|
| 25 |
-
SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
-
CREDENTIAL_FILE = os.path.join(SCRIPT_DIR, "oauth_creds.json")
|
| 27 |
-
CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
| 28 |
-
GEMINI_AUTH_PASSWORD = os.getenv("GEMINI_AUTH_PASSWORD", "123456") # Default password
|
| 29 |
|
| 30 |
# --- Global State ---
|
| 31 |
credentials = None
|
|
@@ -44,7 +35,7 @@ class _OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
| 44 |
self.send_response(200)
|
| 45 |
self.send_header("Content-type", "text/html")
|
| 46 |
self.end_headers()
|
| 47 |
-
self.wfile.write(b"<h1>
|
| 48 |
else:
|
| 49 |
self.send_response(400)
|
| 50 |
self.send_header("Content-type", "text/html")
|
|
@@ -89,7 +80,6 @@ def authenticate_user(request: Request):
|
|
| 89 |
)
|
| 90 |
|
| 91 |
def save_credentials(creds, project_id=None):
|
| 92 |
-
print(f"DEBUG: Saving credentials - Token: {creds.token[:20] if creds.token else 'None'}..., Expired: {creds.expired}, Expiry: {creds.expiry}")
|
| 93 |
|
| 94 |
creds_data = {
|
| 95 |
"client_id": CLIENT_ID,
|
|
@@ -107,9 +97,6 @@ def save_credentials(creds, project_id=None):
|
|
| 107 |
else:
|
| 108 |
expiry_utc = creds.expiry
|
| 109 |
creds_data["expiry"] = expiry_utc.isoformat()
|
| 110 |
-
print(f"DEBUG: Saving expiry as: {creds_data['expiry']}")
|
| 111 |
-
else:
|
| 112 |
-
print("DEBUG: No expiry time available to save")
|
| 113 |
|
| 114 |
if project_id:
|
| 115 |
creds_data["project_id"] = project_id
|
|
@@ -122,23 +109,17 @@ def save_credentials(creds, project_id=None):
|
|
| 122 |
except Exception:
|
| 123 |
pass
|
| 124 |
|
| 125 |
-
print(f"DEBUG: Final credential data to save: {json.dumps(creds_data, indent=2)}")
|
| 126 |
|
| 127 |
with open(CREDENTIAL_FILE, "w") as f:
|
| 128 |
json.dump(creds_data, f, indent=2)
|
| 129 |
|
| 130 |
-
print("DEBUG: Credentials saved to file")
|
| 131 |
|
| 132 |
def get_credentials():
|
| 133 |
"""Loads credentials matching gemini-cli OAuth2 flow."""
|
| 134 |
global credentials
|
| 135 |
|
| 136 |
if credentials and credentials.token:
|
| 137 |
-
print("Using valid credentials from memory cache.")
|
| 138 |
-
print(f"DEBUG: Memory credentials - Token: {credentials.token[:20] if credentials.token else 'None'}..., Expired: {credentials.expired}, Expiry: {credentials.expiry}")
|
| 139 |
return credentials
|
| 140 |
-
else:
|
| 141 |
-
print("No valid credentials in memory. Loading from disk.")
|
| 142 |
|
| 143 |
env_creds = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
| 144 |
if env_creds and os.path.exists(env_creds):
|
|
@@ -146,56 +127,41 @@ def get_credentials():
|
|
| 146 |
with open(env_creds, "r") as f:
|
| 147 |
creds_data = json.load(f)
|
| 148 |
credentials = Credentials.from_authorized_user_info(creds_data, SCOPES)
|
| 149 |
-
|
| 150 |
-
print(f"DEBUG: Env credentials - Token: {credentials.token[:20] if credentials.token else 'None'}..., Expired: {credentials.expired}, Expiry: {credentials.expiry}")
|
| 151 |
-
|
| 152 |
if credentials.refresh_token:
|
| 153 |
-
print("Refreshing environment credentials at startup for reliability...")
|
| 154 |
try:
|
| 155 |
credentials.refresh(GoogleAuthRequest())
|
| 156 |
-
print("Startup token refresh successful for environment credentials.")
|
| 157 |
except Exception as refresh_error:
|
| 158 |
-
|
| 159 |
-
else:
|
| 160 |
-
print("No refresh token available in environment credentials - using as-is.")
|
| 161 |
|
| 162 |
return credentials
|
| 163 |
except Exception as e:
|
| 164 |
-
|
| 165 |
|
| 166 |
if os.path.exists(CREDENTIAL_FILE):
|
| 167 |
try:
|
| 168 |
with open(CREDENTIAL_FILE, "r") as f:
|
| 169 |
creds_data = json.load(f)
|
| 170 |
|
| 171 |
-
print(f"DEBUG: Raw credential data from file: {json.dumps(creds_data, indent=2)}")
|
| 172 |
|
| 173 |
if "access_token" in creds_data and "token" not in creds_data:
|
| 174 |
creds_data["token"] = creds_data["access_token"]
|
| 175 |
-
print("DEBUG: Converted access_token to token field")
|
| 176 |
|
| 177 |
if "scope" in creds_data and "scopes" not in creds_data:
|
| 178 |
creds_data["scopes"] = creds_data["scope"].split()
|
| 179 |
-
print("DEBUG: Converted scope string to scopes list")
|
| 180 |
|
| 181 |
credentials = Credentials.from_authorized_user_info(creds_data, SCOPES)
|
| 182 |
-
|
| 183 |
-
print(f"DEBUG: Loaded credentials - Token: {credentials.token[:20] if credentials.token else 'None'}..., Expired: {credentials.expired}, Expiry: {credentials.expiry}")
|
| 184 |
-
|
| 185 |
if credentials.refresh_token:
|
| 186 |
-
print("Refreshing tokens at startup for reliability...")
|
| 187 |
try:
|
| 188 |
credentials.refresh(GoogleAuthRequest())
|
| 189 |
save_credentials(credentials)
|
| 190 |
-
print("Startup token refresh successful.")
|
| 191 |
except Exception as refresh_error:
|
| 192 |
-
|
| 193 |
-
else:
|
| 194 |
-
print("No refresh token available - using cached credentials as-is.")
|
| 195 |
|
| 196 |
return credentials
|
| 197 |
except Exception as e:
|
| 198 |
-
|
| 199 |
|
| 200 |
client_config = {
|
| 201 |
"installed": {
|
|
@@ -226,7 +192,6 @@ def get_credentials():
|
|
| 226 |
|
| 227 |
auth_code = _OAuthCallbackHandler.auth_code
|
| 228 |
if not auth_code:
|
| 229 |
-
print("Failed to retrieve authorization code.")
|
| 230 |
return None
|
| 231 |
|
| 232 |
import oauthlib.oauth2.rfc6749.parameters
|
|
@@ -259,16 +224,11 @@ def onboard_user(creds, project_id):
|
|
| 259 |
return
|
| 260 |
|
| 261 |
if creds.expired and creds.refresh_token:
|
| 262 |
-
print("Credentials expired. Refreshing before onboarding...")
|
| 263 |
try:
|
| 264 |
creds.refresh(GoogleAuthRequest())
|
| 265 |
save_credentials(creds)
|
| 266 |
-
print("Credentials refreshed successfully.")
|
| 267 |
except Exception as e:
|
| 268 |
-
|
| 269 |
-
raise
|
| 270 |
-
|
| 271 |
-
print("Checking user onboarding status...")
|
| 272 |
headers = {
|
| 273 |
"Authorization": f"Bearer {creds.token}",
|
| 274 |
"Content-Type": "application/json",
|
|
@@ -293,7 +253,6 @@ def onboard_user(creds, project_id):
|
|
| 293 |
tier = None
|
| 294 |
if load_data.get("currentTier"):
|
| 295 |
tier = load_data["currentTier"]
|
| 296 |
-
print("User is already onboarded.")
|
| 297 |
else:
|
| 298 |
for allowed_tier in load_data.get("allowedTiers", []):
|
| 299 |
if allowed_tier.get("isDefault"):
|
|
@@ -315,7 +274,6 @@ def onboard_user(creds, project_id):
|
|
| 315 |
onboarding_complete = True
|
| 316 |
return
|
| 317 |
|
| 318 |
-
print(f"Onboarding user to tier: {tier.get('name', 'legacy-tier')}")
|
| 319 |
onboard_req_payload = {
|
| 320 |
"tierId": tier.get("id"),
|
| 321 |
"cloudaicompanionProject": project_id,
|
|
@@ -332,16 +290,15 @@ def onboard_user(creds, project_id):
|
|
| 332 |
lro_data = onboard_resp.json()
|
| 333 |
|
| 334 |
if lro_data.get("done"):
|
| 335 |
-
print("Onboarding successful.")
|
| 336 |
onboarding_complete = True
|
| 337 |
break
|
| 338 |
|
| 339 |
-
print("Onboarding in progress, waiting 5 seconds...")
|
| 340 |
time.sleep(5)
|
| 341 |
|
| 342 |
except requests.exceptions.HTTPError as e:
|
| 343 |
-
|
| 344 |
-
|
|
|
|
| 345 |
|
| 346 |
def get_user_project_id(creds):
|
| 347 |
"""Gets the user's project ID matching gemini-cli setupUser logic."""
|
|
@@ -352,14 +309,12 @@ def get_user_project_id(creds):
|
|
| 352 |
env_project_id = os.getenv("GOOGLE_CLOUD_PROJECT")
|
| 353 |
if env_project_id:
|
| 354 |
user_project_id = env_project_id
|
| 355 |
-
print(f"Using project ID from GOOGLE_CLOUD_PROJECT: {user_project_id}")
|
| 356 |
save_credentials(creds, user_project_id)
|
| 357 |
return user_project_id
|
| 358 |
|
| 359 |
gemini_env_project_id = os.getenv("GEMINI_PROJECT_ID")
|
| 360 |
if gemini_env_project_id:
|
| 361 |
user_project_id = gemini_env_project_id
|
| 362 |
-
print(f"Using project ID from GEMINI_PROJECT_ID: {user_project_id}")
|
| 363 |
save_credentials(creds, user_project_id)
|
| 364 |
return user_project_id
|
| 365 |
|
|
@@ -370,22 +325,16 @@ def get_user_project_id(creds):
|
|
| 370 |
cached_project_id = creds_data.get("project_id")
|
| 371 |
if cached_project_id:
|
| 372 |
user_project_id = cached_project_id
|
| 373 |
-
print(f"Loaded project ID from cache: {user_project_id}")
|
| 374 |
return user_project_id
|
| 375 |
except Exception as e:
|
| 376 |
-
|
| 377 |
|
| 378 |
-
print("Project ID not found in environment or cache. Probing for user project ID...")
|
| 379 |
-
|
| 380 |
if creds.expired and creds.refresh_token:
|
| 381 |
-
print("Credentials expired. Refreshing before project ID probe...")
|
| 382 |
try:
|
| 383 |
creds.refresh(GoogleAuthRequest())
|
| 384 |
save_credentials(creds)
|
| 385 |
-
print("Credentials refreshed successfully.")
|
| 386 |
except Exception as e:
|
| 387 |
-
|
| 388 |
-
raise
|
| 389 |
|
| 390 |
headers = {
|
| 391 |
"Authorization": f"Bearer {creds.token}",
|
|
@@ -409,12 +358,9 @@ def get_user_project_id(creds):
|
|
| 409 |
user_project_id = data.get("cloudaicompanionProject")
|
| 410 |
if not user_project_id:
|
| 411 |
raise ValueError("Could not find 'cloudaicompanionProject' in loadCodeAssist response.")
|
| 412 |
-
|
| 413 |
-
|
| 414 |
save_credentials(creds, user_project_id)
|
| 415 |
-
print("Project ID saved to credential file for future use.")
|
| 416 |
|
| 417 |
return user_project_id
|
| 418 |
except requests.exceptions.HTTPError as e:
|
| 419 |
-
print(f"Error fetching project ID: {e.response.text}")
|
| 420 |
raise
|
|
|
|
| 13 |
from google.auth.transport.requests import Request as GoogleAuthRequest
|
| 14 |
|
| 15 |
from .utils import get_user_agent, get_client_metadata
|
| 16 |
+
from .config import (
|
| 17 |
+
CLIENT_ID, CLIENT_SECRET, SCOPES, CREDENTIAL_FILE,
|
| 18 |
+
CODE_ASSIST_ENDPOINT, GEMINI_AUTH_PASSWORD
|
| 19 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# --- Global State ---
|
| 22 |
credentials = None
|
|
|
|
| 35 |
self.send_response(200)
|
| 36 |
self.send_header("Content-type", "text/html")
|
| 37 |
self.end_headers()
|
| 38 |
+
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>")
|
| 39 |
else:
|
| 40 |
self.send_response(400)
|
| 41 |
self.send_header("Content-type", "text/html")
|
|
|
|
| 80 |
)
|
| 81 |
|
| 82 |
def save_credentials(creds, project_id=None):
|
|
|
|
| 83 |
|
| 84 |
creds_data = {
|
| 85 |
"client_id": CLIENT_ID,
|
|
|
|
| 97 |
else:
|
| 98 |
expiry_utc = creds.expiry
|
| 99 |
creds_data["expiry"] = expiry_utc.isoformat()
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
if project_id:
|
| 102 |
creds_data["project_id"] = project_id
|
|
|
|
| 109 |
except Exception:
|
| 110 |
pass
|
| 111 |
|
|
|
|
| 112 |
|
| 113 |
with open(CREDENTIAL_FILE, "w") as f:
|
| 114 |
json.dump(creds_data, f, indent=2)
|
| 115 |
|
|
|
|
| 116 |
|
| 117 |
def get_credentials():
|
| 118 |
"""Loads credentials matching gemini-cli OAuth2 flow."""
|
| 119 |
global credentials
|
| 120 |
|
| 121 |
if credentials and credentials.token:
|
|
|
|
|
|
|
| 122 |
return credentials
|
|
|
|
|
|
|
| 123 |
|
| 124 |
env_creds = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
| 125 |
if env_creds and os.path.exists(env_creds):
|
|
|
|
| 127 |
with open(env_creds, "r") as f:
|
| 128 |
creds_data = json.load(f)
|
| 129 |
credentials = Credentials.from_authorized_user_info(creds_data, SCOPES)
|
| 130 |
+
|
|
|
|
|
|
|
| 131 |
if credentials.refresh_token:
|
|
|
|
| 132 |
try:
|
| 133 |
credentials.refresh(GoogleAuthRequest())
|
|
|
|
| 134 |
except Exception as refresh_error:
|
| 135 |
+
pass # Use credentials as-is if refresh fails
|
|
|
|
|
|
|
| 136 |
|
| 137 |
return credentials
|
| 138 |
except Exception as e:
|
| 139 |
+
pass # Fall through to file-based credentials
|
| 140 |
|
| 141 |
if os.path.exists(CREDENTIAL_FILE):
|
| 142 |
try:
|
| 143 |
with open(CREDENTIAL_FILE, "r") as f:
|
| 144 |
creds_data = json.load(f)
|
| 145 |
|
|
|
|
| 146 |
|
| 147 |
if "access_token" in creds_data and "token" not in creds_data:
|
| 148 |
creds_data["token"] = creds_data["access_token"]
|
|
|
|
| 149 |
|
| 150 |
if "scope" in creds_data and "scopes" not in creds_data:
|
| 151 |
creds_data["scopes"] = creds_data["scope"].split()
|
|
|
|
| 152 |
|
| 153 |
credentials = Credentials.from_authorized_user_info(creds_data, SCOPES)
|
| 154 |
+
|
|
|
|
|
|
|
| 155 |
if credentials.refresh_token:
|
|
|
|
| 156 |
try:
|
| 157 |
credentials.refresh(GoogleAuthRequest())
|
| 158 |
save_credentials(credentials)
|
|
|
|
| 159 |
except Exception as refresh_error:
|
| 160 |
+
pass # Use credentials as-is if refresh fails
|
|
|
|
|
|
|
| 161 |
|
| 162 |
return credentials
|
| 163 |
except Exception as e:
|
| 164 |
+
pass # Fall through to new login
|
| 165 |
|
| 166 |
client_config = {
|
| 167 |
"installed": {
|
|
|
|
| 192 |
|
| 193 |
auth_code = _OAuthCallbackHandler.auth_code
|
| 194 |
if not auth_code:
|
|
|
|
| 195 |
return None
|
| 196 |
|
| 197 |
import oauthlib.oauth2.rfc6749.parameters
|
|
|
|
| 224 |
return
|
| 225 |
|
| 226 |
if creds.expired and creds.refresh_token:
|
|
|
|
| 227 |
try:
|
| 228 |
creds.refresh(GoogleAuthRequest())
|
| 229 |
save_credentials(creds)
|
|
|
|
| 230 |
except Exception as e:
|
| 231 |
+
raise Exception(f"Failed to refresh credentials during onboarding: {str(e)}")
|
|
|
|
|
|
|
|
|
|
| 232 |
headers = {
|
| 233 |
"Authorization": f"Bearer {creds.token}",
|
| 234 |
"Content-Type": "application/json",
|
|
|
|
| 253 |
tier = None
|
| 254 |
if load_data.get("currentTier"):
|
| 255 |
tier = load_data["currentTier"]
|
|
|
|
| 256 |
else:
|
| 257 |
for allowed_tier in load_data.get("allowedTiers", []):
|
| 258 |
if allowed_tier.get("isDefault"):
|
|
|
|
| 274 |
onboarding_complete = True
|
| 275 |
return
|
| 276 |
|
|
|
|
| 277 |
onboard_req_payload = {
|
| 278 |
"tierId": tier.get("id"),
|
| 279 |
"cloudaicompanionProject": project_id,
|
|
|
|
| 290 |
lro_data = onboard_resp.json()
|
| 291 |
|
| 292 |
if lro_data.get("done"):
|
|
|
|
| 293 |
onboarding_complete = True
|
| 294 |
break
|
| 295 |
|
|
|
|
| 296 |
time.sleep(5)
|
| 297 |
|
| 298 |
except requests.exceptions.HTTPError as e:
|
| 299 |
+
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)}")
|
| 300 |
+
except Exception as e:
|
| 301 |
+
raise Exception(f"User onboarding failed due to an unexpected error: {str(e)}")
|
| 302 |
|
| 303 |
def get_user_project_id(creds):
|
| 304 |
"""Gets the user's project ID matching gemini-cli setupUser logic."""
|
|
|
|
| 309 |
env_project_id = os.getenv("GOOGLE_CLOUD_PROJECT")
|
| 310 |
if env_project_id:
|
| 311 |
user_project_id = env_project_id
|
|
|
|
| 312 |
save_credentials(creds, user_project_id)
|
| 313 |
return user_project_id
|
| 314 |
|
| 315 |
gemini_env_project_id = os.getenv("GEMINI_PROJECT_ID")
|
| 316 |
if gemini_env_project_id:
|
| 317 |
user_project_id = gemini_env_project_id
|
|
|
|
| 318 |
save_credentials(creds, user_project_id)
|
| 319 |
return user_project_id
|
| 320 |
|
|
|
|
| 325 |
cached_project_id = creds_data.get("project_id")
|
| 326 |
if cached_project_id:
|
| 327 |
user_project_id = cached_project_id
|
|
|
|
| 328 |
return user_project_id
|
| 329 |
except Exception as e:
|
| 330 |
+
pass
|
| 331 |
|
|
|
|
|
|
|
| 332 |
if creds.expired and creds.refresh_token:
|
|
|
|
| 333 |
try:
|
| 334 |
creds.refresh(GoogleAuthRequest())
|
| 335 |
save_credentials(creds)
|
|
|
|
| 336 |
except Exception as e:
|
| 337 |
+
raise Exception(f"Failed to refresh credentials while getting project ID: {str(e)}")
|
|
|
|
| 338 |
|
| 339 |
headers = {
|
| 340 |
"Authorization": f"Bearer {creds.token}",
|
|
|
|
| 358 |
user_project_id = data.get("cloudaicompanionProject")
|
| 359 |
if not user_project_id:
|
| 360 |
raise ValueError("Could not find 'cloudaicompanionProject' in loadCodeAssist response.")
|
| 361 |
+
|
|
|
|
| 362 |
save_credentials(creds, user_project_id)
|
|
|
|
| 363 |
|
| 364 |
return user_project_id
|
| 365 |
except requests.exceptions.HTTPError as e:
|
|
|
|
| 366 |
raise
|
src/config.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, "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 |
+
# Supported Models (for /v1beta/models endpoint)
|
| 39 |
+
SUPPORTED_MODELS = [
|
| 40 |
+
{
|
| 41 |
+
"name": "models/gemini-1.5-pro",
|
| 42 |
+
"version": "001",
|
| 43 |
+
"displayName": "Gemini 1.5 Pro",
|
| 44 |
+
"description": "Mid-size multimodal model that supports up to 2 million tokens",
|
| 45 |
+
"inputTokenLimit": 2097152,
|
| 46 |
+
"outputTokenLimit": 8192,
|
| 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-1.5-flash",
|
| 55 |
+
"version": "001",
|
| 56 |
+
"displayName": "Gemini 1.5 Flash",
|
| 57 |
+
"description": "Fast and versatile multimodal model for scaling across diverse tasks",
|
| 58 |
+
"inputTokenLimit": 1048576,
|
| 59 |
+
"outputTokenLimit": 8192,
|
| 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-preview-05-06",
|
| 68 |
+
"version": "001",
|
| 69 |
+
"displayName": "Gemini 2.5 Pro Preview 05-06",
|
| 70 |
+
"description": "Preview version of Gemini 2.5 Pro from May 6th",
|
| 71 |
+
"inputTokenLimit": 1048576,
|
| 72 |
+
"outputTokenLimit": 8192,
|
| 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-pro-preview-06-05",
|
| 81 |
+
"version": "001",
|
| 82 |
+
"displayName": "Gemini 2.5 Pro Preview 06-05",
|
| 83 |
+
"description": "Preview version of Gemini 2.5 Pro from June 5th",
|
| 84 |
+
"inputTokenLimit": 1048576,
|
| 85 |
+
"outputTokenLimit": 8192,
|
| 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-pro",
|
| 94 |
+
"version": "001",
|
| 95 |
+
"displayName": "Gemini 2.5 Pro",
|
| 96 |
+
"description": "Advanced multimodal model with enhanced capabilities",
|
| 97 |
+
"inputTokenLimit": 1048576,
|
| 98 |
+
"outputTokenLimit": 8192,
|
| 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-preview-05-20",
|
| 107 |
+
"version": "001",
|
| 108 |
+
"displayName": "Gemini 2.5 Flash Preview 05-20",
|
| 109 |
+
"description": "Preview version of Gemini 2.5 Flash from May 20th",
|
| 110 |
+
"inputTokenLimit": 1048576,
|
| 111 |
+
"outputTokenLimit": 8192,
|
| 112 |
+
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 113 |
+
"temperature": 1.0,
|
| 114 |
+
"maxTemperature": 2.0,
|
| 115 |
+
"topP": 0.95,
|
| 116 |
+
"topK": 64
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"name": "models/gemini-2.5-flash",
|
| 120 |
+
"version": "001",
|
| 121 |
+
"displayName": "Gemini 2.5 Flash",
|
| 122 |
+
"description": "Fast and efficient multimodal model with latest improvements",
|
| 123 |
+
"inputTokenLimit": 1048576,
|
| 124 |
+
"outputTokenLimit": 8192,
|
| 125 |
+
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 126 |
+
"temperature": 1.0,
|
| 127 |
+
"maxTemperature": 2.0,
|
| 128 |
+
"topP": 0.95,
|
| 129 |
+
"topK": 64
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"name": "models/gemini-2.0-flash",
|
| 133 |
+
"version": "001",
|
| 134 |
+
"displayName": "Gemini 2.0 Flash",
|
| 135 |
+
"description": "Latest generation fast multimodal model",
|
| 136 |
+
"inputTokenLimit": 1048576,
|
| 137 |
+
"outputTokenLimit": 8192,
|
| 138 |
+
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 139 |
+
"temperature": 1.0,
|
| 140 |
+
"maxTemperature": 2.0,
|
| 141 |
+
"topP": 0.95,
|
| 142 |
+
"topK": 64
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"name": "models/gemini-2.0-flash-preview-image-generation",
|
| 146 |
+
"version": "001",
|
| 147 |
+
"displayName": "Gemini 2.0 Flash Preview Image Generation",
|
| 148 |
+
"description": "Preview version with image generation capabilities",
|
| 149 |
+
"inputTokenLimit": 32000,
|
| 150 |
+
"outputTokenLimit": 8192,
|
| 151 |
+
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 152 |
+
"temperature": 1.0,
|
| 153 |
+
"maxTemperature": 2.0,
|
| 154 |
+
"topP": 0.95,
|
| 155 |
+
"topK": 64
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"name": "models/gemini-embedding-001",
|
| 159 |
+
"version": "001",
|
| 160 |
+
"displayName": "Gemini Embedding 001",
|
| 161 |
+
"description": "Text embedding model for semantic similarity and search",
|
| 162 |
+
"inputTokenLimit": 2048,
|
| 163 |
+
"outputTokenLimit": 1,
|
| 164 |
+
"supportedGenerationMethods": ["embedContent"],
|
| 165 |
+
"temperature": 0.0,
|
| 166 |
+
"maxTemperature": 0.0,
|
| 167 |
+
"topP": 1.0,
|
| 168 |
+
"topK": 1
|
| 169 |
+
}
|
| 170 |
+
]
|
src/gemini.py
DELETED
|
@@ -1,228 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import requests
|
| 3 |
-
from fastapi import APIRouter, Request, Response, Depends
|
| 4 |
-
|
| 5 |
-
from .auth import authenticate_user, get_credentials, get_user_project_id, onboard_user, save_credentials
|
| 6 |
-
from .utils import get_user_agent
|
| 7 |
-
from .gemini_request_builder import build_gemini_request
|
| 8 |
-
from .gemini_response_handler import handle_gemini_response
|
| 9 |
-
|
| 10 |
-
CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
| 11 |
-
|
| 12 |
-
router = APIRouter()
|
| 13 |
-
|
| 14 |
-
@router.get("/v1beta/models")
|
| 15 |
-
async def list_models(request: Request, username: str = Depends(authenticate_user)):
|
| 16 |
-
"""List available models - matching gemini-cli supported models exactly."""
|
| 17 |
-
print(f"[GET] {request.url.path} - User: {username}")
|
| 18 |
-
print(f"[MODELS] Serving models list (both /v1/models and /v1beta/models return the same data)")
|
| 19 |
-
|
| 20 |
-
models_response = {
|
| 21 |
-
"models": [
|
| 22 |
-
{
|
| 23 |
-
"name": "models/gemini-1.5-pro",
|
| 24 |
-
"version": "001",
|
| 25 |
-
"displayName": "Gemini 1.5 Pro",
|
| 26 |
-
"description": "Mid-size multimodal model that supports up to 2 million tokens",
|
| 27 |
-
"inputTokenLimit": 2097152,
|
| 28 |
-
"outputTokenLimit": 8192,
|
| 29 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 30 |
-
"temperature": 1.0,
|
| 31 |
-
"maxTemperature": 2.0,
|
| 32 |
-
"topP": 0.95,
|
| 33 |
-
"topK": 64
|
| 34 |
-
},
|
| 35 |
-
{
|
| 36 |
-
"name": "models/gemini-1.5-flash",
|
| 37 |
-
"version": "001",
|
| 38 |
-
"displayName": "Gemini 1.5 Flash",
|
| 39 |
-
"description": "Fast and versatile multimodal model for scaling across diverse tasks",
|
| 40 |
-
"inputTokenLimit": 1048576,
|
| 41 |
-
"outputTokenLimit": 8192,
|
| 42 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 43 |
-
"temperature": 1.0,
|
| 44 |
-
"maxTemperature": 2.0,
|
| 45 |
-
"topP": 0.95,
|
| 46 |
-
"topK": 64
|
| 47 |
-
},
|
| 48 |
-
{
|
| 49 |
-
"name": "models/gemini-2.5-pro-preview-05-06",
|
| 50 |
-
"version": "001",
|
| 51 |
-
"displayName": "Gemini 2.5 Pro Preview 05-06",
|
| 52 |
-
"description": "Preview version of Gemini 2.5 Pro from May 6th",
|
| 53 |
-
"inputTokenLimit": 1048576,
|
| 54 |
-
"outputTokenLimit": 8192,
|
| 55 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 56 |
-
"temperature": 1.0,
|
| 57 |
-
"maxTemperature": 2.0,
|
| 58 |
-
"topP": 0.95,
|
| 59 |
-
"topK": 64
|
| 60 |
-
},
|
| 61 |
-
{
|
| 62 |
-
"name": "models/gemini-2.5-pro-preview-06-05",
|
| 63 |
-
"version": "001",
|
| 64 |
-
"displayName": "Gemini 2.5 Pro Preview 06-05",
|
| 65 |
-
"description": "Preview version of Gemini 2.5 Pro from June 5th",
|
| 66 |
-
"inputTokenLimit": 1048576,
|
| 67 |
-
"outputTokenLimit": 8192,
|
| 68 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 69 |
-
"temperature": 1.0,
|
| 70 |
-
"maxTemperature": 2.0,
|
| 71 |
-
"topP": 0.95,
|
| 72 |
-
"topK": 64
|
| 73 |
-
},
|
| 74 |
-
{
|
| 75 |
-
"name": "models/gemini-2.5-pro",
|
| 76 |
-
"version": "001",
|
| 77 |
-
"displayName": "Gemini 2.5 Pro",
|
| 78 |
-
"description": "Advanced multimodal model with enhanced capabilities",
|
| 79 |
-
"inputTokenLimit": 1048576,
|
| 80 |
-
"outputTokenLimit": 8192,
|
| 81 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 82 |
-
"temperature": 1.0,
|
| 83 |
-
"maxTemperature": 2.0,
|
| 84 |
-
"topP": 0.95,
|
| 85 |
-
"topK": 64
|
| 86 |
-
},
|
| 87 |
-
{
|
| 88 |
-
"name": "models/gemini-2.5-flash-preview-05-20",
|
| 89 |
-
"version": "001",
|
| 90 |
-
"displayName": "Gemini 2.5 Flash Preview 05-20",
|
| 91 |
-
"description": "Preview version of Gemini 2.5 Flash from May 20th",
|
| 92 |
-
"inputTokenLimit": 1048576,
|
| 93 |
-
"outputTokenLimit": 8192,
|
| 94 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 95 |
-
"temperature": 1.0,
|
| 96 |
-
"maxTemperature": 2.0,
|
| 97 |
-
"topP": 0.95,
|
| 98 |
-
"topK": 64
|
| 99 |
-
},
|
| 100 |
-
{
|
| 101 |
-
"name": "models/gemini-2.5-flash",
|
| 102 |
-
"version": "001",
|
| 103 |
-
"displayName": "Gemini 2.5 Flash",
|
| 104 |
-
"description": "Fast and efficient multimodal model with latest improvements",
|
| 105 |
-
"inputTokenLimit": 1048576,
|
| 106 |
-
"outputTokenLimit": 8192,
|
| 107 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 108 |
-
"temperature": 1.0,
|
| 109 |
-
"maxTemperature": 2.0,
|
| 110 |
-
"topP": 0.95,
|
| 111 |
-
"topK": 64
|
| 112 |
-
},
|
| 113 |
-
{
|
| 114 |
-
"name": "models/gemini-2.0-flash",
|
| 115 |
-
"version": "001",
|
| 116 |
-
"displayName": "Gemini 2.0 Flash",
|
| 117 |
-
"description": "Latest generation fast multimodal model",
|
| 118 |
-
"inputTokenLimit": 1048576,
|
| 119 |
-
"outputTokenLimit": 8192,
|
| 120 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 121 |
-
"temperature": 1.0,
|
| 122 |
-
"maxTemperature": 2.0,
|
| 123 |
-
"topP": 0.95,
|
| 124 |
-
"topK": 64
|
| 125 |
-
},
|
| 126 |
-
{
|
| 127 |
-
"name": "models/gemini-2.0-flash-preview-image-generation",
|
| 128 |
-
"version": "001",
|
| 129 |
-
"displayName": "Gemini 2.0 Flash Preview Image Generation",
|
| 130 |
-
"description": "Preview version with image generation capabilities",
|
| 131 |
-
"inputTokenLimit": 32000,
|
| 132 |
-
"outputTokenLimit": 8192,
|
| 133 |
-
"supportedGenerationMethods": ["generateContent", "streamGenerateContent"],
|
| 134 |
-
"temperature": 1.0,
|
| 135 |
-
"maxTemperature": 2.0,
|
| 136 |
-
"topP": 0.95,
|
| 137 |
-
"topK": 64
|
| 138 |
-
},
|
| 139 |
-
{
|
| 140 |
-
"name": "models/gemini-embedding-001",
|
| 141 |
-
"version": "001",
|
| 142 |
-
"displayName": "Gemini Embedding 001",
|
| 143 |
-
"description": "Text embedding model for semantic similarity and search",
|
| 144 |
-
"inputTokenLimit": 2048,
|
| 145 |
-
"outputTokenLimit": 1,
|
| 146 |
-
"supportedGenerationMethods": ["embedContent"],
|
| 147 |
-
"temperature": 0.0,
|
| 148 |
-
"maxTemperature": 0.0,
|
| 149 |
-
"topP": 1.0,
|
| 150 |
-
"topK": 1
|
| 151 |
-
}
|
| 152 |
-
]
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
return Response(content=json.dumps(models_response), status_code=200, media_type="application/json; charset=utf-8")
|
| 156 |
-
|
| 157 |
-
async def proxy_request(post_data: bytes, full_path: str, username: str, method: str, query_params: dict, is_openai: bool = False, is_streaming: bool = False):
|
| 158 |
-
print(f"[{method}] /{full_path} - User: {username}")
|
| 159 |
-
|
| 160 |
-
creds = get_credentials()
|
| 161 |
-
if not creds:
|
| 162 |
-
print("❌ No credentials available")
|
| 163 |
-
return Response(content="Authentication failed. Please restart the proxy to log in.", status_code=500)
|
| 164 |
-
|
| 165 |
-
print(f"Using credentials - Token: {creds.token[:20] if creds.token else 'None'}..., Expired: {creds.expired}")
|
| 166 |
-
|
| 167 |
-
if creds.expired and creds.refresh_token:
|
| 168 |
-
print("Credentials expired. Refreshing...")
|
| 169 |
-
try:
|
| 170 |
-
from google.auth.transport.requests import Request as GoogleAuthRequest
|
| 171 |
-
creds.refresh(GoogleAuthRequest())
|
| 172 |
-
save_credentials(creds)
|
| 173 |
-
print("Credentials refreshed successfully.")
|
| 174 |
-
except Exception as e:
|
| 175 |
-
print(f"Could not refresh token during request: {e}")
|
| 176 |
-
return Response(content="Token refresh failed. Please restart the proxy to re-authenticate.", status_code=500)
|
| 177 |
-
elif not creds.token:
|
| 178 |
-
print("No access token available.")
|
| 179 |
-
return Response(content="No access token. Please restart the proxy to re-authenticate.", status_code=500)
|
| 180 |
-
|
| 181 |
-
proj_id = get_user_project_id(creds)
|
| 182 |
-
if not proj_id:
|
| 183 |
-
return Response(content="Failed to get user project ID.", status_code=500)
|
| 184 |
-
|
| 185 |
-
onboard_user(creds, proj_id)
|
| 186 |
-
|
| 187 |
-
if is_openai:
|
| 188 |
-
target_url, final_post_data, request_headers, _ = build_gemini_request(post_data, full_path, creds, is_streaming)
|
| 189 |
-
else:
|
| 190 |
-
action = "streamGenerateContent" if is_streaming else "generateContent"
|
| 191 |
-
target_url = f"{CODE_ASSIST_ENDPOINT}/v1internal:{action}" + "?alt=sse"
|
| 192 |
-
|
| 193 |
-
try:
|
| 194 |
-
incoming_json = json.loads(post_data)
|
| 195 |
-
except (json.JSONDecodeError, AttributeError):
|
| 196 |
-
incoming_json = {}
|
| 197 |
-
|
| 198 |
-
final_post_data = json.dumps({
|
| 199 |
-
"model": full_path.split('/')[2].split(':')[0],
|
| 200 |
-
"project": proj_id,
|
| 201 |
-
"request": incoming_json,
|
| 202 |
-
})
|
| 203 |
-
|
| 204 |
-
request_headers = {
|
| 205 |
-
"Authorization": f"Bearer {creds.token}",
|
| 206 |
-
"Content-Type": "application/json",
|
| 207 |
-
"User-Agent": get_user_agent(),
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
if is_streaming:
|
| 212 |
-
print(f"STREAMING REQUEST to: {target_url}")
|
| 213 |
-
print(f"STREAMING REQUEST PAYLOAD: {final_post_data}")
|
| 214 |
-
resp = requests.post(target_url, data=final_post_data, headers=request_headers, stream=True)
|
| 215 |
-
print(f"STREAMING RESPONSE: {resp.status_code}")
|
| 216 |
-
return handle_gemini_response(resp, is_streaming=True)
|
| 217 |
-
else:
|
| 218 |
-
print(f"REQUEST to: {target_url}")
|
| 219 |
-
print(f"REQUEST PAYLOAD: {final_post_data}")
|
| 220 |
-
resp = requests.post(target_url, data=final_post_data, headers=request_headers)
|
| 221 |
-
print(f"RESPONSE: {resp.status_code}, {resp.text}")
|
| 222 |
-
return handle_gemini_response(resp, is_streaming=False)
|
| 223 |
-
|
| 224 |
-
@router.api_route("/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
| 225 |
-
async def proxy(request: Request, full_path: str, username: str = Depends(authenticate_user)):
|
| 226 |
-
post_data = await request.body()
|
| 227 |
-
is_streaming = "stream" in full_path
|
| 228 |
-
return await proxy_request(post_data, full_path, username, request.method, dict(request.query_params), is_streaming=is_streaming)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/gemini_request_builder.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import re
|
| 3 |
-
|
| 4 |
-
from .auth import get_user_project_id
|
| 5 |
-
from .utils import get_user_agent
|
| 6 |
-
|
| 7 |
-
CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
| 8 |
-
|
| 9 |
-
def build_gemini_request(post_data: bytes, full_path: str, creds, is_streaming: bool = False):
|
| 10 |
-
try:
|
| 11 |
-
incoming_json = json.loads(post_data)
|
| 12 |
-
except (json.JSONDecodeError, AttributeError):
|
| 13 |
-
incoming_json = {}
|
| 14 |
-
|
| 15 |
-
# Set the action based on streaming
|
| 16 |
-
action = "streamGenerateContent" if is_streaming else "generateContent"
|
| 17 |
-
|
| 18 |
-
# The target URL is always one of two values
|
| 19 |
-
target_url = f"{CODE_ASSIST_ENDPOINT}/v1internal:{action}"
|
| 20 |
-
|
| 21 |
-
if is_streaming:
|
| 22 |
-
target_url += "?alt=sse"
|
| 23 |
-
|
| 24 |
-
# Extract model from the incoming JSON payload
|
| 25 |
-
final_model = incoming_json.get("model")
|
| 26 |
-
|
| 27 |
-
# Default safety settings if not provided
|
| 28 |
-
safety_settings = incoming_json.get("safetySettings")
|
| 29 |
-
if not safety_settings:
|
| 30 |
-
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 |
-
# Build the final payload for the Google API
|
| 39 |
-
structured_payload = {
|
| 40 |
-
"model": final_model,
|
| 41 |
-
"project": get_user_project_id(creds),
|
| 42 |
-
"request": {
|
| 43 |
-
"contents": incoming_json.get("contents"),
|
| 44 |
-
"systemInstruction": incoming_json.get("systemInstruction"),
|
| 45 |
-
"cachedContent": incoming_json.get("cachedContent"),
|
| 46 |
-
"tools": incoming_json.get("tools"),
|
| 47 |
-
"toolConfig": incoming_json.get("toolConfig"),
|
| 48 |
-
"safetySettings": safety_settings,
|
| 49 |
-
"generationConfig": incoming_json.get("generationConfig", {}),
|
| 50 |
-
},
|
| 51 |
-
}
|
| 52 |
-
# Remove any keys with None values from the request
|
| 53 |
-
structured_payload["request"] = {
|
| 54 |
-
k: v
|
| 55 |
-
for k, v in structured_payload["request"].items()
|
| 56 |
-
if v is not None
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
final_post_data = json.dumps(structured_payload)
|
| 60 |
-
|
| 61 |
-
# Build the request headers
|
| 62 |
-
request_headers = {
|
| 63 |
-
"Authorization": f"Bearer {creds.token}",
|
| 64 |
-
"Content-Type": "application/json",
|
| 65 |
-
"User-Agent": get_user_agent(),
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
return target_url, final_post_data, request_headers, is_streaming
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/gemini_response_handler.py
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import requests
|
| 3 |
-
from fastapi import Response
|
| 4 |
-
from fastapi.responses import StreamingResponse
|
| 5 |
-
import asyncio
|
| 6 |
-
|
| 7 |
-
def handle_gemini_response(resp, is_streaming):
|
| 8 |
-
if is_streaming:
|
| 9 |
-
async def stream_generator():
|
| 10 |
-
try:
|
| 11 |
-
with resp:
|
| 12 |
-
resp.raise_for_status()
|
| 13 |
-
|
| 14 |
-
print("[STREAM] Processing with Gemini SDK-compatible logic")
|
| 15 |
-
|
| 16 |
-
for chunk in resp.iter_lines():
|
| 17 |
-
if chunk:
|
| 18 |
-
if not isinstance(chunk, str):
|
| 19 |
-
chunk = chunk.decode('utf-8')
|
| 20 |
-
|
| 21 |
-
print(chunk)
|
| 22 |
-
|
| 23 |
-
if chunk.startswith('data: '):
|
| 24 |
-
chunk = chunk[len('data: '):]
|
| 25 |
-
|
| 26 |
-
try:
|
| 27 |
-
obj = json.loads(chunk)
|
| 28 |
-
|
| 29 |
-
if "response" in obj:
|
| 30 |
-
response_chunk = obj["response"]
|
| 31 |
-
response_json = json.dumps(response_chunk, separators=(',', ':'))
|
| 32 |
-
response_line = f"data: {response_json}\n\n"
|
| 33 |
-
yield response_line
|
| 34 |
-
await asyncio.sleep(0)
|
| 35 |
-
except json.JSONDecodeError:
|
| 36 |
-
continue
|
| 37 |
-
|
| 38 |
-
except requests.exceptions.RequestException as e:
|
| 39 |
-
print(f"Error during streaming request: {e}")
|
| 40 |
-
yield f'data: {{"error": {{"message": "Upstream request failed: {str(e)}"}}}}\n\n'.encode('utf-8')
|
| 41 |
-
except Exception as e:
|
| 42 |
-
print(f"An unexpected error occurred during streaming: {e}")
|
| 43 |
-
yield f'data: {{"error": {{"message": "An unexpected error occurred: {str(e)}"}}}}\n\n'.encode('utf-8')
|
| 44 |
-
|
| 45 |
-
response_headers = {
|
| 46 |
-
"Content-Type": "text/event-stream",
|
| 47 |
-
"Content-Disposition": "attachment",
|
| 48 |
-
"Vary": "Origin, X-Origin, Referer",
|
| 49 |
-
"X-XSS-Protection": "0",
|
| 50 |
-
"X-Frame-Options": "SAMEORIGIN",
|
| 51 |
-
"X-Content-Type-Options": "nosniff",
|
| 52 |
-
"Server": "ESF"
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
return StreamingResponse(
|
| 56 |
-
stream_generator(),
|
| 57 |
-
media_type="text/event-stream",
|
| 58 |
-
headers=response_headers
|
| 59 |
-
)
|
| 60 |
-
else:
|
| 61 |
-
if resp.status_code == 200:
|
| 62 |
-
try:
|
| 63 |
-
google_api_response = resp.text
|
| 64 |
-
if google_api_response.startswith('data: '):
|
| 65 |
-
google_api_response = google_api_response[len('data: '):]
|
| 66 |
-
google_api_response = json.loads(google_api_response)
|
| 67 |
-
standard_gemini_response = google_api_response.get("response")
|
| 68 |
-
return Response(content=json.dumps(standard_gemini_response), status_code=200, media_type="application/json; charset=utf-8")
|
| 69 |
-
except (json.JSONDecodeError, AttributeError) as e:
|
| 70 |
-
print(f"Error converting to standard Gemini format: {e}")
|
| 71 |
-
return Response(content=resp.content, status_code=resp.status_code, media_type=resp.headers.get("Content-Type"))
|
| 72 |
-
else:
|
| 73 |
-
return Response(content=resp.content, status_code=resp.status_code, media_type=resp.headers.get("Content-Type"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/gemini_routes.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from fastapi import APIRouter, Request, Response, Depends
|
| 8 |
+
|
| 9 |
+
from .auth import authenticate_user
|
| 10 |
+
from .google_api_client import send_gemini_request, build_gemini_payload_from_native
|
| 11 |
+
from .config import SUPPORTED_MODELS
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/v1beta/models")
|
| 17 |
+
async def gemini_list_models(request: Request, username: str = Depends(authenticate_user)):
|
| 18 |
+
"""
|
| 19 |
+
Native Gemini models endpoint.
|
| 20 |
+
Returns available models in Gemini format, matching the official Gemini API.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
models_response = {
|
| 24 |
+
"models": SUPPORTED_MODELS
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
return Response(
|
| 28 |
+
content=json.dumps(models_response),
|
| 29 |
+
status_code=200,
|
| 30 |
+
media_type="application/json; charset=utf-8"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.api_route("/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
| 35 |
+
async def gemini_proxy(request: Request, full_path: str, username: str = Depends(authenticate_user)):
|
| 36 |
+
"""
|
| 37 |
+
Native Gemini API proxy endpoint.
|
| 38 |
+
Handles all native Gemini API calls by proxying them directly to Google's API.
|
| 39 |
+
|
| 40 |
+
This endpoint handles paths like:
|
| 41 |
+
- /v1beta/models/{model}/generateContent
|
| 42 |
+
- /v1beta/models/{model}/streamGenerateContent
|
| 43 |
+
- /v1/models/{model}/generateContent
|
| 44 |
+
- etc.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
# Get the request body
|
| 48 |
+
post_data = await request.body()
|
| 49 |
+
|
| 50 |
+
# Determine if this is a streaming request
|
| 51 |
+
is_streaming = "stream" in full_path.lower()
|
| 52 |
+
|
| 53 |
+
# Extract model name from the path
|
| 54 |
+
# Paths typically look like: v1beta/models/gemini-1.5-pro/generateContent
|
| 55 |
+
model_name = _extract_model_from_path(full_path)
|
| 56 |
+
|
| 57 |
+
if not model_name:
|
| 58 |
+
return Response(
|
| 59 |
+
content=json.dumps({
|
| 60 |
+
"error": {
|
| 61 |
+
"message": f"Could not extract model name from path: {full_path}",
|
| 62 |
+
"code": 400
|
| 63 |
+
}
|
| 64 |
+
}),
|
| 65 |
+
status_code=400,
|
| 66 |
+
media_type="application/json"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Parse the incoming request
|
| 70 |
+
try:
|
| 71 |
+
if post_data:
|
| 72 |
+
incoming_request = json.loads(post_data)
|
| 73 |
+
else:
|
| 74 |
+
incoming_request = {}
|
| 75 |
+
except json.JSONDecodeError:
|
| 76 |
+
return Response(
|
| 77 |
+
content=json.dumps({
|
| 78 |
+
"error": {
|
| 79 |
+
"message": "Invalid JSON in request body",
|
| 80 |
+
"code": 400
|
| 81 |
+
}
|
| 82 |
+
}),
|
| 83 |
+
status_code=400,
|
| 84 |
+
media_type="application/json"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Build the payload for Google API
|
| 88 |
+
gemini_payload = build_gemini_payload_from_native(incoming_request, model_name)
|
| 89 |
+
|
| 90 |
+
# Send the request to Google API
|
| 91 |
+
response = send_gemini_request(gemini_payload, is_streaming=is_streaming)
|
| 92 |
+
|
| 93 |
+
return response
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _extract_model_from_path(path: str) -> str:
|
| 97 |
+
"""
|
| 98 |
+
Extract the model name from a Gemini API path.
|
| 99 |
+
|
| 100 |
+
Examples:
|
| 101 |
+
- "v1beta/models/gemini-1.5-pro/generateContent" -> "gemini-1.5-pro"
|
| 102 |
+
- "v1/models/gemini-2.0-flash/streamGenerateContent" -> "gemini-2.0-flash"
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
path: The API path
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Model name (just the model name, not prefixed with "models/") or None if not found
|
| 109 |
+
"""
|
| 110 |
+
parts = path.split('/')
|
| 111 |
+
|
| 112 |
+
# Look for the pattern: .../models/{model_name}/...
|
| 113 |
+
try:
|
| 114 |
+
models_index = parts.index('models')
|
| 115 |
+
if models_index + 1 < len(parts):
|
| 116 |
+
model_name = parts[models_index + 1]
|
| 117 |
+
# Remove any action suffix like ":streamGenerateContent" or ":generateContent"
|
| 118 |
+
if ':' in model_name:
|
| 119 |
+
model_name = model_name.split(':')[0]
|
| 120 |
+
# Return just the model name without "models/" prefix
|
| 121 |
+
return model_name
|
| 122 |
+
except ValueError:
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
# If we can't find the pattern, return None
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.get("/v1/models")
|
| 130 |
+
async def gemini_list_models_v1(request: Request, username: str = Depends(authenticate_user)):
|
| 131 |
+
"""
|
| 132 |
+
Alternative models endpoint for v1 API version.
|
| 133 |
+
Some clients might use /v1/models instead of /v1beta/models.
|
| 134 |
+
"""
|
| 135 |
+
return await gemini_list_models(request, username)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Health check endpoint
|
| 139 |
+
@router.get("/health")
|
| 140 |
+
async def health_check():
|
| 141 |
+
"""
|
| 142 |
+
Simple health check endpoint.
|
| 143 |
+
"""
|
| 144 |
+
return {"status": "healthy", "service": "geminicli2api"}
|
src/google_api_client.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 requests
|
| 7 |
+
from fastapi import Response
|
| 8 |
+
from fastapi.responses import StreamingResponse
|
| 9 |
+
from google.auth.transport.requests import Request as GoogleAuthRequest
|
| 10 |
+
|
| 11 |
+
from .auth import get_credentials, save_credentials, get_user_project_id, onboard_user
|
| 12 |
+
from .utils import get_user_agent
|
| 13 |
+
from .config import CODE_ASSIST_ENDPOINT, DEFAULT_SAFETY_SETTINGS
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def send_gemini_request(payload: dict, is_streaming: bool = False) -> Response:
|
| 17 |
+
"""
|
| 18 |
+
Send a request to Google's Gemini API.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
payload: The request payload in Gemini format
|
| 22 |
+
is_streaming: Whether this is a streaming request
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
FastAPI Response object
|
| 26 |
+
"""
|
| 27 |
+
# Get and validate credentials
|
| 28 |
+
creds = get_credentials()
|
| 29 |
+
if not creds:
|
| 30 |
+
return Response(
|
| 31 |
+
content="Authentication failed. Please restart the proxy to log in.",
|
| 32 |
+
status_code=500
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Refresh credentials if needed
|
| 37 |
+
if creds.expired and creds.refresh_token:
|
| 38 |
+
try:
|
| 39 |
+
creds.refresh(GoogleAuthRequest())
|
| 40 |
+
save_credentials(creds)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
return Response(
|
| 43 |
+
content="Token refresh failed. Please restart the proxy to re-authenticate.",
|
| 44 |
+
status_code=500
|
| 45 |
+
)
|
| 46 |
+
elif not creds.token:
|
| 47 |
+
return Response(
|
| 48 |
+
content="No access token. Please restart the proxy to re-authenticate.",
|
| 49 |
+
status_code=500
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Get project ID and onboard user
|
| 53 |
+
proj_id = get_user_project_id(creds)
|
| 54 |
+
if not proj_id:
|
| 55 |
+
return Response(content="Failed to get user project ID.", status_code=500)
|
| 56 |
+
|
| 57 |
+
onboard_user(creds, proj_id)
|
| 58 |
+
|
| 59 |
+
# Build the final payload with project info
|
| 60 |
+
final_payload = {
|
| 61 |
+
"model": payload.get("model"),
|
| 62 |
+
"project": proj_id,
|
| 63 |
+
"request": payload.get("request", {})
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
# Determine the action and URL
|
| 67 |
+
action = "streamGenerateContent" if is_streaming else "generateContent"
|
| 68 |
+
target_url = f"{CODE_ASSIST_ENDPOINT}/v1internal:{action}"
|
| 69 |
+
if is_streaming:
|
| 70 |
+
target_url += "?alt=sse"
|
| 71 |
+
|
| 72 |
+
# Build request headers
|
| 73 |
+
request_headers = {
|
| 74 |
+
"Authorization": f"Bearer {creds.token}",
|
| 75 |
+
"Content-Type": "application/json",
|
| 76 |
+
"User-Agent": get_user_agent(),
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
final_post_data = json.dumps(final_payload)
|
| 80 |
+
|
| 81 |
+
# Send the request
|
| 82 |
+
if is_streaming:
|
| 83 |
+
resp = requests.post(target_url, data=final_post_data, headers=request_headers, stream=True)
|
| 84 |
+
return _handle_streaming_response(resp)
|
| 85 |
+
else:
|
| 86 |
+
resp = requests.post(target_url, data=final_post_data, headers=request_headers)
|
| 87 |
+
return _handle_non_streaming_response(resp)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _handle_streaming_response(resp) -> StreamingResponse:
|
| 91 |
+
"""Handle streaming response from Google API."""
|
| 92 |
+
import asyncio
|
| 93 |
+
|
| 94 |
+
async def stream_generator():
|
| 95 |
+
try:
|
| 96 |
+
with resp:
|
| 97 |
+
resp.raise_for_status()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
for chunk in resp.iter_lines():
|
| 101 |
+
if chunk:
|
| 102 |
+
if not isinstance(chunk, str):
|
| 103 |
+
chunk = chunk.decode('utf-8')
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if chunk.startswith('data: '):
|
| 107 |
+
chunk = chunk[len('data: '):]
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
obj = json.loads(chunk)
|
| 111 |
+
|
| 112 |
+
if "response" in obj:
|
| 113 |
+
response_chunk = obj["response"]
|
| 114 |
+
response_json = json.dumps(response_chunk, separators=(',', ':'))
|
| 115 |
+
response_line = f"data: {response_json}\n\n"
|
| 116 |
+
yield response_line
|
| 117 |
+
await asyncio.sleep(0)
|
| 118 |
+
except json.JSONDecodeError:
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
except requests.exceptions.RequestException as e:
|
| 122 |
+
yield f'data: {{"error": {{"message": "Upstream request failed: {str(e)}"}}}}\n\n'.encode('utf-8')
|
| 123 |
+
except Exception as e:
|
| 124 |
+
yield f'data: {{"error": {{"message": "An unexpected error occurred: {str(e)}"}}}}\n\n'.encode('utf-8')
|
| 125 |
+
|
| 126 |
+
response_headers = {
|
| 127 |
+
"Content-Type": "text/event-stream",
|
| 128 |
+
"Content-Disposition": "attachment",
|
| 129 |
+
"Vary": "Origin, X-Origin, Referer",
|
| 130 |
+
"X-XSS-Protection": "0",
|
| 131 |
+
"X-Frame-Options": "SAMEORIGIN",
|
| 132 |
+
"X-Content-Type-Options": "nosniff",
|
| 133 |
+
"Server": "ESF"
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return StreamingResponse(
|
| 137 |
+
stream_generator(),
|
| 138 |
+
media_type="text/event-stream",
|
| 139 |
+
headers=response_headers
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _handle_non_streaming_response(resp) -> Response:
|
| 144 |
+
"""Handle non-streaming response from Google API."""
|
| 145 |
+
if resp.status_code == 200:
|
| 146 |
+
try:
|
| 147 |
+
google_api_response = resp.text
|
| 148 |
+
if google_api_response.startswith('data: '):
|
| 149 |
+
google_api_response = google_api_response[len('data: '):]
|
| 150 |
+
google_api_response = json.loads(google_api_response)
|
| 151 |
+
standard_gemini_response = google_api_response.get("response")
|
| 152 |
+
return Response(
|
| 153 |
+
content=json.dumps(standard_gemini_response),
|
| 154 |
+
status_code=200,
|
| 155 |
+
media_type="application/json; charset=utf-8"
|
| 156 |
+
)
|
| 157 |
+
except (json.JSONDecodeError, AttributeError) as e:
|
| 158 |
+
return Response(
|
| 159 |
+
content=resp.content,
|
| 160 |
+
status_code=resp.status_code,
|
| 161 |
+
media_type=resp.headers.get("Content-Type")
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
return Response(
|
| 165 |
+
content=resp.content,
|
| 166 |
+
status_code=resp.status_code,
|
| 167 |
+
media_type=resp.headers.get("Content-Type")
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def build_gemini_payload_from_openai(openai_payload: dict) -> dict:
|
| 172 |
+
"""
|
| 173 |
+
Build a Gemini API payload from an OpenAI-transformed request.
|
| 174 |
+
This is used when OpenAI requests are converted to Gemini format.
|
| 175 |
+
"""
|
| 176 |
+
# Extract model from the payload
|
| 177 |
+
model = openai_payload.get("model")
|
| 178 |
+
|
| 179 |
+
# Get safety settings or use defaults
|
| 180 |
+
safety_settings = openai_payload.get("safetySettings", DEFAULT_SAFETY_SETTINGS)
|
| 181 |
+
|
| 182 |
+
# Build the request portion
|
| 183 |
+
request_data = {
|
| 184 |
+
"contents": openai_payload.get("contents"),
|
| 185 |
+
"systemInstruction": openai_payload.get("systemInstruction"),
|
| 186 |
+
"cachedContent": openai_payload.get("cachedContent"),
|
| 187 |
+
"tools": openai_payload.get("tools"),
|
| 188 |
+
"toolConfig": openai_payload.get("toolConfig"),
|
| 189 |
+
"safetySettings": safety_settings,
|
| 190 |
+
"generationConfig": openai_payload.get("generationConfig", {}),
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
# Remove any keys with None values
|
| 194 |
+
request_data = {k: v for k, v in request_data.items() if v is not None}
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
"model": model,
|
| 198 |
+
"request": request_data
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def build_gemini_payload_from_native(native_request: dict, model_from_path: str) -> dict:
|
| 203 |
+
"""
|
| 204 |
+
Build a Gemini API payload from a native Gemini request.
|
| 205 |
+
This is used for direct Gemini API calls.
|
| 206 |
+
"""
|
| 207 |
+
# Add default safety settings if not provided
|
| 208 |
+
if "safetySettings" not in native_request:
|
| 209 |
+
native_request["safetySettings"] = DEFAULT_SAFETY_SETTINGS
|
| 210 |
+
|
| 211 |
+
return {
|
| 212 |
+
"model": model_from_path,
|
| 213 |
+
"request": native_request
|
| 214 |
+
}
|
src/main.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from fastapi import FastAPI, Request, Response
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
-
from .
|
| 4 |
-
from .
|
| 5 |
from .auth import get_credentials, get_user_project_id, onboard_user
|
| 6 |
|
| 7 |
app = FastAPI()
|
|
@@ -17,18 +17,23 @@ app.add_middleware(
|
|
| 17 |
|
| 18 |
@app.on_event("startup")
|
| 19 |
async def startup_event():
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@app.options("/{full_path:path}")
|
| 34 |
async def handle_preflight(request: Request, full_path: str):
|
|
|
|
| 1 |
from fastapi import FastAPI, Request, Response
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from .gemini_routes import router as gemini_router
|
| 4 |
+
from .openai_routes import router as openai_router
|
| 5 |
from .auth import get_credentials, get_user_project_id, onboard_user
|
| 6 |
|
| 7 |
app = FastAPI()
|
|
|
|
| 17 |
|
| 18 |
@app.on_event("startup")
|
| 19 |
async def startup_event():
|
| 20 |
+
try:
|
| 21 |
+
creds = get_credentials()
|
| 22 |
+
if creds:
|
| 23 |
+
try:
|
| 24 |
+
proj_id = get_user_project_id(creds)
|
| 25 |
+
if proj_id:
|
| 26 |
+
onboard_user(creds, proj_id)
|
| 27 |
+
print("Gemini proxy server started")
|
| 28 |
+
print("Authentication required - Password: see .env file")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f"Setup failed: {str(e)}")
|
| 31 |
+
print("Server started but may not function properly until setup issues are resolved.")
|
| 32 |
+
else:
|
| 33 |
+
print("Could not obtain credentials. Please authenticate and restart the server.")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"Startup error: {str(e)}")
|
| 36 |
+
print("Server may not function properly.")
|
| 37 |
|
| 38 |
@app.options("/{full_path:path}")
|
| 39 |
async def handle_preflight(request: Request, full_path: str):
|
src/models.py
CHANGED
|
@@ -13,6 +13,15 @@ class OpenAIChatCompletionRequest(BaseModel):
|
|
| 13 |
temperature: Optional[float] = None
|
| 14 |
top_p: Optional[float] = None
|
| 15 |
max_tokens: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
class OpenAIChatCompletionChoice(BaseModel):
|
| 18 |
index: int
|
|
|
|
| 13 |
temperature: Optional[float] = None
|
| 14 |
top_p: Optional[float] = None
|
| 15 |
max_tokens: Optional[int] = None
|
| 16 |
+
stop: Optional[Union[str, List[str]]] = None
|
| 17 |
+
frequency_penalty: Optional[float] = None
|
| 18 |
+
presence_penalty: Optional[float] = None
|
| 19 |
+
n: Optional[int] = None
|
| 20 |
+
seed: Optional[int] = None
|
| 21 |
+
response_format: Optional[Dict[str, Any]] = None
|
| 22 |
+
|
| 23 |
+
class Config:
|
| 24 |
+
extra = "allow" # Allow additional fields not explicitly defined
|
| 25 |
|
| 26 |
class OpenAIChatCompletionChoice(BaseModel):
|
| 27 |
index: int
|
src/openai.py
DELETED
|
@@ -1,184 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import time
|
| 3 |
-
import uuid
|
| 4 |
-
from fastapi import APIRouter, Request, Response, Depends
|
| 5 |
-
from fastapi.responses import StreamingResponse
|
| 6 |
-
|
| 7 |
-
from .auth import authenticate_user
|
| 8 |
-
from .models import OpenAIChatCompletionRequest, OpenAIChatCompletionResponse, OpenAIChatCompletionStreamResponse, OpenAIChatMessage, OpenAIChatCompletionChoice, OpenAIChatCompletionStreamChoice, OpenAIDelta, GeminiRequest, GeminiContent, GeminiPart, GeminiResponse
|
| 9 |
-
from .gemini import proxy_request
|
| 10 |
-
|
| 11 |
-
import asyncio
|
| 12 |
-
|
| 13 |
-
router = APIRouter()
|
| 14 |
-
|
| 15 |
-
def openai_to_gemini(openai_request: OpenAIChatCompletionRequest) -> dict:
|
| 16 |
-
contents = []
|
| 17 |
-
for message in openai_request.messages:
|
| 18 |
-
role = message.role
|
| 19 |
-
if role == "assistant":
|
| 20 |
-
role = "model"
|
| 21 |
-
if role == "system":
|
| 22 |
-
role = "user"
|
| 23 |
-
if isinstance(message.content, list):
|
| 24 |
-
parts = []
|
| 25 |
-
for part in message.content:
|
| 26 |
-
if part.get("type") == "text":
|
| 27 |
-
parts.append({"text": part.get("text", "")})
|
| 28 |
-
elif part.get("type") == "image_url":
|
| 29 |
-
image_url = part.get("image_url", {}).get("url")
|
| 30 |
-
if image_url:
|
| 31 |
-
# Assuming the image_url is a base64 encoded string
|
| 32 |
-
# "data:image/jpeg;base64,{base64_image}"
|
| 33 |
-
mime_type, base64_data = image_url.split(";")
|
| 34 |
-
_, mime_type = mime_type.split(":")
|
| 35 |
-
_, base64_data = base64_data.split(",")
|
| 36 |
-
parts.append({
|
| 37 |
-
"inlineData": {
|
| 38 |
-
"mimeType": mime_type,
|
| 39 |
-
"data": base64_data
|
| 40 |
-
}
|
| 41 |
-
})
|
| 42 |
-
contents.append({"role": role, "parts": parts})
|
| 43 |
-
else:
|
| 44 |
-
contents.append({"role": role, "parts": [{"text": message.content}]})
|
| 45 |
-
|
| 46 |
-
generation_config = {}
|
| 47 |
-
if openai_request.temperature is not None:
|
| 48 |
-
generation_config["temperature"] = openai_request.temperature
|
| 49 |
-
if openai_request.top_p is not None:
|
| 50 |
-
generation_config["topP"] = openai_request.top_p
|
| 51 |
-
if openai_request.max_tokens is not None:
|
| 52 |
-
generation_config["maxOutputTokens"] = openai_request.max_tokens
|
| 53 |
-
|
| 54 |
-
safety_settings = [
|
| 55 |
-
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
| 56 |
-
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
| 57 |
-
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
| 58 |
-
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
| 59 |
-
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}
|
| 60 |
-
]
|
| 61 |
-
|
| 62 |
-
return {
|
| 63 |
-
"contents": contents,
|
| 64 |
-
"generationConfig": generation_config,
|
| 65 |
-
"safetySettings": safety_settings,
|
| 66 |
-
"model": openai_request.model
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
def gemini_to_openai(gemini_response: dict, model: str) -> OpenAIChatCompletionResponse:
|
| 70 |
-
choices = []
|
| 71 |
-
for candidate in gemini_response.get("candidates", []):
|
| 72 |
-
role = candidate.get("content", {}).get("role", "assistant")
|
| 73 |
-
if role == "model":
|
| 74 |
-
role = "assistant"
|
| 75 |
-
choices.append(
|
| 76 |
-
{
|
| 77 |
-
"index": candidate.get("index"),
|
| 78 |
-
"message": {
|
| 79 |
-
"role": role,
|
| 80 |
-
"content": candidate.get("content", {}).get("parts", [{}])[0].get("text"),
|
| 81 |
-
},
|
| 82 |
-
"finish_reason": map_finish_reason(candidate.get("finishReason")),
|
| 83 |
-
}
|
| 84 |
-
)
|
| 85 |
-
return {
|
| 86 |
-
"id": str(uuid.uuid4()),
|
| 87 |
-
"object": "chat.completion",
|
| 88 |
-
"created": int(time.time()),
|
| 89 |
-
"model": model,
|
| 90 |
-
"choices": choices,
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
def gemini_to_openai_stream(gemini_response: dict, model: str, response_id: str) -> dict:
|
| 94 |
-
choices = []
|
| 95 |
-
for candidate in gemini_response.get("candidates", []):
|
| 96 |
-
role = candidate.get("content", {}).get("role", "assistant")
|
| 97 |
-
if role == "model":
|
| 98 |
-
role = "assistant"
|
| 99 |
-
choices.append(
|
| 100 |
-
{
|
| 101 |
-
"index": candidate.get("index"),
|
| 102 |
-
"delta": {
|
| 103 |
-
"content": candidate.get("content", {}).get("parts", [{}])[0].get("text"),
|
| 104 |
-
},
|
| 105 |
-
"finish_reason": map_finish_reason(candidate.get("finishReason")),
|
| 106 |
-
}
|
| 107 |
-
)
|
| 108 |
-
return {
|
| 109 |
-
"id": response_id,
|
| 110 |
-
"object": "chat.completion.chunk",
|
| 111 |
-
"created": int(time.time()),
|
| 112 |
-
"model": model,
|
| 113 |
-
"choices": choices,
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
def map_finish_reason(reason: str) -> str:
|
| 117 |
-
if reason == "STOP":
|
| 118 |
-
return "stop"
|
| 119 |
-
elif reason == "MAX_TOKENS":
|
| 120 |
-
return "length"
|
| 121 |
-
elif reason in ["SAFETY", "RECITATION"]:
|
| 122 |
-
return "content_filter"
|
| 123 |
-
else:
|
| 124 |
-
return None
|
| 125 |
-
|
| 126 |
-
@router.post("/v1/chat/completions")
|
| 127 |
-
async def chat_completions(request: OpenAIChatCompletionRequest, http_request: Request, username: str = Depends(authenticate_user)):
|
| 128 |
-
gemini_request = openai_to_gemini(request)
|
| 129 |
-
|
| 130 |
-
if request.stream:
|
| 131 |
-
async def stream_generator():
|
| 132 |
-
response = await proxy_request(json.dumps(gemini_request).encode('utf-8'), http_request.url.path, username, "POST", dict(http_request.query_params), is_openai=True, is_streaming=True)
|
| 133 |
-
if isinstance(response, StreamingResponse):
|
| 134 |
-
response_id = "chatcmpl-realstream-" + str(uuid.uuid4())
|
| 135 |
-
async for chunk in response.body_iterator:
|
| 136 |
-
if chunk.startswith('data: '):
|
| 137 |
-
try:
|
| 138 |
-
data = json.loads(chunk[6:])
|
| 139 |
-
openai_response = gemini_to_openai_stream(data, request.model, response_id)
|
| 140 |
-
yield f"data: {json.dumps(openai_response)}\n\n"
|
| 141 |
-
await asyncio.sleep(0)
|
| 142 |
-
except (json.JSONDecodeError, KeyError):
|
| 143 |
-
continue
|
| 144 |
-
yield "data: [DONE]\n\n"
|
| 145 |
-
else:
|
| 146 |
-
yield f"data: {response.body.decode()}\n\n"
|
| 147 |
-
yield "data: [DONE]\n\n"
|
| 148 |
-
|
| 149 |
-
return StreamingResponse(stream_generator(), media_type="text/event-stream")
|
| 150 |
-
else:
|
| 151 |
-
response = await proxy_request(json.dumps(gemini_request).encode('utf-8'), http_request.url.path, username, "POST", dict(http_request.query_params), is_openai=True, is_streaming=False)
|
| 152 |
-
if isinstance(response, Response) and response.status_code != 200:
|
| 153 |
-
return response
|
| 154 |
-
gemini_response = json.loads(response.body)
|
| 155 |
-
openai_response = gemini_to_openai(gemini_response, request.model)
|
| 156 |
-
return openai_response
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
async def event_generator():
|
| 160 |
-
"""
|
| 161 |
-
A generator function that yields a message in the Server-Sent Event (SSE)
|
| 162 |
-
format every second, five times.
|
| 163 |
-
"""
|
| 164 |
-
count = 0
|
| 165 |
-
while count < 5:
|
| 166 |
-
# SSE format is "data: <content>\n\n"
|
| 167 |
-
# The two newlines are crucial as they mark the end of an event.
|
| 168 |
-
yield "data: 1\n\n"
|
| 169 |
-
|
| 170 |
-
# Log to the server console to see it working on the backend
|
| 171 |
-
count += 1
|
| 172 |
-
print(f"Sent chunk {count}/5")
|
| 173 |
-
|
| 174 |
-
# Wait for 1 second
|
| 175 |
-
await asyncio.sleep(1)
|
| 176 |
-
|
| 177 |
-
@router.post("/v1/test")
|
| 178 |
-
async def stream_data(request: OpenAIChatCompletionRequest, http_request: Request, username: str = Depends(authenticate_user)):
|
| 179 |
-
"""
|
| 180 |
-
This endpoint returns a streaming response.
|
| 181 |
-
It uses the event_generator to send data chunks.
|
| 182 |
-
The media_type is 'text/event-stream' which is standard for SSE.
|
| 183 |
-
"""
|
| 184 |
-
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/openai_routes.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from fastapi import APIRouter, Request, Response, Depends
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
+
|
| 12 |
+
from .auth import authenticate_user
|
| 13 |
+
from .models import OpenAIChatCompletionRequest
|
| 14 |
+
from .openai_transformers import (
|
| 15 |
+
openai_request_to_gemini,
|
| 16 |
+
gemini_response_to_openai,
|
| 17 |
+
gemini_stream_chunk_to_openai
|
| 18 |
+
)
|
| 19 |
+
from .google_api_client import send_gemini_request, build_gemini_payload_from_openai
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/v1/chat/completions")
|
| 25 |
+
async def openai_chat_completions(
|
| 26 |
+
request: OpenAIChatCompletionRequest,
|
| 27 |
+
http_request: Request,
|
| 28 |
+
username: str = Depends(authenticate_user)
|
| 29 |
+
):
|
| 30 |
+
"""
|
| 31 |
+
OpenAI-compatible chat completions endpoint.
|
| 32 |
+
Transforms OpenAI requests to Gemini format, sends to Google API,
|
| 33 |
+
and transforms responses back to OpenAI format.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
# Transform OpenAI request to Gemini format
|
| 37 |
+
gemini_request_data = openai_request_to_gemini(request)
|
| 38 |
+
|
| 39 |
+
# Build the payload for Google API
|
| 40 |
+
gemini_payload = build_gemini_payload_from_openai(gemini_request_data)
|
| 41 |
+
|
| 42 |
+
if request.stream:
|
| 43 |
+
# Handle streaming response
|
| 44 |
+
async def openai_stream_generator():
|
| 45 |
+
response = send_gemini_request(gemini_payload, is_streaming=True)
|
| 46 |
+
|
| 47 |
+
if isinstance(response, StreamingResponse):
|
| 48 |
+
response_id = "chatcmpl-" + str(uuid.uuid4())
|
| 49 |
+
|
| 50 |
+
async for chunk in response.body_iterator:
|
| 51 |
+
if isinstance(chunk, bytes):
|
| 52 |
+
chunk = chunk.decode('utf-8')
|
| 53 |
+
|
| 54 |
+
if chunk.startswith('data: '):
|
| 55 |
+
try:
|
| 56 |
+
# Parse the Gemini streaming chunk
|
| 57 |
+
chunk_data = chunk[6:] # Remove 'data: ' prefix
|
| 58 |
+
gemini_chunk = json.loads(chunk_data)
|
| 59 |
+
|
| 60 |
+
# Transform to OpenAI format
|
| 61 |
+
openai_chunk = gemini_stream_chunk_to_openai(
|
| 62 |
+
gemini_chunk,
|
| 63 |
+
request.model,
|
| 64 |
+
response_id
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Send as OpenAI streaming format
|
| 68 |
+
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
| 69 |
+
await asyncio.sleep(0)
|
| 70 |
+
|
| 71 |
+
except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
# Send the final [DONE] marker
|
| 75 |
+
yield "data: [DONE]\n\n"
|
| 76 |
+
else:
|
| 77 |
+
# Error case - forward the error response
|
| 78 |
+
error_data = {
|
| 79 |
+
"error": {
|
| 80 |
+
"message": "Streaming request failed",
|
| 81 |
+
"type": "api_error"
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
yield f"data: {json.dumps(error_data)}\n\n"
|
| 85 |
+
yield "data: [DONE]\n\n"
|
| 86 |
+
|
| 87 |
+
return StreamingResponse(
|
| 88 |
+
openai_stream_generator(),
|
| 89 |
+
media_type="text/event-stream"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
else:
|
| 93 |
+
# Handle non-streaming response
|
| 94 |
+
response = send_gemini_request(gemini_payload, is_streaming=False)
|
| 95 |
+
|
| 96 |
+
if isinstance(response, Response) and response.status_code != 200:
|
| 97 |
+
# Forward error responses as-is
|
| 98 |
+
return response
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
# Parse Gemini response and transform to OpenAI format
|
| 102 |
+
gemini_response = json.loads(response.body)
|
| 103 |
+
openai_response = gemini_response_to_openai(gemini_response, request.model)
|
| 104 |
+
|
| 105 |
+
return openai_response
|
| 106 |
+
|
| 107 |
+
except (json.JSONDecodeError, AttributeError) as e:
|
| 108 |
+
return Response(
|
| 109 |
+
content=json.dumps({
|
| 110 |
+
"error": {
|
| 111 |
+
"message": "Failed to process response",
|
| 112 |
+
"type": "api_error"
|
| 113 |
+
}
|
| 114 |
+
}),
|
| 115 |
+
status_code=500,
|
| 116 |
+
media_type="application/json"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.get("/v1/models")
|
| 121 |
+
async def openai_list_models(username: str = Depends(authenticate_user)):
|
| 122 |
+
"""
|
| 123 |
+
OpenAI-compatible models endpoint.
|
| 124 |
+
Returns available models in OpenAI format.
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
# Convert our Gemini models to OpenAI format
|
| 128 |
+
from .config import SUPPORTED_MODELS
|
| 129 |
+
|
| 130 |
+
openai_models = []
|
| 131 |
+
for model in SUPPORTED_MODELS:
|
| 132 |
+
openai_models.append({
|
| 133 |
+
"id": model["name"],
|
| 134 |
+
"object": "model",
|
| 135 |
+
"created": 1677610602, # Static timestamp
|
| 136 |
+
"owned_by": "google",
|
| 137 |
+
"permission": [
|
| 138 |
+
{
|
| 139 |
+
"id": "modelperm-" + model["name"].replace("/", "-"),
|
| 140 |
+
"object": "model_permission",
|
| 141 |
+
"created": 1677610602,
|
| 142 |
+
"allow_create_engine": False,
|
| 143 |
+
"allow_sampling": True,
|
| 144 |
+
"allow_logprobs": False,
|
| 145 |
+
"allow_search_indices": False,
|
| 146 |
+
"allow_view": True,
|
| 147 |
+
"allow_fine_tuning": False,
|
| 148 |
+
"organization": "*",
|
| 149 |
+
"group": None,
|
| 150 |
+
"is_blocking": False
|
| 151 |
+
}
|
| 152 |
+
],
|
| 153 |
+
"root": model["name"],
|
| 154 |
+
"parent": None
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
"object": "list",
|
| 159 |
+
"data": openai_models
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# Test endpoint for debugging (can be removed in production)
|
| 164 |
+
@router.post("/v1/test")
|
| 165 |
+
async def openai_test_endpoint(
|
| 166 |
+
request: OpenAIChatCompletionRequest,
|
| 167 |
+
username: str = Depends(authenticate_user)
|
| 168 |
+
):
|
| 169 |
+
"""
|
| 170 |
+
Test endpoint for debugging OpenAI transformations.
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
# Transform the request and return the result for inspection
|
| 174 |
+
gemini_request_data = openai_request_to_gemini(request)
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
"original_openai_request": request.dict(),
|
| 178 |
+
"transformed_gemini_request": gemini_request_data,
|
| 179 |
+
"message": "Transformation successful"
|
| 180 |
+
}
|
src/openai_transformers.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 DEFAULT_SAFETY_SETTINGS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def openai_request_to_gemini(openai_request: OpenAIChatCompletionRequest) -> Dict[str, Any]:
|
| 15 |
+
"""
|
| 16 |
+
Transform an OpenAI chat completion request to Gemini format.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
openai_request: OpenAI format request
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
Dictionary in Gemini API format
|
| 23 |
+
"""
|
| 24 |
+
contents = []
|
| 25 |
+
|
| 26 |
+
# Process each message in the conversation
|
| 27 |
+
for message in openai_request.messages:
|
| 28 |
+
role = message.role
|
| 29 |
+
|
| 30 |
+
# Map OpenAI roles to Gemini roles
|
| 31 |
+
if role == "assistant":
|
| 32 |
+
role = "model"
|
| 33 |
+
elif role == "system":
|
| 34 |
+
role = "user" # Gemini treats system messages as user messages
|
| 35 |
+
|
| 36 |
+
# Handle different content types (string vs list of parts)
|
| 37 |
+
if isinstance(message.content, list):
|
| 38 |
+
parts = []
|
| 39 |
+
for part in message.content:
|
| 40 |
+
if part.get("type") == "text":
|
| 41 |
+
parts.append({"text": part.get("text", "")})
|
| 42 |
+
elif part.get("type") == "image_url":
|
| 43 |
+
image_url = part.get("image_url", {}).get("url")
|
| 44 |
+
if image_url:
|
| 45 |
+
# Parse data URI: "data:image/jpeg;base64,{base64_image}"
|
| 46 |
+
try:
|
| 47 |
+
mime_type, base64_data = image_url.split(";")
|
| 48 |
+
_, mime_type = mime_type.split(":")
|
| 49 |
+
_, base64_data = base64_data.split(",")
|
| 50 |
+
parts.append({
|
| 51 |
+
"inlineData": {
|
| 52 |
+
"mimeType": mime_type,
|
| 53 |
+
"data": base64_data
|
| 54 |
+
}
|
| 55 |
+
})
|
| 56 |
+
except ValueError:
|
| 57 |
+
continue
|
| 58 |
+
contents.append({"role": role, "parts": parts})
|
| 59 |
+
else:
|
| 60 |
+
# Simple text content
|
| 61 |
+
contents.append({"role": role, "parts": [{"text": message.content}]})
|
| 62 |
+
|
| 63 |
+
# Map OpenAI generation parameters to Gemini format
|
| 64 |
+
generation_config = {}
|
| 65 |
+
if openai_request.temperature is not None:
|
| 66 |
+
generation_config["temperature"] = openai_request.temperature
|
| 67 |
+
if openai_request.top_p is not None:
|
| 68 |
+
generation_config["topP"] = openai_request.top_p
|
| 69 |
+
if openai_request.max_tokens is not None:
|
| 70 |
+
generation_config["maxOutputTokens"] = openai_request.max_tokens
|
| 71 |
+
if openai_request.stop is not None:
|
| 72 |
+
# Gemini supports stop sequences
|
| 73 |
+
if isinstance(openai_request.stop, str):
|
| 74 |
+
generation_config["stopSequences"] = [openai_request.stop]
|
| 75 |
+
elif isinstance(openai_request.stop, list):
|
| 76 |
+
generation_config["stopSequences"] = openai_request.stop
|
| 77 |
+
if openai_request.frequency_penalty is not None:
|
| 78 |
+
# Map frequency_penalty to Gemini's frequencyPenalty
|
| 79 |
+
generation_config["frequencyPenalty"] = openai_request.frequency_penalty
|
| 80 |
+
if openai_request.presence_penalty is not None:
|
| 81 |
+
# Map presence_penalty to Gemini's presencePenalty
|
| 82 |
+
generation_config["presencePenalty"] = openai_request.presence_penalty
|
| 83 |
+
if openai_request.n is not None:
|
| 84 |
+
# Map n (number of completions) to Gemini's candidateCount
|
| 85 |
+
generation_config["candidateCount"] = openai_request.n
|
| 86 |
+
if openai_request.seed is not None:
|
| 87 |
+
# Gemini supports seed for reproducible outputs
|
| 88 |
+
generation_config["seed"] = openai_request.seed
|
| 89 |
+
if openai_request.response_format is not None:
|
| 90 |
+
# Handle JSON mode if specified
|
| 91 |
+
if openai_request.response_format.get("type") == "json_object":
|
| 92 |
+
generation_config["responseMimeType"] = "application/json"
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
"contents": contents,
|
| 96 |
+
"generationConfig": generation_config,
|
| 97 |
+
"safetySettings": DEFAULT_SAFETY_SETTINGS,
|
| 98 |
+
"model": openai_request.model
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def gemini_response_to_openai(gemini_response: Dict[str, Any], model: str) -> Dict[str, Any]:
|
| 103 |
+
"""
|
| 104 |
+
Transform a Gemini API response to OpenAI chat completion format.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
gemini_response: Response from Gemini API
|
| 108 |
+
model: Model name to include in response
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Dictionary in OpenAI chat completion format
|
| 112 |
+
"""
|
| 113 |
+
choices = []
|
| 114 |
+
|
| 115 |
+
for candidate in gemini_response.get("candidates", []):
|
| 116 |
+
role = candidate.get("content", {}).get("role", "assistant")
|
| 117 |
+
|
| 118 |
+
# Map Gemini roles back to OpenAI roles
|
| 119 |
+
if role == "model":
|
| 120 |
+
role = "assistant"
|
| 121 |
+
|
| 122 |
+
# Extract text content from parts
|
| 123 |
+
parts = candidate.get("content", {}).get("parts", [])
|
| 124 |
+
content = ""
|
| 125 |
+
if parts and len(parts) > 0:
|
| 126 |
+
content = parts[0].get("text", "")
|
| 127 |
+
|
| 128 |
+
choices.append({
|
| 129 |
+
"index": candidate.get("index", 0),
|
| 130 |
+
"message": {
|
| 131 |
+
"role": role,
|
| 132 |
+
"content": content,
|
| 133 |
+
},
|
| 134 |
+
"finish_reason": _map_finish_reason(candidate.get("finishReason")),
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
return {
|
| 138 |
+
"id": str(uuid.uuid4()),
|
| 139 |
+
"object": "chat.completion",
|
| 140 |
+
"created": int(time.time()),
|
| 141 |
+
"model": model,
|
| 142 |
+
"choices": choices,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def gemini_stream_chunk_to_openai(gemini_chunk: Dict[str, Any], model: str, response_id: str) -> Dict[str, Any]:
|
| 147 |
+
"""
|
| 148 |
+
Transform a Gemini streaming response chunk to OpenAI streaming format.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
gemini_chunk: Single chunk from Gemini streaming response
|
| 152 |
+
model: Model name to include in response
|
| 153 |
+
response_id: Consistent ID for this streaming response
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
Dictionary in OpenAI streaming format
|
| 157 |
+
"""
|
| 158 |
+
choices = []
|
| 159 |
+
|
| 160 |
+
for candidate in gemini_chunk.get("candidates", []):
|
| 161 |
+
role = candidate.get("content", {}).get("role", "assistant")
|
| 162 |
+
|
| 163 |
+
# Map Gemini roles back to OpenAI roles
|
| 164 |
+
if role == "model":
|
| 165 |
+
role = "assistant"
|
| 166 |
+
|
| 167 |
+
# Extract text content from parts
|
| 168 |
+
parts = candidate.get("content", {}).get("parts", [])
|
| 169 |
+
content = ""
|
| 170 |
+
if parts and len(parts) > 0:
|
| 171 |
+
content = parts[0].get("text", "")
|
| 172 |
+
|
| 173 |
+
choices.append({
|
| 174 |
+
"index": candidate.get("index", 0),
|
| 175 |
+
"delta": {
|
| 176 |
+
"content": content,
|
| 177 |
+
},
|
| 178 |
+
"finish_reason": _map_finish_reason(candidate.get("finishReason")),
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
"id": response_id,
|
| 183 |
+
"object": "chat.completion.chunk",
|
| 184 |
+
"created": int(time.time()),
|
| 185 |
+
"model": model,
|
| 186 |
+
"choices": choices,
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _map_finish_reason(gemini_reason: str) -> str:
|
| 191 |
+
"""
|
| 192 |
+
Map Gemini finish reasons to OpenAI finish reasons.
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
gemini_reason: Finish reason from Gemini API
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
OpenAI-compatible finish reason
|
| 199 |
+
"""
|
| 200 |
+
if gemini_reason == "STOP":
|
| 201 |
+
return "stop"
|
| 202 |
+
elif gemini_reason == "MAX_TOKENS":
|
| 203 |
+
return "length"
|
| 204 |
+
elif gemini_reason in ["SAFETY", "RECITATION"]:
|
| 205 |
+
return "content_filter"
|
| 206 |
+
else:
|
| 207 |
+
return None
|
src/utils.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import platform
|
| 2 |
-
|
| 3 |
-
CLI_VERSION = "0.1.5" # Match current gemini-cli version
|
| 4 |
|
| 5 |
def get_user_agent():
|
| 6 |
"""Generate User-Agent string matching gemini-cli format."""
|
|
|
|
| 1 |
import platform
|
| 2 |
+
from .config import CLI_VERSION
|
|
|
|
| 3 |
|
| 4 |
def get_user_agent():
|
| 5 |
"""Generate User-Agent string matching gemini-cli format."""
|