Coverage for src\funcall\__init__.py: 92%

73 statements  

« prev     ^ index     » next       coverage.py v7.8.1, created at 2025-05-22 01:19 +0900

1import dataclasses 

2import inspect 

3import json 

4from collections.abc import Callable 

5from logging import getLogger 

6from typing import get_type_hints 

7 

8from openai.types.responses import ( 

9 FunctionToolParam, 

10 ResponseFunctionToolCall, 

11) 

12 

13# 新增导入 

14from pydantic import BaseModel 

15from pydantic.fields import FieldInfo 

16 

17logger = getLogger("funcall") 

18 

19 

20def param_type(py_type: str | type | FieldInfo | None) -> str: 

21 """Map Python types to JSON Schema types""" 

22 type_map = { 

23 int: "number", 

24 float: "number", 

25 str: "string", 

26 bool: "boolean", 

27 list: "array", 

28 dict: "object", 

29 } 

30 origin = getattr(py_type, "__origin__", None) 

31 if origin is not None: 

32 origin_map = {list: "array", dict: "object"} 

33 if origin in origin_map: 

34 return origin_map[origin] 

35 if py_type in type_map: 

36 return type_map[py_type] 

37 if BaseModel and isinstance(py_type, type) and issubclass(py_type, BaseModel): 

38 return "object" 

39 if dataclasses.is_dataclass(py_type): 

40 return "object" 

41 if isinstance(py_type, FieldInfo): 

42 return param_type(py_type.annotation) 

43 return "string" 

44 

45 

46def generate_meta(func: Callable) -> FunctionToolParam: 

47 sig = inspect.signature(func) 

48 type_hints = get_type_hints(func) 

49 params = {} 

50 required = [] 

51 doc = func.__doc__.strip() if func.__doc__ else "" 

52 

53 for name in sig.parameters: 

54 hint = type_hints.get(name, str) 

55 if isinstance(hint, type) and issubclass(hint, BaseModel): 

56 model = hint 

57 for field_name, field in model.model_fields.items(): 

58 desc = field.description if field.description else None 

59 params[field_name] = { 

60 "type": param_type(field), 

61 "description": desc or f"{name}.{field_name}", 

62 } 

63 if field.is_required(): 

64 required.append(field_name) 

65 

66 elif dataclasses.is_dataclass(hint): 

67 # Python dataclass 

68 for field in dataclasses.fields(hint): 

69 desc = field.metadata.get("description") if "description" in field.metadata else None 

70 params[field.name] = { 

71 "type": param_type(field.type), 

72 "description": desc or f"{name}.{field.name}", 

73 } 

74 if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING: 

75 required.append(field.name) 

76 else: 

77 # Normal parameter 

78 param_desc = f"The {list(sig.parameters.keys()).index(name) + 1}th parameter" 

79 params[name] = {"type": param_type(hint), "description": param_desc} 

80 required.append(name) 

81 

82 meta: FunctionToolParam = { 

83 "type": "function", 

84 "name": func.__name__, 

85 "description": doc, 

86 "parameters": { 

87 "type": "object", 

88 "properties": params, 

89 "required": required, 

90 "additionalProperties": False, 

91 }, 

92 "strict": True, 

93 } 

94 return meta 

95 

96 

97class Funcall: 

98 def __init__(self, functions: list | None = None) -> None: 

99 if functions is None: 

100 functions = [] 

101 self.functions = functions 

102 self.function_map = {func.__name__: func for func in functions} 

103 

104 def get_tools(self) -> list[FunctionToolParam]: 

105 return [generate_meta(func) for func in self.functions] 

106 

107 def handle_function_call(self, item: ResponseFunctionToolCall): 

108 if item.name in self.function_map: 

109 func = self.function_map[item.name] 

110 args = item.arguments 

111 if BaseModel and issubclass( 

112 func.__annotations__.get("data", None), 

113 BaseModel, 

114 ): 

115 model = func.__annotations__["data"] 

116 data = model.model_validate_json(args) 

117 result = func(data) 

118 else: 

119 kwargs = json.loads(args) 

120 result = func(**kwargs) 

121 

122 return result 

123 msg = f"Function {item.name} not found" 

124 raise ValueError(msg)