diff --git a/python/instrumentation/openinference-instrumentation-langchain/README.md b/python/instrumentation/openinference-instrumentation-langchain/README.md index a3d8dbbf6..b14e72a96 100644 --- a/python/instrumentation/openinference-instrumentation-langchain/README.md +++ b/python/instrumentation/openinference-instrumentation-langchain/README.md @@ -6,18 +6,43 @@ These traces are fully OpenTelemetry compatible and can be sent to an OpenTeleme [![pypi](https://badge.fury.io/py/openinference-instrumentation-langchain.svg)](https://pypi.org/project/openinference-instrumentation-langchain/) +## Compatibility + +This instrumentation works with: +- **LangChain 1.x** (`langchain>=1.0.0`): Modern agent framework built on LangGraph +- **LangChain Classic** (`langchain-classic>=1.0.0`): Legacy chains and tools (formerly `langchain 0.x`) +- All LangChain partner packages (`langchain-openai`, `langchain-anthropic`, `langchain-google-vertexai`, etc.) + +The instrumentation hooks into `langchain-core`, which is the shared foundation used by all LangChain packages. + ## Installation +### For LangChain 1.x (Recommended for New Projects) + +```shell +pip install openinference-instrumentation-langchain langchain langchain-openai +``` + +### For LangChain Classic (Legacy Applications) + +```shell +pip install openinference-instrumentation-langchain langchain-classic langchain-openai +``` + +### For Both (Migration Scenarios) + ```shell -pip install openinference-instrumentation-langchain +pip install openinference-instrumentation-langchain langchain langchain-classic langchain-openai ``` ## Quickstart +### Example with LangChain 1.x (New Agent Framework) + Install packages needed for this demonstration. ```shell -pip install openinference-instrumentation-langchain langchain arize-phoenix opentelemetry-sdk opentelemetry-exporter-otlp +pip install openinference-instrumentation-langchain langchain langchain-openai arize-phoenix opentelemetry-sdk opentelemetry-exporter-otlp ``` Start the Phoenix app in the background as a collector. By default, it listens on `http://localhost:6006`. You can visit the app via a browser at the same address. @@ -31,9 +56,8 @@ python -m phoenix.server.main serve The following Python code sets up the `LangChainInstrumentor` to trace `langchain` and send the traces to Phoenix at the endpoint shown below. ```python -from langchain.chains import LLMChain -from langchain_core.prompts import PromptTemplate -from langchain_openai import OpenAI +from langchain.agents import create_agent +from langchain_openai import ChatOpenAI from openinference.instrumentation.langchain import LangChainInstrumentor from opentelemetry import trace as trace_api from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter @@ -49,7 +73,7 @@ tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) LangChainInstrumentor().instrument() ``` -To demonstrate `langchain` tracing, we'll make a simple chain to tell a joke. First, configure your OpenAI credentials. +To demonstrate tracing, we'll create a simple agent. First, configure your OpenAI credentials. ```python import os @@ -57,9 +81,30 @@ import os os.environ["OPENAI_API_KEY"] = "" ``` -Now we can create a chain and run it. +Now we can create an agent and run it. + +```python +def get_weather(city: str) -> str: + """Get the weather for a city.""" + return f"The weather in {city} is sunny!" + +model = ChatOpenAI(model="gpt-4") +agent = create_agent(model, tools=[get_weather]) +result = agent.invoke({"messages": [{"role": "user", "content": "What's the weather in Paris?"}]}) +print(result) +``` + +### Example with LangChain Classic (Legacy Chains) + +For legacy applications using LangChain Classic: ```python +from langchain_classic.chains import LLMChain +from langchain_core.prompts import PromptTemplate +from langchain_openai import OpenAI + +# ... (same instrumentation setup as above) + prompt_template = "Tell me a {adjective} joke" prompt = PromptTemplate(input_variables=["adjective"], template=prompt_template) llm = LLMChain(llm=OpenAI(), prompt=prompt, metadata={"category": "jokes"}) diff --git a/python/instrumentation/openinference-instrumentation-langchain/examples/langchain_v1_agent.py b/python/instrumentation/openinference-instrumentation-langchain/examples/langchain_v1_agent.py new file mode 100644 index 000000000..b6c49121b --- /dev/null +++ b/python/instrumentation/openinference-instrumentation-langchain/examples/langchain_v1_agent.py @@ -0,0 +1,90 @@ +# /// script +# dependencies = [ +# "langchain>=1.0.0", +# "langchain-openai>=0.2.0", +# "openinference-instrumentation-langchain>=0.1.24", +# "opentelemetry-sdk>=1.25.0", +# "opentelemetry-exporter-otlp>=1.25.0", +# ] +# /// +import os +from typing import Literal + +from langchain.agents import create_agent +from langchain_openai import ChatOpenAI +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + +from openinference.instrumentation.langchain import LangChainInstrumentor + +endpoint = "http://127.0.0.1:6006/v1/traces" +tracer_provider = trace_sdk.TracerProvider() +tracer_provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter(endpoint))) +tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + +LangChainInstrumentor().instrument(tracer_provider=tracer_provider) + + +# Tools +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + weather_data = { + "San Francisco": "Foggy, 60°F", + "New York": "Sunny, 75°F", + "London": "Rainy, 55°F", + "Tokyo": "Clear, 70°F", + } + return weather_data.get(city, f"Weather data not available for {city}") + + +def calculate(operation: Literal["add", "subtract", "multiply", "divide"], a: float, b: float) -> float: + """Perform a mathematical calculation.""" + if operation == "add": + return a + b + elif operation == "subtract": + return a - b + elif operation == "multiply": + return a * b + elif operation == "divide": + if b == 0: + return float("inf") + return a / b + else: + raise ValueError(f"Unknown operation: {operation}") + + +def search_web(query: str) -> str: + """Search the web for information.""" + return f"Here are the top results for '{query}': [Result 1] [Result 2] [Result 3]" + + +if __name__ == "__main__": + if not os.environ.get("OPENAI_API_KEY"): + print("Please set OPENAI_API_KEY environment variable") + exit(1) + + model = ChatOpenAI(model="gpt-4o-mini", temperature=0) + agent = create_agent( + model=model, + tools=[get_weather, calculate, search_web], + system_prompt=( + "You are a helpful assistant with access to weather information, " + "a calculator, and web search. Use these tools to help answer questions." + ), + ) + + queries = [ + "What's the weather in San Francisco?", + "Calculate 234 * 567", + "What's the weather in Tokyo and multiply the temperature by 2?", + ] + + for query in queries: + print(f"\nQuery: {query}") + result = agent.invoke({"messages": [{"role": "user", "content": query}]}) + messages = result.get("messages", []) + if messages: + print(f"Response: {messages[-1].content}") + print() + diff --git a/python/instrumentation/openinference-instrumentation-langchain/examples/langchain_v1_with_middleware.py b/python/instrumentation/openinference-instrumentation-langchain/examples/langchain_v1_with_middleware.py new file mode 100644 index 000000000..c270d1119 --- /dev/null +++ b/python/instrumentation/openinference-instrumentation-langchain/examples/langchain_v1_with_middleware.py @@ -0,0 +1,108 @@ +# /// script +# dependencies = [ +# "langchain>=1.0.0", +# "langchain-openai>=0.2.0", +# "openinference-instrumentation-langchain>=0.1.24", +# "opentelemetry-sdk>=1.25.0", +# "opentelemetry-exporter-otlp>=1.25.0", +# ] +# /// +import os +from typing import Any + +from langchain.agents import create_agent +from langchain.agents.middleware import AgentMiddleware +from langchain_openai import ChatOpenAI +from langgraph.runtime import Runtime +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + +from openinference.instrumentation.langchain import LangChainInstrumentor + +endpoint = "http://127.0.0.1:6006/v1/traces" +tracer_provider = trace_sdk.TracerProvider() +tracer_provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter(endpoint))) +tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + +LangChainInstrumentor().instrument(tracer_provider=tracer_provider) + + +# Custom middleware +class LoggingMiddleware(AgentMiddleware): + """Custom middleware that logs model calls.""" + + def before_model(self, state: dict[str, Any], runtime: Runtime) -> dict[str, Any] | None: + messages = state.get("messages", []) + print(f"Calling model with {len(messages)} messages") + return None + + def after_model(self, state: dict[str, Any], runtime: Runtime) -> dict[str, Any] | None: + messages = state.get("messages", []) + if messages and hasattr(messages[-1], "tool_calls") and messages[-1].tool_calls: + print(f"Model wants to call {len(messages[-1].tool_calls)} tool(s)") + return None + + +# Tools +def get_current_time() -> str: + """Get the current time.""" + from datetime import datetime + return datetime.now().strftime("%I:%M %p") + + +def get_random_fact() -> str: + """Get a random interesting fact.""" + facts = [ + "Honey never spoils. Archaeologists have found 3000-year-old honey that's still edible.", + "A group of flamingos is called a 'flamboyance'.", + "The shortest war in history lasted only 38-45 minutes.", + "Bananas are berries, but strawberries aren't.", + "There are more stars in the universe than grains of sand on Earth.", + ] + import random + return random.choice(facts) + + +def calculate_fibonacci(n: int) -> int: + """Calculate the nth Fibonacci number.""" + if n < 0: + raise ValueError("n must be non-negative") + if n <= 1: + return n + + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + + +if __name__ == "__main__": + if not os.environ.get("OPENAI_API_KEY"): + print("Please set OPENAI_API_KEY environment variable") + exit(1) + + agent = create_agent( + model=ChatOpenAI(model="gpt-4o-mini", temperature=0), + tools=[get_current_time, get_random_fact, calculate_fibonacci], + system_prompt=( + "You are a helpful assistant with access to tools. " + "Use them to provide accurate and interesting information." + ), + middleware=[LoggingMiddleware()], + ) + + queries = [ + "What time is it?", + "Tell me an interesting fact", + "Calculate the 10th Fibonacci number", + ] + + for query in queries: + print(f"\nQuery: {query}") + result = agent.invoke({"messages": [{"role": "user", "content": query}]}) + messages = result.get("messages", []) + if messages: + print(f"Response: {messages[-1].content}") + print() + diff --git a/python/instrumentation/openinference-instrumentation-langchain/examples/langgraph_agent_supervisor.py b/python/instrumentation/openinference-instrumentation-langchain/examples/langgraph_agent_supervisor.py index 7e18a9604..075f24b97 100644 --- a/python/instrumentation/openinference-instrumentation-langchain/examples/langgraph_agent_supervisor.py +++ b/python/instrumentation/openinference-instrumentation-langchain/examples/langgraph_agent_supervisor.py @@ -2,13 +2,16 @@ Based on https://colab.research.google.com/drive/1xDEPe2i_2rRqs7o6oNTtqA4J7Orsnvx1?usp=sharing Requires Tavily API Key https://github.com/tavily-ai/tavily-python + +This example uses LangChain Classic for legacy agent patterns (AgentExecutor). +For new projects, consider using LangChain 1.x with the new agent framework. """ import functools import operator from typing import Annotated, Sequence, TypedDict -from langchain.agents import AgentExecutor, create_openai_tools_agent +from langchain_classic.agents import AgentExecutor, create_openai_tools_agent from langchain_community.tools.tavily_search import TavilySearchResults from langchain_core.messages import BaseMessage, HumanMessage from langchain_core.output_parsers.openai_functions import JsonOutputFunctionsParser diff --git a/python/instrumentation/openinference-instrumentation-langchain/examples/requirements.txt b/python/instrumentation/openinference-instrumentation-langchain/examples/requirements.txt index 8a6ca01ca..f4f12d126 100644 --- a/python/instrumentation/openinference-instrumentation-langchain/examples/requirements.txt +++ b/python/instrumentation/openinference-instrumentation-langchain/examples/requirements.txt @@ -1,4 +1,5 @@ -langchain >=0.2.11 +langchain >=1.0.0 +langchain-classic >=1.0.0 langchain-experimental >=0.0.63 langchain_community >=0.2.10 langchain-openai >=0.1.19 diff --git a/python/instrumentation/openinference-instrumentation-langchain/pyproject.toml b/python/instrumentation/openinference-instrumentation-langchain/pyproject.toml index d069873ee..6e15f282d 100644 --- a/python/instrumentation/openinference-instrumentation-langchain/pyproject.toml +++ b/python/instrumentation/openinference-instrumentation-langchain/pyproject.toml @@ -40,7 +40,8 @@ instruments = [ ] test = [ "langchain_core == 0.3.50", - "langchain == 0.3.15", + "langchain >= 1.0.0", # New 1.x agent framework + "langchain-classic >= 1.0.0", # Legacy chains and tools (formerly langchain 0.x) "langchain_openai == 0.2.14", "langchain-community == 0.3.15", "langchain-google-vertexai == 2.0.12", diff --git a/python/instrumentation/openinference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py b/python/instrumentation/openinference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py index c17b9842b..50148bd9a 100644 --- a/python/instrumentation/openinference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py +++ b/python/instrumentation/openinference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py @@ -363,13 +363,47 @@ def _as_output(values: Iterable[str]) -> Iterator[Tuple[str, str]]: return zip((OUTPUT_VALUE, OUTPUT_MIME_TYPE), values) +def _is_json_parseable(value: str) -> bool: + """ + Check if a string value is valid JSON (object or array). + + This function is performance-optimized to avoid unnecessary parsing: + - Returns early if the string doesn't look like JSON (no braces/brackets) + - Only attempts parsing for strings that structurally resemble JSON + + Args: + value: String to check for JSON parseability. + + Returns: + `True` if the string is valid JSON (dict/list), `False` otherwise. + """ + if not value: + return False + + stripped = value.strip() + if not stripped: + return False + + if not ((stripped.startswith("{") and stripped.endswith("}")) + or (stripped.startswith("[") and stripped.endswith("]"))): + return False + + try: + parsed = json.loads(value) + return isinstance(parsed, (dict, list)) + except (json.JSONDecodeError, ValueError, TypeError): + return False + + def _convert_io(obj: Optional[Mapping[str, Any]]) -> Iterator[str]: """ Convert input/output data to appropriate string representation for OpenInference spans. This function handles different cases with increasing complexity: 1. Empty/None objects: return nothing - 2. Single string values: return the string directly (performance optimization, no MIME type) + 2. Single string values: return the string directly + - If the string is parseable JSON (object/array), also yield JSON MIME type + - Otherwise, no MIME type (defaults to text/plain) 3. Single input/output key with non-string: use custom JSON formatting via _json_dumps - Conditional MIME type: only for structured data (objects/arrays), not primitives 4. Multiple keys or other cases: use _json_dumps for consistent formatting @@ -390,10 +424,14 @@ def _convert_io(obj: Optional[Mapping[str, Any]]) -> Iterator[str]: if len(obj) == 1: value = next(iter(obj.values())) - # Optimization: Single string values are returned as-is without processing - # This is the most common case in LangChain runs (e.g., {"input": "user message"}) + # Handle string values: check if they contain JSON + # This is a common case when producers pass stringified JSON if isinstance(value, str): yield value + # Check if the string is parseable JSON (object or array) + # If so, tag it with JSON MIME type for proper frontend rendering + if _is_json_parseable(value): + yield OpenInferenceMimeTypeValues.JSON.value return key = next(iter(obj.keys())) @@ -588,6 +626,11 @@ def _extract_message_role(message_data: Optional[Mapping[str, Any]]) -> Iterator role = "tool" elif message_class_name.startswith("ChatMessage"): role = message_data["kwargs"]["role"] + elif message_class_name.startswith("RemoveMessage"): + # RemoveMessage is a special message type used by LangGraph to mark messages for removal + # It doesn't have a traditional role, so we skip adding a role attribute + # This prevents ValueError while allowing RemoveMessage to be processed + return else: raise ValueError(f"Cannot parse message of type: {message_class_name}") yield MESSAGE_ROLE, role diff --git a/python/instrumentation/openinference-instrumentation-langchain/tests/test_convert_io.py b/python/instrumentation/openinference-instrumentation-langchain/tests/test_convert_io.py index 3cdd5e921..82ced0506 100644 --- a/python/instrumentation/openinference-instrumentation-langchain/tests/test_convert_io.py +++ b/python/instrumentation/openinference-instrumentation-langchain/tests/test_convert_io.py @@ -1385,3 +1385,268 @@ def test_convert_io_edge_cases_comprehensive(self) -> None: assert parsed["control_chars"] == "Line1\nLine2\tTabbed\rCarriage" print("✓ All edge cases handled correctly") + + @pytest.mark.parametrize( + "input_obj,expected_value,should_have_mime_type", + [ + # Valid JSON strings (should get MIME type) + pytest.param( + {"input": '{"name": "John"}'}, + '{"name": "John"}', + True, + id="json_object_string", + ), + pytest.param( + {"input": '[1, 2, 3]'}, + '[1, 2, 3]', + True, + id="json_array_string", + ), + pytest.param( + {"input": '{"nested": {"key": "value"}}'}, + '{"nested": {"key": "value"}}', + True, + id="nested_json_object", + ), + pytest.param( + {"input": ' {"whitespace": "allowed"} '}, + ' {"whitespace": "allowed"} ', + True, + id="json_with_whitespace", + ), + pytest.param( + {"input": '[]'}, + '[]', + True, + id="empty_json_array", + ), + pytest.param( + {"input": '{}'}, + '{}', + True, + id="empty_json_object", + ), + # Non-JSON strings (should NOT get MIME type) + pytest.param( + {"input": "simple string"}, + "simple string", + False, + id="plain_string", + ), + pytest.param( + {"input": "42"}, + "42", + False, + id="number_string", + ), + pytest.param( + {"input": "true"}, + "true", + False, + id="boolean_string", + ), + pytest.param( + {"input": '"quoted string"'}, + '"quoted string"', + False, + id="json_string_primitive", + ), + pytest.param( + {"input": "null"}, + "null", + False, + id="json_null_primitive", + ), + pytest.param( + {"input": "{not valid json}"}, + "{not valid json}", + False, + id="malformed_json", + ), + pytest.param( + {"input": "[1, 2, missing bracket"}, + "[1, 2, missing bracket", + False, + id="incomplete_json", + ), + pytest.param( + {"input": ""}, + "", + False, + id="empty_string", + ), + pytest.param( + {"input": " "}, + " ", + False, + id="whitespace_only", + ), + pytest.param( + {"input": "{starts but doesn't end properly"}, + "{starts but doesn't end properly", + False, + id="looks_like_json_but_isnt", + ), + ], + ) + def test_convert_io_json_string_detection( + self, + input_obj: dict[str, str], + expected_value: str, + should_have_mime_type: bool, + ) -> None: + """ + Test that JSON strings are properly detected and tagged with JSON MIME type. + + This validates the fix for stringified JSON payloads being correctly identified + so the frontend can render them as pretty JSON instead of plain text. + """ + result = list(_convert_io(input_obj)) + + # First item should always be the original string value + assert result[0] == expected_value, ( + f"Expected value '{expected_value}', got '{result[0]}'" + ) + + # Check MIME type based on whether string is valid JSON + if should_have_mime_type: + assert len(result) == 2, ( + f"Expected 2 items (value + MIME type) for JSON string, got {len(result)}" + ) + assert result[1] == OpenInferenceMimeTypeValues.JSON.value, ( + f"Expected JSON MIME type for JSON string, got '{result[1]}'" + ) + # Verify the string is actually parseable JSON + parsed = json.loads(expected_value.strip()) + assert isinstance(parsed, (dict, list)), ( + f"Expected dict or list, got {type(parsed)}" + ) + else: + assert len(result) == 1, ( + f"Expected 1 item (value only) for non-JSON string, got {len(result)}: {result}" + ) + + def test_convert_io_json_string_complex_scenarios(self) -> None: + """Test JSON string detection in complex real-world scenarios.""" + + # Scenario 1: LangChain chain with stringified JSON input + # This is the exact use case from the feature request + chain_input = {"input": '{"name": "Ada", "age": 30}'} + result = list(_convert_io(chain_input)) + + assert len(result) == 2 + assert result[0] == '{"name": "Ada", "age": 30}' + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + + # Verify the frontend can parse this + parsed = json.loads(result[0]) + assert parsed["name"] == "Ada" + assert parsed["age"] == 30 + + # Scenario 2: Chain output with stringified JSON + chain_output = {"output": '[{"id": 1, "status": "complete"}, {"id": 2, "status": "pending"}]'} + result = list(_convert_io(chain_output)) + + assert len(result) == 2 + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + parsed = json.loads(result[0]) + assert len(parsed) == 2 + assert parsed[0]["status"] == "complete" + + # Scenario 3: Mixed - some keys are JSON strings, others aren't + # (but this is multiple keys, so goes through general path) + mixed = {"data": '{"valid": "json"}', "plain": "not json"} + result = list(_convert_io(mixed)) + + # Multiple keys always get JSON MIME type (general case) + assert len(result) == 2 + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + + def test_convert_io_json_string_performance_early_exit(self) -> None: + """Test that JSON detection exits early for obviously non-JSON strings.""" + + # These should exit early without trying to parse + non_json_strings = [ + {"input": "This is just plain text"}, + {"input": "123456"}, # Number but not in braces/brackets + {"input": "true or false"}, + {"input": "some text with } brace but not at start"}, + {"input": "[ but missing closing bracket"}, + ] + + for input_obj in non_json_strings: + result = list(_convert_io(input_obj)) + # All should return just the string, no MIME type + assert len(result) == 1, ( + f"Expected early exit for non-JSON string: {input_obj['input']}" + ) + + def test_convert_io_json_string_with_escaping(self) -> None: + """Test that JSON strings with escaped characters are handled correctly.""" + + # JSON string with escaped quotes + json_with_quotes = r'{"message": "He said \"Hello\""}' + input_obj = {"input": json_with_quotes} + result = list(_convert_io(input_obj)) + + assert len(result) == 2 + assert result[0] == json_with_quotes + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + + # Verify it's valid JSON + parsed = json.loads(result[0]) + assert parsed["message"] == 'He said "Hello"' + + # JSON string with newlines + json_with_newlines = '{"text": "Line 1\\nLine 2"}' + input_obj = {"input": json_with_newlines} + result = list(_convert_io(input_obj)) + + assert len(result) == 2 + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + parsed = json.loads(result[0]) + assert "Line 1\nLine 2" in parsed["text"] + + def test_convert_io_json_string_unicode(self) -> None: + """Test that JSON strings with Unicode are handled correctly.""" + + # JSON string with Unicode characters + json_unicode = '{"greeting": "Hello 世界 🌍", "emoji": "🎉"}' + input_obj = {"input": json_unicode} + result = list(_convert_io(input_obj)) + + assert len(result) == 2 + assert result[0] == json_unicode + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + + # Verify it's valid JSON with correct Unicode + parsed = json.loads(result[0]) + assert parsed["greeting"] == "Hello 世界 🌍" + assert parsed["emoji"] == "🎉" + + def test_convert_io_json_string_edge_cases(self) -> None: + """Test edge cases for JSON string detection.""" + + # Very nested JSON string + deeply_nested = '{"a": {"b": {"c": {"d": {"e": "value"}}}}}' + result = list(_convert_io({"input": deeply_nested})) + assert len(result) == 2 + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + + # JSON with special number values (NaN, Infinity not in strings) + # Note: These are NOT valid JSON, so should not be detected + invalid_json_numbers = '{"value": NaN}' + result = list(_convert_io({"input": invalid_json_numbers})) + assert len(result) == 1 # Not valid JSON + + # JSON array with mixed types + mixed_array = '[1, "string", true, null, {"nested": "object"}]' + result = list(_convert_io({"input": mixed_array})) + assert len(result) == 2 + assert result[1] == OpenInferenceMimeTypeValues.JSON.value + + # Single-character braces/brackets (not JSON) + assert len(list(_convert_io({"input": "{"}))) == 1 + assert len(list(_convert_io({"input": "["}))) == 1 + assert len(list(_convert_io({"input": "}"}))) == 1 + assert len(list(_convert_io({"input": "]"}))) == 1