Titan-Gateway / core /api_clients.py
M-hv1's picture
Update core/api_clients.py
3cc0c20 verified
Raw
History Blame
37 kB
# ============================================================
# core/api_clients.py -- Titan Gateway V16.6 "HF Rotation"
# ============================================================
# CHANGELOG V16.6:
# [IMAGE] Replaced Pollinations + Hercai with a Hugging Face
# Inference API system backed by a token rotation pool.
#
# TOKEN POOL:
# Scans env vars HF_TOKEN1 ... HF_TOKEN8 at module load.
# NOTE: HF_TOKEN (no number) is intentionally excluded.
# It is reserved for the HuggingFace dataset/vault client
# used elsewhere in the pipeline and must never be consumed
# by image generation.
# All found numbered tokens are stored in _HF_IMAGE_TOKENS.
#
# ROUND-ROBIN ROTATION:
# _hf_image_token_index (module-level int) advances by 1
# on every call to _acquire_hf_image_token(), wrapping
# around the list with modulo.
# On HTTP 429 the current token is considered exhausted
# for this request; the outer loop immediately picks the
# next token without sleeping.
#
# MODEL:
# stabilityai/stable-diffusion-xl-base-1.0
# Endpoint:
# https://api-inference.huggingface.co/models/
# stabilityai/stable-diffusion-xl-base-1.0
#
# 503 HANDLING (model cold-start):
# HF returns HTTP 503 + {"estimated_time": N} while the
# model loads. Retried up to HF_IMAGE_503_RETRIES (3)
# times on the SAME token with a fixed HF_IMAGE_503_WAIT
# (5 s) delay. 503 does NOT advance the token index.
#
# THE BOUNCER:
# After saving any file, os.path.getsize() is checked.
# Files < MIN_REAL_IMAGE_BYTES (30 KB) are deleted and
# False is returned. Real SDXL outputs are 200 KB-1 MB;
# error/placeholder bodies are always smaller than 30 KB.
#
# PROMPT:
# Truncated to <= HF_IMAGE_MAX_KEYWORDS (15) keywords
# using the existing _truncate_to_keywords() helper.
# Sent as JSON payload {"inputs": "<prompt>"} -- NOT
# URL-encoded in a query string, because the HF
# Inference API accepts a POST body, not a GET param.
#
# Entry point: generate_truly_free_image(prompt, out_path)
# Returns False (never raises) -- pipeline never crashes.
#
# [CLEANUP] _image_via_pollinations_turbo(), _image_via_hercai(),
# POLLINATIONS_IMAGE_TIMEOUT, HERCAI_IMAGE_TIMEOUT,
# FREE_IMAGE_MAX_KEYWORDS, MIN_REAL_IMAGE_BYTES (50 KB)
# all replaced or renamed. No Pollinations or Hercai
# URLs remain anywhere in the file.
# [GROQ] frequency_penalty=0.9, presence_penalty=0.7 unchanged.
# ============================================================
import asyncio
import httpx
import json
import logging
import os
import random
import re
import urllib.parse
from typing import Any, Dict, List, Optional
from google import genai
from groq import AsyncGroq, RateLimitError as GroqRateLimitError # noqa: F401
logger = logging.getLogger("titan.api_clients")
# ------------------------------------------------------------------
# Constants -- audio (unchanged)
# ------------------------------------------------------------------
HF_AUDIOLDM2_URL: str = "https://api-inference.huggingface.co/models/cvssp/audioldm2"
HF_AUDIO_DURATION: float = 10.0
HF_AUDIO_GUIDANCE: float = 3.5
HF_AUDIO_RETRIES: int = 3
HF_AUDIO_TIMEOUT: float = 90.0
# ------------------------------------------------------------------
# Constants -- V16.6 HF image generation
# ------------------------------------------------------------------
# Model endpoint (lightweight, stable SDXL variant) – UPDATED to router URL
# Primary: free serverless endpoint (no credits consumed)
HF_IMAGE_MODEL_URL: str = (
"https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0"
)
# Fallback: paid inference provider (used only if primary returns 404/unavailable)
HF_IMAGE_MODEL_URL_PAID: str = (
"https://router.huggingface.co/hf-inference/models/stabilityai/stable-diffusion-xl-base-1.0"
)
HF_IMAGE_TIMEOUT: float = 120.0 # SDXL inference can take 60-90 s
HF_IMAGE_503_RETRIES: int = 3 # retries on model cold-start (503)
HF_IMAGE_503_WAIT: float = 5.0 # seconds between 503 retries
# Max keywords sent to HF -- keeps the prompt concise
HF_IMAGE_MAX_KEYWORDS: int = 15
# The Bouncer threshold -- real SDXL images are 200 KB-1 MB;
# HF error / placeholder bodies are always < 30 KB.
MIN_REAL_IMAGE_BYTES: int = 30 * 1024 # 30 KB
# ------------------------------------------------------------------
# Constants -- LLM / translation (unchanged)
# ------------------------------------------------------------------
IMMERSION_AUTHOR_MODEL: str = "llama-3.3-70b-versatile" # Director + Dice β€” json_object calls
GEMMA_MODEL_ID: str = os.environ.get("GEMINI_MODEL", "gemma-3-27b-it")
TRANSLATION_TEMPERATURE: float = 0.10
# MIN_IMAGE_BYTES retained for AudioLDM2 audio-payload validation only
MIN_IMAGE_BYTES: int = 1024
# ══════════════════════════════════════════════════════════════════
#
# V16.6 -- HF IMAGE TOKEN POOL (initialised once at module load)
#
# Scans HF_TOKEN1 ... HF_TOKEN8.
# HF_TOKEN (no suffix) is deliberately excluded: it belongs to
# the HuggingFace dataset / vault client, not image generation.
#
# ══════════════════════════════════════════════════════════════════
_HF_IMAGE_TOKENS: List[str] = [
tok
for tok in (
os.environ.get(f"HF_TOKEN{i}", "").strip()
for i in range(1, 9) # HF_TOKEN1 .. HF_TOKEN8
)
if tok # skip empty / unset variables
]
# Round-robin cursor -- shared across all coroutines in this process.
# Protected by _hf_token_lock to be safe under async concurrency.
_hf_image_token_index: int = 0
_hf_token_lock = asyncio.Lock()
if _HF_IMAGE_TOKENS:
logger.info(
"[HF-Image] Token pool ready: %d token(s) loaded "
"(HF_TOKEN1-HF_TOKEN8). HF_TOKEN (no suffix) excluded.",
len(_HF_IMAGE_TOKENS),
)
else:
logger.warning(
"[HF-Image] No tokens found (HF_TOKEN1-HF_TOKEN8 not set). "
"Image generation will return False for every chapter."
)
async def _acquire_hf_image_token() -> Optional[str]:
"""
Return the next token from the round-robin pool and advance the
cursor. Thread-safe via _hf_token_lock.
Returns None if the pool is empty.
"""
if not _HF_IMAGE_TOKENS:
return None
global _hf_image_token_index
async with _hf_token_lock:
token = _HF_IMAGE_TOKENS[_hf_image_token_index % len(_HF_IMAGE_TOKENS)]
_hf_image_token_index += 1
return token
# ------------------------------------------------------------------
# Utility helpers (unchanged)
# ------------------------------------------------------------------
def clean_json(raw: str) -> str:
"""Strip markdown code fences and trim to first {...} or [...]."""
raw = re.sub(r"^```(?:json)?\s*\n?", "", raw, flags=re.MULTILINE)
raw = re.sub(r"\n?\s*```\s*$", "", raw, flags=re.MULTILINE)
raw = raw.strip()
starts = [i for i in [raw.find("{"), raw.find("[")] if i >= 0]
if starts:
raw = raw[min(starts):]
end = max(raw.rfind("}"), raw.rfind("]"))
if end >= 0:
raw = raw[: end + 1]
return raw.strip()
_CONTAMINATION_PATTERNS: re.Pattern = re.compile(
r"https?://\S+"
r"|www\.\S+"
r"|\{[^}]{0,500}\}"
r"|\b[45]\d{2}\s+(?:Internal|Not\s+Found|Bad\s+Request|"
r"Forbidden|Unauthorized|Service\s+Unavailable|Error)\b"
r"|(?:^|\n)\s*(?:Error|Exception|Traceback)\s*[:(]"
r'|"(?:error|detail|message|status)"\s*:\s*"',
re.IGNORECASE | re.MULTILINE,
)
def text_is_contaminated(text: str) -> bool:
"""Return True if the text contains technical artifacts."""
return bool(_CONTAMINATION_PATTERNS.search(text))
def _truncate_to_keywords(prompt: str, max_kw: int = HF_IMAGE_MAX_KEYWORDS) -> str:
"""
Reduce an image prompt to at most `max_kw` comma-separated keywords.
Strategy:
1. Commas present -> split on commas, take first max_kw parts.
2. No commas -> split on whitespace, take first max_kw words.
Special characters that could break parsers are stripped from each
token before reassembly. Returns a clean ASCII-safe string.
"""
if "," in prompt:
parts = [p.strip() for p in prompt.split(",") if p.strip()]
else:
parts = prompt.split()
selected = parts[:max_kw]
cleaned = [re.sub(r"[^\w\s-]", "", p).strip() for p in selected]
return ", ".join(w for w in cleaned if w)
# ------------------------------------------------------------------
# Groq -- JSON mode (unchanged)
# ------------------------------------------------------------------
async def call_groq_json(
api_key: str,
prompt: str,
caller: str,
max_tokens: int = 4000,
temperature: float = 0.4,
model: str = IMMERSION_AUTHOR_MODEL, # override per-caller if needed
) -> Dict[str, Any]:
"""Call Groq in JSON-mode. Raises on failure; caller handles retries."""
client = AsyncGroq(api_key=api_key)
response = await client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
temperature=temperature,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": "You are a helpful assistant. Output only valid JSON."},
{"role": "user", "content": prompt},
],
)
raw = clean_json(response.choices[0].message.content.strip())
result = json.loads(raw)
# GPT OSS 120B sometimes wraps the JSON in a list β€” unwrap it
if isinstance(result, list) and len(result) == 1 and isinstance(result[0], dict):
result = result[0]
return result
# ------------------------------------------------------------------
# Gemini -- JSON mode (unchanged)
# ------------------------------------------------------------------
async def call_gemini_json(
api_key: str,
prompt: str,
caller: str = "",
max_tokens: int = 4000,
temperature: float = 0.25,
) -> Dict[str, Any]:
"""Call Gemini expecting JSON output. Uses the google.genai client."""
client = genai.Client(api_key=api_key)
try:
from google.genai import types as _gt
cfg = _gt.GenerateContentConfig(
temperature=temperature,
top_p=0.95,
max_output_tokens=max_tokens,
)
response = await asyncio.to_thread(
client.models.generate_content,
model=GEMMA_MODEL_ID,
contents=prompt,
config=cfg,
)
except Exception:
response = await asyncio.to_thread(
client.models.generate_content,
model=GEMMA_MODEL_ID,
contents=prompt,
)
raw = clean_json(response.text.strip())
return json.loads(raw)
# ------------------------------------------------------------------
# Groq -- Whisper transcription (unchanged)
# ------------------------------------------------------------------
async def dispatch_transcription(
api_key: str,
audio_bytes: bytes,
filename: str,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe audio using Groq Whisper-large-v3-turbo."""
import io
client = AsyncGroq(api_key=api_key)
audio_stream = io.BytesIO(audio_bytes)
audio_stream.name = filename
kwargs: Dict[str, Any] = {
"file": (filename, audio_stream),
"model": "whisper-large-v3-turbo",
"language": language,
"response_format": "verbose_json",
}
if prompt:
kwargs["prompt"] = prompt[-800:]
tx = await client.audio.transcriptions.create(**kwargs)
if hasattr(tx, "model_dump"):
result = tx.model_dump()
elif hasattr(tx, "to_dict"):
result = tx.to_dict()
else:
result = {
"text": getattr(tx, "text", ""),
"segments": getattr(tx, "segments", []),
"language": getattr(tx, "language", "unknown"),
}
for idx, seg in enumerate(result.get("segments", [])):
if "id" not in seg:
seg["id"] = str(idx)
return result
# ------------------------------------------------------------------
# HF AudioLDM2 ambient generation (unchanged)
# ------------------------------------------------------------------
async def generate_audio_ldm2(
hf_token: str,
audio_prompt: str,
out_path: str,
) -> bool:
"""Generate a 10-second ambient clip via HF AudioLDM 2."""
headers = {
"Authorization": f"Bearer {hf_token}",
"Content-Type": "application/json",
}
payload = {
"inputs": audio_prompt,
"parameters": {
"audio_length_in_s": HF_AUDIO_DURATION,
"guidance_scale": HF_AUDIO_GUIDANCE,
"num_waveforms_per_prompt": 1,
},
}
for attempt in range(1, HF_AUDIO_RETRIES + 1):
try:
logger.info("[AudioLDM2] attempt %d/%d", attempt, HF_AUDIO_RETRIES)
async with httpx.AsyncClient(timeout=HF_AUDIO_TIMEOUT) as client:
resp = await client.post(HF_AUDIOLDM2_URL, headers=headers, json=payload)
if resp.status_code == 503:
try:
wait = min(float(resp.json().get("estimated_time", 30.0)), 60.0)
except Exception:
wait = 30.0
await asyncio.sleep(wait)
continue
if resp.status_code == 429:
await asyncio.sleep(65.0 * attempt)
continue
if resp.status_code != 200:
logger.error("[AudioLDM2] HTTP %d", resp.status_code)
if attempt < HF_AUDIO_RETRIES:
await asyncio.sleep(10.0 * attempt)
continue
audio_bytes = resp.content
if len(audio_bytes) < 512:
await asyncio.sleep(10.0 * attempt)
continue
with open(out_path, "wb") as fh:
fh.write(audio_bytes)
logger.info(
"[AudioLDM2] Saved -> %s (%.0f KB)", out_path, len(audio_bytes) / 1024
)
return True
except httpx.TimeoutException:
await asyncio.sleep(10.0 * attempt)
except Exception as exc:
logger.error("[AudioLDM2] Error attempt=%d: %s", attempt, exc)
await asyncio.sleep(10.0 * attempt)
logger.error("[AudioLDM2] Failed after %d attempts", HF_AUDIO_RETRIES)
return False
# ------------------------------------------------------------------
# Gemini -- subtitle translation batch (unchanged)
# ------------------------------------------------------------------
async def call_gemini_translation_batch(
api_key: str,
prompt: str,
) -> Dict[str, str]:
"""Translate subtitle segments via Gemini. Returns id -> text dict."""
client = genai.Client(api_key=api_key)
try:
from google.genai import types as _gt
cfg = _gt.GenerateContentConfig(
temperature=TRANSLATION_TEMPERATURE,
top_p=0.95,
max_output_tokens=8192,
)
response = await asyncio.to_thread(
client.models.generate_content,
model=GEMMA_MODEL_ID,
contents=prompt,
config=cfg,
)
except Exception:
response = await asyncio.to_thread(
client.models.generate_content,
model=GEMMA_MODEL_ID,
contents=prompt,
)
parsed: Dict[str, str] = {}
for line in response.text.strip().splitlines():
m = re.match(r"^\[([^\]]+)\]\s*(.*)", line.strip())
if m:
parsed[m.group(1)] = m.group(2).strip()
return parsed
async def call_gemini_chunk_translation(
api_key: str,
prefix: str,
chunk: str,
) -> str:
"""Translate a single text chunk via Gemini."""
client = genai.Client(api_key=api_key)
try:
from google.genai import types as _gt
cfg = _gt.GenerateContentConfig(
temperature=0.15,
top_p=0.95,
max_output_tokens=3000,
)
response = await asyncio.to_thread(
client.models.generate_content,
model=GEMMA_MODEL_ID,
contents=prefix + chunk,
config=cfg,
)
except Exception:
response = await asyncio.to_thread(
client.models.generate_content,
model=GEMMA_MODEL_ID,
contents=prefix + chunk,
)
return response.text.strip()
# ------------------------------------------------------------------
# Groq -- Chat completion (Narrator / Novelist) (unchanged)
# ------------------------------------------------------------------
async def call_groq_chat_completion(
api_key: str,
system_prompt: str,
user_prompt: str,
model: str,
max_tokens: int,
temperature: float,
) -> str:
"""
Groq chat completion for The Narrator.
V18.2: Robust content extraction β€”
- No frequency_penalty / presence_penalty (not supported by GPT OSS 120B)
- Handles None content via reasoning_content fallback
- Full response logged on empty to diagnose root cause
"""
client = AsyncGroq(api_key=api_key)
response = await client.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
model=model,
temperature=temperature,
max_tokens=max_tokens,
)
if not response.choices:
logger.error(
"[Narrator] No choices in response β€” model=%s | response=%s",
model, str(response)[:500]
)
return ""
choice = response.choices[0]
msg = choice.message
# ONLY use msg.content β€” never reasoning/reasoning_content.
# reasoning field contains the model's thinking process, not the narration.
# If content is empty β†’ max_tokens too low β†’ retry with more tokens.
raw = msg.content if msg.content else None
if not raw:
logger.error(
"[Narrator] EMPTY CONTENT β€” model=%s | finish_reason=%s | "
"msg_fields=%s | full_response=%s",
model,
getattr(choice, "finish_reason", "?"),
list((msg.model_dump() or {}).keys()) if hasattr(msg, "model_dump") else "?",
str(response)[:1000],
)
return ""
return raw.strip()
# ══════════════════════════════════════════════════════════════════
#
# V16.6 -- HF INFERENCE IMAGE GENERATION (new)
#
# Token pool: HF_TOKEN1 ... HF_TOKEN8 (HF_TOKEN excluded)
# Model: stabilityai/stable-diffusion-xl-base-1.0
# Rotation: round-robin; 429 -> skip token, try next
# 503 retry: up to 3x with 5 s delay per token (model warm-up)
# Bouncer: reject + delete files < 30 KB
#
# ══════════════════════════════════════════════════════════════════
def _bouncer(out_path: str, token_label: str) -> bool:
"""
Post-write file-size gate (The Bouncer).
Reads os.path.getsize() on the already-written file.
Rejects and deletes anything < MIN_REAL_IMAGE_BYTES (30 KB).
Real SDXL outputs are 200 KB - 1 MB.
HF error JSON bodies and placeholder images are always < 30 KB.
Returns True if the file is large enough to be a genuine image.
Returns False (and deletes the file) otherwise.
`token_label` is used only in log messages.
"""
try:
size = os.path.getsize(out_path)
except OSError as exc:
logger.error("[Bouncer/%s] Cannot stat '%s': %s", token_label, out_path, exc)
return False
if size < MIN_REAL_IMAGE_BYTES:
logger.error(
"[Bouncer/%s] File too small (%d B < %d B minimum) -- "
"placeholder or error body. Deleting.",
token_label, size, MIN_REAL_IMAGE_BYTES,
)
try:
os.remove(out_path)
except OSError:
pass
return False
logger.info("[Bouncer/%s] Size OK -- %.1f KB", token_label, size / 1024)
return True
async def _hf_infer_image_with_token(
token: str,
token_label: str,
prompt: str,
out_path: str,
) -> bool:
"""
Attempt one image generation call using a single HF Bearer token.
Tries TWO endpoints in order:
1. HF_IMAGE_MODEL_URL β€” free serverless (api-inference.huggingface.co)
2. HF_IMAGE_MODEL_URL_PAID β€” paid inference provider (router.huggingface.co)
Used only if primary returns 402/404/503-exhausted.
402 on primary β†’ silently try paid URL (credits on paid, not free)
402 on paid β†’ token genuinely exhausted, return False
503 anywhere β†’ cold-start, retry same URL up to HF_IMAGE_503_RETRIES
429 anywhere β†’ rate-limited, return False (caller rotates token)
200 + bouncer β†’ True
"""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "image/jpeg",
}
payload = {"inputs": prompt}
# Try free endpoint first, then paid fallback
endpoints = [
(HF_IMAGE_MODEL_URL, "free"),
(HF_IMAGE_MODEL_URL_PAID, "paid"),
]
for endpoint_url, endpoint_label in endpoints:
logger.info(
"[HF-Image/%s] Trying %s endpoint | prompt_len=%d",
token_label, endpoint_label, len(prompt),
)
for cold_attempt in range(1, HF_IMAGE_503_RETRIES + 1):
try:
logger.info(
"[HF-Image/%s] POST | endpoint=%s | cold_attempt=%d/%d | prompt_len=%d",
token_label, endpoint_label, cold_attempt, HF_IMAGE_503_RETRIES, len(prompt),
)
async with httpx.AsyncClient(timeout=HF_IMAGE_TIMEOUT) as client:
resp = await client.post(
endpoint_url, headers=headers, json=payload
)
# -- 503: model loading; retry same URL after a short wait --
if resp.status_code == 503:
try:
wait = min(
float(resp.json().get("estimated_time", HF_IMAGE_503_WAIT)),
60.0,
)
except Exception:
wait = HF_IMAGE_503_WAIT
if cold_attempt < HF_IMAGE_503_RETRIES:
logger.info(
"[HF-Image/%s] 503 model loading (%s) -- waiting %.0f s "
"(retry %d/%d)",
token_label, endpoint_label, wait, cold_attempt, HF_IMAGE_503_RETRIES,
)
await asyncio.sleep(wait)
continue # retry same endpoint
else:
logger.warning(
"[HF-Image/%s] 503 persisted on %s -- trying next endpoint",
token_label, endpoint_label,
)
break # try next endpoint
# -- 402: credits exhausted on this endpoint -- try next ----
if resp.status_code == 402:
logger.warning(
"[HF-Image/%s] 402 credits exhausted on %s -- trying next endpoint",
token_label, endpoint_label,
)
break # try next endpoint
# -- 429: rate-limited; signal caller to rotate token -------
if resp.status_code == 429:
logger.warning(
"[HF-Image/%s] 429 rate-limited on %s -- rotating to next token",
token_label, endpoint_label,
)
return False
# -- Any other non-200 error --------------------------------
if resp.status_code != 200:
logger.error(
"[HF-Image/%s] HTTP %d on %s -- %.200s",
token_label, resp.status_code, endpoint_label, resp.text,
)
break # try next endpoint
# -- 200 success: write file and validate ---------------
image_bytes = resp.content
if not image_bytes:
logger.error(
"[HF-Image/%s] HTTP 200 but empty body on %s",
token_label, endpoint_label,
)
break # try next endpoint
with open(out_path, "wb") as fh:
fh.write(image_bytes)
if not _bouncer(out_path, token_label):
break # try next endpoint
logger.info(
"[HF-Image/%s] Image saved via %s -> %s (%.1f KB)",
token_label, endpoint_label, out_path, len(image_bytes) / 1024,
)
return True
except httpx.TimeoutException:
logger.error(
"[HF-Image/%s] Timed out on %s after %.0f s",
token_label, endpoint_label, HF_IMAGE_TIMEOUT,
)
break # try next endpoint
except Exception as exc:
logger.error(
"[HF-Image/%s] Unexpected exception on %s: %s",
token_label, endpoint_label, exc,
)
break # try next endpoint
# All endpoints exhausted for this token
logger.error("[HF-Image/%s] All endpoints exhausted", token_label)
return False
# ══════════════════════════════════════════════════════════════════
#
# V18.5 -- HYBRID IMAGE SYSTEM
#
# Priority order:
# 1. Wikimedia Commons β€” real historical images, public domain, no key
# 2. Pollinations.ai β€” free AI generation, no key, no limit
# 3. HF Inference β€” paid fallback (existing system)
#
# ══════════════════════════════════════════════════════════════════
WIKIMEDIA_API: str = "https://en.wikipedia.org/w/api.php"
WIKIMEDIA_TIMEOUT: float = 15.0
MIN_WIKIMEDIA_BYTES: int = 20 * 1024 # 20 KB minimum
POLLINATIONS_URL: str = "https://image.pollinations.ai/prompt/{prompt}"
POLLINATIONS_TIMEOUT: float = 60.0
async def _search_wikimedia_image(search_query: str, out_path: str) -> bool:
"""
Search Wikimedia Commons for a relevant historical image.
Uses the Wikipedia opensearch + imageinfo APIs.
Returns True if a valid image >= 20 KB was saved.
"""
try:
# Step 1: Find the most relevant Wikipedia article
async with httpx.AsyncClient(timeout=WIKIMEDIA_TIMEOUT) as client:
resp = await client.get(
WIKIMEDIA_API,
params={
"action": "query",
"list": "search",
"srsearch": search_query,
"srlimit": 1,
"format": "json",
},
headers={"User-Agent": "TitanImmersion/1.0 (educational)"},
)
if resp.status_code != 200:
logger.warning("[Wikimedia] Search HTTP %d", resp.status_code)
return False
results = resp.json().get("query", {}).get("search", [])
if not results:
logger.warning("[Wikimedia] No results for: %s", search_query[:80])
return False
page_title = results[0]["title"]
logger.info("[Wikimedia] Found article: %s", page_title)
# Step 2: Get images from that article
async with httpx.AsyncClient(timeout=WIKIMEDIA_TIMEOUT) as client:
resp = await client.get(
WIKIMEDIA_API,
params={
"action": "query",
"titles": page_title,
"prop": "images",
"imlimit": 10,
"format": "json",
},
headers={"User-Agent": "TitanImmersion/1.0 (educational)"},
)
pages = resp.json().get("query", {}).get("pages", {})
images = []
for page in pages.values():
for img in page.get("images", []):
title = img.get("title", "")
# Skip icons, logos, flags, small decorative images
if any(skip in title.lower() for skip in [
"flag", "icon", "logo", "map", "seal", "coat",
"stub", "commons", "wikimedia", ".svg"
]):
continue
if title.lower().endswith((".jpg", ".jpeg", ".png")):
images.append(title)
if not images:
logger.warning("[Wikimedia] No usable images for: %s", page_title)
return False
# Step 3: Get the actual URL for the first good image
async with httpx.AsyncClient(timeout=WIKIMEDIA_TIMEOUT) as client:
resp = await client.get(
WIKIMEDIA_API,
params={
"action": "query",
"titles": images[0],
"prop": "imageinfo",
"iiprop": "url|size",
"iiurlwidth": 800,
"format": "json",
},
headers={"User-Agent": "TitanImmersion/1.0 (educational)"},
)
pages = resp.json().get("query", {}).get("pages", {})
img_url = None
for page in pages.values():
info = page.get("imageinfo", [])
if info:
img_url = info[0].get("thumburl") or info[0].get("url")
break
if not img_url:
logger.warning("[Wikimedia] No URL for image: %s", images[0])
return False
# Step 4: Download the image
async with httpx.AsyncClient(timeout=WIKIMEDIA_TIMEOUT) as client:
resp = await client.get(
img_url,
headers={"User-Agent": "TitanImmersion/1.0 (educational)"},
follow_redirects=True,
)
if resp.status_code != 200:
logger.warning("[Wikimedia] Download HTTP %d", resp.status_code)
return False
img_bytes = resp.content
if len(img_bytes) < MIN_WIKIMEDIA_BYTES:
logger.warning("[Wikimedia] Image too small: %d B", len(img_bytes))
return False
with open(out_path, "wb") as fh:
fh.write(img_bytes)
logger.info(
"[Wikimedia] βœ… Saved %s (%.1f KB) from: %s",
out_path, len(img_bytes) / 1024, page_title,
)
return True
except Exception as exc:
logger.warning("[Wikimedia] Exception: %s", exc)
return False
async def _generate_via_pollinations(prompt: str, out_path: str) -> bool:
"""
Generate image via Pollinations.ai β€” free, no API key, no rate limit.
Returns True if a valid image >= 20 KB was saved.
"""
try:
safe = urllib.parse.quote(prompt[:500])
url = f"https://image.pollinations.ai/prompt/{safe}?width=800&height=600&nologo=true"
logger.info("[Pollinations] Requesting image | prompt_len=%d", len(prompt))
async with httpx.AsyncClient(timeout=POLLINATIONS_TIMEOUT) as client:
resp = await client.get(url, follow_redirects=True)
if resp.status_code != 200:
logger.warning("[Pollinations] HTTP %d", resp.status_code)
return False
img_bytes = resp.content
if len(img_bytes) < MIN_WIKIMEDIA_BYTES:
logger.warning("[Pollinations] Image too small: %d B", len(img_bytes))
return False
with open(out_path, "wb") as fh:
fh.write(img_bytes)
logger.info(
"[Pollinations] βœ… Saved %s (%.1f KB)",
out_path, len(img_bytes) / 1024,
)
return True
except Exception as exc:
logger.warning("[Pollinations] Exception: %s", exc)
return False
async def generate_truly_free_image(
prompt: str,
out_path: str,
search_query: str = "",
) -> bool:
"""
V18.5 -- Hybrid Image System.
Priority order:
1. Wikimedia Commons β€” real historical images, public domain, no key
Uses search_query (historical_anchor event + year) if provided.
2. Pollinations.ai β€” free AI generation, no key, no rate limit
Uses the cinematic_image_prompt.
3. HF Inference β€” paid fallback (existing token pool system)
Returns True when any provider saves a valid image.
Returns False (non-fatal) β€” pipeline sets image_url="" and continues.
"""
# ── Stage 1: Wikimedia Commons ──────────────────────────────────
if search_query.strip():
logger.info("[Image/Hybrid] Stage 1: Wikimedia | query='%.80s'", search_query)
ok = await _search_wikimedia_image(search_query, out_path)
if ok:
logger.info("[Image/Hybrid] βœ… Wikimedia success")
return True
logger.info("[Image/Hybrid] Wikimedia failed β€” trying Pollinations")
else:
logger.info("[Image/Hybrid] No search_query β€” skipping Wikimedia")
# ── Stage 2: Pollinations.ai ─────────────────────────────────────
logger.info("[Image/Hybrid] Stage 2: Pollinations | prompt_len=%d", len(prompt))
ok = await _generate_via_pollinations(prompt, out_path)
if ok:
logger.info("[Image/Hybrid] βœ… Pollinations success")
return True
logger.info("[Image/Hybrid] Pollinations failed β€” trying HF tokens")
# ── Stage 3: HF Inference (existing paid system) ─────────────────
if not _HF_IMAGE_TOKENS:
logger.error(
"[Image/Hybrid] All stages failed β€” no HF tokens available. "
"Setting image_url='' for this chapter."
)
return False
# Truncate once; every token attempt uses this same string
safe_prompt = _truncate_to_keywords(prompt, HF_IMAGE_MAX_KEYWORDS)
n_tokens = len(_HF_IMAGE_TOKENS)
logger.info(
"[HF-Image] Starting | pool=%d token(s) | prompt='%.80s'",
n_tokens, safe_prompt,
)
for attempt in range(n_tokens):
token = await _acquire_hf_image_token()
if token is None:
break # pool emptied concurrently -- should not happen
token_label = f"TOKEN...{token[-4:]}" # last 4 chars for safe logging
ok = await _hf_infer_image_with_token(
token=token,
token_label=token_label,
prompt=safe_prompt,
out_path=out_path,
)
if ok:
logger.info(
"[HF-Image] Success with %s (attempt %d/%d)",
token_label, attempt + 1, n_tokens,
)
return True
remaining = n_tokens - attempt - 1
logger.warning(
"[HF-Image] %s failed (attempt %d/%d) -- %s",
token_label,
attempt + 1,
n_tokens,
f"{remaining} token(s) remaining" if remaining else "all tokens exhausted",
)
logger.error(
"[HF-Image] All %d token(s) exhausted. "
"Setting image_url='' for this chapter.",
n_tokens,
)
return False