Stage 1: OpenAI-compatible provider layer (llm, vision, audio, metrics, debug)

This commit is contained in:
NSCT Agent
2026-08-23 11:54:50 +00:00
parent e9410be941
commit 9280d69ebf
6 changed files with 901 additions and 1 deletions

89
src/nsct/api/debug.py Normal file
View File

@@ -0,0 +1,89 @@
"""Debug endpoints — model health / connectivity checks (only when NSCT_DEBUG=true)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from nsct.config import AppSettings
from nsct.providers.llm import LLMProvider, get_provider as get_llm_provider
from nsct.providers.metrics import ProviderMetrics
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class LLMProbeRequest(BaseModel):
"""Simple prompt for an LLM probe."""
prompt: str = Field(..., description="Input text sent to the model.")
class LLMProbeResponse(BaseModel):
"""Response from the LLM probe."""
response: str = Field(..., description="The model's text response.")
model: str = Field(..., description="Model identifier used.")
class ModelInfoItem(BaseModel):
"""One discovered model entry."""
id: str
object: str = "model"
owned_by: str | None = None
class ModelListResponse(BaseModel):
"""List of discovered models."""
models: list[ModelInfoItem]
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def create_debug_router(
config: AppSettings,
metrics: ProviderMetrics,
) -> APIRouter:
"""Create a debug router. Routes are only active when config.debug is True."""
router = APIRouter()
@router.post("/debug/models/llm", response_model=LLMProbeResponse)
async def probe_llm(body: LLMProbeRequest) -> LLMProbeResponse:
"""Send a prompt to the configured LLM and return the response.
Only available when NSCT_DEBUG=true.
"""
if not config.debug:
raise HTTPException(status_code=404, detail="Debug endpoints are disabled")
llm_provider = get_llm_provider(config, metrics)
messages: list[dict[str, str]] = [{"role": "user", "content": body.prompt}]
result = await llm_provider.complete(messages)
return LLMProbeResponse(response=result, model=config.llm.model)
@router.get("/debug/models/llm", response_model=ModelListResponse)
async def list_llm_models() -> ModelListResponse:
"""List available LLM model IDs.
Only available when NSCT_DEBUG=true.
"""
if not config.debug:
raise HTTPException(status_code=404, detail="Debug endpoints are disabled")
llm_provider = get_llm_provider(config, metrics)
ids = await llm_provider.list_models()
return ModelListResponse(models=[ModelInfoItem(id=mid) for mid in ids])
return router

View File

@@ -1 +1,38 @@
"""NSCT — providers package init."""
"""NSCT — providers package init.
Re-exports all provider classes and metric classes for convenient imports.
"""
from __future__ import annotations
from nsct.providers.audio import _AudioProviderImpl
from nsct.providers.llm import (
LLMProvider,
ProviderError,
ProviderHTTPError,
ProviderModelNotFoundError,
ProviderRateLimitError,
ProviderTimeoutError,
_LLMProviderImpl,
)
from nsct.providers.metrics import ProviderMetrics
from nsct.providers.vision import _VisionProviderImpl
# Aliases for convenience
VisionProvider = _VisionProviderImpl
AudioProvider = _AudioProviderImpl
__all__ = [
"LLMProvider",
"_LLMProviderImpl",
"ProviderError",
"ProviderHTTPError",
"ProviderModelNotFoundError",
"ProviderRateLimitError",
"ProviderTimeoutError",
"ProviderMetrics",
"VisionProvider",
"_VisionProviderImpl",
"AudioProvider",
"_AudioProviderImpl",
]

183
src/nsct/providers/audio.py Normal file
View File

@@ -0,0 +1,183 @@
"""OpenAI-compatible audio / STT provider."""
from __future__ import annotations
import asyncio
import io
import logging
import os
import time
from typing import Any
import httpx
from httpx import HTTPStatusError
from openai import AsyncOpenAI
from nsct.config import AppSettings
from nsct.providers.llm import (
ProviderError,
ProviderHTTPError,
ProviderModelNotFoundError,
ProviderRateLimitError,
ProviderTimeoutError,
_RetryPolicy,
)
from nsct.providers.metrics import ProviderMetrics
logger = logging.getLogger(__name__)
class _AudioProviderImpl:
"""OpenAI-compatible audio transcription / STT provider."""
def __init__(self, config: AppSettings, metrics: ProviderMetrics) -> None:
self._config = config
self._metrics = metrics
self._base_url: str = config.audio.base_url.rstrip("/")
self._model: str = config.audio.model
self._api_key: str = config.audio.get_secret()
self._connect_timeout: float = float(
os.environ.get("NSCT_AUDIO_CONNECT_TIMEOUT", "30")
)
self._read_timeout: float = float(
os.environ.get("NSCT_AUDIO_READ_TIMEOUT", "120")
)
self._client: AsyncOpenAI | None = None
@property
def client(self) -> AsyncOpenAI:
if self._client is None:
self._client = self._create_client()
return self._client
def _create_client(self) -> AsyncOpenAI:
http_client = httpx.AsyncClient(
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
),
timeout=httpx.Timeout(
connect=self._connect_timeout,
read=self._read_timeout,
write=self._read_timeout,
pool=5,
),
)
return AsyncOpenAI(
base_url=self._base_url,
api_key=self._api_key,
http_client=http_client,
)
async def _ensure_client(self) -> AsyncOpenAI:
if self._client is None:
self._client = self._create_client()
return self._client
async def _request_with_retry(
self,
fn, # noqa: ANN202
*args: Any,
**kwargs: Any,
) -> Any:
last_exc: Exception | None = None
for attempt in range(_RetryPolicy.DEFAULT + 1):
try:
return await fn(*args, **kwargs)
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.PoolTimeout) as exc:
last_exc = ProviderTimeoutError(str(exc))
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_audio_request()
await self._metrics.record_llm_error(error_type="audio_timeout")
raise
except HTTPStatusError as exc:
status = exc.response.status_code
if status == 429:
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="audio_rate_limit")
raise
elif 500 <= status < 600:
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type=f"audio_http_{status}")
raise
else:
await self._metrics.record_llm_error(error_type=f"audio_http_{status}")
raise
except Exception as exc:
last_exc = exc
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="audio_unexpected")
raise
raise last_exc # type: ignore[misc]
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def transcribe(
self,
audio_file_path: str,
language: str | None = None,
prompt: str | None = None,
model: str | None = None,
) -> dict[str, Any]:
"""Transcribe an audio file. Returns ``{text, language, duration}``."""
model = model or self._model
async def _do() -> dict[str, Any]:
client = await self._ensure_client()
with open(audio_file_path, "rb") as fh:
audio_data = fh.read()
build: dict[str, Any] = {
"model": model,
"file": ("audio.wav", io.BytesIO(audio_data), "audio/wav"),
}
if language is not None:
build["language"] = language
if prompt is not None:
build["prompt"] = prompt
resp = await client.audio.transcriptions.create(**build)
result = {
"text": getattr(resp, "text", ""),
"language": getattr(resp, "language", ""),
"duration": getattr(resp, "duration", 0.0),
}
await self._metrics.record_audio_request()
return result
return await self._request_with_retry(_do)
async def list_models(self) -> list[str]:
"""Discover available audio model IDs."""
async def _do() -> list[str]:
client = await self._ensure_client()
resp = await client.models.list()
return [m.id for m in resp.data]
return await self._request_with_retry(_do)
# ---------------------------------------------------------------------------
# Singleton factory
# ---------------------------------------------------------------------------
_instance: _AudioProviderImpl | None = None
def get_provider(config: AppSettings, metrics: ProviderMetrics) -> _AudioProviderImpl:
global _instance
if _instance is None:
_instance = _AudioProviderImpl(config, metrics)
return _instance

314
src/nsct/providers/llm.py Normal file
View File

@@ -0,0 +1,314 @@
"""OpenAI-compatible LLM provider with timeout, retry, pooling, metrics."""
from __future__ import annotations
import asyncio
import enum
import logging
import os
import time
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator, cast
import httpx
from httpx import HTTPStatusError
from openai import AsyncOpenAI, AsyncStream
from openai._types import NOT_GIVEN, NotGiven
from openai.types.chat import ChatCompletion, ChatCompletionChunk
from nsct.config import AppSettings
from nsct.providers.metrics import ProviderMetrics
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Structured errors
# ---------------------------------------------------------------------------
class ProviderError(Exception):
"""Base exception for provider errors."""
class ProviderTimeoutError(ProviderError):
"""Request timed out."""
class ProviderRateLimitError(ProviderError):
"""Rate-limited by the provider."""
class ProviderModelNotFoundError(ProviderError):
"""Requested model not found."""
class ProviderHTTPError(ProviderError):
"""Generic HTTP error (non-2xx)."""
def __init__(self, status_code: int, message: str) -> None:
super().__init__(f"HTTP {status_code}: {message}")
self.status_code = status_code
# ---------------------------------------------------------------------------
# Retries
# ---------------------------------------------------------------------------
class _RetryPolicy(enum.IntEnum):
"""Number of retries for transient failures."""
DEFAULT = 3
MAX_WAIT_SEC = 8 # 1s, 2s, 4s
# ---------------------------------------------------------------------------
# LLM Provider
# ---------------------------------------------------------------------------
class LLMProvider(ABC):
"""Abstract base for LLM providers."""
@abstractmethod
async def complete(
self,
messages: list[dict[str, str]],
model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
response_format: dict[str, Any] | None = None,
) -> str:
"""Issue a chat-completions request and return the assistant text."""
...
@abstractmethod
async def stream_complete(
self,
messages: list[dict[str, str]],
model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat completions. Yields delta content chunks."""
...
@abstractmethod
async def list_models(self) -> list[str]:
"""Discover available model IDs."""
...
class _LLMProviderImpl(LLMProvider):
"""Concrete OpenAI-compatible LLM provider."""
def __init__(self, config: AppSettings, metrics: ProviderMetrics) -> None:
self._config = config
self._metrics = metrics
self._base_url: str = config.llm.base_url
self._model: str = config.llm.model
self._max_concurrency: int = config.llm.max_concurrency
self._api_key: str = config.llm.get_secret()
# Timeout config (seconds) — configurable via env, defaults here
self._connect_timeout: float = float(
os.environ.get("NSCT_LLM_CONNECT_TIMEOUT", "30")
)
self._read_timeout: float = float(
os.environ.get("NSCT_LLM_READ_TIMEOUT", "120")
)
self._client: AsyncOpenAI | None = None
@property
def client(self) -> AsyncOpenAI:
"""Lazy-init the OpenAI client with connection pooling."""
if self._client is None:
self._client = self._create_client()
return self._client
def _create_client(self) -> AsyncOpenAI:
"""Build an AsyncOpenAI client with httpx connection pooling."""
http_client = httpx.AsyncClient(
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
),
timeout=httpx.Timeout(
connect=self._connect_timeout,
read=self._read_timeout,
write=self._read_timeout,
pool=5,
),
)
return AsyncOpenAI(
base_url=self._base_url.rstrip("/") + "/v1",
api_key=self._api_key,
http_client=http_client,
)
async def _ensure_client(self) -> AsyncOpenAI:
"""Make sure the client is fresh and usable."""
if self._client is None:
self._client = self._create_client()
return self._client
async def _request_with_retry(
self,
fn, # noqa: ANN202
*args: Any,
**kwargs: Any,
) -> Any:
"""Execute *fn* with exponential-backoff retries for transient errors."""
last_exc: Exception | None = None
for attempt in range(_RetryPolicy.DEFAULT + 1):
try:
return await fn(*args, **kwargs)
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.PoolTimeout) as exc:
last_exc = ProviderTimeoutError(str(exc))
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="timeout")
raise
except HTTPStatusError as exc:
status = exc.response.status_code
if status == 429:
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="rate_limit")
raise
elif 500 <= status < 600:
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type=f"http_{status}")
raise
else:
await self._metrics.record_llm_error(error_type=f"http_{status}")
raise
except Exception as exc:
last_exc = exc
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="unexpected")
raise
raise last_exc # type: ignore[misc]
async def complete(
self,
messages: list[dict[str, str]],
model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
response_format: dict[str, Any] | None = None,
) -> str:
"""Issue a chat-completions request and return the assistant text."""
model = model or self._model
async def _do() -> str:
client = await self._ensure_client()
build: dict[str, Any] = {
"model": model,
"messages": messages,
}
if temperature is not None:
build["temperature"] = temperature
if max_tokens is not None:
build["max_tokens"] = max_tokens
if response_format is not None:
build["response_format"] = response_format
start = time.monotonic()
resp: ChatCompletion = await client.chat.completions.create(**build) # type: ignore[arg-type]
latency = time.monotonic() - start
input_tokens = (resp.usage.completion_tokens if resp.usage else 0) + (
resp.usage.prompt_tokens if resp.usage else 0
)
output_tokens = resp.usage.completion_tokens if resp.usage else 0
await self._metrics.record_llm_request(
input_tokens=input_tokens,
output_tokens=output_tokens,
latency=latency,
)
choice = resp.choices[0]
return choice.message.content or ""
return await self._request_with_retry(_do)
async def stream_complete(
self,
messages: list[dict[str, str]],
model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat completions. Yields delta content chunks."""
model = model or self._model
tokens_input = 0
tokens_output = 0
start = time.monotonic()
async for chunk in (await self._make_stream(messages, model, temperature, max_tokens)):
delta = chunk.choices[0].delta if chunk.choices and chunk.choices[0] else None
delta_text = delta.content if delta and delta.content else ""
if delta_text:
yield delta_text
tokens_output += 1
latency = time.monotonic() - start
for msg in messages:
tokens_input += len(msg.get("content", "").split())
await self._metrics.record_llm_request(
input_tokens=tokens_input,
output_tokens=tokens_output,
latency=latency,
)
async def _make_stream(
self,
messages: list[dict[str, str]],
model: str,
temperature: float | None,
max_tokens: int | None,
) -> AsyncStream[ChatCompletionChunk]:
"""Helper: build and return the async stream."""
client = await self._ensure_client()
build: dict[str, Any] = {
"model": model,
"messages": messages,
}
if temperature is not None:
build["temperature"] = temperature
if max_tokens is not None:
build["max_tokens"] = max_tokens
return await client.chat.completions.create(stream=True, **build) # type: ignore[arg-type]
async def list_models(self) -> list[str]:
"""Discover available model IDs via GET /v1/models."""
async def _do() -> list[str]:
client = await self._ensure_client()
resp = await client.models.list()
return [m.id for m in resp.data]
return await self._request_with_retry(_do)
# ---------------------------------------------------------------------------
# Singleton factory
# ---------------------------------------------------------------------------
_instance: _LLMProviderImpl | None = None
def get_provider(config: AppSettings, metrics: ProviderMetrics) -> LLMProvider:
"""Return a cached LLMProvider singleton."""
global _instance
if _instance is None:
_instance = _LLMProviderImpl(config, metrics)
return _instance

View File

@@ -0,0 +1,83 @@
"""Provider metrics — thread-safe collection of request / token / latency counters."""
from __future__ import annotations
import asyncio
import time
from collections import defaultdict
from typing import Any
class ProviderMetrics:
"""Collects request-level and token-level metrics for all providers."""
def __init__(self) -> None: # noqa: PLR0913
self._lock = asyncio.Lock()
# Counters
self.llm_requests_total: int = 0
self.llm_tokens_input_total: int = 0
self.llm_tokens_output_total: int = 0
self.vision_requests_total: int = 0
self.audio_requests_total: int = 0
# Error bucket: error_type -> count
self._llm_errors: dict[str, int] = defaultdict(int)
# Latency tracking: list of seconds per successful call
self._llm_latency_samples: list[float] = []
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
async def record_llm_request(self, *, input_tokens: int, output_tokens: int, latency: float) -> None:
"""Record a successful LLM request (token counts, latency)."""
async with self._lock:
self.llm_requests_total += 1
self.llm_tokens_input_total += input_tokens
self.llm_tokens_output_total += output_tokens
self._llm_latency_samples.append(latency)
async def record_llm_error(self, *, error_type: str) -> None:
"""Record a failed LLM call."""
async with self._lock:
self.llm_requests_total += 1
self._llm_errors[error_type] += 1
async def record_vision_request(self) -> None:
"""Record a successful vision request."""
async with self._lock:
self.vision_requests_total += 1
async def record_audio_request(self) -> None:
"""Record a successful audio request."""
async with self._lock:
self.audio_requests_total += 1
async def get_metrics(self) -> dict[str, Any]:
"""Return a flat dict suitable for Prometheus exporters or logging."""
async with self._lock:
samples = list(self._llm_latency_samples)
avg_latency = (sum(samples) / len(samples)) if samples else 0.0
return {
"llm_requests_total": self.llm_requests_total,
"llm_tokens_input_total": self.llm_tokens_input_total,
"llm_tokens_output_total": self.llm_tokens_output_total,
"llm_errors_total": dict(self._llm_errors),
"llm_avg_latency_seconds": round(avg_latency, 4),
"vision_requests_total": self.vision_requests_total,
"audio_requests_total": self.audio_requests_total,
}
# ------------------------------------------------------------------
# Reset helpers (useful in tests)
# ------------------------------------------------------------------
async def reset(self) -> None:
"""Reset all counters to zero."""
async with self._lock:
self.llm_requests_total = 0
self.llm_tokens_input_total = 0
self.llm_tokens_output_total = 0
self._llm_errors.clear()
self._llm_latency_samples.clear()
self.vision_requests_total = 0
self.audio_requests_total = 0

View File

@@ -0,0 +1,194 @@
"""OpenAI-compatible vision provider (image analysis)."""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any
import httpx
from httpx import HTTPStatusError
from openai import AsyncOpenAI
from nsct.config import AppSettings
from nsct.providers.llm import (
ProviderError,
ProviderHTTPError,
ProviderModelNotFoundError,
ProviderRateLimitError,
ProviderTimeoutError,
_RetryPolicy,
)
from nsct.providers.metrics import ProviderMetrics
logger = logging.getLogger(__name__)
class _VisionProviderImpl:
"""OpenAI-compatible vision / image-analysis provider."""
def __init__(self, config: AppSettings, metrics: ProviderMetrics) -> None:
self._config = config
self._metrics = metrics
self._base_url: str = config.vision.base_url
self._model: str = config.vision.model
self._api_key: str = config.vision.get_secret()
self._connect_timeout: float = float(
os.environ.get("NSCT_VISION_CONNECT_TIMEOUT", "30")
)
self._read_timeout: float = float(
os.environ.get("NSCT_VISION_READ_TIMEOUT", "120")
)
self._client: AsyncOpenAI | None = None
@property
def client(self) -> AsyncOpenAI:
if self._client is None:
self._client = self._create_client()
return self._client
def _create_client(self) -> AsyncOpenAI:
http_client = httpx.AsyncClient(
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
),
timeout=httpx.Timeout(
connect=self._connect_timeout,
read=self._read_timeout,
write=self._read_timeout,
pool=5,
),
)
return AsyncOpenAI(
base_url=self._base_url.rstrip("/") + "/v1",
api_key=self._api_key,
http_client=http_client,
)
async def _ensure_client(self) -> AsyncOpenAI:
if self._client is None:
self._client = self._create_client()
return self._client
async def _request_with_retry(
self,
fn, # noqa: ANN202
*args: Any,
**kwargs: Any,
) -> Any:
last_exc: Exception | None = None
for attempt in range(_RetryPolicy.DEFAULT + 1):
try:
return await fn(*args, **kwargs)
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.PoolTimeout) as exc:
last_exc = ProviderTimeoutError(str(exc))
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="vision_timeout")
raise
except HTTPStatusError as exc:
status = exc.response.status_code
if status == 429:
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="vision_rate_limit")
raise
elif 500 <= status < 600:
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type=f"vision_http_{status}")
raise
else:
await self._metrics.record_llm_error(error_type=f"vision_http_{status}")
raise
except Exception as exc:
last_exc = exc
if attempt < _RetryPolicy.DEFAULT:
await asyncio.sleep(2 ** attempt)
else:
await self._metrics.record_llm_error(error_type="vision_unexpected")
raise
raise last_exc # type: ignore[misc]
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def analyze(
self,
image_url_or_base64: str,
prompt: str,
model: str | None = None,
) -> str:
"""Send an image (URL or base64) + text prompt and get analysis text."""
model = model or self._model
start = time.monotonic()
async def _do() -> str:
client = await self._ensure_client()
# Build content array: text part + image part
# Using a plain list — OpenAI accepts it as the content argument
content: list[dict[str, Any]] = [
{"type": "text", "text": prompt},
]
if image_url_or_base64.startswith(("http://", "https://")):
content.append({
"type": "image_url",
"image_url": {"url": image_url_or_base64},
})
else:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_url_or_base64}"},
})
resp = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": content}],
)
return resp.choices[0].message.content or ""
result = await self._request_with_retry(_do)
await self._metrics.record_vision_request()
return result
async def analyze_multiple(
self,
images_and_prompts: list[tuple[str, str]],
model: str | None = None,
) -> list[str]:
"""Analyze multiple images concurrently. Each item is (image, prompt)."""
tasks = [self.analyze(img, pmt, model=model) for img, pmt in images_and_prompts]
results = await asyncio.gather(*tasks)
return list(results)
async def list_models(self) -> list[str]:
"""Discover available vision model IDs."""
async def _do() -> list[str]:
client = await self._ensure_client()
resp = await client.models.list()
return [m.id for m in resp.data]
return await self._request_with_retry(_do)
# ---------------------------------------------------------------------------
# Singleton factory
# ---------------------------------------------------------------------------
_instance: _VisionProviderImpl | None = None
def get_provider(config: AppSettings, metrics: ProviderMetrics) -> _VisionProviderImpl:
global _instance
if _instance is None:
_instance = _VisionProviderImpl(config, metrics)
return _instance