Merge pull request #107 from streetycat/MVP

Compute Node Installation Wizard
This commit is contained in:
Liu Zhicong
2023-12-04 11:29:19 -08:00
committed by GitHub
12 changed files with 1489 additions and 35 deletions
View File
+7 -2
View File
@@ -217,6 +217,13 @@ class AIStorage:
"""
return Path.home() / "myai"
def get_download_dir(self) -> str:
"""
download dir is the dir for user to store the files downloaded with the system.
~/myai/download
"""
return f"{self.get_myai_dir()}/download"
def get_db(self,app_name:str)->ResourceLocation:
pass
@@ -242,5 +249,3 @@ class AIStorage:
except Exception as e:
logger.error(f"open or create file {path} failed! {str(e)}")
+426
View File
@@ -0,0 +1,426 @@
import logging
import os
import pathlib
import shutil
import subprocess
import sys
import re
import time
import ast
from concurrent.futures import ThreadPoolExecutor
from hashlib import md5
from typing import Optional, Union, List, Tuple
from generic_escape import GenericEscape
from aios_kernel import AIStorage
try:
import docker
except ImportError:
docker = None
CODE_BLOCK_PATTERN = r"```[ \t]*(\w+)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```"
UNKNOWN = "unknown"
TIMEOUT_MSG = "Timeout"
DEFAULT_TIMEOUT = 600
WIN32 = sys.platform == "win32"
PATH_SEPARATOR = WIN32 and "\\" or "/"
logger = logging.getLogger(__name__)
BUILT_IN_MODULES = set(
[
"sys",
"os",
"math",
"random",
"datetime",
"json",
"re",
"subprocess",
"time",
"threading",
"logging",
"collections",
"itertools",
"functools",
"operator",
"pathlib",
"shutil",
"tempfile",
"pickle",
"io",
"argparse",
"typing",
"unittest",
"contextlib",
"abc",
"heapq",
"bisect",
"copy",
"decimal",
"fractions",
"hashlib",
"secrets",
"statistics",
"difflib",
"doctest",
"enum",
"inspect",
"traceback",
"weakref",
"gc",
"mmap",
"msvcrt",
"winreg",
"array",
"audioop",
"binascii",
"cProfile",
"concurrent.futures",
"configparser",
"csv",
"ctypes",
"dateutil",
"dis",
"fnmatch",
"getopt",
"glob",
"gzip",
"pdb",
"pprint",
"profile",
"pstats",
"queue",
"socket",
"sqlite3",
"ssl",
"struct",
"tarfile",
"telnetlib",
"timeit",
"tokenize",
"uuid",
"xml",
"zipfile",
"zlib",
]
)
def get_imports(code: str) -> List[str]:
root = ast.parse(code)
imports = []
for node in ast.iter_child_nodes(root):
if isinstance(node, ast.Import):
module_names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom):
module_names = [node.module]
else:
continue
for name in module_names:
# Exclude built-in modules
if name not in BUILT_IN_MODULES:
imports.append(name)
return imports
def write_requirements(code: str, requirements_filepath: str):
imports = get_imports(code)
with open(requirements_filepath, "w") as file:
for module in imports:
file.write(module + "\n")
def _cmd(lang):
if lang.startswith("python") or lang in ["bash", "sh", "powershell"]:
return lang
if lang in ["shell"]:
return "sh"
if lang in ["ps1"]:
return "powershell"
raise NotImplementedError(f"{lang} not recognized in code execution")
def create_runner(code: str, timeout: int = 30) -> str:
"""
Create a Python script that runs the code and prints the output
"""
code = GenericEscape().escape(code)
# Create a runner script
runner = f"""
import os
import subprocess
my_env = os.environ.copy()
my_env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
f"python -i -q -u".split(),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=0,
universal_newlines=True,
env=my_env
)
process.stdin.write("{code}" + "\\n")
process.stdin.write("exit()\\n")
process.stdin.flush()
try:
process.wait({timeout})
except Exception as e:
process.terminate()
for line in iter(process.stdout.readline, ""):
print(line)
for line in iter(process.stderr.readline, ""):
if line.startswith(">>>"):
continue
print(line)
"""
return runner
def _run_cmd(cmd: [str], work_dir: str, timeout: int) -> str:
if WIN32:
logger.warning("SIGALRM is not supported on Windows. No timeout will be enforced.")
result = subprocess.run(
cmd,
cwd=work_dir,
capture_output=True,
text=True,
)
else:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
subprocess.run,
cmd,
cwd=work_dir,
capture_output=True,
text=True,
)
result = future.result(timeout=timeout)
return result
def execute_code(
code: Optional[str] = None,
timeout: Optional[int] = None,
filename: Optional[str] = None,
work_dir: Optional[str] = None,
use_docker: Optional[Union[List[str], str, bool]] = None,
lang: Optional[str] = "python",
) -> Tuple[int, str]:
"""Execute code in a docker container.
This function is not tested on MacOS.
Args:
code (Optional, str): The code to execute.
If None, the code from the file specified by filename will be executed.
Either code or filename must be provided.
timeout (Optional, int): The maximum execution time in seconds.
If None, a default timeout will be used. The default timeout is 600 seconds. On Windows, the timeout is not enforced when use_docker=False.
filename (Optional, str): The file name to save the code or where the code is stored when `code` is None.
If None, a file with a randomly generated name will be created.
The randomly generated file will be deleted after execution.
The file name must be a relative path. Relative paths are relative to the working directory.
work_dir (Optional, str): The working directory for the code execution.
If None, a default working directory will be used.
The default working directory is the "extensions" directory under
"path_to_autogen".
use_docker (Optional, list, str or bool): The docker image to use for code execution.
If a list or a str of image name(s) is provided, the code will be executed in a docker container
with the first image successfully pulled.
If None, False or empty, the code will be executed in the current environment.
Default is None, which will be converted into an empty list when docker package is available.
Expected behaviour:
- If `use_docker` is explicitly set to True and the docker package is available, the code will run in a Docker container.
- If `use_docker` is explicitly set to True but the Docker package is missing, an error will be raised.
- If `use_docker` is not set (i.e., left default to None) and the Docker package is not available, a warning will be displayed, but the code will run natively.
If the code is executed in the current environment,
the code must be trusted.
lang (Optional, str): The language of the code. Default is "python".
Returns:
int: 0 if the code executes successfully.
str: The error message if the code fails to execute; the stdout otherwise.
"""
if all((code is None, filename is None)):
error_msg = f"Either {code=} or {filename=} must be provided."
logger.error(error_msg)
raise AssertionError(error_msg)
# Warn if use_docker was unspecified (or None), and cannot be provided (the default).
# In this case the current behavior is to fall back to run natively, but this behavior
# is subject to change.
if use_docker is None:
if docker is None:
use_docker = False
logger.warning(
"execute_code was called without specifying a value for use_docker. Since the python docker package is not available, code will be run natively. Note: this fallback behavior is subject to change"
)
else:
# Default to true
use_docker = True
timeout = timeout or DEFAULT_TIMEOUT
original_filename = filename
if WIN32 and lang in ["sh", "shell"] and (not use_docker):
lang = "ps1"
if filename is None:
code_hash = md5(code.encode()).hexdigest()
# create a file with a automatically generated name
filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}"
if work_dir is None:
WORKING_DIR = os.path.join(AIStorage.get_instance().get_myai_dir(), "tmp_code")
pathlib.Path(WORKING_DIR).mkdir(exist_ok=True)
work_dir = os.path.join(WORKING_DIR, code_hash)
pathlib.Path(work_dir).mkdir(exist_ok=True)
filepath = os.path.join(work_dir, filename)
file_dir = os.path.dirname(filepath)
os.makedirs(file_dir, exist_ok=True)
if code is not None:
write_requirements(code, os.path.join(file_dir, "requirements.txt"))
code = create_runner(code, 30)
with open(filepath, "w", encoding="utf-8") as fout:
fout.write(code)
# check if already running in a docker container
in_docker_container = os.path.exists("/.dockerenv")
if not use_docker or in_docker_container:
try:
env_cmd = ["python", "-m", "venv", os.path.join(file_dir, "venv")]
_run_cmd(env_cmd, file_dir, timeout)
if WIN32:
venv_path = os.path.join(file_dir, "venv", "Scripts")
else:
venv_path = os.path.join(file_dir, "venv", "bin")
pip_cmd = [os.path.join(venv_path, "python"), "-m", "pip", "install", "-r", "requirements.txt"]
_run_cmd(pip_cmd, file_dir, timeout)
# already running in a docker container
cmd = [
os.path.join(venv_path, "python"),
f".\\{filename}" if WIN32 else filename,
]
result = _run_cmd(cmd, file_dir, timeout)
except TimeoutError:
if original_filename is None:
shutil.rmtree(os.path.join(file_dir, "venv"))
os.remove(filepath)
os.remove(os.path.join(file_dir, "requirements.txt"))
try:
os.removedirs(file_dir)
except Exception:
pass
return 1, TIMEOUT_MSG
if original_filename is None:
shutil.rmtree(os.path.join(file_dir, "venv"))
os.remove(filepath)
os.remove(os.path.join(file_dir, "requirements.txt"))
try:
os.removedirs(file_dir)
except Exception:
pass
if result.returncode:
logs = result.stderr
if original_filename is None:
abs_path = str(pathlib.Path(filepath).absolute())
logs = logs.replace(str(abs_path), "").replace(filename, "")
else:
abs_path = str(pathlib.Path(work_dir).absolute()) + PATH_SEPARATOR
logs = logs.replace(str(abs_path), "")
else:
logs = result.stdout
return result.returncode, logs
# create a docker client
client = docker.from_env()
image_list = (
["python:3-alpine", "python:3", "python:3-windowsservercore"]
if use_docker is True
else [use_docker]
if isinstance(use_docker, str)
else use_docker
)
for image in image_list:
# check if the image exists
try:
client.images.get(image)
break
except docker.errors.ImageNotFound:
# pull the image
logger.info("Pulling image", image)
try:
client.images.pull(image, stream=True, decode=True)
break
except docker.errors.DockerException as e:
logger.error("Failed to pull image", image)
logger.exception(e)
# get a randomized str based on current time to wrap the exit code
exit_code_str = f"exitcode{time.time()}"
start_str = f'start{time.time()}'
abs_path = pathlib.Path(work_dir).absolute()
cmd = [
"sh",
"-c",
f"pip install --quiet -r requirements.txt; echo -n {start_str}; {_cmd(lang)} {filename}; exit_code=$?; echo -n {exit_code_str}; echo -n $exit_code; echo {exit_code_str};",
]
# create a docker container
container = client.containers.run(
image,
command=cmd,
working_dir="/workspace",
detach=True,
# get absolute path to the working directory
volumes={abs_path: {"bind": "/workspace", "mode": "rw"}},
)
start_time = time.time()
while container.status != "exited" and time.time() - start_time < timeout:
# Reload the container object
container.reload()
if container.status != "exited":
container.stop()
container.remove()
if original_filename is None:
os.remove(filepath)
return 1, TIMEOUT_MSG, image
# get the container logs
logs: str = container.logs().decode("utf-8").rstrip()
start_pos = logs.find(start_str)
if start_pos != -1:
logs = logs[start_pos + len(start_str):]
# # commit the image
# tag = filename.replace("/", "")
# container.commit(repository="python", tag=tag)
# remove the container
container.remove()
# check if the code executed successfully
exit_code = container.attrs["State"]["ExitCode"]
if exit_code == 0:
# extract the exit code from the logs
pattern = re.compile(f"{exit_code_str}(\\d+){exit_code_str}")
match = pattern.search(logs)
exit_code = 1 if match is None else int(match.group(1))
# remove the exit code from the logs
logs = logs if match is None else pattern.sub("", logs)
if original_filename is None:
os.remove(filepath)
os.remove(os.path.join(file_dir, "requirements.txt"))
os.removedirs(file_dir)
if exit_code:
logs = logs.replace(f"/workspace/{filename if original_filename is None else ''}", "")
# return the exit code, logs and image
return exit_code, logs
@@ -0,0 +1,41 @@
from typing import Dict
from aios_kernel.ai_function import AIFunction
from aios_kernel.code_interpreter import execute_code
class CodeInterpreterFunction(AIFunction):
def __init__(self):
self.func_id = "code_interpreter"
self.description = "execute python code"
def get_name(self) -> str:
return self.func_id
def get_description(self) -> str:
return self.description
def get_parameters(self) -> Dict:
return {
"type": "object",
"properties": {
"code": {"type": "string", "description": "python code"}
}
}
async def execute(self, **kwargs) -> str:
code = kwargs.get("code")
ret_code, result = execute_code(code=code)
if ret_code == 0:
return result.strip()
else:
return result.strip()
def is_local(self) -> bool:
return True
def is_in_zone(self) -> bool:
return True
def is_ready_only(self) -> bool:
return False
@@ -0,0 +1,52 @@
import json
from typing import Dict
from aios_kernel.ai_function import AIFunction
from duckduckgo_search import AsyncDDGS
class DuckDuckGoTextSearchFunction(AIFunction):
def __init__(self):
self.name = "duckduckgo_text_search"
self.description = "Search text from duckduckgo.com"
self.region = "wt-wt"
self.safesearch = "moderate"
self.time = "y"
self.max_results = 5
def get_name(self) -> str:
return self.name
def get_description(self) -> str:
return self.description
def get_parameters(self) -> Dict:
return {"type": "object",
"properties": {
"query": {"type": "string", "description": "The query to search for."}
}
}
async def execute(self, **kwargs) -> str:
query = kwargs.get("query")
async with AsyncDDGS() as ddgs:
results = [r async for r in ddgs.text(
query,
region=self.region,
safesearch=self.safesearch,
timelimit=self.time,
backend="api",
max_results=self.max_results
)]
return json.dumps(results)
def is_local(self) -> bool:
return True
def is_in_zone(self) -> bool:
return True
def is_ready_only(self) -> bool:
return False
+493
View File
@@ -0,0 +1,493 @@
"""
Taken from: langchain
SQLAlchemy wrapper around a database.
"""
from __future__ import annotations
import os
import warnings
from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Union
import sqlalchemy
from sqlalchemy import MetaData, Table, create_engine, inspect, select, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
from sqlalchemy.schema import CreateTable
from sqlalchemy.types import NullType
def get_from_env(key: str, env_key: str, default: Optional[str] = None) -> str:
"""Get a value from a dictionary or an environment variable."""
if env_key in os.environ and os.environ[env_key]:
return os.environ[env_key]
elif default is not None:
return default
else:
raise ValueError(
f"Did not find {key}, please add an environment variable"
f" `{env_key}` which contains it, or pass"
f" `{key}` as a named parameter."
)
def _format_index(index: sqlalchemy.engine.interfaces.ReflectedIndex) -> str:
return (
f'Name: {index["name"]}, Unique: {index["unique"]},'
f' Columns: {str(index["column_names"])}'
)
def truncate_word(content: Any, *, length: int, suffix: str = "...") -> str:
"""
Truncate a string to a certain number of words, based on the max string
length.
"""
if not isinstance(content, str) or length <= 0:
return content
if len(content) <= length:
return content
return content[: length - len(suffix)].rsplit(" ", 1)[0] + suffix
class SQLDatabase:
"""SQLAlchemy wrapper around a database."""
def __init__(
self,
engine: Engine,
schema: Optional[str] = None,
metadata: Optional[MetaData] = None,
ignore_tables: Optional[List[str]] = None,
include_tables: Optional[List[str]] = None,
sample_rows_in_table_info: int = 3,
indexes_in_table_info: bool = False,
custom_table_info: Optional[dict] = None,
view_support: bool = False,
max_string_length: int = 300,
):
"""Create engine from database URI."""
self._engine = engine
self._schema = schema
if include_tables and ignore_tables:
raise ValueError("Cannot specify both include_tables and ignore_tables")
self._inspector = inspect(self._engine)
# including view support by adding the views as well as tables to the all
# tables list if view_support is True
self._all_tables = set(
self._inspector.get_table_names(schema=schema)
+ (self._inspector.get_view_names(schema=schema) if view_support else [])
)
self._include_tables = set(include_tables) if include_tables else set()
if self._include_tables:
missing_tables = self._include_tables - self._all_tables
if missing_tables:
raise ValueError(
f"include_tables {missing_tables} not found in database"
)
self._ignore_tables = set(ignore_tables) if ignore_tables else set()
if self._ignore_tables:
missing_tables = self._ignore_tables - self._all_tables
if missing_tables:
raise ValueError(
f"ignore_tables {missing_tables} not found in database"
)
usable_tables = self.get_usable_table_names()
self._usable_tables = set(usable_tables) if usable_tables else self._all_tables
if not isinstance(sample_rows_in_table_info, int):
raise TypeError("sample_rows_in_table_info must be an integer")
self._sample_rows_in_table_info = sample_rows_in_table_info
self._indexes_in_table_info = indexes_in_table_info
self._custom_table_info = custom_table_info
if self._custom_table_info:
if not isinstance(self._custom_table_info, dict):
raise TypeError(
"table_info must be a dictionary with table names as keys and the "
"desired table info as values"
)
# only keep the tables that are also present in the database
intersection = set(self._custom_table_info).intersection(self._all_tables)
self._custom_table_info = dict(
(table, self._custom_table_info[table])
for table in self._custom_table_info
if table in intersection
)
self._max_string_length = max_string_length
self._metadata = metadata or MetaData()
# including view support if view_support = true
self._metadata.reflect(
views=view_support,
bind=self._engine,
only=list(self._usable_tables),
schema=self._schema,
)
@classmethod
def from_uri(
cls, database_uri: str, engine_args: Optional[dict] = None, **kwargs: Any
) -> SQLDatabase:
"""Construct a SQLAlchemy engine from URI."""
_engine_args = engine_args or {}
return cls(create_engine(database_uri, **_engine_args), **kwargs)
@classmethod
def from_databricks(
cls,
catalog: str,
schema: str,
host: Optional[str] = None,
api_token: Optional[str] = None,
warehouse_id: Optional[str] = None,
cluster_id: Optional[str] = None,
engine_args: Optional[dict] = None,
**kwargs: Any,
) -> SQLDatabase:
"""
Class method to create an SQLDatabase instance from a Databricks connection.
This method requires the 'databricks-sql-connector' package. If not installed,
it can be added using `pip install databricks-sql-connector`.
Args:
catalog (str): The catalog name in the Databricks database.
schema (str): The schema name in the catalog.
host (Optional[str]): The Databricks workspace hostname, excluding
'https://' part. If not provided, it attempts to fetch from the
environment variable 'DATABRICKS_HOST'. If still unavailable and if
running in a Databricks notebook, it defaults to the current workspace
hostname. Defaults to None.
api_token (Optional[str]): The Databricks personal access token for
accessing the Databricks SQL warehouse or the cluster. If not provided,
it attempts to fetch from 'DATABRICKS_TOKEN'. If still unavailable
and running in a Databricks notebook, a temporary token for the current
user is generated. Defaults to None.
warehouse_id (Optional[str]): The warehouse ID in the Databricks SQL. If
provided, the method configures the connection to use this warehouse.
Cannot be used with 'cluster_id'. Defaults to None.
cluster_id (Optional[str]): The cluster ID in the Databricks Runtime. If
provided, the method configures the connection to use this cluster.
Cannot be used with 'warehouse_id'. If running in a Databricks notebook
and both 'warehouse_id' and 'cluster_id' are None, it uses the ID of the
cluster the notebook is attached to. Defaults to None.
engine_args (Optional[dict]): The arguments to be used when connecting
Databricks. Defaults to None.
**kwargs (Any): Additional keyword arguments for the `from_uri` method.
Returns:
SQLDatabase: An instance of SQLDatabase configured with the provided
Databricks connection details.
Raises:
ValueError: If 'databricks-sql-connector' is not found, or if both
'warehouse_id' and 'cluster_id' are provided, or if neither
'warehouse_id' nor 'cluster_id' are provided and it's not executing
inside a Databricks notebook.
"""
try:
from databricks import sql # noqa: F401
except ImportError:
raise ValueError(
"databricks-sql-connector package not found, please install with"
" `pip install databricks-sql-connector`"
)
context = None
try:
from dbruntime.databricks_repl_context import get_context
context = get_context()
except ImportError:
pass
default_host = context.browserHostName if context else None
if host is None:
host = get_from_env("host", "DATABRICKS_HOST", default_host)
default_api_token = context.apiToken if context else None
if api_token is None:
api_token = get_from_env("api_token", "DATABRICKS_TOKEN", default_api_token)
if warehouse_id is None and cluster_id is None:
if context:
cluster_id = context.clusterId
else:
raise ValueError(
"Need to provide either 'warehouse_id' or 'cluster_id'."
)
if warehouse_id and cluster_id:
raise ValueError("Can't have both 'warehouse_id' or 'cluster_id'.")
if warehouse_id:
http_path = f"/sql/1.0/warehouses/{warehouse_id}"
else:
http_path = f"/sql/protocolv1/o/0/{cluster_id}"
uri = (
f"databricks://token:{api_token}@{host}?"
f"http_path={http_path}&catalog={catalog}&schema={schema}"
)
return cls.from_uri(database_uri=uri, engine_args=engine_args, **kwargs)
@classmethod
def from_cnosdb(
cls,
url: str = "127.0.0.1:8902",
user: str = "root",
password: str = "",
tenant: str = "cnosdb",
database: str = "public",
) -> SQLDatabase:
"""
Class method to create an SQLDatabase instance from a CnosDB connection.
This method requires the 'cnos-connector' package. If not installed, it
can be added using `pip install cnos-connector`.
Args:
url (str): The HTTP connection host name and port number of the CnosDB
service, excluding "http://" or "https://", with a default value
of "127.0.0.1:8902".
user (str): The username used to connect to the CnosDB service, with a
default value of "root".
password (str): The password of the user connecting to the CnosDB service,
with a default value of "".
tenant (str): The name of the tenant used to connect to the CnosDB service,
with a default value of "cnosdb".
database (str): The name of the database in the CnosDB tenant.
Returns:
SQLDatabase: An instance of SQLDatabase configured with the provided
CnosDB connection details.
"""
try:
from cnosdb_connector import make_cnosdb_langchain_uri
uri = make_cnosdb_langchain_uri(url, user, password, tenant, database)
return cls.from_uri(database_uri=uri)
except ImportError:
raise ValueError(
"cnos-connector package not found, please install with"
" `pip install cnos-connector`"
)
@property
def dialect(self) -> str:
"""Return string representation of dialect to use."""
return self._engine.dialect.name
def get_usable_table_names(self) -> Iterable[str]:
"""Get names of tables available."""
if self._include_tables:
return sorted(self._include_tables)
return sorted(self._all_tables - self._ignore_tables)
def get_table_names(self) -> Iterable[str]:
"""Get names of tables available."""
warnings.warn(
"This method is deprecated - please use `get_usable_table_names`."
)
return self.get_usable_table_names()
@property
def table_info(self) -> str:
"""Information about all tables in the database."""
return self.get_table_info()
def get_table_info(self, table_names: Optional[List[str]] = None) -> str:
"""Get information about specified tables.
Follows best practices as specified in: Rajkumar et al, 2022
(https://arxiv.org/abs/2204.00498)
If `sample_rows_in_table_info`, the specified number of sample rows will be
appended to each table description. This can increase performance as
demonstrated in the paper.
"""
all_table_names = self.get_usable_table_names()
if table_names is not None:
missing_tables = set(table_names).difference(all_table_names)
if missing_tables:
raise ValueError(f"table_names {missing_tables} not found in database")
all_table_names = table_names
meta_tables = [
tbl
for tbl in self._metadata.sorted_tables
if tbl.name in set(all_table_names)
and not (self.dialect == "sqlite" and tbl.name.startswith("sqlite_"))
]
tables = []
for table in meta_tables:
if self._custom_table_info and table.name in self._custom_table_info:
tables.append(self._custom_table_info[table.name])
continue
# Ignore JSON datatyped columns
for k, v in table.columns.items():
if type(v.type) is NullType:
table._columns.remove(v)
# add create table command
create_table = str(CreateTable(table).compile(self._engine))
table_info = f"{create_table.rstrip()}"
has_extra_info = (
self._indexes_in_table_info or self._sample_rows_in_table_info
)
if has_extra_info:
table_info += "\n\n/*"
if self._indexes_in_table_info:
table_info += f"\n{self._get_table_indexes(table)}\n"
if self._sample_rows_in_table_info:
table_info += f"\n{self._get_sample_rows(table)}\n"
if has_extra_info:
table_info += "*/"
tables.append(table_info)
tables.sort()
final_str = "\n\n".join(tables)
return final_str
def _get_table_indexes(self, table: Table) -> str:
indexes = self._inspector.get_indexes(table.name)
indexes_formatted = "\n".join(map(_format_index, indexes))
return f"Table Indexes:\n{indexes_formatted}"
def _get_sample_rows(self, table: Table) -> str:
# build the select command
command = select(table).limit(self._sample_rows_in_table_info)
# save the columns in string format
columns_str = "\t".join([col.name for col in table.columns])
try:
# get the sample rows
with self._engine.connect() as connection:
sample_rows_result = connection.execute(command) # type: ignore
# shorten values in the sample rows
sample_rows = list(
map(lambda ls: [str(i)[:100] for i in ls], sample_rows_result)
)
# save the sample rows in string format
sample_rows_str = "\n".join(["\t".join(row) for row in sample_rows])
# in some dialects when there are no rows in the table a
# 'ProgrammingError' is returned
except ProgrammingError:
sample_rows_str = ""
return (
f"{self._sample_rows_in_table_info} rows from {table.name} table:\n"
f"{columns_str}\n"
f"{sample_rows_str}"
)
def _execute(
self,
command: str,
fetch: Union[Literal["all"], Literal["one"]] = "all",
) -> Sequence[Dict[str, Any]]:
"""
Executes SQL command through underlying engine.
If the statement returns no rows, an empty list is returned.
"""
with self._engine.begin() as connection:
if self._schema is not None:
if self.dialect == "snowflake":
connection.exec_driver_sql(
"ALTER SESSION SET search_path = %s", (self._schema,)
)
elif self.dialect == "bigquery":
connection.exec_driver_sql("SET @@dataset_id=?", (self._schema,))
elif self.dialect == "mssql":
pass
elif self.dialect == "trino":
connection.exec_driver_sql("USE ?", (self._schema,))
elif self.dialect == "duckdb":
# Unclear which parameterized argument syntax duckdb supports.
# The docs for the duckdb client say they support multiple,
# but `duckdb_engine` seemed to struggle with all of them:
# https://github.com/Mause/duckdb_engine/issues/796
connection.exec_driver_sql(f"SET search_path TO {self._schema}")
elif self.dialect == "oracle":
connection.exec_driver_sql(
f"ALTER SESSION SET CURRENT_SCHEMA = {self._schema}"
)
else: # postgresql and other compatible dialects
connection.exec_driver_sql("SET search_path TO %s", (self._schema,))
cursor = connection.execute(text(command))
if cursor.returns_rows:
if fetch == "all":
result = [x._asdict() for x in cursor.fetchall()]
elif fetch == "one":
first_result = cursor.fetchone()
result = [] if first_result is None else [first_result._asdict()]
else:
raise ValueError("Fetch parameter must be either 'one' or 'all'")
return result
return []
def run(
self,
command: str,
fetch: Union[Literal["all"], Literal["one"]] = "all",
) -> str:
"""Execute a SQL command and return a string representing the results.
If the statement returns rows, a string of the results is returned.
If the statement returns no rows, an empty string is returned.
"""
result = self._execute(command, fetch)
# Convert columns values to string to avoid issues with sqlalchemy
# truncating text
res = [
tuple(truncate_word(c, length=self._max_string_length) for c in r.values())
for r in result
]
if not res:
return ""
else:
return str(res)
def get_table_info_no_throw(self, table_names: Optional[List[str]] = None) -> str:
"""Get information about specified tables.
Follows best practices as specified in: Rajkumar et al, 2022
(https://arxiv.org/abs/2204.00498)
If `sample_rows_in_table_info`, the specified number of sample rows will be
appended to each table description. This can increase performance as
demonstrated in the paper.
"""
try:
return self.get_table_info(table_names)
except ValueError as e:
"""Format the error message"""
return f"Error: {e}"
def run_no_throw(
self,
command: str,
fetch: Union[Literal["all"], Literal["one"]] = "all",
) -> str:
"""Execute a SQL command and return a string representing the results.
If the statement returns rows, a string of the results is returned.
If the statement returns no rows, an empty string is returned.
If the statement throws an error, the error message is returned.
"""
try:
return self.run(command, fetch)
except SQLAlchemyError as e:
"""Format the error message"""
return f"Error: {e}"
+112
View File
@@ -0,0 +1,112 @@
from datetime import timedelta, datetime
from typing import Dict
from cachetools import TLRUCache, cached
from aios_kernel.ai_function import AIFunction
from aios_kernel.sql_database import SQLDatabase, get_from_env
def _my_ttu(_key, _value, now):
return now + timedelta(seconds=600)
database_cache = TLRUCache(ttu=_my_ttu, maxsize=10000, timer=datetime.now)
@cached(cache=database_cache)
def get_database(uri: str) -> SQLDatabase:
return SQLDatabase.from_uri(uri)
class GetTableInfosFunction(AIFunction):
def __init__(self):
super().__init__()
self.name = "get_table_infos"
self.description = "Get table informations in the database"
def get_name(self) -> str:
return self.name
def get_description(self) -> str:
return self.description
def get_parameters(self) -> Dict:
return {
"type": "object",
"properties": {
"database_url": {"type": "string", "description": "Database URL,Can be set to None"},
}
}
async def execute(self, **kwargs) -> str:
database_url: str = kwargs.get("database_url")
if (database_url is None
or database_url.strip() == ""
or database_url.strip().lower() == "none"
or database_url.strip().lower() == "null"):
database_url = get_from_env(key="database url", env_key="DATABASE_URL")
if database_url is None:
return "error: database_url is None"
database = get_database(database_url)
tables = database.get_usable_table_names()
table_infos = database.get_table_info(tables)
return table_infos
def is_local(self) -> bool:
return True
def is_in_zone(self) -> bool:
return True
def is_ready_only(self) -> bool:
return False
class ExecuteSqlFunction(AIFunction):
def __init__(self):
super().__init__()
self.name = "execute_sql"
self.description = """
Input to this function is a detailed and correct SQL query, output is a result from the database.
If the query is not correct, an error message will be returned.
If an error is returned, rewrite the query, check the query, and try again.
"""
def get_name(self) -> str:
return self.name
def get_description(self) -> str:
return self.description
def get_parameters(self) -> Dict:
return {
"type": "object",
"properties": {
"database_url": {"type": "string", "description": "Database URL,Can be set to None"},
"sql": {"type": "string", "description": "SQL to execute"}
}
}
async def execute(self, **kwargs) -> str:
database_url = kwargs.get("database_url")
if (database_url is None
or database_url.strip() == ""
or database_url.strip().lower() == "none"
or database_url.strip().lower() == "null"):
database_url = get_from_env(key="database url", env_key="DATABASE_URL")
if database_url is None:
return "error: database_url is None"
sql = kwargs.get("sql")
database = get_database(database_url)
return database.run_no_throw(sql)
def is_local(self) -> bool:
return True
def is_in_zone(self) -> bool:
return True
def is_ready_only(self) -> bool:
return False
@@ -1,9 +1,6 @@
import json
import logging
import requests
from typing import Optional, List
from pydantic import BaseModel
from aios import ComputeTask,Queue_ComputeNode, ComputeTaskResult, ComputeTaskResultCode, ComputeTaskState, ComputeTaskType,AIStorage,UserConfig
+24 -28
View File
@@ -28,6 +28,8 @@ sys.path.append(directory + '/../../')
import proxy
from aios import *
import local_compute_node_builder
from component.llama_node.local_llama_compute_node import LocalLlama_ComputeNode
sys.path.append(directory + '/../../component/')
@@ -402,41 +404,34 @@ class AIOS_Shell:
async def handle_node_commands(self, args):
show_text = FormattedText([("class:title", "sub command not support!\n"
"/node add llama $model_name $url\n"
"/node rm llama $model_name $url\n"
"/node add $model_name $url\n"
"/node create\n"
"/node rm $model_name $url\n"
"/node list\n")])
if len(args) < 1:
return show_text
sub_cmd = args[0]
if sub_cmd == "add":
if len(args) < 2:
if sub_cmd == "create":
await local_compute_node_builder.build(session, shell_style)
elif sub_cmd == "add":
if len(args) < 3:
return show_text
if args[1] == "llama":
if len(args) < 4:
return show_text
model_name = args[2]
url = args[3]
ComputeNodeConfig.get_instance().add_node("llama", url, model_name)
ComputeNodeConfig.get_instance().save()
node = LocalLlama_ComputeNode(url, model_name)
node.start()
ComputeKernel.get_instance().add_compute_node(node)
else:
return show_text
model_name = args[1]
url = args[2]
ComputeNodeConfig.get_instance().add_node("llama", url, model_name)
ComputeNodeConfig.get_instance().save()
node = LocalLlama_ComputeNode(url, model_name)
node.start()
ComputeKernel.get_instance().add_compute_node(node)
elif sub_cmd == "rm":
if len(args) < 2:
if len(args) < 3:
return show_text
if args[1] == "llama":
if len(args) < 4:
return show_text
model_name = args[2]
url = args[3]
ComputeNodeConfig.get_instance().remove_node("llama", url, model_name)
ComputeNodeConfig.get_instance().save()
else:
return show_text
model_name = args[1]
url = args[2]
ComputeNodeConfig.get_instance().remove_node("llama", url, model_name)
ComputeNodeConfig.get_instance().save()
elif sub_cmd == "list":
print_formatted_text(ComputeNodeConfig.get_instance().list())
@@ -785,8 +780,9 @@ async def main():
'/set_config $key',
'/enable $feature',
'/disable $feature',
'/node add llama $model_name $url',
'/node rm llama $model_name $url',
'/node add $model_name $url',
'/node create',
'/node rm $model_name $url',
'/node list',
'/show',
'/exit',
@@ -0,0 +1,38 @@
import os
from prompt_toolkit import HTML, PromptSession, print_formatted_text
from prompt_toolkit.styles import Style
from aios.storage.storage import AIStorage
from service.aios_shell.local_compute_node_builder.local_llama_node_builder import LocalLlamaNodeBuilder
from .local_compute_node_builder import BuilderState
async def build(prompt_session: PromptSession, shell_style: Style) -> str or None:
# model_type = await prompt_session.prompt_async(f"Please select the node server type (default: llama.cpp):", style = shell_style)
model_type = 'llama.cpp'
download_dir = AIStorage.get_instance().get_download_dir()
if not os.path.exists(download_dir):
os.mkdir(download_dir)
state = BuilderState(prompt_session, shell_style)
match model_type:
case 'llama.cpp':
builder = LocalLlamaNodeBuilder(state)
while True:
param = builder.next_parameter()
if param is None:
return None
if state.last_result_prompt or param.desc:
print_formatted_text(f"{state.last_result_prompt}{param.desc}", style = state.shell_style)
value = await state.prompt_session.prompt_async(f"{param.prompt}:", style = state.shell_style)
if value:
value = value.strip()
state.params[param.name] = value
url = await param.applier.apply(state, param.name, value)
if url is not None:
return url
@@ -0,0 +1,40 @@
from abc import abstractmethod
from prompt_toolkit import PromptSession
from prompt_toolkit.styles import Style
class BuilderState:
def __init__(self, prompt_session: PromptSession, shell_style: Style):
self.prompt_session = prompt_session
self.shell_style = shell_style
self.next_step = 0
self.last_result_prompt = ""
self.params = {}
# class ApplyResult:
# def __init__(self, next_step: any, url: str or None = None, result_prompt: str or None = None) -> None:
# self.next_step = next_step
# self.url = url
# self.result_prompt = result_prompt
class ParameterApplier:
@abstractmethod
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
pass
class BuildParameter:
def __init__(self, name: str, applier: ParameterApplier, prompt: str or None = None, desc: str or None = None, default_value: str or None = None):
self.name = name
self.prompt = prompt
self.desc = desc
self.default_value = default_value
self.applier = applier
class LocalComputeNodeBuilder:
def __init__(self, state: BuilderState) -> None:
self.state = state
@abstractmethod
def next_parameter(self) -> BuildParameter or None:
pass
@@ -0,0 +1,254 @@
import os
import random
import subprocess
import requests
from prompt_toolkit import print_formatted_text
from prompt_toolkit.shortcuts import ProgressBar
from prompt_toolkit.formatted_text import FormattedText
from aios.storage.storage import AIStorage
from aios import ComputeKernel
from component.llama_node.local_llama_compute_node import LocalLlama_ComputeNode
from service.aios_shell.compute_node_config import ComputeNodeConfig
from .local_compute_node_builder import BuildParameter, BuilderState, LocalComputeNodeBuilder, ParameterApplier
class BuildParameterModelPath:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
if value:
if os.path.exists(value):
state.next_step += 2
else:
print_formatted_text(FormattedText([("class:error", f"Model not exist at {value}")]), style = state.shell_style)
else:
state.next_step += 1
class BuildParameterModelUrl:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
if value is None:
value = "1"
url = value
recommend = _recommend_model_urls.get(value)
if recommend:
url = recommend["url"]
save_path = f"{AIStorage.get_instance().get_download_dir()}/{url.split('/').pop()}"
print_formatted_text(FormattedText([("class:prompt", f"Will save the model to {save_path}:\n")]), style = state.shell_style)
try:
# get file size
response = requests.head(url)
file_size = int(response.headers.get('content-length', 0))
# start download
response = requests.get(url, stream=True)
if response.status_code == 200:
with open(save_path, 'wb') as f, ProgressBar() as pb:
for data in pb(response.iter_content(1024), total = (file_size + 1023) // 1024):
f.write(data)
print_formatted_text(FormattedText([("class:prompt", f"Download model success, save at: {save_path}\n")]), style = state.shell_style)
state.params["model_path"] = save_path
state.next_step += 1
else:
print_formatted_text(FormattedText([("class:error", f"Download model failed, error: {response.status_code}\nYou can retry it or select another one.")]), style = state.shell_style)
except Exception as e:
print_formatted_text(FormattedText([("class:error", f"Download model failed: {e}\nYou can retry it or select another one.")]), style = state.shell_style)
class ParameterNodeNameApplier:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
value = value or os.path.basename(state.params["model_path"])
state.params["node_name"] = value
state.next_step += 1
class ParameterPortApplier:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
if value is None or value == "0":
value = str(random.randint(10000, 60000))
state.params["port"] = value
state.next_step += 1
class ParameterNGpuLayersApplier:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
value = value or "83"
state.params["n_gpu_layers"] = value
state.next_step += 1
class ParameterNCtxApplier:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
value = value or "4096"
state.params["n_ctx"] = value
state.next_step += 1
class ParameterChatFormatApplier:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
value = value or "llama-2"
state.params["chat_format"] = value
state.next_step += 1
class ParameterExternParamsApplier:
async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None:
extern_params = value
docker_image = ""
gpu_options = []
state.next_step += 1
if state.params["n_gpu_layers"] == "0":
docker_image = "ghcr.io/abetlen/llama-cpp-python:latest"
else:
gpu_options = ["--gpus", "all"]
llama_cpp_python_repo_url = "https://github.com/abetlen/llama-cpp-python.git"
download_path = AIStorage.get_instance().get_download_dir()
llama_cpp_python_path = download_path + "/llama-cpp-python"
# update the `llama-cpp-python`
retry = True
while retry:
retry = False
result = None
if os.path.exists(llama_cpp_python_path):
result = subprocess.run(['git', 'pull'], cwd = llama_cpp_python_path, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True)
else:
result = subprocess.run(['git', 'clone', llama_cpp_python_repo_url, llama_cpp_python_path], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True)
if result.stderr:
print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style)
while True:
sel = await state.prompt_session.prompt_async(f"Update 'llama-cpp-python' failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style)
if sel == 'r':
retry = True
break
elif sel == 'c':
break
else:
pass # Select again
else:
break
# build the image
docker_image = 'llama-cpp-python-cuda'
retry = True
while retry:
retry = False
result = subprocess.run(['docker', 'rmi', docker_image], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True)
result = subprocess.run(['docker', 'build', '-t', docker_image, f"{llama_cpp_python_path}/docker/cuda_simple/"], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True)
if result.stderr:
print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style)
while True:
sel = await state.prompt_session.prompt_async(f"Build the image failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style)
if sel == 'r':
retry = True
break
elif sel == 'c':
break
else:
pass # Select again
else:
break
retry = True
while retry:
retry = False
run_options = ['docker', 'run', '-d']
if gpu_options:
run_options.extend(gpu_options)
run_options.extend([
'-p', f"{state.params['port']}:8000",
'-v', f"{os.path.dirname(state.params['model_path'])}:/models", '-e', f"MODEL=/models/{os.path.basename(state.params['model_path'])}",
'llama-cpp-python-cuda',
'python3', '-m', 'llama_cpp.server',
'--n_gpu_layers', state.params["n_gpu_layers"],
'--n_ctx', state.params["n_ctx"],
'--chat_format', state.params["chat_format"],
])
if extern_params:
run_options.extend(extern_params.split(' '))
print_formatted_text(FormattedText([("class:prompt", f"Will start service with: {' '.join(run_options)}")]), style = state.shell_style)
result = subprocess.run(run_options, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True)
if result.stderr:
print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style)
while True:
sel = await state.prompt_session.prompt_async(f"Start the node service failed, you can press 'r' to retry, or 'a' to abort.", style = state.shell_style)
if sel == 'r':
retry = True
break
elif sel == 'a':
break
else:
pass # Select again
else:
local_url = f'http://localhost:{state.params["port"]}'
foreign_url = 'http://{your-host-address}:' + state.params["port"]
model_name = state.params['node_name']
ComputeNodeConfig.get_instance().add_node("llama", local_url, model_name)
ComputeNodeConfig.get_instance().save()
node = LocalLlama_ComputeNode(local_url, model_name)
node.start()
ComputeKernel.get_instance().add_compute_node(node)
print_formatted_text(FormattedText([(
"class:prompt",
f"""
Congratulations! The node ({model_name}) service successed.
You can access it with follow url:
{local_url}
And 'http://{foreign_url}' in other computers.
Now you can refer it in agents as `llm_model_name={model_name}`
"""
)]), style = state.shell_style)
break
_recommend_model_urls = {
"1": {
"model": "Llama-2-70B-Chat-GGUF",
"url": "https://huggingface.co/TheBloke/Llama-2-70B-chat-GGUF/resolve/main/llama-2-70b-chat.Q4_0.gguf"
},
"2": {
"model": "Llama-2-13B-Chat-GGUF",
"url": "https://huggingface.co/TheBloke/Llama-2-13B-chat-GGUF/resolve/main/llama-2-13b-chat.Q4_0.gguf"
},
"3": {
"model": "Llama-2-7B-Chat-GGUF",
"url": "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf"
},
}
_recommend_model_url_table_str = ""
for i in range(1, 999):
id = str(i)
info = _recommend_model_urls.get(id)
if info:
_recommend_model_url_table_str += f"\n\t{id}\t{info['model']}\t{info['url']}"
else:
break
_params = [
BuildParameter("model_path", BuildParameterModelPath(), "Please input the model file path (Press 'Enter' if you need to download it)"),
BuildParameter("model_url", BuildParameterModelUrl(), "Please input (default: Llama-2-70B-chat)", f"Now you need input the url to download the model, or you can input the 'ID' in the follow table to select one:\n\tID\tmodel\t\turl{_recommend_model_url_table_str}"),
BuildParameter("node_name", ParameterNodeNameApplier(), "Please input name for your node, and you can set it in 'llm_model_name' of 'agent.toml' (default: the name of the model file)"),
BuildParameter("port", ParameterPortApplier(), "Please input the port which the node server will listen on (default: random)"),
BuildParameter("n_gpu_layers", ParameterNGpuLayersApplier(), "Please input layers offload to GPU (<=83 for Llama, 0 for CPU only, default: 83)"),
BuildParameter("n_ctx", ParameterNCtxApplier(), "Please input the content limit (default: 4096)"),
BuildParameter("chat_format", ParameterChatFormatApplier(), "Please input the chat format (default: llama-2)"),
BuildParameter("extern_params", ParameterExternParamsApplier(), "Please input other parameters refer to 'llama-cpp-python'(https://github.com/abetlen/llama-cpp-python), press 'Enter' to ignore it"),
]
class LocalLlamaNodeBuilder(LocalComputeNodeBuilder):
def next_parameter(self) -> BuildParameter or None:
if self.state.next_step < len(_params):
return _params[self.state.next_step]