audio-brief / models.py
kalamishere's picture
Initial deploy
3cc5b15
Raw
History Blame Contribute Delete
6.97 kB
"""Pollinations model catalog — fetched once per session, filtered by modality.
The hardcoded model list approach was brittle: aliases came and went, and
several entries were quietly stale (`claude-haiku-4.5`, `gemini-2.5-pro`).
This module talks to `/v1/models` at startup, caches the result for the
session, and exposes two filtered views for the UI dropdowns.
Defaults to a small curated fallback list if Pollinations is unreachable so
the app still boots offline.
"""
from __future__ import annotations
import os
from typing import Any
import requests
MODELS_URL = "https://gen.pollinations.ai/v1/models"
# Curated fallback — small, safe set. Used only if the catalog fetch fails.
# Same IDs as the previous hardcoded list, minus stale entries codex flagged.
_FALLBACK_TEXT = [
"claude-fast", "claude", "claude-opus-4.7", "claude-large",
"openai-fast", "openai", "openai-large",
"gpt-5.4-mini", "gpt-5.4",
"deepseek", "deepseek-pro",
"grok", "grok-large",
"qwen-large", "qwen-coder",
"gemma", "step-flash", "step-3.5-flash",
]
_FALLBACK_AUDIO = ["openai-audio", "openai-audio-large", "gemini", "gemini-3-flash"]
# Models that are audio-input but only do transcription (whisper, scribe,
# universal-*) — useless for our brief-style narrative output. Exclude them
# from the C dropdown so the user doesn't pick a transcription-only model and
# get a flat dump of lyrics back instead of a structured brief.
_TRANSCRIPTION_ONLY = {"whisper", "scribe", "universal-2", "universal-3-pro"}
# Models marked as gemini-style audio-input but designed for live realtime
# session APIs, not single-shot chat completion. Skip in our use case.
_REALTIME_ONLY = {"gpt-realtime-2"}
# Audio-input gemini models support tool/code execution and routinely
# burn token budget on tool round-trips. We still let the user pick them
# (codex P5: tag as experimental) but mark them visibly. gemini-search-*
# pair audio with Google Search grounding — also experimental in our use.
_TOOLS_RISKY_AUDIO = {"gemini", "gemini-3-flash", "gemini-flash-lite-3.1",
"gemini-large", "gemini-search-fast", "gemini-search-large"}
# Preferred order for the audio dropdown — openai-audio family first because
# they're pure listen-and-answer with no tool loop. Then experimentals.
_AUDIO_PREFERRED = ["openai-audio", "openai-audio-large"]
# ---------------------------------------------------------------------------
# Catalog fetch (cached)
# ---------------------------------------------------------------------------
_cache: list[dict[str, Any]] | None = None
def _auth_header() -> dict[str, str]:
for env in ("POLLINATIONS_API_KEY", "POLLINATIONS_TOKEN"):
v = (os.environ.get(env) or "").strip()
if v:
return {"Authorization": f"Bearer {v}"}
try:
from wallet import stored_key
k = (stored_key() or "").strip()
if k:
return {"Authorization": f"Bearer {k}"}
except Exception:
pass
return {}
def fetch_catalog(force: bool = False, timeout: float = 4.0) -> list[dict[str, Any]]:
"""Return the cached /v1/models payload (data array). Fetches once per
session unless force=True. Returns [] on failure — callers must handle."""
global _cache
if _cache is not None and not force:
return _cache
try:
r = requests.get(MODELS_URL, headers=_auth_header(), timeout=timeout)
r.raise_for_status()
_cache = (r.json() or {}).get("data") or []
except Exception:
_cache = []
return _cache
def text_models() -> list[str]:
"""Models accepting text input and producing text output, suitable for
the measured-brief A/B columns. Excludes audio-input variants (those live
in audio_models()) and transcription-only models."""
catalog = fetch_catalog()
if not catalog:
return list(_FALLBACK_TEXT)
ids: list[str] = []
for m in catalog:
mid = m.get("id") or ""
if not mid:
continue
inp = m.get("input_modalities") or []
out = m.get("output_modalities") or []
# text->text models, no audio input. Vision-capable models with
# image input are fine (they just won't be sent images here).
if "text" in out and "audio" not in inp:
# Skip search/embedding/coder oddities by endpoint check
endpoints = m.get("supported_endpoints") or []
if "/v1/chat/completions" not in endpoints:
continue
ids.append(mid)
# Sort with curated favourites first (claude / openai / gemini-fast / etc.)
return _sort_with_favourites(ids, _FALLBACK_TEXT)
def audio_models() -> list[tuple[str, bool]]:
"""Models that accept audio INPUT and produce text — for the audio-only
C column. Returns list of (id, is_experimental) tuples; experimental
models are gemini ones that use tool/code-execution and may eat the
token budget without producing a prose answer."""
catalog = fetch_catalog()
if not catalog:
return [(m, False) for m in _FALLBACK_AUDIO[:2]] + \
[(m, True) for m in _FALLBACK_AUDIO[2:]]
pairs: list[tuple[str, bool]] = []
for m in catalog:
mid = m.get("id") or ""
if not mid or mid in _TRANSCRIPTION_ONLY or mid in _REALTIME_ONLY:
continue
inp = m.get("input_modalities") or []
out = m.get("output_modalities") or []
if "audio" not in inp or "text" not in out:
continue
endpoints = m.get("supported_endpoints") or []
if "/v1/chat/completions" not in endpoints:
continue
is_experimental = mid in _TOOLS_RISKY_AUDIO
pairs.append((mid, is_experimental))
# Stable ordering — non-experimental first, with curated favourites at
# the very top within their group.
def _sort_key(p: tuple[str, bool]) -> tuple[int, int, str]:
mid, exp = p
pref_idx = _AUDIO_PREFERRED.index(mid) if mid in _AUDIO_PREFERRED else 999
return (int(exp), pref_idx, mid)
pairs.sort(key=_sort_key)
return pairs
def audio_model_choices() -> list[tuple[str, str]]:
"""UI-friendly form: (display_label, value) pairs.
No more `· experimental` suffix on gemini — the salvage-from-content-blocks
path + tool_choice:none fallback in narrative.py mean gemini's code-exec
behaviour no longer silently swallows the prose answer. Treating all
audio-input models as first-class makes the Compare default cleaner.
"""
return [(mid, mid) for mid, _exp in audio_models()]
def _sort_with_favourites(ids: list[str], favourites: list[str]) -> list[str]:
"""Stable sort: keep `favourites` order at the front, everything else
alphabetical after. Misses in favourites are silently skipped."""
seen = set(ids)
head = [m for m in favourites if m in seen]
tail = sorted(m for m in ids if m not in head)
return head + tail