triflix commited on
Commit
805ee6f
Β·
verified Β·
1 Parent(s): 7647d8e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +388 -0
app.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gemini CLI β†’ OpenAI-Compatible API Proxy
3
+ Ultra-fast, reliable, with full streaming support.
4
+ Deploy on HuggingFace Spaces (Docker SDK, port 7860).
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import time
10
+ import asyncio
11
+ import logging
12
+ from uuid import uuid4
13
+ from datetime import datetime, timezone
14
+ from contextlib import asynccontextmanager
15
+ from typing import AsyncIterator, Any
16
+
17
+ import httpx
18
+ from fastapi import FastAPI, Request, HTTPException, Depends
19
+ from fastapi.responses import StreamingResponse, JSONResponse
20
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from google.oauth2.credentials import Credentials
23
+ from google.auth.transport.requests import Request as GoogleAuthRequest
24
+
25
+ # ────────────────────── Logging ──────────────────────
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
27
+ log = logging.getLogger("gemini-proxy")
28
+
29
+ # ────────────────────── Config ───────────────────────
30
+ AUTH_PASSWORD = os.environ.get("GEMINI_AUTH_PASSWORD", "")
31
+ CREDS_JSON = os.environ.get("GEMINI_CREDENTIALS", "{}")
32
+ CLIENT_ID = os.environ.get("GEMINI_CLIENT_ID", "")
33
+ CLIENT_SECRET = os.environ.get("GEMINI_CLIENT_SECRET", "")
34
+ API_BASE = os.environ.get("GEMINI_API_BASE", "https://cloudcode-pa.googleapis.com")
35
+
36
+ # ────────────────────── Globals ──────────────────────
37
+ _http: httpx.AsyncClient | None = None
38
+ _creds: Credentials | None = None
39
+ _lock = asyncio.Lock()
40
+ _sec = HTTPBearer(auto_error=False)
41
+
42
+ MODELS = [
43
+ "gemini-2.5-pro",
44
+ "gemini-2.5-flash",
45
+ "gemini-2.0-flash",
46
+ "gemini-2.5-pro-search",
47
+ "gemini-2.5-flash-search",
48
+ "gemini-2.5-pro-nothinking",
49
+ "gemini-2.5-flash-nothinking",
50
+ "gemini-2.5-pro-maxthinking",
51
+ "gemini-2.5-flash-maxthinking",
52
+ ]
53
+
54
+ # ════════════════════ APP LIFESPAN ═══════════════════
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(_app: FastAPI):
58
+ global _http
59
+ _http = httpx.AsyncClient(
60
+ timeout=httpx.Timeout(connect=10, read=300, write=30, pool=10),
61
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
62
+ http2=True,
63
+ )
64
+ log.info("Proxy ready β€” %d models", len(MODELS))
65
+ yield
66
+ await _http.aclose()
67
+
68
+ app = FastAPI(title="Gemini OpenAI Proxy", lifespan=lifespan)
69
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
70
+
71
+
72
+ # ════════════════════ AUTH ═══════════════════════════
73
+
74
+ def _auth(c: HTTPAuthorizationCredentials = Depends(_sec)):
75
+ if not AUTH_PASSWORD:
76
+ raise HTTPException(500, "Server missing GEMINI_AUTH_PASSWORD")
77
+ if not c or c.credentials != AUTH_PASSWORD:
78
+ raise HTTPException(401, "Invalid Bearer token")
79
+
80
+
81
+ # ════════════════════ TOKEN ══════════════════════════
82
+
83
+ async def _token() -> str:
84
+ global _creds
85
+ async with _lock:
86
+ if _creds and _creds.valid and not _creds.expired:
87
+ return _creds.token
88
+ return await asyncio.to_thread(_refresh)
89
+
90
+
91
+ def _refresh() -> str:
92
+ global _creds
93
+ d = json.loads(CREDS_JSON)
94
+
95
+ cid = d.get("client_id") or CLIENT_ID
96
+ csec = d.get("client_secret") or CLIENT_SECRET
97
+ rtok = d.get("refresh_token")
98
+ atok = d.get("access_token") or d.get("token")
99
+
100
+ missing = []
101
+ if not cid: missing.append("GEMINI_CLIENT_ID")
102
+ if not csec: missing.append("GEMINI_CLIENT_SECRET")
103
+ if not rtok: missing.append("refresh_token")
104
+ if missing:
105
+ raise HTTPException(500, f"Missing: {', '.join(missing)}")
106
+
107
+ exp = None
108
+ if "expiry_date" in d:
109
+ exp = datetime.fromtimestamp(d["expiry_date"] / 1000, tz=timezone.utc)
110
+
111
+ c = Credentials(
112
+ token=atok, refresh_token=rtok,
113
+ token_uri=d.get("token_uri", "https://oauth2.googleapis.com/token"),
114
+ client_id=cid, client_secret=csec, expiry=exp,
115
+ )
116
+ if not c.valid or c.expired:
117
+ c.refresh(GoogleAuthRequest())
118
+ log.info("Token refreshed β†’ expires %s", c.expiry)
119
+ _creds = c
120
+ return c.token
121
+
122
+
123
+ # ════════════════════ GEMINI HELPERS ═════════════════
124
+
125
+ def _parse_model(model: str):
126
+ """Returns (base_model, use_search, thinking_budget)."""
127
+ search = model.endswith("-search")
128
+ no_think = model.endswith("-nothinking")
129
+ max_think = model.endswith("-maxthinking")
130
+
131
+ base = (model.removesuffix("-search")
132
+ .removesuffix("-nothinking")
133
+ .removesuffix("-maxthinking"))
134
+
135
+ budget = None
136
+ if no_think: budget = 0
137
+ if max_think: budget = 24576
138
+
139
+ return base, search, budget
140
+
141
+
142
+ def _to_gemini(messages: list, search: bool, budget, **kw) -> dict:
143
+ """OpenAI messages β†’ Gemini request body."""
144
+ contents = []
145
+ sys_parts = []
146
+
147
+ for m in messages:
148
+ role, text = m.get("role", "user"), m.get("content", "")
149
+ if role == "system":
150
+ sys_parts.append({"text": text})
151
+ else:
152
+ contents.append({
153
+ "role": "user" if role == "user" else "model",
154
+ "parts": [{"text": text}],
155
+ })
156
+
157
+ body: dict[str, Any] = {"contents": contents}
158
+ if sys_parts:
159
+ body["systemInstruction"] = {"parts": sys_parts}
160
+
161
+ gc: dict[str, Any] = {}
162
+ if kw.get("temperature") is not None: gc["temperature"] = kw["temperature"]
163
+ if kw.get("max_tokens"): gc["maxOutputTokens"] = kw["max_tokens"]
164
+ if kw.get("top_p") is not None: gc["topP"] = kw["top_p"]
165
+ if kw.get("stop"):
166
+ gc["stopSequences"] = kw["stop"] if isinstance(kw["stop"], list) else [kw["stop"]]
167
+ if budget is not None:
168
+ gc["thinkingConfig"] = {"thinkingBudget": budget}
169
+ if gc:
170
+ body["generationConfig"] = gc
171
+
172
+ if search:
173
+ body["tools"] = [{"googleSearch": {}}]
174
+
175
+ return body
176
+
177
+
178
+ # ─── Stream parser: handles both SSE and JSON-array ──
179
+
180
+ async def _gemini_stream(url: str, headers: dict, body: dict) -> AsyncIterator[dict]:
181
+ """Yields individual Gemini response objects from a stream."""
182
+ sse_url = url + "?alt=sse"
183
+
184
+ async with _http.stream("POST", sse_url, json=body, headers=headers) as r:
185
+ if r.status_code != 200:
186
+ err = (await r.aread()).decode(errors="replace")
187
+ raise HTTPException(r.status_code, f"Gemini: {err[:500]}")
188
+
189
+ ct = r.headers.get("content-type", "")
190
+
191
+ if "text/event-stream" in ct:
192
+ # ── SSE mode (fast, line-by-line) ──
193
+ async for line in r.aiter_lines():
194
+ if not line.startswith("data:"):
195
+ continue
196
+ payload = line[5:].strip()
197
+ if not payload or payload == "[DONE]":
198
+ continue
199
+ try:
200
+ yield json.loads(payload)
201
+ except json.JSONDecodeError:
202
+ continue
203
+ else:
204
+ # ── JSON-array fallback ──
205
+ buf = ""
206
+ async for chunk in r.aiter_text():
207
+ buf += chunk
208
+ while True:
209
+ buf = buf.lstrip(" \t\n\r,[")
210
+ if not buf or buf[0] != "{":
211
+ # also strip trailing ] at end of array
212
+ buf = buf.lstrip("]")
213
+ break
214
+ # find matching }
215
+ depth = 0
216
+ in_s = 0 # 1 = inside string
217
+ esc = 0 # 1 = next char is escaped
218
+ found = -1
219
+ for i, c in enumerate(buf):
220
+ if esc:
221
+ esc = 0; continue
222
+ if c == "\\" and in_s:
223
+ esc = 1; continue
224
+ if c == '"':
225
+ in_s ^= 1; continue
226
+ if in_s:
227
+ continue
228
+ if c == "{": depth += 1
229
+ elif c == "}":
230
+ depth -= 1
231
+ if depth == 0:
232
+ found = i; break
233
+ if found < 0:
234
+ break # incomplete, need more data
235
+ try:
236
+ yield json.loads(buf[:found + 1])
237
+ except json.JSONDecodeError:
238
+ pass
239
+ buf = buf[found + 1:]
240
+
241
+
242
+ def _text(obj: dict) -> str:
243
+ """Extract non-thought text from Gemini response."""
244
+ parts = obj.get("candidates", [{}])[0].get("content", {}).get("parts", [])
245
+ return "".join(p.get("text", "") for p in parts if not p.get("thought"))
246
+
247
+
248
+ def _usage(obj: dict) -> dict:
249
+ m = obj.get("usageMetadata", {})
250
+ return {
251
+ "prompt_tokens": m.get("promptTokenCount", 0),
252
+ "completion_tokens": m.get("candidatesTokenCount", 0),
253
+ "total_tokens": m.get("totalTokenCount", 0),
254
+ }
255
+
256
+
257
+ _FINISH_MAP = {"STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter"}
258
+
259
+ def _finish(obj: dict) -> str | None:
260
+ r = obj.get("candidates", [{}])[0].get("finishReason")
261
+ return _FINISH_MAP.get(r)
262
+
263
+
264
+ # ════════════════════ ROUTES ═════════════════════════
265
+
266
+ @app.get("/")
267
+ async def health():
268
+ return {"status": "ok", "service": "gemini-openai-proxy", "models": len(MODELS)}
269
+
270
+
271
+ @app.get("/v1/models")
272
+ async def list_models(_=Depends(_auth)):
273
+ return {
274
+ "object": "list",
275
+ "data": [{"id": m, "object": "model", "owned_by": "google", "created": 0} for m in MODELS],
276
+ }
277
+
278
+
279
+ @app.post("/v1/chat/completions")
280
+ async def chat(request: Request, _=Depends(_auth)):
281
+ body = await request.json()
282
+ model = body.get("model", "gemini-2.5-pro")
283
+ msgs = body.get("messages", [])
284
+ stream = body.get("stream", False)
285
+
286
+ if not msgs:
287
+ raise HTTPException(400, "messages required")
288
+
289
+ base, search, budget = _parse_model(model)
290
+ gemini_body = _to_gemini(
291
+ msgs, search, budget,
292
+ temperature=body.get("temperature"),
293
+ max_tokens=body.get("max_tokens") or body.get("max_completion_tokens"),
294
+ top_p=body.get("top_p"),
295
+ stop=body.get("stop"),
296
+ )
297
+
298
+ tok = await _token()
299
+ hdrs = {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}
300
+
301
+ if stream:
302
+ url = f"{API_BASE}/v1/models/{base}:streamGenerateContent"
303
+ return StreamingResponse(
304
+ _sse_stream(url, hdrs, gemini_body, model),
305
+ media_type="text/event-stream",
306
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
307
+ )
308
+
309
+ # ── Non-streaming ──
310
+ url = f"{API_BASE}/v1/models/{base}:generateContent"
311
+ return await _non_stream(url, hdrs, gemini_body, model)
312
+
313
+
314
+ # ─── Streaming response ─────────────────────────────
315
+
316
+ async def _sse_stream(url, hdrs, body, model):
317
+ cid = f"chatcmpl-{uuid4().hex[:24]}"
318
+ ts = int(time.time())
319
+
320
+ # role chunk
321
+ yield _sse({"id": cid, "object": "chat.completion.chunk", "created": ts, "model": model,
322
+ "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]})
323
+
324
+ try:
325
+ async for obj in _gemini_stream(url, hdrs, body):
326
+ txt = _text(obj)
327
+ fin = _finish(obj)
328
+
329
+ if txt:
330
+ yield _sse({"id": cid, "object": "chat.completion.chunk", "created": ts, "model": model,
331
+ "choices": [{"index": 0, "delta": {"content": txt}, "finish_reason": None}]})
332
+ if fin:
333
+ yield _sse({"id": cid, "object": "chat.completion.chunk", "created": ts, "model": model,
334
+ "choices": [{"index": 0, "delta": {}, "finish_reason": fin}]})
335
+ except HTTPException as e:
336
+ # Send error as SSE event so client knows what happened
337
+ yield _sse({"error": {"message": e.detail, "code": e.status_code}})
338
+
339
+ yield "data: [DONE]\n\n"
340
+
341
+
342
+ # ─── Non-streaming response ─────────────────────────
343
+
344
+ async def _non_stream(url, hdrs, body, model):
345
+ # Retry once on 401 (expired token)
346
+ for attempt in range(2):
347
+ r = await _http.post(url, json=body, headers=hdrs)
348
+ if r.status_code == 401 and attempt == 0:
349
+ global _creds
350
+ async with _lock:
351
+ _creds = None
352
+ tok = await _token()
353
+ hdrs["Authorization"] = f"Bearer {tok}"
354
+ continue
355
+ break
356
+
357
+ if r.status_code != 200:
358
+ raise HTTPException(r.status_code, f"Gemini: {r.text[:500]}")
359
+
360
+ data = r.json()
361
+
362
+ # Handle error in body
363
+ if "error" in data:
364
+ e = data["error"]
365
+ raise HTTPException(e.get("code", 500), e.get("message", "Unknown"))
366
+
367
+ # Gemini may return list or dict
368
+ if isinstance(data, list):
369
+ full_text = "".join(_text(item) for item in data)
370
+ usg = next((_usage(i) for i in data if _usage(i).get("total_tokens")), _usage({}))
371
+ fin = next((_finish(i) for i in data if _finish(i)), "stop")
372
+ else:
373
+ full_text = _text(data)
374
+ usg = _usage(data)
375
+ fin = _finish(data) or "stop"
376
+
377
+ return JSONResponse({
378
+ "id": f"chatcmpl-{uuid4().hex[:24]}",
379
+ "object": "chat.completion",
380
+ "created": int(time.time()),
381
+ "model": model,
382
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": full_text}, "finish_reason": fin}],
383
+ "usage": usg,
384
+ })
385
+
386
+
387
+ def _sse(obj: dict) -> str:
388
+ return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"