e908228d2d
# Description
Support generate tool meta yaml for custom package tool with custom
strong type connection.
Tool yaml is used to generate flow.tools.json for package tools. And
both portal and local uses the meta in flow.tools.json to display the
node interface. For portal, the node input type should be
CustomConnection. While for local extension, the node input type should
display as custom strong type connection. So we add a another field
"custom_type" for local extension to display (if no custom_type found,
read "type" field), and portal would still read the "type" field.
Test cases:
1. Tool uses custom strong type connection:
tool:
```python
from promptflow import ToolProvider, tool
from my_tool_package.connections import MyFirstConnection
class MyTool(ToolProvider):
"""
Doc reference :
"""
def __init__(self, connection: MyFirstConnection):
super().__init__()
self.connection = connection
@tool
def my_tool2(self, input_text: str) -> str:
# Replace with your tool code.
# Usually connection contains configs to connect to an API.
# Not all tools need a connection. You can remove it if you don't need
it.
return input_text + self.connection.api_base
```
yaml:
```
my_tool_package.tools.my_tool_2.MyTool.my_tool:
class_name: MyTool
function: my_tool
inputs:
connection:
custom_type:
- MyFirstConnection
type:
- CustomConnection
input_text:
type:
- string
module: my_tool_package.tools.my_tool_2
name: MyTool.my_tool
type: python
```
2. Tool has custom strong type connection union as input:
tool:
```python
from promptflow import tool
from my_tool_package.connections import MyFirstConnection,
MySecondConnection
from typing import Union
@tool
def my_tool(connection: Union[MyFirstConnection, MySecondConnection],
input_text: str) -> str:
# Replace with your tool code.
# Usually connection contains configs to connect to an API.
# Not all tools need a connection. You can remove it if you don't need
it.
return f"connection_value is MyFirstConnection:
{str(isinstance(connection, MyFirstConnection))}"
```
yaml:
```
my_tool_package.tools.my_tool_1.my_tool:
function: my_tool
inputs:
connection:
custom_type:
- MyFirstConnection
- MySecondConnection
type:
- CustomConnection
input_text:
type:
- string
module: my_tool_package.tools.my_tool_1
name: my_tool
type: python
```
3. Tool uses orignal builtin CustomConnection:
tool:
```python
from promptflow import tool
from promptflow.connections import CustomConnection
@tool
def my_tool3(connection: CustomConnection, input_text: str) -> str:
# Replace with your tool code.
# Usually connection contains configs to connect to an API.
# Not all tools need a connection. You can remove it if you don't need
it.
return f"{input_text} {connection.api_base}"
```
yaml:
```
my_tool_package.tools.my_tool_3.my_tool3:
function: my_tool3
inputs:
connection:
type:
- CustomConnection
input_text:
type:
- string
module: my_tool_package.tools.my_tool_3
name: my_tool3
type: python
```
---------
Co-authored-by: yalu4 <yalu4@microsoft.com>
124 lines
5.2 KiB
Python
124 lines
5.2 KiB
Python
import inspect
|
|
from enum import Enum, EnumMeta
|
|
from typing import Callable, Union, get_args, get_origin
|
|
from promptflow.contracts.tool import ConnectionType, InputDefinition, ValueType, ToolType
|
|
from promptflow.contracts.types import PromptTemplate
|
|
|
|
|
|
def value_to_str(val):
|
|
if val is inspect.Parameter.empty:
|
|
# For empty case, default field will be skipped when dumping to json
|
|
return None
|
|
if val is None:
|
|
# Dump default: "" in json to avoid UI validation error
|
|
return ""
|
|
if isinstance(val, Enum):
|
|
return val.value
|
|
return str(val)
|
|
|
|
|
|
def resolve_annotation(anno) -> Union[str, list]:
|
|
"""Resolve the union annotation to type list."""
|
|
origin = get_origin(anno)
|
|
if origin != Union:
|
|
return anno
|
|
# Optional[Type] is Union[Type, NoneType], filter NoneType out
|
|
args = [arg for arg in get_args(anno) if arg != type(None)] # noqa: E721
|
|
return args[0] if len(args) == 1 else args
|
|
|
|
|
|
def param_to_definition(param, value_type) -> (InputDefinition, bool):
|
|
default_value = param.default
|
|
enum = None
|
|
custom_type = None
|
|
# Get value type and enum from default if no annotation
|
|
if default_value is not inspect.Parameter.empty and value_type == inspect.Parameter.empty:
|
|
value_type = default_value.__class__ if isinstance(default_value, Enum) else type(default_value)
|
|
# Extract enum for enum class
|
|
if isinstance(value_type, EnumMeta):
|
|
enum = [str(option.value) for option in value_type]
|
|
value_type = str
|
|
is_connection = False
|
|
if ConnectionType.is_connection_value(value_type):
|
|
if ConnectionType.is_custom_strong_type(value_type):
|
|
typ = ["CustomConnection"]
|
|
custom_type = [value_type.__name__]
|
|
else:
|
|
typ = [value_type.__name__]
|
|
is_connection = True
|
|
elif isinstance(value_type, list):
|
|
if not all(ConnectionType.is_connection_value(t) for t in value_type):
|
|
typ = [ValueType.OBJECT]
|
|
else:
|
|
custom_connection_added = False
|
|
typ = []
|
|
custom_type = []
|
|
for t in value_type:
|
|
if ConnectionType.is_custom_strong_type(t):
|
|
if not custom_connection_added:
|
|
custom_connection_added = True
|
|
typ.append("CustomConnection")
|
|
custom_type.append(t.__name__)
|
|
else:
|
|
typ.append(t.__name__)
|
|
is_connection = True
|
|
else:
|
|
typ = [ValueType.from_type(value_type)]
|
|
return InputDefinition(type=typ, default=value_to_str(default_value),
|
|
description=None, enum=enum, custom_type=custom_type), is_connection
|
|
|
|
|
|
def function_to_interface(f: Callable, tool_type, initialize_inputs=None) -> tuple:
|
|
sign = inspect.signature(f)
|
|
all_inputs = {}
|
|
input_defs = {}
|
|
connection_types = []
|
|
# Initialize the counter for prompt template
|
|
prompt_template_count = 0
|
|
# Collect all inputs from class and func
|
|
if initialize_inputs:
|
|
if any(k for k in initialize_inputs if k in sign.parameters):
|
|
raise Exception(f'Duplicate inputs found from {f.__name__!r} and "__init__()"!')
|
|
all_inputs = {**initialize_inputs}
|
|
all_inputs.update(
|
|
{
|
|
k: v
|
|
for k, v in sign.parameters.items()
|
|
if k != "self" and v.kind != v.VAR_KEYWORD and v.kind != v.VAR_POSITIONAL # TODO: Handle these cases
|
|
}
|
|
)
|
|
# Resolve inputs to definitions.
|
|
for k, v in all_inputs.items():
|
|
# Get value type from annotation
|
|
value_type = resolve_annotation(v.annotation)
|
|
if value_type is PromptTemplate:
|
|
# custom llm tool has prompt template as input, skip it
|
|
prompt_template_count += 1
|
|
continue
|
|
input_def, is_connection = param_to_definition(v, value_type)
|
|
input_defs[k] = input_def
|
|
if is_connection:
|
|
connection_types.append(input_def.type)
|
|
|
|
# Check PromptTemplate input:
|
|
# a. For custom llm tool, there should be exactly one PromptTemplate input
|
|
# b. For python tool, PromptTemplate input is not supported
|
|
if tool_type == ToolType.PYTHON and prompt_template_count > 0:
|
|
raise Exception(f"Input of type 'PromptTemplate' not supported in python tool '{f.__name__}'. ")
|
|
|
|
if tool_type == ToolType.CUSTOM_LLM and prompt_template_count == 0:
|
|
raise Exception(f"No input of type 'PromptTemplate' was found in custom llm tool '{f.__name__}'. ")
|
|
|
|
if tool_type == ToolType.CUSTOM_LLM and prompt_template_count > 1:
|
|
raise Exception(f"Multiple inputs of type 'PromptTemplate' were found in '{f.__name__}'. "
|
|
"Only one input of this type is expected.")
|
|
|
|
outputs = {}
|
|
# Note: We don't have output definition now
|
|
# outputs = {"output": OutputDefinition("output", [ValueType.from_type(type(sign.return_annotation))], "", True)}
|
|
# if is_dataclass(sign.return_annotation):
|
|
# for f in fields(sign.return_annotation):
|
|
# outputs[f.name] = OutputDefinition(f.name, [ValueType.from_type(
|
|
# type(getattr(sign.return_annotation, f.name)))], "", False)
|
|
return input_defs, outputs, connection_types
|