mail-swarms 1.3.2__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.
Files changed (137) hide show
  1. mail/__init__.py +35 -0
  2. mail/api.py +1964 -0
  3. mail/cli.py +432 -0
  4. mail/client.py +1657 -0
  5. mail/config/__init__.py +8 -0
  6. mail/config/client.py +87 -0
  7. mail/config/server.py +165 -0
  8. mail/core/__init__.py +72 -0
  9. mail/core/actions.py +69 -0
  10. mail/core/agents.py +73 -0
  11. mail/core/message.py +366 -0
  12. mail/core/runtime.py +3537 -0
  13. mail/core/tasks.py +311 -0
  14. mail/core/tools.py +1206 -0
  15. mail/db/__init__.py +0 -0
  16. mail/db/init.py +182 -0
  17. mail/db/types.py +65 -0
  18. mail/db/utils.py +523 -0
  19. mail/examples/__init__.py +27 -0
  20. mail/examples/analyst_dummy/__init__.py +15 -0
  21. mail/examples/analyst_dummy/agent.py +136 -0
  22. mail/examples/analyst_dummy/prompts.py +44 -0
  23. mail/examples/consultant_dummy/__init__.py +15 -0
  24. mail/examples/consultant_dummy/agent.py +136 -0
  25. mail/examples/consultant_dummy/prompts.py +42 -0
  26. mail/examples/data_analysis/__init__.py +40 -0
  27. mail/examples/data_analysis/analyst/__init__.py +9 -0
  28. mail/examples/data_analysis/analyst/agent.py +67 -0
  29. mail/examples/data_analysis/analyst/prompts.py +53 -0
  30. mail/examples/data_analysis/processor/__init__.py +13 -0
  31. mail/examples/data_analysis/processor/actions.py +293 -0
  32. mail/examples/data_analysis/processor/agent.py +67 -0
  33. mail/examples/data_analysis/processor/prompts.py +48 -0
  34. mail/examples/data_analysis/reporter/__init__.py +10 -0
  35. mail/examples/data_analysis/reporter/actions.py +187 -0
  36. mail/examples/data_analysis/reporter/agent.py +67 -0
  37. mail/examples/data_analysis/reporter/prompts.py +49 -0
  38. mail/examples/data_analysis/statistics/__init__.py +18 -0
  39. mail/examples/data_analysis/statistics/actions.py +343 -0
  40. mail/examples/data_analysis/statistics/agent.py +67 -0
  41. mail/examples/data_analysis/statistics/prompts.py +60 -0
  42. mail/examples/mafia/__init__.py +0 -0
  43. mail/examples/mafia/game.py +1537 -0
  44. mail/examples/mafia/narrator_tools.py +396 -0
  45. mail/examples/mafia/personas.py +240 -0
  46. mail/examples/mafia/prompts.py +489 -0
  47. mail/examples/mafia/roles.py +147 -0
  48. mail/examples/mafia/spec.md +350 -0
  49. mail/examples/math_dummy/__init__.py +23 -0
  50. mail/examples/math_dummy/actions.py +252 -0
  51. mail/examples/math_dummy/agent.py +136 -0
  52. mail/examples/math_dummy/prompts.py +46 -0
  53. mail/examples/math_dummy/types.py +5 -0
  54. mail/examples/research/__init__.py +39 -0
  55. mail/examples/research/researcher/__init__.py +9 -0
  56. mail/examples/research/researcher/agent.py +67 -0
  57. mail/examples/research/researcher/prompts.py +54 -0
  58. mail/examples/research/searcher/__init__.py +10 -0
  59. mail/examples/research/searcher/actions.py +324 -0
  60. mail/examples/research/searcher/agent.py +67 -0
  61. mail/examples/research/searcher/prompts.py +53 -0
  62. mail/examples/research/summarizer/__init__.py +18 -0
  63. mail/examples/research/summarizer/actions.py +255 -0
  64. mail/examples/research/summarizer/agent.py +67 -0
  65. mail/examples/research/summarizer/prompts.py +55 -0
  66. mail/examples/research/verifier/__init__.py +10 -0
  67. mail/examples/research/verifier/actions.py +337 -0
  68. mail/examples/research/verifier/agent.py +67 -0
  69. mail/examples/research/verifier/prompts.py +52 -0
  70. mail/examples/supervisor/__init__.py +11 -0
  71. mail/examples/supervisor/agent.py +4 -0
  72. mail/examples/supervisor/prompts.py +93 -0
  73. mail/examples/support/__init__.py +33 -0
  74. mail/examples/support/classifier/__init__.py +10 -0
  75. mail/examples/support/classifier/actions.py +307 -0
  76. mail/examples/support/classifier/agent.py +68 -0
  77. mail/examples/support/classifier/prompts.py +56 -0
  78. mail/examples/support/coordinator/__init__.py +9 -0
  79. mail/examples/support/coordinator/agent.py +67 -0
  80. mail/examples/support/coordinator/prompts.py +48 -0
  81. mail/examples/support/faq/__init__.py +10 -0
  82. mail/examples/support/faq/actions.py +182 -0
  83. mail/examples/support/faq/agent.py +67 -0
  84. mail/examples/support/faq/prompts.py +42 -0
  85. mail/examples/support/sentiment/__init__.py +15 -0
  86. mail/examples/support/sentiment/actions.py +341 -0
  87. mail/examples/support/sentiment/agent.py +67 -0
  88. mail/examples/support/sentiment/prompts.py +54 -0
  89. mail/examples/weather_dummy/__init__.py +23 -0
  90. mail/examples/weather_dummy/actions.py +75 -0
  91. mail/examples/weather_dummy/agent.py +136 -0
  92. mail/examples/weather_dummy/prompts.py +35 -0
  93. mail/examples/weather_dummy/types.py +5 -0
  94. mail/factories/__init__.py +27 -0
  95. mail/factories/action.py +223 -0
  96. mail/factories/base.py +1531 -0
  97. mail/factories/supervisor.py +241 -0
  98. mail/net/__init__.py +7 -0
  99. mail/net/registry.py +712 -0
  100. mail/net/router.py +728 -0
  101. mail/net/server_utils.py +114 -0
  102. mail/net/types.py +247 -0
  103. mail/server.py +1605 -0
  104. mail/stdlib/__init__.py +0 -0
  105. mail/stdlib/anthropic/__init__.py +0 -0
  106. mail/stdlib/fs/__init__.py +15 -0
  107. mail/stdlib/fs/actions.py +209 -0
  108. mail/stdlib/http/__init__.py +19 -0
  109. mail/stdlib/http/actions.py +333 -0
  110. mail/stdlib/interswarm/__init__.py +11 -0
  111. mail/stdlib/interswarm/actions.py +208 -0
  112. mail/stdlib/mcp/__init__.py +19 -0
  113. mail/stdlib/mcp/actions.py +294 -0
  114. mail/stdlib/openai/__init__.py +13 -0
  115. mail/stdlib/openai/agents.py +451 -0
  116. mail/summarizer.py +234 -0
  117. mail/swarms_json/__init__.py +27 -0
  118. mail/swarms_json/types.py +87 -0
  119. mail/swarms_json/utils.py +255 -0
  120. mail/url_scheme.py +51 -0
  121. mail/utils/__init__.py +53 -0
  122. mail/utils/auth.py +194 -0
  123. mail/utils/context.py +17 -0
  124. mail/utils/logger.py +73 -0
  125. mail/utils/openai.py +212 -0
  126. mail/utils/parsing.py +89 -0
  127. mail/utils/serialize.py +292 -0
  128. mail/utils/store.py +49 -0
  129. mail/utils/string_builder.py +119 -0
  130. mail/utils/version.py +20 -0
  131. mail_swarms-1.3.2.dist-info/METADATA +237 -0
  132. mail_swarms-1.3.2.dist-info/RECORD +137 -0
  133. mail_swarms-1.3.2.dist-info/WHEEL +4 -0
  134. mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
  135. mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
  136. mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
  137. mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
@@ -0,0 +1,27 @@
1
+ from .types import (
2
+ SwarmsJSONAction,
3
+ SwarmsJSONAgent,
4
+ SwarmsJSONFile,
5
+ SwarmsJSONSwarm,
6
+ )
7
+ from .utils import (
8
+ build_action_from_swarms_json,
9
+ build_agent_from_swarms_json,
10
+ build_swarm_from_swarms_json,
11
+ build_swarms_from_swarms_json,
12
+ load_swarms_json_from_file,
13
+ load_swarms_json_from_string,
14
+ )
15
+
16
+ __all__ = [
17
+ "SwarmsJSONAction",
18
+ "SwarmsJSONAgent",
19
+ "SwarmsJSONFile",
20
+ "SwarmsJSONSwarm",
21
+ "build_action_from_swarms_json",
22
+ "build_agent_from_swarms_json",
23
+ "build_swarm_from_swarms_json",
24
+ "build_swarms_from_swarms_json",
25
+ "load_swarms_json_from_file",
26
+ "load_swarms_json_from_string",
27
+ ]
@@ -0,0 +1,87 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ from typing import Any, Literal, TypedDict
5
+
6
+
7
+ class SwarmsJSONFile(TypedDict):
8
+ """
9
+ A standardized container for MAIL swarms and their configuration.
10
+ """
11
+
12
+ swarms: list["SwarmsJSONSwarm"]
13
+
14
+
15
+ class SwarmsJSONSwarm(TypedDict):
16
+ """
17
+ A MAIL swarm and its configuration, following the `swarms.json` format.
18
+ """
19
+
20
+ name: str
21
+ """The swarm's name."""
22
+ version: str
23
+ """The version of `mail` to build this swarm with."""
24
+ description: str # default: ""
25
+ """The description of the swarm."""
26
+ keywords: list[str] # default: []
27
+ """The keywords of the swarm."""
28
+ public: bool # default: False
29
+ """Whether this swarm is publicly accessible."""
30
+ entrypoint: str
31
+ """The name of the swarm's default entrypoint agent."""
32
+ enable_interswarm: bool # default: False
33
+ """Whether to enable interswarm communication for this swarm."""
34
+ action_imports: list[str] # default: []
35
+ """Python import strings that resolve to pre-built MAILAction instances."""
36
+ agents: list["SwarmsJSONAgent"]
37
+ """The agents in this swarm."""
38
+ actions: list["SwarmsJSONAction"]
39
+ """The actions in this swarm."""
40
+ breakpoint_tools: list[str] # default: []
41
+ """The tools that can be used to breakpoint the swarm."""
42
+ exclude_tools: list[str] # default: []
43
+ """The names of MAIL tools that should not be available to the swarm."""
44
+ enable_db_agent_histories: bool # default: False
45
+ """Whether to enable database persistence for agent histories."""
46
+
47
+
48
+ class SwarmsJSONAgent(TypedDict):
49
+ """
50
+ A MAIL agent and its configuration, following the `swarms.json` format.
51
+ """
52
+
53
+ name: str
54
+ """The agent's name."""
55
+ factory: str
56
+ """The agent's factory function as a Python import string."""
57
+ comm_targets: list[str]
58
+ """The names of the agents this agent can communicate with."""
59
+ enable_entrypoint: bool # default: False
60
+ """Whether this agent can be used as a swarm entrypoint."""
61
+ enable_interswarm: bool # default: False
62
+ """Whether this agent can communicate with other swarms."""
63
+ can_complete_tasks: bool # default: False
64
+ """Whether this agent can complete tasks."""
65
+ tool_format: Literal["completions", "responses"] # default: "responses"
66
+ """The format of the tools this agent can use."""
67
+ actions: list[str] # default: []
68
+ """The names of the actions this agent can use."""
69
+ agent_params: dict[str, Any]
70
+ """The parameters for this agent."""
71
+ exclude_tools: list[str] # default: []
72
+ """The names ofMAIL tools that should not be available to this agent."""
73
+
74
+
75
+ class SwarmsJSONAction(TypedDict):
76
+ """
77
+ A MAIL action and its configuration, following the `swarms.json` format.
78
+ """
79
+
80
+ name: str
81
+ """The action's name."""
82
+ description: str
83
+ """The action's description."""
84
+ parameters: dict[str, Any]
85
+ """The parameters for this action."""
86
+ function: str
87
+ """The action's function as a Python import string."""
@@ -0,0 +1,255 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ import json
5
+ import warnings
6
+ from typing import Any
7
+
8
+ from .types import (
9
+ SwarmsJSONAction,
10
+ SwarmsJSONAgent,
11
+ SwarmsJSONFile,
12
+ SwarmsJSONSwarm,
13
+ )
14
+
15
+
16
+ def load_swarms_json_from_file(path: str) -> SwarmsJSONFile:
17
+ """
18
+ Load a `swarms.json` file from a given path.
19
+ """
20
+ with open(path) as f:
21
+ contents = json.load(f)
22
+ if not isinstance(contents, list):
23
+ raise ValueError(
24
+ f"swarms.json file at {path} must contain a list of swarms, actually got {type(contents)}"
25
+ )
26
+ for swarm in contents:
27
+ validate_swarm_from_swarms_json(swarm)
28
+ return SwarmsJSONFile(swarms=contents)
29
+
30
+
31
+ def load_swarms_json_from_string(contents: str) -> SwarmsJSONFile:
32
+ """
33
+ Load a `swarms.json` string from a given string of contents.
34
+ """
35
+ contents = json.loads(contents)
36
+ if not isinstance(contents, list):
37
+ raise ValueError(
38
+ f"swarms.json string must contain a list of swarms, actually got {type(contents)}"
39
+ )
40
+ for swarm in contents:
41
+ validate_swarm_from_swarms_json(swarm)
42
+ return SwarmsJSONFile(swarms=contents)
43
+
44
+
45
+ def build_swarms_from_swarms_json(contents: list[Any]) -> list[SwarmsJSONSwarm]:
46
+ """
47
+ Build a list of `SwarmsJSONSwarm` from a list of `SwarmsJSONFile` contents.
48
+ """
49
+ for swarm_candidate in contents:
50
+ validate_swarm_from_swarms_json(swarm_candidate)
51
+
52
+ return [
53
+ build_swarm_from_swarms_json(swarm_candidate) for swarm_candidate in contents
54
+ ]
55
+
56
+
57
+ def validate_swarm_from_swarms_json(swarm_candidate: Any) -> None:
58
+ """
59
+ Ensure the candidate is a valid `SwarmsJSONSwarm`.
60
+ """
61
+ if not isinstance(swarm_candidate, dict):
62
+ raise ValueError(
63
+ f"swarm candidate must be a dict, actually got {type(swarm_candidate)}"
64
+ )
65
+
66
+ REQUIRED_FIELDS: dict[str, type] = {
67
+ "name": str,
68
+ "version": str,
69
+ "entrypoint": str,
70
+ "agents": list,
71
+ "actions": list,
72
+ }
73
+
74
+ OPTIONAL_FIELDS: dict[str, type] = {
75
+ "enable_interswarm": bool,
76
+ "breakpoint_tools": list,
77
+ "exclude_tools": list,
78
+ "action_imports": list,
79
+ }
80
+
81
+ for field, field_type in REQUIRED_FIELDS.items():
82
+ if field not in swarm_candidate:
83
+ raise ValueError(f"swarm candidate must contain a '{field}' field")
84
+ if not isinstance(swarm_candidate[field], field_type):
85
+ raise ValueError(
86
+ f"swarm candidate field '{field}' must be a {field_type.__name__}, actually got {type(swarm_candidate[field])}"
87
+ )
88
+
89
+ for field, field_type in OPTIONAL_FIELDS.items():
90
+ if field not in swarm_candidate:
91
+ continue
92
+ if not isinstance(swarm_candidate[field], field_type):
93
+ raise ValueError(
94
+ f"swarm candidate field '{field}' must be a {field_type.__name__}, actually got {type(swarm_candidate[field])}"
95
+ )
96
+
97
+ if "action_imports" in swarm_candidate:
98
+ imports = swarm_candidate["action_imports"]
99
+ if any(not isinstance(item, str) for item in imports):
100
+ raise ValueError(
101
+ "swarm candidate field 'action_imports' must be a list of strings"
102
+ )
103
+
104
+ return
105
+
106
+
107
+ def build_swarm_from_swarms_json(swarm_candidate: Any) -> SwarmsJSONSwarm:
108
+ """
109
+ Build a `SwarmsJSONSwarm` from a candidate.
110
+ """
111
+ validate_swarm_from_swarms_json(swarm_candidate)
112
+ return SwarmsJSONSwarm(
113
+ name=swarm_candidate["name"],
114
+ version=swarm_candidate["version"],
115
+ description=swarm_candidate.get("description", ""),
116
+ keywords=swarm_candidate.get("keywords", []),
117
+ public=swarm_candidate.get("public", False),
118
+ entrypoint=swarm_candidate["entrypoint"],
119
+ agents=[
120
+ build_agent_from_swarms_json(agent) for agent in swarm_candidate["agents"]
121
+ ],
122
+ actions=[
123
+ build_action_from_swarms_json(action)
124
+ for action in swarm_candidate["actions"]
125
+ ],
126
+ action_imports=swarm_candidate.get("action_imports", []),
127
+ enable_interswarm=swarm_candidate.get("enable_interswarm", False),
128
+ breakpoint_tools=swarm_candidate.get("breakpoint_tools", []),
129
+ exclude_tools=swarm_candidate.get("exclude_tools", []),
130
+ enable_db_agent_histories=swarm_candidate.get(
131
+ "enable_db_agent_histories", False
132
+ ),
133
+ )
134
+
135
+
136
+ def validate_agent_from_swarms_json(agent_candidate: Any) -> None:
137
+ """
138
+ Ensure the candidate is a valid `SwarmsJSONAgent`.
139
+ """
140
+ if not isinstance(agent_candidate, dict):
141
+ raise ValueError(
142
+ f"agent candidate must be a dict, actually got {type(agent_candidate)}"
143
+ )
144
+
145
+ REQUIRED_FIELDS: dict[str, type] = {
146
+ "name": str,
147
+ "factory": str,
148
+ "comm_targets": list,
149
+ "agent_params": dict,
150
+ }
151
+
152
+ OPTIONAL_FIELDS: dict[str, type] = {
153
+ "enable_entrypoint": bool,
154
+ "enable_interswarm": bool,
155
+ "can_complete_tasks": bool,
156
+ "tool_format": str,
157
+ "actions": list,
158
+ }
159
+
160
+ for field, field_type in REQUIRED_FIELDS.items():
161
+ if field not in agent_candidate:
162
+ raise ValueError(f"agent candidate must contain a '{field}' field")
163
+ if not isinstance(agent_candidate[field], field_type):
164
+ raise ValueError(
165
+ f"agent candidate field '{field}' must be a {field_type.__name__}, actually got {type(agent_candidate[field])}"
166
+ )
167
+
168
+ for field, field_type in OPTIONAL_FIELDS.items():
169
+ if field not in agent_candidate:
170
+ continue
171
+ if not isinstance(agent_candidate[field], field_type):
172
+ raise ValueError(
173
+ f"agent candidate field '{field}' must be a {field_type.__name__}, actually got {type(agent_candidate[field])}"
174
+ )
175
+
176
+ # Warn about deprecated tool_format placement
177
+ if "agent_params" in agent_candidate:
178
+ if "tool_format" in agent_candidate["agent_params"]:
179
+ warnings.warn(
180
+ f"agent '{agent_candidate.get('name', '?')}' has tool_format inside agent_params; "
181
+ "this is deprecated, use top-level tool_format instead",
182
+ DeprecationWarning,
183
+ stacklevel=2,
184
+ )
185
+
186
+ return
187
+
188
+
189
+ def build_agent_from_swarms_json(agent_candidate: Any) -> SwarmsJSONAgent:
190
+ """
191
+ Build a `SwarmsJSONAgent` from a candidate.
192
+ """
193
+ validate_agent_from_swarms_json(agent_candidate)
194
+ return SwarmsJSONAgent(
195
+ name=agent_candidate["name"],
196
+ factory=agent_candidate["factory"],
197
+ comm_targets=agent_candidate["comm_targets"],
198
+ agent_params=agent_candidate["agent_params"],
199
+ enable_entrypoint=agent_candidate.get("enable_entrypoint", False),
200
+ enable_interswarm=agent_candidate.get("enable_interswarm", False),
201
+ can_complete_tasks=agent_candidate.get("can_complete_tasks", False),
202
+ tool_format=agent_candidate.get("tool_format", "responses"),
203
+ actions=agent_candidate.get("actions", []),
204
+ exclude_tools=agent_candidate.get("exclude_tools", []),
205
+ )
206
+
207
+
208
+ def validate_action_from_swarms_json(action_candidate: Any) -> None:
209
+ """
210
+ Ensure the candidate is a valid `SwarmsJSONAction`.
211
+ """
212
+ if not isinstance(action_candidate, dict):
213
+ raise ValueError(
214
+ f"action candidate must be a dict, actually got {type(action_candidate)}"
215
+ )
216
+
217
+ REQUIRED_FIELDS: dict[str, type] = {
218
+ "name": str,
219
+ "description": str,
220
+ "parameters": dict,
221
+ "function": str,
222
+ }
223
+
224
+ OPTIONAL_FIELDS: dict[str, type] = {}
225
+
226
+ for field, field_type in REQUIRED_FIELDS.items():
227
+ if field not in action_candidate:
228
+ raise ValueError(f"action candidate must contain a '{field}' field")
229
+ if not isinstance(action_candidate[field], field_type):
230
+ raise ValueError(
231
+ f"action candidate field '{field}' must be a {field_type.__name__}, actually got {type(action_candidate[field])}"
232
+ )
233
+
234
+ for field, field_type in OPTIONAL_FIELDS.items():
235
+ if field not in action_candidate:
236
+ continue
237
+ if not isinstance(action_candidate[field], field_type):
238
+ raise ValueError(
239
+ f"action candidate field '{field}' must be a {field_type.__name__}, actually got {type(action_candidate[field])}"
240
+ )
241
+
242
+ return
243
+
244
+
245
+ def build_action_from_swarms_json(action_candidate: Any) -> SwarmsJSONAction:
246
+ """
247
+ Build a `SwarmsJSONAction` from a candidate.
248
+ """
249
+ validate_action_from_swarms_json(action_candidate)
250
+ return SwarmsJSONAction(
251
+ name=action_candidate["name"],
252
+ description=action_candidate["description"],
253
+ parameters=action_candidate["parameters"],
254
+ function=action_candidate["function"],
255
+ )
mail/url_scheme.py ADDED
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ """
5
+ URL scheme parsing for swarm:// URLs.
6
+
7
+ Supports:
8
+ swarm://connect?server=<host>&token=<api_key>
9
+ swarm://invite?server=<host>&token=<api_key>
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+
16
+ @dataclass
17
+ class SwarmURL:
18
+ """Parsed swarm:// URL."""
19
+
20
+ action: str # "connect" or "invite"
21
+ server: str | None = None
22
+ token: str | None = None
23
+
24
+
25
+ def parse_swarm_url(url: str) -> SwarmURL | None:
26
+ """
27
+ Parse a swarm:// URL into its components.
28
+
29
+ Args:
30
+ url: The URL to parse (e.g., "swarm://connect?server=example.com&token=abc")
31
+
32
+ Returns:
33
+ SwarmURL if the URL is a valid swarm:// URL, None otherwise.
34
+ """
35
+ if not url.startswith("swarm://"):
36
+ return None
37
+
38
+ parsed = urlparse(url)
39
+
40
+ # For swarm://connect?server=x, parsed.netloc = "connect", parsed.query = "server=x"
41
+ action = parsed.netloc
42
+
43
+ if action in ("connect", "invite"):
44
+ params = parse_qs(parsed.query)
45
+ return SwarmURL(
46
+ action=action,
47
+ server=params.get("server", [None])[0],
48
+ token=params.get("token", [None])[0],
49
+ )
50
+
51
+ return None
mail/utils/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ from .auth import (
2
+ caller_is_admin,
3
+ caller_is_admin_or_user,
4
+ caller_is_agent,
5
+ caller_is_user,
6
+ extract_token,
7
+ extract_token_info,
8
+ get_token_info,
9
+ login,
10
+ require_debug,
11
+ )
12
+ from .logger import (
13
+ get_loggers,
14
+ init_logger,
15
+ )
16
+ from .parsing import (
17
+ read_python_string,
18
+ resolve_prefixed_string_references,
19
+ target_address_is_interswarm,
20
+ )
21
+ from .serialize import (
22
+ export,
23
+ serialize_mail_value,
24
+ )
25
+ from .store import (
26
+ get_langmem_store,
27
+ )
28
+ from .version import (
29
+ get_protocol_version,
30
+ get_version,
31
+ )
32
+
33
+ __all__ = [
34
+ "login",
35
+ "get_token_info",
36
+ "get_loggers",
37
+ "init_logger",
38
+ "read_python_string",
39
+ "resolve_prefixed_string_references",
40
+ "target_address_is_interswarm",
41
+ "get_langmem_store",
42
+ "caller_is_admin",
43
+ "caller_is_user",
44
+ "caller_is_admin_or_user",
45
+ "caller_is_agent",
46
+ "extract_token_info",
47
+ "extract_token",
48
+ "get_version",
49
+ "get_protocol_version",
50
+ "export",
51
+ "serialize_mail_value",
52
+ "require_debug",
53
+ ]
mail/utils/auth.py ADDED
@@ -0,0 +1,194 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ import logging
5
+ import os
6
+ from typing import Any
7
+
8
+ import aiohttp
9
+ from fastapi import HTTPException, Request
10
+
11
+ JWT_SECRET = os.getenv("JWT_SECRET")
12
+
13
+ logger = logging.getLogger("mail.auth")
14
+
15
+
16
+ async def check_auth_endpoints() -> None:
17
+ """
18
+ Check if the auth endpoints are set.
19
+ This is necessary for the server to start.
20
+ """
21
+ AUTH_ENDPOINTS = ["AUTH_ENDPOINT", "TOKEN_INFO_ENDPOINT"]
22
+ for endpoint in AUTH_ENDPOINTS:
23
+ if endpoint not in os.environ or os.getenv(endpoint) is None:
24
+ logger.error(f"required environment variable '{endpoint}' is not set")
25
+ raise Exception(f"required environment variable '{endpoint}' is not set")
26
+
27
+
28
+ def extract_token(request: Request) -> str:
29
+ """
30
+ Extract the token from the request.
31
+ """
32
+ auth_header = request.headers.get("Authorization", "")
33
+ if auth_header.startswith("Bearer "):
34
+ return auth_header.split(" ")[1]
35
+ else:
36
+ raise HTTPException(status_code=401, detail="invalid API key format")
37
+
38
+
39
+ async def login(api_key: str) -> str:
40
+ """
41
+ Authenticate a user with an API key.
42
+
43
+ Args:
44
+ api_key: The API key to validate
45
+
46
+ Returns:
47
+ A user token if authentication is successful
48
+
49
+ Raises:
50
+ ValueError: If the API key is invalid
51
+ """
52
+ await check_auth_endpoints()
53
+ AUTH_ENDPOINT = os.getenv("AUTH_ENDPOINT")
54
+
55
+ # hit the login endpoint in the auth service
56
+ async with aiohttp.ClientSession() as session:
57
+ response = await session.post(
58
+ f"{AUTH_ENDPOINT}", headers={"Authorization": f"Bearer {api_key}"}
59
+ )
60
+ if response.status == 200:
61
+ data = await response.json()
62
+ logger.info(
63
+ f"[[green]{api_key[:8]}...[/green]] user or agent authenticated with API key"
64
+ )
65
+ return data["token"]
66
+ elif response.status == 401:
67
+ logger.warning(f"invalid API key: '{api_key}'")
68
+ raise HTTPException(status_code=401, detail="invalid API key")
69
+ else:
70
+ logger.error(
71
+ f"failed to authenticate user or agent with API key: '{api_key}': '{response.status}'"
72
+ )
73
+ raise HTTPException(
74
+ status_code=500,
75
+ detail="failed to authenticate user or agent with API key",
76
+ )
77
+
78
+
79
+ async def get_token_info(token: str) -> dict[str, Any]:
80
+ """
81
+ Get information about a JWT.
82
+ """
83
+ await check_auth_endpoints()
84
+ TOKEN_INFO_ENDPOINT = os.getenv("TOKEN_INFO_ENDPOINT")
85
+
86
+ async with aiohttp.ClientSession() as session:
87
+ response = await session.get(
88
+ f"{TOKEN_INFO_ENDPOINT}", headers={"Authorization": f"Bearer {token}"}
89
+ )
90
+ if response.status == 200:
91
+ data = await response.json()
92
+ return data
93
+ elif response.status == 401:
94
+ logger.warning(f"invalid token: '{token}'")
95
+ raise HTTPException(status_code=401, detail="invalid token")
96
+ else:
97
+ logger.error(f"failed to get token info: '{token}': '{response.status}'")
98
+ raise HTTPException(status_code=500, detail="failed to get token info")
99
+
100
+
101
+ async def caller_is_role(
102
+ request: Request, role: str, raise_on_false: bool = True
103
+ ) -> bool:
104
+ """
105
+ Check if the caller is a specific role.
106
+ """
107
+ token = request.headers.get("Authorization")
108
+ if token is None:
109
+ logger.warning("no API key provided")
110
+ raise HTTPException(status_code=401, detail="no API key provided")
111
+
112
+ if token.startswith("Bearer "):
113
+ token = token.split(" ")[1]
114
+ else:
115
+ logger.warning("invalid API key format: missing 'Bearer' prefix")
116
+ if raise_on_false:
117
+ raise HTTPException(status_code=401, detail="invalid API key format")
118
+ return False
119
+
120
+ # login to the auth service
121
+ jwt = await login(token)
122
+
123
+ token_info = await get_token_info(jwt)
124
+ if token_info["role"] != role:
125
+ if raise_on_false:
126
+ logger.warning(f"invalid role: '{token_info['role']}' != '{role}'")
127
+ raise HTTPException(status_code=401, detail="invalid role")
128
+ return False
129
+
130
+ return True
131
+
132
+
133
+ async def caller_is_admin(request: Request, raise_on_false: bool = True) -> bool:
134
+ """
135
+ Check if the caller is an `admin`.
136
+ """
137
+ return await caller_is_role(request, "admin", raise_on_false)
138
+
139
+
140
+ async def caller_is_user(request: Request, raise_on_false: bool = True) -> bool:
141
+ """
142
+ Check if the caller is a `user`.
143
+ """
144
+ return await caller_is_role(request, "user", raise_on_false)
145
+
146
+
147
+ async def caller_is_admin_or_user(request: Request) -> bool:
148
+ """
149
+ Check if the caller is an `admin` or a `user`.
150
+ """
151
+ is_admin = await caller_is_admin(request, raise_on_false=False)
152
+ is_user = await caller_is_user(request, raise_on_false=False)
153
+ if is_admin or is_user:
154
+ return True
155
+
156
+ logger.warning("invalid role: caller is not admin or user")
157
+ raise HTTPException(status_code=401, detail="invalid role")
158
+
159
+
160
+ async def caller_is_agent(request: Request, raise_on_false: bool = True) -> bool:
161
+ """
162
+ Check if the caller is an `agent`.
163
+ """
164
+ return await caller_is_role(request, "agent", raise_on_false)
165
+
166
+
167
+ async def extract_token_info(request: Request) -> dict[str, Any]:
168
+ """
169
+ Extract the token info from the request.
170
+ """
171
+ token = request.headers.get("Authorization")
172
+
173
+ if token is None:
174
+ logger.warning("no API key provided")
175
+ raise HTTPException(status_code=401, detail="no API key provided")
176
+ if token.startswith("Bearer "):
177
+ token = token.split(" ")[1]
178
+ else:
179
+ logger.warning("invalid API key format: missing 'Bearer' prefix")
180
+ raise HTTPException(status_code=401, detail="invalid API key format")
181
+
182
+ # login to the auth service
183
+ jwt = await login(token)
184
+
185
+ return await get_token_info(jwt)
186
+
187
+
188
+ def require_debug(request: Request) -> None:
189
+ """
190
+ Require the debug mode to be enabled.
191
+ """
192
+ if not request.app.state.debug:
193
+ logger.warning("debug mode is not enabled")
194
+ raise HTTPException(status_code=404, detail="Not found")
mail/utils/context.py ADDED
@@ -0,0 +1,17 @@
1
+ from functools import lru_cache
2
+
3
+ import litellm
4
+
5
+
6
+ @lru_cache(maxsize=1000)
7
+ def get_model_ctx_len(llm: str) -> int:
8
+ """
9
+ Get the context length of a model from the LiteLLM model cost map.
10
+ """
11
+ item = litellm.model_cost.get(llm)
12
+ if item is None:
13
+ _, slug = llm.split("/")
14
+ item = litellm.model_cost.get(slug)
15
+ if item is None:
16
+ return 0
17
+ return item.get("max_input_tokens", 0)