feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"""Inference gateway: shared client and routing layer.
|
||||
|
||||
Exports:
|
||||
InferenceGateway - Gateway facade for all LLM consumers
|
||||
InferenceTarget - Resolved target configuration
|
||||
StructuredGenerationRequest - Structured generation request
|
||||
InferenceResult - Inference response with lineage
|
||||
ModelLineage - Lineage record for persistence
|
||||
build_lineage_from_result - Extract lineage from a result
|
||||
"""
|
||||
from services.shared.inference.gateway import InferenceGateway
|
||||
from services.shared.inference.lineage import build_lineage_from_result
|
||||
from services.shared.inference.models import (
|
||||
ChatMessage,
|
||||
InferenceResult,
|
||||
InferenceTarget,
|
||||
ModelLineage,
|
||||
ProviderCapabilities,
|
||||
StructuredGenerationRequest,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"InferenceGateway",
|
||||
"InferenceTarget",
|
||||
"StructuredGenerationRequest",
|
||||
"InferenceResult",
|
||||
"ChatMessage",
|
||||
"ModelLineage",
|
||||
"ProviderCapabilities",
|
||||
"TokenUsage",
|
||||
"build_lineage_from_result",
|
||||
]
|
||||
@@ -0,0 +1,683 @@
|
||||
"""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
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Inference gateway clients.
|
||||
|
||||
Exports:
|
||||
OpenAICompatibleClient - Client for OpenAI-compatible endpoints (vLLM, OpenAI, etc.)
|
||||
OllamaNativeClient - Client for Ollama /api/chat native endpoints.
|
||||
"""
|
||||
from services.shared.inference.clients.ollama_native import OllamaNativeClient
|
||||
from services.shared.inference.clients.openai_compatible import OpenAICompatibleClient
|
||||
|
||||
__all__ = ["OpenAICompatibleClient", "OllamaNativeClient"]
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Ollama native client for the shared inference gateway.
|
||||
|
||||
Implements generation via the Ollama /api/chat endpoint with:
|
||||
- Shared StructuredGenerationRequest/InferenceResult types
|
||||
- Native JSON schema formatting when supported (format field)
|
||||
- Prompt-only fallback with explicit reporting
|
||||
- Stall/loop detection as Ollama-specific policy
|
||||
- Configurable max output tokens (num_predict) and context window (num_ctx)
|
||||
- Error mapping to shared InferenceErrorCategory
|
||||
|
||||
Requirements: 2.1, 2.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from services.shared.inference.errors import InferenceError, InferenceErrorCategory
|
||||
from services.shared.inference.models import (
|
||||
InferenceResult,
|
||||
InferenceTarget,
|
||||
StructuredGenerationRequest,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = ["OllamaNativeClient", "StallPolicy"]
|
||||
|
||||
logger = logging.getLogger("inference.ollama_native")
|
||||
|
||||
|
||||
@dataclass
|
||||
class StallPolicy:
|
||||
"""Ollama-specific stall/loop detection configuration.
|
||||
|
||||
Monitors streaming responses for repetitive output patterns.
|
||||
This policy is intentionally Ollama-specific and does not leak
|
||||
into the generic InferenceResult interface.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
check_interval_seconds: float = 5.0
|
||||
max_unchanged_intervals: int = 6
|
||||
loop_window: int = 64
|
||||
loop_threshold: float = 0.5
|
||||
|
||||
|
||||
def _map_http_status_to_category(status_code: int) -> InferenceErrorCategory:
|
||||
"""Map Ollama HTTP status codes to normalized error categories."""
|
||||
if status_code == 401:
|
||||
return InferenceErrorCategory.AUTH_FAILED
|
||||
if status_code == 403:
|
||||
return InferenceErrorCategory.FORBIDDEN
|
||||
if status_code == 404:
|
||||
return InferenceErrorCategory.MODEL_NOT_FOUND
|
||||
if status_code == 429:
|
||||
return InferenceErrorCategory.RATE_LIMITED
|
||||
if status_code in (400, 422):
|
||||
return InferenceErrorCategory.BAD_REQUEST
|
||||
if status_code >= 500:
|
||||
return InferenceErrorCategory.SERVER_ERROR
|
||||
return InferenceErrorCategory.UNKNOWN
|
||||
|
||||
|
||||
def _detect_loop(content: str, window: int, threshold: float) -> bool:
|
||||
"""Detect repetitive output by checking if the tail repeats earlier content.
|
||||
|
||||
Looks at the last `window` characters and checks if a significant
|
||||
portion of the tail matches an earlier substring, indicating the model
|
||||
is stuck in a generation loop.
|
||||
"""
|
||||
if len(content) < window * 2:
|
||||
return False
|
||||
|
||||
tail = content[-window:]
|
||||
body = content[:-window]
|
||||
|
||||
# Check if the tail appears verbatim in the preceding content
|
||||
if tail in body:
|
||||
return True
|
||||
|
||||
# Check character-level repetition ratio in the tail
|
||||
if not tail:
|
||||
return False
|
||||
unique_chars = len(set(tail))
|
||||
ratio = unique_chars / len(tail)
|
||||
return ratio < threshold
|
||||
|
||||
|
||||
class OllamaNativeClient:
|
||||
"""Async client for Ollama /api/chat using shared inference types.
|
||||
|
||||
Translates StructuredGenerationRequest into Ollama's native API format
|
||||
and returns InferenceResult with proper metadata and error classification.
|
||||
|
||||
Stall/loop detection is an Ollama-specific policy that monitors streaming
|
||||
responses for repetitive patterns and aborts generation when detected.
|
||||
This does not affect the InferenceResult interface — stall detection
|
||||
raises an InferenceError with category STALL_DETECTED.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: InferenceTarget,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
stall_policy: StallPolicy | None = None,
|
||||
) -> None:
|
||||
if target.protocol != "ollama_native":
|
||||
raise ValueError(
|
||||
f"OllamaNativeClient requires protocol='ollama_native', got '{target.protocol}'"
|
||||
)
|
||||
self._target = target
|
||||
self._stall_policy = stall_policy or StallPolicy()
|
||||
self._owns_client = http_client is None
|
||||
self._http = http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(300.0, read=300.0),
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client if we own it."""
|
||||
if self._owns_client:
|
||||
await self._http.aclose()
|
||||
|
||||
async def generate(self, request: StructuredGenerationRequest) -> InferenceResult:
|
||||
"""Send a structured generation request to Ollama and return the result.
|
||||
|
||||
Builds the Ollama-native payload, streams the response, applies
|
||||
stall detection, and maps results to InferenceResult.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
|
||||
payload = self._build_payload(request)
|
||||
url = f"{self._target.base_url}/api/chat"
|
||||
|
||||
logger.info(
|
||||
"Ollama POST %s model=%s messages=%d max_tokens=%d",
|
||||
url,
|
||||
self._target.model,
|
||||
len(request.messages),
|
||||
request.max_output_tokens,
|
||||
)
|
||||
|
||||
try:
|
||||
content, metadata = await self._stream_response(url, payload, request.timeout_seconds)
|
||||
except InferenceError:
|
||||
raise
|
||||
except httpx.TimeoutException as exc:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.TIMEOUT,
|
||||
f"Request timed out after {request.timeout_seconds}s",
|
||||
provider_detail=str(exc),
|
||||
) from exc
|
||||
except httpx.ConnectError as exc:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CONNECTION_REFUSED,
|
||||
"Connection refused",
|
||||
provider_detail=str(exc),
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CONNECTION_ERROR,
|
||||
"HTTP connection error",
|
||||
provider_detail=str(exc),
|
||||
) from exc
|
||||
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
|
||||
# Determine structured mode
|
||||
structured_mode = self._determine_structured_mode(request)
|
||||
|
||||
# Attempt JSON parsing if we expect structured output
|
||||
parsed = None
|
||||
if request.json_schema and content:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return InferenceResult(
|
||||
content=content,
|
||||
parsed=parsed,
|
||||
endpoint_id=self._target.endpoint_id,
|
||||
deployment_id=self._target.deployment_id,
|
||||
model=metadata.get("model", self._target.model) or self._target.model,
|
||||
protocol=self._target.protocol,
|
||||
structured_mode=structured_mode,
|
||||
latency_ms=latency_ms,
|
||||
usage=TokenUsage(
|
||||
input_tokens=metadata.get("prompt_eval_count"),
|
||||
output_tokens=metadata.get("eval_count"),
|
||||
),
|
||||
request_id=request.trace_id or None,
|
||||
repaired=False,
|
||||
retries=0,
|
||||
)
|
||||
|
||||
def _build_payload(self, request: StructuredGenerationRequest) -> dict:
|
||||
"""Build the Ollama /api/chat request body."""
|
||||
messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in request.messages
|
||||
]
|
||||
|
||||
options: dict = {}
|
||||
|
||||
# Honor max output tokens via num_predict
|
||||
if request.max_output_tokens:
|
||||
options["num_predict"] = request.max_output_tokens
|
||||
|
||||
# Honor context window from target configuration
|
||||
if self._target.context_window and self._target.context_window > 0:
|
||||
options["num_ctx"] = self._target.context_window
|
||||
|
||||
# Temperature
|
||||
options["temperature"] = request.temperature
|
||||
|
||||
# Seed if provided
|
||||
if request.seed is not None:
|
||||
options["seed"] = request.seed
|
||||
|
||||
payload: dict = {
|
||||
"model": self._target.model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
# Native schema formatting when supported
|
||||
if request.json_schema and self._target.capabilities.json_schema:
|
||||
payload["format"] = request.json_schema
|
||||
|
||||
# Merge any extra body params from target
|
||||
if self._target.extra_body:
|
||||
for key, value in self._target.extra_body.items():
|
||||
if key not in payload:
|
||||
payload[key] = value
|
||||
|
||||
return payload
|
||||
|
||||
def _determine_structured_mode(
|
||||
self, request: StructuredGenerationRequest
|
||||
) -> str:
|
||||
"""Determine which structured output mode was used."""
|
||||
if not request.json_schema:
|
||||
return "none"
|
||||
if self._target.capabilities.json_schema:
|
||||
return "json_schema"
|
||||
# Schema was requested but not natively supported — prompt only
|
||||
return "prompt_only"
|
||||
|
||||
async def _stream_response(
|
||||
self,
|
||||
url: str,
|
||||
payload: dict,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[str, dict]:
|
||||
"""Stream Ollama response, applying stall detection.
|
||||
|
||||
Returns (content, metadata) where metadata contains token counts
|
||||
and timing from the final Ollama response chunk.
|
||||
"""
|
||||
content_parts: list[str] = []
|
||||
metadata: dict = {}
|
||||
last_content_length = 0
|
||||
unchanged_count = 0
|
||||
last_check_time = time.monotonic()
|
||||
|
||||
try:
|
||||
async with self._http.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=payload,
|
||||
timeout=httpx.Timeout(timeout_seconds, read=timeout_seconds),
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
# Read body for error detail
|
||||
body = b""
|
||||
async for chunk in response.aiter_bytes():
|
||||
body += chunk
|
||||
detail = body.decode("utf-8", errors="replace")[:500]
|
||||
category = _map_http_status_to_category(response.status_code)
|
||||
raise InferenceError(
|
||||
category,
|
||||
f"Ollama returned HTTP {response.status_code}",
|
||||
provider_detail=detail,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk_data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Extract content from message
|
||||
message = chunk_data.get("message", {})
|
||||
chunk_content = message.get("content", "")
|
||||
if chunk_content:
|
||||
content_parts.append(chunk_content)
|
||||
|
||||
# Check for completion
|
||||
if chunk_data.get("done", False):
|
||||
# Capture metadata from final chunk
|
||||
metadata["model"] = chunk_data.get("model", "")
|
||||
metadata["prompt_eval_count"] = chunk_data.get("prompt_eval_count")
|
||||
metadata["eval_count"] = chunk_data.get("eval_count")
|
||||
total_ns = chunk_data.get("total_duration")
|
||||
if total_ns:
|
||||
metadata["total_duration_ms"] = total_ns // 1_000_000
|
||||
break
|
||||
|
||||
# Stall detection
|
||||
if self._stall_policy.enabled:
|
||||
now = time.monotonic()
|
||||
if now - last_check_time >= self._stall_policy.check_interval_seconds:
|
||||
current_content = "".join(content_parts)
|
||||
current_length = len(current_content)
|
||||
|
||||
if current_length == last_content_length:
|
||||
unchanged_count += 1
|
||||
else:
|
||||
# Check for loop pattern
|
||||
if _detect_loop(
|
||||
current_content,
|
||||
self._stall_policy.loop_window,
|
||||
self._stall_policy.loop_threshold,
|
||||
):
|
||||
unchanged_count += 1
|
||||
else:
|
||||
unchanged_count = 0
|
||||
|
||||
last_content_length = current_length
|
||||
last_check_time = now
|
||||
|
||||
if unchanged_count >= self._stall_policy.max_unchanged_intervals:
|
||||
logger.warning(
|
||||
"Stall detected after %d unchanged intervals, aborting",
|
||||
unchanged_count,
|
||||
)
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.STALL_DETECTED,
|
||||
f"Generation stalled after {unchanged_count} check intervals",
|
||||
)
|
||||
|
||||
except InferenceError:
|
||||
raise
|
||||
except httpx.TimeoutException as exc:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.TIMEOUT,
|
||||
"Stream read timed out",
|
||||
provider_detail=str(exc),
|
||||
) from exc
|
||||
except httpx.ConnectError as exc:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CONNECTION_REFUSED,
|
||||
"Connection refused during streaming",
|
||||
provider_detail=str(exc),
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CONNECTION_ERROR,
|
||||
"HTTP error during streaming",
|
||||
provider_detail=str(exc),
|
||||
) from exc
|
||||
|
||||
final_content = "".join(content_parts)
|
||||
|
||||
if not final_content:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.EMPTY_RESPONSE,
|
||||
"Ollama returned empty content",
|
||||
)
|
||||
|
||||
return final_content, metadata
|
||||
@@ -0,0 +1,431 @@
|
||||
"""OpenAI-compatible inference client.
|
||||
|
||||
Uses httpx.AsyncClient directly (NOT the openai SDK). This keeps wire payloads
|
||||
explicit, permits provider-specific extra_body, and simplifies redacted request
|
||||
auditing.
|
||||
|
||||
Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.9
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from services.shared.inference.models import (
|
||||
ErrorCategory,
|
||||
InferenceResult,
|
||||
InferenceTarget,
|
||||
StructuredGenerationRequest,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Status codes that trigger retry
|
||||
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||
|
||||
# Sensitive keys that must never appear in logs
|
||||
_SENSITIVE_KEYS = frozenset({"authorization", "x-api-key", "api-key"})
|
||||
|
||||
|
||||
def _resolve_auth_secret(secret_ref: str | None) -> str | None:
|
||||
"""Resolve an authentication secret from environment variables.
|
||||
|
||||
The secret_ref is treated as an environment variable name.
|
||||
Returns None if the ref is None or the env var is not set.
|
||||
"""
|
||||
if not secret_ref:
|
||||
return None
|
||||
return os.environ.get(secret_ref)
|
||||
|
||||
|
||||
def _redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Return a copy of headers with sensitive values redacted."""
|
||||
redacted = {}
|
||||
for key, value in headers.items():
|
||||
if key.lower() in _SENSITIVE_KEYS:
|
||||
redacted[key] = "***REDACTED***"
|
||||
else:
|
||||
redacted[key] = value
|
||||
return redacted
|
||||
|
||||
|
||||
class OpenAICompatibleClient:
|
||||
"""Client for OpenAI-compatible /v1/chat/completions endpoints.
|
||||
|
||||
Supports:
|
||||
- Bearer and configurable authentication via runtime secret resolution
|
||||
- Standard response_format.json_schema payloads
|
||||
- Configurable vLLM structured_outputs extra-body payloads
|
||||
- Explicit JSON-object and prompt-only fallback policies
|
||||
- Retry on transient errors (5xx, 429, timeout)
|
||||
- Local JSON schema revalidation
|
||||
- Metadata capture (request_id, usage, finish_reason, retries)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: InferenceTarget,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._target = target
|
||||
self._owns_client = http_client is None
|
||||
self._http = http_client or httpx.AsyncClient(timeout=target.timeout_seconds)
|
||||
|
||||
@property
|
||||
def target(self) -> InferenceTarget:
|
||||
"""The resolved inference target."""
|
||||
return self._target
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
request: StructuredGenerationRequest,
|
||||
) -> InferenceResult:
|
||||
"""Send a structured generation request and return the result.
|
||||
|
||||
Chooses the strongest structured-output mode the target supports:
|
||||
1. json_schema — sends the actual schema with strict mode
|
||||
2. json_object — only if target explicitly declares json_object capability
|
||||
3. prompt_only — fallback when no structured output is available
|
||||
"""
|
||||
structured_mode = self._choose_structured_mode(request)
|
||||
headers = self._build_headers()
|
||||
body = self._build_body(request, structured_mode)
|
||||
url = f"{self._target.base_url.rstrip('/')}/v1/chat/completions"
|
||||
|
||||
retries = 0
|
||||
max_retries = self._target.max_retries
|
||||
start_time = time.monotonic()
|
||||
last_error: str | None = None
|
||||
last_error_category: str | None = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = await self._http.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=request.timeout_seconds,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
last_error = "Request timed out"
|
||||
last_error_category = ErrorCategory.TIMEOUT
|
||||
if retries < max_retries:
|
||||
retries += 1
|
||||
logger.warning(
|
||||
"Timeout on attempt %d/%d to %s",
|
||||
retries,
|
||||
max_retries,
|
||||
url,
|
||||
)
|
||||
continue
|
||||
return self._error_result(
|
||||
last_error,
|
||||
last_error_category,
|
||||
retries,
|
||||
start_time,
|
||||
structured_mode,
|
||||
)
|
||||
except httpx.ConnectError as exc:
|
||||
last_error = f"Connection error: {exc}"
|
||||
last_error_category = ErrorCategory.CONNECTION_ERROR
|
||||
if retries < max_retries:
|
||||
retries += 1
|
||||
logger.warning(
|
||||
"Connection error on attempt %d/%d to %s: %s",
|
||||
retries,
|
||||
max_retries,
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
return self._error_result(
|
||||
last_error,
|
||||
last_error_category,
|
||||
retries,
|
||||
start_time,
|
||||
structured_mode,
|
||||
)
|
||||
|
||||
# Handle retryable HTTP status codes
|
||||
if response.status_code in _RETRYABLE_STATUS_CODES:
|
||||
if response.status_code == 429:
|
||||
last_error_category = ErrorCategory.RATE_LIMIT
|
||||
last_error = "Rate limited (429)"
|
||||
else:
|
||||
last_error_category = ErrorCategory.SERVER_ERROR
|
||||
last_error = f"Server error ({response.status_code})"
|
||||
|
||||
if retries < max_retries:
|
||||
retries += 1
|
||||
logger.warning(
|
||||
"HTTP %d on attempt %d/%d to %s",
|
||||
response.status_code,
|
||||
retries,
|
||||
max_retries,
|
||||
url,
|
||||
)
|
||||
continue
|
||||
return self._error_result(
|
||||
last_error,
|
||||
last_error_category,
|
||||
retries,
|
||||
start_time,
|
||||
structured_mode,
|
||||
)
|
||||
|
||||
# Handle non-retryable errors
|
||||
if response.status_code == 401 or response.status_code == 403:
|
||||
return self._error_result(
|
||||
f"Authentication failed ({response.status_code})",
|
||||
ErrorCategory.AUTHENTICATION,
|
||||
retries,
|
||||
start_time,
|
||||
structured_mode,
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
return self._error_result(
|
||||
f"Client error ({response.status_code})",
|
||||
ErrorCategory.INVALID_RESPONSE,
|
||||
retries,
|
||||
start_time,
|
||||
structured_mode,
|
||||
)
|
||||
|
||||
# Success path
|
||||
break
|
||||
|
||||
latency_ms = int((time.monotonic() - start_time) * 1000)
|
||||
return self._parse_response(
|
||||
response,
|
||||
request,
|
||||
structured_mode,
|
||||
retries,
|
||||
latency_ms,
|
||||
)
|
||||
|
||||
def _choose_structured_mode(
|
||||
self, request: StructuredGenerationRequest
|
||||
) -> str:
|
||||
"""Choose the strongest structured-output mode available."""
|
||||
caps = self._target.capabilities
|
||||
|
||||
if request.json_schema and caps.json_schema:
|
||||
return "json_schema"
|
||||
if request.json_schema and caps.json_object:
|
||||
return "json_object"
|
||||
if request.json_schema:
|
||||
# Schema requested but endpoint supports neither — prompt-only fallback
|
||||
return "prompt_only"
|
||||
return "none"
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Build request headers including authentication."""
|
||||
headers: dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add extra headers from target config
|
||||
headers.update(self._target.extra_headers)
|
||||
|
||||
# Resolve auth secret
|
||||
secret = _resolve_auth_secret(self._target.auth_secret_ref)
|
||||
if secret:
|
||||
scheme = self._target.auth_scheme.lower()
|
||||
if scheme == "bearer":
|
||||
headers["Authorization"] = f"Bearer {secret}"
|
||||
else:
|
||||
# Custom auth scheme (e.g., "X-API-Key: <value>")
|
||||
headers[self._target.auth_scheme] = secret
|
||||
|
||||
return headers
|
||||
|
||||
def _build_body(
|
||||
self,
|
||||
request: StructuredGenerationRequest,
|
||||
structured_mode: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the request body for /v1/chat/completions."""
|
||||
messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in request.messages
|
||||
]
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": self._target.model,
|
||||
"messages": messages,
|
||||
"temperature": request.temperature,
|
||||
"max_tokens": request.max_output_tokens,
|
||||
}
|
||||
|
||||
# Add seed if the target supports it
|
||||
if self._target.capabilities.seed and request.seed is not None:
|
||||
body["seed"] = request.seed
|
||||
|
||||
# Add response_format based on chosen mode
|
||||
if structured_mode == "json_schema" and request.json_schema:
|
||||
body["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": request.json_schema.get("title", "response"),
|
||||
"strict": True,
|
||||
"schema": request.json_schema,
|
||||
},
|
||||
}
|
||||
elif structured_mode == "json_object":
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
|
||||
# Add extra_body from target config (vLLM structured_outputs, etc.)
|
||||
if self._target.extra_body:
|
||||
body.update(self._target.extra_body)
|
||||
|
||||
return body
|
||||
|
||||
def _parse_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
request: StructuredGenerationRequest,
|
||||
structured_mode: str,
|
||||
retries: int,
|
||||
latency_ms: int,
|
||||
) -> InferenceResult:
|
||||
"""Parse a successful response into InferenceResult."""
|
||||
# Extract request ID from response headers
|
||||
request_id = response.headers.get("x-request-id")
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return InferenceResult(
|
||||
content="",
|
||||
endpoint_id=self._target.endpoint_id,
|
||||
deployment_id=self._target.deployment_id,
|
||||
model=self._target.model,
|
||||
protocol=self._target.protocol,
|
||||
structured_mode=structured_mode, # type: ignore[arg-type]
|
||||
latency_ms=latency_ms,
|
||||
retries=retries,
|
||||
request_id=request_id,
|
||||
error="Invalid JSON in response body",
|
||||
error_category=ErrorCategory.INVALID_RESPONSE,
|
||||
)
|
||||
|
||||
# Extract content from choices
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
return InferenceResult(
|
||||
content="",
|
||||
endpoint_id=self._target.endpoint_id,
|
||||
deployment_id=self._target.deployment_id,
|
||||
model=self._target.model,
|
||||
protocol=self._target.protocol,
|
||||
structured_mode=structured_mode, # type: ignore[arg-type]
|
||||
latency_ms=latency_ms,
|
||||
retries=retries,
|
||||
request_id=request_id,
|
||||
error="Empty choices in response",
|
||||
error_category=ErrorCategory.INVALID_RESPONSE,
|
||||
)
|
||||
|
||||
message = choices[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
finish_reason = choices[0].get("finish_reason")
|
||||
|
||||
# Extract usage metadata
|
||||
usage_data = data.get("usage", {})
|
||||
input_tokens = usage_data.get("prompt_tokens")
|
||||
output_tokens = usage_data.get("completion_tokens")
|
||||
total_tokens = usage_data.get("total_tokens")
|
||||
|
||||
# Parse JSON content if structured output was requested
|
||||
parsed: dict[str, Any] | None = None
|
||||
schema_valid: bool | None = None
|
||||
|
||||
if structured_mode in ("json_schema", "json_object") and content:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Content is not valid JSON
|
||||
schema_valid = False
|
||||
|
||||
# Validate parsed JSON against supplied schema
|
||||
if parsed is not None and request.json_schema:
|
||||
schema_valid = self._validate_schema(parsed, request.json_schema)
|
||||
|
||||
return InferenceResult(
|
||||
content=content,
|
||||
parsed=parsed,
|
||||
endpoint_id=self._target.endpoint_id,
|
||||
deployment_id=self._target.deployment_id,
|
||||
model=self._target.model,
|
||||
protocol=self._target.protocol,
|
||||
structured_mode=structured_mode, # type: ignore[arg-type]
|
||||
latency_ms=latency_ms,
|
||||
usage=TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
),
|
||||
request_id=request_id,
|
||||
finish_reason=finish_reason,
|
||||
retries=retries,
|
||||
schema_valid=schema_valid,
|
||||
)
|
||||
|
||||
def _validate_schema(
|
||||
self, data: dict[str, Any], schema: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Revalidate parsed JSON locally against the supplied schema.
|
||||
|
||||
Returns True if valid, False otherwise.
|
||||
"""
|
||||
try:
|
||||
import jsonschema
|
||||
|
||||
jsonschema.validate(instance=data, schema=schema)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Schema validation failed for response data")
|
||||
return False
|
||||
|
||||
def _error_result(
|
||||
self,
|
||||
error: str,
|
||||
error_category: str,
|
||||
retries: int,
|
||||
start_time: float,
|
||||
structured_mode: str,
|
||||
) -> InferenceResult:
|
||||
"""Build an error InferenceResult."""
|
||||
latency_ms = int((time.monotonic() - start_time) * 1000)
|
||||
return InferenceResult(
|
||||
content="",
|
||||
endpoint_id=self._target.endpoint_id,
|
||||
deployment_id=self._target.deployment_id,
|
||||
model=self._target.model,
|
||||
protocol=self._target.protocol,
|
||||
structured_mode=structured_mode, # type: ignore[arg-type]
|
||||
latency_ms=latency_ms,
|
||||
retries=retries,
|
||||
error=error,
|
||||
error_category=error_category,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client if we own it."""
|
||||
if self._owns_client:
|
||||
await self._http.aclose()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"OpenAICompatibleClient("
|
||||
f"model={self._target.model!r}, "
|
||||
f"base_url={self._target.base_url!r})"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Normalized error categories for the inference gateway.
|
||||
|
||||
Maps provider-specific failures into a protocol-agnostic taxonomy
|
||||
so that retry logic, alerting, and metrics work uniformly across
|
||||
Ollama, OpenAI-compatible, and specialist endpoints.
|
||||
|
||||
Requirements: 2.1, 2.9
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class InferenceErrorCategory(str, Enum):
|
||||
"""Normalized error categories for inference failures."""
|
||||
|
||||
# Network / transport
|
||||
TIMEOUT = "timeout"
|
||||
CONNECTION_REFUSED = "connection_refused"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
|
||||
# Authentication / authorization
|
||||
AUTH_FAILED = "auth_failed"
|
||||
FORBIDDEN = "forbidden"
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMITED = "rate_limited"
|
||||
|
||||
# Server errors
|
||||
SERVER_ERROR = "server_error"
|
||||
SERVICE_UNAVAILABLE = "service_unavailable"
|
||||
|
||||
# Client errors
|
||||
BAD_REQUEST = "bad_request"
|
||||
MODEL_NOT_FOUND = "model_not_found"
|
||||
INVALID_REQUEST = "invalid_request"
|
||||
|
||||
# Response problems
|
||||
INVALID_RESPONSE = "invalid_response"
|
||||
EMPTY_RESPONSE = "empty_response"
|
||||
SCHEMA_VIOLATION = "schema_violation"
|
||||
|
||||
# Capability / policy
|
||||
CAPABILITY_UNAVAILABLE = "capability_unavailable"
|
||||
POLICY_VIOLATION = "policy_violation"
|
||||
|
||||
# Ollama-specific
|
||||
STALL_DETECTED = "stall_detected"
|
||||
|
||||
# Unknown
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
"""Whether this error category should generally be retried."""
|
||||
return self in _RETRYABLE_CATEGORIES
|
||||
|
||||
|
||||
_RETRYABLE_CATEGORIES = frozenset({
|
||||
InferenceErrorCategory.TIMEOUT,
|
||||
InferenceErrorCategory.CONNECTION_ERROR,
|
||||
InferenceErrorCategory.CONNECTION_REFUSED,
|
||||
InferenceErrorCategory.SERVER_ERROR,
|
||||
InferenceErrorCategory.SERVICE_UNAVAILABLE,
|
||||
InferenceErrorCategory.RATE_LIMITED,
|
||||
InferenceErrorCategory.STALL_DETECTED,
|
||||
InferenceErrorCategory.EMPTY_RESPONSE,
|
||||
})
|
||||
|
||||
|
||||
class InferenceError(Exception):
|
||||
"""Typed inference error with category and optional provider detail."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
category: InferenceErrorCategory,
|
||||
message: str = "",
|
||||
*,
|
||||
provider_detail: str | None = None,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
self.category = category
|
||||
self.provider_detail = provider_detail
|
||||
self.status_code = status_code
|
||||
super().__init__(message or category.value)
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
return self.category.retryable
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Inference client factory with protocol alias resolution.
|
||||
|
||||
Replaces the legacy VLLMClient/OllamaClient fallback pattern with a
|
||||
capability-aware routing layer. Unknown protocols ALWAYS fail closed —
|
||||
they never silently fall back to Ollama.
|
||||
|
||||
Requirements: 2.2, 2.6
|
||||
Design: Inference Gateway — profiles and capability probes
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
from services.shared.inference.clients.ollama_native import OllamaNativeClient
|
||||
from services.shared.inference.clients.openai_compatible import OpenAICompatibleClient
|
||||
from services.shared.inference.errors import InferenceError, InferenceErrorCategory
|
||||
from services.shared.inference.models import InferenceTarget
|
||||
|
||||
# Backward-compatible protocol aliases.
|
||||
# "vllm" is retained as a deprecated alias for "openai_chat".
|
||||
# "ollama" is retained as an alias for "ollama_native".
|
||||
PROTOCOL_ALIASES: dict[str, str] = {
|
||||
"vllm": "openai_chat",
|
||||
"ollama": "ollama_native",
|
||||
}
|
||||
|
||||
# Canonical protocol names that the factory can instantiate.
|
||||
KNOWN_PROTOCOLS: frozenset[str] = frozenset({
|
||||
"openai_chat",
|
||||
"ollama_native",
|
||||
"specialist_http",
|
||||
})
|
||||
|
||||
|
||||
def resolve_protocol(provider_name: str) -> str:
|
||||
"""Resolve a protocol name or alias to a canonical protocol.
|
||||
|
||||
Raises InferenceError(CAPABILITY_UNAVAILABLE) for unknown protocols.
|
||||
Emits a deprecation warning for the deprecated "vllm" alias.
|
||||
|
||||
Args:
|
||||
provider_name: Raw protocol/provider string (e.g. "vllm", "ollama_native").
|
||||
|
||||
Returns:
|
||||
Canonical protocol string.
|
||||
|
||||
Raises:
|
||||
InferenceError: If the protocol is unknown and cannot be resolved.
|
||||
"""
|
||||
normalized = provider_name.strip().lower()
|
||||
|
||||
# Check if it's already a known canonical protocol
|
||||
if normalized in KNOWN_PROTOCOLS:
|
||||
return normalized
|
||||
|
||||
# Check aliases
|
||||
if normalized in PROTOCOL_ALIASES:
|
||||
canonical = PROTOCOL_ALIASES[normalized]
|
||||
if normalized == "vllm":
|
||||
warnings.warn(
|
||||
"Provider 'vllm' is deprecated. Use protocol 'openai_chat' instead. "
|
||||
"The 'vllm' alias will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return canonical
|
||||
|
||||
# Unknown protocol — fail closed, NEVER fall back to Ollama
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Unknown protocol: {normalized!r}. "
|
||||
f"Supported protocols: {sorted(KNOWN_PROTOCOLS)}. "
|
||||
f"Supported aliases: {sorted(PROTOCOL_ALIASES.keys())}.",
|
||||
)
|
||||
|
||||
|
||||
def create_client(
|
||||
target: InferenceTarget,
|
||||
) -> OpenAICompatibleClient | OllamaNativeClient:
|
||||
"""Create the appropriate inference client for the given target.
|
||||
|
||||
Routes based on the target's protocol field. If the protocol is an alias
|
||||
(e.g. "vllm"), it is resolved first. Unknown protocols raise a typed
|
||||
configuration error — they NEVER silently fall back to Ollama.
|
||||
|
||||
Args:
|
||||
target: Fully resolved inference target with protocol, URL, etc.
|
||||
|
||||
Returns:
|
||||
An OpenAICompatibleClient or OllamaNativeClient instance.
|
||||
|
||||
Raises:
|
||||
InferenceError: If the protocol is unknown or unsupported.
|
||||
"""
|
||||
protocol = resolve_protocol(target.protocol)
|
||||
|
||||
if protocol == "openai_chat":
|
||||
return OpenAICompatibleClient(target)
|
||||
|
||||
if protocol == "ollama_native":
|
||||
return OllamaNativeClient(target)
|
||||
|
||||
# specialist_http is a valid protocol but has no client implementation yet
|
||||
# (handled by a separate specialist service layer). Fail closed here.
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Protocol {protocol!r} is recognized but has no client implementation in this factory. "
|
||||
f"Use the specialist HTTP service layer directly.",
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Inference Gateway facade.
|
||||
|
||||
Wraps the factory + client lifecycle for all LLM consumers.
|
||||
Provides a single ``generate()`` method that dispatches to the correct
|
||||
client implementation based on the InferenceTarget protocol.
|
||||
|
||||
Manages client instances (one per endpoint, reusable) and provides
|
||||
explicit ``refresh_target()`` for configuration changes — no private
|
||||
``_config`` mutation.
|
||||
|
||||
Requirements: 2.1, 2.12, 13.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
from services.shared.inference.models import (
|
||||
InferenceResult,
|
||||
InferenceTarget,
|
||||
StructuredGenerationRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("inference.gateway")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InferenceClient(Protocol):
|
||||
"""Protocol for inference client implementations."""
|
||||
|
||||
async def generate(self, request: StructuredGenerationRequest) -> InferenceResult:
|
||||
"""Send a structured generation request and return the result."""
|
||||
...
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release underlying HTTP resources."""
|
||||
...
|
||||
|
||||
|
||||
class InferenceGateway:
|
||||
"""Gateway facade that routes inference requests to protocol-specific clients.
|
||||
|
||||
Key behaviours:
|
||||
- Maintains a client pool keyed by (endpoint_id, protocol)
|
||||
- Creates clients lazily on first request for a target
|
||||
- Reuses existing clients for the same endpoint
|
||||
- Provides ``refresh_target()`` to invalidate a cached client
|
||||
(replaces private ``_config`` mutation pattern)
|
||||
- Fails closed on unknown protocols
|
||||
|
||||
Usage::
|
||||
|
||||
gateway = InferenceGateway()
|
||||
result = await gateway.generate(target, request)
|
||||
# Later, when config changes:
|
||||
await gateway.refresh_target(target.endpoint_id)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Pool of active clients keyed by endpoint_id
|
||||
self._clients: dict[UUID, InferenceClient] = {}
|
||||
# Track the target associated with each client for refresh comparison
|
||||
self._targets: dict[UUID, InferenceTarget] = {}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
target: InferenceTarget,
|
||||
request: StructuredGenerationRequest,
|
||||
) -> InferenceResult:
|
||||
"""Route a structured generation request to the appropriate client.
|
||||
|
||||
Creates or reuses a client for the target's endpoint. The result
|
||||
always includes endpoint_id, deployment_id, model, and protocol
|
||||
for lineage recording.
|
||||
|
||||
Args:
|
||||
target: Resolved inference target with protocol, endpoint, and capabilities.
|
||||
request: The structured generation request.
|
||||
|
||||
Returns:
|
||||
InferenceResult with content and full lineage metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If the target protocol is unknown (fail-closed).
|
||||
"""
|
||||
client = self._get_or_create_client(target)
|
||||
result = await client.generate(request)
|
||||
|
||||
# Ensure lineage fields are populated from target
|
||||
if result.endpoint_id is None:
|
||||
result.endpoint_id = target.endpoint_id
|
||||
if result.deployment_id is None:
|
||||
result.deployment_id = target.deployment_id
|
||||
if not result.model:
|
||||
result.model = target.model
|
||||
if not result.protocol or result.protocol != target.protocol:
|
||||
result.protocol = target.protocol
|
||||
|
||||
return result
|
||||
|
||||
async def refresh_target(self, endpoint_id: UUID) -> None:
|
||||
"""Invalidate and close the cached client for an endpoint.
|
||||
|
||||
Call this when endpoint configuration changes (model swap,
|
||||
URL change, credential rotation, etc.) instead of mutating
|
||||
private ``_config`` attributes.
|
||||
|
||||
The next ``generate()`` call for this endpoint will create
|
||||
a fresh client from the new target.
|
||||
"""
|
||||
client = self._clients.pop(endpoint_id, None)
|
||||
self._targets.pop(endpoint_id, None)
|
||||
if client is not None:
|
||||
logger.info("Refreshing client for endpoint %s", endpoint_id)
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Error closing client during refresh for endpoint %s",
|
||||
endpoint_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close all managed clients and release resources."""
|
||||
for endpoint_id, client in list(self._clients.items()):
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Error closing client for endpoint %s",
|
||||
endpoint_id,
|
||||
exc_info=True,
|
||||
)
|
||||
self._clients.clear()
|
||||
self._targets.clear()
|
||||
|
||||
def _get_or_create_client(self, target: InferenceTarget) -> InferenceClient:
|
||||
"""Get an existing client or create a new one for the target.
|
||||
|
||||
Clients are cached by endpoint_id. If the target has changed
|
||||
(different model, URL, etc.), the old client is replaced.
|
||||
"""
|
||||
endpoint_id = target.endpoint_id
|
||||
existing = self._clients.get(endpoint_id)
|
||||
|
||||
if existing is not None:
|
||||
# Reuse if target hasn't changed
|
||||
cached_target = self._targets.get(endpoint_id)
|
||||
if cached_target is target or cached_target == target:
|
||||
return existing
|
||||
|
||||
# Create a new client for the target's protocol
|
||||
client = self._create_client(target)
|
||||
self._clients[endpoint_id] = client
|
||||
self._targets[endpoint_id] = target
|
||||
return client
|
||||
|
||||
def _create_client(self, target: InferenceTarget) -> InferenceClient:
|
||||
"""Create a protocol-specific client for the target.
|
||||
|
||||
Raises:
|
||||
ValueError: If the protocol is unknown (fail-closed per Req 2.6).
|
||||
"""
|
||||
protocol = target.protocol
|
||||
|
||||
if protocol == "openai_chat":
|
||||
from services.shared.inference.clients.openai_compatible import (
|
||||
OpenAICompatibleClient,
|
||||
)
|
||||
logger.info(
|
||||
"Creating OpenAICompatibleClient for endpoint %s (model=%s)",
|
||||
target.endpoint_id,
|
||||
target.model,
|
||||
)
|
||||
return OpenAICompatibleClient(target)
|
||||
|
||||
if protocol == "ollama_native":
|
||||
from services.shared.inference.clients.ollama_native import (
|
||||
OllamaNativeClient,
|
||||
)
|
||||
logger.info(
|
||||
"Creating OllamaNativeClient for endpoint %s (model=%s)",
|
||||
target.endpoint_id,
|
||||
target.model,
|
||||
)
|
||||
return OllamaNativeClient(target)
|
||||
|
||||
# Unknown protocol — fail closed (Requirement 2.6)
|
||||
raise ValueError(
|
||||
f"Unknown inference protocol '{protocol}' for endpoint {target.endpoint_id}. "
|
||||
"Supported protocols: 'ollama_native', 'openai_chat', 'specialist_http'. "
|
||||
"The gateway will NOT silently route to a default provider."
|
||||
)
|
||||
|
||||
@property
|
||||
def active_endpoints(self) -> list[UUID]:
|
||||
"""Return the list of endpoint IDs with active cached clients."""
|
||||
return list(self._clients.keys())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Lineage recording for inference results.
|
||||
|
||||
Extracts persistence fields from InferenceResult so that actual endpoint,
|
||||
model, and route lineage are recorded — fixing the hardcoded
|
||||
``model_provider = 'ollama'`` pattern.
|
||||
|
||||
Requirements: 2.9, 13.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.shared.inference.models import InferenceResult, ModelLineage
|
||||
|
||||
|
||||
def build_lineage_from_result(result: InferenceResult, trace_id: str = "") -> ModelLineage:
|
||||
"""Extract a ModelLineage record from an InferenceResult.
|
||||
|
||||
This replaces hardcoded ``model_provider = 'ollama'`` persistence
|
||||
by capturing the actual endpoint, deployment, model, protocol,
|
||||
structured mode, request ID, latency, and retries from the result.
|
||||
|
||||
Args:
|
||||
result: The completed inference result.
|
||||
trace_id: Optional distributed trace ID for correlation.
|
||||
|
||||
Returns:
|
||||
A ModelLineage with all required persistence fields.
|
||||
"""
|
||||
return ModelLineage(
|
||||
endpoint_id=result.endpoint_id,
|
||||
deployment_id=result.deployment_id,
|
||||
model=result.model,
|
||||
protocol=result.protocol,
|
||||
structured_mode=result.structured_mode,
|
||||
request_id=result.request_id,
|
||||
latency_ms=result.latency_ms,
|
||||
retries=result.retries,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
def lineage_to_persistence_dict(lineage: ModelLineage) -> dict:
|
||||
"""Convert a ModelLineage to a flat dict for database persistence.
|
||||
|
||||
Returns fields suitable for inserting into agent_performance_log,
|
||||
document_intelligence, or similar tables.
|
||||
|
||||
The ``model_provider`` field is derived from the protocol:
|
||||
- ``ollama_native`` → ``"ollama"``
|
||||
- ``openai_chat`` → ``"openai_compatible"`` (covers vLLM, OpenAI, etc.)
|
||||
- ``specialist_http`` → ``"specialist"``
|
||||
"""
|
||||
protocol_to_provider = {
|
||||
"ollama_native": "ollama",
|
||||
"openai_chat": "openai_compatible",
|
||||
"specialist_http": "specialist",
|
||||
}
|
||||
|
||||
return {
|
||||
"model_provider": protocol_to_provider.get(lineage.protocol, lineage.protocol),
|
||||
"model_name": lineage.model,
|
||||
"endpoint_id": str(lineage.endpoint_id) if lineage.endpoint_id else None,
|
||||
"deployment_id": str(lineage.deployment_id) if lineage.deployment_id else None,
|
||||
"protocol": lineage.protocol,
|
||||
"structured_mode": lineage.structured_mode,
|
||||
"request_id": lineage.request_id,
|
||||
"latency_ms": lineage.latency_ms,
|
||||
"retries": lineage.retries,
|
||||
"trace_id": lineage.trace_id,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Migration helpers for deprecated provider records.
|
||||
|
||||
Scans agent configuration records for deprecated provider values (e.g. "vllm")
|
||||
and produces structured deprecation warnings to guide operators toward the
|
||||
canonical protocol names.
|
||||
|
||||
Requirements: 2.2, 3.8
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.shared.inference.factory import PROTOCOL_ALIASES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDeprecationWarning:
|
||||
"""Structured deprecation warning for an agent record using a deprecated provider.
|
||||
|
||||
Attributes:
|
||||
agent_id: The ID of the agent with the deprecated provider.
|
||||
current_value: The current deprecated provider string (e.g. "vllm").
|
||||
recommended_value: The canonical protocol to migrate to.
|
||||
message: Human-readable migration guidance.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
current_value: str
|
||||
recommended_value: str
|
||||
message: str
|
||||
|
||||
|
||||
def check_deprecated_providers(
|
||||
agent_records: list[dict],
|
||||
) -> list[ProviderDeprecationWarning]:
|
||||
"""Scan agent records for deprecated provider values.
|
||||
|
||||
Checks the ``model_provider`` field of each agent record against
|
||||
PROTOCOL_ALIASES. Records using deprecated aliases get a warning
|
||||
with migration guidance.
|
||||
|
||||
Args:
|
||||
agent_records: List of dicts, each having at least ``agent_id``
|
||||
(or ``id``) and ``model_provider`` fields.
|
||||
|
||||
Returns:
|
||||
List of ProviderDeprecationWarning for records using deprecated providers.
|
||||
"""
|
||||
warnings_list: list[ProviderDeprecationWarning] = []
|
||||
|
||||
for record in agent_records:
|
||||
agent_id = str(record.get("agent_id") or record.get("id", "unknown"))
|
||||
provider = (record.get("model_provider") or "").strip().lower()
|
||||
|
||||
if provider in PROTOCOL_ALIASES:
|
||||
recommended = PROTOCOL_ALIASES[provider]
|
||||
warnings_list.append(
|
||||
ProviderDeprecationWarning(
|
||||
agent_id=agent_id,
|
||||
current_value=provider,
|
||||
recommended_value=recommended,
|
||||
message=(
|
||||
f"Agent {agent_id} uses deprecated provider '{provider}'. "
|
||||
f"Migrate to protocol '{recommended}'. "
|
||||
f"The '{provider}' alias will be removed in a future version."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return warnings_list
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Inference gateway domain models.
|
||||
|
||||
Core types for the capability-aware inference gateway.
|
||||
Requirements: 2.1, 2.8, 2.9
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderCapabilities:
|
||||
"""Declared capabilities for an inference endpoint/deployment."""
|
||||
|
||||
chat_completions: bool = False
|
||||
responses_api: bool = False
|
||||
json_schema: bool = False
|
||||
json_object: bool = False
|
||||
seed: bool = False
|
||||
usage: bool = False
|
||||
max_completion_tokens: bool = False
|
||||
reasoning_toggle: bool = False
|
||||
model_listing: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InferenceTarget:
|
||||
"""Resolved target for an inference request."""
|
||||
|
||||
endpoint_id: UUID
|
||||
deployment_id: UUID
|
||||
protocol: Literal["ollama_native", "openai_chat", "specialist_http"]
|
||||
base_url: str
|
||||
model: str
|
||||
capabilities: ProviderCapabilities
|
||||
auth_secret_ref: str | None = None
|
||||
auth_scheme: str = "bearer"
|
||||
extra_headers: dict[str, str] = field(default_factory=dict)
|
||||
extra_body: dict[str, Any] = field(default_factory=dict)
|
||||
max_retries: int = 3
|
||||
timeout_seconds: float = 120.0
|
||||
context_window: int = 0
|
||||
max_output_tokens: int | None = None
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""A single chat message."""
|
||||
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
|
||||
|
||||
class StructuredGenerationRequest(BaseModel):
|
||||
"""Request for structured generation via the inference gateway."""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
json_schema: dict[str, Any] | None = None
|
||||
max_output_tokens: int = 4096
|
||||
temperature: float = 0.0
|
||||
seed: int | None = 0
|
||||
timeout_seconds: float = 120.0
|
||||
trace_id: str = ""
|
||||
|
||||
|
||||
class TokenUsage(BaseModel):
|
||||
"""Token usage metadata from an inference response."""
|
||||
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
|
||||
|
||||
class InferenceResult(BaseModel):
|
||||
"""Result of an inference request.
|
||||
|
||||
Contains the generated content plus metadata for lineage,
|
||||
observability, and audit.
|
||||
"""
|
||||
|
||||
content: str
|
||||
parsed: dict[str, Any] | None = None
|
||||
endpoint_id: UUID | None = None
|
||||
deployment_id: UUID | None = None
|
||||
model: str = ""
|
||||
protocol: Literal["ollama_native", "openai_chat", "specialist_http"] = "openai_chat"
|
||||
structured_mode: Literal["json_schema", "json_object", "prompt_only", "none"] = "none"
|
||||
latency_ms: int = 0
|
||||
usage: TokenUsage = Field(default_factory=TokenUsage)
|
||||
request_id: str | None = None
|
||||
finish_reason: str | None = None
|
||||
repaired: bool = False
|
||||
retries: int = 0
|
||||
error: str | None = None
|
||||
error_category: str | None = None
|
||||
schema_valid: bool | None = None
|
||||
|
||||
|
||||
class ModelLineage(BaseModel):
|
||||
"""Lineage record capturing which endpoint, model, and route served a request.
|
||||
|
||||
Used for persistence so actual endpoint, model, and route lineage are
|
||||
recorded (fixes hardcoded model_provider = 'ollama').
|
||||
"""
|
||||
|
||||
endpoint_id: UUID | None = None
|
||||
deployment_id: UUID | None = None
|
||||
model: str = ""
|
||||
protocol: Literal["ollama_native", "openai_chat", "specialist_http"] = "openai_chat"
|
||||
structured_mode: str = "none"
|
||||
request_id: str | None = None
|
||||
latency_ms: int = 0
|
||||
retries: int = 0
|
||||
trace_id: str = ""
|
||||
|
||||
|
||||
# Error categories for provider failures
|
||||
class ErrorCategory:
|
||||
"""Normalized error categories for inference failures."""
|
||||
|
||||
TIMEOUT = "timeout"
|
||||
AUTHENTICATION = "authentication"
|
||||
RATE_LIMIT = "rate_limit"
|
||||
SERVER_ERROR = "server_error"
|
||||
INVALID_RESPONSE = "invalid_response"
|
||||
SCHEMA_VIOLATION = "schema_violation"
|
||||
CAPABILITY_ERROR = "capability_error"
|
||||
POLICY_ERROR = "policy_error"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Credential and sensitive header redaction utilities.
|
||||
|
||||
Ensures that authentication values, API keys, bearer tokens, and other
|
||||
sensitive headers are never included in logs, traces, serialized request
|
||||
snapshots, or error messages.
|
||||
|
||||
Requirements: 2.8
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
from services.shared.inference.models import InferenceTarget
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sensitive header detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Header names (lowercase) that are always redacted.
|
||||
SENSITIVE_HEADER_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"x-auth-token",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-secret",
|
||||
"x-access-token",
|
||||
}
|
||||
)
|
||||
|
||||
_REDACTED = "***REDACTED***"
|
||||
|
||||
# Patterns that look like bearer tokens or API keys in free text
|
||||
_TOKEN_PATTERNS: list[re.Pattern[str]] = [
|
||||
# Bearer tokens
|
||||
re.compile(r"(Bearer\s+)\S+", re.IGNORECASE),
|
||||
# Common API key formats (sk-..., pk-..., key-..., token-..., api_key_...)
|
||||
re.compile(r"\b((?:sk|pk|key|token|api[_-]?key)[_-])\S{8,}", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def redact_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
||||
"""Return a copy of headers with sensitive values replaced by a placeholder.
|
||||
|
||||
Matches header names case-insensitively against ``SENSITIVE_HEADER_NAMES``.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for name, value in headers.items():
|
||||
if name.lower() in SENSITIVE_HEADER_NAMES:
|
||||
result[name] = _REDACTED
|
||||
else:
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error message redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def redact_error_message(message: str) -> str:
|
||||
"""Remove bearer tokens and API key patterns from text.
|
||||
|
||||
Applied to error messages before they are logged or stored.
|
||||
"""
|
||||
result = message
|
||||
for pattern in _TOKEN_PATTERNS:
|
||||
result = pattern.sub(
|
||||
lambda m: m.group(1) + _REDACTED if m.lastindex else _REDACTED,
|
||||
result,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target redaction for logging / serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def redact_target_for_logging(target: InferenceTarget) -> dict[str, Any]:
|
||||
"""Serialize an InferenceTarget for logging with credentials redacted.
|
||||
|
||||
- ``auth_secret_ref`` is included as the reference name (e.g.
|
||||
"vault://inference/openai-key") so operators can identify which
|
||||
secret is in use. The target never holds the resolved secret value.
|
||||
- Sensitive headers in ``extra_headers`` are redacted.
|
||||
- ``extra_body`` is included as-is (callers should not place secrets there).
|
||||
|
||||
Requirements: 2.8
|
||||
"""
|
||||
return {
|
||||
"endpoint_id": str(target.endpoint_id),
|
||||
"deployment_id": str(target.deployment_id),
|
||||
"protocol": target.protocol,
|
||||
"base_url": target.base_url,
|
||||
"model": target.model,
|
||||
"capabilities": {
|
||||
"chat_completions": target.capabilities.chat_completions,
|
||||
"responses_api": target.capabilities.responses_api,
|
||||
"json_schema": target.capabilities.json_schema,
|
||||
"json_object": target.capabilities.json_object,
|
||||
"seed": target.capabilities.seed,
|
||||
"usage": target.capabilities.usage,
|
||||
"max_completion_tokens": target.capabilities.max_completion_tokens,
|
||||
"reasoning_toggle": target.capabilities.reasoning_toggle,
|
||||
"model_listing": target.capabilities.model_listing,
|
||||
},
|
||||
"auth_secret_ref": target.auth_secret_ref,
|
||||
"extra_headers": redact_headers(dict(target.extra_headers)),
|
||||
"extra_body": dict(target.extra_body),
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Registry resolver for the inference gateway.
|
||||
|
||||
Resolves agent stage bindings to complete InferenceTarget instances
|
||||
using TTL-cached lookups against the registry tables. Auth secrets
|
||||
are NOT resolved during caching — only at invocation time.
|
||||
|
||||
Requirements: 3.5, 3.9
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from services.shared.inference.errors import InferenceError, InferenceErrorCategory
|
||||
from services.shared.inference.models import InferenceTarget, ProviderCapabilities
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Default cache TTL in seconds
|
||||
DEFAULT_CACHE_TTL_SECONDS = 60.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RegistryCache — internal TTL dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegistryCache:
|
||||
"""TTL-based in-memory cache for registry lookups.
|
||||
|
||||
Keys follow the format:
|
||||
- "binding:{agent_id}:{stage}"
|
||||
- "endpoint:{endpoint_id}"
|
||||
- "deployment:{deployment_id}"
|
||||
|
||||
Expired entries are lazily evicted on access.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float = DEFAULT_CACHE_TTL_SECONDS) -> None:
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._store: dict[str, tuple[float, Any]] = {}
|
||||
|
||||
@property
|
||||
def ttl_seconds(self) -> float:
|
||||
return self._ttl_seconds
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
"""Retrieve a cached value if it exists and hasn't expired."""
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
stored_at, value = entry
|
||||
if (time.monotonic() - stored_at) > self._ttl_seconds:
|
||||
del self._store[key]
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Store a value with the current timestamp."""
|
||||
self._store[key] = (time.monotonic(), value)
|
||||
|
||||
def invalidate(self, key_pattern: str) -> None:
|
||||
"""Invalidate all entries whose key starts with the given pattern.
|
||||
|
||||
Supports prefix-based invalidation:
|
||||
- invalidate("endpoint:abc-123") removes that specific endpoint
|
||||
- invalidate("endpoint:") removes ALL endpoint entries
|
||||
- invalidate("binding:agent-1:") removes all bindings for agent-1
|
||||
"""
|
||||
keys_to_remove = [k for k in self._store if k.startswith(key_pattern)]
|
||||
for k in keys_to_remove:
|
||||
del self._store[k]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached entries."""
|
||||
self._store.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._store)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
"""Check if a non-expired entry exists for the key."""
|
||||
return self.get(key) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database query protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegistryDB:
|
||||
"""Protocol for registry database queries.
|
||||
|
||||
In production this would be backed by asyncpg. For testing,
|
||||
a simple dict-based mock implements the same interface.
|
||||
"""
|
||||
|
||||
async def get_active_binding(
|
||||
self, agent_id: UUID, stage: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get the active binding for an agent+stage.
|
||||
|
||||
Returns a dict with keys: id, agent_id, stage, model_deployment_id,
|
||||
route_order, routing_config, is_active, revision.
|
||||
Returns None if no active binding exists.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_model_deployment(
|
||||
self, deployment_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a model deployment by ID.
|
||||
|
||||
Returns a dict with keys: id, endpoint_id, served_model_name,
|
||||
display_name, capabilities, context_window, max_output_tokens,
|
||||
quantization, runtime_metadata, enabled, revision.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_inference_endpoint(
|
||||
self, endpoint_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get an inference endpoint by ID.
|
||||
|
||||
Returns a dict with keys: id, name, protocol, base_url,
|
||||
auth_secret_ref, auth_scheme, default_headers, health_path,
|
||||
enabled, revision.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RegistryResolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegistryResolver:
|
||||
"""Resolves agent stage bindings to InferenceTarget instances.
|
||||
|
||||
Resolution order:
|
||||
1. Find active binding for (agent_id, stage)
|
||||
2. Get model_deployment from binding
|
||||
3. Get inference_endpoint from deployment
|
||||
4. Build InferenceTarget
|
||||
|
||||
Fail-closed: any missing or disabled resource raises InferenceError
|
||||
with CAPABILITY_UNAVAILABLE. Never returns a fallback target.
|
||||
|
||||
Auth secrets are NOT resolved during caching — auth_secret_ref is
|
||||
preserved as-is in the target. Resolution happens at invocation time
|
||||
by the client layer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: RegistryDB,
|
||||
*,
|
||||
cache_ttl_seconds: float = DEFAULT_CACHE_TTL_SECONDS,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self._cache = RegistryCache(ttl_seconds=cache_ttl_seconds)
|
||||
|
||||
@property
|
||||
def cache(self) -> RegistryCache:
|
||||
"""Access the internal cache (for testing/inspection)."""
|
||||
return self._cache
|
||||
|
||||
async def resolve_target(self, agent_id: UUID, stage: str) -> InferenceTarget:
|
||||
"""Resolve the active binding for an agent+stage into an InferenceTarget.
|
||||
|
||||
Uses TTL-cached lookups. If any component is missing or disabled,
|
||||
raises InferenceError(CAPABILITY_UNAVAILABLE).
|
||||
|
||||
Auth secret is NOT resolved here — only at invocation time.
|
||||
"""
|
||||
# Check cache first for the full resolved target
|
||||
cache_key = f"binding:{agent_id}:{stage}"
|
||||
cached_target = self._cache.get(cache_key)
|
||||
if cached_target is not None:
|
||||
return cached_target
|
||||
|
||||
# Step 1: Find active binding
|
||||
binding = await self._db.get_active_binding(agent_id, stage)
|
||||
if binding is None:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"No active binding for agent={agent_id} stage={stage}",
|
||||
)
|
||||
|
||||
if not binding.get("is_active", False):
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Binding for agent={agent_id} stage={stage} is inactive",
|
||||
)
|
||||
|
||||
deployment_id = binding.get("model_deployment_id")
|
||||
if deployment_id is None:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Binding for agent={agent_id} stage={stage} has no deployment",
|
||||
)
|
||||
|
||||
# Step 2: Get model deployment
|
||||
deployment = await self._resolve_deployment(deployment_id)
|
||||
|
||||
# Step 3: Get inference endpoint
|
||||
endpoint_id = deployment["endpoint_id"]
|
||||
endpoint = await self._resolve_endpoint(endpoint_id)
|
||||
|
||||
# Step 4: Build InferenceTarget
|
||||
capabilities_data = deployment.get("capabilities", {})
|
||||
capabilities = ProviderCapabilities(
|
||||
chat_completions=capabilities_data.get("chat_completions", False),
|
||||
responses_api=capabilities_data.get("responses_api", False),
|
||||
json_schema=capabilities_data.get("json_schema", False),
|
||||
json_object=capabilities_data.get("json_object", False),
|
||||
seed=capabilities_data.get("seed", False),
|
||||
usage=capabilities_data.get("usage", False),
|
||||
max_completion_tokens=capabilities_data.get("max_completion_tokens", False),
|
||||
reasoning_toggle=capabilities_data.get("reasoning_toggle", False),
|
||||
model_listing=capabilities_data.get("model_listing", False),
|
||||
)
|
||||
|
||||
extra_headers = endpoint.get("default_headers", {})
|
||||
if not isinstance(extra_headers, dict):
|
||||
extra_headers = {}
|
||||
|
||||
runtime_metadata = deployment.get("runtime_metadata", {})
|
||||
extra_body = runtime_metadata.get("extra_body", {}) if isinstance(runtime_metadata, dict) else {}
|
||||
|
||||
target = InferenceTarget(
|
||||
endpoint_id=endpoint_id,
|
||||
deployment_id=deployment_id,
|
||||
protocol=endpoint["protocol"],
|
||||
base_url=endpoint["base_url"],
|
||||
model=deployment["served_model_name"],
|
||||
capabilities=capabilities,
|
||||
auth_secret_ref=endpoint.get("auth_secret_ref"),
|
||||
auth_scheme=endpoint.get("auth_scheme", "bearer"),
|
||||
extra_headers=extra_headers,
|
||||
extra_body=extra_body,
|
||||
context_window=deployment.get("context_window") or 0,
|
||||
max_output_tokens=deployment.get("max_output_tokens"),
|
||||
)
|
||||
|
||||
# Cache the resolved target
|
||||
self._cache.set(cache_key, target)
|
||||
|
||||
return target
|
||||
|
||||
async def _resolve_deployment(self, deployment_id: UUID) -> dict[str, Any]:
|
||||
"""Resolve a model deployment, using cache if available."""
|
||||
dep_cache_key = f"deployment:{deployment_id}"
|
||||
cached = self._cache.get(dep_cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
deployment = await self._db.get_model_deployment(deployment_id)
|
||||
if deployment is None:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Model deployment {deployment_id} not found",
|
||||
)
|
||||
|
||||
if not deployment.get("enabled", False):
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Model deployment {deployment_id} is disabled",
|
||||
)
|
||||
|
||||
self._cache.set(dep_cache_key, deployment)
|
||||
return deployment
|
||||
|
||||
async def _resolve_endpoint(self, endpoint_id: UUID) -> dict[str, Any]:
|
||||
"""Resolve an inference endpoint, using cache if available."""
|
||||
ep_cache_key = f"endpoint:{endpoint_id}"
|
||||
cached = self._cache.get(ep_cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
endpoint = await self._db.get_inference_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Inference endpoint {endpoint_id} not found",
|
||||
)
|
||||
|
||||
if not endpoint.get("enabled", False):
|
||||
raise InferenceError(
|
||||
InferenceErrorCategory.CAPABILITY_UNAVAILABLE,
|
||||
f"Inference endpoint {endpoint_id} is disabled",
|
||||
)
|
||||
|
||||
self._cache.set(ep_cache_key, endpoint)
|
||||
return endpoint
|
||||
|
||||
def invalidate(self, endpoint_id: UUID) -> None:
|
||||
"""Evict cache entries related to an endpoint.
|
||||
|
||||
Called on revision changes or probe failures to force
|
||||
re-resolution on the next request.
|
||||
"""
|
||||
self._cache.invalidate(f"endpoint:{endpoint_id}")
|
||||
# Also clear all binding caches since they may reference this endpoint
|
||||
# We clear all bindings because we can't efficiently know which bindings
|
||||
# use this endpoint without scanning
|
||||
self._cache.invalidate("binding:")
|
||||
logger.info("Invalidated cache for endpoint %s", endpoint_id)
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Full cache clear."""
|
||||
self._cache.clear()
|
||||
logger.info("Full registry cache invalidated")
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Seed migration helpers for the inference registry.
|
||||
|
||||
Provides canonical endpoint profiles, model deployments, and agent
|
||||
provider-to-stage-binding conversion logic for migrating from the legacy
|
||||
model_provider/model_name fields to the v3 registry.
|
||||
|
||||
Task 18.1-18.5: Migrate existing provider records.
|
||||
Requirements: 3.8, 3.9
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
# ─── Well-known IDs ────────────────────────────────────────────────────────────
|
||||
# These match the SQL seed migration (042_seed_inference_registry.sql)
|
||||
|
||||
OLLAMA_ENDPOINT_ID = UUID("a0000000-0000-4000-8000-000000000001")
|
||||
VLLM_ENDPOINT_ID = UUID("a0000000-0000-4000-8000-000000000002")
|
||||
OLLAMA_DEPLOYMENT_ID = UUID("b0000000-0000-4000-8000-000000000001")
|
||||
VLLM_DEPLOYMENT_ID = UUID("b0000000-0000-4000-8000-000000000002")
|
||||
|
||||
|
||||
def get_initial_endpoints() -> list[dict[str, Any]]:
|
||||
"""Return the canonical endpoint profiles for the seed migration.
|
||||
|
||||
Returns:
|
||||
List of endpoint dicts matching the inference_endpoints table schema.
|
||||
- stonks-ollama: Ollama native protocol at cluster-internal URL.
|
||||
- stonks-vllm: OpenAI-chat protocol (vLLM) at cluster-internal URL.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"id": OLLAMA_ENDPOINT_ID,
|
||||
"name": "stonks-ollama",
|
||||
"protocol": "ollama_native",
|
||||
"base_url": "http://ollama.ollama-service.svc.cluster.local:11434",
|
||||
"auth_secret_ref": None,
|
||||
"auth_scheme": "none",
|
||||
"default_headers": {},
|
||||
"health_path": "/api/tags",
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"id": VLLM_ENDPOINT_ID,
|
||||
"name": "stonks-vllm",
|
||||
"protocol": "openai_chat",
|
||||
"base_url": "http://kube-vllm.stonks-oracle.svc.cluster.local:8000",
|
||||
"auth_secret_ref": None,
|
||||
"auth_scheme": "none",
|
||||
"default_headers": {},
|
||||
"health_path": "/health",
|
||||
"enabled": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_initial_deployments() -> list[dict[str, Any]]:
|
||||
"""Return the initial model deployments for the seed migration.
|
||||
|
||||
Returns:
|
||||
List of deployment dicts matching the model_deployments table schema.
|
||||
- Ollama: qwen3.5:9b with native JSON mode.
|
||||
- vLLM: AxionML/Qwen3.5-9B-NVFP4 on RTX 4070 Ti SUPER, strict JSON Schema.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"id": OLLAMA_DEPLOYMENT_ID,
|
||||
"endpoint_id": OLLAMA_ENDPOINT_ID,
|
||||
"served_model_name": "qwen3.5:9b",
|
||||
"display_name": "Qwen 3.5 9B (Ollama)",
|
||||
"capabilities": {
|
||||
"chat_completions": True,
|
||||
"json_schema": False,
|
||||
"json_object": True,
|
||||
"seed": False,
|
||||
"usage": False,
|
||||
"max_completion_tokens": False,
|
||||
"model_listing": True,
|
||||
},
|
||||
"context_window": 32768,
|
||||
"max_output_tokens": 32768,
|
||||
"quantization": None,
|
||||
"runtime_metadata": {
|
||||
"source": "ollama_native",
|
||||
"notes": "Ollama-served model with native JSON mode",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"id": VLLM_DEPLOYMENT_ID,
|
||||
"endpoint_id": VLLM_ENDPOINT_ID,
|
||||
"served_model_name": "AxionML/Qwen3.5-9B-NVFP4",
|
||||
"display_name": "Qwen 3.5 9B NVFP4 (vLLM)",
|
||||
"capabilities": {
|
||||
"chat_completions": True,
|
||||
"json_schema": True,
|
||||
"json_object": True,
|
||||
"seed": True,
|
||||
"usage": True,
|
||||
"max_completion_tokens": True,
|
||||
"model_listing": True,
|
||||
},
|
||||
"context_window": 8192,
|
||||
"max_output_tokens": 2048,
|
||||
"quantization": "NVFP4",
|
||||
"runtime_metadata": {
|
||||
"gpu": "RTX 4070 Ti SUPER",
|
||||
"gpu_memory_utilization": 0.80,
|
||||
"max_num_seqs": 8,
|
||||
"vllm_structured_outputs": True,
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─── Provider mapping ──────────────────────────────────────────────────────────
|
||||
# Maps legacy model_provider values to endpoint/deployment IDs.
|
||||
|
||||
_PROVIDER_TO_ENDPOINT: dict[str, UUID] = {
|
||||
"ollama": OLLAMA_ENDPOINT_ID,
|
||||
"vllm": VLLM_ENDPOINT_ID,
|
||||
}
|
||||
|
||||
_PROVIDER_TO_DEPLOYMENT: dict[str, UUID] = {
|
||||
"ollama": OLLAMA_DEPLOYMENT_ID,
|
||||
"vllm": VLLM_DEPLOYMENT_ID,
|
||||
}
|
||||
|
||||
|
||||
class UnknownProviderError(ValueError):
|
||||
"""Raised when an agent record has an unrecognized model_provider value."""
|
||||
|
||||
def __init__(self, provider: str, agent_id: str) -> None:
|
||||
self.provider = provider
|
||||
self.agent_id = agent_id
|
||||
super().__init__(
|
||||
f"Unknown model_provider '{provider}' for agent '{agent_id}'. "
|
||||
f"Supported providers: {sorted(_PROVIDER_TO_ENDPOINT.keys())}. "
|
||||
f"Cannot silently convert unknown providers."
|
||||
)
|
||||
|
||||
|
||||
def convert_agent_providers(agent_records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert existing agent model_provider/model_name fields to stage bindings.
|
||||
|
||||
For each agent record with a known model_provider (ollama or vllm), produces
|
||||
a stage binding record that maps the agent to the appropriate endpoint and
|
||||
model deployment. The original model_provider and model_name fields are
|
||||
retained for backward compatibility — this function supplements, not replaces.
|
||||
|
||||
Args:
|
||||
agent_records: List of dicts with at least 'id' (or 'agent_id'),
|
||||
'model_provider', and optionally 'slug' fields.
|
||||
|
||||
Returns:
|
||||
List of stage binding dicts suitable for insertion into
|
||||
agent_stage_bindings.
|
||||
|
||||
Raises:
|
||||
UnknownProviderError: If a record has a model_provider that cannot be
|
||||
mapped. Unknown providers must NOT be silently converted.
|
||||
"""
|
||||
bindings: list[dict[str, Any]] = []
|
||||
|
||||
for record in agent_records:
|
||||
agent_id = str(record.get("id") or record.get("agent_id", ""))
|
||||
provider = (record.get("model_provider") or "").strip().lower()
|
||||
|
||||
if not provider:
|
||||
# No provider set — skip, nothing to convert
|
||||
continue
|
||||
|
||||
if provider not in _PROVIDER_TO_ENDPOINT:
|
||||
raise UnknownProviderError(provider=provider, agent_id=agent_id)
|
||||
|
||||
endpoint_id = _PROVIDER_TO_ENDPOINT[provider]
|
||||
deployment_id = _PROVIDER_TO_DEPLOYMENT[provider]
|
||||
|
||||
# Determine stage from agent slug or default to 'extraction'
|
||||
slug = record.get("slug", "")
|
||||
stage = _infer_stage_from_slug(slug)
|
||||
|
||||
bindings.append({
|
||||
"agent_id": agent_id,
|
||||
"stage": stage,
|
||||
"endpoint_id": endpoint_id,
|
||||
"model_deployment_id": str(deployment_id),
|
||||
"route_order": 0,
|
||||
"routing_config": {},
|
||||
"is_active": True,
|
||||
})
|
||||
|
||||
return bindings
|
||||
|
||||
|
||||
def _infer_stage_from_slug(slug: str) -> str:
|
||||
"""Map an agent slug to a pipeline stage name.
|
||||
|
||||
Known agent slugs and their corresponding stages:
|
||||
- document-extractor -> extraction
|
||||
- event-classifier -> classification
|
||||
- thesis-rewriter -> thesis_rewrite
|
||||
- report-summarizer -> summarization
|
||||
|
||||
Falls back to 'extraction' for unrecognized slugs.
|
||||
"""
|
||||
slug_to_stage: dict[str, str] = {
|
||||
"document-extractor": "extraction",
|
||||
"event-classifier": "classification",
|
||||
"thesis-rewriter": "thesis_rewrite",
|
||||
"report-summarizer": "summarization",
|
||||
}
|
||||
return slug_to_stage.get(slug, "extraction")
|
||||
|
||||
|
||||
# ─── Conflicting defaults identification ──────────────────────────────────────
|
||||
|
||||
# Known locations where model/provider defaults have historically conflicted.
|
||||
_KNOWN_CONFLICT_LOCATIONS: list[dict[str, str]] = [
|
||||
{
|
||||
"location": "services/shared/config.py",
|
||||
"field": "VLLMConfig.model",
|
||||
"description": "Python config default for vLLM model name",
|
||||
},
|
||||
{
|
||||
"location": "services/shared/config.py",
|
||||
"field": "VLLMConfig.base_url",
|
||||
"description": "Python config default for vLLM base URL (192.168.42.254:8000)",
|
||||
},
|
||||
{
|
||||
"location": "infra/migrations/026_ai_agents.sql",
|
||||
"field": "model_provider/model_name DEFAULT",
|
||||
"description": "Agent table DDL defaults to 'ollama'/'qwen3.5:9b-fast'",
|
||||
},
|
||||
{
|
||||
"location": "infra/migrations/031_fix_agent_defaults.sql",
|
||||
"field": "model_provider UPDATE",
|
||||
"description": "Migration updates agents to 'vllm'/'AxionML/Qwen3.5-9B-NVFP4'",
|
||||
},
|
||||
{
|
||||
"location": "infra/migrations/033_stop_hardcoding_agent_model.sql",
|
||||
"field": "model_provider UPDATE",
|
||||
"description": "Migration updates agents to 'vllm'/'AxionML/Qwen3.5-9B-NVFP4'",
|
||||
},
|
||||
{
|
||||
"location": "infra/helm/stonks-oracle/values.yaml",
|
||||
"field": "VLLM_BASE_URL / VLLM_MODEL",
|
||||
"description": "Helm values point to nuextract-external.vllm-service with NuExtract3",
|
||||
},
|
||||
{
|
||||
"location": "infra/helm/stonks-oracle/values.yaml",
|
||||
"field": "OLLAMA_BASE_URL / OLLAMA_MODEL",
|
||||
"description": "Helm values point to nuextract-external.vllm-service with NuExtract3",
|
||||
},
|
||||
{
|
||||
"location": "infra/kube-vllm/deployment.yaml",
|
||||
"field": "vLLM deployment model arg",
|
||||
"description": "Standalone kube-vllm deployment with AxionML/Qwen3.5-9B-NVFP4",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def identify_conflicting_defaults() -> list[str]:
|
||||
"""List locations where model defaults historically conflict.
|
||||
|
||||
Returns a list of human-readable strings identifying places where
|
||||
the model provider, model name, or base URL have conflicting
|
||||
defaults across config.py, migrations, Helm values, and the
|
||||
kube-vllm deployment.
|
||||
|
||||
These conflicts should be resolved after the inference registry
|
||||
is established as the single source of truth.
|
||||
|
||||
Returns:
|
||||
List of conflict description strings.
|
||||
"""
|
||||
conflicts: list[str] = []
|
||||
|
||||
for entry in _KNOWN_CONFLICT_LOCATIONS:
|
||||
conflicts.append(
|
||||
f"{entry['location']} [{entry['field']}]: {entry['description']}"
|
||||
)
|
||||
|
||||
return conflicts
|
||||
Reference in New Issue
Block a user