unique_toolkit 1.1.2__py3-none-any.whl → 1.1.4__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.
@@ -0,0 +1,250 @@
1
+ import hashlib
2
+ from datetime import datetime
3
+ from logging import Logger
4
+ from typing import Any, Generic
5
+
6
+ import jinja2
7
+ from pydantic import BaseModel
8
+
9
+ from unique_toolkit._common.endpoint_builder import (
10
+ ApiOperationProtocol,
11
+ PathParamsSpec,
12
+ PathParamsType,
13
+ PayloadParamSpec,
14
+ PayloadType,
15
+ ResponseType,
16
+ )
17
+ from unique_toolkit._common.endpoint_requestor import (
18
+ RequestorType,
19
+ build_requestor,
20
+ )
21
+ from unique_toolkit._common.pydantic_helpers import create_union_model
22
+ from unique_toolkit._common.string_utilities import (
23
+ dict_to_markdown_table,
24
+ extract_dicts_from_string,
25
+ )
26
+ from unique_toolkit.chat.schemas import ChatMessage, ChatMessageRole
27
+
28
+
29
+ class HumanConfirmation(BaseModel):
30
+ payload_hash: str
31
+ time_stamp: datetime
32
+
33
+
34
+ NEXT_USER_MESSAGE_JINJA2_TEMPLATE = jinja2.Template("""I confirm the api call with the following data:
35
+ ```json
36
+ {{ api_call_as_json }}
37
+ ```""")
38
+
39
+
40
+ ASSISTANT_CONFIRMATION_MESSAGE_JINJA2_TEMPLATE = jinja2.Template("""I would like to call the api with the following data:
41
+
42
+ {{ api_call_as_markdown_table }}
43
+
44
+ [{{ button_text }}](https://prompt={{ next_user_message | urlencode }})""")
45
+
46
+
47
+ class HumanVerificationManagerForApiCalling(
48
+ Generic[
49
+ PathParamsSpec,
50
+ PathParamsType,
51
+ PayloadParamSpec,
52
+ PayloadType,
53
+ ResponseType,
54
+ ]
55
+ ):
56
+ """
57
+ Manages human verification for api calling.
58
+
59
+ The idea is that the manager is able to produce the verification message to the user
60
+ and to detect an api call from the user message.
61
+
62
+ If it detects such a verification message in the user message, it will call the api
63
+ and incorporate the response into the user message.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ logger: Logger,
70
+ operation: type[
71
+ ApiOperationProtocol[
72
+ PathParamsSpec,
73
+ PathParamsType,
74
+ PayloadParamSpec,
75
+ PayloadType,
76
+ ResponseType,
77
+ ]
78
+ ],
79
+ requestor_type: RequestorType = RequestorType.REQUESTS,
80
+ **kwargs: dict[str, Any],
81
+ ):
82
+ self._logger = logger
83
+ self._operation = operation
84
+
85
+ # Create internal models for this manager instance
86
+
87
+ class ConcreteApiCall(BaseModel):
88
+ confirmation: HumanConfirmation
89
+ payload: self._operation.payload_model() # type: ignore
90
+
91
+ self._api_call_model = ConcreteApiCall
92
+
93
+ self._combined_params_model = create_union_model(
94
+ model_type_a=self._operation.path_params_model(),
95
+ model_type_b=self._operation.payload_model(),
96
+ )
97
+ self._requestor_type = requestor_type
98
+ self._requestor = build_requestor(
99
+ requestor_type=requestor_type,
100
+ operation_type=operation,
101
+ combined_model=self._combined_params_model,
102
+ **kwargs,
103
+ )
104
+
105
+ def detect_api_calls_from_user_message(
106
+ self, *, last_assistant_message: ChatMessage, user_message: str
107
+ ) -> PayloadType | None:
108
+ user_message_dicts = extract_dicts_from_string(user_message)
109
+ if len(user_message_dicts) == 0:
110
+ return None
111
+
112
+ user_message_dicts.reverse()
113
+ for user_message_dict in user_message_dicts:
114
+ try:
115
+ # Convert dict to payload model first, then create payload
116
+ api_call = self._api_call_model.model_validate(
117
+ user_message_dict, by_alias=True, by_name=True
118
+ )
119
+ if self._verify_human_verification(
120
+ api_call.confirmation, last_assistant_message
121
+ ):
122
+ return api_call.payload
123
+ except Exception as e:
124
+ self._logger.error(f"Error detecting api calls from user message: {e}")
125
+
126
+ return None
127
+
128
+ def _verify_human_verification(
129
+ self, confirmation: HumanConfirmation, last_assistant_message: ChatMessage
130
+ ) -> bool:
131
+ if (
132
+ last_assistant_message.role != ChatMessageRole.ASSISTANT
133
+ or last_assistant_message.content is None
134
+ ):
135
+ self._logger.error(
136
+ "Last assistant message is not an assistant message or content is empty."
137
+ )
138
+ return False
139
+
140
+ return confirmation.payload_hash in last_assistant_message.content
141
+
142
+ def _create_next_user_message(self, payload: PayloadType) -> str:
143
+ api_call = self._api_call_model(
144
+ payload=payload,
145
+ confirmation=HumanConfirmation(
146
+ payload_hash=hashlib.sha256(
147
+ payload.model_dump_json().encode()
148
+ ).hexdigest(),
149
+ time_stamp=datetime.now(),
150
+ ),
151
+ )
152
+ return NEXT_USER_MESSAGE_JINJA2_TEMPLATE.render(
153
+ api_call_as_json=api_call.model_dump_json(indent=2)
154
+ )
155
+
156
+ def create_assistant_confirmation_message(self, *, payload: PayloadType) -> str:
157
+ return ASSISTANT_CONFIRMATION_MESSAGE_JINJA2_TEMPLATE.render(
158
+ api_call_as_markdown_table=dict_to_markdown_table(payload.model_dump()),
159
+ button_text="Please confirm the call by pressing this button.",
160
+ next_user_message=self._create_next_user_message(payload),
161
+ )
162
+
163
+ def call_api(
164
+ self,
165
+ *,
166
+ headers: dict[str, str],
167
+ path_params: PathParamsType,
168
+ payload: PayloadType,
169
+ ) -> ResponseType:
170
+ params = path_params.model_dump()
171
+ params.update(payload.model_dump())
172
+
173
+ response = self._requestor.request(
174
+ headers=headers,
175
+ **params,
176
+ )
177
+ return self._operation.handle_response(response)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ import logging
182
+ from string import Template
183
+
184
+ from unique_toolkit._common.endpoint_builder import (
185
+ EndpointMethods,
186
+ build_api_operation,
187
+ )
188
+
189
+ class GetUserPathParams(BaseModel):
190
+ user_id: int
191
+
192
+ class GetUserRequestBody(BaseModel):
193
+ include_profile: bool = False
194
+
195
+ class UserResponse(BaseModel):
196
+ id: int
197
+ name: str
198
+
199
+ class CombinedParams(GetUserPathParams, GetUserRequestBody):
200
+ pass
201
+
202
+ UserEndpoint = build_api_operation(
203
+ method=EndpointMethods.GET,
204
+ url_template=Template("https://api.example.com/users/{user_id}"),
205
+ path_params_constructor=GetUserPathParams,
206
+ payload_constructor=GetUserRequestBody,
207
+ response_model_type=UserResponse,
208
+ )
209
+
210
+ human_verification_manager = HumanVerificationManagerForApiCalling(
211
+ logger=logging.getLogger(__name__),
212
+ operation=UserEndpoint,
213
+ requestor_type=RequestorType.FAKE,
214
+ return_value={"id": 100, "name": "John Doe"},
215
+ )
216
+
217
+ payload = GetUserRequestBody(include_profile=True)
218
+
219
+ api_call = human_verification_manager._api_call_model(
220
+ payload=payload,
221
+ confirmation=HumanConfirmation(
222
+ payload_hash=hashlib.sha256(payload.model_dump_json().encode()).hexdigest(),
223
+ time_stamp=datetime.now(),
224
+ ),
225
+ )
226
+
227
+ last_assistant_message = ChatMessage(
228
+ role=ChatMessageRole.ASSISTANT,
229
+ text=api_call.confirmation.payload_hash,
230
+ chat_id="123",
231
+ )
232
+
233
+ user_message_with_api_call = human_verification_manager._create_next_user_message(
234
+ payload=payload
235
+ )
236
+
237
+ print(user_message_with_api_call)
238
+
239
+ payload = human_verification_manager.detect_api_calls_from_user_message(
240
+ user_message=user_message_with_api_call,
241
+ last_assistant_message=last_assistant_message,
242
+ )
243
+
244
+ if payload is None:
245
+ print("❌ Detection failed - payload is None")
246
+ exit(1)
247
+ else:
248
+ print("✅ Detection successful!")
249
+ print(f"Payload: {payload.model_dump()}")
250
+ print("✅ Dict extraction from string works correctly!")
@@ -12,9 +12,11 @@ from typing import (
12
12
  ParamSpec,
13
13
  Protocol,
14
14
  TypeVar,
15
+ cast,
15
16
  )
16
17
 
17
18
  from pydantic import BaseModel
19
+ from typing_extensions import deprecated
18
20
 
19
21
  # Paramspecs
20
22
  PayloadParamSpec = ParamSpec("PayloadParamSpec")
@@ -31,7 +33,7 @@ PayloadType = TypeVar("PayloadType", bound=BaseModel)
31
33
  ModelConstructor = Callable[..., BaseModel]
32
34
 
33
35
 
34
- class EndpointMethods(StrEnum):
36
+ class HttpMethods(StrEnum):
35
37
  GET = "GET"
36
38
  POST = "POST"
37
39
  PUT = "PUT"
@@ -41,8 +43,11 @@ class EndpointMethods(StrEnum):
41
43
  HEAD = "HEAD"
42
44
 
43
45
 
44
- # Necessary for typing of make_endpoint_class
45
- class EndpointClassProtocol(
46
+ # Backward compatibility TODO: Remove in 2.0.0.
47
+ EndpointMethods = HttpMethods
48
+
49
+
50
+ class ApiOperationProtocol(
46
51
  Protocol,
47
52
  Generic[
48
53
  PathParamsSpec,
@@ -52,9 +57,14 @@ class EndpointClassProtocol(
52
57
  ResponseType,
53
58
  ],
54
59
  ):
55
- path_params_model: type[PathParamsType]
56
- payload_model: type[PayloadType]
57
- response_model: type[ResponseType]
60
+ @staticmethod
61
+ def path_params_model() -> type[PathParamsType]: ...
62
+
63
+ @staticmethod
64
+ def payload_model() -> type[PayloadType]: ...
65
+
66
+ @staticmethod
67
+ def response_model() -> type[ResponseType]: ...
58
68
 
59
69
  @staticmethod
60
70
  def create_url(
@@ -78,18 +88,23 @@ class EndpointClassProtocol(
78
88
  @staticmethod
79
89
  def request_method() -> EndpointMethods: ...
80
90
 
91
+ @staticmethod
92
+ def models_from_combined(
93
+ combined: dict[str, Any],
94
+ ) -> tuple[PathParamsType, PayloadType]: ...
95
+
81
96
 
82
97
  # Model for any client to implement
83
- def build_endpoint_class(
98
+ def build_api_operation(
84
99
  *,
85
- method: EndpointMethods,
100
+ method: HttpMethods,
86
101
  url_template: Template,
87
102
  path_params_constructor: Callable[PathParamsSpec, PathParamsType],
88
103
  payload_constructor: Callable[PayloadParamSpec, PayloadType],
89
104
  response_model_type: type[ResponseType],
90
105
  dump_options: dict | None = None,
91
106
  ) -> type[
92
- EndpointClassProtocol[
107
+ ApiOperationProtocol[
93
108
  PathParamsSpec,
94
109
  PathParamsType,
95
110
  PayloadParamSpec,
@@ -107,11 +122,6 @@ def build_endpoint_class(
107
122
  """
108
123
 
109
124
  # Verify that the path_params_constructor and payload_constructor are valid pydantic models
110
- if not isinstance(path_params_constructor, type(BaseModel)):
111
- raise ValueError("path_params_constructor must be a pydantic model")
112
- if not isinstance(payload_constructor, type(BaseModel)):
113
- raise ValueError("payload_constructor must be a pydantic model")
114
-
115
125
  if not dump_options:
116
126
  dump_options = {
117
127
  "exclude_unset": True,
@@ -119,10 +129,18 @@ def build_endpoint_class(
119
129
  "exclude_defaults": True,
120
130
  }
121
131
 
122
- class EndpointClass(EndpointClassProtocol):
123
- path_params_model = path_params_constructor # type: ignore
124
- payload_model = payload_constructor # type: ignore
125
- response_model = response_model_type
132
+ class Operation(ApiOperationProtocol):
133
+ @staticmethod
134
+ def path_params_model() -> type[PathParamsType]:
135
+ return cast(type[PathParamsType], path_params_constructor)
136
+
137
+ @staticmethod
138
+ def payload_model() -> type[PayloadType]:
139
+ return cast(type[PayloadType], payload_constructor)
140
+
141
+ @staticmethod
142
+ def response_model() -> type[ResponseType]:
143
+ return response_model_type
126
144
 
127
145
  @staticmethod
128
146
  def create_url(
@@ -130,7 +148,7 @@ def build_endpoint_class(
130
148
  **kwargs: PathParamsSpec.kwargs,
131
149
  ) -> str:
132
150
  """Create URL from path parameters."""
133
- path_model = EndpointClass.path_params_model(*args, **kwargs)
151
+ path_model = Operation.path_params_model()(*args, **kwargs)
134
152
  path_dict = path_model.model_dump(**dump_options)
135
153
 
136
154
  # Extract expected path parameters from template
@@ -158,7 +176,7 @@ def build_endpoint_class(
158
176
  *args: PayloadParamSpec.args, **kwargs: PayloadParamSpec.kwargs
159
177
  ) -> dict[str, Any]:
160
178
  """Create request body payload."""
161
- request_model = EndpointClass.payload_model(*args, **kwargs)
179
+ request_model = Operation.payload_model()(*args, **kwargs)
162
180
  return request_model.model_dump(**dump_options)
163
181
 
164
182
  @staticmethod
@@ -167,13 +185,58 @@ def build_endpoint_class(
167
185
 
168
186
  @staticmethod
169
187
  def handle_response(response: dict[str, Any]) -> ResponseType:
170
- return EndpointClass.response_model.model_validate(response)
188
+ return Operation.response_model().model_validate(response)
171
189
 
172
190
  @staticmethod
173
- def request_method() -> EndpointMethods:
191
+ def request_method() -> HttpMethods:
174
192
  return method
175
193
 
176
- return EndpointClass
194
+ @staticmethod
195
+ def models_from_combined(
196
+ combined: dict[str, Any],
197
+ ) -> tuple[PathParamsType, PayloadType]:
198
+ path_params = Operation.path_params_model().model_validate(
199
+ combined, by_alias=True, by_name=True
200
+ )
201
+ payload = Operation.payload_model().model_validate(
202
+ combined, by_alias=True, by_name=True
203
+ )
204
+ return path_params, payload
205
+
206
+ return Operation
207
+
208
+
209
+ @deprecated("Use ApiOperationProtocol instead")
210
+ class EndpointClassProtocol(ApiOperationProtocol):
211
+ pass
212
+
213
+
214
+ @deprecated("Use build_api_operation instead")
215
+ def build_endpoint_class(
216
+ *,
217
+ method: HttpMethods,
218
+ url_template: Template,
219
+ path_params_constructor: Callable[PathParamsSpec, PathParamsType],
220
+ payload_constructor: Callable[PayloadParamSpec, PayloadType],
221
+ response_model_type: type[ResponseType],
222
+ dump_options: dict | None = None,
223
+ ) -> type[
224
+ ApiOperationProtocol[
225
+ PathParamsSpec,
226
+ PathParamsType,
227
+ PayloadParamSpec,
228
+ PayloadType,
229
+ ResponseType,
230
+ ]
231
+ ]:
232
+ return build_api_operation(
233
+ method=method,
234
+ url_template=url_template,
235
+ path_params_constructor=path_params_constructor,
236
+ payload_constructor=payload_constructor,
237
+ response_model_type=response_model_type,
238
+ dump_options=dump_options,
239
+ )
177
240
 
178
241
 
179
242
  if __name__ == "__main__":
@@ -195,7 +258,7 @@ if __name__ == "__main__":
195
258
  name: str
196
259
 
197
260
  # Example usage of make_endpoint_class
198
- UserEndpoint = build_endpoint_class(
261
+ UserOperation = build_endpoint_class(
199
262
  url_template=Template("/users/${user_id}"),
200
263
  path_params_constructor=GetUserPathParams,
201
264
  payload_constructor=GetUserRequestBody,
@@ -204,15 +267,15 @@ if __name__ == "__main__":
204
267
  )
205
268
 
206
269
  # Create URL from path parameters
207
- url = UserEndpoint.create_url(user_id=123)
270
+ url = UserOperation.create_url(user_id=123)
208
271
  print(f"URL: {url}")
209
272
 
210
273
  # Create payload from request body parameters
211
- payload = UserEndpoint.create_payload(include_profile=True)
274
+ payload = UserOperation.create_payload(include_profile=True)
212
275
  print(f"Payload: {payload}")
213
276
 
214
277
  # Create response from endpoint
215
- response = UserEndpoint.handle_response(
278
+ response = UserOperation.handle_response(
216
279
  {
217
280
  "id": 123,
218
281
  "name": "John Doe",
@@ -5,8 +5,8 @@ from pydantic import BaseModel
5
5
  from typing_extensions import ParamSpec
6
6
 
7
7
  from unique_toolkit._common.endpoint_builder import (
8
- EndpointClassProtocol,
9
- EndpointMethods,
8
+ ApiOperationProtocol,
9
+ HttpMethods,
10
10
  PathParamsSpec,
11
11
  PathParamsType,
12
12
  PayloadParamSpec,
@@ -21,19 +21,22 @@ CombinedParamsSpec = ParamSpec("CombinedParamsSpec")
21
21
  CombinedParamsType = TypeVar("CombinedParamsType", bound=BaseModel)
22
22
 
23
23
 
24
- class EndpointRequestorProtocol(Protocol, Generic[CombinedParamsSpec, ResponseType]):
24
+ ResponseT_co = TypeVar("ResponseT_co", bound=BaseModel, covariant=True)
25
+
26
+
27
+ class EndpointRequestorProtocol(Protocol, Generic[CombinedParamsSpec, ResponseT_co]):
25
28
  @classmethod
26
29
  def request(
27
30
  cls,
28
31
  headers: dict[str, str],
29
32
  *args: CombinedParamsSpec.args,
30
33
  **kwargs: CombinedParamsSpec.kwargs,
31
- ) -> ResponseType: ...
34
+ ) -> ResponseT_co: ...
32
35
 
33
36
 
34
37
  def build_fake_requestor(
35
- endpoint_type: type[
36
- EndpointClassProtocol[
38
+ operation_type: type[
39
+ ApiOperationProtocol[
37
40
  PathParamsSpec,
38
41
  PathParamsType,
39
42
  PayloadParamSpec,
@@ -45,7 +48,7 @@ def build_fake_requestor(
45
48
  return_value: dict[str, Any],
46
49
  ) -> type[EndpointRequestorProtocol[CombinedParamsSpec, ResponseType]]:
47
50
  class FakeRequestor(EndpointRequestorProtocol):
48
- _endpoint = endpoint_type
51
+ _operation = operation_type
49
52
 
50
53
  @classmethod
51
54
  def request(
@@ -55,20 +58,22 @@ def build_fake_requestor(
55
58
  **kwargs: CombinedParamsSpec.kwargs,
56
59
  ) -> ResponseType:
57
60
  try:
58
- combined_model(*args, **kwargs)
61
+ path_params, payload_model = cls._operation.models_from_combined(
62
+ combined=kwargs
63
+ )
59
64
  except Exception as e:
60
65
  raise ValueError(
61
66
  f"Invalid parameters passed to combined model {combined_model.__name__}: {e}"
62
67
  )
63
68
 
64
- return cls._endpoint.handle_response(return_value)
69
+ return cls._operation.handle_response(return_value)
65
70
 
66
71
  return FakeRequestor
67
72
 
68
73
 
69
74
  def build_request_requestor(
70
- endpoint_type: type[
71
- EndpointClassProtocol[
75
+ operation_type: type[
76
+ ApiOperationProtocol[
72
77
  PathParamsSpec,
73
78
  PathParamsType,
74
79
  PayloadParamSpec,
@@ -77,11 +82,11 @@ def build_request_requestor(
77
82
  ]
78
83
  ],
79
84
  combined_model: Callable[CombinedParamsSpec, CombinedParamsType],
80
- ) -> type[EndpointRequestorProtocol]:
85
+ ) -> type[EndpointRequestorProtocol[CombinedParamsSpec, ResponseType]]:
81
86
  import requests
82
87
 
83
88
  class RequestRequestor(EndpointRequestorProtocol):
84
- _endpoint = endpoint_type
89
+ _operation = operation_type
85
90
 
86
91
  @classmethod
87
92
  def request(
@@ -90,18 +95,21 @@ def build_request_requestor(
90
95
  *args: CombinedParamsSpec.args,
91
96
  **kwargs: CombinedParamsSpec.kwargs,
92
97
  ) -> ResponseType:
93
- url = cls._endpoint.create_url_from_model(combined_model(*args, **kwargs))
94
- payload = cls._endpoint.create_payload_from_model(
95
- combined_model(*args, **kwargs)
98
+ # Create separate instances for path params and payload using endpoint helper
99
+ path_params, payload_model = cls._operation.models_from_combined(
100
+ combined=kwargs
96
101
  )
97
102
 
103
+ url = cls._operation.create_url_from_model(path_params)
104
+ payload = cls._operation.create_payload_from_model(payload_model)
105
+
98
106
  response = requests.request(
99
- method=cls._endpoint.request_method(),
107
+ method=cls._operation.request_method(),
100
108
  url=url,
101
109
  headers=headers,
102
110
  json=payload,
103
111
  )
104
- return cls._endpoint.handle_response(response.json())
112
+ return cls._operation.handle_response(response.json())
105
113
 
106
114
  return RequestRequestor
107
115
 
@@ -113,8 +121,8 @@ class RequestorType(StrEnum):
113
121
 
114
122
  def build_requestor(
115
123
  requestor_type: RequestorType,
116
- endpoint_type: type[
117
- EndpointClassProtocol[
124
+ operation_type: type[
125
+ ApiOperationProtocol[
118
126
  PathParamsSpec,
119
127
  PathParamsType,
120
128
  PayloadParamSpec,
@@ -129,13 +137,13 @@ def build_requestor(
129
137
  match requestor_type:
130
138
  case RequestorType.REQUESTS:
131
139
  return build_request_requestor(
132
- endpoint_type=endpoint_type, combined_model=combined_model
140
+ operation_type=operation_type, combined_model=combined_model
133
141
  )
134
142
  case RequestorType.FAKE:
135
143
  if return_value is None:
136
144
  raise ValueError("return_value is required for fake requestor")
137
145
  return build_fake_requestor(
138
- endpoint_type=endpoint_type,
146
+ operation_type=operation_type,
139
147
  combined_model=combined_model,
140
148
  return_value=return_value,
141
149
  )
@@ -144,7 +152,7 @@ def build_requestor(
144
152
  if __name__ == "__main__":
145
153
  from string import Template
146
154
 
147
- from unique_toolkit._common.endpoint_builder import build_endpoint_class
155
+ from unique_toolkit._common.endpoint_builder import build_api_operation
148
156
 
149
157
  class GetUserPathParams(BaseModel):
150
158
  user_id: int
@@ -159,8 +167,8 @@ if __name__ == "__main__":
159
167
  class CombinedParams(GetUserPathParams, GetUserRequestBody):
160
168
  pass
161
169
 
162
- UserEndpoint = build_endpoint_class(
163
- method=EndpointMethods.GET,
170
+ UserEndpoint = build_api_operation(
171
+ method=HttpMethods.GET,
164
172
  url_template=Template("https://api.example.com/users/{user_id}"),
165
173
  path_params_constructor=GetUserPathParams,
166
174
  payload_constructor=GetUserRequestBody,
@@ -168,7 +176,7 @@ if __name__ == "__main__":
168
176
  )
169
177
 
170
178
  FakeUserRequestor = build_fake_requestor(
171
- endpoint_type=UserEndpoint,
179
+ operation_type=UserEndpoint,
172
180
  combined_model=CombinedParams,
173
181
  return_value={"id": 100, "name": "John Doe"},
174
182
  )
@@ -180,6 +188,16 @@ if __name__ == "__main__":
180
188
  include_profile=True,
181
189
  )
182
190
 
191
+ RequestRequstor = build_request_requestor(
192
+ operation_type=UserEndpoint,
193
+ combined_model=CombinedParams,
194
+ )
195
+
196
+ # Check type hints
197
+ response = RequestRequstor().request(
198
+ headers={"a": "b"}, user_id=123, include_profile=True
199
+ )
200
+
183
201
  print(response.model_dump())
184
202
  print(response.model_json_schema())
185
203
  print(response.id)
@@ -1,7 +1,9 @@
1
1
  import logging
2
+ import warnings
3
+ from typing import TypeVar
2
4
 
3
5
  import humps
4
- from pydantic import ConfigDict
6
+ from pydantic import BaseModel, ConfigDict, Field, create_model
5
7
  from pydantic.fields import ComputedFieldInfo, FieldInfo
6
8
 
7
9
  logger = logging.getLogger(__name__)
@@ -19,11 +21,133 @@ def model_title_generator(model: type) -> str:
19
21
 
20
22
 
21
23
  def get_configuration_dict(**kwargs) -> ConfigDict:
22
- return ConfigDict(
24
+ config = {
23
25
  # alias_generator=to_camel,
24
- field_title_generator=field_title_generator,
25
- model_title_generator=model_title_generator,
26
+ "field_title_generator": field_title_generator,
27
+ "model_title_generator": model_title_generator,
26
28
  # populate_by_name=True,
27
29
  # protected_namespaces=(),
28
- **kwargs,
29
- )
30
+ }
31
+ config.update(kwargs)
32
+ return ConfigDict(**config)
33
+
34
+
35
+ ModelTypeA = TypeVar("ModelTypeA", bound=BaseModel)
36
+ ModelTypeB = TypeVar("ModelTypeB", bound=BaseModel)
37
+
38
+
39
+ def _name_intersection(
40
+ model_type_a: type[ModelTypeA], model_type_b: type[ModelTypeB]
41
+ ) -> set[str]:
42
+ field_names_a = set(model_type_a.model_fields.keys())
43
+ field_names_b = set(model_type_b.model_fields.keys())
44
+ return field_names_a.intersection(field_names_b)
45
+
46
+
47
+ def create_union_model(
48
+ model_type_a: type[ModelTypeA],
49
+ model_type_b: type[ModelTypeB],
50
+ model_name: str = "UnionModel",
51
+ config_dict: ConfigDict = ConfigDict(),
52
+ ) -> type[BaseModel]:
53
+ """
54
+ Creates a model that is the union of the two input models.
55
+ Prefers fields from model_type_a.
56
+ """
57
+
58
+ if len(_name_intersection(model_type_a, model_type_b)) > 0:
59
+ warnings.warn(
60
+ f"The two input models have common field names: {_name_intersection(model_type_a, model_type_b)}"
61
+ )
62
+
63
+ fields = {}
64
+ for name, field in model_type_b.model_fields.items():
65
+ fields[name] = (field.annotation, field)
66
+ for name, field in model_type_a.model_fields.items():
67
+ fields[name] = (field.annotation, field)
68
+
69
+ CombinedModel = create_model(model_name, __config__=config_dict, **fields)
70
+ return CombinedModel
71
+
72
+
73
+ def create_intersection_model(
74
+ model_type_a: type[ModelTypeA],
75
+ model_type_b: type[ModelTypeB],
76
+ model_name: str = "IntersectionModel",
77
+ config_dict: ConfigDict = ConfigDict(),
78
+ ) -> type[BaseModel]:
79
+ """
80
+ Creates a model that is the intersection of the two input models.
81
+ Prefers fields from model_type_a.
82
+ """
83
+
84
+ if len(_name_intersection(model_type_a, model_type_b)) == 0:
85
+ warnings.warn(
86
+ f"The two input models have no common field names: {_name_intersection(model_type_a, model_type_b)}"
87
+ )
88
+
89
+ fields = {}
90
+ field_names1 = set(model_type_a.model_fields.keys())
91
+ field_names2 = set(model_type_b.model_fields.keys())
92
+ common_field_names = field_names1.intersection(field_names2)
93
+
94
+ for name in common_field_names:
95
+ if name in field_names1.intersection(field_names2):
96
+ fields[name] = (
97
+ model_type_a.model_fields[name].annotation,
98
+ model_type_a.model_fields[name],
99
+ )
100
+
101
+ IntersectionModel = create_model(model_name, __config__=config_dict, **fields)
102
+ return IntersectionModel
103
+
104
+
105
+ def create_complement_model(
106
+ model_type_a: type[ModelTypeA],
107
+ model_type_b: type[ModelTypeB],
108
+ model_name: str = "ComplementModel",
109
+ config_dict: ConfigDict = ConfigDict(),
110
+ ) -> type[BaseModel]:
111
+ """
112
+ Creates a model that is the complement of the two input models
113
+ i.e all fields from model_type_a that are not in model_type_b
114
+ """
115
+
116
+ if len(_name_intersection(model_type_a, model_type_b)) == 0:
117
+ warnings.warn(
118
+ f"The two input models have no common field names: {_name_intersection(model_type_a, model_type_b)}"
119
+ )
120
+
121
+ fields = {}
122
+ field_names_a = set(model_type_a.model_fields.keys())
123
+ field_names_b = set(model_type_b.model_fields.keys())
124
+ complement_field_names = field_names_a.difference(field_names_b)
125
+
126
+ for name in complement_field_names:
127
+ fields[name] = (
128
+ model_type_a.model_fields[name].annotation,
129
+ model_type_a.model_fields[name],
130
+ )
131
+
132
+ ComplementModel = create_model(model_name, __config__=config_dict, **fields)
133
+
134
+ return ComplementModel
135
+
136
+
137
+ if __name__ == "__main__":
138
+
139
+ class ModelType1(BaseModel):
140
+ field1: int = Field(default=1, description="Field 1")
141
+ field2: str = Field(
142
+ default="test",
143
+ description="Field 2",
144
+ json_schema_extra={"title": "Field 2"},
145
+ )
146
+
147
+ class ModelType2(BaseModel):
148
+ field3: float
149
+ field4: bool
150
+
151
+ combined_model = create_union_model(ModelType1, ModelType2)
152
+
153
+ print(combined_model.model_fields)
@@ -0,0 +1,89 @@
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+
6
+ def _is_elementary_type(value: Any) -> bool:
7
+ """Check if a value is an elementary type (str, int, float, bool, None)."""
8
+ return isinstance(value, (str, int, float, bool, type(None)))
9
+
10
+
11
+ def _is_elementary_dict(data: dict[str, Any]) -> bool:
12
+ """Check if all values in the dictionary are elementary types."""
13
+ return all(_is_elementary_type(value) for value in data.values())
14
+
15
+
16
+ def dict_to_markdown_table(data: dict[str, Any]) -> str:
17
+ """
18
+ Convert a dictionary to a markdown table if all values are elementary types,
19
+ otherwise return stringified JSON.
20
+
21
+ Args:
22
+ data: Dictionary to convert
23
+
24
+ Returns:
25
+ Markdown table string or JSON string
26
+ """
27
+ if not isinstance(data, dict):
28
+ return json.dumps(data, indent=2)
29
+
30
+ if not _is_elementary_dict(data):
31
+ return json.dumps(data, indent=2)
32
+
33
+ if not data: # Empty dict
34
+ return "| Key | Value |\n|-----|-------|\n| (empty) | (empty) |"
35
+
36
+ # Create markdown table
37
+ table_lines = ["| Key | Value |", "|-----|-------|"]
38
+
39
+ for key, value in data.items():
40
+ # Handle None values
41
+ if value is None:
42
+ value_str = "null"
43
+ # Handle boolean values
44
+ elif isinstance(value, bool):
45
+ value_str = "true" if value else "false"
46
+ # Handle other values
47
+ else:
48
+ value_str = str(value)
49
+
50
+ # Escape pipe characters in the content
51
+ key_escaped = str(key).replace("|", "\\|")
52
+ value_escaped = value_str.replace("|", "\\|")
53
+
54
+ table_lines.append(f"| {key_escaped} | {value_escaped} |")
55
+
56
+ return "\n".join(table_lines) + "\n"
57
+
58
+
59
+ def extract_dicts_from_string(text: str) -> list[dict[str, Any]]:
60
+ """
61
+ Extract and parse a JSON dictionary from a string.
62
+
63
+ The string should be wrapped in ```json tags. Example:
64
+
65
+ ```json
66
+ {"key": "value"}
67
+ ```
68
+
69
+ Args:
70
+ text: String that may contain JSON
71
+
72
+ Returns:
73
+ Parsed dictionary or None if no valid JSON found
74
+ """
75
+ # Find JSON-like content between ```json and ``` tags
76
+ pattern = r"```json\s*(\{.*?\})\s*```"
77
+ matches = re.findall(pattern, text, re.DOTALL)
78
+
79
+ dictionaries = []
80
+ for match in matches:
81
+ try:
82
+ # Try to parse as JSON
83
+ parsed = json.loads(match)
84
+ if isinstance(parsed, dict):
85
+ dictionaries.append(parsed)
86
+ except json.JSONDecodeError:
87
+ continue
88
+
89
+ return dictionaries
@@ -17,7 +17,14 @@ LMI = Annotated[
17
17
  LanguageModelInfo,
18
18
  BeforeValidator(
19
19
  lambda v: validate_and_init_language_model_info(v),
20
- json_schema_input_type=str | LanguageModelName | LanguageModelInfo,
20
+ json_schema_input_type=LanguageModelName
21
+ | Annotated[
22
+ str,
23
+ Field(
24
+ title="Language Model String",
25
+ ),
26
+ ]
27
+ | LanguageModelInfo,
21
28
  ),
22
29
  PlainSerializer(
23
30
  lambda v: serialize_lmi(v),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary:
5
5
  License: Proprietary
6
6
  Author: Cedric Klinkert
@@ -118,6 +118,13 @@ All notable changes to this project will be documented in this file.
118
118
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
119
119
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
120
120
 
121
+
122
+ ## [1.1.4] - 2025-09-23
123
+ - First version human verification on api calls
124
+
125
+ ## [1.1.3] - 2025-09-23
126
+ - Updated LMI JSON schema input type to include annotated string field with title
127
+
121
128
  ## [1.1.2] - 2025-09-22
122
129
  - Fixed bug tool selection for exclusive tools
123
130
 
@@ -1,6 +1,7 @@
1
1
  unique_toolkit/__init__.py,sha256=nbOYPIKERt-ITsgifrnJhatn1YNR38Ntumw-dCn_tsA,714
2
2
  unique_toolkit/_common/_base_service.py,sha256=S8H0rAebx7GsOldA7xInLp3aQJt9yEPDQdsGSFRJsGg,276
3
3
  unique_toolkit/_common/_time_utils.py,sha256=ztmTovTvr-3w71Ns2VwXC65OKUUh-sQlzbHdKTQWm-w,135
4
+ unique_toolkit/_common/api_calling/human_verification_manager.py,sha256=UMOkY1cNhJ6JmtiBl3a4mQ3J21uOyKoCbVu7nR3A5eY,7799
4
5
  unique_toolkit/_common/base_model_type_attribute.py,sha256=7rzVqjXa0deYEixeo_pJSJcQ7nKXpWK_UGpOiEH3yZY,10382
5
6
  unique_toolkit/_common/chunk_relevancy_sorter/config.py,sha256=kDSEcXeIWGvzK4IXT3pBofTXeUnq3a9qRWaPllweR-s,1817
6
7
  unique_toolkit/_common/chunk_relevancy_sorter/exception.py,sha256=1mY4zjbvnXsd5oIxwiVsma09bS2XRnHrxW8KJBGtgCM,126
@@ -8,17 +9,18 @@ unique_toolkit/_common/chunk_relevancy_sorter/schemas.py,sha256=YAyvXzVk8h5q6FEs
8
9
  unique_toolkit/_common/chunk_relevancy_sorter/service.py,sha256=ZX1pxcy53zh3Ha0_pN6yYIbMX1acRxcvqKTPTKpGKwA,13938
9
10
  unique_toolkit/_common/chunk_relevancy_sorter/tests/test_service.py,sha256=UhDllC40Y1OUQvkU6pe3nu6NR7v0d25yldE6FyozuZI,8926
10
11
  unique_toolkit/_common/default_language_model.py,sha256=tmHSqg6e8G7RmKqmdE_tmLxkSN0x-aGoyUdy6Pl2oAE,334
11
- unique_toolkit/_common/endpoint_builder.py,sha256=zZFEeMAHySWh2AU-nuufT7iNd0NT54qA6AAxNEVDZb0,6635
12
- unique_toolkit/_common/endpoint_requestor.py,sha256=q-H4kT2Ip63omsodMZ8Bgo3HLMFhnOpaGoc2MqIZdmI,5317
12
+ unique_toolkit/_common/endpoint_builder.py,sha256=WzJrJ7azUQhvQRd-vsFFoyj6omJpHiVYrh1UFxNQvVg,8242
13
+ unique_toolkit/_common/endpoint_requestor.py,sha256=JbbfJGLxgxLz8a3Yx1FdJvdHGbCYO8MSBd7cLg_Mtp0,5927
13
14
  unique_toolkit/_common/exception.py,sha256=caQIE1btsQnpKCHqL2cgWUSbHup06enQu_Pt7uGUTTE,727
14
15
  unique_toolkit/_common/feature_flags/schema.py,sha256=F1NdVJFNU8PKlS7bYzrIPeDu2LxRqHSM9pyw622a1Kk,547
15
- unique_toolkit/_common/pydantic_helpers.py,sha256=4a8LPey31k4dCztYag1OBhYnGHREN08-l3NEjbFD1ok,743
16
+ unique_toolkit/_common/pydantic_helpers.py,sha256=TVEYUaBnsXRXmjmdtaM0LV_0-7NX9SWfAgqNBrKReOE,4729
17
+ unique_toolkit/_common/string_utilities.py,sha256=pbsjpnz1mwGeugebHzubzmmDtlm18B8e7xJdSvLnor0,2496
16
18
  unique_toolkit/_common/token/image_token_counting.py,sha256=VpFfZyY0GIH27q_Wy4YNjk2algqvbCtJyzuuROoFQPw,2189
17
19
  unique_toolkit/_common/token/token_counting.py,sha256=gM4B_aUqKqEPvmStFNcvCWNMNNNNKbVaywBDxlbgIps,7121
18
20
  unique_toolkit/_common/utils/structured_output/schema.py,sha256=Tp7kDYcmKtnUhcuRkH86TSYhylRff0ZZJYb2dLkISts,131
19
21
  unique_toolkit/_common/utils/write_configuration.py,sha256=fzvr4C-XBL3OSM3Od9TbqIxeeDS9_d9CLEyTq6DDknY,1409
20
22
  unique_toolkit/_common/validate_required_values.py,sha256=Y_M1ub9gIKP9qZ45F6Zq3ZHtuIqhmOjl8Z2Vd3avg8w,588
21
- unique_toolkit/_common/validators.py,sha256=aZwbMho7XszN7lT5RtemaiXgC0WJ4u40oeVgsNGhF4U,2803
23
+ unique_toolkit/_common/validators.py,sha256=LFZmAalNa886EXm1VYamFvfBuUZjYKwDdT_HOYU0BtE,2934
22
24
  unique_toolkit/agentic/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
23
25
  unique_toolkit/agentic/debug_info_manager/debug_info_manager.py,sha256=8u3_oxcln7y2zOsfiGh5YOm1zYAlV5QxZ5YAsbEJG0c,584
24
26
  unique_toolkit/agentic/evaluation/config.py,sha256=ywHIrJs5SFdKr1WXfrofWuFfzb0iPQw8iZDpq5oEug4,953
@@ -124,7 +126,7 @@ unique_toolkit/short_term_memory/schemas.py,sha256=OhfcXyF6ACdwIXW45sKzjtZX_gkcJ
124
126
  unique_toolkit/short_term_memory/service.py,sha256=5PeVBu1ZCAfyDb2HLVvlmqSbyzBBuE9sI2o9Aajqjxg,8884
125
127
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
128
  unique_toolkit/smart_rules/compile.py,sha256=cxWjb2dxEI2HGsakKdVCkSNi7VK9mr08w5sDcFCQyWI,9553
127
- unique_toolkit-1.1.2.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
128
- unique_toolkit-1.1.2.dist-info/METADATA,sha256=gka_NfFWIYCImnxZX-b-4Pwe20WrYHpN-gwgO7ljcso,32541
129
- unique_toolkit-1.1.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
130
- unique_toolkit-1.1.2.dist-info/RECORD,,
129
+ unique_toolkit-1.1.4.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
130
+ unique_toolkit-1.1.4.dist-info/METADATA,sha256=2BBFs3a2HGMSWyyk2MQV5gAVJUINJC3Bua6hF4VklcI,32722
131
+ unique_toolkit-1.1.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
132
+ unique_toolkit-1.1.4.dist-info/RECORD,,