Stage 1: OpenAI-compatible provider layer (llm, vision, audio, metrics, debug)
This commit is contained in:
89
src/nsct/api/debug.py
Normal file
89
src/nsct/api/debug.py
Normal 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
|
||||||
@@ -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
183
src/nsct/providers/audio.py
Normal 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
314
src/nsct/providers/llm.py
Normal 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
|
||||||
83
src/nsct/providers/metrics.py
Normal file
83
src/nsct/providers/metrics.py
Normal 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
|
||||||
194
src/nsct/providers/vision.py
Normal file
194
src/nsct/providers/vision.py
Normal 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
|
||||||
Reference in New Issue
Block a user