cc165 commited on
Commit
9a2e8a5
·
verified ·
1 Parent(s): ec52c24

Upload 16 files

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