Skip to content

Commit a63515a

Browse files
committed
First testable implementation of tooling.
1 parent a9fd638 commit a63515a

3 files changed

Lines changed: 683 additions & 92 deletions

File tree

llms_wrapper/llms.py

Lines changed: 178 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
import inspect
1313
import docstring_parser
1414
from loguru import logger
15-
from typing import Optional, Dict, List, Union, Tuple, Callable
15+
import typing
16+
from typing import Optional, Dict, List, Union, Tuple, Callable, get_args, get_origin
1617
from copy import deepcopy
1718

1819
from litellm import completion, completion_cost, token_counter
@@ -45,6 +46,18 @@
4546

4647

4748
def toolnames2funcs(tools):
49+
"""
50+
Convert a list of tool names to a dictionary of functions.
51+
52+
Args:
53+
tools: List of tools, each with a name.
54+
55+
Returns:
56+
Dictionary of function names to functions.
57+
58+
Raises:
59+
Exception: If a function is not found.
60+
"""
4861
fmap = {}
4962
for tool in tools:
5063
name = tool["function"]["name"]
@@ -54,15 +67,165 @@ def toolnames2funcs(tools):
5467
fmap[name] = func
5568
return fmap
5669

70+
5771
def get_func_by_name(name):
58-
# Walk up the call stack
72+
"""
73+
Get a function by name.
74+
75+
Args:
76+
name: Name of the function.
77+
78+
Returns:
79+
Function if found, None otherwise.
80+
81+
Raises:
82+
Exception: If a function is not found.
83+
"""
5984
for frame_info in inspect.stack():
6085
frame = frame_info.frame
6186
func = frame.f_locals.get(name) or frame.f_globals.get(name)
6287
if callable(func):
6388
return func
6489
return None # Not found
6590

91+
def ptype2schema(py_type):
92+
"""
93+
Convert a Python type to a JSON schema.
94+
95+
Args:
96+
py_type: Python type to convert.
97+
98+
Returns:
99+
JSON schema for the given Python type.
100+
101+
Raises:
102+
ValueError: If the type is not supported.
103+
"""
104+
# Handle bare None
105+
if py_type is type(None):
106+
return {"type": "null"}
107+
108+
origin = get_origin(py_type)
109+
args = get_args(py_type)
110+
111+
if origin is None:
112+
# Base types
113+
if py_type is str:
114+
return {"type": "string"}
115+
elif py_type is int:
116+
return {"type": "integer"}
117+
elif py_type is float:
118+
return {"type": "number"}
119+
elif py_type is bool:
120+
return {"type": "boolean"}
121+
elif py_type is type(None):
122+
return {"type": "null"}
123+
else:
124+
return {"type": "string"} # Fallback
125+
126+
elif origin is list or origin is typing.List:
127+
item_type = ptype2schema(args[0]) if args else {"type": "string"}
128+
return {"type": "array", "items": item_type}
129+
130+
elif origin is dict or origin is typing.Dict:
131+
key_type, val_type = args if args else (str, str)
132+
# JSON Schema requires string keys
133+
if key_type != str:
134+
raise ValueError("JSON object keys must be strings")
135+
return {"type": "object", "additionalProperties": ptype2schema(val_type)}
136+
137+
elif origin is typing.Union:
138+
# Flatten nested Union
139+
flat_args = []
140+
for arg in args:
141+
if get_origin(arg) is typing.Union:
142+
flat_args.extend(get_args(arg))
143+
else:
144+
flat_args.append(arg)
145+
146+
schemas = [ptype2schema(a) for a in flat_args]
147+
return {"anyOf": schemas}
148+
149+
elif origin is typing.Literal:
150+
return {"enum": list(args)}
151+
152+
else:
153+
return {"type": "string"} # fallback for unsupported/unknown
154+
155+
def function2schema(func, include_return_type=True):
156+
"""
157+
Convert a function to a JSON schema.
158+
159+
Args:
160+
func: Function to convert.
161+
include_return_type: Whether to include the return type in the schema.
162+
163+
Returns:
164+
JSON schema for the given function.
165+
166+
Raises:
167+
ValueError: If the function docstring is empty.
168+
"""
169+
doc = docstring_parser.parse(func.__doc__)
170+
desc = doc.short_description + "\n\n" + doc.long_description if doc.long_description else doc.short_description
171+
if not desc:
172+
raise ValueError("Function docstring is empty")
173+
argdescs = {arg.arg_name: arg.description for arg in doc.params}
174+
argtypes = {}
175+
for arg in doc.params:
176+
argtype = arg.type_name
177+
# if the argtype is not specified, skip, we will use the argument type
178+
if argtype is None:
179+
continue
180+
# if the argtype starts with a brace, we assume it is already specified as a JSON schema
181+
if argtype.startswith("{"):
182+
argtypes[arg.arg_name] = json.loads(argtype)
183+
else:
184+
# otherwise, we assume it is a python type
185+
argtypes[arg.arg_name] = ptype2schema(argtype)
186+
retdesc = doc.returns.description if doc.returns else ""
187+
if not retdesc:
188+
raise ValueError("Function return type is not specified in docstring")
189+
retschema = ptype2schema(func.__annotations__.get("return", None))
190+
desc = desc + "\n\n" + "The function returns: " + str(retdesc)
191+
if include_return_type:
192+
desc = desc + "\n\n" + "The return type is: " + str(retschema)
193+
sig = inspect.signature(func)
194+
parameters = sig.parameters
195+
196+
props = {}
197+
required = []
198+
199+
for name, param in parameters.items():
200+
if name == 'self':
201+
continue
202+
203+
if name in argtypes:
204+
schema = argtypes[name]
205+
else:
206+
# Use the type annotation if available, otherwise default to string
207+
ptype = param.annotation if param.annotation != inspect.Parameter.empty else str
208+
schema = ptype2schema(ptype)
209+
schema["description"] = argdescs.get(name, "")
210+
211+
if param.default != inspect.Parameter.empty:
212+
schema["default"] = param.default
213+
else:
214+
required.append(name)
215+
216+
props[name] = schema
217+
218+
return {
219+
"name": func.__name__,
220+
"description": desc,
221+
"parameters": {
222+
"type": "object",
223+
"properties": props,
224+
"required": required
225+
}
226+
}
227+
228+
66229
class LLMS:
67230
"""
68231
Class that represents a preconfigured set of large language modelservices.
@@ -345,38 +508,20 @@ def make_tooling(functions: Union[Callable, List[Callable]]) -> List[Dict]:
345508
be useful to the LLM. The same goes for the description of each of the arguments for
346509
the function.
347510
348-
With the ReST docustring format, use :param argname: description followed by
349-
a new line, then the type of the argument specified via :type argname: type.
350-
Specify the description of the return value via :returns: description followed
351-
by a new line, then the type of the return value specified via :rtype: type.
352-
The description should have one summary line, followed by a blank line then
353-
followed by the detailed description text.
354-
355-
IMPORTANT: the standard python type names are not supported, instead use the json
356-
schema types: string, number, integer, boolean, array, object, null. object can be
357-
used to specify a dictionary type.
358-
If you need to specify complex nested types, avoid using this method and instead
359-
create the tooling descriptions directly, using a nested json schema e.g.
360-
to specify a list of dictionaries with the key 'name' of type string and 'age' of type integer:
361-
```
362-
{
363-
"type": "array",
364-
"items": {
365-
"type": "object",
366-
"properties": {
367-
"name": {"type": "string"},
368-
"age": {"type": "integer"}
369-
},
370-
"required": ["name", "age"]
371-
}
372-
"description": "A list of people with their name and age"
373-
}
374-
```
511+
The type of all arguments and of the function return value should get specified using
512+
standard Python type annotations. These types will get converted to json schema types.
513+
514+
Each argument and the return value must be documented in the docstring.
515+
516+
If the type of a parameter is specified in the docstring, that type will get used
517+
instead of the type annotation specified in the function signature.
518+
If the type of a parameter is specified in the docstring as a json schema type
519+
starting and ending with a brace, that schema is directly used.
520+
375521
See https://platform.openai.com/docs/guides/function-calling
376522
377523
Args:
378-
functions: a function or list of functions. The function(s) documentation strings are parsed
379-
to create the tooling descriptions.
524+
functions: a function or list of functions.
380525
381526
Returns:
382527
A list of tool dictionaries, each dictionary describing a tool.
@@ -385,48 +530,10 @@ def make_tooling(functions: Union[Callable, List[Callable]]) -> List[Dict]:
385530
functions = [functions]
386531
tools = []
387532
for func in functions:
388-
if not callable(func):
389-
raise ValueError(f"Error: {func} is not callable")
390-
doc = docstring_parser.parse(func.__doc__)
391-
argspec = inspect.getfullargspec(func)
392-
nrequired = len(argspec.args) - len(argspec.defaults) if argspec.defaults else len(argspec.args)
393-
# for each parameter get the type as specified in the docstring, if not specified, get the
394-
# name of the type from the argspec annotation information, if not specified there, assume string
395-
argtypes = []
396-
for idx, aname in enumerate(argspec.args):
397-
if idx < len(doc.params):
398-
argtypes.append(doc.params[idx].type_name)
399-
# it seems proper python types are not supported?
400-
# elif argspec.annotations.get(aname):
401-
# argtypes.append(argspec.annotations[aname].__name__)
402-
else:
403-
argtypes.append("string")
404-
argdescs = []
405-
for idx, aname in enumerate(argspec.args):
406-
if idx < len(doc.params):
407-
argdescs.append(doc.params[idx].description)
408-
else:
409-
raise ValueError(f"Error: Missing description for parameter {aname} in doc of function {func.__name__}")
410-
desc = doc.short_description + "\n\n" + doc.long_description
411-
tools.append({
412-
"type": "function",
413-
"function": {
414-
"name": func.__name__,
415-
"description": desc,
416-
"parameters": {
417-
"type": "object",
418-
"properties": {
419-
doc.params[i].arg_name: {
420-
"type": argtypes[i],
421-
"description": argdescs[i],
422-
} for i in range(len(argspec.args))
423-
},
424-
"required": [param.arg_name for param in doc.params[:nrequired]],
425-
},
426-
},
427-
})
533+
tools.append(function2schema(func))
428534
return tools
429535

536+
430537
def supports_response_format(self, llmalias: str) -> bool:
431538
"""
432539
Check if the model supports the response format parameters. This usually just indicates support

llms_wrapper/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
import importlib.metadata
2-
__version__ = "0.1.32"
2+
__version__ = "0.1.33"
33

0 commit comments

Comments
 (0)