From 0eb7ddddc7bb018518180e0e14d25bcb00207e8f Mon Sep 17 00:00:00 2001 From: baonudesifeizhai Date: Sun, 17 Aug 2025 18:34:19 -0400 Subject: [PATCH 1/7] feat: Add default parameter values support for tool definitions - Add parameter_defaults field to ToolFunctionDef and ToolFunctionDefDict - Implement automatic default extraction from function signatures using inspect - Add manual default specification support - Update JSON schema generation to include default values - Add comprehensive test coverage for default parameter values - Maintain backward compatibility with existing tool definitions Closes #90 --- src/lmstudio/json_api.py | 50 ++++++++++++- src/lmstudio/schemas.py | 23 ++++++ tests/test_default_values.py | 141 +++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 tests/test_default_values.py diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index 231e0fa..25c8255 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -11,6 +11,7 @@ import asyncio import copy +import inspect import json import uuid import warnings @@ -1089,13 +1090,14 @@ def __init__( super().__init__(model_key, params, on_load_progress) -class ToolFunctionDefDict(TypedDict): +class ToolFunctionDefDict(TypedDict, total=False): """SDK input format to specify an LLM tool call and its implementation (as a dict).""" name: str description: str parameters: Mapping[str, Any] implementation: Callable[..., Any] + parameter_defaults: Mapping[str, Any] @dataclass(kw_only=True, frozen=True, slots=True) @@ -1106,10 +1108,21 @@ class ToolFunctionDef: description: str parameters: Mapping[str, Any] implementation: Callable[..., Any] + parameter_defaults: Mapping[str, Any] = field(default_factory=dict) def _to_llm_tool_def(self) -> tuple[type[Struct], LlmTool]: params_struct_name = f"{self.name.capitalize()}Parameters" - params_struct = defstruct(params_struct_name, self.parameters.items()) + + # Build fields list with defaults + fields: list[tuple[str, Any] | tuple[str, Any, Any]] = [] + for param_name, param_type in self.parameters.items(): + if param_name in self.parameter_defaults: + default_value = self.parameter_defaults[param_name] + fields.append((param_name, param_type, default_value)) + else: + fields.append((param_name, param_type)) + + params_struct = defstruct(params_struct_name, fields, kw_only=True) return params_struct, LlmTool._from_api_dict( { "type": "function", @@ -1160,8 +1173,24 @@ def from_callable( ) from exc # Tool definitions only annotate the input parameters, not the return type parameters.pop("return", None) + + # Extract default values from function signature + parameter_defaults: dict[str, Any] = {} + try: + sig = inspect.signature(f) + for param_name, param in sig.parameters.items(): + if param.default is not inspect.Parameter.empty: + parameter_defaults[param_name] = param.default + except Exception as exc: + # If we can't extract defaults, continue without them + pass + return cls( - name=name, description=description, parameters=parameters, implementation=f + name=name, + description=description, + parameters=parameters, + implementation=f, + parameter_defaults=parameter_defaults ) @@ -1601,7 +1630,20 @@ def parse_tools( elif callable(tool): tool_def = ToolFunctionDef.from_callable(tool) else: - tool_def = ToolFunctionDef(**tool) + # Handle dictionary-based tool definition + tool_dict = cast(ToolFunctionDefDict, tool) + name = cast(str, tool_dict["name"]) + description = cast(str, tool_dict["description"]) + parameters = cast(Mapping[str, Any], tool_dict["parameters"]) + implementation = cast(Callable[..., Any], tool_dict["implementation"]) + parameter_defaults = cast(Mapping[str, Any], tool_dict.get("parameter_defaults", {})) + tool_def = ToolFunctionDef( + name=name, + description=description, + parameters=parameters, + implementation=implementation, + parameter_defaults=parameter_defaults + ) if tool_def.name in client_tool_map: raise LMStudioValueError( f"Duplicate tool names are not permitted ({tool_def.name!r} repeated)" diff --git a/src/lmstudio/schemas.py b/src/lmstudio/schemas.py index 92d4a11..2e5a2ab 100644 --- a/src/lmstudio/schemas.py +++ b/src/lmstudio/schemas.py @@ -65,6 +65,29 @@ def _to_json_schema(cls: type, *, omit: Sequence[str] = ()) -> DictSchema: for field in omit: named_schema.pop(field, None) json_schema.update(named_schema) + + # Add default values to properties if they exist in the msgspec Struct + if hasattr(cls, "__struct_fields__") and hasattr(cls, "__struct_defaults__"): + properties = json_schema.get("properties", {}) + if properties: + # Get ordered field names and default values + field_names = cls.__struct_fields__ + default_values = cls.__struct_defaults__ + + # Map default values to field names by position + # Only fields with defaults will have entries in default_values + default_count = len(default_values) + field_count = len(field_names) + + # For kw_only=True structs, default values correspond to the last N fields + # where N is the number of default values + for i, field_name in enumerate(field_names): + if field_name in properties: + # Calculate the index into default_values + default_index = i - (field_count - default_count) + if 0 <= default_index < default_count: + properties[field_name]["default"] = default_values[default_index] + return json_schema diff --git a/tests/test_default_values.py b/tests/test_default_values.py new file mode 100644 index 0000000..2dd69c9 --- /dev/null +++ b/tests/test_default_values.py @@ -0,0 +1,141 @@ +"""Tests for default parameter values in tool definitions.""" + +import pytest +from typing import Any + +from src.lmstudio.json_api import ToolFunctionDef, ToolFunctionDefDict +from src.lmstudio.schemas import _to_json_schema + + +def greet(name: str, greeting: str = "Hello", punctuation: str = "!") -> str: + """Greet someone with a customizable message. + + Args: + name: The name of the person to greet + greeting: The greeting word to use (default: "Hello") + punctuation: The punctuation to end with (default: "!") + + Returns: + A greeting message + """ + return f"{greeting}, {name}{punctuation}" + + +def calculate(expression: str, precision: int = 2) -> str: + """Calculate a mathematical expression. + + Args: + expression: The mathematical expression to evaluate + precision: Number of decimal places (default: 2) + + Returns: + The calculated result + """ + return f"Result: {eval(expression):.{precision}f}" + + +class TestDefaultValues: + """Test default parameter value functionality.""" + + def test_extract_defaults_from_callable(self): + """Test extracting default values from function signature.""" + tool_def = ToolFunctionDef.from_callable(greet) + + assert tool_def.name == "greet" + assert tool_def.parameter_defaults == { + "greeting": "Hello", + "punctuation": "!" + } + assert "name" not in tool_def.parameter_defaults + + def test_manual_defaults(self): + """Test manually specifying default values.""" + tool_def = ToolFunctionDef( + name="calculate", + description="Calculate a mathematical expression", + parameters={"expression": str, "precision": int}, + implementation=calculate, + parameter_defaults={"precision": 2} + ) + + assert tool_def.parameter_defaults == {"precision": 2} + assert "expression" not in tool_def.parameter_defaults + + def test_json_schema_with_defaults(self): + """Test that JSON Schema includes default values.""" + tool_def = ToolFunctionDef.from_callable(greet) + params_struct, _ = tool_def._to_llm_tool_def() + json_schema = _to_json_schema(params_struct) + + properties = json_schema["properties"] + + # name should not have a default (required parameter) + assert "name" in properties + assert "default" not in properties["name"] + + # greeting should have default "Hello" + assert "greeting" in properties + assert properties["greeting"]["default"] == "Hello" + + # punctuation should have default "!" + assert "punctuation" in properties + assert properties["punctuation"]["default"] == "!" + + # Only name should be required + assert json_schema["required"] == ["name"] + + def test_dict_based_definition(self): + """Test dictionary-based tool definition with defaults.""" + dict_tool: ToolFunctionDefDict = { + "name": "format_text", + "description": "Format text with specified style", + "parameters": {"text": str, "style": str, "uppercase": bool}, + "implementation": lambda text, style="normal", uppercase=False: text.upper() if uppercase else text, + "parameter_defaults": {"style": "normal", "uppercase": False} + } + + # This should work without errors + tool_def = ToolFunctionDef(**dict_tool) + assert tool_def.parameter_defaults == {"style": "normal", "uppercase": False} + + def test_no_defaults(self): + """Test function with no default values.""" + def no_defaults(a: int, b: str) -> str: + """Function with no default parameters.""" + return f"{a}{b}" + + tool_def = ToolFunctionDef.from_callable(no_defaults) + assert tool_def.parameter_defaults == {} + + params_struct, _ = tool_def._to_llm_tool_def() + json_schema = _to_json_schema(params_struct) + + # All parameters should be required + assert json_schema["required"] == ["a", "b"] + + # No properties should have defaults + for prop in json_schema["properties"].values(): + assert "default" not in prop + + def test_mixed_defaults(self): + """Test function with some parameters having defaults.""" + def mixed_defaults(required: str, optional1: int = 42, optional2: bool = True) -> str: + """Function with mixed required and optional parameters.""" + return f"{required}{optional1}{optional2}" + + tool_def = ToolFunctionDef.from_callable(mixed_defaults) + assert tool_def.parameter_defaults == { + "optional1": 42, + "optional2": True + } + + params_struct, _ = tool_def._to_llm_tool_def() + json_schema = _to_json_schema(params_struct) + + # Only required should be in required list + assert json_schema["required"] == ["required"] + + # Check defaults + assert json_schema["properties"]["optional1"]["default"] == 42 + assert json_schema["properties"]["optional2"]["default"] is True + assert "default" not in json_schema["properties"]["required"] From c5cee65b8a0b2e4a2b7b2fed2fa502670c43590e Mon Sep 17 00:00:00 2001 From: baonudesifeizhai Date: Mon, 18 Aug 2025 18:51:55 -0400 Subject: [PATCH 2/7] Implement inline default format for tool parameters - Replace separate parameter_defaults with inline format - Use generic TypedDict with NotRequired for better type safety - Remove manual default value injection, rely on msgspec auto-handling - Update all tests to use inline format instead of separate mapping --- src/lmstudio/json_api.py | 60 ++++++++------ src/lmstudio/schemas.py | 23 +----- tests/test_default_values.py | 154 ++++++++++++++++++++++------------- 3 files changed, 136 insertions(+), 101 deletions(-) diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index 25c8255..e9b2dee 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -1090,14 +1090,23 @@ def __init__( super().__init__(model_key, params, on_load_progress) +# Add new type definitions for inline parameter format +from typing import TypeVar +from typing_extensions import NotRequired + +T = TypeVar('T') + +class ToolParamDefDict(TypedDict, Generic[T]): + type: type[T] + default: NotRequired[T] + class ToolFunctionDefDict(TypedDict, total=False): """SDK input format to specify an LLM tool call and its implementation (as a dict).""" name: str description: str - parameters: Mapping[str, Any] + parameters: Mapping[str, type[Any] | ToolParamDefDict[Any]] implementation: Callable[..., Any] - parameter_defaults: Mapping[str, Any] @dataclass(kw_only=True, frozen=True, slots=True) @@ -1106,18 +1115,29 @@ class ToolFunctionDef: name: str description: str - parameters: Mapping[str, Any] + parameters: Mapping[str, type[Any] | ToolParamDefDict[Any]] implementation: Callable[..., Any] - parameter_defaults: Mapping[str, Any] = field(default_factory=dict) + + def _extract_type_and_default(self, param_name: str, param_value: type[Any] | ToolParamDefDict[Any]) -> tuple[type[Any], Any | None]: + """Extract type and default value from parameter definition.""" + if isinstance(param_value, dict) and "type" in param_value: + # Inline format: {"type": type, "default": value} + param_type = param_value["type"] + default_value = param_value.get("default") + return param_type, default_value + else: + # Simple format: just the type + return param_value, None def _to_llm_tool_def(self) -> tuple[type[Struct], LlmTool]: params_struct_name = f"{self.name.capitalize()}Parameters" # Build fields list with defaults fields: list[tuple[str, Any] | tuple[str, Any, Any]] = [] - for param_name, param_type in self.parameters.items(): - if param_name in self.parameter_defaults: - default_value = self.parameter_defaults[param_name] + for param_name, param_value in self.parameters.items(): + param_type, default_value = self._extract_type_and_default(param_name, param_value) + + if default_value is not None: fields.append((param_name, param_type, default_value)) else: fields.append((param_name, param_type)) @@ -1174,13 +1194,17 @@ def from_callable( # Tool definitions only annotate the input parameters, not the return type parameters.pop("return", None) - # Extract default values from function signature - parameter_defaults: dict[str, Any] = {} + # Extract default values from function signature and convert to inline format try: sig = inspect.signature(f) for param_name, param in sig.parameters.items(): if param.default is not inspect.Parameter.empty: - parameter_defaults[param_name] = param.default + # Convert to inline format: {"type": type, "default": value} + original_type = parameters[param_name] + parameters[param_name] = { + "type": original_type, + "default": param.default + } except Exception as exc: # If we can't extract defaults, continue without them pass @@ -1189,8 +1213,7 @@ def from_callable( name=name, description=description, parameters=parameters, - implementation=f, - parameter_defaults=parameter_defaults + implementation=f ) @@ -1632,18 +1655,7 @@ def parse_tools( else: # Handle dictionary-based tool definition tool_dict = cast(ToolFunctionDefDict, tool) - name = cast(str, tool_dict["name"]) - description = cast(str, tool_dict["description"]) - parameters = cast(Mapping[str, Any], tool_dict["parameters"]) - implementation = cast(Callable[..., Any], tool_dict["implementation"]) - parameter_defaults = cast(Mapping[str, Any], tool_dict.get("parameter_defaults", {})) - tool_def = ToolFunctionDef( - name=name, - description=description, - parameters=parameters, - implementation=implementation, - parameter_defaults=parameter_defaults - ) + tool_def = ToolFunctionDef(**tool_dict) if tool_def.name in client_tool_map: raise LMStudioValueError( f"Duplicate tool names are not permitted ({tool_def.name!r} repeated)" diff --git a/src/lmstudio/schemas.py b/src/lmstudio/schemas.py index 2e5a2ab..b07d47d 100644 --- a/src/lmstudio/schemas.py +++ b/src/lmstudio/schemas.py @@ -66,27 +66,8 @@ def _to_json_schema(cls: type, *, omit: Sequence[str] = ()) -> DictSchema: named_schema.pop(field, None) json_schema.update(named_schema) - # Add default values to properties if they exist in the msgspec Struct - if hasattr(cls, "__struct_fields__") and hasattr(cls, "__struct_defaults__"): - properties = json_schema.get("properties", {}) - if properties: - # Get ordered field names and default values - field_names = cls.__struct_fields__ - default_values = cls.__struct_defaults__ - - # Map default values to field names by position - # Only fields with defaults will have entries in default_values - default_count = len(default_values) - field_count = len(field_names) - - # For kw_only=True structs, default values correspond to the last N fields - # where N is the number of default values - for i, field_name in enumerate(field_names): - if field_name in properties: - # Calculate the index into default_values - default_index = i - (field_count - default_count) - if 0 <= default_index < default_count: - properties[field_name]["default"] = default_values[default_index] + # msgspec automatically handles default values in the generated JSON schema + # when they are properly defined in the Struct fields return json_schema diff --git a/tests/test_default_values.py b/tests/test_default_values.py index 2dd69c9..cf6cdb7 100644 --- a/tests/test_default_values.py +++ b/tests/test_default_values.py @@ -2,6 +2,7 @@ import pytest from typing import Any +from msgspec import defstruct from src.lmstudio.json_api import ToolFunctionDef, ToolFunctionDefDict from src.lmstudio.schemas import _to_json_schema @@ -29,113 +30,154 @@ def calculate(expression: str, precision: int = 2) -> str: precision: Number of decimal places (default: 2) Returns: - The calculated result + The calculated result as a string """ return f"Result: {eval(expression):.{precision}f}" class TestDefaultValues: - """Test default parameter value functionality.""" - + """Test cases for default parameter values in tool definitions.""" + def test_extract_defaults_from_callable(self): - """Test extracting default values from function signature.""" + """Test extracting default values from a callable function.""" tool_def = ToolFunctionDef.from_callable(greet) assert tool_def.name == "greet" - assert tool_def.parameter_defaults == { - "greeting": "Hello", - "punctuation": "!" - } - assert "name" not in tool_def.parameter_defaults + # Check that defaults are converted to inline format + assert tool_def.parameters["greeting"] == {"type": str, "default": "Hello"} + assert tool_def.parameters["punctuation"] == {"type": str, "default": "!"} + assert tool_def.parameters["name"] == str # No default, just type - def test_manual_defaults(self): - """Test manually specifying default values.""" + def test_manual_inline_defaults(self): + """Test manually specifying default values in inline format.""" tool_def = ToolFunctionDef( name="calculate", description="Calculate a mathematical expression", - parameters={"expression": str, "precision": int}, - implementation=calculate, - parameter_defaults={"precision": 2} + parameters={ + "expression": str, + "precision": {"type": int, "default": 2} + }, + implementation=calculate ) - assert tool_def.parameter_defaults == {"precision": 2} - assert "expression" not in tool_def.parameter_defaults + # Check that the inline format is preserved + assert tool_def.parameters["precision"] == {"type": int, "default": 2} + assert tool_def.parameters["expression"] == str # No default, just type def test_json_schema_with_defaults(self): - """Test that JSON Schema includes default values.""" + """Test that JSON schema includes default values.""" tool_def = ToolFunctionDef.from_callable(greet) params_struct, _ = tool_def._to_llm_tool_def() - json_schema = _to_json_schema(params_struct) - - properties = json_schema["properties"] - - # name should not have a default (required parameter) - assert "name" in properties - assert "default" not in properties["name"] - # greeting should have default "Hello" - assert "greeting" in properties - assert properties["greeting"]["default"] == "Hello" - - # punctuation should have default "!" - assert "punctuation" in properties - assert properties["punctuation"]["default"] == "!" + json_schema = _to_json_schema(params_struct) - # Only name should be required - assert json_schema["required"] == ["name"] + # Check that default values are included in the schema + assert json_schema["properties"]["greeting"]["default"] == "Hello" + assert json_schema["properties"]["punctuation"]["default"] == "!" + assert "default" not in json_schema["properties"]["name"] def test_dict_based_definition(self): - """Test dictionary-based tool definition with defaults.""" + """Test dictionary-based tool definition with inline defaults.""" dict_tool: ToolFunctionDefDict = { "name": "format_text", "description": "Format text with specified style", - "parameters": {"text": str, "style": str, "uppercase": bool}, - "implementation": lambda text, style="normal", uppercase=False: text.upper() if uppercase else text, - "parameter_defaults": {"style": "normal", "uppercase": False} + "parameters": { + "text": str, + "style": {"type": str, "default": "normal"}, + "uppercase": {"type": bool, "default": False} + }, + "implementation": lambda text, style="normal", uppercase=False: text.upper() if uppercase else text } # This should work without errors tool_def = ToolFunctionDef(**dict_tool) - assert tool_def.parameter_defaults == {"style": "normal", "uppercase": False} + assert tool_def.parameters["style"] == {"type": str, "default": "normal"} + assert tool_def.parameters["uppercase"] == {"type": bool, "default": False} + assert tool_def.parameters["text"] == str # No default, just type def test_no_defaults(self): """Test function with no default values.""" def no_defaults(a: int, b: str) -> str: """Function with no default parameters.""" - return f"{a}{b}" + return f"{a}: {b}" tool_def = ToolFunctionDef.from_callable(no_defaults) - assert tool_def.parameter_defaults == {} + # All parameters should be simple types without defaults + assert tool_def.parameters["a"] == int + assert tool_def.parameters["b"] == str params_struct, _ = tool_def._to_llm_tool_def() json_schema = _to_json_schema(params_struct) - # All parameters should be required - assert json_schema["required"] == ["a", "b"] - - # No properties should have defaults - for prop in json_schema["properties"].values(): - assert "default" not in prop + # No default values should be present + assert "default" not in json_schema["properties"]["a"] + assert "default" not in json_schema["properties"]["b"] def test_mixed_defaults(self): - """Test function with some parameters having defaults.""" + """Test function with some parameters having defaults and others not.""" def mixed_defaults(required: str, optional1: int = 42, optional2: bool = True) -> str: """Function with mixed required and optional parameters.""" - return f"{required}{optional1}{optional2}" + return f"{required}: {optional1}, {optional2}" tool_def = ToolFunctionDef.from_callable(mixed_defaults) - assert tool_def.parameter_defaults == { - "optional1": 42, - "optional2": True - } + # Check inline format for parameters with defaults + assert tool_def.parameters["optional1"] == {"type": int, "default": 42} + assert tool_def.parameters["optional2"] == {"type": bool, "default": True} + assert tool_def.parameters["required"] == str # No default, just type params_struct, _ = tool_def._to_llm_tool_def() json_schema = _to_json_schema(params_struct) - # Only required should be in required list - assert json_schema["required"] == ["required"] - - # Check defaults + # Check that default values are correctly included in schema assert json_schema["properties"]["optional1"]["default"] == 42 assert json_schema["properties"]["optional2"]["default"] is True assert "default" not in json_schema["properties"]["required"] + + def test_extract_type_and_default_method(self): + """Test the _extract_type_and_default helper method.""" + tool_def = ToolFunctionDef( + name="test", + description="Test tool", + parameters={ + "simple": str, + "with_default": {"type": int, "default": 42}, + "complex_default": {"type": list, "default": [1, 2, 3]} + }, + implementation=lambda x, y, z: None + ) + + # Test simple type + param_type, default = tool_def._extract_type_and_default("simple", str) + assert param_type == str + assert default is None + + # Test inline format with default + param_type, default = tool_def._extract_type_and_default("with_default", {"type": int, "default": 42}) + assert param_type == int + assert default == 42 + + # Test complex default + param_type, default = tool_def._extract_type_and_default("complex_default", {"type": list, "default": [1, 2, 3]}) + assert param_type == list + assert default == [1, 2, 3] + + def test_msgspec_auto_defaults(self): + """msgspec automatically reflects default values in the JSON schema.""" + TestStruct = defstruct( + "TestStruct", + [ + ("name", str), + ("age", int, 18), + ("active", bool, True), + ], + kw_only=True, + ) + + schema = _to_json_schema(TestStruct) + properties = schema.get("properties", {}) + required = schema.get("required", []) + + assert "name" in properties and "default" not in properties["name"] + assert properties["age"].get("default") == 18 + assert properties["active"].get("default") is True + assert "name" in required and "age" not in required and "active" not in required From abcd8a98c411cd07ab3fa854244bbcc3178c21b3 Mon Sep 17 00:00:00 2001 From: baonudesifeizhai Date: Mon, 18 Aug 2025 18:55:53 -0400 Subject: [PATCH 3/7] Remove total=False from ToolFunctionDefDict - all fields are required with inline format --- src/lmstudio/json_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index e9b2dee..559ec19 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -1100,7 +1100,7 @@ class ToolParamDefDict(TypedDict, Generic[T]): type: type[T] default: NotRequired[T] -class ToolFunctionDefDict(TypedDict, total=False): +class ToolFunctionDefDict(TypedDict): """SDK input format to specify an LLM tool call and its implementation (as a dict).""" name: str From 296e78ed79a739ea8dcbce357c7ebc2ef075611e Mon Sep 17 00:00:00 2001 From: Alyssa Coghlan Date: Tue, 19 Aug 2025 12:36:36 +1000 Subject: [PATCH 4/7] Cleanups from local PR testing --- src/lmstudio/json_api.py | 64 ++++++++-------- src/lmstudio/schemas.py | 4 +- tests/test_default_values.py | 140 ++++++++++++++++++----------------- 3 files changed, 109 insertions(+), 99 deletions(-) diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index 559ec19..1ea721a 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -41,6 +41,7 @@ # Native in 3.11+ assert_never, NoReturn, + NotRequired, Self, ) @@ -1090,16 +1091,11 @@ def __init__( super().__init__(model_key, params, on_load_progress) -# Add new type definitions for inline parameter format -from typing import TypeVar -from typing_extensions import NotRequired - -T = TypeVar('T') - class ToolParamDefDict(TypedDict, Generic[T]): type: type[T] default: NotRequired[T] + class ToolFunctionDefDict(TypedDict): """SDK input format to specify an LLM tool call and its implementation (as a dict).""" @@ -1109,6 +1105,10 @@ class ToolFunctionDefDict(TypedDict): implementation: Callable[..., Any] +# Sentinel for parameters with no defined default value +_NO_DEFAULT = object() + + @dataclass(kw_only=True, frozen=True, slots=True) class ToolFunctionDef: """SDK input format to specify an LLM tool call and its implementation.""" @@ -1118,30 +1118,35 @@ class ToolFunctionDef: parameters: Mapping[str, type[Any] | ToolParamDefDict[Any]] implementation: Callable[..., Any] - def _extract_type_and_default(self, param_name: str, param_value: type[Any] | ToolParamDefDict[Any]) -> tuple[type[Any], Any | None]: + @staticmethod + def _extract_type_and_default( + param_value: type[Any] | ToolParamDefDict[Any], + ) -> tuple[type[Any], Any]: """Extract type and default value from parameter definition.""" - if isinstance(param_value, dict) and "type" in param_value: + if isinstance(param_value, dict): # Inline format: {"type": type, "default": value} - param_type = param_value["type"] - default_value = param_value.get("default") + param_type = param_value.get("type", None) + if param_type is None: + raise TypeError( + f"Missing 'type' key in tool parameter definition {param_value!r}" + ) + default_value = param_value.get("default", _NO_DEFAULT) return param_type, default_value else: # Simple format: just the type - return param_value, None + return param_value, _NO_DEFAULT def _to_llm_tool_def(self) -> tuple[type[Struct], LlmTool]: params_struct_name = f"{self.name.capitalize()}Parameters" - # Build fields list with defaults - fields: list[tuple[str, Any] | tuple[str, Any, Any]] = [] + fields: list[tuple[str, type[Any]] | tuple[str, type[Any], Any]] = [] for param_name, param_value in self.parameters.items(): - param_type, default_value = self._extract_type_and_default(param_name, param_value) - - if default_value is not None: - fields.append((param_name, param_type, default_value)) - else: + param_type, default_value = self._extract_type_and_default(param_value) + if default_value is _NO_DEFAULT: fields.append((param_name, param_type)) - + else: + fields.append((param_name, param_type, default_value)) + # Define msgspec struct and API tool definition from the field list params_struct = defstruct(params_struct_name, fields, kw_only=True) return params_struct, LlmTool._from_api_dict( { @@ -1193,27 +1198,25 @@ def from_callable( ) from exc # Tool definitions only annotate the input parameters, not the return type parameters.pop("return", None) - + # Extract default values from function signature and convert to inline format try: sig = inspect.signature(f) + except Exception: + # If we can't extract defaults, continue without them + pass + else: for param_name, param in sig.parameters.items(): if param.default is not inspect.Parameter.empty: # Convert to inline format: {"type": type, "default": value} original_type = parameters[param_name] parameters[param_name] = { "type": original_type, - "default": param.default + "default": param.default, } - except Exception as exc: - # If we can't extract defaults, continue without them - pass - + return cls( - name=name, - description=description, - parameters=parameters, - implementation=f + name=name, description=description, parameters=parameters, implementation=f ) @@ -1654,8 +1657,7 @@ def parse_tools( tool_def = ToolFunctionDef.from_callable(tool) else: # Handle dictionary-based tool definition - tool_dict = cast(ToolFunctionDefDict, tool) - tool_def = ToolFunctionDef(**tool_dict) + tool_def = ToolFunctionDef(**tool) if tool_def.name in client_tool_map: raise LMStudioValueError( f"Duplicate tool names are not permitted ({tool_def.name!r} repeated)" diff --git a/src/lmstudio/schemas.py b/src/lmstudio/schemas.py index b07d47d..992c802 100644 --- a/src/lmstudio/schemas.py +++ b/src/lmstudio/schemas.py @@ -65,10 +65,10 @@ def _to_json_schema(cls: type, *, omit: Sequence[str] = ()) -> DictSchema: for field in omit: named_schema.pop(field, None) json_schema.update(named_schema) - + # msgspec automatically handles default values in the generated JSON schema # when they are properly defined in the Struct fields - + return json_schema diff --git a/tests/test_default_values.py b/tests/test_default_values.py index cf6cdb7..4bcdd7e 100644 --- a/tests/test_default_values.py +++ b/tests/test_default_values.py @@ -1,21 +1,21 @@ """Tests for default parameter values in tool definitions.""" import pytest -from typing import Any + from msgspec import defstruct -from src.lmstudio.json_api import ToolFunctionDef, ToolFunctionDefDict -from src.lmstudio.schemas import _to_json_schema +from lmstudio.json_api import _NO_DEFAULT, ToolFunctionDef, ToolFunctionDefDict +from lmstudio.schemas import _to_json_schema def greet(name: str, greeting: str = "Hello", punctuation: str = "!") -> str: """Greet someone with a customizable message. - + Args: name: The name of the person to greet greeting: The greeting word to use (default: "Hello") punctuation: The punctuation to end with (default: "!") - + Returns: A greeting message """ @@ -24,11 +24,11 @@ def greet(name: str, greeting: str = "Hello", punctuation: str = "!") -> str: def calculate(expression: str, precision: int = 2) -> str: """Calculate a mathematical expression. - + Args: expression: The mathematical expression to evaluate precision: Number of decimal places (default: 2) - + Returns: The calculated result as a string """ @@ -38,45 +38,42 @@ def calculate(expression: str, precision: int = 2) -> str: class TestDefaultValues: """Test cases for default parameter values in tool definitions.""" - def test_extract_defaults_from_callable(self): + def test_extract_defaults_from_callable(self) -> None: """Test extracting default values from a callable function.""" tool_def = ToolFunctionDef.from_callable(greet) - + assert tool_def.name == "greet" # Check that defaults are converted to inline format assert tool_def.parameters["greeting"] == {"type": str, "default": "Hello"} assert tool_def.parameters["punctuation"] == {"type": str, "default": "!"} - assert tool_def.parameters["name"] == str # No default, just type - - def test_manual_inline_defaults(self): + assert tool_def.parameters["name"] is str # No default, just type + + def test_manual_inline_defaults(self) -> None: """Test manually specifying default values in inline format.""" tool_def = ToolFunctionDef( name="calculate", description="Calculate a mathematical expression", - parameters={ - "expression": str, - "precision": {"type": int, "default": 2} - }, - implementation=calculate + parameters={"expression": str, "precision": {"type": int, "default": 2}}, + implementation=calculate, ) - + # Check that the inline format is preserved assert tool_def.parameters["precision"] == {"type": int, "default": 2} - assert tool_def.parameters["expression"] == str # No default, just type - - def test_json_schema_with_defaults(self): + assert tool_def.parameters["expression"] is str # No default, just type + + def test_json_schema_with_defaults(self) -> None: """Test that JSON schema includes default values.""" tool_def = ToolFunctionDef.from_callable(greet) params_struct, _ = tool_def._to_llm_tool_def() - + json_schema = _to_json_schema(params_struct) - + # Check that default values are included in the schema assert json_schema["properties"]["greeting"]["default"] == "Hello" assert json_schema["properties"]["punctuation"]["default"] == "!" assert "default" not in json_schema["properties"]["name"] - - def test_dict_based_definition(self): + + def test_dict_based_definition(self) -> None: """Test dictionary-based tool definition with inline defaults.""" dict_tool: ToolFunctionDefDict = { "name": "format_text", @@ -84,84 +81,95 @@ def test_dict_based_definition(self): "parameters": { "text": str, "style": {"type": str, "default": "normal"}, - "uppercase": {"type": bool, "default": False} + "uppercase": {"type": bool, "default": False}, }, - "implementation": lambda text, style="normal", uppercase=False: text.upper() if uppercase else text + "implementation": lambda text, style="normal", uppercase=False: text.upper() + if uppercase + else text, } - + # This should work without errors tool_def = ToolFunctionDef(**dict_tool) assert tool_def.parameters["style"] == {"type": str, "default": "normal"} assert tool_def.parameters["uppercase"] == {"type": bool, "default": False} - assert tool_def.parameters["text"] == str # No default, just type - - def test_no_defaults(self): + assert tool_def.parameters["text"] is str # No default, just type + + def test_no_defaults(self) -> None: """Test function with no default values.""" + def no_defaults(a: int, b: str) -> str: """Function with no default parameters.""" return f"{a}: {b}" - + tool_def = ToolFunctionDef.from_callable(no_defaults) # All parameters should be simple types without defaults - assert tool_def.parameters["a"] == int - assert tool_def.parameters["b"] == str - + assert tool_def.parameters["a"] is int + assert tool_def.parameters["b"] is str + params_struct, _ = tool_def._to_llm_tool_def() json_schema = _to_json_schema(params_struct) - + # No default values should be present assert "default" not in json_schema["properties"]["a"] assert "default" not in json_schema["properties"]["b"] - - def test_mixed_defaults(self): + + def test_mixed_defaults(self) -> None: """Test function with some parameters having defaults and others not.""" - def mixed_defaults(required: str, optional1: int = 42, optional2: bool = True) -> str: + + def mixed_defaults( + required: str, optional1: int = 42, optional2: bool = True + ) -> str: """Function with mixed required and optional parameters.""" return f"{required}: {optional1}, {optional2}" - + tool_def = ToolFunctionDef.from_callable(mixed_defaults) # Check inline format for parameters with defaults assert tool_def.parameters["optional1"] == {"type": int, "default": 42} assert tool_def.parameters["optional2"] == {"type": bool, "default": True} - assert tool_def.parameters["required"] == str # No default, just type - + assert tool_def.parameters["required"] is str # No default, just type + params_struct, _ = tool_def._to_llm_tool_def() json_schema = _to_json_schema(params_struct) - + # Check that default values are correctly included in schema assert json_schema["properties"]["optional1"]["default"] == 42 assert json_schema["properties"]["optional2"]["default"] is True assert "default" not in json_schema["properties"]["required"] - - def test_extract_type_and_default_method(self): + + def test_extract_type_and_default_method(self) -> None: """Test the _extract_type_and_default helper method.""" - tool_def = ToolFunctionDef( - name="test", - description="Test tool", - parameters={ - "simple": str, - "with_default": {"type": int, "default": 42}, - "complex_default": {"type": list, "default": [1, 2, 3]} - }, - implementation=lambda x, y, z: None - ) - + # Test simple type - param_type, default = tool_def._extract_type_and_default("simple", str) - assert param_type == str - assert default is None - + param_type, default = ToolFunctionDef._extract_type_and_default(str) + assert param_type is str + assert default is _NO_DEFAULT + + # Test inline format with missing type key + with pytest.raises(TypeError, match="Missing 'type' key"): + param_type, default = ToolFunctionDef._extract_type_and_default( + {"default": 42} # type: ignore[arg-type] + ) + + # Test inline format with no default + param_type, default = ToolFunctionDef._extract_type_and_default({"type": int}) + assert param_type is int + assert default is _NO_DEFAULT + # Test inline format with default - param_type, default = tool_def._extract_type_and_default("with_default", {"type": int, "default": 42}) - assert param_type == int + param_type, default = ToolFunctionDef._extract_type_and_default( + {"type": int, "default": 42} + ) + assert param_type is int assert default == 42 - + # Test complex default - param_type, default = tool_def._extract_type_and_default("complex_default", {"type": list, "default": [1, 2, 3]}) - assert param_type == list + param_type, default = ToolFunctionDef._extract_type_and_default( + {"type": list, "default": [1, 2, 3]} + ) + assert param_type is list assert default == [1, 2, 3] - def test_msgspec_auto_defaults(self): + def test_msgspec_auto_defaults(self) -> None: """msgspec automatically reflects default values in the JSON schema.""" TestStruct = defstruct( "TestStruct", From b77669010a01d3c3c2295ac09d23ffe30a2055a0 Mon Sep 17 00:00:00 2001 From: Alyssa Coghlan Date: Tue, 19 Aug 2025 12:49:19 +1000 Subject: [PATCH 5/7] No generic typed dicts in Python 3.10 --- src/lmstudio/json_api.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index 1ea721a..89742da 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -13,6 +13,7 @@ import copy import inspect import json +import sys import uuid import warnings @@ -1091,9 +1092,17 @@ def __init__( super().__init__(model_key, params, on_load_progress) -class ToolParamDefDict(TypedDict, Generic[T]): - type: type[T] - default: NotRequired[T] +if sys.version_info < (3, 11): + # Generic typed dictionaries aren't supported in Python 3.10 + # https://github.com/python/cpython/issues/89026 + class ToolParamDefDict(TypedDict): + type: type[Any] + default: NotRequired[Any] +else: + + class ToolParamDefDict(TypedDict, Generic[T]): + type: type[T] + default: NotRequired[T] class ToolFunctionDefDict(TypedDict): From 1f1c225e99dd49eb80cea3c396392c9bafd00da2 Mon Sep 17 00:00:00 2001 From: baonudesifeizhai Date: Mon, 18 Aug 2025 23:16:46 -0400 Subject: [PATCH 6/7] Fix Python 3.10 TypedDict compatibility issue with ParamDefDict type alias --- src/lmstudio/json_api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index 89742da..72ae93d 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -1098,19 +1098,20 @@ def __init__( class ToolParamDefDict(TypedDict): type: type[Any] default: NotRequired[Any] + ParamDefDict: TypeAlias = ToolParamDefDict else: class ToolParamDefDict(TypedDict, Generic[T]): type: type[T] default: NotRequired[T] - + ParamDefDict: TypeAlias = ToolParamDefDict[Any] class ToolFunctionDefDict(TypedDict): """SDK input format to specify an LLM tool call and its implementation (as a dict).""" name: str description: str - parameters: Mapping[str, type[Any] | ToolParamDefDict[Any]] + parameters: Mapping[str, type[Any] | ParamDefDict] implementation: Callable[..., Any] @@ -1124,12 +1125,12 @@ class ToolFunctionDef: name: str description: str - parameters: Mapping[str, type[Any] | ToolParamDefDict[Any]] + parameters: Mapping[str, type[Any] | ParamDefDict] implementation: Callable[..., Any] @staticmethod def _extract_type_and_default( - param_value: type[Any] | ToolParamDefDict[Any], + param_value: type[Any] | ParamDefDict, ) -> tuple[type[Any], Any]: """Extract type and default value from parameter definition.""" if isinstance(param_value, dict): From 876f8ed3ce9ca5730c5b21a4d774a7c9e8f1b61f Mon Sep 17 00:00:00 2001 From: Alyssa Coghlan Date: Tue, 19 Aug 2025 13:24:11 +1000 Subject: [PATCH 7/7] Run autoformatter --- src/lmstudio/json_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lmstudio/json_api.py b/src/lmstudio/json_api.py index 72ae93d..c70f57b 100644 --- a/src/lmstudio/json_api.py +++ b/src/lmstudio/json_api.py @@ -1098,14 +1098,17 @@ def __init__( class ToolParamDefDict(TypedDict): type: type[Any] default: NotRequired[Any] + ParamDefDict: TypeAlias = ToolParamDefDict else: class ToolParamDefDict(TypedDict, Generic[T]): type: type[T] default: NotRequired[T] + ParamDefDict: TypeAlias = ToolParamDefDict[Any] + class ToolFunctionDefDict(TypedDict): """SDK input format to specify an LLM tool call and its implementation (as a dict)."""