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.
- dv_llmclient-0.1.1/PKG-INFO +12 -0
- dv_llmclient-0.1.1/README.md +0 -0
- dv_llmclient-0.1.1/pyproject.toml +18 -0
- dv_llmclient-0.1.1/src/dv_llmclient/__init__.py +4 -0
- dv_llmclient-0.1.1/src/dv_llmclient/exceptions.py +18 -0
- dv_llmclient-0.1.1/src/dv_llmclient/lf_client.py +4 -0
- dv_llmclient-0.1.1/src/dv_llmclient/llm_client.py +213 -0
- dv_llmclient-0.1.1/src/dv_llmclient/models.py +127 -0
- dv_llmclient-0.1.1/src/dv_llmclient/prompt.py +100 -0
- dv_llmclient-0.1.1/src/dv_llmclient/py.typed +0 -0
- dv_llmclient-0.1.1/src/dv_llmclient/response_validator.py +186 -0
|
@@ -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,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,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
|
+
|