mbxai 1.5.0__tar.gz → 1.6.0__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.
- {mbxai-1.5.0 → mbxai-1.6.0}/.gitignore +2 -1
- {mbxai-1.5.0 → mbxai-1.6.0}/PKG-INFO +1 -1
- {mbxai-1.5.0 → mbxai-1.6.0}/pyproject.toml +2 -2
- {mbxai-1.5.0 → mbxai-1.6.0}/setup.py +1 -1
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/__init__.py +1 -1
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/mcp/server.py +1 -1
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/tools/types.py +32 -7
- mbxai-1.6.0/tests/test_mcp_tool_registration.py +221 -0
- mbxai-1.6.0/tests/test_real_mcp_schema.py +308 -0
- {mbxai-1.5.0 → mbxai-1.6.0/tests}/test_schema_conversion.py +139 -66
- {mbxai-1.5.0 → mbxai-1.6.0}/LICENSE +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/README.md +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/core.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/mcp/mcp_client_example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/mcp/mcp_server_example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/openrouter_example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/parse_example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/parse_tool_example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/request.json +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/response.json +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/send_request.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/examples/tool_client_example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/mcp/__init__.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/mcp/client.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/mcp/example.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/openrouter/__init__.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/openrouter/client.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/openrouter/config.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/openrouter/models.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/openrouter/schema.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/tools/__init__.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/tools/client.py +0 -0
- {mbxai-1.5.0 → mbxai-1.6.0}/src/mbxai/tools/example.py +0 -0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "mbxai"
|
7
|
-
version = "1.
|
7
|
+
version = "1.6.0"
|
8
8
|
authors = [
|
9
9
|
{ name = "MBX AI" }
|
10
10
|
]
|
@@ -82,6 +82,6 @@ strict_equality = true
|
|
82
82
|
|
83
83
|
[dependency-groups]
|
84
84
|
dev = [
|
85
|
-
"build>=1.
|
85
|
+
"build>=1.6.0.post1",
|
86
86
|
"twine>=6.1.0",
|
87
87
|
]
|
@@ -18,7 +18,7 @@ def _resolve_ref(schema: dict[str, Any], ref: str, root_schema: dict[str, Any])
|
|
18
18
|
root_schema: The root schema containing $defs
|
19
19
|
|
20
20
|
Returns:
|
21
|
-
The resolved schema definition
|
21
|
+
The resolved schema definition with all nested $refs also resolved
|
22
22
|
"""
|
23
23
|
if ref.startswith("#/"):
|
24
24
|
# Remove the "#/" prefix and split the path
|
@@ -33,7 +33,11 @@ def _resolve_ref(schema: dict[str, Any], ref: str, root_schema: dict[str, Any])
|
|
33
33
|
logger.warning(f"Could not resolve $ref: {ref}")
|
34
34
|
return {"type": "object", "additionalProperties": False}
|
35
35
|
|
36
|
-
|
36
|
+
if isinstance(current, dict):
|
37
|
+
# Recursively process the resolved schema to handle nested $refs
|
38
|
+
return _convert_property_to_strict(current, root_schema)
|
39
|
+
else:
|
40
|
+
return {"type": "object", "additionalProperties": False}
|
37
41
|
else:
|
38
42
|
logger.warning(f"Unsupported $ref format: {ref}")
|
39
43
|
return {"type": "object", "additionalProperties": False}
|
@@ -46,12 +50,32 @@ def _convert_property_to_strict(prop: dict[str, Any], root_schema: dict[str, Any
|
|
46
50
|
root_schema: The root schema containing $defs
|
47
51
|
|
48
52
|
Returns:
|
49
|
-
A strict format property definition
|
53
|
+
A strict format property definition with all $refs resolved
|
50
54
|
"""
|
51
|
-
# Handle $ref resolution
|
55
|
+
# Handle $ref resolution first - this may return a completely different schema
|
52
56
|
if "$ref" in prop:
|
53
|
-
|
54
|
-
|
57
|
+
# Get the referenced schema path
|
58
|
+
ref = prop["$ref"]
|
59
|
+
if ref.startswith("#/"):
|
60
|
+
path_parts = ref[2:].split("/")
|
61
|
+
current = root_schema
|
62
|
+
|
63
|
+
# Navigate through the path to get the referenced schema
|
64
|
+
for part in path_parts:
|
65
|
+
if isinstance(current, dict) and part in current:
|
66
|
+
current = current[part]
|
67
|
+
else:
|
68
|
+
logger.warning(f"Could not resolve $ref: {ref}")
|
69
|
+
return {"type": "object", "additionalProperties": False}
|
70
|
+
|
71
|
+
if isinstance(current, dict):
|
72
|
+
# Recursively process the resolved schema
|
73
|
+
return _convert_property_to_strict(current, root_schema)
|
74
|
+
else:
|
75
|
+
return {"type": "object", "additionalProperties": False}
|
76
|
+
else:
|
77
|
+
logger.warning(f"Unsupported $ref format: {ref}")
|
78
|
+
return {"type": "object", "additionalProperties": False}
|
55
79
|
|
56
80
|
# Get the property type, defaulting to string
|
57
81
|
prop_type = prop.get("type", "string")
|
@@ -69,7 +93,7 @@ def _convert_property_to_strict(prop: dict[str, Any], root_schema: dict[str, Any
|
|
69
93
|
new_prop["properties"] = {}
|
70
94
|
new_prop["required"] = []
|
71
95
|
|
72
|
-
# Process nested properties
|
96
|
+
# Process nested properties recursively
|
73
97
|
if "properties" in prop:
|
74
98
|
for nested_name, nested_prop in prop["properties"].items():
|
75
99
|
new_prop["properties"][nested_name] = _convert_property_to_strict(nested_prop, root_schema)
|
@@ -81,6 +105,7 @@ def _convert_property_to_strict(prop: dict[str, Any], root_schema: dict[str, Any
|
|
81
105
|
elif prop_type == "array":
|
82
106
|
# Arrays must have items definition
|
83
107
|
if "items" in prop:
|
108
|
+
# Recursively process items schema (this could also have $refs)
|
84
109
|
new_prop["items"] = _convert_property_to_strict(prop["items"], root_schema)
|
85
110
|
else:
|
86
111
|
# Default items schema if missing
|
@@ -0,0 +1,221 @@
|
|
1
|
+
"""
|
2
|
+
Test MCP tool registration flow to identify where $ref schemas are still used.
|
3
|
+
|
4
|
+
This test simulates the server-client registration process to find the bug.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import json
|
8
|
+
import os
|
9
|
+
from typing import Any
|
10
|
+
from pydantic import BaseModel, Field
|
11
|
+
|
12
|
+
# Add the src directory to the path so we can import from mbxai
|
13
|
+
import sys
|
14
|
+
from pathlib import Path
|
15
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
16
|
+
|
17
|
+
from mbxai.mcp.client import MCPTool
|
18
|
+
from mbxai.tools.types import convert_to_strict_schema
|
19
|
+
|
20
|
+
|
21
|
+
class MetadataFilter(BaseModel):
|
22
|
+
"""Model for a single metadata filter key-value pair."""
|
23
|
+
key: str = Field(description="The metadata field name to filter by")
|
24
|
+
value: Any = Field(description="The value to filter for")
|
25
|
+
|
26
|
+
|
27
|
+
class ShopwareKnowledgeSearchInput(BaseModel):
|
28
|
+
"""Input model for Shopware knowledge search."""
|
29
|
+
|
30
|
+
query: str = Field(description="The search query")
|
31
|
+
max_results: int = Field(description="Maximum results (1-20)", ge=1, le=20)
|
32
|
+
include_metadata: bool = Field(description="Whether to include metadata")
|
33
|
+
metadata_filter: list[MetadataFilter] = Field(description="List of metadata filters")
|
34
|
+
|
35
|
+
|
36
|
+
def test_mcp_tool_registration_flow():
|
37
|
+
"""Test the exact MCP tool registration flow to identify $ref issues."""
|
38
|
+
|
39
|
+
print("🔍 Testing MCP Tool Registration Flow")
|
40
|
+
print("=" * 50)
|
41
|
+
|
42
|
+
# Step 1: Simulate what FastMCP generates (like what MCP server gets)
|
43
|
+
print("\n1. Simulating FastMCP schema generation...")
|
44
|
+
fastmcp_schema = ShopwareKnowledgeSearchInput.model_json_schema()
|
45
|
+
|
46
|
+
print("FastMCP Raw Schema:")
|
47
|
+
print(json.dumps(fastmcp_schema, indent=2))
|
48
|
+
|
49
|
+
# Create output directory
|
50
|
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
51
|
+
tmp_dir.mkdir(exist_ok=True)
|
52
|
+
|
53
|
+
# Save FastMCP schema
|
54
|
+
with open(tmp_dir / "fastmcp_raw_schema.json", 'w') as f:
|
55
|
+
json.dump(fastmcp_schema, f, indent=2)
|
56
|
+
|
57
|
+
# Step 2: Simulate what MCP server stores (what goes in Tool.inputSchema)
|
58
|
+
print("\n2. What MCP server stores in Tool.inputSchema...")
|
59
|
+
mcp_server_stored_schema = fastmcp_schema # Server just stores the raw schema
|
60
|
+
|
61
|
+
with open(tmp_dir / "mcp_server_stored_schema.json", 'w') as f:
|
62
|
+
json.dump(mcp_server_stored_schema, f, indent=2)
|
63
|
+
|
64
|
+
# Step 3: Simulate what /tools endpoint returns
|
65
|
+
print("\n3. What /tools endpoint returns...")
|
66
|
+
tools_endpoint_response = {
|
67
|
+
"name": "search_shopware_knowledge",
|
68
|
+
"description": "Search Shopware knowledge base",
|
69
|
+
"inputSchema": mcp_server_stored_schema, # This has $ref!
|
70
|
+
"internal_url": "http://localhost:8000/tools/search_shopware_knowledge/invoke",
|
71
|
+
"service": "shopware-search",
|
72
|
+
"strict": True
|
73
|
+
}
|
74
|
+
|
75
|
+
print("Tools endpoint response:")
|
76
|
+
print(json.dumps(tools_endpoint_response, indent=2))
|
77
|
+
|
78
|
+
with open(tmp_dir / "tools_endpoint_response.json", 'w') as f:
|
79
|
+
json.dump(tools_endpoint_response, f, indent=2)
|
80
|
+
|
81
|
+
# Step 4: Simulate MCPClient creating MCPTool
|
82
|
+
print("\n4. MCPClient creating MCPTool...")
|
83
|
+
try:
|
84
|
+
# This is what MCPClient.register_mcp_server() does
|
85
|
+
mcp_tool = MCPTool(**tools_endpoint_response)
|
86
|
+
|
87
|
+
print("✅ MCPTool created successfully")
|
88
|
+
print(f"MCPTool.inputSchema: {type(mcp_tool.inputSchema)}")
|
89
|
+
|
90
|
+
# Check if inputSchema still has $ref
|
91
|
+
schema_str = json.dumps(mcp_tool.inputSchema)
|
92
|
+
has_ref = "$ref" in schema_str or "$defs" in schema_str
|
93
|
+
print(f"❌ MCPTool.inputSchema still has $ref/$defs: {has_ref}")
|
94
|
+
|
95
|
+
if has_ref:
|
96
|
+
print(" 🚨 PROBLEM: Raw schema with $ref is stored in MCPTool!")
|
97
|
+
|
98
|
+
with open(tmp_dir / "mcp_tool_input_schema.json", 'w') as f:
|
99
|
+
json.dump(mcp_tool.inputSchema, f, indent=2)
|
100
|
+
|
101
|
+
except Exception as e:
|
102
|
+
print(f"❌ Failed to create MCPTool: {e}")
|
103
|
+
return
|
104
|
+
|
105
|
+
# Step 5: Test MCPTool.to_openai_function()
|
106
|
+
print("\n5. Testing MCPTool.to_openai_function()...")
|
107
|
+
try:
|
108
|
+
openai_function = mcp_tool.to_openai_function()
|
109
|
+
|
110
|
+
print("✅ OpenAI function created")
|
111
|
+
|
112
|
+
# Check if the converted schema still has $ref
|
113
|
+
function_schema_str = json.dumps(openai_function["function"]["parameters"])
|
114
|
+
has_ref_after = "$ref" in function_schema_str or "$defs" in function_schema_str
|
115
|
+
|
116
|
+
print(f"❌ Final OpenAI schema still has $ref/$defs: {has_ref_after}")
|
117
|
+
|
118
|
+
if has_ref_after:
|
119
|
+
print(" 🚨 CRITICAL PROBLEM: convert_to_strict_schema didn't resolve $ref!")
|
120
|
+
else:
|
121
|
+
print(" ✅ convert_to_strict_schema successfully resolved $ref")
|
122
|
+
|
123
|
+
# Check if arrays have items
|
124
|
+
def check_arrays_in_schema(schema, path=""):
|
125
|
+
issues = []
|
126
|
+
if isinstance(schema, dict):
|
127
|
+
if schema.get("type") == "array":
|
128
|
+
if "items" not in schema:
|
129
|
+
issues.append(f"Array at {path} missing items")
|
130
|
+
else:
|
131
|
+
print(f" ✅ Array at {path} has items")
|
132
|
+
|
133
|
+
for key, value in schema.items():
|
134
|
+
if key == "properties" and isinstance(value, dict):
|
135
|
+
for prop_name, prop_schema in value.items():
|
136
|
+
issues.extend(check_arrays_in_schema(prop_schema, f"{path}.{prop_name}" if path else prop_name))
|
137
|
+
elif key == "items" and isinstance(value, dict):
|
138
|
+
issues.extend(check_arrays_in_schema(value, f"{path}.items"))
|
139
|
+
return issues
|
140
|
+
|
141
|
+
array_issues = check_arrays_in_schema(openai_function["function"]["parameters"])
|
142
|
+
if array_issues:
|
143
|
+
for issue in array_issues:
|
144
|
+
print(f" ❌ {issue}")
|
145
|
+
|
146
|
+
with open(tmp_dir / "final_openai_function.json", 'w') as f:
|
147
|
+
json.dump(openai_function, f, indent=2)
|
148
|
+
|
149
|
+
print(f"\n📁 Files created in {tmp_dir}:")
|
150
|
+
print(" - fastmcp_raw_schema.json")
|
151
|
+
print(" - mcp_server_stored_schema.json")
|
152
|
+
print(" - tools_endpoint_response.json")
|
153
|
+
print(" - mcp_tool_input_schema.json")
|
154
|
+
print(" - final_openai_function.json")
|
155
|
+
|
156
|
+
return openai_function
|
157
|
+
|
158
|
+
except Exception as e:
|
159
|
+
print(f"❌ Failed to create OpenAI function: {e}")
|
160
|
+
import traceback
|
161
|
+
traceback.print_exc()
|
162
|
+
return None
|
163
|
+
|
164
|
+
def test_direct_schema_conversion():
|
165
|
+
"""Test direct schema conversion to compare with MCP flow."""
|
166
|
+
|
167
|
+
print("\n\n🔧 Testing Direct Schema Conversion (for comparison)")
|
168
|
+
print("=" * 55)
|
169
|
+
|
170
|
+
# Generate the schema
|
171
|
+
schema = ShopwareKnowledgeSearchInput.model_json_schema()
|
172
|
+
|
173
|
+
print("\n1. Direct conversion without input wrapper...")
|
174
|
+
strict_schema = convert_to_strict_schema(schema, strict=True, keep_input_wrapper=False)
|
175
|
+
|
176
|
+
schema_str = json.dumps(strict_schema)
|
177
|
+
has_ref = "$ref" in schema_str or "$defs" in schema_str
|
178
|
+
print(f" Direct conversion has $ref/$defs: {has_ref}")
|
179
|
+
|
180
|
+
print("\n2. Direct conversion with input wrapper...")
|
181
|
+
# Simulate MCP-style wrapper
|
182
|
+
wrapped_schema = {
|
183
|
+
"type": "object",
|
184
|
+
"properties": {
|
185
|
+
"input": schema
|
186
|
+
},
|
187
|
+
"required": ["input"]
|
188
|
+
}
|
189
|
+
|
190
|
+
strict_wrapped = convert_to_strict_schema(wrapped_schema, strict=True, keep_input_wrapper=True)
|
191
|
+
|
192
|
+
wrapped_str = json.dumps(strict_wrapped)
|
193
|
+
has_ref_wrapped = "$ref" in wrapped_str or "$defs" in wrapped_str
|
194
|
+
print(f" Wrapped conversion has $ref/$defs: {has_ref_wrapped}")
|
195
|
+
|
196
|
+
# Save for comparison
|
197
|
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
198
|
+
with open(tmp_dir / "direct_conversion_no_wrapper.json", 'w') as f:
|
199
|
+
json.dump(strict_schema, f, indent=2)
|
200
|
+
|
201
|
+
with open(tmp_dir / "direct_conversion_with_wrapper.json", 'w') as f:
|
202
|
+
json.dump(strict_wrapped, f, indent=2)
|
203
|
+
|
204
|
+
|
205
|
+
if __name__ == "__main__":
|
206
|
+
try:
|
207
|
+
# Test the MCP registration flow
|
208
|
+
result = test_mcp_tool_registration_flow()
|
209
|
+
|
210
|
+
# Test direct conversion for comparison
|
211
|
+
test_direct_schema_conversion()
|
212
|
+
|
213
|
+
if result:
|
214
|
+
print("\n✅ MCP flow test completed - check the JSON files for details")
|
215
|
+
else:
|
216
|
+
print("\n❌ MCP flow test failed")
|
217
|
+
|
218
|
+
except Exception as e:
|
219
|
+
print(f"\n💥 Test failed with error: {e}")
|
220
|
+
import traceback
|
221
|
+
traceback.print_exc()
|
@@ -0,0 +1,308 @@
|
|
1
|
+
"""
|
2
|
+
Test with the real MCP schema structure from production logs.
|
3
|
+
|
4
|
+
This test uses the exact schema structure that was causing the error
|
5
|
+
to verify the nested $ref resolution fix works correctly.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import json
|
9
|
+
import sys
|
10
|
+
from pathlib import Path
|
11
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
12
|
+
|
13
|
+
from mbxai.mcp.client import MCPTool
|
14
|
+
from mbxai.tools.types import convert_to_strict_schema
|
15
|
+
|
16
|
+
|
17
|
+
def test_real_production_schema():
|
18
|
+
"""Test with the exact schema structure from production logs."""
|
19
|
+
|
20
|
+
print("🔍 Testing Real Production MCP Schema")
|
21
|
+
print("=" * 45)
|
22
|
+
|
23
|
+
# This is the exact schema structure from your production logs
|
24
|
+
real_mcp_tool_data = {
|
25
|
+
"description": "Search the Shopware knowledge base for relevant information.\n \n This tool performs semantic search through the Shopware knowledge collection\n to find documentation, guides, and other relevant information based on the query.\n \n Args:\n input: ShopwareKnowledgeSearchInput containing search parameters\n \n Returns:\n ShopwareKnowledgeSearchResponse with search results and metadata\n \n Raises:\n RuntimeError: If the search operation fails\n ",
|
26
|
+
"inputSchema": {
|
27
|
+
"$defs": {
|
28
|
+
"MetadataFilter": {
|
29
|
+
"description": "Model for a single metadata filter key-value pair.\n\nEach filter represents one condition to apply to the search.\nCommon filter keys include:\n- category: Content category (e.g., 'api', 'plugins', 'themes')\n- version: Shopware version (e.g., '6.5', '6.6') \n- title: Document title (partial match)\n- optimized: Whether content was AI-optimized (true/false)\n- source_url: Source URL (partial match)\n- optimization_strategy: Strategy used ('enhance_readability', etc.)\n- ai_generated_metadata: Whether metadata was AI-generated (true/false)",
|
30
|
+
"properties": {
|
31
|
+
"key": {
|
32
|
+
"description": "The metadata field name to filter by",
|
33
|
+
"title": "Key",
|
34
|
+
"type": "string"
|
35
|
+
},
|
36
|
+
"value": {
|
37
|
+
"description": "The value to filter for",
|
38
|
+
"title": "Value"
|
39
|
+
}
|
40
|
+
},
|
41
|
+
"required": [
|
42
|
+
"key",
|
43
|
+
"value"
|
44
|
+
],
|
45
|
+
"title": "MetadataFilter",
|
46
|
+
"type": "object"
|
47
|
+
},
|
48
|
+
"ShopwareKnowledgeSearchInput": {
|
49
|
+
"description": "Input model for Shopware knowledge search.",
|
50
|
+
"properties": {
|
51
|
+
"include_metadata": {
|
52
|
+
"description": "Whether to include metadata in the search results",
|
53
|
+
"title": "Include Metadata",
|
54
|
+
"type": "boolean"
|
55
|
+
},
|
56
|
+
"max_results": {
|
57
|
+
"description": "Maximum number of search results to return (1-20)",
|
58
|
+
"maximum": 20,
|
59
|
+
"minimum": 1,
|
60
|
+
"title": "Max Results",
|
61
|
+
"type": "integer"
|
62
|
+
},
|
63
|
+
"metadata_filter": {
|
64
|
+
"description": "List of metadata filters to apply to the search. Use empty list [] for no filtering, or specify key-value pairs like [{'key': 'category', 'value': 'api'}, {'key': 'version', 'value': '6.5'}]",
|
65
|
+
"items": {
|
66
|
+
"$ref": "#/$defs/MetadataFilter" # This is the problematic nested $ref
|
67
|
+
},
|
68
|
+
"title": "Metadata Filter",
|
69
|
+
"type": "array"
|
70
|
+
},
|
71
|
+
"query": {
|
72
|
+
"description": "The search query to find relevant Shopware knowledge and documentation",
|
73
|
+
"title": "Query",
|
74
|
+
"type": "string"
|
75
|
+
}
|
76
|
+
},
|
77
|
+
"required": [
|
78
|
+
"query",
|
79
|
+
"max_results",
|
80
|
+
"include_metadata",
|
81
|
+
"metadata_filter"
|
82
|
+
],
|
83
|
+
"title": "ShopwareKnowledgeSearchInput",
|
84
|
+
"type": "object"
|
85
|
+
}
|
86
|
+
},
|
87
|
+
"properties": {
|
88
|
+
"input": {
|
89
|
+
"$ref": "#/$defs/ShopwareKnowledgeSearchInput" # This references the above
|
90
|
+
}
|
91
|
+
},
|
92
|
+
"required": [
|
93
|
+
"input"
|
94
|
+
],
|
95
|
+
"title": "search_shopware_knowledgeArguments",
|
96
|
+
"type": "object"
|
97
|
+
},
|
98
|
+
"internal_url": "http://shopware-knowledge.mbxai-mcp.svc.cluster.local:5000/tools/search_shopware_knowledge/invoke",
|
99
|
+
"name": "search_shopware_knowledge",
|
100
|
+
"service": "shopware-knowledge",
|
101
|
+
"strict": True
|
102
|
+
}
|
103
|
+
|
104
|
+
print("\n1. Creating MCPTool with real production schema...")
|
105
|
+
|
106
|
+
try:
|
107
|
+
# Create MCPTool (this should work)
|
108
|
+
mcp_tool = MCPTool(**real_mcp_tool_data)
|
109
|
+
print("✅ MCPTool created successfully")
|
110
|
+
|
111
|
+
# Verify the raw schema still has $refs
|
112
|
+
schema_str = json.dumps(mcp_tool.inputSchema)
|
113
|
+
has_refs = "$ref" in schema_str and "$defs" in schema_str
|
114
|
+
print(f" Raw inputSchema has $ref/$defs: {has_refs}")
|
115
|
+
|
116
|
+
if not has_refs:
|
117
|
+
print(" ⚠️ Warning: Expected raw schema to have $refs")
|
118
|
+
|
119
|
+
except Exception as e:
|
120
|
+
print(f"❌ Failed to create MCPTool: {e}")
|
121
|
+
return False
|
122
|
+
|
123
|
+
print("\n2. Converting to OpenAI function with nested $ref resolution...")
|
124
|
+
|
125
|
+
try:
|
126
|
+
# This is where the magic happens - convert to OpenAI function
|
127
|
+
openai_function = mcp_tool.to_openai_function()
|
128
|
+
print("✅ OpenAI function created successfully")
|
129
|
+
|
130
|
+
# Check if all $refs are resolved
|
131
|
+
function_params = openai_function["function"]["parameters"]
|
132
|
+
params_str = json.dumps(function_params)
|
133
|
+
|
134
|
+
has_refs_after = "$ref" in params_str or "$defs" in params_str
|
135
|
+
print(f" Final schema has $ref/$defs: {has_refs_after}")
|
136
|
+
|
137
|
+
if has_refs_after:
|
138
|
+
print(" ❌ FAIL: Schema conversion didn't resolve all $refs!")
|
139
|
+
return False
|
140
|
+
else:
|
141
|
+
print(" ✅ SUCCESS: All $refs resolved!")
|
142
|
+
|
143
|
+
# Check the specific structure we care about
|
144
|
+
print("\n3. Validating specific schema structure...")
|
145
|
+
|
146
|
+
# Should have input property
|
147
|
+
if "input" not in function_params["properties"]:
|
148
|
+
print(" ❌ Missing 'input' property")
|
149
|
+
return False
|
150
|
+
|
151
|
+
input_schema = function_params["properties"]["input"]
|
152
|
+
print(" ✅ Found 'input' property")
|
153
|
+
|
154
|
+
# Should have metadata_filter array
|
155
|
+
if "metadata_filter" not in input_schema["properties"]:
|
156
|
+
print(" ❌ Missing 'metadata_filter' in input schema")
|
157
|
+
return False
|
158
|
+
|
159
|
+
metadata_filter = input_schema["properties"]["metadata_filter"]
|
160
|
+
print(" ✅ Found 'metadata_filter' property")
|
161
|
+
|
162
|
+
# Should be array type
|
163
|
+
if metadata_filter["type"] != "array":
|
164
|
+
print(f" ❌ metadata_filter type is {metadata_filter['type']}, expected 'array'")
|
165
|
+
return False
|
166
|
+
print(" ✅ metadata_filter is array type")
|
167
|
+
|
168
|
+
# Should have items property
|
169
|
+
if "items" not in metadata_filter:
|
170
|
+
print(" ❌ metadata_filter missing 'items' property")
|
171
|
+
return False
|
172
|
+
print(" ✅ metadata_filter has 'items' property")
|
173
|
+
|
174
|
+
# Items should be object with key/value properties (no $ref)
|
175
|
+
items_schema = metadata_filter["items"]
|
176
|
+
if items_schema["type"] != "object":
|
177
|
+
print(f" ❌ metadata_filter items type is {items_schema['type']}, expected 'object'")
|
178
|
+
return False
|
179
|
+
print(" ✅ metadata_filter items is object type")
|
180
|
+
|
181
|
+
if "key" not in items_schema["properties"] or "value" not in items_schema["properties"]:
|
182
|
+
print(" ❌ metadata_filter items missing 'key' or 'value' properties")
|
183
|
+
return False
|
184
|
+
print(" ✅ metadata_filter items has 'key' and 'value' properties")
|
185
|
+
|
186
|
+
# Check that items schema has no $ref
|
187
|
+
items_str = json.dumps(items_schema)
|
188
|
+
if "$ref" in items_str:
|
189
|
+
print(" ❌ metadata_filter items still contains $ref")
|
190
|
+
return False
|
191
|
+
print(" ✅ metadata_filter items has no $ref")
|
192
|
+
|
193
|
+
# Save the results
|
194
|
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
195
|
+
tmp_dir.mkdir(exist_ok=True)
|
196
|
+
|
197
|
+
with open(tmp_dir / "real_production_schema_raw.json", 'w') as f:
|
198
|
+
json.dump(mcp_tool.inputSchema, f, indent=2)
|
199
|
+
|
200
|
+
with open(tmp_dir / "real_production_schema_converted.json", 'w') as f:
|
201
|
+
json.dump(openai_function, f, indent=2)
|
202
|
+
|
203
|
+
print(f"\n📁 Files saved to {tmp_dir}:")
|
204
|
+
print(" - real_production_schema_raw.json")
|
205
|
+
print(" - real_production_schema_converted.json")
|
206
|
+
|
207
|
+
print("\n🎉 ALL TESTS PASSED! Nested $ref resolution works correctly!")
|
208
|
+
return True
|
209
|
+
|
210
|
+
except Exception as e:
|
211
|
+
print(f"❌ Failed to convert to OpenAI function: {e}")
|
212
|
+
import traceback
|
213
|
+
traceback.print_exc()
|
214
|
+
return False
|
215
|
+
|
216
|
+
|
217
|
+
def test_direct_conversion_with_nested_refs():
|
218
|
+
"""Test direct schema conversion with the nested refs structure."""
|
219
|
+
|
220
|
+
print("\n\n🔧 Testing Direct Conversion with Nested $refs")
|
221
|
+
print("=" * 50)
|
222
|
+
|
223
|
+
# The problematic schema structure
|
224
|
+
nested_ref_schema = {
|
225
|
+
"$defs": {
|
226
|
+
"MetadataFilter": {
|
227
|
+
"type": "object",
|
228
|
+
"properties": {
|
229
|
+
"key": {"type": "string", "description": "Filter key"},
|
230
|
+
"value": {"type": "string", "description": "Filter value"}
|
231
|
+
},
|
232
|
+
"required": ["key", "value"]
|
233
|
+
},
|
234
|
+
"ShopwareInput": {
|
235
|
+
"type": "object",
|
236
|
+
"properties": {
|
237
|
+
"query": {"type": "string", "description": "Search query"},
|
238
|
+
"metadata_filter": {
|
239
|
+
"type": "array",
|
240
|
+
"description": "Metadata filters",
|
241
|
+
"items": {
|
242
|
+
"$ref": "#/$defs/MetadataFilter" # Nested $ref
|
243
|
+
}
|
244
|
+
}
|
245
|
+
},
|
246
|
+
"required": ["query", "metadata_filter"]
|
247
|
+
}
|
248
|
+
},
|
249
|
+
"type": "object",
|
250
|
+
"properties": {
|
251
|
+
"input": {
|
252
|
+
"$ref": "#/$defs/ShopwareInput" # Top-level $ref
|
253
|
+
}
|
254
|
+
},
|
255
|
+
"required": ["input"]
|
256
|
+
}
|
257
|
+
|
258
|
+
print("Testing direct conversion with keep_input_wrapper=True...")
|
259
|
+
|
260
|
+
try:
|
261
|
+
result = convert_to_strict_schema(nested_ref_schema, strict=True, keep_input_wrapper=True)
|
262
|
+
|
263
|
+
# Check no $refs remain
|
264
|
+
result_str = json.dumps(result)
|
265
|
+
has_refs = "$ref" in result_str or "$defs" in result_str
|
266
|
+
|
267
|
+
print(f" Result has $ref/$defs: {has_refs}")
|
268
|
+
|
269
|
+
if has_refs:
|
270
|
+
print(" ❌ Direct conversion still has $refs")
|
271
|
+
return False
|
272
|
+
else:
|
273
|
+
print(" ✅ Direct conversion resolved all $refs")
|
274
|
+
|
275
|
+
# Save result
|
276
|
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
277
|
+
with open(tmp_dir / "direct_nested_ref_conversion.json", 'w') as f:
|
278
|
+
json.dump(result, f, indent=2)
|
279
|
+
|
280
|
+
return True
|
281
|
+
|
282
|
+
except Exception as e:
|
283
|
+
print(f" ❌ Direct conversion failed: {e}")
|
284
|
+
return False
|
285
|
+
|
286
|
+
|
287
|
+
if __name__ == "__main__":
|
288
|
+
try:
|
289
|
+
print("🧪 Testing Nested $ref Resolution Fix")
|
290
|
+
print("=" * 40)
|
291
|
+
|
292
|
+
# Test with real production schema
|
293
|
+
test1_passed = test_real_production_schema()
|
294
|
+
|
295
|
+
# Test direct conversion
|
296
|
+
test2_passed = test_direct_conversion_with_nested_refs()
|
297
|
+
|
298
|
+
if test1_passed and test2_passed:
|
299
|
+
print("\n🎉 ALL TESTS PASSED!")
|
300
|
+
print("The nested $ref resolution fix is working correctly!")
|
301
|
+
else:
|
302
|
+
print("\n❌ SOME TESTS FAILED")
|
303
|
+
print("The fix needs more work.")
|
304
|
+
|
305
|
+
except Exception as e:
|
306
|
+
print(f"\n💥 Test suite failed: {e}")
|
307
|
+
import traceback
|
308
|
+
traceback.print_exc()
|
@@ -6,10 +6,16 @@ when converting Pydantic models to OpenAI function schemas.
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
import json
|
9
|
+
import os
|
9
10
|
from typing import Any
|
10
11
|
from pydantic import BaseModel, Field
|
11
12
|
|
12
|
-
|
13
|
+
# Add the src directory to the path so we can import from mbxai
|
14
|
+
import sys
|
15
|
+
from pathlib import Path
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
17
|
+
|
18
|
+
from mbxai.tools.types import convert_to_strict_schema
|
13
19
|
|
14
20
|
|
15
21
|
class MetadataFilter(BaseModel):
|
@@ -49,8 +55,8 @@ class ShopwareKnowledgeSearchInput(BaseModel):
|
|
49
55
|
)
|
50
56
|
|
51
57
|
|
52
|
-
def
|
53
|
-
"""Test that Pydantic models are properly converted to OpenAI-compatible schemas."""
|
58
|
+
def test_shopware_schema_conversion():
|
59
|
+
"""Test that Shopware knowledge search Pydantic models are properly converted to OpenAI-compatible schemas."""
|
54
60
|
|
55
61
|
print("🧪 Testing Schema Conversion for Shopware Knowledge Search")
|
56
62
|
print("=" * 60)
|
@@ -59,62 +65,53 @@ def test_schema_conversion():
|
|
59
65
|
print("\n1. Generating Pydantic JSON Schema...")
|
60
66
|
pydantic_schema = ShopwareKnowledgeSearchInput.model_json_schema()
|
61
67
|
|
62
|
-
|
63
|
-
print(
|
64
|
-
|
65
|
-
# Test 1: Convert without input wrapper
|
66
|
-
print("\n2. Converting to OpenAI strict schema (no input wrapper)...")
|
67
|
-
strict_schema_no_wrapper = convert_to_strict_schema(
|
68
|
+
# Convert to OpenAI strict schema
|
69
|
+
print("2. Converting to OpenAI strict schema...")
|
70
|
+
strict_schema = convert_to_strict_schema(
|
68
71
|
pydantic_schema,
|
69
72
|
strict=True,
|
70
73
|
keep_input_wrapper=False
|
71
74
|
)
|
72
75
|
|
73
|
-
|
74
|
-
print(
|
75
|
-
|
76
|
-
# Test 2: Convert with input wrapper (MCP style)
|
77
|
-
print("\n3. Converting to OpenAI strict schema (with input wrapper)...")
|
78
|
-
|
79
|
-
# Create MCP-style schema with input wrapper
|
80
|
-
mcp_style_schema = {
|
81
|
-
"type": "object",
|
82
|
-
"properties": {
|
83
|
-
"input": pydantic_schema
|
84
|
-
},
|
85
|
-
"required": ["input"],
|
86
|
-
"additionalProperties": False
|
87
|
-
}
|
88
|
-
|
89
|
-
strict_schema_with_wrapper = convert_to_strict_schema(
|
90
|
-
mcp_style_schema,
|
91
|
-
strict=True,
|
92
|
-
keep_input_wrapper=True
|
93
|
-
)
|
94
|
-
|
95
|
-
print("Strict Schema (with input wrapper):")
|
96
|
-
print(json.dumps(strict_schema_with_wrapper, indent=2))
|
97
|
-
|
98
|
-
# Test 3: Create OpenAI function definition
|
99
|
-
print("\n4. Creating OpenAI Function Definition...")
|
100
|
-
|
76
|
+
# Create OpenAI function definition
|
77
|
+
print("3. Creating OpenAI Function Definition...")
|
101
78
|
function_def = {
|
102
79
|
"type": "function",
|
103
80
|
"function": {
|
104
81
|
"name": "search_shopware_knowledge",
|
105
82
|
"description": "Search Shopware knowledge base for relevant documentation and information",
|
106
|
-
"parameters":
|
83
|
+
"parameters": strict_schema,
|
107
84
|
"strict": True
|
108
85
|
}
|
109
86
|
}
|
110
87
|
|
111
|
-
|
112
|
-
|
88
|
+
# Create local tmp directory for output files
|
89
|
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
90
|
+
tmp_dir.mkdir(exist_ok=True)
|
91
|
+
|
92
|
+
# Create output files in local tmp directory
|
93
|
+
original_schema_file = tmp_dir / "shopware_original_schema.json"
|
94
|
+
strict_schema_file = tmp_dir / "shopware_strict_schema.json"
|
95
|
+
function_def_file = tmp_dir / "shopware_openai_function.json"
|
96
|
+
|
97
|
+
with open(original_schema_file, 'w') as f:
|
98
|
+
json.dump(pydantic_schema, f, indent=2)
|
99
|
+
|
100
|
+
with open(strict_schema_file, 'w') as f:
|
101
|
+
json.dump(strict_schema, f, indent=2)
|
102
|
+
|
103
|
+
with open(function_def_file, 'w') as f:
|
104
|
+
json.dump(function_def, f, indent=2)
|
105
|
+
|
106
|
+
print(f"\n📄 Generated files:")
|
107
|
+
print(f" Original Pydantic Schema: {original_schema_file}")
|
108
|
+
print(f" OpenAI Strict Schema: {strict_schema_file}")
|
109
|
+
print(f" OpenAI Function Definition: {function_def_file}")
|
113
110
|
|
114
111
|
# Validation checks
|
115
|
-
print("\
|
116
|
-
print("✅ Checking that all arrays have 'items' property...")
|
112
|
+
print("\n4. Validation Checks...")
|
117
113
|
|
114
|
+
# Check that all arrays have 'items' property
|
118
115
|
def check_arrays_have_items(schema, path=""):
|
119
116
|
"""Recursively check that all arrays have items property."""
|
120
117
|
issues = []
|
@@ -136,22 +133,18 @@ def test_schema_conversion():
|
|
136
133
|
|
137
134
|
return issues
|
138
135
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
raise AssertionError(f"Schema validation failed: {issues}")
|
144
|
-
else:
|
145
|
-
print(" ✓ All arrays have proper 'items' definitions")
|
136
|
+
print("✅ Checking that all arrays have 'items' property...")
|
137
|
+
issues = check_arrays_have_items(strict_schema)
|
138
|
+
assert not issues, f"Schema validation failed: {issues}"
|
139
|
+
print(" ✓ All arrays have proper 'items' definitions")
|
146
140
|
|
141
|
+
# Check that no $ref or $defs exist
|
147
142
|
print("\n✅ Checking that no $ref or $defs exist...")
|
148
|
-
schema_str = json.dumps(
|
149
|
-
|
150
|
-
|
151
|
-
else:
|
152
|
-
print(" ✓ No $ref or $defs found - schema is fully inlined")
|
143
|
+
schema_str = json.dumps(strict_schema)
|
144
|
+
assert "$ref" not in schema_str and "$defs" not in schema_str, "Schema still contains $ref or $defs"
|
145
|
+
print(" ✓ No $ref or $defs found - schema is fully inlined")
|
153
146
|
|
154
|
-
|
147
|
+
# Check that all objects have additionalProperties: false
|
155
148
|
def check_additional_properties(schema, path=""):
|
156
149
|
"""Recursively check that all objects have additionalProperties: false."""
|
157
150
|
issues = []
|
@@ -175,13 +168,30 @@ def test_schema_conversion():
|
|
175
168
|
|
176
169
|
return issues
|
177
170
|
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
171
|
+
print("\n✅ Checking that all objects have additionalProperties: false...")
|
172
|
+
issues = check_additional_properties(strict_schema)
|
173
|
+
assert not issues, f"additionalProperties validation failed: {issues}"
|
174
|
+
print(" ✓ All objects have additionalProperties: false")
|
175
|
+
|
176
|
+
# Check that constraints are preserved
|
177
|
+
print("\n✅ Checking that constraints are preserved...")
|
178
|
+
max_results_prop = strict_schema["properties"]["max_results"]
|
179
|
+
assert max_results_prop["minimum"] == 1, "minimum constraint not preserved"
|
180
|
+
assert max_results_prop["maximum"] == 20, "maximum constraint not preserved"
|
181
|
+
print(" ✓ Constraints preserved (ge=1, le=20 for max_results)")
|
182
|
+
|
183
|
+
# Check that metadata_filter array has proper MetadataFilter items
|
184
|
+
print("\n✅ Checking metadata_filter array structure...")
|
185
|
+
metadata_filter_prop = strict_schema["properties"]["metadata_filter"]
|
186
|
+
assert metadata_filter_prop["type"] == "array", "metadata_filter should be array"
|
187
|
+
assert "items" in metadata_filter_prop, "metadata_filter array missing items"
|
188
|
+
|
189
|
+
items_schema = metadata_filter_prop["items"]
|
190
|
+
assert items_schema["type"] == "object", "metadata_filter items should be object"
|
191
|
+
assert "key" in items_schema["properties"], "metadata_filter items missing 'key' property"
|
192
|
+
assert "value" in items_schema["properties"], "metadata_filter items missing 'value' property"
|
193
|
+
assert items_schema["required"] == ["key", "value"], "metadata_filter items missing required fields"
|
194
|
+
print(" ✓ metadata_filter array has proper MetadataFilter object items")
|
185
195
|
|
186
196
|
print("\n🎉 All tests passed! Schema is OpenAI/OpenRouter compatible!")
|
187
197
|
print("\nKey improvements:")
|
@@ -190,15 +200,78 @@ def test_schema_conversion():
|
|
190
200
|
print("- ✅ All objects have additionalProperties: false")
|
191
201
|
print("- ✅ Constraints preserved (ge=1, le=20 for max_results)")
|
192
202
|
print("- ✅ Complex nested structures handled correctly")
|
203
|
+
print("- ✅ metadata_filter array properly defines MetadataFilter items")
|
193
204
|
|
194
|
-
|
205
|
+
# Return file paths for further inspection
|
206
|
+
return {
|
207
|
+
"original_schema_file": str(original_schema_file),
|
208
|
+
"strict_schema_file": str(strict_schema_file),
|
209
|
+
"function_def_file": str(function_def_file),
|
210
|
+
"function_definition": function_def
|
211
|
+
}
|
212
|
+
|
213
|
+
|
214
|
+
def test_mcp_style_schema_conversion():
|
215
|
+
"""Test schema conversion with MCP-style input wrapper."""
|
216
|
+
|
217
|
+
print("\n🧪 Testing MCP-Style Schema Conversion")
|
218
|
+
print("=" * 50)
|
219
|
+
|
220
|
+
# Generate the JSON schema from the Pydantic model
|
221
|
+
pydantic_schema = ShopwareKnowledgeSearchInput.model_json_schema()
|
222
|
+
|
223
|
+
# Create MCP-style schema with input wrapper
|
224
|
+
mcp_style_schema = {
|
225
|
+
"type": "object",
|
226
|
+
"properties": {
|
227
|
+
"input": pydantic_schema
|
228
|
+
},
|
229
|
+
"required": ["input"],
|
230
|
+
"additionalProperties": False
|
231
|
+
}
|
232
|
+
|
233
|
+
# Convert with input wrapper
|
234
|
+
strict_schema_with_wrapper = convert_to_strict_schema(
|
235
|
+
mcp_style_schema,
|
236
|
+
strict=True,
|
237
|
+
keep_input_wrapper=True
|
238
|
+
)
|
239
|
+
|
240
|
+
# Create output file in local tmp directory
|
241
|
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
242
|
+
tmp_dir.mkdir(exist_ok=True)
|
243
|
+
|
244
|
+
mcp_schema_file = tmp_dir / "shopware_mcp_style_schema.json"
|
245
|
+
with open(mcp_schema_file, 'w') as f:
|
246
|
+
json.dump(strict_schema_with_wrapper, f, indent=2)
|
247
|
+
|
248
|
+
print(f"📄 MCP-Style Schema: {mcp_schema_file}")
|
249
|
+
|
250
|
+
# Validate MCP-style structure
|
251
|
+
assert "input" in strict_schema_with_wrapper["properties"], "MCP wrapper missing input property"
|
252
|
+
input_schema = strict_schema_with_wrapper["properties"]["input"]
|
253
|
+
assert "metadata_filter" in input_schema["properties"], "Input schema missing metadata_filter"
|
254
|
+
|
255
|
+
print("✅ MCP-style schema conversion successful!")
|
256
|
+
|
257
|
+
return str(mcp_schema_file)
|
195
258
|
|
196
259
|
|
197
260
|
if __name__ == "__main__":
|
198
261
|
try:
|
199
|
-
|
200
|
-
|
201
|
-
|
262
|
+
# Run the main test
|
263
|
+
result = test_shopware_schema_conversion()
|
264
|
+
|
265
|
+
# Run the MCP-style test
|
266
|
+
mcp_file = test_mcp_style_schema_conversion()
|
267
|
+
|
268
|
+
print(f"\n✅ All tests completed successfully!")
|
269
|
+
print(f"\n📁 Check these files to inspect the generated schemas:")
|
270
|
+
print(f" - Original: {result['original_schema_file']}")
|
271
|
+
print(f" - Strict: {result['strict_schema_file']}")
|
272
|
+
print(f" - Function: {result['function_def_file']}")
|
273
|
+
print(f" - MCP Style: {mcp_file}")
|
274
|
+
|
202
275
|
except Exception as e:
|
203
276
|
print(f"\n❌ Test failed: {e}")
|
204
277
|
raise
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|