File size: 13,604 Bytes
39ff632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ab43d7
 
 
 
 
 
 
 
 
39ff632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""Async client for Anthropic-compatible Messages APIs (with an
OpenAI-compatible chat/completions fallback).

Verified wire protocol (2026-07, against https://openrouter.ai/api and
https://api.anthropic.com):

  POST {base}/v1/messages          (or {base}/messages when base ends /v1)
  headers: x-api-key, authorization: Bearer, anthropic-version: 2023-06-01
  body:    {model, max_tokens, system, messages:[{role, content:[
               {type:"image", source:{type:"base64", media_type, data}},
               {type:"text", text}]}]}
  reply:   {content:[{type:"text", text}], stop_reason, ...}

OpenAI fallback (only on HTTP 404, or when LLM_API_STYLE=openai):
  POST {base}/chat/completions (base normalised to include /v1)
  headers: authorization: Bearer
  body:    {model, max_tokens, messages:[{role:"user", content:[
               {type:"text", text}, {type:"image_url",
                image_url:{url:"data:image/png;base64,..."}}]}]}
  reply:   {choices:[{message:{content}}]}

Retry policy: retry at most ``max_retries`` times on 408/409/429/5xx and
transport errors with exponential backoff + jitter, honouring Retry-After.
Never retry 400/401/403/404-style client faults. The API key is never logged
or included in error messages.
"""

from __future__ import annotations

import asyncio
import json
import random
import re
from dataclasses import dataclass

import httpx

from .config import Settings

ANTHROPIC_VERSION = "2023-06-01"
_RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}


class LLMError(Exception):
    """User-safe LLM failure description (never contains credentials)."""

    def __init__(self, message: str, *, code: str = "llm_error",
                 status: int | None = None) -> None:
        super().__init__(message)
        self.code = code
        self.status = status


@dataclass(frozen=True)
class LLMResponse:
    text: str
    stop_reason: str | None
    style: str  # "anthropic" | "openai"


def user_turn(prompt: str, image_png: bytes | None = None) -> dict:
    """Anthropic-format user message; optionally with a PNG image block."""
    content: list[dict] = []
    if image_png is not None:
        import base64

        content.append({
            "type": "image",
            "source": {"type": "base64", "media_type": "image/png",
                       "data": base64.b64encode(image_png).decode("ascii")},
        })
    content.append({"type": "text", "text": prompt})
    return {"role": "user", "content": content}


def assistant_turn(text: str) -> dict:
    return {"role": "assistant", "content": [{"type": "text", "text": text}]}


def _messages_url(base: str) -> str:
    base = base.rstrip("/")
    return base + "/messages" if base.endswith("/v1") else base + "/v1/messages"


def _chat_completions_url(base: str) -> str:
    base = base.rstrip("/")
    return base + "/chat/completions" if base.endswith("/v1") else base + "/v1/chat/completions"


def _headers(settings: Settings, style: str) -> dict[str, str]:
    headers = {"content-type": "application/json"}
    key = settings.llm_api_key or ""
    if style == "anthropic":
        # Both header conventions are sent: Anthropic accepts x-api-key;
        # OpenRouter's anthropic-compatible endpoint accepts either.
        headers["x-api-key"] = key
        headers["authorization"] = f"Bearer {key}"
        headers["anthropic-version"] = ANTHROPIC_VERSION
    else:
        headers["authorization"] = f"Bearer {key}"
    if settings.llm_referer:
        headers["HTTP-Referer"] = settings.llm_referer
    if settings.llm_title:
        headers["X-Title"] = settings.llm_title
    return headers


def _anthropic_body(settings: Settings, system: str, messages: list[dict]) -> dict:
    return {
        "model": settings.llm_model,
        "max_tokens": settings.llm_max_tokens,
        "system": system,
        "messages": messages,
    }


def _openai_body(settings: Settings, system: str, messages: list[dict]) -> dict:
    converted: list[dict] = [{"role": "system", "content": system}]
    for item in messages:
        role = item.get("role", "user")
        parts = item.get("content")
        if isinstance(parts, str):
            converted.append({"role": role, "content": parts})
            continue
        out_parts: list[dict] = []
        for part in parts if isinstance(parts, list) else []:
            if not isinstance(part, dict):
                continue
            if part.get("type") == "text":
                out_parts.append({"type": "text", "text": part.get("text", "")})
            elif part.get("type") == "image":
                source = part.get("source") or {}
                url = f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}"
                out_parts.append({"type": "image_url", "image_url": {"url": url}})
        # Collapse text-only content to a plain string (canonical OpenAI shape).
        if out_parts and all(p["type"] == "text" for p in out_parts):
            converted.append({"role": role,
                              "content": "".join(p["text"] for p in out_parts)})
        else:
            converted.append({"role": role, "content": out_parts})
    return {
        "model": settings.llm_model,
        "max_tokens": settings.llm_max_tokens,
        "messages": converted,
    }


def _parse_anthropic(payload: dict) -> LLMResponse:
    content = payload.get("content")
    if not isinstance(content, list):
        raise LLMError("The model returned an unexpected response shape (no content).",
                       code="llm_bad_response")
    text = "".join(
        block.get("text", "") for block in content
        if isinstance(block, dict) and block.get("type") == "text"
    ).strip()
    if not text:
        raise LLMError("The model returned an empty response.", code="llm_bad_response")
    return LLMResponse(text=text, stop_reason=payload.get("stop_reason"), style="anthropic")


def _parse_openai(payload: dict) -> LLMResponse:
    try:
        choice = payload["choices"][0]
        text = (choice.get("message") or {}).get("content") or ""
    except (KeyError, IndexError, TypeError) as exc:
        raise LLMError("The model returned an unexpected response shape.",
                       code="llm_bad_response") from exc
    text = text.strip()
    if not text:
        raise LLMError("The model returned an empty response.", code="llm_bad_response")
    return LLMResponse(text=text, stop_reason=choice.get("finish_reason"), style="openai")


class LLMClient:
    """One configured provider client. Instantiate per job or share."""

    def __init__(self, settings: Settings, http: httpx.AsyncClient | None = None) -> None:
        self.settings = settings
        self._http = http

    async def complete_vision(
        self,
        *,
        system: str,
        messages: list[dict],
    ) -> LLMResponse:
        style_pref = self.settings.llm_api_style
        styles = ["anthropic", "openai"] if style_pref == "auto" else [style_pref]

        last_error: LLMError | None = None
        for index, style in enumerate(styles):
            try:
                return await self._call(style, system, messages)
            except LLMError as exc:
                last_error = exc
                # Only fall over to the other protocol when the endpoint
                # plainly does not speak it.
                if exc.status == 404 and index < len(styles) - 1:
                    continue
                raise
        raise last_error or LLMError("No LLM API style available.", code="llm_error")

    async def _call(self, style: str, system: str,
                    messages: list[dict]) -> LLMResponse:
        settings = self.settings
        if style == "anthropic":
            url = _messages_url(settings.llm_base_url)
            body = _anthropic_body(settings, system, messages)
        else:
            url = _chat_completions_url(settings.llm_base_url)
            body = _openai_body(settings, system, messages)

        timeout = httpx.Timeout(connect=10.0, read=settings.llm_timeout_s,
                                write=30.0, pool=10.0)
        close_client = False
        http = self._http
        if http is None:
            http = httpx.AsyncClient(timeout=timeout)
            close_client = True

        attempts = max(1, settings.llm_max_retries + 1)
        delay = 2.0
        try:
            for attempt in range(attempts):
                try:
                    response = await http.post(url, headers=_headers(settings, style), json=body)
                except httpx.HTTPError as exc:
                    if attempt + 1 >= attempts:
                        raise LLMError(
                            f"The model endpoint could not be reached ({type(exc).__name__}).",
                            code="llm_unreachable") from exc
                    await asyncio.sleep(delay + random.uniform(0, delay))
                    delay = min(delay * 4, 32.0)
                    continue

                if response.status_code == 404:
                    raise LLMError("The model endpoint returned 404 (unknown path/model).",
                                   code="llm_not_found", status=404)
                if response.status_code in (400, 401, 403):
                    detail = _safe_error_detail(response)
                    raise LLMError(
                        f"The model endpoint rejected the request "
                        f"(HTTP {response.status_code}). {detail}",
                        code="llm_rejected", status=response.status_code)
                if response.status_code in _RETRYABLE_STATUS:
                    if attempt + 1 >= attempts:
                        raise LLMError(
                            f"The model endpoint is unavailable "
                            f"(HTTP {response.status_code} after {attempts} attempts).",
                            code="llm_unavailable", status=response.status_code)
                    retry_after = response.headers.get("retry-after")
                    wait = delay + random.uniform(0, delay)
                    if retry_after:
                        try:
                            wait = max(wait, float(retry_after))
                        except ValueError:
                            pass
                    await asyncio.sleep(wait)
                    delay = min(delay * 4, 32.0)
                    continue
                if response.status_code >= 400:
                    raise LLMError(
                        f"The model endpoint returned HTTP {response.status_code}.",
                        code="llm_error", status=response.status_code)

                try:
                    payload = response.json()
                except json.JSONDecodeError as exc:
                    # Some upstream gateways occasionally return an HTML or
                    # plain-text error body with HTTP 200 after a long model
                    # wait. Treat that exactly like the transient transport
                    # failures above while the configured retry budget
                    # remains; never expose or log the provider body.
                    if attempt + 1 < attempts:
                        await asyncio.sleep(delay + random.uniform(0, delay))
                        delay = min(delay * 4, 32.0)
                        continue
                    raise LLMError("The model endpoint returned non-JSON.",
                                   code="llm_bad_response") from exc
                parsed = _parse_anthropic(payload) if style == "anthropic" else _parse_openai(payload)
                if parsed.stop_reason in {"max_tokens", "length"}:
                    raise LLMError(
                        "The model's reply was truncated (max_tokens reached). "
                        "Increase LLM_MAX_TOKENS or simplify the subject.",
                        code="llm_truncated")
                return parsed
        finally:
            if close_client:
                await http.aclose()
        raise LLMError("The model call failed unexpectedly.", code="llm_error")


def _safe_error_detail(response: httpx.Response) -> str:
    """Extract a short, credential-free detail string from an error body."""
    try:
        payload = response.json()
        message = payload.get("error", {})
        if isinstance(message, dict):
            message = message.get("message") or message.get("type") or ""
        if isinstance(message, str) and message:
            return message[:200]
    except Exception:
        pass
    return ""


_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)


def extract_json_object(text: str) -> dict:
    """Parse a JSON object from an LLM reply, tolerating markdown fences
    and leading/trailing prose. Raises LLMError on failure."""
    candidate = _FENCE_RE.sub("", text).strip()
    try:
        parsed = json.loads(candidate)
    except json.JSONDecodeError:
        start = candidate.find("{")
        end = candidate.rfind("}")
        if start == -1 or end == -1 or end <= start:
            raise LLMError("The model did not return a JSON object.",
                           code="llm_bad_json")
        try:
            parsed = json.loads(candidate[start:end + 1])
        except json.JSONDecodeError as exc:
            raise LLMError(f"The model returned malformed JSON ({exc.msg}).",
                           code="llm_bad_json") from exc
    if not isinstance(parsed, dict):
        raise LLMError("The model returned JSON that is not an object.",
                       code="llm_bad_json")
    return parsed