managarten/services/mana-llm/src/providers/base.py
Till JS e757470cb0 feat(mana-llm): add OpenAI-style tools + tool_calls passthrough
Extends the chat-completions surface so callers can ask any provider
to call named functions and get structured tool_calls back. Wired
through all three provider adapters so the planner and companion can
switch off the fragile JSON-parsing pathway.

- Request: tools[], tool_choice, assistant tool_calls, tool-role
  messages with tool_call_id.
- Response: MessageResponse.tool_calls, Choice.finish_reason adds
  "tool_calls", DeltaContent streams tool_calls.
- Google provider: Tool(function_declarations=...) build, result
  normalised (args dict → JSON string), function_response parts on
  a user turn for tool-role messages.
- OpenAI-compat: 1:1 passthrough of the OpenAI spec.
- Ollama: /api/chat passthrough; model-level capability check via a
  TOOL_CAPABLE_OLLAMA_PATTERNS whitelist (llama3.1+, qwen2.5+,
  mistral, command-r, …) — unsupported models rejected rather than
  silently falling back to prose.
- Router: model_supports_tools() check upfront for both streaming
  and non-streaming paths; ProviderCapabilityError bubbles as 400.

No silent downgrade. Missing tool support = explicit error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:22:48 +02:00

76 lines
2.1 KiB
Python

"""Abstract base class for LLM providers."""
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from typing import Any
from src.models import (
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionStreamResponse,
EmbeddingRequest,
EmbeddingResponse,
ModelInfo,
)
class LLMProvider(ABC):
"""Abstract base class for LLM providers."""
name: str = "base"
# Set to True if the provider supports OpenAI-style `tools` + `tool_calls`
# for chat completions. The router rejects tool-bearing requests routed
# to providers without support rather than silently dropping the tools.
# Provider adapters may further narrow this per-model if needed.
supports_tools: bool = False
def model_supports_tools(self, model: str) -> bool:
"""Check if a specific model within this provider supports tools.
Default: falls back to the provider-wide flag. Providers with a
mixed capability surface (e.g. Ollama — depends on the local
model) override this.
"""
return self.supports_tools
@abstractmethod
async def chat_completion(
self,
request: ChatCompletionRequest,
model: str,
) -> ChatCompletionResponse:
"""Generate a chat completion (non-streaming)."""
...
@abstractmethod
async def chat_completion_stream(
self,
request: ChatCompletionRequest,
model: str,
) -> AsyncIterator[ChatCompletionStreamResponse]:
"""Generate a chat completion (streaming)."""
...
@abstractmethod
async def list_models(self) -> list[ModelInfo]:
"""List available models."""
...
@abstractmethod
async def embeddings(
self,
request: EmbeddingRequest,
model: str,
) -> EmbeddingResponse:
"""Generate embeddings for input text."""
...
@abstractmethod
async def health_check(self) -> dict[str, Any]:
"""Check provider health status."""
...
async def close(self) -> None:
"""Clean up resources."""
pass