| import base64 |
| import json |
| import time |
| from pathlib import Path |
|
|
| import openai |
|
|
| from core.config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, MODEL_NAME |
|
|
|
|
| class LLMUnavailable(Exception): |
| pass |
|
|
|
|
| class LLM: |
| def __init__(self, api_key: str | None = None): |
| resolved = api_key if api_key is not None else DEEPSEEK_API_KEY |
| self._api_key = resolved |
| self._client = openai.OpenAI( |
| api_key=resolved or "no-key", |
| base_url=DEEPSEEK_BASE_URL, |
| ) |
|
|
| def _check_key(self) -> None: |
| if not self._api_key: |
| raise LLMUnavailable("No API key configured") |
|
|
| def chat_json(self, system: str, user: str, max_retries: int = 2) -> dict: |
| self._check_key() |
| last_exc: Exception | None = None |
| for attempt in range(max_retries + 1): |
| try: |
| resp = self._client.chat.completions.create( |
| model=MODEL_NAME, |
| messages=[ |
| {"role": "system", "content": system}, |
| {"role": "user", "content": user}, |
| ], |
| temperature=0, |
| response_format={"type": "json_object"}, |
| ) |
| content = resp.choices[0].message.content or "" |
| try: |
| return json.loads(content) |
| except json.JSONDecodeError: |
| if attempt < max_retries: |
| system = system + " Respond ONLY with valid JSON, no prose." |
| continue |
| raise LLMUnavailable("Malformed JSON after retries") |
| except openai.AuthenticationError as e: |
| raise LLMUnavailable("Invalid API key") from e |
| except (openai.APIStatusError, openai.APIConnectionError) as e: |
| last_exc = e |
| if attempt < max_retries: |
| time.sleep(2 ** attempt) |
| continue |
| raise LLMUnavailable(f"API error after retries: {last_exc}") from last_exc |
|
|
| def chat_vision( |
| self, |
| system: str, |
| user_text: str, |
| image: bytes | str | Path, |
| max_retries: int = 2, |
| ) -> str: |
| self._check_key() |
| if isinstance(image, (str, Path)): |
| raw = Path(image).read_bytes() |
| else: |
| raw = image |
| b64 = base64.b64encode(raw).decode() |
| data_uri = f"data:image/png;base64,{b64}" |
|
|
| last_exc: Exception | None = None |
| for attempt in range(max_retries + 1): |
| try: |
| resp = self._client.chat.completions.create( |
| model=MODEL_NAME, |
| messages=[ |
| {"role": "system", "content": system}, |
| {"role": "user", "content": [ |
| {"type": "text", "text": user_text}, |
| {"type": "image_url", "image_url": {"url": data_uri}}, |
| ]}, |
| ], |
| temperature=0, |
| ) |
| return resp.choices[0].message.content or "" |
| except openai.AuthenticationError as e: |
| raise LLMUnavailable("Invalid API key") from e |
| except (openai.APIStatusError, openai.APIConnectionError) as e: |
| last_exc = e |
| if attempt < max_retries: |
| time.sleep(2 ** attempt) |
| continue |
| raise LLMUnavailable(f"API error after retries: {last_exc}") from last_exc |
|
|