Spaces:
Running
Running
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."""
|