"""Capability probing for inference endpoints. Discovers actual endpoint capabilities by sending probe requests, rather than relying solely on declared capabilities. Probe results are cached with a configurable TTL and used to validate that required capabilities work before routing real requests. Requirements: 2.10, 2.11 """ from __future__ import annotations import json import logging import time from datetime import datetime, timezone from typing import Any from uuid import UUID import httpx from pydantic import BaseModel, Field from services.shared.inference.models import ( ChatMessage, InferenceTarget, ) logger = logging.getLogger(__name__) # Default TTL for probe result cache (seconds) DEFAULT_PROBE_TTL_SECONDS = 300 # 5 minutes # --------------------------------------------------------------------------- # Probe result models # --------------------------------------------------------------------------- class HealthProbeResult(BaseModel): """Result of a health probe against an endpoint.""" success: bool detail: str = "" timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) software_version: str | None = None latency_ms: int = 0 class ModelListingResult(BaseModel): """Result of probing the /v1/models endpoint.""" success: bool detail: str = "" timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) software_version: str | None = None models: list[str] = Field(default_factory=list) target_model_found: bool = False class JsonSchemaProbeResult(BaseModel): """Result of probing strict JSON Schema output.""" success: bool detail: str = "" timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) software_version: str | None = None schema_valid: bool = False structured_mode_used: str = "" class UsageProbeResult(BaseModel): """Result of probing usage metadata return.""" success: bool detail: str = "" timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) software_version: str | None = None has_prompt_tokens: bool = False has_completion_tokens: bool = False class SeedProbeResult(BaseModel): """Result of probing seed/determinism behavior.""" success: bool detail: str = "" timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) software_version: str | None = None outputs_match: bool = False class OutputTokenFieldResult(BaseModel): """Result of probing max_completion_tokens field support.""" success: bool detail: str = "" timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) software_version: str | None = None field_accepted: bool = False class FullProbeResult(BaseModel): """Aggregated results from all capability probes.""" endpoint_id: UUID timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) health: HealthProbeResult | None = None model_listing: ModelListingResult | None = None json_schema: JsonSchemaProbeResult | None = None usage_metadata: UsageProbeResult | None = None seed_determinism: SeedProbeResult | None = None output_token_field: OutputTokenFieldResult | None = None software_version: str | None = None probe_duration_ms: int = 0 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _MINIMAL_PROBE_SCHEMA = { "title": "probe_response", "type": "object", "properties": { "status": {"type": "string"}, }, "required": ["status"], } _PROBE_MESSAGES = [ ChatMessage(role="user", content="Respond with JSON: {\"status\": \"ok\"}"), ] def _resolve_auth_headers(target: InferenceTarget) -> dict[str, str]: """Build auth headers for probe requests from the target.""" import os headers: dict[str, str] = {"Content-Type": "application/json"} headers.update(target.extra_headers) if target.auth_secret_ref: secret = os.environ.get(target.auth_secret_ref) if secret: scheme = target.auth_scheme.lower() if scheme == "bearer": headers["Authorization"] = f"Bearer {secret}" else: headers[target.auth_scheme] = secret return headers def _extract_software_version(response: httpx.Response) -> str | None: """Try to extract software/version from response headers.""" # Common version headers across providers for header in ("x-vllm-version", "server", "x-server-version", "x-version"): value = response.headers.get(header) if value: return value return None # --------------------------------------------------------------------------- # EndpointProber # --------------------------------------------------------------------------- class EndpointProber: """Probes inference endpoints to discover actual capabilities. Each probe method sends a minimal request to verify that a declared capability actually works, returning structured results with timing and software version metadata. """ def __init__( self, *, http_client: httpx.AsyncClient | None = None, probe_timeout: float = 15.0, ) -> None: self._owns_client = http_client is None self._http = http_client or httpx.AsyncClient(timeout=probe_timeout) self._probe_timeout = probe_timeout async def probe_health(self, target: InferenceTarget) -> HealthProbeResult: """Check if the endpoint is reachable and responsive. Tries the configured health_path or falls back to common paths: /health, /v1/models, or the base URL itself. """ headers = _resolve_auth_headers(target) base = target.base_url.rstrip("/") # Try common health paths in order health_paths = ["/health", "/v1/models", "/"] start = time.monotonic() for path in health_paths: url = f"{base}{path}" try: response = await self._http.get( url, headers=headers, timeout=self._probe_timeout ) latency_ms = int((time.monotonic() - start) * 1000) sw_version = _extract_software_version(response) if response.status_code < 500: return HealthProbeResult( success=True, detail=f"Endpoint reachable via {path} (HTTP {response.status_code})", latency_ms=latency_ms, software_version=sw_version, ) except (httpx.ConnectError, httpx.TimeoutException) as exc: latency_ms = int((time.monotonic() - start) * 1000) return HealthProbeResult( success=False, detail=f"Connection failed: {type(exc).__name__}: {exc}", latency_ms=latency_ms, ) latency_ms = int((time.monotonic() - start) * 1000) return HealthProbeResult( success=False, detail="All health paths returned 5xx", latency_ms=latency_ms, ) async def probe_model_listing( self, target: InferenceTarget ) -> ModelListingResult: """Check if the /v1/models endpoint works and lists the target model.""" headers = _resolve_auth_headers(target) base = target.base_url.rstrip("/") url = f"{base}/v1/models" try: response = await self._http.get( url, headers=headers, timeout=self._probe_timeout ) sw_version = _extract_software_version(response) if response.status_code != 200: return ModelListingResult( success=False, detail=f"Model listing returned HTTP {response.status_code}", software_version=sw_version, ) data = response.json() models_data = data.get("data", []) model_ids = [m.get("id", "") for m in models_data if isinstance(m, dict)] target_found = target.model in model_ids return ModelListingResult( success=True, detail=f"Found {len(model_ids)} model(s)", software_version=sw_version, models=model_ids, target_model_found=target_found, ) except (httpx.ConnectError, httpx.TimeoutException) as exc: return ModelListingResult( success=False, detail=f"Connection failed: {type(exc).__name__}: {exc}", ) except (json.JSONDecodeError, ValueError) as exc: return ModelListingResult( success=False, detail=f"Invalid JSON in model listing response: {exc}", ) async def probe_json_schema( self, target: InferenceTarget ) -> JsonSchemaProbeResult: """Send a minimal schema-constrained request and verify the response validates.""" headers = _resolve_auth_headers(target) base = target.base_url.rstrip("/") url = f"{base}/v1/chat/completions" body: dict[str, Any] = { "model": target.model, "messages": [{"role": m.role, "content": m.content} for m in _PROBE_MESSAGES], "temperature": 0, "max_tokens": 64, "response_format": { "type": "json_schema", "json_schema": { "name": "probe_response", "strict": True, "schema": _MINIMAL_PROBE_SCHEMA, }, }, } try: response = await self._http.post( url, json=body, headers=headers, timeout=self._probe_timeout ) sw_version = _extract_software_version(response) if response.status_code != 200: return JsonSchemaProbeResult( success=False, detail=f"JSON schema probe returned HTTP {response.status_code}", software_version=sw_version, structured_mode_used="json_schema", ) data = response.json() choices = data.get("choices", []) if not choices: return JsonSchemaProbeResult( success=False, detail="Empty choices in schema probe response", software_version=sw_version, structured_mode_used="json_schema", ) content = choices[0].get("message", {}).get("content", "") try: parsed = json.loads(content) # Validate against the minimal schema is_valid = ( isinstance(parsed, dict) and "status" in parsed and isinstance(parsed["status"], str) ) return JsonSchemaProbeResult( success=is_valid, detail="Schema probe response validates" if is_valid else "Response did not match expected schema", software_version=sw_version, schema_valid=is_valid, structured_mode_used="json_schema", ) except (json.JSONDecodeError, ValueError): return JsonSchemaProbeResult( success=False, detail=f"Response content is not valid JSON: {content[:100]}", software_version=sw_version, schema_valid=False, structured_mode_used="json_schema", ) except (httpx.ConnectError, httpx.TimeoutException) as exc: return JsonSchemaProbeResult( success=False, detail=f"Connection failed: {type(exc).__name__}: {exc}", ) async def probe_usage_metadata( self, target: InferenceTarget ) -> UsageProbeResult: """Check if usage tokens (prompt_tokens, completion_tokens) are returned.""" headers = _resolve_auth_headers(target) base = target.base_url.rstrip("/") url = f"{base}/v1/chat/completions" body: dict[str, Any] = { "model": target.model, "messages": [{"role": m.role, "content": m.content} for m in _PROBE_MESSAGES], "temperature": 0, "max_tokens": 32, } try: response = await self._http.post( url, json=body, headers=headers, timeout=self._probe_timeout ) sw_version = _extract_software_version(response) if response.status_code != 200: return UsageProbeResult( success=False, detail=f"Usage probe returned HTTP {response.status_code}", software_version=sw_version, ) data = response.json() usage = data.get("usage", {}) has_prompt = "prompt_tokens" in usage and usage["prompt_tokens"] is not None has_completion = "completion_tokens" in usage and usage["completion_tokens"] is not None return UsageProbeResult( success=has_prompt or has_completion, detail=f"Usage metadata: prompt_tokens={'yes' if has_prompt else 'no'}, completion_tokens={'yes' if has_completion else 'no'}", software_version=sw_version, has_prompt_tokens=has_prompt, has_completion_tokens=has_completion, ) except (httpx.ConnectError, httpx.TimeoutException) as exc: return UsageProbeResult( success=False, detail=f"Connection failed: {type(exc).__name__}: {exc}", ) async def probe_seed_determinism( self, target: InferenceTarget ) -> SeedProbeResult: """Send the same request twice with the same seed and check if outputs match.""" headers = _resolve_auth_headers(target) base = target.base_url.rstrip("/") url = f"{base}/v1/chat/completions" body: dict[str, Any] = { "model": target.model, "messages": [{"role": "user", "content": "Say exactly: hello"}], "temperature": 0, "max_tokens": 16, "seed": 42, } outputs: list[str] = [] sw_version: str | None = None try: for _ in range(2): response = await self._http.post( url, json=body, headers=headers, timeout=self._probe_timeout ) if not sw_version: sw_version = _extract_software_version(response) if response.status_code != 200: return SeedProbeResult( success=False, detail=f"Seed probe returned HTTP {response.status_code}", software_version=sw_version, ) data = response.json() choices = data.get("choices", []) if not choices: return SeedProbeResult( success=False, detail="Empty choices in seed probe response", software_version=sw_version, ) content = choices[0].get("message", {}).get("content", "") outputs.append(content) match = outputs[0] == outputs[1] return SeedProbeResult( success=True, detail=f"Outputs {'match' if match else 'differ'} with same seed", software_version=sw_version, outputs_match=match, ) except (httpx.ConnectError, httpx.TimeoutException) as exc: return SeedProbeResult( success=False, detail=f"Connection failed: {type(exc).__name__}: {exc}", ) async def probe_output_token_field( self, target: InferenceTarget ) -> OutputTokenFieldResult: """Check if the endpoint accepts max_completion_tokens field. Some providers use max_completion_tokens instead of (or in addition to) max_tokens. This probe tests if the field is accepted without error. """ headers = _resolve_auth_headers(target) base = target.base_url.rstrip("/") url = f"{base}/v1/chat/completions" body: dict[str, Any] = { "model": target.model, "messages": [{"role": "user", "content": "Say hi"}], "temperature": 0, "max_completion_tokens": 16, } try: response = await self._http.post( url, json=body, headers=headers, timeout=self._probe_timeout ) sw_version = _extract_software_version(response) # If the endpoint accepts the field, it should respond 200 # If it rejects it, it will typically return 400 or 422 accepted = response.status_code == 200 detail = ( "max_completion_tokens field accepted" if accepted else f"max_completion_tokens field rejected (HTTP {response.status_code})" ) return OutputTokenFieldResult( success=accepted, detail=detail, software_version=sw_version, field_accepted=accepted, ) except (httpx.ConnectError, httpx.TimeoutException) as exc: return OutputTokenFieldResult( success=False, detail=f"Connection failed: {type(exc).__name__}: {exc}", ) async def run_full_probe(self, target: InferenceTarget) -> FullProbeResult: """Run all probes against a target and return aggregated results.""" start = time.monotonic() health = await self.probe_health(target) if not health.success: # If health fails, skip remaining probes duration_ms = int((time.monotonic() - start) * 1000) return FullProbeResult( endpoint_id=target.endpoint_id, health=health, software_version=health.software_version, probe_duration_ms=duration_ms, ) model_listing = await self.probe_model_listing(target) json_schema_result = await self.probe_json_schema(target) usage_result = await self.probe_usage_metadata(target) seed_result = await self.probe_seed_determinism(target) output_token_result = await self.probe_output_token_field(target) duration_ms = int((time.monotonic() - start) * 1000) # Determine software version from whichever probe returned one sw_version = ( health.software_version or model_listing.software_version or json_schema_result.software_version or usage_result.software_version or seed_result.software_version or output_token_result.software_version ) return FullProbeResult( endpoint_id=target.endpoint_id, health=health, model_listing=model_listing, json_schema=json_schema_result, usage_metadata=usage_result, seed_determinism=seed_result, output_token_field=output_token_result, software_version=sw_version, probe_duration_ms=duration_ms, ) async def close(self) -> None: """Close the HTTP client if we own it.""" if self._owns_client: await self._http.aclose() # --------------------------------------------------------------------------- # ProbeResultStore — TTL-based cache for probe results # --------------------------------------------------------------------------- class ProbeResultStore: """In-memory cache for endpoint probe results with configurable TTL. Uses a simple dict with timestamps. Expired entries are lazily evicted on access. The TTL can be configured globally or per-store instance. """ def __init__(self, ttl_seconds: float = DEFAULT_PROBE_TTL_SECONDS) -> None: self._ttl_seconds = ttl_seconds self._store: dict[UUID, tuple[float, FullProbeResult]] = {} @property def ttl_seconds(self) -> float: """Current TTL configuration.""" return self._ttl_seconds def store(self, endpoint_id: UUID, result: FullProbeResult) -> None: """Store a probe result with the current timestamp.""" self._store[endpoint_id] = (time.monotonic(), result) def get(self, endpoint_id: UUID) -> FullProbeResult | None: """Retrieve a probe result if it exists and hasn't expired. Returns None if no result is stored or if the TTL has elapsed. """ entry = self._store.get(endpoint_id) if entry is None: return None stored_at, result = entry elapsed = time.monotonic() - stored_at if elapsed > self._ttl_seconds: # Expired — evict lazily del self._store[endpoint_id] return None return result def invalidate(self, endpoint_id: UUID) -> None: """Force cache eviction for an endpoint.""" self._store.pop(endpoint_id, None) def clear(self) -> None: """Clear all cached results.""" self._store.clear() def __len__(self) -> int: """Number of entries (including possibly expired ones).""" return len(self._store) # --------------------------------------------------------------------------- # Required capability validation # --------------------------------------------------------------------------- def validate_required_capabilities( target: InferenceTarget, probe_result: FullProbeResult, ) -> list[str]: """Validate that declared required capabilities actually work. Returns a list of failure descriptions. If the list is non-empty, the endpoint should NOT be activated for routing. Checks: - Health must succeed - If chat_completions is declared, health must pass - If json_schema is declared, json_schema probe must succeed and validate - If usage is declared, usage probe must show token counts - If seed is declared, seed probe must show matching outputs - If max_completion_tokens is declared, output_token_field probe must pass - If model_listing is declared, model listing must succeed and find the model """ failures: list[str] = [] caps = target.capabilities # Health is always required if probe_result.health is None or not probe_result.health.success: detail = probe_result.health.detail if probe_result.health else "No health probe result" failures.append(f"Health check failed: {detail}") # If health fails, all other checks are meaningless return failures # Model listing capability if caps.model_listing: if probe_result.model_listing is None or not probe_result.model_listing.success: detail = probe_result.model_listing.detail if probe_result.model_listing else "Not probed" failures.append(f"Model listing failed: {detail}") elif not probe_result.model_listing.target_model_found: failures.append( f"Model '{target.model}' not found in endpoint model list" ) # JSON Schema capability if caps.json_schema: if probe_result.json_schema is None or not probe_result.json_schema.success: detail = probe_result.json_schema.detail if probe_result.json_schema else "Not probed" failures.append(f"JSON Schema structured output failed: {detail}") elif not probe_result.json_schema.schema_valid: failures.append( "JSON Schema probe succeeded but response did not validate" ) # Usage metadata capability if caps.usage: if probe_result.usage_metadata is None or not probe_result.usage_metadata.success: detail = probe_result.usage_metadata.detail if probe_result.usage_metadata else "Not probed" failures.append(f"Usage metadata not available: {detail}") # Seed determinism capability if caps.seed: if probe_result.seed_determinism is None or not probe_result.seed_determinism.success: detail = probe_result.seed_determinism.detail if probe_result.seed_determinism else "Not probed" failures.append(f"Seed determinism probe failed: {detail}") elif not probe_result.seed_determinism.outputs_match: failures.append( "Seed declared but outputs differ with same seed" ) # max_completion_tokens capability if caps.max_completion_tokens: if probe_result.output_token_field is None or not probe_result.output_token_field.success: detail = probe_result.output_token_field.detail if probe_result.output_token_field else "Not probed" failures.append(f"max_completion_tokens field not supported: {detail}") return failures