Skip to content

Conversation

@gustavocidornelas
Copy link
Contributor

Pull Request

Summary

Introduces:

  1. Multimodal trace support for OpenAI chat completion and responses API (image, audio, files).
  2. "Attachment" support for every trace step.

Changes

Attachments

  • Introduces the Attachment abstraction, which represents media data uploaded to a blob store. The Attachment object stores metadata and the storageUri, which can be used by the Openlayer platform to fetch the media.
  • Every step can have an attachments field, which is an array of Attachment objects. This allows users to log arbitrary media to a step. For example:
from openlayer.lib import trace
from openlayer.lib.tracing import log_attachment

@trace()
def my_function():
    # Do something

    # Log attachment to the `my_function` step
    log_attachment("/path/to/file")
    return
  • When streaming the trace to the Openlayer step, we now scan the trace to check if there are any attachments. If there are, they are uploaded first and then the trace is streamed. The upload happens via the typical presigned URL flow.

OpenAI multimodal

  • Besides having attachments, this PR also instruments the trace_openai wrapper to parse image/audio/files in the input or output of OpenAI LLM calls.
  • To support media in OpenAI traces, we extend how inputs/outputs are represented. In summary, the schema is:
  # -------------- Inputs --------------------
  {
      "prompt": [
          # Old format. We still use it when the prompt only has strings
          {
              "role": "user",
              "content": "Simple text message"  # String for text-only (backwards compatibility)
          },
           # New format. Content as a list of objects with `type` (one of `text`, `image`, `audio`, or `file`)
          {
              "role": "user",
              "content": [  # List for multimodal
                  {"type": "text", "text": "What's in this image?"},
                  {"type": "image", "attachment": {"id": "...", "storageUri": "...", ...}}
              ]
          }
      ]
  }

# -------------- Output --------------------
# Old format. We still use it if the output is a simple string
  "Simple text response"  # String for text-only (backwards compatibility)

  # or
  {"type": "text", "text": "Text response"}

  # or
  {"type": "audio", "attachment": {"id": "...", "storageUri": "...", ...}}

  # or mixed
  [
      {"type": "text", "text": "Here's the image you requested:"},
      {"type": "image", "attachment": {...}}
  ]

Note that if the type is one of image, audio, or file, the other object field is an attachment, which is a serialized Attachment object.

Context

OPEN-8683: Multimodal attachment support for the Python SDK and OPEN-8684: Enhance OpenAI tracer to support multimodal inputs/outputs

Testing

  • Manual testing

if name:
processed_msg["name"] = name
processed_messages.append(processed_msg)
else:
Copy link
Contributor

@shah-siddd shah-siddd Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this cause issues?
when the content type is unknown?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we encounter an unknown content type, we preserve the original message unchanged. If OpenAI introduces a new content type we don't recognize, we don't break the trace, we just pass it through as-is. The trace will still work, we just won't have special handling for that new type.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it log a warning or emit an alert so we can learn about this new type and add it later?

elif item_type == "file":
file_data = item.get("file", {})
file_id = file_data.get("file_id")
file_data_b64 = file_data.get("file_data")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the base64 is too big? I don't know if it will cause issues while sending like for example if the request size exceeds.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! The base64 data is stored temporarily in the Attachment object, but it's not sent in the trace payload. The flow is:

  1. Base64 data is extracted and stored in Attachment.data_base64
  2. Before sending the trace, upload_trace_attachments() uploads the data to our blob storage and gets a storage_uri
  3. After upload, data_base64 is cleared (set to None) in attachment_uploader.py
  4. Only the storage_uri is serialized into the trace payload

So the actual trace request only contains the URI reference, not the heavy base64 data.

elif file_id:
# Just reference the file ID (can't download without API call)
attachment = Attachment(name=filename)
attachment.metadata["openai_file_id"] = file_id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we just have openai_file_id, what will we show in the UI?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we only have openai_file_id, we create an attachment with just the filename and store the file_id in metadata. This is a design decision and in the UI, I thought we could show:

  • The filename (if provided, otherwise "file")
  • The openai_file_id reference in metadata
  • No preview/content (since we don't have access to the actual data)

This is a limitation. We could potentially add an option to fetch the file content using the client, but that adds latency and complexity. For now, I thought we could just capture the reference.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets add this as a comment somewhere so @eluzzi5 can come up with a good design.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a Linear issue?

The attachment with storage_uri populated (if upload was needed).
"""
# Skip if already uploaded
if attachment.is_uploaded():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we still upload data from openai or anywhere else to our storage? they might remove that data after sometime.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, if an attachment has an external URL (like a hosted image URL), we keep that URL and don't re-upload to our storage.

Pros of current approach:

  • Faster (no download + re-upload)
  • Less storage cost

Cons:

  • External URLs may expire
  • Data could become unavailable

I think this is worth discussing as a follow-up improvement. We could add an option like persist_external_urls=True that would download and re-upload external content to ensure long-term availability. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think storage is cheap. We can do it async on our backend later but we should surely store it with us. I am sure that particular file is important and if it expires we miss critical information. We can do it later as a followup task but make it default.
Thoughts @gustavocidornelas @whoseoyster ?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a compliance risk if we copy data to Openlayer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! The attachment upload feature is opt-in. The user needs to explicitly set attachment_upload_enabled=True when they configure the tracer.

However, we should document this clearly. Worth discussing further

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree with us copying files over to our storage wherever possible, but allowing opt out

try:
from .attachment_uploader import upload_trace_attachments

upload_count = upload_trace_attachments(trace)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this _upload_and_publish_trace will be called when we do have an attachment to upload - right?
because if theres nothing to upload then upload_count will be 0.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_upload_and_publish_trace is called for every trace, regardless of whether there are attachments. It handles both uploading attachments (if any exist and attachment_upload_enabled=True) and publishing the trace data to Openlayer.

If there are no attachments, upload_count will be 0, but the function still proceeds to publish the trace. The attachment upload is just one step in the trace completion process. The naming could be clearer - it's really "process and publish trace" with attachment upload being an optional first step.

Copy link

@matheusportela matheusportela left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gustavocidornelas I left a bunch of comments but none is blocking (some are literally just because I'm curious)! Let me know if you have any questions

Comment on lines +118 to +119
upload_fields = dict(fields) if fields else {}
upload_fields["file"] = (object_name, data, content_type)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you change the fields type signature to fields: Dict = {}, you can simplify this:

upload_fields = {
  "file": (object_name, data, content_type),
  **fields
}

Comment on lines +152 to +154
headers = {"Content-Type": content_type}
if extra_headers:
headers.update(extra_headers)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the previous comment, this can be simplified by changing extra_headers to a default {}:

extra_headers: Dict[str, str] = {}
headers = {
  "Content-Type": content_type,
  **extra_headers
}

Comment on lines +233 to +235
collected_function_call["arguments"] += delta.tool_calls[
0
].function.arguments

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird formatting choice by the linter lol

Comment on lines +403 to +406
has_audio = (
hasattr(response.choices[0].message, "audio")
and response.choices[0].message.audio is not None
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly simpler check:

Suggested change
has_audio = (
hasattr(response.choices[0].message, "audio")
and response.choices[0].message.audio is not None
)
has_audio = bool(getattr(response.choices[0].message, "audio", None))

Comment on lines +485 to +487
for item in content:
content_item = _normalize_content_item(item)
normalized_content.append(content_item)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a list comprehension:

normalized_content = [_normalize_content_item(item) for item in content)

return Path(self.file_path).exists()
return False

def is_valid(self) -> bool:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we do if an attachment is not valid?

decoded = base64.b64decode(data_base64)
size_bytes = len(decoded)
checksum_md5 = hashlib.md5(decoded).hexdigest()
except Exception:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would this happen?

Comment on lines +365 to +366
# TODO: Implement metadata extraction from file
pass

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still relevant?

Comment on lines +97 to +98
# File-like object
file_bytes = data.read()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would this happen? Can we explicit check for the classes (e.g. BinaryIO) and have an else clause that will raise an error if an unexpected type is passed in? IMO it would be a better developer experience than breaking on data.read(), for instance.

# Reset attachment uploader
from .attachment_uploader import reset_uploader

reset_uploader()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to reset the uploader here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _uploader holds a reference to the Openlayer client. When configure() is called with new credentials/settings, we need to reset the uploader so it creates a new client with the updated configuration. Otherwise, it would continue using the old client.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants