feat: serialize LangChain multimodal content to ingest content blocks#487
feat: serialize LangChain multimodal content to ingest content blocks#487
Conversation
5617134 to
aefd885
Compare
| @async_warn_catch_exception(logger=_logger) | ||
| async def ingest_traces(self, traces_ingest_request: TracesIngestRequest) -> dict[str, Any]: | ||
| if self.experiment_id: | ||
| traces_ingest_request.experiment_id = UUID(self.experiment_id) | ||
| elif self.log_stream_id: |
There was a problem hiding this comment.
IngestTraces.ingest_traces now reimplements the experiment/log_stream wiring (and by extension the model_dump/_log_ingest_content_blocks flow) that already exists in Traces.ingest_traces; keeping two copies means every future change to request preparation or logging must be applied twice. Can we extract a shared helper that sets experiment_id/log_stream_id, dumps the request, logs the content blocks, and optionally sets logging_method, then call it from both ingestion clients so we only maintain that plumbing in one place?
Finding type: Code Dedup and Conventions
- Apply fix with Baz
There was a problem hiding this comment.
Will clean this up in a follow-up, keeping it as-is for now.
There was a problem hiding this comment.
Understood, we can leave it as-is for now and plan the cleanup for a follow-up; I’ll remember this once the PR is merged.
| elif self.experiment_id: | ||
| self._traces_client = Traces(project_id=self.project_id, experiment_id=self.experiment_id) | ||
|
|
||
| if os.environ.get("GALILEO_INGEST_URL"): |
There was a problem hiding this comment.
Only created when this URL is set
1d6a39e to
30195c9
Compare
|
30195c9 to
3a955c2
Compare
0819bbc to
d714676
Compare
| """ | ||
| # Auto-convert non-string metadata values to strings | ||
| if metadata: | ||
| metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} | ||
| if dataset_metadata: | ||
| dataset_metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in dataset_metadata.items()} | ||
|
|
There was a problem hiding this comment.
The same metadata normalization block (if metadata: metadata = {k: GalileoLogger._convert_metadata_value(v) ...}) appears in add_single_llm_span_trace, add_llm_span, add_tool_span, add_protect_span, add_agent_span, add_workflow_span, and add_retriever_span; keeping 7 copies in sync if the MetadataValue handling changes is error prone. Can we extract a helper like _normalize_metadata(metadata) and call it before building each kwargs dict so all spans share the logic?
Finding type: Code Dedup and Conventions | Severity: 🟢 Low
Want Baz to fix this for you? Activate Fixer
There was a problem hiding this comment.
Pre-existing pattern, out of scope for this PR.
There was a problem hiding this comment.
Understood, I’ll treat this as an existing pattern and out of scope for this PR, and I’ll remember this for future reviews once the PR is merged.
6335fd3 to
c89bd88
Compare
| # LangChain multimodal message format mapping. | ||
| # See https://python.langchain.com/docs/concepts/multimodality/ | ||
| # LangChain uses {"type": "<modality>_url", "<modality>_url": {"url": "..."}} for media, | ||
| # and {"type": "text", "text": "..."} for text segments. | ||
| _LANGCHAIN_TYPE_TO_MODALITY = { | ||
| "image_url": "image", | ||
| "audio_url": "audio", | ||
| "video_url": "video", | ||
| "document_url": "document", | ||
| "input_image": "image", | ||
| "input_audio": "audio", | ||
| } |
There was a problem hiding this comment.
Message.content is being converted to structured content blocks (TextContentBlock / DataContentBlock) when LangChain multimodal messages are encountered. The new functions _convert_langchain_content_block and _normalize_multimodal_content convert list-of-dict LangChain multimodal content into IngestContentBlock instances (TextContentBlock/DataContentBlock). This causes the runtime payloads to include arrays/objects for message.content rather than the legacy string. The OpenAPI schema (openapi.yaml) still declares galileo_core__schemas__logging__llm__Message.content as a string, so traces with multimodal content will no longer conform to the public ingest contract and may be rejected. The schema and downstream validation should be updated to accept content block arrays (or the serialization should continue sending a string representation). Also review the EventSerializer branches that call _normalize_multimodal_content (further down in this file) as related call sites.
Finding type: Breaking Changes | Severity: 🔴 High
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents:
In src/galileo/utils/serialization.py around lines 61-117, the new functions
_convert_langchain_content_block and _normalize_multimodal_content convert LangChain
multimodal message.content into TextContentBlock/DataContentBlock objects, causing
Message.content to become an array/object instead of the legacy string. Either: (A)
Update the public ingest contract by changing the OpenAPI schema (openapi.yaml) and the
galileo_core__schemas__logging__llm__Message.content type to permit an array of content
blocks (and add/adjust validators and tests), or (B) revert the serialization here to
preserve backward compatibility by keeping message.content as a string (e.g., convert
content list/dicts to a stable string representation before returning), and remove or
limit calls to _normalize_multimodal_content in EventSerializer branches. Apply the
chosen approach consistently to all places in this file that call
_normalize_multimodal_content (and add/update unit tests and schema validation) so
runtime payloads match the public contract.
There was a problem hiding this comment.
Intentional. The schema is being widened across the stack (galileo-core, orbit, API). The openapi.yaml here is auto-generated from the API via scripts/import-openapi-yaml.sh, so it'll be updated once the API PR lands and we re-import.
There was a problem hiding this comment.
Got it, thanks for the clarification on the intentional schema widening and OpenAPI auto-generation; I’ll remember this behavior and treat it as expected once the PR is merged.
| # Auto-convert non-string metadata values to strings | ||
| if metadata: | ||
| metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} | ||
|
|
There was a problem hiding this comment.
add_llm_span now normalizes metadata with {k: _convert_metadata_value(v)} before every span, and the same 3-line snippet appears at least in add_retriever_span (1189‑1290), add_tool_span (1277‑1281), add_protect_span (1355‑1358), add_workflow_span (1442‑1445) and add_agent_span (1532‑1535). This is copy‑paste plumbing that will need the same edit every time metadata handling changes. Can we extract a helper such as _normalize_metadata(metadata: Optional[dict[str,MetadataValue]]) -> Optional[dict[str,str]] and call it from each span method so the conversion logic lives in one place?
Finding type: Code Dedup and Conventions | Severity: 🟢 Low
Want Baz to fix this for you? Activate Fixer
There was a problem hiding this comment.
Pre-existing pattern, out of scope for this PR.
There was a problem hiding this comment.
Understood, I’ll treat this as an existing pattern and keep that in mind for future suggestions, and I’ll save this feedback to memory after the PR is merged.
5796034 to
107aeeb
Compare
Convert LangChain multimodal message format (image_url, audio_url, etc.) into Galileo IngestContentBlock schema (TextContentBlock, DataContentBlock). Add IngestTraces client for the orbit ingest service.
107aeeb to
576744f
Compare
User description
Summary
image_url,audio_url,video_url, etc.) into Galileo'sIngestContentBlockschema (TextContentBlock,DataContentBlock) instead of flattening list content to a plain stringIngestTracesclient for the orbit ingest service (opt-in viaGALILEO_INGEST_URLenv var)Changes
Serialization (
src/galileo/utils/serialization.py)_convert_langchain_content_block()maps LangChain's{"type": "image_url", "image_url": {"url": "..."}}format toDataContentBlock(type="image", url="..."), with base64 data URI detectionEventSerializernow converts list content on bothAIMessageandBaseMessagesubclasses to content block arrays instead of extracting only the first text elementIngest client (
src/galileo/traces.py)IngestTracesclass sends traces directly to the orbit ingest service viahttpx.AsyncClient_log_ingest_content_blocks()helper logs content block types at DEBUG level before HTTP POSTRoutes.ingest_tracesconstantLogger integration (
src/galileo/logger/logger.py)GalileoLoggercreates anIngestTracesclient whenGALILEO_INGEST_URLis set, preferring it over the API-proxied pathHandler (
src/galileo/handlers/langchain/handler.py)on_chat_model_startshowing multimodal message countTest plan
TestConvertLangchainContentBlock-- unit tests for text, image URL, base64, audio, unknown fallbackTestMultimodalContentSerialization-- round-trip serialization of AIMessage/HumanMessage with multimodal contenttest_on_chat_model_start_multimodal-- integration test verifying LangChain callback produces structured content blocksDependencies
Requires galileo-core#feature/sc-56110 to be merged and released first (adds
IngestContentBlock,TextContentBlock,DataContentBlocktogalileo_core.schemas.shared.content_blocks).Made with Cursor
Generated description
Below is a concise technical summary of the changes proposed in this PR:
Convert LangChain message serialization in
EventSerializerto emitTextContentBlock/DataContentBlockarrays so multimodalcontent_blocksmap directly to the ingest schema while preserving string-only legacy flows and surfacing debug logs for multimodal callbacks. Introduce theIngestTracesclient along with theGALILEO_INGEST_URLwiring soGalileoLoggercan post structured traces directly to the ingest service when available.IngestTracesclient, routes, and logger wiring so structured traces can be sent to the ingest service viaGALILEO_INGEST_URL, including dependency updates.Modified files (5)
Latest Contributors(2)
IngestContentBlockarrays throughEventSerializer, ensure handlers report multimodal counts, and cover the flow with new serialization tests and assertions in both sync/async callbacks.Modified files (4)
Latest Contributors(2)