arcade-core 2.3.0__tar.gz → 2.5.0rc1__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.
Files changed (24) hide show
  1. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/PKG-INFO +1 -4
  2. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/catalog.py +97 -38
  3. arcade_core-2.5.0rc1/arcade_core/context.py +128 -0
  4. arcade_core-2.5.0rc1/arcade_core/converters/openai.py +220 -0
  5. arcade_core-2.5.0rc1/arcade_core/discovery.py +253 -0
  6. arcade_core-2.5.0rc1/arcade_core/errors.py +378 -0
  7. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/executor.py +10 -17
  8. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/output.py +45 -9
  9. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/parse.py +12 -0
  10. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/schema.py +82 -20
  11. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/toolkit.py +74 -3
  12. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/utils.py +4 -1
  13. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/pyproject.toml +1 -4
  14. arcade_core-2.3.0/arcade_core/errors.py +0 -103
  15. arcade_core-2.3.0/arcade_core/telemetry.py +0 -130
  16. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/.gitignore +0 -0
  17. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/README.md +0 -0
  18. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/__init__.py +0 -0
  19. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/annotations.py +0 -0
  20. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/auth.py +0 -0
  21. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/config.py +0 -0
  22. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/config_model.py +0 -0
  23. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/py.typed +0 -0
  24. {arcade_core-2.3.0 → arcade_core-2.5.0rc1}/arcade_core/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade-core
3
- Version: 2.3.0
3
+ Version: 2.5.0rc1
4
4
  Summary: Arcade Core - Core library for Arcade platform
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License: MIT
@@ -14,9 +14,6 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
16
  Requires-Dist: loguru>=0.7.0
17
- Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.28.2
18
- Requires-Dist: opentelemetry-exporter-otlp-proto-http==1.28.2
19
- Requires-Dist: opentelemetry-instrumentation-fastapi==0.49b2
20
17
  Requires-Dist: packaging>=24.1
21
18
  Requires-Dist: pydantic>=2.7.0
22
19
  Requires-Dist: pyjwt>=2.8.0
@@ -27,7 +27,12 @@ from pydantic_core import PydanticUndefined
27
27
 
28
28
  from arcade_core.annotations import Inferrable
29
29
  from arcade_core.auth import OAuth2, ToolAuthorization
30
- from arcade_core.errors import ToolDefinitionError
30
+ from arcade_core.errors import (
31
+ ToolDefinitionError,
32
+ ToolInputSchemaError,
33
+ ToolkitLoadError,
34
+ ToolOutputSchemaError,
35
+ )
31
36
  from arcade_core.schema import (
32
37
  TOOL_NAME_SEPARATOR,
33
38
  FullyQualifiedName,
@@ -224,7 +229,9 @@ class ToolCatalog(BaseModel):
224
229
  fully_qualified_name = definition.get_fully_qualified_name()
225
230
 
226
231
  if fully_qualified_name in self._tools:
227
- raise KeyError(f"Tool '{definition.name}' already exists in the catalog.")
232
+ raise ToolkitLoadError(
233
+ f"Tool '{definition.name}' in toolkit '{toolkit_name}' already exists in the catalog."
234
+ )
228
235
 
229
236
  if str(fully_qualified_name).lower() in self._disabled_tools:
230
237
  logger.info(f"Tool '{fully_qualified_name!s}' is disabled and will not be cataloged.")
@@ -270,20 +277,26 @@ class ToolCatalog(BaseModel):
270
277
  tool_func = getattr(module, tool_name)
271
278
  self.add_tool(tool_func, toolkit, module)
272
279
 
280
+ except ToolDefinitionError as e:
281
+ raise e.with_context(tool_name) from e
282
+ except ToolkitLoadError as e:
283
+ raise e.with_context(toolkit.name) from e
284
+ except ImportError as e:
285
+ raise ToolkitLoadError(
286
+ f"Could not import module {module_name}. Reason: {e}"
287
+ ).with_context(tool_name)
273
288
  except AttributeError as e:
274
289
  raise ToolDefinitionError(
275
290
  f"Could not import tool {tool_name} in module {module_name}. Reason: {e}"
276
- )
277
- except ImportError as e:
278
- raise ToolDefinitionError(f"Could not import module {module_name}. Reason: {e}")
291
+ ).with_context(tool_name)
279
292
  except TypeError as e:
280
293
  raise ToolDefinitionError(
281
294
  f"Type error encountered while adding tool {tool_name} from {module_name}. Reason: {e}"
282
- )
295
+ ).with_context(tool_name)
283
296
  except Exception as e:
284
297
  raise ToolDefinitionError(
285
298
  f"Error encountered while adding tool {tool_name} from {module_name}. Reason: {e}"
286
- )
299
+ ).with_context(tool_name)
287
300
 
288
301
  def __getitem__(self, name: FullyQualifiedName) -> MaterializedTool:
289
302
  return self.get_tool(name)
@@ -392,11 +405,13 @@ class ToolCatalog(BaseModel):
392
405
  # Hard requirement: tools must have descriptions
393
406
  tool_description = getattr(tool, "__tool_description__", None)
394
407
  if not tool_description:
395
- raise ToolDefinitionError(f"Tool {raw_tool_name} is missing a description")
408
+ raise ToolDefinitionError(
409
+ f"Tool '{raw_tool_name}' is missing a description. Tool descriptions are specified as docstrings for the tool function."
410
+ )
396
411
 
397
412
  # If the function returns a value, it must have a type annotation
398
413
  if does_function_return_value(tool) and tool.__annotations__.get("return") is None:
399
- raise ToolDefinitionError(f"Tool {raw_tool_name} must have a return type annotation")
414
+ raise ToolOutputSchemaError(f"Tool '{raw_tool_name}' must have a return type")
400
415
 
401
416
  auth_requirement = create_auth_requirement(tool)
402
417
  secrets_requirement = create_secrets_requirement(tool)
@@ -436,9 +451,11 @@ def create_input_definition(func: Callable) -> ToolInput:
436
451
  tool_context_param_name: str | None = None
437
452
 
438
453
  for _, param in inspect.signature(func, follow_wrapped=True).parameters.items():
439
- if param.annotation is ToolContext:
454
+ ann = param.annotation
455
+ if isinstance(ann, type) and issubclass(ann, ToolContext):
456
+ # Soft guidance for developers using legacy ToolContext
440
457
  if tool_context_param_name is not None:
441
- raise ToolDefinitionError(
458
+ raise ToolInputSchemaError(
442
459
  f"Only one ToolContext parameter is supported, but tool {func.__name__} has multiple."
443
460
  )
444
461
 
@@ -483,7 +500,11 @@ def create_output_definition(func: Callable) -> ToolOutput:
483
500
  )
484
501
 
485
502
  if hasattr(return_type, "__metadata__"):
486
- description = return_type.__metadata__[0] if return_type.__metadata__ else None # type: ignore[assignment]
503
+ description = (
504
+ return_type.__metadata__[0]
505
+ if return_type.__metadata__
506
+ else "No description provided for return type."
507
+ )
487
508
  return_type = return_type.__origin__
488
509
 
489
510
  # Unwrap Optional types
@@ -631,7 +652,7 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
631
652
  """
632
653
  annotation = param.annotation
633
654
  if annotation == inspect.Parameter.empty:
634
- raise ToolDefinitionError(f"Parameter {param} has no type annotation.")
655
+ raise ToolInputSchemaError(f"Parameter {param} has no type annotation.")
635
656
 
636
657
  # Get the majority of the param info from either the Pydantic Field() or regular inspection
637
658
  if isinstance(param.default, FieldInfo):
@@ -650,7 +671,7 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
650
671
  elif len(str_annotations) == 2:
651
672
  new_name = str_annotations[0]
652
673
  if not new_name.isidentifier():
653
- raise ToolDefinitionError(
674
+ raise ToolInputSchemaError(
654
675
  f"Invalid parameter name: '{new_name}' is not a valid identifier. "
655
676
  "Identifiers must start with a letter or underscore, "
656
677
  "and can only contain letters, digits, or underscores."
@@ -658,7 +679,7 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
658
679
  param_info.name = new_name
659
680
  param_info.description = str_annotations[1]
660
681
  else:
661
- raise ToolDefinitionError(
682
+ raise ToolInputSchemaError(
662
683
  f"Parameter {param} has too many string annotations. Expected 0, 1, or 2, got {len(str_annotations)}."
663
684
  )
664
685
 
@@ -673,10 +694,12 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
673
694
 
674
695
  # Final reality check
675
696
  if param_info.description is None:
676
- raise ToolDefinitionError(f"Parameter {param_info.name} is missing a description")
697
+ raise ToolInputSchemaError(
698
+ f"Parameter '{param_info.name}' is missing a description. Parameter descriptions are specified as string annotations using the typing.Annotated class."
699
+ )
677
700
 
678
701
  if wire_type_info.wire_type is None:
679
- raise ToolDefinitionError(f"Unknown parameter type: {param_info.field_type}")
702
+ raise ToolInputSchemaError(f"Unknown parameter type: {param_info.field_type}")
680
703
 
681
704
  return ToolParamInfo.from_param_info(param_info, wire_type_info, is_inferrable)
682
705
 
@@ -792,6 +815,7 @@ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None:
792
815
  field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
793
816
 
794
817
  # Get wire type info recursively
818
+ # field_type cannot be None here due to the check above
795
819
  wire_info = get_wire_type_info(field_type)
796
820
  properties[field_name] = wire_info
797
821
 
@@ -870,7 +894,7 @@ def extract_python_param_info(param: inspect.Parameter) -> ParamInfo:
870
894
  # Union types are not currently supported
871
895
  # (other than optional, which is handled above)
872
896
  if is_union(field_type):
873
- raise ToolDefinitionError(
897
+ raise ToolInputSchemaError(
874
898
  f"Parameter {param.name} is a union type. Only optional types are supported."
875
899
  )
876
900
 
@@ -890,7 +914,7 @@ def extract_pydantic_param_info(param: inspect.Parameter) -> ParamInfo:
890
914
  if callable(param.default.default_factory):
891
915
  default_value = param.default.default_factory()
892
916
  else:
893
- raise ToolDefinitionError(f"Default factory for parameter {param} is not callable.")
917
+ raise ToolInputSchemaError(f"Default factory for parameter {param} is not callable.")
894
918
 
895
919
  # If the param is Annotated[], unwrap the annotation to get the "real" type
896
920
  # Otherwise, use the literal type
@@ -965,15 +989,18 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
965
989
  if asyncio.iscoroutinefunction(func) and hasattr(func, "__wrapped__"):
966
990
  func = func.__wrapped__
967
991
  for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
968
- # Skip ToolContext parameters
969
- if param.annotation is ToolContext:
992
+ # Skip ToolContext parameters (including subclasses like arcade_mcp_server.Context)
993
+ ann = param.annotation
994
+ if isinstance(ann, type) and issubclass(ann, ToolContext):
970
995
  continue
971
996
 
972
997
  # TODO make this cleaner
973
998
  tool_field_info = extract_field_info(param)
974
999
  param_fields = {
975
1000
  "default": tool_field_info.default,
976
- "description": tool_field_info.description,
1001
+ "description": tool_field_info.description
1002
+ if tool_field_info.description
1003
+ else "No description provided.",
977
1004
  # TODO more here?
978
1005
  }
979
1006
  input_fields[name] = (tool_field_info.field_type, Field(**param_fields))
@@ -981,18 +1008,23 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
981
1008
  input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields) # type: ignore[call-overload]
982
1009
 
983
1010
  output_model = determine_output_model(func)
984
-
985
1011
  return input_model, output_model
986
1012
 
987
1013
 
988
- def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1014
+ def determine_output_model(func: Callable) -> type[BaseModel]:
989
1015
  """
990
1016
  Determine the output model for a function based on its return annotation.
991
1017
  """
992
1018
  return_annotation = inspect.signature(func).return_annotation
993
1019
  output_model_name = f"{snake_to_pascal_case(func.__name__)}Output"
1020
+
1021
+ # If the return annotation is empty, create a model with no fields
994
1022
  if return_annotation is inspect.Signature.empty:
995
1023
  return create_model(output_model_name)
1024
+
1025
+ # If the return annotation has an __origin__ attribute
1026
+ # and does not have a __metadata__ attribute.
1027
+ # This is the case for TypedDicts.
996
1028
  elif hasattr(return_annotation, "__origin__"):
997
1029
  if hasattr(return_annotation, "__metadata__"):
998
1030
  field_type = return_annotation.__args__[0]
@@ -1008,15 +1040,30 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1008
1040
  )
1009
1041
  return create_model(
1010
1042
  output_model_name,
1011
- result=(typeddict_model, Field(description=str(description))),
1043
+ result=(
1044
+ typeddict_model,
1045
+ Field(
1046
+ description=str(description)
1047
+ if description
1048
+ else "No description provided."
1049
+ ),
1050
+ ),
1012
1051
  )
1013
1052
 
1053
+ # If the return annotation has a description, use it
1014
1054
  if description:
1015
- return create_model(
1016
- output_model_name,
1017
- result=(field_type, Field(description=str(description))),
1018
- )
1019
- # Handle Union types
1055
+ try:
1056
+ return create_model(
1057
+ output_model_name,
1058
+ result=(field_type, Field(description=str(description))),
1059
+ )
1060
+ except Exception:
1061
+ raise ToolOutputSchemaError(
1062
+ f"Unsupported output type '{field_type}'. Only built-in Python types, TypedDicts, "
1063
+ "Pydantic models, and standard collections are supported as tool output types."
1064
+ )
1065
+
1066
+ # If the return annotation is a Union type
1020
1067
  origin = return_annotation.__origin__
1021
1068
  if origin is typing.Union:
1022
1069
  # For union types, create a model with the first non-None argument
@@ -1037,10 +1084,15 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1037
1084
  )
1038
1085
  return create_model(
1039
1086
  output_model_name,
1040
- result=(arg, Field(description="No description provided.")),
1087
+ result=(
1088
+ arg,
1089
+ Field(description="No description provided."),
1090
+ ),
1041
1091
  )
1042
- # when the return_annotation has an __origin__ attribute
1092
+
1093
+ # If the return annotation has an __origin__ attribute
1043
1094
  # and does not have a __metadata__ attribute.
1095
+ # This is the case for TypedDicts.
1044
1096
  return create_model(
1045
1097
  output_model_name,
1046
1098
  result=(
@@ -1049,7 +1101,7 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1049
1101
  ),
1050
1102
  )
1051
1103
  else:
1052
- # Check if return type is TypedDict
1104
+ # If the return annotation is a TypedDict
1053
1105
  if is_typeddict(return_annotation):
1054
1106
  typeddict_model = create_model_from_typeddict(return_annotation, output_model_name)
1055
1107
  return create_model(
@@ -1060,10 +1112,13 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1060
1112
  ),
1061
1113
  )
1062
1114
 
1063
- # Handle simple return types (like str)
1115
+ # If the return annotation is a simple type (like str)
1064
1116
  return create_model(
1065
1117
  output_model_name,
1066
- result=(return_annotation, Field(description="No description provided.")),
1118
+ result=(
1119
+ return_annotation,
1120
+ Field(description="No description provided."),
1121
+ ),
1067
1122
  )
1068
1123
 
1069
1124
 
@@ -1101,9 +1156,13 @@ def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[
1101
1156
  def to_tool_secret_requirements(
1102
1157
  secrets_requirement: list[str],
1103
1158
  ) -> list[ToolSecretRequirement]:
1104
- # Iterate through the list, de-dupe case-insensitively, and convert each string to a ToolSecretRequirement
1105
- unique_secrets = {name.lower(): name.lower() for name in secrets_requirement}.values()
1106
- return [ToolSecretRequirement(key=name) for name in unique_secrets]
1159
+ # De-dupe case-insensitively but preserve the original casing for env var lookup
1160
+ unique_map: dict[str, str] = {}
1161
+ for name in secrets_requirement:
1162
+ lowered = str(name).lower()
1163
+ if lowered not in unique_map:
1164
+ unique_map[lowered] = str(name)
1165
+ return [ToolSecretRequirement(key=orig_name) for orig_name in unique_map.values()]
1107
1166
 
1108
1167
 
1109
1168
  def to_tool_metadata_requirements(
@@ -0,0 +1,128 @@
1
+ """
2
+ Arcade Core Runtime Context Protocols
3
+
4
+ Defines the developer-facing, transport-agnostic runtime context interfaces
5
+ (namespaced APIs: logs, progress, resources, tools, prompts, sampling, UI,
6
+ notifications) and the top-level ModelContext Protocol that aggregates them.
7
+
8
+ Implementations live in runtime packages (e.g., arcade_mcp_server); tool authors should
9
+ use `arcade_mcp_server.Context` for concrete usage.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from pydantic import BaseModel
17
+
18
+
19
+ class LogsContext(Protocol):
20
+ async def debug(self, message: str, **kwargs: dict[str, Any]) -> None: ...
21
+
22
+ async def info(self, message: str, **kwargs: dict[str, Any]) -> None: ...
23
+
24
+ async def warning(self, message: str, **kwargs: dict[str, Any]) -> None: ...
25
+
26
+ async def error(self, message: str, **kwargs: dict[str, Any]) -> None: ...
27
+
28
+
29
+ class ProgressContext(Protocol):
30
+ async def report(
31
+ self, progress: float, total: float | None = None, message: str | None = None
32
+ ) -> None: ...
33
+
34
+
35
+ class ResourcesContext(Protocol):
36
+ async def list_(self) -> list[Any]: ...
37
+
38
+ async def get(self, uri: str) -> Any: ...
39
+
40
+ async def read(self, uri: str) -> list[Any]: ...
41
+
42
+ async def list_roots(self) -> list[Any]: ...
43
+
44
+ async def list_templates(self) -> list[Any]: ...
45
+
46
+
47
+ class ToolsContext(Protocol):
48
+ async def list_(self) -> list[Any]: ...
49
+
50
+ async def call_raw(self, name: str, params: dict[str, Any]) -> BaseModel: ...
51
+
52
+
53
+ class PromptsContext(Protocol):
54
+ async def list_(self) -> list[Any]: ...
55
+
56
+ async def get(self, name: str, arguments: dict[str, str] | None = None) -> Any: ...
57
+
58
+
59
+ class SamplingContext(Protocol):
60
+ async def create_message(
61
+ self,
62
+ messages: str | list[str | Any],
63
+ system_prompt: str | None = None,
64
+ include_context: str | None = None,
65
+ temperature: float | None = None,
66
+ max_tokens: int | None = None,
67
+ model_preferences: Any | None = None,
68
+ ) -> Any: ...
69
+
70
+
71
+ class UIContext(Protocol):
72
+ async def elicit(self, message: str, schema: dict[str, Any] | None = None) -> Any: ...
73
+
74
+
75
+ class NotificationsToolsContext(Protocol):
76
+ async def list_changed(self) -> None: ...
77
+
78
+
79
+ class NotificationsResourcesContext(Protocol):
80
+ async def list_changed(self) -> None: ...
81
+
82
+
83
+ class NotificationsPromptsContext(Protocol):
84
+ async def list_changed(self) -> None: ...
85
+
86
+
87
+ class NotificationsContext(Protocol):
88
+ @property
89
+ def tools(self) -> NotificationsToolsContext: ...
90
+
91
+ @property
92
+ def resources(self) -> NotificationsResourcesContext: ...
93
+
94
+ @property
95
+ def prompts(self) -> NotificationsPromptsContext: ...
96
+
97
+
98
+ @runtime_checkable
99
+ class ModelContext(Protocol):
100
+ @property
101
+ def log(self) -> LogsContext: ...
102
+
103
+ @property
104
+ def progress(self) -> ProgressContext: ...
105
+
106
+ @property
107
+ def resources(self) -> ResourcesContext: ...
108
+
109
+ @property
110
+ def tools(self) -> ToolsContext: ...
111
+
112
+ @property
113
+ def prompts(self) -> PromptsContext: ...
114
+
115
+ @property
116
+ def sampling(self) -> SamplingContext: ...
117
+
118
+ @property
119
+ def ui(self) -> UIContext: ...
120
+
121
+ @property
122
+ def notifications(self) -> NotificationsContext: ...
123
+
124
+ @property
125
+ def request_id(self) -> str | None: ...
126
+
127
+ @property
128
+ def session_id(self) -> str | None: ...
@@ -0,0 +1,220 @@
1
+ """Converter for converting Arcade ToolDefinition to OpenAI tool schema."""
2
+
3
+ from typing import Any, Literal, TypedDict
4
+
5
+ from arcade_core.catalog import MaterializedTool
6
+ from arcade_core.schema import InputParameter, ValueSchema
7
+
8
+ # ----------------------------------------------------------------------------
9
+ # Type definitions for JSON tool schemas used by OpenAI APIs.
10
+ # Defines the proper types for tool schemas to ensure
11
+ # compatibility with OpenAI's Responses and Chat Completions APIs.
12
+ # ----------------------------------------------------------------------------
13
+
14
+
15
+ class OpenAIFunctionParameterProperty(TypedDict, total=False):
16
+ """Type definition for a property within OpenAI function parameters schema."""
17
+
18
+ type: str | list[str]
19
+ """The JSON Schema type(s) for this property. Can be a single type or list for unions (e.g., ["string", "null"])."""
20
+
21
+ description: str
22
+ """Description of the property."""
23
+
24
+ enum: list[Any]
25
+ """Allowed values for enum properties."""
26
+
27
+ items: dict[str, Any]
28
+ """Schema for array items when type is 'array'."""
29
+
30
+ properties: dict[str, "OpenAIFunctionParameterProperty"]
31
+ """Nested properties when type is 'object'."""
32
+
33
+ required: list[str]
34
+ """Required fields for nested objects."""
35
+
36
+ additionalProperties: Literal[False]
37
+ """Must be False for strict mode compliance."""
38
+
39
+
40
+ class OpenAIFunctionParameters(TypedDict, total=False):
41
+ """Type definition for OpenAI function parameters schema."""
42
+
43
+ type: Literal["object"]
44
+ """Must be 'object' for function parameters."""
45
+
46
+ properties: dict[str, OpenAIFunctionParameterProperty]
47
+ """The properties of the function parameters."""
48
+
49
+ required: list[str]
50
+ """List of required parameter names. In strict mode, all properties should be listed here."""
51
+
52
+ additionalProperties: Literal[False]
53
+ """Must be False for strict mode compliance."""
54
+
55
+
56
+ class OpenAIFunctionSchema(TypedDict, total=False):
57
+ """Type definition for a function tool parameter matching OpenAI's API."""
58
+
59
+ name: str
60
+ """The name of the function to call."""
61
+
62
+ parameters: OpenAIFunctionParameters | None
63
+ """A JSON schema object describing the parameters of the function."""
64
+
65
+ strict: Literal[True]
66
+ """Always enforce strict parameter validation. Default `true`."""
67
+
68
+ description: str | None
69
+ """A description of the function.
70
+ Used by the model to determine whether or not to call the function.
71
+ """
72
+
73
+
74
+ class OpenAIToolSchema(TypedDict):
75
+ """
76
+ Schema for a tool definition passed to OpenAI's `tools` parameter.
77
+ A tool wraps a callable function for function-calling. Each tool
78
+ includes a type (always 'function') and a `function` payload that
79
+ specifies the callable via `OpenAIFunctionSchema`.
80
+ """
81
+
82
+ type: Literal["function"]
83
+ """The type field, always 'function'."""
84
+
85
+ function: OpenAIFunctionSchema
86
+ """The function definition."""
87
+
88
+
89
+ # Type alias for a list of openai tool schemas
90
+ OpenAIToolList = list[OpenAIToolSchema]
91
+
92
+
93
+ # ----------------------------------------------------------------------------
94
+ # Converters
95
+ # ----------------------------------------------------------------------------
96
+ def to_openai(tool: MaterializedTool) -> OpenAIToolSchema:
97
+ """Convert a MaterializedTool to OpenAI JsonToolSchema format.
98
+
99
+ Args:
100
+ tool: The MaterializedTool to convert
101
+ Returns:
102
+ The OpenAI JsonToolSchema format (what is passed to the OpenAI API)
103
+ """
104
+ name = tool.definition.fully_qualified_name.replace(".", "_")
105
+ description = tool.description
106
+ parameters_schema = _convert_input_parameters_to_json_schema(tool.definition.input.parameters)
107
+ return _create_tool_schema(name, description, parameters_schema)
108
+
109
+
110
+ def _create_tool_schema(
111
+ name: str, description: str, parameters: OpenAIFunctionParameters
112
+ ) -> OpenAIToolSchema:
113
+ """Create a properly typed tool schema.
114
+ Args:
115
+ name: The name of the function
116
+ description: Description of what the function does
117
+ parameters: JSON schema for the function parameters
118
+ strict: Whether to enforce strict validation (default: True for reliable function calls)
119
+ Returns:
120
+ A properly typed OpenAIToolSchema
121
+ """
122
+
123
+ function: OpenAIFunctionSchema = {
124
+ "name": name,
125
+ "description": description,
126
+ "parameters": parameters,
127
+ "strict": True,
128
+ }
129
+
130
+ tool: OpenAIToolSchema = {
131
+ "type": "function",
132
+ "function": function,
133
+ }
134
+
135
+ return tool
136
+
137
+
138
+ def _convert_value_schema_to_json_schema(
139
+ value_schema: ValueSchema,
140
+ ) -> OpenAIFunctionParameterProperty:
141
+ """Convert Arcade ValueSchema to JSON Schema format."""
142
+ type_mapping = {
143
+ "string": "string",
144
+ "integer": "integer",
145
+ "number": "number",
146
+ "boolean": "boolean",
147
+ "json": "object",
148
+ "array": "array",
149
+ }
150
+
151
+ schema: OpenAIFunctionParameterProperty = {"type": type_mapping[value_schema.val_type]}
152
+
153
+ if value_schema.val_type == "array" and value_schema.inner_val_type:
154
+ items_schema: dict[str, Any] = {"type": type_mapping[value_schema.inner_val_type]}
155
+
156
+ # For arrays, enum should be applied to the items, not the array itself
157
+ if value_schema.enum:
158
+ items_schema["enum"] = value_schema.enum
159
+
160
+ schema["items"] = items_schema
161
+ else:
162
+ # Handle enum for non-array types
163
+ if value_schema.enum:
164
+ schema["enum"] = value_schema.enum
165
+
166
+ # Handle object properties
167
+ if value_schema.val_type == "json" and value_schema.properties:
168
+ schema["properties"] = {
169
+ name: _convert_value_schema_to_json_schema(nested_schema)
170
+ for name, nested_schema in value_schema.properties.items()
171
+ }
172
+
173
+ return schema
174
+
175
+
176
+ def _convert_input_parameters_to_json_schema(
177
+ parameters: list[InputParameter],
178
+ ) -> OpenAIFunctionParameters:
179
+ """Convert list of InputParameter to JSON schema parameters object."""
180
+ if not parameters:
181
+ # Minimal JSON schema for a tool with no input parameters
182
+ return {
183
+ "type": "object",
184
+ "properties": {},
185
+ "additionalProperties": False,
186
+ }
187
+
188
+ properties = {}
189
+ required = []
190
+
191
+ for parameter in parameters:
192
+ param_schema = _convert_value_schema_to_json_schema(parameter.value_schema)
193
+
194
+ # For optional parameters in strict mode, we need to add "null" as a type option
195
+ if not parameter.required:
196
+ param_type = param_schema.get("type")
197
+ if isinstance(param_type, str):
198
+ # Convert single type to union with null
199
+ param_schema["type"] = [param_type, "null"]
200
+ elif isinstance(param_type, list) and "null" not in param_type:
201
+ param_schema["type"] = [*param_type, "null"]
202
+
203
+ if parameter.description:
204
+ param_schema["description"] = parameter.description
205
+ properties[parameter.name] = param_schema
206
+
207
+ # In strict mode, all parameters (including optional ones) go in required array
208
+ # Optional parameters are handled by adding "null" to their type
209
+ required.append(parameter.name)
210
+
211
+ json_schema: OpenAIFunctionParameters = {
212
+ "type": "object",
213
+ "properties": properties,
214
+ "required": required,
215
+ "additionalProperties": False,
216
+ }
217
+ if not required:
218
+ del json_schema["required"]
219
+
220
+ return json_schema