langchain-content-normalizer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_content_normalizer-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +30 -0
- langchain_content_normalizer-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +19 -0
- langchain_content_normalizer-0.1.0/.github/dependabot.yml +10 -0
- langchain_content_normalizer-0.1.0/.github/pull_request_template.md +9 -0
- langchain_content_normalizer-0.1.0/.github/workflows/build.yml +27 -0
- langchain_content_normalizer-0.1.0/.github/workflows/ci.yml +25 -0
- langchain_content_normalizer-0.1.0/.github/workflows/publish.yml +22 -0
- langchain_content_normalizer-0.1.0/.github/workflows/smoke.yml +23 -0
- langchain_content_normalizer-0.1.0/.gitignore +10 -0
- langchain_content_normalizer-0.1.0/CHANGELOG.md +15 -0
- langchain_content_normalizer-0.1.0/CONTRIBUTING.md +25 -0
- langchain_content_normalizer-0.1.0/LICENSE +21 -0
- langchain_content_normalizer-0.1.0/MAINTAINERS.md +20 -0
- langchain_content_normalizer-0.1.0/PKG-INFO +131 -0
- langchain_content_normalizer-0.1.0/README.md +91 -0
- langchain_content_normalizer-0.1.0/docs/release-process.md +33 -0
- langchain_content_normalizer-0.1.0/examples/build_vision_content.py +10 -0
- langchain_content_normalizer-0.1.0/examples/normalize_mcp_output.py +20 -0
- langchain_content_normalizer-0.1.0/pyproject.toml +46 -0
- langchain_content_normalizer-0.1.0/scripts/smoke.py +23 -0
- langchain_content_normalizer-0.1.0/src/lc_content_normalizer/__init__.py +20 -0
- langchain_content_normalizer-0.1.0/src/lc_content_normalizer/text.py +74 -0
- langchain_content_normalizer-0.1.0/src/lc_content_normalizer/vision.py +51 -0
- langchain_content_normalizer-0.1.0/tests/test_text.py +83 -0
- langchain_content_normalizer-0.1.0/tests/test_vision.py +60 -0
- langchain_content_normalizer-0.1.0/uv.lock +108 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Bug report
|
|
2
|
+
description: Report incorrect content normalization behavior.
|
|
3
|
+
title: "[Bug]: "
|
|
4
|
+
labels: [bug]
|
|
5
|
+
body:
|
|
6
|
+
- type: textarea
|
|
7
|
+
id: input
|
|
8
|
+
attributes:
|
|
9
|
+
label: Input content shape
|
|
10
|
+
description: Paste the minimal content shape that fails.
|
|
11
|
+
render: python
|
|
12
|
+
validations:
|
|
13
|
+
required: true
|
|
14
|
+
- type: textarea
|
|
15
|
+
id: expected
|
|
16
|
+
attributes:
|
|
17
|
+
label: Expected output
|
|
18
|
+
validations:
|
|
19
|
+
required: true
|
|
20
|
+
- type: textarea
|
|
21
|
+
id: actual
|
|
22
|
+
attributes:
|
|
23
|
+
label: Actual output
|
|
24
|
+
validations:
|
|
25
|
+
required: true
|
|
26
|
+
- type: input
|
|
27
|
+
id: version
|
|
28
|
+
attributes:
|
|
29
|
+
label: Package version
|
|
30
|
+
placeholder: 0.1.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Feature request
|
|
2
|
+
description: Suggest a new content shape, provider adapter, or option.
|
|
3
|
+
title: "[Feature]: "
|
|
4
|
+
labels: [enhancement]
|
|
5
|
+
body:
|
|
6
|
+
- type: textarea
|
|
7
|
+
id: problem
|
|
8
|
+
attributes:
|
|
9
|
+
label: Problem
|
|
10
|
+
description: What content shape or workflow is not covered today?
|
|
11
|
+
validations:
|
|
12
|
+
required: true
|
|
13
|
+
- type: textarea
|
|
14
|
+
id: proposal
|
|
15
|
+
attributes:
|
|
16
|
+
label: Proposal
|
|
17
|
+
description: Describe the behavior you want.
|
|
18
|
+
validations:
|
|
19
|
+
required: true
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Summary
|
|
2
|
+
|
|
3
|
+
## Checklist
|
|
4
|
+
|
|
5
|
+
- [ ] Added or updated tests.
|
|
6
|
+
- [ ] `uv run ruff check .` passes.
|
|
7
|
+
- [ ] `uv run pytest` passes.
|
|
8
|
+
- [ ] Runtime dependencies are still zero, or the dependency is justified.
|
|
9
|
+
- [ ] Unknown non-empty content is not silently dropped.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Build
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- name: Install uv
|
|
16
|
+
uses: astral-sh/setup-uv@v5
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.11"
|
|
21
|
+
- name: Build package
|
|
22
|
+
run: uv build
|
|
23
|
+
- name: Upload distributions
|
|
24
|
+
uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/*
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v5
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.11"
|
|
20
|
+
- name: Install dependencies
|
|
21
|
+
run: uv sync --dev
|
|
22
|
+
- name: Lint
|
|
23
|
+
run: uv run ruff check .
|
|
24
|
+
- name: Test
|
|
25
|
+
run: uv run pytest
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v5
|
|
19
|
+
- name: Build package
|
|
20
|
+
run: uv build
|
|
21
|
+
- name: Publish package
|
|
22
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Smoke
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
smoke:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v5
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.11"
|
|
20
|
+
- name: Install dependencies
|
|
21
|
+
run: uv sync --dev
|
|
22
|
+
- name: Run smoke test
|
|
23
|
+
run: uv run python scripts/smoke.py
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Added maintainer documentation, release process, examples, and issue/PR templates.
|
|
6
|
+
- Added build and manual publish workflows.
|
|
7
|
+
|
|
8
|
+
## 0.1.0
|
|
9
|
+
|
|
10
|
+
Initial public release.
|
|
11
|
+
|
|
12
|
+
- Added text normalization for strings, Anthropic blocks, MCP objects, tool results, and message-like wrappers.
|
|
13
|
+
- Added tool output truncation helper.
|
|
14
|
+
- Added provider-aware multimodal image payload builders.
|
|
15
|
+
- Added tests and CI.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for helping improve `langchain-content-normalizer`.
|
|
4
|
+
|
|
5
|
+
## Development setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync --dev
|
|
9
|
+
uv run ruff check .
|
|
10
|
+
uv run pytest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Contribution guidelines
|
|
14
|
+
|
|
15
|
+
- Keep runtime dependencies at zero unless there is a strong reason.
|
|
16
|
+
- Prefer duck typing over importing LangChain, MCP, or provider SDK classes.
|
|
17
|
+
- Add tests for every new content shape or provider format.
|
|
18
|
+
- Do not silently drop unknown non-empty content. Preserve it with a safe fallback.
|
|
19
|
+
- Keep public APIs small and documented in `README.md`.
|
|
20
|
+
|
|
21
|
+
## Useful PRs
|
|
22
|
+
|
|
23
|
+
- New MCP or LangChain content fixtures.
|
|
24
|
+
- Better multimodal provider adapters.
|
|
25
|
+
- Strict-mode behavior for applications that prefer errors over fallback strings.
|
|
@@ -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
|
+
# Maintainers
|
|
2
|
+
|
|
3
|
+
## Primary maintainer
|
|
4
|
+
|
|
5
|
+
- Benjamin Jornet (`@BenjaminJornet`)
|
|
6
|
+
|
|
7
|
+
## Maintainer responsibilities
|
|
8
|
+
|
|
9
|
+
- Review pull requests that add new content shapes or provider adapters.
|
|
10
|
+
- Triage issues with reproducible input/output examples.
|
|
11
|
+
- Keep CI, packaging, and release workflows working.
|
|
12
|
+
- Preserve the zero-runtime-dependency contract unless a change is explicitly justified.
|
|
13
|
+
- Publish releases and update `CHANGELOG.md`.
|
|
14
|
+
|
|
15
|
+
## Review priorities
|
|
16
|
+
|
|
17
|
+
1. Unknown non-empty content must not be silently dropped.
|
|
18
|
+
2. Runtime dependencies should remain at zero.
|
|
19
|
+
3. Tests must cover each new block shape.
|
|
20
|
+
4. Public APIs should stay small and documented.
|
|
@@ -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
|
+
[](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml)
|
|
44
|
+
[](LICENSE)
|
|
45
|
+
[](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,91 @@
|
|
|
1
|
+
# langchain-content-normalizer
|
|
2
|
+
|
|
3
|
+
[](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](pyproject.toml)
|
|
6
|
+
|
|
7
|
+
Normalize the messy content shapes produced by LangChain, MCP tools, Anthropic content blocks, and multimodal chat APIs.
|
|
8
|
+
|
|
9
|
+
The package has no runtime dependencies. It works by duck typing instead of importing LangChain or MCP classes.
|
|
10
|
+
|
|
11
|
+
## What it solves
|
|
12
|
+
|
|
13
|
+
LLM agent stacks often receive content as one of many incompatible shapes:
|
|
14
|
+
|
|
15
|
+
| Source | Example shape | Output |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| Classic chat | `"plain text"` | `"plain text"` |
|
|
18
|
+
| Anthropic blocks | `[{"type": "text", "text": "hi"}]` | `"hi"` |
|
|
19
|
+
| Tool calls | `[{"type": "tool_use", ...}]` | skipped by default |
|
|
20
|
+
| MCP tool results | `[{"type": "tool_result", "content": [...]}]` | flattened text |
|
|
21
|
+
| MCP objects | objects exposing `.text` | extracted text |
|
|
22
|
+
| Message wrappers | objects exposing `.content` | recursively normalized |
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add langchain-content-normalizer
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Text normalization
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from lc_content_normalizer import extract_text_content, normalize_tool_output
|
|
34
|
+
|
|
35
|
+
content = [
|
|
36
|
+
{"type": "text", "text": "Reading logs..."},
|
|
37
|
+
{"type": "tool_use", "name": "tail_logs", "input": {"service": "api"}},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
assert extract_text_content(content) == "Reading logs..."
|
|
41
|
+
assert "tail_logs" in extract_text_content(content, skip_tool_use=False)
|
|
42
|
+
|
|
43
|
+
safe_output = normalize_tool_output(huge_tool_payload, max_chars=50_000)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Vision format routing
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from lc_content_normalizer import build_human_message_content, detect_vision_format
|
|
50
|
+
|
|
51
|
+
vision_format = detect_vision_format("anthropic", "claude-3-5-sonnet")
|
|
52
|
+
content = build_human_message_content(
|
|
53
|
+
"Explain this alert screenshot",
|
|
54
|
+
images=[{"data_url": "data:image/png;base64,...", "mime_type": "image/png"}],
|
|
55
|
+
vision_format=vision_format,
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`detect_vision_format()` returns:
|
|
60
|
+
|
|
61
|
+
| Provider/model | Format |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `anthropic` | native Anthropic `image` block with `source.base64` |
|
|
64
|
+
| `ollama` + `llava`/`vision` model name | OpenAI-compatible `image_url` block |
|
|
65
|
+
| `ollama` text-only model | `none`, images are dropped |
|
|
66
|
+
| OpenAI-compatible providers | OpenAI-compatible `image_url` block |
|
|
67
|
+
|
|
68
|
+
## Examples
|
|
69
|
+
|
|
70
|
+
- `examples/normalize_mcp_output.py` shows how MCP-style tool results are flattened.
|
|
71
|
+
- `examples/build_vision_content.py` shows provider-aware image block generation.
|
|
72
|
+
|
|
73
|
+
## Roadmap
|
|
74
|
+
|
|
75
|
+
- Add strict mode for unknown content blocks.
|
|
76
|
+
- Add more MCP fixture coverage.
|
|
77
|
+
- Add provider-specific adapters as content formats evolve.
|
|
78
|
+
- Keep runtime dependencies at zero.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv sync --dev
|
|
84
|
+
uv run ruff check .
|
|
85
|
+
uv run pytest
|
|
86
|
+
uv run python scripts/smoke.py
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Release Process
|
|
2
|
+
|
|
3
|
+
This project uses semantic versioning while the API is pre-1.0.
|
|
4
|
+
|
|
5
|
+
## Checklist
|
|
6
|
+
|
|
7
|
+
1. Run `uv run ruff check .`.
|
|
8
|
+
2. Run `uv run pytest`.
|
|
9
|
+
3. Run `uv build`.
|
|
10
|
+
4. Update `CHANGELOG.md`.
|
|
11
|
+
5. Create a Git tag, for example `v0.1.1`.
|
|
12
|
+
6. Push the tag.
|
|
13
|
+
7. Create a GitHub release.
|
|
14
|
+
8. Publish to PyPI through the manual `Publish to PyPI` workflow.
|
|
15
|
+
|
|
16
|
+
## Smoke checks
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv run python examples/normalize_mcp_output.py
|
|
20
|
+
uv run python examples/build_vision_content.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Release notes format
|
|
24
|
+
|
|
25
|
+
```md
|
|
26
|
+
## vX.Y.Z
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lc_content_normalizer import build_human_message_content, detect_vision_format
|
|
4
|
+
|
|
5
|
+
image = {"data_url": "data:image/png;base64,abc123", "mime_type": "image/png"}
|
|
6
|
+
|
|
7
|
+
for provider, model in [("anthropic", "claude"), ("ollama", "llava"), ("ollama", "llama")]:
|
|
8
|
+
vision_format = detect_vision_format(provider, model)
|
|
9
|
+
content = build_human_message_content("Explain this screenshot", [image], vision_format)
|
|
10
|
+
print(provider, model, vision_format, content)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lc_content_normalizer import extract_text_content, normalize_tool_output
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeTextContent:
|
|
7
|
+
def __init__(self, text: str) -> None:
|
|
8
|
+
self.type = "text"
|
|
9
|
+
self.text = text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
raw_tool_output = [
|
|
13
|
+
{
|
|
14
|
+
"type": "tool_result",
|
|
15
|
+
"content": [FakeTextContent("service=api status=healthy\n")],
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
print(extract_text_content(raw_tool_output))
|
|
20
|
+
print(normalize_tool_output(raw_tool_output, max_chars=80))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-content-normalizer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Normalize LangChain, MCP, and multimodal content blocks into provider-ready text and image payloads."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Benjamin Jornet", email = "benjamin.jornet@gmail.com" }]
|
|
13
|
+
keywords = ["langchain", "mcp", "llm", "multimodal", "content-normalization"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/benjaminjornet/langchain-content-normalizer"
|
|
27
|
+
Issues = "https://github.com/benjaminjornet/langchain-content-normalizer/issues"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
"ruff>=0.8",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/lc_content_normalizer"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
target-version = "py311"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lc_content_normalizer import build_human_message_content, extract_text_content
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
text = extract_text_content(
|
|
8
|
+
[{"type": "tool_result", "content": [{"type": "text", "text": "ok"}]}]
|
|
9
|
+
)
|
|
10
|
+
assert text == "ok"
|
|
11
|
+
|
|
12
|
+
image_content = build_human_message_content(
|
|
13
|
+
"describe",
|
|
14
|
+
[{"data_url": "data:image/png;base64,abc", "mime_type": "image/png"}],
|
|
15
|
+
"openai",
|
|
16
|
+
)
|
|
17
|
+
assert isinstance(image_content, list)
|
|
18
|
+
assert image_content[1]["type"] == "image_url"
|
|
19
|
+
print("smoke ok")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
|
@@ -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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lc_content_normalizer import extract_text_content, normalize_tool_output
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeMessage:
|
|
7
|
+
def __init__(self, content):
|
|
8
|
+
self.content = content
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeTextContent:
|
|
12
|
+
def __init__(self, text: str):
|
|
13
|
+
self.type = "text"
|
|
14
|
+
self.text = text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_string_passthrough():
|
|
18
|
+
assert extract_text_content("hello") == "hello"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_none_returns_empty():
|
|
22
|
+
assert extract_text_content(None) == ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_anthropic_text_blocks_are_concatenated_and_tool_use_is_skipped():
|
|
26
|
+
content = [
|
|
27
|
+
{"type": "text", "text": "The file "},
|
|
28
|
+
{"type": "tool_use", "name": "read_file", "input": {"path": "app.py"}},
|
|
29
|
+
{"type": "text", "text": "is ready."},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
assert extract_text_content(content) == "The file is ready."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_tool_use_can_be_included_explicitly():
|
|
36
|
+
content = [{"type": "tool_use", "input": {"path": "app.py"}}]
|
|
37
|
+
|
|
38
|
+
assert "app.py" in extract_text_content(content, skip_tool_use=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_tool_result_nested_content_is_flattened():
|
|
42
|
+
content = [{"type": "tool_result", "content": [{"type": "text", "text": "inner"}]}]
|
|
43
|
+
|
|
44
|
+
assert extract_text_content(content) == "inner"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_mcp_text_content_object_is_extracted():
|
|
48
|
+
assert extract_text_content(FakeTextContent("from MCP")) == "from MCP"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_mcp_text_content_list_is_extracted():
|
|
52
|
+
assert extract_text_content([FakeTextContent("from MCP")]) == "from MCP"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_message_like_content_is_unwrapped():
|
|
56
|
+
assert extract_text_content(FakeMessage([{"type": "text", "text": "wrapped"}])) == "wrapped"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_unknown_dict_is_preserved_as_string():
|
|
60
|
+
result = extract_text_content({"status": "ok", "count": 2})
|
|
61
|
+
|
|
62
|
+
assert "status" in result
|
|
63
|
+
assert "2" in result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_unknown_block_list_does_not_silently_disappear():
|
|
67
|
+
result = extract_text_content([{"request_id": "abc", "message": "hello"}])
|
|
68
|
+
|
|
69
|
+
assert "request_id" in result
|
|
70
|
+
assert "hello" in result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_image_blocks_are_dropped():
|
|
74
|
+
content = [{"type": "text", "text": "screenshot:"}, {"type": "image", "source": {}}]
|
|
75
|
+
|
|
76
|
+
assert extract_text_content(content) == "screenshot:"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_normalize_tool_output_truncates_large_payloads():
|
|
80
|
+
result = normalize_tool_output("x" * 20, max_chars=10)
|
|
81
|
+
|
|
82
|
+
assert result.startswith("x" * 10)
|
|
83
|
+
assert "truncated 10 chars" in result
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lc_content_normalizer import (
|
|
4
|
+
VISION_FORMAT_ANTHROPIC_NATIVE,
|
|
5
|
+
VISION_FORMAT_NONE,
|
|
6
|
+
VISION_FORMAT_OPENAI,
|
|
7
|
+
build_human_message_content,
|
|
8
|
+
detect_vision_format,
|
|
9
|
+
image_block_for_format,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
PNG = {"data_url": "data:image/png;base64,abc123", "mime_type": "image/png"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_detect_vision_format_anthropic_native():
|
|
16
|
+
assert detect_vision_format("anthropic", "claude-3-5-sonnet") == VISION_FORMAT_ANTHROPIC_NATIVE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_detect_vision_format_ollama_vision_models_use_openai_blocks():
|
|
20
|
+
assert detect_vision_format("ollama", "llava:13b") == VISION_FORMAT_OPENAI
|
|
21
|
+
assert detect_vision_format("ollama", "model-with-vision") == VISION_FORMAT_OPENAI
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_detect_vision_format_ollama_text_only():
|
|
25
|
+
assert detect_vision_format("ollama", "llama3") == VISION_FORMAT_NONE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_detect_vision_format_openai_compatible_default():
|
|
29
|
+
assert detect_vision_format("openrouter", "gpt-4o-mini") == VISION_FORMAT_OPENAI
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_image_block_for_openai_format():
|
|
33
|
+
assert image_block_for_format(PNG, VISION_FORMAT_OPENAI) == {
|
|
34
|
+
"type": "image_url",
|
|
35
|
+
"image_url": {"url": "data:image/png;base64,abc123"},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_image_block_for_anthropic_native_format():
|
|
40
|
+
block = image_block_for_format(PNG, VISION_FORMAT_ANTHROPIC_NATIVE)
|
|
41
|
+
|
|
42
|
+
assert block["type"] == "image"
|
|
43
|
+
assert block["source"] == {"type": "base64", "media_type": "image/png", "data": "abc123"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_build_human_message_content_returns_text_without_images():
|
|
47
|
+
assert build_human_message_content("hello", []) == "hello"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_build_human_message_content_drops_images_when_no_vision():
|
|
51
|
+
assert build_human_message_content("hello", [PNG], VISION_FORMAT_NONE) == "hello"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_build_human_message_content_returns_multimodal_blocks():
|
|
55
|
+
content = build_human_message_content("hello", [PNG], VISION_FORMAT_OPENAI)
|
|
56
|
+
|
|
57
|
+
assert content == [
|
|
58
|
+
{"type": "text", "text": "hello"},
|
|
59
|
+
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}},
|
|
60
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "iniconfig"
|
|
16
|
+
version = "2.3.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "langchain-content-normalizer"
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
source = { editable = "." }
|
|
27
|
+
|
|
28
|
+
[package.dev-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
{ name = "pytest" },
|
|
31
|
+
{ name = "ruff" },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[package.metadata]
|
|
35
|
+
|
|
36
|
+
[package.metadata.requires-dev]
|
|
37
|
+
dev = [
|
|
38
|
+
{ name = "pytest", specifier = ">=8.0" },
|
|
39
|
+
{ name = "ruff", specifier = ">=0.8" },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[[package]]
|
|
43
|
+
name = "packaging"
|
|
44
|
+
version = "26.2"
|
|
45
|
+
source = { registry = "https://pypi.org/simple" }
|
|
46
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
|
47
|
+
wheels = [
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[[package]]
|
|
52
|
+
name = "pluggy"
|
|
53
|
+
version = "1.6.0"
|
|
54
|
+
source = { registry = "https://pypi.org/simple" }
|
|
55
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
56
|
+
wheels = [
|
|
57
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[[package]]
|
|
61
|
+
name = "pygments"
|
|
62
|
+
version = "2.20.0"
|
|
63
|
+
source = { registry = "https://pypi.org/simple" }
|
|
64
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
65
|
+
wheels = [
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[[package]]
|
|
70
|
+
name = "pytest"
|
|
71
|
+
version = "9.0.3"
|
|
72
|
+
source = { registry = "https://pypi.org/simple" }
|
|
73
|
+
dependencies = [
|
|
74
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
75
|
+
{ name = "iniconfig" },
|
|
76
|
+
{ name = "packaging" },
|
|
77
|
+
{ name = "pluggy" },
|
|
78
|
+
{ name = "pygments" },
|
|
79
|
+
]
|
|
80
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
|
81
|
+
wheels = [
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[[package]]
|
|
86
|
+
name = "ruff"
|
|
87
|
+
version = "0.15.15"
|
|
88
|
+
source = { registry = "https://pypi.org/simple" }
|
|
89
|
+
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
|
90
|
+
wheels = [
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
|
92
|
+
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
|
94
|
+
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
|
95
|
+
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
|
96
|
+
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
|
97
|
+
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
|
98
|
+
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
|
100
|
+
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
|
101
|
+
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
|
104
|
+
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
|
105
|
+
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
|
106
|
+
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
|
107
|
+
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
|
108
|
+
]
|