Files
odysseus/src/endpoint_resolver.py
T

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