feat(models): integrate Gemini Hub (Veo 3 video + Gemini 3 image/text)
Browse filesAdds 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.
- app/config.py +8 -0
- app/index.py +220 -4
|
@@ -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": [
|
|
@@ -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"\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
|