mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-19 13:38:04 +00:00
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:
+115
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user