feat(models): define capability schema and readers (#2739)

* feat(models): define capability schema and readers

* fix(models): harden Google catalog probing

Restrict native catalog probing to the Gemini host, keep provider keys out of request URLs, filter non-chat model resources, and preserve the manual refresh default in the built-in Google add flow.
This commit is contained in:
RaresKeY
2026-07-18 10:40:58 +02:00
committed by GitHub
parent f87107e16f
commit 4f04c347cc
17 changed files with 3861 additions and 4 deletions
+115 -3
View File
@@ -472,7 +472,11 @@ def _endpoint_kind(ep: Any) -> str:
def _endpoint_refresh_mode(ep: Any, endpoint_kind: str | None = None) -> str:
return _normalize_refresh_mode(getattr(ep, "model_refresh_mode", None), endpoint_kind or _endpoint_kind(ep))
return _normalize_endpoint_refresh_mode(
getattr(ep, "model_refresh_mode", None),
endpoint_kind or _endpoint_kind(ep),
getattr(ep, "base_url", ""),
)
def _endpoint_refresh_interval(ep: Any, category: str) -> float:
@@ -851,6 +855,99 @@ def _ollama_model_names(data: Any) -> List[str]:
return out
def _is_google_api_base(base_url: str) -> bool:
try:
return (urlparse(base_url).hostname or "").lower() == "generativelanguage.googleapis.com"
except Exception:
return False
def _normalize_endpoint_refresh_mode(value: Any, endpoint_kind: str = "auto", base_url: str = "") -> str:
if not str(value or "").strip() and _is_google_api_base(base_url):
return "manual"
return _normalize_refresh_mode(value, endpoint_kind)
def _google_native_root(base_url: str) -> str:
"""Return the Gemini native API root for a Google endpoint.
Chat calls may be configured against Google's OpenAI-compatible
`/openai` path, but model catalog reads should use the native Models API
so we get Google's current Model resource shape.
"""
try:
parsed = urlparse(base_url)
except Exception:
return "https://generativelanguage.googleapis.com/v1beta"
path = (parsed.path or "").rstrip("/")
if path.endswith("/openai"):
path = path[: -len("/openai")].rstrip("/")
if not path:
path = "/v1beta"
return urlunparse(parsed._replace(path=path, query="", fragment="")).rstrip("/")
def _google_native_models_url(base_url: str) -> str:
return _google_native_root(base_url) + "/models"
def _google_model_id_from_item(item: Any) -> str:
if not isinstance(item, dict):
return ""
value = item.get("baseModelId") or item.get("name") or item.get("model") or ""
return str(value or "").strip().removeprefix("models/")
def _google_model_supports_chat(item: Any) -> bool:
"""Return whether a native Google Model resource supports chat generation."""
if not isinstance(item, dict):
return False
methods = item.get("supportedGenerationMethods")
if not isinstance(methods, list):
return False
chat_methods = {"generateContent", "generateMessage", "generateText", "generateAnswer"}
return any(method in chat_methods for method in methods)
def _probe_google_models(base_url: str, api_key: str = None, timeout: int = 5, page_size: int = 1000) -> List[str]:
"""Read Google's native paginated Models API.
This intentionally returns only provider-reported model IDs. Capability
mapping is handled by the model capability reader and must not infer from
names here.
"""
url = _google_native_models_url(base_url)
try:
page_size = min(max(int(page_size or 1000), 1), 1000)
except Exception:
page_size = 1000
headers = {"Accept": "application/json"}
if api_key:
headers["x-goog-api-key"] = api_key
params: Dict[str, Any] = {"pageSize": page_size}
models: List[str] = []
seen = set()
page_token = ""
for _ in range(20):
request_params = dict(params)
if page_token:
request_params["pageToken"] = page_token
r = httpx.get(url, headers=headers, params=request_params, timeout=timeout, verify=llm_verify())
r.raise_for_status()
data = r.json()
for item in data.get("models") or []:
if not _google_model_supports_chat(item):
continue
model_id = _google_model_id_from_item(item)
if model_id and model_id not in seen:
seen.add(model_id)
models.append(model_id)
page_token = str(data.get("nextPageToken") or "").strip()
if not page_token:
break
return models
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
"""Probe a base URL's /models endpoint and return list of model IDs.
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
@@ -863,6 +960,17 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
if api_key:
return fetch_available_models(api_key, timeout=timeout)
return []
if _is_google_api_base(base):
try:
models = _probe_google_models(base, api_key, timeout=timeout)
if models:
return models
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else "unknown"
logger.warning(f"Google native models probe failed: HTTP {status}")
except Exception as e:
logger.warning(f"Google native models probe failed: {e}")
return []
if provider == "anthropic":
# Try Anthropic's /v1/models endpoint first
url = _safe_build_models_url(base)
@@ -1854,7 +1962,7 @@ def setup_model_routes(model_discovery):
name = base_url.replace("http://", "").replace("https://", "").split("/")[0]
requested_kind = _normalize_endpoint_kind(endpoint_kind)
refresh_mode = _normalize_refresh_mode(model_refresh_mode, requested_kind)
refresh_mode = _normalize_endpoint_refresh_mode(model_refresh_mode, requested_kind, base_url)
refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400)
refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
require_model_list = _truthy(require_models)
@@ -2364,7 +2472,11 @@ def setup_model_routes(model_discovery):
if "endpoint_kind" in body:
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
if "model_refresh_mode" in body:
ep.model_refresh_mode = _normalize_refresh_mode(body.get("model_refresh_mode"), _endpoint_kind(ep))
ep.model_refresh_mode = _normalize_endpoint_refresh_mode(
body.get("model_refresh_mode"),
_endpoint_kind(ep),
ep.base_url,
)
if "model_refresh_interval" in body:
interval = _parse_positive_int(body.get("model_refresh_interval"), minimum=30, maximum=86400)
ep.model_refresh_interval = interval