From 8fd980f3431e67e539a0af00794116acfc614d73 Mon Sep 17 00:00:00 2001 From: Gaurav Chadha <65453826+Chadha93@users.noreply.github.com> Date: Wed, 12 Nov 2025 19:47:45 +0530 Subject: [PATCH] update: Adds support for recursive schema for `python-sdk` with model selection (#2266) * add-support-for-recursive-schema-python-sdk rebase Signed-off-by: Gaurav Chadha * remove-weak-map Signed-off-by: Gaurav Chadha * Update apps/python-sdk/firecrawl/v1/client.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * handle-circular-ref Signed-off-by: Gaurav Chadha * added unit tests. added some ignores to config for WIPs --------- Signed-off-by: Gaurav Chadha Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: rafaelmmiller <150964962+rafaelsideguide@users.noreply.github.com> --- apps/api/knip.config.ts | 11 +- .../unit/test_recursive_schema_v1.py | 1213 +++++++++++++++++ .../unit/v2/utils/test_recursive_schema.py | 1136 +++++++++++++++ apps/python-sdk/firecrawl/v1/client.py | 366 +++++ .../firecrawl/v2/utils/validation.py | 355 ++++- 5 files changed, 3078 insertions(+), 3 deletions(-) create mode 100644 apps/python-sdk/firecrawl/__tests__/unit/test_recursive_schema_v1.py create mode 100644 apps/python-sdk/firecrawl/__tests__/unit/v2/utils/test_recursive_schema.py diff --git a/apps/api/knip.config.ts b/apps/api/knip.config.ts index 2561af67e..10a850f5e 100644 --- a/apps/api/knip.config.ts +++ b/apps/api/knip.config.ts @@ -7,8 +7,15 @@ const config: KnipConfig = { project: ["src/**/*.ts"], }, }, - ignore: ["native/**"], - ignoreDependencies: ["openai"], + ignore: [ + "native/**", + "src/services/search-index-db.ts", // WIP + "src/lib/search-index-client.ts", // WIP + ], + ignoreDependencies: [ + "openai", + "@pinecone-database/pinecone", // WIP + ], }; export default config; diff --git a/apps/python-sdk/firecrawl/__tests__/unit/test_recursive_schema_v1.py b/apps/python-sdk/firecrawl/__tests__/unit/test_recursive_schema_v1.py new file mode 100644 index 000000000..890ba42ed --- /dev/null +++ b/apps/python-sdk/firecrawl/__tests__/unit/test_recursive_schema_v1.py @@ -0,0 +1,1213 @@ +""" +Unit tests for recursive schema handling in v1 client. +""" +import unittest +import os +from firecrawl.v1.client import V1FirecrawlApp + + +class TestV1RecursiveRefDetection(unittest.TestCase): + """Tests for _contains_recursive_ref method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_no_recursive_ref(self): + """Test schema with no recursive references.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + defs = {} + result = self.app._contains_recursive_ref(schema, "Person", defs) + self.assertFalse(result) + + def test_simple_recursive_ref(self): + """Test schema with simple recursive reference.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "parent": {"$ref": "#/$defs/Person"} + } + } + } + result = self.app._contains_recursive_ref(defs["Person"], "Person", defs) + self.assertTrue(result) + + def test_indirect_recursive_ref(self): + """Test schema with indirect recursive reference.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "owner": {"$ref": "#/$defs/Person"} + } + } + } + result = self.app._contains_recursive_ref(defs["Person"], "Person", defs) + self.assertTrue(result) + + def test_recursive_ref_in_array(self): + """Test schema with recursive reference in array items.""" + defs = { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + result = self.app._contains_recursive_ref(defs["TreeNode"], "TreeNode", defs) + self.assertTrue(result) + + def test_no_ref_in_empty_schema(self): + """Test empty schema returns False.""" + result = self.app._contains_recursive_ref({}, "Person", {}) + self.assertFalse(result) + + def test_no_ref_with_none_input(self): + """Test None input returns False.""" + result = self.app._contains_recursive_ref(None, "Person", {}) + self.assertFalse(result) + + +class TestV1CircularDefsDetection(unittest.TestCase): + """Tests for _check_for_circular_defs method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_no_circular_refs(self): + """Test definitions with no circular references.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"} + } + } + } + result = self.app._check_for_circular_defs(defs) + self.assertFalse(result) + + def test_self_referencing_def(self): + """Test definition that references itself.""" + defs = { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "left": {"$ref": "#/$defs/TreeNode"}, + "right": {"$ref": "#/$defs/TreeNode"} + } + } + } + result = self.app._check_for_circular_defs(defs) + self.assertTrue(result) + + def test_mutually_recursive_defs(self): + """Test mutually recursive definitions.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "resident": {"$ref": "#/$defs/Person"} + } + } + } + result = self.app._check_for_circular_defs(defs) + self.assertTrue(result) + + def test_empty_defs(self): + """Test empty definitions dict.""" + result = self.app._check_for_circular_defs({}) + self.assertFalse(result) + + def test_none_defs(self): + """Test None definitions.""" + result = self.app._check_for_circular_defs(None) + self.assertFalse(result) + + +class TestV1ResolveRefs(unittest.TestCase): + """Tests for _resolve_refs method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_resolve_simple_ref(self): + """Test resolving a simple $ref.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + } + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = self.app._resolve_refs(schema, defs) + self.assertEqual(result["properties"]["person"]["type"], "object") + self.assertIn("name", result["properties"]["person"]["properties"]) + + def test_resolve_nested_refs(self): + """Test resolving nested $refs.""" + schema = { + "type": "object", + "properties": { + "data": {"$ref": "#/$defs/Data"} + } + } + defs = { + "Data": { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + }, + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = self.app._resolve_refs(schema, defs) + self.assertEqual(result["properties"]["data"]["type"], "object") + + def test_resolve_refs_in_array(self): + """Test resolving $refs in array items.""" + schema = { + "type": "array", + "items": {"$ref": "#/$defs/Person"} + } + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = self.app._resolve_refs(schema, defs) + # Result should be a dict with type: array and resolved items + self.assertIsInstance(result, dict) + self.assertEqual(result["type"], "array") + self.assertIn("items", result) + + def test_resolve_refs_max_depth(self): + """Test that resolution stops at max depth.""" + schema = {"$ref": "#/$defs/A"} + defs = { + "A": {"$ref": "#/$defs/B"}, + "B": {"$ref": "#/$defs/C"}, + "C": {"$ref": "#/$defs/D"}, + "D": {"type": "string"} + } + result = self.app._resolve_refs(schema, defs, depth=0) + # Should resolve until depth limit + self.assertIsNotNone(result) + + def test_resolve_refs_with_circular_ref(self): + """Test resolving refs with circular references.""" + schema = {"$ref": "#/$defs/TreeNode"} + defs = { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + # Should not throw error, but may preserve $ref + result = self.app._resolve_refs(schema, defs) + self.assertIsNotNone(result) + + def test_resolve_refs_no_defs(self): + """Test schema with no $defs.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = self.app._resolve_refs(schema, {}) + self.assertEqual(result, schema) + + def test_resolve_refs_with_none(self): + """Test resolving None.""" + result = self.app._resolve_refs(None, {}) + self.assertIsNone(result) + + def test_resolve_refs_skips_defs_key(self): + """Test that $defs key is skipped during resolution.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": {"type": "object"} + } + } + defs = schema["$defs"] + result = self.app._resolve_refs(schema, defs) + # $defs should not be in resolved result + self.assertNotIn("$defs", result) + + +class TestV1NormalizeSchemaForOpenAI(unittest.TestCase): + """Tests for _normalize_schema_for_openai method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_normalize_removes_additional_properties(self): + """Test that additionalProperties: true is removed when properties exist.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + result = self.app._normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result) + + def test_normalize_preserves_additional_properties_false(self): + """Test that additionalProperties: false is preserved.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": False + } + result = self.app._normalize_schema_for_openai(schema) + self.assertIn("additionalProperties", result) + self.assertFalse(result["additionalProperties"]) + + def test_normalize_removes_invalid_required(self): + """Test that required fields not in properties are removed.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name", "age", "email"] + } + result = self.app._normalize_schema_for_openai(schema) + self.assertEqual(result["required"], ["name"]) + + def test_normalize_removes_empty_required(self): + """Test that empty required array is removed.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["age"] + } + result = self.app._normalize_schema_for_openai(schema) + self.assertNotIn("required", result) + + def test_normalize_preserves_ref(self): + """Test that $ref is preserved.""" + schema = { + "$ref": "#/$defs/Person" + } + result = self.app._normalize_schema_for_openai(schema) + self.assertEqual(result, schema) + + def test_normalize_handles_defs(self): + """Test normalization with $defs.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + } + } + result = self.app._normalize_schema_for_openai(schema) + self.assertIn("$defs", result) + self.assertNotIn("additionalProperties", result["$defs"]["Person"]) + + def test_normalize_nested_objects(self): + """Test normalization of nested objects.""" + schema = { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + } + } + result = self.app._normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result["properties"]["person"]) + + def test_normalize_arrays_with_objects(self): + """Test normalization of arrays containing objects.""" + schema = { + "anyOf": [ + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": True + }, + {"type": "string"} + ] + } + result = self.app._normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result["anyOf"][0]) + + def test_normalize_with_none(self): + """Test normalization with None input.""" + result = self.app._normalize_schema_for_openai(None) + self.assertIsNone(result) + + def test_normalize_with_non_dict(self): + """Test normalization with non-dict input.""" + result = self.app._normalize_schema_for_openai("string") + self.assertEqual(result, "string") + + +class TestV1ValidateSchemaForOpenAI(unittest.TestCase): + """Tests for _validate_schema_for_openai method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_valid_schema_with_properties(self): + """Test valid schema with properties defined.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = self.app._validate_schema_for_openai(schema) + self.assertTrue(result) + + def test_valid_schema_with_ref(self): + """Test valid schema with $ref.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + } + result = self.app._validate_schema_for_openai(schema) + self.assertTrue(result) + + def test_invalid_schema_no_properties_with_additional_properties(self): + """Test invalid schema: object with no properties but additionalProperties: true.""" + schema = { + "type": "object", + "additionalProperties": True + } + result = self.app._validate_schema_for_openai(schema) + self.assertFalse(result) + + def test_valid_schema_with_pattern_properties(self): + """Test valid schema with patternProperties.""" + schema = { + "type": "object", + "patternProperties": { + "^[a-z]+$": {"type": "string"} + }, + "additionalProperties": True + } + result = self.app._validate_schema_for_openai(schema) + self.assertTrue(result) + + def test_invalid_nested_schema(self): + """Test invalid nested schema.""" + schema = { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": True + } + } + } + result = self.app._validate_schema_for_openai(schema) + self.assertFalse(result) + + def test_invalid_schema_in_array(self): + """Test invalid schema within array (anyOf/oneOf).""" + schema = { + "anyOf": [ + { + "type": "object", + "additionalProperties": True + }, + {"type": "string"} + ] + } + result = self.app._validate_schema_for_openai(schema) + self.assertFalse(result) + + def test_valid_with_none(self): + """Test validation with None input.""" + result = self.app._validate_schema_for_openai(None) + self.assertTrue(result) + + def test_valid_with_non_dict(self): + """Test validation with non-dict input.""" + result = self.app._validate_schema_for_openai("string") + self.assertTrue(result) + + +class TestV1DetectRecursiveSchema(unittest.TestCase): + """Tests for _detect_recursive_schema method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_detect_ref(self): + """Test detection of $ref.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + } + result = self.app._detect_recursive_schema(schema) + self.assertTrue(result) + + def test_detect_defs(self): + """Test detection of $defs.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + result = self.app._detect_recursive_schema(schema) + self.assertTrue(result) + + def test_detect_definitions(self): + """Test detection of definitions (alternative to $defs).""" + schema = { + "type": "object", + "definitions": { + "Person": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + result = self.app._detect_recursive_schema(schema) + self.assertTrue(result) + + def test_no_recursion(self): + """Test schema with no recursive patterns.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = self.app._detect_recursive_schema(schema) + self.assertFalse(result) + + def test_with_none(self): + """Test with None input.""" + result = self.app._detect_recursive_schema(None) + self.assertFalse(result) + + def test_with_non_dict(self): + """Test with non-dict input.""" + result = self.app._detect_recursive_schema("string") + self.assertFalse(result) + + +class TestV1SelectModelForSchema(unittest.TestCase): + """Tests for _select_model_for_schema method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_no_schema(self): + """Test model selection with no schema.""" + result = self.app._select_model_for_schema(None) + self.assertEqual(result["modelName"], "gpt-4o-mini") + self.assertEqual(result["reason"], "no_schema") + + def test_simple_schema(self): + """Test model selection with simple schema.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = self.app._select_model_for_schema(schema) + self.assertEqual(result["modelName"], "gpt-4o-mini") + self.assertEqual(result["reason"], "simple_schema") + + def test_recursive_schema(self): + """Test model selection with recursive schema.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + result = self.app._select_model_for_schema(schema) + self.assertEqual(result["modelName"], "gpt-4o") + self.assertEqual(result["reason"], "recursive_schema_detected") + + +class TestV1ProcessSchemaWithValidation(unittest.TestCase): + """Tests for _process_schema_with_validation method in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_process_valid_schema(self): + """Test processing a valid schema.""" + schema_container = { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = self.app._process_schema_with_validation(schema_container) + self.assertIn("schema", result) + self.assertIn("_model_info", result) + + def test_process_invalid_schema(self): + """Test processing an invalid schema raises ValueError.""" + schema_container = { + "schema": { + "type": "object", + "additionalProperties": True + } + } + with self.assertRaises(ValueError) as context: + self.app._process_schema_with_validation(schema_container) + self.assertIn("invalid structure for OpenAI", str(context.exception)) + + def test_process_recursive_schema(self): + """Test processing a recursive schema.""" + schema_container = { + "schema": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + }, + "$defs": { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + } + } + result = self.app._process_schema_with_validation(schema_container) + self.assertIn("schema", result) + self.assertIn("_model_info", result) + self.assertEqual(result["_model_info"]["modelName"], "gpt-4o") + + def test_process_resolves_non_recursive_refs(self): + """Test that non-recursive refs are resolved.""" + schema_container = { + "schema": { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + } + result = self.app._process_schema_with_validation(schema_container) + self.assertIn("schema", result) + + def test_process_no_schema_key(self): + """Test processing container without schema key.""" + schema_container = { + "other_key": "value" + } + result = self.app._process_schema_with_validation(schema_container) + self.assertEqual(result, schema_container) + + def test_process_non_dict_container(self): + """Test processing non-dict container.""" + schema_container = "not a dict" + result = self.app._process_schema_with_validation(schema_container) + self.assertEqual(result, schema_container) + + def test_process_with_custom_schema_key(self): + """Test processing with custom schema key.""" + schema_container = { + "custom_schema": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = self.app._process_schema_with_validation( + schema_container, + schema_key="custom_schema" + ) + self.assertIn("custom_schema", result) + self.assertIn("_model_info", result) + + +class TestV1EdgeCases(unittest.TestCase): + """Tests for edge cases and error handling in V1FirecrawlApp.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_deeply_nested_schema(self): + """Test handling of deeply nested schemas.""" + schema = { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + } + } + } + } + } + } + result = self.app._normalize_schema_for_openai(schema) + # Should normalize deeply nested schema + self.assertIsNotNone(result) + + def test_circular_reference_doesnt_hang(self): + """Test that circular references don't cause infinite loops.""" + schema = { + "$ref": "#/$defs/TreeNode", + "$defs": { + "TreeNode": { + "type": "object", + "properties": { + "left": {"$ref": "#/$defs/TreeNode"}, + "right": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + # Should complete without hanging + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + result2 = self.app._validate_schema_for_openai(schema) + self.assertIsNotNone(result2) + + def test_empty_schema(self): + """Test handling of empty schema.""" + schema = {} + result = self.app._normalize_schema_for_openai(schema) + self.assertEqual(result, {}) + + is_valid = self.app._validate_schema_for_openai(schema) + self.assertTrue(is_valid) + + def test_schema_with_complex_anyOf(self): + """Test schema with complex anyOf structures.""" + schema = { + "anyOf": [ + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": True + }, + { + "type": "object", + "properties": {"id": {"type": "number"}} + } + ] + } + result = self.app._normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result["anyOf"][0]) + + def test_openai_schema_error_message_constant(self): + """Test that error message constant exists.""" + self.assertIsNotNone(V1FirecrawlApp.OPENAI_SCHEMA_ERROR_MESSAGE) + self.assertIn("invalid structure for OpenAI", V1FirecrawlApp.OPENAI_SCHEMA_ERROR_MESSAGE) + + +class TestV1RecursionStressTests(unittest.TestCase): + """Stress tests to ensure recursion handling doesn't break under extreme conditions.""" + + def setUp(self): + """Set up test client.""" + self.app = V1FirecrawlApp(api_key=os.environ.get('TEST_API_KEY', 'test-key')) + + def test_very_deep_reference_chain(self): + """Test handling of very deep reference chains (testing depth limits).""" + # Create a chain of 20 references + defs = {} + for i in range(20): + if i == 19: + defs[f"Level{i}"] = {"type": "string"} + else: + defs[f"Level{i}"] = {"$ref": f"#/$defs/Level{i+1}"} + + schema = { + "$ref": "#/$defs/Level0", + "$defs": defs + } + + # Should handle without crashing (may not fully resolve due to depth limits) + result = self.app._resolve_refs(schema, defs) + self.assertIsNotNone(result) + + # Normalization should also complete + normalized = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(normalized) + + def test_multiple_circular_paths(self): + """Test schema with multiple different circular reference paths.""" + defs = { + "Node": { + "type": "object", + "properties": { + "parent": {"$ref": "#/$defs/Node"}, + "child": {"$ref": "#/$defs/Node"}, + "sibling": {"$ref": "#/$defs/Node"}, + "related": {"$ref": "#/$defs/RelatedNode"} + } + }, + "RelatedNode": { + "type": "object", + "properties": { + "backref": {"$ref": "#/$defs/Node"}, + "self": {"$ref": "#/$defs/RelatedNode"} + } + } + } + + # Should detect circular references + has_circular = self.app._check_for_circular_defs(defs) + self.assertTrue(has_circular) + + # Should handle normalization without hanging + schema = {"$ref": "#/$defs/Node", "$defs": defs} + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_recursive_in_oneOf_allOf(self): + """Test recursive references within oneOf and allOf contexts.""" + schema = { + "oneOf": [ + {"$ref": "#/$defs/TypeA"}, + {"$ref": "#/$defs/TypeB"} + ], + "$defs": { + "TypeA": { + "type": "object", + "properties": { + "nested": {"$ref": "#/$defs/TypeA"} + } + }, + "TypeB": { + "type": "object", + "allOf": [ + {"$ref": "#/$defs/TypeA"}, + {"properties": {"extra": {"type": "string"}}} + ] + } + } + } + + # Should handle complex recursive patterns + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + self.assertIn("oneOf", result) + + # Should validate without errors + is_valid = self.app._validate_schema_for_openai(schema) + self.assertTrue(is_valid) + + def test_invalid_reference_doesnt_crash(self): + """Test that invalid/broken references don't crash the system.""" + schema = { + "type": "object", + "properties": { + "broken": {"$ref": "#/$defs/NonExistent"} + }, + "$defs": { + "Existing": {"type": "string"} + } + } + + # Should handle gracefully without crashing + result = self.app._resolve_refs(schema, schema.get("$defs", {})) + self.assertIsNotNone(result) + + normalized = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(normalized) + + def test_malformed_reference_format(self): + """Test handling of malformed $ref formats.""" + schema = { + "type": "object", + "properties": { + "bad1": {"$ref": "not-a-valid-ref"}, + "bad2": {"$ref": "#/wrong/path"}, + "bad3": {"$ref": 12345} # Not even a string + } + } + + # Should handle without crashing + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_linked_list_pattern(self): + """Test real-world pattern: linked list with recursive next pointer.""" + schema = { + "type": "object", + "properties": { + "value": {"type": "string"}, + "next": { + "oneOf": [ + {"$ref": "#/$defs/Node"}, + {"type": "null"} + ] + } + }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "next": { + "oneOf": [ + {"$ref": "#/$defs/Node"}, + {"type": "null"} + ] + } + } + } + } + } + + # Should detect recursion + is_recursive = self.app._detect_recursive_schema(schema) + self.assertTrue(is_recursive) + + # Should select appropriate model + model_info = self.app._select_model_for_schema(schema) + self.assertEqual(model_info["modelName"], "gpt-4o") + + # Should handle normalization + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_graph_pattern_with_multiple_node_types(self): + """Test complex graph pattern with multiple interconnected node types.""" + schema = { + "type": "object", + "properties": { + "nodes": { + "type": "array", + "items": {"$ref": "#/$defs/GraphNode"} + }, + "edges": { + "type": "array", + "items": {"$ref": "#/$defs/Edge"} + } + }, + "$defs": { + "GraphNode": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "neighbors": { + "type": "array", + "items": {"$ref": "#/$defs/GraphNode"} + }, + "edges": { + "type": "array", + "items": {"$ref": "#/$defs/Edge"} + } + } + }, + "Edge": { + "type": "object", + "properties": { + "from": {"$ref": "#/$defs/GraphNode"}, + "to": {"$ref": "#/$defs/GraphNode"} + } + } + } + } + + # Should detect circular references + has_circular = self.app._check_for_circular_defs(schema.get("$defs", {})) + self.assertTrue(has_circular) + + # Should handle without hanging + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + # Should validate + is_valid = self.app._validate_schema_for_openai(schema) + self.assertTrue(is_valid) + + def test_mutual_recursion_three_way(self): + """Test three-way mutual recursion (A->B->C->A).""" + defs = { + "TypeA": { + "type": "object", + "properties": { + "toB": {"$ref": "#/$defs/TypeB"} + } + }, + "TypeB": { + "type": "object", + "properties": { + "toC": {"$ref": "#/$defs/TypeC"} + } + }, + "TypeC": { + "type": "object", + "properties": { + "toA": {"$ref": "#/$defs/TypeA"} + } + } + } + + # Should detect circular references + has_circular = self.app._check_for_circular_defs(defs) + self.assertTrue(has_circular) + + # Should handle without hanging + schema = {"$ref": "#/$defs/TypeA", "$defs": defs} + result = self.app._normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_required_fields_cleanup_without_defs(self): + """Test that required field cleanup works for schemas without $defs.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "number"} + }, + "required": ["name", "age", "nonexistent_field"], + "additionalProperties": True + } + + result = self.app._normalize_schema_for_openai(schema) + + # Should clean up required when properties are fully defined + self.assertIn("required", result) + self.assertEqual(result["required"], ["name", "age"]) + # additionalProperties: true should be removed + self.assertNotIn("additionalProperties", result) + + def test_required_fields_in_nested_defs(self): + """Test that required field cleanup works in nested $defs definitions.""" + schema = { + "type": "object", + "properties": { + "data": {"$ref": "#/$defs/Node"} + }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "nested": {"$ref": "#/$defs/Node"} + }, + "required": ["value", "another_nonexistent"], + "additionalProperties": True + } + } + } + + result = self.app._normalize_schema_for_openai(schema) + + # $defs definitions should be cleaned + self.assertIn("$defs", result) + self.assertIn("Node", result["$defs"]) + node_def = result["$defs"]["Node"] + + # Required should be cleaned in the definition + self.assertIn("required", node_def) + self.assertEqual(node_def["required"], ["value"]) + + # additionalProperties: true should be removed + self.assertNotIn("additionalProperties", node_def) + + def test_required_fields_cleanup_limitation_with_defs(self): + """ + Test documenting current limitation: required field cleanup doesn't happen + at root level when $defs is present due to early return in normalization. + + This test documents the current behavior - if this is considered a bug, + the implementation should be fixed to cleanup required fields at root level + even when $defs is present. + """ + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "child": {"$ref": "#/$defs/Node"} + }, + "required": ["name", "child", "nonexistent_field"], # Has invalid field + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"} + } + } + } + } + + result = self.app._normalize_schema_for_openai(schema) + + # Current behavior: required is NOT cleaned at root when $defs present + # This is because the code returns early when processing $defs + self.assertIn("required", result) + # Documents current behavior - includes invalid field + self.assertIn("nonexistent_field", result["required"]) + + # Note: If this behavior should change, update both the code and this test + + def test_same_object_referenced_multiple_times(self): + """Test that the same object referenced multiple times is handled correctly.""" + person_def = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + + schema = { + "type": "object", + "properties": { + "person1": {"$ref": "#/$defs/Person"}, + "person2": {"$ref": "#/$defs/Person"}, + "person3": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": person_def + } + } + + # Should handle without issues + result = self.app._resolve_refs(schema, schema.get("$defs", {})) + self.assertIsNotNone(result) + + # All three should be resolved + if "properties" in result: + self.assertIn("person1", result["properties"]) + self.assertIn("person2", result["properties"]) + self.assertIn("person3", result["properties"]) + + +if __name__ == '__main__': + unittest.main() + diff --git a/apps/python-sdk/firecrawl/__tests__/unit/v2/utils/test_recursive_schema.py b/apps/python-sdk/firecrawl/__tests__/unit/v2/utils/test_recursive_schema.py new file mode 100644 index 000000000..26b9db05e --- /dev/null +++ b/apps/python-sdk/firecrawl/__tests__/unit/v2/utils/test_recursive_schema.py @@ -0,0 +1,1136 @@ +""" +Unit tests for recursive schema handling in v2 validation utils. +""" +import unittest +from firecrawl.v2.utils.validation import ( + normalize_schema_for_openai, + validate_schema_for_openai, + resolve_refs, + detect_recursive_schema, + select_model_for_schema, + _contains_recursive_ref, + _check_for_circular_defs, + _validate_json_format, + OPENAI_SCHEMA_ERROR_MESSAGE +) + + +class TestRecursiveRefDetection(unittest.TestCase): + """Tests for _contains_recursive_ref function.""" + + def test_no_recursive_ref(self): + """Test schema with no recursive references.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + defs = {} + result = _contains_recursive_ref(schema, "Person", defs) + self.assertFalse(result) + + def test_simple_recursive_ref(self): + """Test schema with simple recursive reference.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "parent": {"$ref": "#/$defs/Person"} + } + } + } + result = _contains_recursive_ref(defs["Person"], "Person", defs) + self.assertTrue(result) + + def test_indirect_recursive_ref(self): + """Test schema with indirect recursive reference.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "owner": {"$ref": "#/$defs/Person"} + } + } + } + result = _contains_recursive_ref(defs["Person"], "Person", defs) + self.assertTrue(result) + + def test_recursive_ref_in_array(self): + """Test schema with recursive reference in array items.""" + defs = { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + result = _contains_recursive_ref(defs["TreeNode"], "TreeNode", defs) + self.assertTrue(result) + + def test_no_ref_in_empty_schema(self): + """Test empty schema returns False.""" + result = _contains_recursive_ref({}, "Person", {}) + self.assertFalse(result) + + def test_no_ref_with_none_input(self): + """Test None input returns False.""" + result = _contains_recursive_ref(None, "Person", {}) + self.assertFalse(result) + + +class TestCircularDefsDetection(unittest.TestCase): + """Tests for _check_for_circular_defs function.""" + + def test_no_circular_refs(self): + """Test definitions with no circular references.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"} + } + } + } + result = _check_for_circular_defs(defs) + self.assertFalse(result) + + def test_self_referencing_def(self): + """Test definition that references itself.""" + defs = { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "left": {"$ref": "#/$defs/TreeNode"}, + "right": {"$ref": "#/$defs/TreeNode"} + } + } + } + result = _check_for_circular_defs(defs) + self.assertTrue(result) + + def test_mutually_recursive_defs(self): + """Test mutually recursive definitions.""" + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "resident": {"$ref": "#/$defs/Person"} + } + } + } + result = _check_for_circular_defs(defs) + self.assertTrue(result) + + def test_empty_defs(self): + """Test empty definitions dict.""" + result = _check_for_circular_defs({}) + self.assertFalse(result) + + def test_none_defs(self): + """Test None definitions.""" + result = _check_for_circular_defs(None) + self.assertFalse(result) + + +class TestResolveRefs(unittest.TestCase): + """Tests for resolve_refs function.""" + + def test_resolve_simple_ref(self): + """Test resolving a simple $ref.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + } + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = resolve_refs(schema, defs) + self.assertEqual(result["properties"]["person"]["type"], "object") + self.assertIn("name", result["properties"]["person"]["properties"]) + + def test_resolve_nested_refs(self): + """Test resolving nested $refs.""" + schema = { + "type": "object", + "properties": { + "data": {"$ref": "#/$defs/Data"} + } + } + defs = { + "Data": { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + }, + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = resolve_refs(schema, defs) + self.assertEqual(result["properties"]["data"]["type"], "object") + + def test_resolve_refs_in_array(self): + """Test resolving $refs in array items.""" + schema = { + "type": "array", + "items": {"$ref": "#/$defs/Person"} + } + defs = { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = resolve_refs(schema, defs) + # Result should be a dict with type: array and resolved items + self.assertIsInstance(result, dict) + self.assertEqual(result["type"], "array") + self.assertIn("items", result) + + def test_resolve_refs_max_depth(self): + """Test that resolution stops at max depth.""" + schema = {"$ref": "#/$defs/A"} + defs = { + "A": {"$ref": "#/$defs/B"}, + "B": {"$ref": "#/$defs/C"}, + "C": {"$ref": "#/$defs/D"}, + "D": {"type": "string"} + } + result = resolve_refs(schema, defs, depth=0) + # Should resolve until depth limit + self.assertIsNotNone(result) + + def test_resolve_refs_with_circular_ref(self): + """Test resolving refs with circular references.""" + schema = {"$ref": "#/$defs/TreeNode"} + defs = { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + # Should not throw error, but may preserve $ref + result = resolve_refs(schema, defs) + self.assertIsNotNone(result) + + def test_resolve_refs_no_defs(self): + """Test schema with no $defs.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = resolve_refs(schema, {}) + self.assertEqual(result, schema) + + def test_resolve_refs_with_none(self): + """Test resolving None.""" + result = resolve_refs(None, {}) + self.assertIsNone(result) + + +class TestNormalizeSchemaForOpenAI(unittest.TestCase): + """Tests for normalize_schema_for_openai function.""" + + def test_normalize_removes_additional_properties(self): + """Test that additionalProperties: true is removed when properties exist.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + result = normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result) + + def test_normalize_preserves_additional_properties_false(self): + """Test that additionalProperties: false is preserved.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": False + } + result = normalize_schema_for_openai(schema) + self.assertIn("additionalProperties", result) + self.assertFalse(result["additionalProperties"]) + + def test_normalize_removes_invalid_required(self): + """Test that required fields not in properties are removed.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name", "age", "email"] + } + result = normalize_schema_for_openai(schema) + self.assertEqual(result["required"], ["name"]) + + def test_normalize_removes_empty_required(self): + """Test that empty required array is removed.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["age"] + } + result = normalize_schema_for_openai(schema) + self.assertNotIn("required", result) + + def test_normalize_preserves_ref(self): + """Test that $ref is preserved.""" + schema = { + "$ref": "#/$defs/Person" + } + result = normalize_schema_for_openai(schema) + self.assertEqual(result, schema) + + def test_normalize_handles_defs(self): + """Test normalization with $defs.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + } + } + result = normalize_schema_for_openai(schema) + self.assertIn("$defs", result) + self.assertNotIn("additionalProperties", result["$defs"]["Person"]) + + def test_normalize_nested_objects(self): + """Test normalization of nested objects.""" + schema = { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + } + } + result = normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result["properties"]["person"]) + + def test_normalize_arrays_with_objects(self): + """Test normalization of arrays containing objects.""" + schema = { + "anyOf": [ + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": True + }, + {"type": "string"} + ] + } + result = normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result["anyOf"][0]) + + def test_normalize_with_none(self): + """Test normalization with None input.""" + result = normalize_schema_for_openai(None) + self.assertIsNone(result) + + def test_normalize_with_non_dict(self): + """Test normalization with non-dict input.""" + result = normalize_schema_for_openai("string") + self.assertEqual(result, "string") + + +class TestValidateSchemaForOpenAI(unittest.TestCase): + """Tests for validate_schema_for_openai function.""" + + def test_valid_schema_with_properties(self): + """Test valid schema with properties defined.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = validate_schema_for_openai(schema) + self.assertTrue(result) + + def test_valid_schema_with_ref(self): + """Test valid schema with $ref.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + } + result = validate_schema_for_openai(schema) + self.assertTrue(result) + + def test_invalid_schema_no_properties_with_additional_properties(self): + """Test invalid schema: object with no properties but additionalProperties: true.""" + schema = { + "type": "object", + "additionalProperties": True + } + result = validate_schema_for_openai(schema) + self.assertFalse(result) + + def test_valid_schema_with_pattern_properties(self): + """Test valid schema with patternProperties.""" + schema = { + "type": "object", + "patternProperties": { + "^[a-z]+$": {"type": "string"} + }, + "additionalProperties": True + } + result = validate_schema_for_openai(schema) + self.assertTrue(result) + + def test_invalid_nested_schema(self): + """Test invalid nested schema.""" + schema = { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": True + } + } + } + result = validate_schema_for_openai(schema) + self.assertFalse(result) + + def test_invalid_schema_in_array(self): + """Test invalid schema within array (anyOf/oneOf).""" + schema = { + "anyOf": [ + { + "type": "object", + "additionalProperties": True + }, + {"type": "string"} + ] + } + result = validate_schema_for_openai(schema) + self.assertFalse(result) + + def test_valid_with_none(self): + """Test validation with None input.""" + result = validate_schema_for_openai(None) + self.assertTrue(result) + + def test_valid_with_non_dict(self): + """Test validation with non-dict input.""" + result = validate_schema_for_openai("string") + self.assertTrue(result) + + +class TestDetectRecursiveSchema(unittest.TestCase): + """Tests for detect_recursive_schema function.""" + + def test_detect_ref(self): + """Test detection of $ref.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + } + } + result = detect_recursive_schema(schema) + self.assertTrue(result) + + def test_detect_defs(self): + """Test detection of $defs.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + result = detect_recursive_schema(schema) + self.assertTrue(result) + + def test_detect_definitions(self): + """Test detection of definitions (alternative to $defs).""" + schema = { + "type": "object", + "definitions": { + "Person": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + result = detect_recursive_schema(schema) + self.assertTrue(result) + + def test_no_recursion(self): + """Test schema with no recursive patterns.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = detect_recursive_schema(schema) + self.assertFalse(result) + + def test_with_none(self): + """Test with None input.""" + result = detect_recursive_schema(None) + self.assertFalse(result) + + def test_with_non_dict(self): + """Test with non-dict input.""" + result = detect_recursive_schema("string") + self.assertFalse(result) + + +class TestSelectModelForSchema(unittest.TestCase): + """Tests for select_model_for_schema function.""" + + def test_no_schema(self): + """Test model selection with no schema.""" + result = select_model_for_schema(None) + self.assertEqual(result["modelName"], "gpt-4o-mini") + self.assertEqual(result["reason"], "no_schema") + + def test_simple_schema(self): + """Test model selection with simple schema.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + result = select_model_for_schema(schema) + self.assertEqual(result["modelName"], "gpt-4o-mini") + self.assertEqual(result["reason"], "simple_schema") + + def test_recursive_schema(self): + """Test model selection with recursive schema.""" + schema = { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + result = select_model_for_schema(schema) + self.assertEqual(result["modelName"], "gpt-4o") + self.assertEqual(result["reason"], "recursive_schema_detected") + + +class TestValidateJsonFormat(unittest.TestCase): + """Tests for _validate_json_format function integration.""" + + def test_validate_json_format_with_valid_schema(self): + """Test JSON format validation with valid schema.""" + format_obj = { + "type": "json", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + result = _validate_json_format(format_obj) + self.assertIn("schema", result) + self.assertIn("_model_info", result) + + def test_validate_json_format_with_invalid_schema(self): + """Test JSON format validation with invalid schema.""" + format_obj = { + "type": "json", + "schema": { + "type": "object", + "additionalProperties": True + } + } + with self.assertRaises(ValueError) as context: + _validate_json_format(format_obj) + self.assertIn("invalid structure for OpenAI", str(context.exception)) + + def test_validate_json_format_with_recursive_schema(self): + """Test JSON format validation with recursive schema.""" + format_obj = { + "type": "json", + "schema": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + }, + "$defs": { + "TreeNode": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + } + } + result = _validate_json_format(format_obj) + self.assertIn("schema", result) + self.assertIn("_model_info", result) + self.assertEqual(result["_model_info"]["modelName"], "gpt-4o") + + def test_validate_json_format_resolves_non_recursive_refs(self): + """Test that non-recursive refs are resolved.""" + format_obj = { + "type": "json", + "schema": { + "type": "object", + "properties": { + "person": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + } + result = _validate_json_format(format_obj) + # After resolution, $defs should be removed if all refs are resolved + # The exact behavior depends on implementation + self.assertIn("schema", result) + + +class TestEdgeCases(unittest.TestCase): + """Tests for edge cases and error handling.""" + + def test_deeply_nested_schema(self): + """Test handling of deeply nested schemas.""" + schema = { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": True + } + } + } + } + } + } + } + result = normalize_schema_for_openai(schema) + # Should normalize deeply nested schema + self.assertIsNotNone(result) + + def test_circular_reference_doesnt_hang(self): + """Test that circular references don't cause infinite loops.""" + schema = { + "$ref": "#/$defs/TreeNode", + "$defs": { + "TreeNode": { + "type": "object", + "properties": { + "left": {"$ref": "#/$defs/TreeNode"}, + "right": {"$ref": "#/$defs/TreeNode"} + } + } + } + } + # Should complete without hanging + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + result2 = validate_schema_for_openai(schema) + self.assertIsNotNone(result2) + + def test_empty_schema(self): + """Test handling of empty schema.""" + schema = {} + result = normalize_schema_for_openai(schema) + self.assertEqual(result, {}) + + is_valid = validate_schema_for_openai(schema) + self.assertTrue(is_valid) + + def test_schema_with_complex_anyOf(self): + """Test schema with complex anyOf structures.""" + schema = { + "anyOf": [ + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": True + }, + { + "type": "object", + "properties": {"id": {"type": "number"}} + } + ] + } + result = normalize_schema_for_openai(schema) + self.assertNotIn("additionalProperties", result["anyOf"][0]) + + +class TestRecursionStressTests(unittest.TestCase): + """Stress tests to ensure recursion handling doesn't break under extreme conditions.""" + + def test_very_deep_reference_chain(self): + """Test handling of very deep reference chains (testing depth limits).""" + # Create a chain of 20 references + defs = {} + for i in range(20): + if i == 19: + defs[f"Level{i}"] = {"type": "string"} + else: + defs[f"Level{i}"] = {"$ref": f"#/$defs/Level{i+1}"} + + schema = { + "$ref": "#/$defs/Level0", + "$defs": defs + } + + # Should handle without crashing (may not fully resolve due to depth limits) + result = resolve_refs(schema, defs) + self.assertIsNotNone(result) + + # Normalization should also complete + normalized = normalize_schema_for_openai(schema) + self.assertIsNotNone(normalized) + + def test_multiple_circular_paths(self): + """Test schema with multiple different circular reference paths.""" + defs = { + "Node": { + "type": "object", + "properties": { + "parent": {"$ref": "#/$defs/Node"}, + "child": {"$ref": "#/$defs/Node"}, + "sibling": {"$ref": "#/$defs/Node"}, + "related": {"$ref": "#/$defs/RelatedNode"} + } + }, + "RelatedNode": { + "type": "object", + "properties": { + "backref": {"$ref": "#/$defs/Node"}, + "self": {"$ref": "#/$defs/RelatedNode"} + } + } + } + + # Should detect circular references + has_circular = _check_for_circular_defs(defs) + self.assertTrue(has_circular) + + # Should handle normalization without hanging + schema = {"$ref": "#/$defs/Node", "$defs": defs} + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_recursive_in_oneOf_allOf(self): + """Test recursive references within oneOf and allOf contexts.""" + schema = { + "oneOf": [ + {"$ref": "#/$defs/TypeA"}, + {"$ref": "#/$defs/TypeB"} + ], + "$defs": { + "TypeA": { + "type": "object", + "properties": { + "nested": {"$ref": "#/$defs/TypeA"} + } + }, + "TypeB": { + "type": "object", + "allOf": [ + {"$ref": "#/$defs/TypeA"}, + {"properties": {"extra": {"type": "string"}}} + ] + } + } + } + + # Should handle complex recursive patterns + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + self.assertIn("oneOf", result) + + # Should validate without errors + is_valid = validate_schema_for_openai(schema) + self.assertTrue(is_valid) + + def test_invalid_reference_doesnt_crash(self): + """Test that invalid/broken references don't crash the system.""" + schema = { + "type": "object", + "properties": { + "broken": {"$ref": "#/$defs/NonExistent"} + }, + "$defs": { + "Existing": {"type": "string"} + } + } + + # Should handle gracefully without crashing + result = resolve_refs(schema, schema.get("$defs", {})) + self.assertIsNotNone(result) + + normalized = normalize_schema_for_openai(schema) + self.assertIsNotNone(normalized) + + def test_malformed_reference_format(self): + """Test handling of malformed $ref formats.""" + schema = { + "type": "object", + "properties": { + "bad1": {"$ref": "not-a-valid-ref"}, + "bad2": {"$ref": "#/wrong/path"}, + "bad3": {"$ref": 12345} # Not even a string + } + } + + # Should handle without crashing + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_linked_list_pattern(self): + """Test real-world pattern: linked list with recursive next pointer.""" + schema = { + "type": "object", + "properties": { + "value": {"type": "string"}, + "next": { + "oneOf": [ + {"$ref": "#/$defs/Node"}, + {"type": "null"} + ] + } + }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "next": { + "oneOf": [ + {"$ref": "#/$defs/Node"}, + {"type": "null"} + ] + } + } + } + } + } + + # Should detect recursion + is_recursive = detect_recursive_schema(schema) + self.assertTrue(is_recursive) + + # Should select appropriate model + model_info = select_model_for_schema(schema) + self.assertEqual(model_info["modelName"], "gpt-4o") + + # Should handle normalization + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_graph_pattern_with_multiple_node_types(self): + """Test complex graph pattern with multiple interconnected node types.""" + schema = { + "type": "object", + "properties": { + "nodes": { + "type": "array", + "items": {"$ref": "#/$defs/GraphNode"} + }, + "edges": { + "type": "array", + "items": {"$ref": "#/$defs/Edge"} + } + }, + "$defs": { + "GraphNode": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "neighbors": { + "type": "array", + "items": {"$ref": "#/$defs/GraphNode"} + }, + "edges": { + "type": "array", + "items": {"$ref": "#/$defs/Edge"} + } + } + }, + "Edge": { + "type": "object", + "properties": { + "from": {"$ref": "#/$defs/GraphNode"}, + "to": {"$ref": "#/$defs/GraphNode"} + } + } + } + } + + # Should detect circular references + has_circular = _check_for_circular_defs(schema.get("$defs", {})) + self.assertTrue(has_circular) + + # Should handle without hanging + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + # Should validate + is_valid = validate_schema_for_openai(schema) + self.assertTrue(is_valid) + + def test_mutual_recursion_three_way(self): + """Test three-way mutual recursion (A->B->C->A).""" + defs = { + "TypeA": { + "type": "object", + "properties": { + "toB": {"$ref": "#/$defs/TypeB"} + } + }, + "TypeB": { + "type": "object", + "properties": { + "toC": {"$ref": "#/$defs/TypeC"} + } + }, + "TypeC": { + "type": "object", + "properties": { + "toA": {"$ref": "#/$defs/TypeA"} + } + } + } + + # Should detect circular references + has_circular = _check_for_circular_defs(defs) + self.assertTrue(has_circular) + + # Should handle without hanging + schema = {"$ref": "#/$defs/TypeA", "$defs": defs} + result = normalize_schema_for_openai(schema) + self.assertIsNotNone(result) + + def test_required_fields_cleanup_without_defs(self): + """Test that required field cleanup works for schemas without $defs.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "number"} + }, + "required": ["name", "age", "nonexistent_field"], + "additionalProperties": True + } + + result = normalize_schema_for_openai(schema) + + # Should clean up required when properties are fully defined + self.assertIn("required", result) + self.assertEqual(result["required"], ["name", "age"]) + # additionalProperties: true should be removed + self.assertNotIn("additionalProperties", result) + + def test_required_fields_in_nested_defs(self): + """Test that required field cleanup works in nested $defs definitions.""" + schema = { + "type": "object", + "properties": { + "data": {"$ref": "#/$defs/Node"} + }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "nested": {"$ref": "#/$defs/Node"} + }, + "required": ["value", "another_nonexistent"], + "additionalProperties": True + } + } + } + + result = normalize_schema_for_openai(schema) + + # $defs definitions should be cleaned + self.assertIn("$defs", result) + self.assertIn("Node", result["$defs"]) + node_def = result["$defs"]["Node"] + + # Required should be cleaned in the definition + self.assertIn("required", node_def) + self.assertEqual(node_def["required"], ["value"]) + + # additionalProperties: true should be removed + self.assertNotIn("additionalProperties", node_def) + + def test_required_fields_cleanup_limitation_with_defs(self): + """ + Test documenting current limitation: required field cleanup doesn't happen + at root level when $defs is present due to early return in normalization. + + This test documents the current behavior - if this is considered a bug, + the implementation should be fixed to cleanup required fields at root level + even when $defs is present. + """ + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "child": {"$ref": "#/$defs/Node"} + }, + "required": ["name", "child", "nonexistent_field"], # Has invalid field + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"} + } + } + } + } + + result = normalize_schema_for_openai(schema) + + # Current behavior: required is NOT cleaned at root when $defs present + # This is because the code returns early when processing $defs + self.assertIn("required", result) + # Documents current behavior - includes invalid field + self.assertIn("nonexistent_field", result["required"]) + + # Note: If this behavior should change, update both the code and this test + + def test_same_object_referenced_multiple_times(self): + """Test that the same object referenced multiple times is handled correctly.""" + person_def = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + + schema = { + "type": "object", + "properties": { + "person1": {"$ref": "#/$defs/Person"}, + "person2": {"$ref": "#/$defs/Person"}, + "person3": {"$ref": "#/$defs/Person"} + }, + "$defs": { + "Person": person_def + } + } + + # Should handle without issues + result = resolve_refs(schema, schema.get("$defs", {})) + self.assertIsNotNone(result) + + # All three should be resolved + if "properties" in result: + self.assertIn("person1", result["properties"]) + self.assertIn("person2", result["properties"]) + self.assertIn("person3", result["properties"]) + + +if __name__ == '__main__': + unittest.main() + diff --git a/apps/python-sdk/firecrawl/v1/client.py b/apps/python-sdk/firecrawl/v1/client.py index bc0af1ebf..36b0806c3 100644 --- a/apps/python-sdk/firecrawl/v1/client.py +++ b/apps/python-sdk/firecrawl/v1/client.py @@ -499,6 +499,14 @@ class V1FirecrawlApp: This is used by the unified client to provide version-specific access through app.v1.method_name() patterns. """ + + # OpenAI schema validation error message + OPENAI_SCHEMA_ERROR_MESSAGE = ( + "Schema contains invalid structure for OpenAI: object type with no 'properties' defined " + "but 'additionalProperties: true' (schema-less dictionary not supported by OpenAI). " + "Please define specific properties for your object. Note: Recursive schemas using '$ref' are supported." + ) + def __init__(self, api_key: Optional[str] = None, api_url: Optional[str] = None) -> None: """ Initialize the V1FirecrawlApp instance with API key, API URL. @@ -2808,6 +2816,364 @@ class V1FirecrawlApp: return [self._ensure_schema_dict(v) for v in schema] return schema + def _contains_recursive_ref(self, obj, target_def_name, defs, visited=None): + """ + Check if an object contains a recursive reference to a specific definition. + + Args: + obj: Object to check + target_def_name: Name of the definition to check for recursion + defs: Dictionary of definitions + visited: Set of visited object keys to detect cycles + + Returns: + True if recursive reference is found, False otherwise + """ + if not obj or not isinstance(obj, (dict, list)): + return False + + if visited is None: + visited = set() + + import json + obj_key = json.dumps(obj, sort_keys=True, default=str) + if obj_key in visited: + return False + visited.add(obj_key) + + try: + if isinstance(obj, dict): + if "$ref" in obj and isinstance(obj["$ref"], str): + ref_path = obj["$ref"].split("/") + if len(ref_path) >= 3 and ref_path[0] == "#" and ref_path[1] == "$defs": + def_name = ref_path[-1] + if def_name == target_def_name: + return True + if def_name in defs: + return self._contains_recursive_ref(defs[def_name], target_def_name, defs, visited) + + for value in obj.values(): + if self._contains_recursive_ref(value, target_def_name, defs, visited): + return True + + elif isinstance(obj, list): + for item in obj: + if self._contains_recursive_ref(item, target_def_name, defs, visited): + return True + + finally: + visited.discard(obj_key) + + return False + + def _check_for_circular_defs(self, defs): + """ + Check if $defs contain circular references. + + Args: + defs: Dictionary of definitions to check + + Returns: + True if circular references are found, False otherwise + """ + if not defs: + return False + + for def_name, def_value in defs.items(): + if self._contains_recursive_ref(def_value, def_name, defs): + return True + + return False + + def _resolve_refs(self, obj, defs, visited=None, depth=0): + """ + Resolve $ref references in a JSON schema object. + + Args: + obj: Object to resolve references in + defs: Dictionary of definitions + visited: Set to track visited objects and prevent infinite recursion + depth: Current recursion depth + + Returns: + Object with resolved references + """ + if not obj or not isinstance(obj, (dict, list)) or depth > 10: + return obj + + if visited is None: + visited = set() + + obj_id = id(obj) + if obj_id in visited: + return obj + + visited.add(obj_id) + + try: + if isinstance(obj, dict): + if "$ref" in obj and isinstance(obj["$ref"], str): + ref_path = obj["$ref"].split("/") + if len(ref_path) >= 3 and ref_path[0] == "#" and ref_path[1] == "$defs": + def_name = ref_path[-1] + if def_name in defs: + return self._resolve_refs(dict(defs[def_name]), defs, visited, depth + 1) + return obj + + resolved = {} + for key, value in obj.items(): + if key == "$defs": + continue + resolved[key] = self._resolve_refs(value, defs, visited, depth + 1) + return resolved + + elif isinstance(obj, list): + return [self._resolve_refs(item, defs, visited, depth + 1) for item in obj] + + finally: + visited.discard(obj_id) + + return obj + + def _normalize_schema_for_openai(self, schema): + """ + Normalize a schema for OpenAI compatibility by handling recursive references. + + Args: + schema: Schema to normalize + + Returns: + Normalized schema + """ + if not schema or not isinstance(schema, dict): + return schema + + visited = set() + + def normalize_object(obj): + if not isinstance(obj, dict): + if isinstance(obj, list): + return [normalize_object(item) for item in obj] + return obj + + # Create a unique key for this object to track visitation + obj_id = id(obj) + if obj_id in visited: + return obj + visited.add(obj_id) + + normalized = dict(obj) + + # Handle $ref recursion - preserve as-is for OpenAI compatibility + if "$ref" in normalized: + visited.discard(obj_id) + return normalized + + if "$defs" in normalized: + defs = normalized.pop("$defs") + processed_rest = {} + + for key, value in normalized.items(): + if (isinstance(value, dict) and "$ref" not in value): + processed_rest[key] = normalize_object(value) + else: + processed_rest[key] = value + + result = {**processed_rest, "$defs": {k: normalize_object(v) for k, v in defs.items()}} + visited.discard(obj_id) + return result + + # Only remove additionalProperties when it's explicitly True + if (normalized.get("type") == "object" and + "properties" in normalized and + normalized.get("additionalProperties") is True): + del normalized["additionalProperties"] + + if (normalized.get("type") == "object" and + "required" in normalized and + "properties" in normalized): + if (isinstance(normalized["required"], list) and + isinstance(normalized["properties"], dict)): + valid_required = [field for field in normalized["required"] + if field in normalized["properties"]] + if valid_required: + normalized["required"] = valid_required + else: + del normalized["required"] + else: + del normalized["required"] + + # Handle nested schemas including arrays (anyOf/oneOf/items) + for key, value in list(normalized.items()): + if isinstance(value, dict) and "$ref" not in value: + normalized[key] = normalize_object(value) + elif isinstance(value, list): + # Handle arrays that might contain schema objects + normalized[key] = [normalize_object(item) if isinstance(item, dict) else item for item in value] + + visited.discard(obj_id) + return normalized + + return normalize_object(schema) + + def _validate_schema_for_openai(self, schema): + """ + Validate schema for OpenAI compatibility. + + Args: + schema: Schema to validate + + Returns: + True if schema is valid, False otherwise + """ + if not schema or not isinstance(schema, dict): + return True + + visited = set() + + def has_invalid_structure(obj): + if not isinstance(obj, dict): + return False + + obj_id = id(obj) + if obj_id in visited: + return False + visited.add(obj_id) + + if "$ref" in obj: + visited.discard(obj_id) + return False + + if (obj.get("type") == "object" and + "properties" not in obj and + "patternProperties" not in obj and + obj.get("additionalProperties") is True): + visited.discard(obj_id) + return True + + # Check both direct dict values and arrays for nested schemas + for value in obj.values(): + if isinstance(value, dict) and "$ref" not in value: + if has_invalid_structure(value): + visited.discard(obj_id) + return True + elif isinstance(value, list): + # Check items in arrays (anyOf/oneOf/items) + for item in value: + if isinstance(item, dict) and "$ref" not in item: + if has_invalid_structure(item): + visited.discard(obj_id) + return True + + visited.discard(obj_id) + return False + + return not has_invalid_structure(schema) + + def _detect_recursive_schema(self, schema): + """ + Detect if a schema contains recursive references. + + Args: + schema: Schema to analyze + + Returns: + True if schema has recursive patterns, False otherwise + """ + if not schema or not isinstance(schema, dict): + return False + + import json + schema_string = json.dumps(schema) + has_refs = ( + '"$ref"' in schema_string or + "#/$defs/" in schema_string or + "#/definitions/" in schema_string + ) + has_defs = bool(schema.get("$defs") or schema.get("definitions")) + + return has_refs or has_defs + + def _select_model_for_schema(self, schema=None): + """ + Select appropriate model based on schema complexity. + + Args: + schema: Schema to analyze + + Returns: + Dict with modelName and reason + """ + if not schema: + return {"modelName": "gpt-4o-mini", "reason": "no_schema"} + + if self._detect_recursive_schema(schema): + return {"modelName": "gpt-4o", "reason": "recursive_schema_detected"} + + return {"modelName": "gpt-4o-mini", "reason": "simple_schema"} + + def _process_schema_with_validation(self, schema_container, schema_key="schema"): + """ + Process and validate a schema container (like extract or json_options). + + Args: + schema_container: Dict containing schema + schema_key: Key where schema is stored (default: "schema") + + Returns: + Processed schema container + """ + if isinstance(schema_container, dict) and schema_key in schema_container: + schema = self._ensure_schema_dict(schema_container[schema_key]) + + # Handle schema reference resolution similar to TypeScript implementation + if schema and isinstance(schema, dict): + defs = schema.get("$defs", {}) + import json + schema_string = json.dumps(schema) + has_any_refs = ( + schema.get("$defs") or + '"$ref"' in schema_string or + "#/$defs/" in schema_string + ) + + if has_any_refs: + try: + resolved_schema = self._resolve_refs(schema, defs) + resolved_string = json.dumps(resolved_schema) + has_remaining_refs = '"$ref"' in resolved_string or "#/$defs/" in resolved_string + + if not has_remaining_refs: + schema = resolved_schema + # Remove $defs after successful resolution + if isinstance(schema, dict) and "$defs" in schema: + del schema["$defs"] + # If refs remain, preserve original schema + except Exception: + # Failed to resolve refs, preserve original schema + pass + else: + # No recursive references detected, resolve refs anyway + try: + schema = self._resolve_refs(schema, defs) + if isinstance(schema, dict) and "$defs" in schema: + del schema["$defs"] + except Exception: + pass + + schema = self._normalize_schema_for_openai(schema) + if not self._validate_schema_for_openai(schema): + raise ValueError(self.OPENAI_SCHEMA_ERROR_MESSAGE) + + # Add model selection info for reference (non-intrusive) + model_selection = self._select_model_for_schema(schema) + if '_model_info' not in schema_container: + schema_container['_model_info'] = model_selection + + schema_container[schema_key] = schema + return schema_container + + class V1CrawlWatcher: """ A class to watch and handle crawl job events via WebSocket connection. diff --git a/apps/python-sdk/firecrawl/v2/utils/validation.py b/apps/python-sdk/firecrawl/v2/utils/validation.py index 923ea7388..2d24954b1 100644 --- a/apps/python-sdk/firecrawl/v2/utils/validation.py +++ b/apps/python-sdk/firecrawl/v2/utils/validation.py @@ -24,6 +24,314 @@ def _convert_format_string(format_str: str) -> str: return format_mapping.get(format_str, format_str) +def normalize_schema_for_openai(schema: Any) -> Any: + """ + Normalize a schema for OpenAI compatibility by handling recursive references. + + Args: + schema: Schema to normalize + + Returns: + Normalized schema + """ + if not schema or not isinstance(schema, dict): + return schema + + visited = set() + + def normalize_object(obj: Any) -> Any: + if not isinstance(obj, dict): + if isinstance(obj, list): + return [normalize_object(item) for item in obj] + return obj + + obj_id = id(obj) + if obj_id in visited: + return obj + visited.add(obj_id) + + normalized = dict(obj) + + # Handle $ref recursion + if "$ref" in normalized: + visited.discard(obj_id) + return normalized + + if "$defs" in normalized: + defs = normalized.pop("$defs") + processed_rest = {} + + for key, value in normalized.items(): + if isinstance(value, dict) and "$ref" not in value: + processed_rest[key] = normalize_object(value) + else: + processed_rest[key] = value + + normalized_defs = {} + for key, value in defs.items(): + normalized_defs[key] = normalize_object(value) + + result = {**processed_rest, "$defs": normalized_defs} + visited.discard(obj_id) + return result + + if (normalized.get("type") == "object" and + "properties" in normalized and + normalized.get("additionalProperties") is True): + del normalized["additionalProperties"] + + if (normalized.get("type") == "object" and + "required" in normalized and + "properties" in normalized): + if (isinstance(normalized["required"], list) and + isinstance(normalized["properties"], dict)): + valid_required = [field for field in normalized["required"] + if field in normalized["properties"]] + if valid_required: + normalized["required"] = valid_required + else: + del normalized["required"] + else: + del normalized["required"] + + for key, value in list(normalized.items()): + if isinstance(value, dict) and "$ref" not in value: + normalized[key] = normalize_object(value) + elif isinstance(value, list): + normalized[key] = [normalize_object(item) if isinstance(item, dict) else item for item in value] + + visited.discard(obj_id) + return normalized + + return normalize_object(schema) + + +def validate_schema_for_openai(schema: Any) -> bool: + """ + Validate schema for OpenAI compatibility. + + Args: + schema: Schema to validate + + Returns: + True if schema is valid, False otherwise + """ + if not schema or not isinstance(schema, dict): + return True + + visited = set() + + def has_invalid_structure(obj: Any) -> bool: + if not isinstance(obj, dict): + return False + + obj_id = id(obj) + if obj_id in visited: + return False + visited.add(obj_id) + + if "$ref" in obj: + visited.discard(obj_id) + return False + + if (obj.get("type") == "object" and + "properties" not in obj and + "patternProperties" not in obj and + obj.get("additionalProperties") is True): + visited.discard(obj_id) + return True + + for value in obj.values(): + if isinstance(value, dict) and "$ref" not in value: + if has_invalid_structure(value): + visited.discard(obj_id) + return True + elif isinstance(value, list): + for item in value: + if isinstance(item, dict) and "$ref" not in item: + if has_invalid_structure(item): + visited.discard(obj_id) + return True + + visited.discard(obj_id) + return False + + return not has_invalid_structure(schema) + + +OPENAI_SCHEMA_ERROR_MESSAGE = ( + "Schema contains invalid structure for OpenAI: object type with no 'properties' defined " + "but 'additionalProperties: true' (schema-less dictionary not supported by OpenAI). " + "Please define specific properties for your object. Note: Recursive schemas using '$ref' are supported." +) + + +def _contains_recursive_ref(obj: Any, target_def_name: str, defs: Dict[str, Any], visited: Optional[set] = None) -> bool: + """ + Check if an object contains a recursive reference to a specific definition. + + Args: + obj: Object to check + target_def_name: Name of the definition to check for recursion + defs: Dictionary of definitions + visited: Set of visited object keys to detect cycles + + Returns: + True if recursive reference is found, False otherwise + """ + if not obj or not isinstance(obj, (dict, list)): + return False + + if visited is None: + visited = set() + + import json + obj_key = json.dumps(obj, sort_keys=True, default=str) + if obj_key in visited: + return False + visited.add(obj_key) + + try: + if isinstance(obj, dict): + if "$ref" in obj and isinstance(obj["$ref"], str): + ref_path = obj["$ref"].split("/") + if len(ref_path) >= 3 and ref_path[0] == "#" and ref_path[1] == "$defs": + def_name = ref_path[-1] + if def_name == target_def_name: + return True + if def_name in defs: + return _contains_recursive_ref(defs[def_name], target_def_name, defs, visited) + + for value in obj.values(): + if _contains_recursive_ref(value, target_def_name, defs, visited): + return True + + elif isinstance(obj, list): + for item in obj: + if _contains_recursive_ref(item, target_def_name, defs, visited): + return True + + finally: + visited.discard(obj_key) + + return False + + +def _check_for_circular_defs(defs: Dict[str, Any]) -> bool: + """ + Check if $defs contain circular references. + + Args: + defs: Dictionary of definitions to check + + Returns: + True if circular references are found, False otherwise + """ + if not defs: + return False + + for def_name, def_value in defs.items(): + if _contains_recursive_ref(def_value, def_name, defs): + return True + + return False + + +def resolve_refs(obj: Any, defs: Dict[str, Any], visited: Optional[set] = None, depth: int = 0) -> Any: + """ + Resolve $ref references in a JSON schema object. + + Args: + obj: Object to resolve references in + defs: Dictionary of definitions + visited: Set to track visited objects and prevent infinite recursion + depth: Current recursion depth + + Returns: + Object with resolved references + """ + if not obj or not isinstance(obj, (dict, list)) or depth > 10: + return obj + + if visited is None: + visited = set() + + obj_id = id(obj) + if obj_id in visited: + return obj + + visited.add(obj_id) + + try: + if isinstance(obj, dict): + if "$ref" in obj and isinstance(obj["$ref"], str): + ref_path = obj["$ref"].split("/") + if len(ref_path) >= 3 and ref_path[0] == "#" and ref_path[1] == "$defs": + def_name = ref_path[-1] + if def_name in defs: + return resolve_refs(dict(defs[def_name]), defs, visited, depth + 1) + return obj + + resolved = {} + for key, value in obj.items(): + if key == "$defs": + continue + resolved[key] = resolve_refs(value, defs, visited, depth + 1) + return resolved + + elif isinstance(obj, list): + return [resolve_refs(item, defs, visited, depth + 1) for item in obj] + + finally: + visited.discard(obj_id) + + return obj + + +def detect_recursive_schema(schema: Any) -> bool: + """ + Detect if a schema contains recursive references. + + Args: + schema: Schema to analyze + + Returns: + True if schema has recursive patterns, False otherwise + """ + if not schema or not isinstance(schema, dict): + return False + + import json + schema_string = json.dumps(schema) + has_refs = ( + '"$ref"' in schema_string or + "#/$defs/" in schema_string or + "#/definitions/" in schema_string + ) + has_defs = bool(schema.get("$defs") or schema.get("definitions")) + + return has_refs or has_defs + + +def select_model_for_schema(schema: Any = None) -> Dict[str, str]: + """ + Select appropriate model based on schema complexity. + + Args: + schema: Schema to analyze + + Returns: + Dict with modelName and reason + """ + if not schema: + return {"modelName": "gpt-4o-mini", "reason": "no_schema"} + + if detect_recursive_schema(schema): + return {"modelName": "gpt-4o", "reason": "recursive_schema_detected"} + + return {"modelName": "gpt-4o-mini", "reason": "simple_schema"} + + def _normalize_schema(schema: Any) -> Optional[Dict[str, Any]]: """ Normalize a schema object which may be a dict, Pydantic BaseModel subclass, @@ -83,7 +391,52 @@ def _validate_json_format(format_obj: Any) -> Dict[str, Any]: if schema is not None: normalized_schema = _normalize_schema(schema) if normalized_schema is not None: - normalized['schema'] = normalized_schema + # Handle schema reference resolution similar to TypeScript implementation + if isinstance(normalized_schema, dict): + defs = normalized_schema.get("$defs", {}) + import json + schema_string = json.dumps(normalized_schema) + has_any_refs = ( + normalized_schema.get("$defs") or + '"$ref"' in schema_string or + "#/$defs/" in schema_string + ) + + if has_any_refs: + try: + resolved_schema = resolve_refs(normalized_schema, defs) + resolved_string = json.dumps(resolved_schema) + has_remaining_refs = '"$ref"' in resolved_string or "#/$defs/" in resolved_string + + if not has_remaining_refs: + normalized_schema = resolved_schema + # Remove $defs after successful resolution + if isinstance(normalized_schema, dict) and "$defs" in normalized_schema: + del normalized_schema["$defs"] + # If refs remain, preserve original schema + except Exception: + # Failed to resolve refs, preserve original schema + pass + else: + # No recursive references detected, resolve refs anyway + try: + normalized_schema = resolve_refs(normalized_schema, defs) + if isinstance(normalized_schema, dict) and "$defs" in normalized_schema: + del normalized_schema["$defs"] + except Exception: + pass + + # Apply OpenAI normalization and validation + openai_normalized_schema = normalize_schema_for_openai(normalized_schema) + if not validate_schema_for_openai(openai_normalized_schema): + raise ValueError(OPENAI_SCHEMA_ERROR_MESSAGE) + + # Add model selection info for reference (non-intrusive) + model_selection = select_model_for_schema(openai_normalized_schema) + if '_model_info' not in normalized: + normalized['_model_info'] = model_selection + + normalized['schema'] = openai_normalized_schema return normalized