speedy-utils 1.1.17__py3-none-any.whl → 1.1.18__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.
- llm_utils/__init__.py +8 -1
- llm_utils/chat_format/display.py +109 -14
- llm_utils/lm/__init__.py +12 -11
- llm_utils/lm/async_lm/async_llm_task.py +0 -10
- llm_utils/lm/async_lm/async_lm.py +13 -4
- llm_utils/lm/async_lm/async_lm_base.py +24 -14
- llm_utils/lm/base_prompt_builder.py +288 -0
- llm_utils/lm/llm_task.py +400 -0
- llm_utils/lm/lm.py +207 -0
- llm_utils/lm/lm_base.py +285 -0
- llm_utils/vector_cache/core.py +285 -89
- speedy_utils/common/patcher.py +68 -0
- speedy_utils/common/utils_cache.py +5 -5
- speedy_utils/common/utils_io.py +232 -6
- speedy_utils/multi_worker/process.py +124 -193
- {speedy_utils-1.1.17.dist-info → speedy_utils-1.1.18.dist-info}/METADATA +3 -2
- {speedy_utils-1.1.17.dist-info → speedy_utils-1.1.18.dist-info}/RECORD +19 -14
- {speedy_utils-1.1.17.dist-info → speedy_utils-1.1.18.dist-info}/WHEEL +1 -1
- {speedy_utils-1.1.17.dist-info → speedy_utils-1.1.18.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# type: ignore
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Simplified LLM Task module for handling language model interactions with structured input/output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Type, Union, cast
|
|
8
|
+
|
|
9
|
+
from openai import OpenAI
|
|
10
|
+
from openai.types.chat import ChatCompletionMessageParam
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from pydantic import create_model
|
|
13
|
+
from typing import Callable, Tuple
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
|
|
16
|
+
# Type aliases for better readability
|
|
17
|
+
Messages = List[ChatCompletionMessageParam]
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Type, TypeVar
|
|
21
|
+
|
|
22
|
+
B = TypeVar("B", bound="BasePromptBuilder")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BasePromptBuilder(BaseModel, ABC):
|
|
26
|
+
"""
|
|
27
|
+
Abstract base class for prompt builders.
|
|
28
|
+
Provides a consistent interface for:
|
|
29
|
+
- input/output key declaration
|
|
30
|
+
- prompt building
|
|
31
|
+
- schema enforcement via auto-built modget_io_keysels
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------ #
|
|
35
|
+
# Abstract methods
|
|
36
|
+
# ------------------------------------------------------------------ #
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_instruction(self) -> str:
|
|
39
|
+
"""Return the system instruction string (role of the model)."""
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def get_io_keys(self) -> Tuple[List[str], List[Union[str, Tuple[str, str]]]]:
|
|
44
|
+
"""
|
|
45
|
+
Return (input_keys, output_keys).
|
|
46
|
+
Each key must match a field of the subclass.
|
|
47
|
+
For output_keys, you can use:
|
|
48
|
+
- str: Use the field name as-is
|
|
49
|
+
- tuple[str, str]: (original_field_name, renamed_field_name)
|
|
50
|
+
Input keys are always strings.
|
|
51
|
+
"""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ #
|
|
55
|
+
# Auto-build models from keys
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
def _build_model_from_keys(self, keys: Union[List[str], List[Union[str, Tuple[str, str]]]], name: str) -> Type[BaseModel]:
|
|
58
|
+
fields: Dict[str, tuple[Any, Any]] = {}
|
|
59
|
+
for key in keys:
|
|
60
|
+
if isinstance(key, tuple):
|
|
61
|
+
# Handle tuple: (original_field_name, renamed_field_name)
|
|
62
|
+
original_key, renamed_key = key
|
|
63
|
+
if original_key not in self.model_fields:
|
|
64
|
+
raise ValueError(f"Key '{original_key}' not found in model fields")
|
|
65
|
+
field_info = self.model_fields[original_key]
|
|
66
|
+
field_type = field_info.annotation if field_info.annotation is not None else (Any,)
|
|
67
|
+
default = field_info.default if field_info.default is not None else ...
|
|
68
|
+
fields[renamed_key] = (field_type, default)
|
|
69
|
+
else:
|
|
70
|
+
# Handle string key
|
|
71
|
+
if key not in self.model_fields:
|
|
72
|
+
raise ValueError(f"Key '{key}' not found in model fields")
|
|
73
|
+
field_info = self.model_fields[key]
|
|
74
|
+
field_type = field_info.annotation if field_info.annotation is not None else (Any,)
|
|
75
|
+
default = field_info.default if field_info.default is not None else ...
|
|
76
|
+
fields[key] = (field_type, default)
|
|
77
|
+
return create_model(name, **fields) # type: ignore
|
|
78
|
+
|
|
79
|
+
def get_input_model(self) -> Type[BaseModel]:
|
|
80
|
+
input_keys, _ = self.get_io_keys()
|
|
81
|
+
return self._build_model_from_keys(input_keys, "InputModel")
|
|
82
|
+
|
|
83
|
+
def get_output_model(self) -> Type[BaseModel]:
|
|
84
|
+
_, output_keys = self.get_io_keys()
|
|
85
|
+
return self._build_model_from_keys(output_keys, "OutputModel")
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ #
|
|
88
|
+
# Dump methods (JSON)
|
|
89
|
+
# ------------------------------------------------------------------ #
|
|
90
|
+
def _dump_json_unique(self, schema_model: Type[BaseModel], keys: Union[List[str], List[Union[str, Tuple[str, str]]]], **kwargs) -> str:
|
|
91
|
+
allowed = list(schema_model.model_fields.keys())
|
|
92
|
+
seen = set()
|
|
93
|
+
unique_keys = [k for k in allowed if not (k in seen or seen.add(k))]
|
|
94
|
+
data = self.model_dump()
|
|
95
|
+
|
|
96
|
+
# Handle key mapping for renamed fields
|
|
97
|
+
filtered = {}
|
|
98
|
+
for key in keys:
|
|
99
|
+
if isinstance(key, tuple):
|
|
100
|
+
original_key, renamed_key = key
|
|
101
|
+
if original_key in data and renamed_key in unique_keys:
|
|
102
|
+
filtered[renamed_key] = data[original_key]
|
|
103
|
+
else:
|
|
104
|
+
if key in data and key in unique_keys:
|
|
105
|
+
filtered[key] = data[key]
|
|
106
|
+
|
|
107
|
+
return schema_model(**filtered).model_dump_json(**kwargs)
|
|
108
|
+
|
|
109
|
+
def model_dump_json_input(self, **kwargs) -> str:
|
|
110
|
+
input_keys, _ = self.get_io_keys()
|
|
111
|
+
return self._dump_json_unique(self.get_input_model(), input_keys, **kwargs)
|
|
112
|
+
|
|
113
|
+
def model_dump_json_output(self, **kwargs) -> str:
|
|
114
|
+
_, output_keys = self.get_io_keys()
|
|
115
|
+
return self._dump_json_unique(self.get_output_model(), output_keys, **kwargs)
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ #
|
|
118
|
+
# Markdown helpers
|
|
119
|
+
# ------------------------------------------------------------------ #
|
|
120
|
+
def _to_markdown(self, obj: Any, level: int = 1, title: Optional[str] = None) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Recursively convert dict/list/primitive into clean, generic Markdown.
|
|
123
|
+
"""
|
|
124
|
+
md: List[str] = []
|
|
125
|
+
|
|
126
|
+
# Format title if provided
|
|
127
|
+
if title is not None:
|
|
128
|
+
formatted_title = title.replace('_', ' ').title()
|
|
129
|
+
if level <= 2:
|
|
130
|
+
md.append(f"{'#' * level} {formatted_title}")
|
|
131
|
+
else:
|
|
132
|
+
md.append(f"**{formatted_title}:**")
|
|
133
|
+
|
|
134
|
+
if isinstance(obj, dict):
|
|
135
|
+
if not obj: # Empty dict
|
|
136
|
+
md.append("None")
|
|
137
|
+
else:
|
|
138
|
+
for k, v in obj.items():
|
|
139
|
+
if isinstance(v, (str, int, float, bool)) and len(str(v)) < 100:
|
|
140
|
+
# Short values inline
|
|
141
|
+
key_name = k.replace('_', ' ').title()
|
|
142
|
+
if level <= 2:
|
|
143
|
+
md.append(f"**{key_name}:** {v}")
|
|
144
|
+
else:
|
|
145
|
+
md.append(f"- **{key_name}:** {v}")
|
|
146
|
+
else:
|
|
147
|
+
# Complex values get recursive handling
|
|
148
|
+
md.append(self._to_markdown(v, level=level + 1, title=k))
|
|
149
|
+
elif isinstance(obj, list):
|
|
150
|
+
if not obj: # Empty list
|
|
151
|
+
md.append("None")
|
|
152
|
+
elif all(isinstance(i, dict) for i in obj):
|
|
153
|
+
# List of objects
|
|
154
|
+
for i, item in enumerate(obj, 1):
|
|
155
|
+
if level <= 2:
|
|
156
|
+
md.append(f"### {title or 'Item'} {i}")
|
|
157
|
+
else:
|
|
158
|
+
md.append(f"**{title or 'Item'} {i}:**")
|
|
159
|
+
# Process dict items inline for cleaner output
|
|
160
|
+
for k, v in item.items():
|
|
161
|
+
key_name = k.replace('_', ' ').title()
|
|
162
|
+
md.append(f"- **{key_name}:** {v}")
|
|
163
|
+
if i < len(obj): # Add spacing between items
|
|
164
|
+
md.append("")
|
|
165
|
+
else:
|
|
166
|
+
# Simple list
|
|
167
|
+
for item in obj:
|
|
168
|
+
md.append(f"- {item}")
|
|
169
|
+
else:
|
|
170
|
+
# Primitive value
|
|
171
|
+
value_str = str(obj) if obj is not None else "None"
|
|
172
|
+
if title is None:
|
|
173
|
+
md.append(value_str)
|
|
174
|
+
else:
|
|
175
|
+
md.append(value_str)
|
|
176
|
+
|
|
177
|
+
return "\n".join(md)
|
|
178
|
+
|
|
179
|
+
def _dump_markdown_unique(self, keys: Union[List[str], List[Union[str, Tuple[str, str]]]]) -> str:
|
|
180
|
+
data = self.model_dump()
|
|
181
|
+
filtered: Dict[str, Any] = {}
|
|
182
|
+
for key in keys:
|
|
183
|
+
if isinstance(key, tuple):
|
|
184
|
+
original_key, renamed_key = key
|
|
185
|
+
if original_key in data:
|
|
186
|
+
filtered[renamed_key] = data[original_key]
|
|
187
|
+
else:
|
|
188
|
+
if key in data:
|
|
189
|
+
filtered[key] = data[key]
|
|
190
|
+
|
|
191
|
+
# Generate markdown without top-level headers to avoid duplication
|
|
192
|
+
parts = []
|
|
193
|
+
for key, value in filtered.items():
|
|
194
|
+
if value is None:
|
|
195
|
+
continue
|
|
196
|
+
formatted_key = key.replace('_', ' ').title()
|
|
197
|
+
if isinstance(value, (str, int, float, bool)) and len(str(value)) < 200:
|
|
198
|
+
parts.append(f"**{formatted_key}:** {value}")
|
|
199
|
+
else:
|
|
200
|
+
parts.append(self._to_markdown(value, level=2, title=key))
|
|
201
|
+
|
|
202
|
+
return '\n'.join(parts)
|
|
203
|
+
|
|
204
|
+
def model_dump_markdown_input(self) -> str:
|
|
205
|
+
input_keys, _ = self.get_io_keys()
|
|
206
|
+
return self._dump_markdown_unique(input_keys)
|
|
207
|
+
|
|
208
|
+
def model_dump_markdown_output(self) -> str:
|
|
209
|
+
_, output_keys = self.get_io_keys()
|
|
210
|
+
return self._dump_markdown_unique(output_keys)
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------ #
|
|
213
|
+
# Training & preview (JSON or Markdown)
|
|
214
|
+
# ------------------------------------------------------------------ #
|
|
215
|
+
def build_training_data(self, format: str = "json", indent=None) -> dict[str, Any]:
|
|
216
|
+
"""
|
|
217
|
+
Build training data in either JSON (dict for OpenAI-style messages)
|
|
218
|
+
or Markdown (clean format without role prefixes).
|
|
219
|
+
"""
|
|
220
|
+
if format == "json":
|
|
221
|
+
return {
|
|
222
|
+
"messages": [
|
|
223
|
+
{"role": "system", "content": self.get_instruction()},
|
|
224
|
+
{"role": "user", "content": self.model_dump_json_input(indent=indent)},
|
|
225
|
+
{"role": "assistant", "content": self.model_dump_json_output(indent=indent)},
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
elif format == "markdown":
|
|
229
|
+
system_content = self.get_instruction()
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
'messages': [
|
|
233
|
+
{"role": "system", "content": system_content},
|
|
234
|
+
{"role": "user", "content": self.model_dump_markdown_input()},
|
|
235
|
+
{"role": "assistant", "content": self.model_dump_markdown_output()},
|
|
236
|
+
]
|
|
237
|
+
}
|
|
238
|
+
raise ValueError("format must be either 'json' or 'markdown'")
|
|
239
|
+
|
|
240
|
+
def __str__(self) -> str:
|
|
241
|
+
# Return clean format without explicit role prefixes
|
|
242
|
+
training_data = self.build_training_data(format="markdown")
|
|
243
|
+
messages = training_data['messages'] # type: ignore[index]
|
|
244
|
+
|
|
245
|
+
parts = []
|
|
246
|
+
for msg in messages:
|
|
247
|
+
content = msg['content']
|
|
248
|
+
if msg['role'] == 'system':
|
|
249
|
+
parts.append(content)
|
|
250
|
+
elif msg['role'] == 'user':
|
|
251
|
+
parts.append(content)
|
|
252
|
+
elif msg['role'] == 'assistant':
|
|
253
|
+
# Get output keys to determine the main output field name
|
|
254
|
+
_, output_keys = self.get_io_keys()
|
|
255
|
+
main_output = output_keys[0] if output_keys else 'response'
|
|
256
|
+
if isinstance(main_output, tuple):
|
|
257
|
+
main_output = main_output[1] # Use renamed key
|
|
258
|
+
title = main_output.replace('_', ' ').title()
|
|
259
|
+
parts.append(f"## {title}\n{content}")
|
|
260
|
+
|
|
261
|
+
return '\n\n'.join(parts)
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def from_messages(cls: Type[B], messages: list[dict]) -> B:
|
|
265
|
+
"""
|
|
266
|
+
Reconstruct a prompt builder instance from OpenAI-style messages.
|
|
267
|
+
"""
|
|
268
|
+
user_msg = next((m for m in messages if m.get("role") == "user"), None)
|
|
269
|
+
assistant_msg = next((m for m in messages if m.get("role") == "assistant"), None)
|
|
270
|
+
|
|
271
|
+
if user_msg is None:
|
|
272
|
+
raise ValueError("No user message found")
|
|
273
|
+
if assistant_msg is None:
|
|
274
|
+
raise ValueError("No assistant message found")
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
user_data = json.loads(user_msg["content"]) # type: ignore[index]
|
|
278
|
+
except Exception as e:
|
|
279
|
+
raise ValueError(f"Invalid user JSON content: {e}")
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
assistant_data = json.loads(assistant_msg["content"]) # type: ignore[index]
|
|
283
|
+
except Exception as e:
|
|
284
|
+
raise ValueError(f"Invalid assistant JSON content: {e}")
|
|
285
|
+
|
|
286
|
+
combined_data = {**user_data, **assistant_data}
|
|
287
|
+
return cast(B, cls(**combined_data))
|
|
288
|
+
|
llm_utils/lm/llm_task.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# type: ignore
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Simplified LLM Task module for handling language model interactions with structured input/output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Type, Union, cast
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from openai import OpenAI
|
|
11
|
+
from openai.types.chat import ChatCompletionMessageParam
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .base_prompt_builder import BasePromptBuilder
|
|
15
|
+
|
|
16
|
+
# Type aliases for better readability
|
|
17
|
+
Messages = List[ChatCompletionMessageParam]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_base_client(
|
|
21
|
+
client: Union[OpenAI, int, str, None] = None, cache: bool = True, api_key="abc"
|
|
22
|
+
) -> OpenAI:
|
|
23
|
+
"""Get OpenAI client from port number, base_url string, or existing client."""
|
|
24
|
+
from llm_utils import MOpenAI
|
|
25
|
+
|
|
26
|
+
open_ai_class = OpenAI if not cache else MOpenAI
|
|
27
|
+
if client is None:
|
|
28
|
+
return open_ai_class()
|
|
29
|
+
elif isinstance(client, int):
|
|
30
|
+
return open_ai_class(base_url=f"http://localhost:{client}/v1", api_key=api_key)
|
|
31
|
+
elif isinstance(client, str):
|
|
32
|
+
return open_ai_class(base_url=client, api_key=api_key)
|
|
33
|
+
elif isinstance(client, OpenAI):
|
|
34
|
+
return client
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"Invalid client type. Must be OpenAI instance, port number (int), base_url (str), or None."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LLMTask:
|
|
42
|
+
"""
|
|
43
|
+
Language model task with structured input/output and optional system instruction.
|
|
44
|
+
|
|
45
|
+
Supports str or Pydantic models for both input and output. Automatically handles
|
|
46
|
+
message formatting and response parsing.
|
|
47
|
+
|
|
48
|
+
Two main APIs:
|
|
49
|
+
- text(): Returns raw text responses as list of dicts (alias for text_completion)
|
|
50
|
+
- parse(): Returns parsed Pydantic model responses as list of dicts (alias for pydantic_parse)
|
|
51
|
+
- __call__(): Backward compatibility method that delegates based on output_model
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
```python
|
|
55
|
+
from pydantic import BaseModel
|
|
56
|
+
from llm_utils.lm.llm_task import LLMTask
|
|
57
|
+
|
|
58
|
+
class EmailOutput(BaseModel):
|
|
59
|
+
content: str
|
|
60
|
+
estimated_read_time: int
|
|
61
|
+
|
|
62
|
+
# Set up task with Pydantic output model
|
|
63
|
+
task = LLMTask(
|
|
64
|
+
instruction="Generate professional email content.",
|
|
65
|
+
output_model=EmailOutput,
|
|
66
|
+
client=OpenAI(),
|
|
67
|
+
temperature=0.7
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Use parse() for structured output
|
|
71
|
+
results = task.parse("Write a meeting follow-up email")
|
|
72
|
+
result = results[0]
|
|
73
|
+
print(result["parsed"].content, result["parsed"].estimated_read_time)
|
|
74
|
+
|
|
75
|
+
# Use text() for plain text output
|
|
76
|
+
results = task.text("Write a meeting follow-up email")
|
|
77
|
+
text_result = results[0]
|
|
78
|
+
print(text_result["parsed"])
|
|
79
|
+
|
|
80
|
+
# Multiple responses
|
|
81
|
+
results = task.parse("Write a meeting follow-up email", n=3)
|
|
82
|
+
for result in results:
|
|
83
|
+
print(f"Content: {result['parsed'].content}")
|
|
84
|
+
|
|
85
|
+
# Override parameters at runtime
|
|
86
|
+
results = task.text(
|
|
87
|
+
"Write a meeting follow-up email",
|
|
88
|
+
temperature=0.9,
|
|
89
|
+
n=2,
|
|
90
|
+
max_tokens=500
|
|
91
|
+
)
|
|
92
|
+
for result in results:
|
|
93
|
+
print(result["parsed"])
|
|
94
|
+
|
|
95
|
+
# Backward compatibility (uses output_model to choose method)
|
|
96
|
+
results = task("Write a meeting follow-up email") # Calls parse()
|
|
97
|
+
result = results[0]
|
|
98
|
+
print(result["parsed"].content)
|
|
99
|
+
```
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
instruction: Optional[str] = None,
|
|
105
|
+
input_model: Union[Type[BaseModel], type[str]] = str,
|
|
106
|
+
output_model: Type[BaseModel] | Type[str] = None,
|
|
107
|
+
client: Union[OpenAI, int, str, None] = None,
|
|
108
|
+
cache=True,
|
|
109
|
+
**model_kwargs,
|
|
110
|
+
):
|
|
111
|
+
"""
|
|
112
|
+
Initialize the LLMTask.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
instruction: Optional system instruction for the task
|
|
116
|
+
input_model: Input type (str or BaseModel subclass)
|
|
117
|
+
output_model: Output BaseModel type
|
|
118
|
+
client: OpenAI client, port number, or base_url string
|
|
119
|
+
cache: Whether to use cached responses (default True)
|
|
120
|
+
**model_kwargs: Additional model parameters including:
|
|
121
|
+
- temperature: Controls randomness (0.0 to 2.0)
|
|
122
|
+
- n: Number of responses to generate (when n > 1, returns list)
|
|
123
|
+
- max_tokens: Maximum tokens in response
|
|
124
|
+
- model: Model name (auto-detected if not provided)
|
|
125
|
+
"""
|
|
126
|
+
self.instruction = instruction
|
|
127
|
+
self.input_model = input_model
|
|
128
|
+
self.output_model = output_model
|
|
129
|
+
self.model_kwargs = model_kwargs
|
|
130
|
+
|
|
131
|
+
# if cache:
|
|
132
|
+
# print("Caching is enabled will use llm_utils.MOpenAI")
|
|
133
|
+
|
|
134
|
+
# self.client = MOpenAI(base_url=base_url, api_key=api_key)
|
|
135
|
+
# else:
|
|
136
|
+
# self.client = OpenAI(base_url=base_url, api_key=api_key)
|
|
137
|
+
self.client = get_base_client(client, cache=cache)
|
|
138
|
+
|
|
139
|
+
if not self.model_kwargs.get("model", ""):
|
|
140
|
+
self.model_kwargs["model"] = self.client.models.list().data[0].id
|
|
141
|
+
print(self.model_kwargs)
|
|
142
|
+
|
|
143
|
+
def _prepare_input(self, input_data: Union[str, BaseModel, List[Dict]]) -> Messages:
|
|
144
|
+
"""Convert input to messages format."""
|
|
145
|
+
if isinstance(input_data, list):
|
|
146
|
+
assert isinstance(input_data[0], dict) and "role" in input_data[0], (
|
|
147
|
+
"If input_data is a list, it must be a list of messages with 'role' and 'content' keys."
|
|
148
|
+
)
|
|
149
|
+
return cast(Messages, input_data)
|
|
150
|
+
else:
|
|
151
|
+
# Convert input to string format
|
|
152
|
+
if isinstance(input_data, str):
|
|
153
|
+
user_content = input_data
|
|
154
|
+
elif hasattr(input_data, "model_dump_json"):
|
|
155
|
+
user_content = input_data.model_dump_json()
|
|
156
|
+
elif isinstance(input_data, dict):
|
|
157
|
+
user_content = str(input_data)
|
|
158
|
+
else:
|
|
159
|
+
user_content = str(input_data)
|
|
160
|
+
|
|
161
|
+
# Build messages
|
|
162
|
+
messages = (
|
|
163
|
+
[
|
|
164
|
+
{"role": "system", "content": self.instruction},
|
|
165
|
+
]
|
|
166
|
+
if self.instruction is not None
|
|
167
|
+
else []
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
messages.append({"role": "user", "content": user_content})
|
|
171
|
+
return cast(Messages, messages)
|
|
172
|
+
|
|
173
|
+
def text_completion(
|
|
174
|
+
self, input_data: Union[str, BaseModel, list[Dict]], **runtime_kwargs
|
|
175
|
+
) -> List[Dict[str, Any]]:
|
|
176
|
+
"""
|
|
177
|
+
Execute the LLM task and return text responses.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
input_data: Input as string or BaseModel
|
|
181
|
+
**runtime_kwargs: Runtime model parameters that override defaults
|
|
182
|
+
- temperature: Controls randomness (0.0 to 2.0)
|
|
183
|
+
- n: Number of responses to generate
|
|
184
|
+
- max_tokens: Maximum tokens in response
|
|
185
|
+
- model: Model name override
|
|
186
|
+
- Any other model parameters supported by OpenAI API
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
List of dicts [{'parsed': text_response, 'messages': messages}, ...]
|
|
190
|
+
When n=1: List contains one dict
|
|
191
|
+
When n>1: List contains multiple dicts
|
|
192
|
+
"""
|
|
193
|
+
# Prepare messages
|
|
194
|
+
messages = self._prepare_input(input_data)
|
|
195
|
+
|
|
196
|
+
# Merge runtime kwargs with default model kwargs (runtime takes precedence)
|
|
197
|
+
effective_kwargs = {**self.model_kwargs, **runtime_kwargs}
|
|
198
|
+
model_name = effective_kwargs.get("model", self.model_kwargs["model"])
|
|
199
|
+
|
|
200
|
+
# Extract model name from kwargs for API call
|
|
201
|
+
api_kwargs = {k: v for k, v in effective_kwargs.items() if k != "model"}
|
|
202
|
+
|
|
203
|
+
completion = self.client.chat.completions.create(
|
|
204
|
+
model=model_name, messages=messages, **api_kwargs
|
|
205
|
+
)
|
|
206
|
+
# print(completion)
|
|
207
|
+
|
|
208
|
+
results: List[Dict[str, Any]] = []
|
|
209
|
+
for choice in completion.choices:
|
|
210
|
+
choice_messages = cast(
|
|
211
|
+
Messages,
|
|
212
|
+
messages + [{"role": "assistant", "content": choice.message.content}],
|
|
213
|
+
)
|
|
214
|
+
results.append(
|
|
215
|
+
{"parsed": choice.message.content, "messages": choice_messages}
|
|
216
|
+
)
|
|
217
|
+
return results
|
|
218
|
+
|
|
219
|
+
def pydantic_parse(
|
|
220
|
+
self,
|
|
221
|
+
input_data: Union[str, BaseModel, list[Dict]],
|
|
222
|
+
response_model: Optional[Type[BaseModel]] | Type[str] = None,
|
|
223
|
+
**runtime_kwargs,
|
|
224
|
+
) -> List[Dict[str, Any]]:
|
|
225
|
+
"""
|
|
226
|
+
Execute the LLM task and return parsed Pydantic model responses.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
input_data: Input as string or BaseModel
|
|
230
|
+
response_model: Pydantic model for response parsing (overrides default)
|
|
231
|
+
**runtime_kwargs: Runtime model parameters that override defaults
|
|
232
|
+
- temperature: Controls randomness (0.0 to 2.0)
|
|
233
|
+
- n: Number of responses to generate
|
|
234
|
+
- max_tokens: Maximum tokens in response
|
|
235
|
+
- model: Model name override
|
|
236
|
+
- Any other model parameters supported by OpenAI API
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of dicts [{'parsed': parsed_model, 'messages': messages}, ...]
|
|
240
|
+
When n=1: List contains one dict
|
|
241
|
+
When n>1: List contains multiple dicts
|
|
242
|
+
"""
|
|
243
|
+
# Prepare messages
|
|
244
|
+
messages = self._prepare_input(input_data)
|
|
245
|
+
|
|
246
|
+
# Merge runtime kwargs with default model kwargs (runtime takes precedence)
|
|
247
|
+
effective_kwargs = {**self.model_kwargs, **runtime_kwargs}
|
|
248
|
+
model_name = effective_kwargs.get("model", self.model_kwargs["model"])
|
|
249
|
+
|
|
250
|
+
# Extract model name from kwargs for API call
|
|
251
|
+
api_kwargs = {k: v for k, v in effective_kwargs.items() if k != "model"}
|
|
252
|
+
|
|
253
|
+
pydantic_model_to_use_opt = response_model or self.output_model
|
|
254
|
+
if pydantic_model_to_use_opt is None:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
"No response model specified. Either set output_model in constructor or pass response_model parameter."
|
|
257
|
+
)
|
|
258
|
+
pydantic_model_to_use: Type[BaseModel] = cast(
|
|
259
|
+
Type[BaseModel], pydantic_model_to_use_opt
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
completion = self.client.chat.completions.parse(
|
|
263
|
+
model=model_name,
|
|
264
|
+
messages=messages,
|
|
265
|
+
response_format=pydantic_model_to_use,
|
|
266
|
+
**api_kwargs,
|
|
267
|
+
)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
is_length_error = "Length" in str(e) or "maximum context length" in str(e)
|
|
270
|
+
if is_length_error:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"Input too long for model {model_name}. Error: {str(e)[:100]}..."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
results: List[Dict[str, Any]] = []
|
|
276
|
+
for choice in completion.choices: # type: ignore[attr-defined]
|
|
277
|
+
choice_messages = cast(
|
|
278
|
+
Messages,
|
|
279
|
+
messages + [{"role": "assistant", "content": choice.message.content}],
|
|
280
|
+
)
|
|
281
|
+
results.append(
|
|
282
|
+
{"parsed": choice.message.parsed, "messages": choice_messages}
|
|
283
|
+
) # type: ignore[attr-defined]
|
|
284
|
+
return results
|
|
285
|
+
|
|
286
|
+
def __call__(
|
|
287
|
+
self,
|
|
288
|
+
input_data: Union[str, BaseModel, list[Dict]],
|
|
289
|
+
response_model: Optional[Type[BaseModel] | Type[str]] = None,
|
|
290
|
+
two_step_parse_pydantic=False,
|
|
291
|
+
**runtime_kwargs,
|
|
292
|
+
) -> List[Dict[str, Any]]:
|
|
293
|
+
"""
|
|
294
|
+
Execute the LLM task. Delegates to text() or parse() based on output_model.
|
|
295
|
+
|
|
296
|
+
This method maintains backward compatibility by automatically choosing
|
|
297
|
+
between text and parse methods based on the output_model configuration.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
input_data: Input as string or BaseModel
|
|
301
|
+
response_model: Optional override for output model
|
|
302
|
+
**runtime_kwargs: Runtime model parameters
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
List of dicts [{'parsed': response, 'messages': messages}, ...]
|
|
306
|
+
"""
|
|
307
|
+
pydantic_model_to_use = response_model or self.output_model
|
|
308
|
+
|
|
309
|
+
if pydantic_model_to_use is str or pydantic_model_to_use is None:
|
|
310
|
+
return self.text_completion(input_data, **runtime_kwargs)
|
|
311
|
+
elif two_step_parse_pydantic:
|
|
312
|
+
# step 1: get text completions
|
|
313
|
+
results = self.text_completion(input_data, **runtime_kwargs)
|
|
314
|
+
parsed_results = []
|
|
315
|
+
for result in results:
|
|
316
|
+
response_text = result["parsed"]
|
|
317
|
+
messages = result["messages"]
|
|
318
|
+
# check if the pydantic_model_to_use is validated
|
|
319
|
+
if "</think>" in response_text:
|
|
320
|
+
response_text = response_text.split("</think>")[1]
|
|
321
|
+
try:
|
|
322
|
+
parsed = pydantic_model_to_use.model_validate_json(response_text)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
# logger.info(
|
|
325
|
+
# f"Warning: Failed to parsed JSON, Falling back to LLM parsing. Error: {str(e)[:100]}..."
|
|
326
|
+
# )
|
|
327
|
+
# use model to parse the response_text
|
|
328
|
+
_parsed_messages = [
|
|
329
|
+
{
|
|
330
|
+
"role": "system",
|
|
331
|
+
"content": "You are a helpful assistant that extracts JSON from text.",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"role": "user",
|
|
335
|
+
"content": f"Extract JSON from the following text:\n{response_text}",
|
|
336
|
+
},
|
|
337
|
+
]
|
|
338
|
+
parsed_result = self.pydantic_parse(
|
|
339
|
+
_parsed_messages,
|
|
340
|
+
response_model=pydantic_model_to_use,
|
|
341
|
+
**runtime_kwargs,
|
|
342
|
+
)[0]
|
|
343
|
+
parsed = parsed_result["parsed"]
|
|
344
|
+
# ---
|
|
345
|
+
parsed_results.append({"parsed": parsed, "messages": messages})
|
|
346
|
+
return parsed_results
|
|
347
|
+
|
|
348
|
+
else:
|
|
349
|
+
return self.pydantic_parse(
|
|
350
|
+
input_data, response_model=response_model, **runtime_kwargs
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Backward compatibility aliases
|
|
354
|
+
def text(self, *args, **kwargs) -> List[Dict[str, Any]]:
|
|
355
|
+
"""Alias for text_completion() for backward compatibility."""
|
|
356
|
+
return self.text_completion(*args, **kwargs)
|
|
357
|
+
|
|
358
|
+
def parse(self, *args, **kwargs) -> List[Dict[str, Any]]:
|
|
359
|
+
"""Alias for pydantic_parse() for backward compatibility."""
|
|
360
|
+
return self.pydantic_parse(*args, **kwargs)
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
def from_prompt_builder(
|
|
364
|
+
builder: BasePromptBuilder,
|
|
365
|
+
client: Union[OpenAI, int, str, None] = None,
|
|
366
|
+
cache=True,
|
|
367
|
+
**model_kwargs,
|
|
368
|
+
) -> "LLMTask":
|
|
369
|
+
"""
|
|
370
|
+
Create an LLMTask instance from a BasePromptBuilder instance.
|
|
371
|
+
|
|
372
|
+
This method extracts the instruction, input model, and output model
|
|
373
|
+
from the provided builder and initializes an LLMTask accordingly.
|
|
374
|
+
"""
|
|
375
|
+
instruction = builder.get_instruction()
|
|
376
|
+
input_model = builder.get_input_model()
|
|
377
|
+
output_model = builder.get_output_model()
|
|
378
|
+
|
|
379
|
+
# Extract data from the builder to initialize LLMTask
|
|
380
|
+
return LLMTask(
|
|
381
|
+
instruction=instruction,
|
|
382
|
+
input_model=input_model,
|
|
383
|
+
output_model=output_model,
|
|
384
|
+
client=client,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def list_models(client: Union[OpenAI, int, str, None] = None) -> List[str]:
|
|
389
|
+
"""
|
|
390
|
+
List available models from the OpenAI client.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
client: OpenAI client, port number, or base_url string
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of available model names.
|
|
397
|
+
"""
|
|
398
|
+
client = get_base_client(client, cache=False)
|
|
399
|
+
models = client.models.list().data
|
|
400
|
+
return [m.id for m in models]
|