Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@

import static io.opentelemetry.api.common.AttributeKey.stringKey;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
import com.openai.models.chat.completions.ChatCompletionContentPartImage;
import com.openai.models.chat.completions.ChatCompletionContentPartText;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.openai.models.chat.completions.ChatCompletionDeveloperMessageParam;
Expand All @@ -18,12 +23,14 @@
import com.openai.models.chat.completions.ChatCompletionSystemMessageParam;
import com.openai.models.chat.completions.ChatCompletionToolMessageParam;
import com.openai.models.chat.completions.ChatCompletionUserMessageParam;
import dev.braintrust.trace.Base64Attachment;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Value;
import io.opentelemetry.api.logs.LogRecordBuilder;
import io.opentelemetry.api.logs.Logger;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.context.Context;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
Expand All @@ -41,8 +48,38 @@
final class ChatCompletionEventsHelper {

private static final AttributeKey<String> EVENT_NAME = stringKey("event.name");
private static final ObjectMapper JSON_MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
private static final ObjectMapper JSON_MAPPER = createObjectMapper();

private static ObjectMapper createObjectMapper() {
final JsonSerializer<Base64Attachment> attachmentSerializer =
Base64Attachment.createSerializer();
ObjectMapper mapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addSerializer(
ChatCompletionContentPartImage.class,
new JsonSerializer<>() {
@Override
public void serialize(
ChatCompletionContentPartImage value,
JsonGenerator gen,
SerializerProvider serializers)
throws IOException {
try {
var attachment =
Base64Attachment.of(
value.validate().imageUrl().validate().url());
attachmentSerializer.serialize(attachment, gen, serializers);
} catch (Exception e) {
JsonSerializer<Object> defaultSerializer =
serializers.findValueSerializer(
ChatCompletionContentPartImage.class, null);
defaultSerializer.serialize(value, gen, serializers);
}
}
});
mapper.registerModule(module);
return mapper;
}

@SneakyThrows
public static void emitPromptLogEvents(
Expand Down
121 changes: 121 additions & 0 deletions src/main/java/dev/braintrust/trace/Base64Attachment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package dev.braintrust.trace;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.Objects;
import javax.annotation.Nonnull;
import lombok.Getter;

/**
* Utility to serialize LLM attachment data in a braintrust-friendly manner.
*
* <p>Users of the SDK likely don't need to use this utility directly because instrumentation will
* properly serialize messages out of the box.
*
* <p>The serialized json will conform to the otel input/output GenericPart schema. See
* https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json and
* https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json
*/
public class Base64Attachment {
@Getter private final String type = "base64_attachment";
@Getter private final String base64Data;

private Base64Attachment(@Nonnull String base64Data) {
if (Objects.requireNonNull(base64Data).isEmpty()) {
throw new IllegalArgumentException("base64Data cannot be empty");
}
// Check for data URL prefix (e.g., "data:image/png;base64,...")
if (!base64Data.startsWith("data:") || !base64Data.contains(";base64,")) {
throw new IllegalArgumentException(
"base64Data must be a data URL with format:"
+ " data:<mime-type>;base64,<base64-string>");
}

this.base64Data = base64Data;
}

/**
* Create a new attachment out of base64 data
*
* @param base64DataUri must conform to data:(content-type);base64,BYTES
*/
public static Base64Attachment of(String base64DataUri) {
return new Base64Attachment(base64DataUri);
}

/** convenience utility to convert a file to a base64 attachment */
public static Base64Attachment ofFile(ContentType contentType, String filePath) {
try {
Path path = Paths.get(filePath);
byte[] fileBytes = Files.readAllBytes(path);
String base64Encoded = Base64.getEncoder().encodeToString(fileBytes);
String dataUrl = "data:" + contentType.getMimeType() + ";base64," + base64Encoded;
return of(dataUrl);
} catch (IOException e) {
throw new RuntimeException("Failed to read file: " + filePath, e);
}
}

/** create a jackson serializer for attachment data */
public static JsonSerializer<Base64Attachment> createSerializer() {
return new JsonSerializer<>() {
@Override
public void serialize(
Base64Attachment value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeStartObject();
try {
gen.writeStringField("type", value.type);
gen.writeStringField("content", value.base64Data);
} finally {
gen.writeEndObject();
}
}
};
}

public static class ContentType {
// Common image formats
public static ContentType IMAGE_PNG = new ContentType("image/png");
public static ContentType IMAGE_JPEG = new ContentType("image/jpeg");
public static ContentType IMAGE_GIF = new ContentType("image/gif");
public static ContentType IMAGE_WEBP = new ContentType("image/webp");
public static ContentType IMAGE_SVG = new ContentType("image/svg+xml");

// Common document formats
public static ContentType APPLICATION_PDF = new ContentType("application/pdf");
public static ContentType TEXT_PLAIN = new ContentType("text/plain");
public static ContentType APPLICATION_JSON = new ContentType("application/json");

public static ContentType of(@Nonnull String mimeType) {
return new ContentType(mimeType);
}

@Getter private final @Nonnull String mimeType;

@Override
public int hashCode() {
return mimeType.hashCode();
}

@Override
public boolean equals(Object obj) {
if (obj instanceof ContentType) {
return mimeType.equals(((ContentType) obj).mimeType);
} else {
return super.equals(obj);
}
}

private ContentType(@Nonnull String mimeType) {
Objects.requireNonNull(mimeType);
this.mimeType = mimeType.toLowerCase();
}
}
}
Loading