inferencesh 0.3.0__py3-none-any.whl → 0.4.0__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.
Potentially problematic release.
This version of inferencesh might be problematic. Click here for more details.
- inferencesh/__init__.py +37 -1
- inferencesh/client.py +830 -0
- inferencesh/models/__init__.py +29 -0
- inferencesh/models/base.py +94 -0
- inferencesh/models/file.py +252 -0
- inferencesh/models/llm.py +729 -0
- inferencesh/utils/__init__.py +6 -0
- inferencesh/utils/download.py +59 -0
- inferencesh/utils/storage.py +16 -0
- {inferencesh-0.3.0.dist-info → inferencesh-0.4.0.dist-info}/METADATA +6 -1
- inferencesh-0.4.0.dist-info/RECORD +15 -0
- {inferencesh-0.3.0.dist-info → inferencesh-0.4.0.dist-info}/WHEEL +1 -1
- inferencesh/sdk.py +0 -363
- inferencesh-0.3.0.dist-info/RECORD +0 -8
- {inferencesh-0.3.0.dist-info → inferencesh-0.4.0.dist-info}/entry_points.txt +0 -0
- {inferencesh-0.3.0.dist-info → inferencesh-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {inferencesh-0.3.0.dist-info → inferencesh-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Models package for inference.sh SDK."""
|
|
2
|
+
|
|
3
|
+
from .base import BaseApp, BaseAppInput, BaseAppOutput
|
|
4
|
+
from .file import File
|
|
5
|
+
from .llm import (
|
|
6
|
+
ContextMessageRole,
|
|
7
|
+
Message,
|
|
8
|
+
ContextMessage,
|
|
9
|
+
LLMInput,
|
|
10
|
+
LLMOutput,
|
|
11
|
+
build_messages,
|
|
12
|
+
stream_generate,
|
|
13
|
+
timing_context,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseApp",
|
|
18
|
+
"BaseAppInput",
|
|
19
|
+
"BaseAppOutput",
|
|
20
|
+
"File",
|
|
21
|
+
"ContextMessageRole",
|
|
22
|
+
"Message",
|
|
23
|
+
"ContextMessage",
|
|
24
|
+
"LLMInput",
|
|
25
|
+
"LLMOutput",
|
|
26
|
+
"build_messages",
|
|
27
|
+
"stream_generate",
|
|
28
|
+
"timing_context",
|
|
29
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from typing import Any, Dict, List
|
|
2
|
+
from pydantic import BaseModel, ConfigDict
|
|
3
|
+
import inspect
|
|
4
|
+
import ast
|
|
5
|
+
import textwrap
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderedSchemaModel(BaseModel):
|
|
10
|
+
"""A base model that ensures the JSON schema properties and required fields are in the order of field definition."""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def model_json_schema(cls, by_alias: bool = True, **kwargs: Any) -> Dict[str, Any]:
|
|
14
|
+
schema = super().model_json_schema(by_alias=by_alias, **kwargs)
|
|
15
|
+
|
|
16
|
+
field_order = cls._get_field_order()
|
|
17
|
+
|
|
18
|
+
if field_order:
|
|
19
|
+
# Order properties
|
|
20
|
+
ordered_properties = OrderedDict()
|
|
21
|
+
for field_name in field_order:
|
|
22
|
+
if field_name in schema['properties']:
|
|
23
|
+
ordered_properties[field_name] = schema['properties'][field_name]
|
|
24
|
+
|
|
25
|
+
# Add any remaining properties that weren't in field_order
|
|
26
|
+
for field_name, field_schema in schema['properties'].items():
|
|
27
|
+
if field_name not in ordered_properties:
|
|
28
|
+
ordered_properties[field_name] = field_schema
|
|
29
|
+
|
|
30
|
+
schema['properties'] = ordered_properties
|
|
31
|
+
|
|
32
|
+
# Order required fields
|
|
33
|
+
if 'required' in schema:
|
|
34
|
+
ordered_required = [field for field in field_order if field in schema['required']]
|
|
35
|
+
# Add any remaining required fields that weren't in field_order
|
|
36
|
+
ordered_required.extend([field for field in schema['required'] if field not in ordered_required])
|
|
37
|
+
schema['required'] = ordered_required
|
|
38
|
+
|
|
39
|
+
return schema
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _get_field_order(cls) -> List[str]:
|
|
43
|
+
"""Get the order of fields as they were defined in the class."""
|
|
44
|
+
source = inspect.getsource(cls)
|
|
45
|
+
|
|
46
|
+
# Unindent the entire source code
|
|
47
|
+
source = textwrap.dedent(source)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
module = ast.parse(source)
|
|
51
|
+
except IndentationError:
|
|
52
|
+
# If we still get an IndentationError, wrap the class in a dummy module
|
|
53
|
+
source = f"class DummyModule:\n{textwrap.indent(source, ' ')}"
|
|
54
|
+
module = ast.parse(source)
|
|
55
|
+
# Adjust to look at the first class def inside DummyModule
|
|
56
|
+
# noinspection PyUnresolvedReferences
|
|
57
|
+
class_def = module.body[0].body[0]
|
|
58
|
+
else:
|
|
59
|
+
# Find the class definition
|
|
60
|
+
class_def = next(
|
|
61
|
+
node for node in module.body if isinstance(node, ast.ClassDef) and node.name == cls.__name__
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Extract field names in the order they were defined
|
|
65
|
+
field_order = []
|
|
66
|
+
for node in class_def.body:
|
|
67
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
68
|
+
field_order.append(node.target.id)
|
|
69
|
+
|
|
70
|
+
return field_order
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BaseAppInput(OrderedSchemaModel):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseAppOutput(OrderedSchemaModel):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BaseApp(BaseModel):
|
|
82
|
+
model_config = ConfigDict(
|
|
83
|
+
arbitrary_types_allowed=True,
|
|
84
|
+
extra='allow'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def setup(self):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
async def run(self, app_input: BaseAppInput) -> BaseAppOutput:
|
|
91
|
+
raise NotImplementedError("run method must be implemented")
|
|
92
|
+
|
|
93
|
+
async def unload(self):
|
|
94
|
+
pass
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from typing import Optional, Union, Any
|
|
2
|
+
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
|
3
|
+
import mimetypes
|
|
4
|
+
import os
|
|
5
|
+
import urllib.request
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import tempfile
|
|
8
|
+
import hashlib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class File(BaseModel):
|
|
14
|
+
"""A class representing a file in the inference.sh ecosystem."""
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def get_cache_dir(cls) -> Path:
|
|
18
|
+
"""Get the cache directory path based on environment variables or default location."""
|
|
19
|
+
if cache_dir := os.environ.get("FILE_CACHE_DIR"):
|
|
20
|
+
path = Path(cache_dir)
|
|
21
|
+
else:
|
|
22
|
+
path = Path.home() / ".cache" / "inferencesh" / "files"
|
|
23
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
def _get_cache_path(self, url: str) -> Path:
|
|
27
|
+
"""Get the cache path for a URL using a hash-based directory structure."""
|
|
28
|
+
# Parse URL components
|
|
29
|
+
parsed_url = urllib.parse.urlparse(url)
|
|
30
|
+
|
|
31
|
+
# Create hash from URL path and query parameters for uniqueness
|
|
32
|
+
url_components = parsed_url.netloc + parsed_url.path
|
|
33
|
+
if parsed_url.query:
|
|
34
|
+
url_components += '?' + parsed_url.query
|
|
35
|
+
url_hash = hashlib.sha256(url_components.encode()).hexdigest()[:12]
|
|
36
|
+
|
|
37
|
+
# Get filename from URL or use default
|
|
38
|
+
filename = os.path.basename(parsed_url.path)
|
|
39
|
+
if not filename:
|
|
40
|
+
filename = 'download'
|
|
41
|
+
|
|
42
|
+
# Create hash directory in cache
|
|
43
|
+
cache_dir = self.get_cache_dir() / url_hash
|
|
44
|
+
cache_dir.mkdir(exist_ok=True)
|
|
45
|
+
|
|
46
|
+
return cache_dir / filename
|
|
47
|
+
uri: Optional[str] = Field(default=None) # Original location (URL or file path)
|
|
48
|
+
path: Optional[str] = None # Resolved local file path
|
|
49
|
+
content_type: Optional[str] = None # MIME type of the file
|
|
50
|
+
size: Optional[int] = None # File size in bytes
|
|
51
|
+
filename: Optional[str] = None # Original filename if available
|
|
52
|
+
_tmp_path: Optional[str] = PrivateAttr(default=None) # Internal storage for temporary file path
|
|
53
|
+
|
|
54
|
+
def __init__(self, initializer=None, **data):
|
|
55
|
+
if initializer is not None:
|
|
56
|
+
if isinstance(initializer, str):
|
|
57
|
+
data['uri'] = initializer
|
|
58
|
+
elif isinstance(initializer, File):
|
|
59
|
+
data = initializer.model_dump()
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f'Invalid input for File: {initializer}')
|
|
62
|
+
super().__init__(**data)
|
|
63
|
+
|
|
64
|
+
@model_validator(mode='before')
|
|
65
|
+
@classmethod
|
|
66
|
+
def convert_str_to_file(cls, values):
|
|
67
|
+
if isinstance(values, str): # Only accept strings
|
|
68
|
+
return {"uri": values}
|
|
69
|
+
elif isinstance(values, dict):
|
|
70
|
+
return values
|
|
71
|
+
raise ValueError(f'Invalid input for File: {values}')
|
|
72
|
+
|
|
73
|
+
@model_validator(mode='after')
|
|
74
|
+
def validate_required_fields(self) -> 'File':
|
|
75
|
+
"""Validate that either uri or path is provided."""
|
|
76
|
+
if not self.uri and not self.path:
|
|
77
|
+
raise ValueError("Either 'uri' or 'path' must be provided")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def model_post_init(self, _: Any) -> None:
|
|
81
|
+
"""Initialize file path and metadata after model creation.
|
|
82
|
+
|
|
83
|
+
This method handles:
|
|
84
|
+
1. Downloading URLs to local files if uri is a URL
|
|
85
|
+
2. Converting relative paths to absolute paths
|
|
86
|
+
3. Populating file metadata
|
|
87
|
+
"""
|
|
88
|
+
# Handle uri if provided
|
|
89
|
+
if self.uri:
|
|
90
|
+
if self._is_url(self.uri):
|
|
91
|
+
self._download_url()
|
|
92
|
+
else:
|
|
93
|
+
# Convert relative paths to absolute, leave absolute paths unchanged
|
|
94
|
+
self.path = os.path.abspath(self.uri)
|
|
95
|
+
|
|
96
|
+
# Handle path if provided
|
|
97
|
+
if self.path:
|
|
98
|
+
# Convert relative paths to absolute, leave absolute paths unchanged
|
|
99
|
+
self.path = os.path.abspath(self.path)
|
|
100
|
+
self._populate_metadata()
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
raise ValueError("Either 'uri' or 'path' must be provided and be valid")
|
|
104
|
+
|
|
105
|
+
def _is_url(self, path: str) -> bool:
|
|
106
|
+
"""Check if the path is a URL."""
|
|
107
|
+
parsed = urllib.parse.urlparse(path)
|
|
108
|
+
return parsed.scheme in ('http', 'https')
|
|
109
|
+
|
|
110
|
+
def _download_url(self) -> None:
|
|
111
|
+
"""Download the URL to the cache directory and update the path."""
|
|
112
|
+
original_url = self.uri
|
|
113
|
+
cache_path = self._get_cache_path(original_url)
|
|
114
|
+
|
|
115
|
+
# If file exists in cache, use it
|
|
116
|
+
if cache_path.exists():
|
|
117
|
+
print(f"Using cached file: {cache_path}")
|
|
118
|
+
self.path = str(cache_path)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
print(f"Downloading URL: {original_url} to {cache_path}")
|
|
122
|
+
tmp_file = None
|
|
123
|
+
try:
|
|
124
|
+
# Download to temporary file first to avoid partial downloads in cache
|
|
125
|
+
suffix = os.path.splitext(urllib.parse.urlparse(original_url).path)[1]
|
|
126
|
+
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
|
127
|
+
self._tmp_path = tmp_file.name
|
|
128
|
+
|
|
129
|
+
# Set up request with user agent
|
|
130
|
+
headers = {
|
|
131
|
+
'User-Agent': (
|
|
132
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
|
133
|
+
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
|
134
|
+
'Chrome/91.0.4472.124 Safari/537.36'
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
req = urllib.request.Request(original_url, headers=headers)
|
|
138
|
+
|
|
139
|
+
# Download the file with progress bar
|
|
140
|
+
print(f"Downloading URL: {original_url} to {self._tmp_path}")
|
|
141
|
+
try:
|
|
142
|
+
with urllib.request.urlopen(req) as response:
|
|
143
|
+
# Safely retrieve content-length if available
|
|
144
|
+
total_size = 0
|
|
145
|
+
try:
|
|
146
|
+
if hasattr(response, 'headers') and response.headers is not None:
|
|
147
|
+
# urllib may expose headers as an email.message.Message
|
|
148
|
+
cl = response.headers.get('content-length')
|
|
149
|
+
total_size = int(cl) if cl is not None else 0
|
|
150
|
+
elif hasattr(response, 'getheader'):
|
|
151
|
+
cl = response.getheader('content-length')
|
|
152
|
+
total_size = int(cl) if cl is not None else 0
|
|
153
|
+
except Exception:
|
|
154
|
+
total_size = 0
|
|
155
|
+
|
|
156
|
+
block_size = 1024 # 1 Kibibyte
|
|
157
|
+
|
|
158
|
+
with tqdm(total=total_size, unit='iB', unit_scale=True) as pbar:
|
|
159
|
+
with open(self._tmp_path, 'wb') as out_file:
|
|
160
|
+
while True:
|
|
161
|
+
non_chunking = False
|
|
162
|
+
try:
|
|
163
|
+
buffer = response.read(block_size)
|
|
164
|
+
except TypeError:
|
|
165
|
+
# Some mocks (or minimal implementations) expose read() without size
|
|
166
|
+
buffer = response.read()
|
|
167
|
+
non_chunking = True
|
|
168
|
+
if not buffer:
|
|
169
|
+
break
|
|
170
|
+
out_file.write(buffer)
|
|
171
|
+
try:
|
|
172
|
+
pbar.update(len(buffer))
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
if non_chunking:
|
|
176
|
+
# If we read the whole body at once, exit loop
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
# Move the temporary file to the cache location
|
|
180
|
+
os.replace(self._tmp_path, cache_path)
|
|
181
|
+
self._tmp_path = None # Prevent deletion in __del__
|
|
182
|
+
self.path = str(cache_path)
|
|
183
|
+
except (urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
184
|
+
raise RuntimeError(f"Failed to download URL {original_url}: {str(e)}")
|
|
185
|
+
except IOError as e:
|
|
186
|
+
raise RuntimeError(f"Failed to write downloaded file to {self._tmp_path}: {str(e)}")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
# Clean up temp file if something went wrong
|
|
189
|
+
if tmp_file is not None and hasattr(self, '_tmp_path'):
|
|
190
|
+
try:
|
|
191
|
+
os.unlink(self._tmp_path)
|
|
192
|
+
except (OSError, IOError):
|
|
193
|
+
pass
|
|
194
|
+
raise RuntimeError(f"Error downloading URL {original_url}: {str(e)}")
|
|
195
|
+
|
|
196
|
+
def __del__(self):
|
|
197
|
+
"""Cleanup temporary file if it exists."""
|
|
198
|
+
if hasattr(self, '_tmp_path') and self._tmp_path:
|
|
199
|
+
try:
|
|
200
|
+
os.unlink(self._tmp_path)
|
|
201
|
+
except (OSError, IOError):
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
def _populate_metadata(self) -> None:
|
|
205
|
+
"""Populate file metadata from the path if it exists."""
|
|
206
|
+
if os.path.exists(self.path):
|
|
207
|
+
if not self.content_type:
|
|
208
|
+
self.content_type = self._guess_content_type()
|
|
209
|
+
if not self.size:
|
|
210
|
+
self.size = self._get_file_size()
|
|
211
|
+
if not self.filename:
|
|
212
|
+
self.filename = self._get_filename()
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_path(cls, path: Union[str, os.PathLike]) -> 'File':
|
|
216
|
+
"""Create a File instance from a file path."""
|
|
217
|
+
return cls(uri=str(path))
|
|
218
|
+
|
|
219
|
+
def _guess_content_type(self) -> Optional[str]:
|
|
220
|
+
"""Guess the MIME type of the file."""
|
|
221
|
+
return mimetypes.guess_type(self.path)[0]
|
|
222
|
+
|
|
223
|
+
def _get_file_size(self) -> int:
|
|
224
|
+
"""Get the size of the file in bytes."""
|
|
225
|
+
return os.path.getsize(self.path)
|
|
226
|
+
|
|
227
|
+
def _get_filename(self) -> str:
|
|
228
|
+
"""Get the base filename from the path."""
|
|
229
|
+
return os.path.basename(self.path)
|
|
230
|
+
|
|
231
|
+
def exists(self) -> bool:
|
|
232
|
+
"""Check if the file exists."""
|
|
233
|
+
return os.path.exists(self.path)
|
|
234
|
+
|
|
235
|
+
def refresh_metadata(self) -> None:
|
|
236
|
+
"""Refresh all metadata from the file."""
|
|
237
|
+
if os.path.exists(self.path):
|
|
238
|
+
self.content_type = self._guess_content_type()
|
|
239
|
+
self.size = self._get_file_size() # Always update size
|
|
240
|
+
self.filename = self._get_filename()
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def model_json_schema(cls, **kwargs):
|
|
244
|
+
schema = super().model_json_schema(**kwargs)
|
|
245
|
+
schema["$id"] = "/schemas/File"
|
|
246
|
+
# Create a schema that accepts either a string or the full object
|
|
247
|
+
return {
|
|
248
|
+
"oneOf": [
|
|
249
|
+
{"type": "string"}, # Accept string input
|
|
250
|
+
schema # Accept full object input
|
|
251
|
+
]
|
|
252
|
+
}
|