rnilkyway commited on
Commit
e0f8aa6
Β·
1 Parent(s): ed8a947

feat(models): integrate Gemini Hub (Veo 3 video + Gemini 3 image/text)

Browse files

Adds first-class support for the public Gemini Hub backend at
https://gemini-rag-api.pespinoza.online which is NOT OpenAI-compatible
on the wire (custom /call/{tool_name} + /query endpoints).

New config:
- GEMINI_HUB_URL constant in config.py

New code in index.py:
- GEMINI_HUB_VIDEO_MODELS β†’ veo-3 / veo-3-fast / veo-3.1-fast
- GEMINI_HUB_IMAGE_MODELS β†’ gemini-3-image variants (flash/pro)
- GEMINI_HUB_TEXT_MODELS β†’ gemini-3 / gemini-3-flash / gemini-3-pro
- _gemini_hub_extract_prompt(): flatten OpenAI chat messages to a single
prompt (multi-modal content arrays collapsed to text parts)
- _gemini_hub_chat_envelope(): produce a non-streaming chat.completion
- _gemini_hub_chat(): pick the right Gemini Hub endpoint based on model
family, forward generation knobs (aspect_ratio, duration_seconds,
resolution, image_url), and wrap the response (video/image URL or RAG
text) in an OpenAI assistant message with Markdown media links

chat_completions() short-circuits on Gemini Hub models BEFORE the
normal upstream proxy path. Streaming clients get a synthesized
single-chunk SSE response so OpenAI SDKs keep working.

_available_models is appended with sorted(GEMINI_HUB_MODELS) in all
three places where it is rebuilt. /admin/models lists each model with
type="video-backend" / "image-backend" / "text-backend" and
provider="GeminiHub" so the dashboard renders them next to dynamic
providers.

Files changed (2) hide show
  1. app/config.py +8 -0
  2. app/index.py +220 -4
app/config.py CHANGED
@@ -46,6 +46,14 @@ QWEN_LOCAL_KEY = _decrypt("enc:v1:I5MWk2kg-lfUAMJZ:L74MW5GaciwIIsV0gWDbfc0vZyBm5
46
  # ── ChatGPT Image Generation API ─────────────────────────────────────────────
47
  CHATGPT_IMAGE_API_URL = "http://89.47.113.13:3999"
48
 
 
 
 
 
 
 
 
 
49
  UPSTREAMS = {
50
  # ── Existing aliases ──
51
  "claude-opus-4.6": [
 
46
  # ── ChatGPT Image Generation API ─────────────────────────────────────────────
47
  CHATGPT_IMAGE_API_URL = "http://89.47.113.13:3999"
48
 
49
+ # ── Gemini Hub (Veo 3 video + Gemini 3 image/text) ───────────────────────────
50
+ # Public OpenAPI: https://gemini-rag-api.pespinoza.online/openapi.json
51
+ # Exposes the following capabilities through /call/{tool_name}:
52
+ # - generar_video β†’ Veo 3.1 Fast (text-to-video, image-to-video)
53
+ # - generar_imagen β†’ Gemini 3.1 Flash Image (flash/pro variants)
54
+ # - query β†’ RAG-grounded text response (defaults to gemini-flash-latest)
55
+ GEMINI_HUB_URL = "https://gemini-rag-api.pespinoza.online"
56
+
57
  UPSTREAMS = {
58
  # ── Existing aliases ──
59
  "claude-opus-4.6": [
app/index.py CHANGED
@@ -23,7 +23,7 @@ from config import (
23
  MASTER_KEY,
24
  AI_GATEWAY_URL, UPSTREAM_14448_URL,
25
  BLOCKED_IPS,
26
- CHATGPT_IMAGE_API_URL,
27
  TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, BALANCE_ENDPOINTS,
28
  )
29
  from pydantic import BaseModel
@@ -48,6 +48,16 @@ RPM_LIMIT = 4
48
  KEYS_FILE = os.path.join(os.path.dirname(__file__), "data", "api_keys.json")
49
  PROVIDERS_FILE = os.path.join(os.path.dirname(__file__), "data", "providers.json")
50
 
 
 
 
 
 
 
 
 
 
 
51
  _rate_limits: dict = defaultdict(list)
52
 
53
  # ── Dynamic Providers (runtime-imported OpenAI-compatible backends) ─────────
@@ -396,7 +406,7 @@ async def _fetch_upstream_models():
396
  await client.aclose()
397
 
398
  # Build available models from UPSTREAMS keys + image models
399
- _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
400
  logger.info("Available models: %s", _available_models)
401
 
402
 
@@ -1248,6 +1258,18 @@ async def admin_models(request: Request):
1248
  # Mark the raw upstream id as "owned" by a dynamic provider so we
1249
  # don't double-list it from the built-in UPSTREAMS dict.
1250
  dynamic_models.add(upstream_id)
 
 
 
 
 
 
 
 
 
 
 
 
1251
 
1252
  def _provider_from_url(url: str) -> str:
1253
  """Extract provider name from upstream URL."""
@@ -1670,7 +1692,7 @@ async def admin_import_provider(request: Request, body: ProviderImportRequest):
1670
  # Refresh global available models list (built-in only β€” prefixed models
1671
  # are surfaced separately by /v1/models)
1672
  global _available_models
1673
- _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
1674
  logger.info(
1675
  "Imported provider %s (prefix=%r) with %d selected models (%d available)",
1676
  pid, prefix, len(cleaned_aliases), len(available),
@@ -1782,7 +1804,7 @@ async def admin_delete_provider(request: Request, provider_id: str):
1782
 
1783
  _save_providers()
1784
  global _available_models
1785
- _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
1786
  return {"success": True}
1787
 
1788
 
@@ -2397,6 +2419,158 @@ async def admin_settings_page():
2397
  # ── Image Generation Endpoint ─────────────────────────────────────────────────
2398
 
2399
  @app.post("/v1/images/generations")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2400
  async def generate_images(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
2401
  """OpenAI-compatible image generation endpoint. Routes to gpt-image-2 backend."""
2402
  api_key = credentials.credentials
@@ -2535,6 +2709,48 @@ async def chat_completions(request: Request, _auth=Depends(verify_request)):
2535
  safe_body["has_tools"] = bool(body.get("tools"))
2536
  logger.info("CHAT_REQ ip=%s model=%s->%s stream=%s body_keys=%s",
2537
  get_client_ip(request), model_raw, model, stream, list(safe_body.keys()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2538
  tools = body.get("tools")
2539
  tool_choice = body.get("tool_choice")
2540
  req_id = uuid.uuid4().hex
 
23
  MASTER_KEY,
24
  AI_GATEWAY_URL, UPSTREAM_14448_URL,
25
  BLOCKED_IPS,
26
+ CHATGPT_IMAGE_API_URL, GEMINI_HUB_URL,
27
  TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, BALANCE_ENDPOINTS,
28
  )
29
  from pydantic import BaseModel
 
48
  KEYS_FILE = os.path.join(os.path.dirname(__file__), "data", "api_keys.json")
49
  PROVIDERS_FILE = os.path.join(os.path.dirname(__file__), "data", "providers.json")
50
 
51
+ # ── Gemini Hub model aliases ─────────────────────────────────────────────────
52
+ # Public Gemini Hub backend (see config.GEMINI_HUB_URL) exposes Veo 3 video,
53
+ # Gemini 3 image, and RAG-grounded text generation. We surface these as
54
+ # regular OpenAI model ids; chat_completions short-circuits on them and
55
+ # routes to the dedicated adapter (_gemini_hub_chat) defined further below.
56
+ GEMINI_HUB_VIDEO_MODELS = {"veo-3", "veo-3-fast", "veo-3.1-fast"}
57
+ GEMINI_HUB_IMAGE_MODELS = {"gemini-3-image", "gemini-3-flash-image", "gemini-3.1-flash-image"}
58
+ GEMINI_HUB_TEXT_MODELS = {"gemini-3", "gemini-3-flash", "gemini-3-pro"}
59
+ GEMINI_HUB_MODELS = GEMINI_HUB_VIDEO_MODELS | GEMINI_HUB_IMAGE_MODELS | GEMINI_HUB_TEXT_MODELS
60
+
61
  _rate_limits: dict = defaultdict(list)
62
 
63
  # ── Dynamic Providers (runtime-imported OpenAI-compatible backends) ─────────
 
406
  await client.aclose()
407
 
408
  # Build available models from UPSTREAMS keys + image models
409
+ _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"] + sorted(GEMINI_HUB_MODELS)
410
  logger.info("Available models: %s", _available_models)
411
 
412
 
 
1258
  # Mark the raw upstream id as "owned" by a dynamic provider so we
1259
  # don't double-list it from the built-in UPSTREAMS dict.
1260
  dynamic_models.add(upstream_id)
1261
+
1262
+ # Gemini Hub (Veo 3 + Gemini 3) virtual provider β€” surfaced for the
1263
+ # dashboard so it can render the same as any other registered backend.
1264
+ for vid in sorted(GEMINI_HUB_VIDEO_MODELS):
1265
+ models.append({"name": vid, "provider": "GeminiHub", "type": "video-backend"})
1266
+ dynamic_models.add(vid)
1267
+ for iid in sorted(GEMINI_HUB_IMAGE_MODELS):
1268
+ models.append({"name": iid, "provider": "GeminiHub", "type": "image-backend"})
1269
+ dynamic_models.add(iid)
1270
+ for tid in sorted(GEMINI_HUB_TEXT_MODELS):
1271
+ models.append({"name": tid, "provider": "GeminiHub", "type": "text-backend"})
1272
+ dynamic_models.add(tid)
1273
 
1274
  def _provider_from_url(url: str) -> str:
1275
  """Extract provider name from upstream URL."""
 
1692
  # Refresh global available models list (built-in only β€” prefixed models
1693
  # are surfaced separately by /v1/models)
1694
  global _available_models
1695
+ _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"] + sorted(GEMINI_HUB_MODELS)
1696
  logger.info(
1697
  "Imported provider %s (prefix=%r) with %d selected models (%d available)",
1698
  pid, prefix, len(cleaned_aliases), len(available),
 
1804
 
1805
  _save_providers()
1806
  global _available_models
1807
+ _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"] + sorted(GEMINI_HUB_MODELS)
1808
  return {"success": True}
1809
 
1810
 
 
2419
  # ── Image Generation Endpoint ─────────────────────────────────────────────────
2420
 
2421
  @app.post("/v1/images/generations")
2422
+ def _gemini_hub_extract_prompt(messages: list) -> str:
2423
+ """Flatten OpenAI chat messages into a single prompt string for Gemini Hub.
2424
+
2425
+ We concatenate every user/system turn (skipping tool noise) and return
2426
+ the joined plain text. Multi-modal ``content`` arrays are reduced to their
2427
+ textual parts; image parts are dropped because the dashboard exposes only
2428
+ text-to-* generation today.
2429
+ """
2430
+ parts: list[str] = []
2431
+ for msg in messages:
2432
+ if not isinstance(msg, dict):
2433
+ continue
2434
+ role = msg.get("role")
2435
+ if role not in ("system", "user"):
2436
+ continue
2437
+ content = msg.get("content")
2438
+ if isinstance(content, str):
2439
+ parts.append(content.strip())
2440
+ elif isinstance(content, list):
2441
+ for piece in content:
2442
+ if isinstance(piece, dict) and piece.get("type") in ("text", "input_text"):
2443
+ txt = piece.get("text") or piece.get("input_text") or ""
2444
+ if txt:
2445
+ parts.append(str(txt).strip())
2446
+ return "\n\n".join(p for p in parts if p)
2447
+
2448
+
2449
+ def _gemini_hub_chat_envelope(model: str, content: str, *, usage_prompt: int = 0) -> dict:
2450
+ """Wrap ``content`` in a non-streaming OpenAI chat.completion response."""
2451
+ return {
2452
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
2453
+ "object": "chat.completion",
2454
+ "created": int(time.time()),
2455
+ "model": model,
2456
+ "choices": [
2457
+ {
2458
+ "index": 0,
2459
+ "message": {"role": "assistant", "content": content},
2460
+ "finish_reason": "stop",
2461
+ }
2462
+ ],
2463
+ "usage": {
2464
+ "prompt_tokens": usage_prompt,
2465
+ "completion_tokens": len(content) // 4,
2466
+ "total_tokens": usage_prompt + len(content) // 4,
2467
+ },
2468
+ }
2469
+
2470
+
2471
+ async def _gemini_hub_chat(model: str, messages: list, body: dict) -> dict:
2472
+ """Route a chat completion to the Gemini Hub backend based on ``model``.
2473
+
2474
+ Returns an OpenAI-compatible ``chat.completion`` dict. Raises HTTPException
2475
+ on upstream failures so the outer ``chat_completions`` handler reports them
2476
+ consistently with the rest of the stack.
2477
+ """
2478
+ prompt = _gemini_hub_extract_prompt(messages)
2479
+ if not prompt:
2480
+ raise HTTPException(
2481
+ status_code=400,
2482
+ detail={"error": {"message": "No textual prompt found in messages"}},
2483
+ )
2484
+
2485
+ # Optional generation knobs forwarded from the request body (best-effort).
2486
+ aspect_ratio = body.get("aspect_ratio") or body.get("size")
2487
+ duration = body.get("duration_seconds") or body.get("duration")
2488
+ resolution = body.get("resolution")
2489
+
2490
+ if model in GEMINI_HUB_VIDEO_MODELS:
2491
+ payload: dict = {"prompt": prompt}
2492
+ if aspect_ratio in ("16:9", "9:16"):
2493
+ payload["aspect_ratio"] = aspect_ratio
2494
+ if duration and str(duration) in ("4", "6", "8"):
2495
+ payload["duration_seconds"] = str(duration)
2496
+ if resolution in ("720p", "1080p"):
2497
+ payload["resolution"] = resolution
2498
+ # Allow image-to-video when the client passes an image_url in the body
2499
+ if isinstance(body.get("image_url"), str):
2500
+ payload["image_url"] = body["image_url"]
2501
+
2502
+ url = f"{GEMINI_HUB_URL}/call/generar_video"
2503
+ logger.info("GeminiHub video request model=%s prompt=%s", model, prompt[:80])
2504
+ async with httpx.AsyncClient(timeout=600.0) as client:
2505
+ try:
2506
+ resp = await client.post(url, json=payload)
2507
+ except Exception as e:
2508
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub unreachable: {e}"}})
2509
+ if resp.status_code != 200:
2510
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub {resp.status_code}: {resp.text[:300]}"}})
2511
+ data = resp.json() or {}
2512
+ if data.get("error"):
2513
+ raise HTTPException(status_code=400, detail={"error": {"message": str(data["error"])}})
2514
+
2515
+ video_url = data.get("video_url") or data.get("url")
2516
+ if not video_url:
2517
+ raise HTTPException(status_code=502, detail={"error": {"message": "Gemini Hub returned no video_url"}})
2518
+ # OpenAI clients render Markdown; embed both a clickable link and
2519
+ # the raw URL so plain-text consumers still see it.
2520
+ content = (
2521
+ f"🎬 Generated with **{data.get('model', model)}** "
2522
+ f"({data.get('resolution', payload.get('resolution', '720p'))}, "
2523
+ f"{data.get('aspect_ratio', payload.get('aspect_ratio', '16:9'))}, "
2524
+ f"{data.get('duration_seconds', payload.get('duration_seconds', '8'))}s)\n\n"
2525
+ f"[β–Ά Video]({video_url})\n\n{video_url}"
2526
+ )
2527
+ return _gemini_hub_chat_envelope(model, content, usage_prompt=len(prompt) // 4)
2528
+
2529
+ if model in GEMINI_HUB_IMAGE_MODELS:
2530
+ payload = {"prompt": prompt, "model": "pro" if model.endswith("-pro") else "flash"}
2531
+ if aspect_ratio in ("1:1", "16:9", "9:16", "4:3", "3:4"):
2532
+ payload["aspect_ratio"] = aspect_ratio
2533
+ url = f"{GEMINI_HUB_URL}/call/generar_imagen"
2534
+ logger.info("GeminiHub image request model=%s prompt=%s", model, prompt[:80])
2535
+ async with httpx.AsyncClient(timeout=180.0) as client:
2536
+ try:
2537
+ resp = await client.post(url, json=payload)
2538
+ except Exception as e:
2539
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub unreachable: {e}"}})
2540
+ if resp.status_code != 200:
2541
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub {resp.status_code}: {resp.text[:300]}"}})
2542
+ data = resp.json() or {}
2543
+ if data.get("error"):
2544
+ raise HTTPException(status_code=400, detail={"error": {"message": str(data["error"])}})
2545
+ image_url = data.get("image_url") or data.get("url")
2546
+ if not image_url:
2547
+ raise HTTPException(status_code=502, detail={"error": {"message": "Gemini Hub returned no image_url"}})
2548
+ content = (
2549
+ f"πŸ–ΌοΈ Generated with **{data.get('model', model)}** "
2550
+ f"({data.get('aspect_ratio', payload.get('aspect_ratio', '1:1'))})\n\n"
2551
+ f"![image]({image_url})\n\n{image_url}"
2552
+ )
2553
+ return _gemini_hub_chat_envelope(model, content, usage_prompt=len(prompt) // 4)
2554
+
2555
+ # Text models β†’ /query (RAG-grounded). Trim noisy "Fuentes:" tail so
2556
+ # OpenAI clients see the answer first.
2557
+ payload = {"query": prompt, "top_k": int(body.get("top_k") or 5)}
2558
+ url = f"{GEMINI_HUB_URL}/query"
2559
+ logger.info("GeminiHub text request model=%s prompt=%s", model, prompt[:80])
2560
+ async with httpx.AsyncClient(timeout=120.0) as client:
2561
+ try:
2562
+ resp = await client.post(url, json=payload)
2563
+ except Exception as e:
2564
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub unreachable: {e}"}})
2565
+ if resp.status_code != 200:
2566
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub {resp.status_code}: {resp.text[:300]}"}})
2567
+ data = resp.json() or {}
2568
+ answer = data.get("response") or data.get("text") or ""
2569
+ if not answer:
2570
+ raise HTTPException(status_code=502, detail={"error": {"message": "Gemini Hub returned empty response"}})
2571
+ return _gemini_hub_chat_envelope(model, answer, usage_prompt=len(prompt) // 4)
2572
+
2573
+
2574
  async def generate_images(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
2575
  """OpenAI-compatible image generation endpoint. Routes to gpt-image-2 backend."""
2576
  api_key = credentials.credentials
 
2709
  safe_body["has_tools"] = bool(body.get("tools"))
2710
  logger.info("CHAT_REQ ip=%s model=%s->%s stream=%s body_keys=%s",
2711
  get_client_ip(request), model_raw, model, stream, list(safe_body.keys()))
2712
+
2713
+ # ── Gemini Hub short-circuit (Veo 3 video, Gemini 3 image/text) ─────
2714
+ # These models talk to a non-OpenAI backend; bypass the upstream proxy
2715
+ # entirely. Streaming is emulated as a single chunk so clients that
2716
+ # set ``stream=true`` still work.
2717
+ if model in GEMINI_HUB_MODELS:
2718
+ try:
2719
+ completion = await _gemini_hub_chat(model, messages, body)
2720
+ except HTTPException:
2721
+ raise
2722
+ except Exception as e:
2723
+ logger.exception("Gemini Hub adapter failed: %s", e)
2724
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Gemini Hub error: {e}"}})
2725
+
2726
+ if not stream:
2727
+ return completion
2728
+
2729
+ # Emulate a single-chunk SSE stream so OpenAI-style clients still work.
2730
+ async def _gemini_hub_stream():
2731
+ choice = completion["choices"][0]
2732
+ chunk = {
2733
+ "id": completion["id"],
2734
+ "object": "chat.completion.chunk",
2735
+ "created": completion["created"],
2736
+ "model": completion["model"],
2737
+ "choices": [{
2738
+ "index": 0,
2739
+ "delta": {"role": "assistant", "content": choice["message"]["content"]},
2740
+ "finish_reason": None,
2741
+ }],
2742
+ }
2743
+ yield f"data: {json.dumps(chunk)}\n\n"
2744
+ final = {
2745
+ "id": completion["id"],
2746
+ "object": "chat.completion.chunk",
2747
+ "created": completion["created"],
2748
+ "model": completion["model"],
2749
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
2750
+ }
2751
+ yield f"data: {json.dumps(final)}\n\n"
2752
+ yield "data: [DONE]\n\n"
2753
+ return StreamingResponse(_gemini_hub_stream(), media_type="text/event-stream")
2754
  tools = body.get("tools")
2755
  tool_choice = body.get("tool_choice")
2756
  req_id = uuid.uuid4().hex