langchain-content-normalizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-content-normalizer
3
+ Version: 0.1.0
4
+ Summary: Normalize LangChain, MCP, and multimodal content blocks into provider-ready text and image payloads.
5
+ Project-URL: Homepage, https://github.com/benjaminjornet/langchain-content-normalizer
6
+ Project-URL: Issues, https://github.com/benjaminjornet/langchain-content-normalizer/issues
7
+ Author-email: Benjamin Jornet <benjamin.jornet@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Benjamin Jornet
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: content-normalization,langchain,llm,mcp,multimodal
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Requires-Python: >=3.11
39
+ Description-Content-Type: text/markdown
40
+
41
+ # langchain-content-normalizer
42
+
43
+ [![CI](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml/badge.svg)](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
46
+
47
+ Normalize the messy content shapes produced by LangChain, MCP tools, Anthropic content blocks, and multimodal chat APIs.
48
+
49
+ The package has no runtime dependencies. It works by duck typing instead of importing LangChain or MCP classes.
50
+
51
+ ## What it solves
52
+
53
+ LLM agent stacks often receive content as one of many incompatible shapes:
54
+
55
+ | Source | Example shape | Output |
56
+ | --- | --- | --- |
57
+ | Classic chat | `"plain text"` | `"plain text"` |
58
+ | Anthropic blocks | `[{"type": "text", "text": "hi"}]` | `"hi"` |
59
+ | Tool calls | `[{"type": "tool_use", ...}]` | skipped by default |
60
+ | MCP tool results | `[{"type": "tool_result", "content": [...]}]` | flattened text |
61
+ | MCP objects | objects exposing `.text` | extracted text |
62
+ | Message wrappers | objects exposing `.content` | recursively normalized |
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ uv add langchain-content-normalizer
68
+ ```
69
+
70
+ ## Text normalization
71
+
72
+ ```python
73
+ from lc_content_normalizer import extract_text_content, normalize_tool_output
74
+
75
+ content = [
76
+ {"type": "text", "text": "Reading logs..."},
77
+ {"type": "tool_use", "name": "tail_logs", "input": {"service": "api"}},
78
+ ]
79
+
80
+ assert extract_text_content(content) == "Reading logs..."
81
+ assert "tail_logs" in extract_text_content(content, skip_tool_use=False)
82
+
83
+ safe_output = normalize_tool_output(huge_tool_payload, max_chars=50_000)
84
+ ```
85
+
86
+ ## Vision format routing
87
+
88
+ ```python
89
+ from lc_content_normalizer import build_human_message_content, detect_vision_format
90
+
91
+ vision_format = detect_vision_format("anthropic", "claude-3-5-sonnet")
92
+ content = build_human_message_content(
93
+ "Explain this alert screenshot",
94
+ images=[{"data_url": "data:image/png;base64,...", "mime_type": "image/png"}],
95
+ vision_format=vision_format,
96
+ )
97
+ ```
98
+
99
+ `detect_vision_format()` returns:
100
+
101
+ | Provider/model | Format |
102
+ | --- | --- |
103
+ | `anthropic` | native Anthropic `image` block with `source.base64` |
104
+ | `ollama` + `llava`/`vision` model name | OpenAI-compatible `image_url` block |
105
+ | `ollama` text-only model | `none`, images are dropped |
106
+ | OpenAI-compatible providers | OpenAI-compatible `image_url` block |
107
+
108
+ ## Examples
109
+
110
+ - `examples/normalize_mcp_output.py` shows how MCP-style tool results are flattened.
111
+ - `examples/build_vision_content.py` shows provider-aware image block generation.
112
+
113
+ ## Roadmap
114
+
115
+ - Add strict mode for unknown content blocks.
116
+ - Add more MCP fixture coverage.
117
+ - Add provider-specific adapters as content formats evolve.
118
+ - Keep runtime dependencies at zero.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ uv sync --dev
124
+ uv run ruff check .
125
+ uv run pytest
126
+ uv run python scripts/smoke.py
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,7 @@
1
+ lc_content_normalizer/__init__.py,sha256=kzmKj1eLlp5crQclwE28v3pE091nyjX5mLja_FIfmiE,516
2
+ lc_content_normalizer/text.py,sha256=-TQjxZwXUJoaZpXlxjzt-qo78Y5m8XJhjayW48yAAwI,2729
3
+ lc_content_normalizer/vision.py,sha256=Q2VPgSafQtXfNpBlc41YBIETQvGT46a26zZp2zl9A0o,1870
4
+ langchain_content_normalizer-0.1.0.dist-info/METADATA,sha256=7SI4OsoNCJoZYizynLImejdN800F1vCOd9v8WK-LWcw,5096
5
+ langchain_content_normalizer-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ langchain_content_normalizer-0.1.0.dist-info/licenses/LICENSE,sha256=WdZfrCBxqj0eY04I5UNmeuqGPeyWa4YGULcHq6cWXS8,1072
7
+ langchain_content_normalizer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Jornet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ from .text import extract_text_content, normalize_tool_output
2
+ from .vision import (
3
+ VISION_FORMAT_ANTHROPIC_NATIVE,
4
+ VISION_FORMAT_NONE,
5
+ VISION_FORMAT_OPENAI,
6
+ build_human_message_content,
7
+ detect_vision_format,
8
+ image_block_for_format,
9
+ )
10
+
11
+ __all__ = [
12
+ "VISION_FORMAT_ANTHROPIC_NATIVE",
13
+ "VISION_FORMAT_NONE",
14
+ "VISION_FORMAT_OPENAI",
15
+ "build_human_message_content",
16
+ "detect_vision_format",
17
+ "extract_text_content",
18
+ "image_block_for_format",
19
+ "normalize_tool_output",
20
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def extract_text_content(content: Any, *, skip_tool_use: bool = True) -> str:
7
+ """Normalize LangChain, Anthropic, and MCP content shapes to plain text.
8
+
9
+ Supported inputs include strings, Anthropic-style content block lists,
10
+ MCP TextContent-like objects exposing ``.text``, and message-like objects
11
+ exposing ``.content``. Unknown non-empty block lists fall back to ``str`` so
12
+ tool outputs are not silently lost.
13
+ """
14
+ if content is None:
15
+ return ""
16
+ if isinstance(content, str):
17
+ return content
18
+ if isinstance(content, list):
19
+ parts: list[str] = []
20
+ saw_known_block = False
21
+ for block in content:
22
+ if isinstance(block, str):
23
+ parts.append(block)
24
+ saw_known_block = True
25
+ continue
26
+ if isinstance(block, dict):
27
+ block_type = block.get("type")
28
+ if block_type == "text" and isinstance(block.get("text"), str):
29
+ parts.append(block["text"])
30
+ saw_known_block = True
31
+ elif block_type == "tool_result":
32
+ parts.append(
33
+ extract_text_content(block.get("content", ""), skip_tool_use=skip_tool_use)
34
+ )
35
+ saw_known_block = True
36
+ elif block_type == "tool_use":
37
+ saw_known_block = True
38
+ if not skip_tool_use:
39
+ parts.append(str(block.get("input", "")))
40
+ elif block_type in {"image", "image_url"}:
41
+ saw_known_block = True
42
+ continue
43
+
44
+ text_attr = getattr(block, "text", None)
45
+ if isinstance(text_attr, str):
46
+ parts.append(text_attr)
47
+ saw_known_block = True
48
+ continue
49
+
50
+ parts.append(str(block))
51
+
52
+ result = "".join(parts)
53
+ if not result and content and not saw_known_block:
54
+ return str(content)
55
+ return result
56
+
57
+ inner = getattr(content, "content", None)
58
+ if inner is not None and inner is not content:
59
+ return extract_text_content(inner, skip_tool_use=skip_tool_use)
60
+
61
+ text_attr = getattr(content, "text", None)
62
+ if isinstance(text_attr, str):
63
+ return text_attr
64
+
65
+ return str(content)
66
+
67
+
68
+ def normalize_tool_output(raw: Any, *, max_chars: int = 50_000) -> str:
69
+ """Extract a readable tool output string and truncate oversized payloads."""
70
+ text = extract_text_content(raw)
71
+ if len(text) <= max_chars:
72
+ return text
73
+ omitted = len(text) - max_chars
74
+ return text[:max_chars] + f"\n\n[...truncated {omitted} chars]"
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ VISION_FORMAT_OPENAI = "openai"
6
+ VISION_FORMAT_ANTHROPIC_NATIVE = "anthropic_native"
7
+ VISION_FORMAT_NONE = "none"
8
+
9
+
10
+ def detect_vision_format(provider: str, model: str) -> str:
11
+ """Pick the multimodal block format expected by a provider/model pair."""
12
+ provider_name = (provider or "").lower()
13
+ model_name = (model or "").lower()
14
+
15
+ if provider_name == "anthropic":
16
+ return VISION_FORMAT_ANTHROPIC_NATIVE
17
+ if provider_name == "ollama":
18
+ if "llava" in model_name or "vision" in model_name:
19
+ return VISION_FORMAT_OPENAI
20
+ return VISION_FORMAT_NONE
21
+ return VISION_FORMAT_OPENAI
22
+
23
+
24
+ def image_block_for_format(image: dict[str, str], vision_format: str) -> dict[str, Any]:
25
+ """Render one image into the multimodal block expected by the target API."""
26
+ if vision_format == VISION_FORMAT_ANTHROPIC_NATIVE:
27
+ data_url = image.get("data_url", "")
28
+ header, _, b64_data = data_url.partition(",")
29
+ media_type = image.get("mime_type") or header.removeprefix("data:").split(";", 1)[0]
30
+ return {
31
+ "type": "image",
32
+ "source": {"type": "base64", "media_type": media_type, "data": b64_data},
33
+ }
34
+ return {"type": "image_url", "image_url": {"url": image["data_url"]}}
35
+
36
+
37
+ def build_human_message_content(
38
+ text: str,
39
+ images: list[dict[str, str]] | None = None,
40
+ vision_format: str = VISION_FORMAT_OPENAI,
41
+ ) -> str | list[dict[str, Any]]:
42
+ """Build text-only or multimodal human message content for LangChain."""
43
+ if not images or vision_format == VISION_FORMAT_NONE:
44
+ return text
45
+
46
+ content: list[dict[str, Any]] = []
47
+ if text:
48
+ content.append({"type": "text", "text": text})
49
+ for image in images:
50
+ content.append(image_block_for_format(image, vision_format))
51
+ return content