lionagi 0.18.1__py3-none-any.whl → 0.18.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.
@@ -1,361 +1,198 @@
1
1
  # Copyright (c) 2023-2025, HaiyangLi <quantocean.li at gmail dot com>
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- from typing import Any
4
+ from typing import TYPE_CHECKING, Any, Literal
5
5
 
6
- from pydantic import BaseModel
7
- from pydantic.fields import FieldInfo
6
+ from lionagi.ln.types import Operable
8
7
 
9
- from lionagi.ln import extract_json, fuzzy_match_keys
10
- from lionagi.ln.types import Undefined
11
- from lionagi.models import FieldModel, ModelParams, OperableModel
8
+ if TYPE_CHECKING:
9
+ from pydantic import BaseModel
12
10
 
13
11
 
14
12
  class Operative:
15
- """Class representing an operative that handles request and response models for operations.
13
+ """Framework-agnostic operation handler using Spec/Operable system.
16
14
 
17
- This implementation uses OperableModel internally for better performance while
18
- maintaining backward compatibility with the existing API.
15
+ Manages request/response field specifications, delegating framework-specific
16
+ operations to adapters. Single source of truth pattern with one Operable
17
+ containing all fields.
18
+
19
+ Architecture:
20
+ Spec Definition → Operable Collection → Adapter → Framework Model
19
21
  """
20
22
 
21
23
  def __init__(
22
24
  self,
23
25
  name: str | None = None,
24
- request_type: type[BaseModel] | None = None,
25
- response_type: type[BaseModel] | None = None,
26
- response_model: BaseModel | None = None,
27
- response_str_dict: dict | str | None = None,
26
+ adapter: Literal["pydantic"] = "pydantic",
27
+ strict: bool = False,
28
28
  auto_retry_parse: bool = True,
29
29
  max_retries: int = 3,
30
- parse_kwargs: dict | None = None,
31
- request_params: (
32
- ModelParams | None
33
- ) = None, # Deprecated, for backward compatibility
34
- **_kwargs, # Ignored for backward compatibility
30
+ base_type: type["BaseModel"] | None = None,
31
+ operable: Operable | None = None,
32
+ request_exclude: set[str] | None = None,
35
33
  ):
36
- """Initialize the Operative.
34
+ """Initialize Operative with a single immutable Operable.
37
35
 
38
36
  Args:
39
- name: Name of the operative
40
- request_type: Pydantic model type for requests
41
- response_type: Pydantic model type for responses
42
- response_model: Current response model instance
43
- response_str_dict: Raw response string/dict
44
- auto_retry_parse: Whether to auto-retry parsing
45
- max_retries: Maximum parse retries
46
- parse_kwargs: Additional parse arguments
47
- request_params: Deprecated - use direct field addition
48
- response_params: Deprecated - use direct field addition
37
+ name: Operation name
38
+ adapter: Validation framework ("pydantic" only for now)
39
+ strict: If True, raise on validation errors
40
+ auto_retry_parse: Auto-retry validation with fuzzy matching
41
+ max_retries: Maximum validation retry attempts
42
+ base_type: Base Pydantic model to extend
43
+ operable: Single Operable with all fields
44
+ request_exclude: Fields to exclude from request (e.g., {"action_responses"})
49
45
  """
50
- self.name = name
51
- self.request_type = request_type
52
- self.response_type = response_type
53
- self.response_model = response_model
54
- self.response_str_dict = response_str_dict
46
+ self.name = name or (base_type.__name__ if base_type else "Operative")
47
+ self.adapter = adapter
48
+ self.strict = strict
55
49
  self.auto_retry_parse = auto_retry_parse
56
50
  self.max_retries = max_retries
57
- self.parse_kwargs = parse_kwargs or {}
58
- self._should_retry = None
51
+ self.base_type = base_type
59
52
 
60
- # Internal OperableModel instances
61
- self._request_operable = OperableModel()
62
- self._response_operable = OperableModel()
53
+ # Single source of truth
54
+ self.operable = operable or Operable((), name=self.name)
55
+ self.request_exclude = request_exclude or set()
63
56
 
64
- # Handle deprecated ModelParams for backward compatibility
65
- if request_params:
66
- self._init_from_model_params(request_params)
57
+ # Materialized models (cached)
58
+ self._request_model_cls = None
59
+ self._response_model_cls = None
67
60
 
68
- # Set default name if not provided
69
- if not self.name:
70
- self.name = (
71
- self.request_type.__name__
72
- if self.request_type
73
- else "Operative"
74
- )
75
-
76
- def _init_from_model_params(self, params: ModelParams):
77
- """Initialize from ModelParams for backward compatibility."""
78
- # Add field models to the request operable
79
- if params.field_models:
80
- # Coerce to list if single FieldModel instance
81
- field_models_list = (
82
- [params.field_models]
83
- if isinstance(params.field_models, FieldModel)
84
- else params.field_models
85
- )
86
- for field_model in field_models_list:
87
- self._request_operable.add_field(
88
- field_model.name,
89
- field_model=field_model,
90
- annotation=field_model.base_type,
91
- )
92
-
93
- # Add parameter fields (skip if already added from field_models)
94
- if params.parameter_fields:
95
- for name, field_info in params.parameter_fields.items():
96
- if (
97
- name not in (params.exclude_fields or [])
98
- and name not in self._request_operable.all_fields
99
- ):
100
- self._request_operable.add_field(
101
- name, field_obj=field_info
102
- )
61
+ # Response state
62
+ self.response_model = None
63
+ self.response_str_dict = None
64
+ self._should_retry = None
103
65
 
104
- # Generate request_type if not provided
105
- if not self.request_type:
106
- exclude_fields = params.exclude_fields or []
107
- use_fields = set(self._request_operable.all_fields.keys()) - set(
108
- exclude_fields
109
- )
66
+ def _get_adapter(self):
67
+ """Get adapter class for current adapter type."""
68
+ if self.adapter == "pydantic":
69
+ from lionagi.adapters.spec_adapters import PydanticSpecAdapter
70
+
71
+ return PydanticSpecAdapter
72
+ else:
73
+ raise ValueError(f"Unsupported adapter: {self.adapter}")
74
+
75
+ def create_request_model(self) -> type:
76
+ """Materialize request specs into model (excluding certain fields)."""
77
+ if self._request_model_cls:
78
+ return self._request_model_cls
79
+
80
+ self._request_model_cls = self.operable.create_model(
81
+ adapter=self.adapter,
82
+ model_name=f"{self.name}Request",
83
+ base_type=self.base_type,
84
+ exclude=self.request_exclude,
85
+ )
86
+ return self._request_model_cls
87
+
88
+ def create_response_model(self) -> type:
89
+ """Materialize all specs into response model."""
90
+ if self._response_model_cls:
91
+ return self._response_model_cls
92
+
93
+ # Ensure request model exists first
94
+ if not self._request_model_cls:
95
+ self.create_request_model()
96
+
97
+ # Response model uses ALL fields and inherits from request
98
+ self._response_model_cls = self.operable.create_model(
99
+ adapter=self.adapter,
100
+ model_name=f"{self.name}Response",
101
+ base_type=self._request_model_cls,
102
+ )
110
103
 
111
- # Determine model name - prefer explicit name, then base_type name, then default
112
- model_name = "RequestModel"
113
- if not params._is_sentinel(params.name):
114
- model_name = params.name
115
- elif not params._is_sentinel(params.base_type):
116
- model_name = params.base_type.__name__
117
-
118
- self.request_type = self._request_operable.new_model(
119
- name=model_name,
120
- use_fields=use_fields,
121
- base_type=params.base_type,
122
- frozen=params.frozen,
123
- config_dict=params.config_dict,
124
- doc=params.doc,
125
- )
104
+ return self._response_model_cls
126
105
 
127
- # Update name if not set - prefer explicit name, then base_type name
128
- if not self.name:
129
- if not params._is_sentinel(params.name):
130
- self.name = params.name
131
- elif not params._is_sentinel(params.base_type):
132
- self.name = params.base_type.__name__
106
+ def validate_response(self, text: str, strict: bool | None = None) -> Any:
107
+ """Validate response text using adapter.
133
108
 
134
- def model_dump(self) -> dict[str, Any]:
135
- """Convert to dictionary for backward compatibility.
109
+ Args:
110
+ text: Raw response text
111
+ strict: If True, raise on validation errors
136
112
 
137
- Note: This returns a Python dict, not JSON-serializable data.
138
- For JSON serialization, convert types appropriately.
113
+ Returns:
114
+ Validated model instance or None
139
115
  """
140
- return {
141
- "name": self.name,
142
- "request_type": self.request_type, # Python class object
143
- "response_type": self.response_type, # Python class object
144
- "response_model": self.response_model,
145
- "response_str_dict": self.response_str_dict,
146
- "auto_retry_parse": self.auto_retry_parse,
147
- "max_retries": self.max_retries,
148
- "parse_kwargs": self.parse_kwargs,
149
- }
150
-
151
- def to_dict(self) -> dict[str, Any]:
152
- """Alias for model_dump() - more appropriate name for non-Pydantic class."""
153
- return self.model_dump()
154
-
155
- def raise_validate_pydantic(self, text: str) -> None:
156
- """Validates and updates the response model using strict matching.
116
+ strict = self.strict if strict is None else strict
157
117
 
158
- Args:
159
- text (str): The text to validate and parse into the response model.
118
+ if not self._response_model_cls:
119
+ self.create_response_model()
120
+
121
+ adapter_cls = self._get_adapter()
160
122
 
161
- Raises:
162
- Exception: If the validation fails.
163
- """
164
- d_ = extract_json(text, fuzzy_parse=True)
165
- if isinstance(d_, list | tuple) and len(d_) == 1:
166
- d_ = d_[0]
167
123
  try:
168
- d_ = fuzzy_match_keys(
169
- d_, self.request_type.model_fields, handle_unmatched="raise"
124
+ self.response_model = adapter_cls.validate_response(
125
+ text,
126
+ self._response_model_cls,
127
+ strict=strict,
128
+ fuzzy_parse=True,
170
129
  )
171
- d_ = {k: v for k, v in d_.items() if v != Undefined}
172
- self.response_model = self.request_type.model_validate(d_)
173
130
  self._should_retry = False
174
- except Exception:
175
- self.response_str_dict = d_
176
- self._should_retry = True
131
+ return self.response_model
177
132
 
178
- def force_validate_pydantic(self, text: str):
179
- """Forcibly validates and updates the response model, allowing unmatched fields.
133
+ except Exception as e:
134
+ self.response_str_dict = text
135
+ self._should_retry = strict
136
+
137
+ if strict:
138
+ raise e
139
+
140
+ # Try fuzzy validation if auto-retry enabled
141
+ if self.auto_retry_parse and not strict:
142
+ try:
143
+ self.response_model = adapter_cls.validate_response(
144
+ text,
145
+ self._response_model_cls,
146
+ strict=False,
147
+ fuzzy_parse=True,
148
+ )
149
+ self._should_retry = False
150
+ return self.response_model
151
+ except Exception:
152
+ pass
180
153
 
181
- Args:
182
- text (str): The text to validate and parse into the response model.
183
- """
184
- d_ = text
185
- try:
186
- d_ = extract_json(text, fuzzy_parse=True)
187
- if isinstance(d_, list | tuple) and len(d_) == 1:
188
- d_ = d_[0]
189
- d_ = fuzzy_match_keys(
190
- d_, self.request_type.model_fields, handle_unmatched="force"
191
- )
192
- d_ = {k: v for k, v in d_.items() if v != Undefined}
193
- self.response_model = self.request_type.model_validate(d_)
194
- self._should_retry = False
195
- except Exception:
196
- self.response_str_dict = d_
197
- self.response_model = None
198
- self._should_retry = True
154
+ return None
199
155
 
200
156
  def update_response_model(
201
157
  self, text: str | None = None, data: dict | None = None
202
- ) -> BaseModel | dict | str | None:
203
- """Updates the response model based on the provided text or data.
158
+ ) -> Any:
159
+ """Update response model from text or dict.
204
160
 
205
161
  Args:
206
- text (str, optional): The text to parse and validate.
207
- data (dict, optional): The data to update the response model with.
162
+ text: Raw response text to validate
163
+ data: Dictionary updates to merge
208
164
 
209
165
  Returns:
210
- BaseModel | dict | str | None: The updated response model or raw data.
211
-
212
- Raises:
213
- ValueError: If neither text nor data is provided.
166
+ Updated model instance or raw data
214
167
  """
215
168
  if text is None and data is None:
216
- raise ValueError("Either text or data must be provided.")
169
+ raise ValueError("Either text or data must be provided")
217
170
 
218
171
  if text:
219
172
  self.response_str_dict = text
220
- try:
221
- self.raise_validate_pydantic(text)
222
- except Exception:
223
- self.force_validate_pydantic(text)
224
-
225
- if data and self.response_type:
226
- d_ = self.response_model.model_dump()
227
- d_.update(data)
228
- self.response_model = self.response_type.model_validate(d_)
229
-
230
- if not self.response_model and isinstance(
231
- self.response_str_dict, list
232
- ):
233
- try:
234
- self.response_model = [
235
- self.request_type.model_validate(d_)
236
- for d_ in self.response_str_dict
237
- ]
238
- except Exception:
239
- pass
240
-
241
- return self.response_model or self.response_str_dict
242
-
243
- def create_response_type(
244
- self,
245
- response_params: ModelParams | None = None,
246
- field_models: list[FieldModel] | None = None,
247
- parameter_fields: dict[str, FieldInfo] | None = None,
248
- exclude_fields: list[str] | None = None,
249
- field_descriptions: dict[str, str] | None = None,
250
- inherit_base: bool = True,
251
- config_dict: dict | None = None,
252
- doc: str | None = None,
253
- frozen: bool = False,
254
- validators: dict | None = None,
255
- ) -> None:
256
- """Creates a new response type based on the provided parameters.
173
+ self.validate_response(text, strict=False)
257
174
 
258
- Args:
259
- response_params (ModelParams, optional): Parameters for the new response model.
260
- field_models (list[FieldModel], optional): List of field models.
261
- parameter_fields (dict[str, FieldInfo], optional): Dictionary of parameter fields.
262
- exclude_fields (list, optional): List of fields to exclude.
263
- field_descriptions (dict, optional): Dictionary of field descriptions.
264
- inherit_base (bool, optional): Whether to inherit the base model.
265
- config_dict (dict | None, optional): Configuration dictionary.
266
- doc (str | None, optional): Documentation string.
267
- frozen (bool, optional): Whether the model is frozen.
268
- validators (dict, optional): Dictionary of validators.
269
- """
270
- # Process response_params if provided (for backward compatibility)
271
- if response_params:
272
- # Extract values from ModelParams
273
- field_models = field_models or response_params.field_models
274
- parameter_fields = (
275
- parameter_fields or response_params.parameter_fields
175
+ if data and self._response_model_cls and self.response_model:
176
+ adapter_cls = self._get_adapter()
177
+ self.response_model = adapter_cls.update_model(
178
+ self.response_model, data, self._response_model_cls
276
179
  )
277
- exclude_fields = exclude_fields or response_params.exclude_fields
278
- field_descriptions = (
279
- field_descriptions or response_params.field_descriptions
280
- )
281
- inherit_base = (
282
- response_params.inherit_base if inherit_base else False
283
- )
284
- config_dict = config_dict or response_params.config_dict
285
- doc = doc or response_params.doc
286
- frozen = frozen or response_params.frozen
287
-
288
- # Clear response operable and rebuild
289
- self._response_operable = OperableModel()
290
-
291
- # Copy fields from request operable if inherit_base
292
- if inherit_base and self._request_operable:
293
- for (
294
- field_name,
295
- field_model,
296
- ) in self._request_operable.extra_field_models.items():
297
- self._response_operable.add_field(
298
- field_name, field_model=field_model
299
- )
300
-
301
- # Add field models (skip if already exists from inheritance)
302
- if field_models:
303
- # Coerce to list if single FieldModel instance
304
- field_models_list = (
305
- [field_models]
306
- if isinstance(field_models, FieldModel)
307
- else field_models
308
- )
309
- for field_model in field_models_list:
310
- if field_model.name not in self._response_operable.all_fields:
311
- self._response_operable.add_field(
312
- field_model.name,
313
- field_model=field_model,
314
- annotation=field_model.base_type,
315
- )
316
180
 
317
- # Add parameter fields (skip if already added)
318
- if parameter_fields:
319
- for name, field_info in parameter_fields.items():
320
- if (
321
- name not in (exclude_fields or [])
322
- and name not in self._response_operable.all_fields
323
- ):
324
- self._response_operable.add_field(
325
- name, field_obj=field_info
326
- )
181
+ return self.response_model or self.response_str_dict
327
182
 
328
- # Add validators if provided
329
- if validators:
330
- for field_name, validator in validators.items():
331
- if field_name in self._response_operable.all_fields:
332
- field_model = (
333
- self._response_operable.extra_field_models.get(
334
- field_name
335
- )
336
- )
337
- if field_model:
338
- field_model.validator = validator
183
+ @property
184
+ def request_type(self) -> type | None:
185
+ """Get request model type."""
186
+ if not self._request_model_cls:
187
+ self.create_request_model()
188
+ return self._request_model_cls
339
189
 
340
- # Generate response type
341
- exclude_fields = exclude_fields or []
342
- use_fields = set(self._response_operable.all_fields.keys()) - set(
343
- exclude_fields
344
- )
190
+ @property
191
+ def response_type(self) -> type | None:
192
+ """Get response model type."""
193
+ if not self._response_model_cls:
194
+ self.create_response_model()
195
+ return self._response_model_cls
345
196
 
346
- # Determine base type - use request_type if inheriting and no specific base provided
347
- base_type = None
348
- if response_params and response_params.base_type:
349
- base_type = response_params.base_type
350
- elif inherit_base and self.request_type:
351
- base_type = self.request_type
352
-
353
- self.response_type = self._response_operable.new_model(
354
- name=(response_params.name if response_params else None)
355
- or "ResponseModel",
356
- use_fields=use_fields,
357
- base_type=base_type,
358
- frozen=frozen,
359
- config_dict=config_dict,
360
- doc=doc,
361
- )
197
+
198
+ __all__ = ("Operative",)