-
Notifications
You must be signed in to change notification settings - Fork 46
(Openinference Migration: Langchain): Capture multimodal image content (OpenAI image_url and Anthropic image blocks) as Blob/Uri message parts.
#296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8837fc4
41b35b6
5464667
8ef04a2
86522fa
501f7a3
80774cf
c2a941f
a779861
8558166
a7d1477
b7cd8a6
0447f86
a0bb771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| (Openinference Migration: Langchain) - Capture multimodal image content (OpenAI ``image_url`` and Anthropic ``image`` blocks) as ``Blob``/``Uri`` message parts. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| gen_ai_attributes as GenAIAttributes, | ||
| ) | ||
| from opentelemetry.util.genai.types import ( | ||
| Blob, | ||
|
lmolkova marked this conversation as resolved.
|
||
| FunctionToolDefinition, | ||
| InputMessage, | ||
| MessagePart, | ||
|
|
@@ -29,6 +30,7 @@ | |
| ToolCallResponse, | ||
| ToolDefinition, | ||
| ) | ||
| from opentelemetry.util.genai.utils import decode_base64, image_from_url | ||
|
|
||
| # Mapping from LangChain ``ls_provider`` metadata values to the well-known | ||
| # ``gen_ai.provider.name`` values defined by the GenAI semantic conventions. | ||
|
|
@@ -75,6 +77,59 @@ def _normalize_role(message: BaseMessage) -> str: | |
| return _ROLE_MAP.get(message.type, message.type) | ||
|
|
||
|
|
||
| def _media_part(item: dict[str, Any]) -> MessagePart | None: | ||
| """Convert a LangChain multimodal image content block into a media part. | ||
|
|
||
| Handles the two shapes LangChain chat models accept: | ||
|
|
||
| - OpenAI style ``{"type": "image_url", "image_url": {"url": ...}}`` (or a | ||
| bare ``"image_url": "..."`` string). A ``data:<mime>;base64,<payload>`` | ||
| URL becomes a :class:`Blob`; any other URL becomes a :class:`Uri`. | ||
| - Anthropic style ``{"type": "image", "source": {...}}`` where ``source`` | ||
| is either ``{"type": "base64", "media_type": ..., "data": ...}`` (→ | ||
| :class:`Blob`) or ``{"type": "url", "url": ...}`` (→ :class:`Uri`). | ||
| """ | ||
| block_type = item.get("type") | ||
| if block_type == "image_url": | ||
| image_url = item.get("image_url") | ||
| url: str | None = None | ||
| if isinstance(image_url, str): | ||
| url = image_url | ||
| elif isinstance(image_url, dict): | ||
| image_url_dict = cast(dict[str, Any], image_url) | ||
| raw_url = image_url_dict.get("url") | ||
| url = raw_url if isinstance(raw_url, str) else None | ||
| if not url: | ||
| return None | ||
| return image_from_url(url) | ||
| if block_type == "image": | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LangChain's own standard image blocks are silently dropped - only the raw provider shapes are handled. This fails on the branch - all four cases: @pytest.mark.parametrize(
"block,expected",
[
({"type": "image", "base64": B64, "mime_type": "image/png"}, Blob),
({"type": "image", "url": "https://example.com/a.png"}, Uri),
({"type": "image", "source_type": "base64", "data": B64, "mime_type": "image/png"}, Blob),
({"type": "image", "source_type": "url", "url": "https://e/b.png"}, Uri),
],
)
def test_langchain_standard_image_blocks_are_captured(block, expected):
messages = to_input_messages([HumanMessage(content=[block])])
assert messages, "message dropped entirely - no parts extracted"
assert any(isinstance(p, expected) for p in messages[0].parts)Please handle these shapes and add the tests. |
||
| source = item.get("source") | ||
| if not isinstance(source, dict): | ||
| return None | ||
| source_dict = cast(dict[str, Any], source) | ||
| source_type = source_dict.get("type") | ||
| if source_type == "base64": | ||
| data = source_dict.get("data") | ||
| if not isinstance(data, str): | ||
| return None | ||
| decoded = decode_base64(data) | ||
| if decoded is None: | ||
| return None | ||
| media_type = source_dict.get("media_type") | ||
| return Blob( | ||
| mime_type=( | ||
| media_type if isinstance(media_type, str) else None | ||
| ), | ||
| modality="image", | ||
| content=decoded, | ||
| ) | ||
| if source_type == "url": | ||
| source_url = source_dict.get("url") | ||
| if isinstance(source_url, str) and source_url: | ||
| return image_from_url(source_url) | ||
| return None | ||
|
|
||
|
|
||
| def _content_to_parts( | ||
| content: str | list[str | dict[str, Any]], | ||
| ) -> list[MessagePart]: | ||
|
|
@@ -109,6 +164,10 @@ def _content_to_parts( | |
| ) | ||
| if isinstance(reasoning_value, str) and reasoning_value: | ||
| parts.append(Reasoning(content=reasoning_value)) | ||
| elif block_type in ("image_url", "image"): | ||
| media = _media_part(item) | ||
| if media is not None: | ||
| parts.append(media) | ||
| return parts | ||
|
|
||
|
|
||
|
|
@@ -187,7 +246,11 @@ def _message_parts(message: BaseMessage) -> list[MessagePart]: | |
| def to_input_messages( | ||
| messages: Iterable[Any], | ||
| ) -> list[InputMessage]: | ||
| """Convert LangChain messages into spec-conformant ``InputMessage`` s.""" | ||
| """Convert LangChain messages into spec-conformant ``InputMessage`` s. | ||
|
|
||
| Called only when content capture is enabled | ||
| (``TelemetryHandler.should_capture_content()``). | ||
| """ | ||
| try: | ||
| normalized_messages: Iterable[BaseMessage] = convert_to_messages( | ||
| list(messages) | ||
|
|
@@ -216,6 +279,9 @@ def to_output_messages( | |
| as ``gen_ai.output.messages``. Tool execution results belong on the | ||
| *input* side of the next inference call, not the output side of the | ||
| previous one. | ||
|
|
||
| Called only when content capture is enabled | ||
| (``TelemetryHandler.should_capture_content()``). | ||
| """ | ||
| result: list[OutputMessage] = [] | ||
| for message in messages: | ||
|
|
@@ -295,6 +361,9 @@ def make_input_message(data: Any) -> list[InputMessage]: | |
| When no ``messages`` key exists (common in LangGraph state dicts), the | ||
| remaining state fields are serialized as JSON and emitted as a single | ||
| user-role :class:`Text` part. | ||
|
|
||
| Called only when content capture is enabled | ||
| (``TelemetryHandler.should_capture_content()``). | ||
| """ | ||
| if not isinstance(data, dict): | ||
| return [] | ||
|
|
@@ -329,6 +398,9 @@ def make_output_message(data: Any) -> list[OutputMessage]: | |
| empty: the underlying per-LLM-call finish reasons are recorded on child | ||
| inference spans, and util-genai filters empty values out of | ||
| ``gen_ai.response.finish_reasons``. | ||
|
|
||
| Called only when content capture is enabled | ||
| (``TelemetryHandler.should_capture_content()``). | ||
| """ | ||
| if not isinstance(data, dict): | ||
| return [] | ||
|
|
@@ -349,6 +421,9 @@ def make_last_output_message(data: Any) -> list[OutputMessage]: | |
| For Workflow and AgentInvocation spans, the final AI message best represents | ||
| the actual output. Intermediate AI messages (e.g., tool-call decisions) are | ||
| already captured in child LLM invocation spans. | ||
|
|
||
| Called only when content capture is enabled | ||
| (``TelemetryHandler.should_capture_content()``). | ||
| """ | ||
| all_messages = make_output_message(data) | ||
| if all_messages: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # TODO: this is generated by AI, re-record | ||
| # against the live Anthropic API once an ANTHROPIC_API_KEY is available. | ||
| interactions: | ||
| - request: | ||
| body: |- | ||
| {"model": "claude-sonnet-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": [{"type": "text", "text": "What is in this image?"}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAARklEQVR42u3XQQ0AIAwAsSnZG4lInJxJwMRICGlyAvq9yF1PFUBAQEBAQBdAXWskICAgICAgICAgICAgIOcKBAQEBPQd6ACUHHNEU5qggAAAAABJRU5ErkJggg=="}}]}], "temperature": 0.1} | ||
| headers: | ||
| Content-Type: | ||
| - application/json | ||
| User-Agent: | ||
| - !!binary | | ||
| QW50aHJvcGljL1B5dGhvbiAxLjAuMA== | ||
| x-api-key: | ||
| - test_key | ||
| anthropic-version: | ||
| - '2023-06-01' | ||
| method: POST | ||
| uri: https://api.anthropic.com/v1/messages | ||
| response: | ||
| body: | ||
| string: |- | ||
| { | ||
| "id": "msg_01MultimodalImagePlaceholder", | ||
| "type": "message", | ||
| "role": "assistant", | ||
| "model": "claude-sonnet-4-5", | ||
| "content": [ | ||
| { | ||
| "type": "text", | ||
| "text": "This is a tiny 1x1 pixel PNG image." | ||
| } | ||
| ], | ||
| "stop_reason": "end_turn", | ||
| "stop_sequence": null, | ||
| "usage": { | ||
| "input_tokens": 16, | ||
| "output_tokens": 12 | ||
| } | ||
| } | ||
| headers: | ||
| Content-Type: | ||
| - application/json | ||
| Date: | ||
| - Thu, 04 Sep 2025 20:00:58 GMT | ||
| status: | ||
| code: 200 | ||
| message: OK | ||
| version: 1 |
Uh oh!
There was an error while loading. Please reload this page.