dv-llmclient 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: dv-llmclient
3
+ Version: 0.1.1
4
+ Summary: An LLM client with cache and type checking
5
+ Author: pnmougel
6
+ Author-email: pnmougel <pierre-nicolas.mougel@datavalor.com>
7
+ Requires-Dist: hishel>=1.3.0
8
+ Requires-Dist: langfuse>=4.14.1
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
File without changes
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "dv-llmclient"
3
+ version = "0.1.1"
4
+ description = "An LLM client with cache and type checking"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "pnmougel", email = "pierre-nicolas.mougel@datavalor.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "hishel>=1.3.0",
12
+ "langfuse>=4.14.1",
13
+ "pydantic>=2.13.4",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.21,<0.12.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,4 @@
1
+ from .models import Endpoint, ReasoningEffort, LlmModel, ListModel
2
+ from .prompt import Prompt, PromptPart, FilePart, BinaryPart, ImagePart, LfPrompt
3
+ from .response_validator import CompletionResponseValidator, ResponseValidator
4
+ from .llm_client import LlmClient
@@ -0,0 +1,18 @@
1
+ from typing import Optional
2
+
3
+ class LLMClientError(Exception):
4
+ """Base exception class for LLM client errors."""
5
+ def __init__(self, message: str, base_exception: Optional[Exception] = None):
6
+ super().__init__(message)
7
+ self.message = message
8
+ self.base_exception = base_exception
9
+
10
+ class LLMClientSchemaBuildError(LLMClientError):
11
+ def __init__(self, base_exception: Exception, model):
12
+ super().__init__(f"Error building schema for model: {model}", base_exception)
13
+ self.model = model
14
+
15
+ class LLMClientMaxRetryError(LLMClientError):
16
+ def __init__(self, max_retries: int):
17
+ super().__init__(f"Maximum retries reached ({max_retries})")
18
+
@@ -0,0 +1,4 @@
1
+ from langfuse import Langfuse
2
+ import os
3
+
4
+ lf_client = Langfuse(public_key=os.getenv("LANGFUSE_PUBLIC_KEY"), secret_key=os.getenv("LANGFUSE_SECRET_KEY"), media_upload_thread_count=5)
@@ -0,0 +1,213 @@
1
+ from pathlib import Path
2
+
3
+ from langfuse import LangfuseGeneration
4
+ from pydantic import BaseModel
5
+ from typing import TypeVar, Generic, Type, Optional, Union
6
+ import hishel
7
+ import json
8
+ import os
9
+ import base64
10
+ from hishel.httpx import SyncCacheClient
11
+
12
+ from .lf_client import lf_client
13
+ from .exceptions import LLMClientMaxRetryError, LLMClientSchemaBuildError
14
+ from .models import ImageOCR, OCRResponse, LlmModel, ReasoningEffort, Endpoint
15
+ from .prompt import ImagePart, LfPrompt, Prompt, PromptPart
16
+ from .response_validator import OcrResponseValidator, RequestCacheFilter, ResponseValidator, CompletionResponseValidator
17
+
18
+ M = TypeVar("M", bound=Union[str, BaseModel])
19
+
20
+ class LlmClient(Generic[M]):
21
+ def __init__(
22
+ self,
23
+ model: LlmModel,
24
+ output_type: Type[M] = str,
25
+ temperature: Optional[float] = None,
26
+ max_retries: int = 3,
27
+ reasoning: Optional[ReasoningEffort] = None,
28
+ endpoint: Endpoint = "eden",
29
+ extra_body: dict = {},
30
+ validation: Optional[ResponseValidator[M]] = None,
31
+ default_on_error: Optional[M] = None,
32
+ force_cache_refresh: bool = False,
33
+ disable_cache: bool = False
34
+ ):
35
+ self.model = model
36
+ self.output_type = output_type
37
+ self.temperature = temperature
38
+ self.max_retries = max_retries
39
+ self.reasoning = reasoning
40
+ self.endpoint = endpoint
41
+ self.extra_body = extra_body
42
+ self.validation = validation
43
+ self.default_on_error = default_on_error
44
+ self.force_cache_refresh = force_cache_refresh
45
+ self.disable_cache = disable_cache
46
+
47
+ def ocr(self, pdf_path: str | Path, extract_header: bool = True, extract_footer: bool = True, table_format: str = "markdown", include_image_base64: bool = True) -> M:
48
+ with lf_client.start_as_current_generation(name="llm_call", model=self.model,
49
+ metadata={"endpoint": self.endpoint, "max_retries": self.max_retries, "cache_disabled": self.disable_cache, "cache_refresh": self.force_cache_refresh},
50
+ model_parameters={"temperature": str(self.temperature), "reasoning": self.reasoning}) as span:
51
+ document_url = f"data:application/pdf;base64,{base64.b64encode(Path(pdf_path).read_bytes()).decode()}"
52
+ bbox_annotation_format = {"type":"json_schema","json_schema":{"name":"ImageOCR","schema":ImageOCR.model_json_schema(),"strict":True}}
53
+ query = dict(
54
+ model=self.model,
55
+ extract_header=extract_header,
56
+ extract_footer=extract_footer,
57
+ table_format=table_format,
58
+ document={"type": "document_url", "document_url": document_url},
59
+ bbox_annotation_format=bbox_annotation_format, # type: ignore
60
+ include_image_base64=include_image_base64
61
+ )
62
+ return self.__run(query=query, span=span, path="/v1/ocr", validator=OcrResponseValidator()) # type: ignore
63
+
64
+ def completion(self, prompt: Prompt | PromptPart | list[PromptPart]) -> M:
65
+ with lf_client.start_as_current_generation(name="llm_call", model=self.model,
66
+ metadata={"endpoint": self.endpoint, "max_retries": self.max_retries, "cache_disabled": self.disable_cache, "cache_refresh": self.force_cache_refresh},
67
+ model_parameters={"temperature": str(self.temperature), "reasoning": self.reasoning}) as span:
68
+ response_format: dict[str, str] = {"type": "text"}
69
+ try:
70
+ if self.output_type != str:
71
+ response_format = { "type": "json_schema", "json_schema": self.output_type.model_json_schema()} # type: ignore
72
+ except Exception as e:
73
+ # lf_client.create_event(name="output_schema_error", status_message="Error while building output schema", level="ERROR", metadata={"error": str(e)})
74
+ raise LLMClientSchemaBuildError(e, self.output_type)
75
+ span.update_trace(metadata={"response_format_type": response_format['type']})
76
+ span.update_trace(metadata={"response_format": response_format})
77
+
78
+ if not isinstance(prompt, Prompt):
79
+ if not isinstance(prompt, list):
80
+ prompt = [prompt]
81
+ prompt = Prompt(*prompt)
82
+ for part in prompt.parts:
83
+ if isinstance(part, LfPrompt):
84
+ span.update(prompt=part.lf_prompt)
85
+ if isinstance(part, ImagePart):
86
+ lf_client.update_current_trace( metadata={"document": part.as_lf_media()} )
87
+ span.update(input=prompt.as_log_output())
88
+
89
+ query = {
90
+ "model": self.model,
91
+ "temperature": self.temperature,
92
+ "response_format": response_format,
93
+ "messages": [prompt.build()],
94
+ "reasoning_effort": self.reasoning,
95
+ "temperature": self.temperature,
96
+ }
97
+ validator: ResponseValidator[M] = (
98
+ CompletionResponseValidator(output_type=self.output_type)
99
+ if self.validation is None
100
+ else self.validation
101
+ )
102
+ path = "/v3/llm/chat/completions" if self.endpoint == 'eden' else "/v1/chat/completions"
103
+
104
+ return self.__run(query=query, span=span, path=path, validator=validator)
105
+
106
+ def __run(
107
+ self,
108
+ query: dict[str, Union[str, bool, dict]],
109
+ path: str,
110
+ span: LangfuseGeneration,
111
+ validator: ResponseValidator[M]
112
+ ) -> M:
113
+ try:
114
+ self.cache_storage = hishel.SyncSqliteStorage(database_path="./cache.db", default_ttl=3600*24*30)
115
+
116
+ # Build the response validator for both the http cache
117
+ # validator: ResponseValidator[M] = (
118
+ # CompletionResponseValidator(output_type=self.output_type)
119
+ # if self.validation is None
120
+ # else self.validation
121
+ # )
122
+ request_cache_filter = RequestCacheFilter(cache_storage=self.cache_storage, force_refresh=self.force_cache_refresh, disable_cache=self.disable_cache)
123
+
124
+ base_url_mapping = {
125
+ "openrouter": "https://openrouter.ai/api",
126
+ "dgx": "http://localhost:12000/api",
127
+ "test": "http://localhost:8000",
128
+ "mistral": "https://api.mistral.ai",
129
+ "eden": "https://api.edenai.run"
130
+ }
131
+ base_url = base_url_mapping.get(self.endpoint, self.endpoint)
132
+ api_key_mapping = {"openrouter": "OPENROUTER_API_KEY", "dgx": "OPEN_WEBUI_KEY", "mistral": "MISTRAL_API_KEY", "eden": "EDEN_AI_API_KEY"}
133
+ http_client = SyncCacheClient(
134
+ storage=self.cache_storage,
135
+ policy=hishel.FilterPolicy(request_filters=[request_cache_filter], response_filters=[validator])
136
+ )
137
+
138
+ nb_retry = 0
139
+ is_ok = False
140
+ res = None
141
+ while not is_ok and nb_retry < self.max_retries:
142
+ is_ok = True
143
+ with lf_client.start_as_current_observation(name="llm_query", as_type="generation", input=query) as llm_query_span:
144
+ try:
145
+ res = http_client.post(
146
+ url=f"{base_url}{path}",
147
+ content=json.dumps(query),
148
+ headers={
149
+ "Authorization": f"Bearer {os.getenv(api_key_mapping.get(self.endpoint, 'OPENAI_KEY'))}"
150
+ },
151
+ extensions={"hishel_body_key": True},
152
+ )
153
+ res = validator.build_response(res, None, llm_query_span)
154
+ except Exception as e:
155
+ print(f"Error while making request: {e}")
156
+ llm_query_span.update(level="ERROR", status_message=f"Error during HTTP request: {e}")
157
+ raise e
158
+ if res is None:
159
+ # Subsequent requests should not be loaded from cache, as the cache might be storing invalid responses
160
+ # This is the case when the validation change for a given request that was previously cached.
161
+ # We must be able to reload a fresh response.
162
+ request_cache_filter.force_refresh = True
163
+ is_ok = False
164
+ nb_retry += 1
165
+
166
+ if nb_retry == self.max_retries:
167
+ if self.default_on_error is not None:
168
+ return self.default_on_error
169
+ else:
170
+ raise LLMClientMaxRetryError(self.max_retries)
171
+
172
+ # Update the span with the clean output
173
+ if res is not None:
174
+ if self.output_type == str:
175
+ span.update(output=res)
176
+ else:
177
+ span.update(output=res.model_dump_json()) # type: ignore
178
+ return res # type: ignore
179
+ except Exception as e:
180
+ span.update(level="ERROR", status_message=f"{e}")
181
+ raise e
182
+
183
+ class LlmOCRClient(LlmClient[OCRResponse]):
184
+ def __init__(
185
+ self,
186
+ model: LlmModel = 'mistral-ocr-latest',
187
+ temperature: Optional[float] = None,
188
+ max_retries: int = 3,
189
+ reasoning: Optional[ReasoningEffort] = None,
190
+ endpoint: Endpoint = "mistral",
191
+ validation: Optional[ResponseValidator[OCRResponse]] = None,
192
+ default_on_error: Optional[OCRResponse] = None,
193
+ force_cache_refresh: bool = False,
194
+ disable_cache: bool = False
195
+ ):
196
+ super().__init__(
197
+ model=model,
198
+ output_type=OCRResponse,
199
+ temperature=temperature,
200
+ max_retries=max_retries,
201
+ reasoning=reasoning,
202
+ endpoint=endpoint,
203
+ validation=validation,
204
+ default_on_error=default_on_error,
205
+ force_cache_refresh=force_cache_refresh,
206
+ disable_cache=disable_cache
207
+ )
208
+
209
+ def ocr(self, pdf_path: str | Path, extract_header: bool = True, extract_footer: bool = True, table_format: str = "markdown", include_image_base64: bool = True) -> OCRResponse:
210
+ return super().ocr(pdf_path=pdf_path, extract_header=extract_header, extract_footer=extract_footer, table_format=table_format, include_image_base64=include_image_base64)
211
+
212
+ def completion(self, prompt):
213
+ raise NotImplementedError("completion is not implemented for LlmOCR, use the ocr method instead.")
@@ -0,0 +1,127 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, Field, RootModel
4
+
5
+ from pydantic import BaseModel
6
+ from typing import Iterator, TypeVar, List, Optional, Literal
7
+
8
+ type ReasoningEffort = Literal["none", "low", "medium", "high"]
9
+ type Endpoint = Literal["dgx", "openrouter", "test", "mistral", "eden"] | str
10
+ type LlmModel = Literal[
11
+ "google/gemini-2.5-flash",
12
+ "google/gemini-2.5-pro",
13
+ "google/gemini-3-pro-preview",
14
+ "gpt-oss:120b",
15
+ "mistral-ocr-latest",
16
+ ] | str
17
+
18
+ T = TypeVar("T", bound=BaseModel)
19
+
20
+ class ListModel[T](RootModel[List[T]]):
21
+ """
22
+ Helper class to handle responses that are lists of items. It provides list-like access to the items while still being a Pydantic model.
23
+ """
24
+ root: list[T]
25
+
26
+ def __iter__(self) -> Iterator[T]: # type: ignore
27
+ return iter(self.root)
28
+
29
+ def __getitem__(self, item: int) -> T:
30
+ return self.root[item]
31
+
32
+ def __len__(self) -> int:
33
+ return len(self.root)
34
+
35
+ def aslist(self) -> list[T]:
36
+ return self.root
37
+
38
+
39
+ class LLMResponseMessage(BaseModel):
40
+ role: str
41
+ content: str
42
+ reasoning_context: Optional[str] = None
43
+ reasoning: Optional[str] = None
44
+
45
+
46
+ class LLMResponseChoice(BaseModel):
47
+ finish_reason: str
48
+ message: LLMResponseMessage
49
+
50
+
51
+ class LLMResponseCompletionTokensDetails(BaseModel):
52
+ reasoning_tokens: int
53
+ image_tokens: Optional[int] = None
54
+
55
+
56
+ class LLMResponseCostDetails(BaseModel):
57
+ upstream_inference_cost: Optional[float] = None
58
+ upstream_inference_prompt_cost: Optional[float] = None
59
+ upstream_inference_completions_cost: Optional[float] = None
60
+
61
+ class LLMResponseUsage(BaseModel):
62
+ cost: Optional[float] = None
63
+ completion_tokens: int
64
+ prompt_tokens: int
65
+ total_tokens: int
66
+ completion_tokens_details: LLMResponseCompletionTokensDetails
67
+ cost_details: Optional[LLMResponseCostDetails] = None
68
+
69
+
70
+ class LLMResponseRaw(BaseModel):
71
+ choices: List[LLMResponseChoice]
72
+ usage: LLMResponseUsage
73
+ cost: Optional[float] = None
74
+
75
+
76
+ # OCR related models
77
+ class ImageType(str, Enum):
78
+ GRAPH = "graph"
79
+ TEXT = "text"
80
+ TABLE = "table"
81
+ IMAGE = "image"
82
+
83
+ class ImageOCR(BaseModel):
84
+ image_type: ImageType = Field(..., description="Type de l'image. Doit être 'graph', 'text', 'table' ou 'image'.")
85
+ caption: str = Field(..., description="Légende de la figure, si disponible.")
86
+ description: str = Field(..., description="Description de l'image.")
87
+
88
+ class OCRUsageInfo(BaseModel):
89
+ pages_processed: int
90
+ doc_size_bytes: int
91
+
92
+ class OCRTable(BaseModel):
93
+ id: str
94
+ content: str
95
+ format: str
96
+ # word_confidence_score: Optional[float]
97
+
98
+ class OCRPageDimension(BaseModel):
99
+ dpi: int
100
+ height: int
101
+ width: int
102
+
103
+
104
+ class OCRImageResponse(BaseModel):
105
+ id: str
106
+ bottom_right_x: Optional[int]
107
+ bottom_right_y: Optional[int]
108
+ top_left_x: Optional[int]
109
+ top_left_y: Optional[int]
110
+ image_annotation: Optional[str]
111
+ image_base64: Optional[str]
112
+
113
+ class OCRPage(BaseModel):
114
+ index: int
115
+ markdown: str
116
+ images: list[OCRImageResponse]
117
+ tables: list[OCRTable]
118
+ header: Optional[str]
119
+ footer: Optional[str]
120
+ hyperlinks: Optional[list[str]]
121
+ dimensions: Optional[OCRPageDimension]
122
+ confidence_scores: Optional[float]
123
+
124
+ class OCRResponse(BaseModel):
125
+ usage_info: Optional[OCRUsageInfo]
126
+ pages: list[OCRPage]
127
+ document_annotation: Optional[str]
@@ -0,0 +1,100 @@
1
+ import base64
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from mimetypes import guess_type
5
+ from langfuse import get_client
6
+ from langfuse.media import LangfuseMedia
7
+ from lf.client import lf_client
8
+
9
+ class FilePart:
10
+ def __init__(self, file_path: str | Path):
11
+ self.file_path = Path(file_path) if isinstance(file_path, str) else file_path
12
+ self.media_type = guess_type(self.file_path)[0]
13
+ self.base64_content = base64.b64encode(self.file_path.read_bytes()).decode()
14
+
15
+ def as_dict(self) -> dict:
16
+ return {
17
+ "file": {
18
+ "file_data": f"data:{self.media_type};base64,{self.base64_content}",
19
+ "filename": self.file_path.name,
20
+ },
21
+ "type": "file",
22
+ }
23
+
24
+ def __repr__(self):
25
+ return f"<FilePart filename={self.file_path.name} media_type={self.media_type} b64_size={len(self.base64_content)}>"
26
+
27
+ class BinaryPart():
28
+ def __init__(self, data: bytes, filename: str, media_type: Optional[str] = None):
29
+ self.data = data
30
+ self.filename = filename
31
+ self.media_type = media_type or "application/octet-stream"
32
+
33
+ def as_dict(self) -> dict:
34
+ return {
35
+ "file": {
36
+ "file_data": f"data:{self.media_type};base64,{base64.b64encode(self.data).decode()}",
37
+ "filename": self.filename,
38
+ },
39
+ "type": "file",
40
+ }
41
+
42
+ def __repr__(self):
43
+ return f"<BinaryPart filename={self.filename} media_type={self.media_type}>"
44
+
45
+ class ImagePart():
46
+ def __init__(self, file_path: Path, media_type: Optional[str] = None):
47
+ self.file_path = file_path
48
+ self.media_type = media_type or guess_type(file_path)[0]
49
+
50
+ def as_dict(self) -> dict:
51
+ base64_content = base64.b64encode(self.file_path.read_bytes()).decode()
52
+ return {
53
+ # "image_url": {
54
+ # "url": f"data:{self.media_type};base64,{base64_content}",
55
+ # },
56
+ "image_url": f"data:{self.media_type};base64,{base64_content}",
57
+ "type": "image_url",
58
+ }
59
+
60
+ def as_lf_media(self) -> LangfuseMedia:
61
+ return LangfuseMedia(
62
+ content_type=self.media_type, # type: ignore
63
+ content_bytes=self.file_path.read_bytes(),
64
+ file_path=self.file_path.name
65
+ )
66
+
67
+ def __repr__(self):
68
+ return f"<ImagePart media_type={self.media_type} path={self.file_path}>"
69
+
70
+ class LfPrompt:
71
+ def __init__(self, name: str, label: Optional[str] = "latest", params: dict = {}):
72
+ self.lf_prompt = lf_client.get_prompt(name, label=label, cache_ttl_seconds=0)
73
+ self.text = self.lf_prompt.compile(**params)
74
+
75
+ def as_dict(self) -> dict:
76
+ return {"type": "text", "text": self.text}
77
+
78
+ def __repr__(self):
79
+ return self.text
80
+
81
+
82
+ type PromptPart = FilePart | BinaryPart | ImagePart | LfPrompt | str
83
+
84
+ class Prompt():
85
+ def __init__(self, *args: PromptPart):
86
+ self.parts: list[FilePart | BinaryPart | ImagePart | LfPrompt | str] = list(args)
87
+
88
+ def build(self) -> dict:
89
+ content = [{"type": "text", "text": part} if isinstance(part, str) else part.as_dict() for part in self.parts]
90
+ return {"role": "user", "content": content}
91
+
92
+ def add_part(self, part: PromptPart):
93
+ self.parts.append(part)
94
+
95
+ def __repr__(self):
96
+ prefix = f"<Prompt {len(self.parts)} parts>"
97
+ return prefix + "\n" + "\n".join([f" - {repr(part)}" for part in self.parts])
98
+
99
+ def as_log_output(self) -> str:
100
+ return "\n".join([repr(part) for part in self.parts])
File without changes
@@ -0,0 +1,186 @@
1
+
2
+ from typing import Iterable, Iterator, Optional, Type
3
+
4
+ import hishel
5
+ import httpx
6
+ import gzip
7
+ import hashlib
8
+
9
+ from langfuse import LangfuseGeneration, LangfuseSpan
10
+ from pydantic_core import ValidationError
11
+
12
+ from .models import LLMResponseRaw, OCRResponse
13
+ from hishel._utils import make_sync_iterator
14
+ from abc import ABC, abstractmethod
15
+
16
+ class RequestCacheFilter(hishel.BaseFilter[hishel.Request]):
17
+ def __init__(self, cache_storage: hishel.SyncBaseStorage, force_refresh: bool = True, disable_cache: bool = False):
18
+ self.cache_storage = cache_storage
19
+ self.force_refresh = force_refresh
20
+ self.disable_cache = disable_cache
21
+
22
+ def needs_body(self) -> bool:
23
+ return True
24
+
25
+ def apply(self, item: hishel.Request, body: bytes | None):
26
+ if self.disable_cache:
27
+ # Returning False completely disables the cache
28
+ return False
29
+ # If we just return False when force_refresh is False, the request will be sent but it wont be stored in cache...
30
+ # Instead we remove the cached response for the storage and always return True.
31
+
32
+ # Dangerous : the key calculation is extracted from the hishel codebase, and might change in future versions.
33
+ if self.force_refresh:
34
+ assert isinstance(item.stream, (Iterator, Iterable))
35
+ collected = b"".join([chunk for chunk in item.stream])
36
+ key = hashlib.sha256(collected).hexdigest()
37
+ item.stream = make_sync_iterator([collected])
38
+ for entry in self.cache_storage.get_entries(key):
39
+ self.cache_storage.remove_entry(entry.id)
40
+ return True
41
+
42
+ class ResponseValidator[M](hishel.BaseFilter[hishel.Response]):
43
+ def __init__(self, output_type: Type[M]):
44
+ self.output_type = output_type
45
+
46
+ def needs_body(self) -> bool:
47
+ return True
48
+
49
+ def apply(self, item: hishel.Response, body: bytes | None):
50
+ try:
51
+ res = self.build_response(item, body)
52
+ return res is not None
53
+ except Exception as e:
54
+ return False
55
+
56
+ def build_response(
57
+ self, response: hishel.Response | httpx.Response, body: Optional[bytes] = None, langfuse_span: Optional[LangfuseGeneration] = None
58
+ ) -> Optional[M]:
59
+ """
60
+ Build the response object from the http response.
61
+
62
+ There is three possible outputs :
63
+ - None : the response is invalid, but it can be retried. This is the case when the response content from the LLM is not valid according to the validation rules.
64
+ - M : the response is valid, and can be returned as is.
65
+ - Exception : the response is invalid, and should not be retried.
66
+ """
67
+ is_post_validation = isinstance(response, httpx.Response)
68
+ if response.status_code >= 300:
69
+ # Http status
70
+ if is_post_validation:
71
+ raise Exception(f"Invalid response status: {response.status_code}, body: {response.text}")
72
+ else:
73
+ return None
74
+ if isinstance(response, httpx.Response):
75
+ body_content = response.text
76
+ elif isinstance(response, hishel.Response):
77
+ try:
78
+ if body is None:
79
+ return None
80
+ if response.headers.get("Content-Encoding", "") == "gzip":
81
+ body = gzip.decompress(body)
82
+ body_content: str = body.decode("utf-8")
83
+ except Exception as e:
84
+ raise Exception(f"Unable to decode response body") from e
85
+
86
+ if langfuse_span is not None:
87
+ langfuse_span.update(output=body_content)
88
+
89
+ return self.validate_body_content(body_content, langfuse_span)
90
+ # # Handle the case where the response is wrapped in a markdown code block
91
+ # try:
92
+ # llm_response = LLMResponseRaw.model_validate_json(body_content)
93
+ # except ValidationError as e:
94
+ # raise Exception(f"Http response body is not a valid LLM response: {body_content}") from e
95
+
96
+ # if langfuse_span is not None:
97
+ # usage_details = {
98
+ # "total": llm_response.usage.total_tokens, "input": llm_response.usage.prompt_tokens, "output": llm_response.usage.completion_tokens, "reasoning_tokens": llm_response.usage.completion_tokens_details.reasoning_tokens
99
+ # }
100
+ # cost_details = {
101
+ # "total": llm_response.usage.cost or 0,
102
+ # "upstream_inference_cost": llm_response.usage.cost_details.upstream_inference_cost if llm_response.usage.cost_details else None,
103
+ # "input": llm_response.usage.cost_details.upstream_inference_prompt_cost if llm_response.usage.cost_details else None,
104
+ # "output": llm_response.usage.cost_details.upstream_inference_completions_cost if llm_response.usage.cost_details else None,
105
+ # }
106
+ # # cost_details = { "total": llm_response.usage.cost or 0 }
107
+ # langfuse_span.update(usage_details=usage_details, cost_details=cost_details)
108
+
109
+ # try:
110
+ # response_content = llm_response.choices[0].message.content
111
+ # if self.output_type == str:
112
+ # content_to_validate: M = str(response_content) # type: ignore
113
+ # else:
114
+ # start_idx = response_content.find("```json")
115
+ # end_idx = response_content.find("```", start_idx + 7)
116
+ # if start_idx != -1 and end_idx != -1:
117
+ # response_content = response_content[start_idx + 7:end_idx].strip()
118
+ # content_to_validate: M = self.output_type.model_validate_json(response_content) # type: ignore
119
+
120
+ # res = self.validate(content_to_validate)
121
+ # except ValidationError as e:
122
+ # return None
123
+
124
+ # return res
125
+
126
+ @abstractmethod
127
+ def validate_body_content(self, body_content: str, langfuse_span: Optional[LangfuseGeneration]) -> Optional[M]:
128
+ pass
129
+
130
+ def validate(self, content: M) -> M:
131
+ # Implement your validation logic here
132
+ return content
133
+
134
+
135
+ class CompletionResponseValidator[M](ResponseValidator[M]):
136
+ def __init__(self, output_type: Type[M]):
137
+ super().__init__(output_type)
138
+
139
+ def validate_body_content(self, body_content: str, langfuse_span: Optional[LangfuseGeneration]) -> Optional[M]:
140
+ # Handle the case where the response is wrapped in a markdown code block
141
+ try:
142
+ llm_response = LLMResponseRaw.model_validate_json(body_content)
143
+ except ValidationError as e:
144
+ raise Exception(f"Http response body is not a valid LLM response: {body_content}") from e
145
+
146
+ if langfuse_span is not None:
147
+ usage_details = {
148
+ "total": llm_response.usage.total_tokens, "input": llm_response.usage.prompt_tokens, "output": llm_response.usage.completion_tokens, "reasoning_tokens": llm_response.usage.completion_tokens_details.reasoning_tokens
149
+ }
150
+ cost_details = {
151
+ "total": llm_response.usage.cost or 0,
152
+ "upstream_inference_cost": llm_response.usage.cost_details.upstream_inference_cost if llm_response.usage.cost_details else None,
153
+ "input": llm_response.usage.cost_details.upstream_inference_prompt_cost if llm_response.usage.cost_details else None,
154
+ "output": llm_response.usage.cost_details.upstream_inference_completions_cost if llm_response.usage.cost_details else None,
155
+ }
156
+ # cost_details = { "total": llm_response.usage.cost or 0 }
157
+ langfuse_span.update(usage_details=usage_details, cost_details=cost_details)
158
+
159
+ try:
160
+ response_content = llm_response.choices[0].message.content
161
+ if self.output_type == str:
162
+ content_to_validate: M = str(response_content) # type: ignore
163
+ else:
164
+ start_idx = response_content.find("```json")
165
+ end_idx = response_content.find("```", start_idx + 7)
166
+ if start_idx != -1 and end_idx != -1:
167
+ response_content = response_content[start_idx + 7:end_idx].strip()
168
+ content_to_validate: M = self.output_type.model_validate_json(response_content) # type: ignore
169
+
170
+ res = self.validate(content_to_validate)
171
+ except ValidationError as e:
172
+ return None
173
+
174
+ return res
175
+
176
+ class OcrResponseValidator(ResponseValidator[OCRResponse]):
177
+ def __init__(self):
178
+ super().__init__(OCRResponse)
179
+
180
+ def validate_body_content(self, body_content: str, langfuse_span: Optional[LangfuseGeneration]) -> Optional[OCRResponse]:
181
+ try:
182
+ llm_response = OCRResponse.model_validate_json(body_content)
183
+ return llm_response
184
+ except ValidationError as e:
185
+ raise Exception(f"Http response body is not a valid OCR response: {body_content}") from e
186
+