Files
mcp-nextcloud/nextcloud_mcp_server/providers/base.py
T
Chris CoutinhoandClaude Opus 4.8 d15ce627ab refactor(usage): extract indexing metering helper; address review round 2
Round-2 claude-review findings:

- 🟡 Base-class recursion invariant: documented on embed_with_usage /
  embed_batch_with_usage that a provider overriding embed()/embed_batch() to
  delegate to the *_with_usage variant MUST also override that variant, or the
  two recurse. (No recursion today; the shipped providers pair the overrides.)
- 🟡 Processor metering had no unit test: extracted the two-event recording
  into a module-level record_indexing_usage() helper and added
  tests/unit/test_processor_metering.py (value mapping, flag/zero-chunk no-ops,
  best-effort failure swallowed).
- 🟡 SonarQube hotspots (python:S5332) were 3 http:// URLs in the new test
  fixtures (mock hosts, never contacted) blocking the quality gate
  (new_security_hotspots_reviewed). Switched them to https:// so no hotspot is
  raised.
- 🟢 Zero-chunk guard: record_indexing_usage() no-ops when chunk_count == 0, so
  an empty document no longer writes zero-value billing rows.

Deferred (stated on the PR): Mistral x.index-or-0 sort key (pre-existing,
equivalent), CHANGELOG note for the Ollama /api/embed switch (CHANGELOG is
commitizen-generated from commit bodies, which document it), class-var
query_token_count (safe under the per-request instance pattern).

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:22:03 +02:00

138 lines
4.3 KiB
Python

"""Unified provider interface for embeddings and text generation."""
import math
from abc import ABC, abstractmethod
class Provider(ABC):
"""
Unified base class for LLM providers.
Providers can support embeddings, text generation, or both.
Use capability properties to determine what features are available.
"""
@property
@abstractmethod
def supports_embeddings(self) -> bool:
"""Whether this provider supports embedding generation."""
pass
@property
@abstractmethod
def supports_generation(self) -> bool:
"""Whether this provider supports text generation."""
pass
@abstractmethod
async def embed(self, text: str) -> list[float]:
"""
Generate embedding vector for text.
Args:
text: Input text to embed
Returns:
Vector embedding as list of floats
Raises:
NotImplementedError: If provider doesn't support embeddings
"""
pass
@abstractmethod
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for multiple texts (optimized).
Args:
texts: List of texts to embed
Returns:
List of vector embeddings
Raises:
NotImplementedError: If provider doesn't support embeddings
"""
pass
@staticmethod
def _estimate_tokens(texts: list[str]) -> int:
"""Best-effort token estimate when a provider returns no usage data.
Uses a coarse ~4-chars-per-token heuristic so the billable token
value stays non-zero and monotone with input size for local/dev
providers (Simple, Ollama without ``prompt_eval_count``). Real
providers override ``*_with_usage`` to report exact counts.
"""
return math.ceil(sum(len(t) for t in texts) / 4)
async def embed_with_usage(self, text: str) -> tuple[list[float], int]:
"""Embed one text and report the request's token count.
Returns ``(embedding, token_count)``. The default delegates to
:meth:`embed` and estimates the tokens; providers that surface real
usage from their embedding response override this. Used by the
usage-metering hooks (Deck #67) to bill ``embeddings_queries`` by
tokens rather than by operation count.
IMPORTANT (recursion invariant): this default calls ``self.embed``. A
provider that overrides ``embed()`` to delegate to ``embed_with_usage()``
(to avoid duplicating request logic) MUST also override this method, or
the two will call each other forever. The shipped providers that use
that delegation (Bedrock) do override both — keep that pairing.
"""
embedding = await self.embed(text)
return embedding, self._estimate_tokens([text])
async def embed_batch_with_usage(
self, texts: list[str]
) -> tuple[list[list[float]], int]:
"""Embed multiple texts and report the total token count.
Returns ``(embeddings, token_count)``; the default estimates. See
:meth:`embed_with_usage`.
IMPORTANT (recursion invariant): this default calls ``self.embed_batch``.
A provider that overrides ``embed_batch()`` to delegate to
``embed_batch_with_usage()`` (Mistral, OpenAI, Ollama do) MUST also
override this method, or the two recurse infinitely. Keep the pairing.
"""
embeddings = await self.embed_batch(texts)
return embeddings, self._estimate_tokens(texts)
@abstractmethod
def get_dimension(self) -> int:
"""
Get embedding dimension for this provider.
Returns:
Vector dimension (e.g., 768 for nomic-embed-text)
Raises:
NotImplementedError: If provider doesn't support embeddings
"""
pass
@abstractmethod
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
"""
Generate text from a prompt.
Args:
prompt: The prompt to generate from
max_tokens: Maximum tokens to generate
Returns:
Generated text
Raises:
NotImplementedError: If provider doesn't support generation
"""
pass
@abstractmethod
async def close(self) -> None:
"""Close the provider and release resources."""
pass