evidor 1.10.5.dev1__tar.gz → 1.10.6.dev1__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 (102) hide show
  1. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/CHANGELOG.md +14 -0
  2. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/PKG-INFO +1 -1
  3. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/pyproject.toml +1 -1
  4. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/gemini/__init__.py +1 -2
  5. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/gemini/builder.py +95 -74
  6. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/utils/schema.py +29 -1
  7. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_providers.py +62 -4
  8. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_tools.py +24 -0
  9. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/.github/workflows/release.yml +0 -0
  10. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/.gitignore +0 -0
  11. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/AGENTS.md +0 -0
  12. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/CODE_OF_CONDUCT.md +0 -0
  13. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/LICENSE +0 -0
  14. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/README.md +0 -0
  15. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/requirements.txt +0 -0
  16. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/__init__.py +0 -0
  17. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/__init__.py +0 -0
  18. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/core/__init__.py +0 -0
  19. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/core/agent.py +0 -0
  20. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/core/context.py +0 -0
  21. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/core/runner.py +0 -0
  22. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/core/telemetry_bridge.py +0 -0
  23. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/__init__.py +0 -0
  24. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/anthropic/__init__.py +0 -0
  25. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/anthropic/builder.py +0 -0
  26. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/anthropic/parser.py +0 -0
  27. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/anthropic/provider.py +0 -0
  28. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/anthropic/stream.py +0 -0
  29. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/base.py +0 -0
  30. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/client_pool.py +0 -0
  31. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/gemini/parser.py +0 -0
  32. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/gemini/provider.py +0 -0
  33. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/gemini/stream.py +0 -0
  34. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/openai/__init__.py +0 -0
  35. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/openai/builder.py +0 -0
  36. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/openai/parser.py +0 -0
  37. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/openai/provider.py +0 -0
  38. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/agent/providers/openai/stream.py +0 -0
  39. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/constants.py +0 -0
  40. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/errors.py +0 -0
  41. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/mcp/__init__.py +0 -0
  42. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/mcp/client.py +0 -0
  43. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/mcp/config.py +0 -0
  44. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/mcp/runner.py +0 -0
  45. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/mcp/session.py +0 -0
  46. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/models/__init__.py +0 -0
  47. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/models/generation.py +0 -0
  48. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/models/streaming.py +0 -0
  49. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/models/thinking.py +0 -0
  50. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/__init__.py +0 -0
  51. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/adapters/__init__.py +0 -0
  52. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/adapters/langfuse.py +0 -0
  53. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/adapters/opentelemetry.py +0 -0
  54. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/adapters/phoenix.py +0 -0
  55. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/adapters/prometheus.py +0 -0
  56. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/context.py +0 -0
  57. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/events.py +0 -0
  58. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/runtime.py +0 -0
  59. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/semconv.py +0 -0
  60. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/telemetry/sink.py +0 -0
  61. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/__init__.py +0 -0
  62. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/core/__init__.py +0 -0
  63. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/core/tool_executor.py +0 -0
  64. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/core/tools.py +0 -0
  65. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/core/utils.py +0 -0
  66. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/__init__.py +0 -0
  67. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/calculator.py +0 -0
  68. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/evidence_store.py +0 -0
  69. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/filesystem/__init__.py +0 -0
  70. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/filesystem/_common.py +0 -0
  71. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/filesystem/list_files.py +0 -0
  72. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/filesystem/mutate_files.py +0 -0
  73. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/filesystem/read_file.py +0 -0
  74. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/filesystem/search_files.py +0 -0
  75. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/get_current_time.py +0 -0
  76. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/read_webpage.py +0 -0
  77. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/__init__.py +0 -0
  78. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/_base.py +0 -0
  79. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/http.py +0 -0
  80. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/providers/__init__.py +0 -0
  81. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/providers/brave.py +0 -0
  82. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/providers/exa.py +0 -0
  83. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/providers/tavily.py +0 -0
  84. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/tools/native/web_search/tool.py +0 -0
  85. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/utils/__init__.py +0 -0
  86. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/src/evidor/utils/execution.py +0 -0
  87. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_agent.py +0 -0
  88. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_builtin_tools.py +0 -0
  89. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_conversation.py +0 -0
  90. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_filesystem_tools.py +0 -0
  91. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_langfuse_adapter.py +0 -0
  92. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_mcp.py +0 -0
  93. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_models.py +0 -0
  94. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_native_tools.py +0 -0
  95. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_opentelemetry_adapter.py +0 -0
  96. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_phoenix_adapter.py +0 -0
  97. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_prometheus_adapter.py +0 -0
  98. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_streaming.py +0 -0
  99. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_telemetry_events.py +0 -0
  100. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_telemetry_integration.py +0 -0
  101. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_thinking.py +0 -0
  102. {evidor-1.10.5.dev1 → evidor-1.10.6.dev1}/tests/test_web_search.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.10.6-dev.1 (2026-10-30)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **gemini**: Fixing tool schema sanitization issues
10
+ ([`4fb1034`](https://github.com/vedangiitb/evidor-core/commit/4fb103470787d203cc7542c68283f28a724c5041))
11
+
12
+ - **gemini**: Fixing tool schema sanitization issues
13
+ ([`186bdd3`](https://github.com/vedangiitb/evidor-core/commit/186bdd394b08f003953dc362bd020a99004346f9))
14
+
15
+
16
+ ## v1.10.5 (2026-10-29)
17
+
18
+
5
19
  ## v1.10.5-dev.1 (2026-10-29)
6
20
 
7
21
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evidor
3
- Version: 1.10.5-dev.1
3
+ Version: 1.10.6-dev.1
4
4
  Summary: A small, provider-agnostic harness for LLM input and output.
5
5
  Project-URL: Homepage, https://github.com/evidor/evidor-core
6
6
  Project-URL: Repository, https://github.com/evidor/evidor-core
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "evidor"
7
- version = "1.10.5-dev.1"
7
+ version = "1.10.6-dev.1"
8
8
  description = "A small, provider-agnostic harness for LLM input and output."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  """Google Gemini provider package."""
2
2
 
3
- from .builder import GeminiPayloadBuilder, sanitize_gemini_schema
3
+ from .builder import GeminiPayloadBuilder
4
4
  from .parser import GeminiResponseParser
5
5
  from .provider import GeminiProvider
6
6
  from .stream import GeminiStreamHandler
@@ -10,6 +10,5 @@ __all__ = [
10
10
  "GeminiProvider",
11
11
  "GeminiResponseParser",
12
12
  "GeminiStreamHandler",
13
- "sanitize_gemini_schema",
14
13
  ]
15
14
 
@@ -6,6 +6,17 @@ from typing import Any
6
6
  from evidor.models import GenerationRequest, Message
7
7
  from ..base import extract_system_prompt, sort_tools
8
8
 
9
+ _SNAKE_TO_CAMEL: dict[str, str] = {
10
+ "any_of": "anyOf",
11
+ "max_length": "maxLength",
12
+ "min_length": "minLength",
13
+ "max_items": "maxItems",
14
+ "min_items": "minItems",
15
+ "max_properties": "maxProperties",
16
+ "min_properties": "minProperties",
17
+ "property_ordering": "propertyOrdering",
18
+ }
19
+
9
20
  _ALLOWED_GEMINI_SCHEMA_KEYS = {
10
21
  "type",
11
22
  "format",
@@ -17,94 +28,29 @@ _ALLOWED_GEMINI_SCHEMA_KEYS = {
17
28
  "properties",
18
29
  "required",
19
30
  "items",
20
- "any_of",
21
31
  "anyOf",
32
+ "any_of",
22
33
  "maximum",
23
34
  "minimum",
24
- "max_length",
25
35
  "maxLength",
26
- "min_length",
36
+ "max_length",
27
37
  "minLength",
38
+ "min_length",
28
39
  "pattern",
29
- "max_items",
30
40
  "maxItems",
31
- "min_items",
41
+ "max_items",
32
42
  "minItems",
33
- "max_properties",
43
+ "min_items",
34
44
  "maxProperties",
35
- "min_properties",
45
+ "max_properties",
36
46
  "minProperties",
47
+ "min_properties",
37
48
  "example",
38
- "property_ordering",
39
49
  "propertyOrdering",
50
+ "property_ordering",
40
51
  }
41
52
 
42
53
 
43
- def sanitize_gemini_schema(schema: Any, types_mod: Any = None) -> Any:
44
- """Sanitize a JSON schema dictionary for Google GenAI types.Schema."""
45
- if not isinstance(schema, dict):
46
- if isinstance(schema, list):
47
- return [sanitize_gemini_schema(item, types_mod) for item in schema]
48
- return schema
49
-
50
- cleaned: dict[str, Any] = {}
51
-
52
- # Handle 'type' if given as a list (union types, e.g. ['string', 'number', 'boolean'])
53
- raw_type = schema.get("type")
54
- if isinstance(raw_type, list):
55
- types_list = [t for t in raw_type if t != "null"]
56
- has_null = "null" in raw_type
57
- if len(types_list) == 1:
58
- cleaned["type"] = types_list[0]
59
- if has_null:
60
- cleaned["nullable"] = True
61
- elif len(types_list) > 1:
62
- subschemas = [{"type": t} for t in types_list]
63
- if has_null:
64
- cleaned["nullable"] = True
65
- cleaned["anyOf"] = subschemas
66
- elif has_null:
67
- cleaned["type"] = "NULL"
68
- elif raw_type is not None:
69
- cleaned["type"] = raw_type
70
-
71
- # Handle const -> enum
72
- if "const" in schema and "enum" not in schema:
73
- cleaned["enum"] = [schema["const"]]
74
-
75
- for k, v in schema.items():
76
- if k == "type":
77
- continue
78
- if k in ("oneOf", "one_of") and "anyOf" not in cleaned and "any_of" not in cleaned:
79
- cleaned["anyOf"] = (
80
- [sanitize_gemini_schema(it, types_mod) for it in v]
81
- if isinstance(v, list)
82
- else sanitize_gemini_schema(v, types_mod)
83
- )
84
- continue
85
- if k == "examples" and "example" not in schema and isinstance(v, list) and v:
86
- cleaned["example"] = v[0]
87
- continue
88
-
89
- if k == "properties" and isinstance(v, dict):
90
- cleaned["properties"] = {
91
- prop_name: sanitize_gemini_schema(prop_s, types_mod)
92
- for prop_name, prop_s in v.items()
93
- }
94
- elif k == "items":
95
- if isinstance(v, list):
96
- if len(v) == 1:
97
- cleaned["items"] = sanitize_gemini_schema(v[0], types_mod)
98
- elif len(v) > 1:
99
- cleaned["items"] = {"anyOf": [sanitize_gemini_schema(it, types_mod) for it in v]}
100
- else:
101
- cleaned["items"] = sanitize_gemini_schema(v, types_mod)
102
- elif k in _ALLOWED_GEMINI_SCHEMA_KEYS:
103
- cleaned[k] = sanitize_gemini_schema(v, types_mod)
104
-
105
- return cleaned
106
-
107
-
108
54
  class GeminiPayloadBuilder:
109
55
  """Builds and formats API request payloads for Google Gemini Interactions API."""
110
56
 
@@ -118,6 +64,80 @@ class GeminiPayloadBuilder:
118
64
  self.cached_content = cached_content
119
65
  self.thinking = thinking
120
66
 
67
+ def _sanitize_schema(self, schema: Any) -> Any:
68
+ """Sanitize a JSON schema dictionary for Google GenAI types.Schema."""
69
+ if not isinstance(schema, dict):
70
+ if isinstance(schema, list):
71
+ return [self._sanitize_schema(item) for item in schema]
72
+ return schema
73
+
74
+ cleaned: dict[str, Any] = {}
75
+
76
+ # Handle 'type' if given as a list (union types, e.g. ['string', 'number', 'boolean'])
77
+ raw_type = schema.get("type")
78
+ if isinstance(raw_type, list):
79
+ types_list = [t for t in raw_type if t != "null"]
80
+ has_null = "null" in raw_type
81
+ if len(types_list) == 1:
82
+ cleaned["type"] = types_list[0]
83
+ if has_null:
84
+ cleaned["nullable"] = True
85
+ elif len(types_list) > 1:
86
+ subschemas = [{"type": t} for t in types_list]
87
+ if has_null:
88
+ cleaned["nullable"] = True
89
+ cleaned["anyOf"] = subschemas
90
+ elif has_null:
91
+ cleaned["type"] = "NULL"
92
+ elif raw_type is not None:
93
+ cleaned["type"] = raw_type
94
+
95
+ # Handle const -> enum
96
+ if "const" in schema and "enum" not in schema:
97
+ cleaned["enum"] = [schema["const"]]
98
+
99
+ for k, v in schema.items():
100
+ if k == "type":
101
+ continue
102
+ if k in ("oneOf", "one_of") and "anyOf" not in cleaned:
103
+ cleaned["anyOf"] = (
104
+ [self._sanitize_schema(it) for it in v]
105
+ if isinstance(v, list)
106
+ else self._sanitize_schema(v)
107
+ )
108
+ continue
109
+ if k == "examples" and "example" not in schema and isinstance(v, list) and v:
110
+ cleaned["example"] = v[0]
111
+ continue
112
+
113
+ target_k = _SNAKE_TO_CAMEL.get(k, k)
114
+
115
+ if k == "properties" and isinstance(v, dict):
116
+ cleaned["properties"] = {
117
+ prop_name: self._sanitize_schema(prop_s)
118
+ for prop_name, prop_s in v.items()
119
+ }
120
+ elif k == "items":
121
+ if isinstance(v, list):
122
+ if len(v) == 1:
123
+ cleaned["items"] = self._sanitize_schema(v[0])
124
+ elif len(v) > 1:
125
+ cleaned["items"] = {"anyOf": [self._sanitize_schema(it) for it in v]}
126
+ else:
127
+ cleaned["items"] = self._sanitize_schema(v)
128
+ elif k in _ALLOWED_GEMINI_SCHEMA_KEYS:
129
+ cleaned[target_k] = self._sanitize_schema(v)
130
+
131
+ # Ensure ARRAY types have an items schema (required by Gemini API)
132
+ if cleaned.get("type") in ("array", "ARRAY") and "items" not in cleaned:
133
+ cleaned["items"] = {"type": "string"}
134
+
135
+ # Omit empty required lists (Gemini schema validation rejects empty required arrays)
136
+ if "required" in cleaned and not cleaned["required"]:
137
+ del cleaned["required"]
138
+
139
+ return cleaned
140
+
121
141
  def build(self, request: GenerationRequest, types: Any = None) -> dict[str, Any]:
122
142
  """Construct the full request dictionary for Gemini Interactions API."""
123
143
  system_prompt = extract_system_prompt(request)
@@ -142,7 +162,7 @@ class GeminiPayloadBuilder:
142
162
  "type": "function",
143
163
  "name": t.name,
144
164
  "description": t.description,
145
- "parameters": sanitize_gemini_schema(t.parameters, types) if t.parameters else None,
165
+ "parameters": self._sanitize_schema(t.parameters) if t.parameters else None,
146
166
  }
147
167
  for t in sorted_tools
148
168
  ]
@@ -241,3 +261,4 @@ class GeminiPayloadBuilder:
241
261
  })
242
262
  return input_steps
243
263
 
264
+
@@ -70,6 +70,19 @@ def python_type_to_json_type(annotation: Any) -> str:
70
70
  return TYPE_MAPPING.get(annotation, "string")
71
71
 
72
72
 
73
+ def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
74
+ """Unwrap Optional / Union[..., None] and return (inner_type, is_nullable)."""
75
+ origin = get_origin(annotation)
76
+ if origin in (Union, types.UnionType):
77
+ args = get_args(annotation)
78
+ is_nullable = type(None) in args
79
+ non_none_args = [arg for arg in args if arg is not type(None)]
80
+ if len(non_none_args) == 1:
81
+ return non_none_args[0], is_nullable
82
+ return annotation, is_nullable
83
+ return annotation, False
84
+
85
+
73
86
  def generate_parameters_schema(func: Callable[..., Any], param_docs: dict[str, str]) -> dict[str, Any]:
74
87
  """Generate a JSON Schema object describing the function parameters."""
75
88
  sig = inspect.signature(func)
@@ -86,7 +99,22 @@ def generate_parameters_schema(func: Callable[..., Any], param_docs: dict[str, s
86
99
  inspect.Parameter.VAR_KEYWORD,
87
100
  ):
88
101
  continue
89
- prop_schema: dict[str, Any] = {"type": python_type_to_json_type(type_hints.get(name, param.annotation))}
102
+ annotation = type_hints.get(name, param.annotation)
103
+ unwrapped, is_nullable = _unwrap_optional(annotation)
104
+ json_type = python_type_to_json_type(unwrapped)
105
+ prop_schema: dict[str, Any] = {"type": json_type}
106
+ if is_nullable:
107
+ prop_schema["nullable"] = True
108
+
109
+ if json_type == "array":
110
+ item_type = "string"
111
+ item_origin = get_origin(unwrapped)
112
+ if item_origin is not None:
113
+ args = get_args(unwrapped)
114
+ if args and args[0] is not Ellipsis:
115
+ item_type = python_type_to_json_type(args[0])
116
+ prop_schema["items"] = {"type": item_type}
117
+
90
118
  if name in param_docs:
91
119
  prop_schema["description"] = param_docs[name]
92
120
  properties[name] = prop_schema
@@ -1449,30 +1449,88 @@ def test_gemini_payload_builder_thinking_config() -> None:
1449
1449
 
1450
1450
 
1451
1451
  def test_gemini_payload_builder_types_optional() -> None:
1452
- from evidor.agent.providers.gemini.builder import GeminiPayloadBuilder, sanitize_gemini_schema
1452
+ from evidor.agent.providers.gemini.builder import GeminiPayloadBuilder
1453
1453
  from evidor.models import GenerationRequest
1454
1454
  from evidor.tools import Tool
1455
1455
 
1456
- # sanitize_gemini_schema works without types_mod
1456
+ builder = GeminiPayloadBuilder(model="gemini-3.8-flash")
1457
+
1458
+ # _sanitize_schema works as an internal instance method
1457
1459
  schema = {
1458
1460
  "type": "object",
1459
1461
  "properties": {"name": {"type": "string"}},
1460
1462
  "required": ["name"],
1461
1463
  }
1462
- sanitized = sanitize_gemini_schema(schema)
1464
+ sanitized = builder._sanitize_schema(schema)
1463
1465
  assert sanitized["type"] == "object"
1464
1466
  assert "name" in sanitized["properties"]
1465
1467
 
1466
1468
  # builder.build works without types argument
1467
1469
  tool = Tool(name="test_tool", description="A test tool", parameters=schema, func=lambda: None)
1468
1470
  req = GenerationRequest(prompt="Use tool", tools=[tool])
1469
- builder = GeminiPayloadBuilder(model="gemini-3.8-flash")
1470
1471
  payload = builder.build(req)
1471
1472
  assert "cached_content" not in payload
1472
1473
  assert len(payload["tools"]) == 1
1473
1474
  assert payload["tools"][0]["name"] == "test_tool"
1474
1475
 
1475
1476
 
1477
+ def test_gemini_schema_sanitizer_enhancements() -> None:
1478
+ from evidor.agent.providers.gemini.builder import GeminiPayloadBuilder
1479
+
1480
+ builder = GeminiPayloadBuilder(model="gemini-3.8-flash")
1481
+
1482
+ # 1. Bare array missing items gets default string items
1483
+ schema_array = {
1484
+ "type": "object",
1485
+ "properties": {
1486
+ "tags": {"type": "array"},
1487
+ },
1488
+ }
1489
+ sanitized_array = builder._sanitize_schema(schema_array)
1490
+ assert sanitized_array["properties"]["tags"]["items"] == {"type": "string"}
1491
+
1492
+ # 2. Empty required list is omitted
1493
+ schema_empty_req = {
1494
+ "type": "object",
1495
+ "properties": {"opt": {"type": "string"}},
1496
+ "required": [],
1497
+ }
1498
+ sanitized_empty_req = builder._sanitize_schema(schema_empty_req)
1499
+ assert "required" not in sanitized_empty_req
1500
+
1501
+ # 3. Non-empty required list is preserved
1502
+ schema_with_req = {
1503
+ "type": "object",
1504
+ "properties": {"req_field": {"type": "string"}},
1505
+ "required": ["req_field"],
1506
+ }
1507
+ sanitized_with_req = builder._sanitize_schema(schema_with_req)
1508
+ assert sanitized_with_req["required"] == ["req_field"]
1509
+
1510
+ # 4. Normalization of snake_case to camelCase
1511
+ schema_snake = {
1512
+ "type": "object",
1513
+ "properties": {
1514
+ "val": {
1515
+ "max_length": 50,
1516
+ "min_length": 5,
1517
+ "any_of": [{"type": "string"}, {"type": "number"}],
1518
+ }
1519
+ },
1520
+ "property_ordering": ["val"],
1521
+ }
1522
+ sanitized_snake = builder._sanitize_schema(schema_snake)
1523
+ val_prop = sanitized_snake["properties"]["val"]
1524
+ assert "maxLength" in val_prop
1525
+ assert "max_length" not in val_prop
1526
+ assert "minLength" in val_prop
1527
+ assert "min_length" not in val_prop
1528
+ assert "anyOf" in val_prop
1529
+ assert "any_of" not in val_prop
1530
+ assert "propertyOrdering" in sanitized_snake
1531
+ assert "property_ordering" not in sanitized_snake
1532
+
1533
+
1476
1534
  def test_gemini_parser_no_thought_leakage() -> None:
1477
1535
  from evidor.agent.providers.gemini.parser import GeminiResponseParser
1478
1536
 
@@ -257,3 +257,27 @@ def test_async_tool_preserves_contextvars() -> None:
257
257
  asyncio.run(_run())
258
258
  # When executed synchronously outside any active loop, it runs in a new loop and preserves contextvars
259
259
  assert get_token.execute() == "custom_token_123"
260
+
261
+
262
+ def test_tool_nullable_and_array_items() -> None:
263
+ @tool
264
+ def search_records(
265
+ scores: list[int],
266
+ filter_name: Optional[str] = None,
267
+ tags: list = [], # type: ignore[assignment]
268
+ ) -> str:
269
+ """Search records."""
270
+ return "ok"
271
+
272
+ props = search_records.parameters["properties"]
273
+ # Generic list[int] has items: integer
274
+ assert props["scores"]["type"] == "array"
275
+ assert props["scores"]["items"] == {"type": "integer"}
276
+
277
+ # Optional[str] has nullable: True
278
+ assert props["filter_name"]["type"] == "string"
279
+ assert props["filter_name"]["nullable"] is True
280
+
281
+ # Bare list defaults items to string
282
+ assert props["tags"]["type"] == "array"
283
+ assert props["tags"]["items"] == {"type": "string"}
File without changes
File without changes
File without changes
File without changes