mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
748 lines
26 KiB
Python
748 lines
26 KiB
Python
# src/endpoint_resolver.py
|
|
"""Unified endpoint resolution for all backend services.
|
|
|
|
Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one place.
|
|
"""
|
|
|
|
import json
|
|
import ipaddress
|
|
import logging
|
|
import socket
|
|
import subprocess
|
|
from typing import Optional, Tuple, Dict
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
from core.database import SessionLocal, ModelEndpoint
|
|
from src.llm_core import _detect_provider, _host_match, _is_kimi_code_url, KIMI_CODE_USER_AGENT, _ollama_api_root
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Model-name substrings that are NOT chat/generation models. When an endpoint
|
|
# has no explicit model configured we pick the first CHAT model from its list —
|
|
# never an embedding/tts/etc. (an OpenAI-style endpoint often lists
|
|
# `text-embedding-ada-002` first, which silently broke email-summarize and
|
|
# other resolve_endpoint callers with "Cannot reach model").
|
|
_NON_CHAT_MODEL = (
|
|
"text-embedding", "embedding", "tts-", "whisper", "dall-e",
|
|
"moderation", "rerank", "reranker", "clip", "stable-diffusion",
|
|
)
|
|
|
|
|
|
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
|
|
"""Return whether token cost should be tracked for a concrete route.
|
|
|
|
This is intentionally a non-secret route classification. It mirrors the
|
|
frontend's local/subscription exclusions without exposing endpoint URLs to
|
|
message metadata.
|
|
"""
|
|
|
|
try:
|
|
parsed = urlparse(url or "")
|
|
host = (parsed.hostname or "").lower().rstrip(".")
|
|
path = (parsed.path or "").rstrip("/")
|
|
except Exception:
|
|
return False
|
|
if not host:
|
|
return False
|
|
if host == "chatgpt.com" and (
|
|
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
|
|
):
|
|
return False
|
|
kind = str(endpoint_kind or "auto").strip().lower()
|
|
if kind == "local":
|
|
return False
|
|
if kind in {"api", "proxy"}:
|
|
return True
|
|
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
|
|
return False
|
|
try:
|
|
ip = ipaddress.ip_address(host)
|
|
return ip.is_global
|
|
except ValueError:
|
|
pass
|
|
if "." not in host:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _first_chat_model(models) -> Optional[str]:
|
|
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
|
for m in (models or []):
|
|
if not any(p in str(m).lower() for p in _NON_CHAT_MODEL):
|
|
return m
|
|
return (models[0] if models else None)
|
|
|
|
|
|
def _endpoint_cached_models(ep) -> list:
|
|
"""Return cached model ids from the current or legacy endpoint field."""
|
|
raw = getattr(ep, "cached_models", None) or getattr(ep, "models", None)
|
|
if not raw:
|
|
return []
|
|
try:
|
|
models = json.loads(raw) if isinstance(raw, str) else raw
|
|
except Exception:
|
|
return []
|
|
return models if isinstance(models, list) else []
|
|
|
|
|
|
def _endpoint_pinned_models(ep) -> list:
|
|
raw = getattr(ep, "pinned_models", None)
|
|
if not raw:
|
|
return []
|
|
try:
|
|
models = json.loads(raw) if isinstance(raw, str) else raw
|
|
except Exception:
|
|
return []
|
|
return models if isinstance(models, list) else []
|
|
|
|
|
|
def _is_mlx_deepseek_v4_repo_id(model_id: str) -> bool:
|
|
return "mlx-community/deepseek-v4" in str(model_id or "").lower()
|
|
|
|
|
|
def _is_mlx_deepseek_v4_shim_id(model_id: str) -> bool:
|
|
return "/.cache/odysseus/mlx-shims/deepseek-v4" in str(model_id or "").lower()
|
|
|
|
|
|
def _filter_mlx_deepseek_v4_repo_when_shimmed(model_ids) -> list:
|
|
ids = list(model_ids or [])
|
|
has_shim = any(_is_mlx_deepseek_v4_shim_id(m) for m in ids)
|
|
if not has_shim:
|
|
return ids
|
|
return [m for m in ids if not _is_mlx_deepseek_v4_repo_id(m)]
|
|
|
|
|
|
def _endpoint_hidden_models(ep) -> set:
|
|
"""Model ids the admin disabled on this endpoint (the UI's hidden list)."""
|
|
raw = getattr(ep, "hidden_models", None)
|
|
if not raw:
|
|
return set()
|
|
try:
|
|
hidden = json.loads(raw) if isinstance(raw, str) else raw
|
|
except Exception:
|
|
return set()
|
|
return set(hidden) if isinstance(hidden, list) else set()
|
|
|
|
|
|
def _endpoint_enabled_models(ep) -> list:
|
|
"""Cached models minus the ones disabled on the endpoint, order preserved.
|
|
|
|
The auto-pick fallback must never select a model the user disabled — a
|
|
Groq endpoint can list 16 models with only 1 enabled, and picking the
|
|
raw first one resolves to a model that 400s ("requires terms acceptance").
|
|
"""
|
|
hidden = _endpoint_hidden_models(ep)
|
|
merged = []
|
|
seen = set()
|
|
for m in [*_endpoint_cached_models(ep), *_endpoint_pinned_models(ep)]:
|
|
if not isinstance(m, str) or not m or m in seen:
|
|
continue
|
|
seen.add(m)
|
|
merged.append(m)
|
|
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
|
|
return [m for m in merged if m not in hidden]
|
|
|
|
|
|
def resolve_endpoint_runtime(
|
|
ep,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> Tuple[str, Optional[str]]:
|
|
"""Resolve a ModelEndpoint row to its runtime base URL and bearer/API key.
|
|
|
|
Static-key providers use ``ModelEndpoint.api_key``. Session-backed providers
|
|
store refreshable credentials in ProviderAuthSession and must resolve a
|
|
current access token at call time.
|
|
"""
|
|
base = normalize_base(getattr(ep, "base_url", "") or "")
|
|
api_key = getattr(ep, "api_key", None)
|
|
auth_id = getattr(ep, "provider_auth_id", None)
|
|
if auth_id:
|
|
from src.chatgpt_subscription import resolve_runtime_credentials
|
|
|
|
credential_kwargs = {}
|
|
if not allow_live_probes:
|
|
credential_kwargs["allow_live_probes"] = False
|
|
creds = resolve_runtime_credentials(auth_id, owner=owner, **credential_kwargs)
|
|
base = normalize_base(creds.get("base_url") or base)
|
|
api_key = creds.get("api_key")
|
|
return base, api_key
|
|
|
|
|
|
# Cache for Tailscale hostname → IP resolution
|
|
_tailscale_cache: Dict[str, Optional[str]] = {}
|
|
|
|
|
|
def _resolve_tailscale_host(hostname: str) -> Optional[str]:
|
|
"""Try to resolve a hostname via 'tailscale status' if DNS fails."""
|
|
if hostname in _tailscale_cache:
|
|
return _tailscale_cache[hostname]
|
|
|
|
# First check if normal DNS works
|
|
try:
|
|
socket.getaddrinfo(hostname, None, socket.AF_INET)
|
|
_tailscale_cache[hostname] = None # DNS works, no override needed
|
|
return None
|
|
except socket.gaierror:
|
|
pass
|
|
|
|
# DNS failed — try tailscale
|
|
try:
|
|
result = subprocess.run(
|
|
["tailscale", "status", "--json"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
import json as _json
|
|
data = _json.loads(result.stdout)
|
|
peers = data.get("Peer", {})
|
|
for _id, peer in peers.items():
|
|
peer_name = (peer.get("HostName") or "").lower()
|
|
dns_name = (peer.get("DNSName") or "").split(".")[0].lower()
|
|
if peer_name == hostname.lower() or dns_name == hostname.lower():
|
|
addrs = peer.get("TailscaleIPs", [])
|
|
if addrs:
|
|
ip = addrs[0]
|
|
logger.info(f"Resolved '{hostname}' via Tailscale → {ip}")
|
|
_tailscale_cache[hostname] = ip
|
|
return ip
|
|
except Exception as e:
|
|
logger.debug(f"Tailscale resolution failed for '{hostname}': {e}")
|
|
|
|
_tailscale_cache[hostname] = None
|
|
return None
|
|
|
|
|
|
def resolve_url(url: str) -> str:
|
|
"""If a URL's hostname can't be resolved via DNS, try Tailscale."""
|
|
parsed = urlparse(url)
|
|
hostname = parsed.hostname
|
|
if not hostname:
|
|
return url
|
|
ip = _resolve_tailscale_host(hostname)
|
|
if ip:
|
|
# Replace hostname with IP in the URL
|
|
netloc = ip
|
|
if parsed.port:
|
|
netloc = f"{ip}:{parsed.port}"
|
|
return urlunparse(parsed._replace(netloc=netloc))
|
|
return url
|
|
|
|
|
|
def normalize_base(url: str) -> str:
|
|
"""Strip known API path suffixes from a base URL."""
|
|
url = (url or "").strip().rstrip("/")
|
|
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages", "/responses"]:
|
|
if url.endswith(suffix):
|
|
url = url[: -len(suffix)].rstrip("/")
|
|
for suffix in ["/chat", "/tags", "/generate"]:
|
|
if url.endswith("/api" + suffix):
|
|
url = url[: -len(suffix)].rstrip("/")
|
|
return url
|
|
|
|
|
|
def _validated_endpoint_base(url: str) -> str:
|
|
"""Return a base URL that is safe for endpoint path appends."""
|
|
base = (url or "").strip().rstrip("/")
|
|
if "?" in base or "#" in base:
|
|
raise ValueError("Endpoint base URL must not include query or fragment")
|
|
return urlunparse(urlparse(base)._replace(query="", fragment="")).rstrip("/")
|
|
|
|
|
|
def _prepare_endpoint_base(base: str) -> str:
|
|
base = _validated_endpoint_base(normalize_base(base))
|
|
return _validated_endpoint_base(normalize_base(resolve_url(base)))
|
|
|
|
|
|
def _append_endpoint_path(base: str, suffix: str) -> str:
|
|
parsed = urlparse(base)
|
|
current = (parsed.path or "").rstrip("/")
|
|
extra = "/" + suffix.lstrip("/")
|
|
path = f"{current}{extra}" if current else extra
|
|
return urlunparse(parsed._replace(path=path, query="", fragment=""))
|
|
|
|
|
|
def _pathless_host(base: str, host: str) -> bool:
|
|
parsed = urlparse(base)
|
|
return (parsed.hostname or "").lower() == host and not (parsed.path or "").strip("/")
|
|
|
|
|
|
def _anthropic_api_root(base: str) -> str:
|
|
"""Return Anthropic's API root, preserving /v1 for OpenAI-compatible APIs elsewhere."""
|
|
base = (base or "").strip().rstrip("/")
|
|
if _host_match(base, "anthropic.com") and base.endswith("/v1"):
|
|
return base[:-3].rstrip("/")
|
|
return base
|
|
|
|
|
|
def build_chat_url(base: str) -> str:
|
|
"""Return the correct chat endpoint URL for a given base."""
|
|
base = _prepare_endpoint_base(base)
|
|
provider = _detect_provider(base)
|
|
if provider == "anthropic":
|
|
return _append_endpoint_path(_anthropic_api_root(base), "/v1/messages")
|
|
if provider == "ollama":
|
|
return _append_endpoint_path(_ollama_api_root(base), "/chat")
|
|
if provider == "chatgpt-subscription":
|
|
return _append_endpoint_path(base, "/responses")
|
|
if _pathless_host(base, "api.openai.com"):
|
|
base = _append_endpoint_path(base, "/v1")
|
|
return _append_endpoint_path(base, "/chat/completions")
|
|
|
|
|
|
def build_models_url(base: str) -> Optional[str]:
|
|
"""Return the provider-specific model-list endpoint URL for a base.
|
|
|
|
For OpenAI-compatible servers (LM Studio, llama.cpp, vLLM,
|
|
text-generation-webui, etc.) the model list is exposed at ``/v1/models``.
|
|
When the user-supplied base has no path — e.g. ``http://localhost:1234`` —
|
|
we still need to land on ``/v1/models`` (issue #25); insert the ``/v1``
|
|
segment only when the path is empty, leaving any explicit non-empty path
|
|
untouched (so custom prefixes like ``/openai`` or ``/api/openai/v1`` keep
|
|
their semantics).
|
|
"""
|
|
base = _prepare_endpoint_base(base)
|
|
provider = _detect_provider(base)
|
|
if provider == "anthropic":
|
|
return _append_endpoint_path(_anthropic_api_root(base), "/v1/models")
|
|
if provider == "ollama":
|
|
return _append_endpoint_path(_ollama_api_root(base), "/tags")
|
|
if provider == "chatgpt-subscription":
|
|
return None
|
|
# Generic OpenAI-compatible fallback: local model servers with no explicit
|
|
# path conventionally expose `/v1/models` (LM Studio, llama.cpp, vLLM).
|
|
# For non-local unknown hosts, do not invent `/v1`; append `/models` to the
|
|
# caller's base so look-alike provider hosts stay generic.
|
|
parsed = urlparse(base)
|
|
host = (parsed.hostname or "").lower()
|
|
is_local = host in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
|
|
uses_v1_models_by_default = is_local or host in {"api.deepseek.com", "api.openai.com"}
|
|
if not parsed.path and uses_v1_models_by_default:
|
|
base = _append_endpoint_path(base, "/v1")
|
|
return _append_endpoint_path(base, "/models")
|
|
|
|
|
|
def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
|
"""Build auth headers for an endpoint."""
|
|
provider = _detect_provider(base)
|
|
headers: Dict[str, str] = {}
|
|
if provider == "anthropic":
|
|
if api_key:
|
|
headers["x-api-key"] = api_key
|
|
headers["anthropic-version"] = "2023-06-01"
|
|
return headers
|
|
if provider == "copilot":
|
|
from src.copilot import copilot_headers
|
|
return copilot_headers(api_key)
|
|
if provider == "chatgpt-subscription":
|
|
from src.chatgpt_subscription import chatgpt_headers
|
|
return chatgpt_headers(api_key)
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
if provider == "openrouter":
|
|
headers.setdefault("HTTP-Referer", "https://github.com/odysseus-dev/odysseus")
|
|
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
|
if _is_kimi_code_url(base):
|
|
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
|
return headers
|
|
|
|
|
|
def resolve_endpoint(
|
|
setting_prefix: str,
|
|
fallback_url: Optional[str] = None,
|
|
fallback_model: Optional[str] = None,
|
|
fallback_headers: Optional[Dict] = None,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> Tuple[Optional[str], Optional[str], Optional[Dict]]:
|
|
"""Resolve an endpoint/model from settings, with fallback.
|
|
|
|
Args:
|
|
setting_prefix: Settings key prefix, e.g. "research", "task", "utility", "default".
|
|
Reads ``{prefix}_endpoint_id`` and ``{prefix}_model`` from settings.
|
|
fallback_url: URL to use if settings are empty or endpoint missing.
|
|
fallback_model: Model to use if settings are empty.
|
|
fallback_headers: Headers to use if using fallback.
|
|
|
|
Returns:
|
|
(endpoint_url, model, headers) — resolved or fallback values.
|
|
"""
|
|
try:
|
|
from src.settings import get_user_setting, load_settings
|
|
settings = load_settings()
|
|
except Exception:
|
|
return fallback_url, fallback_model, fallback_headers
|
|
|
|
owner_str = owner or ""
|
|
def _stg(key: str) -> str:
|
|
return (get_user_setting(key, owner_str, settings.get(key, "")) or "").strip()
|
|
|
|
ep_id = _stg(f"{setting_prefix}_endpoint_id")
|
|
model = _stg(f"{setting_prefix}_model")
|
|
|
|
# Fall back to utility model for task/research/auto-naming if not specifically configured.
|
|
if not ep_id and setting_prefix not in ("utility", "default"):
|
|
ep_id = _stg("utility_endpoint_id")
|
|
model = _stg("utility_model")
|
|
|
|
# If the endpoint is STILL not configured, but the caller provided a
|
|
# valid fallback (e.g. the active session model), use that immediately.
|
|
# This prevents background tasks from jumping to the global default_model
|
|
# when the user is mid-conversation with a different model.
|
|
if not ep_id and fallback_url and fallback_model:
|
|
return fallback_url, fallback_model, fallback_headers
|
|
|
|
# Unset Utility (or anything else that didn't have a fallback) means "same as Default Chat Model".
|
|
if not ep_id:
|
|
ep_id = _stg("default_endpoint_id")
|
|
model = _stg("default_model")
|
|
|
|
if not ep_id:
|
|
return fallback_url, fallback_model, fallback_headers
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
ep = db.query(ModelEndpoint).filter(
|
|
ModelEndpoint.id == ep_id,
|
|
ModelEndpoint.is_enabled == True,
|
|
)
|
|
if owner:
|
|
from src.auth_helpers import owner_filter
|
|
ep = owner_filter(ep, ModelEndpoint, owner).first()
|
|
else:
|
|
ep = ep.first()
|
|
if not ep:
|
|
return fallback_url, fallback_model, fallback_headers
|
|
|
|
try:
|
|
runtime_kwargs = {}
|
|
if not allow_live_probes:
|
|
runtime_kwargs["allow_live_probes"] = False
|
|
base, api_key = resolve_endpoint_runtime(
|
|
ep,
|
|
owner=owner,
|
|
**runtime_kwargs,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Could not resolve endpoint runtime credentials: %s", e)
|
|
return fallback_url, fallback_model, fallback_headers
|
|
chat_url = build_chat_url(base)
|
|
headers = build_headers(api_key, base)
|
|
|
|
# Discard a configured model the user has since disabled on the
|
|
# endpoint (e.g. a stale `default_model` left pointing at a now-hidden
|
|
# model). Treat it as unset so the picker below selects a live one
|
|
# instead of dispatching to a disabled model that 400s.
|
|
if model and model in _endpoint_hidden_models(ep):
|
|
model = ""
|
|
# If no (usable) model specified, pick the first enabled chat model.
|
|
if not model:
|
|
model = _first_chat_model(_endpoint_enabled_models(ep)) or ""
|
|
if not model and not fallback_model:
|
|
logger.warning('[resolve_endpoint] no usable model (all models hidden or list empty)')
|
|
|
|
return chat_url, model or fallback_model, headers
|
|
except Exception as e:
|
|
logger.debug(f"Could not resolve {setting_prefix} endpoint: {e}")
|
|
return fallback_url, fallback_model, fallback_headers
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _resolve_endpoint_by_id_with_descriptor(
|
|
ep_id: str,
|
|
model: Optional[str] = None,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
require_exact_model: bool = False,
|
|
allow_live_probes: bool = True,
|
|
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
|
|
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
|
|
|
|
Returns None if the endpoint doesn't exist or is disabled. Used to turn
|
|
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
|
|
"""
|
|
if not ep_id:
|
|
return None
|
|
db = SessionLocal()
|
|
try:
|
|
q = db.query(ModelEndpoint).filter(
|
|
ModelEndpoint.id == ep_id,
|
|
ModelEndpoint.is_enabled == True,
|
|
)
|
|
if owner:
|
|
from src.auth_helpers import owner_filter
|
|
q = owner_filter(q, ModelEndpoint, owner)
|
|
ep = q.first()
|
|
if not ep:
|
|
return None
|
|
try:
|
|
runtime_kwargs = {}
|
|
if not allow_live_probes:
|
|
runtime_kwargs["allow_live_probes"] = False
|
|
base, api_key = resolve_endpoint_runtime(
|
|
ep,
|
|
owner=owner,
|
|
**runtime_kwargs,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Could not resolve endpoint runtime credentials: %s", e)
|
|
return None
|
|
chat_url = build_chat_url(base)
|
|
headers = build_headers(api_key, base)
|
|
m = (model or "").strip()
|
|
enabled_models = _endpoint_enabled_models(ep)
|
|
if require_exact_model:
|
|
# Explicit foreground fallback entries are concrete choices. A
|
|
# hidden or known-missing model must disable the entry instead of
|
|
# silently substituting another model from the endpoint.
|
|
if not m or m in _endpoint_hidden_models(ep):
|
|
return None
|
|
if enabled_models and m not in enabled_models:
|
|
return None
|
|
else:
|
|
# Legacy Utility/Vision chains retain their model-repair behavior.
|
|
if m and m in _endpoint_hidden_models(ep):
|
|
m = ""
|
|
if not m:
|
|
m = _first_chat_model(enabled_models) or ""
|
|
if not m:
|
|
return None
|
|
return (
|
|
(chat_url, m, headers),
|
|
{
|
|
"endpoint_id": ep.id,
|
|
"endpoint_label": getattr(ep, "name", None) or ep.id,
|
|
"endpoint_cost_tracked": endpoint_cost_tracked(
|
|
chat_url,
|
|
getattr(ep, "endpoint_kind", None),
|
|
),
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
|
return None
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def resolve_endpoint_by_id(
|
|
ep_id: str,
|
|
model: Optional[str] = None,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
require_exact_model: bool = False,
|
|
allow_live_probes: bool = True,
|
|
) -> Optional[Tuple[str, str, Dict]]:
|
|
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
|
|
|
|
descriptor_kwargs = {
|
|
"owner": owner,
|
|
"require_exact_model": require_exact_model,
|
|
}
|
|
if not allow_live_probes:
|
|
descriptor_kwargs["allow_live_probes"] = False
|
|
resolved = _resolve_endpoint_by_id_with_descriptor(ep_id, model, **descriptor_kwargs)
|
|
return resolved[0] if resolved else None
|
|
|
|
|
|
def resolve_route_descriptor(
|
|
endpoint_url: str,
|
|
model: str,
|
|
headers: Optional[Dict] = None,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> dict:
|
|
"""Return the visible endpoint identity for an already-resolved route.
|
|
|
|
Headers are compared only inside the process so two endpoints using the
|
|
same provider URL/model but different credentials remain distinguishable.
|
|
No credential material is returned or logged.
|
|
"""
|
|
|
|
if not endpoint_url or not model:
|
|
return {
|
|
"endpoint_id": None,
|
|
"endpoint_label": "Selected route",
|
|
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
|
}
|
|
db = SessionLocal()
|
|
try:
|
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
|
if owner:
|
|
from src.auth_helpers import owner_filter
|
|
q = owner_filter(q, ModelEndpoint, owner)
|
|
expected = (endpoint_url.rstrip("/"), model, headers or {})
|
|
for ep in q.all():
|
|
descriptor_kwargs = {
|
|
"owner": owner,
|
|
"require_exact_model": True,
|
|
}
|
|
if not allow_live_probes:
|
|
descriptor_kwargs["allow_live_probes"] = False
|
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
|
ep.id,
|
|
model,
|
|
**descriptor_kwargs,
|
|
)
|
|
if not resolved:
|
|
continue
|
|
candidate, descriptor = resolved
|
|
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
|
if actual == expected:
|
|
return descriptor
|
|
except Exception as e:
|
|
logger.debug("Could not identify selected endpoint route: %s", e)
|
|
finally:
|
|
db.close()
|
|
return {
|
|
"endpoint_id": None,
|
|
"endpoint_label": "Selected route",
|
|
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
|
}
|
|
|
|
|
|
def resolve_route_descriptor_by_id(
|
|
endpoint_id: str,
|
|
endpoint_url: str,
|
|
model: str,
|
|
headers: Optional[Dict] = None,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> Optional[dict]:
|
|
"""Resolve a selected route's identity without relying on row order.
|
|
|
|
The explicit endpoint id is still verified against the resolved runtime
|
|
route. This prevents stale or mismatched request metadata from being used
|
|
for attribution while disambiguating endpoints whose routes are otherwise
|
|
identical.
|
|
"""
|
|
|
|
descriptor_kwargs = {
|
|
"owner": owner,
|
|
"require_exact_model": True,
|
|
}
|
|
if not allow_live_probes:
|
|
descriptor_kwargs["allow_live_probes"] = False
|
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
|
endpoint_id,
|
|
model,
|
|
**descriptor_kwargs,
|
|
)
|
|
if not resolved:
|
|
return None
|
|
candidate, descriptor = resolved
|
|
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
|
|
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
|
return descriptor if actual == expected else None
|
|
|
|
|
|
def resolve_utility_fallback_candidates(
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> list:
|
|
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
|
|
fallback_kwargs = {"owner": owner}
|
|
if not allow_live_probes:
|
|
fallback_kwargs["allow_live_probes"] = False
|
|
return _resolve_fallback_candidates("utility_model_fallbacks", **fallback_kwargs)
|
|
|
|
|
|
def resolve_vision_fallback_candidates(
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> list:
|
|
"""Configured fallback chain for the Vision model (`vision_model_fallbacks`)."""
|
|
fallback_kwargs = {"owner": owner}
|
|
if not allow_live_probes:
|
|
fallback_kwargs["allow_live_probes"] = False
|
|
return _resolve_fallback_candidates("vision_model_fallbacks", **fallback_kwargs)
|
|
|
|
|
|
def _resolve_fallback_candidates(
|
|
setting_key: str,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
allow_live_probes: bool = True,
|
|
) -> list:
|
|
try:
|
|
from src.settings import get_user_setting, load_settings
|
|
settings = load_settings()
|
|
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
|
except Exception:
|
|
return []
|
|
resolver_kwargs = {"owner": owner}
|
|
if not allow_live_probes:
|
|
resolver_kwargs["allow_live_probes"] = False
|
|
return resolve_fallback_entries(chain, **resolver_kwargs)
|
|
|
|
|
|
def resolve_fallback_entries(
|
|
entries,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
require_exact_model: bool = False,
|
|
allow_live_probes: bool = True,
|
|
) -> list:
|
|
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
|
|
|
|
out = []
|
|
for entry in entries or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
resolver_kwargs = {
|
|
"owner": owner,
|
|
"require_exact_model": require_exact_model,
|
|
}
|
|
if not allow_live_probes:
|
|
resolver_kwargs["allow_live_probes"] = False
|
|
resolved = resolve_endpoint_by_id(
|
|
entry.get("endpoint_id", ""),
|
|
entry.get("model", ""),
|
|
**resolver_kwargs,
|
|
)
|
|
if resolved and resolved not in out:
|
|
out.append(resolved)
|
|
return out
|
|
|
|
|
|
def resolve_fallback_entries_with_descriptors(
|
|
entries,
|
|
owner: Optional[str] = None,
|
|
*,
|
|
require_exact_model: bool = False,
|
|
allow_live_probes: bool = True,
|
|
) -> list:
|
|
"""Resolve ordered entries while retaining safe endpoint provenance."""
|
|
|
|
out = []
|
|
seen = []
|
|
for entry in entries or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
descriptor_kwargs = {
|
|
"owner": owner,
|
|
"require_exact_model": require_exact_model,
|
|
}
|
|
if not allow_live_probes:
|
|
descriptor_kwargs["allow_live_probes"] = False
|
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
|
entry.get("endpoint_id", ""),
|
|
entry.get("model", ""),
|
|
**descriptor_kwargs,
|
|
)
|
|
if not resolved:
|
|
continue
|
|
candidate, descriptor = resolved
|
|
if any(candidate == prior for prior in seen):
|
|
continue
|
|
seen.append(candidate)
|
|
out.append((candidate, descriptor))
|
|
return out
|