structai 0.1.6__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.
- structai/__init__.py +586 -0
- structai/io.py +227 -0
- structai/llm_api.py +713 -0
- structai/mp.py +96 -0
- structai/openai_server.py +79 -0
- structai/utils.py +209 -0
- structai-0.1.6.dist-info/METADATA +601 -0
- structai-0.1.6.dist-info/RECORD +11 -0
- structai-0.1.6.dist-info/WHEEL +5 -0
- structai-0.1.6.dist-info/licenses/LICENSE +21 -0
- structai-0.1.6.dist-info/top_level.txt +1 -0
structai/mp.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
from tqdm import tqdm
|
|
3
|
+
|
|
4
|
+
def multi_thread(inp_list, function, max_workers=40, use_tqdm=True):
|
|
5
|
+
results = [None] * len(inp_list) # Initialize results list
|
|
6
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
7
|
+
future_to_index = {
|
|
8
|
+
executor.submit(function, **item): index # Unpack item
|
|
9
|
+
for index, item in enumerate(inp_list)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
futures_iterator = concurrent.futures.as_completed(future_to_index)
|
|
13
|
+
if use_tqdm:
|
|
14
|
+
futures_iterator = tqdm(futures_iterator, total=len(future_to_index))
|
|
15
|
+
|
|
16
|
+
for future in futures_iterator:
|
|
17
|
+
index = future_to_index[future]
|
|
18
|
+
try:
|
|
19
|
+
result = future.result()
|
|
20
|
+
results[index] = result # Place result in correct position
|
|
21
|
+
except Exception as e:
|
|
22
|
+
print(f"Error processing item {inp_list[index]}: {str(e)}")
|
|
23
|
+
|
|
24
|
+
return results
|
|
25
|
+
|
|
26
|
+
def multi_process(inp_list, function, max_workers=40, use_tqdm=True):
|
|
27
|
+
results = [None] * len(inp_list) # Initialize results list
|
|
28
|
+
with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
29
|
+
future_to_index = {
|
|
30
|
+
executor.submit(function, **item): index # Unpack item
|
|
31
|
+
for index, item in enumerate(inp_list)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
futures_iterator = concurrent.futures.as_completed(future_to_index)
|
|
35
|
+
if use_tqdm:
|
|
36
|
+
futures_iterator = tqdm(futures_iterator, total=len(future_to_index))
|
|
37
|
+
|
|
38
|
+
for future in futures_iterator:
|
|
39
|
+
index = future_to_index[future]
|
|
40
|
+
try:
|
|
41
|
+
result = future.result()
|
|
42
|
+
results[index] = result # Place result in correct position
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"Error processing item {inp_list[index]}: {str(e)}")
|
|
45
|
+
|
|
46
|
+
return results
|
|
47
|
+
|
|
48
|
+
if __name__ == '__main__':
|
|
49
|
+
# python -m structai.mp
|
|
50
|
+
print("Testing mp.py...")
|
|
51
|
+
import time
|
|
52
|
+
|
|
53
|
+
def process(a, b):
|
|
54
|
+
time.sleep(0.1)
|
|
55
|
+
return a + b
|
|
56
|
+
|
|
57
|
+
# Test data
|
|
58
|
+
inp = []
|
|
59
|
+
for a in range(10):
|
|
60
|
+
inp.append({'a': a, 'b': 100})
|
|
61
|
+
|
|
62
|
+
# Test multi_thread
|
|
63
|
+
print("Testing multi_thread...")
|
|
64
|
+
results = multi_thread(inp, process, max_workers=5)
|
|
65
|
+
expected = [a + 100 for a in range(10)]
|
|
66
|
+
assert results == expected, f"[===ERROR===][structai][mp.py][main] multi_thread failed: {results} != {expected}"
|
|
67
|
+
print("multi_thread passed")
|
|
68
|
+
|
|
69
|
+
# Test multi_process
|
|
70
|
+
print("Testing multi_process...")
|
|
71
|
+
results = multi_process(inp, process, max_workers=5)
|
|
72
|
+
assert results == expected, f"[===ERROR===][structai][mp.py][main] multi_process failed: {results} != {expected}"
|
|
73
|
+
print("multi_process passed")
|
|
74
|
+
|
|
75
|
+
# Test with torch if available
|
|
76
|
+
try:
|
|
77
|
+
import torch
|
|
78
|
+
if torch.cuda.is_available():
|
|
79
|
+
print("Testing with CUDA tensors...")
|
|
80
|
+
inp_cuda = []
|
|
81
|
+
for a in range(5):
|
|
82
|
+
inp_cuda.append({'a': torch.tensor(a).cuda(), 'b': torch.tensor(100).cuda()})
|
|
83
|
+
|
|
84
|
+
# Note: multi_process with CUDA tensors might be tricky due to pickling/context
|
|
85
|
+
# We'll just test multi_thread here as it's safer for shared CUDA tensors in simple scripts
|
|
86
|
+
results = multi_thread(inp_cuda, process, max_workers=2)
|
|
87
|
+
results_cpu = [r.item() for r in results]
|
|
88
|
+
expected_cpu = [a + 100 for a in range(5)]
|
|
89
|
+
assert results_cpu == expected_cpu, f"[===ERROR===][structai][mp.py][main] CUDA multi_thread failed: {results_cpu} != {expected_cpu}"
|
|
90
|
+
print("CUDA multi_thread passed")
|
|
91
|
+
else:
|
|
92
|
+
print("Skipping CUDA tests: CUDA not available")
|
|
93
|
+
except ImportError:
|
|
94
|
+
print("Skipping torch tests: torch not installed")
|
|
95
|
+
|
|
96
|
+
print("mp.py tests completed.")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
# =========================
|
|
4
|
+
# Config
|
|
5
|
+
# =========================
|
|
6
|
+
|
|
7
|
+
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "") # Do not include /v1
|
|
8
|
+
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
|
9
|
+
|
|
10
|
+
def run_server(host="0.0.0.0", port=8001):
|
|
11
|
+
"""
|
|
12
|
+
Run the OpenAI proxy server.
|
|
13
|
+
"""
|
|
14
|
+
from fastapi import FastAPI, Request, Response
|
|
15
|
+
import httpx
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
# =========================
|
|
19
|
+
# App
|
|
20
|
+
# =========================
|
|
21
|
+
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
|
|
24
|
+
# =========================
|
|
25
|
+
# Universal OpenAI Proxy
|
|
26
|
+
# =========================
|
|
27
|
+
|
|
28
|
+
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
29
|
+
async def openai_proxy(path: str, request: Request):
|
|
30
|
+
"""
|
|
31
|
+
Universal OpenAI-compatible proxy.
|
|
32
|
+
Supports chat.completions, responses, models, embeddings, etc.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Target URL
|
|
36
|
+
target_url = f"{LLM_BASE_URL}/v1/{path}"
|
|
37
|
+
|
|
38
|
+
# Copy headers
|
|
39
|
+
headers = dict(request.headers)
|
|
40
|
+
|
|
41
|
+
# Override authorization
|
|
42
|
+
headers["authorization"] = f"Bearer {LLM_API_KEY}"
|
|
43
|
+
headers.pop("host", None)
|
|
44
|
+
|
|
45
|
+
# Read body
|
|
46
|
+
body = await request.body()
|
|
47
|
+
|
|
48
|
+
# Forward request
|
|
49
|
+
async with httpx.AsyncClient(timeout=300) as client:
|
|
50
|
+
upstream_resp = await client.request(
|
|
51
|
+
method=request.method,
|
|
52
|
+
url=target_url,
|
|
53
|
+
headers=headers,
|
|
54
|
+
content=body,
|
|
55
|
+
params=request.query_params,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# -------------------------
|
|
59
|
+
# IMPORTANT: header cleanup
|
|
60
|
+
# -------------------------
|
|
61
|
+
resp_headers = dict(upstream_resp.headers)
|
|
62
|
+
|
|
63
|
+
# httpx already decompressed the body
|
|
64
|
+
# Do NOT forward these headers
|
|
65
|
+
resp_headers.pop("content-encoding", None)
|
|
66
|
+
resp_headers.pop("content-length", None)
|
|
67
|
+
|
|
68
|
+
return Response(
|
|
69
|
+
content=upstream_resp.content,
|
|
70
|
+
status_code=upstream_resp.status_code,
|
|
71
|
+
headers=resp_headers,
|
|
72
|
+
media_type=resp_headers.get("content-type"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
uvicorn.run(app, host=host, port=port)
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
# python -m structai.openai_server
|
|
79
|
+
run_server()
|
structai/utils.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
import threading
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_with_timeout(func, args=(), kwargs=None, timeout=None):
|
|
7
|
+
"""
|
|
8
|
+
Run a function with a timeout limit.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
func (callable): The function to run.
|
|
12
|
+
args (tuple): Positional arguments for the function.
|
|
13
|
+
kwargs (dict): Keyword arguments for the function.
|
|
14
|
+
timeout (float or None): Maximum allowed execution time in seconds.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The function's return value.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
TimeoutError: If execution exceeds the allowed time.
|
|
21
|
+
Exception: Any exception raised by the function.
|
|
22
|
+
"""
|
|
23
|
+
if kwargs is None:
|
|
24
|
+
kwargs = {}
|
|
25
|
+
|
|
26
|
+
if timeout is None:
|
|
27
|
+
return func(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
result = [None]
|
|
30
|
+
exc = [None]
|
|
31
|
+
|
|
32
|
+
def target():
|
|
33
|
+
try:
|
|
34
|
+
result[0] = func(*args, **kwargs)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
exc[0] = e
|
|
37
|
+
|
|
38
|
+
thread = threading.Thread(target=target)
|
|
39
|
+
thread.start()
|
|
40
|
+
thread.join(timeout)
|
|
41
|
+
|
|
42
|
+
if thread.is_alive():
|
|
43
|
+
raise TimeoutError(f"[{str(func.__name__)} timeout after {timeout}s]")
|
|
44
|
+
|
|
45
|
+
if exc[0] is not None:
|
|
46
|
+
raise exc[0]
|
|
47
|
+
|
|
48
|
+
return result[0]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def timeout_limit(timeout=None):
|
|
52
|
+
"""
|
|
53
|
+
A decorator for enforcing function execution time limits.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
timeout (float or None): Maximum allowed execution time in seconds.
|
|
57
|
+
If None, no time limit is applied.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The function's return value if completed in time.
|
|
61
|
+
Raises TimeoutError if execution exceeds the allowed time.
|
|
62
|
+
"""
|
|
63
|
+
def decorator(func):
|
|
64
|
+
@wraps(func)
|
|
65
|
+
def wrapper(*args, **kwargs):
|
|
66
|
+
return run_with_timeout(func, args, kwargs, timeout)
|
|
67
|
+
return wrapper
|
|
68
|
+
|
|
69
|
+
return decorator
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def remove_tag(s: str, tags: list[str] = ["<think>", "</think>", "<answer>", "</answer>"], r: str = "\n"):
|
|
73
|
+
for tag in tags:
|
|
74
|
+
s = s.replace(tag, r)
|
|
75
|
+
return s.strip(r).strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_think_answer(text):
|
|
79
|
+
if not isinstance(text, str):
|
|
80
|
+
raise ValueError("Input text must be a string")
|
|
81
|
+
|
|
82
|
+
text = text.strip()
|
|
83
|
+
|
|
84
|
+
tag_think_start = "<think>"
|
|
85
|
+
tag_think_end = "</think>"
|
|
86
|
+
tag_answer_start = "<answer>"
|
|
87
|
+
tag_answer_end = "</answer>"
|
|
88
|
+
|
|
89
|
+
idx_answer_start = text.find(tag_answer_start)
|
|
90
|
+
idx_think_end = text.find(tag_think_end)
|
|
91
|
+
|
|
92
|
+
think_raw = ""
|
|
93
|
+
answer_raw = ""
|
|
94
|
+
|
|
95
|
+
if idx_answer_start != -1:
|
|
96
|
+
think_raw = text[:idx_answer_start]
|
|
97
|
+
answer_raw = text[idx_answer_start + len(tag_answer_start):]
|
|
98
|
+
|
|
99
|
+
idx_answer_end = answer_raw.find(tag_answer_end)
|
|
100
|
+
if idx_answer_end != -1:
|
|
101
|
+
answer_raw = answer_raw[:idx_answer_end]
|
|
102
|
+
|
|
103
|
+
elif idx_think_end != -1:
|
|
104
|
+
think_raw = text[:idx_think_end]
|
|
105
|
+
answer_raw = text[idx_think_end + len(tag_think_end):]
|
|
106
|
+
|
|
107
|
+
idx_answer_end = answer_raw.find(tag_answer_end)
|
|
108
|
+
if idx_answer_end != -1:
|
|
109
|
+
answer_raw = answer_raw[:idx_answer_end]
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError("Could not parse response: missing <answer> or </think> tags.")
|
|
113
|
+
|
|
114
|
+
idx_think_start = think_raw.find(tag_think_start)
|
|
115
|
+
if idx_think_start != -1:
|
|
116
|
+
think_raw = think_raw[idx_think_start + len(tag_think_start):]
|
|
117
|
+
|
|
118
|
+
think = remove_tag(think_raw, [tag_think_start, tag_think_end, tag_answer_start, tag_answer_end], "")
|
|
119
|
+
answer = remove_tag(answer_raw, [tag_think_start, tag_think_end, tag_answer_start, tag_answer_end], "")
|
|
120
|
+
|
|
121
|
+
if not think:
|
|
122
|
+
raise ValueError("Parsed think content is empty")
|
|
123
|
+
if not answer:
|
|
124
|
+
raise ValueError("Parsed answer content is empty")
|
|
125
|
+
|
|
126
|
+
return think, answer
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_within_tags(content: str, start_tag='<answer>', end_tag='</answer>', default_return=None):
|
|
130
|
+
content = str(content)
|
|
131
|
+
start_index = content.rfind(start_tag)
|
|
132
|
+
if start_index != -1:
|
|
133
|
+
end_index = content.find(end_tag, start_index)
|
|
134
|
+
if end_index != -1:
|
|
135
|
+
return content[start_index + len(start_tag):end_index].strip()
|
|
136
|
+
return default_return
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_all_file_paths(directory, suffix=''):
|
|
140
|
+
"""
|
|
141
|
+
Get all file paths with the specified suffix in the directory.
|
|
142
|
+
"""
|
|
143
|
+
file_paths = []
|
|
144
|
+
for root, dirs, files in os.walk(directory):
|
|
145
|
+
for file in files:
|
|
146
|
+
if file.endswith(suffix):
|
|
147
|
+
file_paths.append(os.path.join(root, file))
|
|
148
|
+
return file_paths
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
# python -m structai.utils
|
|
153
|
+
print("Testing utils.py...")
|
|
154
|
+
import time
|
|
155
|
+
|
|
156
|
+
# Test run_with_timeout
|
|
157
|
+
print("Testing run_with_timeout...")
|
|
158
|
+
def slow_func(seconds):
|
|
159
|
+
time.sleep(seconds)
|
|
160
|
+
return "done"
|
|
161
|
+
|
|
162
|
+
# Should pass
|
|
163
|
+
res = run_with_timeout(slow_func, args=(0.1,), timeout=1.0)
|
|
164
|
+
assert res == "done", f"[===ERROR===][structai][utils.py][main] run_with_timeout failed: {res} != done"
|
|
165
|
+
|
|
166
|
+
# Should fail
|
|
167
|
+
try:
|
|
168
|
+
run_with_timeout(slow_func, args=(0.5,), timeout=0.1)
|
|
169
|
+
print("run_with_timeout failed to timeout (unexpected)")
|
|
170
|
+
except TimeoutError:
|
|
171
|
+
print("run_with_timeout correctly timed out")
|
|
172
|
+
|
|
173
|
+
# Test timeout_limit decorator
|
|
174
|
+
print("Testing timeout_limit decorator...")
|
|
175
|
+
@timeout_limit(timeout=0.2)
|
|
176
|
+
def decorated_slow_func(seconds):
|
|
177
|
+
time.sleep(seconds)
|
|
178
|
+
return "done"
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
decorated_slow_func(0.5)
|
|
182
|
+
print("timeout_limit failed to timeout (unexpected)")
|
|
183
|
+
except TimeoutError:
|
|
184
|
+
print("timeout_limit correctly timed out")
|
|
185
|
+
|
|
186
|
+
# Test remove_tag
|
|
187
|
+
print("Testing remove_tag...")
|
|
188
|
+
text = "<think>thinking...</think> <answer>42</answer>"
|
|
189
|
+
cleaned = remove_tag(text)
|
|
190
|
+
assert "thinking..." in cleaned and "42" in cleaned, f"[===ERROR===][structai][utils.py][main] remove_tag failed: {cleaned}"
|
|
191
|
+
assert "<think>" not in cleaned, f"[===ERROR===][structai][utils.py][main] remove_tag failed: {cleaned}"
|
|
192
|
+
print("remove_tag passed")
|
|
193
|
+
|
|
194
|
+
# Test parse_think_answer
|
|
195
|
+
print("Testing parse_think_answer...")
|
|
196
|
+
text = "<think>This is thinking</think><answer>This is answer</answer>"
|
|
197
|
+
think, answer = parse_think_answer(text)
|
|
198
|
+
assert think == "This is thinking", f"[===ERROR===][structai][utils.py][main] parse_think_answer failed: {think} != This is thinking"
|
|
199
|
+
assert answer == "This is answer", f"[===ERROR===][structai][utils.py][main] parse_think_answer failed: {answer} != This is answer"
|
|
200
|
+
print("parse_think_answer passed")
|
|
201
|
+
|
|
202
|
+
# Test extract_within_tags
|
|
203
|
+
print("Testing extract_within_tags...")
|
|
204
|
+
text = "prefix <tag>content</tag> suffix"
|
|
205
|
+
extracted = extract_within_tags(text, "<tag>", "</tag>")
|
|
206
|
+
assert extracted == "content", f"[===ERROR===][structai][utils.py][main] extract_within_tags failed: {extracted} != content"
|
|
207
|
+
print("extract_within_tags passed")
|
|
208
|
+
|
|
209
|
+
print("utils.py tests completed.")
|