lightmodel 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {lightmodel-0.2.0 → lightmodel-0.3.0}/LICENSE +1 -1
- {lightmodel-0.2.0/src/lightmodel.egg-info → lightmodel-0.3.0}/PKG-INFO +39 -5
- lightmodel-0.3.0/README.md +75 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0}/pyproject.toml +3 -3
- lightmodel-0.3.0/src/lightmodel/__init__.py +12 -0
- lightmodel-0.3.0/src/lightmodel/agent.py +134 -0
- lightmodel-0.3.0/src/lightmodel/exceptions.py +22 -0
- lightmodel-0.3.0/src/lightmodel/model.py +126 -0
- lightmodel-0.3.0/src/lightmodel/tools.py +123 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0/src/lightmodel.egg-info}/PKG-INFO +39 -5
- {lightmodel-0.2.0 → lightmodel-0.3.0}/src/lightmodel.egg-info/SOURCES.txt +1 -0
- lightmodel-0.2.0/README.md +0 -41
- lightmodel-0.2.0/src/lightmodel/__init__.py +0 -7
- lightmodel-0.2.0/src/lightmodel/agent.py +0 -71
- lightmodel-0.2.0/src/lightmodel/model.py +0 -45
- lightmodel-0.2.0/src/lightmodel/tools.py +0 -61
- {lightmodel-0.2.0 → lightmodel-0.3.0}/setup.cfg +0 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0}/src/lightmodel.egg-info/dependency_links.txt +0 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0}/src/lightmodel.egg-info/requires.txt +0 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0}/src/lightmodel.egg-info/top_level.txt +0 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0}/tests/test_model.py +0 -0
- {lightmodel-0.2.0 → lightmodel-0.3.0}/tests/test_tools.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightmodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Ultra-light autonomous AI agents for any OpenAI-compatible API
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: PlimbAI <plimb.ai.contact@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
|
-
Keywords: llm,ai,agent,openai,nvidia-nim,tool-calling
|
|
7
|
+
Keywords: llm,ai,agent,openai,nvidia-nim,tool-calling,no-dependencies
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Operating System :: OS Independent
|
|
10
10
|
Requires-Python: >=3.9
|
|
@@ -20,10 +20,18 @@ Dynamic: license-file
|
|
|
20
20
|
Ultra-light **autonomous AI agents** for any OpenAI-compatible API.
|
|
21
21
|
No `smolagents`, no `litellm`, no `openai` — just `requests`.
|
|
22
22
|
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- `LightModel`: HTTP client with automatic retry on transient errors
|
|
26
|
+
- `Agent`: autonomous tool-calling loop with parallel tools and callbacks
|
|
27
|
+
- Built-in tools: `web_search`, `visit_webpage`, `calculator`
|
|
28
|
+
- Extensible: add a tool by subclassing `Tool`
|
|
29
|
+
- Zero heavy dependencies
|
|
30
|
+
|
|
23
31
|
## Installation
|
|
24
32
|
|
|
25
33
|
pip install lightmodel
|
|
26
|
-
pip install lightmodel[search] # with beautifulsoup4 for web
|
|
34
|
+
pip install lightmodel[search] # with beautifulsoup4 for web tools
|
|
27
35
|
|
|
28
36
|
## Quick start
|
|
29
37
|
|
|
@@ -38,21 +46,47 @@ No `smolagents`, no `litellm`, no `openai` — just `requests`.
|
|
|
38
46
|
model=model,
|
|
39
47
|
tools=[SimpleDuckDuckGoSearchTool()],
|
|
40
48
|
system_prompt="You are a helpful research assistant.",
|
|
49
|
+
max_steps=6,
|
|
50
|
+
verbose=True,
|
|
41
51
|
)
|
|
42
52
|
print(agent.run("What are the latest AI news in 2026?"))
|
|
43
53
|
|
|
54
|
+
## Multi-turn conversation
|
|
55
|
+
|
|
56
|
+
print(agent.chat("Tell me more about the first result."))
|
|
57
|
+
print(agent.chat("Summarize in one sentence."))
|
|
58
|
+
|
|
44
59
|
## Custom tools
|
|
45
60
|
|
|
46
61
|
from lightmodel import Tool
|
|
47
62
|
|
|
48
63
|
class CalculatorTool(Tool):
|
|
49
64
|
name = "calculator"
|
|
50
|
-
description = "Evaluate
|
|
65
|
+
description = "Evaluate arithmetic."
|
|
51
66
|
inputs = {"expression": {"type": "string", "description": "e.g. 2+2"}}
|
|
52
67
|
|
|
53
68
|
def forward(self, expression):
|
|
54
69
|
return str(eval(expression, {"__builtins__": {}}, {}))
|
|
55
70
|
|
|
71
|
+
## Callbacks
|
|
72
|
+
|
|
73
|
+
agent = Agent(
|
|
74
|
+
model=model,
|
|
75
|
+
tools=[SimpleDuckDuckGoSearchTool()],
|
|
76
|
+
callbacks={
|
|
77
|
+
"on_step": lambda step, msg: print(f"step {step}"),
|
|
78
|
+
"on_tool_call": lambda name, args: print(f"→ {name}({args})"),
|
|
79
|
+
"on_tool_result": lambda name, result: print(f"← {result[:80]}"),
|
|
80
|
+
"on_final": lambda answer: print(f"✓ done"),
|
|
81
|
+
"on_error": lambda exc: print(f"✗ {exc}"),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
## Streaming
|
|
86
|
+
|
|
87
|
+
for chunk in model.stream_chat([{"role": "user", "content": "Hi"}]):
|
|
88
|
+
print(chunk, end="", flush=True)
|
|
89
|
+
|
|
56
90
|
## License
|
|
57
91
|
|
|
58
92
|
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# LightModel
|
|
2
|
+
|
|
3
|
+
Ultra-light **autonomous AI agents** for any OpenAI-compatible API.
|
|
4
|
+
No `smolagents`, no `litellm`, no `openai` — just `requests`.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- `LightModel`: HTTP client with automatic retry on transient errors
|
|
9
|
+
- `Agent`: autonomous tool-calling loop with parallel tools and callbacks
|
|
10
|
+
- Built-in tools: `web_search`, `visit_webpage`, `calculator`
|
|
11
|
+
- Extensible: add a tool by subclassing `Tool`
|
|
12
|
+
- Zero heavy dependencies
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
pip install lightmodel
|
|
17
|
+
pip install lightmodel[search] # with beautifulsoup4 for web tools
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
from lightmodel import Agent, LightModel, SimpleDuckDuckGoSearchTool
|
|
22
|
+
|
|
23
|
+
model = LightModel(
|
|
24
|
+
model_id="meta/llama-3.3-70b-instruct",
|
|
25
|
+
api_base="https://integrate.api.nvidia.com/v1",
|
|
26
|
+
api_key="your_key",
|
|
27
|
+
)
|
|
28
|
+
agent = Agent(
|
|
29
|
+
model=model,
|
|
30
|
+
tools=[SimpleDuckDuckGoSearchTool()],
|
|
31
|
+
system_prompt="You are a helpful research assistant.",
|
|
32
|
+
max_steps=6,
|
|
33
|
+
verbose=True,
|
|
34
|
+
)
|
|
35
|
+
print(agent.run("What are the latest AI news in 2026?"))
|
|
36
|
+
|
|
37
|
+
## Multi-turn conversation
|
|
38
|
+
|
|
39
|
+
print(agent.chat("Tell me more about the first result."))
|
|
40
|
+
print(agent.chat("Summarize in one sentence."))
|
|
41
|
+
|
|
42
|
+
## Custom tools
|
|
43
|
+
|
|
44
|
+
from lightmodel import Tool
|
|
45
|
+
|
|
46
|
+
class CalculatorTool(Tool):
|
|
47
|
+
name = "calculator"
|
|
48
|
+
description = "Evaluate arithmetic."
|
|
49
|
+
inputs = {"expression": {"type": "string", "description": "e.g. 2+2"}}
|
|
50
|
+
|
|
51
|
+
def forward(self, expression):
|
|
52
|
+
return str(eval(expression, {"__builtins__": {}}, {}))
|
|
53
|
+
|
|
54
|
+
## Callbacks
|
|
55
|
+
|
|
56
|
+
agent = Agent(
|
|
57
|
+
model=model,
|
|
58
|
+
tools=[SimpleDuckDuckGoSearchTool()],
|
|
59
|
+
callbacks={
|
|
60
|
+
"on_step": lambda step, msg: print(f"step {step}"),
|
|
61
|
+
"on_tool_call": lambda name, args: print(f"→ {name}({args})"),
|
|
62
|
+
"on_tool_result": lambda name, result: print(f"← {result[:80]}"),
|
|
63
|
+
"on_final": lambda answer: print(f"✓ done"),
|
|
64
|
+
"on_error": lambda exc: print(f"✗ {exc}"),
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
## Streaming
|
|
69
|
+
|
|
70
|
+
for chunk in model.stream_chat([{"role": "user", "content": "Hi"}]):
|
|
71
|
+
print(chunk, end="", flush=True)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lightmodel"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Ultra-light autonomous AI agents for any OpenAI-compatible API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = "MIT"
|
|
12
|
-
authors = [{name = "
|
|
13
|
-
keywords = ["llm", "ai", "agent", "openai", "nvidia-nim", "tool-calling"]
|
|
12
|
+
authors = [{name = "PlimbAI", email = "plimb.ai.contact@gmail.com"}]
|
|
13
|
+
keywords = ["llm", "ai", "agent", "openai", "nvidia-nim", "tool-calling", "no-dependencies"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"Operating System :: OS Independent",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""LightModel - Ultra-light autonomous AI agents."""
|
|
2
|
+
from .model import LightModel
|
|
3
|
+
from .tools import Tool, SimpleDuckDuckGoSearchTool, CalculatorTool, VisitWebpageTool
|
|
4
|
+
from .agent import Agent
|
|
5
|
+
from .exceptions import LightModelError, APIError, ToolError, MaxStepsError
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"LightModel", "Agent",
|
|
10
|
+
"Tool", "SimpleDuckDuckGoSearchTool", "CalculatorTool", "VisitWebpageTool",
|
|
11
|
+
"LightModelError", "APIError", "ToolError", "MaxStepsError",
|
|
12
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""LightModel Agent - autonomous tool-calling loop."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
|
|
6
|
+
from .exceptions import MaxStepsError
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Agent:
|
|
12
|
+
"""Autonomous agent using native OpenAI tool calling."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, model, tools=None, system_prompt=None,
|
|
15
|
+
max_steps=10, verbose=False, parallel_tools=True,
|
|
16
|
+
callbacks=None, raise_on_max_steps=False):
|
|
17
|
+
self.model = model
|
|
18
|
+
self.tools = {t.name: t for t in (tools or [])}
|
|
19
|
+
self.system_prompt = system_prompt
|
|
20
|
+
self.max_steps = max_steps
|
|
21
|
+
self.verbose = verbose
|
|
22
|
+
self.parallel_tools = parallel_tools
|
|
23
|
+
self.callbacks = callbacks or {}
|
|
24
|
+
self.raise_on_max_steps = raise_on_max_steps
|
|
25
|
+
self.messages = []
|
|
26
|
+
self.reset()
|
|
27
|
+
|
|
28
|
+
def reset(self):
|
|
29
|
+
"""Clear conversation history (keeps system prompt)."""
|
|
30
|
+
self.messages = []
|
|
31
|
+
if self.system_prompt:
|
|
32
|
+
self.messages.append({"role": "system", "content": self.system_prompt})
|
|
33
|
+
|
|
34
|
+
def _cb(self, name, *args):
|
|
35
|
+
fn = self.callbacks.get(name)
|
|
36
|
+
if fn:
|
|
37
|
+
try:
|
|
38
|
+
fn(*args)
|
|
39
|
+
except Exception:
|
|
40
|
+
logger.exception("Callback %s failed", name)
|
|
41
|
+
|
|
42
|
+
def _log(self, msg):
|
|
43
|
+
if self.verbose:
|
|
44
|
+
print(msg)
|
|
45
|
+
|
|
46
|
+
def run(self, task, reset=True):
|
|
47
|
+
"""Execute a task and return the final text answer."""
|
|
48
|
+
if reset:
|
|
49
|
+
self.reset()
|
|
50
|
+
self.messages.append({"role": "user", "content": task})
|
|
51
|
+
return self._loop()
|
|
52
|
+
|
|
53
|
+
def chat(self, message, reset=False):
|
|
54
|
+
"""Continue the conversation with a new message."""
|
|
55
|
+
if reset:
|
|
56
|
+
self.reset()
|
|
57
|
+
self.messages.append({"role": "user", "content": message})
|
|
58
|
+
return self._loop()
|
|
59
|
+
|
|
60
|
+
def _loop(self):
|
|
61
|
+
openai_tools = [t.to_openai() for t in self.tools.values()] or None
|
|
62
|
+
|
|
63
|
+
for step in range(1, self.max_steps + 1):
|
|
64
|
+
try:
|
|
65
|
+
msg = self.model.chat(self.messages, tools=openai_tools)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
self._cb("on_error", e)
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
self._cb("on_step", step, msg)
|
|
71
|
+
|
|
72
|
+
assistant = {"role": "assistant"}
|
|
73
|
+
if msg.get("content"):
|
|
74
|
+
assistant["content"] = msg["content"]
|
|
75
|
+
if msg.get("tool_calls"):
|
|
76
|
+
assistant["tool_calls"] = msg["tool_calls"]
|
|
77
|
+
self.messages.append(assistant)
|
|
78
|
+
|
|
79
|
+
tool_calls = msg.get("tool_calls") or []
|
|
80
|
+
if not tool_calls:
|
|
81
|
+
final = msg.get("content") or ""
|
|
82
|
+
self._cb("on_final", final)
|
|
83
|
+
return final
|
|
84
|
+
|
|
85
|
+
self._log(f"[step {step}] {len(tool_calls)} tool call(s)")
|
|
86
|
+
|
|
87
|
+
if self.parallel_tools and len(tool_calls) > 1:
|
|
88
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
89
|
+
max_workers=len(tool_calls)) as ex:
|
|
90
|
+
results = list(ex.map(self._run_tool, tool_calls))
|
|
91
|
+
else:
|
|
92
|
+
results = [self._run_tool(tc) for tc in tool_calls]
|
|
93
|
+
|
|
94
|
+
for tc, result in zip(tool_calls, results):
|
|
95
|
+
self.messages.append({
|
|
96
|
+
"role": "tool",
|
|
97
|
+
"tool_call_id": tc["id"],
|
|
98
|
+
"content": result,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
err = MaxStepsError(
|
|
102
|
+
f"Reached {self.max_steps} steps without a final answer"
|
|
103
|
+
)
|
|
104
|
+
self._cb("on_error", err)
|
|
105
|
+
if self.raise_on_max_steps:
|
|
106
|
+
raise err
|
|
107
|
+
return str(err)
|
|
108
|
+
|
|
109
|
+
def _run_tool(self, tc):
|
|
110
|
+
name = tc["function"]["name"]
|
|
111
|
+
raw_args = tc["function"]["arguments"]
|
|
112
|
+
try:
|
|
113
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
114
|
+
if not isinstance(args, dict):
|
|
115
|
+
args = {}
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
args = {}
|
|
118
|
+
|
|
119
|
+
self._log(f" → {name}({args})")
|
|
120
|
+
self._cb("on_tool_call", name, args)
|
|
121
|
+
|
|
122
|
+
tool = self.tools.get(name)
|
|
123
|
+
if tool is None:
|
|
124
|
+
result = f"Unknown tool: {name}"
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
result = str(tool.forward(**args))
|
|
128
|
+
except Exception as e:
|
|
129
|
+
result = f"Tool error ({name}): {e}"
|
|
130
|
+
|
|
131
|
+
preview = result[:120].replace("\n", " ")
|
|
132
|
+
self._log(f" ← {preview}...")
|
|
133
|
+
self._cb("on_tool_result", name, result)
|
|
134
|
+
return result
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Custom exceptions for lightmodel."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LightModelError(Exception):
|
|
5
|
+
"""Base exception for all lightmodel errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(LightModelError):
|
|
9
|
+
"""Raised when the API returns an HTTP error."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status, message, body=None):
|
|
12
|
+
self.status = status
|
|
13
|
+
self.body = body
|
|
14
|
+
super().__init__(f"API {status}: {message}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolError(LightModelError):
|
|
18
|
+
"""Raised when a tool fails to execute."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MaxStepsError(LightModelError):
|
|
22
|
+
"""Raised when the agent reaches max_steps without a final answer."""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""LightModel - pure HTTP client for OpenAI-compatible APIs."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import logging
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .exceptions import APIError
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LightModel:
|
|
13
|
+
"""Client for any OpenAI-compatible /chat/completions API."""
|
|
14
|
+
|
|
15
|
+
RETRIABLE_STATUS = {429, 500, 502, 503, 504}
|
|
16
|
+
|
|
17
|
+
def __init__(self, model_id, api_base, api_key="not-needed",
|
|
18
|
+
temperature=0.7, max_tokens=2048, timeout=120,
|
|
19
|
+
max_retries=3, retry_backoff=2.0, extra=None, headers=None):
|
|
20
|
+
self.model_id = model_id
|
|
21
|
+
base = api_base.rstrip("/")
|
|
22
|
+
if not base.endswith("/chat/completions"):
|
|
23
|
+
base += "/chat/completions"
|
|
24
|
+
self.api_url = base
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
self.temperature = temperature
|
|
27
|
+
self.max_tokens = max_tokens
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
self.max_retries = max_retries
|
|
30
|
+
self.retry_backoff = retry_backoff
|
|
31
|
+
self.extra = extra or {}
|
|
32
|
+
self.headers = headers or {}
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
return f"LightModel({self.model_id!r} @ {self.api_url!r})"
|
|
36
|
+
|
|
37
|
+
def _headers(self):
|
|
38
|
+
h = {"Content-Type": "application/json"}
|
|
39
|
+
if self.api_key:
|
|
40
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
41
|
+
h.update(self.headers)
|
|
42
|
+
return h
|
|
43
|
+
|
|
44
|
+
def _payload(self, messages, tools=None, stream=False, **overrides):
|
|
45
|
+
payload = {
|
|
46
|
+
"model": self.model_id,
|
|
47
|
+
"messages": messages,
|
|
48
|
+
"temperature": overrides.get("temperature", self.temperature),
|
|
49
|
+
"max_tokens": overrides.get("max_tokens", self.max_tokens),
|
|
50
|
+
**self.extra,
|
|
51
|
+
}
|
|
52
|
+
if tools:
|
|
53
|
+
payload["tools"] = tools
|
|
54
|
+
payload["tool_choice"] = overrides.get("tool_choice", "auto")
|
|
55
|
+
if "response_format" in overrides:
|
|
56
|
+
payload["response_format"] = overrides["response_format"]
|
|
57
|
+
if stream:
|
|
58
|
+
payload["stream"] = True
|
|
59
|
+
return payload
|
|
60
|
+
|
|
61
|
+
def chat(self, messages, tools=None, **overrides):
|
|
62
|
+
"""Send messages and return the assistant message dict (with retry)."""
|
|
63
|
+
last_exc = None
|
|
64
|
+
for attempt in range(self.max_retries + 1):
|
|
65
|
+
try:
|
|
66
|
+
return self._do_chat(messages, tools, **overrides)
|
|
67
|
+
except APIError as e:
|
|
68
|
+
if e.status not in self.RETRIABLE_STATUS:
|
|
69
|
+
raise
|
|
70
|
+
last_exc = e
|
|
71
|
+
if attempt < self.max_retries:
|
|
72
|
+
wait = self.retry_backoff ** attempt
|
|
73
|
+
logger.warning("API %s, retrying in %.1fs", e.status, wait)
|
|
74
|
+
time.sleep(wait)
|
|
75
|
+
raise last_exc
|
|
76
|
+
|
|
77
|
+
def _do_chat(self, messages, tools, **overrides):
|
|
78
|
+
r = requests.post(
|
|
79
|
+
self.api_url,
|
|
80
|
+
json=self._payload(messages, tools, **overrides),
|
|
81
|
+
headers=self._headers(),
|
|
82
|
+
timeout=self.timeout,
|
|
83
|
+
)
|
|
84
|
+
if r.status_code >= 400:
|
|
85
|
+
raise APIError(r.status_code, r.text[:300], body=r.text)
|
|
86
|
+
try:
|
|
87
|
+
return r.json()["choices"][0]["message"]
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise APIError(r.status_code, f"Invalid response: {r.text[:300]}") from e
|
|
90
|
+
|
|
91
|
+
def stream_chat(self, messages, tools=None, **overrides):
|
|
92
|
+
"""Yield content chunks as they arrive (streaming)."""
|
|
93
|
+
r = requests.post(
|
|
94
|
+
self.api_url,
|
|
95
|
+
json=self._payload(messages, tools, stream=True, **overrides),
|
|
96
|
+
headers=self._headers(),
|
|
97
|
+
timeout=self.timeout,
|
|
98
|
+
stream=True,
|
|
99
|
+
)
|
|
100
|
+
if r.status_code >= 400:
|
|
101
|
+
raise APIError(r.status_code, r.text[:300])
|
|
102
|
+
for line in r.iter_lines():
|
|
103
|
+
if not line:
|
|
104
|
+
continue
|
|
105
|
+
line = line.decode("utf-8")
|
|
106
|
+
if not line.startswith("data: "):
|
|
107
|
+
continue
|
|
108
|
+
data = line[6:]
|
|
109
|
+
if data == "[DONE]":
|
|
110
|
+
break
|
|
111
|
+
try:
|
|
112
|
+
chunk = json.loads(data)
|
|
113
|
+
delta = chunk["choices"][0].get("delta", {})
|
|
114
|
+
content = delta.get("content")
|
|
115
|
+
if content:
|
|
116
|
+
yield content
|
|
117
|
+
except (json.JSONDecodeError, KeyError, IndexError):
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
def list_models(self):
|
|
121
|
+
"""Return available model IDs (best-effort, server may not support it)."""
|
|
122
|
+
base = self.api_url.rsplit("/chat/completions", 1)[0]
|
|
123
|
+
r = requests.get(f"{base}/models", headers=self._headers(), timeout=30)
|
|
124
|
+
if r.status_code >= 400:
|
|
125
|
+
raise APIError(r.status_code, r.text[:200])
|
|
126
|
+
return [m["id"] for m in r.json().get("data", [])]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Lightweight tools for LightModel agents."""
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _get_bs4():
|
|
6
|
+
try:
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
return BeautifulSoup
|
|
9
|
+
except ImportError:
|
|
10
|
+
raise ImportError(
|
|
11
|
+
"beautifulsoup4 is required: pip install 'lightmodel[search]'"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Tool:
|
|
16
|
+
"""Base class for all tools. Subclass and override forward()."""
|
|
17
|
+
|
|
18
|
+
name = ""
|
|
19
|
+
description = ""
|
|
20
|
+
inputs = {}
|
|
21
|
+
|
|
22
|
+
def forward(self, **kwargs):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def to_openai(self):
|
|
26
|
+
props, required = {}, []
|
|
27
|
+
for name, spec in self.inputs.items():
|
|
28
|
+
props[name] = {
|
|
29
|
+
"type": spec.get("type", "string"),
|
|
30
|
+
"description": spec.get("description", ""),
|
|
31
|
+
}
|
|
32
|
+
required.append(name)
|
|
33
|
+
return {
|
|
34
|
+
"type": "function",
|
|
35
|
+
"function": {
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"description": self.description,
|
|
38
|
+
"parameters": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": props,
|
|
41
|
+
"required": required,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def __repr__(self):
|
|
47
|
+
return f"<{self.__class__.__name__} name={self.name!r}>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SimpleDuckDuckGoSearchTool(Tool):
|
|
51
|
+
"""Lightweight DuckDuckGo web search (HTML scraping, no API key)."""
|
|
52
|
+
|
|
53
|
+
name = "web_search"
|
|
54
|
+
description = "Search the web via DuckDuckGo. Returns the top 5 results."
|
|
55
|
+
inputs = {"query": {"type": "string", "description": "The search query."}}
|
|
56
|
+
|
|
57
|
+
def forward(self, query):
|
|
58
|
+
BeautifulSoup = _get_bs4()
|
|
59
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
60
|
+
try:
|
|
61
|
+
r = requests.post("https://html.duckduckgo.com/html/",
|
|
62
|
+
data={"q": query}, headers=headers, timeout=10)
|
|
63
|
+
r.raise_for_status()
|
|
64
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
65
|
+
out = []
|
|
66
|
+
for res in soup.select(".result")[:5]:
|
|
67
|
+
title = res.select_one(".result__a")
|
|
68
|
+
snippet = res.select_one(".result__snippet")
|
|
69
|
+
if title and snippet:
|
|
70
|
+
out.append(
|
|
71
|
+
f"- {title.get_text(strip=True)}\n "
|
|
72
|
+
f"{title['href']}\n {snippet.get_text(strip=True)}"
|
|
73
|
+
)
|
|
74
|
+
return "\n\n".join(out) if out else "No results found."
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return f"Search error: {e}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class VisitWebpageTool(Tool):
|
|
80
|
+
"""Fetch a webpage and return its main text content."""
|
|
81
|
+
|
|
82
|
+
name = "visit_webpage"
|
|
83
|
+
description = "Fetch a webpage and return its main text (truncated)."
|
|
84
|
+
inputs = {"url": {"type": "string", "description": "Full URL to visit"}}
|
|
85
|
+
|
|
86
|
+
def __init__(self, max_chars=3000):
|
|
87
|
+
self.max_chars = max_chars
|
|
88
|
+
|
|
89
|
+
def forward(self, url):
|
|
90
|
+
BeautifulSoup = _get_bs4()
|
|
91
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
92
|
+
try:
|
|
93
|
+
r = requests.get(url, headers=headers, timeout=15)
|
|
94
|
+
r.raise_for_status()
|
|
95
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
96
|
+
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
|
|
97
|
+
tag.decompose()
|
|
98
|
+
text = " ".join(soup.get_text(separator=" ").split())
|
|
99
|
+
return text[:self.max_chars] or "(empty page)"
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return f"Error fetching {url}: {e}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CalculatorTool(Tool):
|
|
105
|
+
"""Safe arithmetic evaluator."""
|
|
106
|
+
|
|
107
|
+
name = "calculator"
|
|
108
|
+
description = "Evaluate a basic arithmetic expression (numbers, + - * / % ( ) .)."
|
|
109
|
+
inputs = {
|
|
110
|
+
"expression": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"description": "Python arithmetic expression like '2 + 2' or '3 * 7'",
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def forward(self, expression):
|
|
117
|
+
allowed = set("0123456789+-*/().% ")
|
|
118
|
+
if not all(c in allowed for c in expression):
|
|
119
|
+
return "Error: only numbers and + - * / % ( ) . space allowed"
|
|
120
|
+
try:
|
|
121
|
+
return str(eval(expression, {"__builtins__": {}}, {}))
|
|
122
|
+
except Exception as e:
|
|
123
|
+
return f"Error: {e}"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightmodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Ultra-light autonomous AI agents for any OpenAI-compatible API
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: PlimbAI <plimb.ai.contact@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
|
-
Keywords: llm,ai,agent,openai,nvidia-nim,tool-calling
|
|
7
|
+
Keywords: llm,ai,agent,openai,nvidia-nim,tool-calling,no-dependencies
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Operating System :: OS Independent
|
|
10
10
|
Requires-Python: >=3.9
|
|
@@ -20,10 +20,18 @@ Dynamic: license-file
|
|
|
20
20
|
Ultra-light **autonomous AI agents** for any OpenAI-compatible API.
|
|
21
21
|
No `smolagents`, no `litellm`, no `openai` — just `requests`.
|
|
22
22
|
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- `LightModel`: HTTP client with automatic retry on transient errors
|
|
26
|
+
- `Agent`: autonomous tool-calling loop with parallel tools and callbacks
|
|
27
|
+
- Built-in tools: `web_search`, `visit_webpage`, `calculator`
|
|
28
|
+
- Extensible: add a tool by subclassing `Tool`
|
|
29
|
+
- Zero heavy dependencies
|
|
30
|
+
|
|
23
31
|
## Installation
|
|
24
32
|
|
|
25
33
|
pip install lightmodel
|
|
26
|
-
pip install lightmodel[search] # with beautifulsoup4 for web
|
|
34
|
+
pip install lightmodel[search] # with beautifulsoup4 for web tools
|
|
27
35
|
|
|
28
36
|
## Quick start
|
|
29
37
|
|
|
@@ -38,21 +46,47 @@ No `smolagents`, no `litellm`, no `openai` — just `requests`.
|
|
|
38
46
|
model=model,
|
|
39
47
|
tools=[SimpleDuckDuckGoSearchTool()],
|
|
40
48
|
system_prompt="You are a helpful research assistant.",
|
|
49
|
+
max_steps=6,
|
|
50
|
+
verbose=True,
|
|
41
51
|
)
|
|
42
52
|
print(agent.run("What are the latest AI news in 2026?"))
|
|
43
53
|
|
|
54
|
+
## Multi-turn conversation
|
|
55
|
+
|
|
56
|
+
print(agent.chat("Tell me more about the first result."))
|
|
57
|
+
print(agent.chat("Summarize in one sentence."))
|
|
58
|
+
|
|
44
59
|
## Custom tools
|
|
45
60
|
|
|
46
61
|
from lightmodel import Tool
|
|
47
62
|
|
|
48
63
|
class CalculatorTool(Tool):
|
|
49
64
|
name = "calculator"
|
|
50
|
-
description = "Evaluate
|
|
65
|
+
description = "Evaluate arithmetic."
|
|
51
66
|
inputs = {"expression": {"type": "string", "description": "e.g. 2+2"}}
|
|
52
67
|
|
|
53
68
|
def forward(self, expression):
|
|
54
69
|
return str(eval(expression, {"__builtins__": {}}, {}))
|
|
55
70
|
|
|
71
|
+
## Callbacks
|
|
72
|
+
|
|
73
|
+
agent = Agent(
|
|
74
|
+
model=model,
|
|
75
|
+
tools=[SimpleDuckDuckGoSearchTool()],
|
|
76
|
+
callbacks={
|
|
77
|
+
"on_step": lambda step, msg: print(f"step {step}"),
|
|
78
|
+
"on_tool_call": lambda name, args: print(f"→ {name}({args})"),
|
|
79
|
+
"on_tool_result": lambda name, result: print(f"← {result[:80]}"),
|
|
80
|
+
"on_final": lambda answer: print(f"✓ done"),
|
|
81
|
+
"on_error": lambda exc: print(f"✗ {exc}"),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
## Streaming
|
|
86
|
+
|
|
87
|
+
for chunk in model.stream_chat([{"role": "user", "content": "Hi"}]):
|
|
88
|
+
print(chunk, end="", flush=True)
|
|
89
|
+
|
|
56
90
|
## License
|
|
57
91
|
|
|
58
92
|
MIT
|
lightmodel-0.2.0/README.md
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# LightModel
|
|
2
|
-
|
|
3
|
-
Ultra-light **autonomous AI agents** for any OpenAI-compatible API.
|
|
4
|
-
No `smolagents`, no `litellm`, no `openai` — just `requests`.
|
|
5
|
-
|
|
6
|
-
## Installation
|
|
7
|
-
|
|
8
|
-
pip install lightmodel
|
|
9
|
-
pip install lightmodel[search] # with beautifulsoup4 for web search
|
|
10
|
-
|
|
11
|
-
## Quick start
|
|
12
|
-
|
|
13
|
-
from lightmodel import Agent, LightModel, SimpleDuckDuckGoSearchTool
|
|
14
|
-
|
|
15
|
-
model = LightModel(
|
|
16
|
-
model_id="meta/llama-3.3-70b-instruct",
|
|
17
|
-
api_base="https://integrate.api.nvidia.com/v1",
|
|
18
|
-
api_key="your_key",
|
|
19
|
-
)
|
|
20
|
-
agent = Agent(
|
|
21
|
-
model=model,
|
|
22
|
-
tools=[SimpleDuckDuckGoSearchTool()],
|
|
23
|
-
system_prompt="You are a helpful research assistant.",
|
|
24
|
-
)
|
|
25
|
-
print(agent.run("What are the latest AI news in 2026?"))
|
|
26
|
-
|
|
27
|
-
## Custom tools
|
|
28
|
-
|
|
29
|
-
from lightmodel import Tool
|
|
30
|
-
|
|
31
|
-
class CalculatorTool(Tool):
|
|
32
|
-
name = "calculator"
|
|
33
|
-
description = "Evaluate a basic arithmetic expression."
|
|
34
|
-
inputs = {"expression": {"type": "string", "description": "e.g. 2+2"}}
|
|
35
|
-
|
|
36
|
-
def forward(self, expression):
|
|
37
|
-
return str(eval(expression, {"__builtins__": {}}, {}))
|
|
38
|
-
|
|
39
|
-
## License
|
|
40
|
-
|
|
41
|
-
MIT
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"""LightModel Agent - autonomous tool-calling loop."""
|
|
2
|
-
import json
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class Agent:
|
|
6
|
-
"""Autonomous agent using native OpenAI tool calling."""
|
|
7
|
-
|
|
8
|
-
def __init__(self, model, tools=None, system_prompt=None,
|
|
9
|
-
max_steps=10, verbose=False):
|
|
10
|
-
self.model = model
|
|
11
|
-
self.tools = {t.name: t for t in (tools or [])}
|
|
12
|
-
self.system_prompt = system_prompt
|
|
13
|
-
self.max_steps = max_steps
|
|
14
|
-
self.verbose = verbose
|
|
15
|
-
|
|
16
|
-
def run(self, task):
|
|
17
|
-
messages = []
|
|
18
|
-
if self.system_prompt:
|
|
19
|
-
messages.append({"role": "system", "content": self.system_prompt})
|
|
20
|
-
messages.append({"role": "user", "content": task})
|
|
21
|
-
|
|
22
|
-
openai_tools = [t.to_openai() for t in self.tools.values()] or None
|
|
23
|
-
|
|
24
|
-
for step in range(1, self.max_steps + 1):
|
|
25
|
-
msg = self.model.chat(messages, tools=openai_tools)
|
|
26
|
-
|
|
27
|
-
assistant_msg = {"role": "assistant"}
|
|
28
|
-
if msg.get("content"):
|
|
29
|
-
assistant_msg["content"] = msg["content"]
|
|
30
|
-
if msg.get("tool_calls"):
|
|
31
|
-
assistant_msg["tool_calls"] = msg["tool_calls"]
|
|
32
|
-
messages.append(assistant_msg)
|
|
33
|
-
|
|
34
|
-
tool_calls = msg.get("tool_calls") or []
|
|
35
|
-
if not tool_calls:
|
|
36
|
-
return msg.get("content") or ""
|
|
37
|
-
|
|
38
|
-
if self.verbose:
|
|
39
|
-
print(f"[step {step}] {len(tool_calls)} tool call(s)")
|
|
40
|
-
|
|
41
|
-
for tc in tool_calls:
|
|
42
|
-
name = tc["function"]["name"]
|
|
43
|
-
raw_args = tc["function"]["arguments"]
|
|
44
|
-
try:
|
|
45
|
-
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
46
|
-
except json.JSONDecodeError:
|
|
47
|
-
args = {}
|
|
48
|
-
|
|
49
|
-
if self.verbose:
|
|
50
|
-
print(f" → {name}({args})")
|
|
51
|
-
|
|
52
|
-
tool = self.tools.get(name)
|
|
53
|
-
if tool is None:
|
|
54
|
-
result = f"Unknown tool: {name}"
|
|
55
|
-
else:
|
|
56
|
-
try:
|
|
57
|
-
result = tool.forward(**args)
|
|
58
|
-
except Exception as e:
|
|
59
|
-
result = f"Tool error: {e}"
|
|
60
|
-
|
|
61
|
-
if self.verbose:
|
|
62
|
-
preview = str(result)[:120].replace("\n", " ")
|
|
63
|
-
print(f" ← {preview}...")
|
|
64
|
-
|
|
65
|
-
messages.append({
|
|
66
|
-
"role": "tool",
|
|
67
|
-
"tool_call_id": tc["id"],
|
|
68
|
-
"content": str(result),
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
return "[max_steps reached without final answer]"
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""LightModel - pure HTTP client for OpenAI-compatible APIs."""
|
|
2
|
-
import requests
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class LightModel:
|
|
6
|
-
"""Client for any OpenAI-compatible /chat/completions API."""
|
|
7
|
-
|
|
8
|
-
def __init__(self, model_id, api_base, api_key="not-needed",
|
|
9
|
-
temperature=0.7, max_tokens=2048, timeout=120, extra=None):
|
|
10
|
-
self.model_id = model_id
|
|
11
|
-
base = api_base.rstrip("/")
|
|
12
|
-
if not base.endswith("/chat/completions"):
|
|
13
|
-
base += "/chat/completions"
|
|
14
|
-
self.api_url = base
|
|
15
|
-
self.api_key = api_key
|
|
16
|
-
self.temperature = temperature
|
|
17
|
-
self.max_tokens = max_tokens
|
|
18
|
-
self.timeout = timeout
|
|
19
|
-
self.extra = extra or {}
|
|
20
|
-
|
|
21
|
-
def chat(self, messages, tools=None):
|
|
22
|
-
"""Send messages to the API. Returns the assistant message dict."""
|
|
23
|
-
headers = {"Content-Type": "application/json"}
|
|
24
|
-
if self.api_key:
|
|
25
|
-
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
26
|
-
|
|
27
|
-
payload = {
|
|
28
|
-
"model": self.model_id,
|
|
29
|
-
"messages": messages,
|
|
30
|
-
"temperature": self.temperature,
|
|
31
|
-
"max_tokens": self.max_tokens,
|
|
32
|
-
**self.extra,
|
|
33
|
-
}
|
|
34
|
-
if tools:
|
|
35
|
-
payload["tools"] = tools
|
|
36
|
-
payload["tool_choice"] = "auto"
|
|
37
|
-
|
|
38
|
-
r = requests.post(self.api_url, json=payload, headers=headers,
|
|
39
|
-
timeout=self.timeout)
|
|
40
|
-
if r.status_code >= 400:
|
|
41
|
-
raise RuntimeError(f"API {r.status_code}: {r.text[:300]}")
|
|
42
|
-
try:
|
|
43
|
-
return r.json()["choices"][0]["message"]
|
|
44
|
-
except (KeyError, IndexError):
|
|
45
|
-
raise RuntimeError(f"Invalid response: {r.text[:300]}")
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Lightweight tools for LightModel agents."""
|
|
2
|
-
import requests
|
|
3
|
-
from bs4 import BeautifulSoup
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class Tool:
|
|
7
|
-
"""Base class for all tools. Subclass and override forward()."""
|
|
8
|
-
name = ""
|
|
9
|
-
description = ""
|
|
10
|
-
inputs = {} # {"arg_name": {"type": "string", "description": "..."}}
|
|
11
|
-
|
|
12
|
-
def forward(self, **kwargs):
|
|
13
|
-
raise NotImplementedError
|
|
14
|
-
|
|
15
|
-
def to_openai(self):
|
|
16
|
-
props, required = {}, []
|
|
17
|
-
for name, spec in self.inputs.items():
|
|
18
|
-
props[name] = {
|
|
19
|
-
"type": spec.get("type", "string"),
|
|
20
|
-
"description": spec.get("description", ""),
|
|
21
|
-
}
|
|
22
|
-
required.append(name)
|
|
23
|
-
return {
|
|
24
|
-
"type": "function",
|
|
25
|
-
"function": {
|
|
26
|
-
"name": self.name,
|
|
27
|
-
"description": self.description,
|
|
28
|
-
"parameters": {
|
|
29
|
-
"type": "object",
|
|
30
|
-
"properties": props,
|
|
31
|
-
"required": required,
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class SimpleDuckDuckGoSearchTool(Tool):
|
|
38
|
-
"""Lightweight DuckDuckGo web search (HTML scraping, no API key)."""
|
|
39
|
-
name = "web_search"
|
|
40
|
-
description = "Search the web via DuckDuckGo. Returns the top 5 results."
|
|
41
|
-
inputs = {
|
|
42
|
-
"query": {"type": "string", "description": "The search query."}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
def forward(self, query):
|
|
46
|
-
url = "https://html.duckduckgo.com/html/"
|
|
47
|
-
headers = {"User-Agent": "Mozilla/5.0"}
|
|
48
|
-
try:
|
|
49
|
-
r = requests.post(url, data={"q": query}, headers=headers, timeout=10)
|
|
50
|
-
r.raise_for_status()
|
|
51
|
-
soup = BeautifulSoup(r.text, "html.parser")
|
|
52
|
-
out = []
|
|
53
|
-
for res in soup.select(".result")[:5]:
|
|
54
|
-
title = res.select_one(".result__a")
|
|
55
|
-
snippet = res.select_one(".result__snippet")
|
|
56
|
-
if title and snippet:
|
|
57
|
-
out.append(f"- {title.get_text(strip=True)}\n "
|
|
58
|
-
f"{title['href']}\n {snippet.get_text(strip=True)}")
|
|
59
|
-
return "\n\n".join(out) if out else "No results found."
|
|
60
|
-
except Exception as e:
|
|
61
|
-
return f"Search error: {e}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|