Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openhands-sdk/openhands/sdk/llm/utils/model_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class ModelFeatures:
# Keep these entries as bare substrings without wildcards.
FORCE_STRING_SERIALIZER_PATTERNS: list[str] = [
"deepseek", # e.g., DeepSeek-V3.2-Exp
"glm", # e.g., GLM-4.5 / GLM-4.6
"glm-4", # e.g., GLM-4.5 / GLM-4.6
# Kimi K2-Instruct requires string serialization only on Groq
"groq/kimi-k2-instruct", # explicit provider-prefixed IDs
]
Expand Down
78 changes: 76 additions & 2 deletions openhands-sdk/openhands/sdk/tool/schema.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import types
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any, ClassVar, TypeVar
from typing import Annotated, Any, ClassVar, TypeVar, Union, get_args, get_origin

from pydantic import ConfigDict, Field, create_model
from pydantic import ConfigDict, Field, create_model, model_validator
from rich.text import Text

from openhands.sdk.llm import ImageContent, TextContent
Expand Down Expand Up @@ -100,6 +102,78 @@ class Schema(DiscriminatedUnionMixin):

model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True)

@model_validator(mode="before")
@classmethod
def _decode_json_strings(cls, data: Any) -> Any:
"""Pre-validator that automatically decodes JSON strings for list/dict fields.

This validator runs before field validation and checks if any field that
expects a list or dict type has received a JSON string instead. If so,
it automatically decodes the string using json.loads().

This handles cases where LLMs (like GLM-4) return array/object values
as JSON strings instead of native JSON arrays/objects i.e.
<parameter=view_range>"[1, 100]"</parameter> instead of
<parameter=view_range>[1, 100]</parameter>.

Args:
data: The input data (usually a dict) before validation.

Returns:
The data with JSON strings decoded where appropriate.
"""
if not isinstance(data, dict):
return data

# Use model_fields to properly handle aliases and inherited fields
for field_name, field_info in cls.model_fields.items():
# Check both the field name and its alias (if any)
data_key = field_info.alias if field_info.alias else field_name
if data_key not in data:
continue

value = data[data_key]
# Skip if value is not a string
if not isinstance(value, str):
continue

expected_type = field_info.annotation

# Unwrap Annotated types - only the first arg is the actual type
if get_origin(expected_type) is Annotated:
type_args = get_args(expected_type)
expected_type = type_args[0] if type_args else expected_type

# Get the origin of the expected type (e.g., list from list[str])
origin = get_origin(expected_type)

# For Union types, we need to check all union members
if origin is Union or (
hasattr(types, "UnionType") and origin is types.UnionType
):
# For Union types, check each union member
type_args = get_args(expected_type)
expected_origins = [get_origin(arg) or arg for arg in type_args]
else:
# For non-Union types, just check the origin
expected_origins = [origin or expected_type]

# Check if any of the expected types is list or dict
if any(exp in (list, dict) for exp in expected_origins):
# Try to parse the string as JSON
try:
parsed_value = json.loads(value)
# json.loads() returns dict, list, str, int, float, bool, or None
# Only use parsed value if it matches expected collection types
if isinstance(parsed_value, (list, dict)):
data[data_key] = parsed_value
except (json.JSONDecodeError, ValueError):
# If parsing fails, leave the original value
# Pydantic will raise validation error if needed
pass

return data

@classmethod
def to_mcp_schema(cls) -> dict[str, Any]:
"""Convert to JSON schema format compatible with MCP."""
Expand Down
Loading