File size: 5,514 Bytes
3530326
 
 
 
 
 
 
 
 
270d701
3530326
52d25f7
 
270d701
 
52d25f7
3719fca
3530326
 
 
 
 
 
 
52d25f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3530326
 
 
 
 
 
 
 
 
 
52d25f7
 
 
3530326
52d25f7
 
 
3530326
52d25f7
 
3530326
 
 
 
 
 
3719fca
 
3530326
 
270d701
 
 
 
 
3530326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3719fca
3530326
 
 
 
 
 
3719fca
3530326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270d701
 
 
 
 
 
3530326
 
270d701
3530326
 
270d701
 
3530326
 
 
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
from __future__ import annotations

import base64
import os
import time
from dataclasses import dataclass

import requests

from parser_output import shape_parse_response

DEFAULT_ENVIRONMENT = "staging"
ENVIRONMENT_CONFIG = {
    "production": ("COHERE_API_KEY", "https://api.cohere.com/v2/parse"),
    "staging": ("COHERE_STG_API_KEY", "https://stg.api.cohere.ai/v2/parse"),
}
DEFAULT_MODEL = "parse-v5.0"
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}


class ParserServiceError(RuntimeError):
    """A safe parser-service error that may be displayed to a user."""


def selected_environment() -> str:
    environment = os.environ.get("COHERE_ENV", DEFAULT_ENVIRONMENT).strip().lower()
    if environment not in ENVIRONMENT_CONFIG:
        raise ParserServiceError(
            "COHERE_ENV must be either 'staging' or 'production'."
        )
    return environment


def api_key_name_for_environment(environment: str | None = None) -> str:
    selected = environment or selected_environment()
    try:
        return ENVIRONMENT_CONFIG[selected][0]
    except KeyError as error:
        raise ParserServiceError(
            "COHERE_ENV must be either 'staging' or 'production'."
        ) from error


@dataclass(frozen=True)
class ParserConfig:
    api_url: str
    api_key: str
    model: str
    timeout: float
    max_retries: int = 4

    @classmethod
    def from_environment(cls) -> "ParserConfig":
        environment = selected_environment()
        api_key_name, default_api_url = ENVIRONMENT_CONFIG[environment]
        api_key = os.environ.get(api_key_name, "").strip()
        if not api_key:
            raise ParserServiceError(
                f"Cohere Parse is not configured for {environment}."
            )
        return cls(
            api_url=os.environ.get("COHERE_API_URL", "").strip()
            or default_api_url,
            api_key=api_key,
            model=os.environ.get("COHERE_MODEL", DEFAULT_MODEL).strip(),
            timeout=float(os.environ.get("COHERE_TIMEOUT", "300")),
        )


def build_payload(model: str, png: bytes) -> dict:
    encoded = base64.b64encode(png).decode("ascii")
    return {
        "model": model,
        "document": {
            "type": "image_url",
            "image_url": f"data:image/png;base64,{encoded}",
        },
        "output_format": "blocks",
    }


class ParserClient:
    def __init__(
        self,
        config: ParserConfig | None = None,
        *,
        session: requests.Session | None = None,
        sleeper=time.sleep,
    ) -> None:
        self.config = config or ParserConfig.from_environment()
        self.session = session or requests.Session()
        self.sleeper = sleeper

    def _retry_delay(self, response: requests.Response, attempt: int) -> float:
        header = response.headers.get("Retry-After", "")
        try:
            return min(60.0, max(0.0, float(header)))
        except ValueError:
            return min(60.0, 2.0 ** (attempt + 1))

    def parse_page(self, png: bytes) -> dict:
        started = time.perf_counter()
        headers = {
            "accept": "application/json",
            "content-type": "application/json",
            "Authorization": f"Bearer {self.config.api_key}",
        }
        payload = build_payload(self.config.model, png)

        for attempt in range(self.config.max_retries):
            try:
                response = self.session.post(
                    self.config.api_url,
                    headers=headers,
                    json=payload,
                    timeout=self.config.timeout,
                )
            except (requests.Timeout, requests.ConnectionError) as exc:
                if attempt + 1 < self.config.max_retries:
                    self.sleeper(min(60.0, 2.0 ** (attempt + 1)))
                    continue
                raise ParserServiceError(
                    "Cohere Parse could not be reached. Try again shortly."
                ) from exc

            request_id = (
                response.headers.get("x-request-id")
                or response.headers.get("request-id")
            )
            if response.status_code in RETRYABLE_STATUSES:
                if attempt + 1 < self.config.max_retries:
                    self.sleeper(self._retry_delay(response, attempt))
                    continue
                raise ParserServiceError(
                    f"Cohere Parse is temporarily unavailable ({response.status_code})."
                )
            if not response.ok:
                raise ParserServiceError(
                    f"Cohere Parse rejected the request ({response.status_code})."
                )

            try:
                body = response.json()
            except requests.JSONDecodeError as exc:
                raise ParserServiceError(
                    "Cohere Parse returned an invalid response."
                ) from exc

            try:
                parsed = shape_parse_response(body)
            except (TypeError, ValueError) as exc:
                raise ParserServiceError(
                    "Cohere Parse returned an invalid response."
                ) from exc

            return {
                **parsed,
                "elapsed_ms": round((time.perf_counter() - started) * 1000),
                "model": self.config.model,
                "usage": body.get("meta"),
                "request_id": request_id or body.get("id"),
            }

        raise ParserServiceError("Cohere Parse is temporarily unavailable.")