Files
meeting-room/meeting_room/api_client.py
vitya 6bbbefaa6f feat: refactor APIClient and ToolRegistry to classes
- api_client.py: APIClient class with ProviderConfig types,
  chat_stream() returns str instead of printing,
  module-level wrappers for backward compat
- tools.py: ToolRegistry class with instance methods,
  pure Python list_files (os.walk) and search_in_files (re.search)
  replacing find/grep for Windows compat,
  English tool descriptions and error messages,
  module-level wrappers for backward compat
- 74 new tests (21 api_client + 53 tools)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:09:41 +03:00

178 lines
5.4 KiB
Python

"""Universal API client supporting multiple OpenAI-compatible providers."""
from __future__ import annotations
import json
from typing import Iterator
import httpx
from meeting_room.models import ProviderConfig
class APIClient:
"""Stateful client that holds provider configs and makes LLM calls.
Parameters
----------
providers:
Mapping of provider name to ``ProviderConfig`` (base_url + api_key).
"""
def __init__(self, providers: dict[str, ProviderConfig]) -> None:
self.providers: dict[str, ProviderConfig] = providers
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_client(self, provider_name: str) -> httpx.Client:
"""Create a short-lived ``httpx.Client`` for *provider_name*."""
p = self.providers.get(provider_name)
if not p:
raise ValueError(f"Unknown provider: {provider_name}")
return httpx.Client(
base_url=p.base_url,
headers={"Authorization": f"Bearer {p.api_key}"},
timeout=120.0,
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def chat(
self,
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
tools: list[dict] | None = None,
) -> dict:
"""Send a non-streaming chat completion request.
Returns a dict with keys ``content`` (str) and ``tool_calls``
(list | None).
"""
client = self._get_client(provider)
payload: dict = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
resp = client.post("/chat/completions", json=payload)
resp.raise_for_status()
data = resp.json()
message = data["choices"][0]["message"]
result: dict = {
"content": message.get("content") or "",
"tool_calls": None,
}
if message.get("tool_calls"):
result["tool_calls"] = [
{
"id": tc["id"],
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}
for tc in message["tool_calls"]
]
return result
def chat_stream(
self,
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
) -> str:
"""Send a streaming chat completion request.
Accumulates all content deltas and **returns** the full text.
Does NOT print to stdout.
"""
client = self._get_client(provider)
payload: dict = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
full_text = ""
with client.stream("POST", "/chat/completions", json=payload) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line or not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"]
if "content" in delta and delta["content"]:
full_text += delta["content"]
except (json.JSONDecodeError, KeyError, IndexError):
continue
return full_text
# ======================================================================
# Module-level backward-compatible wrappers
# ======================================================================
_default_client: APIClient | None = None
def init_providers(config: dict) -> None:
"""Initialise the module-level default ``APIClient``.
*config* is the raw dict produced by ``yaml.safe_load()`` — it must
contain a ``providers`` key whose value maps provider names to
``{base_url, api_key}`` dicts.
"""
global _default_client
raw: dict = config.get("providers", {})
providers: dict[str, ProviderConfig] = {
name: ProviderConfig(**vals) for name, vals in raw.items()
}
_default_client = APIClient(providers)
def _require_default() -> APIClient:
if _default_client is None:
raise RuntimeError(
"init_providers() must be called before using module-level chat/chat_stream"
)
return _default_client
def chat(
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
tools: list[dict] | None = None,
) -> dict:
"""Module-level wrapper that delegates to the default ``APIClient``."""
return _require_default().chat(provider, model, messages, temperature, tools)
def chat_stream(
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
) -> str:
"""Module-level wrapper that delegates to the default ``APIClient``.
Returns the accumulated text — does NOT print to stdout.
"""
return _require_default().chat_stream(provider, model, messages, temperature)