146 lines
4.7 KiB
Python
146 lines
4.7 KiB
Python
# OpenAI-compatible LLM client with retry, token tracking, and vision support.
|
||
|
||
import base64
|
||
import logging
|
||
import os
|
||
import time
|
||
from typing import Optional
|
||
|
||
from openai import OpenAI
|
||
|
||
logger = logging.getLogger("testflow")
|
||
|
||
|
||
class LLMClient:
|
||
"""Generic OpenAI-compatible LLM client.
|
||
|
||
Usage::
|
||
|
||
client = LLMClient(api_key="sk-xxx", base_url="https://api.deepseek.com/v1")
|
||
text = client.chat("deepseek-chat", [{"role": "user", "content": "Hello"}])
|
||
print(client.usage)
|
||
"""
|
||
|
||
TIMEOUT = 120
|
||
MAX_RETRIES = 3
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
api_key: str = "",
|
||
base_url: str = "",
|
||
model: str = "",
|
||
timeout: int | None = None,
|
||
):
|
||
if not api_key:
|
||
raise ValueError(f"API key is required for LLMClient")
|
||
self._client = OpenAI(api_key=api_key, base_url=base_url)
|
||
self._timeout = timeout or self.TIMEOUT
|
||
self._model = model
|
||
self._prompt_tokens = 0
|
||
self._completion_tokens = 0
|
||
|
||
@property
|
||
def usage(self) -> dict:
|
||
return {
|
||
"prompt_tokens": self._prompt_tokens,
|
||
"completion_tokens": self._completion_tokens,
|
||
"total_tokens": self._prompt_tokens + self._completion_tokens,
|
||
}
|
||
|
||
@staticmethod
|
||
def estimate_tokens(text: str) -> int:
|
||
cjk = sum(1 for c in text if '一' <= c <= '鿿' or ' ' <= c <= '〿')
|
||
other = len(text) - cjk
|
||
return max(1, int(cjk / 1.7 + other / 3.0))
|
||
|
||
@staticmethod
|
||
def estimate_image_tokens() -> int:
|
||
return 500
|
||
|
||
def chat(
|
||
self,
|
||
model: str,
|
||
messages: list[dict],
|
||
*,
|
||
timeout: int | None = None,
|
||
response_format: dict | None = None,
|
||
temperature: float = 0.0,
|
||
) -> str:
|
||
# Estimate prompt size
|
||
prompt_chars = sum(len(str(m.get("content", ""))) for m in messages)
|
||
label = f"chat({model})"
|
||
|
||
def _call():
|
||
t0 = time.time()
|
||
kwargs = dict(
|
||
model=model,
|
||
messages=messages,
|
||
timeout=timeout or self._timeout,
|
||
temperature=temperature,
|
||
)
|
||
if response_format is not None:
|
||
kwargs["response_format"] = response_format
|
||
logger.info("[LLM] → %s (prompt ~%d chars / ~%d tokens)",
|
||
model, prompt_chars, prompt_chars // 3)
|
||
resp = self._client.chat.completions.create(**kwargs)
|
||
content = resp.choices[0].message.content
|
||
usg = resp.usage
|
||
if usg:
|
||
self._prompt_tokens += usg.prompt_tokens
|
||
self._completion_tokens += usg.completion_tokens
|
||
elapsed = time.time() - t0
|
||
logger.info("[LLM] ← %s: %d chars, p:%d c:%d tokens, %.1fs",
|
||
model, len(content) if content else 0,
|
||
usg.prompt_tokens if usg else 0,
|
||
usg.completion_tokens if usg else 0,
|
||
elapsed)
|
||
if not content:
|
||
raise RuntimeError("Empty response from LLM")
|
||
return content
|
||
|
||
return self._retry(_call, label)
|
||
|
||
def chat_with_image(
|
||
self,
|
||
model: str,
|
||
image_path: str,
|
||
prompt: str,
|
||
*,
|
||
timeout: int | None = None,
|
||
) -> str:
|
||
"""Send a prompt with an image attachment (for vision models)."""
|
||
with open(image_path, "rb") as f:
|
||
img_b64 = base64.b64encode(f.read()).decode()
|
||
mime = self._mime_type(image_path)
|
||
|
||
messages = [{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{img_b64}"}},
|
||
{"type": "text", "text": prompt},
|
||
],
|
||
}]
|
||
return self.chat(model, messages, timeout=timeout)
|
||
|
||
@staticmethod
|
||
def _mime_type(image_path: str) -> str:
|
||
ext = os.path.splitext(image_path)[1].lstrip(".").lower()
|
||
return {
|
||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||
"gif": "image/gif", "bmp": "image/bmp",
|
||
"webp": "image/webp", "svg": "image/svg+xml", "tiff": "image/tiff",
|
||
}.get(ext, "image/png")
|
||
|
||
def _retry(self, fn, label: str) -> str:
|
||
last_error: Optional[Exception] = None
|
||
for attempt in range(self.MAX_RETRIES):
|
||
try:
|
||
return fn()
|
||
except Exception as e:
|
||
last_error = e
|
||
logger.warning("%s error (attempt %d/%d): %s", label, attempt + 1, self.MAX_RETRIES, e)
|
||
if attempt < self.MAX_RETRIES - 1:
|
||
time.sleep(2 ** attempt)
|
||
raise RuntimeError(f"{label}: all retries exhausted") from last_error
|