openmail 0.1.5__py3-none-any.whl
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.
- openmail/__init__.py +6 -0
- openmail/assistants/__init__.py +35 -0
- openmail/assistants/classify_emails.py +83 -0
- openmail/assistants/compose_email.py +43 -0
- openmail/assistants/detect_phishing_for_email.py +61 -0
- openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
- openmail/assistants/extract_tasks_from_emails.py +126 -0
- openmail/assistants/generate_follow_up_for_email.py +54 -0
- openmail/assistants/natural_language_query.py +699 -0
- openmail/assistants/prioritize_emails.py +89 -0
- openmail/assistants/reply.py +58 -0
- openmail/assistants/reply_suggestions.py +46 -0
- openmail/assistants/rewrite_email.py +50 -0
- openmail/assistants/summarize_attachments_for_email.py +101 -0
- openmail/assistants/summarize_thread_emails.py +55 -0
- openmail/assistants/summary.py +44 -0
- openmail/assistants/summary_multi.py +57 -0
- openmail/assistants/translate_email.py +54 -0
- openmail/auth/__init__.py +6 -0
- openmail/auth/base.py +34 -0
- openmail/auth/no_auth.py +19 -0
- openmail/auth/oauth2.py +58 -0
- openmail/auth/password.py +26 -0
- openmail/config.py +26 -0
- openmail/email_assistant.py +418 -0
- openmail/email_manager.py +777 -0
- openmail/email_query.py +279 -0
- openmail/errors.py +16 -0
- openmail/imap/__init__.py +5 -0
- openmail/imap/attachment_parts.py +55 -0
- openmail/imap/bodystructure.py +296 -0
- openmail/imap/client.py +806 -0
- openmail/imap/fetch_response.py +115 -0
- openmail/imap/inline_cid.py +106 -0
- openmail/imap/pagination.py +16 -0
- openmail/imap/parser.py +298 -0
- openmail/imap/query.py +233 -0
- openmail/llm/__init__.py +3 -0
- openmail/llm/claude.py +35 -0
- openmail/llm/costs.py +108 -0
- openmail/llm/gemini.py +34 -0
- openmail/llm/gpt.py +33 -0
- openmail/llm/groq.py +36 -0
- openmail/llm/model.py +126 -0
- openmail/llm/xai.py +35 -0
- openmail/logger.py +20 -0
- openmail/models/__init__.py +20 -0
- openmail/models/attachment.py +128 -0
- openmail/models/message.py +113 -0
- openmail/models/subscription.py +45 -0
- openmail/models/task.py +24 -0
- openmail/py.typed +0 -0
- openmail/smtp/__init__.py +7 -0
- openmail/smtp/builder.py +41 -0
- openmail/smtp/client.py +218 -0
- openmail/smtp/templates.py +16 -0
- openmail/subscription/__init__.py +7 -0
- openmail/subscription/detector.py +58 -0
- openmail/subscription/parser.py +32 -0
- openmail/subscription/service.py +237 -0
- openmail/types.py +30 -0
- openmail/utils/__init__.py +39 -0
- openmail/utils/utils.py +295 -0
- openmail-0.1.5.dist-info/METADATA +180 -0
- openmail-0.1.5.dist-info/RECORD +67 -0
- openmail-0.1.5.dist-info/WHEEL +4 -0
- openmail-0.1.5.dist-info/licenses/LICENSE +21 -0
openmail/imap/query.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _imap_date(iso_yyyy_mm_dd: str) -> str:
|
|
9
|
+
dt = datetime.strptime(iso_yyyy_mm_dd, "%Y-%m-%d")
|
|
10
|
+
return dt.strftime("%d-%b-%Y")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _q(s: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Quote/escape a string for IMAP SEARCH.
|
|
16
|
+
"""
|
|
17
|
+
s = s.replace("\\", "\\\\").replace('"', r"\"")
|
|
18
|
+
return f'"{s}"'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class IMAPQuery:
|
|
23
|
+
parts: List[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
# --- basic fields ---
|
|
26
|
+
def from_(self, s: str) -> IMAPQuery:
|
|
27
|
+
self.parts += ["FROM", _q(s)]
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def to(self, s: str) -> IMAPQuery:
|
|
31
|
+
self.parts += ["TO", _q(s)]
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def cc(self, s: str) -> IMAPQuery:
|
|
35
|
+
self.parts += ["CC", _q(s)]
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def bcc(self, s: str) -> IMAPQuery:
|
|
39
|
+
self.parts += ["BCC", _q(s)]
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def subject(self, s: str) -> IMAPQuery:
|
|
43
|
+
self.parts += ["SUBJECT", _q(s)]
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def text(self, s: str) -> IMAPQuery:
|
|
47
|
+
"""
|
|
48
|
+
Match in headers OR body text.
|
|
49
|
+
"""
|
|
50
|
+
self.parts += ["TEXT", _q(s)]
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def body(self, s: str) -> IMAPQuery:
|
|
54
|
+
"""
|
|
55
|
+
Match only in body text.
|
|
56
|
+
"""
|
|
57
|
+
self.parts += ["BODY", _q(s)]
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def header(self, name: str, value: str) -> IMAPQuery:
|
|
61
|
+
self.parts += ["HEADER", _q(name), _q(value)]
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
# --- date filters ---
|
|
65
|
+
def since(self, iso_date: str) -> IMAPQuery:
|
|
66
|
+
self.parts += ["SINCE", _imap_date(iso_date)]
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def before(self, iso_date: str) -> IMAPQuery:
|
|
70
|
+
self.parts += ["BEFORE", _imap_date(iso_date)]
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def on(self, iso_date: str) -> IMAPQuery:
|
|
74
|
+
self.parts += ["ON", _imap_date(iso_date)]
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def sent_since(self, iso_date: str) -> IMAPQuery:
|
|
78
|
+
self.parts += ["SENTSINCE", _imap_date(iso_date)]
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def sent_before(self, iso_date: str) -> IMAPQuery:
|
|
82
|
+
self.parts += ["SENTBEFORE", _imap_date(iso_date)]
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def sent_on(self, iso_date: str) -> IMAPQuery:
|
|
86
|
+
self.parts += ["SENTON", _imap_date(iso_date)]
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
# --- flags/status ---
|
|
90
|
+
def seen(self) -> IMAPQuery:
|
|
91
|
+
self.parts += ["SEEN"]
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def unseen(self) -> IMAPQuery:
|
|
95
|
+
self.parts += ["UNSEEN"]
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def answered(self) -> IMAPQuery:
|
|
99
|
+
self.parts += ["ANSWERED"]
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def unanswered(self) -> IMAPQuery:
|
|
103
|
+
self.parts += ["UNANSWERED"]
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def flagged(self) -> IMAPQuery:
|
|
107
|
+
self.parts += ["FLAGGED"]
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def unflagged(self) -> IMAPQuery:
|
|
111
|
+
self.parts += ["UNFLAGGED"]
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def deleted(self) -> IMAPQuery:
|
|
115
|
+
self.parts += ["DELETED"]
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def undeleted(self) -> IMAPQuery:
|
|
119
|
+
self.parts += ["UNDELETED"]
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def draft(self) -> IMAPQuery:
|
|
123
|
+
self.parts += ["DRAFT"]
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def undraft(self) -> IMAPQuery:
|
|
127
|
+
self.parts += ["UNDRAFT"]
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def recent(self) -> IMAPQuery:
|
|
131
|
+
self.parts += ["RECENT"]
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def new(self) -> IMAPQuery:
|
|
135
|
+
self.parts += ["NEW"]
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def old(self) -> IMAPQuery:
|
|
139
|
+
self.parts += ["OLD"]
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
# --- size ---
|
|
143
|
+
def larger(self, n_bytes: int) -> IMAPQuery:
|
|
144
|
+
self.parts += ["LARGER", str(n_bytes)]
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def smaller(self, n_bytes: int) -> IMAPQuery:
|
|
148
|
+
self.parts += ["SMALLER", str(n_bytes)]
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
# --- keyword ---
|
|
152
|
+
def keyword(self, name: str) -> IMAPQuery:
|
|
153
|
+
self.parts += ["KEYWORD", _q(name)]
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def unkeyword(self, name: str) -> IMAPQuery:
|
|
157
|
+
self.parts += ["UNKEYWORD", _q(name)]
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
# --- UID search ---
|
|
161
|
+
def uid(self, *uids: int | str) -> IMAPQuery:
|
|
162
|
+
"""
|
|
163
|
+
Accepts ranges ("1:100") or explicit UIDs (1,2,3)
|
|
164
|
+
"""
|
|
165
|
+
joined = ",".join(str(u) for u in uids)
|
|
166
|
+
self.parts += ["UID", joined]
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
# --- exclude fields ---
|
|
170
|
+
def _not(self, *tokens: str) -> IMAPQuery:
|
|
171
|
+
self.parts += ["NOT", *tokens]
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def exclude_from(self, s: str) -> IMAPQuery:
|
|
175
|
+
return self._not("FROM", _q(s))
|
|
176
|
+
|
|
177
|
+
def exclude_to(self, s: str) -> IMAPQuery:
|
|
178
|
+
return self._not("TO", _q(s))
|
|
179
|
+
|
|
180
|
+
def exclude_cc(self, s: str) -> IMAPQuery:
|
|
181
|
+
return self._not("CC", _q(s))
|
|
182
|
+
|
|
183
|
+
def exclude_bcc(self, s: str) -> IMAPQuery:
|
|
184
|
+
return self._not("BCC", _q(s))
|
|
185
|
+
|
|
186
|
+
def exclude_subject(self, s: str) -> IMAPQuery:
|
|
187
|
+
return self._not("SUBJECT", _q(s))
|
|
188
|
+
|
|
189
|
+
def exclude_header(self, name: str, value: str) -> IMAPQuery:
|
|
190
|
+
return self._not("HEADER", _q(name), _q(value))
|
|
191
|
+
|
|
192
|
+
def exclude_text(self, s: str) -> IMAPQuery:
|
|
193
|
+
return self._not("TEXT", _q(s))
|
|
194
|
+
|
|
195
|
+
def exclude_body(self, s: str) -> IMAPQuery:
|
|
196
|
+
return self._not("BODY", _q(s))
|
|
197
|
+
|
|
198
|
+
def or_(self, *queries: IMAPQuery) -> IMAPQuery:
|
|
199
|
+
qs = [q for q in (self, *queries) if q.parts]
|
|
200
|
+
|
|
201
|
+
if len(qs) < 2:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"or_ requires at least two non-empty IMAPQuery instances (including self)"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
tokens: List[str] = list(qs[0].parts)
|
|
207
|
+
|
|
208
|
+
for q in qs[1:]:
|
|
209
|
+
right = list(q.parts)
|
|
210
|
+
|
|
211
|
+
tokens = ["OR", "("] + tokens + [")", "("] + right + [")"]
|
|
212
|
+
|
|
213
|
+
self.parts = tokens
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
# --- composition helpers ---
|
|
217
|
+
def all(self) -> IMAPQuery:
|
|
218
|
+
self.parts += ["ALL"]
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def raw(self, *tokens: str) -> IMAPQuery:
|
|
222
|
+
"""
|
|
223
|
+
Append raw tokens for advanced users, e.g. raw("OR", 'FROM "a"', 'FROM "b"')
|
|
224
|
+
"""
|
|
225
|
+
self.parts += list(tokens)
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def build(self) -> str:
|
|
229
|
+
if not self.parts:
|
|
230
|
+
return "ALL"
|
|
231
|
+
s = " ".join(self.parts)
|
|
232
|
+
s = s.replace("( ", "(").replace(" )", ")")
|
|
233
|
+
return s
|
openmail/llm/__init__.py
ADDED
openmail/llm/claude.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from langchain_anthropic import ChatAnthropic
|
|
4
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_claude(
|
|
9
|
+
model_name: str,
|
|
10
|
+
pydantic_model: Type[BaseModel],
|
|
11
|
+
temperature: float = 0.1,
|
|
12
|
+
timeout: int = 120,
|
|
13
|
+
):
|
|
14
|
+
|
|
15
|
+
base_llm = ChatAnthropic(
|
|
16
|
+
model=model_name,
|
|
17
|
+
temperature=temperature,
|
|
18
|
+
timeout=timeout,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
llm_structured = base_llm.with_structured_output(pydantic_model)
|
|
22
|
+
|
|
23
|
+
base_prompt = ChatPromptTemplate.from_messages(
|
|
24
|
+
[
|
|
25
|
+
("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
|
|
26
|
+
MessagesPlaceholder("messages"),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
chain = base_prompt | llm_structured
|
|
31
|
+
|
|
32
|
+
if chain is None:
|
|
33
|
+
raise RuntimeError("LLM not available for the given model_name")
|
|
34
|
+
|
|
35
|
+
return chain
|
openmail/llm/costs.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
4
|
+
from langchain_core.outputs import LLMResult
|
|
5
|
+
|
|
6
|
+
OPENAI_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
|
|
7
|
+
"gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
|
|
8
|
+
"gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
|
|
9
|
+
"gpt-5.2": {"input": 1.75, "cached_input": 0.175, "output": 14.00},
|
|
10
|
+
"gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
|
|
11
|
+
"gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
XAI_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
|
|
15
|
+
"grok-4-1-fast-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
|
|
16
|
+
"grok-4-1-fast-non-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
|
|
17
|
+
"grok-4": {"input": 3.00, "cached_input": 0.75, "output": 15.00},
|
|
18
|
+
"grok-4-fast-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
|
|
19
|
+
"grok-4-fast-non-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
|
|
20
|
+
"grok-3-mini": {"input": 0.30, "cached_input": 0.075, "output": 0.50},
|
|
21
|
+
"grok-3": {"input": 3.00, "cached_input": 0.75, "output": 15.00},
|
|
22
|
+
}
|
|
23
|
+
GROQ_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
|
|
24
|
+
"openai/gpt-oss-20b": {"input": 0.075, "cached_input": 0.037, "output": 0.30},
|
|
25
|
+
"openai/gpt-oss-120b": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
|
|
26
|
+
"moonshotai/kimi-k2-instruct-0905": {"input": 1.00, "cached_input": 0.50, "output": 3.00},
|
|
27
|
+
"meta-llama/llama-4-scout-17b-16e-instruct": {
|
|
28
|
+
"input": 0.11,
|
|
29
|
+
"cached_input": 0.00,
|
|
30
|
+
"output": 0.34,
|
|
31
|
+
},
|
|
32
|
+
"meta-llama/llama-4-maverick-17b-128e-instruct": {
|
|
33
|
+
"input": 0.20,
|
|
34
|
+
"cached_input": 0.00,
|
|
35
|
+
"output": 0.60,
|
|
36
|
+
},
|
|
37
|
+
"qwen/qwen3-32b": {"input": 0.29, "cached_input": 0.00, "output": 0.59},
|
|
38
|
+
"llama-3.1-8b-instant": {"input": 0.05, "cached_input": 0.00, "output": 0.08},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
GEMINI_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
|
|
42
|
+
"gemini-3-flash-preview": {"input": 0.50, "cached_input": 0.05, "output": 3.00},
|
|
43
|
+
"gemini-2.5-flash": {"input": 0.30, "cached_input": 0.03, "output": 2.50},
|
|
44
|
+
"gemini-2.5-flash-lite": {"input": 0.10, "cached_input": 0.01, "output": 0.40},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
CLAUDE_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
|
|
48
|
+
"claude-opus-4.5": {"input": 5.00, "cached_input": 0.50, "output": 25.00},
|
|
49
|
+
"claude-opus-4.1": {"input": 15.00, "cached_input": 1.50, "output": 75.00},
|
|
50
|
+
"claude-opus-4": {"input": 15.00, "cached_input": 1.50, "output": 75.00},
|
|
51
|
+
"claude-sonnet-4.5": {"input": 3.00, "cached_input": 0.30, "output": 15.00},
|
|
52
|
+
"claude-sonnet-4": {"input": 3.00, "cached_input": 0.30, "output": 15.00},
|
|
53
|
+
"claude-haiku-4.5": {"input": 1.00, "cached_input": 0.10, "output": 5.00},
|
|
54
|
+
"claude-haiku-3.5": {"input": 0.80, "cached_input": 0.08, "output": 4.00},
|
|
55
|
+
"claude-haiku-3": {"input": 0.25, "cached_input": 0.03, "output": 1.25},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TokenUsageCallback(BaseCallbackHandler):
|
|
60
|
+
"""Collect token usage from a single LLM call."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.prompt_tokens = 0
|
|
64
|
+
self.completion_tokens = 0
|
|
65
|
+
self.total_tokens = 0
|
|
66
|
+
self.cached_prompt_tokens = 0
|
|
67
|
+
|
|
68
|
+
def on_llm_end(self, response: LLMResult, **kwargs) -> None:
|
|
69
|
+
|
|
70
|
+
usage = (response.llm_output or {}).get("token_usage")
|
|
71
|
+
if usage:
|
|
72
|
+
self.prompt_tokens += usage.get("prompt_tokens", 0)
|
|
73
|
+
self.completion_tokens += usage.get("completion_tokens", 0)
|
|
74
|
+
self.total_tokens += usage.get("total_tokens", 0)
|
|
75
|
+
|
|
76
|
+
prompt_details = usage.get("prompt_tokens_details") or {}
|
|
77
|
+
self.cached_prompt_tokens += prompt_details.get("cached_tokens", 0)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _lookup_price(provider: str, model_name: str) -> Dict[str, float]:
|
|
82
|
+
if provider == "openai":
|
|
83
|
+
return OPENAI_PRICES_PER_1M[model_name]
|
|
84
|
+
if provider == "gemini":
|
|
85
|
+
return GEMINI_PRICES_PER_1M[model_name]
|
|
86
|
+
if provider == "xai":
|
|
87
|
+
return XAI_PRICES_PER_1M[model_name]
|
|
88
|
+
if provider == "groq":
|
|
89
|
+
return GROQ_PRICES_PER_1M[model_name]
|
|
90
|
+
if provider == "claude":
|
|
91
|
+
return CLAUDE_PRICES_PER_1M[model_name]
|
|
92
|
+
|
|
93
|
+
return {"input": 0.0, "cached_input": 0.0, "output": 0.0}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compute_cost_usd(
|
|
97
|
+
provider: str,
|
|
98
|
+
model_name: str,
|
|
99
|
+
prompt_tokens: int,
|
|
100
|
+
completion_tokens: int,
|
|
101
|
+
cached_prompt_tokens: int = 0,
|
|
102
|
+
) -> float:
|
|
103
|
+
prices = _lookup_price(provider, model_name)
|
|
104
|
+
return (
|
|
105
|
+
((prompt_tokens - cached_prompt_tokens) / 1000000.0) * prices["input"]
|
|
106
|
+
+ (cached_prompt_tokens / 1000000.0) * prices["cached_input"]
|
|
107
|
+
+ (completion_tokens / 1000000.0) * prices["output"]
|
|
108
|
+
)
|
openmail/llm/gemini.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
4
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_gemini(
|
|
9
|
+
model_name: str,
|
|
10
|
+
pydantic_model: Type[BaseModel],
|
|
11
|
+
temperature: float = 0.1,
|
|
12
|
+
timeout: int = 120,
|
|
13
|
+
):
|
|
14
|
+
|
|
15
|
+
base_llm = ChatGoogleGenerativeAI(
|
|
16
|
+
model=model_name,
|
|
17
|
+
temperature=temperature,
|
|
18
|
+
timeout=timeout,
|
|
19
|
+
convert_system_message_to_human=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
llm_structured = base_llm.with_structured_output(pydantic_model)
|
|
23
|
+
base_prompt = ChatPromptTemplate.from_messages(
|
|
24
|
+
[
|
|
25
|
+
("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
|
|
26
|
+
MessagesPlaceholder("messages"),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
chain = base_prompt | llm_structured
|
|
30
|
+
|
|
31
|
+
if chain is None:
|
|
32
|
+
raise RuntimeError("LLM not available for the given model_name")
|
|
33
|
+
|
|
34
|
+
return chain
|
openmail/llm/gpt.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
4
|
+
from langchain_openai import ChatOpenAI
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_openai(
|
|
9
|
+
model_name: str,
|
|
10
|
+
pydantic_model: Type[BaseModel],
|
|
11
|
+
temperature: float = 0.1,
|
|
12
|
+
timeout: int = 120,
|
|
13
|
+
):
|
|
14
|
+
|
|
15
|
+
base_llm = ChatOpenAI(
|
|
16
|
+
model=model_name,
|
|
17
|
+
temperature=temperature,
|
|
18
|
+
timeout=timeout,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
llm_structured = base_llm.with_structured_output(pydantic_model)
|
|
22
|
+
base_prompt = ChatPromptTemplate.from_messages(
|
|
23
|
+
[
|
|
24
|
+
("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
|
|
25
|
+
MessagesPlaceholder("messages"),
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
chain = base_prompt | llm_structured
|
|
29
|
+
|
|
30
|
+
if chain is None:
|
|
31
|
+
raise RuntimeError("LLM not available for the given model_name")
|
|
32
|
+
|
|
33
|
+
return chain
|
openmail/llm/groq.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
4
|
+
from langchain_groq import ChatGroq
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_groq(
|
|
9
|
+
model_name: str,
|
|
10
|
+
pydantic_model: Type[BaseModel],
|
|
11
|
+
temperature: float = 0.1,
|
|
12
|
+
timeout: int = 120,
|
|
13
|
+
):
|
|
14
|
+
|
|
15
|
+
base_llm = ChatGroq(
|
|
16
|
+
model=model_name,
|
|
17
|
+
temperature=temperature,
|
|
18
|
+
timeout=timeout,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
llm_structured = base_llm.with_structured_output(
|
|
22
|
+
pydantic_model,
|
|
23
|
+
method="function_calling",
|
|
24
|
+
)
|
|
25
|
+
base_prompt = ChatPromptTemplate.from_messages(
|
|
26
|
+
[
|
|
27
|
+
("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
|
|
28
|
+
MessagesPlaceholder("messages"),
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
chain = base_prompt | llm_structured
|
|
32
|
+
|
|
33
|
+
if chain is None:
|
|
34
|
+
raise RuntimeError("LLM not available for the given model_name")
|
|
35
|
+
|
|
36
|
+
return chain
|
openmail/llm/model.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from json import JSONDecodeError
|
|
4
|
+
from random import random
|
|
5
|
+
from time import sleep
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar
|
|
7
|
+
|
|
8
|
+
from langchain_core.exceptions import OutputParserException
|
|
9
|
+
from openai import APIConnectionError, APITimeoutError, RateLimitError
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
from openmail.llm.claude import get_claude
|
|
13
|
+
from openmail.llm.costs import TokenUsageCallback, compute_cost_usd
|
|
14
|
+
from openmail.llm.gemini import get_gemini
|
|
15
|
+
from openmail.llm.gpt import get_openai
|
|
16
|
+
from openmail.llm.groq import get_groq
|
|
17
|
+
from openmail.llm.xai import get_xai
|
|
18
|
+
|
|
19
|
+
TModel = TypeVar("TModel", bound=BaseModel)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache(maxsize=None)
|
|
23
|
+
def _get_base_llm(
|
|
24
|
+
provider: str,
|
|
25
|
+
model_name: str,
|
|
26
|
+
pydantic_model: Type[TModel],
|
|
27
|
+
temperature: float = 0.1,
|
|
28
|
+
timeout: int = 120,
|
|
29
|
+
):
|
|
30
|
+
if provider == "openai":
|
|
31
|
+
return get_openai(model_name, pydantic_model, temperature, timeout)
|
|
32
|
+
if provider == "gemini":
|
|
33
|
+
return get_gemini(model_name, pydantic_model, temperature, timeout)
|
|
34
|
+
if provider == "xai":
|
|
35
|
+
return get_xai(model_name, pydantic_model, temperature, timeout)
|
|
36
|
+
if provider == "groq":
|
|
37
|
+
return get_groq(model_name, pydantic_model, temperature, timeout)
|
|
38
|
+
if provider == "claude":
|
|
39
|
+
return get_claude(model_name, pydantic_model, temperature, timeout)
|
|
40
|
+
|
|
41
|
+
raise RuntimeError("LLM not available for the given model_name")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_model(
|
|
45
|
+
provider: str,
|
|
46
|
+
model_name: str,
|
|
47
|
+
pydantic_model: Type[TModel],
|
|
48
|
+
temperature: float = 0.1,
|
|
49
|
+
retries: int = 5, # -1 for infinite retries
|
|
50
|
+
base_delay: float = 1.5,
|
|
51
|
+
max_delay: float = 30.0,
|
|
52
|
+
timeout: int = 120,
|
|
53
|
+
all_fail_raise: bool = True,
|
|
54
|
+
) -> Callable[[str], Tuple[TModel, Dict[str, Any]]]:
|
|
55
|
+
|
|
56
|
+
chain = _get_base_llm(provider, model_name, pydantic_model, temperature, timeout)
|
|
57
|
+
|
|
58
|
+
TRANSIENT_EXC = (APIConnectionError, APITimeoutError, RateLimitError)
|
|
59
|
+
PARSE_EXC = (JSONDecodeError, OutputParserException, ValidationError)
|
|
60
|
+
|
|
61
|
+
history: List[Dict[str, str]] = []
|
|
62
|
+
|
|
63
|
+
def run(prompt_text: str) -> Tuple[TModel, Dict[str, Any]]:
|
|
64
|
+
nonlocal history
|
|
65
|
+
infinite = retries == -1
|
|
66
|
+
max_tries = float("inf") if infinite else max(1, retries)
|
|
67
|
+
delay = base_delay
|
|
68
|
+
last_exc: Optional[BaseException] = None
|
|
69
|
+
|
|
70
|
+
attempt = 0
|
|
71
|
+
while attempt < max_tries:
|
|
72
|
+
try:
|
|
73
|
+
messages = history + [
|
|
74
|
+
{"role": "user", "content": prompt_text},
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
usage_cb = TokenUsageCallback()
|
|
78
|
+
t0 = time.perf_counter()
|
|
79
|
+
out = chain.invoke(
|
|
80
|
+
{"messages": messages},
|
|
81
|
+
config={"callbacks": [usage_cb]},
|
|
82
|
+
)
|
|
83
|
+
t1 = time.perf_counter()
|
|
84
|
+
|
|
85
|
+
model_obj = (
|
|
86
|
+
out if isinstance(out, BaseModel) else pydantic_model.model_validate(out)
|
|
87
|
+
)
|
|
88
|
+
out_dict = model_obj.model_dump()
|
|
89
|
+
|
|
90
|
+
history = messages + [{"role": "assistant", "content": model_obj.model_dump_json()}]
|
|
91
|
+
|
|
92
|
+
cost_usd = compute_cost_usd(
|
|
93
|
+
provider,
|
|
94
|
+
model_name,
|
|
95
|
+
prompt_tokens=usage_cb.prompt_tokens,
|
|
96
|
+
completion_tokens=usage_cb.completion_tokens,
|
|
97
|
+
cached_prompt_tokens=usage_cb.cached_prompt_tokens,
|
|
98
|
+
)
|
|
99
|
+
llm_call_info = {
|
|
100
|
+
"model": model_name,
|
|
101
|
+
"prompt": prompt_text,
|
|
102
|
+
"response": out_dict,
|
|
103
|
+
"call_time": t1 - t0,
|
|
104
|
+
"timestamp": time.strftime("%m/%d/%Y_%H:%M:%S"),
|
|
105
|
+
"prompt_tokens": usage_cb.prompt_tokens,
|
|
106
|
+
"completion_tokens": usage_cb.completion_tokens,
|
|
107
|
+
"cached_prompt_tokens": usage_cb.cached_prompt_tokens,
|
|
108
|
+
"cost_usd": cost_usd,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return model_obj, llm_call_info
|
|
112
|
+
|
|
113
|
+
except (*TRANSIENT_EXC, *PARSE_EXC) as e:
|
|
114
|
+
last_exc = e
|
|
115
|
+
except Exception as e:
|
|
116
|
+
last_exc = e
|
|
117
|
+
|
|
118
|
+
attempt += 1
|
|
119
|
+
if not infinite and attempt >= max_tries:
|
|
120
|
+
if all_fail_raise and last_exc is not None:
|
|
121
|
+
raise last_exc
|
|
122
|
+
|
|
123
|
+
sleep(delay + random() * 0.5)
|
|
124
|
+
delay = min(delay * 2, max_delay)
|
|
125
|
+
|
|
126
|
+
return run
|
openmail/llm/xai.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
4
|
+
from langchain_xai import ChatXAI
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_xai(
|
|
9
|
+
model_name: str,
|
|
10
|
+
pydantic_model: Type[BaseModel],
|
|
11
|
+
temperature: float = 0.1,
|
|
12
|
+
timeout: int = 120,
|
|
13
|
+
):
|
|
14
|
+
|
|
15
|
+
base_llm = ChatXAI(
|
|
16
|
+
model=model_name,
|
|
17
|
+
temperature=temperature,
|
|
18
|
+
timeout=timeout,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
llm_structured = base_llm.with_structured_output(pydantic_model)
|
|
22
|
+
|
|
23
|
+
base_prompt = ChatPromptTemplate.from_messages(
|
|
24
|
+
[
|
|
25
|
+
("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
|
|
26
|
+
MessagesPlaceholder("messages"),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
chain = base_prompt | llm_structured
|
|
31
|
+
|
|
32
|
+
if chain is None:
|
|
33
|
+
raise RuntimeError("LLM not available for the given model_name")
|
|
34
|
+
|
|
35
|
+
return chain
|
openmail/logger.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
LOGGER_NAME = "email_manager"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_logger(name: str = LOGGER_NAME) -> logging.Logger:
|
|
9
|
+
return logging.getLogger(name)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
13
|
+
logger = get_logger()
|
|
14
|
+
if logger.handlers:
|
|
15
|
+
return
|
|
16
|
+
handler = logging.StreamHandler()
|
|
17
|
+
formatter = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
|
18
|
+
handler.setFormatter(formatter)
|
|
19
|
+
logger.addHandler(handler)
|
|
20
|
+
logger.setLevel(level)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from openmail.models.attachment import Attachment, AttachmentMeta
|
|
2
|
+
from openmail.models.message import EmailAddress, EmailMessage, EmailOverview
|
|
3
|
+
from openmail.models.subscription import (
|
|
4
|
+
UnsubscribeActionResult,
|
|
5
|
+
UnsubscribeCandidate,
|
|
6
|
+
UnsubscribeMethod,
|
|
7
|
+
)
|
|
8
|
+
from openmail.models.task import Task
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EmailAddress",
|
|
12
|
+
"EmailMessage",
|
|
13
|
+
"EmailOverview",
|
|
14
|
+
"AttachmentMeta",
|
|
15
|
+
"Attachment",
|
|
16
|
+
"UnsubscribeMethod",
|
|
17
|
+
"UnsubscribeCandidate",
|
|
18
|
+
"UnsubscribeActionResult",
|
|
19
|
+
"Task",
|
|
20
|
+
]
|